# CLI Browser

Drive one real browser from the shell — call browser functions directly, or hand a task to the AI agent.

## Quick Start (60 seconds)

```bash
co browser go_to news.ycombinator.com    # opens a browser, navigates
co browser get_current_url               # → https://news.ycombinator.com/
co browser take_screenshot /tmp/shot.png # saves a PNG, prints the path
co browser close                         # done
```

The browser stays open **between commands**. Each `co browser ...` call drives the *same* window — your navigation, cookies, and logged-in session persist until you `close`.

## Choosing the browser engine

ConnectOnion 1.8 resolves the engine once when the browser daemon starts:

```bash
co browser go_to example.com                  # default: Patchright + system Chrome, $0
co browser --engine system go_to example.com  # the same thing, said explicitly
co browser --engine wtf go_to example.com     # paid WTF Browser, and only when asked
```

Paying is opt-in. An ordinary command never starts a billable session, even on
a machine where Onionwright is installed and in credit.

The paid path requires Onionwright 0.0.14 or newer. Install or upgrade the real
private wheel explicitly:

```bash
co browser install-onion
```

This command uses the current ConnectOnion login, verifies the release feed
with ConnectOnion's pinned Ed25519 public key, verifies the wheel checksum from
that signed manifest, and only then invokes this Python interpreter's pip. It
does not start the browser daemon or a paid session, and downloading/installing
the client costs $0. The real wheel comes from OpenOnion's authenticated
artifact endpoint, not the public PyPI placeholder.

| mode | behavior |
|---|---|
| `auto` | The default, and what every command that names no engine sends. Resolves to system Chrome without importing Onionwright, reading paid credentials, calling oo-api, or downloading an artifact. Cost: $0. |
| `system` | The same resolution, requested explicitly. |
| `wtf` | Require the compatible Onion artifact and enough balance. Any preflight failure is returned as a typed error; there is no silent system fallback. `onion`, the older spelling, is still accepted and means the same. |

A daemon is pinned to the engine it started with. When you select an engine
explicitly, keep that flag on subsequent commands, including `close`. Starting
fresh with no engine gives you system Chrome again. This is session selection,
not automatic read/write switching; there is no separate paid UI panel.

Artifact checking and download do not charge. A paid session starts only after
the complete artifact is locally ready, then prepays $0.025 for one 15-minute
interval. Renewal occurs before the signed deadline. If renewal fails, that
exact Onion process stops at `paid_until`; it is never changed into system
Chrome mid-session.

The daemon is pinned to its chosen engine. Close it before changing modes:

```bash
co browser close
co browser --engine wtf go_to example.com
co browser --engine wtf get_text
co browser --engine wtf close
co browser go_to example.com  # a fresh default session is free again
```

System Chrome and Onion Browser use separate persistent profiles. Cookies and
fingerprint state are not silently copied between them. `co browser status`
shows requested/resolved engine, typed reason, and exact artifact when paid;
it never prints tokens, licence bytes, or paid-cache paths.

## Why Use This

Two ways to use a browser from the CLI, and you pick per command:

- **Direct function call** — `co browser go_to x.com`. Deterministic and instant, with no LLM charge; browser runtime cost follows the selected engine. Great for scripting and exact steps you already know.
- **Natural language** — `co browser "find the cheapest flight"`. The AI agent figures out the steps. Great when you don't want to spell them out.

Both drive the **same live browser**, so you can mix them: script the boring parts, let the agent handle the hard part.

```bash
co browser go_to myapp.com/login
co browser "log me in and open the billing page"   # agent takes over the same window
co browser take_screenshot /tmp/billing.png           # back to a direct call
```

## How It Works

The first `co browser` command starts a small background **daemon** that owns one browser. Every later command connects to it over a local socket and drives that same browser. The daemon lives exactly as long as the browser:

```
co browser go_to x.com   ──► starts daemon ──► opens browser ─┐
co browser click "Login" ──────────────────► same browser    │  state persists
co browser screenshot    ──────────────────► same browser    │
co browser close         ──► browser closes ──► daemon exits ─┘
```

You never manage the daemon directly — the **first command starts it**, and `close` (or closing the window) stops it. There is no separate "start" step.

### How a command is dispatched

The first word is compared against the browser's function names:

| You type | What happens |
|----------|--------------|
| `co browser go_to x.com` | `go_to` **is** a function → runs it directly |
| `co browser "..."` | Hands one quoted task to the AI agent |
| `co browser frobnicate` | matches nothing → `unknown command: frobnicate` (exit 1) |

> Quote natural-language instructions: `co browser "click the blue button"`. A bare word that happens to be a function name (like `click`) is treated as a direct call, not language.

## Discovering Functions

The CLI describes itself — run `help` to list every callable function with its arguments and a one-line summary (no browser is launched):

```bash
co browser help
```

```
Functions:
  go_to(url) — Navigate to a URL.
  take_screenshot(path=None, full_page=False) — Take a screenshot of the current page...
  click(description) — Click on an element using natural language description.
  get_links_from_page(domain_filter='') — Extract all unique links from the current page...
  ...
```

This is the fastest way — for a person or an AI agent — to find the exact function name and arguments before calling it.

## Common Functions

Any function listed by `co browser help` is callable. The ones you'll reach for most:

```bash
co browser go_to <url>                     # navigate
co browser get_current_url                 # print the current URL
co browser get_text                        # print visible page text
co browser take_screenshot /tmp/shot.png [--full-page]
co browser click "<description or selector>"
co browser type_text_by_selector <css> "<text>"
co browser fill_text_by_selector <css> --stdin < secret.txt  # replace controlled input; secret stays out of argv
co browser get_focused_element             # bounded JSON; password values are redacted
co browser keyboard_press Meta+a           # refused unless focus is editable
co browser get_links_from_page             # one link per line
co browser scroll                          # scroll the main content
co browser close                           # close browser, stop daemon
```

Arguments are plain strings; flags like `--full-page` and `--index=2` map to the function's parameters, and `--index 2` or `--full-page true` mean the same as the `=` forms. For `fill_text_by_selector`, `type_text_by_selector`, and `keyboard_type`, a final `--stdin` reads the text from redirected standard input so passwords and one-run codes do not appear in process arguments. Prefer `fill_text_by_selector` when replacing a controlled framework input; use `type_text_by_selector` when appending human-shaped keystrokes is required.

`keyboard_type` pastes Chinese, Japanese, and Korean runs and falls back to IME composition only when the field's text did not change at all. An empty rich-text editor (Feishu, Lark, Slate) replaces a zero-width placeholder on the first paste, so "grew by the full length" is the wrong test; since 1.8.9b19 the text is typed once, not twice (#1877). A paste that lands partly is not retyped, so still screenshot before pressing Enter.

Before replacing focused text with a keyboard shortcut, inspect the target:

```bash
co browser get_focused_element
co browser keyboard_press Meta+a       # macOS
co browser keyboard_press Control+a    # Windows/Linux
co browser keyboard_press Backspace
```

`get_focused_element` follows focus into open shadow roots and reports whether
the target is editable. Its value preview is bounded, and password values are
always redacted. `keyboard_press` refuses select-all, Backspace, and Delete when
focus is outside an editable input, textarea, or contenteditable element. For a
deliberate page-level shortcut, acknowledge the risk explicitly with
`--allow-non-editable`.

Focus inspection is scoped to the top-level document and open shadow roots. If
focus is inside an iframe, the result describes the iframe itself and treats it
as non-editable; closed shadow roots cannot be inspected. These cases fail safe:
target the field by selector, or use the explicit override only after verifying
the frame and intended page-level action.

> **Use absolute paths for files the browser reads.** `upload_file_by_selector` and `run_page_script` resolve a relative path against the *daemon's* working directory (where it was first started), not the directory you run each command from. Screenshots are the exception: the image is streamed back and saved by your own command, so its path is relative to where you ran it.

## Screenshots

`take_screenshot` writes a PNG and prints **where it saved** — not the image data:

```bash
$ co browser take_screenshot /tmp/shot.png
Screenshot saved to: /tmp/shot.png
```

A relative path is resolved against the directory you ran the command in. Omit the path and it is named for you under `.tmp/screenshots/` in that directory:

```bash
$ co browser take_screenshot
Screenshot saved to: /Users/you/project/.tmp/screenshots/screenshot-3f9a1c2b7d4e.png
```

Add `--full-page` to capture the entire scrollable height instead of just the viewport.

> **Why a path, not the image?** The underlying `take_screenshot()` function returns a base64 data URL — that's what the AI agent "sees" during a quoted task. A direct CLI call deliberately prints the **file path** instead, so `co browser take_screenshot` never floods your terminal with a screenful of base64. Open or pipe the saved file when you want the actual image.

## Scripting

Output is clean stdout, errors go to stderr, and the exit code is `0` on success / `1` when the action failed (a selector that matched nothing, a missing script, a page index that does not exist, or a command that ran out of its 120-second deadline) / `2` for a usage error (wrong arguments for a function, or a `go_to` address that is not a web URL) / `3` when there is nothing to act on (no browser open yet, or an unknown `-t` tab) / `4` when another agent holds the tab / `5` when a quoted task has no account or credentials to run its model (`co auth`) / `6` when the running daemon is pinned to a different engine — so commands compose like any Unix tool:

```bash
# Capture a value
url=$(co browser get_current_url)

# Pipe list output (one item per line)
co browser get_links_from_page | grep github | wc -l

# Fail-fast in a script
co browser go_to "$DEPLOY_URL" && co browser take_screenshot /tmp/deployed.png
```

## Sharing the Browser With Other Agents

One machine, one browser, often several agents. They stay out of each other's
way through named tabs — and by saying how long they expect to need one.

```bash
co browser tab open scrape --who alice --for "scrape pricing" --needs 10m
co browser -t scrape go_to example.com/pricing    # -t on EVERY command
co browser -t scrape get_text
co browser tab close scrape                        # release when done
```

`--needs` takes `30s`, `10m`, or `2h`. It is not a lock — it is the estimate
other agents read before touching your tab:

```bash
co browser tab ls
```

```
Tabs (2):
   [scrape] https://example.com/pricing  who=alice  purpose='scrape pricing'  open 3m
      last: get_text · 12s ago
      owner expects to finish by 14:20 (7m left) — leave it alone until then
   [stale] https://...  who=bob  purpose='check stock'  open 2h
      owner expected to finish by 12:30 (1h ago) — free for another agent to close
```

**Inside the window, leave it alone** — open your own tab instead. **Once it has
passed, the tab is free**, and closing it is a courtesy: an estimate that ran out
with the tab still open means that agent crashed, not that it is still working.

A tab opened without `--needs` frees up after ~2 minutes of silence, which is
wrong whenever you are waiting on a slow page or a human. Say the number.

Named tabs are also the concurrency boundary. The daemon owns one asyncio browser
runtime: two operations aimed at `scrape` queue behind each other, while `scrape`
and `inbox` may run at the same time. A registry lock decides claim races before
either operation touches a page, so concurrency never means two agents silently
sharing one tab.

Set `CO_WHO` so your commands carry your identity:

```bash
export CO_WHO=alice
```

## Headless vs GUI

By default the browser is **visible** (a real Chrome window you can watch). Add `--headless` for scripts/CI:

```bash
co browser --headless go_to example.com    # no window
co browser go_to example.com               # visible window (default)
```

The mode is fixed when the daemon starts (the first command that needs one — a read such as `get_current_url` with nothing running answers exit 3 without starting it). A `--headless` that reaches a daemon already showing a window prints a note on stderr instead of being ignored. To switch modes, `co browser close` first, then start again with the mode you want.

## Natural Language Agent

A quoted task runs the full AI browser agent on the live browser and prints its final answer:

```bash
co browser "search for wireless headphones and list the top 3 prices"
```

This path uses managed keys — run `co auth` once if you see an authentication message.

## Installation

**None needed (1.2.1+).** The Patchright library ships with connectonion, and the first
page-driving command auto-installs a browser when none exists — a one-time download,
announced in your terminal, into your per-user directory (no admin rights). If a desktop
Google Chrome is installed at the standard location, it is detected and used instead,
with zero downloads.

Manual fallback (older versions, airgapped machines, or a failed auto-install):

```bash
python -m patchright install chromium   # per-user, never needs admin
python -m patchright install chrome     # branded Chrome: best stealth, system installer
```

## Sessions & Profile

- One async browser runtime per machine, backed by a persistent profile at `~/.co/browser_profile/` — so logins survive restarts.
- The daemon endpoint: a Unix socket at `/tmp/co-<user>/browser.sock` on Linux and `<per-user temp dir>/co-<user>/browser.sock` on macOS — the same however you logged in, since no session variable moves it — and a per-user named pipe on Windows (native, 1.2.1+ — no WSL). Override with `$CO_BROWSER_SOCK`. A daemon an older version started at `$XDG_RUNTIME_DIR/co/`, `/run/user/<uid>/co/` or `$TMPDIR/co-<user>/` is still found until it is closed.
- Client work is bounded: 1 MiB request cap, 120-second read/reply deadlines,
  a 120-second deadline on the command itself (cancelled past it, which frees
  its tab), 32 admitted connections (a command beyond them is told the daemon is
  busy at connection capacity — except `status`, `close`, `tab ls` and
  `tab close`, which have eight connections of their own), and eight blocking
  transport workers on Windows. A client that disconnects takes its command
  with it. The after-reply liveness check runs once the connection is closed
  and gives Chrome 3 seconds; no answer is never read as a dead browser.
- The client waits 30 seconds for `status`/`tab ls`, and for other commands
  the daemon's own deadline — 120 seconds, or a longer `--timeout` plus 15 —
  and 10 seconds more for the answer to arrive. A read (`get_current_url`,
  `list_pages`, `cookies`, `save_state`, ...) asks `status` every 10 seconds
  while it waits, so against a frozen daemon it gives up as soon as `status`
  would, while one queued behind a slow command on a live daemon keeps
  waiting. A daemon that says nothing (stopped, wedged) is reported as not
  answering, and `co browser close` stops it by force and removes the
  socket, `.pid` and `.lock` it left.
- With no daemon running, reads (`get_current_url`, `list_pages`, `cookies`,
  `save_state`, `tab ls`, `tab close`, `status`) answer that no browser is open
  and start nothing, so a later `--headless` command still decides the window
  mode.
- On Windows, `co browser close` returns only after the serving daemon exits, so
  an immediate next command can safely start a fresh daemon.
- For an isolated automation run, set `$CO_BROWSER_PROFILE_DIR` to a dedicated absolute directory and `$CO_BROWSER_SOCK` to a dedicated socket. Keep the real `$HOME`; replacing it can break OS-backed browser behavior and credentials. The paid Onion engine honours it too: its profile goes in `$CO_BROWSER_PROFILE_DIR/onion` instead of `~/.onionwright/profiles/<address>` (a separate folder because it is a different Chromium build from the system Chrome the free engine opens in the directory itself).
- A supervisor can set `$CO_BROWSER_DAEMON_PID` to the positive PID of its
  already running local daemon. Commands then fail if that recorded owner is
  missing, replaced or unavailable; they never start a daemon or install a
  browser. Engine probes, reconnects and read keepalives retain the same pin.
  A close never follows a replacement owner for force cleanup. Keep this
  environment local to the supervised job and use a private endpoint/profile;
  the PID is a lifecycle constraint, not authentication or a paid-session ID.
  Explicit Host private targets ignore this local pin. Without it, ordinary
  first-command startup works as before. Pinned ordinary commands also skip
  automatic paid-client installation; an explicit `install-onion` command
  remains an installation request.

## Importing logins from Chrome

A new browser profile starts signed out of everything, and signing in again
from a new browser is exactly what some sites flag as unusual. `co browser
import` carries the session you already have in Google Chrome into the co
browser profile instead:

```bash
co browser import --profile "Profile 1" --domain linkedin.com --dry-run   # sites and counts, nothing written
co browser import --profile "Profile 1" --domain linkedin.com             # asks once, then imports
co browser --engine wtf go_to https://www.linkedin.com/feed/              # check it landed signed in
```

- `--profile` takes Chrome's folder name (`Default`, `Profile 1`) or the name
  Chrome shows for the profile (`openonion`). Default: `Default`.
- `--domain` keeps one site and its subdomains; repeat it for more. Without it
  every site in the profile is imported.
- `--engine` picks the target. Without it the import goes to the engine
  `co browser config` names, else to the paid WTF Browser, since that is the
  profile that starts empty. A real import starts a browser session, and a WTF
  Browser session is billed.
- `--dry-run` lists sites and cookie counts only: no Keychain, no browser.
- `--yes` skips the one confirmation; without a terminal it is required.
- A site the target browser is already signed in to is left alone and
  reported as "already signed in in the target", naming the login cookie it
  found, so an import never switches an account the target is using. Signed
  in means a login cookie, not any cookie: `user_session` on github.com,
  `li_at` on linkedin.com, `SID`/`__Secure-1PSID` on google.com, `auth_token`
  on x.com, `c_user` on facebook.com, `_aat` on airbnb.com; on other sites a
  Secure, HttpOnly cookie named like a session (session/auth/token/sid) that
  Chrome's profile also has. Anonymous cookies from an earlier visit do not
  count: the import goes ahead and reports how many it replaced. `--replace`
  imports over a real login; cookies with the same name, domain and path are
  overwritten and the others stay.

How it works: the command reads a private copy of Chrome's cookie database
(Chrome locks the file while it runs, and the source profile is never
modified), decrypts it with the "Chrome Safe Storage" key from the macOS
Keychain the way Chrome does, and writes each site through the target
browser's own cookie API (Playwright `add_cookies`) — so the target encrypts
them however it stores cookies, including a mock keychain. Cookie values are
never printed. The report names every cookie that was skipped and why:
expired, partitioned (it belongs to one embedding site), undecryptable, or
rejected by the target browser.

Limits of this first version:

- **macOS and Google Chrome only.** Other browsers and platforms are not read yet.
- **Cookies only.** Local storage, saved passwords, history and extensions are
  not imported; saved passwords are never read.
- **The Keychain dialog.** macOS may ask whether `security` may read
  "Chrome Safe Storage". That is the key Chrome encrypts its cookies with;
  choose Allow. Deny, and nothing is imported.
- **Device-bound sessions.** A site that ties its session to the device
  (Google, some banks) may still ask you to sign in. Verify with
  `co browser --engine wtf go_to https://<site>`.
- Playwright cannot create host-only cookies, so a cookie Chrome held for one
  exact host is set as a domain cookie for that host; a `__Host-` cookie may be
  rejected for the same reason and is reported as such.

## Error Messages

`go_to` returns exit code `1` with `BrowserNavigationError` when the proxy
rejects authentication or Chromium returns a network error instead of the
destination document. The code is `NAVIGATION_PROXY_AUTH_FAILED` for an observed
407 or explicit authentication error, and `NAVIGATION_NETWORK_ERROR` for other
Chromium network failures (including rejected challenges surfaced that way by
the driver). These messages omit URLs and driver logs that may hold credentials.
An ordinary empty page or HTTP 404 document remains readable and is not treated
as a transport failure.

An address that cannot load at all is refused before anything starts, with
exit code `2`: a scheme other than `http`, `https`, `file`, `data`, `about` or
`chrome`, or text that is not a host name.

```bash
$ co browser go_to htp://example.com
go_to 'htp://example.com': htp: is not a web address this browser opens
Next: co browser go_to https://example.com   (http, https, file, data, about and chrome addresses)
```

Errors print to **stderr** and exit non-zero (`1` for a failed action; see Scripting for the others). Each one tells you the next step — handy when an AI agent is driving the CLI and needs to self-correct.

**Unknown function**
```bash
$ co browser frobnicate
unknown command: frobnicate
Run 'co browser help' to list functions, or 'co browser "<instruction>"' for natural language.
```
The first word didn't match any browser function. List them with `co browser help`, or quote a complete task: `co browser "<instruction>"`.

**Wrong arguments**
```bash
$ co browser go_to
go_to: missing a required argument: 'url'
usage: co browser go_to(url, purpose='', who='', hours=0.0)
```
The function exists but the arguments don't fit, so it exits `2` (usage) without running. The `usage:` line shows the exact signature — pass the missing argument: `co browser go_to example.com`. A value of the wrong type (`wait abc`) is the same kind of error.

**Ran out of time**
```bash
$ co browser cookies --all
cookies did not finish within 120s — it was waiting on Chrome's cookie store — on macOS it waits on a Keychain prompt until someone answers it (look behind other windows).
It was cancelled; tab 'main' is free again.
Next: retry once that answers, or start over (logins are kept): co browser close
```
Every command is answered within 120 seconds, or its own longer `--timeout` plus 15; the client gives up 10 seconds after that if a stopped daemon never answers. A driver timeout reads the same way — `go_to timed out: Timeout 30000ms exceeded.` — with a next step, not an exception name and a call log.

**Authentication required** (only for a quoted browser task)
```bash
$ co browser "find the price"
Browser agent requires authentication. Run: co auth
```
The natural-language agent uses managed keys. Run `co auth` once. Direct function calls don't need this.

**Patchright not installed**
```bash
Browser tools not installed. Run: pip install patchright && patchright install chrome
```

## Troubleshooting

```bash
# Nothing happens / stuck browser → close and start fresh
co browser close

# See what the agent/daemon is doing
cat ~/.co/browser.log

# Authentication needed (only for a quoted browser task)
co auth
```

## See Also

- [`co auth`](auth.md) — managed keys for the browser task agent
- [Browser tools library](../useful_tools/browser_tools.md) — `BrowserAutomation` used in your own agents
- [Templates](../templates/README.md) — scaffold a project whose agent drives this CLI
