From ad6bddb8332d35d3217d00750ec136763abfc3bd Mon Sep 17 00:00:00 2001 From: DiegoSGbr <57621928+DiegoSGbr@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:44:48 -0300 Subject: [PATCH 1/6] browser tab --- CHANGELOG.md | 9 + SECURITY.md | 5 + Taskfile.yml | 6 +- docs/browser-tab.md | 47 +++ docs/ceilings.md | 11 +- docs/chromium-shell.md | 7 +- docs/cli.md | 47 ++- .../src/components/sidebar/SessionCard.tsx | 16 +- frontend/src/lib/api-types.ts | 9 + frontend/src/lib/rpc.ts | 6 + go.mod | 7 + go.sum | 18 + internal/browser/actions.go | 169 ++++++++ internal/browser/browser.go | 294 ++++++++++++++ internal/browser/browser_test.go | 361 ++++++++++++++++++ internal/browser/chrome.go | 155 ++++++++ internal/browser/fake_test.go | 143 +++++++ internal/browser/page.go | 81 ++++ internal/browser/pageinfo.go | 82 ++++ internal/browser/pageinfo_test.go | 51 +++ internal/browser/url.go | 34 ++ internal/browser/url_test.go | 37 ++ internal/cli/browser.go | 254 ++++++++++++ internal/cli/browser_test.go | 87 +++++ internal/cli/cli.go | 2 + internal/cli/cli_test.go | 2 +- internal/cli/help.go | 7 + internal/cli/mcp.go | 70 +++- internal/cli/mcp_browser.go | 226 +++++++++++ internal/cli/mcp_test.go | 33 +- internal/terminal/terminal.go | 17 + internal/terminal/terminal_test.go | 29 ++ main.go | 13 + main_test.go | 39 +- 34 files changed, 2326 insertions(+), 48 deletions(-) create mode 100644 docs/browser-tab.md create mode 100644 internal/browser/actions.go create mode 100644 internal/browser/browser.go create mode 100644 internal/browser/browser_test.go create mode 100644 internal/browser/chrome.go create mode 100644 internal/browser/fake_test.go create mode 100644 internal/browser/page.go create mode 100644 internal/browser/pageinfo.go create mode 100644 internal/browser/pageinfo_test.go create mode 100644 internal/browser/url.go create mode 100644 internal/browser/url_test.go create mode 100644 internal/cli/browser.go create mode 100644 internal/cli/browser_test.go create mode 100644 internal/cli/mcp_browser.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 789462d5b..d13c0ac21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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 and screenshot. It is the browser already on the machine, not + an in-app tab, and closing it does not quit lich. `file:` and `javascript:` + URLs are refused, and form values never enter the transcript. + ## [0.35.0] - 2026-08-16 ### Added diff --git a/SECURITY.md b/SECURITY.md index ae82f9ade..a2b80effe 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/Taskfile.yml b/Taskfile.yml index 5a22676d6..93b27ac40 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -10,7 +10,8 @@ vars: VITE_PORT: '{{.LICH_VITE_PORT | default 9245}}' # Package version follows the git tag (env VERSION overrides) — no manual bump. VERSION: - sh: echo "${VERSION:-$(git describe --tags --always 2>/dev/null | sed 's/^v//')}" + # ${v#v} strips a leading v without sed (missing on a stock Windows PATH). + sh: v="${VERSION:-$(git describe --tags --always 2>/dev/null)}"; echo "${v#v}" # -X main.version stamps the git tag into the binary so the in-app update # check knows what it is running (dev builds via `go run` stay "dev"). LDFLAGS: '-s -w -X main.version={{.VERSION}}' @@ -86,7 +87,8 @@ tasks: # Task's embedded shell (mvdan/sh) only knows the EXIT trap; Ctrl+C # reaches the whole process group (Vite included) on its own. trap 'kill $VITE_PID 2>/dev/null || true' EXIT - sleep 1.5 + # sleep(1) is not on a stock Windows PATH; Node is a prerequisite. + node -e "setTimeout(function(){process.exit(0)},1500)" LICH_DEV=1 LICH_DEV_URL="http://127.0.0.1:{{.VITE_PORT}}" LICH_LISTEN_PORT=47822 go run . test: diff --git a/docs/browser-tab.md b/docs/browser-tab.md new file mode 100644 index 000000000..18495af6c --- /dev/null +++ b/docs/browser-tab.md @@ -0,0 +1,47 @@ +# 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`, 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 and type are real input events, not `element.click()`. + +## 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 10ef06cb1..dc0cf79ba 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -56,10 +56,19 @@ work when nobody knows it and that the call site never shows. The mechanism and - **Installing the plugin writes into three 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, + 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` 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 diff --git a/docs/chromium-shell.md b/docs/chromium-shell.md index 6c433abc0..e035bc3c9 100644 --- a/docs/chromium-shell.md +++ b/docs/chromium-shell.md @@ -148,6 +148,9 @@ Costs, and why it waits: **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. The migration path from option 1 is small: the whole app is +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. +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. diff --git a/docs/cli.md b/docs/cli.md index 9c12841ae..984198eaf 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 @@ -359,6 +360,27 @@ 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 mcp` Serves the commands above as MCP tools over stdio: one JSON-RPC 2.0 message per @@ -378,6 +400,16 @@ at lich. | `open_session` | optional `project`, `kind`, `worktree`, `base`, `model` — `lich open` — plus optional `prompt` — `lich open --prompt`, the same hand-off in the same call. | | `close_session` | `session`, optional `project`, `worktree` (`keep`/`remove`), `force`. | | `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. @@ -385,8 +417,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 ]` @@ -474,7 +506,7 @@ own command line (`providers.AcceptsMCPServer`): | Claude Code | `--mcp-config` with a JSON string, no file on disk | at spawn | | Codex | `-c mcp_servers.lich.command=…` and `…args=["mcp"]` | at spawn | | Crush | an `mcp add` line in the block the plugin install writes into `crushrc` | with the plugin | -| opencode | its plugin defines the same seven 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 | Only the first two can be told on their own command line, which is what makes @@ -618,10 +650,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 b76c72511..88f7433bc 100644 --- a/frontend/src/components/sidebar/SessionCard.tsx +++ b/frontend/src/components/sidebar/SessionCard.tsx @@ -8,6 +8,7 @@ import { CornerDownLeft, GitBranch, GitPullRequestArrow, + Globe, Inbox, Pencil, Pin, @@ -17,7 +18,7 @@ import { X, } from "lucide-react" import { useSortable } from "@dnd-kit/sortable" -import { cn } from "@/lib/utils" +import { cn, errorText } from "@/lib/utils" import { dragStyle } from "@/lib/use-sortable-list" import { displayPath } from "@/lib/paths" import type { Session } from "@/lib/session/sessions" @@ -43,12 +44,13 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu" -import { Terminal as TerminalService } from "@/lib/rpc" +import { Terminal as TerminalService, Browser } from "@/lib/rpc" import type { DelegateGroup } from "@/lib/session/delegate-targets" import { delegatePrompt, delegateWorktreePrompt } from "@/lib/session/delegate-prompt" import { bracketedPaste } from "@/lib/terminal/bracketed-paste" import { requestTerminalFocus } from "@/lib/terminal/focus-request" import { SessionTargetPicker } from "./SessionTargetPicker" +import { toast } from "sonner" interface SessionCardProps { session: Session @@ -431,6 +433,16 @@ export function SessionCard({ {pinned ? : } {pinned ? "Unpin" : "Pin"} + { + void Browser.OpenVisible(session.id).catch((err) => { + toast.error(`Browser: ${errorText(err)}`) + }) + }} + > + + Browser tab + {session.kind !== "shell" && ( onOpenTerminal(shownPath)}> diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 94ee0622e..4d24096ae 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -431,3 +431,12 @@ export interface DetectedProvider { installed: boolean path: string } + +/** internal/browser.Handle — this session's Chromium sidecar. */ +export interface BrowserHandle { + id: string + url: string + title: string + owner: string + headed: boolean +} diff --git a/frontend/src/lib/rpc.ts b/frontend/src/lib/rpc.ts index fe196a621..eb7f9ecdc 100644 --- a/frontend/src/lib/rpc.ts +++ b/frontend/src/lib/rpc.ts @@ -11,6 +11,7 @@ import type { BaseStatus, BranchRules, Branches, + BrowserHandle, CommitIdentity, DetectedProvider, Diagnostics as DiagnosticsData, @@ -400,6 +401,11 @@ export const Providers = { Detect: () => call("providers.Detect", []), } +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 Themes = { /** Bundled themes plus user-imported themes from the config dir. */ List: () => call("themes.List", []), diff --git a/go.mod b/go.mod index f6c4bd085..58636bef2 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.26.6 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 @@ -15,8 +17,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 19d977f63..f8a32882a 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..03c94ebee --- /dev/null +++ b/internal/browser/actions.go @@ -0,0 +1,169 @@ +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) + }) +} + +// 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..74f543d99 --- /dev/null +++ b/internal/browser/browser_test.go @@ -0,0 +1,361 @@ +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) + } + 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 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 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..95844fcf8 --- /dev/null +++ b/internal/browser/chrome.go @@ -0,0 +1,155 @@ +package browser + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + + "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 — the same binary as the lich window — but UserDataDir +// is this ephemeral dir, never lich/chromium-profile. Attaching to the --app +// window would expose the UI 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 +} + +func (p *chromePage) Navigate(ctx context.Context, url string) error { + return chromedp.Run(ctx, chromedp.Navigate(url)) +} + +func (p *chromePage) Info(ctx context.Context) (PageInfo, error) { + var info PageInfo + if err := chromedp.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 chromedp.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 := chromedp.Run(ctx, chromedp.Evaluate(`(() => { + const el = document.activeElement; + if (el && 'value' in el) el.value = ''; + })()`, nil)) + if err != nil { + return err + } + } + return chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { + return input.InsertText(text).Do(ctx) + })) +} + +func (p *chromePage) Screenshot(ctx context.Context, dest string) error { + var buf []byte + if err := chromedp.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 chromedp.Run(ctx, chromedp.Reload()) +} + +func (p *chromePage) Back(ctx context.Context) error { + return chromedp.Run(ctx, chromedp.NavigateBack()) +} + +func (p *chromePage) Forward(ctx context.Context) error { + return chromedp.Run(ctx, chromedp.NavigateForward()) +} + +func (p *chromePage) Scroll(ctx context.Context, dy int) error { + return chromedp.Run(ctx, chromedp.Evaluate(fmt.Sprintf("window.scrollBy(0, %d)", dy), nil)) +} + +func (p *chromePage) Focus(ctx context.Context) error { + return chromedp.Run(ctx, chromedp.Evaluate("window.focus()", nil)) +} + +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 := chromedp.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/fake_test.go b/internal/browser/fake_test.go new file mode 100644 index 000000000..4c36a5f9e --- /dev/null +++ b/internal/browser/fake_test.go @@ -0,0 +1,143 @@ +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 + 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 +} + +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) 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/page.go b/internal/browser/page.go new file mode 100644 index 000000000..0c6c6e0a7 --- /dev/null +++ b/internal/browser/page.go @@ -0,0 +1,81 @@ +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 + 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/cli/browser.go b/internal/cli/browser.go new file mode 100644 index 000000000..e9b0d1ae2 --- /dev/null +++ b/internal/cli/browser.go @@ -0,0 +1,254 @@ +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 "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) 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..321a97816 --- /dev/null +++ b/internal/cli/browser_test.go @@ -0,0 +1,87 @@ +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 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 ce4f1c467..90ec22a61 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 2ef7148f7..c4b9a958c 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 1c09956d5..60e58a7f4 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -70,6 +70,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, screenshot [path], navigate ,\n" + + "reload, back, forward, scroll, list, close.", + }, { name: "mcp", args: "", diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 4475f13b4..c5ed3bcb2 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, 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..e49d96dcb --- /dev/null +++ b/internal/cli/mcp_browser.go @@ -0,0 +1,226 @@ +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_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 a27e2aa3e..6f45eb676 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,17 +218,32 @@ func TestMCPListsEveryTool(t *testing.T) { resultOf(t, replies[0], &result) want := map[string][]string{ - "list_sessions": {}, - "open_session": {}, - "close_session": {"session"}, - "list_worktrees": {}, - "send_to_session": {"session", "prompt"}, - "wait_for_answer": {}, - "reply_to_session": {"ticket", "answer"}, + "list_sessions": {}, + "open_session": {}, + "close_session": {"session"}, + "list_worktrees": {}, + "send_to_session": {"session", "prompt"}, + "wait_for_answer": {}, + "reply_to_session": {"ticket", "answer"}, + "browser_open": {}, + "browser_info": {}, + "browser_click": {}, + "browser_type": {"text"}, + "browser_screenshot": {}, + "browser_navigate": {"url"}, + "browser_reload": {}, + "browser_back": {}, + "browser_forward": {}, + "browser_scroll": {}, + "browser_list": {}, + "browser_close": {}, } // 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 b2b3e2a59..9b590efd5 100644 --- a/internal/terminal/terminal.go +++ b/internal/terminal/terminal.go @@ -183,6 +183,10 @@ 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) // 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: @@ -339,6 +343,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) { @@ -754,11 +767,15 @@ func (s *Service) Close(id string) error { delete(s.sessions, id) close(sess.done) } + onClose := s.onClose s.mu.Unlock() 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 90605c505..636a95b4e 100644 --- a/internal/terminal/terminal_test.go +++ b/internal/terminal/terminal_test.go @@ -1151,6 +1151,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 14b7ab2a8..a42330476 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,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" @@ -148,6 +149,12 @@ func main() { // 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)) @@ -184,6 +191,10 @@ func main() { // (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. +// - 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", @@ -193,6 +204,8 @@ func denyInternal(d *rpc.Handler) { "relay.SetPlugins", "project.SetAccounts", "project.SetProjects", + "browser.Cleanup", + "browser.CloseOwnedBy", } { d.Deny(method) } diff --git a/main_test.go b/main_test.go index d8c1261be..6d7360826 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" @@ -20,32 +21,36 @@ import ( // fails TestDeniedMethodsAreUnreachable and an entry that names nothing — // Deny takes any string — fails TestDeniedMethodsExist. var denied = map[string]reflect.Type{ - "store.Close": reflect.TypeFor[*store.Service](), - "drop.Upload": reflect.TypeFor[*drop.Service](), - "drop.Save": reflect.TypeFor[*drop.Service](), - "relay.Observe": reflect.TypeFor[*relay.Service](), - "relay.SetPlugins": reflect.TypeFor[*relay.Service](), - "project.SetAccounts": reflect.TypeFor[*project.Service](), - "project.SetProjects": reflect.TypeFor[*project.Service](), + "store.Close": reflect.TypeFor[*store.Service](), + "drop.Upload": reflect.TypeFor[*drop.Service](), + "drop.Save": reflect.TypeFor[*drop.Service](), + "relay.Observe": reflect.TypeFor[*relay.Service](), + "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](), } -// stubService stands in for the four registered services: dispatch resolves a +// stubService stands in for the registered services: dispatch resolves a // method by name alone, and the real ones want a database, a config directory // and a running terminal. type stubService struct{} -func (stubService) Close() error { return nil } -func (stubService) Upload() error { return nil } -func (stubService) Save() error { return nil } -func (stubService) Observe() error { return nil } -func (stubService) SetPlugins() error { return nil } -func (stubService) SetAccounts() error { return nil } -func (stubService) SetProjects() error { return nil } -func (stubService) Allowed() error { return nil } +func (stubService) Close() error { return nil } +func (stubService) Upload() error { return nil } +func (stubService) Save() error { return nil } +func (stubService) Observe() error { return nil } +func (stubService) SetPlugins() error { return nil } +func (stubService) SetAccounts() error { return nil } +func (stubService) SetProjects() error { return nil } +func (stubService) Cleanup() error { return nil } +func (stubService) CloseOwnedBy() error { return nil } +func (stubService) Allowed() error { return nil } func denyingDispatcher() *rpc.Handler { d := rpc.New() - for _, service := range []string{"store", "drop", "relay", "project"} { + for _, service := range []string{"store", "drop", "relay", "project", "browser"} { d.Register(service, stubService{}) } denyInternal(d) From 70aabdd3eac6be5ba2caf140b9d8ccefb22d8abe Mon Sep 17 00:00:00 2001 From: DiegoSGbr <57621928+DiegoSGbr@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:51:16 -0300 Subject: [PATCH 2/6] =?UTF-8?q?corre=C3=A7=C3=B5es=20gerais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 5 ++++ internal/browser/chrome.go | 51 +++++++++++++++++++++++++-------- internal/browser/chrome_test.go | 32 +++++++++++++++++++++ 3 files changed, 76 insertions(+), 12 deletions(-) create mode 100644 internal/browser/chrome_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d13c0ac21..67c72c1f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 an in-app tab, and closing it does not quit lich. `file:` and `javascript:` URLs are refused, and form values never enter the transcript. +### 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. + ## [0.35.0] - 2026-08-16 ### Added diff --git a/internal/browser/chrome.go b/internal/browser/chrome.go index 95844fcf8..e586f07c5 100644 --- a/internal/browser/chrome.go +++ b/internal/browser/chrome.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/exec" + "time" "github.com/chromedp/cdproto/input" "github.com/chromedp/chromedp" @@ -51,13 +52,22 @@ func (p *chromePage) Close() error { 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 chromedp.Run(ctx, chromedp.Navigate(url)) + return p.run(ctx, chromedp.Navigate(url)) } func (p *chromePage) Info(ctx context.Context) (PageInfo, error) { var info PageInfo - if err := chromedp.Run(ctx, chromedp.Evaluate(pageInfoScript, &info)); err != nil { + if err := p.run(ctx, chromedp.Evaluate(pageInfoScript, &info)); err != nil { return PageInfo{}, err } return info, nil @@ -68,7 +78,7 @@ func (p *chromePage) Click(ctx context.Context, t Target) error { if err != nil { return err } - return chromedp.Run(ctx, chromedp.MouseClickXY(pt.X, pt.Y)) + return p.run(ctx, chromedp.MouseClickXY(pt.X, pt.Y)) } func (p *chromePage) Type(ctx context.Context, text string, clear bool, t Target) error { @@ -78,7 +88,7 @@ func (p *chromePage) Type(ctx context.Context, text string, clear bool, t Target } } if clear { - err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { + err := p.run(ctx, chromedp.Evaluate(`(() => { const el = document.activeElement; if (el && 'value' in el) el.value = ''; })()`, nil)) @@ -86,37 +96,54 @@ func (p *chromePage) Type(ctx context.Context, text string, clear bool, t Target return err } } - return chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { + return p.run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { return input.InsertText(text).Do(ctx) })) } func (p *chromePage) Screenshot(ctx context.Context, dest string) error { var buf []byte - if err := chromedp.Run(ctx, chromedp.FullScreenshot(&buf, 90)); err != nil { + 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 chromedp.Run(ctx, chromedp.Reload()) + return p.run(ctx, chromedp.Reload()) } func (p *chromePage) Back(ctx context.Context) error { - return chromedp.Run(ctx, chromedp.NavigateBack()) + return p.run(ctx, chromedp.NavigateBack()) } func (p *chromePage) Forward(ctx context.Context) error { - return chromedp.Run(ctx, chromedp.NavigateForward()) + return p.run(ctx, chromedp.NavigateForward()) } func (p *chromePage) Scroll(ctx context.Context, dy int) error { - return chromedp.Run(ctx, chromedp.Evaluate(fmt.Sprintf("window.scrollBy(0, %d)", dy), nil)) + return p.run(ctx, chromedp.Evaluate(fmt.Sprintf("window.scrollBy(0, %d)", dy), nil)) } func (p *chromePage) Focus(ctx context.Context) error { - return chromedp.Run(ctx, chromedp.Evaluate("window.focus()", nil)) + 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 { @@ -145,7 +172,7 @@ func (p *chromePage) resolve(ctx context.Context, t Target) (point, error) { } expr := resolveJS + "(" + string(args[1:len(args)-1]) + ")" var pt *point - if err := chromedp.Run(ctx, chromedp.Evaluate(expr, &pt)); err != nil { + if err := p.run(ctx, chromedp.Evaluate(expr, &pt)); err != nil { return point{}, err } if 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") + } +} From 8bf01b2cc2eb883611e9e4ac8a1fc313c8c7969d Mon Sep 17 00:00:00 2001 From: diegogoncalves-lgtm Date: Mon, 24 Aug 2026 14:20:57 -0300 Subject: [PATCH 3/6] =?UTF-8?q?corre=C3=A7=C3=B5es=20gerais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/sidebar/SessionCard.tsx | 3 +-- frontend/src/lib/api-types.ts | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/sidebar/SessionCard.tsx b/frontend/src/components/sidebar/SessionCard.tsx index 886171b4f..4c6758210 100644 --- a/frontend/src/components/sidebar/SessionCard.tsx +++ b/frontend/src/components/sidebar/SessionCard.tsx @@ -56,7 +56,7 @@ import { ContextMenuSeparator, 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" @@ -66,7 +66,6 @@ import { requestTerminalFocus } from "@/lib/terminal/focus-request" import { useSessionIntent } from "@/lib/use-sidebar-intent" import { useProjects } from "@/providers/projects" import { SessionTargetPicker } from "./SessionTargetPicker" -import { toast } from "sonner" import { EntrypointDialog } from "./EntrypointDialog" interface SessionCardProps { diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index b88cbe0b0..7e2c86f77 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -477,6 +477,8 @@ export interface BrowserHandle { 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 { From c79ef86c537caa525c6760635315bbe527a32558 Mon Sep 17 00:00:00 2001 From: DiegoSGbr Date: Thu, 10 Sep 2026 16:23:12 -0300 Subject: [PATCH 4/6] Update mcp_test.go --- internal/cli/mcp_test.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/cli/mcp_test.go b/internal/cli/mcp_test.go index 17af97792..7d7b8e0e7 100644 --- a/internal/cli/mcp_test.go +++ b/internal/cli/mcp_test.go @@ -237,9 +237,7 @@ func TestMCPListsEveryTool(t *testing.T) { "browser_scroll": {}, "browser_list": {}, "browser_close": {}, - "rename_session": {"label"}, - "list_worktrees": {}, - + "rename_session": {"label"}, } // The read-only ones, which a client may auto-allow. wait_for_answer is // not among them: collecting drains the inbox. From 731b0bef9ccb9998fc96784206baa1e9c96c4fe0 Mon Sep 17 00:00:00 2001 From: DiegoSGbr Date: Thu, 10 Sep 2026 17:57:36 -0300 Subject: [PATCH 5/6] fix: restore browser tab after merge with main. #570 dropped system-browser discovery for the UI window; bring FindBrowser back for the agent sidecar only, and repair the merge damage so Browser tab is usable again from the session card. Co-authored-by: Cursor --- CHANGELOG.md | 531 +++++++++++++++++- docs/browser-tab.md | 7 +- docs/ceilings.md | 2 +- .../src/components/sidebar/SessionCard.tsx | 56 +- frontend/src/lib/rpc.ts | 2 - internal/browser/chrome.go | 6 +- internal/chromium/candidates_darwin.go | 13 + internal/chromium/candidates_unix.go | 13 + internal/chromium/candidates_unix_test.go | 42 ++ internal/chromium/candidates_windows.go | 13 + internal/chromium/find.go | 102 ++++ internal/chromium/find_test.go | 125 +++++ internal/cli/help.go | 2 - internal/cli/mcp_test.go | 2 +- 14 files changed, 850 insertions(+), 66 deletions(-) create mode 100644 internal/chromium/candidates_darwin.go create mode 100644 internal/chromium/candidates_unix.go create mode 100644 internal/chromium/candidates_unix_test.go create mode 100644 internal/chromium/candidates_windows.go create mode 100644 internal/chromium/find.go create mode 100644 internal/chromium/find_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b819e5b01..d952b13d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ 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 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 +55,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 @@ -1557,20 +1567,525 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.40.0] - 2026-08-23 +### Added + +- **Antigravity CLI runs as a session, like every other provider.** Google's + `agy` joins Claude Code, Codex, opencode, oh-my-pi and Crush in Settings › + Providers: a session of its own, resumed by `--conversation` when a card comes + back from a restart, run without permission prompts if you turn that rung on, + confined by the sandbox with its `~/.gemini` credentials still reachable, and + told which model to run. The companion plugin installs there too — reporting + the session id, the spinner, the title and the git-status refresh — and the + same install registers lich's own tools, so an Antigravity session can drive + the sessions beside it. Two things it does not have, both by its own design: + no lich briefing in its system prompt (it takes no append flag at spawn) and + no context ring in the footer (it files a conversation as a database rather + than as a transcript lich can read). +- **A session's card hands out the command that reaches it.** Naming a session + is what makes `lich send` usable from another terminal, but the name lived on a + card in the window and the line had to be retyped from memory, quoting + included. Copy send command puts the whole invocation on the clipboard, with + the label quoted for the shell — so a card called `the $PATH bug` pastes as one + argument rather than as three words and an empty variable. The project is named + in the line only when another session holds the same label, which is the one + case `lich send` cannot resolve without it. +- **A waiting card now says what it is waiting for.** A session blocked on you + wore the same amber bell and the same "Waiting on you" whether it wanted + permission to delete a directory or an answer about which file to touch — the + only way to tell was to open it. The card and the toast now carry the question + itself, in the words the agent used. Claude Code sessions say it in a sentence; + Codex and opencode name the tool or permission being asked about, which is + coarser and still enough to pick the card; oh-my-pi and Crush report no block at + all, so their cards keep the line they had. +- **Closing a session no longer throws it away, and the palette has a History + tab to find it in.** A closed session used to be gone for good unless it lived + in a worktree you chose to keep; now every close parks it, and `Ctrl+K` → + History lists what you have closed, newest first, across every project — the + closed ones included. Each row names the session, the agent that ran it, the + project, the branch its checkout is on and when you closed it, and the search + narrows on all of those, so "the conpty thing, three weeks ago" is a query and + not an archaeology dig. Enter resumes: the card comes back where it was and + picks the conversation up rather than starting cold. A row whose checkout was + removed behind lich's back says so and offers to forget it instead. +- **Sessions can be renamed from the command line and from an agent's own + tools.** Renaming a card was a thing only the window could do; `lich rename + "the login bug"` now renames the session the command runs in, `lich rename + auth-fix "the login bug"` renames another one, and the `rename_session` tool is + the same for an agent — which is what lets a worker's card say what the work in + it is rather than the number it was born with. The name becomes the user's + either way: the provider's auto-title never overwrites it again. A name another + session in that project already holds is refused, because two sessions under + one label is the one thing `lich send` cannot resolve. +- **The session sidebar filters.** With a dozen cards open across three + checkouts, finding the two you are actually shepherding was a scan. A + magnifier beside New Session opens a field that narrows the list as you type, + matching a card's name or the checkout it runs in — so typing a worktree name + keeps that whole block. Unlike the command palette, which jumps once and + closes, this one is held while you work: the surviving cards stay live, with + their status rings, their close buttons and everything else. A group with + nothing left drops out entirely, the session you are looking at stays visible + whether it matches or not, and the filter is never narrowing the list out of + sight — it clears when you close it, when you collapse the sidebar and when + you switch project, and it is never restored on a restart. +- **A session's card now shows the ticket it is answering.** Hover a card with an + open request and the tooltip names the other end and the ticket number, and on + the side that owes the answer it spells the command that sends it home. Until + now the number was written down in exactly one place — the message typed at the + prompt — so a session whose context was compacted past it left nobody, agent or + person, able to close the errand. +- **A confined session can push and use `gh` again, if you let it.** The sandbox + gives a session an empty home, which takes the ssh agent and gh's keyring with + it: `git push` has nothing to sign with and `gh` opens on a login prompt. Two + switches hand each one back, separately and off by default — the ssh agent, so + a push authenticates without any key entering the sandbox, and a GitHub token, + so `gh` works as the account the project already answers as. They are separate + because they open different doors: wanting `gh` to work is not wanting to hand + over your ssh keys. The agent switch lists the identities loaded in your agent + right beside it, because a session holding that socket can sign with every one + of them, against any host — not only the key you had in mind. +- **Settings has a Sandbox pane.** Everything about confining sessions now lives + in one place: whether this machine can confine at all and what it uses to do + it, the rung for each provider you have enabled, and the two grants above. The + rung used to be repeated inside every provider's own section, so a machine with + several enabled drew the same ladder several times — and on a machine with no + bubblewrap the control simply vanished, leaving the question unanswered instead + of answered. +- **A keyboard shortcut can now be left unassigned.** Settings › Hotkeys gained a + clear button beside each binding's reset, because every chord the window claims + is a chord the agent's TUI in the terminal never sees, and rebinding lich's + action onto some key you will never press was the closest thing to giving one + back. Cleared, the action holds no chord at all: it fires on nothing, collides + with nothing, and the keypress falls through to the session underneath. The + shortcuts sheet reads it as unassigned rather than naming a default it no + longer answers to, and reset still puts that default back. +- **A card can open its own checkout.** Right-click a session and *Open in + editor* opens its folder in `$VISUAL`/`$EDITOR` — a terminal editor lands in a + new shell session at the checkout, the same as opening a single file from the + files panel — while *Open folder* shows it in the system file manager. Getting + to a worktree from outside lich meant reading the path off the card and typing + it somewhere else, and a worktree path is exactly the kind nobody types twice. + A card whose checkout was removed behind lich's back says so instead of + launching at nothing. + +### Changed + +- **A card's git readout costs half of what it did.** Every second, each checkout + on screen spawned six `git` children to answer one status badge — and on a + normal repository most of that was process startup rather than work. One + `git status --porcelain=v2` reports the branch, the HEAD commit and every dirty + file in a single call, so a tick is three children now: measured here on git + 2.55, a small worktree fell from 21.2 ms to 13.4 ms per read (20.1 ms to 12.6 ms + of CPU), and a 50,000-file checkout with 200 dirty files from 101.8 ms to + 66.4 ms (202.7 ms to 168.2 ms of CPU). Nothing about the badge changes. +- **`lich reply` no longer needs the ticket.** Called with the answer alone — + `lich reply ""`, or `reply_to_session` with no ticket — it answers + the request open against the calling session, so an agent that has lost the + message naming the number can still get its answer home. With several requests + open the oldest delivered is the one closed, and naming the ticket is still the + way to pick a specific errand. +- **Building lich from source now needs Go 1.27.0.** The pin stays exact so that + every release binary carries the current toolchain's own security fixes, and + the module is what CI reads its Go version from. `GOTOOLCHAIN=auto` — the + default — fetches 1.27.0 on its own; a build pinned to `local` on anything + older will refuse the module. +- **The cost readout scans a transcript about three times faster.** Nothing in + lich changed: Go 1.27 rebuilt `encoding/json` on top of its v2 implementation, + and the per-line decode that prices a session went from ~90µs to ~27µs on a + full assistant turn, allocating 2 objects where it used to allocate 13. A + project whose sessions carry long transcripts feels it on every refresh. +- **A `lich rage` bundle now names the goroutines that leaked.** `goroutines.txt` + carried every stack in the process, leaving whoever read it to work out which + of a few hundred was the one still waiting on something nobody will ever send. + The dump now ends with a second section holding only the goroutines blocked on + a channel, mutex or WaitGroup that no runnable goroutine can still reach — + which is the shape of a window that stopped updating while the process stayed + alive. An empty section is the answer too: the hang is somewhere else. +- **A finished turn now says whether you have read it.** A session that came back + while you were away wears the same solid green ring as one you dealt with + twenty minutes ago, which is the one question that ring exists to answer. It + now fades once you have actually watched that session's card — and only that + card, so a sidebar of finished agents shows at a glance which results nobody + has collected. The project tab badge and the notifications list read the same + mark, so a session cannot be news in one place and read in another. + +### Removed + +- **macOS builds no longer run on Big Sur or Monterey.** Go 1.27 dropped every + macOS before 13 Ventura, so the cask now declares that floor and Homebrew + refuses the install on an older machine rather than handing it a binary that + cannot start. Nothing in lich itself needs 13 — the floor is the compiler's, + and it moves with the next Go bump. + +### Fixed + +- **A Claude Code session's context readout now follows a `/compact`.** The + percentage came from the newest assistant message in the transcript, and a + compaction writes none — so a card that had just been emptied from 236k to 13k + went on reporting 24% full until the next reply landed, which is exactly the + moment the number is read. The compaction boundary is now what the readout + takes its count from when it is the newer of the two, for an automatic + compaction as much as a manual one; the model, which that line does not record, + still comes from the last message that named one. The cost readout is unchanged + — it sums assistant messages and never saw the boundary. + +- **One session's output can no longer freeze every other session's.** Each session + already had its own queue, so a card producing faster than the window could draw + it backed up alone. One socket carries them all, though, and a session's turn to + write held a lock the others queued on — so whenever a write to the window + stalled, every card in the workspace stopped updating with it, for up to five + seconds at a time, however quiet those sessions were. The socket now belongs to a + writer of its own: a session hands over its output and goes back to work, and a + stalled window costs the connection rather than the workspace. Nothing is + dropped — output the socket refuses still crosses on the event bridge, in order. +- **The plan gauge now reads the account the session in front of you actually + spends.** A project pointed at a binary of your own — a wrapper exporting + another `CLAUDE_CONFIG_DIR`, or an OAuth token for a second account — still had + its footer showing what lich's own login had left, which is a number about + somebody else's plan and no way to tell. The reading is now taken against the + environment the session's own process runs with, so each card reports the + subscription it is spending, and two accounts on one machine no longer read as + one. A session whose login is a long-lived token (`claude setup-token`) is + measured through a one-token request, since the usage route refuses a token + that can only infer; a session pointed at another API host or running on an API + key shows no gauge, having no plan to report — and that now counts a machine + whose *own* environment carries one, which used to be read as a subscription + and drawn as a full gauge for a plan nothing on that machine was spending. On + macOS and Windows, where the environment of another process is out of reach, a + session running a configured binary shows nothing rather than the wrong + account's numbers. Codex sessions follow the same rule through `CODEX_HOME`. +- **A keystroke no longer waits on a batching window that has nothing to batch.** + A session's output is collected into short windows so a burst — a full-screen + redraw, a build log — reaches the window as a couple of frames instead of + dozens. That window was charged to every write, the echo of a single keypress + on an otherwise silent terminal included, where there is no burst to collapse: + the character sat for the full 8 ms before it appeared. The first write after a + quiet window now goes out at once, and the window still batches everything the + burst piles up behind it — a lone write's echo went from 8034 µs to under 2 µs. + A hidden session is deliberately unchanged: it does not paint, so it has no + latency to protect and batches as hard as before. +- **Removing a worktree no longer fails when the agent in it has just exited.** A + session's terminal is released twice — once by the close you asked for, once by + the reap of the process that ended — and whichever of the two arrived second + reported the handle as already closed. Closing a card ignored that, but removing + a checkout reads it as a failed close and stops before it touches git: the + worktree stayed on disk, its row stayed on screen, and the only account of why + was an error naming `/dev/ptmx`. The hang-up now happens once, and both callers + are told what it did. Windows already closed once, where a second one costs more + than a message. +- **A turn you interrupt no longer spins forever.** Pressing Esc or Ctrl+C to stop + an agent mid-turn left its card spinning until some later turn happened to + finish — Claude Code, Codex and oh-my-pi all say nothing at all when a turn is + stopped, so the last thing lich ever heard was "working". lich now reads the + interrupt from the keystroke itself: a lone Esc or Ctrl+C at a session that is + mid-turn ends that turn and the card goes quiet. It never reads as a *finished* + turn — stopping is not finishing, so no check, no notification, no tab badge — + and it never fires for a key pressed at an idle prompt, arriving inside a paste, + or belonging to an arrow key or a mouse report. Whatever the agent reports next + still wins. +- **Closing a session hangs up on the agent instead of killing it outright.** A + card's close sent the process `SIGKILL`, which leaves an agent no chance to run + its own exit path. Claude Code reads that as a crash: a session killed within + ten seconds of its first frame counts as a fullscreen renderer that failed to + start, and two of those turn fullscreen off for every session on the machine — + the `tui` setting still says `fullscreen`, every session comes up on the classic + renderer anyway, and only `/tui fullscreen` clears it. A close now sends + `SIGTERM` and kills only a child that outstays a one-second grace, so exit + hooks, transcripts and renderer state are written the way they are in any + terminal. Windows keeps the abrupt close — a ConPTY has no signal to send. +- **A deep file tree is readable again.** A pull request whose paths run + `src/main/java/br/com/acme/...` spent the panel's whole width on indentation, + and every file name arrived as an ellipsis with no way to reach the rest of + it. Directories with nothing in them but the next directory now collapse into + one row, the tree scrolls sideways when a name still overruns, and the Files + changed tree drags wider by its right edge like every other side panel — the + width is remembered. The dock's file browser gets the same three. + +## [0.39.0] - 2026-08-21 ### 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 and screenshot. It is the browser already on the machine, not - an in-app tab, and closing it does not quit lich. `file:` and `javascript:` - URLs are refused, and form values never enter the transcript. +- **lich says when git or the GitHub CLI is missing, instead of going quiet.** + Every branch, diff and worktree on screen is read through `git`, and pull + requests, checks and PR checkouts through `gh` — but a machine without either + was told nothing: the readers swallow their failures by design, so the sidebar + simply showed no branch and the Pull Requests screen no pull requests, with + nothing anywhere saying why. A launch without `git` now opens with a dialog + that says what stays empty and offers the download page; the Pull Requests + screen without `gh` says the same in place of the list. Settings › Version + Control opens on both tools, each with the path it resolved to — which + answers the question a machine with two `git` installs actually has — or an + install button. All three warn that the `PATH` is read at launch, so a tool + installed with lich open needs a restart. `lich doctor` reports both as well, + as warnings: lich starts and every session spawns without them. +- **A custom binary can be switched off without being deleted.** Settings › + Providers › Binary gives each layer — the project's own override and the + global one — a switch beside its path. Off, the layer resolves as if it were + unset and falls through to the layer below, while the path stays written: going + back to the default and back again is two clicks rather than deleting a path + and typing it in from memory. A switched-off layer is also no longer checked, + so a custom binary that has gone missing stops holding the block open and + colouring it red. Nothing was migrated: a layer with no switch stored is on, + which is every override configured before this release. ### 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. +- **A worktree opens on a branch that already exists, instead of refusing it.** + Every worktree lich made was handed to git as `worktree add -b`, which creates + the branch — so naming a branch that was already there failed with "A branch or + worktree with that name already exists", and the only way through was to delete + the branch first. That is exactly what a prepared branch cannot afford: cut + from a freshly fetched remote, pushed, linked to a GitHub issue. An existing + branch is now checked out as it stands, on the New worktree dialog, on + `lich open --worktree` and on the `open_session` tool alike — naming a branch + is naming the work on it, which is already how a branch holding a worktree is + read. The base goes unused in that case: it says where a branch starts, and + that one already started. A branch checked out somewhere else is still refused, + and now says so on modern git too, which reworded that refusal and had been + falling through to "git could not complete the operation". +- **The Files tab browses a folder that is not a git repository.** The tree was + built from `git ls-files`, so a project opened on a plain directory — notes, a + scratch folder, a checkout of something that is not git — answered "Not a git + repository" and offered nothing to read, though every file beside it was + perfectly readable. Outside a repository lich now walks the directory itself: + `.git` is skipped, symlinks are left alone, and the listing stops at 20,000 + files, since there is no `.gitignore` out there to keep a `node_modules` out. + Inside a repository nothing changes — git still answers, ignored files stay + invisible, and a broken repository still reports its error rather than + flooding the tree. +- **The Version Control screen no longer answers a missing `gh` with `gh auth + login`.** Every failure to list your GitHub accounts had that sentence + appended to it, including the one where `gh` is not installed at all — which + sent you to authenticate a command that does not exist. The advice gh's own + failure carries is now the only advice shown, and a `gh` that is installed but + signed in to nothing says so before it becomes an error. +- **A provider installed by Homebrew is found again when lich is opened from + its icon.** lich already recovers the environment your shell's rc files + export, but a bare command name is resolved against the process's own `PATH`, + which a launch from Finder or a desktop entry inherits from the session + manager — without Homebrew's prefix, or any other directory an rc file adds. + Codex installed with `brew install` was reported missing, and a session + configured to run it could not start; the same launch from a terminal worked. + The recovered `PATH` is now pinned into lich itself, so detection, the spawn + and the Chromium search all read it. + +## [0.38.0] - 2026-08-19 + +### Added + +- **Six more keyboard shortcuts, all aimed at the session sidebar.** + `Ctrl+Shift+B` opens the worktree dialog — the New session menu's **Worktree** + item, reached without the menu — and the active session's own card actions get + the rest: `Ctrl+Shift+E` renames it in place, `Ctrl+Shift+X` closes it, + `Ctrl+Shift+K` pins or unpins it, `Ctrl+Shift+L` opens a terminal in its + directory, and `Ctrl+Shift+H` opens the delegate picker. A collapsed sidebar + opens first, since none of that chrome lives in the rail, and a shortcut the + card does not offer — closing a pinned session, delegating from a terminal — + is declined rather than silently doing nothing. All are listed in the + shortcuts overlay (`Ctrl+/`) and rebindable in Settings › Hotkeys. +- **The terminal search chord is documented.** `Ctrl+F` has always opened the + session's search bar; the shortcuts overlay and Settings › Hotkeys now say so, + under a **Terminal** heading beside the chords passed through to the agent. + +### Changed + +- **The command palette leads with the sessions worth interrupting.** Its `All` + view now lists the sessions holding a turn first and the plain shells last, + so a project of parked terminals no longer fills the list ahead of the agent + that is actually running. It shows five rows per group instead of three, and + closed projects moved out of `All` into the `Projects` tab, which lists them + whole. + +### Fixed + +- **A long tool name stays inside its session card.** While an agent ran an MCP + tool, the card drew the harness's own spelling of it — `mcp____` + and three other forms, one per harness — at full length, and a name that did + not fit ran out past the card's right edge into the sidebar. The line now + drops the machinery in the name and keeps the parts worth reading, and both it + and the detail beside it give way with an ellipsis rather than overflowing. + The card keeps its height either way: this never wraps. + +- **A pull request that will not check out says why.** Opening a session on a + pull request runs `gh pr checkout`, which shells out to git — and when git's + ssh key was refused, all the screen said was that gh had failed and the log + had the rest. The reason now reaches the screen the same way it already did + for lich's own git calls, including the case behind most of them: a key whose + passphrase nothing can be typed into, because lich runs from a desktop + launcher and has no terminal to ask in. Load it with `ssh-add` and the + checkout goes through. + +- **Handing a file to a confined session works again, dropped or attached.** A + file dragged onto a sandboxed terminal — or chosen through the footer's attach + button — pasted the path it has on your machine, and that session's home is an + empty private one, so the agent opened nothing and said the file did not + exist. lich now attaches a copy instead for anything outside the session's + checkout, and mounts it read-only where the agent can read it. The copy is the + agent's to read, not to edit: changes land on the copy, not on your file. It is + deleted when the session closes, and a *folder* from outside the checkout is + still refused — there is nothing to copy a directory into. An attached path is + now quoted too, so a file whose name has a space in it stops arriving at the + prompt as two. + +- **Removing a worktree takes every session in it off the sidebar.** With more + than one session open in the same checkout — after merging its pull request, + say — only one card went away and the others stayed behind on a directory + that no longer existed. Closing such a leftover card asked again whether to + keep or remove the worktree, and removing it failed. + +- **The clipboard works in a confined session again.** Copying out of an + agent's TUI and attaching an image to it are the agent's own work — Claude + Code shells out to `wl-copy`, `wl-paste`, `xclip` and `xsel` for both — and a + sandboxed session had no display socket left to reach, so every copy and every + paste of a screenshot failed with nothing on screen saying why. The sandbox + now hands the session the display server's socket: Wayland where the host has + it, X11 with its cookie otherwise. A confined session can read the clipboard + as well as write it, which is the trade the clipboard is. + +- **ssh no longer fails outright inside a confined session.** Every remote git + command in a sandboxed session died on `Bad owner or permissions on + /etc/ssh/ssh_config.d/...`: inside the sandbox's user namespace the + distribution's ssh drop-in configs belong to nobody, and ssh refuses to read + them. The sandbox now hands ssh an empty drop-in directory, so `git fetch` + works again. Pushing still needs the credentials a confined session + deliberately does not have. + +- **Codex context usage now follows the session's configured window.** A default + 272k session no longer appears to use the model catalog's optional 872k + maximum; sessions configured for either size report their own effective + window from the rollout. + +## [0.37.0] - 2026-08-18 + +### Added + +- **Codex sessions now carry their model and context usage in the footer.** The + model-and-context readout is available for Codex, backed by the active context + count, model and reasoning effort in its rollout transcript and the maximum + effective window advertised by its model cache. + +## [0.36.0] - 2026-08-18 + +### Added + +- **Sessions can run confined.** Settings › Providers gains a **Sandbox** ladder + beside the permission one — Off, Ask each time, Worktrees only, Everywhere — + and a session on a confined rung opens inside an OS sandbox: a fresh empty + home holding only that provider's own state, the rest of the machine + read-only, and write access to the checkout it was opened for. Your ssh keys, + your cloud credentials and every other repository on the disk are simply not + there. The network stays on, so the agent still reaches its API and lich still + hears its hooks, and the plan gauge, the cost readout and resume all keep + working — the provider writes its transcript to the same place it always did. + + It is the counterweight to skipping permission prompts: the two rungs are read + together, and an agent turned loose in a worktree can be turned loose inside a + sandbox. The rung is per provider and can be set for one project alone, and + the New worktree dialog carries a **Run confined** box that starts on whatever + the rung would do and overrides it for that session alone — including every + later spawn of it, so a reload or a resume opens the way you opened it. + + A confined card wears a shield beside its status dot, and its tooltip says + what that means and that the answer was taken when the session opened. + + Linux runs bubblewrap and macOS `sandbox-exec`; the control is absent on a + machine with neither, and on Windows. It is not a boundary against hostile + code — see `docs/ceilings.md` for what it does not stop. + +- **How much of your plan is left, without leaving lich.** Claude Code and Codex + both report what a subscription has spent of its rolling windows, and lich now + reads it: the footer carries the window closest to running out beside the + context ring — `5h 2%`, `wk 84%` — with every window, its share and its reset + time behind the tooltip. Each provider's own settings section opens with the + same reading in full, including the weekly caps Anthropic scopes to a single + model. Windows are named by the length the provider reports, so a Codex free + plan's 30-day window is labelled as one rather than mistaken for the five-hour + one a paid plan reports in its place. + + lich reads the login its provider's CLI already wrote and never refreshes or + rewrites it — token rotation stays with the CLI that owns it. A rejected token + reads as signed out, with the command that fixes it. opencode, oh-my-pi and + Crush run on your own API keys, where there is no plan to meter, so they are + absent from the block rather than shown empty. + +- **A terminal can open straight into a tool.** Right-click a terminal card and + set its **Entrypoint** — `lazygit`, `lazydocker`, `k9s`, `pnpm dev` — and that + command runs every time the terminal starts: the card's first view, a reload, + a respawn, the resume of a parked worktree. Quit the tool and you are back at + the prompt in the same card, so a crashed dev server leaves its error on + screen with a shell under it. The card takes the command as its name until you + rename it yourself, and then says what it runs in its tooltip. Emptying the + field puts the terminal back on a plain shell. + + It belongs to that one terminal card and nothing else: a session running an + agent has no entrypoint to set, and it is not the project-wide + `.lich/setup-worktree.sh`, which still runs once when a worktree is born. + +### Changed + +- **Terminals no longer offer to delegate.** *Delegate to session…* was on every + card, terminals included, where the thing reading the prompt it writes is a + shell — so the request landed as a command line rather than as a request. The + action now belongs to sessions running an agent, which are the ones that can + act on it. + +- **The binary setting says which binary, and whether it is there.** *Custom + path* and *Override for * were two fields and a sentence about + inheritance, and neither said what a session would actually spawn — a typo sat + there until a terminal refused to start. A provider's section now opens with + the answer: the executable a session here would run, where it came from, and a + tick that it exists and can be executed. **Use a different binary** unfolds the + whole resolution — this project, all projects, `$PATH` — in the order lich + reads it, with the layer that wins marked and the others shown as overridden. + Paths can be picked from a file dialog rather than typed. + + It also names the two mistakes a shell would have hidden, because lich spawns + the binary directly: a leading `~` is never expanded, and a relative path means + a different binary in every session. A layer that resolves to nothing runnable + unfolds the block by itself. + +- **The two footer switches are one ladder.** *Model & context in the footer* and + *Session cost in the footer* asked the same question twice and left you to + assemble the answer; Claude Code's settings now offer **Nothing · Model & + context · And cost**, in the same shape as the permission ladder above it, with + a line under it saying what each rung puts on screen. The spend ceiling still + appears with the rung that shows a cost. Nothing needs setting again: an + install already showing both reads as the top rung, one showing neither as the + bottom. + +### Fixed + +- **Resuming a parked worktree session no longer forgets how it was started.** A + session opened with `lich open --model` came back on the provider's default + model after the worktree was kept and reopened, with nothing on screen saying + the model had changed. + +## [0.35.1] - 2026-08-17 + +### Fixed + +- **A full-screen TUI no longer gets a stray blinking cursor back.** Switching + away from a card and back rebuilt its terminal from a snapshot, and that + snapshot does not record whether the app had hidden the cursor — so every TUI + that draws its own (bubbletea, and anything else that hides the real one) came + back with lich's blinking block parked in the middle of its output. Cursor + visibility now rides along with the mouse encoding as state the snapshot is + known not to carry. + +- **A relayed message no longer sends the sentence you were typing with it.** A + peer's answer or task arriving while you had a half-written prompt was pasted + onto your line and submitted with it, and your half was gone. lich now waits + for the prompt to be free — you send your line, or you leave it alone for a + minute — before typing anything into it. Same for every provider: it is the + session's terminal that is watched, not the agent running in it. + +- **A session card being dragged stays visible, and stays a card.** Dragged past + the end of its group the card slid under the edge that clips the group and + disappeared; short of that it read as if it were dissolving into its + neighbour, because a card in flight had no background of its own and the + labels underneath showed straight through it. A dragged card now carries a + 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 diff --git a/docs/browser-tab.md b/docs/browser-tab.md index 18495af6c..0623050d7 100644 --- a/docs/browser-tab.md +++ b/docs/browser-tab.md @@ -12,9 +12,10 @@ 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`, with -a **different** `user-data-dir` than `lich/chromium-profile`. Closing that -window does not quit lich. Closing the session card kills the sidecar. +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 diff --git a/docs/ceilings.md b/docs/ceilings.md index 8f3f66688..fa113615d 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -304,7 +304,7 @@ work when nobody knows it and that the call site never shows. The mechanism and - **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` with its own user-data-dir. Promoting a headless context to a visible window replaces it, so the page reloads. +- **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. diff --git a/frontend/src/components/sidebar/SessionCard.tsx b/frontend/src/components/sidebar/SessionCard.tsx index 7aa893694..11ce9b7bb 100644 --- a/frontend/src/components/sidebar/SessionCard.tsx +++ b/frontend/src/components/sidebar/SessionCard.tsx @@ -12,15 +12,8 @@ import { FolderOpen, GitBranch, GitPullRequestArrow, - FolderOpen, - GitBranch, - GitPullRequestArrow, Globe, Hourglass, - Inbox, - ListChecks, - Pencil, - Inbox, ListChecks, Pencil, @@ -709,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 && ( @@ -745,30 +748,6 @@ export function SessionCard({ Copy send command - setEditing(true)}> - - Rename - - {session.kind === "shell" && ( - setEntrypointOpen(true)}> - - Entrypoint… - - )} - onPin(!pinned)}> - {pinned ? : } - {pinned ? "Unpin" : "Pin"} - - { - void Browser.OpenVisible(session.id).catch((err) => { - toast.error(`Browser: ${errorText(err)}`) - }) - }} - > - - Browser tab - @@ -792,21 +771,6 @@ export function SessionCard({ - {session.kind !== "shell" && ( - onOpenTerminal(shownPath)}> - - Open Terminal - - )} - - - Open in editor - - - - Open folder - - Pull request diff --git a/frontend/src/lib/rpc.ts b/frontend/src/lib/rpc.ts index fedffa068..cad6db259 100644 --- a/frontend/src/lib/rpc.ts +++ b/frontend/src/lib/rpc.ts @@ -579,8 +579,6 @@ export const Spawn = { Run: (projectId: string, cwd: string) => call("spawn.Run", [projectId, cwd]), } -} - export const Themes = { /** Bundled themes plus user-imported themes from the config dir. */ List: () => call("themes.List", []), diff --git a/internal/browser/chrome.go b/internal/browser/chrome.go index e586f07c5..b2646556c 100644 --- a/internal/browser/chrome.go +++ b/internal/browser/chrome.go @@ -14,9 +14,9 @@ import ( ) // chromeLaunch starts a Chromium-family browser via CDP. ExecPath is -// chromium.FindBrowser — the same binary as the lich window — but UserDataDir -// is this ephemeral dir, never lich/chromium-profile. Attaching to the --app -// window would expose the UI and the session token; this process must stay +// 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) 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/help.go b/internal/cli/help.go index f484d7b83..e1546d5ba 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -101,8 +101,6 @@ var commands = []command{ "it is a lower bound. The source column says whose arithmetic a row's\n" + "money is — priced here, or reported by the provider that spent it.", }, - - }, { name: "mcp", args: "", diff --git a/internal/cli/mcp_test.go b/internal/cli/mcp_test.go index 7f8f4ce67..baab28a23 100644 --- a/internal/cli/mcp_test.go +++ b/internal/cli/mcp_test.go @@ -224,7 +224,7 @@ func TestMCPListsEveryTool(t *testing.T) { "list_worktrees": {}, "send_to_session": {"session", "prompt"}, "wait_for_answer": {}, - "reply_to_session": {"ticket", "answer"}, + "reply_to_session": {"answer"}, "browser_open": {}, "browser_info": {}, "browser_click": {}, From c05d9bb4d0ff53c8247d62e6a5c5d80fa634b196 Mon Sep 17 00:00:00 2001 From: DiegoSGbr Date: Fri, 11 Sep 2026 09:59:55 -0300 Subject: [PATCH 6/6] feat: let the session browser press named keys Type inserts text; Enter, Tab and Escape are UI keys and need a real key event. browser press / browser_press cover the named set so an agent can submit forms and dismiss dialogs the way a user would. Co-authored-by: Cursor --- CHANGELOG.md | 7 ++-- docs/browser-tab.md | 4 +- internal/browser/actions.go | 11 ++++++ internal/browser/browser_test.go | 19 +++++++++ internal/browser/chrome.go | 13 ++++++ internal/browser/fake_test.go | 13 ++++++ internal/browser/keys.go | 68 ++++++++++++++++++++++++++++++++ internal/browser/keys_test.go | 41 +++++++++++++++++++ internal/browser/page.go | 1 + internal/cli/browser.go | 19 +++++++++ internal/cli/browser_test.go | 38 ++++++++++++++++++ internal/cli/help.go | 2 +- internal/cli/mcp.go | 2 +- internal/cli/mcp_browser.go | 17 ++++++++ internal/cli/mcp_test.go | 3 +- 15 files changed, 251 insertions(+), 7 deletions(-) create mode 100644 internal/browser/keys.go create mode 100644 internal/browser/keys_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d952b13d5..568a3b3ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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 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. + 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 diff --git a/docs/browser-tab.md b/docs/browser-tab.md index 0623050d7..647cfad42 100644 --- a/docs/browser-tab.md +++ b/docs/browser-tab.md @@ -36,7 +36,9 @@ target `LICH_SESSION_ID`, not a pool of ids. `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 and type are real input events, not `element.click()`. +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 diff --git a/internal/browser/actions.go b/internal/browser/actions.go index 03c94ebee..e823e7f26 100644 --- a/internal/browser/actions.go +++ b/internal/browser/actions.go @@ -86,6 +86,17 @@ func (s *Service) Type(id, text string, clear bool, t Target) error { }) } +// 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) { diff --git a/internal/browser/browser_test.go b/internal/browser/browser_test.go index 74f543d99..a25a910a5 100644 --- a/internal/browser/browser_test.go +++ b/internal/browser/browser_test.go @@ -158,6 +158,9 @@ func TestActionsNeedABrowserAndRecordOnThePage(t *testing.T) { 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) @@ -187,6 +190,9 @@ func TestActionsNeedABrowserAndRecordOnThePage(t *testing.T) { 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) } @@ -205,6 +211,19 @@ func TestClickRequiresATarget(t *testing.T) { } } +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 { diff --git a/internal/browser/chrome.go b/internal/browser/chrome.go index b2646556c..c8718bbf3 100644 --- a/internal/browser/chrome.go +++ b/internal/browser/chrome.go @@ -101,6 +101,19 @@ func (p *chromePage) Type(ctx context.Context, text string, clear bool, t Target })) } +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 { diff --git a/internal/browser/fake_test.go b/internal/browser/fake_test.go index 4c36a5f9e..c72b18519 100644 --- a/internal/browser/fake_test.go +++ b/internal/browser/fake_test.go @@ -15,6 +15,7 @@ type fakePage struct { navigations []string clicks []Target typed []typed + pressed []pressed reloads int backs int forwards int @@ -32,6 +33,11 @@ type typed struct { t Target } +type pressed struct { + key string + t Target +} + func (p *fakePage) Alive() bool { p.mu.Lock() defer p.mu.Unlock() @@ -86,6 +92,13 @@ func (p *fakePage) Type(_ context.Context, text string, clear bool, t Target) er 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() 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 index 0c6c6e0a7..5e866bde8 100644 --- a/internal/browser/page.go +++ b/internal/browser/page.go @@ -66,6 +66,7 @@ type Page interface { 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 diff --git a/internal/cli/browser.go b/internal/cli/browser.go index e9b0d1ae2..97c4e8d15 100644 --- a/internal/cli/browser.go +++ b/internal/cli/browser.go @@ -26,6 +26,8 @@ func (c *client) browser(args []string) error { 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": @@ -114,6 +116,23 @@ func (c *client) browserType(args []string) error { 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 { diff --git a/internal/cli/browser_test.go b/internal/cli/browser_test.go index 321a97816..f9d68e214 100644 --- a/internal/cli/browser_test.go +++ b/internal/cli/browser_test.go @@ -45,6 +45,44 @@ func TestBrowserClickSendsATarget(t *testing.T) { } } +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 diff --git a/internal/cli/help.go b/internal/cli/help.go index e1546d5ba..d38a874f2 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -88,7 +88,7 @@ var commands = []command{ name: "browser", args: " [args]", about: "Drive this session's browser — the same window Browser tab opens.\n" + - "Verbs: open [url], info, click, type, screenshot [path], navigate ,\n" + + "Verbs: open [url], info, click, type, press , screenshot [path], navigate ,\n" + "reload, back, forward, scroll, list, close.", }, { diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 5eee32282..a870b305d 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -96,7 +96,7 @@ Never poll for results. A send that outlives its wait hands back a ticket and yo 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, screenshot. Screenshots are files — never paste their bytes into a reply.` +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 { diff --git a/internal/cli/mcp_browser.go b/internal/cli/mcp_browser.go index e49d96dcb..ea015b43d 100644 --- a/internal/cli/mcp_browser.go +++ b/internal/cli/mcp_browser.go @@ -74,6 +74,23 @@ var browserTools = []mcpTool{ 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 " + diff --git a/internal/cli/mcp_test.go b/internal/cli/mcp_test.go index baab28a23..da118ceab 100644 --- a/internal/cli/mcp_test.go +++ b/internal/cli/mcp_test.go @@ -229,6 +229,7 @@ func TestMCPListsEveryTool(t *testing.T) { "browser_info": {}, "browser_click": {}, "browser_type": {"text"}, + "browser_press": {"key"}, "browser_screenshot": {}, "browser_navigate": {"url"}, "browser_reload": {}, @@ -237,7 +238,7 @@ func TestMCPListsEveryTool(t *testing.T) { "browser_scroll": {}, "browser_list": {}, "browser_close": {}, - "rename_session": {"label"}, + "rename_session": {"label"}, } // The read-only ones, which a client may auto-allow. wait_for_answer is // not among them: collecting drains the inbox.