diff --git a/CHANGELOG.md b/CHANGELOG.md index dcb6a4ddb..568a3b3ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **A session can open a real browser the agent drives.** Right-click a session + card and pick **Browser tab** to open a Chromium window of that session's + own — the same page `lich browser` and the `browser_*` MCP tools inspect, + click, type, press named keys (Enter, Tab, Escape, …) and screenshot. It is + a second Chromium on the machine (never lich-shell / the UI window), and + closing it does not quit lich. `file:` and `javascript:` URLs are refused, + and form values never enter the transcript. + - **The Commits tab says which GitHub account each commit actually landed under.** A project's GitHub account governs what lich asks `gh`; it has never governed the push, which still signs with your global `user.email`. So a pull @@ -48,6 +56,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Browser tab no longer flashes shut with "invalid context".** CDP actions + now run on the Chromium they just launched, so the window stays open. + - **What you type while a message is being relayed into your session no longer goes out with it.** A relayed message is pasted at the prompt and an Enter is sent behind it, and anything typed between the two was submitted as part of @@ -275,20 +286,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 measured against that login instead of the one lich is signed in to. A session whose environment cannot be read at all shows no gauge on any platform now, which is what a card whose terminal is not yet up looks like until it is. - - **Closing a session on Windows now asks the agent to leave before terminating it.** The close sends the terminal's Ctrl+C and gives the agent a moment to run its own exit path — hooks, transcripts, whatever it writes on the way out — where it used to be killed outright. An agent whose TUI wants a second Ctrl+C to quit is still terminated when that moment runs out. - - **A session whose shell runs inside tmux, over ssh or in a container now says its directory is unknown instead of naming a local one.** The card, its tooltip and the footer read "cwd unknown · inside tmux": the directory those hosts sit in is real and is not where the user is typing, which is the one wrong answer that looks right. Linux and macOS; Windows has no foreground process group to read it from. - - **The Review panel's recap band no longer goes blank behind a large tool result, and says when its words are the previous turn's.** The band read a bounded tail of the conversation, so a turn whose closing words ended up @@ -297,13 +305,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 where the last read stopped, so a turn is read whole however large it is; while a turn is running the band is labelled "from the previous turn", which is whose words they are beside a diff that has no window to draw yet. - - **Searching a large history no longer reads whole conversations to show one sentence.** Every row a search matched on its conversation had its indexed body read out whole: megabytes each, a page of a hundred rows, once per settled keystroke. The stretch the snippet is cut from is now cut by the query itself, so the page reads kilobytes whatever the conversations behind it hold. - - **On Windows, lich's own window no longer opens a stray console window beside it, or gives up and reopens in Chrome.** The window was built for the console subsystem, so Windows allocated a console for it and for each of Chromium's @@ -375,35 +381,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 way — it is what each sender would have been told alone — and the worker is asked at its prompt to name the ticket. One open request behaves as before on both paths. + - **The shortcut recorder now says what a chord costs the terminal.** A bound chord is caught before the session sees it, so rebinding one to `Ctrl+R` took the shell's history search with nothing on screen connecting the two. The row now names what the chord already does down there — the shell's own control codes, the terminal's search, the image paste the agent reads — and still records it. + - **The sandbox's "Ask each time" rung now reaches every session.** It only ever asked in the New worktree dialog, so a session started from the New session menu, by another session, or through the MCP tool quietly ran on the machine. The New session menu now puts the question before the card opens, and a session nobody can be asked about — one opened by an agent through `open_session` or `lich open` — is confined, which that call now says. + - **A session card now says when the sandbox setting moved past it.** A session is confined by the answer it opened with, so changing the rung in Settings never reaches a card already open, and the card's shield said confined or not without ever saying why. A session the rung would confine today now carries a crossed shield, and the tooltip on either side of the disagreement says to reopen the session to apply the setting. + - **`lich cost` now says whose arithmetic each dollar is.** A new `source` column, carried in `--json` and `--csv` too, marks a row `priced` where lich derived the money from Claude Code's and Codex's token counts and `reported` where oh-my-pi, opencode or Crush handed over the figure they computed themselves. A project that ran both reads `mixed`, and a total that mixes them gains a line splitting the sessions between the two. + - **A session card now says why it cannot be forked.** "Fork to worktree…" used to be missing altogether on Antigravity, oh-my-pi, Crush, Cursor CLI and Kiro CLI cards, so the offer looked lost rather than withheld. The item is there now, disabled, under one line naming the provider — their CLIs keep no fork, only resume. + - **A session card's right-click menu now reads the same on every card.** The shortcuts printed beside Rename, Pin, Delegate, Terminal and Close session were only ever true of the card in view, since that is the card a chord acts @@ -2075,6 +2087,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 raised surface and stops at the ends of the list it is being reordered in — the same for checkout groups and project tabs. + ## [0.35.0] - 2026-08-16 ### Added diff --git a/SECURITY.md b/SECURITY.md index 375e15f58..16247d1ad 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -39,6 +39,11 @@ telemetry, so the interesting boundaries are local ones: [lich-plugin](https://github.com/omartelo/lich-plugin) and post to the same authenticated listener. A payload that escapes its contract belongs here too; a bug in the scripts themselves belongs in that repository. +- **The agent browser.** Each session may launch a second Chromium, driven over + CDP, with its own profile — never the lich `--app` window, never + `lich/chromium-profile`. URLs are allow-listed (`http`/`https`/`about:blank`); + page-info never serializes form values. Attaching CDP to the UI process, or + loading `file:` / `javascript:` / `data:` for the agent, is a vulnerability. ## What is not a vulnerability diff --git a/docs/browser-tab.md b/docs/browser-tab.md new file mode 100644 index 000000000..647cfad42 --- /dev/null +++ b/docs/browser-tab.md @@ -0,0 +1,50 @@ +# Decision: the session browser is a sidecar window, not an in-app tab + +**Status: shipped.** Right-click a session card → **Browser tab** opens a +Chromium window owned by that session. The agent drives the same page through +`lich browser` and the `browser_*` MCP tools. + +## Why not a tab inside lich + +lich's window is a system Chromium in `--app` mode (`docs/chromium-shell.md`). +lich does not embed Chromium: no `WebContentsView`, no ``, no CEF. +An iframe of arbitrary `https://` is unsafe (X-Frame-Options, and the RPC +token in the page URL). Attaching CDP to the `--app` process would expose the +UI and the session token. + +So the product is a **second Chromium process** on `chromium.FindBrowser` +(system Chrome/Edge/Chromium/… — never `lich-shell`), with a **different** +`user-data-dir` than `lich/chromium-profile`. Closing that window does not +quit lich. Closing the session card kills the sidecar. + +## One browser per card + +The human entry point is the session's context menu (after Pin, before Open +Terminal), not the file tree and not a dock tab. Clicking **Browser tab**: + +- none yet → headed window at `about:blank`, chromedp attached +- already headed → focus that window +- headless only (the agent opened first) → replace with a headed window at + the same URL (chromedp cannot promote a headless process in place) + +The agent without the menu uses `browser_open`: headless if nobody asked for a +window, reuse the headed one if the human already did. MCP and `lich browser` +target `LICH_SESSION_ID`, not a pool of ids. + +## What the agent may load + +`http`, `https`, `about:blank`. Not `file:`, `javascript:`, `data:`, `chrome:`. +Page-info never serializes form control values (passwords, recovery phrases); +agents get `filled` / `checked`. Screenshots are files, not base64 in the +transcript. Click, type and press are real input events, not `element.click()` +or `el.value = …`. `press` is named keys only (Enter, Tab, Escape, arrows, …); +text still goes through `type`. + +## Providers + +Claude Code and Codex see the tools at spawn (`lich mcp`). Crush and oh-my-pi +see them once the plugin has registered that server. **opencode does not**: +its plugin cannot register MCP and still defines the original seven tools — +see `docs/ceilings.md`. `lich browser` still works in that PTY. + +`evaluate` (arbitrary JS in the page) is out of v1. diff --git a/docs/ceilings.md b/docs/ceilings.md index 832369143..fa113615d 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -300,35 +300,17 @@ work when nobody knows it and that the call site never shows. The mechanism and holds the delivery back while the user has unsent input there. What it counts is printable input since the last Enter, escape sequences skipped — it cannot see the line, so an edit that leaves it empty by another route (Ctrl+W, a click into the middle of it) reads as a draft that is still there, and a delivery waits out - `draftIdle` for nothing. The stale-draft release is what keeps that a delay instead of a wedged relay, and the - wait says so where it is happening: the pull request and issue handoffs - (`frontend/src/lib/terminal/write-at-prompt.ts`) wait on the same answer with no Enter of their own to justify - it, so while one is held its target's card carries a rung saying something is waiting for that prompt — hand a - conflict to a session you left half a sentence in and the card says where it went, rather than the click reading - as having done nothing. The mark lives in the page that is waiting, so it is only ever on the window that started - the handoff, and the relay's own hold is not drawn at all: a delivery held there answers to its sender, through - the ticket. What stays open is the provider that takes keystrokes through anything other than this PTY, which is - invisible to every part of this. -- **A relayed Enter is timed against silence, not against the target** (`internal/relay`, `awaitSettled`): lich - presses Enter once the target's PTY has been quiet for `defaultSubmitDelay`, because nothing here can read a TUI's - screen to know it has taken the paste in. The window that opens on the target's own keyboard is closed rather - than lived with: from the paste to the Enter its keystrokes are held and written at the prompt the Enter leaves - behind (`terminal.HoldInput`), so what a person types there is late by the length of the window instead of - submitted inside somebody else's message. Held, not dropped — but held is still not typed: an interrupt reached - for in that window lands after the delivery, on the turn the delivery started. On Windows that quiet is the whole instrument — ConPTY hands a child key - events rather than bytes, the bracketed paste markers do not survive, and every provider TUI then guesses at where - a paste ends from timing alone. A target that repaints on a timer of its own never goes quiet and gets its Enter - at `defaultSettleLimit` regardless, which is the case this cannot tell from a paste still arriving. -- **An install started from `go run` registers the lich on PATH, not itself** (`internal/agentplugin/crush.go`, - `resolveLichBinary`): Crush's, oh-my-pi's and Cursor's registrations name the absolute path of the lich that - wrote them, and under `go run` — `task dev` — that path is the binary the toolchain built into its cache and - deletes when the run ends, so writing it gives a registration that works for the rest of that session and then - fails silently forever. lich writes `lich` from PATH instead, recognising the cache by shape - (`go-build*/b*/exe/*`) since the toolchain exports no marker. The trap is that a dev install then points at - whatever version is installed on the machine — harmless, because the registration is only the transport and a - session reaches the lich its PTY's coordinates name, but not what the file appears to say. With no lich on - PATH at all, a dev install registers nothing: Crush and oh-my-pi still get their hooks, and Cursor's install - refuses outright. +- **An answer that names no ticket is matched by delivery order** (`internal/relay/relay.go`, `errandOfLocked`): `lich reply ""` and `reply_to_session` without a ticket close the oldest message delivered to that session and still open, because nothing in an answer itself says which request it belongs to. A session working two relayed tasks at once that answers the second one first sends it home as the answer to the first, and both senders read a confident wrong report — nothing anywhere reports the mismatch. Naming the ticket is still the only exact route, which is why every relayed message spells it and why the card's tooltip shows it. + +- **Installing the plugin writes into four harnesses' own directories** (`internal/agentplugin`): Claude Code and Codex are driven through their plugin CLI, but opencode, oh-my-pi and Crush have none, so lich writes the released files itself. None of them records what is installed, so the version lives in a marker line lich wrote — edit the file by hand and lich reads it as not installed. Crush below 0.88.0 ignores those lines in silence, which is why the install asks its version first. Crush's block and omp's `mcp.json` register lich's MCP server by the absolute path of the binary that installed it, and omp's is a JSON document lich rewrites rather than appends to: every key survives, the user's formatting does not. +- **opencode does not see the session browser tools.** Claude Code and Codex get them at spawn, Crush and oh-my-pi through the plugin's MCP registration; opencode's plugin cannot register an MCP server and still defines only the original seven tools. `lich browser` still works in an opencode PTY. The plugin's `toolsMinVersion` is not bumped for this. +- **The agent browser is a second Chromium, never CDP on the lich window.** Attaching to the `--app` process would expose the UI and the session token. The sidecar uses `chromium.FindBrowser` (system Chrome/Edge/…, never `lich-shell`) with its own user-data-dir. Promoting a headless context to a visible window replaces it, so the page reloads. +- **omp's state directory answers to two variables, and the profile wins** (`internal/agentplugin/omp.go`, `internal/terminal/transcript.go`, resolving it independently as the Claude Code pair do): `OMP_PROFILE` moves the whole directory and beats an explicit `PI_CODING_AGENT_DIR`. Get it backwards and the install lands where omp is not reading and every restored card silently starts fresh. + +- **A relayed Enter is timed against silence, not against the target** (`internal/relay`, `awaitSettled`): lich presses Enter once the target's PTY has been quiet for `defaultSubmitDelay`, because nothing here can read a TUI's screen to know it has taken the paste in. The window that opens on the target's own keyboard is closed rather than lived with: from the paste to the Enter its keystrokes are held and written at the prompt the Enter leaves behind (`terminal.HoldInput`), so what a person types there is late by the length of the window instead of submitted inside somebody else's message. Held, not dropped — but held is still not typed: an interrupt reached for in that window lands after the delivery, on the turn the delivery started. On Windows that quiet is the whole instrument — ConPTY hands a child key events rather than bytes, the bracketed paste markers do not survive, and every provider TUI then guesses at where a paste ends from timing alone. A target that repaints on a timer of its own never goes quiet and gets its Enter at `defaultSettleLimit` regardless, which is the case this cannot tell from a paste still arriving. +- **An install started from `go run` registers the lich on PATH, not itself** (`internal/agentplugin/crush.go`, `resolveLichBinary`): Crush's, oh-my-pi's and Cursor's registrations name the absolute path of the lich that wrote them, and under `go run` — `task dev` — that path is the binary the toolchain built into its cache and deletes when the run ends, so writing it gives a registration that works for the rest of that session and then fails silently forever. lich writes `lich` from PATH instead, recognising the cache by shape (`go-build*/b*/exe/*`) since the toolchain exports no marker. The trap is that a dev install then points at whatever version is installed on the machine — harmless, because the registration is only the transport and a session reaches the lich its PTY's coordinates name, but not what the file appears to say. With no lich on PATH at all, a dev install registers nothing: Crush and oh-my-pi still get their hooks, and Cursor's install refuses outright. + +- **The plan gauge answers to two undocumented endpoints, and only two providers have one** (`internal/quota`): Claude Code's and Codex's usage routes are what their own CLIs poll, not published API. A field renamed upstream drops the window it fed rather than raising anything — an entry lich has no name for is - **The plan gauge answers to two undocumented endpoints, and only two providers have one** (`internal/quota`): Claude Code's and Codex's usage routes are what their own CLIs poll, not published API. A field renamed upstream drops the window it fed rather than raising anything — an entry lich has no name for is diff --git a/docs/chromium-shell.md b/docs/chromium-shell.md index 4c0428b6d..21b2a2728 100644 --- a/docs/chromium-shell.md +++ b/docs/chromium-shell.md @@ -171,71 +171,12 @@ driver in both. `seq 1 400000` into an xterm.js session paced at 84 rAF/s on a 100 Hz display. Native Wayland, XWayland and X11 all open with the class and title the Go side asks for. -kurogane needed two things it did not have — a WM_CLASS / app_id and a -title on the window it creates — so `shell/` builds against a fork carrying -that patch (`App::window_class`, `App::window_title`; cef-rs drops an owned -string on the way back into a CEF out-struct, so the fork allocates them -through CEF itself). The patch is upstream as -[kurogane#11](https://github.com/0x48piraj/kurogane/pull/11); the fork -(`omartelo/kurogane`) is the pin until it lands. - -Windows ships the same window, flat beside `lich.exe` as `shell\` the way -CEF lays itself out there, inside the installer (`build/windows/lich.iss`) -and as a zip beside the portable exe. The update button downloads the release's -installer when this window is present or when Inno Setup's `unins000.exe` sits -beside `lich.exe` (an installer install from before the window, v0.10.0 to -v0.45.x, carries only that mark), checks its SHA-256 against the release -checksums, and runs it silently after closing lich; the same AppId means the -setup upgrades that install in place, bringing the window, the Start Menu -shortcut and the "Installed apps" entry up with the exe. A Scoop install -(`apps\lich\current`) carries the window too, but Scoop owns it: the button -offers `scoop update lich` followed by a `/restart` spelled for PowerShell, -and never runs the installer into Scoop's directory. The per-user installer updates -the same directory without elevation and reopens lich on its previous port. A -failed installation shows its error; `.lich-update-setup.log` beside `lich.exe` -records the install. The last downloaded installer is retained there as -`.lich-update-setup.exe` for retry and replaced on the next update. The portable -exe without `shell\lich-shell.exe` still swaps only itself and offers Restart. - -Two things the Linux window gets from -its WM_CLASS come from elsewhere on Windows: the executable carries lich's -icon and manifest as resources (`shell/build.rs`), and the process claims the -AppUserModelID the Start Menu shortcut declares, which is what makes the -taskbar group the running window under the pinned icon. The sandbox stays -off there, as kurogane runs it, so the binary is a plain exe rather than CEF's -`bootstrap.exe` loading a DLL; Linux is the one platform whose window runs -sandboxed — from a package, where the deb, rpm and AUR install Chromium's -setuid helper root-owned 4755 beside `lich-shell` for the desktops that deny -unprivileged user namespaces (`docs/ceilings.md`). Built and smoke-tested on -the CI runner only. - -macOS ships the same window inside `Lich.app`, Apple Silicon only: the -release runner is arm64 and builds the window for itself, and the Intel -bundle opens lich as a plain tab in the default browser instead -(`main.go`, `openWithoutWindow`) — the same tab an Apple Silicon window that -dies at startup falls back to. `lich-shell` sits beside `lich` in -`Contents/MacOS`, because macOS reads a process's bundle off its executable's -path and only a process inside the bundle is `Lich.app` to the Dock, to -Cmd-Tab and to the menu bar; the framework goes to `Contents/Frameworks`, -where kurogane looks for it (upstream's `seal-of-approval/distribution` -branch, commit -[53d51ba](https://github.com/0x48piraj/kurogane/commit/53d51ba7d234161f8709109dcb8d6395f38c7bb4), -carried on the fork meanwhile; lich's #13 was closed as covered). CEF's -subprocesses run as the five helper apps beside the framework (`Lich -Helper` and the Renderer, GPU, Plugin and Alerts variants, each a copy of -the binary under an `LSUIElement` plist), the way CEF's own samples lay -them out, and kurogane points `browser_subprocess_path` at the base one (the same -upstream commit; lich's #15 was closed as covered). Both -halves were measured on the runner before they were written: re-executing -the window binary itself gave every subprocess a Dock tile of its own -(four tiles for one window, until -[kurogane#14](https://github.com/0x48piraj/kurogane/pull/14) kept the -`NSApplication` to the browser process) and, inside a bundle, never started -a renderer at all, since Chromium derives the renderer's executable from -the helper's path and finds nothing there. The bundle is ad-hoc signed -innermost first (ANGLE's dylibs, the framework, the helpers, the window, -the app), never notarized. The window keeps its cookie -store unencrypted (`CredentialStorage::Basic`): Chromium keys it through the -Keychain to one code identity, and an ad-hoc signature is a new identity per -build, so the alternative is a Keychain prompt on every start. Built and -smoke-tested on the CI runner only, like Windows. +**Trigger to revisit**: the project grows an audience — packaging for users who won't install a browser dependency, or a hard requirement to pin the Chromium version, or a need to paint a page *inside* the lich window (an embedded tab). The migration path from option 1 is small: the whole app is already "Go server + browser window"; option 2 only swaps who provides the window. The session's agent browser is a separate Chromium process on `FindBrowser` + CDP (`internal/browser`) and does not wait on option 2; it must never attach to this `--app` window. + +kurogane needed two things it did not have — a WM_CLASS / app_id and a title on the window it creates — so `shell/` builds against a fork carrying that patch (`App::window_class`, `App::window_title`; cef-rs drops an owned string on the way back into a CEF out-struct, so the fork allocates them through CEF itself). The patch is upstream as [kurogane#11](https://github.com/0x48piraj/kurogane/pull/11); the fork (`omartelo/kurogane`) is the pin until it lands. + +Windows ships the same window, flat beside `lich.exe` as `shell\` the way CEF lays itself out there, inside the installer (`build/windows/lich.iss`) and as a zip beside the portable exe. The update button downloads the release's installer when this window is present or when Inno Setup's `unins000.exe` sits beside `lich.exe` (an installer install from before the window, v0.10.0 to v0.45.x, carries only that mark), checks its SHA-256 against the release checksums, and runs it silently after closing lich; the same AppId means the setup upgrades that install in place, bringing the window, the Start Menu shortcut and the "Installed apps" entry up with the exe. A Scoop install (`apps\lich\current`) carries the window too, but Scoop owns it: the button offers `scoop update lich` followed by a `/restart` spelled for PowerShell, and never runs the installer into Scoop's directory. The per-user installer updates the same directory without elevation and reopens lich on its previous port. A failed installation shows its error; `.lich-update-setup.log` beside `lich.exe` records the install. The last downloaded installer is retained there as `.lich-update-setup.exe` for retry and replaced on the next update. The portable exe without `shell\lich-shell.exe` still swaps only itself and offers Restart. + +Two things the Linux window gets from its WM_CLASS come from elsewhere on Windows: the executable carries lich's icon and manifest as resources (`shell/build.rs`), and the process claims the AppUserModelID the Start Menu shortcut declares, which is what makes the taskbar group the running window under the pinned icon. The sandbox stays off there, as kurogane runs it, so the binary is a plain exe rather than CEF's `bootstrap.exe` loading a DLL; Linux is the one platform whose window runs sandboxed — from a package, where the deb, rpm and AUR install Chromium's setuid helper root-owned 4755 beside `lich-shell` for the desktops that deny unprivileged user namespaces (`docs/ceilings.md`). Built and smoke-tested on the CI runner only. + +macOS ships the same window inside `Lich.app`, Apple Silicon only: the release runner is arm64 and builds the window for itself, and the Intel bundle opens lich as a plain tab in the default browser instead (`main.go`, `openWithoutWindow`) — the same tab an Apple Silicon window that dies at startup falls back to. `lich-shell` sits beside `lich` in `Contents/MacOS`, because macOS reads a process's bundle off its executable's path and only a process inside the bundle is `Lich.app` to the Dock, to Cmd-Tab and to the menu bar; the framework goes to `Contents/Frameworks`, where kurogane looks for it (upstream's `seal-of-approval/distribution` branch, commit [53d51ba](https://github.com/0x48piraj/kurogane/commit/53d51ba7d234161f8709109dcb8d6395f38c7bb4), carried on the fork meanwhile; lich's #13 was closed as covered). CEF's subprocesses run as the five helper apps beside the framework (`Lich Helper` and the Renderer, GPU, Plugin and Alerts variants, each a copy of the binary under an `LSUIElement` plist), the way CEF's own samples lay them out, and kurogane points `browser_subprocess_path` at the base one (the same upstream commit; lich's #15 was closed as covered). Both halves were measured on the runner before they were written: re-executing the window binary itself gave every subprocess a Dock tile of its own (four tiles for one window, until [kurogane#14](https://github.com/0x48piraj/kurogane/pull/14) kept the `NSApplication` to the browser process) and, inside a bundle, never started a renderer at all, since Chromium derives the renderer's executable from the helper's path and finds nothing there. The bundle is ad-hoc signed innermost first (ANGLE's dylibs, the framework, the helpers, the window, the app), never notarized. The window keeps its cookie store unencrypted (`CredentialStorage::Basic`): Chromium keys it through the Keychain to one code identity, and an ad-hoc signature is a new identity per build, so the alternative is a Keychain prompt on every start. Built and smoke-tested on the CI runner only, like Windows. diff --git a/docs/cli.md b/docs/cli.md index 364fd8b0e..53173229d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -58,6 +58,7 @@ exceptions: they read the machine, not a running lich. ``` POST http://127.0.0.1:${LICH_PORT}/rpc/relay.?token=${LICH_TOKEN} POST http://127.0.0.1:${LICH_PORT}/rpc/spawn.?token=${LICH_TOKEN} +POST http://127.0.0.1:${LICH_PORT}/rpc/browser.?token=${LICH_TOKEN} ``` **Outside a session** those are absent, and the command finds the running lich @@ -442,10 +443,28 @@ one whose fate that session's close decides, and a checkout with none is one nobody is working in. The project's own directory is not listed — it is the checkout every project has and the one that cannot be removed. +### `lich browser [args]` + +Drives **this session's** browser — the same Chromium window **Browser tab** opens on the card. It is not the lich UI. Run it inside a session (`LICH_SESSION_ID`); outside one it exits 1. + +| Verb | What it does | +|------|----------------| +| `open [url]` | Create the browser if needed and load `url` (omit for `about:blank`). `http`/`https` only. | +| `info` | URL, title, visible text, interactive elements as JSON. No form values. | +| `click --index N` / `--selector S` / `--x X --y Y` | Real mouse event. | +| `type [--clear] [--index N] ` | Keystrokes. | +| `screenshot [path]` | PNG on disk; prints the path. | +| `navigate ` | Load in the already-open browser. | +| `reload` / `back` / `forward` | History. | +| `scroll [--dy N]` | Viewport. Default 400px down. | +| `list` | This session's handle, or `[]`. | +| `close` | Tear the sidecar down. Closing the card does this too. | + +See [`browser-tab.md`](browser-tab.md). + ### `lich cost [--project ] [--provider ] [--since ] [--json|--csv]` -What the sessions lich still remembers have cost, at API prices, one row per -project and a total under them: +What the sessions lich still remembers have cost, at API prices, one row per project and a total under them: ``` project sessions unpriced source cost @@ -456,55 +475,21 @@ total 15 2 mixed $5.19 Lower bound: 2 unpriced of 15 sessions — their spend is not in this total. ``` -**The last line is the contract.** A session lich never priced holds no ledger -row and contributes nothing, so a sum over a unit containing one is a *lower -bound* — and nothing else on screen would say so. The count travels with the -money on every surface: the `unpriced` column, the line under the table, the -`unpriced` field in `--json`, the `unpriced` column in `--csv`. With none, the -line reads `Complete: every session in this total is priced.` instead. - -Why a session has no price is not reported here. That is the live scan's -`costMiss` (`internal/terminal/usage_cost.go`), decided per turn and never -persisted, so the ledger can only say a session carries no price — an -unreadable transcript, an unpriced model, a provider lich has no reader for -(Antigravity and Cursor CLI), or a session that ran while the readout was off -all land in the same count. - -**The `source` column is the other half of that contract.** The money comes -from two kinds of accounting and reads identically either way: `priced` is -lich's own arithmetic over the token counts Claude Code and Codex write down, -`reported` is the figure oh-my-pi, opencode or Crush computed themselves and -handed over, with the omission their own accounting has. A row that ran both -reads `mixed`, and a total that mixes them adds the line above the last one, -splitting the counted sessions between the two. A row with no money to -attribute — every session in it unpriced, or its spend earned by a provider CLI -started by hand inside a shell session — reads `—` and claims no rung. -`docs/ceilings.md` has what each rung's own figure leaves out. - -With the cost readout switched off, no transcript is ever summed and every -session is unpriced, so the total can only be `$0.00`. That case adds a second -line naming the setting, because a zero on a machine that was never asked to -count reads exactly like a machine that spent nothing. +**The last line is the contract.** A session lich never priced holds no ledger row and contributes nothing, so a sum over a unit containing one is a *lower bound* — and nothing else on screen would say so. The count travels with the money on every surface: the `unpriced` column, the line under the table, the `unpriced` field in `--json`, the `unpriced` column in `--csv`. With none, the line reads `Complete: every session in this total is priced.` instead. + +Why a session has no price is not reported here. That is the live scan's `costMiss` (`internal/terminal/usage_cost.go`), decided per turn and never persisted, so the ledger can only say a session carries no price — an unreadable transcript, an unpriced model, a provider lich has no reader for (Antigravity and Cursor CLI), or a session that ran while the readout was off all land in the same count. + +**The `source` column is the other half of that contract.** The money comes from two kinds of accounting and reads identically either way: `priced` is lich's own arithmetic over the token counts Claude Code and Codex write down, `reported` is the figure oh-my-pi, opencode or Crush computed themselves and handed over, with the omission their own accounting has. A row that ran both reads `mixed`, and a total that mixes them adds the line above the last one, splitting the counted sessions between the two. A row with no money to attribute — every session in it unpriced, or its spend earned by a provider CLI started by hand inside a shell session — reads `—` and claims no rung. `docs/ceilings.md` has what each rung's own figure leaves out. + +With the cost readout switched off, no transcript is ever summed and every session is unpriced, so the total can only be `$0.00`. That case adds a second line naming the setting, because a zero on a machine that was never asked to count reads exactly like a machine that spent nothing. The filters: -- `--project ` — one project, matched case-insensitively the way every - other command matches one. A name no session sits under is **refused**, not - answered with a zero: about money, a typo that reads as "that project cost - nothing" is worse than no answer. -- `--provider ` — one session kind (`claude`, `codex`, `crush`, …). - Combined with the total line this is what a provider cost across the machine. - An unknown provider is not refused; it matches nothing and the report is - empty. -- `--since ` — `7d`, `24h`, `90m`. Sessions **active** in the window, - counted whole: a session's activity is its last counted turn, and a session - that ran through the boundary brings all of its cost with it. The ledger is a - running total with no per-turn history behind it, so this is a filter over - sessions, never a slice of the money by day. A session with nothing counted is - dated by when it was parked, or by now while it is still open. - -`--csv` writes the per-project rows and no total — the totals are a sum of the -columns, `unpriced` included, so the bound survives the export: +- `--project ` — one project, matched case-insensitively the way every other command matches one. A name no session sits under is **refused**, not answered with a zero: about money, a typo that reads as "that project cost nothing" is worse than no answer. +- `--provider ` — one session kind (`claude`, `codex`, `crush`, …). Combined with the total line this is what a provider cost across the machine. An unknown provider is not refused; it matches nothing and the report is empty. +- `--since ` — `7d`, `24h`, `90m`. Sessions **active** in the window, counted whole: a session's activity is its last counted turn, and a session that ran through the boundary brings all of its cost with it. The ledger is a running total with no per-turn history behind it, so this is a filter over sessions, never a slice of the money by day. A session with nothing counted is dated by when it was parked, or by now while it is still open. + +`--csv` writes the per-project rows and no total — the totals are a sum of the columns, `unpriced` included, so the bound survives the export: ``` project,sessions,unpriced,source,cost_usd @@ -512,24 +497,13 @@ lich,12,2,mixed,4.312500 revu,3,0,priced,0.880000 ``` -A row with no rung leaves the `source` cell empty rather than carrying the -table's dash: to a script a blank is the absence, and the dash is a thing to -print. +A row with no rung leaves the `source` cell empty rather than carrying the table's dash: to a script a blank is the absence, and the dash is a thing to print. -`--json` is the whole report on one line — `projects`, the summed `sessions`, -`unpriced` and `costUsd`, the total's own `source` with the `priced` and -`reported` session counts behind it, and `readout` for whether anything is -being counted at all. +`--json` is the whole report on one line — `projects`, the summed `sessions`, `unpriced` and `costUsd`, the total's own `source` with the `priced` and `reported` session counts behind it, and `readout` for whether anything is being counted at all. -It is not registered as an MCP tool. Every tool definition is in the prompt of -every session lich spawns whether or not it is used (see the ceiling below), and -a total nobody asked for is not worth that on every turn — an agent that wants -it runs the command. +It is not registered as an MCP tool. Every tool definition is in the prompt of every session lich spawns whether or not it is used (see the ceiling below), and a total nobody asked for is not worth that on every turn — an agent that wants it runs the command. -There is no `--model` breakdown. The ledger records what a `(session, -transcript)` pair cost and never which model each priced turn ran under, and -the model on a session row is the one selected now — so a per-model total would -have to be a second accounting path, not a filter on this one. +There is no `--model` breakdown. The ledger records what a `(session, transcript)` pair cost and never which model each priced turn ran under, and the model on a session row is the one selected now — so a per-model total would have to be a second accounting path, not a filter on this one. ### `lich mcp` @@ -552,6 +526,16 @@ at lich. | `close_session` | `session`, optional `project`, `worktree` (`keep`/`remove`), `force`. | | `rename_session` | `label`, optional `session` (omitted renames the caller's own) and `project` — `lich rename`. | | `list_worktrees` | optional `project` — the checkouts, as JSON. | +| `browser_open` | optional `url` — this session's browser. | +| `browser_info` | the current page as JSON. Read-only. | +| `browser_click` | `index` / `selector` / `x`+`y`. | +| `browser_type` | `text`, optional `clear` and a target. | +| `browser_screenshot` | optional `path`; returns a file path. Read-only. | +| `browser_navigate` | `url`. | +| `browser_reload` / `browser_back` / `browser_forward` | history. | +| `browser_scroll` | optional `dy`. | +| `browser_list` | this session's handle, or `[]`. Read-only. | +| `browser_close` | tear the sidecar down. | A tool that fails answers with `isError` and the reason as text, not a JSON-RPC error: the agent should read what went wrong and act on it, not lose the turn. @@ -559,8 +543,8 @@ error: the agent should read what went wrong and act on it, not lose the turn. `initialize` also carries `instructions` — the server's own briefing, which clients inject into the agent's system prompt. It is the one place the whole journey (fan out into worktree sessions, carry on, collect) is told as one; -the tool descriptions each only explain their own door. `list_sessions` and -`list_worktrees` are annotated `readOnlyHint`, so a client may auto-allow +the tool descriptions each only explain their own door. `list_sessions`, `list_worktrees`, `browser_info`, `browser_list` and +`browser_screenshot` are annotated `readOnlyHint`, so a client may auto-allow them. ### `lich rage [--output ]` @@ -662,7 +646,7 @@ own command line (`providers.AcceptsMCPServer`): | Codex | `-c mcp_servers.lich.command=…` and `…args=["mcp"]` | at spawn | | Antigravity | an `agy mcp add` the plugin install runs — its own supported interface, so lich never formats that document | with the plugin | | Crush | an `mcp add` line in the block the plugin install writes into `crushrc` | with the plugin | -| opencode | its plugin defines the same eight as tools of its own — a plugin there cannot register an MCP server | with the plugin | +| opencode | its plugin defines the original seven as tools of its own — a plugin there cannot register an MCP server, so the session browser tools are absent | with the plugin | | oh-my-pi | a `lich` entry merged into `mcp.json` beside the extension the plugin install writes | with the plugin | | Cursor CLI | a `lich` entry merged into `~/.cursor/mcp.json` by the install — its `mcp` subcommand only lists, enables and disables what is already there | with the plugin | | Kiro CLI | a `kiro-cli mcp add --agent lich` the plugin install runs, so the entry lands in the agent lich owns rather than in the user's global `mcp.json` | with the plugin | @@ -814,10 +798,11 @@ receiving agent only because this text describes it. and a test that reached it would deliver into a real session. - **Finding a lich from outside** — `internal/singleton`, `Read`: the runtime file the CLI falls back to when the environment carries no coordinates. -- **MCP server** — `internal/cli/mcp.go`: the stdio transport, the tool table - and the handshake. Adding a tool is one entry in `mcpTools`, which is what - registering once buys — every tool lich grows later reaches every provider - that took the registration, with no further per-provider work. +- **MCP server** — `internal/cli/mcp.go` and `mcp_browser.go`: the stdio + transport, the tool tables and the handshake. Relay tools are `mcpTools`; + browser tools are `browserTools`; `allMCPTools` concatenates them, which is + what registering once buys — every tool lich grows later reaches every + provider that took the registration, with no further per-provider work. - **Registration** — `internal/terminal`, `providerArgs` / `mcpArgs`, beside `nameArgs` and `resumeArgs`; which providers accept one is `providers.AcceptsMCPServer`. The names it registers under diff --git a/frontend/src/components/sidebar/SessionCard.tsx b/frontend/src/components/sidebar/SessionCard.tsx index 2e7206f1a..11ce9b7bb 100644 --- a/frontend/src/components/sidebar/SessionCard.tsx +++ b/frontend/src/components/sidebar/SessionCard.tsx @@ -12,6 +12,7 @@ import { FolderOpen, GitBranch, GitPullRequestArrow, + Globe, Hourglass, Inbox, ListChecks, @@ -66,7 +67,7 @@ import { ContextMenuSubTrigger, ContextMenuTrigger, } from "@/components/ui/context-menu" -import { System, Terminal as TerminalService } from "@/lib/rpc" +import { Browser, System, Terminal as TerminalService } from "@/lib/rpc" import { queuePaste } from "@/lib/terminal/paste-queue" import type { DelegateGroup } from "@/lib/session/delegate-targets" import { delegatePrompt, delegateWorktreePrompt } from "@/lib/session/delegate-prompt" @@ -701,6 +702,16 @@ export function SessionCard({ {pinned ? : } {pinned ? "Unpin" : "Pin"} + { + void Browser.OpenVisible(session.id).catch((err) => { + toast.error(`Browser: ${errorText(err)}`) + }) + }} + > + + Browser tab + setEntrypointOpen(true)} /> {!active && ( diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 8eda3ec0e..363f0be1e 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -592,6 +592,15 @@ export interface DetectedProvider { docs: string } +/** internal/browser.Handle — this session's Chromium sidecar. */ +export interface BrowserHandle { + id: string + url: string + title: string + owner: string + headed: boolean +} + /** internal/providers.Check — what a configured binary resolves to. `path` is * the executable a session would spawn, empty for every status but "ok". */ export interface BinaryCheck { diff --git a/frontend/src/lib/rpc.ts b/frontend/src/lib/rpc.ts index 4d10c4cd2..cad6db259 100644 --- a/frontend/src/lib/rpc.ts +++ b/frontend/src/lib/rpc.ts @@ -15,6 +15,7 @@ import type { LastSaid, LastTurn, Branches, + BrowserHandle, CommitIdentity, DetectedProvider, Diagnostics as DiagnosticsData, @@ -560,6 +561,11 @@ export const Quota = { Plans: (sessionId: string) => call("quota.Plans", [sessionId]), } +export const Browser = { + /** Open or focus this session's visible Chromium window — not the lich UI. */ + OpenVisible: (owner: string) => call("browser.OpenVisible", [owner]), +} + export const Spawn = { /** Open the project's Run card in the checkout at cwd: a terminal session * whose entrypoint is .lich/run-worktree.sh, its PTY started by the backend diff --git a/go.mod b/go.mod index 38edbed84..793695426 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.27.0 require ( github.com/UserExistsError/conpty v0.1.4 + github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f + github.com/chromedp/chromedp v0.16.0 github.com/coder/websocket v1.8.14 github.com/creack/pty v1.1.24 github.com/minio/selfupdate v0.6.0 @@ -16,8 +18,13 @@ require ( require ( aead.dev/minisign v0.2.0 // indirect github.com/akavel/rsrc v0.10.2 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/josephspurrier/goversioninfo v1.4.1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index 7ff000c99..39500edb3 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,12 @@ github.com/UserExistsError/conpty v0.1.4 h1:+3FhJhiqhyEJa+K5qaK3/w6w+sN3Nh9O9VbJ github.com/UserExistsError/conpty v0.1.4/go.mod h1:PDglKIkX3O/2xVk0MV9a6bCWxRmPVfxqZoTG/5sSd9I= github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= +github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f h1:0Z1zcSLEmnj2c2CmJYBqewtS6pxhB39bNWUSEUAWjgk= +github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0= +github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk= +github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= @@ -15,6 +21,14 @@ github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f h1:OGqDDftRTwrvUoL6pO github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f/go.mod h1:Dv9D0NUlAsaQcGQZa5kc5mqR9ua72SmA8VXi4cd+cBw= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg= +github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -23,6 +37,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/josephspurrier/goversioninfo v1.4.1 h1:5LvrkP+n0tg91J9yTkoVnt/QgNnrI1t4uSsWjIonrqY= github.com/josephspurrier/goversioninfo v1.4.1/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= @@ -31,6 +47,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/zenity v0.10.14 h1:OBFl7qfXcvsdo1NUEGxTlZvAakgWMqz9nG38TuiaGLI= github.com/ncruces/zenity v0.10.14/go.mod h1:ZBW7uVe/Di3IcRYH0Br8X59pi+O6EPnNIOU66YHpOO4= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 h1:GranzK4hv1/pqTIhMTXt2X8MmMOuH3hMeUR0o9SP5yc= diff --git a/internal/browser/actions.go b/internal/browser/actions.go new file mode 100644 index 000000000..e823e7f26 --- /dev/null +++ b/internal/browser/actions.go @@ -0,0 +1,180 @@ +package browser + +import ( + "context" + "fmt" + "path/filepath" +) + +var errNoBrowser = fmt.Errorf("no browser for this session — open one first") + +func (s *Service) live(id string) (*session, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.reapLocked() + sess := s.lookupLocked(id) + if sess == nil { + return nil, errNoBrowser + } + return sess, nil +} + +func (s *Service) touch(sess *session) { + s.mu.Lock() + defer s.mu.Unlock() + if s.byID[sess.id] == sess { + sess.lastUsed = s.now() + } +} + +func (s *Service) drop(sess *session) { + s.mu.Lock() + defer s.mu.Unlock() + if s.byID[sess.id] == sess { + _ = s.destroyLocked(sess) + } +} + +func (s *Service) remember(sess *session, url, title string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.byID[sess.id] != sess { + return + } + if url != "" { + sess.url = url + } + sess.title = title + sess.lastUsed = s.now() +} + +// Info returns the page the agent is looking at. Never includes form values. +func (s *Service) Info(id string) (PageInfo, error) { + sess, err := s.live(id) + if err != nil { + return PageInfo{}, err + } + var info PageInfo + err = runPage(func(ctx context.Context) error { + var e error + info, e = sess.page.Info(ctx) + return e + }) + if err != nil { + s.drop(sess) + return PageInfo{}, err + } + s.remember(sess, info.URL, info.Title) + return info, nil +} + +// Click sends a real mouse event at the target, not element.click(). +func (s *Service) Click(id string, t Target) error { + if t.empty() { + return fmt.Errorf("click needs an index, a selector, or x and y") + } + return s.act(id, func(p Page, ctx context.Context) error { + return p.Click(ctx, t) + }) +} + +// Type sends keystrokes. clear selects-and-replaces; the typed text never +// comes back through Info. +func (s *Service) Type(id, text string, clear bool, t Target) error { + return s.act(id, func(p Page, ctx context.Context) error { + return p.Type(ctx, text, clear, t) + }) +} + +// Press sends one named key (Enter, Tab, Escape, …) as a real key event. +// An optional target is focused first, the same way Type focuses before typing. +func (s *Service) Press(id, key string, t Target) error { + if _, err := keySequence(key); err != nil { + return err + } + return s.act(id, func(p Page, ctx context.Context) error { + return p.Press(ctx, key, t) + }) +} + +// Screenshot writes a PNG to dest (created if empty: inside the profile dir) +// and returns that path. The bytes stay off the transcript. +func (s *Service) Screenshot(id, dest string) (string, error) { + sess, err := s.live(id) + if err != nil { + return "", err + } + if dest == "" { + dest = filepath.Join(sess.dir, "screenshot.png") + } else { + abs, err := filepath.Abs(dest) + if err != nil { + return "", err + } + dest = abs + } + err = runPage(func(ctx context.Context) error { + return sess.page.Screenshot(ctx, dest) + }) + if err != nil { + s.drop(sess) + return "", err + } + s.touch(sess) + return dest, nil +} + +// Navigate loads url in this session's browser. +func (s *Service) Navigate(id, rawURL string) (Handle, error) { + u, err := allowURL(rawURL) + if err != nil { + return Handle{}, err + } + sess, err := s.live(id) + if err != nil { + return Handle{}, err + } + err = runPage(func(ctx context.Context) error { + return sess.page.Navigate(ctx, u) + }) + if err != nil { + s.drop(sess) + return Handle{}, err + } + s.remember(sess, u, "") + return sess.handle(), nil +} + +// Reload reloads the current page. +func (s *Service) Reload(id string) error { + return s.act(id, func(p Page, ctx context.Context) error { return p.Reload(ctx) }) +} + +// Back goes back in history. +func (s *Service) Back(id string) error { + return s.act(id, func(p Page, ctx context.Context) error { return p.Back(ctx) }) +} + +// Forward goes forward in history. +func (s *Service) Forward(id string) error { + return s.act(id, func(p Page, ctx context.Context) error { return p.Forward(ctx) }) +} + +// Scroll moves the viewport by dy CSS pixels (positive is down). +func (s *Service) Scroll(id string, dy int) error { + return s.act(id, func(p Page, ctx context.Context) error { return p.Scroll(ctx, dy) }) +} + +func (s *Service) act(id string, fn func(Page, context.Context) error) error { + sess, err := s.live(id) + if err != nil { + return err + } + err = runPage(func(ctx context.Context) error { return fn(sess.page, ctx) }) + if err != nil { + s.drop(sess) + return err + } + s.touch(sess) + return nil +} diff --git a/internal/browser/browser.go b/internal/browser/browser.go new file mode 100644 index 000000000..604fdcfff --- /dev/null +++ b/internal/browser/browser.go @@ -0,0 +1,294 @@ +package browser + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "sync" + "time" +) + +// idleAfter is how long a session's browser sits unused before the reaper +// closes it. Five minutes matches the "walked away from the card" window +// without holding a Chromium for a session that finished hours ago. +const idleAfter = 5 * time.Minute + +// reapEvery is how often the reaper looks. Shorter than idleAfter so a +// browser that went idle is gone within a fraction of the idle window, not a +// full extra one. +const reapEvery = 30 * time.Second + +// pageWait bounds one CDP action. Under the 90 seconds an MCP tool call may +// block for, long enough for a cold page, short enough that a hung Chrome +// does not take the tool with it. +const pageWait = 30 * time.Second + +var errNoOwner = errors.New("browser needs a lich session id") + +// Service is one Chromium context per lich session: the window Browser tab +// opens and the page the agent drives are the same one. Headless until +// someone asks for a window; headed after OpenVisible. +type Service struct { + mu sync.Mutex + launch Launcher + mkTmp func(dir, pattern string) (string, error) + now func() time.Time + idle time.Duration + byOwner map[string]*session + byID map[string]*session + stop chan struct{} + stopped bool +} + +type session struct { + id, owner, dir string + url, title string + headed bool + page Page + lastUsed time.Time +} + +// New starts a service that launches the system Chromium (chromium.FindBrowser) +// into an ephemeral profile, never the lich --app one. +func New() *Service { + s := newService(chromeLaunch, os.MkdirTemp, time.Now, idleAfter) + s.startReaper() + return s +} + +func newService(launch Launcher, mkTmp func(string, string) (string, error), now func() time.Time, idle time.Duration) *Service { + return &Service{ + launch: launch, + mkTmp: mkTmp, + now: now, + idle: idle, + byOwner: make(map[string]*session), + byID: make(map[string]*session), + stop: make(chan struct{}), + } +} + +// Open navigates this session's browser to url, creating a headless one if +// none exists. A headed window the user already opened is reused, not replaced. +func (s *Service) Open(owner, rawURL string) (Handle, error) { + if owner == "" { + return Handle{}, errNoOwner + } + u, err := allowURL(rawURL) + if err != nil { + return Handle{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + s.reapLocked() + if sess := s.liveByOwner(owner); sess != nil { + if err := runPage(func(ctx context.Context) error { + return sess.page.Navigate(ctx, u) + }); err != nil { + s.destroyLocked(sess) + return Handle{}, err + } + sess.url = u + sess.lastUsed = s.now() + return sess.handle(), nil + } + return s.launchLocked(owner, u, false) +} + +// OpenVisible brings this session's browser on screen. No browser yet: a +// headed window at about:blank. Already headed: focus it. Headless only: +// replace it with a headed window at the same URL — chromedp cannot promote +// a headless process in place. +func (s *Service) OpenVisible(owner string) (Handle, error) { + if owner == "" { + return Handle{}, errNoOwner + } + s.mu.Lock() + defer s.mu.Unlock() + s.reapLocked() + if sess := s.liveByOwner(owner); sess != nil { + if sess.headed { + _ = runPage(sess.page.Focus) + sess.lastUsed = s.now() + return sess.handle(), nil + } + u := sess.url + if u == "" { + u = "about:blank" + } + s.destroyLocked(sess) + return s.launchLocked(owner, u, true) + } + return s.launchLocked(owner, "about:blank", true) +} + +// List returns this session's browser, or an empty list. Never null: a +// script should not have to tell those apart. +func (s *Service) List(owner string) ([]Handle, error) { + if owner == "" { + return nil, errNoOwner + } + s.mu.Lock() + defer s.mu.Unlock() + s.reapLocked() + out := []Handle{} + if sess := s.liveByOwner(owner); sess != nil { + out = append(out, sess.handle()) + } + return out, nil +} + +// Close tears down one browser, by handle id or by the lich session that owns it. +func (s *Service) Close(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + sess := s.lookupLocked(id) + if sess == nil { + return nil + } + return s.destroyLocked(sess) +} + +// CloseOwnedBy tears down the browser of a lich session that is going away. +// Missing is not an error: most sessions never opened one. +func (s *Service) CloseOwnedBy(owner string) error { + return s.Close(owner) +} + +// Cleanup closes every browser this process started. Wired from main so a +// window-close exit does not leave Chromiums behind. +func (s *Service) Cleanup() error { + s.mu.Lock() + defer s.mu.Unlock() + if !s.stopped { + s.stopped = true + close(s.stop) + } + var errs []error + for _, sess := range s.byID { + if err := s.destroyLocked(sess); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +func (s *Service) launchLocked(owner, u string, headed bool) (Handle, error) { + dir, err := s.mkTmp("", "lich-browser-") + if err != nil { + return Handle{}, err + } + page, err := s.launch(dir, headed) + if err != nil { + _ = os.RemoveAll(dir) + return Handle{}, err + } + if err := runPage(func(ctx context.Context) error { + return page.Navigate(ctx, u) + }); err != nil { + _ = page.Close() + _ = os.RemoveAll(dir) + return Handle{}, err + } + sess := &session{ + id: newID(), owner: owner, dir: dir, url: u, headed: headed, page: page, lastUsed: s.now(), + } + s.byOwner[owner] = sess + s.byID[sess.id] = sess + return sess.handle(), nil +} + +func (s *Service) liveByOwner(owner string) *session { + sess := s.byOwner[owner] + if sess == nil || !sess.alive() { + if sess != nil { + s.destroyLocked(sess) + } + return nil + } + return sess +} + +func (s *Service) lookupLocked(id string) *session { + if sess := s.byID[id]; sess != nil { + if sess.alive() { + return sess + } + s.destroyLocked(sess) + } + if sess := s.byOwner[id]; sess != nil { + if sess.alive() { + return sess + } + s.destroyLocked(sess) + } + return nil +} + +func (s *Service) destroyLocked(sess *session) error { + delete(s.byID, sess.id) + if s.byOwner[sess.owner] == sess { + delete(s.byOwner, sess.owner) + } + err := sess.page.Close() + if rmErr := os.RemoveAll(sess.dir); err == nil { + err = rmErr + } + return err +} + +func (s *session) alive() bool { + return s != nil && s.page != nil && s.page.Alive() +} + +func (s *session) handle() Handle { + return Handle{ID: s.id, URL: s.url, Title: s.title, Owner: s.owner, Headed: s.headed} +} + +func (s *Service) startReaper() { + go func() { + tick := time.NewTicker(reapEvery) + defer tick.Stop() + for { + select { + case <-s.stop: + return + case <-tick.C: + s.Reap() + } + } + }() +} + +// Reap closes browsers that have sat idle past the idle window. +func (s *Service) Reap() { + s.mu.Lock() + defer s.mu.Unlock() + s.reapLocked() +} + +func (s *Service) reapLocked() { + cutoff := s.now().Add(-s.idle) + for _, sess := range s.byID { + if sess.lastUsed.Before(cutoff) { + _ = s.destroyLocked(sess) + } + } +} + +func runPage(fn func(context.Context) error) error { + ctx, cancel := context.WithTimeout(context.Background(), pageWait) + defer cancel() + return fn(ctx) +} + +func newID() string { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b[:]) +} diff --git a/internal/browser/browser_test.go b/internal/browser/browser_test.go new file mode 100644 index 000000000..a25a910a5 --- /dev/null +++ b/internal/browser/browser_test.go @@ -0,0 +1,380 @@ +package browser + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func testService(t *testing.T, launch *fakeLauncher) *Service { + t.Helper() + now := time.Date(2026, 8, 16, 12, 0, 0, 0, time.UTC) + s := newService(launch.launch, func(string, string) (string, error) { + return t.TempDir(), nil + }, func() time.Time { return now }, 5*time.Minute) + s.now = func() time.Time { return now } + t.Cleanup(func() { _ = s.Cleanup() }) + return s +} + +func TestOpenCreatesOneHeadlessPerOwner(t *testing.T) { + launch := &fakeLauncher{} + s := testService(t, launch) + + a, err := s.Open("sess-a", "https://example.com") + if err != nil { + t.Fatal(err) + } + if a.Headed || a.Owner != "sess-a" || a.URL != "https://example.com" { + t.Fatalf("handle = %+v", a) + } + b, err := s.Open("sess-a", "https://example.com/next") + if err != nil { + t.Fatal(err) + } + if a.ID != b.ID { + t.Fatalf("second Open minted a new browser: %s then %s", a.ID, b.ID) + } + if len(launch.pages) != 1 { + t.Fatalf("launched %d, want 1", len(launch.pages)) + } + if got := launch.pages[0].navigations; len(got) != 2 { + t.Fatalf("navigations = %v", got) + } + c, err := s.Open("sess-b", "https://other.example") + if err != nil { + t.Fatal(err) + } + if c.ID == a.ID { + t.Fatal("a second lich session shared the first's browser") + } + if len(launch.pages) != 2 { + t.Fatalf("launched %d, want 2", len(launch.pages)) + } +} + +func TestOpenRejectsBlockedURL(t *testing.T) { + s := testService(t, &fakeLauncher{}) + if _, err := s.Open("s", "file:///etc/passwd"); err == nil { + t.Fatal("file: URL was allowed") + } + list, err := s.List("s") + if err != nil { + t.Fatal(err) + } + if len(list) != 0 { + t.Fatalf("a rejected Open still left a browser: %v", list) + } +} + +func TestOpenVisibleReusesHeadedAndReplacesHeadless(t *testing.T) { + launch := &fakeLauncher{} + s := testService(t, launch) + + first, err := s.OpenVisible("s") + if err != nil { + t.Fatal(err) + } + if !first.Headed { + t.Fatal("OpenVisible launched headless") + } + second, err := s.OpenVisible("s") + if err != nil { + t.Fatal(err) + } + if first.ID != second.ID { + t.Fatal("second OpenVisible replaced a headed window") + } + if launch.pages[0].focused == 0 { + t.Fatal("reuse did not focus the existing window") + } + + h, err := s.Open("other", "https://example.com/page") + if err != nil { + t.Fatal(err) + } + if h.Headed { + t.Fatal("Open without a window should be headless") + } + vis, err := s.OpenVisible("other") + if err != nil { + t.Fatal(err) + } + if vis.ID == h.ID { + t.Fatal("OpenVisible kept the headless page instead of replacing it") + } + if !vis.Headed || vis.URL != "https://example.com/page" { + t.Fatalf("replaced handle = %+v", vis) + } + if !launch.pages[1].closed { + t.Fatal("headless page was not closed when promoting") + } +} + +func TestCloseOwnedByAndCleanup(t *testing.T) { + launch := &fakeLauncher{} + s := testService(t, launch) + if _, err := s.Open("a", "https://a.example"); err != nil { + t.Fatal(err) + } + if _, err := s.Open("b", "https://b.example"); err != nil { + t.Fatal(err) + } + if err := s.CloseOwnedBy("a"); err != nil { + t.Fatal(err) + } + if !launch.pages[0].closed { + t.Fatal("CloseOwnedBy left the page open") + } + list, _ := s.List("a") + if len(list) != 0 { + t.Fatal("owner a still has a browser") + } + if err := s.Cleanup(); err != nil { + t.Fatal(err) + } + if !launch.pages[1].closed { + t.Fatal("Cleanup left a page open") + } +} + +func TestActionsNeedABrowserAndRecordOnThePage(t *testing.T) { + launch := &fakeLauncher{} + s := testService(t, launch) + if _, err := s.Info("missing"); !errors.Is(err, errNoBrowser) { + t.Fatalf("Info with none: %v", err) + } + + h, err := s.Open("s", "https://example.com") + if err != nil { + t.Fatal(err) + } + idx := 3 + if err := s.Click(h.ID, Target{Index: &idx}); err != nil { + t.Fatal(err) + } + if err := s.Type("s", "hello", true, Target{}); err != nil { + t.Fatal(err) + } + if err := s.Press("s", "Enter", Target{}); err != nil { + t.Fatal(err) + } + dest, err := s.Screenshot("s", "") + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(dest); err != nil { + t.Fatalf("screenshot: %v", err) + } + if filepath.Base(dest) != "screenshot.png" { + t.Fatalf("screenshot path = %s", dest) + } + if err := s.Reload("s"); err != nil { + t.Fatal(err) + } + if err := s.Back("s"); err != nil { + t.Fatal(err) + } + if err := s.Forward("s"); err != nil { + t.Fatal(err) + } + if err := s.Scroll("s", 400); err != nil { + t.Fatal(err) + } + p := launch.pages[0] + if len(p.clicks) != 1 || p.clicks[0].Index == nil || *p.clicks[0].Index != 3 { + t.Fatalf("clicks = %+v", p.clicks) + } + if len(p.typed) != 1 || p.typed[0].text != "hello" || !p.typed[0].clear { + t.Fatalf("typed = %+v", p.typed) + } + if len(p.pressed) != 1 || p.pressed[0].key != "Enter" { + t.Fatalf("pressed = %+v", p.pressed) + } + if p.reloads != 1 || p.backs != 1 || p.forwards != 1 { + t.Fatalf("nav = reload %d back %d forward %d", p.reloads, p.backs, p.forwards) + } + if len(p.scrolled) != 1 || p.scrolled[0] != 400 { + t.Fatalf("scrolled = %v", p.scrolled) + } +} + +func TestClickRequiresATarget(t *testing.T) { + s := testService(t, &fakeLauncher{}) + if _, err := s.Open("s", ""); err != nil { + t.Fatal(err) + } + if err := s.Click("s", Target{}); err == nil { + t.Fatal("click with no target succeeded") + } +} + +func TestPressRejectsUnknownKey(t *testing.T) { + s := testService(t, &fakeLauncher{}) + if _, err := s.Open("s", ""); err != nil { + t.Fatal(err) + } + if err := s.Press("s", "Ctrl+A", Target{}); err == nil { + t.Fatal("unknown key was allowed") + } + if err := s.Press("s", "", Target{}); err == nil { + t.Fatal("empty key was allowed") + } +} + +func TestNavigateRejectsBlockedURL(t *testing.T) { + s := testService(t, &fakeLauncher{}) + if _, err := s.Open("s", "https://ok.example"); err != nil { + t.Fatal(err) + } + if _, err := s.Navigate("s", "javascript:alert(1)"); err == nil { + t.Fatal("navigate allowed javascript:") + } +} + +func TestIdleReaperClosesUnused(t *testing.T) { + launch := &fakeLauncher{} + now := time.Date(2026, 8, 16, 12, 0, 0, 0, time.UTC) + s := newService(launch.launch, func(string, string) (string, error) { + return t.TempDir(), nil + }, func() time.Time { return now }, time.Minute) + t.Cleanup(func() { _ = s.Cleanup() }) + + if _, err := s.Open("s", "https://example.com"); err != nil { + t.Fatal(err) + } + s.Reap() + if launch.pages[0].closed { + t.Fatal("reaper closed a browser that was just used") + } + now = now.Add(time.Minute + time.Second) + s.now = func() time.Time { return now } + s.Reap() + if !launch.pages[0].closed { + t.Fatal("reaper left an idle browser running") + } +} + +func TestOpenRequiresOwner(t *testing.T) { + s := testService(t, &fakeLauncher{}) + if _, err := s.Open("", "https://example.com"); !errors.Is(err, errNoOwner) { + t.Fatalf("Open with no owner: %v", err) + } + if _, err := s.OpenVisible(""); !errors.Is(err, errNoOwner) { + t.Fatalf("OpenVisible with no owner: %v", err) + } +} + +func TestNavigateUpdatesHandle(t *testing.T) { + s := testService(t, &fakeLauncher{}) + if _, err := s.Open("s", "https://a.example"); err != nil { + t.Fatal(err) + } + h, err := s.Navigate("s", "https://b.example") + if err != nil { + t.Fatal(err) + } + if h.URL != "https://b.example" { + t.Fatalf("handle url = %q", h.URL) + } +} + +func TestScreenshotWritesTheAskedPath(t *testing.T) { + s := testService(t, &fakeLauncher{}) + if _, err := s.Open("s", ""); err != nil { + t.Fatal(err) + } + dest := filepath.Join(t.TempDir(), "shot.png") + got, err := s.Screenshot("s", dest) + if err != nil { + t.Fatal(err) + } + if got != dest { + t.Fatalf("path = %q, want %q", got, dest) + } +} + +func TestFailedInfoDropsTheBrowser(t *testing.T) { + launch := &fakeLauncher{} + s := testService(t, launch) + if _, err := s.Open("s", "https://example.com"); err != nil { + t.Fatal(err) + } + launch.pages[0].infoErr = errors.New("chrome gone") + if _, err := s.Info("s"); err == nil { + t.Fatal("Info succeeded on a dead page") + } + list, _ := s.List("s") + if len(list) != 0 { + t.Fatal("dead page was not dropped") + } +} + +func TestOpenDropsWhenReuseNavigateFails(t *testing.T) { + launch := &fakeLauncher{} + s := testService(t, launch) + if _, err := s.Open("s", "https://a.example"); err != nil { + t.Fatal(err) + } + launch.pages[0].navErr = errors.New("gone") + if _, err := s.Open("s", "https://b.example"); err == nil { + t.Fatal("Open reused a page that could not navigate") + } + if !launch.pages[0].closed { + t.Fatal("failed reuse left the page open") + } +} + +func TestLaunchFailureLeavesNoBrowser(t *testing.T) { + s := newService(func(string, bool) (Page, error) { + return nil, errors.New("no chrome") + }, func(string, string) (string, error) { + return t.TempDir(), nil + }, time.Now, time.Minute) + t.Cleanup(func() { _ = s.Cleanup() }) + if _, err := s.Open("s", "https://example.com"); err == nil { + t.Fatal("Open succeeded without a launcher") + } + list, _ := s.List("s") + if len(list) != 0 { + t.Fatal("a failed launch left a handle") + } +} + +func TestCloseByHandleID(t *testing.T) { + launch := &fakeLauncher{} + s := testService(t, launch) + h, err := s.Open("s", "https://example.com") + if err != nil { + t.Fatal(err) + } + if err := s.Close(h.ID); err != nil { + t.Fatal(err) + } + if !launch.pages[0].closed { + t.Fatal("Close(id) left the page open") + } +} + +func TestInfoReturnsPageInfoWithoutDroppingTheBrowser(t *testing.T) { + launch := &fakeLauncher{} + s := testService(t, launch) + if _, err := s.Open("s", "https://example.com"); err != nil { + t.Fatal(err) + } + launch.pages[0].title = "Example" + launch.pages[0].info = PageInfo{Title: "Example", Text: "hello", Interactive: []Interactive{{Tag: "a", Text: "Home"}}} + info, err := s.Info("s") + if err != nil { + t.Fatal(err) + } + if info.Title != "Example" || info.Text != "hello" { + t.Fatalf("info = %+v", info) + } + list, _ := s.List("s") + if len(list) != 1 { + t.Fatal("Info dropped the browser") + } +} diff --git a/internal/browser/chrome.go b/internal/browser/chrome.go new file mode 100644 index 000000000..c8718bbf3 --- /dev/null +++ b/internal/browser/chrome.go @@ -0,0 +1,195 @@ +package browser + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "time" + + "github.com/chromedp/cdproto/input" + "github.com/chromedp/chromedp" + "github.com/omartelo/lich/internal/chromium" +) + +// chromeLaunch starts a Chromium-family browser via CDP. ExecPath is +// chromium.FindBrowser (system Chrome/Edge/… — never lich-shell); UserDataDir +// is this ephemeral dir, never lich/chromium-profile. Attaching to the UI +// window would expose the page and the session token; this process must stay +// a sidecar. +func chromeLaunch(dir string, headed bool) (Page, error) { + exe, err := chromium.FindBrowser(exec.LookPath) + if err != nil { + return nil, err + } + opts := append([]chromedp.ExecAllocatorOption{}, chromedp.DefaultExecAllocatorOptions[:]...) + opts = append(opts, chromedp.ExecPath(exe), chromedp.UserDataDir(dir)) + if headed { + opts = append(opts, chromedp.Flag("headless", false)) + } + allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...) + ctx, cancel := chromedp.NewContext(allocCtx) + if err := chromedp.Run(ctx); err != nil { + cancel() + allocCancel() + return nil, err + } + return &chromePage{ctx: ctx, cancel: cancel, allocCancel: allocCancel}, nil +} + +type chromePage struct { + ctx context.Context + cancel context.CancelFunc + allocCancel context.CancelFunc +} + +func (p *chromePage) Alive() bool { return p.ctx.Err() == nil } + +func (p *chromePage) Close() error { + p.cancel() + p.allocCancel() + return nil +} + +// run drives CDP on the allocator context chromedp attached, applying wait's +// deadline. Service callers pass a timeout derived from Background; chromedp.Run +// on that context is "invalid context" and the headed window is then closed. +func (p *chromePage) run(wait context.Context, actions ...chromedp.Action) error { + ctx, cancel := withPageDeadline(p.ctx, wait) + defer cancel() + return chromedp.Run(ctx, actions...) +} + +func (p *chromePage) Navigate(ctx context.Context, url string) error { + return p.run(ctx, chromedp.Navigate(url)) +} + +func (p *chromePage) Info(ctx context.Context) (PageInfo, error) { + var info PageInfo + if err := p.run(ctx, chromedp.Evaluate(pageInfoScript, &info)); err != nil { + return PageInfo{}, err + } + return info, nil +} + +func (p *chromePage) Click(ctx context.Context, t Target) error { + pt, err := p.resolve(ctx, t) + if err != nil { + return err + } + return p.run(ctx, chromedp.MouseClickXY(pt.X, pt.Y)) +} + +func (p *chromePage) Type(ctx context.Context, text string, clear bool, t Target) error { + if !t.empty() { + if err := p.Click(ctx, t); err != nil { + return err + } + } + if clear { + err := p.run(ctx, chromedp.Evaluate(`(() => { + const el = document.activeElement; + if (el && 'value' in el) el.value = ''; + })()`, nil)) + if err != nil { + return err + } + } + return p.run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { + return input.InsertText(text).Do(ctx) + })) +} + +func (p *chromePage) Press(ctx context.Context, key string, t Target) error { + seq, err := keySequence(key) + if err != nil { + return err + } + if !t.empty() { + if err := p.Click(ctx, t); err != nil { + return err + } + } + return p.run(ctx, chromedp.KeyEvent(seq)) +} + +func (p *chromePage) Screenshot(ctx context.Context, dest string) error { + var buf []byte + if err := p.run(ctx, chromedp.FullScreenshot(&buf, 90)); err != nil { + return err + } + return os.WriteFile(dest, buf, 0o600) +} + +func (p *chromePage) Reload(ctx context.Context) error { + return p.run(ctx, chromedp.Reload()) +} + +func (p *chromePage) Back(ctx context.Context) error { + return p.run(ctx, chromedp.NavigateBack()) +} + +func (p *chromePage) Forward(ctx context.Context) error { + return p.run(ctx, chromedp.NavigateForward()) +} + +func (p *chromePage) Scroll(ctx context.Context, dy int) error { + return p.run(ctx, chromedp.Evaluate(fmt.Sprintf("window.scrollBy(0, %d)", dy), nil)) +} + +func (p *chromePage) Focus(ctx context.Context) error { + return p.run(ctx, chromedp.Evaluate("window.focus()", nil)) +} + +// withPageDeadline hangs wait's timeout off the chromedp page context. A +// timeout from context.Background has no CDP target; wrapping it the other +// way around is how OpenVisible returned "invalid context" and closed the +// window it had just opened. +func withPageDeadline(pageCtx, waitCtx context.Context) (context.Context, context.CancelFunc) { + timeout := pageWait + if waitCtx != nil { + if d, ok := waitCtx.Deadline(); ok { + timeout = time.Until(d) + if timeout < 0 { + timeout = 0 + } + } + } + return context.WithTimeout(pageCtx, timeout) +} + +type point struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +const resolveJS = `(function(index, selector, x, y) { + if (typeof x === 'number' && typeof y === 'number') { + return {x: x, y: y}; + } + var el = null; + if (selector) el = document.querySelector(selector); + else if (typeof index === 'number') { + el = document.querySelector('[data-lich-idx="' + index + '"]'); + } + if (!el) return null; + var r = el.getBoundingClientRect(); + return {x: r.left + r.width / 2, y: r.top + r.height / 2}; +})` + +func (p *chromePage) resolve(ctx context.Context, t Target) (point, error) { + args, err := json.Marshal([]any{t.Index, t.Selector, t.X, t.Y}) + if err != nil { + return point{}, err + } + expr := resolveJS + "(" + string(args[1:len(args)-1]) + ")" + var pt *point + if err := p.run(ctx, chromedp.Evaluate(expr, &pt)); err != nil { + return point{}, err + } + if pt == nil { + return point{}, fmt.Errorf("no element matches that target") + } + return *pt, nil +} diff --git a/internal/browser/chrome_test.go b/internal/browser/chrome_test.go new file mode 100644 index 000000000..d57645b62 --- /dev/null +++ b/internal/browser/chrome_test.go @@ -0,0 +1,32 @@ +package browser + +import ( + "context" + "testing" + "time" + + "github.com/chromedp/chromedp" +) + +func TestWithPageDeadlineKeepsChromedpTarget(t *testing.T) { + pageCtx, cancel := chromedp.NewContext(context.Background()) + defer cancel() + if chromedp.FromContext(pageCtx) == nil { + t.Fatal("NewContext produced no chromedp target") + } + + wait, stop := context.WithTimeout(context.Background(), time.Second) + defer stop() + if chromedp.FromContext(wait) != nil { + t.Fatal("a Background timeout unexpectedly carried a chromedp target") + } + + got, done := withPageDeadline(pageCtx, wait) + defer done() + if chromedp.FromContext(got) == nil { + t.Fatal("deadline wrapper dropped the chromedp target; CDP would return invalid context") + } + if _, ok := got.Deadline(); !ok { + t.Fatal("deadline wrapper dropped the wait deadline") + } +} diff --git a/internal/browser/fake_test.go b/internal/browser/fake_test.go new file mode 100644 index 000000000..c72b18519 --- /dev/null +++ b/internal/browser/fake_test.go @@ -0,0 +1,156 @@ +package browser + +import ( + "context" + "os" + "sync" +) + +// fakePage is a Page that never talks to Chrome. Tests inject it through +// Launcher so the suite stays off the OS boundary. +type fakePage struct { + mu sync.Mutex + url, title string + closed bool + navigations []string + clicks []Target + typed []typed + pressed []pressed + reloads int + backs int + forwards int + scrolled []int + focused int + info PageInfo + shotErr error + navErr error + infoErr error +} + +type typed struct { + text string + clear bool + t Target +} + +type pressed struct { + key string + t Target +} + +func (p *fakePage) Alive() bool { + p.mu.Lock() + defer p.mu.Unlock() + return !p.closed +} + +func (p *fakePage) Close() error { + p.mu.Lock() + defer p.mu.Unlock() + p.closed = true + return nil +} + +func (p *fakePage) Navigate(_ context.Context, url string) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.navErr != nil { + return p.navErr + } + p.url = url + p.navigations = append(p.navigations, url) + return nil +} + +func (p *fakePage) Info(context.Context) (PageInfo, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.infoErr != nil { + return PageInfo{}, p.infoErr + } + info := p.info + if info.URL == "" { + info.URL = p.url + } + if info.Title == "" { + info.Title = p.title + } + return info, nil +} + +func (p *fakePage) Click(_ context.Context, t Target) error { + p.mu.Lock() + defer p.mu.Unlock() + p.clicks = append(p.clicks, t) + return nil +} + +func (p *fakePage) Type(_ context.Context, text string, clear bool, t Target) error { + p.mu.Lock() + defer p.mu.Unlock() + p.typed = append(p.typed, typed{text: text, clear: clear, t: t}) + return nil +} + +func (p *fakePage) Press(_ context.Context, key string, t Target) error { + p.mu.Lock() + defer p.mu.Unlock() + p.pressed = append(p.pressed, pressed{key: key, t: t}) + return nil +} + +func (p *fakePage) Screenshot(_ context.Context, dest string) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.shotErr != nil { + return p.shotErr + } + return os.WriteFile(dest, []byte("png"), 0o600) +} + +func (p *fakePage) Reload(context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + p.reloads++ + return nil +} + +func (p *fakePage) Back(context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + p.backs++ + return nil +} + +func (p *fakePage) Forward(context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + p.forwards++ + return nil +} + +func (p *fakePage) Scroll(_ context.Context, dy int) error { + p.mu.Lock() + defer p.mu.Unlock() + p.scrolled = append(p.scrolled, dy) + return nil +} + +func (p *fakePage) Focus(context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + p.focused++ + return nil +} + +type fakeLauncher struct { + pages []*fakePage + headed []bool +} + +func (f *fakeLauncher) launch(_ string, headed bool) (Page, error) { + p := &fakePage{url: "about:blank"} + f.pages = append(f.pages, p) + f.headed = append(f.headed, headed) + return p, nil +} diff --git a/internal/browser/keys.go b/internal/browser/keys.go new file mode 100644 index 000000000..da5d67bfe --- /dev/null +++ b/internal/browser/keys.go @@ -0,0 +1,68 @@ +package browser + +import ( + "fmt" + "strings" + + "github.com/chromedp/chromedp/kb" +) + +// namedKeys maps the names agents and `lich browser press` use to the +// single-rune sequences chromedp's KeyEvent understands (kb package). +// Aliases are folded in normalizeKey; this table is the canonical spellings. +var namedKeys = map[string]string{ + "enter": kb.Enter, + "tab": kb.Tab, + "escape": kb.Escape, + "backspace": kb.Backspace, + "delete": kb.Delete, + "space": " ", + "arrowup": kb.ArrowUp, + "arrowdown": kb.ArrowDown, + "arrowleft": kb.ArrowLeft, + "arrowright": kb.ArrowRight, + "home": kb.Home, + "end": kb.End, + "pageup": kb.PageUp, + "pagedown": kb.PageDown, +} + +// keyAliases fold common short names onto the table above. +var keyAliases = map[string]string{ + "return": "enter", + "esc": "escape", + "bs": "backspace", + "del": "delete", + "up": "arrowup", + "down": "arrowdown", + "left": "arrowleft", + "right": "arrowright", + "pgup": "pageup", + "pgdn": "pagedown", +} + +// keySequence turns a named key into the string chromedp.KeyEvent expects. +// Unknown names are refused so agents cannot invent chords or paste text here — +// that is what Type is for. +func keySequence(name string) (string, error) { + n := normalizeKey(name) + if n == "" { + return "", fmt.Errorf("press needs a key name (Enter, Tab, Escape, …)") + } + seq, ok := namedKeys[n] + if !ok { + return "", fmt.Errorf("unknown key %q — use Enter, Tab, Escape, Backspace, Delete, Space, ArrowUp/Down/Left/Right, Home, End, PageUp, PageDown", name) + } + return seq, nil +} + +func normalizeKey(name string) string { + n := strings.ToLower(strings.TrimSpace(name)) + n = strings.ReplaceAll(n, "-", "") + n = strings.ReplaceAll(n, "_", "") + n = strings.ReplaceAll(n, " ", "") + if alias, ok := keyAliases[n]; ok { + return alias + } + return n +} diff --git a/internal/browser/keys_test.go b/internal/browser/keys_test.go new file mode 100644 index 000000000..0d88093ff --- /dev/null +++ b/internal/browser/keys_test.go @@ -0,0 +1,41 @@ +package browser + +import "testing" + +func TestKeySequenceAcceptsNamedKeysAndAliases(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"Enter", "\r"}, + {"return", "\r"}, + {"Tab", "\t"}, + {"Esc", "\u001b"}, + {"Escape", "\u001b"}, + {"Arrow-Down", "\u0301"}, + {"arrow_up", "\u0304"}, + {"Space", " "}, + {" ENTER ", "\r"}, + } + for _, c := range cases { + got, err := keySequence(c.in) + if err != nil { + t.Fatalf("%q: %v", c.in, err) + } + if got != c.want { + t.Fatalf("%q → %q, want %q", c.in, got, c.want) + } + } +} + +func TestKeySequenceRejectsUnknownAndEmpty(t *testing.T) { + if _, err := keySequence(""); err == nil { + t.Fatal("empty key was allowed") + } + if _, err := keySequence("Ctrl+C"); err == nil { + t.Fatal("chord was allowed") + } + if _, err := keySequence("hello"); err == nil { + t.Fatal("text was allowed through press") + } +} diff --git a/internal/browser/page.go b/internal/browser/page.go new file mode 100644 index 000000000..5e866bde8 --- /dev/null +++ b/internal/browser/page.go @@ -0,0 +1,82 @@ +package browser + +import "context" + +// Handle is what Open and OpenVisible return: the id later calls take, and +// enough for a card or a tool result to say which window it is. +type Handle struct { + ID string `json:"id"` + URL string `json:"url"` + Title string `json:"title"` + Owner string `json:"owner"` + Headed bool `json:"headed"` +} + +// Target picks an element for click and type. Index is the one page-info +// stamped (data-lich-idx); selector is a CSS query; x/y are viewport +// coordinates. Coordinates win when set, then selector, then index. +type Target struct { + Index *int `json:"index,omitempty"` + Selector string `json:"selector,omitempty"` + X *float64 `json:"x,omitempty"` + Y *float64 `json:"y,omitempty"` +} + +func (t Target) empty() bool { + return t.Index == nil && t.Selector == "" && (t.X == nil || t.Y == nil) +} + +// Rect is an element's viewport box, integer pixels. +type Rect struct { + X int `json:"x"` + Y int `json:"y"` + W int `json:"w"` + H int `json:"h"` +} + +// Interactive is one clickable or fillable node from page-info. It never +// carries a control's value — filled/checked say that typing landed without +// putting the content in the transcript. +type Interactive struct { + Index int `json:"index"` + Tag string `json:"tag"` + Type string `json:"type,omitempty"` + Role string `json:"role,omitempty"` + Text string `json:"text"` + Href string `json:"href,omitempty"` + Rect Rect `json:"rect"` + Filled *bool `json:"filled,omitempty"` + Checked *bool `json:"checked,omitempty"` +} + +// PageInfo is what the agent reads instead of a screenshot: the URL, the +// visible text, and the interactive nodes it can click or type into. +type PageInfo struct { + URL string `json:"url"` + Title string `json:"title"` + Text string `json:"text"` + Interactive []Interactive `json:"interactive"` + Truncated bool `json:"truncated"` +} + +// Page is one Chromium tab the service drives. chrome.go is the real one; +// tests inject a fake. +type Page interface { + Navigate(ctx context.Context, url string) error + Info(ctx context.Context) (PageInfo, error) + Click(ctx context.Context, t Target) error + Type(ctx context.Context, text string, clear bool, t Target) error + Press(ctx context.Context, key string, t Target) error + Screenshot(ctx context.Context, dest string) error + Reload(ctx context.Context) error + Back(ctx context.Context) error + Forward(ctx context.Context) error + Scroll(ctx context.Context, dy int) error + Focus(ctx context.Context) error + Alive() bool + Close() error +} + +// Launcher starts a Chromium with its own user-data-dir. headed is a real +// window; false is headless. The directory is the caller's to delete. +type Launcher func(dir string, headed bool) (Page, error) diff --git a/internal/browser/pageinfo.go b/internal/browser/pageinfo.go new file mode 100644 index 000000000..f15460050 --- /dev/null +++ b/internal/browser/pageinfo.go @@ -0,0 +1,82 @@ +package browser + +// pageInfoScript is injected into the page the agent is driving. It stamps +// data-lich-idx so a later click/type can find the same node, and it never +// reads a form control's value into the result. +// +// A password input has no innerText, so an innerText || value label chain +// hands the live credential to the agent and into the session transcript. +// Rather than classify which fields are sensitive — type === 'password' is +// defeated by a show-password toggle, and an ordinary text input can still +// hold a recovery phrase — no control value is serialized at all. Agents get +// filled / checked instead, which is enough to confirm that typing landed +// without the content leaving the page. +// +// A textarea is covered by the same rule: its content is its default value, so +// innerText on a textarea is a value read. The single exception is a +// push-button input (submit / button / reset), whose value is the author-written +// caption and the only label it has. A user cannot type into one. +const pageInfoScript = `(() => { + const sel = 'a[href], button, input, textarea, select, [role=button], [role=link], [onclick], [contenteditable=""], [contenteditable="true"]'; + const isVisible = (el) => { + const r = el.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) return false; + const s = getComputedStyle(el); + return s.visibility !== 'hidden' && s.display !== 'none'; + }; + const CAPTION_TYPES = ['submit', 'button', 'reset']; + const attr = (el, name) => el.getAttribute(name) || ''; + const isCaptionInput = (tag, type) => + tag === 'input' && CAPTION_TYPES.indexOf(type) !== -1; + const labelFor = (el, tag, type) => { + if (tag === 'select') { + const opt = el.options ? el.options[el.selectedIndex] : null; + const optText = opt ? (opt.textContent || '').trim() : ''; + if (optText) return optText; + } else if (isCaptionInput(tag, type)) { + const caption = (el.value || '').trim(); + if (caption) return caption; + } else if (tag !== 'input' && tag !== 'textarea') { + const own = (el.innerText || '').trim(); + if (own) return own; + } + const labelled = el.labels && el.labels[0]; + const labelText = labelled ? (labelled.innerText || labelled.textContent || '').trim() : ''; + if (labelText) return labelText; + return ( + attr(el, 'aria-label') || + attr(el, 'title') || + attr(el, 'placeholder') || + attr(el, 'name') + ).trim(); + }; + const els = [...document.querySelectorAll(sel)].filter(isVisible); + const interactive = els.slice(0, 200).map((el, i) => { + el.setAttribute('data-lich-idx', String(i)); + const r = el.getBoundingClientRect(); + const tag = el.tagName.toLowerCase(); + const type = (el.getAttribute('type') || '').toLowerCase(); + const entry = { + index: i, + tag: tag, + type: el.getAttribute('type') || undefined, + role: el.getAttribute('role') || undefined, + text: labelFor(el, tag, type).slice(0, 120), + href: el.getAttribute('href') || undefined, + rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) }, + }; + if (type === 'checkbox' || type === 'radio') { + entry.checked = el.checked === true; + } else if (tag === 'textarea' || (tag === 'input' && !isCaptionInput(tag, type))) { + entry.filled = typeof el.value === 'string' && el.value.length > 0; + } + return entry; + }); + return { + url: location.href, + title: document.title, + text: (document.body ? document.body.innerText : '').trim().slice(0, 5000), + interactive: interactive, + truncated: els.length > 200, + }; +})()` diff --git a/internal/browser/pageinfo_test.go b/internal/browser/pageinfo_test.go new file mode 100644 index 000000000..ad0637de2 --- /dev/null +++ b/internal/browser/pageinfo_test.go @@ -0,0 +1,51 @@ +package browser + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestPageInfoScriptNeverSerializesControlValues(t *testing.T) { + if !strings.Contains(pageInfoScript, "data-lich-idx") { + t.Fatal("page-info must stamp data-lich-idx so click/type can find the node") + } + if strings.Contains(pageInfoScript, "data-nim-idx") { + t.Fatal("page-info still names the other product's attribute") + } + if strings.Contains(pageInfoScript, "innerText || el.value") || strings.Contains(pageInfoScript, "innerText || value") { + t.Fatal("innerText || value is how a password reaches the transcript") + } + if !strings.Contains(pageInfoScript, "entry.filled") { + t.Fatal("agents need filled, not the value, to know typing landed") + } + if !strings.Contains(pageInfoScript, "entry.checked") { + t.Fatal("agents need checked, not the value, for radios and checkboxes") + } +} + +func TestInteractiveJSONHasNoValueField(t *testing.T) { + filled, checked := true, false + el := Interactive{ + Index: 0, Tag: "input", Type: "password", Text: "Password", + Filled: &filled, Checked: &checked, + } + raw, err := json.Marshal(el) + if err != nil { + t.Fatal(err) + } + var keys map[string]json.RawMessage + if err := json.Unmarshal(raw, &keys); err != nil { + t.Fatal(err) + } + if _, ok := keys["value"]; ok { + t.Fatalf("interactive JSON leaked a value field: %s", raw) + } + body, err := json.Marshal(PageInfo{URL: "https://ex", Interactive: []Interactive{el}}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(body), `"value"`) { + t.Fatalf("page-info JSON leaked a value key: %s", body) + } +} diff --git a/internal/browser/url.go b/internal/browser/url.go new file mode 100644 index 000000000..47c692576 --- /dev/null +++ b/internal/browser/url.go @@ -0,0 +1,34 @@ +package browser + +import ( + "fmt" + "net/url" + "strings" +) + +// allowURL is the only URL the agent browser will load. http, https and +// about:blank are in; file, javascript, data, chrome and everything else are +// out — a file: URL would hand the agent the machine, and javascript: is just +// evaluate by another name. +func allowURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" || strings.EqualFold(raw, "about:blank") { + return "about:blank", nil + } + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("invalid url: %w", err) + } + switch strings.ToLower(u.Scheme) { + case "http", "https": + if u.Host == "" { + return "", fmt.Errorf("url %q has no host", raw) + } + return u.String(), nil + default: + if u.Scheme == "" { + return "", fmt.Errorf("url %q is missing a scheme", raw) + } + return "", fmt.Errorf("blocked url scheme %q", u.Scheme) + } +} diff --git a/internal/browser/url_test.go b/internal/browser/url_test.go new file mode 100644 index 000000000..57ded7d21 --- /dev/null +++ b/internal/browser/url_test.go @@ -0,0 +1,37 @@ +package browser + +import "testing" + +func TestAllowURL(t *testing.T) { + cases := []struct { + in, want, err string + }{ + {"", "about:blank", ""}, + {"about:blank", "about:blank", ""}, + {" ABOUT:BLANK ", "about:blank", ""}, + {"https://example.com/path", "https://example.com/path", ""}, + {"http://localhost:3000", "http://localhost:3000", ""}, + {"file:///etc/passwd", "", `blocked url scheme "file"`}, + {"javascript:alert(1)", "", `blocked url scheme "javascript"`}, + {"data:text/html,hi", "", `blocked url scheme "data"`}, + {"chrome://settings", "", `blocked url scheme "chrome"`}, + {"example.com", "", `url "example.com" is missing a scheme`}, + {"https://", "", `url "https://" has no host`}, + } + for _, c := range cases { + got, err := allowURL(c.in) + if c.err != "" { + if err == nil || err.Error() != c.err { + t.Errorf("allowURL(%q) err = %v, want %s", c.in, err, c.err) + } + continue + } + if err != nil { + t.Errorf("allowURL(%q): %v", c.in, err) + continue + } + if got != c.want { + t.Errorf("allowURL(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/chromium/candidates_darwin.go b/internal/chromium/candidates_darwin.go new file mode 100644 index 000000000..ba05c5f83 --- /dev/null +++ b/internal/chromium/candidates_darwin.go @@ -0,0 +1,13 @@ +//go:build darwin + +package chromium + +import "os" + +// browserCandidates lists candidate browsers in preference order. macOS +// installs land in .app bundles under /Applications, not on PATH, so the list +// is absolute paths built from the install roots (darwinBrowserCandidates in +// find.go, where the logic stays testable from any OS). +func browserCandidates() []string { + return darwinBrowserCandidates(os.Getenv) +} diff --git a/internal/chromium/candidates_unix.go b/internal/chromium/candidates_unix.go new file mode 100644 index 000000000..f5077773c --- /dev/null +++ b/internal/chromium/candidates_unix.go @@ -0,0 +1,13 @@ +//go:build !windows && !darwin + +package chromium + +// browserCandidates lists candidate binaries in preference order — the shared +// chromiumNames list (find.go), since every Linux/BSD install lands on PATH +// under one of those names. Any Chromium gives the same CDP surface; the +// preference only picks the most conventional install. macOS keeps browsers in +// .app bundles off PATH and Windows under versioned install roots, so both have +// their own list. +func browserCandidates() []string { + return chromiumNames +} diff --git a/internal/chromium/candidates_unix_test.go b/internal/chromium/candidates_unix_test.go new file mode 100644 index 000000000..033776cef --- /dev/null +++ b/internal/chromium/candidates_unix_test.go @@ -0,0 +1,42 @@ +//go:build !windows && !darwin + +package chromium + +import ( + "slices" + "testing" +) + +// TestUnixBrowserCandidates pins the exact Linux/BSD candidate list in +// preference order: bare PATH names (Linux/BSD installs live on PATH), Chromium +// first. It mirrors the exact-list style of TestWindowsBrowserCandidates / +// TestDarwinBrowserCandidates — a membership check would miss an order +// regression or a stray entry — and it spells the list out rather than reading +// chromiumNames, which is the thing under test. +// +// The build tag matches candidates_unix.go (`!windows && !darwin`) on purpose: +// under a bare `!windows` this test also compiles into the darwin build, where +// browserCandidates() returns the .app list and none of these bare names, so +// the assertion would fail the macOS release suite. +func TestUnixBrowserCandidates(t *testing.T) { + want := []string{ + "chromium", + "chromium-browser", + "google-chrome", + "google-chrome-stable", + "google-chrome-beta", + "helium-browser", + "brave", + "brave-browser", + "vivaldi", + "vivaldi-stable", + "microsoft-edge", + "microsoft-edge-stable", + "thorium-browser", + "ungoogled-chromium", + "chrome", + } + if got := browserCandidates(); !slices.Equal(got, want) { + t.Fatalf("browserCandidates() = %v, want %v", got, want) + } +} diff --git a/internal/chromium/candidates_windows.go b/internal/chromium/candidates_windows.go new file mode 100644 index 000000000..a5c5df4f3 --- /dev/null +++ b/internal/chromium/candidates_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package chromium + +import "os" + +// browserCandidates lists candidate browsers in preference order. Windows +// installs don't land on PATH, so the list is mostly absolute paths built +// from the install-root environment variables (windowsBrowserCandidates in +// find.go, where the logic stays testable from any OS). +func browserCandidates() []string { + return windowsBrowserCandidates(os.Getenv) +} diff --git a/internal/chromium/find.go b/internal/chromium/find.go new file mode 100644 index 000000000..697c75f48 --- /dev/null +++ b/internal/chromium/find.go @@ -0,0 +1,102 @@ +package chromium + +import ( + "errors" + "fmt" +) + +// ErrNoBrowser is a machine with no Chromium-family browser the agent sidecar +// can drive. Distinct from ErrNoShell: the UI window is lich-shell; this is +// Chrome/Edge/Chromium/… for CDP (internal/browser). +var ErrNoBrowser = errors.New( + "no chromium-family browser found — install chromium, chrome, brave, vivaldi or edge") + +// FindBrowser returns the first Chromium-family binary that resolves, trying +// this OS's candidates (candidates_*.go) in preference order. lookPath is +// injectable for tests (production passes exec.LookPath, which also accepts +// the absolute paths the Windows and macOS lists use). +// +// This is the agent-browser sidecar only — never the lich UI window, which +// Resolve pins to LICH_SHELL / lich-shell (#570). +func FindBrowser(lookPath func(name string) (string, error)) (string, error) { + candidates := browserCandidates() + for _, name := range candidates { + if path, err := lookPath(name); err == nil { + return path, nil + } + } + return "", fmt.Errorf("%w (tried %v)", ErrNoBrowser, candidates) +} + +// chromiumNames are the Chromium-family executables on Linux/BSD PATH, in +// preference order. candidates_unix.go returns this list; Windows and macOS +// build absolute paths under the install roots instead. +var chromiumNames = []string{ + "chromium", + "chromium-browser", + "google-chrome", + "google-chrome-stable", + "google-chrome-beta", + "helium-browser", + "brave", + "brave-browser", + "vivaldi", + "vivaldi-stable", + "microsoft-edge", + "microsoft-edge-stable", + "thorium-browser", + "ungoogled-chromium", + "chrome", +} + +// windowsBrowserCandidates builds the Windows candidate list: chrome, then +// edge (present on every Windows), then brave and vivaldi, each under the +// install roots Windows exposes as environment variables, with bare PATH names +// last. Paths are joined with a literal backslash so the pure logic tests the +// same on any OS. Kept out of the build-tagged file for exactly that reason. +func windowsBrowserCandidates(getenv func(string) string) []string { + roots := []struct{ env, rel string }{ + {"ProgramFiles", `Google\Chrome\Application\chrome.exe`}, + {"ProgramFiles(x86)", `Google\Chrome\Application\chrome.exe`}, + {"LocalAppData", `Google\Chrome\Application\chrome.exe`}, + {"ProgramFiles(x86)", `Microsoft\Edge\Application\msedge.exe`}, + {"ProgramFiles", `Microsoft\Edge\Application\msedge.exe`}, + {"ProgramFiles", `BraveSoftware\Brave-Browser\Application\brave.exe`}, + {"LocalAppData", `BraveSoftware\Brave-Browser\Application\brave.exe`}, + {"ProgramFiles", `Vivaldi\Application\vivaldi.exe`}, + {"LocalAppData", `Vivaldi\Application\vivaldi.exe`}, + } + var out []string + for _, r := range roots { + if root := getenv(r.env); root != "" { + out = append(out, root+`\`+r.rel) + } + } + return append(out, "chrome", "msedge") +} + +// darwinBrowserCandidates builds the macOS candidate list: chrome, then +// chromium, then edge, then brave, then vivaldi, each as its .app executable +// under the system (/Applications) and per-user (~/Applications) install roots, +// with bare PATH names last for a Homebrew-formula install. Paths are joined +// with a literal slash so the pure logic tests the same on any OS. +func darwinBrowserCandidates(getenv func(string) string) []string { + apps := []string{ + "Google Chrome.app/Contents/MacOS/Google Chrome", + "Chromium.app/Contents/MacOS/Chromium", + "Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "Brave Browser.app/Contents/MacOS/Brave Browser", + "Vivaldi.app/Contents/MacOS/Vivaldi", + } + roots := []string{"/Applications"} + if home := getenv("HOME"); home != "" { + roots = append(roots, home+"/Applications") + } + var out []string + for _, app := range apps { + for _, root := range roots { + out = append(out, root+"/"+app) + } + } + return append(out, "chromium", "google-chrome") +} diff --git a/internal/chromium/find_test.go b/internal/chromium/find_test.go new file mode 100644 index 000000000..4707347ea --- /dev/null +++ b/internal/chromium/find_test.go @@ -0,0 +1,125 @@ +package chromium + +import ( + "errors" + "slices" + "testing" +) + +// TestFindBrowserPicksFirstHit proves missing candidates are skipped and +// preference order decides among the installed ones — against whichever +// candidate list this OS compiles in. +func TestFindBrowserPicksFirstHit(t *testing.T) { + candidates := browserCandidates() + if len(candidates) < 2 { + t.Fatal("candidate list too short to prove ordering") + } + hits := map[string]bool{candidates[1]: true, candidates[len(candidates)-1]: true} + lookPath := func(name string) (string, error) { + if hits[name] { + return "/resolved/" + name, nil + } + return "", errors.New("not found") + } + got, err := FindBrowser(lookPath) + if err != nil { + t.Fatalf("FindBrowser: %v", err) + } + if want := "/resolved/" + candidates[1]; got != want { + t.Fatalf("FindBrowser = %q, want the earliest installed candidate %q", got, want) + } +} + +func TestFindBrowserErrorsWhenNoneInstalled(t *testing.T) { + lookPath := func(string) (string, error) { return "", errors.New("not found") } + if _, err := FindBrowser(lookPath); err == nil { + t.Fatal("want error when no browser is on PATH") + } +} + +// TestWindowsBrowserCandidates proves the Windows list expands only the +// install roots present in the environment, prefers chrome > edge > brave > +// vivaldi, and always ends with the bare PATH names as a last resort. +func TestWindowsBrowserCandidates(t *testing.T) { + env := map[string]string{ + "ProgramFiles": `C:\Program Files`, + "ProgramFiles(x86)": `C:\Program Files (x86)`, + "LocalAppData": `C:\Users\u\AppData\Local`, + } + got := windowsBrowserCandidates(func(k string) string { return env[k] }) + + want := []string{ + `C:\Program Files\Google\Chrome\Application\chrome.exe`, + `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`, + `C:\Users\u\AppData\Local\Google\Chrome\Application\chrome.exe`, + `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`, + `C:\Program Files\Microsoft\Edge\Application\msedge.exe`, + `C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe`, + `C:\Users\u\AppData\Local\BraveSoftware\Brave-Browser\Application\brave.exe`, + `C:\Program Files\Vivaldi\Application\vivaldi.exe`, + `C:\Users\u\AppData\Local\Vivaldi\Application\vivaldi.exe`, + "chrome", + "msedge", + } + if !slices.Equal(got, want) { + t.Fatalf("candidates = %v, want %v", got, want) + } +} + +// TestWindowsBrowserCandidatesEmptyEnv proves a bare environment still leaves +// the PATH names, so FindBrowser never iterates an empty list. +func TestWindowsBrowserCandidatesEmptyEnv(t *testing.T) { + got := windowsBrowserCandidates(func(string) string { return "" }) + if !slices.Equal(got, []string{"chrome", "msedge"}) { + t.Fatalf("candidates = %v, want PATH names only", got) + } +} + +// TestDarwinBrowserCandidates proves the macOS list expands each browser under +// both the system and per-user Applications roots, prefers chrome > chromium > +// edge > brave > vivaldi, and ends with the bare PATH names. +func TestDarwinBrowserCandidates(t *testing.T) { + got := darwinBrowserCandidates(func(k string) string { + if k == "HOME" { + return "/Users/u" + } + return "" + }) + + want := []string{ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Users/u/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Users/u/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Users/u/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + "/Users/u/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi", + "/Users/u/Applications/Vivaldi.app/Contents/MacOS/Vivaldi", + "chromium", + "google-chrome", + } + if !slices.Equal(got, want) { + t.Fatalf("candidates = %v, want %v", got, want) + } +} + +// TestDarwinBrowserCandidatesNoHome proves a missing HOME drops the per-user +// root but still leaves the system paths and PATH names, so FindBrowser never +// iterates an empty list. +func TestDarwinBrowserCandidatesNoHome(t *testing.T) { + got := darwinBrowserCandidates(func(string) string { return "" }) + want := []string{ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi", + "chromium", + "google-chrome", + } + if !slices.Equal(got, want) { + t.Fatalf("candidates = %v, want %v", got, want) + } +} diff --git a/internal/cli/browser.go b/internal/cli/browser.go new file mode 100644 index 000000000..97c4e8d15 --- /dev/null +++ b/internal/cli/browser.go @@ -0,0 +1,273 @@ +package cli + +import ( + "flag" + "fmt" + "strconv" + "strings" + + "github.com/omartelo/lich/internal/browser" +) + +func (c *client) browser(args []string) error { + if len(args) == 0 { + return usageError("browser") + } + if args[0] == "-h" || args[0] == "--help" { + printHelp(c.stdout, newFlagSet("browser")) + return errHelpShown + } + switch args[0] { + case "open": + return c.browserOpen(args[1:]) + case "info": + return c.browserInfo(args[1:]) + case "click": + return c.browserClick(args[1:]) + case "type": + return c.browserType(args[1:]) + case "press": + return c.browserPress(args[1:]) + case "screenshot": + return c.browserScreenshot(args[1:]) + case "navigate": + return c.browserNavigate(args[1:]) + case "reload": + return c.browserSimple("browser.Reload", args[1:]) + case "back": + return c.browserSimple("browser.Back", args[1:]) + case "forward": + return c.browserSimple("browser.Forward", args[1:]) + case "scroll": + return c.browserScroll(args[1:]) + case "list": + return c.browserList(args[1:]) + case "close": + return c.browserSimple("browser.Close", args[1:]) + default: + return usageError("browser") + } +} + +func (c *client) browserOpen(args []string) error { + flags := newFlagSet("browser") + if err := c.parse(flags, args); err != nil { + return err + } + owner, err := c.browserOwner() + if err != nil { + return err + } + var h browser.Handle + if err := c.call("browser.Open", []any{owner, strings.Join(flags.Args(), " ")}, openCall, &h); err != nil { + return err + } + fmt.Fprintln(c.stdout, h.URL) + return nil +} + +func (c *client) browserInfo(args []string) error { + if err := c.browserNoArgs(args); err != nil { + return err + } + owner, err := c.browserOwner() + if err != nil { + return err + } + var info browser.PageInfo + if err := c.call("browser.Info", []any{owner}, openCall, &info); err != nil { + return err + } + return c.emit(info) +} + +func (c *client) browserClick(args []string) error { + flags := newFlagSet("browser") + tf := registerTarget(flags) + if err := c.parse(flags, args); err != nil { + return err + } + t := tf.target() + if t.Index == nil && t.Selector == "" && (t.X == nil || t.Y == nil) { + return fmt.Errorf("click needs --index, --selector, or --x and --y") + } + owner, err := c.browserOwner() + if err != nil { + return err + } + return c.call("browser.Click", []any{owner, t}, shortCall, nil) +} + +func (c *client) browserType(args []string) error { + flags := newFlagSet("browser") + clear := flags.Bool("clear", false, "replace the field before typing") + tf := registerTarget(flags) + if err := c.parse(flags, args); err != nil { + return err + } + text := strings.Join(flags.Args(), " ") + if text == "" { + return usageError("browser") + } + owner, err := c.browserOwner() + if err != nil { + return err + } + return c.call("browser.Type", []any{owner, text, *clear, tf.target()}, shortCall, nil) +} + +func (c *client) browserPress(args []string) error { + flags := newFlagSet("browser") + tf := registerTarget(flags) + if err := c.parse(flags, args); err != nil { + return err + } + key := strings.Join(flags.Args(), " ") + if key == "" { + return usageError("browser") + } + owner, err := c.browserOwner() + if err != nil { + return err + } + return c.call("browser.Press", []any{owner, key, tf.target()}, shortCall, nil) +} + +func (c *client) browserScreenshot(args []string) error { + flags := newFlagSet("browser") + if err := c.parse(flags, args); err != nil { + return err + } + owner, err := c.browserOwner() + if err != nil { + return err + } + dest := "" + if flags.NArg() > 0 { + dest = flags.Arg(0) + } + var path string + if err := c.call("browser.Screenshot", []any{owner, dest}, openCall, &path); err != nil { + return err + } + fmt.Fprintln(c.stdout, path) + return nil +} + +func (c *client) browserNavigate(args []string) error { + flags := newFlagSet("browser") + if err := c.parse(flags, args); err != nil { + return err + } + url := strings.Join(flags.Args(), " ") + if url == "" { + return usageError("browser") + } + owner, err := c.browserOwner() + if err != nil { + return err + } + var h browser.Handle + if err := c.call("browser.Navigate", []any{owner, url}, openCall, &h); err != nil { + return err + } + fmt.Fprintln(c.stdout, h.URL) + return nil +} + +func (c *client) browserScroll(args []string) error { + flags := newFlagSet("browser") + dy := flags.Int("dy", 400, "pixels to scroll (positive is down)") + if err := c.parse(flags, args); err != nil { + return err + } + owner, err := c.browserOwner() + if err != nil { + return err + } + return c.call("browser.Scroll", []any{owner, *dy}, shortCall, nil) +} + +func (c *client) browserList(args []string) error { + if err := c.browserNoArgs(args); err != nil { + return err + } + owner, err := c.browserOwner() + if err != nil { + return err + } + var list []browser.Handle + if err := c.call("browser.List", []any{owner}, shortCall, &list); err != nil { + return err + } + if list == nil { + list = []browser.Handle{} + } + return c.emit(list) +} + +func (c *client) browserSimple(method string, args []string) error { + if err := c.browserNoArgs(args); err != nil { + return err + } + owner, err := c.browserOwner() + if err != nil { + return err + } + return c.call(method, []any{owner}, shortCall, nil) +} + +func (c *client) browserNoArgs(args []string) error { + flags := newFlagSet("browser") + if err := c.parse(flags, args); err != nil { + return err + } + if flags.NArg() != 0 { + return usageError("browser") + } + return nil +} + +type targetFlags struct { + index *int + selector *string + x, y *float64 +} + +func registerTarget(flags *flag.FlagSet) *targetFlags { + t := &targetFlags{} + t.selector = flags.String("selector", "", "CSS selector") + flags.Func("index", "element index from browser info", func(s string) error { + n, err := strconv.Atoi(s) + if err != nil { + return err + } + t.index = &n + return nil + }) + flags.Func("x", "viewport X", func(s string) error { + n, err := strconv.ParseFloat(s, 64) + if err != nil { + return err + } + t.x = &n + return nil + }) + flags.Func("y", "viewport Y", func(s string) error { + n, err := strconv.ParseFloat(s, 64) + if err != nil { + return err + } + t.y = &n + return nil + }) + return t +} + +func (t *targetFlags) target() browser.Target { + sel := "" + if t.selector != nil { + sel = *t.selector + } + return browser.Target{Index: t.index, Selector: sel, X: t.x, Y: t.y} +} diff --git a/internal/cli/browser_test.go b/internal/cli/browser_test.go new file mode 100644 index 000000000..f9d68e214 --- /dev/null +++ b/internal/cli/browser_test.go @@ -0,0 +1,125 @@ +package cli + +import ( + "bytes" + "strings" + "testing" +) + +func TestBrowserOpenCallsThisSessionsBrowser(t *testing.T) { + f := newFakeLich(t, `{"id":"b1","url":"https://example.com","owner":"s1","headed":false}`) + + code, stdout, stderr := run(t, f, "browser", "open", "https://example.com") + if code != 0 { + t.Fatalf("exit = %d, stderr = %q", code, stderr) + } + call := f.only(t) + if call.method != "browser.Open" { + t.Errorf("method = %q", call.method) + } + if len(call.args) != 2 || call.args[0] != "s1" || call.args[1] != "https://example.com" { + t.Errorf("args = %v, want this session and the url", call.args) + } + if !strings.Contains(stdout, "https://example.com") { + t.Errorf("stdout = %q", stdout) + } +} + +func TestBrowserClickSendsATarget(t *testing.T) { + f := newFakeLich(t, `null`) + + code, _, stderr := run(t, f, "browser", "click", "--index", "3") + if code != 0 { + t.Fatalf("exit = %d, stderr = %q", code, stderr) + } + call := f.only(t) + if call.method != "browser.Click" { + t.Errorf("method = %q", call.method) + } + if len(call.args) != 2 || call.args[0] != "s1" { + t.Errorf("args = %v", call.args) + } + target, _ := call.args[1].(map[string]any) + if target["index"] != float64(3) { + t.Errorf("target = %v", call.args[1]) + } +} + +func TestBrowserPressSendsKeyAndOptionalTarget(t *testing.T) { + f := newFakeLich(t, `null`) + + code, _, stderr := run(t, f, "browser", "press", "--index", "2", "Enter") + if code != 0 { + t.Fatalf("exit = %d, stderr = %q", code, stderr) + } + call := f.only(t) + if call.method != "browser.Press" { + t.Errorf("method = %q", call.method) + } + if len(call.args) != 3 || call.args[0] != "s1" || call.args[1] != "Enter" { + t.Errorf("args = %v", call.args) + } + target, _ := call.args[2].(map[string]any) + if target["index"] != float64(2) { + t.Errorf("target = %v", call.args[2]) + } +} + +func TestMCPBrowserPressCallsPress(t *testing.T) { + f := newFakeLich(t, `null`) + + replies := speak(t, f, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params": + {"name":"browser_press","arguments":{"key":"Tab"}}}`) + text, failed := textOf(t, replies[0]) + if failed { + t.Fatalf("tool reported a failure: %s", text) + } + call := f.only(t) + if call.method != "browser.Press" { + t.Errorf("method = %q", call.method) + } + if len(call.args) != 3 || call.args[0] != "s1" || call.args[1] != "Tab" { + t.Errorf("args = %v", call.args) + } +} + +func TestBrowserRefusesWithoutASession(t *testing.T) { + f := newFakeLich(t, `null`) + var stdout, stderr bytes.Buffer + code := dispatch([]string{"browser", "open", "https://example.com"}, &client{ + env: func(key string) string { + if key == "LICH_SESSION_ID" { + return "" + } + return f.env(key) + }, + version: testVersion, stdout: &stdout, stderr: &stderr, running: noInstance, + }) + if code != 1 { + t.Fatalf("exit = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "inside a lich session") { + t.Errorf("stderr = %q", stderr.String()) + } + if len(f.calls) != 0 { + t.Errorf("reached the app without a session: %+v", f.calls) + } +} + +func TestMCPBrowserOpenUsesTheCaller(t *testing.T) { + f := newFakeLich(t, `{"id":"b1","url":"https://example.com","owner":"s1","headed":false}`) + + replies := speak(t, f, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params": + {"name":"browser_open","arguments":{"url":"https://example.com"}}}`) + text, failed := textOf(t, replies[0]) + if failed { + t.Fatalf("tool reported a failure: %s", text) + } + call := f.only(t) + if call.method != "browser.Open" { + t.Errorf("method = %q", call.method) + } + if len(call.args) != 2 || call.args[0] != "s1" { + t.Errorf("args = %v, want this session", call.args) + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 4b9f5c6f9..fede3e3b2 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -123,6 +123,8 @@ func dispatch(args []string, c *client) int { // printed, and a second "lich: …" line on stderr would only say it // again, worse. return c.doctor(args[1:]) + case "browser": + return c.run(c.browser, args[1:]) case "help", "--help", "-h": // `lich help ` is the command's own help, which is the flags // and not just the paragraph the list above shows. diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 0a57563de..601046c50 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -237,7 +237,7 @@ func TestHelpPrintsEveryCommand(t *testing.T) { } for _, want := range []string{ "lich sessions", "lich send", "lich wait", "lich reply", "lich open", - "lich close", "lich worktrees", + "lich close", "lich worktrees", "lich browser", } { if !strings.Contains(stdout, want) { t.Errorf("help does not document %q", want) diff --git a/internal/cli/help.go b/internal/cli/help.go index 55e1e662b..d38a874f2 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -84,6 +84,13 @@ var commands = []command{ about: "List a project's git worktrees: what is uncommitted in each and which\n" + "sessions are open in it.", }, + { + name: "browser", + args: " [args]", + about: "Drive this session's browser — the same window Browser tab opens.\n" + + "Verbs: open [url], info, click, type, press , screenshot [path], navigate ,\n" + + "reload, back, forward, scroll, list, close.", + }, { name: "cost", args: "[--project ] [--provider ] [--since ]\n" + diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index e3d2c5ce3..a870b305d 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -94,7 +94,9 @@ Delegate in parallel: for work that can run beside yours, open a worker session Never poll for results. A send that outlives its wait hands back a ticket and you carry on with your own work; when results are ready, one short [lich] note arrives at your prompt — collect everything at once with wait_for_answer (no ticket). Polling in a loop burns the tokens this design exists to save. -When a [lich] message carrying a ticket arrives at YOUR prompt, you are the worker: do the task, then answer with ` + relay.ToolReply + ` — a concise report (what was done, where, what remains), never a transcript.` +When a [lich] message carrying a ticket arrives at YOUR prompt, you are the worker: do the task, then answer with ` + relay.ToolReply + ` — a concise report (what was done, where, what remains), never a transcript. + +This session also has a browser of its own (browser_* tools): the same Chromium window Browser tab opens on this card, not the lich UI. Open it, read the page, click, type, press named keys (Enter/Tab/Escape/…), screenshot. Screenshots are files — never paste their bytes into a reply.` // serveMCP runs the stdio server until its input ends. func (c *client) serveMCP(args []string) error { @@ -180,7 +182,7 @@ func (c *client) callMCPTool(params json.RawMessage) (any, *jsonRPCError) { if err := json.Unmarshal(params, &call); err != nil { return nil, &jsonRPCError{Code: codeInvalidParams, Message: err.Error()} } - for _, tool := range mcpTools { + for _, tool := range allMCPTools() { if tool.Name != call.Name { continue } @@ -204,8 +206,9 @@ func mcpText(text string, failed bool) map[string]any { } func mcpToolList() []map[string]any { - list := make([]map[string]any, 0, len(mcpTools)) - for _, tool := range mcpTools { + tools := allMCPTools() + list := make([]map[string]any, 0, len(tools)) + for _, tool := range tools { entry := map[string]any{ "name": tool.Name, "description": tool.Description, @@ -219,6 +222,11 @@ func mcpToolList() []map[string]any { return list } +func allMCPTools() []mcpTool { + out := make([]mcpTool, 0, len(mcpTools)+len(browserTools)) + return append(append(out, mcpTools...), browserTools...) +} + // mcpArgs is one tool call's arguments, read leniently: a model that sends a // number as a string, or omits an optional field, should not lose its turn to a // type error. @@ -266,6 +274,53 @@ func (a mcpArgs) seconds(key string) int { return asked } +// optionalInt reads a number that may be absent. Missing, empty, or a type +// this cannot parse is nil rather than zero: zero is a valid element index. +func (a mcpArgs) optionalInt(key string) *int { + v, ok := a[key] + if !ok || v == nil { + return nil + } + n := 0 + switch x := v.(type) { + case float64: + n = int(x) + case string: + if x == "" { + return nil + } + if _, err := fmt.Sscanf(x, "%d", &n); err != nil { + return nil + } + default: + return nil + } + return &n +} + +// optionalFloat is optionalInt for viewport coordinates. +func (a mcpArgs) optionalFloat(key string) *float64 { + v, ok := a[key] + if !ok || v == nil { + return nil + } + n := 0.0 + switch x := v.(type) { + case float64: + n = x + case string: + if x == "" { + return nil + } + if _, err := fmt.Sscanf(x, "%f", &n); err != nil { + return nil + } + default: + return nil + } + return &n +} + // schema builds an inputSchema from named properties and the subset of them // that is required. func schema(properties map[string]any, required ...string) map[string]any { @@ -283,9 +338,10 @@ func property(kind, description string) map[string]any { return map[string]any{"type": kind, "description": description} } -// mcpTools is the whole tool surface. Adding one is an entry here — the reason -// registering MCP once buys every tool lich grows later, on every provider, -// with no further per-provider work. +// mcpTools is the relay/session surface. Browser tools live in mcp_browser.go +// and allMCPTools concatenates the two, which is what registering MCP once +// buys — every tool lich grows later reaches every provider that took the +// registration, with no further per-provider work. var mcpTools = []mcpTool{ { Name: "list_sessions", diff --git a/internal/cli/mcp_browser.go b/internal/cli/mcp_browser.go new file mode 100644 index 000000000..ea015b43d --- /dev/null +++ b/internal/cli/mcp_browser.go @@ -0,0 +1,243 @@ +package cli + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/omartelo/lich/internal/browser" +) + +// browserTools are this session's Chromium sidecar — the same window Browser +// tab opens. They are not in mcpTools because that file is already the relay +// surface; allMCPTools concatenates the two so tools/list cannot drift. +var browserTools = []mcpTool{ + { + Name: "browser_open", + Description: "Open a page in this lich session's browser. Creates the browser if " + + "this session has none (headless unless the user already opened Browser tab on " + + "this card). The URL must be http, https, or omitted (about:blank). file, " + + "javascript and data URLs are refused. This is not the lich window.", + Schema: schema(map[string]any{ + "url": property("string", "Page to load. Omit for about:blank."), + }), + Run: func(c *client, a mcpArgs) (string, error) { + owner, err := c.browserOwner() + if err != nil { + return "", err + } + var h browser.Handle + if err := c.call("browser.Open", []any{owner, a.text("url")}, openCall, &h); err != nil { + return "", err + } + return encodeJSON(h) + }, + }, + { + Name: "browser_info", + Description: "Read this session's current page: URL, title, visible text, and " + + "interactive elements (index, tag, label, href, box). Form values are never " + + "included — filled/checked say that typing landed. Call this before click/type " + + "so indexes match. Read-only.", + Schema: schema(map[string]any{}), + ReadOnly: true, + Run: mcpBrowserInfo, + }, + { + Name: "browser_click", + Description: "Click in this session's browser. Prefer index from browser_info, or a " + + "CSS selector, or viewport x and y. Sends a real mouse event, not element.click().", + Schema: schema(map[string]any{ + "index": property("integer", "Index from the last browser_info."), + "selector": property("string", "CSS selector."), + "x": property("number", "Viewport X."), + "y": property("number", "Viewport Y."), + }), + Run: func(c *client, a mcpArgs) (string, error) { + return mcpBrowserVoid(c, "browser.Click", []any{mcpTarget(a)}, shortCall) + }, + }, + { + Name: "browser_type", + Description: "Type into this session's browser. Target as for browser_click; omit it " + + "to type into whatever is focused. clear replaces the field first. The text never " + + "comes back through browser_info.", + Schema: schema(map[string]any{ + "text": property("string", "Keys to send."), + "clear": property("boolean", "Replace the field before typing."), + "index": property("integer", "Index from the last browser_info."), + "selector": property("string", "CSS selector."), + "x": property("number", "Viewport X."), + "y": property("number", "Viewport Y."), + }, "text"), + Run: func(c *client, a mcpArgs) (string, error) { + return mcpBrowserVoid(c, "browser.Type", []any{a.text("text"), a.flag("clear"), mcpTarget(a)}, shortCall) + }, + }, + { + Name: "browser_press", + Description: "Press one named key in this session's browser (Enter, Tab, Escape, " + + "Backspace, Delete, Space, ArrowUp/Down/Left/Right, Home, End, PageUp, PageDown). " + + "Real key event, not typed text — use browser_type for that. Optional target " + + "focuses first, as for browser_type.", + Schema: schema(map[string]any{ + "key": property("string", "Named key: Enter, Tab, Escape, …"), + "index": property("integer", "Index from the last browser_info."), + "selector": property("string", "CSS selector."), + "x": property("number", "Viewport X."), + "y": property("number", "Viewport Y."), + }, "key"), + Run: func(c *client, a mcpArgs) (string, error) { + return mcpBrowserVoid(c, "browser.Press", []any{a.text("key"), mcpTarget(a)}, shortCall) + }, + }, + { + Name: "browser_screenshot", + Description: "Capture this session's page to a PNG file and return the path. Do not " + + "inline the bytes. Optional path; otherwise lich writes one next to the browser profile.", + Schema: schema(map[string]any{ + "path": property("string", "Destination PNG. Omit to let lich choose."), + }), + ReadOnly: true, + Run: func(c *client, a mcpArgs) (string, error) { + owner, err := c.browserOwner() + if err != nil { + return "", err + } + var dest string + if err := c.call("browser.Screenshot", []any{owner, a.text("path")}, openCall, &dest); err != nil { + return "", err + } + return dest, nil + }, + }, + { + Name: "browser_navigate", + Description: "Load a URL in this session's already-open browser. Same allowlist as browser_open.", + Schema: schema(map[string]any{ + "url": property("string", "http(s) URL, or about:blank."), + }, "url"), + Run: func(c *client, a mcpArgs) (string, error) { + owner, err := c.browserOwner() + if err != nil { + return "", err + } + var h browser.Handle + if err := c.call("browser.Navigate", []any{owner, a.text("url")}, openCall, &h); err != nil { + return "", err + } + return encodeJSON(h) + }, + }, + { + Name: "browser_reload", + Description: "Reload this session's current page.", + Schema: schema(map[string]any{}), + Run: func(c *client, _ mcpArgs) (string, error) { return mcpBrowserVoid(c, "browser.Reload", nil, shortCall) }, + }, + { + Name: "browser_back", + Description: "Go back in this session's browser history.", + Schema: schema(map[string]any{}), + Run: func(c *client, _ mcpArgs) (string, error) { return mcpBrowserVoid(c, "browser.Back", nil, shortCall) }, + }, + { + Name: "browser_forward", + Description: "Go forward in this session's browser history.", + Schema: schema(map[string]any{}), + Run: func(c *client, _ mcpArgs) (string, error) { + return mcpBrowserVoid(c, "browser.Forward", nil, shortCall) + }, + }, + { + Name: "browser_scroll", + Description: "Scroll this session's page. dy is CSS pixels; positive is down. Default 400.", + Schema: schema(map[string]any{ + "dy": property("integer", "Pixels to scroll. Default 400."), + }), + Run: func(c *client, a mcpArgs) (string, error) { + dy := 400 + if n := a.optionalInt("dy"); n != nil { + dy = *n + } + return mcpBrowserVoid(c, "browser.Scroll", []any{dy}, shortCall) + }, + }, + { + Name: "browser_list", + Description: "This session's browser, or an empty list. Read-only.", + Schema: schema(map[string]any{}), + ReadOnly: true, + Run: mcpBrowserList, + }, + { + Name: "browser_close", + Description: "Close this session's browser. The lich window is untouched. Closing the " + + "session card also closes it.", + Schema: schema(map[string]any{}), + Run: func(c *client, _ mcpArgs) (string, error) { return mcpBrowserVoid(c, "browser.Close", nil, shortCall) }, + }, +} + +func (c *client) browserOwner() (string, error) { + id := c.sessionID() + if id == "" { + return "", fmt.Errorf("browser tools run inside a lich session") + } + return id, nil +} + +func mcpTarget(a mcpArgs) browser.Target { + t := browser.Target{Selector: a.text("selector")} + t.Index = a.optionalInt("index") + t.X = a.optionalFloat("x") + t.Y = a.optionalFloat("y") + return t +} + +func mcpBrowserVoid(c *client, method string, extra []any, wait time.Duration) (string, error) { + owner, err := c.browserOwner() + if err != nil { + return "", err + } + args := append([]any{owner}, extra...) + if err := c.call(method, args, wait, nil); err != nil { + return "", err + } + return "ok", nil +} + +func encodeJSON(v any) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil +} + +func mcpBrowserInfo(c *client, _ mcpArgs) (string, error) { + owner, err := c.browserOwner() + if err != nil { + return "", err + } + var info browser.PageInfo + if err := c.call("browser.Info", []any{owner}, openCall, &info); err != nil { + return "", err + } + return encodeJSON(info) +} + +func mcpBrowserList(c *client, _ mcpArgs) (string, error) { + owner, err := c.browserOwner() + if err != nil { + return "", err + } + var list []browser.Handle + if err := c.call("browser.List", []any{owner}, shortCall, &list); err != nil { + return "", err + } + if list == nil { + list = []browser.Handle{} + } + return encodeJSON(list) +} diff --git a/internal/cli/mcp_test.go b/internal/cli/mcp_test.go index ee2a007c5..da118ceab 100644 --- a/internal/cli/mcp_test.go +++ b/internal/cli/mcp_test.go @@ -102,7 +102,7 @@ func TestMCPHandshakeAdvertisesTools(t *testing.T) { // the one thing they get wrong on their own: that fanning work out means // these sessions and not the subagents their own harness offers. for _, want := range []string{ - "open_session", "send_to_session", "wait_for_answer", "worktree", "subagent", + "open_session", "send_to_session", "wait_for_answer", "worktree", "subagent", "browser_", } { if !strings.Contains(result.Instructions, want) { t.Errorf("instructions are missing %q:\n%s", want, result.Instructions) @@ -218,18 +218,34 @@ func TestMCPListsEveryTool(t *testing.T) { resultOf(t, replies[0], &result) want := map[string][]string{ - "list_sessions": {}, - "open_session": {}, - "close_session": {"session"}, - "rename_session": {"label"}, - "list_worktrees": {}, - "send_to_session": {"session", "prompt"}, - "wait_for_answer": {}, - "reply_to_session": {"answer"}, + "list_sessions": {}, + "open_session": {}, + "close_session": {"session"}, + "list_worktrees": {}, + "send_to_session": {"session", "prompt"}, + "wait_for_answer": {}, + "reply_to_session": {"answer"}, + "browser_open": {}, + "browser_info": {}, + "browser_click": {}, + "browser_type": {"text"}, + "browser_press": {"key"}, + "browser_screenshot": {}, + "browser_navigate": {"url"}, + "browser_reload": {}, + "browser_back": {}, + "browser_forward": {}, + "browser_scroll": {}, + "browser_list": {}, + "browser_close": {}, + "rename_session": {"label"}, } // The read-only ones, which a client may auto-allow. wait_for_answer is // not among them: collecting drains the inbox. - readOnly := map[string]bool{"list_sessions": true, "list_worktrees": true} + readOnly := map[string]bool{ + "list_sessions": true, "list_worktrees": true, + "browser_info": true, "browser_list": true, "browser_screenshot": true, + } if len(result.Tools) != len(want) { t.Fatalf("got %d tools, want %d", len(result.Tools), len(want)) } diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go index 21f667cd9..af0a9a923 100644 --- a/internal/terminal/terminal.go +++ b/internal/terminal/terminal.go @@ -270,6 +270,11 @@ type Service struct { // without answering (internal/relay). Guarded by mu: wired after the // transport is already serving. onState func(id, state string) + // onClose, when set, receives every session id Close removes. The agent + // browser hangs off it so a card that goes away takes its Chromium with it. + // Guarded by mu: wired after the transport is already serving. + onClose func(id string) + // spawns is how each live session's PTY was started — what it runs and where // — keyed by session id. Read by providerKind, which is what stops a // session-start report from repainting a card lich itself chose the provider @@ -283,6 +288,7 @@ type Service struct { // Written once per spawn and read on every report, which is what sync.Map // is for. spawns sync.Map + // turns is which sessions have a turn open right now, which is what tells a // `waiting` report that a human is blocking from one that only says the // session is sitting at its prompt (see turnLog). It carries its own lock: @@ -634,6 +640,15 @@ func (s *Service) SetSessionState(fn func(id, state string)) { s.onState = fn } +// SetOnClose wires fn to every session Close. Only one watcher: the agent +// browser is torn down with the card that owned it, and the terminal package +// must not import that one. +func (s *Service) SetOnClose(fn func(id string)) { + s.mu.Lock() + defer s.mu.Unlock() + s.onClose = fn +} + // stateWatcher reads the watcher under the lock, so a report arriving while it // is being wired sees one function or none, never a torn read. func (s *Service) stateWatcher() func(id, state string) { @@ -730,6 +745,7 @@ func (s *Service) Close(id string) error { delete(s.sessions, id) close(sess.done) } + onClose := s.onClose s.mu.Unlock() s.spawns.Delete(id) // A turn dies with its session, and an open one left behind would keep the @@ -751,6 +767,9 @@ func (s *Service) Close(id string) error { if !ok { return nil } + if onClose != nil { + onClose(id) + } return sess.pty.Close() } diff --git a/internal/terminal/terminal_test.go b/internal/terminal/terminal_test.go index edfc29a1d..05484fa3b 100644 --- a/internal/terminal/terminal_test.go +++ b/internal/terminal/terminal_test.go @@ -1471,6 +1471,35 @@ func TestSessionStateWithoutAWatcherIsHarmless(t *testing.T) { } } +// TestCloseNotifiesOnClose proves the seam the agent browser hangs off: closing +// a card must tell the watcher, not only tear down the PTY. A miss here leaves +// a Chromium running after the session is gone. +func TestCloseNotifiesOnClose(t *testing.T) { + svc := &Service{sessions: map[string]*session{}} + pty := &shortWritePTY{} + svc.sessions["s1"] = &session{pty: pty, done: make(chan struct{})} + var seen string + svc.SetOnClose(func(id string) { seen = id }) + + if err := svc.Close("s1"); err != nil { + t.Fatalf("Close: %v", err) + } + if seen != "s1" { + t.Fatalf("onClose = %q, want s1", seen) + } + if svc.Live("s1") { + t.Fatal("session still live after Close") + } + + seen = "stale" + if err := svc.Close("gone"); err != nil { + t.Fatalf("Close missing: %v", err) + } + if seen != "stale" { + t.Fatal("onClose fired for a session that was not running") + } +} + // The size a session starts at is a contract with the window, not an // implementation detail: a session drawn into a grid the window does not have // is repainted on its first view, and whatever its provider had already written diff --git a/main.go b/main.go index 893c1114f..ffd2c654c 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,7 @@ import ( "github.com/ncruces/zenity" "github.com/omartelo/lich/internal/agentplugin" "github.com/omartelo/lich/internal/appupdate" + "github.com/omartelo/lich/internal/browser" "github.com/omartelo/lich/internal/chromium" "github.com/omartelo/lich/internal/cli" "github.com/omartelo/lich/internal/drop" @@ -282,6 +283,12 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub // the PTY here rather than waiting for someone to click the card. dispatcher.Register("spawn", spawn.New(db, proj, term, hub)) dispatcher.Register("themes", themes.New()) + // One Chromium sidecar per lich session — never the --app window. Closed + // with the card, and with the process on window-close. + brows := browser.New() + defer brows.Cleanup() + term.SetOnClose(func(id string) { _ = brows.CloseOwnedBy(id) }) + dispatcher.Register("browser", brows) denyInternal(dispatcher) term.Mount("/rpc/", dispatcher) term.Mount("/drop", http.HandlerFunc(drops.Upload)) @@ -313,12 +320,17 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub // due prompt. // - relay.SetPlugins, project.SetAccounts, project.SetProjects, // quota.SetSessions, store.SetSessionGone, store.SetScheduleForfeited, -// store.SetBranchOf, store.SetTranscriptOf and terminal.SetDropDir are startup wiring. Called with [null] they silently -// nil what they wired (encoding/json leaves a func or pointer alone on -// null), and the write races the readers already serving — nilling -// SetProjects also disarms the guard that keeps two projects off the same -// directory, and SetDropDir points the sandbox's read-only bind wherever -// the caller likes. +// store.SetBranchOf, store.SetTranscriptOf and terminal.SetDropDir are +// startup wiring. Called with [null] they silently nil what they wired +// (encoding/json leaves a func or pointer alone on null), and the write +// races the readers already serving — nilling SetProjects also disarms the +// guard that keeps two projects off the same directory, and SetDropDir +// points the sandbox's read-only bind wherever the caller likes. +// - browser.Cleanup and browser.CloseOwnedBy are process/card teardown. The +// window opens a visible browser through OpenVisible; the agent closes one +// through Close. Cleanup on a page POST would kill every session's Chromium +// without closing a card. + func denyInternal(d *rpc.Handler) { for _, method := range []string{ "store.Close", @@ -335,6 +347,8 @@ func denyInternal(d *rpc.Handler) { "relay.SetPlugins", "project.SetAccounts", "project.SetProjects", + "browser.Cleanup", + "browser.CloseOwnedBy", "quota.SetSessions", "terminal.SessionAccount", "terminal.SetDropDir", diff --git a/main_test.go b/main_test.go index 94e1fd944..ff350c9b4 100644 --- a/main_test.go +++ b/main_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/omartelo/lich/internal/browser" "github.com/omartelo/lich/internal/drop" "github.com/omartelo/lich/internal/project" "github.com/omartelo/lich/internal/relay" @@ -32,6 +33,8 @@ var denied = map[string]reflect.Type{ "relay.SetPlugins": reflect.TypeFor[*relay.Service](), "project.SetAccounts": reflect.TypeFor[*project.Service](), "project.SetProjects": reflect.TypeFor[*project.Service](), + "browser.Cleanup": reflect.TypeFor[*browser.Service](), + "browser.CloseOwnedBy": reflect.TypeFor[*browser.Service](), "terminal.SetDropDir": reflect.TypeFor[*terminal.Service](), } @@ -53,10 +56,13 @@ func (stubService) SetAccounts() error { return nil } func (stubService) SetProjects() error { return nil } func (stubService) SetDropDir() error { return nil } func (stubService) Allowed() error { return nil } +func (stubService) Cleanup() error { return nil } +func (stubService) CloseOwnedBy() error { return nil } + func denyingDispatcher() *rpc.Handler { d := rpc.New() - for _, service := range []string{"store", "drop", "relay", "project", "terminal"} { + for _, service := range []string{"store", "drop", "relay", "project", "terminal", "browser"} { d.Register(service, stubService{}) } denyInternal(d)