diff --git a/CLAUDE.md b/CLAUDE.md index b5b5c10..e2a2783 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,11 +10,65 @@ Key files: | File | Role | |---|---| | `src/cli.ts` | Command parsing and dispatch (`opera-browser-cli `) | -| `src/client.ts` | HTTP client for the bridge + bridge lifecycle (start/stop/health) | +| `src/client.ts` | HTTP client for the bridge + bridge lifecycle (discovery, start lock, recovery) | | `src/bridge.ts` | Persistent HTTP ↔ MCP adapter; spawns `opera-devtools-mcp` as a child process | | `src/bridge.ts` → `runBridge()` | Entry point for the bridge process | +| `src/identity.ts` | Bridge identity contract — version skew and PID-recycling safety | +| `src/detect.ts` | Locating installed Opera builds | +| `src/config.ts` | Config read/write/validate, and first-run autoconfiguration | +| `src/profile.ts` | Profile lock (`SingletonLock`) and debug-port (`DevToolsActivePort`) inspection | +| `src/browser-target.ts` | Decides launch vs attach; quits and relaunches a browser on takeover | +| `src/version.ts` | Package version lookup shared by CLI, bridge, and `/health` | | `bin/opera-browser-cli-bridge.js` | Bridge binary entrypoint (calls `runBridge`) | +### Bridge lifecycle invariants + +Four rules hold the lifecycle together. Breaking any of them reintroduces a class of bug +that M1 removed — see `specs/robustness-hardening.md`. + +1. **Never signal an unidentified PID.** A process is only signalled once it has answered + `/health` as ours, or its PID file entry records the *current* boot (`identity.ts` → + `sameBoot`). After a reboot a recycled PID may belong to anyone. +2. **Version equality, not just health.** A bridge on a different package version is + unusable however healthy it looks — it is serving pre-upgrade code from memory. +3. **Bind the port before connecting to MCP.** `runBridge` listens first so that losing a + start race costs nothing; connecting first would launch a browser only to discard it. +4. **Never silently replay an Opera AI tool.** `callTool` recovers dropped connections by + restarting and retrying, except for `opera_do`/`opera_make`/`opera_research`/`opera_chat`, + which may already have acted and are billable to re-run. + +### Browser target invariants + +5. **`--remote-debugging-port` is startup-only.** A browser the user opened normally can + never be attached to. Everything in `browser-target.ts` follows from this. +6. **A live debug port beats the lock.** If `DevToolsActivePort` answers, attach — whatever + `SingletonLock` says. Both files outlive the browser (a crash leaves the lock, a clean + exit leaves the port file), so neither is trusted without confirming against the system. +7. **Never quit a browser unprompted.** Takeover needs a TTY answer or an explicit + `--takeover`. Agents and other non-interactive callers fall back to a separate profile. +8. **SIGTERM, never SIGKILL, for a browser.** Chromium treats SIGTERM as a clean shutdown; + SIGKILL risks a corrupted profile and loses the user's tabs. A browser that will not + quit is reported, not forced. + +### Caller-contract invariants + +11. **Exit codes are a public interface.** `EXIT_CODES` in `cli.ts` is documented in + `README.md` and `SKILL.md`, and agents branch on it. Changing a mapping is a breaking + change; adding an `ErrorCode` means adding its exit code too. +12. **`AUTH_REQUIRED` is distinct from `BROWSER_ERROR`.** "Ask the user" (4) and "the + environment is broken" (3) call for different responses, so entitlement failures must + not be folded back into `BROWSER_ERROR`. + +### Configuration invariants + +9. **Config is a cache, not a prerequisite.** An absent config means "detect it now", never + "fail" or "tell the user to run setup". `ensureConfigured` runs before every + browser-touching command and works identically under an agent. +10. **Headless stays the default without a configured browser.** Headed is chosen only when + an Opera binary is configured, because Opera AI sign-in needs a window. Machines with + no display and no Opera — CI, Docker, the openclaw sidecar — must keep working. + `OPERA_CLI_HEADED=0`/`=1` overrides either way. + ## Benchmarks Token-cost and agentic-quality measurements live in `benchmarks/`. See `benchmarks/CLAUDE.md` for file roles and how to run them. @@ -26,36 +80,21 @@ Always check there before starting implementation work. | Spec | Status | |---|---| +| [`specs/robustness-hardening.md`](specs/robustness-hardening.md) | Planned — self-healing bridge, zero-config first run, graceful error handling | | [`specs/fix-parallel-streaming-routing.md`](specs/fix-parallel-streaming-routing.md) | Planned — parallel chunk routing for concurrent Opera AI calls | | [`specs/chat-model-selector.md`](specs/chat-model-selector.md) | Planned — model selector for chat command | ## Common issues -### Stale bridge process after update (`BRIDGE_NOT_READY` / "different server") - -**Symptom:** `opera-browser-cli` commands fail with: -``` -error: Port 9224 is in use by a different server (not opera-devtools-mcp). -code: BRIDGE_NOT_READY -``` -even though the bridge is running (`lsof -i :9224` shows a `node` process). +### Stale bridge after a rebuild — resolved as of M1 -**Cause:** The bridge process was started before `dist/src/bridge.js` was rebuilt. The -running process has old code in memory; its `/health` response is missing the -`server: "opera-browser-cli"` field that `checkPortStatus` (`client.ts`) requires to -recognise the bridge as healthy. Without that field the port is classified as a conflict. +This used to require `opera-browser-cli stop`, or `lsof -ti :9224 | xargs kill` when the +PID file was missing. It no longer does: `/health` carries the package version, and a +bridge running different code is shut down and replaced automatically on the next +command. `opera-browser-cli status` shows the skew if you want to see it happen. -**Fix:** Restart the bridge: -```sh -opera-browser-cli stop -# next command auto-starts a fresh bridge with current code -``` - -If `stop` does nothing (the bridge was started without a PID file, or the PID file was -deleted), kill it by port instead: -```sh -lsof -ti :9224 | xargs kill -``` +If a bridge ever does get wedged, `opera-browser-cli restart` is the one command to +reach for — it escalates to SIGKILL and clears any stale PID file. ## Architecture notes diff --git a/README.md b/README.md index 8a5863c..0f43139 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,13 @@ It wraps [opera-devtools-mcp](https://github.com/operasoftware/opera-devtools-mc ```sh npm install -g opera-browser-cli -opera-browser-cli setup # interactive wizard — run in a terminal where you can answer prompts opera-browser-cli open https://example.com ``` +That is the whole setup. The first command detects your Opera installation, +writes `~/.opera-browser-cli/config`, and gets on with it. Run +`opera-browser-cli setup` only when you want to change what it chose. + Once installed, `open` navigates to a URL and returns a structured snapshot you can act on: ```sh @@ -64,21 +67,30 @@ Prerequisites: **Node.js >= 20**, **Opera** browser ([Opera Neon](https://www.op npm install -g opera-browser-cli ``` -Run first-time setup — this is an interactive wizard, so run it in a terminal where you can answer prompts: +No setup step is required. The first command you run detects your Opera +installation, writes `~/.opera-browser-cli/config`, and continues: ```sh -opera-browser-cli setup +opera-browser-cli --version +opera-browser-cli open https://example.com ``` -This detects Opera installations, lets you pick one, saves configuration to `~/.opera-browser-cli/config`, and installs the skill to `~/.claude/skills/opera-browser-cli/SKILL.md` (Claude Code) and `~/.agents/skills/opera-browser-cli/SKILL.md` (generic cross-agent path used by Codex and other agents). - -Verify: +`setup` exists for when you want to change that choice — pick a different +browser or profile, or install the agent skill files: ```sh -opera-browser-cli --version -opera-browser-cli open https://example.com +opera-browser-cli setup # interactive wizard +opera-browser-cli setup -y # detect and accept, no prompts +opera-browser-cli setup --executable "/Applications/Opera Neon.app/Contents/MacOS/Opera" \ + --profile skip --headless ``` +It saves to `~/.opera-browser-cli/config` and installs the skill to +`~/.claude/skills/opera-browser-cli/SKILL.md` (Claude Code) and +`~/.agents/skills/opera-browser-cli/SKILL.md` (generic cross-agent path used by +Codex and other agents). The non-interactive form needs no terminal, so agents +and provisioning scripts can run it too. + ### From source ```sh @@ -86,7 +98,7 @@ opera-browser-cli open https://example.com npm install && npm run build && npm link ``` -Then run `opera-browser-cli setup` as above. +Then just run a command — configuration happens on first use. ### Usage examples @@ -127,7 +139,7 @@ OPERA_CLI_BROWSER_URL=http://127.0.0.1:9222 opera-browser-cli open https://examp ``` - **Persistent bridge** — a detached process keeps the MCP session alive across commands, so Chrome doesn't restart every invocation -- **Auto-lifecycle** — the bridge starts on first command and writes a PID file to `~/.opera-browser-cli/bridge.pid` +- **Auto-lifecycle** — the bridge starts on first command, writes a PID file to `~/.opera-browser-cli/bridge.pid`, and restarts itself on version skew or a dropped connection - **Snapshot parsing** — accessibility tree snapshots are extracted and analyzed for interactive elements (`uid=` refs) - **TOON encoding** — structured metadata uses [TOON format](https://www.npmjs.com/package/@toon-format/toon) for compact, token-efficient output @@ -219,14 +231,83 @@ opera-browser-cli eval "(() => { const rows = [...document.querySelectorAll('tr' |----------|--------------------------------------------------| | `setup` | Interactive first-time setup (browser path, etc) | | `doctor` | Check configuration and environment | +| `doctor --fix` | Repair what can be repaired mechanically | +| `login` | Sign in to your Opera account (needed for AI) | | `logs` | Show bridge server logs | +### Using your real Opera profile + +By default the CLI launches its own browser. To drive **your** Opera — with your +logins, your session — the browser has to have been started with a debugging +port. That flag cannot be added to a browser that is already open, so there are +two ways in: + +```sh +opera-browser-cli launch-args # prints the command to start Opera with a port +``` + +Start Opera that way once, and every later command finds it automatically — the +port is recorded in `DevToolsActivePort` inside the profile, so nothing needs +configuring. Or let the CLI do it for you: + +```sh +opera-browser-cli open example.com # detects the conflict, offers to restart Opera +opera-browser-cli open example.com --takeover # skip the prompt (scripts, agents) +``` + +If Opera is already running on the configured profile and has no debugging port, +the CLI asks whether to restart it (tabs are restored). Without a terminal to ask +in, it quietly uses a separate profile instead — an agent will never quit your +browser on its own. Restarting is always SIGTERM, never SIGKILL: a forced kill +risks a corrupted profile. + +```sh +opera-browser-cli attach --port 9222 # connect to a specific endpoint +opera-browser-cli attach --clear # go back to a CLI-launched browser +``` + +> **Note:** a debugging port has no authentication of its own — the CLI's bearer +> token protects the bridge, not the browser. Any local process can drive a +> browser with an open port, and this one is signed into everything you are. The +> CLI lets the browser pick a random port rather than a predictable 9222, binds +> it to loopback, and never passes `--remote-allow-origins`, which is what stops +> a web page from driving it. Close the browser when you are done. + ### Bridge -| Command | Description | -|---------|-------------------------| -| `start` | Start the bridge server | -| `stop` | Stop the bridge server | +| Command | Description | +|-----------|--------------------------------------------------------------------| +| `start` | Start the bridge server | +| `stop` | Stop the bridge server (escalates to SIGKILL; clears a stale PID) | +| `restart` | Stop and start again — forces a clean state | +| `status` | Report bridge pid, port, and running version without starting one | + +You should rarely need any of these. The bridge starts on first use, and repairs +itself without being asked: + +- **Upgraded package** — a bridge running pre-upgrade code is detected by version + and replaced on the next command. +- **Crashed or killed bridge** — the next command restarts it and retries. Opera AI + commands are the exception: they are never silently re-run, since they may already + have acted on the page. +- **Port in use** — the next port in the range is used instead of failing. +- **Several commands at once** — a start lock means exactly one bridge comes up. +- **Stale PID file** — cleared automatically, and never signalled if the PID could + belong to an unrelated process from before a reboot. + +### Exit codes + +Scripts and agents can branch on why a command failed without parsing messages: + +| Code | Meaning | Caller action | +|---|---|---| +| 0 | Success | — | +| 1 | Unknown / internal | Report | +| 2 | Bad arguments, or unsupported on this browser | Fix the command | +| 3 | Environment not ready after auto-recovery | Run `doctor` | +| 4 | Sign-in, subscription, or consent required | Ask the user | +| 5 | Timed out | Retry | +| 6 | Stale element ref or closed page | Re-snapshot, then retry | Running with no command shows the CLI home view. It prepends `bin` and `description` metadata, then includes the current snapshot when a browser @@ -265,14 +346,15 @@ session is active or the no-session status/help block when one is not. | Variable | Default | Purpose | |-----------------------------|----------------------------------|------------------------------------------------------------------| -| `OPERA_CLI_PORT` | `9225` | Bridge server port | +| `OPERA_CLI_PORT` | `9225` | Base bridge port; the next 9 are tried if it is occupied | | `OPERA_CLI_MCP_BIN` | _(bundled `opera-devtools-mcp`)_ | Override the MCP server binary | | `OPERA_CLI_EXECUTABLE_PATH` | _(system Chrome)_ | Custom browser binary | | `OPERA_CLI_BROWSER_URL` | — | Connect to an existing browser instance instead of launching one | | `OPERA_CLI_USER_DATA_DIR` | — | Persistent Chrome profile directory (skips isolated mode) | -| `OPERA_CLI_HEADED` | — | Set to `1` to run in headed (visible) mode | +| `OPERA_CLI_HEADED` | `1` when an Opera binary is configured | `1` headed, `0` headless. Opera AI needs a window to sign in | | `OPERA_CLI_CHROME_ARGS` | — | Extra Chrome flags, space-separated | | `OPERA_CLI_ENABLE_HOOKS` | — | Set to `1` to auto-install session hooks on startup | +| `OPERA_CLI_TAKEOVER` | — | Set to `1` to restart a running Opera without asking | State is stored in `~/.opera-browser-cli/`: diff --git a/SKILL.md b/SKILL.md index 10ae567..b7aa104 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: opera-browser-cli -description: Browser automation and web interaction using the opera-browser-cli tool. Use for navigating pages, clicking elements, filling forms, taking screenshots, inspecting console/network, running performance audits, and Opera AI features (chat available on any Opera browser; invoke-do, make, research require Opera Neon). +description: Browser automation and web interaction using the opera-browser-cli tool. Use for navigating pages, clicking elements, filling forms, taking screenshots, inspecting console/network, running performance audits, and Opera AI features (chat available on any Opera browser; invoke-do, make, research require Opera Neon). When a browser is already running without automation enabled, this tool can restart it with a debug port (takeover) or use a separate profile — ask the user which they prefer. metadata: {"openclaw": {"requires": {"bins": ["opera-browser-cli"]}}} --- @@ -47,6 +47,35 @@ Commands that accept these flags: `open`, `snapshot`, `click`, `fill`, `type`, ` To wire this CLI into a Docker-based OpenClaw setup (Chromium sidecar, shared netns, config bootstrap), see [`openclaw/README.md`](openclaw/README.md). +## Exit codes + +Branch on the exit code rather than parsing messages: + +| Code | Meaning | What to do | +|---|---|---| +| 0 | Success | — | +| 2 | Bad arguments, or the browser cannot do this | Fix the command; do not retry as-is | +| 3 | Environment not ready after auto-recovery | Run `opera-browser-cli doctor` | +| 4 | Sign-in, subscription, or consent needed | Ask the user — you cannot fix this | +| 5 | Timed out | Retry | +| 6 | Stale element ref or closed page | Re-run `snapshot`, then retry with fresh refs | +| 1 | Anything else | Report it | + +## Recovery is automatic + +The bridge restarts itself on version skew, a crash, or a dropped connection, and falls back to another port if one is taken. Do not run `stop`/`restart` speculatively — just re-run the command. The exception is the Opera AI tools (`invoke-do`, `make`, `research`, `chat`): if one reports the connection dropped mid-call, it was **not** retried, because it may already have acted. Ask before re-running it. + +## When the browser can't be automated + +The CLI only drives a browser started with a debug port. If the user's Opera is already open **without** one, the CLI can't attach to that window — `open`/AI then fail with **"Could not connect to Chrome"**. Run `opera-browser-cli doctor` (it reports the profile state); the bridge self-heals, so don't restart it blindly. + +**Always ask the user** — restarting their browser is their call, not a judgement you infer: +> "Your Opera is open but wasn't started with automation. May I restart it with a debug port (tabs restored)? Or should I use a separate profile (you'd sign in there)?" + +- **They approve restart** → run with `--takeover` (or `OPERA_CLI_TAKEOVER=1`). Restarts with a debug port, restores tabs, attaches — drives the real browser thereafter. +- **They decline** → no flag → separate profile at `~/.opera-browser-cli/profile` (they sign in there; AI may then return exit `4` — surface it). +- `opera-browser-cli launch-args` prints the flags to start Opera attachable so a restart is never needed later. + ## Sign-in errors -If you hit `Opera: user is not signed in` on an AI command, suggest signing in to their Opera account. Run `opera-browser-cli setup` or `opera-browser-cli doctor` to configure or diagnose. +If an AI command fails with `AUTH_REQUIRED` (exit code 4) — not signed in, no subscription, or consent pending — tell the user to run `opera-browser-cli login`, which opens the Opera account page in a visible window. `opera-browser-cli login --check` verifies the current state. Run `opera-browser-cli doctor` to diagnose anything else. diff --git a/specs/robustness-hardening.md b/specs/robustness-hardening.md new file mode 100644 index 0000000..ff4b078 --- /dev/null +++ b/specs/robustness-hardening.md @@ -0,0 +1,476 @@ +# Robustness hardening — "it just works" + +**Status:** Complete. M1 (P0.1–P0.8), M2 (P1.1–P1.4), M3 (P2.1–P2.4), M4 (P3.1–P3.3), M5 (P4, P5), M6 (tests). +**Goal:** A user (or agent) can run any `opera-browser-cli` command at any time, in any state, and either get a correct result or a single actionable sentence. No manual `stop`, no `lsof | xargs kill`, no reading `bridge.log` to find out why nothing happened. + +--- + +## 1. Success criteria + +The CLI is "done" when all of these hold: + +| # | Scenario | Required behaviour | State | +|---|---|---|---| +| S1 | Fresh machine, never configured | First command auto-detects the browser, writes config, and works. No prompt required. | ✅ M2 | +| S2 | Package upgraded, old bridge still running | Next command detects version skew, restarts the bridge transparently. | ✅ M1 | +| S3 | Bridge crashed / was killed | Next command restarts it transparently. | ✅ M1 | +| S4 | Port 9225 taken by something else | Falls back to the next free port. No error. | ✅ M1 | +| S5 | Two commands race on a cold start | Exactly one bridge starts; both commands succeed. | ✅ M1 | +| S6 | Opera already running on the same profile | Connects to it (or uses a distinct profile) instead of failing to launch. | ✅ M3 | +| S7 | User closes the browser window mid-session | Next command relaunches and reports the lost page state, not a CDP stack trace. | ✅ M1 | +| S8 | PID file deleted / token stale | Token is re-read or the bridge restarted; never a bare `unauthorized`. | ✅ M1 | +| S9 | Not signed in / no subscription / consent pending | One-line message + a command that fixes it. | ✅ M4 | +| S10 | `opera-devtools-mcp` missing or broken | Named in the error, with the install command. | ✅ M1 | +| S11 | Anything fails anyway | `doctor` explains it, `doctor --fix` repairs it, exit code is machine-readable. | ✅ M5 | + +**Non-goal:** hiding genuine user decisions (which browser, which profile). Those get a sane default and a way to change it — not a prompt on the hot path. + +--- + +## 2. Current failure inventory + +Everything below is a real gap in the code today, not a hypothetical. + +### 2.1 Bridge lifecycle (`src/client.ts`, `src/bridge.ts`) + +| ID | Location | Problem | +|---|---|---| +| F1 | `client.ts:265-274` | `ensureBridge` trusts a live PID from the PID file and **SIGTERMs it** if unhealthy. After a reboot the PID is recycled — we can signal an unrelated user process. No identity verification. | +| F2 | `bridge.ts:396-403` | `/health` returns `{status, server}` with **no version**. A bridge running pre-upgrade code looks healthy forever. This is the documented `BRIDGE_NOT_READY` / "different server" issue in `CLAUDE.md`. | +| F3 | `bridge.ts:633-637` | `server.listen()` has **no `error` handler**. `EADDRINUSE` (lost start race, or port grabbed between probe and listen) is an uncaught exception — the bridge dies with a stack trace in `bridge.log` and the client just times out after 30 s. | +| F4 | `client.ts:277-290` | Port conflict is a **hard error**. No fallback port, no port range. | +| F5 | `client.ts:312-330` | **No start lock.** Concurrent invocations (an agent firing three commands at once) all pass the health probe and all spawn a bridge. Losers die on F3; the winner's PID file may already have been overwritten. | +| F6 | `client.ts:312-321` | Bridge is spawned `detached` with stdio to a log file, so the **`READY\n` handshake** written by `writeReadySignal()` (`bridge.ts:453`) is never read. We poll blind for 30 s even when the child died in 200 ms. | +| F7 | `client.ts:332-335` | Startup failure message is generic and does not include **the tail of `bridge.log`**, which already contains the actual cause. | +| F8 | `client.ts:375-379` | `ECONNREFUSED` maps to "Bridge is not running" **with no retry**. The bridge shuts itself down when the MCP transport closes (`bridge.ts:655-659`), so a browser crash reliably produces this — and we make the user re-run by hand. | +| F9 | `client.ts:294-299` | Dev-mode detection spawns `npx tsx`. If `tsx` is not cached, **`npx` blocks on an install prompt** with stdio pointed at a log file — a silent 30 s hang. | +| F10 | `client.ts:504-514` | `stopBridge` sends SIGTERM, **does not wait, does not escalate to SIGKILL, and does not clean a stale PID file**. `stop` can report success against a process that ignored the signal. | +| F11 | `bridge.ts:107-120` | `writePidFile` runs inside the `listen` callback. If `~/.opera-browser-cli` is unwritable (e.g. root-owned after a `sudo` run) it throws **uncaught** and the bridge dies after binding the port. | +| F12 | — | No `restart` command. `CLAUDE.md` documents `stop` + `lsof -ti :9224 | xargs kill` as the recovery procedure. That procedure should not need to exist. | +| F13 | `client.ts:301-310` | `bridge.log` is opened `"a"` and **never rotated**. Unbounded growth on a long-lived machine. | + +### 2.2 Browser launch (`bridge.ts:457-503`) + +| ID | Problem | +|---|---| +| F14 | **Profile already in use.** With `OPERA_CLI_USER_DATA_DIR` pointing at the real Opera profile (which `setup` offers as the detected default, `cli.ts:1862`), launching while Opera is open hits the `SingletonLock` and the launch fails or silently attaches to nothing. This is the single most likely everyday failure — users have their browser open. | +| F15 | **No attach path.** There is no way to say "use the Opera I already have open". `OPERA_CLI_BROWSER_URL` exists but requires the user to have started Opera with `--remote-debugging-port` themselves. | +| F16 | **Headless is the default** (`bridge.ts:490`) unless `OPERA_CLI_HEADED=1`. Un-configured users get headless + `--isolated`, where sign-in is impossible, so every AI command fails on a state they cannot fix from the CLI. | +| F17 | **MCP binary is not preflighted.** `resolveOperaMcpBin` (`bridge.ts:505`) falls back to bare `opera-devtools-mcp` on `PATH`; if absent, the spawn fails inside the transport and surfaces as a 30 s timeout. | +| F18 | **No browser-crash recovery.** Transport close → bridge exits (correct) → next call is F8. | + +### 2.3 Auth and entitlement (`cli.ts:2191-2272`) + +| ID | Problem | +|---|---| +| F19 | Sign-in / subscription / consent are detected (`CDP_RESULT_ERRORS`) but only produce **prose advice**. There is no `login` command that opens the sign-in page in the headed browser and waits. | +| F20 | `requireNeon` (`cli.ts:2191`) only checks that the executable **path exists**. It cannot tell Neon from Opera from Chrome, and cannot tell signed-in from signed-out — so the fast-fail misses the two most common cases. | +| F21 | The bridge's own bearer token has **no error mapping**. A stale PID file yields a bare `unauthorized` string with no code and no recovery. | + +### 2.4 Configuration and first run + +| ID | Problem | +|---|---| +| F22 | `warnIfUnconfigured` (`cli.ts:2591`) prints a hint to stderr and proceeds into a broken configuration. | +| F23 | `setup` **requires a TTY** (`cli.ts:1749`) and refuses to run under an agent — which is exactly how most of these users invoke the CLI. There is no non-interactive path. | +| F24 | Config keys are **not validated**. A typo (`OPERA_CLI_EXEC_PATH=`) is silently ignored and `doctor` does not flag it. | + +### 2.5 Contract with callers + +| ID | Problem | +|---|---| +| F25 | **Exit codes are undifferentiated** — everything non-zero is 1. Agents and scripts cannot distinguish "retry later" from "ask the user to sign in". | +| F26 | No transient-error retry. Element-detached-during-navigation and similar races surface raw. | + +--- + +## 3. Design principles + +1. **Recover, then report.** Any failure with a mechanical fix is fixed silently and the command completes. The user only ever hears about decisions they must make. +2. **Every error carries a next action.** The existing `CdpError(message, code, suggestions)` shape is already right; the gap is coverage, not format. +3. **One bridge, provably ours.** Identity = `{server, version, pid, startedAt, bootId}`. Never signal a process we have not identified. +4. **Idempotent and concurrency-safe.** Any command can run twice, or three at once, from a cold start. +5. **Config is a cache, not a prerequisite.** Absent config means "detect it now", not "fail". +6. **Fail fast, not fail slow.** No 30 s poll for a child that died in 200 ms. + +--- + +## 4. Workstreams + +### P0 — Self-healing bridge lifecycle ✅ done +*Fixes F1–F13, F21. Highest value: it removes the documented manual recovery procedure entirely.* + +**Shipped.** `src/identity.ts` and `src/version.ts` are new; `src/client.ts` was substantially +rewritten. Covered by `test/identity.test.ts`, `test/bridge-lifecycle.test.ts`, +`test/bridge-startup.test.ts`, `test/bridge-recovery.test.ts` (41 tests), with +`test/fixtures/stub-mcp.js` standing in for `opera-devtools-mcp` so the whole lifecycle is +tested without launching a browser. + +Two deviations from the plan as written, both deliberate: + +- **`bootMinute`, not a hashed `bootId`.** A hash cannot be compared with a tolerance, and + two processes computing boot time from `os.uptime()` seconds apart legitimately disagree + by a second or two. A raw boot-minute compared with `sameBoot` (±1) is robust where an + equality check on a hash would produce false mismatches at minute boundaries. +- **The bridge binds its port before connecting to MCP.** Not in the original plan, and + necessary: with the reverse order, losing a port race meant launching an entire browser + and then discarding it, leaving an orphaned child. Binding first makes a lost race free, + which is what allows the port scan to be cheap enough to be the default path. + +**P0.1 — Health contract with identity** (`bridge.ts`, `client.ts`) + +`/health` returns: + +```json +{ + "status": "ok", + "server": "opera-browser-cli", + "version": "0.1.46", + "pid": 12345, + "startedAt": 1755400000000, + "bootId": "", + "browser": { "connected": true, "target": "Opera Neon", "headed": true } +} +``` + +- `version` read from `package.json` at bridge start (reuse `readPackageVersion`, `cli.ts:916` — extract to a shared module). +- `bootId` derived from `os.uptime()` at start, rounded to the minute, so a recycled PID after reboot never matches. +- Client-side `BridgeIdentity` check replaces the current boolean `isBridgeHealthy`: + - `version !== ourVersion` → **restart** (fixes F2). + - `pid`/`bootId` mismatch with the PID file → treat the PID file as stale, **do not signal** (fixes F1). + - `server !== "opera-browser-cli"` → foreign server, port fallback (P0.3). + +**P0.2 — Start lock** (`client.ts`) + +Wrap the spawn in an exclusive lock at `~/.opera-browser-cli/bridge.lock`, created with `openSync(path, "wx")`, containing `{pid, startedAt}`. + +- Lock acquired → spawn, wait for ready, release. +- Lock held by a live process → poll `/health` for up to 30 s instead of spawning (fixes F5). +- Lock held by a dead PID, or older than 60 s → steal it. +- Released in a `finally` and on `process.on("exit")`. + +**P0.3 — Port allocation** (`client.ts`, `bridge.ts`) + +- Probe `OPERA_CLI_PORT` (default 9225), then 9226…9234. +- First port answering with our identity → use it. +- First port with nothing listening → spawn there; the child receives it via `OPERA_CLI_PORT`. +- All busy with foreign servers → the current hard error, which is now genuinely exceptional (fixes F4). +- Add `server.on("error")` in `runBridge`: on `EADDRINUSE`, log and exit `75` (EX_TEMPFAIL) rather than throwing; the parent reads the exit code and retries the next port (fixes F3). + +**P0.4 — Ready handshake and fast failure** (`client.ts`, `bridge.ts`) + +- Spawn with `stdio: ["ignore", "pipe", logFd]`, read the `READY\n` line from the pipe, then `unref` and detach (fixes F6). Startup latency drops from "poll interval" to "as fast as the bridge binds". +- Bridge emits `FAILED \n` on the same channel for known-fatal startup errors (MCP spawn failure, unwritable state dir). +- Watch for child `exit` during the wait — if it dies, abort the poll immediately and read the last ~40 lines of `bridge.log` into the thrown `CdpError` (fixes F7). +- Wrap `writePidFile` in try/catch; on failure log `FAILED state-dir-unwritable` and exit cleanly with a message naming the directory and the fix (`chown`) (fixes F11). + +**P0.5 — Transparent retry on connection loss** (`client.ts`) + +`callTool` gains a single-retry wrapper: + +``` +attempt → ECONNREFUSED | ECONNRESET | 401 | "MCP transport disconnected" + → invalidate cached identity, ensureBridge() (which restarts), replay once + → still failing → CdpError with recovery suggestions +``` + +- Replay is safe for reads and navigation; **not** replayed for `opera_do` / `opera_make` (side-effecting and expensive) — those report the restart and ask for a re-run (fixes F8, F18). +- 401 first re-reads the PID file (the token may have rotated under us) before escalating to a restart (fixes F21). + +**P0.6 — Lifecycle commands** (`cli.ts`) + +- `restart` — stop (with escalation) + start, one command (fixes F12). +- `stop` — SIGTERM, poll up to 5 s, SIGKILL, remove the PID file, report what actually happened. Also handles the "PID file exists, process dead" case by cleaning up and reporting `stopped (stale)` (fixes F10). +- `status` — identity + browser state, no side effects (thin alias over the new health payload). + +**P0.7 — Log rotation** (`client.ts`) + +Before opening `bridge.log`, if it exceeds 5 MB, rename to `bridge.log.1` (keep one generation) (fixes F13). + +**P0.8 — Dev-mode gating** (`client.ts`) + +Only take the `tsx` path when `OPERA_CLI_DEV=1` **and** `tsx` resolves locally; never invoke `npx` with a non-interactive stdio (fixes F9). + +--- + +### P1 — Zero-config first run ✅ done +*Fixes F22, F23, F16, F24. This is the "one-click" half of the goal.* + +**Shipped.** `src/detect.ts` and `src/config.ts` are new; `warnIfUnconfigured` is replaced +by `ensureConfigured`, and `setup` no longer requires a TTY. Covered by +`test/config.test.ts` plus setup/flag parsing in `test/cli.test.ts` (26 tests). + +One significant deviation: + +- **P1.3 is narrower than "headed by default".** A blanket inversion would break every + machine with no display — CI, Docker, and anyone driving plain Chrome — for whom + headless is not a preference but the only thing that works. The rule shipped instead is + *headed when an Opera binary is configured*, which is exactly the population that needs + a window (sign-in and consent cannot be completed headlessly) and excludes the + headless-only population entirely. Autoconfiguration writes `OPERA_CLI_HEADED=1` when it + detects a browser, so the F16 case — an unconfigured user getting an unusable headless + AI command — is closed from both directions. `OPERA_CLI_HEADED=0` still overrides. + The openclaw sidecar is unaffected: it uses `OPERA_CLI_BROWSER_URL`, which never reaches + the headless branch. +- **Autoconfiguration prefers the browser's real profile**, per M3's revised P2.1, rather + than the CLI-owned profile the original P1.1 specified. A profile that turns out to be + in use is now resolved at launch time, so there is no reason to avoid it. + +**P1.1 — Autoconfigure on first use** (`cli.ts`) + +Replace `warnIfUnconfigured` with `ensureConfigured()`, run before any browser-touching command: + +1. Config exists → done. +2. No config → run **detection silently**: `neonCandidatePaths()` → `operaCandidatePaths()` → first hit wins. +3. Write `~/.opera-browser-cli/config` with the detected binary, `OPERA_CLI_HEADED=1`, and a **CLI-owned profile** at `~/.opera-browser-cli/profile` (not the live Opera profile — see P2.1). +4. Print one line to stderr: `configured: Opera Neon (headed) — run 'opera-browser-cli setup' to change`. +5. Nothing detected → a single actionable error naming the download URL and the `OPERA_CLI_EXECUTABLE_PATH` override. + +This works identically under an agent and in a terminal (fixes F22, F23). + +**P1.2 — `setup --non-interactive`** (`cli.ts`) + +Same detection as P1.1, plus flags for scripted installs: `--executable`, `--profile`, `--headed`/`--headless`, `--yes`. Removes the TTY hard requirement; the wizard stays as the default interactive path. + +**P1.3 — Headed by default** (`bridge.ts:490`) + +Invert the default: headed unless `OPERA_CLI_HEADED=0` or `OPERA_CLI_HEADLESS=1`. Sign-in, consent, and every Opera AI feature depend on a real window; headless-by-default makes the AI commands unusable for anyone who skipped setup (fixes F16). +*Note: this is a behaviour change for existing users who rely on the current default — call it out in the changelog and honour an explicit `OPERA_CLI_HEADED=0`.* + +**P1.4 — Config validation** (`client.ts`, `cli.ts`) + +`loadConfig` collects unknown keys against a known-key allowlist; `doctor` reports them as `warn` with a did-you-mean suggestion (fixes F24). + +--- + +### P2 — Browser launch conflicts ✅ done +*Fixes F14, F15, F17. Highest-frequency real-world failure after the bridge.* + +**Shipped.** `src/profile.ts` and `src/browser-target.ts` are new; conflict resolution runs +as a preflight in `cli.ts` before the bridge starts, because resolving one may need to ask +the user something and the bridge is detached with no terminal. Covered by +`test/profile.test.ts` and `test/browser-target.test.ts` (39 tests). + +Deviations from the plan as written: + +- **`DevToolsActivePort` does the work P2.3 was going to ask the user to do.** Chromium + records the debug port inside the user-data-dir whenever it is given one. So detection + needs no configuration at all: start Opera with a port once, and every later command + finds it. `attach` remains for pointing at a *different* endpoint, but is no longer the + primary path. +- **A live debug port overrides the lock**, rather than being checked after it. A profile + locked by another host reads as `unknown`, but if something answers on the recorded port + the question is already settled. +- **Self-launch only after a takeover**, not whenever a real profile is configured. When + the profile is free, the existing managed launch already works and is well-tested; + changing it would be an enhancement with regression risk, not a fix. The asymmetry is + deliberate: having just quit the user's browser, we owe them one that outlives the + bridge, which is what the self-launched, detached, attachable browser gives them. +- **No `--takeover` escalation to SIGKILL.** Forcing a browser risks a corrupted profile + and loses the user's tabs; a browser that will not quit is reported instead. + +**P2.1 — Keep the live profile; resolve the conflict instead of avoiding it** + +*Revised from "own the profile by default" — the original plan traded away the thing users +actually want (their real logged-in browser) to dodge a conflict that can be resolved.* + +The governing constraint: **`--remote-debugging-port` is a startup-only flag.** An Opera +launched normally cannot be attached to, ever. So there is no way to "hook up to the +browser that is already open" — only ways to arrange that the open browser was started +correctly in the first place. Given that, the default becomes: + +1. **Opera not running** → launch it ourselves with the real profile and a debug port. + The user gets their own logged-in browser, no sign-in needed. +2. **Opera running on the target profile** → offer to restart it (P2.2). One keypress, + session restore returns the tabs, all logins intact. +3. **User declines** → fall back to `~/.opera-browser-cli/profile` for that run and say so. + +`setup` keeps a "use a separate CLI profile" option for anyone who prefers isolation. + +**P2.2 — Detect the lock and offer a takeover** (`bridge.ts`, `cli.ts`) + +Before spawning with a `userDataDir`, read `SingletonLock` at the user-data-dir root. On +POSIX it is a symlink whose target is `-`, so `readlink` → parse the PID → +`kill(pid, 0)` tells us whether the owner is alive. A dangling link means Chromium will +clean it up itself and the directory is free. (Windows uses a `lockfile` plus a mutex and +needs a separate probe — tracked in §7.) + +If the profile is genuinely in use: + +1. **Attach** if a debug port is already open — probe `/json/version` and confirm the + `Browser` string is Opera, then switch to `--browserUrl`. +2. **Offer a restart**: "Opera is running. Restart it so the CLI can drive it? [Y/n]". + Non-interactive callers get this only with an explicit `--takeover` flag; an agent must + never quit a user's browser unprompted. +3. **Fall back** to the CLI-owned profile, warning once. +4. Never emit a raw Chrome launch failure (fixes F14). + +**P2.2a — Debug-port exposure** + +Attaching to the live profile means an unauthenticated CDP port on a browser logged into +everything the user is. The bridge's bearer token does not cover this — CDP has no auth of +its own — so the mitigations are structural: + +- Allocate a **random high port per launch**, never a fixed 9222, and record it in the PID + file so only our CLI knows where it is. +- Bind `--remote-debugging-address=127.0.0.1` explicitly. +- **Never** pass `--remote-allow-origins=*`. Chromium's default rejection of CDP WebSocket + upgrades carrying an `Origin` header is what stops a web page from driving the browser. +- Tear the port down with the browser when the CLI owns its lifecycle. +- Say plainly in the docs that live-profile mode means an open local CDP port for as long + as that browser runs. + +**P2.3 — `attach` command** (`cli.ts`) + +`opera-browser-cli attach [--port 9222]` — persists `OPERA_CLI_BROWSER_URL` and verifies the endpoint. Plus a `launch-args` helper that prints the exact flags to start Opera with remote debugging, so "use the browser I already have open" is a two-step, documented path (fixes F15). + +**P2.4 — Preflight the MCP binary** (`bridge.ts`, `cli.ts doctor`) + +Resolve `opera-devtools-mcp` at bridge start; if unresolvable, emit `FAILED mcp-not-found` and have the client raise a `CdpError` naming the install command. Add a `doctor` check for it (fixes F17). + +--- + +### P3 — Auth and entitlement UX ✅ done +*Fixes F19, F20.* + +**Shipped.** `login` opens the Opera account page in a visible window and confirms afterwards; +`requireNeon` now classifies the browser instead of only checking that a path exists; the +entitlement descriptors carry `AUTH_REQUIRED` and name `login`. + +Deviation: + +- **`login` does not poll for sign-in state.** P3.1 assumed that state is observable. It is + not: sign-in, subscription, and consent are only visible in the reply to a real Opera AI + call, so polling would mean repeatedly making billable calls against a user who has not + finished typing their password. Instead `login` waits on the user (Enter on a TTY) and + then verifies once. `login --check` verifies without navigating. +- **P3.2's capability probe is path- and attach-based, not a live browser query.** When + attached we have the browser's real version string and use it; when launching, the build + is identified from the binary, which is how Opera names them. A CDP-level probe would add + a round trip to every AI command to distinguish cases the binary name already separates. + +**P3.1 — `login` command** (`cli.ts`) + +`opera-browser-cli login` — forces a headed session, navigates to the Opera account sign-in, polls sign-in state, and returns when authenticated (or times out with a clear message). This turns F19's advice into an action. + +**P3.2 — Capability probe replaces path-sniffing** (`cli.ts:2191`) + +Ask the browser what it is rather than guessing from the path: a lightweight probe (browser version string / Opera AI tool availability) cached in the bridge and exposed on `/health` as `browser.capabilities`. `requireNeon` then checks the actual capability, and can distinguish: + +- not an Opera browser → install prompt +- Opera but not Neon → "chat works here; `invoke-do`/`make`/`research` need Neon" +- Neon but signed out → `opera-browser-cli login` + +(fixes F20). + +**P3.3 — Entitlement errors carry the fix** + +Extend `CDP_RESULT_ERRORS` (`cli.ts:2222`) so each entry names a command, not just a URL: `NOT_SIGNED_IN` → `opera-browser-cli login`; `CONSENT_REQUIRED` → open the consent surface in the headed window. + +--- + +### P4 — Caller contract ✅ done +*Fixes F25, F26.* + +**Shipped** via the `formatError` hook `runAxiCli` already exposes. Required a new +`AUTH_REQUIRED` error code — entitlement failures were previously `BROWSER_ERROR`, which is +indistinguishable from a browser fault and would have collapsed exit codes 3 and 4. + +**P4.1 — Exit codes** + +| Code | Meaning | Caller action | +|---|---|---| +| 0 | Success | — | +| 1 | Unknown / internal | Report | +| 2 | `VALIDATION_ERROR` — bad arguments | Fix the command | +| 3 | Environment not ready (bridge/browser/MCP) after auto-recovery | Run `doctor` | +| 4 | Auth or entitlement (`login`, subscription, consent) | Ask the user | +| 5 | `TIMEOUT` | Retry | +| 6 | `REF_NOT_FOUND` / `PAGE_CLOSED` — stale page state | Re-snapshot and retry | + +Mapped centrally from `ErrorCode` in the top-level error handler. Documented in `README.md` and `SKILL.md` so agents can branch on it. + +**P4.2 — Transient retry** + +Retry once, after a 250 ms delay, for known-transient CDP failures (element detached, execution context destroyed, target crashed during navigation) — with the retry recorded so it shows in `logs` (fixes F26). + +--- + +### P5 — Diagnosis and repair ✅ done + +**P5.1 — `doctor --fix`** + +Every check that currently prints advice gains a repair action: + +| Check | Repair | +|---|---| +| stale PID file | remove it | +| dead/unhealthy bridge | restart | +| version skew | restart | +| missing config | run P1.1 autoconfigure | +| unknown config keys | report (no auto-edit) | +| oversized log | rotate | +| missing MCP binary | print install command (no auto-install) | + +**P5.2 — Richer `doctor` checks** + +Add: MCP binary resolution, browser capability probe, profile-lock state, port scan (what is on 9225–9234), Node version, package version vs. running bridge version. + +**P5.3 — `logs --follow` and `logs --errors`** + +Tail mode and a filter for the failure lines that actually matter, so the common debugging step is one command. + +--- + +### P6 — Test matrix + +New tests, mirroring the existing `test/*.test.ts` layout: + +| File | Covers | +|---|---| +| `test/bridge-lifecycle.test.ts` | version skew → restart; recycled PID → no signal sent; stale lock stolen after 60 s; EADDRINUSE → clean exit 75 | +| `test/bridge-concurrency.test.ts` | N=5 concurrent `ensureBridge()` → exactly one spawn, five successes | +| `test/port-fallback.test.ts` | foreign server on 9225 → bridge lands on 9226 | +| `test/recovery.test.ts` | bridge killed mid-session → next `callTool` succeeds; `opera_do` is *not* silently replayed | +| `test/first-run.test.ts` | empty `$HOME` + detected binary → config written, command succeeds, no TTY | +| `test/profile-lock.test.ts` | `SingletonLock` present → attach or fall back, never a raw launch error | +| `test/exit-codes.test.ts` | each `ErrorCode` → its documented exit code | + +Plus a manual pre-release checklist (real browser, real account) for the states that cannot be faked: signed-out, no subscription, consent pending, Opera already running, browser closed mid-command. + +--- + +## 5. Sequencing + +| Milestone | Contents | Why this order | +|---|---|---| +| ~~**M1 — Bridge never needs a human**~~ ✅ | P0.1–P0.8 | Removes the documented manual recovery procedure; every later workstream depends on a trustworthy bridge. | +| ~~**M2 — First run needs no setup**~~ ✅ | P1.1–P1.4 | Turns the largest new-user cliff into a no-op. (P2.4 shipped with M3.) | +| ~~**M3 — Browser conflicts resolve themselves**~~ ✅ | P2.1–P2.4 | The most common everyday failure once M1/M2 land. | +| ~~**M4 — Auth is one command**~~ ✅ | P3.1–P3.3 | Depends on M3's headed, capability-probed session. | +| ~~**M5 — Contract and repair**~~ ✅ | P4, P5 | Polish; makes the remaining failures self-service. | +| ~~**M6 — Test matrix**~~ ✅ | P6 | Written alongside each milestone, not after. | + +M1 and M2 together cover S1–S5, S8, S10 — the majority of the success criteria — and are independently shippable. + +--- + +## 6. Risks and trade-offs + +| Risk | Mitigation | +|---|---| +| **Headed-by-default (P1.3)** changes behaviour for existing headless CI users. | Honour `OPERA_CLI_HEADED=0`; announce in `CHANGELOG.md`; keep `--headless` documented in `TOP_HELP`. | +| **Restarting the user's browser (P2.2)** interrupts what they were doing. | Never without consent: interactive prompt, or an explicit `--takeover` flag for scripted callers. Session restore returns the tabs. | +| **Live-profile CDP port (P2.2a)** exposes a fully logged-in browser to any local process. | Random per-launch port, loopback bind, no `--remote-allow-origins`, torn down with the browser, documented plainly. Inherent to attaching at all — not fixable by our bearer token. | +| **Auto-restart (P0.5)** could mask a genuine crash loop. | Cap at one restart per invocation; count restarts in `bridge.log`; surface repeated restarts as a `doctor` `fail`. | +| **Port fallback (P0.3)** breaks anything hardcoding 9225. | The PID file remains the source of truth for the port; document that callers must read it, not assume. | +| **Capability probe (P3.2)** adds latency to the first AI command. | Probe once at bridge start, cache on the bridge, expose via `/health`. | +| **Retry (P0.5, P4.2)** could double a side effect. | Explicit allowlist: reads and navigation replay; `opera_do` / `opera_make` / `opera_research` never do. | + +--- + +## 7. Out of scope + +- Linux support for Opera Neon (`neonCandidatePaths` returns `[]` — Neon does not ship for Linux). +- Windows process-group teardown (`bridge.ts:664-671` uses `process.kill(-pid)`, a POSIX construct). Worth a follow-up spec if Windows becomes a supported target. +- The parallel streaming work already tracked in [`fix-parallel-streaming-routing.md`](fix-parallel-streaming-routing.md) and [`fix-streaming-timeout-and-cleanup.md`](fix-streaming-timeout-and-cleanup.md). diff --git a/src/bridge.ts b/src/bridge.ts index 11b4e33..7c55689 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -19,6 +19,7 @@ import type { TransportSendOptions } from "@modelcontextprotocol/sdk/shared/tran import { LoggingMessageNotificationSchema } from "@modelcontextprotocol/sdk/types.js"; import { createServer, + request, type IncomingMessage, type Server, type ServerResponse, @@ -29,6 +30,12 @@ import { extractPageOrigin } from "./snapshot.js"; import { createRequire } from "node:module"; import { dirname, join, resolve } from "node:path"; import { homedir } from "node:os"; +import { + BRIDGE_SERVER_NAME, + computeBootMinute, + type BridgeHealth, +} from "./identity.js"; +import { getPackageVersion } from "./version.js"; const DEFAULT_PORT = Number.parseInt( process.env.OPERA_CLI_PORT ?? "9225", @@ -104,6 +111,65 @@ export async function isBridgeClientConnected( } } +/** + * "Usable" health for the bridge: the MCP server is up AND, when the bridge is + * in attach mode, the browser at the attach URL is actually reachable. + * + * In attach mode devtools-mcp stays connected over stdio even when the browser + * it points at is gone (it reaches for the browser lazily on a tool call), so + * MCP liveness alone cannot tell that a bridge is wedged on a dead URL. Probing + * the attach endpoint directly keeps `/health` honest, which lets the CLI stop + * reusing a wedged bridge and rebuild it against the current target. + */ +export async function isBridgeHealthConnected( + client: BridgeClient, +): Promise { + if (!(await isBridgeClientConnected(client))) return false; + const browserUrl = process.env.OPERA_CLI_BROWSER_URL; + if (browserUrl) { + return await probeHttp(`${browserUrl}/json/version`, 800); + } + return true; +} + +/** GET a URL, answering whether it looks like a live CDP endpoint. */ +function probeHttp(url: string, timeoutMs: number): Promise { + return new Promise((resolve) => { + let req; + try { + req = request(url, { method: "GET", timeout: timeoutMs }, (res) => { + let body = ""; + res.setEncoding("utf-8"); + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => { + try { + resolve( + typeof (JSON.parse(body) as { Browser?: unknown }).Browser === + "string", + ); + } catch { + resolve(false); + } + }); + }); + } catch { + // Malformed browser URL — treat as unreachable, never crash the health ping. + resolve(false); + return; + } + req.on("error", () => resolve(false)); + req.on("timeout", () => { + req.destroy(); + resolve(false); + }); + req.end(); + }); +} + +/** Wall-clock start of this bridge process — part of its identity. */ +const STARTED_AT = Date.now(); +const BOOT_MINUTE = computeBootMinute(); + function writePidFile(port: number, token: string): void { mkdirSync(STATE_DIR, { recursive: true }); // Unlink first: writeFileSync's `mode` only applies on create, so overwriting @@ -114,9 +180,20 @@ function writePidFile(port: number, token: string): void { // Didn't exist — fine } // 0600: only the owning user may read the auth token. - writeFileSync(PID_FILE, JSON.stringify({ pid: process.pid, port, token }), { - mode: 0o600, - }); + // version/startedAt/bootMinute let a CLI process verify this file describes + // *our* bridge on *this* boot before it ever signals the PID. + writeFileSync( + PID_FILE, + JSON.stringify({ + pid: process.pid, + port, + token, + version: getPackageVersion(), + startedAt: STARTED_AT, + bootMinute: BOOT_MINUTE, + }), + { mode: 0o600 }, + ); } function removePidFile(): void { @@ -181,6 +258,51 @@ export function resolveBridgeScript(importMetaDir: string): string { const sourceScript = builtScript.replace(/\.js$/, ".ts"); return existsSync(sourceScript) ? sourceScript : builtScript; } + +export type BridgeLauncher = + | { ok: true; command: string; args: string[] } + | { ok: false; reason: string }; + +/** Locate the tsx CLI entrypoint without going through `npx`. */ +function resolveTsxCli(): string | null { + try { + const require = createRequire(import.meta.url); + const pkgPath = require.resolve("tsx/package.json"); + const cli = join(dirname(pkgPath), "dist", "cli.mjs"); + return existsSync(cli) ? cli : null; + } catch { + return null; + } +} + +/** + * Decide how to launch the bridge process. + * + * Prefers the built JavaScript. The TypeScript entrypoint is used only in a + * source checkout (or under OPERA_CLI_DEV=1), and only when tsx is already + * installed — never via `npx`, which blocks on an install prompt when the + * package is uncached and would hang invisibly behind a redirected stdio. + */ +export function resolveBridgeLauncher( + importMetaDir: string, + execPath: string = process.execPath, +): BridgeLauncher { + const builtScript = resolve( + importMetaDir, + "../bin/opera-browser-cli-bridge.js", + ); + const sourceScript = builtScript.replace(/\.js$/, ".ts"); + const preferSource = + process.env.OPERA_CLI_DEV === "1" || !existsSync(builtScript); + + if (preferSource && existsSync(sourceScript)) { + const tsx = resolveTsxCli(); + if (tsx === null) return { ok: false, reason: "tsx-not-installed" }; + return { ok: true, command: execPath, args: [tsx, sourceScript] }; + } + if (!existsSync(builtScript)) return { ok: false, reason: "bridge-not-built" }; + return { ok: true, command: execPath, args: [builtScript] }; +} async function readRequestBody(req: IncomingMessage): Promise { let body = ""; for await (const chunk of req) { @@ -286,6 +408,18 @@ export function generateBridgeToken(): string { return randomBytes(32).toString("hex"); } +export function buildHealth(connected: boolean): BridgeHealth { + return { + status: connected ? "ok" : "not-connected", + server: BRIDGE_SERVER_NAME, + version: getPackageVersion(), + pid: process.pid, + startedAt: STARTED_AT, + bootMinute: BOOT_MINUTE, + browser: { connected }, + }; +} + async function handleToolsRequest( client: BridgeClient, res: ServerResponse, @@ -392,13 +526,12 @@ export async function handleBridgeRequest( return; } - // /health is token-free so the CLI can detect a running bridge before it knows the token. + // /health is token-free so the CLI can detect a running bridge before it knows + // the token. It carries the full identity (version, pid, boot) so the caller + // can tell a usable bridge from a stale-version one from a foreign server. if (req.method === "GET" && req.url === "/health") { - if (await isBridgeClientConnected(client)) { - writeJson(res, 200, { status: "ok", server: "opera-browser-cli" }); - } else { - writeJson(res, 503, { status: "not-connected", server: "opera-browser-cli" }); - } + const connected = await isBridgeHealthConnected(client); + writeJson(res, connected ? 200 : 503, buildHealth(connected)); return; } @@ -436,12 +569,28 @@ export async function handleBridgeRequest( writeJson(res, 404, { error: "not found" }); } +/** + * Build the HTTP server. + * + * `resolve` is called per request rather than the client being captured up + * front, so the port can be bound before the MCP connection exists. Until it + * returns a client every route answers 503 with a well-formed health body — + * which is exactly what a caller probing /health during startup should see. + */ export function createBridgeServer( - client: BridgeClient, - captureNextId?: () => Promise, + resolve: () => { + client: BridgeClient | null; + captureNextId?: () => Promise; + }, token: string | null = null, ): Server { return createServer((req, res) => { + const { client, captureNextId } = resolve(); + if (client === null) { + res.setHeader("Content-Type", "application/json"); + writeJson(res, 503, buildHealth(false)); + return; + } void handleBridgeRequest(client, req, res, captureNextId, token); }); } @@ -450,10 +599,49 @@ function logBridgeMessage(message: string): void { process.stderr.write(`[opera-browser-cli] ${message}\n`); } +// --------------------------------------------------------------------------- +// Startup handshake +// +// The parent spawns us with stdout on a pipe and reads exactly one line: +// READY — listening, MCP connected, PID file written +// FAILED — fatal, with a machine-readable reason +// +// Without this the parent can only poll /health blind, which costs a full +// timeout window even when we died in milliseconds. stderr goes to the log +// file, so stdout carries nothing but this handshake. +// --------------------------------------------------------------------------- + +/** Exit code signalling "port taken, try the next one" (EX_TEMPFAIL). */ +export const EXIT_PORT_IN_USE = 75; + function writeReadySignal(): void { process.stdout.write("READY\n"); } +function writeFailedSignal(reason: string, detail?: string): void { + process.stdout.write(`FAILED ${reason}${detail ? ` ${detail}` : ""}\n`); +} + +/** + * Whether to launch a visible browser. + * + * Headless is the safe default for a tool — it is the only thing that works on + * a server with no display. But a configured Opera binary means the user wants + * *their* browser, and every Opera AI feature needs a window: sign-in and + * consent cannot be completed headlessly, so a headless AI command fails on a + * state the user has no way to fix. + * + * So: headed when an Opera executable is configured, headless otherwise, and + * OPERA_CLI_HEADED=0 or =1 overrides either way. A machine with no Opera + * installed — CI, Docker, a plain-Chrome setup — keeps the old behaviour. + */ +export function shouldRunHeaded(): boolean { + const explicit = process.env.OPERA_CLI_HEADED; + if (explicit === "1") return true; + if (explicit === "0") return false; + return Boolean(process.env.OPERA_CLI_EXECUTABLE_PATH); +} + export function buildTransportArgs(): string[] { const args: string[] = []; @@ -487,7 +675,7 @@ export function buildTransportArgs(): string[] { } else { args.push("--isolated"); } - if (process.env.OPERA_CLI_HEADED !== "1") { + if (!shouldRunHeaded()) { args.push("--headless"); } } @@ -502,6 +690,45 @@ export function buildTransportArgs(): string[] { return args; } +export interface McpBinStatus { + bin: string; + found: boolean; + source: "env" | "dependency" | "path"; +} + +/** Look a bare command up on PATH, the way a shell would. */ +function existsOnPath(command: string): boolean { + const pathVar = process.env.PATH ?? ""; + const separator = process.platform === "win32" ? ";" : ":"; + const extensions = + process.platform === "win32" + ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") + : [""]; + for (const entry of pathVar.split(separator)) { + if (!entry) continue; + for (const ext of extensions) { + if (existsSync(join(entry, command + ext))) return true; + } + } + return false; +} + +/** + * Where opera-devtools-mcp is coming from, and whether it is actually there. + * Used by `doctor` so a missing MCP server is named before it costs a failed + * bridge start. + */ +export function resolveMcpBinStatus(): McpBinStatus { + const bin = resolveOperaMcpBin(); + if (process.env.OPERA_CLI_MCP_BIN) { + return { bin, found: existsSync(bin) || existsOnPath(bin), source: "env" }; + } + if (bin === "opera-devtools-mcp") { + return { bin, found: existsOnPath(bin), source: "path" }; + } + return { bin, found: existsSync(bin), source: "dependency" }; +} + function resolveOperaMcpBin(): string { if (process.env.OPERA_CLI_MCP_BIN) return process.env.OPERA_CLI_MCP_BIN; try { @@ -622,19 +849,71 @@ async function closeServer(server: Server): Promise { } export async function runBridge(port = DEFAULT_PORT): Promise { + // The parent destroys its end of the stdout pipe once the handshake is read. + // We never write to stdout again, but guard anyway so a stray write can never + // take the bridge down with an EPIPE. + process.stdout.on("error", () => {}); + + // Bind the port before anything expensive happens. Connecting to MCP first + // would launch an entire browser only to throw it away when the listen + // fails — so losing a start race would cost a browser launch and leave an + // orphaned child. Binding first makes losing the race free. + let client: BridgeClient | null = null; + let captureNextId: (() => Promise) | undefined; + const token = generateBridgeToken(); + const server = createBridgeServer(() => ({ client, captureNextId }), token); + + // Without an error handler this is an uncaught exception: the bridge dies + // with a stack trace in the log and the parent waits out the whole startup + // timeout. EADDRINUSE is routine — we lost a start race, or the port was + // taken between the parent's probe and our listen — so exit with a code the + // parent can read as "try the next port". + server.on("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE") { + writeFailedSignal("port-in-use", String(port)); + logBridgeMessage(`Port ${port} already in use`); + process.exit(EXIT_PORT_IN_USE); + } + writeFailedSignal("listen-failed", getErrorMessage(error)); + logBridgeMessage(`Listen failed: ${getErrorMessage(error)}`); + process.exit(1); + }); + + await new Promise((resolve) => { + server.listen(port, "127.0.0.1", resolve); + }); + logBridgeMessage(`Listening on http://127.0.0.1:${port}`); + const transport = createTransport(); - const captureNextId = wrapTransportForIdCapture(transport); - const client = createBridgeClient(); - await client.connect(transport); + captureNextId = wrapTransportForIdCapture(transport); + const mcpClient = createBridgeClient(); + try { + await mcpClient.connect(transport); + } catch (error) { + // Almost always a missing or broken opera-devtools-mcp. Name it now rather + // than letting the parent time out with nothing to report. + writeFailedSignal("mcp-connect", getErrorMessage(error)); + logBridgeMessage( + `Failed to connect to opera-devtools-mcp: ${getErrorMessage(error)}`, + ); + process.exit(1); + } + client = mcpClient; logBridgeMessage("Connected to opera-devtools-mcp"); - const token = generateBridgeToken(); - const server = createBridgeServer(client, captureNextId, token); - server.listen(port, "127.0.0.1", () => { + try { writePidFile(port, token); - logBridgeMessage(`Listening on http://127.0.0.1:${port}`); - writeReadySignal(); - }); + } catch (error) { + // Typically a state dir left root-owned by an earlier `sudo` run. This used + // to throw uncaught from inside the listen callback, killing the bridge + // *after* it had bound the port. + writeFailedSignal("state-dir-unwritable", STATE_DIR); + logBridgeMessage( + `Cannot write ${PID_FILE}: ${getErrorMessage(error)} — check ownership of ${STATE_DIR}`, + ); + process.exit(1); + } + writeReadySignal(); let shuttingDown = false; const shutdown = async () => { @@ -642,7 +921,7 @@ export async function runBridge(port = DEFAULT_PORT): Promise { shuttingDown = true; removePidFile(); await closeServer(server); - await client.close(); + await mcpClient.close(); await transport.close(); process.exit(0); }; diff --git a/src/browser-target.ts b/src/browser-target.ts new file mode 100644 index 0000000..f273323 --- /dev/null +++ b/src/browser-target.ts @@ -0,0 +1,214 @@ +/** + * Decide what browser the bridge should talk to, before it starts. + * + * The constraint that shapes all of this: --remote-debugging-port is a + * startup-only flag. A browser the user opened normally cannot be attached to, + * ever. So there is no way to "connect to the Opera that is already open" — + * only ways to arrange that the open Opera was started with a port in the first + * place, and a way to detect it when it was. + * + * That gives three states for a configured profile: + * + * free → let opera-devtools-mcp launch it, as before. + * locked, debug port live → attach. No prompt, no restart, nothing to do. + * locked, no debug port → a conflict only the user can resolve, by + * letting us restart their browser. + * + * The second case is the one that makes this feel automatic: once a browser has + * been started with a port — by us, or by the user following `launch-args` — + * every later command finds it on its own via DevToolsActivePort. + */ + +import { spawn } from "node:child_process"; +import { existsSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { + findAttachableEndpoint, + inspectProfileLock, + probeDevToolsEndpoint, + readDevToolsPort, + type ProfileLock, +} from "./profile.js"; + +export interface BrowserTargetContext { + browserUrl?: string | undefined; + userDataDir?: string | undefined; + executablePath?: string | undefined; +} + +export type BrowserTarget = + /** Connect to a browser that is already running. */ + | { mode: "attach"; url: string; note: string } + /** Let opera-devtools-mcp launch the browser, as it always has. */ + | { mode: "managed"; note: string } + /** The profile is in use and we cannot reach the browser holding it. */ + | { mode: "conflict"; userDataDir: string; lock: ProfileLock }; + +export async function resolveBrowserTarget( + ctx: BrowserTargetContext, +): Promise { + // An explicit browser URL is the user telling us they manage the browser. + if (ctx.browserUrl) { + return { mode: "attach", url: ctx.browserUrl, note: "OPERA_CLI_BROWSER_URL" }; + } + + // No persistent profile means an isolated one, which nothing else can hold. + if (!ctx.userDataDir) { + return { mode: "managed", note: "isolated profile" }; + } + + // A live debug port wins outright: the browser is running and reachable, so + // there is no conflict to resolve regardless of what the lock says. + const attachable = await findAttachableEndpoint(ctx.userDataDir); + if (attachable !== null) { + return { + mode: "attach", + url: attachable.url, + note: `running ${attachable.identity.browser}`, + }; + } + + const lock = inspectProfileLock(ctx.userDataDir); + if (lock.state === "free") { + return { mode: "managed", note: "profile is free" }; + } + return { mode: "conflict", userDataDir: ctx.userDataDir, lock }; +} + +// --------------------------------------------------------------------------- +// Takeover +// --------------------------------------------------------------------------- + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +export interface QuitResult { + ok: boolean; + reason?: "no-pid" | "timeout"; +} + +/** + * Ask a running browser to quit, and wait for it to let go of the profile. + * + * SIGTERM only. Chromium treats it as a clean shutdown — session saved, profile + * flushed — whereas SIGKILL risks a corrupted profile and loses the user's + * tabs. If it will not go, we say so rather than escalating: this is somebody's + * browser, and forcing it is not ours to decide. + */ +export async function quitBrowser( + lock: ProfileLock, + userDataDir: string, + timeoutMs = 20_000, +): Promise { + if (lock.pid === null) return { ok: false, reason: "no-pid" }; + + try { + process.kill(lock.pid, "SIGTERM"); + } catch { + // Already gone between inspection and now — that is a success. + return { ok: true }; + } + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await sleep(250); + if (inspectProfileLock(userDataDir).state === "free") return { ok: true }; + } + return { ok: false, reason: "timeout" }; +} + +export interface LaunchResult { + ok: boolean; + url?: string; + reason?: "no-executable" | "spawn-failed" | "timeout"; + detail?: string; +} + +/** + * Start a browser we can attach to, and that outlives us. + * + * `--remote-debugging-port=0` has Chromium pick a free port itself and record + * it in DevToolsActivePort. That satisfies two requirements at once: we never + * squat a predictable port like 9222, and the port is discoverable by every + * later command without being written to any config. + * + * The browser is detached deliberately. Having just restarted the user's + * browser, closing it again when the CLI's bridge stops would be a poor trade. + */ +export async function launchAttachableBrowser( + executablePath: string | undefined, + userDataDir: string, + extraArgs: string[] = [], + timeoutMs = 30_000, +): Promise { + if (!executablePath || !existsSync(executablePath)) { + return { ok: false, reason: "no-executable" }; + } + + // Chromium rewrites this on startup, but clearing it first means a stale port + // from a previous run can never be mistaken for the new browser's. + const portFile = join(userDataDir, "DevToolsActivePort"); + try { + unlinkSync(portFile); + } catch { + // Absent already — fine. + } + + const args = [ + "--remote-debugging-port=0", + // Explicit even though it is the default: the debug port must never be + // reachable from off-box. + "--remote-debugging-address=127.0.0.1", + `--user-data-dir=${userDataDir}`, + // We just took their browser away; give the tabs back. + "--restore-last-session", + ...extraArgs, + ]; + + let child; + try { + child = spawn(executablePath, args, { stdio: "ignore", detached: true }); + } catch (error) { + return { + ok: false, + reason: "spawn-failed", + detail: error instanceof Error ? error.message : String(error), + }; + } + child.unref(); + + let spawnError: string | null = null; + child.on("error", (error) => { + spawnError = error.message; + }); + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (spawnError !== null) { + return { ok: false, reason: "spawn-failed", detail: spawnError }; + } + const port = readDevToolsPort(userDataDir); + if (port !== null && (await probeDevToolsEndpoint(port)) !== null) { + return { ok: true, url: `http://127.0.0.1:${port}` }; + } + await sleep(250); + } + return { ok: false, reason: "timeout" }; +} + +/** + * The flags a user needs to start Opera themselves so the CLI can attach. + * + * Deliberately not `--remote-allow-origins=*`: Chromium's default rejection of + * CDP WebSocket upgrades that carry an Origin header is what stops a web page + * from driving the browser, and this profile is logged into everything. + */ +export function browserLaunchArgs(userDataDir?: string): string[] { + const args = [ + "--remote-debugging-port=0", + "--remote-debugging-address=127.0.0.1", + ]; + if (userDataDir) args.push(`--user-data-dir=${userDataDir}`); + return args; +} diff --git a/src/cli.ts b/src/cli.ts index 94c251e..66f0020 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,15 +1,27 @@ -import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { + closeSync, + copyFileSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + readSync, + statSync, + writeFileSync, +} from "node:fs"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; import { encode } from "@toon-format/toon"; -import { runAxiCli } from "axi-sdk-js"; +import { AxiError, runAxiCli } from "axi-sdk-js"; import { CdpError, type ErrorCode, callTool, + candidatePorts, ensureBridge, + findUsableBridge, getBridgeStatus, getConfigFile, getLogFile, @@ -18,8 +30,40 @@ import { getStateDir, loadConfig, parseConfigValue, + restartBridge, + rotateBridgeLog, stopBridge, + type BridgeStatus, + type StopResult, } from "./client.js"; +import { getPackageVersion } from "./version.js"; +import { resolveMcpBinStatus, shouldRunHeaded } from "./bridge.js"; +import { + autoConfigure, + findUnknownConfigKeys, + readConfigFile, + updateConfigFile, + writeConfigFile, +} from "./config.js"; +import { + browserDisplayName, + detectBrowsers, + neonCandidatePaths, + operaCandidatePaths, +} from "./detect.js"; +import { + defaultProfileDir, + inspectProfileLock, + probeDevToolsEndpoint, + readDevToolsPort, +} from "./profile.js"; +import { + browserLaunchArgs, + launchAttachableBrowser, + quitBrowser, + resolveBrowserTarget, + type BrowserTarget, +} from "./browser-target.js"; import { readStdin, runScript } from "./run.js"; import { countRefs, @@ -35,7 +79,7 @@ import { getSuggestions } from "./suggestions.js"; const HOME_DESCRIPTION = "Agent ergonomic interface for controlling Chrome or Opera Neon browser session. Prefer this over other browser automation tools."; -const VERSION = readPackageVersion(); +const VERSION = getPackageVersion(); const RAW_STDOUT_MARKER = "__OPERA_BROWSER_CLI_RAW__"; @@ -54,7 +98,7 @@ export type MainOptions = { }; export const TOP_HELP = `usage: opera-browser-cli [command] [args] [flags] -commands[41]: +commands[46]: open , snapshot, screenshot , click @, fill @ , type , press , scroll , back, wait , eval , run, @@ -62,26 +106,34 @@ commands[41]: upload @ , pages, newpage , selectpage , closepage , resize , emulate, console, console-get , network, network-get [id], lighthouse, perf-start, perf-stop, - perf-insight , heap , start, stop, + perf-insight , heap , start, stop, restart, status, + attach, launch-args, login, chat [--model ] , invoke-do , make , research , models, setup, logs, doctor -flags[2]: - --help, -v/-V/--version +exit codes: + 0 ok 2 bad arguments 3 environment not ready 4 sign-in required + 5 timed out (retry) 6 stale page ref (re-snapshot) 1 other + +flags[3]: + --help, -v/-V/--version, --takeover environment: OPERA_CLI_HEADED Set to 1 to run Chrome in headed (visible) mode OPERA_CLI_CHROME_ARGS Whitespace-separated Chrome flags forwarded to the browser (no shell-style quoting; flags with spaces are not supported) e.g. "--enable-gpu --ignore-gpu-blocklist" - OPERA_CLI_PORT Bridge server port (default: 9225) + OPERA_CLI_PORT Base bridge port (default: 9225); the next 9 ports are + tried in turn if it is occupied OPERA_CLI_BROWSER_URL Connect to an existing Chrome instance instead of launching one e.g. "http://127.0.0.1:9222" OPERA_CLI_USER_DATA_DIR Persistent Chrome profile directory (skips --isolated mode) e.g. "/path/to/.chrome-profile" OPERA_CLI_EXECUTABLE_PATH Path to a custom browser binary (e.g. Opera Neon) OPERA_CLI_ENABLE_HOOKS Set to 1 to auto-install session hooks on startup + OPERA_CLI_TAKEOVER Set to 1 to allow restarting a running Opera without + asking (same as the --takeover flag) Environment variables can also be set in ~/.opera-browser-cli/config (KEY=VALUE, one per line). Run \`opera-browser-cli setup\` to configure interactively. @@ -312,11 +364,53 @@ examples: opera-browser-cli start`, stop: `usage: opera-browser-cli stop -Stop the bridge server and close the browser. +Stop the bridge server and close the browser. Escalates to SIGKILL if the +bridge ignores the shutdown signal, and clears a stale pid file if one is left. examples: opera-browser-cli stop`, + restart: `usage: opera-browser-cli restart +Stop the bridge and start a fresh one. Rarely needed — the bridge restarts +itself on version skew or a dropped connection — but useful after changing +configuration, or to force a clean state. + +examples: + opera-browser-cli restart`, + + attach: `usage: opera-browser-cli attach [--port ] [--clear] +Connect to a browser that is already running, instead of launching one. + +The browser must have been started with a debugging port — that flag cannot be +added to a browser that is already open. Run \`opera-browser-cli launch-args\` +for the flags. With no --port, the port recorded by the configured profile is +used, which is usually what you want. + +Saves OPERA_CLI_BROWSER_URL to ~/.opera-browser-cli/config. + +flags: + --port DevTools debugging port to connect to + --clear Stop attaching; go back to a CLI-launched browser + +examples: + opera-browser-cli attach + opera-browser-cli attach --port 9222 + opera-browser-cli attach --clear`, + + "launch-args": `usage: opera-browser-cli launch-args +Print the command to start Opera so opera-browser-cli can attach to it, keeping +your real profile and all its logins. + +examples: + opera-browser-cli launch-args`, + + status: `usage: opera-browser-cli status +Report bridge state without starting one: pid, port, and the running version +against the installed version. + +examples: + opera-browser-cli status`, + // Page management pages: `usage: opera-browser-cli pages List all open pages/tabs in the browser. @@ -630,13 +724,35 @@ examples: opera-browser-cli logs opera-browser-cli logs --lines 200`, - doctor: `usage: opera-browser-cli doctor + doctor: `usage: opera-browser-cli doctor [--fix] Diagnose opera-browser-cli configuration: bridge status, config file, Opera Neon -executable, session hooks, and log file. Each check is reported as ok, warn, -or fail with actionable hints. +executable, MCP server, browser profile, session hooks, and log file. Each check +is reported as ok, warn, or fail with actionable hints. + +--fix repairs what can be repaired mechanically — a stale pid file, an unhealthy +bridge, a missing config, an oversized log. Anything needing a decision (an +install, a config edit) is reported, not done for you. + +flags: + --fix Apply repairs, then re-run the checks examples: - opera-browser-cli doctor`, + opera-browser-cli doctor + opera-browser-cli doctor --fix`, + + login: `usage: opera-browser-cli login [--check] +Sign in to your Opera account, which Opera AI commands require. + +Opens the account page in a visible browser window and waits for you to finish, +then confirms Opera AI answers. Sign-in state is only observable by asking Opera +AI something, so the check costs one small AI call and is never run implicitly. + +flags: + --check Only verify the current state; do not open the sign-in page + +examples: + opera-browser-cli login + opera-browser-cli login --check`, }; export function getCommandHelp(command: string): string | null { @@ -913,26 +1029,40 @@ function renderOutput(blocks: string[]): string { return blocks.filter(Boolean).join("\n"); } -function readPackageVersion(): string { - const here = dirname(fileURLToPath(import.meta.url)); - - for (const candidate of [ - join(here, "..", "package.json"), - join(here, "..", "..", "package.json"), - ]) { - if (!existsSync(candidate)) { - continue; - } +/** + * Exit codes, so a caller can branch on *why* something failed without parsing + * the message. Documented in README.md and SKILL.md — treat as a contract. + * + * 2 fix the command 3 environment not ready 4 ask the user + * 5 retry later 6 page state moved; re-snapshot + */ +export const EXIT_CODES: Record = { + VALIDATION_ERROR: 2, + UNSUPPORTED_OPERATION: 2, + BRIDGE_NOT_READY: 3, + BROWSER_ERROR: 3, + AUTH_REQUIRED: 4, + TIMEOUT: 5, + REF_NOT_FOUND: 6, + PAGE_CLOSED: 6, + UNKNOWN: 1, +}; - const parsed = JSON.parse(readFileSync(candidate, "utf-8")) as { - version?: unknown; - }; - if (typeof parsed.version === "string" && parsed.version.length > 0) { - return parsed.version; - } +export function exitCodeForCdpError(error: unknown): number { + if (error instanceof AxiError) { + return EXIT_CODES[error.code as ErrorCode] ?? 1; } + return 1; +} - throw new Error("Could not determine opera-browser-cli package version"); +export function formatCliError(error: unknown): { output: string; exitCode: number } { + const code = error instanceof AxiError ? error.code : "UNKNOWN"; + const message = error instanceof Error ? error.message : String(error); + const suggestions = error instanceof AxiError ? error.suggestions : []; + return { + output: renderError(message, code, suggestions), + exitCode: exitCodeForCdpError(error), + }; } function splitFullFlag(args: string[]): { args: string[]; full: boolean; raw: boolean } { @@ -1130,6 +1260,22 @@ function normalizeUrl(raw: string): string { return `https://${raw}`; } +/** A real page snapshot (vs. "No page selected"). */ +function hasLivePage(snapshot: string): boolean { + return /\bRootWebArea\b/.test(snapshot); +} + +/** The bridge is up but its browser target is dead/unreachable. */ +function isBrowserConnectionFailure(snapshot: string): boolean { + return /could not connect to chrome|failed to fetch browser websocket url/i.test( + snapshot, + ); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + async function handleOpen(args: string[], full: boolean, raw = false): Promise { const url = args[0] ? normalizeUrl(args[0]) : undefined; if (!url) { @@ -1138,23 +1284,72 @@ async function handleOpen(args: string[], full: boolean, raw = false): Promise --takeover`", + "Or use a separate profile (no flag) if the browser cannot be restarted", + ], + ); + } + // All retries exhausted but no page is actually live: navigate_page can + // report success while no tab exists (e.g. a takeover relaunch whose restored + // session never produced one). Never hand back a non-live snapshot as if it + // were a navigated page. + if (!hasLivePage(snapshot)) { + throw new CdpError( + "The browser did not produce a page to navigate to after several attempts.", + "BROWSER_ERROR", + [ + "Run `opera-browser-cli doctor` to check the profile and bridge state", + "Restart the running browser: `opera-browser-cli open --takeover`", + "Or use a separate profile (no flag) if the browser cannot be restarted", + ], + ); + } + return formatPageOutput(snapshot, "open", url, full, raw); +} + +/** Navigate the current page, creating one when there is nothing to navigate. */ +async function openOrCreatePage(url: string): Promise { try { const navResult = await callTool("navigate_page", { type: "url", url }); if (/selected page has been closed/i.test(navResult)) { - needNewPage = true; + await callTool("new_page", { url }); + return true; } + return false; } catch (error) { - if (!isRecoverableOpenError(error)) { - throw error; - } - needNewPage = true; - } - if (needNewPage) { + if (!isRecoverableOpenError(error)) throw error; await callTool("new_page", { url }); + return true; } - const snapshot = stripSnapshotHeader(await callTool("take_snapshot")); - return formatPageOutput(snapshot, "open", url, full, raw); } async function handleSnapshot(full: boolean, raw = false): Promise { @@ -1362,13 +1557,76 @@ async function handleStart(): Promise { return encode({ status: "ready", port }); } -export function formatStopOutput(wasStopped: boolean): string { - return encode({ status: wasStopped ? "stopped" : "stopped (no-op)" }); +export function formatStopOutput(result: StopResult): string { + const status = result.stopped + ? result.forced + ? "stopped (forced)" + : "stopped" + : result.stale + ? "stopped (stale pid file removed)" + : "stopped (no-op)"; + const payload: Record = { status }; + if (result.pid != null) payload.pid = result.pid; + if (result.port != null) payload.port = result.port; + return encode(payload); } async function handleStop(): Promise { - const wasStopped = await stopBridge(); - return formatStopOutput(wasStopped); + return formatStopOutput(await stopBridge()); +} + +async function handleRestart(): Promise { + const port = await restartBridge(); + return encode({ status: "ready", port, version: VERSION }); +} + +export function formatStatusOutput(status: BridgeStatus): string { + if (!status.pidFileExists && !status.processAlive) { + return renderOutput([ + encode({ bridge: "not running", version: status.expectedVersion }), + renderHelp(["Run `opera-browser-cli open ` — the bridge starts automatically"]), + ]); + } + if (status.versionSkew) { + return renderOutput([ + encode({ + bridge: "running (stale version)", + pid: status.pid, + port: status.port, + running: status.runningVersion, + expected: status.expectedVersion, + }), + renderHelp([ + "The next command restarts it automatically", + "Run `opera-browser-cli restart` to do it now", + ]), + ]); + } + if (status.stalePidFile) { + return renderOutput([ + encode({ bridge: "not running", stale_pid: status.pid }), + renderHelp(["Run `opera-browser-cli stop` to clean up the stale pid file"]), + ]); + } + if (!status.healthy) { + return renderOutput([ + encode({ bridge: "unhealthy", pid: status.pid, port: status.port }), + renderHelp([ + "Run `opera-browser-cli restart` to bring it back", + "Run `opera-browser-cli logs` to see why", + ]), + ]); + } + return encode({ + bridge: "ready", + pid: status.pid, + port: status.port, + version: status.runningVersion, + }); +} + +async function handleStatus(): Promise { + return formatStatusOutput(await getBridgeStatus()); } // --- Page management handlers --- @@ -1671,103 +1929,133 @@ async function handleHeap(args: string[]): Promise { * matching profile (Neon vs Neon Developer). */ function defaultNeonProfileDir(neonPath: string | undefined): string | null { - const home = homedir(); - let candidate: string; - if (process.platform === "darwin") { - const isDeveloper = neonPath?.includes("Opera Neon Developer.app") ?? false; - const bundle = isDeveloper - ? "com.operasoftware.OperaNeonDeveloper" - : "com.operasoftware.OperaNeon"; - candidate = `${home}/Library/Application Support/${bundle}`; - } else if (process.platform === "win32") { - const appData = process.env.APPDATA ?? `${home}\\AppData\\Roaming`; - const isDeveloper = neonPath?.includes("Developer") ?? false; - candidate = isDeveloper - ? `${appData}\\Opera Software\\Opera Neon Developer` - : `${appData}\\Opera Software\\Opera Neon`; - } else { - return null; - } - return existsSync(candidate) ? candidate : null; + return defaultProfileDir(neonPath, homedir()); } -function neonCandidatePaths(): string[] { - const home = homedir(); - if (process.platform === "darwin") { - return [ - "/Applications/Opera Neon.app/Contents/MacOS/Opera", - "/Applications/Opera Neon Developer.app/Contents/MacOS/Opera", - `${home}/Applications/Opera Neon.app/Contents/MacOS/Opera`, - `${home}/Applications/Opera Neon Developer.app/Contents/MacOS/Opera`, - ]; - } - if (process.platform === "win32") { - const localAppData = process.env.LOCALAPPDATA ?? `${home}\\AppData\\Local`; - const programFiles = process.env.PROGRAMFILES ?? "C:\\Program Files"; - return [ - `${localAppData}\\Programs\\Opera Neon\\opera.exe`, - `${programFiles}\\Opera Neon\\opera.exe`, - `${localAppData}\\Programs\\Opera Neon Developer\\opera.exe`, - `${programFiles}\\Opera Neon Developer\\opera.exe`, - ]; +export interface SetupArgs { + interactive: boolean; + executable: string | undefined; + profile: string | undefined; + headed: boolean | undefined; +} + +export function parseSetupArgs(args: string[]): SetupArgs { + let interactive = true; + let executable: string | undefined; + let profile: string | undefined; + let headed: boolean | undefined; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case "--non-interactive": + case "--yes": + case "-y": + interactive = false; + break; + case "--executable": + if (i + 1 < args.length) { + executable = args[++i]; + interactive = false; + } + break; + case "--profile": + if (i + 1 < args.length) { + profile = args[++i]; + interactive = false; + } + break; + case "--headed": + headed = true; + interactive = false; + break; + case "--headless": + headed = false; + interactive = false; + break; + } } - // Opera Neon does not ship for Linux. - return []; + return { interactive, executable, profile, headed }; } -function operaCandidatePaths(): string[] { - const home = homedir(); - if (process.platform === "darwin") { - return [ - "/Applications/Opera GX.app/Contents/MacOS/Opera", - "/Applications/Opera.app/Contents/MacOS/Opera", - `${home}/Applications/Opera GX.app/Contents/MacOS/Opera`, - `${home}/Applications/Opera.app/Contents/MacOS/Opera`, - ]; +/** Install SKILL.md for Claude Code and the generic cross-agent path. */ +function installSkillFiles(report: (line: string) => void): void { + const here = dirname(fileURLToPath(import.meta.url)); + const skillSrc = [join(here, "..", "SKILL.md"), join(here, "..", "..", "SKILL.md")].find( + (p) => existsSync(p), + ); + if (!skillSrc) { + report("SKILL.md not found — skipping skill install"); + return; } - if (process.platform === "win32") { - const localAppData = process.env.LOCALAPPDATA ?? `${home}\\AppData\\Local`; - const programFiles = process.env.PROGRAMFILES ?? "C:\\Program Files"; - return [ - `${localAppData}\\Programs\\Opera GX\\opera.exe`, - `${localAppData}\\Programs\\Opera\\opera.exe`, - `${programFiles}\\Opera GX\\opera.exe`, - `${programFiles}\\Opera\\opera.exe`, - ]; + for (const { agent, dir } of [ + { agent: "Claude", dir: join(homedir(), ".claude", "skills") }, + { agent: "generic", dir: join(homedir(), ".agents", "skills") }, + ]) { + const skillDst = join(dir, "opera-browser-cli", "SKILL.md"); + mkdirSync(dirname(skillDst), { recursive: true }); + copyFileSync(skillSrc, skillDst); + report(`Installed ${agent} skill -> ${skillDst}`); } - return []; } -function browserDisplayName(binPath: string): string { - if (binPath.includes("Neon Developer")) return "Opera Neon Developer"; - if (binPath.includes("Neon")) return "Opera Neon"; - if (binPath.includes("GX")) return "Opera GX"; - return "Opera"; -} +/** + * Configure without prompting: detection plus whatever the flags override. + * + * `setup` used to refuse outright without a TTY, which ruled out exactly the + * callers that most need it — agents, provisioning scripts, containers. + */ +function setupNonInteractive(parsed: SetupArgs): string { + const config = readConfigFile(); -async function handleSetup(_args: string[]): Promise { - if (!process.stdin.isTTY) { - throw new CdpError( - "setup requires an interactive terminal", - "VALIDATION_ERROR", - ["Run `opera-browser-cli setup` directly in your shell, not through an agent"], + const executable = + parsed.executable ?? + config.OPERA_CLI_EXECUTABLE_PATH ?? + detectBrowsers(process.platform, homedir())[0]?.path; + if (executable) config.OPERA_CLI_EXECUTABLE_PATH = executable; + + const headed = parsed.headed ?? (config.OPERA_CLI_HEADED === "1" || Boolean(executable)); + if (headed) config.OPERA_CLI_HEADED = "1"; + else delete config.OPERA_CLI_HEADED; + + if (parsed.profile === "skip") { + delete config.OPERA_CLI_USER_DATA_DIR; + } else { + const profile = + parsed.profile ?? + config.OPERA_CLI_USER_DATA_DIR ?? + defaultProfileDir(executable, homedir()) ?? + join(getStateDir(), "profile"); + config.OPERA_CLI_USER_DATA_DIR = profile; + } + + writeConfigFile(config); + const notes: string[] = []; + installSkillFiles((line) => notes.push(line)); + + const help = ["Run `opera-browser-cli open https://example.com` to start browsing"]; + if (!executable) { + help.unshift( + "No Opera installation found — set OPERA_CLI_EXECUTABLE_PATH or pass --executable ", ); } + return renderOutput([ + encode({ config: getConfigFile(), settings: config }), + notes.join("\n"), + renderHelp(help), + ]); +} - const stateDir = join(homedir(), ".opera-browser-cli"); - const configFile = join(stateDir, "config"); - - const existing: Record = {}; - if (existsSync(configFile)) { - for (const line of readFileSync(configFile, "utf-8").split("\n")) { - const t = line.trim(); - if (!t || t.startsWith("#")) continue; - const eq = t.indexOf("="); - if (eq === -1) continue; - existing[t.slice(0, eq).trim()] = parseConfigValue(t.slice(eq + 1).trim()); - } +async function handleSetup(args: string[]): Promise { + const parsed = parseSetupArgs(args); + // No terminal to prompt in is a reason to fall back, not to fail. + if (!parsed.interactive || !process.stdin.isTTY) { + return setupNonInteractive(parsed); } + const stateDir = getStateDir(); + const configFile = getConfigFile(); + const existing = readConfigFile(); + const rl = createInterface({ input: process.stdin, output: process.stdout }); const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); @@ -1778,8 +2066,12 @@ async function handleSetup(_args: string[]): Promise { process.stdout.write("opera-browser-cli setup\n\n"); // 1. Browser executable path - const detectedNeons = neonCandidatePaths().filter((p) => existsSync(p)); - const detectedOpera = operaCandidatePaths().find((p) => existsSync(p)); + const detectedNeons = neonCandidatePaths(process.platform, homedir()).filter( + (p) => existsSync(p), + ); + const detectedOpera = operaCandidatePaths(process.platform, homedir()).find( + (p) => existsSync(p), + ); const currentExec = existing["OPERA_CLI_EXECUTABLE_PATH"]; if (detectedNeons.length > 0) { @@ -1895,38 +2187,9 @@ async function handleSetup(_args: string[]): Promise { rl.close(); } - // Write config - mkdirSync(stateDir, { recursive: true }); - const lines = [ - "# opera-browser-cli configuration — auto-loaded on every run", - "# Values here are used as defaults when the env var is not already set.", - "", - ...Object.entries(config).map(([k, v]) => `${k}="${v.replace(/"/g, '\\"')}"`), - ]; - writeFileSync(configFile, lines.join("\n") + "\n"); - + writeConfigFile(config); process.stdout.write(`\nSaved to ${configFile}\n`); - - // Install SKILL.md as the Claude Code skill, plus the generic - // ~/.agents/skills path that cross-agent tools (Codex, etc.) scan. - const here = dirname(fileURLToPath(import.meta.url)); - const skillSrc = [join(here, "..", "SKILL.md"), join(here, "..", "..", "SKILL.md")].find( - (p) => existsSync(p), - ); - const skillRoots = [ - { agent: "Claude", dir: join(homedir(), ".claude", "skills") }, - { agent: "generic", dir: join(homedir(), ".agents", "skills") }, - ]; - if (skillSrc) { - for (const { agent, dir } of skillRoots) { - const skillDst = join(dir, "opera-browser-cli", "SKILL.md"); - mkdirSync(dirname(skillDst), { recursive: true }); - copyFileSync(skillSrc, skillDst); - process.stdout.write(`Installed ${agent} skill -> ${skillDst}\n`); - } - } else { - process.stdout.write("SKILL.md not found — skipping skill install\n"); - } + installSkillFiles((line) => process.stdout.write(line + "\n")); return renderOutput([ encode({ config: configFile, settings: config }), @@ -1938,6 +2201,90 @@ async function handleSetup(_args: string[]): Promise { ]); } +// --- Attach --- + +export function parseAttachArgs(args: string[]): { port: number | null; clear: boolean } { + let port: number | null = null; + let clear = false; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--port" && i + 1 < args.length) { + const parsed = Number.parseInt(args[++i] ?? "", 10); + if (Number.isInteger(parsed) && parsed > 0) port = parsed; + } else if (args[i] === "--clear") { + clear = true; + } + } + return { port, clear }; +} + +async function handleAttach(args: string[]): Promise { + const { port, clear } = parseAttachArgs(args); + + if (clear) { + updateConfigFile({ OPERA_CLI_BROWSER_URL: null }); + return renderOutput([ + encode({ attach: "cleared" }), + renderHelp(["opera-browser-cli will launch its own browser from now on"]), + ]); + } + + // With no explicit port, look for one the configured profile advertised. + const userDataDir = process.env.OPERA_CLI_USER_DATA_DIR; + const resolved = port ?? (userDataDir ? readDevToolsPort(userDataDir) : null); + if (resolved === null) { + throw new CdpError( + "No debugging port given, and none found for the configured profile", + "VALIDATION_ERROR", + [ + "Run `opera-browser-cli attach --port ` if you know the port", + "Run `opera-browser-cli launch-args` to start Opera with a debugging port", + ], + ); + } + + const identity = await probeDevToolsEndpoint(resolved); + if (identity === null) { + throw new CdpError( + `Nothing is answering DevTools on port ${resolved}`, + "BROWSER_ERROR", + [ + "Check the browser is running and was started with --remote-debugging-port", + "Run `opera-browser-cli launch-args` for the exact flags", + ], + ); + } + + const url = `http://127.0.0.1:${resolved}`; + updateConfigFile({ OPERA_CLI_BROWSER_URL: url }); + + const help = ["Run `opera-browser-cli attach --clear` to go back to a CLI-launched browser"]; + if (!identity.isOpera) { + help.unshift(`Note: ${identity.browser} is not an Opera browser — Opera AI commands will not work`); + } + return renderOutput([ + encode({ attach: url, browser: identity.browser }), + renderHelp(help), + ]); +} + +function handleLaunchArgs(): string { + const execPath = process.env.OPERA_CLI_EXECUTABLE_PATH; + const userDataDir = process.env.OPERA_CLI_USER_DATA_DIR; + const args = browserLaunchArgs(userDataDir); + const binary = execPath ?? "/Applications/Opera Neon.app/Contents/MacOS/Opera"; + const command = [JSON.stringify(binary), ...args.map((a) => JSON.stringify(a))].join(" "); + + return renderOutput([ + encode({ launch: "start Opera with these flags, then run `opera-browser-cli attach`" }), + `command:\n ${command}`, + renderHelp([ + "The port is chosen by the browser and recorded in DevToolsActivePort", + "opera-browser-cli finds it automatically — `attach` is only needed for a different profile", + "A debugging port lets any local process drive this browser; close it when done", + ]), + ]); +} + // --- Doctor --- interface DoctorCheck { @@ -1966,29 +2313,35 @@ async function runDoctorChecks(): Promise { // Bridge const bridge = await getBridgeStatus(); - if (!bridge.pidFileExists) { + if (!bridge.pidFileExists && !bridge.processAlive) { checks.push({ name: "bridge", status: "warn", detail: "not running (will auto-start on first command)", }); - } else if (!bridge.processAlive) { + } else if (bridge.stalePidFile) { checks.push({ name: "bridge", - status: "fail", - detail: `pid ${bridge.pid} in pid file but process is dead`, + status: "warn", + detail: `stale pid file (pid ${bridge.pid} is not a running bridge) — cleared on next start`, + }); + } else if (bridge.versionSkew) { + checks.push({ + name: "bridge", + status: "warn", + detail: `running ${bridge.runningVersion}, installed ${bridge.expectedVersion} — restarts automatically on next command`, }); } else if (!bridge.healthy) { checks.push({ name: "bridge", status: "fail", - detail: `pid ${bridge.pid} alive on port ${bridge.port} but /health did not respond`, + detail: `pid ${bridge.pid} on port ${bridge.port} is not serving a healthy /health`, }); } else { checks.push({ name: "bridge", status: "ok", - detail: `running, pid ${bridge.pid}, port ${bridge.port}`, + detail: `running ${bridge.runningVersion}, pid ${bridge.pid}, port ${bridge.port}`, }); } @@ -2001,14 +2354,27 @@ async function runDoctorChecks(): Promise { detail: `${configFile} not found — run \`opera-browser-cli setup\``, }); } else { - const lines = readFileSync(configFile, "utf-8") - .split("\n") - .filter((l) => l.trim() && !l.trim().startsWith("#")); - checks.push({ - name: "config", - status: "ok", - detail: `${configFile} (${lines.length} var${lines.length === 1 ? "" : "s"} set)`, - }); + const config = readConfigFile(); + const count = Object.keys(config).length; + const unknown = findUnknownConfigKeys(config); + if (unknown.length > 0) { + // A typo'd key is silently ignored at load time and looks perfectly + // correct in the file, so it has to be called out here or never. + const described = unknown + .map((u) => (u.suggestion ? `${u.key} (did you mean ${u.suggestion}?)` : u.key)) + .join(", "); + checks.push({ + name: "config", + status: "warn", + detail: `${configFile} — unrecognised key${unknown.length === 1 ? "" : "s"}: ${described}`, + }); + } else { + checks.push({ + name: "config", + status: "ok", + detail: `${configFile} (${count} var${count === 1 ? "" : "s"} set)`, + }); + } } // Opera Neon executable @@ -2040,6 +2406,70 @@ async function runDoctorChecks(): Promise { }); } + // opera-devtools-mcp — the bridge cannot start without it + const mcp = resolveMcpBinStatus(); + checks.push( + mcp.found + ? { name: "mcp", status: "ok", detail: `${mcp.bin} (${mcp.source})` } + : { + name: "mcp", + status: "fail", + detail: `opera-devtools-mcp not found at ${mcp.bin} (${mcp.source})`, + }, + ); + + // Browser target — launch, or attach to something already running + if (browserUrl) { + const attachPort = Number.parseInt(new URL(browserUrl).port, 10); + const identity = Number.isFinite(attachPort) + ? await probeDevToolsEndpoint(attachPort) + : null; + checks.push( + identity + ? { name: "browser", status: "ok", detail: `attached to ${identity.browser}` } + : { + name: "browser", + status: "fail", + detail: `OPERA_CLI_BROWSER_URL=${browserUrl} is not answering`, + }, + ); + } + + // Profile lock — the usual reason a launch silently fails + const profileDir = process.env.OPERA_CLI_USER_DATA_DIR; + if (!profileDir) { + checks.push({ + name: "profile", + status: "ok", + detail: "isolated (no persistent profile configured)", + }); + } else if (!existsSync(profileDir)) { + checks.push({ + name: "profile", + status: "ok", + detail: `${profileDir} (will be created on first launch)`, + }); + } else { + const lock = inspectProfileLock(profileDir); + const attachable = readDevToolsPort(profileDir); + const live = attachable !== null ? await probeDevToolsEndpoint(attachable) : null; + if (lock.state === "free") { + checks.push({ name: "profile", status: "ok", detail: `${profileDir} (free)` }); + } else if (live) { + checks.push({ + name: "profile", + status: "ok", + detail: `in use by ${live.browser}, attachable on port ${attachable}`, + }); + } else { + checks.push({ + name: "profile", + status: "warn", + detail: `in use${lock.pid ? ` by pid ${lock.pid}` : ""} with no debugging port — a separate profile will be used`, + }); + } + } + // Session hooks const home = homedir(); const claudeSettings = join(home, ".claude", "settings.json"); @@ -2098,7 +2528,61 @@ async function runDoctorChecks(): Promise { return checks; } -async function handleDoctor(_args: string[]): Promise { +/** + * Repair what can be repaired mechanically. Anything needing a decision — an + * install, a config edit — is reported, never done on the user's behalf. + */ +async function runDoctorFixes(checks: DoctorCheck[]): Promise { + const done: string[] = []; + + const bridge = checks.find((c) => c.name === "bridge"); + if (bridge && bridge.status !== "ok") { + if (bridge.detail.includes("stale pid file")) { + await stopBridge(); + done.push("cleared the stale pid file"); + } else if (bridge.detail.includes("not running")) { + // Nothing broken — it starts on demand. + } else { + await restartBridge(); + done.push("restarted the bridge"); + } + } + + if (checks.some((c) => c.name === "config" && c.detail.includes("not found"))) { + const result = autoConfigure(); + if (result.status === "configured") { + done.push(`wrote a config for ${result.browser.name}`); + } + } + + const logs = checks.find((c) => c.name === "logs"); + if (logs && /\d+(\.\d+)? MB/.test(logs.detail)) { + const size = Number.parseFloat(logs.detail.match(/([\d.]+) MB/)?.[1] ?? "0"); + if (size >= 5 && rotateBridgeLog()) done.push("rotated the bridge log"); + } + + return done; +} + +async function handleDoctor(args: string[]): Promise { + if (args.includes("--fix")) { + const applied = await runDoctorFixes(await runDoctorChecks()); + const after = await runDoctorChecks(); + const summary = { + fixed: applied.length, + ok: after.filter((c) => c.status === "ok").length, + warn: after.filter((c) => c.status === "warn").length, + fail: after.filter((c) => c.status === "fail").length, + }; + return renderOutput([ + encode({ doctor: summary }), + applied.length > 0 + ? `fixed[${applied.length}]:\n${applied.map((f) => ` ${f}`).join("\n")}` + : "fixed: nothing needed repairing", + `checks[${after.length}]:\n${after.map((c) => ` ${c.name}: ${c.status} (${c.detail})`).join("\n")}`, + ]); + } + const checks = await runDoctorChecks(); const summary = { ok: checks.filter((c) => c.status === "ok").length, @@ -2119,7 +2603,7 @@ async function handleDoctor(_args: string[]): Promise { ); } if (checks.some((c) => c.name === "bridge" && c.status === "fail")) { - help.push("Run `opera-browser-cli stop` then any command to restart the bridge"); + help.push("Run `opera-browser-cli restart` to bring the bridge back"); help.push("Run `opera-browser-cli logs` to see why the bridge is unhealthy"); } if (checks.some((c) => c.name === "hooks" && c.status !== "ok")) { @@ -2127,6 +2611,19 @@ async function handleDoctor(_args: string[]): Promise { "Run any command with OPERA_CLI_ENABLE_HOOKS=1 to install session hooks", ); } + if (checks.some((c) => c.name === "mcp" && c.status !== "ok")) { + help.push( + "Install the MCP server: `npm install -g opera-devtools-mcp`, or set OPERA_CLI_MCP_BIN", + ); + } + if (checks.some((c) => c.name === "profile" && c.status === "warn")) { + help.push( + "Run `opera-browser-cli launch-args` to restart Opera so the CLI can attach to your real profile", + ); + } + if (checks.some((c) => c.name === "browser" && c.status === "fail")) { + help.push("Run `opera-browser-cli attach --clear` to stop attaching to a dead endpoint"); + } return renderOutput([ encode({ doctor: summary }), @@ -2139,19 +2636,73 @@ async function handleDoctor(_args: string[]): Promise { const LOGS_DEFAULT_LINES = 50; -function parseLogsArgs(args: string[]): { lines: number } { +export function parseLogsArgs(args: string[]): { + lines: number; + follow: boolean; + errorsOnly: boolean; +} { let lines = LOGS_DEFAULT_LINES; + let follow = false; + let errorsOnly = false; for (let i = 0; i < args.length; i++) { if ((args[i] === "-n" || args[i] === "--lines") && i + 1 < args.length) { const parsed = parseInt(args[++i] ?? "", 10); if (Number.isFinite(parsed) && parsed > 0) lines = parsed; + } else if (args[i] === "-f" || args[i] === "--follow") { + follow = true; + } else if (args[i] === "--errors") { + errorsOnly = true; + } + } + return { lines, follow, errorsOnly }; +} + +/** The lines worth looking at when something has gone wrong. */ +const LOG_ERROR_PATTERN = + /error|failed|fatal|exception|refused|denied|timeout|timed out|in use|EADDRINUSE|ECONNREFUSED|EACCES|not found|unauthorized|cannot/i; + +export function filterLogLines(lines: string[], errorsOnly: boolean): string[] { + return errorsOnly ? lines.filter((l) => LOG_ERROR_PATTERN.test(l)) : lines; +} + +/** Stream appended log output until interrupted. */ +async function followLog(errorsOnly: boolean): Promise { + const logFile = getLogFile(); + let offset = existsSync(logFile) ? statSync(logFile).size : 0; + let stop = false; + const onSigint = (): void => { + stop = true; + }; + process.on("SIGINT", onSigint); + try { + while (!stop) { + await new Promise((r) => setTimeout(r, 500)); + if (!existsSync(logFile)) continue; + const size = statSync(logFile).size; + // A rotation shrinks the file; start over from the top of the new one. + if (size < offset) offset = 0; + if (size === offset) continue; + const fd = openSync(logFile, "r"); + try { + const buffer = Buffer.alloc(size - offset); + readSync(fd, buffer, 0, buffer.length, offset); + offset = size; + const fresh = filterLogLines( + buffer.toString("utf-8").split("\n").filter(Boolean), + errorsOnly, + ); + if (fresh.length > 0) process.stdout.write(fresh.join("\n") + "\n"); + } finally { + closeSync(fd); + } } + } finally { + process.off("SIGINT", onSigint); } - return { lines }; } async function handleLogs(args: string[]): Promise { - const { lines } = parseLogsArgs(args); + const { lines, follow, errorsOnly } = parseLogsArgs(args); const logFile = getLogFile(); if (!existsSync(logFile)) { return renderOutput([ @@ -2167,13 +2718,32 @@ async function handleLogs(args: string[]): Promise { if (allLines.length > 0 && allLines[allLines.length - 1] === "") { allLines.pop(); } - const tail = allLines.slice(-lines); + const matched = filterLogLines(allLines, errorsOnly); + const tail = matched.slice(-lines); + + if (follow) { + process.stdout.write( + renderOutput([ + encode({ path: logFile, following: true, errors_only: errorsOnly }), + tail.join("\n"), + ]) + "\n", + ); + await followLog(errorsOnly); + return ""; + } + return renderOutput([ - encode({ path: logFile, lines: tail.length, total: allLines.length }), + encode({ + path: logFile, + lines: tail.length, + total: allLines.length, + ...(errorsOnly ? { matched: matched.length } : {}), + }), tail.join("\n"), renderHelp([ `Run \`opera-browser-cli logs --lines \` to show more (default ${LOGS_DEFAULT_LINES})`, - `Tail live: \`tail -f ${logFile}\``, + "Run `opera-browser-cli logs --errors` to show only failure lines", + "Run `opera-browser-cli logs --follow` to stream new output", ]), ]); } @@ -2188,23 +2758,164 @@ async function handleLogs(args: string[]): Promise { * Skipped when OPERA_CLI_BROWSER_URL is set — the user manages the browser * themselves and presumably knows it's Opera Neon. */ +export type BrowserKind = "neon" | "opera" | "other" | "unknown"; + +/** + * What kind of browser we are about to drive. + * + * The old check only asked whether the configured path existed, which cannot + * tell Neon from Opera from Chrome — so it passed in exactly the two cases that + * fail: a plain Opera (no invoke-do/make/research) and a non-Opera browser + * (no Opera AI at all). Attached browsers report their real identity; launched + * ones are identified by their build, which is how Opera names its binaries. + */ +export function classifyBrowser( + executablePath: string | undefined, + attachedBrowser?: string | undefined, +): BrowserKind { + if (attachedBrowser) { + if (/neon/i.test(attachedBrowser)) return "neon"; + if (/opera|opr\//i.test(attachedBrowser)) return "opera"; + return "other"; + } + if (!executablePath) return "unknown"; + if (/neon/i.test(executablePath)) return "neon"; + if (/opera/i.test(executablePath)) return "opera"; + return "other"; +} + +const NEON_ONLY_HELP = [ + "Install Opera Neon from https://www.operaneon.com", + "Run `opera-browser-cli setup` to point at it", + "Run `opera-browser-cli doctor` to inspect the current configuration", +]; + +/** + * Fail fast for commands that need Opera Neon, so we do not pay a browser + * launch to surface a confusing protocol error. + */ function requireNeon(command: string): void { + // An explicitly attached browser is identified for real by `doctor`; here we + // trust the user to know what they pointed us at. if (process.env.OPERA_CLI_BROWSER_URL) return; + const execPath = process.env.OPERA_CLI_EXECUTABLE_PATH; - if (execPath && existsSync(execPath)) return; - - const reason = execPath - ? `OPERA_CLI_EXECUTABLE_PATH points at "${execPath}" which does not exist` - : "OPERA_CLI_EXECUTABLE_PATH is not set — opera-browser-cli would launch vanilla Chrome, which has no Opera AI"; - throw new CdpError( - `${command} requires Opera Neon — ${reason}`, - "VALIDATION_ERROR", - [ - "Run `opera-browser-cli setup` to detect and configure Opera Neon", - "Or set OPERA_CLI_EXECUTABLE_PATH to your Opera Neon binary", + if (execPath && !existsSync(execPath)) { + throw new CdpError( + `${command} requires Opera Neon, and OPERA_CLI_EXECUTABLE_PATH points at "${execPath}", which does not exist`, + "VALIDATION_ERROR", + NEON_ONLY_HELP, + ); + } + + switch (classifyBrowser(execPath)) { + case "neon": + return; + case "opera": + throw new CdpError( + `${command} is only available on Opera Neon — the configured browser is a standard Opera build`, + "UNSUPPORTED_OPERATION", + ["`opera-browser-cli chat` works on this browser", ...NEON_ONLY_HELP], + ); + case "other": + throw new CdpError( + `${command} requires Opera Neon — the configured browser is not an Opera build`, + "VALIDATION_ERROR", + NEON_ONLY_HELP, + ); + default: + throw new CdpError( + `${command} requires Opera Neon — no browser is configured, so a plain Chrome would be launched`, + "VALIDATION_ERROR", + NEON_ONLY_HELP, + ); + } +} + +// --- Login --- + +const OPERA_ACCOUNT_URL = "https://auth.opera.com/account/"; + +/** + * Ask Opera AI something trivial purely to find out whether it will answer. + * + * There is no cheaper signal: sign-in, subscription, and consent state are only + * observable through the reply to a real call. So this is never run implicitly + * — only when the user asks to check. + */ +async function probeOperaAuth(): Promise<{ ok: boolean; detail: string }> { + try { + const result = await callTool("opera_chat", { prompt: "ping" }); + for (const descriptor of CDP_RESULT_ERRORS) { + if (descriptor.match(result)) { + return { + ok: false, + detail: + typeof descriptor.message === "function" + ? descriptor.message("login") + : descriptor.message, + }; + } + } + return { ok: true, detail: "Opera AI responded" }; + } catch (error) { + return { ok: false, detail: error instanceof Error ? error.message : String(error) }; + } +} + +async function handleLogin(args: string[]): Promise { + const checkOnly = args.includes("--check"); + + if (!checkOnly) { + if (!shouldRunHeaded()) { + throw new CdpError( + "Signing in needs a visible browser window, and this session is headless", + "VALIDATION_ERROR", + [ + "Run `OPERA_CLI_HEADED=1 opera-browser-cli login`", + "Or run `opera-browser-cli setup --headed` to make it the default", + ], + ); + } + await callTool("new_page", { url: OPERA_ACCOUNT_URL }); + + if (!process.stdin.isTTY) { + // No way to wait for the user, and probing now would just report the + // state they have not had a chance to change yet. + return renderOutput([ + encode({ login: "sign-in page opened", url: OPERA_ACCOUNT_URL }), + renderHelp([ + "Complete sign-in in the browser window", + "Run `opera-browser-cli login --check` to confirm it worked", + ]), + ]); + } + + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + await new Promise((resolve) => + rl.question( + `\nSign in at ${OPERA_ACCOUNT_URL} in the browser window, then press Enter: `, + resolve, + ), + ); + } finally { + rl.close(); + } + } + + const probe = await probeOperaAuth(); + if (!probe.ok) { + throw new CdpError(`Opera AI is not available: ${probe.detail}`, "AUTH_REQUIRED", [ + "Run `opera-browser-cli login` to sign in", + "Check your subscription at https://auth.opera.com/account/", "Run `opera-browser-cli doctor` to inspect the current configuration", - ], - ); + ]); + } + return renderOutput([ + encode({ login: "signed in", detail: probe.detail }), + renderHelp(['Run `opera-browser-cli chat "summarise this page"` to use Opera AI']), + ]); } interface CdpResultErrorDescriptor { @@ -2223,16 +2934,16 @@ const CDP_RESULT_ERRORS: readonly CdpResultErrorDescriptor[] = [ { match: (r) => r.includes(CdpResultErrorKey.NOT_SIGNED_IN), message: "Opera: user is not signed in", - code: "BROWSER_ERROR", + code: "AUTH_REQUIRED", suggestions: (cmd) => [ - `Re-run \`opera-browser-cli ${cmd}\` after signing in`, - "Run `opera-browser-cli doctor` to inspect the current configuration", + "Run `opera-browser-cli login` to sign in to your Opera account", + `Re-run \`opera-browser-cli ${cmd}\` afterwards`, ], }, { match: (r) => r.includes(CdpResultErrorKey.SUBSCRIPTION_REQUIRED), message: "Opera: an active subscription is required", - code: "BROWSER_ERROR", + code: "AUTH_REQUIRED", suggestions: (cmd) => [ "Check your Opera subscription at https://auth.opera.com/account/", `Re-run \`opera-browser-cli ${cmd}\` after activating a subscription`, @@ -2241,21 +2952,17 @@ const CDP_RESULT_ERRORS: readonly CdpResultErrorDescriptor[] = [ { match: (r) => r.includes(CdpResultErrorKey.CONSENT_REQUIRED), message: "Opera: user consent has not been accepted", - code: "BROWSER_ERROR", + code: "AUTH_REQUIRED", suggestions: (cmd) => [ - "Open Opera and accept the consent prompt before using AI features", + "Run `opera-browser-cli login` — the consent prompt appears on first use", `Re-run \`opera-browser-cli ${cmd}\` after accepting consent`, ], }, { match: (r) => r.includes(CdpResultErrorKey.NEON_ONLY), message: (cmd) => `Opera: ${cmd} is only available on Opera Neon`, - code: "BROWSER_ERROR", - suggestions: () => [ - "Install Opera Neon from https://www.operaneon.com", - "Run `opera-browser-cli setup` to configure the Opera Neon executable path", - "Run `opera-browser-cli doctor` to inspect the current configuration", - ], + code: "UNSUPPORTED_OPERATION", + suggestions: () => NEON_ONLY_HELP, }, ]; @@ -2576,6 +3283,11 @@ const COMMANDS: Record = { heap: withoutFullFlag(handleHeap), start: async () => handleStart(), stop: async () => handleStop(), + restart: async () => handleRestart(), + status: async () => handleStatus(), + attach: withoutFullFlag(handleAttach), + "launch-args": async () => handleLaunchArgs(), + login: withoutFullFlag(handleLogin), chat: withoutFullFlag(handleChat), "invoke-do": withoutFullFlag(handleInvokeDo), make: withoutFullFlag(handleMake), @@ -2586,28 +3298,229 @@ const COMMANDS: Record = { doctor: withoutFullFlag(handleDoctor), }; -const SETUP_SKIP_COMMANDS = new Set(["setup", "doctor", "logs", "--help", "-h", "--version", "-v", "-V"]); +// --- Browser conflict preflight --- + +/** Commands that never touch a browser, so never need a target resolved. */ +const BROWSER_SKIP_COMMANDS = new Set([ + "setup", + "doctor", + "logs", + "status", + "stop", + "attach", + "launch-args", + "models", + "--help", + "-h", + "--version", + "-v", + "-V", +]); + +function separateProfileDir(): string { + return join(getStateDir(), "profile"); +} + +/** + * Resolve a profile conflict: the user's browser is holding the profile and we + * cannot reach it. + * + * Restarting somebody's browser is not a decision to make on their behalf, so + * it happens only on an explicit yes — a TTY prompt, or `--takeover` for + * scripted callers. Everything else falls back to a separate profile, which + * always works and costs only a sign-in. + */ +async function resolveBrowserConflict( + target: Extract, + takeover: boolean, +): Promise { + const canPrompt = Boolean(process.stdin.isTTY && process.stdout.isTTY); + + let choice: "takeover" | "separate" = "separate"; + if (takeover) { + choice = "takeover"; + } else if (canPrompt) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + process.stdout.write( + `\nOpera is already running on the profile opera-browser-cli is configured to use:\n ${target.userDataDir}\n\n` + + "A browser can only be automated if it was started with a debugging port,\n" + + "and that flag cannot be added to a browser that is already open.\n\n" + + " [1] Restart Opera now so the CLI can drive it (tabs are restored)\n" + + " [2] Use a separate profile instead (you will need to sign in there)\n\n" + + "Restarting opens a local debugging port for as long as that browser runs.\n", + ); + const answer = (await new Promise((resolve) => + rl.question("Select [1/2] (default 2): ", resolve), + )) + .trim() + .toLowerCase(); + if (answer === "1" || answer === "y") choice = "takeover"; + } finally { + rl.close(); + } + } + + if (choice === "separate") { + const dir = separateProfileDir(); + process.env.OPERA_CLI_USER_DATA_DIR = dir; + process.stderr.write( + `note: Opera is running on the configured profile; using ${dir} for this run.\n` + + " Run `opera-browser-cli launch-args` to start Opera so the CLI can attach to it.\n", + ); + return; + } + + const quit = await quitBrowser(target.lock, target.userDataDir); + if (!quit.ok) { + throw new CdpError( + quit.reason === "no-pid" + ? "Could not identify the process holding the profile, so it was not signalled." + : "Opera did not shut down within 20s.", + "BROWSER_ERROR", + [ + "Quit Opera yourself, then re-run the command", + "Or run `opera-browser-cli launch-args` to restart it with a debugging port", + ], + ); + } + + const launched = await launchAttachableBrowser( + process.env.OPERA_CLI_EXECUTABLE_PATH, + target.userDataDir, + ); + if (!launched.ok || !launched.url) { + throw new CdpError( + `Opera was stopped but could not be restarted (${launched.reason ?? "unknown"}).`, + "BROWSER_ERROR", + [ + "Start Opera yourself, then re-run the command", + "Run `opera-browser-cli launch-args` for the flags that let the CLI attach", + "Run `opera-browser-cli doctor` to check the configured executable path", + ], + ); + } + process.env.OPERA_CLI_BROWSER_URL = launched.url; + process.stderr.write(`note: restarted Opera and attached at ${launched.url}\n`); +} + +/** + * Work out which browser this command should drive, before the bridge starts. + * + * Runs in the CLI rather than the bridge because resolving a conflict may need + * to ask the user something, and the bridge is detached with no terminal. + * + * This runs even when a bridge is already alive. A bridge fixes its browser + * (attach URL, profile, flags) at startup, so a healthy bridge is only "the + * question is settled" while it is still driving the right browser. The case + * that must never be silently skipped is a conflict: the user's own Opera is + * running on the configured profile without a debug port. That used to be + * bypassed whenever any bridge was running, so the restart prompt never fired + * and the CLI kept driving a stale headless / separate-profile browser. + */ +export async function preflightBrowser(argv: string[], takeover: boolean): Promise { + const cmd = argv[0]; + if (cmd === undefined || BROWSER_SKIP_COMMANDS.has(cmd)) return; + // Explicitly pointed at a browser, or using an isolated profile that nothing + // else can hold: either way there is no conflict possible. + if (process.env.OPERA_CLI_BROWSER_URL) return; + if (!process.env.OPERA_CLI_USER_DATA_DIR) return; + + const target = await resolveBrowserTarget({ + browserUrl: process.env.OPERA_CLI_BROWSER_URL, + userDataDir: process.env.OPERA_CLI_USER_DATA_DIR, + executablePath: process.env.OPERA_CLI_EXECUTABLE_PATH, + }); + + if (target.mode === "attach") { + // A live debug port on the configured profile. Set the attach URL so any + // freshly-started bridge (including a recovery rebuild) attaches to it. + // This is inert when a healthy bridge is already driving this browser — + // ensureBridge reuses it and the env is only read at bridge startup. + process.env.OPERA_CLI_BROWSER_URL = target.url; + return; + } + if (target.mode === "managed") return; + + // Conflict: a browser is holding the configured profile with no debug port. + // Settle it even when a bridge is running — this is the case that used to be + // silently skipped, leaving the user on a headless / separate-profile browser. + await resolveBrowserConflict(target, takeover); + + // Takeover relaunched the user's browser with a debug port and set a fresh + // BROWSER_URL, which an already-running bridge (it fixed its browser at + // startup) would not reflect — so replace it. The separate-profile fallback + // is different: a bridge that is already running was started on that separate + // profile, so it should be reused, not reset (which would relaunch its + // browser on every command). Only a takeover needs the bridge rebuilt. + if (process.env.OPERA_CLI_BROWSER_URL) { + if ((await findUsableBridge(candidatePorts())) !== null) { + process.stderr.write( + "note: browser selection changed; resetting the running bridge.\n", + ); + await restartBridge(); + } + } +} + +const SETUP_SKIP_COMMANDS = new Set(["setup", "logs", "--help", "-h", "--version", "-v", "-V"]); -function warnIfUnconfigured(argv: string[]): void { +/** + * Configure a machine that has never been configured, in place, without asking. + * + * This replaces a stderr hint that told the user to go and run `setup` and then + * carried on into a broken configuration anyway. Detection is unambiguous on + * the platforms Opera ships for, so there is nothing to ask; and doing it here + * rather than in `setup` means it works identically under an agent, which is + * how most of these commands are actually run. + */ +function ensureConfigured(argv: string[]): void { const cmd = argv[0]; if (cmd !== undefined && SETUP_SKIP_COMMANDS.has(cmd)) return; - const configFile = join(homedir(), ".opera-browser-cli", "config"); - if (!existsSync(configFile) && !process.env.OPERA_CLI_EXECUTABLE_PATH && !process.env.OPERA_CLI_BROWSER_URL) { + + const result = autoConfigure(); + if (result.status === "configured") { + process.stderr.write( + `configured: ${result.browser.name} (${result.browser.isNeon ? "Opera AI available" : "chat only — install Opera Neon for invoke-do/make/research"}) ` + + "— run `opera-browser-cli setup` to change\n", + ); + return; + } + if (result.status === "no-browser" && cmd !== "doctor") { process.stderr.write( - "hint: run `opera-browser-cli setup` to configure (first-time setup)\n", + "hint: no Opera installation found — run `opera-browser-cli setup`, or set OPERA_CLI_EXECUTABLE_PATH\n", ); } } +export function extractTakeoverFlag(argv: string[]): { + argv: string[]; + takeover: boolean; +} { + return { + argv: argv.filter((arg) => arg !== "--takeover"), + takeover: + argv.includes("--takeover") || process.env.OPERA_CLI_TAKEOVER === "1", + }; +} + export async function main( options: MainOptions | string[] = {}, ): Promise { loadConfig(); const normalized = normalizeMainOptions(options); - const requestedArgv = resolveArgv(normalized.argv); - warnIfUnconfigured(requestedArgv); + const rawArgv = resolveArgv(normalized.argv); + const { argv: requestedArgv, takeover } = extractTakeoverFlag(rawArgv); + ensureConfigured(requestedArgv); + await preflightBrowser(requestedArgv, takeover); const homeFull = shouldRenderFullHome(requestedArgv); - const argv = homeFull ? [] : normalized.argv; + // Only hand axi an explicit argv when we have one to give: either the caller + // supplied it, or we stripped --takeover out of it. Otherwise let axi read + // process.argv itself, which is the documented behaviour. + const stripped = requestedArgv.length !== rawArgv.length; + const passthroughArgv = + normalized.argv !== undefined || stripped ? requestedArgv : undefined; + const argv = homeFull ? [] : passthroughArgv; const stdout = wrapStdout(normalized.stdout, argv); await runAxiCli({ @@ -2621,5 +3534,6 @@ export async function main( commands: COMMANDS, getCommandHelp, renderUnknownCommand, + formatError: formatCliError, }); } diff --git a/src/client.ts b/src/client.ts index 2e9f9c8..178a73f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,21 +1,70 @@ /** * HTTP client for the opera-browser-cli bridge + bridge lifecycle management. + * + * The lifecycle rules, in one place: + * + * - A process is only ever signalled once it has been positively identified + * as our bridge — by answering /health, or by matching a PID file entry + * recorded on this same boot. A recycled PID after a reboot must never be + * mistaken for ours. + * - A bridge running a different package version is unusable, however + * healthy it looks: it is serving pre-upgrade code from memory. + * - Exactly one process starts a bridge at a time (an exclusive lock), and + * if the port it wants is taken it moves to the next one. + * - A connection lost mid-command is recovered transparently, except for the + * expensive Opera AI tools, which are never silently replayed. */ import { spawn } from "node:child_process"; -import { mkdirSync, openSync, readFileSync, existsSync } from "node:fs"; +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeSync, +} from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { request } from "node:http"; import { AxiError } from "axi-sdk-js"; -import { resolveBridgeScript, type LastSnapshotCache } from "./bridge.js"; +import { + resolveBridgeLauncher, + type LastSnapshotCache, +} from "./bridge.js"; +import { + computeBootMinute, + isOurBridge, + isUsableBridge, + parseHealth, + sameBoot, + type BridgeHealth, +} from "./identity.js"; +import { getPackageVersion } from "./version.js"; const STATE_DIR = join(homedir(), ".opera-browser-cli"); const PID_FILE = join(STATE_DIR, "bridge.pid"); const CONFIG_FILE = join(STATE_DIR, "config"); const LOG_FILE = join(STATE_DIR, "bridge.log"); +const LOCK_FILE = join(STATE_DIR, "bridge.lock"); const DEFAULT_PORT = 9225; +/** How many consecutive ports to try before giving up. */ +const PORT_SCAN_COUNT = 10; +/** Budget for a single bridge process to reach READY (Chrome launch is slow). */ +const START_TIMEOUT_MS = 30_000; +/** A start lock older than this is assumed abandoned. */ +const LOCK_STALE_MS = 60_000; +/** Grace period for a SIGTERMed bridge before escalating to SIGKILL. */ +const STOP_GRACE_MS = 5_000; +/** Rotate the bridge log past this size so it cannot grow without bound. */ +const MAX_LOG_BYTES = 5 * 1024 * 1024; +/** Lines of bridge.log to quote back when a startup fails. */ +const LOG_TAIL_LINES = 20; + export function getLogFile(): string { return LOG_FILE; } @@ -68,6 +117,8 @@ export type ErrorCode = | "TIMEOUT" | "PAGE_CLOSED" | "BROWSER_ERROR" + /** Sign-in, subscription, or consent — only the user can resolve it. */ + | "AUTH_REQUIRED" | "VALIDATION_ERROR" | "UNSUPPORTED_OPERATION" | "UNKNOWN"; @@ -83,10 +134,18 @@ export class CdpError extends AxiError { } } +// --------------------------------------------------------------------------- +// PID file +// --------------------------------------------------------------------------- + interface PidInfo { pid: number; port: number; token?: string; + /** Absent on bridges from <= 0.1.45, which predate the identity fields. */ + version?: string; + startedAt?: number; + bootMinute?: number; } function readPidFile(): PidInfo | null { @@ -102,6 +161,14 @@ function readPidFile(): PidInfo | null { } } +function removePidFile(): void { + try { + unlinkSync(PID_FILE); + } catch { + // Already gone — fine + } +} + /** Read the bridge's per-instance auth token from the PID file, if present. */ function readBridgeToken(): string | null { return readPidFile()?.token ?? null; @@ -116,6 +183,40 @@ function isProcessAlive(pid: number): boolean { } } +/** + * Whether a PID file entry may be signalled. + * + * Requires the entry to record the boot it was written on, and that boot to be + * the current one. Entries without a boot stamp (pre-0.1.46) are only + * trustworthy when something has *also* identified the port as ours — see + * `resolveSignalablePid`. + */ +function pidFileIsFromThisBoot(info: PidInfo): boolean { + return ( + typeof info.bootMinute === "number" && + sameBoot(info.bootMinute, computeBootMinute()) + ); +} + +/** + * Work out which PID, if any, it is safe to signal for the bridge on `port`. + * + * `health.pid` is authoritative — that process just told us who it is. Older + * bridges do not report a PID; for those we fall back to the PID file, but only + * when it names the same port, which means the file was written by whatever is + * answering there now. + */ +function resolveSignalablePid(port: number, health: BridgeHealth): number | null { + if (health.pid > 0) return health.pid; + const info = readPidFile(); + if (info && info.port === port && isProcessAlive(info.pid)) return info.pid; + return null; +} + +// --------------------------------------------------------------------------- +// HTTP +// --------------------------------------------------------------------------- + function httpGet( port: number, path: string, @@ -219,122 +320,564 @@ function httpPost( }); } -async function isBridgeHealthy(port: number): Promise { +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +// --------------------------------------------------------------------------- +// Discovery +// --------------------------------------------------------------------------- + +/** The ports a bridge may live on, in preference order. */ +export function candidatePorts(): number[] { + const base = Number.parseInt( + process.env.OPERA_CLI_PORT ?? String(DEFAULT_PORT), + 10, + ); + const start = Number.isFinite(base) ? base : DEFAULT_PORT; + return Array.from({ length: PORT_SCAN_COUNT }, (_, i) => start + i); +} + +/** + * Ask what is listening on a port. + * + * Returns the identity if it is one of our bridges (of any version), and null + * for everything else: nothing listening, a foreign server, or a response we + * cannot parse. A foreign server is deliberately indistinguishable from an + * empty port here — the caller handles both the same way, by moving on and + * letting the bridge's own EADDRINUSE handling sort out the collision. + */ +async function probeHealth(port: number): Promise { try { - const resp = await httpGet(port, "/health", 2000); - const data = JSON.parse(resp); - return data.status === "ok"; + return parseHealth(await httpGet(port, "/health", 2000)); } catch { - return false; + return null; } } +interface PortProbe { + port: number; + health: BridgeHealth | null; +} + +async function probeAll(ports: number[]): Promise { + return Promise.all( + ports.map(async (port) => ({ port, health: await probeHealth(port) })), + ); +} + /** - * Check what is listening on a port. - * Returns "ok" if it is our bridge, "conflict" if something else responded, - * or "free" if nothing is listening. + * Find a bridge we can use, cleaning up any of our own that we cannot. + * + * Stale-version bridges are shut down rather than left running: they hold a + * port, they will never become usable, and leaving them behind is how a machine + * accumulates zombies across upgrades. */ -async function checkPortStatus(port: number): Promise<"ok" | "conflict" | "free"> { +export async function findUsableBridge(ports: number[]): Promise { + const version = getPackageVersion(); + + // Fast path: the port in the PID file is nearly always the answer, and + // checking it alone keeps the common case to a single round trip. + const preferred = readPidFile()?.port; + if (preferred !== undefined && ports.includes(preferred)) { + const health = await probeHealth(preferred); + if (isUsableBridge(health, version)) return preferred; + if (isOurBridge(health)) await shutdownBridgeOnPort(preferred, health); + } + + const probes = await probeAll(ports.filter((p) => p !== preferred)); + for (const { port, health } of probes) { + if (isUsableBridge(health, version)) return port; + } + for (const { port, health } of probes) { + if (isOurBridge(health)) await shutdownBridgeOnPort(port, health); + } + return null; +} + +/** Poll for a bridge someone else is starting. */ +async function waitForUsableBridge( + ports: number[], + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const port = await findUsableBridge(ports); + if (port !== null) return port; + await sleep(250); + } + return null; +} + +// --------------------------------------------------------------------------- +// Shutdown +// --------------------------------------------------------------------------- + +/** SIGTERM, wait, SIGKILL. Returns true if the process is gone afterwards. */ +async function terminateProcess(pid: number): Promise<{ gone: boolean; forced: boolean }> { try { - const resp = await httpGet(port, "/health", 2000); - const data = JSON.parse(resp); - if (data.server === "opera-browser-cli") { - return data.status === "ok" ? "ok" : "free"; - } - return "conflict"; + process.kill(pid, "SIGTERM"); } catch { - return "free"; + return { gone: true, forced: false }; } + const deadline = Date.now() + STOP_GRACE_MS; + while (Date.now() < deadline) { + if (!isProcessAlive(pid)) return { gone: true, forced: false }; + await sleep(100); + } + try { + process.kill(pid, "SIGKILL"); + } catch { + return { gone: true, forced: true }; + } + await sleep(200); + return { gone: !isProcessAlive(pid), forced: true }; } -function sleep(ms: number): Promise { - return new Promise((r) => setTimeout(r, ms)); +/** Shut down a bridge we have positively identified on `port`. */ +async function shutdownBridgeOnPort( + port: number, + health: BridgeHealth, +): Promise { + const pid = resolveSignalablePid(port, health); + if (pid === null) return; + await terminateProcess(pid); + if (readPidFile()?.pid === pid) removePidFile(); } -/** - * Ensure the bridge is running, starting it if needed. Returns the port. - */ -export async function ensureBridge(): Promise { - const port = parseInt( - process.env.OPERA_CLI_PORT ?? String(DEFAULT_PORT), - 10, - ); +/** Shut down every bridge of ours across the candidate ports. */ +async function shutdownOurBridges(ports: number[]): Promise { + for (const { port, health } of await probeAll(ports)) { + if (isOurBridge(health)) await shutdownBridgeOnPort(port, health); + } +} - // Check existing bridge via PID file (lenient: we trust our own PID file). - const pidInfo = readPidFile(); - if (pidInfo && isProcessAlive(pidInfo.pid)) { - if (await isBridgeHealthy(pidInfo.port)) { - return pidInfo.port; +// --------------------------------------------------------------------------- +// Start lock +// +// Without this, N concurrent commands on a cold start all see no bridge and all +// spawn one. The losers die on EADDRINUSE, and whichever PID file lands last +// may not describe the survivor. +// --------------------------------------------------------------------------- + +interface LockInfo { + pid: number; + startedAt: number; +} + +let holdingLock = false; + +function readLock(): LockInfo | null { + try { + const data = JSON.parse(readFileSync(LOCK_FILE, "utf-8")) as Partial; + if (typeof data.pid !== "number" || typeof data.startedAt !== "number") { + return null; } + return { pid: data.pid, startedAt: data.startedAt }; + } catch { + return null; + } +} + +function acquireStartLock(): boolean { + try { + mkdirSync(STATE_DIR, { recursive: true }); + const fd = openSync(LOCK_FILE, "wx"); try { - process.kill(pidInfo.pid, "SIGTERM"); - } catch { - // Best effort — if shutdown fails, the startup poll below will time out. + writeSync(fd, JSON.stringify({ pid: process.pid, startedAt: Date.now() })); + } finally { + closeSync(fd); } + holdingLock = true; + // Held only while we hold the lock, so an interrupted start still releases + // it and we never accumulate listeners across repeated acquisitions. + process.on("exit", releaseStartLock); + return true; + } catch { + return false; + } +} + +function releaseStartLock(): void { + if (!holdingLock) return; + holdingLock = false; + process.off("exit", releaseStartLock); + try { + unlinkSync(LOCK_FILE); + } catch { + // Someone else cleaned it up — fine } +} + +/** True when the lock is held by a dead process or has simply been there too long. */ +function startLockIsStale(): boolean { + const lock = readLock(); + if (lock === null) return true; // unreadable or malformed + if (!isProcessAlive(lock.pid)) return true; + return Date.now() - lock.startedAt > LOCK_STALE_MS; +} - // Check for a foreign server already occupying the target port before spawning. - const portStatus = await checkPortStatus(port); - if (portStatus === "ok") { - // A healthy bridge is already running (no PID file or stale PID). - return port; +/** + * Remove an abandoned lock and take it. + * + * Two processes can both decide a lock is stale and both end up believing they + * hold it. That is tolerable: the loser's bridge fails with EADDRINUSE and + * retries the next port, which is exactly the path the port scan already + * handles. The lock removes the common case; the port scan is the real backstop. + */ +function stealStartLock(): boolean { + try { + unlinkSync(LOCK_FILE); + } catch { + // Already gone } - if (portStatus === "conflict") { - throw new CdpError( - `Port ${port} is in use by a different server (not opera-devtools-mcp). Stop it or choose a different port.`, - "BRIDGE_NOT_READY", - [ - `Stop the process on port ${port} and try again, or set OPERA_CLI_PORT to a different port number`, - ], - ); + return acquireStartLock(); +} + +// --------------------------------------------------------------------------- +// Logging +// --------------------------------------------------------------------------- + +/** Rotate the bridge log now, whatever its size. Used by `doctor --fix`. */ +export function rotateBridgeLog(): boolean { + try { + renameSync(LOG_FILE, `${LOG_FILE}.1`); + return true; + } catch { + return false; } +} - // Start a new bridge +function rotateLogIfLarge(): void { + try { + if (statSync(LOG_FILE).size < MAX_LOG_BYTES) return; + renameSync(LOG_FILE, `${LOG_FILE}.1`); + } catch { + // No log yet, or rotation is not possible — never block a start over it. + } +} - const bridgeScript = resolveBridgeScript(import.meta.dirname); - // Try .ts first (dev mode), fall back to .js (built) - const script = existsSync(bridgeScript.replace(/\.js$/, ".ts")) - ? bridgeScript.replace(/\.js$/, ".ts") - : bridgeScript; - const runner = script.endsWith(".ts") ? "tsx" : "node"; +/** The tail of the bridge log, for quoting back when a start fails. */ +function readLogTail(lines = LOG_TAIL_LINES): string { + try { + const all = readFileSync(LOG_FILE, "utf-8").split("\n").filter(Boolean); + return all.slice(-lines).join("\n"); + } catch { + return ""; + } +} - // Pipe bridge stdout/stderr to ~/.opera-browser-cli/bridge.log so failures - // are inspectable. Falls back to "ignore" if the file can't be opened. - let stdio: "ignore" | ["ignore", number, number] = "ignore"; +function openLogFd(): number | null { try { mkdirSync(STATE_DIR, { recursive: true }); - const logFd = openSync(LOG_FILE, "a"); - stdio = ["ignore", logFd, logFd]; + rotateLogIfLarge(); + return openSync(LOG_FILE, "a"); } catch { - // Log directory unwritable — bridge still runs, just no logs. + // Log directory unwritable — the bridge still runs, just without logs. + return null; } +} - const child = spawn( - runner === "tsx" ? "npx" : "node", - runner === "tsx" ? ["tsx", script] : [script], - { - stdio, - env: { ...process.env, OPERA_CLI_PORT: String(port) }, - detached: true, - }, - ); - child.unref(); +// --------------------------------------------------------------------------- +// Spawn +// --------------------------------------------------------------------------- - // Poll for health (max 30s — Chrome launch can be slow) - const deadline = Date.now() + 30_000; - while (Date.now() < deadline) { - if (await isBridgeHealthy(port)) { - return port; +interface SpawnOutcome { + ok: boolean; + /** Machine-readable failure class, matching the bridge's FAILED signals. */ + reason?: string; + detail?: string; +} + +/** + * Start one bridge process on one port and wait for its handshake. + * + * The bridge reports READY or FAILED on stdout, so a dead child is detected in + * milliseconds instead of costing the full startup budget. Its stderr goes to + * the log file, whose tail is folded into the failure detail. + */ +async function spawnBridge(port: number): Promise { + const launcher = resolveBridgeLauncher(import.meta.dirname); + if (!launcher.ok) return { ok: false, reason: launcher.reason }; + + const logFd = openLogFd(); + const child = spawn(launcher.command, launcher.args, { + stdio: ["ignore", "pipe", logFd ?? "ignore"], + env: { ...process.env, OPERA_CLI_PORT: String(port) }, + detached: true, + }); + + return new Promise((resolve) => { + let settled = false; + let buffer = ""; + + const finish = (outcome: SpawnOutcome): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + child.removeAllListeners("exit"); + child.removeAllListeners("error"); + child.stdout?.removeAllListeners("data"); + // Release the pipe so this process can exit; the bridge writes nothing + // to stdout after the handshake and guards against EPIPE regardless. + child.stdout?.destroy(); + child.unref(); + if (logFd !== null) { + try { + closeSync(logFd); + } catch { + // Already closed + } + } + resolve(outcome); + }; + + const timer = setTimeout( + () => finish({ ok: false, reason: "timeout", detail: readLogTail() }), + START_TIMEOUT_MS, + ); + + child.stdout?.setEncoding("utf-8"); + child.stdout?.on("data", (chunk: string) => { + buffer += chunk; + let newline: number; + while ((newline = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (line === "READY") { + finish({ ok: true }); + return; + } + if (line.startsWith("FAILED ")) { + const rest = line.slice("FAILED ".length); + const spaceAt = rest.indexOf(" "); + finish({ + ok: false, + reason: spaceAt === -1 ? rest : rest.slice(0, spaceAt), + detail: spaceAt === -1 ? undefined : rest.slice(spaceAt + 1), + }); + return; + } + } + }); + + child.on("error", (error) => + finish({ ok: false, reason: "spawn-failed", detail: error.message }), + ); + + child.on("exit", (code) => + finish({ + ok: false, + reason: code === 75 ? "port-in-use" : "exited", + detail: `bridge exited with code ${code}\n${readLogTail()}`, + }), + ); + }); +} + +function startFailureError(outcome: SpawnOutcome, ports: number[]): CdpError { + const detail = outcome.detail ? `\n${outcome.detail}` : ""; + switch (outcome.reason) { + case "mcp-connect": + return new CdpError( + `Bridge could not connect to opera-devtools-mcp.${detail}`, + "BRIDGE_NOT_READY", + [ + "Check that opera-devtools-mcp is installed: `npx opera-devtools-mcp@latest --help`", + "For local dev: set OPERA_CLI_MCP_BIN to the linked binary", + "Run `opera-browser-cli logs` for the full bridge output", + ], + ); + case "state-dir-unwritable": + return new CdpError( + `Bridge cannot write to its state directory (${outcome.detail ?? STATE_DIR}).`, + "BRIDGE_NOT_READY", + [ + `Check ownership: \`ls -ld ${outcome.detail ?? STATE_DIR}\``, + `If it is root-owned from an earlier sudo run: \`sudo chown -R "$(whoami)" ${outcome.detail ?? STATE_DIR}\``, + ], + ); + case "tsx-not-installed": + return new CdpError( + "Bridge cannot run from TypeScript source — tsx is not installed.", + "BRIDGE_NOT_READY", + [ + "Run `npm install` in the opera-browser-cli checkout", + "Or build first: `npm run build`", + ], + ); + case "bridge-not-built": + return new CdpError( + "Bridge entrypoint not found — the package looks unbuilt.", + "BRIDGE_NOT_READY", + ["Run `npm run build` in the opera-browser-cli checkout"], + ); + case "port-in-use": + return new CdpError( + `Ports ${ports[0]}-${ports[ports.length - 1]} are all in use by other servers.`, + "BRIDGE_NOT_READY", + [ + "Free one of those ports, or set OPERA_CLI_PORT to a different base port", + ], + ); + case "timeout": + return new CdpError( + `Bridge did not become ready within ${START_TIMEOUT_MS / 1000}s.${detail}`, + "BRIDGE_NOT_READY", + [ + "Run `opera-browser-cli logs` to see what the bridge was doing", + "Run `opera-browser-cli doctor` to check the configuration", + ], + ); + default: + return new CdpError( + `Bridge failed to start.${detail}`, + "BRIDGE_NOT_READY", + [ + "Run `opera-browser-cli logs` for the full bridge output", + "Run `opera-browser-cli doctor` to check the configuration", + ], + ); + } +} + +/** + * Take the start lock and bring a bridge up, walking the port range. + * + * If another process holds the lock we wait for its bridge instead of racing + * it; only an abandoned lock is stolen. + */ +async function startBridge(ports: number[], attempt = 0): Promise { + if (!acquireStartLock()) { + const port = await waitForUsableBridge(ports, START_TIMEOUT_MS); + if (port !== null) return port; + if (attempt >= 1 || !startLockIsStale() || !stealStartLock()) { + throw new CdpError( + "Timed out waiting for another opera-browser-cli process to start the bridge", + "BRIDGE_NOT_READY", + [ + "Run `opera-browser-cli logs` to see what the other process was doing", + "Run `opera-browser-cli restart` to force a clean start", + ], + ); + } + return startBridge(ports, attempt + 1); + } + + try { + let lastOutcome: SpawnOutcome = { ok: false, reason: "port-in-use" }; + for (const port of ports) { + lastOutcome = await spawnBridge(port); + if (lastOutcome.ok) return port; + // Only a port collision is worth trying the next port for; anything else + // will fail the same way everywhere, so surface it immediately. + if (lastOutcome.reason !== "port-in-use") break; + } + throw startFailureError(lastOutcome, ports); + } finally { + releaseStartLock(); + } +} + +// --------------------------------------------------------------------------- +// Public lifecycle API +// --------------------------------------------------------------------------- + +export interface EnsureBridgeOptions { + /** Tear down any bridge of ours first, then start fresh. */ + forceRestart?: boolean; +} + +/** + * Ensure a bridge running our version is up. Returns the port it is on. + */ +export async function ensureBridge( + options: EnsureBridgeOptions = {}, +): Promise { + const ports = candidatePorts(); + + if (options.forceRestart) { + await shutdownOurBridges(ports); + } else { + const existing = await findUsableBridge(ports); + if (existing !== null) return existing; + } + + return startBridge(ports); +} + +export interface StopResult { + /** A running bridge was signalled and is now gone. */ + stopped: boolean; + /** A PID file was present but the process was already dead. */ + stale: boolean; + /** SIGTERM was ignored and SIGKILL was needed. */ + forced: boolean; + pid: number | null; + port: number | null; +} + +/** + * Stop the bridge. + * + * Looks past the PID file: if the file is missing or stale but a bridge of ours + * is answering on one of the candidate ports, that one is stopped too. Escalates + * to SIGKILL rather than reporting success against a process that ignored the + * signal, and always leaves the PID file cleaned up. + */ +export async function stopBridge(): Promise { + const result: StopResult = { + stopped: false, + stale: false, + forced: false, + pid: null, + port: null, + }; + + // Prefer a live, identified bridge — that is the one actually holding a port. + for (const { port, health } of await probeAll(candidatePorts())) { + if (!isOurBridge(health)) continue; + const pid = resolveSignalablePid(port, health); + if (pid === null) continue; + const outcome = await terminateProcess(pid); + result.stopped ||= outcome.gone; + result.forced ||= outcome.forced; + result.pid ??= pid; + result.port ??= port; + } + + const info = readPidFile(); + if (info) { + if (!result.stopped) { + // Nothing answered. Only signal the recorded PID if the file is provably + // from this boot — otherwise the PID may belong to a stranger. + if (pidFileIsFromThisBoot(info) && isProcessAlive(info.pid)) { + const outcome = await terminateProcess(info.pid); + result.stopped = outcome.gone; + result.forced = outcome.forced; + result.pid = info.pid; + result.port = info.port; + } else { + result.stale = true; + result.pid = info.pid; + result.port = info.port; + } } - await sleep(500); + removePidFile(); } - throw new CdpError("Bridge failed to start within 30s", "BRIDGE_NOT_READY", [ - "For local dev: set OPERA_CLI_MCP_BIN to the linked binary, e.g. OPERA_CLI_MCP_BIN=opera-devtools-mcp", - "For published version: check that opera-devtools-mcp is installed: npx opera-devtools-mcp@latest --help", - ]); + return result; } +/** Stop whatever is running and bring a fresh bridge up. Returns the port. */ +export async function restartBridge(): Promise { + return ensureBridge({ forceRestart: true }); +} + +// --------------------------------------------------------------------------- +// Tool calls +// --------------------------------------------------------------------------- + const OPERA_AI_TIMEOUT = 1_200_000; // 20 minutes const OPERA_AI_TOOLS = new Set([ "opera_chat", @@ -344,37 +887,190 @@ const OPERA_AI_TOOLS = new Set([ ]); /** - * Call an MCP tool via the bridge. Returns the text result. + * Tools that must never be replayed after a dropped connection. + * + * All four Opera AI tools are long-running, billable, and may have already + * acted on the page before the bridge went away. A silent second run could + * double a booking as easily as it could double a bill. */ -export async function callTool( +const NON_REPLAYABLE_TOOLS = OPERA_AI_TOOLS; + +function errorMessageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** A dropped or rejected bridge connection, as opposed to a tool-level failure. */ +function isTransportFailure(message: string): boolean { + return ( + /ECONNREFUSED|ECONNRESET|EPIPE|socket hang up|MCP transport disconnected/i.test( + message, + ) || isAuthFailure(message) + ); +} + +/** + * The bridge's own 401. Matched exactly: page content and tool output routinely + * contain the word "unauthorized" and must not trigger a restart. + */ +function isAuthFailure(message: string): boolean { + return message.trim().toLowerCase() === "unauthorized"; +} + +/** + * A page-state race rather than a real failure: the DOM moved under us while + * the call was in flight. Common during navigation, and almost always gone by + * the time we ask again. + */ +function isTransientPageFailure(message: string): boolean { + return /detached|execution context was destroyed|cannot find context|no node with given id|target closed/i.test( + message, + ); +} + +async function callToolOnce( name: string, - args: Record = {}, + args: Record, + options: EnsureBridgeOptions, ): Promise { - const port = await ensureBridge(); + const port = await ensureBridge(options); const isStreaming = OPERA_AI_TOOLS.has(name); const timeoutMs = isStreaming ? OPERA_AI_TIMEOUT : undefined; const onLog = isStreaming ? (msg: string) => process.stderr.write(msg + "\n") : undefined; + const resp = await httpPost( + port, + "/call", + { name, args }, + timeoutMs, + onLog, + readBridgeToken(), + ); + const data = JSON.parse(resp); + if (data.error) throw new Error(data.error); + return data.result ?? ""; +} + +/** + * Call an MCP tool via the bridge. Returns the text result. + * + * A connection lost mid-call is recovered once: the bridge is restarted and the + * call replayed. The Opera AI tools are exempt from *that* recovery — they are + * reported instead, so the user decides whether to pay for a second run. + * + * A second, distinct failure is also repaired: the bridge answers, but the + * browser it was told to drive is unreachable (a dead attach URL, or a managed + * launch that never produced a browser). devtools-mcp reports that as a tool + * *result*, not an error, so it would otherwise look like success. We rebuild + * the bridge against the current target and retry once — safe for every tool, + * because nothing could have acted on a browser that was never reached. + */ +export async function callTool( + name: string, + args: Record = {}, +): Promise { + let result: string; try { - const token = readBridgeToken(); - const resp = await httpPost(port, "/call", { name, args }, timeoutMs, onLog, token); - const data = JSON.parse(resp); - if (data.error) { - throw new Error(data.error); + result = await callToolOnce(name, args, {}); + } catch (error) { + return recoverFailedCall(name, args, error); + } + + // The bridge answered, but the browser it was told to drive is unreachable + // (a dead attach URL, or a managed launch that never produced a browser). + // Rebuild the bridge against the current target and retry once — safe for + // every tool, because nothing could have acted on a browser that was never + // reached. A persistent failure is a real error, not a fake success. + if (isBrowserUnreachableResult(result)) { + try { + const recovered = await callToolOnce(name, args, { forceRestart: true }); + if (!isBrowserUnreachableResult(recovered)) return recovered; + } catch (retryError) { + return recoverFailedCall(name, args, retryError); + } + throw browserUnreachableError(); + } + + return result; +} + +/** + * Handle an exception thrown by the bridge: recover what is worth recovering + * (transient page races, dropped transport), and map the rest to an error code. + */ +async function recoverFailedCall( + name: string, + args: Record, + error: unknown, +): Promise { + const message = errorMessageOf(error); + + // A page-state race is worth one immediate retry against the same bridge — + // no restart, no user-visible failure. + if (isTransientPageFailure(message) && !NON_REPLAYABLE_TOOLS.has(name)) { + await sleep(250); + try { + return await callToolOnce(name, args, {}); + } catch (retryError) { + throw mapErrorMessage(errorMessageOf(retryError)); } - return data.result ?? ""; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw mapErrorMessage(message); } + + if (!isTransportFailure(message)) throw mapErrorMessage(message); + + if (NON_REPLAYABLE_TOOLS.has(name)) { + throw new CdpError( + `The bridge connection dropped while running ${name}, and the command was not retried automatically because it may already have taken effect.`, + "BRIDGE_NOT_READY", + [ + "Re-run the command — the bridge restarts automatically", + "Run `opera-browser-cli logs` to see why the bridge dropped", + ], + ); + } + + try { + return await callToolOnce(name, args, { forceRestart: true }); + } catch (retryError) { + throw mapErrorMessage(errorMessageOf(retryError)); + } +} + +/** devtools-mcp's "I have no browser to talk to" result text. */ +function isBrowserUnreachableResult(result: string): boolean { + return /could not connect to chrome|failed to fetch browser websocket url/i.test( + result, + ); +} + +function browserUnreachableError(): CdpError { + return new CdpError( + "The browser is not reachable. It may be running without a debugging port, or the bridge is pointing at a browser that has closed.", + "BROWSER_ERROR", + [ + "Run `opera-browser-cli doctor` to check the profile and bridge state", + "Restart the running browser with a debug port: `opera-browser-cli open --takeover`", + "Or use a separate profile (no flag) if the browser cannot be restarted", + ], + ); } export function mapErrorMessage(message: string): CdpError { + if (isAuthFailure(message)) { + return new CdpError( + "Bridge rejected the auth token", + "BRIDGE_NOT_READY", + [ + "Run `opera-browser-cli restart` to issue a fresh token", + "Run `opera-browser-cli doctor` to inspect the bridge state", + ], + ); + } if (message.includes("ECONNREFUSED") || message.includes("ECONNRESET")) { return new CdpError("Bridge is not running", "BRIDGE_NOT_READY", [ "Run `opera-browser-cli open ` — the bridge starts automatically", + "Run `opera-browser-cli restart` if it keeps failing", ]); } if ( @@ -401,14 +1097,10 @@ export function mapErrorMessage(message: string): CdpError { (message.includes("Opera.dispatchAction") && message.includes("not signed in")) ) { - return new CdpError( - "Opera: user is not signed in", - "BROWSER_ERROR", - [ - "Sign in to your Opera account to use this feature", - "Run `opera-browser-cli setup` to configure the executable path", - ], - ); + return new CdpError("Opera: user is not signed in", "AUTH_REQUIRED", [ + "Run `opera-browser-cli login` to sign in to your Opera account", + "Run `opera-browser-cli doctor` to inspect the current configuration", + ]); } // Try to parse JSON error try { @@ -424,71 +1116,120 @@ export function mapErrorMessage(message: string): CdpError { return new CdpError(message, "UNKNOWN"); } +// --------------------------------------------------------------------------- +// Introspection +// --------------------------------------------------------------------------- + export interface BridgeStatus { pidFileExists: boolean; processAlive: boolean; healthy: boolean; port: number | null; pid: number | null; + /** Version the running bridge reports, when one is answering. */ + runningVersion: string | null; + /** Our version — differs from runningVersion after an upgrade. */ + expectedVersion: string; + /** Running, ours, but on stale code: needs a restart to become usable. */ + versionSkew: boolean; + /** PID file names a process from a previous boot, or a dead one. */ + stalePidFile: boolean; } /** - * Inspect the bridge without starting it. Used by `opera-browser-cli doctor`. + * Inspect the bridge without starting it. Used by `doctor` and `status`. */ export async function getBridgeStatus(): Promise { - const pidInfo = readPidFile(); - if (!pidInfo) { + const expectedVersion = getPackageVersion(); + const base: BridgeStatus = { + pidFileExists: false, + processAlive: false, + healthy: false, + port: null, + pid: null, + runningVersion: null, + expectedVersion, + versionSkew: false, + stalePidFile: false, + }; + + // A live bridge is the best source of truth, wherever its port came from. + for (const { port, health } of await probeAll(candidatePorts())) { + if (!isOurBridge(health)) continue; return { - pidFileExists: false, - processAlive: false, - healthy: false, - port: null, - pid: null, + ...base, + pidFileExists: existsSync(PID_FILE), + processAlive: true, + healthy: isUsableBridge(health, expectedVersion), + port, + pid: health.pid > 0 ? health.pid : (readPidFile()?.pid ?? null), + runningVersion: health.version, + versionSkew: health.version !== expectedVersion, }; } - const alive = isProcessAlive(pidInfo.pid); - const healthy = alive ? await isBridgeHealthy(pidInfo.port) : false; + + const info = readPidFile(); + if (!info) return base; + + const fromThisBoot = pidFileIsFromThisBoot(info); + const alive = fromThisBoot && isProcessAlive(info.pid); return { + ...base, pidFileExists: true, processAlive: alive, - healthy, - port: pidInfo.port, - pid: pidInfo.pid, + port: info.port, + pid: info.pid, + // Nothing answered on any port, so a PID file that survives is stale + // whether its process is gone or merely wedged. + stalePidFile: !alive || !fromThisBoot, }; } export type { LastSnapshotCache }; +/** The bridge to read from, without starting one. */ +async function activeBridge(): Promise<{ port: number; token: string | null } | null> { + const info = readPidFile(); + if (info) { + const health = await probeHealth(info.port); + if (isUsableBridge(health, getPackageVersion())) { + return { port: info.port, token: info.token ?? null }; + } + } + const port = await findUsableBridge(candidatePorts()); + if (port === null) return null; + return { port, token: readBridgeToken() }; +} + /** Retrieve the most recent snapshot the bridge has cached, without triggering a new one. */ export async function getLastSnapshot(): Promise { - const pidInfo = readPidFile(); - if (!pidInfo || !isProcessAlive(pidInfo.pid)) return null; + const bridge = await activeBridge(); + if (bridge === null) return null; try { - const resp = await httpGet(pidInfo.port, "/last-snapshot", 2000, pidInfo.token); + const resp = await httpGet(bridge.port, "/last-snapshot", 2000, bridge.token); const data = JSON.parse(resp) as { error?: string } & Partial; if (data.error || !data.raw) return null; - return { raw: data.raw, pageUrl: data.pageUrl ?? null, capturedAt: data.capturedAt ?? 0 }; + return { + raw: data.raw, + pageUrl: data.pageUrl ?? null, + capturedAt: data.capturedAt ?? 0, + }; } catch { return null; } } export async function getSessionSnapshotIfRunning(): Promise { - const pidInfo = readPidFile(); - if (!pidInfo || !isProcessAlive(pidInfo.pid)) { - return null; - } - if (!(await isBridgeHealthy(pidInfo.port))) { - return null; - } + const bridge = await activeBridge(); + if (bridge === null) return null; try { const resp = await httpPost( - pidInfo.port, + bridge.port, "/call", { name: "take_snapshot", args: {} }, 5000, undefined, - pidInfo.token, + bridge.token, ); const data = JSON.parse(resp); if (data.error) return null; @@ -497,18 +1238,3 @@ export async function getSessionSnapshotIfRunning(): Promise { return null; } } - -/** - * Stop the bridge process. - */ -export function stopBridge(): boolean { - const pidInfo = readPidFile(); - if (!pidInfo) { - return false; - } - if (isProcessAlive(pidInfo.pid)) { - process.kill(pidInfo.pid, "SIGTERM"); - return true; - } - return false; -} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..a624b0a --- /dev/null +++ b/src/config.ts @@ -0,0 +1,215 @@ +/** + * Reading, writing, validating, and — on a fresh machine — inventing the + * configuration file. + * + * The guiding rule: config is a cache of decisions, not a prerequisite. A user + * who has never run `setup` should get a working browser on their first + * command, not a hint telling them to go and configure something. Detection is + * cheap and unambiguous on the platforms Opera ships for, so there is nothing + * worth asking about up front. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { getConfigFile, getStateDir, parseConfigValue } from "./client.js"; +import { detectBrowser, type DetectedBrowser } from "./detect.js"; +import { defaultProfileDir } from "./profile.js"; + +/** + * Every variable the CLI reads. Used to catch typos, which otherwise sit in the + * config file doing nothing and looking correct. + */ +export const KNOWN_CONFIG_KEYS = [ + "OPERA_CLI_PORT", + "OPERA_CLI_MCP_BIN", + "OPERA_CLI_EXECUTABLE_PATH", + "OPERA_CLI_BROWSER_URL", + "OPERA_CLI_USER_DATA_DIR", + "OPERA_CLI_HEADED", + "OPERA_CLI_CHROME_ARGS", + "OPERA_CLI_ENABLE_HOOKS", + "OPERA_CLI_TAKEOVER", + "OPERA_CLI_DEV", +] as const; + +/** Levenshtein distance, capped — only used to suggest a corrected key. */ +function editDistance(a: string, b: string): number { + const rows = a.length + 1; + const cols = b.length + 1; + let prev = Array.from({ length: cols }, (_, i) => i); + for (let i = 1; i < rows; i++) { + const curr = [i, ...Array(cols - 1).fill(0)]; + for (let j = 1; j < cols; j++) { + curr[j] = Math.min( + prev[j]! + 1, + curr[j - 1]! + 1, + prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1), + ); + } + prev = curr; + } + return prev[cols - 1]!; +} + +export interface UnknownKey { + key: string; + suggestion: string | null; +} + +/** Config keys the CLI does not read, with a likely intended key where obvious. */ +export function findUnknownConfigKeys( + config: Record, +): UnknownKey[] { + const known = new Set(KNOWN_CONFIG_KEYS); + const unknown: UnknownKey[] = []; + for (const key of Object.keys(config)) { + if (known.has(key)) continue; + let best: string | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (const candidate of KNOWN_CONFIG_KEYS) { + const distance = editDistance(key, candidate); + if (distance < bestDistance) { + bestDistance = distance; + best = candidate; + } + } + // Scale the tolerance with key length. A fixed threshold is too tight for + // the common abbreviation typo (EXEC_PATH for EXECUTABLE_PATH is six + // edits) while still being loose enough to "suggest" a wholly unrelated + // key, which is worse than saying nothing. + const tolerance = Math.max(4, Math.ceil(key.length / 3)); + unknown.push({ key, suggestion: bestDistance <= tolerance ? best : null }); + } + return unknown; +} + +export function readConfigFile(): Record { + const configFile = getConfigFile(); + const config: Record = {}; + if (!existsSync(configFile)) return config; + try { + for (const line of readFileSync(configFile, "utf-8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + config[trimmed.slice(0, eq).trim()] = parseConfigValue( + trimmed.slice(eq + 1).trim(), + ); + } + } catch { + // Unreadable config is treated as absent — never fail a command over it. + } + return config; +} + +export function writeConfigFile(config: Record): void { + mkdirSync(getStateDir(), { recursive: true }); + const lines = [ + "# opera-browser-cli configuration — auto-loaded on every run", + "# Values here are used as defaults when the env var is not already set.", + "", + ...Object.entries(config).map( + ([key, value]) => `${key}="${value.replace(/"/g, '\\"')}"`, + ), + ]; + writeFileSync(getConfigFile(), lines.join("\n") + "\n"); +} + +/** Apply a patch to the config file. A null value removes the key. */ +export function updateConfigFile(patch: Record): void { + const config = readConfigFile(); + for (const [key, value] of Object.entries(patch)) { + if (value === null) delete config[key]; + else config[key] = value; + } + writeConfigFile(config); +} + +// --------------------------------------------------------------------------- +// First-run autoconfiguration +// --------------------------------------------------------------------------- + +export interface AutoConfigureOptions { + home?: string; + platform?: NodeJS.Platform; + exists?: (path: string) => boolean; + /** Write the result to disk. Off for previewing what would happen. */ + persist?: boolean; +} + +export type AutoConfigureResult = + | { status: "already-configured" } + | { status: "no-browser" } + | { + status: "configured"; + browser: DetectedBrowser; + settings: Record; + }; + +/** + * Decide the settings for a machine that has never been configured. + * + * Chooses the browser's real profile when there is one, rather than a private + * CLI profile: the point of using Opera is the session you are already signed + * in to. A profile that turns out to be in use is resolved at launch time — + * see `browser-target.ts` — so preferring it here costs nothing. + */ +export function computeAutoConfig( + options: AutoConfigureOptions = {}, +): AutoConfigureResult { + const home = options.home ?? homedir(); + const platform = options.platform ?? process.platform; + const exists = options.exists ?? existsSync; + + const alreadyConfigured = + exists(getConfigFile()) || + Boolean(process.env.OPERA_CLI_EXECUTABLE_PATH) || + Boolean(process.env.OPERA_CLI_BROWSER_URL); + if (alreadyConfigured) return { status: "already-configured" }; + + const browser = detectBrowser(platform, home, exists); + if (browser === null) return { status: "no-browser" }; + + const settings: Record = { + OPERA_CLI_EXECUTABLE_PATH: browser.path, + // Every Opera AI feature needs a window to sign in with; a real browser + // implies the user wants to see it. + OPERA_CLI_HEADED: "1", + OPERA_CLI_USER_DATA_DIR: + defaultProfileDir(browser.path, home, platform) ?? + join(getStateDir(), "profile"), + }; + + return { status: "configured", browser, settings }; +} + +/** Apply settings to this process, so the current command uses them too. */ +export function applySettingsToEnv(settings: Record): void { + for (const [key, value] of Object.entries(settings)) { + if (!(key in process.env)) process.env[key] = value; + } +} + +/** + * Configure a fresh machine, if it needs it. Returns what happened so the + * caller can tell the user in one line. + */ +export function autoConfigure( + options: AutoConfigureOptions = {}, +): AutoConfigureResult { + const result = computeAutoConfig(options); + if (result.status !== "configured") return result; + + if (options.persist !== false) { + try { + writeConfigFile(result.settings); + } catch { + // An unwritable state dir should not stop this run — the settings still + // apply in-process, and `doctor` reports the directory problem. + } + } + applySettingsToEnv(result.settings); + return result; +} diff --git a/src/detect.ts b/src/detect.ts new file mode 100644 index 0000000..c85077a --- /dev/null +++ b/src/detect.ts @@ -0,0 +1,99 @@ +/** + * Finding an installed browser. + * + * Opera Neon is preferred because it is the only build with the full Opera AI + * tool set; a plain Opera still gives `chat`. Anything else means the AI + * commands cannot work, which the caller reports rather than discovering + * halfway through a command. + */ + +import { existsSync } from "node:fs"; + +export function neonCandidatePaths( + platform: NodeJS.Platform = process.platform, + home: string = "", +): string[] { + if (platform === "darwin") { + return [ + "/Applications/Opera Neon.app/Contents/MacOS/Opera", + "/Applications/Opera Neon Developer.app/Contents/MacOS/Opera", + `${home}/Applications/Opera Neon.app/Contents/MacOS/Opera`, + `${home}/Applications/Opera Neon Developer.app/Contents/MacOS/Opera`, + ]; + } + if (platform === "win32") { + const localAppData = process.env.LOCALAPPDATA ?? `${home}\\AppData\\Local`; + const programFiles = process.env.PROGRAMFILES ?? "C:\\Program Files"; + return [ + `${localAppData}\\Programs\\Opera Neon\\opera.exe`, + `${programFiles}\\Opera Neon\\opera.exe`, + `${localAppData}\\Programs\\Opera Neon Developer\\opera.exe`, + `${programFiles}\\Opera Neon Developer\\opera.exe`, + ]; + } + // Opera Neon does not ship for Linux. + return []; +} + +export function operaCandidatePaths( + platform: NodeJS.Platform = process.platform, + home: string = "", +): string[] { + if (platform === "darwin") { + return [ + "/Applications/Opera GX.app/Contents/MacOS/Opera", + "/Applications/Opera.app/Contents/MacOS/Opera", + `${home}/Applications/Opera GX.app/Contents/MacOS/Opera`, + `${home}/Applications/Opera.app/Contents/MacOS/Opera`, + ]; + } + if (platform === "win32") { + const localAppData = process.env.LOCALAPPDATA ?? `${home}\\AppData\\Local`; + const programFiles = process.env.PROGRAMFILES ?? "C:\\Program Files"; + return [ + `${localAppData}\\Programs\\Opera GX\\opera.exe`, + `${localAppData}\\Programs\\Opera\\opera.exe`, + `${programFiles}\\Opera GX\\opera.exe`, + `${programFiles}\\Opera\\opera.exe`, + ]; + } + return []; +} + +export function browserDisplayName(binPath: string): string { + if (binPath.includes("Neon Developer")) return "Opera Neon Developer"; + if (binPath.includes("Neon")) return "Opera Neon"; + if (binPath.includes("GX")) return "Opera GX"; + return "Opera"; +} + +export interface DetectedBrowser { + path: string; + name: string; + /** Only Neon has invoke-do / make / research. */ + isNeon: boolean; +} + +/** Every Opera install we can find, Neon first. */ +export function detectBrowsers( + platform: NodeJS.Platform = process.platform, + home: string = "", + exists: (p: string) => boolean = existsSync, +): DetectedBrowser[] { + const neon = neonCandidatePaths(platform, home).filter(exists); + const opera = operaCandidatePaths(platform, home).filter(exists); + return [...neon, ...opera].map((path) => ({ + path, + name: browserDisplayName(path), + isNeon: neon.includes(path), + })); +} + +/** The browser to use when nobody has said which. */ +export function detectBrowser( + platform: NodeJS.Platform = process.platform, + home: string = "", + exists: (p: string) => boolean = existsSync, +): DetectedBrowser | null { + return detectBrowsers(platform, home, exists)[0] ?? null; +} diff --git a/src/identity.ts b/src/identity.ts new file mode 100644 index 0000000..7b19428 --- /dev/null +++ b/src/identity.ts @@ -0,0 +1,111 @@ +/** + * Bridge identity — the contract that lets a CLI process decide whether a + * process or a listening port is *our* bridge, and whether it is running the + * same code we are. + * + * Two problems this solves: + * + * 1. Version skew. A bridge started before an upgrade keeps serving stale + * code from memory and looks perfectly healthy. `version` makes the skew + * visible so the client can restart it. + * + * 2. PID recycling. After a reboot the PID in a leftover PID file may belong + * to an unrelated process. Signalling it would kill a stranger's process. + * `bootMinute` scopes a PID to the boot it was recorded in. + * + * The rule the client enforces: never signal a PID that has not been positively + * identified as our bridge, either by answering /health or by matching both the + * PID file and the current boot. + */ + +import { uptime } from "node:os"; + +export const BRIDGE_SERVER_NAME = "opera-browser-cli"; + +/** Payload returned by GET /health. */ +export interface BridgeHealth { + status: "ok" | "not-connected"; + server: string; + version: string; + pid: number; + startedAt: number; + bootMinute: number; + browser: { connected: boolean }; +} + +/** + * The instant this machine booted, in whole minutes since the epoch. + * + * Derived from uptime rather than stored, so any process can compute it + * independently. Uptime drifts by a second or two across suspend/resume and + * between processes, so callers must compare with `sameBoot` (±1 minute) + * rather than testing for equality. + */ +export function computeBootMinute( + nowMs: number = Date.now(), + uptimeSeconds: number = uptime(), +): number { + return Math.floor((nowMs - uptimeSeconds * 1000) / 60_000); +} + +/** + * True when two boot minutes describe the same boot. + * + * The ±1 tolerance absorbs both uptime drift and the case where two processes + * compute the value either side of a minute boundary. A false negative is + * cheap (we decline to signal a PID and start a fresh bridge on another port); + * a false positive would mean signalling a stranger, so the tolerance stays + * tight. + */ +export function sameBoot(a: number, b: number): boolean { + return Math.abs(a - b) <= 1; +} + +/** True when the payload came from a bridge of ours (any version). */ +export function isOurBridge(health: BridgeHealth | null): health is BridgeHealth { + return health !== null && health.server === BRIDGE_SERVER_NAME; +} + +/** True when the bridge is ours, connected, and running our exact version. */ +export function isUsableBridge( + health: BridgeHealth | null, + ourVersion: string, +): health is BridgeHealth { + return ( + isOurBridge(health) && + health.status === "ok" && + health.version === ourVersion + ); +} + +/** + * Parse a /health response body. Returns null for anything that is not a + * well-formed bridge identity — a foreign server, an HTML error page, or a + * bridge old enough to predate the identity fields. + */ +export function parseHealth(body: string): BridgeHealth | null { + let data: unknown; + try { + data = JSON.parse(body); + } catch { + return null; + } + if (!data || typeof data !== "object") return null; + const record = data as Record; + if (record.server !== BRIDGE_SERVER_NAME) return null; + + const browser = record.browser as { connected?: unknown } | undefined; + return { + status: record.status === "ok" ? "ok" : "not-connected", + server: BRIDGE_SERVER_NAME, + // Pre-identity bridges (<= 0.1.45) omit these. Coercing to sentinel values + // rather than rejecting keeps them recognisable as ours — which is what + // lets the client shut one down instead of colliding with it. + version: typeof record.version === "string" ? record.version : "unknown", + pid: typeof record.pid === "number" ? record.pid : 0, + startedAt: typeof record.startedAt === "number" ? record.startedAt : 0, + bootMinute: + typeof record.bootMinute === "number" ? record.bootMinute : Number.NaN, + browser: { connected: browser?.connected === true }, + }; +} diff --git a/src/profile.ts b/src/profile.ts new file mode 100644 index 0000000..b89619b --- /dev/null +++ b/src/profile.ts @@ -0,0 +1,223 @@ +/** + * Browser profile inspection — is this user-data-dir in use, and if so, can we + * talk to the browser that holds it? + * + * Chromium refuses to start a second instance on a user-data-dir that is + * already open: it hands its command line to the running instance through a + * singleton socket and exits. Launching into a live profile therefore does not + * fail loudly, it fails as "the browser we asked for never appeared" — which is + * why this has to be detected before launch rather than diagnosed after it. + * + * Two files in the user-data-dir root tell us what we need: + * + * SingletonLock a symlink whose target is "-" (POSIX). + * Present and live => the profile is in use. + * DevToolsActivePort written whenever the browser was started with + * --remote-debugging-port. Line 1 is the port. Its + * presence is what makes attaching to an already-running + * browser possible without any configuration. + * + * Neither file is authoritative on its own: SingletonLock outlives a crash, and + * DevToolsActivePort outlives a clean exit. Both are confirmed against the live + * system before being acted on. + */ + +import { existsSync, lstatSync, readFileSync, readlinkSync } from "node:fs"; +import { hostname } from "node:os"; +import { join } from "node:path"; +import { request } from "node:http"; + +export type ProfileLockState = + /** No lock file, or the lock belongs to a process that is gone. */ + | "free" + /** A live process on this machine holds the profile. */ + | "locked" + /** A lock exists but we cannot attribute it — another host, or unreadable. */ + | "unknown"; + +export interface ProfileLock { + state: ProfileLockState; + /** The owning browser process, when the lock names one we can verify. */ + pid: number | null; + hostname: string | null; +} + +/** + * Split a SingletonLock target into hostname and pid. + * + * The hostname routinely contains dashes ("Someones-MacBook-Pro-24601"), so the + * split has to come from the right. + */ +export function parseSingletonTarget( + target: string, +): { hostname: string; pid: number } | null { + const split = target.lastIndexOf("-"); + if (split <= 0) return null; + const pid = Number.parseInt(target.slice(split + 1), 10); + if (!Number.isInteger(pid) || pid <= 0) return null; + return { hostname: target.slice(0, split), pid }; +} + +/** First line of DevToolsActivePort is the port; the second is a ws path. */ +export function parseDevToolsActivePort(contents: string): number | null { + const first = contents.split("\n")[0]?.trim() ?? ""; + const port = Number.parseInt(first, 10); + if (!Number.isInteger(port) || port <= 0 || port > 65_535) return null; + return port; +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM means it exists but belongs to another user — still alive. + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +/** + * Determine whether a user-data-dir is currently held by a running browser. + * + * A dangling lock reads as "free": Chromium cleans those up itself on the next + * launch, so treating one as a conflict would block a launch that would in fact + * succeed. + */ +export function inspectProfileLock( + userDataDir: string, + aliveCheck: (pid: number) => boolean = isProcessAlive, +): ProfileLock { + const lockPath = join(userDataDir, "SingletonLock"); + + let target: string; + try { + // lstat, not stat: the link is expected to dangle after a crash, and a + // dangling symlink is exactly the case we want to report as free. + if (!lstatSync(lockPath).isSymbolicLink()) { + // Windows writes a regular file instead of a symlink. We can see that the + // profile is claimed but not by whom. + return { state: "unknown", pid: null, hostname: null }; + } + target = readlinkSync(lockPath); + } catch { + return { state: "free", pid: null, hostname: null }; + } + + const parsed = parseSingletonTarget(target); + if (parsed === null) return { state: "unknown", pid: null, hostname: null }; + + // A lock written by a different machine (a synced or networked profile) says + // nothing about processes here, and its pid must never be signalled. + if (parsed.hostname !== hostname()) { + return { state: "unknown", pid: null, hostname: parsed.hostname }; + } + if (!aliveCheck(parsed.pid)) { + return { state: "free", pid: null, hostname: parsed.hostname }; + } + return { state: "locked", pid: parsed.pid, hostname: parsed.hostname }; +} + +/** The debug port a running browser advertised, if it was given one. */ +export function readDevToolsPort(userDataDir: string): number | null { + const portFile = join(userDataDir, "DevToolsActivePort"); + try { + if (!existsSync(portFile)) return null; + return parseDevToolsActivePort(readFileSync(portFile, "utf-8")); + } catch { + return null; + } +} + +export interface DevToolsIdentity { + /** e.g. "Opera/121.0.0.0" or "Chrome/141.0.0.0" */ + browser: string; + isOpera: boolean; +} + +/** + * Confirm a debug port is live and find out what is on the other end. + * + * DevToolsActivePort survives a clean exit, so a recorded port proves nothing + * until something answers on it. + */ +export function probeDevToolsEndpoint( + port: number, + timeoutMs = 1500, +): Promise { + return new Promise((resolve) => { + const req = request( + { + hostname: "127.0.0.1", + port, + path: "/json/version", + method: "GET", + timeout: timeoutMs, + }, + (res) => { + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => { + try { + const parsed = JSON.parse(body) as { Browser?: unknown }; + if (typeof parsed.Browser !== "string") return resolve(null); + resolve({ + browser: parsed.Browser, + isOpera: /opera|opr\//i.test(parsed.Browser), + }); + } catch { + resolve(null); + } + }); + }, + ); + req.on("error", () => resolve(null)); + req.on("timeout", () => { + req.destroy(); + resolve(null); + }); + req.end(); + }); +} + +/** + * The browser URL to attach to for this profile, or null if there is nothing + * live to attach to. + */ +export async function findAttachableEndpoint( + userDataDir: string, +): Promise<{ url: string; identity: DevToolsIdentity } | null> { + const port = readDevToolsPort(userDataDir); + if (port === null) return null; + const identity = await probeDevToolsEndpoint(port); + if (identity === null) return null; + return { url: `http://127.0.0.1:${port}`, identity }; +} + +// --------------------------------------------------------------------------- +// Default profile locations +// --------------------------------------------------------------------------- + +/** Where the given Opera build keeps its real profile, if we can find it. */ +export function defaultProfileDir( + browserPath: string | undefined, + home: string, + platform: NodeJS.Platform = process.platform, +): string | null { + let candidate: string; + if (platform === "darwin") { + const isDeveloper = browserPath?.includes("Opera Neon Developer.app") ?? false; + const bundle = isDeveloper + ? "com.operasoftware.OperaNeonDeveloper" + : "com.operasoftware.OperaNeon"; + candidate = `${home}/Library/Application Support/${bundle}`; + } else if (platform === "win32") { + const appData = process.env.APPDATA ?? `${home}\\AppData\\Roaming`; + const isDeveloper = browserPath?.includes("Developer") ?? false; + candidate = isDeveloper + ? `${appData}\\Opera Software\\Opera Neon Developer` + : `${appData}\\Opera Software\\Opera Neon`; + } else { + return null; + } + return existsSync(candidate) ? candidate : null; +} diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..88c4b29 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,39 @@ +/** + * Package version lookup, shared by the CLI, the bridge, and the health contract. + * + * Resolution walks up from this module so it works both from source + * (`src/version.ts` → `../package.json`) and from the build output + * (`dist/src/version.js` → `../../package.json`). + */ + +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +let cached: string | null = null; + +export function getPackageVersion(): string { + if (cached !== null) return cached; + + const here = dirname(fileURLToPath(import.meta.url)); + for (const candidate of [ + join(here, "..", "package.json"), + join(here, "..", "..", "package.json"), + ]) { + if (!existsSync(candidate)) continue; + const parsed = JSON.parse(readFileSync(candidate, "utf-8")) as { + version?: unknown; + }; + if (typeof parsed.version === "string" && parsed.version.length > 0) { + cached = parsed.version; + return cached; + } + } + + throw new Error("Could not determine opera-browser-cli package version"); +} + +/** Reset the memoised version — for use in tests only. */ +export function resetVersionCache(): void { + cached = null; +} diff --git a/test/bridge-lifecycle.test.ts b/test/bridge-lifecycle.test.ts new file mode 100644 index 0000000..3470b1a --- /dev/null +++ b/test/bridge-lifecycle.test.ts @@ -0,0 +1,267 @@ +/** + * Bridge discovery and shutdown, exercised against real loopback servers and + * real processes — no browser involved. + * + * The assertions that matter most here are the negative ones: that a PID we + * have not positively identified is never signalled, and that a foreign server + * is never touched. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createServer, type Server } from "node:http"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; +import { BRIDGE_SERVER_NAME, computeBootMinute } from "../src/identity.js"; +import { getPackageVersion } from "../src/version.js"; + +type ClientModule = typeof import("../src/client.js"); + +let home: string; +let basePort: number; +let client: ClientModule; +const servers: Server[] = []; +const children: ChildProcess[] = []; + +/** A base port unlikely to collide with anything else on the machine or in CI. */ +function pickBasePort(): number { + return 41_000 + Math.floor(Math.random() * 100) * 10; +} + +function pidFilePath(): string { + return join(home, ".opera-browser-cli", "bridge.pid"); +} + +function writePidFile(contents: Record): void { + mkdirSync(join(home, ".opera-browser-cli"), { recursive: true }); + writeFileSync(pidFilePath(), JSON.stringify(contents)); +} + +function bridgeHealth(overrides: Record = {}): Record { + return { + status: "ok", + server: BRIDGE_SERVER_NAME, + version: getPackageVersion(), + pid: 0, + startedAt: Date.now(), + bootMinute: computeBootMinute(), + browser: { connected: true }, + ...overrides, + }; +} + +/** Stand in for a bridge on `port`, answering /health with whatever we say. */ +function startFakeServer(port: number, payload: unknown): Promise { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + res.setHeader("Content-Type", "application/json"); + if (req.url === "/health") { + res.statusCode = 200; + res.end(JSON.stringify(payload)); + return; + } + res.statusCode = 404; + res.end("{}"); + }); + servers.push(server); + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve(server)); + }); +} + +interface Victim { + pid: number; + exited: Promise; + hasExited: () => boolean; +} + +/** A real, long-lived process we can assert is — or crucially is not — killed. */ +function startVictim(): Victim { + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + stdio: "ignore", + }); + children.push(child); + let done = false; + const exited = new Promise((resolve) => { + child.on("exit", () => { + done = true; + resolve(); + }); + }); + return { pid: child.pid!, exited, hasExited: () => done }; +} + +function settle(ms = 400): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +beforeEach(async () => { + home = mkdtempSync(join(tmpdir(), "obc-lifecycle-")); + basePort = pickBasePort(); + vi.stubEnv("HOME", home); + vi.stubEnv("USERPROFILE", home); + vi.stubEnv("OPERA_CLI_PORT", String(basePort)); + vi.resetModules(); + // Imported after HOME is stubbed: the state dir is resolved at module load. + client = await import("../src/client.js"); +}); + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (s) => new Promise((resolve) => s.close(() => resolve())), + ), + ); + for (const child of children.splice(0)) { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + } + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); +}); + +describe("PID recycling safety", () => { + it("never signals a PID recorded on a previous boot", async () => { + // The exact post-reboot shape: the PID file survives, the number in it now + // belongs to somebody else's process. Signalling it would kill a stranger. + const victim = startVictim(); + writePidFile({ + pid: victim.pid, + port: basePort, + token: "stale-token", + version: getPackageVersion(), + startedAt: 0, + bootMinute: 1, // long before this machine booted + }); + + const result = await client.stopBridge(); + + expect(result.stale).toBe(true); + expect(result.stopped).toBe(false); + await settle(); + expect(victim.hasExited()).toBe(false); + // ...and the misleading file is cleared so it cannot mislead twice. + expect(existsSync(pidFilePath())).toBe(false); + }); + + it("does signal a PID recorded on this boot", async () => { + const victim = startVictim(); + writePidFile({ + pid: victim.pid, + port: basePort, + token: "t", + version: getPackageVersion(), + startedAt: Date.now(), + bootMinute: computeBootMinute(), + }); + + const result = await client.stopBridge(); + + expect(result.stopped).toBe(true); + expect(result.pid).toBe(victim.pid); + await victim.exited; + }); + + it("reports a stale pid file through getBridgeStatus without signalling", async () => { + const victim = startVictim(); + writePidFile({ + pid: victim.pid, + port: basePort, + bootMinute: 1, + }); + + const status = await client.getBridgeStatus(); + + expect(status.stalePidFile).toBe(true); + expect(status.processAlive).toBe(false); + await settle(150); + expect(victim.hasExited()).toBe(false); + }); +}); + +describe("version skew", () => { + it("refuses a bridge running different code and shuts it down", async () => { + // A pre-upgrade bridge answers /health perfectly while serving stale code. + const victim = startVictim(); + await startFakeServer( + basePort, + bridgeHealth({ version: "0.0.1-old", pid: victim.pid }), + ); + + const found = await client.findUsableBridge(client.candidatePorts()); + + expect(found).toBeNull(); + await victim.exited; + }); + + it("accepts a bridge on our version", async () => { + await startFakeServer(basePort, bridgeHealth({ pid: process.pid })); + + expect(await client.findUsableBridge(client.candidatePorts())).toBe(basePort); + }); + + it("surfaces the skew in getBridgeStatus", async () => { + await startFakeServer( + basePort, + bridgeHealth({ version: "0.0.1-old", pid: process.pid }), + ); + + const status = await client.getBridgeStatus(); + + expect(status.versionSkew).toBe(true); + expect(status.healthy).toBe(false); + expect(status.runningVersion).toBe("0.0.1-old"); + expect(status.expectedVersion).toBe(getPackageVersion()); + }); +}); + +describe("port discovery", () => { + it("finds a bridge that landed on a fallback port", async () => { + await startFakeServer(basePort + 3, bridgeHealth({ pid: process.pid })); + + expect(await client.findUsableBridge(client.candidatePorts())).toBe(basePort + 3); + }); + + it("prefers the port named in the pid file", async () => { + await startFakeServer(basePort + 1, bridgeHealth({ pid: process.pid })); + await startFakeServer(basePort + 5, bridgeHealth({ pid: process.pid })); + writePidFile({ pid: process.pid, port: basePort + 5, bootMinute: computeBootMinute() }); + + expect(await client.findUsableBridge(client.candidatePorts())).toBe(basePort + 5); + }); + + it("ignores a foreign server and leaves it running", async () => { + const foreign = await startFakeServer(basePort, { + status: "ok", + server: "some-other-dev-server", + }); + + expect(await client.findUsableBridge(client.candidatePorts())).toBeNull(); + expect(foreign.listening).toBe(true); + }); + + it("treats an unparseable /health as not ours", async () => { + await startFakeServer(basePort, "hello"); + + expect(await client.findUsableBridge(client.candidatePorts())).toBeNull(); + }); +}); + +describe("stopBridge", () => { + it("is a no-op when nothing is running", async () => { + const result = await client.stopBridge(); + + expect(result).toMatchObject({ stopped: false, stale: false, pid: null }); + }); + + it("stops a live bridge found by port scan even with no pid file", async () => { + const victim = startVictim(); + await startFakeServer(basePort + 2, bridgeHealth({ pid: victim.pid })); + + const result = await client.stopBridge(); + + expect(result.stopped).toBe(true); + expect(result.port).toBe(basePort + 2); + await victim.exited; + }); +}); diff --git a/test/bridge-recovery.test.ts b/test/bridge-recovery.test.ts new file mode 100644 index 0000000..1c466b2 --- /dev/null +++ b/test/bridge-recovery.test.ts @@ -0,0 +1,213 @@ +/** + * Recovery from a bridge that goes away mid-session. + * + * The rule under test: a dropped connection is repaired silently for ordinary + * calls, and never silently for the Opera AI tools, which may already have + * acted on the page and are billable to re-run. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createServer } from "node:http"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +type ClientModule = typeof import("../src/client.js"); + +const HERE = dirname(fileURLToPath(import.meta.url)); +const STUB_MCP = join(HERE, "fixtures", "stub-mcp.js"); + +let home: string; +let basePort: number; +let client: ClientModule; + +function pickBasePort(): number { + return 43_000 + Math.floor(Math.random() * 100) * 10; +} + +function pidFilePath(): string { + return join(home, ".opera-browser-cli", "bridge.pid"); +} + +/** Kill the running bridge and its children outright, as a crash would. */ +async function crashBridge(): Promise { + const status = await client.getBridgeStatus(); + const pid = status.pid!; + try { + process.kill(-pid, "SIGKILL"); // the bridge leads its own process group + } catch { + process.kill(pid, "SIGKILL"); + } + // Wait for the port to actually go quiet. + for (let i = 0; i < 50; i++) { + if (!(await client.getBridgeStatus()).processAlive) break; + await new Promise((r) => setTimeout(r, 100)); + } + return pid; +} + +beforeEach(async () => { + home = mkdtempSync(join(tmpdir(), "obc-recovery-")); + basePort = pickBasePort(); + vi.stubEnv("HOME", home); + vi.stubEnv("USERPROFILE", home); + vi.stubEnv("OPERA_CLI_PORT", String(basePort)); + vi.stubEnv("OPERA_CLI_MCP_BIN", STUB_MCP); + vi.resetModules(); + client = await import("../src/client.js"); +}); + +afterEach(async () => { + await client.stopBridge(); + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); +}, 60_000); + +describe("recovery from a dropped bridge", () => { + it("restarts and replays an ordinary tool call", async () => { + await client.ensureBridge(); + const deadPid = await crashBridge(); + + // No manual restart, no error surfaced to the caller. + const result = await client.callTool("take_snapshot"); + + expect(result).toBe("stub:take_snapshot"); + const status = await client.getBridgeStatus(); + expect(status.healthy).toBe(true); + expect(status.pid).not.toBe(deadPid); + }, 90_000); + + it("recovers from a stale auth token by reissuing one", async () => { + await client.ensureBridge(); + + // The shape this takes in the wild: the pid file and the running bridge + // disagree about the token, so every call comes back 401. + const info = JSON.parse(readFileSync(pidFilePath(), "utf-8")); + writeFileSync(pidFilePath(), JSON.stringify({ ...info, token: "wrong-token" })); + + const result = await client.callTool("take_snapshot"); + + expect(result).toBe("stub:take_snapshot"); + }, 90_000); + + it("does not replay opera_do when the bridge dies mid-call", async () => { + // The dangerous case: the tool was already running, so it may have booked + // the table before the bridge went away. Re-running it must be the user's + // call, not ours. + await client.ensureBridge(); + const call = client.callTool("opera_do", { prompt: "book a table" }); + setTimeout(() => void crashBridge(), 500); + + await expect(call).rejects.toThrow(/not retried automatically/); + }, 90_000); + + it("treats an unreachable browser target as a real failure, not a fake success", async () => { + // The wedge: the bridge answers (stdio up) but the browser it was told to + // drive is dead. Previously the CLI handed back the "Could not connect to + // Chrome" text as if it were a successful result. + const unreachable = join(HERE, "fixtures", "stub-mcp-unreachable.js"); + vi.stubEnv("OPERA_CLI_MCP_BIN", unreachable); + vi.resetModules(); + client = await import("../src/client.js"); + + await expect(client.callTool("take_snapshot", {})).rejects.toMatchObject({ + code: "BROWSER_ERROR", + }); + }, 90_000); + + it("recovers from an unreachable browser when the rebuilt bridge reaches one", async () => { + // First bridge points at a dead browser. Mid-test we repoint the env at a + // healthy stub, so the recovery rebuild picks a working target and succeeds. + const unreachable = join(HERE, "fixtures", "stub-mcp-unreachable.js"); + vi.stubEnv("OPERA_CLI_MCP_BIN", unreachable); + vi.resetModules(); + client = await import("../src/client.js"); + + // Ensure the wedged bridge exists up front. + await client.ensureBridge(); + // Repoint to the healthy stub for the recovery rebuild. + vi.stubEnv("OPERA_CLI_MCP_BIN", STUB_MCP); + vi.resetModules(); + client = await import("../src/client.js"); + + const result = await client.callTool("take_snapshot", {}); + expect(result).toBe("stub:take_snapshot"); + }, 90_000); + + it("reports a bridge as unusable when its attach target is unreachable", async () => { + // A fake CDP endpoint that /health should probe. With it live the bridge is + // usable; once it goes away, the CLI must stop reusing the bridge (the + // agent-side wedge) and report it unhealthy. + const cdp = createServer((req, res) => { + if (req.url === "/json/version") { + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ Browser: "Opera/121.0.0.0" })); + } else { + res.end("{}"); + } + }); + await new Promise((resolve) => + cdp.listen(0, "127.0.0.1", () => resolve()), + ); + const browserPort = (cdp.address() as { port: number }).port; + + vi.stubEnv("OPERA_CLI_BROWSER_URL", `http://127.0.0.1:${browserPort}`); + vi.resetModules(); + client = await import("../src/client.js"); + + await client.ensureBridge(); + // Live target → the bridge is usable and reused. + expect(await client.findUsableBridge(client.candidatePorts())).not.toBeNull(); + + // Kill the browser target. The bridge's MCP-over-stdio link is still up, so + // only the reachability probe can tell that it is wedged. + await new Promise((resolve) => cdp.close(() => resolve())); + await new Promise((r) => setTimeout(r, 400)); + + expect(await client.findUsableBridge(client.candidatePorts())).toBeNull(); + expect((await client.getBridgeStatus()).healthy).toBe(false); + }, 90_000); + + it("tells the user to re-run rather than leaving them guessing", async () => { + await client.ensureBridge(); + const call = client.callTool("opera_make", { prompt: "a todo app" }); + setTimeout(() => void crashBridge(), 500); + + await expect(call).rejects.toMatchObject({ + code: "BRIDGE_NOT_READY", + suggestions: expect.arrayContaining([expect.stringContaining("Re-run")]), + }); + }, 90_000); + + it("runs an AI tool normally when the bridge was already down", async () => { + // Distinct from the case above: nothing had started, so there is no risk of + // a double booking. Starting a bridge and running it once is the whole job. + await client.ensureBridge(); + await crashBridge(); + + await expect(client.callTool("opera_do", { prompt: "book a table" })).resolves.toBe( + "stub:opera_do", + ); + }, 90_000); + + it("gives up with a clear error when the bridge cannot come back", async () => { + await client.ensureBridge(); + await crashBridge(); + // Break the restart too, so recovery has nowhere to go. + vi.stubEnv("OPERA_CLI_MCP_BIN", join(HERE, "fixtures", "no-such-mcp-binary")); + + await expect(client.callTool("take_snapshot")).rejects.toThrow( + /opera-devtools-mcp/, + ); + }, 90_000); + + it("leaves no pid file behind after a crash and recovery cycle", async () => { + await client.ensureBridge(); + await crashBridge(); + await client.callTool("take_snapshot"); + await client.stopBridge(); + + expect(existsSync(pidFilePath())).toBe(false); + }, 90_000); +}); diff --git a/test/bridge-startup.test.ts b/test/bridge-startup.test.ts new file mode 100644 index 0000000..c631579 --- /dev/null +++ b/test/bridge-startup.test.ts @@ -0,0 +1,192 @@ +/** + * The real bridge start path — actual child processes, actual ports — with a + * stub MCP server in place of opera-devtools-mcp so no browser is launched. + * + * Covers the three things that used to fail silently: the startup handshake, + * losing a port race, and several CLI processes starting at once. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createServer as createTcpServer, type Server as TcpServer } from "node:net"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { getPackageVersion } from "../src/version.js"; + +type ClientModule = typeof import("../src/client.js"); + +const HERE = dirname(fileURLToPath(import.meta.url)); +const STUB_MCP = join(HERE, "fixtures", "stub-mcp.js"); + +let home: string; +let basePort: number; +let client: ClientModule; +const blockers: TcpServer[] = []; + +function pickBasePort(): number { + return 42_000 + Math.floor(Math.random() * 100) * 10; +} + +/** + * Hold a port with a plain TCP listener that never speaks HTTP. + * + * Connections are dropped on arrival so a probe fails instantly rather than + * waiting out its timeout, and so nothing is left open to stall teardown. + */ +function blockPort(port: number): Promise { + return new Promise((resolve, reject) => { + const server = createTcpServer((socket) => socket.destroy()); + blockers.push(server); + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve()); + }); +} + +beforeEach(async () => { + home = mkdtempSync(join(tmpdir(), "obc-startup-")); + basePort = pickBasePort(); + vi.stubEnv("HOME", home); + vi.stubEnv("USERPROFILE", home); + vi.stubEnv("OPERA_CLI_PORT", String(basePort)); + vi.stubEnv("OPERA_CLI_MCP_BIN", STUB_MCP); + vi.resetModules(); + client = await import("../src/client.js"); +}); + +afterEach(async () => { + await client.stopBridge(); + await Promise.all( + blockers.splice(0).map( + (s) => new Promise((resolve) => s.close(() => resolve())), + ), + ); + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); +}, 60_000); + +describe("bridge startup", () => { + it("starts a bridge and reports it as healthy", async () => { + const port = await client.ensureBridge(); + + expect(port).toBe(basePort); + const status = await client.getBridgeStatus(); + expect(status.healthy).toBe(true); + expect(status.versionSkew).toBe(false); + expect(status.runningVersion).toBe(getPackageVersion()); + expect(status.port).toBe(basePort); + }, 60_000); + + it("reuses a running bridge instead of starting a second one", async () => { + const first = await client.ensureBridge(); + const second = await client.ensureBridge(); + + expect(second).toBe(first); + const status = await client.getBridgeStatus(); + expect(status.pid).not.toBeNull(); + }, 60_000); + + it("falls back to the next port when the base port is taken", async () => { + // A plain TCP listener: it answers no HTTP, so the probe cannot recognise + // it, and only the bridge's own EADDRINUSE handling can resolve the clash. + await blockPort(basePort); + + const port = await client.ensureBridge(); + + expect(port).toBe(basePort + 1); + }, 60_000); + + it("skips over several occupied ports", async () => { + await blockPort(basePort); + await blockPort(basePort + 1); + await blockPort(basePort + 2); + + const port = await client.ensureBridge(); + + expect(port).toBe(basePort + 3); + }, 90_000); + + it("starts exactly one bridge when several commands race", async () => { + const results = await Promise.all([ + client.ensureBridge(), + client.ensureBridge(), + client.ensureBridge(), + client.ensureBridge(), + client.ensureBridge(), + ]); + + // All five callers succeed, and all of them are pointed at the same bridge. + expect(new Set(results).size).toBe(1); + expect(results[0]).toBe(basePort); + }, 90_000); + + it("restarts into a new process", async () => { + await client.ensureBridge(); + const before = (await client.getBridgeStatus()).pid; + + await client.restartBridge(); + const after = await client.getBridgeStatus(); + + expect(after.healthy).toBe(true); + expect(after.pid).not.toBe(before); + }, 90_000); + + it("stops cleanly and leaves no pid file", async () => { + await client.ensureBridge(); + + const result = await client.stopBridge(); + + expect(result.stopped).toBe(true); + expect(result.forced).toBe(false); + expect((await client.getBridgeStatus()).pidFileExists).toBe(false); + }, 60_000); +}); + +describe("log hygiene", () => { + it("rotates an oversized bridge log instead of appending forever", async () => { + const stateDir = join(home, ".opera-browser-cli"); + mkdirSync(stateDir, { recursive: true }); + const logFile = join(stateDir, "bridge.log"); + writeFileSync(logFile, "x".repeat(6 * 1024 * 1024)); + + await client.ensureBridge(); + + expect(existsSync(`${logFile}.1`)).toBe(true); + expect(statSync(logFile).size).toBeLessThan(1024 * 1024); + }, 60_000); +}); + +describe("bridge startup failures", () => { + it("names the MCP server when it cannot be started", async () => { + vi.stubEnv("OPERA_CLI_MCP_BIN", join(HERE, "fixtures", "no-such-mcp-binary")); + + await expect(client.ensureBridge()).rejects.toThrow(/opera-devtools-mcp/); + }, 60_000); + + it("fails fast rather than waiting out the startup timeout", async () => { + vi.stubEnv("OPERA_CLI_MCP_BIN", join(HERE, "fixtures", "no-such-mcp-binary")); + + const started = Date.now(); + await expect(client.ensureBridge()).rejects.toThrow(); + + // The point of the handshake: a child that dies immediately is noticed + // immediately, instead of costing the full 30s poll. + expect(Date.now() - started).toBeLessThan(15_000); + }, 60_000); + + it("suggests checking the bridge log", async () => { + vi.stubEnv("OPERA_CLI_MCP_BIN", join(HERE, "fixtures", "no-such-mcp-binary")); + + await expect(client.ensureBridge()).rejects.toMatchObject({ + code: "BRIDGE_NOT_READY", + suggestions: expect.arrayContaining([expect.stringContaining("logs")]), + }); + }, 60_000); +}); diff --git a/test/bridge.test.ts b/test/bridge.test.ts index ff4d3eb..7204879 100644 --- a/test/bridge.test.ts +++ b/test/bridge.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; @@ -15,6 +18,7 @@ import { isBridgeClientConnected, isLoopbackHost, parseBridgeCallPayload, + resolveBridgeLauncher, resolveBridgeScript, resetLastSnapshotCache, wrapTransportForIdCapture, @@ -65,6 +69,70 @@ describe("resolveBridgeScript", () => { }); }); +describe("resolveBridgeLauncher", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "obc-launcher-")); + mkdirSync(join(dir, "bin"), { recursive: true }); + mkdirSync(join(dir, "src"), { recursive: true }); + }); + + afterEach(() => { + delete process.env.OPERA_CLI_DEV; + rmSync(dir, { recursive: true, force: true }); + }); + + it("prefers the built JavaScript entrypoint", () => { + writeFileSync(join(dir, "bin", "opera-browser-cli-bridge.js"), ""); + writeFileSync(join(dir, "bin", "opera-browser-cli-bridge.ts"), ""); + + const launcher = resolveBridgeLauncher(join(dir, "src"), "/usr/bin/node"); + + expect(launcher).toMatchObject({ ok: true, command: "/usr/bin/node" }); + expect(launcher.ok && launcher.args[0]).toMatch(/bridge\.js$/); + }); + + it("uses the TypeScript entrypoint in a source checkout", () => { + // No built output — this is someone running from a clone. + writeFileSync(join(dir, "bin", "opera-browser-cli-bridge.ts"), ""); + + const launcher = resolveBridgeLauncher(join(dir, "src"), "/usr/bin/node"); + + // tsx is a devDependency of this repo, so it resolves here. + expect(launcher).toMatchObject({ ok: true }); + expect(launcher.ok && launcher.args[0]).toMatch(/tsx/); + expect(launcher.ok && launcher.args[1]).toMatch(/bridge\.ts$/); + }); + + it("never shells out to npx", () => { + writeFileSync(join(dir, "bin", "opera-browser-cli-bridge.ts"), ""); + + const launcher = resolveBridgeLauncher(join(dir, "src"), "/usr/bin/node"); + + // npx blocks on an install prompt when the package is uncached, which + // behind a redirected stdio looks exactly like a hang. + expect(launcher.ok && launcher.command).not.toMatch(/npx/); + expect(launcher.ok && launcher.args.join(" ")).not.toMatch(/npx/); + }); + + it("reports an unbuilt package rather than spawning nothing", () => { + const launcher = resolveBridgeLauncher(join(dir, "src"), "/usr/bin/node"); + + expect(launcher).toEqual({ ok: false, reason: "bridge-not-built" }); + }); + + it("honours OPERA_CLI_DEV=1 over a present build", () => { + writeFileSync(join(dir, "bin", "opera-browser-cli-bridge.js"), ""); + writeFileSync(join(dir, "bin", "opera-browser-cli-bridge.ts"), ""); + process.env.OPERA_CLI_DEV = "1"; + + const launcher = resolveBridgeLauncher(join(dir, "src"), "/usr/bin/node"); + + expect(launcher.ok && launcher.args[1]).toMatch(/bridge\.ts$/); + }); +}); + describe("buildTransportArgs", () => { const savedEnv: Record = {}; @@ -169,7 +237,21 @@ describe("buildTransportArgs", () => { const args = buildTransportArgs(); expect(args).toContain("--executablePath=/Applications/Opera Neon.app/Contents/MacOS/Opera"); expect(args).toContain("--isolated"); - expect(args).toContain("--headless"); + // A configured Opera means headed: sign-in and consent — and so every + // Opera AI feature — cannot be completed in a headless window. + expect(args).not.toContain("--headless"); + }); + + it("stays headless when OPERA_CLI_HEADED=0 overrides a configured browser", () => { + process.env.OPERA_CLI_EXECUTABLE_PATH = "/Applications/Opera Neon.app/Contents/MacOS/Opera"; + process.env.OPERA_CLI_HEADED = "0"; + expect(buildTransportArgs()).toContain("--headless"); + }); + + it("stays headless when no browser is configured", () => { + // CI, Docker, and plain-Chrome setups have no display; the old default + // has to survive for them. + expect(buildTransportArgs()).toContain("--headless"); }); it("omits --executablePath when OPERA_CLI_BROWSER_URL is also set", () => { @@ -435,6 +517,23 @@ describe("handleBridgeRequest access control", () => { close: async () => {}, }; + const savedEnv: Record = {}; + beforeEach(() => { + // /health's reachability probe keys off OPERA_CLI_BROWSER_URL; keep this + // access-control suite hermetic against env leaked by other tests. + for (const key of [ + "OPERA_CLI_BROWSER_URL", + "OPERA_CLI_USER_DATA_DIR", + "OPERA_CLI_EXECUTABLE_PATH", + ]) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + afterEach(() => { + for (const key of Object.keys(savedEnv)) process.env[key] = savedEnv[key]; + }); + it("rejects a forged-Host /call with 403 before dispatching", async () => { let called = false; const spyClient: BridgeClient = { diff --git a/test/browser-target.test.ts b/test/browser-target.test.ts new file mode 100644 index 0000000..0e830ce --- /dev/null +++ b/test/browser-target.test.ts @@ -0,0 +1,247 @@ +/** + * Choosing between launching a browser and attaching to one, and the takeover + * that resolves the case where neither is possible. + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createServer, type Server } from "node:http"; +import { lstatSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { hostname, tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; +import { + browserLaunchArgs, + launchAttachableBrowser, + quitBrowser, + resolveBrowserTarget, +} from "../src/browser-target.js"; +import { inspectProfileLock } from "../src/profile.js"; + +let dir: string; +const servers: Server[] = []; +const children: ChildProcess[] = []; + +function pickPort(): number { + return 45_000 + Math.floor(Math.random() * 1_000); +} + +function startDevToolsStub(port: number, browser: string): Promise { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + if (req.url === "/json/version") { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ Browser: browser })); + return; + } + res.statusCode = 404; + res.end(); + }); + servers.push(server); + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve(server)); + }); +} + +function writeLock(pid: number): void { + symlinkSync(`${hostname()}-${pid}`, join(dir, "SingletonLock")); +} + +function writePortFile(port: number): void { + writeFileSync(join(dir, "DevToolsActivePort"), `${port}\n/devtools/browser/x\n`); +} + +/** + * A process that holds the profile lock the way a browser does, and releases it + * on SIGTERM. `stubborn` ignores SIGTERM, standing in for a browser that hangs. + */ +function startLockHolder(stubborn = false): ChildProcess { + const lockPath = join(dir, "SingletonLock"); + const script = ` + const fs = require("fs"); + const os = require("os"); + fs.symlinkSync(os.hostname() + "-" + process.pid, process.argv[1]); + process.on("SIGTERM", () => { + if (${stubborn}) return; + try { fs.unlinkSync(process.argv[1]); } catch {} + process.exit(0); + }); + setInterval(() => {}, 1000); + `; + const child = spawn(process.execPath, ["-e", script, lockPath], { stdio: "ignore" }); + children.push(child); + return child; +} + +async function waitForLock(): Promise { + for (let i = 0; i < 100; i++) { + // lstat, not existsSync: the lock is a symlink to "-", which + // is not a real path, so existsSync follows it and reports false. + try { + lstatSync(join(dir, "SingletonLock")); + return; + } catch { + await new Promise((r) => setTimeout(r, 20)); + } + } + throw new Error("lock holder never took the lock"); +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "obc-target-")); +}); + +afterEach(async () => { + await Promise.all( + servers.splice(0).map((s) => new Promise((r) => s.close(() => r()))), + ); + for (const child of children.splice(0)) { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + } + rmSync(dir, { recursive: true, force: true }); +}); + +describe("resolveBrowserTarget", () => { + it("honours an explicit browser URL", async () => { + const target = await resolveBrowserTarget({ + browserUrl: "http://127.0.0.1:9222", + userDataDir: dir, + }); + + expect(target).toMatchObject({ mode: "attach", url: "http://127.0.0.1:9222" }); + }); + + it("uses a managed launch when no profile is configured", async () => { + // An isolated profile cannot be held by anything else. + expect(await resolveBrowserTarget({})).toMatchObject({ mode: "managed" }); + }); + + it("uses a managed launch when the profile is free", async () => { + expect(await resolveBrowserTarget({ userDataDir: dir })).toMatchObject({ + mode: "managed", + }); + }); + + it("attaches to a running browser that exposes a debug port", async () => { + // The case that makes this feel automatic: no prompt, no restart. + const port = pickPort(); + await startDevToolsStub(port, "Opera/121.0.0.0"); + writePortFile(port); + writeLock(process.pid); + + const target = await resolveBrowserTarget({ userDataDir: dir }); + + expect(target).toMatchObject({ + mode: "attach", + url: `http://127.0.0.1:${port}`, + }); + }); + + it("reports a conflict when the profile is held with no debug port", async () => { + writeLock(process.pid); + + const target = await resolveBrowserTarget({ userDataDir: dir }); + + expect(target.mode).toBe("conflict"); + expect(target).toMatchObject({ lock: { state: "locked", pid: process.pid } }); + }); + + it("does not attach on a stale port file left by an exited browser", async () => { + writePortFile(pickPort()); // nothing listening + writeLock(process.pid); + + expect((await resolveBrowserTarget({ userDataDir: dir })).mode).toBe("conflict"); + }); + + it("attaches even when the lock cannot be attributed, if the port is live", async () => { + // A profile locked by another host is 'unknown', but a live debug port + // settles the question regardless of what the lock says. + const port = pickPort(); + await startDevToolsStub(port, "Opera/121.0.0.0"); + writePortFile(port); + symlinkSync("some-other-host-4242", join(dir, "SingletonLock")); + + expect((await resolveBrowserTarget({ userDataDir: dir })).mode).toBe("attach"); + }); +}); + +describe("quitBrowser", () => { + it("stops a browser gracefully and waits for the profile to be released", async () => { + const holder = startLockHolder(); + await waitForLock(); + + const result = await quitBrowser( + { state: "locked", pid: holder.pid!, hostname: hostname() }, + dir, + ); + + expect(result.ok).toBe(true); + expect(inspectProfileLock(dir).state).toBe("free"); + }, 30_000); + + it("reports rather than escalating when the browser will not quit", async () => { + // SIGKILL on a browser risks a corrupted profile and loses the user's + // tabs, so a stubborn browser is reported, never forced. + const holder = startLockHolder(true); + await waitForLock(); + + const result = await quitBrowser( + { state: "locked", pid: holder.pid!, hostname: hostname() }, + dir, + 1_500, + ); + + expect(result).toEqual({ ok: false, reason: "timeout" }); + expect(holder.exitCode).toBeNull(); // still alive — we did not kill it + }, 30_000); + + it("refuses to signal when the lock names no usable pid", async () => { + const result = await quitBrowser( + { state: "unknown", pid: null, hostname: "other-host" }, + dir, + ); + + expect(result).toEqual({ ok: false, reason: "no-pid" }); + }); +}); + +describe("launchAttachableBrowser", () => { + it("reports a missing executable instead of spawning nothing", async () => { + const result = await launchAttachableBrowser( + join(dir, "no-such-browser"), + dir, + ); + + expect(result).toEqual({ ok: false, reason: "no-executable" }); + }); + + it("reports no-executable when the path is unset", async () => { + expect(await launchAttachableBrowser(undefined, dir)).toEqual({ + ok: false, + reason: "no-executable", + }); + }); +}); + +describe("browserLaunchArgs", () => { + it("lets the browser choose its own port", () => { + // Port 0 means Chromium picks a free one and records it in + // DevToolsActivePort — so we never squat a predictable port like 9222. + expect(browserLaunchArgs()).toContain("--remote-debugging-port=0"); + }); + + it("binds the debug port to loopback", () => { + expect(browserLaunchArgs()).toContain("--remote-debugging-address=127.0.0.1"); + }); + + it("never opens CDP to web origins", () => { + // --remote-allow-origins=* would let any page drive a browser that is + // logged into everything the user is. + expect(browserLaunchArgs("/tmp/profile").join(" ")).not.toContain( + "remote-allow-origins", + ); + }); + + it("includes the profile when one is given", () => { + expect(browserLaunchArgs("/tmp/profile")).toContain("--user-data-dir=/tmp/profile"); + }); +}); diff --git a/test/cli.test.ts b/test/cli.test.ts index a1705c1..2f9145e 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,17 +1,96 @@ import { describe, it, expect } from "vitest"; -import { formatStopOutput, formatScreenshotOutput, getCommandHelp, parseChatArgs, parseScreenshotArgs } from "../src/cli.js"; +import { + extractTakeoverFlag, + formatStopOutput, + formatScreenshotOutput, + getCommandHelp, + parseChatArgs, + parseScreenshotArgs, + parseSetupArgs, +} from "../src/cli.js"; describe("formatStopOutput", () => { + const base = { stopped: false, stale: false, forced: false, pid: null, port: null }; + it("returns stopped status when bridge was running", () => { - const output = formatStopOutput(true); + const output = formatStopOutput({ ...base, stopped: true, pid: 42, port: 9225 }); expect(output).toContain("stopped"); expect(output).not.toContain("no-op"); + expect(output).toContain("42"); + expect(output).toContain("9225"); }); it("returns no-op status when bridge was not running", () => { - const output = formatStopOutput(false); + const output = formatStopOutput(base); expect(output).toContain("no-op"); }); + + it("reports a forced kill distinctly", () => { + const output = formatStopOutput({ ...base, stopped: true, forced: true, pid: 42 }); + expect(output).toContain("forced"); + }); + + it("reports a cleared stale pid file", () => { + const output = formatStopOutput({ ...base, stale: true, pid: 42 }); + expect(output).toContain("stale"); + expect(output).not.toContain("no-op"); + }); +}); + +describe("parseSetupArgs", () => { + it("defaults to the interactive wizard", () => { + expect(parseSetupArgs([])).toMatchObject({ interactive: true }); + }); + + it("accepts the non-interactive flags", () => { + expect(parseSetupArgs(["--non-interactive"]).interactive).toBe(false); + expect(parseSetupArgs(["-y"]).interactive).toBe(false); + expect(parseSetupArgs(["--yes"]).interactive).toBe(false); + }); + + it("treats any explicit setting as non-interactive", () => { + // Passing a value means the caller already knows what they want; stopping + // to ask would defeat the point in a provisioning script. + expect(parseSetupArgs(["--executable", "/x/opera"])).toMatchObject({ + interactive: false, + executable: "/x/opera", + }); + expect(parseSetupArgs(["--profile", "skip"])).toMatchObject({ + interactive: false, + profile: "skip", + }); + expect(parseSetupArgs(["--headless"])).toMatchObject({ + interactive: false, + headed: false, + }); + expect(parseSetupArgs(["--headed"])).toMatchObject({ + interactive: false, + headed: true, + }); + }); + + it("ignores a flag with no value rather than consuming the next one", () => { + expect(parseSetupArgs(["--executable"])).toMatchObject({ + interactive: true, + executable: undefined, + }); + }); +}); + +describe("extractTakeoverFlag", () => { + it("strips the flag so it never reaches command parsing", () => { + expect(extractTakeoverFlag(["open", "https://x", "--takeover"])).toEqual({ + argv: ["open", "https://x"], + takeover: true, + }); + }); + + it("leaves other args untouched", () => { + expect(extractTakeoverFlag(["open", "https://x"])).toEqual({ + argv: ["open", "https://x"], + takeover: false, + }); + }); }); describe("getCommandHelp", () => { diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..f892ea8 --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,235 @@ +/** + * First-run configuration and config-file hygiene. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + KNOWN_CONFIG_KEYS, + findUnknownConfigKeys, +} from "../src/config.js"; +import { + browserDisplayName, + detectBrowser, + detectBrowsers, + neonCandidatePaths, +} from "../src/detect.js"; + +type ConfigModule = typeof import("../src/config.js"); + +let home: string; +let config: ConfigModule; + +/** A fake filesystem predicate so detection can be tested off-machine. */ +function existsOnly(...paths: string[]): (p: string) => boolean { + const set = new Set(paths); + return (p) => set.has(p); +} + +const NEON = "/Applications/Opera Neon.app/Contents/MacOS/Opera"; +const OPERA = "/Applications/Opera.app/Contents/MacOS/Opera"; + +beforeEach(async () => { + home = mkdtempSync(join(tmpdir(), "obc-config-")); + vi.stubEnv("HOME", home); + vi.stubEnv("USERPROFILE", home); + vi.stubEnv("OPERA_CLI_EXECUTABLE_PATH", ""); + vi.stubEnv("OPERA_CLI_BROWSER_URL", ""); + delete process.env.OPERA_CLI_EXECUTABLE_PATH; + delete process.env.OPERA_CLI_BROWSER_URL; + vi.resetModules(); + config = await import("../src/config.js"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); +}); + +describe("detectBrowsers", () => { + it("prefers Opera Neon over plain Opera", () => { + const found = detectBrowsers("darwin", home, existsOnly(NEON, OPERA)); + + expect(found[0]).toMatchObject({ path: NEON, isNeon: true }); + expect(found[1]).toMatchObject({ path: OPERA, isNeon: false }); + }); + + it("falls back to plain Opera when Neon is absent", () => { + const found = detectBrowser("darwin", home, existsOnly(OPERA)); + + expect(found).toMatchObject({ name: "Opera", isNeon: false }); + }); + + it("finds nothing on Linux, where Opera Neon does not ship", () => { + expect(neonCandidatePaths("linux", home)).toEqual([]); + expect(detectBrowser("linux", home, () => true)).toBeNull(); + }); + + it("returns null when nothing is installed", () => { + expect(detectBrowser("darwin", home, () => false)).toBeNull(); + }); + + it("names the Developer build distinctly", () => { + expect(browserDisplayName("/Applications/Opera Neon Developer.app/x")).toBe( + "Opera Neon Developer", + ); + expect(browserDisplayName("/Applications/Opera GX.app/x")).toBe("Opera GX"); + }); +}); + +describe("computeAutoConfig", () => { + it("configures a fresh machine from a detected browser", () => { + const result = config.computeAutoConfig({ + home, + platform: "darwin", + exists: existsOnly(NEON), + }); + + expect(result.status).toBe("configured"); + if (result.status !== "configured") return; + expect(result.browser.isNeon).toBe(true); + expect(result.settings.OPERA_CLI_EXECUTABLE_PATH).toBe(NEON); + // Headed, because sign-in and every Opera AI feature need a window. + expect(result.settings.OPERA_CLI_HEADED).toBe("1"); + expect(result.settings.OPERA_CLI_USER_DATA_DIR).toBeTruthy(); + }); + + it("prefers the browser's real profile over a private one", () => { + // The point of using Opera is the session you are already signed in to. + // A profile that turns out to be in use is resolved at launch time. + const realProfile = join(home, "Library", "Application Support", "com.operasoftware.OperaNeon"); + mkdirSync(realProfile, { recursive: true }); + + const result = config.computeAutoConfig({ + home, + platform: "darwin", + exists: (p) => p === NEON || existsSync(p), + }); + + expect(result.status).toBe("configured"); + if (result.status !== "configured") return; + expect(result.settings.OPERA_CLI_USER_DATA_DIR).toBe(realProfile); + }); + + it("falls back to a CLI-owned profile when there is no real one", () => { + const result = config.computeAutoConfig({ + home, + platform: "darwin", + exists: existsOnly(NEON), + }); + + expect(result.status).toBe("configured"); + if (result.status !== "configured") return; + expect(result.settings.OPERA_CLI_USER_DATA_DIR).toContain(".opera-browser-cli"); + }); + + it("does nothing when a config file already exists", () => { + config.writeConfigFile({ OPERA_CLI_HEADED: "1" }); + + expect( + config.computeAutoConfig({ home, platform: "darwin", exists: () => true }), + ).toEqual({ status: "already-configured" }); + }); + + it("does nothing when the environment already points at a browser", () => { + vi.stubEnv("OPERA_CLI_EXECUTABLE_PATH", NEON); + + expect( + config.computeAutoConfig({ home, platform: "darwin", exists: existsOnly(NEON) }), + ).toEqual({ status: "already-configured" }); + }); + + it("reports no-browser rather than writing a useless config", () => { + expect( + config.computeAutoConfig({ home, platform: "darwin", exists: () => false }), + ).toEqual({ status: "no-browser" }); + }); +}); + +describe("autoConfigure", () => { + it("writes the config and applies it to this process", () => { + const result = config.autoConfigure({ + home, + platform: "darwin", + exists: existsOnly(NEON), + }); + + expect(result.status).toBe("configured"); + expect(existsSync(join(home, ".opera-browser-cli", "config"))).toBe(true); + // Applied in-process too, so the very first command benefits. + expect(process.env.OPERA_CLI_EXECUTABLE_PATH).toBe(NEON); + }); + + it("never overwrites a value already set in the environment", () => { + vi.stubEnv("OPERA_CLI_HEADED", "0"); + + config.autoConfigure({ home, platform: "darwin", exists: existsOnly(NEON) }); + + expect(process.env.OPERA_CLI_HEADED).toBe("0"); + }); + + it("still applies settings when the config file cannot be written", () => { + rmSync(home, { recursive: true, force: true }); + writeFileSync(home, "not a directory"); + + const result = config.autoConfigure({ + home, + platform: "darwin", + exists: existsOnly(NEON), + }); + + expect(result.status).toBe("configured"); + expect(process.env.OPERA_CLI_EXECUTABLE_PATH).toBe(NEON); + }); +}); + +describe("findUnknownConfigKeys", () => { + it("accepts every documented key", () => { + const all = Object.fromEntries(KNOWN_CONFIG_KEYS.map((k) => [k, "x"])); + + expect(findUnknownConfigKeys(all)).toEqual([]); + }); + + it("suggests the intended key for a typo", () => { + // Silently ignored at load time and looks correct in the file — the only + // place this can surface is a check like this one. + const found = findUnknownConfigKeys({ OPERA_CLI_EXEC_PATH: "/x" }); + + expect(found).toHaveLength(1); + expect(found[0]?.suggestion).toBe("OPERA_CLI_EXECUTABLE_PATH"); + }); + + it("flags an unrelated key without a misleading suggestion", () => { + const found = findUnknownConfigKeys({ TOTALLY_UNRELATED_THING: "1" }); + + expect(found).toHaveLength(1); + expect(found[0]?.suggestion).toBeNull(); + }); +}); + +describe("config file round-trip", () => { + it("preserves values containing quotes", () => { + config.writeConfigFile({ OPERA_CLI_CHROME_ARGS: '--flag="value"' }); + + expect(config.readConfigFile().OPERA_CLI_CHROME_ARGS).toBe('--flag="value"'); + }); + + it("patches without disturbing other keys", () => { + config.writeConfigFile({ OPERA_CLI_HEADED: "1", OPERA_CLI_PORT: "9225" }); + + config.updateConfigFile({ OPERA_CLI_PORT: null, OPERA_CLI_BROWSER_URL: "http://x" }); + + expect(config.readConfigFile()).toEqual({ + OPERA_CLI_HEADED: "1", + OPERA_CLI_BROWSER_URL: "http://x", + }); + }); + + it("treats an unreadable config as absent rather than failing", () => { + mkdirSync(join(home, ".opera-browser-cli", "config"), { recursive: true }); + + expect(config.readConfigFile()).toEqual({}); + }); +}); diff --git a/test/exit-codes.test.ts b/test/exit-codes.test.ts new file mode 100644 index 0000000..519e857 --- /dev/null +++ b/test/exit-codes.test.ts @@ -0,0 +1,143 @@ +/** + * The caller contract: exit codes, browser classification, and log filtering. + * + * Exit codes are a documented interface — an agent branches on them to decide + * between retrying, fixing its command, and asking the user. Changing one is a + * breaking change. + */ + +import { describe, expect, it } from "vitest"; +import { AxiError } from "axi-sdk-js"; +import { + EXIT_CODES, + classifyBrowser, + exitCodeForCdpError, + filterLogLines, + formatCliError, + parseLogsArgs, +} from "../src/cli.js"; +import { CdpError, type ErrorCode } from "../src/client.js"; + +describe("exit codes", () => { + it("maps each error class to its documented code", () => { + const expected: Record = { + VALIDATION_ERROR: 2, + UNSUPPORTED_OPERATION: 2, + BRIDGE_NOT_READY: 3, + BROWSER_ERROR: 3, + AUTH_REQUIRED: 4, + TIMEOUT: 5, + REF_NOT_FOUND: 6, + PAGE_CLOSED: 6, + UNKNOWN: 1, + }; + + expect(EXIT_CODES).toEqual(expected); + }); + + it("separates 'fix your command' from 'ask the user' from 'retry'", () => { + // The distinctions an agent actually branches on. + expect(exitCodeForCdpError(new CdpError("bad args", "VALIDATION_ERROR"))).toBe(2); + expect(exitCodeForCdpError(new CdpError("signed out", "AUTH_REQUIRED"))).toBe(4); + expect(exitCodeForCdpError(new CdpError("slow", "TIMEOUT"))).toBe(5); + expect(exitCodeForCdpError(new CdpError("stale ref", "REF_NOT_FOUND"))).toBe(6); + }); + + it("falls back to 1 for anything unrecognised", () => { + expect(exitCodeForCdpError(new Error("boom"))).toBe(1); + expect(exitCodeForCdpError("not an error")).toBe(1); + expect(exitCodeForCdpError(new AxiError("odd", "SOMETHING_ELSE"))).toBe(1); + }); +}); + +describe("formatCliError", () => { + it("renders the code and suggestions alongside the exit code", () => { + const result = formatCliError( + new CdpError("Opera: user is not signed in", "AUTH_REQUIRED", [ + "Run `opera-browser-cli login`", + ]), + ); + + expect(result.exitCode).toBe(4); + expect(result.output).toContain("AUTH_REQUIRED"); + expect(result.output).toContain("login"); + }); + + it("handles a plain Error without suggestions", () => { + const result = formatCliError(new Error("boom")); + + expect(result.exitCode).toBe(1); + expect(result.output).toContain("boom"); + }); +}); + +describe("classifyBrowser", () => { + it("identifies Neon from the executable path", () => { + expect( + classifyBrowser("/Applications/Opera Neon.app/Contents/MacOS/Opera"), + ).toBe("neon"); + }); + + it("distinguishes a plain Opera build from Neon", () => { + // The case the old existsSync check waved through, and which then failed + // at runtime with a confusing protocol error. + expect(classifyBrowser("/Applications/Opera.app/Contents/MacOS/Opera")).toBe( + "opera", + ); + }); + + it("identifies a non-Opera browser", () => { + expect(classifyBrowser("/usr/bin/google-chrome")).toBe("other"); + }); + + it("reports unknown when nothing is configured", () => { + expect(classifyBrowser(undefined)).toBe("unknown"); + }); + + it("prefers what an attached browser says about itself", () => { + // A live browser's own version string beats a guess from the path. + expect(classifyBrowser("/usr/bin/google-chrome", "Opera Neon/121.0")).toBe("neon"); + expect(classifyBrowser(undefined, "Opera/121.0")).toBe("opera"); + expect(classifyBrowser(undefined, "Chrome/141.0")).toBe("other"); + }); +}); + +describe("parseLogsArgs", () => { + it("defaults to a plain tail", () => { + expect(parseLogsArgs([])).toEqual({ lines: 50, follow: false, errorsOnly: false }); + }); + + it("parses follow and errors flags", () => { + expect(parseLogsArgs(["-f"]).follow).toBe(true); + expect(parseLogsArgs(["--follow"]).follow).toBe(true); + expect(parseLogsArgs(["--errors"]).errorsOnly).toBe(true); + }); + + it("combines flags with a line count", () => { + expect(parseLogsArgs(["--errors", "-n", "200", "-f"])).toEqual({ + lines: 200, + follow: true, + errorsOnly: true, + }); + }); +}); + +describe("filterLogLines", () => { + const lines = [ + "[opera-browser-cli] Listening on http://127.0.0.1:9225", + "[opera-browser-cli] Connected to opera-devtools-mcp", + "[opera-browser-cli] Port 9225 already in use", + "Error: connect ECONNREFUSED 127.0.0.1:9225", + ]; + + it("returns everything by default", () => { + expect(filterLogLines(lines, false)).toHaveLength(4); + }); + + it("keeps only the lines worth acting on", () => { + const errors = filterLogLines(lines, true); + + expect(errors).toHaveLength(2); + expect(errors.every((l) => /in use|ECONNREFUSED/.test(l))).toBe(true); + }); +}); diff --git a/test/fixtures/stub-mcp-unreachable.js b/test/fixtures/stub-mcp-unreachable.js new file mode 100644 index 0000000..183ae06 --- /dev/null +++ b/test/fixtures/stub-mcp-unreachable.js @@ -0,0 +1,59 @@ +/** + * A stub MCP that always answers with opera-devtools-mcp's "no browser to + * talk to" result — standing in for a bridge pointed at a dead/closed browser. + * The bridge stays healthy (stdio up), but every tool reports unreachable. + */ +function send(message) { + process.stdout.write(JSON.stringify(message) + "\n"); +} +function reply(id, result) { + send({ jsonrpc: "2.0", id, result }); +} +let buffer = ""; +process.stdin.setEncoding("utf-8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let nl; + while ((nl = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (!line) continue; + let message; + try { + message = JSON.parse(line); + } catch { + continue; + } + if (message.id === undefined) continue; + switch (message.method) { + case "initialize": + reply(message.id, { + protocolVersion: message.params?.protocolVersion ?? "2024-11-05", + capabilities: { tools: {}, logging: {} }, + serverInfo: { name: "stub-mcp-unreachable", version: "0.0.0" }, + }); + break; + case "tools/list": + reply(message.id, { + tools: [ + { + name: "take_snapshot", + description: "stub", + inputSchema: { type: "object", properties: {} }, + }, + ], + }); + break; + case "tools/call": { + const text = + "Could not connect to Chrome. Check if Chrome is running. " + + "Cause: Failed to fetch browser webSocket URL from http://127.0.0.1:59999/json/version: fetch failed"; + reply(message.id, { content: [{ type: "text", text }] }); + break; + } + default: + reply(message.id, {}); + } + } +}); +process.stdin.on("end", () => process.exit(0)); diff --git a/test/fixtures/stub-mcp.js b/test/fixtures/stub-mcp.js new file mode 100644 index 0000000..5b73c68 --- /dev/null +++ b/test/fixtures/stub-mcp.js @@ -0,0 +1,79 @@ +/** + * A minimal MCP server over stdio, standing in for opera-devtools-mcp. + * + * Speaks just enough of the protocol for the bridge to complete `connect()` + * and `listTools()`, so the real bridge lifecycle can be tested end to end + * without launching a browser. Point OPERA_CLI_MCP_BIN at this file. + * + * Note: the MCP SDK's stdio transport passes the child a filtered allowlist of + * environment variables, not the parent's full environment — so this stub + * cannot be configured through env vars from a test. Failure paths are tested + * by pointing OPERA_CLI_MCP_BIN somewhere else instead. + */ + +function send(message) { + process.stdout.write(JSON.stringify(message) + "\n"); +} + +function reply(id, result) { + send({ jsonrpc: "2.0", id, result }); +} + +let buffer = ""; +process.stdin.setEncoding("utf-8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (!line) continue; + + let message; + try { + message = JSON.parse(line); + } catch { + continue; + } + if (message.id === undefined) continue; // notification — nothing to answer + + switch (message.method) { + case "initialize": + reply(message.id, { + // Echo the client's protocol version so we never fail its check. + protocolVersion: message.params?.protocolVersion ?? "2024-11-05", + capabilities: { tools: {}, logging: {} }, + serverInfo: { name: "stub-mcp", version: "0.0.0" }, + }); + break; + case "tools/list": + reply(message.id, { + tools: [ + { + name: "take_snapshot", + description: "stub", + inputSchema: { type: "object", properties: {} }, + }, + ], + }); + break; + case "tools/call": { + const name = message.params?.name ?? ""; + const result = { content: [{ type: "text", text: `stub:${name}` }] }; + // Opera AI tools are slow in reality. Holding the response open lets a + // test kill the bridge *during* a call rather than before it. + if (name.startsWith("opera_")) { + setTimeout(() => reply(message.id, result), 3_000); + } else { + reply(message.id, result); + } + break; + } + default: + reply(message.id, {}); + } + } +}); + +// Stay alive until the bridge closes our stdin. +process.stdin.on("end", () => process.exit(0)); diff --git a/test/identity.test.ts b/test/identity.test.ts new file mode 100644 index 0000000..7e636be --- /dev/null +++ b/test/identity.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { + BRIDGE_SERVER_NAME, + computeBootMinute, + isOurBridge, + isUsableBridge, + parseHealth, + sameBoot, + type BridgeHealth, +} from "../src/identity.js"; + +function health(overrides: Partial = {}): BridgeHealth { + return { + status: "ok", + server: BRIDGE_SERVER_NAME, + version: "1.2.3", + pid: 4242, + startedAt: 1_700_000_000_000, + bootMinute: 28_000_000, + browser: { connected: true }, + ...overrides, + }; +} + +describe("computeBootMinute", () => { + it("derives the boot instant from now minus uptime", () => { + // 10:00:00 with 600s uptime → booted 09:50. + const now = Date.UTC(2026, 0, 1, 10, 0, 0); + expect(computeBootMinute(now, 600)).toBe( + Math.floor(Date.UTC(2026, 0, 1, 9, 50, 0) / 60_000), + ); + }); + + it("agrees between two processes measuring seconds apart", () => { + const now = Date.UTC(2026, 0, 1, 10, 0, 0); + const a = computeBootMinute(now, 600); + const b = computeBootMinute(now + 3_000, 603); + expect(sameBoot(a, b)).toBe(true); + }); +}); + +describe("sameBoot", () => { + it("tolerates a minute of drift in either direction", () => { + expect(sameBoot(100, 100)).toBe(true); + expect(sameBoot(100, 101)).toBe(true); + expect(sameBoot(101, 100)).toBe(true); + }); + + it("rejects anything further apart — a reboot must never look like drift", () => { + expect(sameBoot(100, 102)).toBe(false); + expect(sameBoot(100, 5_000)).toBe(false); + }); +}); + +describe("parseHealth", () => { + it("parses a full identity payload", () => { + const parsed = parseHealth(JSON.stringify(health())); + expect(parsed).toEqual(health()); + }); + + it("rejects a foreign server", () => { + expect( + parseHealth(JSON.stringify({ status: "ok", server: "some-other-tool" })), + ).toBeNull(); + }); + + it("rejects a non-JSON body", () => { + expect(parseHealth("404")).toBeNull(); + }); + + it("still recognises a pre-identity bridge as ours", () => { + // 0.1.45 and earlier answered with only status + server. Recognising these + // is what lets the client shut one down after an upgrade instead of + // colliding with it forever. + const parsed = parseHealth( + JSON.stringify({ status: "ok", server: BRIDGE_SERVER_NAME }), + ); + expect(isOurBridge(parsed)).toBe(true); + expect(parsed?.version).toBe("unknown"); + expect(parsed?.pid).toBe(0); + }); +}); + +describe("isUsableBridge", () => { + it("accepts our bridge on our version", () => { + expect(isUsableBridge(health(), "1.2.3")).toBe(true); + }); + + it("rejects a healthy bridge running different code", () => { + // The whole point: a pre-upgrade bridge looks perfectly healthy while + // serving stale code from memory. + expect(isUsableBridge(health({ version: "1.2.2" }), "1.2.3")).toBe(false); + }); + + it("rejects a bridge whose MCP client is not connected", () => { + expect(isUsableBridge(health({ status: "not-connected" }), "1.2.3")).toBe(false); + }); + + it("rejects null", () => { + expect(isUsableBridge(null, "1.2.3")).toBe(false); + }); +}); diff --git a/test/main.test.ts b/test/main.test.ts index 2c9ce3c..67fb79c 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -16,12 +16,21 @@ vi.mock("../src/client.js", () => ({ } }, callTool, + candidatePorts: vi.fn(() => [9225]), ensureBridge: vi.fn(), + findUsableBridge: vi.fn(async () => null), getSessionSnapshotIfRunning: vi.fn(), loadConfig: vi.fn(), stopBridge: vi.fn(), })); +// Command dispatch is what these tests cover; first-run configuration has its +// own suite, and stubbing it here keeps the real ~/.opera-browser-cli untouched. +vi.mock("../src/config.js", async (importOriginal) => ({ + ...(await importOriginal()), + autoConfigure: vi.fn(() => ({ status: "already-configured" as const })), +})); + import { main } from "../src/cli.js"; import { CdpError, getSessionSnapshotIfRunning } from "../src/client.js"; @@ -112,4 +121,48 @@ describe("main", () => { ); expect(process.exitCode).toBeUndefined(); }); + + it("fails loudly (exit 3) instead of a fake refs:0 page when the browser is unreachable", async () => { + const write = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + + // Every tool returns the bridge's "browser dead" message: the bridge is up + // but its Chrome target (dead URL / no debug port) cannot be reached. + callTool.mockResolvedValue( + "Could not connect to Chrome. Check if Chrome is running.", + ); + + await main(["open", "https://example.org"]); + + expect(process.exitCode).toBe(3); // BROWSER_ERROR + const out = String(write.mock.calls[0]?.[0] ?? ""); + expect(out).toContain("The browser is not reachable"); + expect(out).toContain("--takeover"); + }); + + it("forces a new page when navigate reports success but no page is live (takeover)", async () => { + const write = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + + // navigate_page returns success, but take_snapshot shows no page is + // selected — the shape of a freshly restarted browser whose session has not + // yet produced a tab. The CLI must fall back to new_page. + callTool + .mockResolvedValueOnce("") // navigate_page + .mockResolvedValueOnce("No page selected") // take_snapshot + .mockResolvedValueOnce("") // new_page + .mockResolvedValueOnce('RootWebArea "G"\n uid=1 link "About"'); // take_snapshot + + await main(["open", "https://google.com"]); + + const names = callTool.mock.calls.map((c) => c[0]); + expect(names).toContain("new_page"); + expect(names).toContain("take_snapshot"); + expect(String(write.mock.calls[0]?.[0])).toContain( + 'url: "https://google.com"', + ); + expect(process.exitCode).toBeUndefined(); + }); }); diff --git a/test/preflight.test.ts b/test/preflight.test.ts new file mode 100644 index 0000000..97b4024 --- /dev/null +++ b/test/preflight.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AxiError } from "axi-sdk-js"; +import { mkdtempSync, symlinkSync, mkdirSync } from "node:fs"; +import { tmpdir, hostname } from "node:os"; +import { join } from "node:path"; + +// The conflict-resolution path must run even when a bridge is already alive. +// Previously `preflightBrowser` returned early on `findUsableBridge !== null`, +// so a running browser (no debug port) was silently ignored and the restart +// prompt / separate-profile fallback never ran. +const mocks = vi.hoisted(() => ({ + findUsableBridge: vi.fn(), + restartBridge: vi.fn(), + getStateDir: vi.fn(), +})); + +vi.mock("../src/client.js", () => ({ + CdpError: class CdpError extends AxiError { + constructor( + message: string, + public readonly code: string, + public readonly suggestions: string[] = [], + ) { + super(message, code, suggestions); + } + }, + candidatePorts: vi.fn(() => [9225]), + ensureBridge: vi.fn(), + findUsableBridge: mocks.findUsableBridge, + getSessionSnapshotIfRunning: vi.fn(), + getStateDir: mocks.getStateDir, + loadConfig: vi.fn(), + restartBridge: mocks.restartBridge, + stopBridge: vi.fn(), +})); + +vi.mock("../src/config.js", async (importOriginal) => ({ + ...(await importOriginal()), + autoConfigure: vi.fn(() => ({ status: "already-configured" as const })), +})); + +// Default to the real implementations (the first two tests rely on real +// profile-lock detection); the takeover test overrides them per-test and +// afterEach restores the originals. +vi.mock("../src/browser-target.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + quitBrowser: vi.fn(actual.quitBrowser), + launchAttachableBrowser: vi.fn(actual.launchAttachableBrowser), + resolveBrowserTarget: vi.fn(actual.resolveBrowserTarget), + }; +}); + +import { preflightBrowser } from "../src/cli.js"; +import { + launchAttachableBrowser, + quitBrowser, + resolveBrowserTarget, +} from "../src/browser-target.js"; + +describe("preflightBrowser reconciles even when a bridge is running", () => { + let profile: string; + + beforeEach(() => { + process.env.OPERA_CLI_HEADED = "1"; + mocks.getStateDir.mockReturnValue(profile = mkdtempSync(join(tmpdir(), "preflight-state-"))); + // A profile locked by a live local process, with NO debug port => conflict. + const lockDir = mkdtempSync(join(tmpdir(), "preflight-profile-")); + mkdirSync(lockDir, { recursive: true }); + symlinkSync(`${hostname()}-${process.pid}`, join(lockDir, "SingletonLock")); + process.env.OPERA_CLI_USER_DATA_DIR = lockDir; + delete process.env.OPERA_CLI_BROWSER_URL; + process.env.OPERA_CLI_EXECUTABLE_PATH = "/fake/opera"; + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.mocked(resolveBrowserTarget).mockRestore(); + vi.mocked(quitBrowser).mockRestore(); + vi.mocked(launchAttachableBrowser).mockRestore(); + delete process.env.OPERA_CLI_USER_DATA_DIR; + delete process.env.OPERA_CLI_BROWSER_URL; + delete process.env.OPERA_CLI_EXECUTABLE_PATH; + delete process.env.OPERA_CLI_HEADED; + }); + + it("resolves the conflict and REUSES a running bridge on the separate-profile path", async () => { + mocks.findUsableBridge.mockResolvedValue(9225); + + const writes: string[] = []; + const note = vi + .spyOn(process.stderr, "write") + .mockImplementation((s: unknown) => { + writes.push(String(s)); + return true; + }); + try { + await preflightBrowser(["open", "https://x"], false); + } finally { + note.mockRestore(); + } + + // The conflict is still settled (falls back to a separate profile), but the + // running bridge is already on that separate profile, so it must be reused + // — not reset, which would relaunch its browser on every command. + expect(mocks.restartBridge).not.toHaveBeenCalled(); + expect(process.env.OPERA_CLI_USER_DATA_DIR).toContain("profile"); + expect(writes.join("\n")).not.toContain("browser selection changed"); + expect(writes.join("\n")).toContain("using"); + }); + + it("does not reset the bridge when none is running", async () => { + mocks.findUsableBridge.mockResolvedValue(null); + + const note = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + await preflightBrowser(["open", "https://x"], false); + } finally { + note.mockRestore(); + } + + expect(mocks.restartBridge).not.toHaveBeenCalled(); + }); + + it("restarts a running bridge after a takeover relaunches the browser", async () => { + // A bridge is already running on some browser. + mocks.findUsableBridge.mockResolvedValue(9225); + + // Takeover quits the profile-holder and relaunches it with a debug port. + const launchedUrl = `http://127.0.0.1:59999`; + vi.mocked(quitBrowser).mockResolvedValue({ ok: true }); + vi.mocked(launchAttachableBrowser).mockResolvedValue({ + ok: true, + url: launchedUrl, + }); + vi.mocked(resolveBrowserTarget).mockResolvedValue({ + mode: "conflict", + userDataDir: process.env.OPERA_CLI_USER_DATA_DIR as string, + lock: { pid: process.pid, state: "locked" as const }, + }); + + const writes: string[] = []; + const note = vi + .spyOn(process.stderr, "write") + .mockImplementation((s: unknown) => { + writes.push(String(s)); + return true; + }); + try { + await preflightBrowser(["open", "https://x"], true); + } finally { + note.mockRestore(); + } + + // The relaunch set a fresh BROWSER_URL, which the already-running bridge + // (it fixed its browser at startup) does not reflect — so it must be + // replaced, not silently reused to keep driving the old browser. + expect(mocks.restartBridge).toHaveBeenCalled(); + expect(process.env.OPERA_CLI_BROWSER_URL).toBe(launchedUrl); + expect(writes.join("\n")).toContain("browser selection changed"); + }); +}); diff --git a/test/profile.test.ts b/test/profile.test.ts new file mode 100644 index 0000000..ce813c8 --- /dev/null +++ b/test/profile.test.ts @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createServer, type Server } from "node:http"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { hostname, tmpdir } from "node:os"; +import { join } from "node:path"; +import { + defaultProfileDir, + findAttachableEndpoint, + inspectProfileLock, + parseDevToolsActivePort, + parseSingletonTarget, + probeDevToolsEndpoint, + readDevToolsPort, +} from "../src/profile.js"; + +let dir: string; +const servers: Server[] = []; + +function startDevToolsStub( + port: number, + body: unknown, +): Promise { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + if (req.url === "/json/version") { + res.setHeader("Content-Type", "application/json"); + res.end(typeof body === "string" ? body : JSON.stringify(body)); + return; + } + res.statusCode = 404; + res.end(); + }); + servers.push(server); + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve(server)); + }); +} + +function pickPort(): number { + return 44_000 + Math.floor(Math.random() * 1_000); +} + +/** Write a SingletonLock symlink the way Chromium does. */ +function writeLock(target: string): void { + symlinkSync(target, join(dir, "SingletonLock")); +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "obc-profile-")); +}); + +afterEach(async () => { + await Promise.all( + servers.splice(0).map((s) => new Promise((r) => s.close(() => r()))), + ); + rmSync(dir, { recursive: true, force: true }); +}); + +describe("parseSingletonTarget", () => { + it("splits hostname and pid", () => { + expect(parseSingletonTarget("mymachine-4242")).toEqual({ + hostname: "mymachine", + pid: 4242, + }); + }); + + it("handles a hostname containing dashes", () => { + // The common case on macOS, and the one a naive split() gets wrong. + expect(parseSingletonTarget("Someones-MacBook-Pro-24601")).toEqual({ + hostname: "Someones-MacBook-Pro", + pid: 24601, + }); + }); + + it("rejects malformed targets", () => { + expect(parseSingletonTarget("nodashhere")).toBeNull(); + expect(parseSingletonTarget("host-notanumber")).toBeNull(); + expect(parseSingletonTarget("host-")).toBeNull(); + expect(parseSingletonTarget("-123")).toBeNull(); // no hostname + expect(parseSingletonTarget("host-0")).toBeNull(); // pid 0 is not a process + }); +}); + +describe("parseDevToolsActivePort", () => { + it("reads the port from the first line", () => { + expect(parseDevToolsActivePort("54321\n/devtools/browser/abc-def\n")).toBe(54321); + }); + + it("rejects junk and out-of-range values", () => { + expect(parseDevToolsActivePort("")).toBeNull(); + expect(parseDevToolsActivePort("not-a-port")).toBeNull(); + expect(parseDevToolsActivePort("99999")).toBeNull(); + }); +}); + +describe("inspectProfileLock", () => { + it("reports a directory with no lock as free", () => { + expect(inspectProfileLock(dir)).toMatchObject({ state: "free" }); + }); + + it("reports a live local lock as locked, naming the pid", () => { + writeLock(`${hostname()}-${process.pid}`); + + expect(inspectProfileLock(dir)).toEqual({ + state: "locked", + pid: process.pid, + hostname: hostname(), + }); + }); + + it("treats a lock from a dead process as free", () => { + // Chromium cleans these up itself on the next launch, so calling it a + // conflict would block a launch that would actually succeed. + writeLock(`${hostname()}-999999`); + + expect(inspectProfileLock(dir, () => false)).toMatchObject({ + state: "free", + pid: null, + }); + }); + + it("treats a dangling symlink as free", () => { + writeLock(`${hostname()}-4242`); + + expect(inspectProfileLock(dir, () => false).state).toBe("free"); + }); + + it("never attributes a lock written by another machine", () => { + // A synced or networked profile: that pid means nothing here and must + // never be signalled. + writeLock("some-other-host-4242"); + + const lock = inspectProfileLock(dir, () => true); + expect(lock.state).toBe("unknown"); + expect(lock.pid).toBeNull(); + }); + + it("reports a non-symlink lock as unknown rather than free", () => { + // Windows writes a regular file — claimed, but by whom we cannot tell. + writeFileSync(join(dir, "SingletonLock"), ""); + + expect(inspectProfileLock(dir)).toMatchObject({ state: "unknown", pid: null }); + }); +}); + +describe("readDevToolsPort", () => { + it("returns null when the browser was started without a debug port", () => { + expect(readDevToolsPort(dir)).toBeNull(); + }); + + it("reads a recorded port", () => { + writeFileSync(join(dir, "DevToolsActivePort"), "54321\n/devtools/browser/x\n"); + + expect(readDevToolsPort(dir)).toBe(54321); + }); +}); + +describe("probeDevToolsEndpoint", () => { + it("identifies an Opera browser", async () => { + const port = pickPort(); + await startDevToolsStub(port, { Browser: "Opera/121.0.0.0" }); + + const identity = await probeDevToolsEndpoint(port); + + expect(identity).toEqual({ browser: "Opera/121.0.0.0", isOpera: true }); + }); + + it("identifies a non-Opera browser as such", async () => { + const port = pickPort(); + await startDevToolsStub(port, { Browser: "Chrome/141.0.0.0" }); + + expect((await probeDevToolsEndpoint(port))?.isOpera).toBe(false); + }); + + it("returns null when nothing is listening", async () => { + expect(await probeDevToolsEndpoint(pickPort())).toBeNull(); + }); + + it("returns null for a non-DevTools server on the port", async () => { + const port = pickPort(); + await startDevToolsStub(port, "not devtools"); + + expect(await probeDevToolsEndpoint(port)).toBeNull(); + }); +}); + +describe("findAttachableEndpoint", () => { + it("finds a live endpoint from the recorded port", async () => { + const port = pickPort(); + await startDevToolsStub(port, { Browser: "Opera/121.0.0.0" }); + writeFileSync(join(dir, "DevToolsActivePort"), `${port}\n/devtools/browser/x\n`); + + const found = await findAttachableEndpoint(dir); + + expect(found?.url).toBe(`http://127.0.0.1:${port}`); + expect(found?.identity.isOpera).toBe(true); + }); + + it("ignores a stale port file left by a browser that has exited", async () => { + // DevToolsActivePort survives a clean exit, so the recorded port proves + // nothing until something answers on it. + writeFileSync(join(dir, "DevToolsActivePort"), `${pickPort()}\n/x\n`); + + expect(await findAttachableEndpoint(dir)).toBeNull(); + }); + + it("returns null when the browser had no debug port at all", async () => { + expect(await findAttachableEndpoint(dir)).toBeNull(); + }); +}); + +describe("defaultProfileDir", () => { + it("returns null on platforms Opera Neon does not ship for", () => { + expect(defaultProfileDir(undefined, "/home/x", "linux")).toBeNull(); + }); + + it("returns null when the expected directory does not exist", () => { + expect(defaultProfileDir(undefined, join(dir, "nope"), "darwin")).toBeNull(); + }); + + it("distinguishes the Developer build", () => { + const home = join(dir, "home"); + const support = join(home, "Library", "Application Support"); + mkdirSync(join(support, "com.operasoftware.OperaNeonDeveloper"), { recursive: true }); + mkdirSync(join(support, "com.operasoftware.OperaNeon"), { recursive: true }); + + expect( + defaultProfileDir("/Applications/Opera Neon Developer.app/x", home, "darwin"), + ).toContain("OperaNeonDeveloper"); + expect(defaultProfileDir("/Applications/Opera Neon.app/x", home, "darwin")).toBe( + join(support, "com.operasoftware.OperaNeon"), + ); + }); +}); diff --git a/test/run.test.ts b/test/run.test.ts index faf6657..51e8063 100644 --- a/test/run.test.ts +++ b/test/run.test.ts @@ -18,12 +18,21 @@ vi.mock("../src/client.js", () => ({ } }, callTool, + candidatePorts: vi.fn(() => [9225]), ensureBridge: vi.fn(), + findUsableBridge: vi.fn(async () => null), getSessionSnapshotIfRunning: vi.fn(), loadConfig: vi.fn(), stopBridge: vi.fn(), })); +// Command dispatch is what these tests cover; first-run configuration has its +// own suite, and stubbing it here keeps the real ~/.opera-browser-cli untouched. +vi.mock("../src/config.js", async (importOriginal) => ({ + ...(await importOriginal()), + autoConfigure: vi.fn(() => ({ status: "already-configured" as const })), +})); + import { main, getCommandHelp } from "../src/cli.js"; import { CdpError } from "../src/client.js"; import {