Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 37 additions & 12 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,20 @@ that, `EADDRINUSE` stops it. The control socket file that used to carry both
jobs is gone, and with it the watchdog that self-exited a daemon whose socket
was replaced — there is nothing left to replace.

### daemon ownership
Which process owns the single **endpoint**: `launchd` (an installed LaunchAgent
plist runs `serve`, ADR-0012 rules 1–3) or `cli` (the CLI started the same
`serve` detached — `browserwright-daemon restart` / `serve`, or
`daemon_lifecycle.ensure`'s on-demand spawn — with no plist anywhere). Both are
supported modes; `restart` works in both, takes the plist case through launchd
unchanged, and reports which one it bounced (`owner`).

**Trap:** this is not the **owner** above, which is about *browsers*. This one
is about the *daemon process*, and it is what decides where durable bind config
lives: in the plist's `ProgramArguments` (launchd-owned) or in the config file
(`--config`, else `$BD_CONFIG`, else `$BS_HOME/config.toml`) — where a flag the
plist passes still tops the file.

### downstream
Everything that connects **into** the daemon: the CLI, the skill client, a
Playwright client on the cdp surface. Downstream must never branch on backend —
Expand Down Expand Up @@ -348,11 +362,11 @@ just before it is the attribution.
### daemon lifecycle
The client side's one owner of "is the daemon up, and make it so"
(`src/browserwright/daemon_lifecycle.py`). Three calls: `diagnose()` returns
one `DaemonVerdict` (`up` · `stale` · `down` · `foreign` · `unreachable` ·
`undecided`), `ensure(reason)` is the **only** Layer 2 code that starts or
replaces the daemon (executor handoff, initiator, `LIFECYCLE` line, child env),
and `unreachable_fix()` builds the agent-facing diagnosis text. Every
`browserwright-daemon <verb>` a Layer 2 module runs goes through its
one `DaemonVerdict` (`up` · `stale` · `down` · `foreign` · `unresponsive` ·
`unreachable` · `undecided`), `ensure(reason)` is the **only** Layer 2 code
that starts or replaces the daemon (executor handoff, initiator, `LIFECYCLE`
line, child env), and `unreachable_fix()` builds the agent-facing diagnosis
text. Every `browserwright-daemon <verb>` a Layer 2 module runs goes through its
`run_verb` adapter, which carries the resolved endpoint into the child.

**Trap:** constructing a `Session` (or its `ModeBClient`) has no lifecycle side
Expand All @@ -363,8 +377,13 @@ configured endpoint (see `endpoint`) is diagnosed, never started or replaced.

**Trap:** `diagnose(confirm=True)` takes two probes and concludes nothing when
they disagree (`undecided`); only `down` and `stale` on the default endpoint
are `replaceable`. `foreign` (something that is not browserwright holds the
port) is never replaced by `recover` — ADR-0013 rule 2.
are `replaceable`. `foreign` (something *answered* HTTP that is not our pong)
and `unresponsive` (the socket opened and nothing came back, for the whole
escalated probe window) are never replaced by `recover` — ADR-0013 rule 2.
Folding silence into `foreign` is exactly the bug that replaced a live daemon
mid-call on 2026-10-19: two agreeing timeouts are not evidence of anything.
A probe that times out is therefore re-asked with a much larger budget
(`_ipc.probes_until_answered`), so a merely slow daemon comes back `up`/`stale`.

### activity gate
The daemon's own answer to "would interrupting me hurt someone right now":
Expand All @@ -375,11 +394,17 @@ and the e2e runner all consult it and refuse while it says busy;
`restart --force` is the only override, for humans. ADR-0012 rules 2 and 4.

### lifecycle event
A daemon start / stop / restart / spawn, written as one `LIFECYCLE <event>`
line into the daemon log (`_ipc.log_lifecycle`) by whichever process caused
it, so the record exists even when the daemon being replaced never logs its
exit. Distinct from the extension's `hello` / relay reconnects, which are
connection events, not process events.
A daemon start / stop / restart / spawn / signal, written as one
`LIFECYCLE <event>` line into the daemon log (`_ipc.log_lifecycle`) by
whichever process caused it, so the record exists even when the daemon being
replaced never logs its exit. Distinct from the extension's `hello` / relay
reconnects, which are connection events, not process events.

**Trap:** a *signal-driven* stop is the one event only the daemon can record
(`LIFECYCLE signal sig=… pid=… initiator=…`): whoever ran `kill` writes no
log line. It is skipped when the `stop` verb already recorded that stop, so
one stop is one line. A stop with neither line is a stop the daemon never got
to write — SIGKILL, or a crash — not an ordinary shutdown.

### Router
The frame-routing engine (`daemon/server/proxy.py`). Owns request-id rewriting,
Expand Down
58 changes: 57 additions & 1 deletion docs/adr/0012-development-never-touches-the-global-daemon.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Development never touches the global daemon, the global binary, or the daily Chrome

Status: accepted (2026-09-02). Rule 5 implemented (#89, via #92); rules 1–4 and 6 implemented (#91). Amended the same day: remote use over the tailnet is a real requirement (a VPS drives this Mac's Chrome), so rule 3 keeps the non-loopback bind and makes it durable instead of removing it; rule 4 became an activity gate rather than a concurrency cap (rule 4 was later withdrawn, 2026-10-05); rule 6 was added after the maintainer's own verification run stopped the global daemon. Supersedes the partial
Status: accepted (2026-09-02). Rule 5 implemented (#89, via #92); rules 1–4 and 6 implemented (#91). Amended the same day: remote use over the tailnet is a real requirement (a VPS drives this Mac's Chrome), so rule 3 keeps the non-loopback bind and makes it durable instead of removing it; rule 4 became an activity gate rather than a concurrency cap (rule 4 was later withdrawn, 2026-10-05); rule 6 was added after the maintainer's own verification run stopped the global daemon. Amended 2026-10-19: rule 7 — the endpoint has exactly one owner, and a CLI-owned daemon with no LaunchAgent is a supported owner. Supersedes the partial
isolation in `tests/conftest.py` (kept) by extending the same rule to every
developer-facing verb.

Expand Down Expand Up @@ -156,6 +156,62 @@ Rules, one per leak found:
`stop` skips the guard when the override is port 0 (the per-test
"bind anywhere" scheme), where the state file is the only truth.

### Amendment (2026-10-19, ownership): a CLI-owned daemon is the other supported owner

Rules 1–6 above are all written against an *installed* LaunchAgent, and read
together they made the plist look mandatory: rule 3 gave durable bind config no
home but the plist, and `install` was "the" way to run the daemon as a service.
Three gaps followed, each found by hitting it:

- **`restart` required a plist.** It refused with "no LaunchAgent installed"
(exit 2) before doing anything, and `mise run upgrade-global` and `mise run
restart-daemon` both end on `browserwright-daemon restart` — so on a machine
that runs a CLI-owned daemon, a release upgrade died at the restart step with
the new package already installed.
- **Durable bind config lived only in the plist.** `facade_host` (the tailnet
address a remote consumer connects to, with loopback co-bound) could only be
made durable by installing a LaunchAgent. `upgrade-global` scrubs
`BD_CONFIG`/`BD_FACADE_HOST` before it touches the global install (rule 2),
so an environment variable cannot be the durable home either.
- **A signal-driven stop was unattributable.** Rule 5's `LIFECYCLE stop` line is
written by the `stop` verb, never by the daemon, so a daemon that something
else SIGTERMed left nothing behind but `shutdown requested` — the exact
evidence gap rule 5 exists to close, and the reason the 2026-10-19 incident
(a dev-checkout script killing the machine-global daemon mid-call) was
expensive to reconstruct.

7. **The endpoint has exactly one owner, and "no LaunchAgent" is a supported
mode.** The owner is either launchd (an installed plist, rules 1–3) or the
CLI that started `serve` (a *CLI-owned* daemon: `browserwright-daemon
restart`/`serve`, or `daemon_lifecycle.ensure`'s on-demand spawn). Both are
supported; neither is a degraded form of the other. Concretely:
- **Durable bind config lives in the config file.** The toml is `--config`,
else `$BD_CONFIG`, else `$BS_HOME/config.toml` (default
`~/.browserwright/config.toml`, beside `global.md` / `sessions/` /
`site-skills/`). A missing file is an empty config, never an error, and
the precedence is unchanged: a flag the plist passes still tops the file
(rule 3), so the two homes cannot silently fight over one setting.
- **`restart` never requires a LaunchAgent.** With a plist it does what it
always did. Without one it stops the incumbent and starts the same
`serve` detached, then proves the *same* post-condition: a pid exists, it
differs from the incumbent's, and `/__status__` reports the expected
version. A `serve` that cannot bind exits at once (status 2) and the
incumbent is already stopped, so every failure is reported with its exit
status — never a silent ok. `install`/`uninstall` are untouched: a plist
is still how you ask launchd to own the daemon.
- **Both owners write the same lifecycle trail.** A signal-driven shutdown
records its own `LIFECYCLE signal` line (signal, pid, initiator from the
daemon's environment) before the shutdown sequence starts, and skips it
when the `stop` verb already recorded that stop — one event, one line.
- **The mode is visible, not merely tolerated.** `browserwright-daemon
list` reports `service: manual` when no plist is installed, and
`restart`'s report carries `owner: cli | launchd`.

What this does NOT change: the isolation rules themselves (a development
daemon still owns its own ports, runtime dir and ledger), the diagnosis and
activity gates on `restart`, or who may signal what — a CLI-owned restart
still stops only a locally signalable endpoint.

## What this does NOT change

- ADR-0011's single-endpoint transport stays. Whether a daemon restart should
Expand Down
12 changes: 12 additions & 0 deletions docs/adr/0013-daemon-owns-session-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,18 @@ is logged with its reason and both probe results (ADR-0012 rule 5). If the ladde
ends in `needs-human`, `recover` says so with the diagnosis and stops; it never
loops.

Amendment (2026-10-19, incident): a **probe timeout is not evidence about a
process**. The first implementation classified an unanswered `GET /__ping__` as
`foreign` ("something else holds the port"), so two agreeing 1.5s timeouts
authorized a replacement — and, through `serve`'s port reclaim, a SIGTERM —
against a machine-global daemon that was alive but momentarily slow (still
accepting ws clients, graceful on the signal). Silence now has its own verdict,
`unresponsive`: a timeout (or non-HTTP bytes) is escalated for a bounded window
before anything is concluded, `ensure` never spawns or stops on it, and the
implicit reclaim that SIGTERMs a port holder requires the holder to have stayed
unanswered for that whole window. `foreign` keeps its original meaning —
something *answered*, just not with our pong.

### 3. Agent-visible error text follows a banned-word list

Text an agent can see (exceptions, `doctor`, skill documents' runtime-error
Expand Down
14 changes: 7 additions & 7 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ semantics, tab creation, facade behavior, or teardown:
--port <X> --profile /tmp/...` or the e2e harness. Never short-connect to
the user's daily Chrome.
2. *Filesystem*: any code path that writes outside the temp dir
(e.g. `~/.config/browserwright-daemon/config.toml`) must accept a
`*_PATH` env override so tests can redirect to `tmp_path`. The wizard's
`BS_DAEMON_CONFIG_PATH` is the canonical example.
(e.g. `$BS_HOME/config.toml`) must accept a
`*_PATH` env override so tests can redirect to `tmp_path`. The daemon's
config resolves through `BS_HOME` for exactly this reason.
- **Develop without touching global state.** The host machine very likely
already runs a global `browserwright`/`browserwright-daemon` plus a loaded
Chrome extension; this checkout must coexist with that, not fight it. The
Expand Down Expand Up @@ -87,7 +87,7 @@ touching any of the above.
| Daemon endpoint | `127.0.0.1:19990` | two daemons cannot share the port — the second refuses to start |
| Extension relay port | `127.0.0.1:19989` | starting a second relay there steals connections from the global extension |
| CDP port | `9222` (Chrome's default) | a stray `--remote-debugging-port=9222` collides with your daily Chrome and pops Allow dialogs |
| Daemon TOML config | `~/.config/browserwright-daemon/config.toml` (override with `BD_CONFIG`) | inherited backend / port settings leak into your dev daemon |
| Daemon TOML config | `~/.browserwright/config.toml` (`$BS_HOME`; override with `BD_CONFIG`) | inherited backend / port settings — including a durable `facade_host` — leak into your dev daemon |
| Skill home | `~/.browserwright/` (override with `BS_HOME`) | site-skill writes and memory leak across instances |
| Chrome profile | the user's daily profile | `--load-extension` / `--remote-debugging-port` ruin it on Chrome 144+ |

Expand Down Expand Up @@ -129,7 +129,7 @@ which port is which:
| daemon endpoint host | `127.0.0.1` (loopback) | tailnet/LAN IP or `0.0.0.0` to reach off-box | `--facade-host` / `BD_FACADE_HOST` |
| which daemon a client talks to | `http://127.0.0.1:19990` | the test daemon's endpoint URL | `BW_DAEMON_URL` / `--daemon-url` |
| runtime dir (pid, executor sockets, log) | `${XDG_RUNTIME_DIR:-/tmp}` | `$(mktemp -d)` | `XDG_RUNTIME_DIR` |
| daemon TOML config | `~/.config/browserwright-daemon/config.toml` | none | `BD_CONFIG=""` |
| daemon TOML config | `$BS_HOME/config.toml` | nothing (redirected `BS_HOME` has no file) | redirect `BS_HOME`; `BD_CONFIG` for a file of your own |
| skill home | `~/.browserwright/` | tmpdir | `BS_HOME` |

Isolation is **port-based** (ADR-0011): give the test daemon its own
Expand Down Expand Up @@ -236,7 +236,7 @@ End-to-end isolation matrix (kept in sync with `tests/daemon/e2e/README.md`):
| Chrome `user-data-dir` | the user's daily profile | per-test tmpdir |
| Chrome binary | Google Chrome | Chrome for Testing |
| extension `RELAY_URL` | `ws://127.0.0.1:19989/` | `ws://127.0.0.1:29989/` (patched copy) |
| `BD_CONFIG` | `~/.config/browserwright-daemon/config.toml` | `""` (no file) |
| daemon TOML config | `$BS_HOME/config.toml` (when `BD_CONFIG` is unset) | `tests/daemon/e2e/_bs_home/…/config.toml` (via `BS_HOME`) |
| `BS_HOME` | `~/.browserwright/` | `tests/daemon/e2e/_bs_home/...` |

Nothing escapes the boundary. You can have your daily Chrome + global
Expand Down Expand Up @@ -306,7 +306,7 @@ lsof -iTCP:29989 -sTCP:LISTEN
ls -la ~/.browserwright/

# Daemon config untouched?
stat -f '%m' ~/.config/browserwright-daemon/config.toml 2>/dev/null
stat -f '%m' ~/.browserwright/config.toml 2>/dev/null
```

If `lsof :19989` and `lsof :29989` print **different** PIDs (or
Expand Down
5 changes: 4 additions & 1 deletion docs/daemon.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,10 @@ daemon 会让已连接的扩展调用 `chrome.runtime.reload()` 从磁盘重新

## 配置(可选)

`~/.config/browserwright-daemon/config.toml`:
`$BS_HOME/config.toml`(默认 `~/.browserwright/config.toml`,与 `global.md`、`sessions/`、
`site-skills/` 同一个 home;`--config` / `BD_CONFIG` 可指定别的文件,文件不存在等于空配置)。
装 LaunchAgent 的机器上也可以把同样的条目写进 plist 的 `ProgramArguments`——那是另一种
endpoint 归属模式(ADR-0012 规则 7),两种模式只需要选一种:

```toml
# 覆盖 daemon shared upstream(与 `BD_BACKEND` 等价;
Expand Down
Loading
Loading