From 12d57ac41fbe9090c00880d92de779f74f6562c2 Mon Sep 17 00:00:00 2001 From: macieju-opera <280402227+macieju-opera@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:43:52 +0200 Subject: [PATCH 1/3] Harden CLI so browser automation works without manual recovery The bridge, the browser, and first-run setup each had failure modes that left the user to diagnose and repair by hand. Each is now detected and resolved automatically, or reported with the command that fixes it. Bridge lifecycle: - /health carries package version, pid and boot id; a bridge running pre-upgrade code is replaced instead of looking healthy forever - never signal a pid that has not been identified as ours (a recycled pid after reboot could previously be SIGTERMed) - exclusive start lock and a port range, so concurrent commands start exactly one bridge and a taken port is not a hard error - READY/FAILED handshake surfaces a dead child in milliseconds rather than a 30s blind poll, with the bridge log tail in the error - dropped connections restart and retry once; Opera AI tools are never silently replayed - add restart/status; stop escalates to SIGKILL and clears stale pid files Browser conflicts: - detect a profile held by a running browser via SingletonLock - attach automatically when it exposes a debug port (DevToolsActivePort) - otherwise offer to restart it (TTY, or --takeover); never quit a browser unprompted, and never SIGKILL one - add attach and launch-args First run: - detect and configure on first use instead of hinting at `setup` - setup gains non-interactive flags and no longer requires a TTY - headed when an Opera binary is configured, since AI sign-in needs a window; headless machines keep the old default Caller contract: - exit codes distinguish bad arguments, environment, auth, timeout and stale page state; new AUTH_REQUIRED code - add login; doctor gains --fix and MCP/profile/browser checks - logs gains --follow and --errors Plan and rationale in specs/robustness-hardening.md. --- CLAUDE.md | 87 ++- README.md | 114 ++- SKILL.md | 20 +- specs/robustness-hardening.md | 476 +++++++++++++ src/bridge.ts | 267 ++++++- src/browser-target.ts | 214 ++++++ src/cli.ts | 1229 +++++++++++++++++++++++++++------ src/client.ts | 958 +++++++++++++++++++++---- src/config.ts | 215 ++++++ src/detect.ts | 99 +++ src/identity.ts | 111 +++ src/profile.ts | 223 ++++++ src/version.ts | 39 ++ test/bridge-lifecycle.test.ts | 267 +++++++ test/bridge-recovery.test.ts | 145 ++++ test/bridge-startup.test.ts | 192 +++++ test/bridge.test.ts | 84 ++- test/browser-target.test.ts | 247 +++++++ test/cli.test.ts | 85 ++- test/config.test.ts | 235 +++++++ test/exit-codes.test.ts | 143 ++++ test/fixtures/stub-mcp.js | 79 +++ test/identity.test.ts | 102 +++ test/main.test.ts | 9 + test/profile.test.ts | 234 +++++++ test/run.test.ts | 9 + 26 files changed, 5468 insertions(+), 415 deletions(-) create mode 100644 specs/robustness-hardening.md create mode 100644 src/browser-target.ts create mode 100644 src/config.ts create mode 100644 src/detect.ts create mode 100644 src/identity.ts create mode 100644 src/profile.ts create mode 100644 src/version.ts create mode 100644 test/bridge-lifecycle.test.ts create mode 100644 test/bridge-recovery.test.ts create mode 100644 test/bridge-startup.test.ts create mode 100644 test/browser-target.test.ts create mode 100644 test/config.test.ts create mode 100644 test/exit-codes.test.ts create mode 100644 test/fixtures/stub-mcp.js create mode 100644 test/identity.test.ts create mode 100644 test/profile.test.ts 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..320156e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -47,6 +47,24 @@ 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. + ## 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..1caae7a 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -29,6 +29,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 +110,10 @@ export async function isBridgeClientConnected( } } +/** 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 +124,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 +202,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 +352,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 +470,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 isBridgeClientConnected(client); + writeJson(res, connected ? 200 : 503, buildHealth(connected)); return; } @@ -436,12 +513,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 +543,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 +619,7 @@ export function buildTransportArgs(): string[] { } else { args.push("--isolated"); } - if (process.env.OPERA_CLI_HEADED !== "1") { + if (!shouldRunHeaded()) { args.push("--headless"); } } @@ -502,6 +634,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 +793,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 +865,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..7dc7c63 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 --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 doctor`, + 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 } { @@ -1362,13 +1492,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 +1864,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 +2001,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 +2122,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 +2136,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 +2248,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 +2289,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 +2341,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 +2463,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 +2538,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 +2546,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 +2571,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 }; + 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); + } } 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 +2653,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 +2693,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 +2869,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 +2887,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 +3218,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 +3233,201 @@ 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); -function warnIfUnconfigured(argv: string[]): void { + 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. + */ +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; + // A running bridge already owns a browser — the question is settled. + if ((await findUsableBridge(candidatePorts())) !== null) 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") { + process.env.OPERA_CLI_BROWSER_URL = target.url; + return; + } + if (target.mode === "managed") return; + + await resolveBrowserConflict(target, takeover); +} + +const SETUP_SKIP_COMMANDS = new Set(["setup", "logs", "--help", "-h", "--version", "-v", "-V"]); + +/** + * 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 +3441,6 @@ export async function main( commands: COMMANDS, getCommandHelp, renderUnknownCommand, + formatError: formatCliError, }); } diff --git a/src/client.ts b/src/client.ts index 2e9f9c8..4f04ee6 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); + } +} + +// --------------------------------------------------------------------------- +// 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; +} - // 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; +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; } +} - // 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; +function releaseStartLock(): void { + if (!holdingLock) return; + holdingLock = false; + process.off("exit", releaseStartLock); + try { + unlinkSync(LOCK_FILE); + } catch { + // Someone else cleaned it up — fine } - 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`, - ], - ); +} + +/** 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; +} + +/** + * 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 } + return acquireStartLock(); +} + +// --------------------------------------------------------------------------- +// Logging +// --------------------------------------------------------------------------- - // Start a new bridge +/** 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; + } +} - 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"; +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. + } +} - // 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"; +/** 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 ""; + } +} + +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,134 @@ 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 — they are reported instead, so + * the user decides whether to pay for a second run. + */ +export async function callTool( + name: string, + args: Record = {}, +): Promise { 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); + return await callToolOnce(name, args, {}); + } catch (error) { + 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)); + } + } + + 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)); } - return data.result ?? ""; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw mapErrorMessage(message); } } 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 +1041,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 +1060,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 +1182,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..dff3bb9 --- /dev/null +++ b/test/bridge-recovery.test.ts @@ -0,0 +1,145 @@ +/** + * 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 { 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("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..a2f4b08 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", () => { 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.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..723964a 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"; 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 { From fded5e60ac3404cba807868648091f69b9e51a81 Mon Sep 17 00:00:00 2001 From: macieju-opera <280402227+macieju-opera@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:01:39 +0200 Subject: [PATCH 2/3] Harden bridge self-healing on dead browsers; condense skill Detect a wedged bridged (up but pointed at a dead browser) by probing the attach URL on /health, so findUsableBridge stops reusing it and the next command rebuilds against the current target. open now fails loudly (exit 3) instead of faking success. Condense SKILL.md and keep the ask-the-user rule. --- SKILL.md | 13 +++- src/bridge.ts | 58 +++++++++++++- src/cli.ts | 104 +++++++++++++++++++++---- src/client.ts | 108 +++++++++++++++++++------- test/bridge-recovery.test.ts | 68 ++++++++++++++++ test/bridge.test.ts | 17 ++++ test/fixtures/stub-mcp-unreachable.js | 59 ++++++++++++++ test/main.test.ts | 44 +++++++++++ test/preflight.test.ts | 104 +++++++++++++++++++++++++ 9 files changed, 534 insertions(+), 41 deletions(-) create mode 100644 test/fixtures/stub-mcp-unreachable.js create mode 100644 test/preflight.test.ts diff --git a/SKILL.md b/SKILL.md index 320156e..d1aa386 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"]}}} --- @@ -65,6 +65,17 @@ Branch on the exit code rather than parsing messages: 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 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/src/bridge.ts b/src/bridge.ts index 1caae7a..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, @@ -110,6 +111,61 @@ 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(); @@ -474,7 +530,7 @@ export async function handleBridgeRequest( // 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") { - const connected = await isBridgeClientConnected(client); + const connected = await isBridgeHealthConnected(client); writeJson(res, connected ? 200 : 503, buildHealth(connected)); return; } diff --git a/src/cli.ts b/src/cli.ts index 7dc7c63..5536f84 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1260,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) { @@ -1268,23 +1284,57 @@ async function handleOpen(args: string[], full: boolean, raw = false): Promise --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 { @@ -3344,16 +3394,22 @@ async function resolveBrowserConflict( * * 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. */ -async function preflightBrowser(argv: string[], takeover: boolean): Promise { +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; - // A running bridge already owns a browser — the question is settled. - if ((await findUsableBridge(candidatePorts())) !== null) return; const target = await resolveBrowserTarget({ browserUrl: process.env.OPERA_CLI_BROWSER_URL, @@ -3362,12 +3418,34 @@ async function preflightBrowser(argv: string[], takeover: boolean): Promise = {}, ): Promise { + let result: string; try { - return await callToolOnce(name, args, {}); + result = await callToolOnce(name, args, {}); } catch (error) { - 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 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(); + } - if (!isTransportFailure(message)) throw mapErrorMessage(message); + return result; +} - 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", - ], - ); - } +/** + * 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, { forceRestart: true }); + return await callToolOnce(name, args, {}); } catch (retryError) { throw mapErrorMessage(errorMessageOf(retryError)); } } + + 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 { diff --git a/test/bridge-recovery.test.ts b/test/bridge-recovery.test.ts index dff3bb9..1c466b2 100644 --- a/test/bridge-recovery.test.ts +++ b/test/bridge-recovery.test.ts @@ -7,6 +7,7 @@ */ 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"; @@ -101,6 +102,73 @@ describe("recovery from a dropped bridge", () => { 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" }); diff --git a/test/bridge.test.ts b/test/bridge.test.ts index a2f4b08..7204879 100644 --- a/test/bridge.test.ts +++ b/test/bridge.test.ts @@ -517,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/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/main.test.ts b/test/main.test.ts index 723964a..67fb79c 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -121,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..861024a --- /dev/null +++ b/test/preflight.test.ts @@ -0,0 +1,104 @@ +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 })), +})); + +import { preflightBrowser } from "../src/cli.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(); + 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(); + }); +}); From a9fd83fa74b10ee4178c8e70c7789ae4ad1b10f9 Mon Sep 17 00:00:00 2001 From: macieju-opera <280402227+macieju-opera@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:27:59 +0200 Subject: [PATCH 3/3] Fix open fake-success gap, trailing space, and takeover test --- SKILL.md | 2 +- src/cli.ts | 19 +++++++++++-- test/preflight.test.ts | 60 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/SKILL.md b/SKILL.md index d1aa386..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). 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. +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"]}}} --- diff --git a/src/cli.ts b/src/cli.ts index 5536f84..66f0020 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1307,7 +1307,7 @@ async function handleOpen(args: string[], full: boolean, raw = false): Promise --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. */ diff --git a/test/preflight.test.ts b/test/preflight.test.ts index 861024a..97b4024 100644 --- a/test/preflight.test.ts +++ b/test/preflight.test.ts @@ -39,7 +39,26 @@ vi.mock("../src/config.js", async (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; @@ -58,6 +77,9 @@ describe("preflightBrowser reconciles even when a bridge is running", () => { 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; @@ -101,4 +123,42 @@ describe("preflightBrowser reconciles even when a bridge is running", () => { 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"); + }); });