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
28 changes: 25 additions & 3 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,11 @@ jobs:
# docs/deployment/serve-decisions.md). What it does assert is everything
# between the browser and that boundary.
serve:
timeout-minutes: 30
# Was 30 against a measured 9.5m. The lifecycle step below builds
# biorouter-cli's tests, which unify its dev-dependency features and so
# recompile part of the graph a plain `cargo build` already built (4m56s on
# a loaded M4 Max); the budget keeps the same margin over that.
timeout-minutes: 45
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
steps:
Expand Down Expand Up @@ -437,7 +441,9 @@ jobs:
[ "$(curl -s -o /dev/null -w '%{http_code}' 'http://127.0.0.1:18777/?t=wrong')" = 401 ] \
|| fail 'a wrong token was accepted'

# The token is spent once, for a cookie, and leaves the address bar.
# The token is exchanged for a cookie and leaves the address bar. It is
# not consumed (SD-9 in docs/deployment/serve-decisions.md): the
# readiness loop above has already redeemed it.
curl -s -o /dev/null -D "$RUNNER_TEMP/h" "http://127.0.0.1:18777/?t=citoken"
grep -qi '^HTTP/1.1 303' "$RUNNER_TEMP/h" || fail 'no redirect after the token exchange'
grep -qi 'set-cookie: biorouter_session=' "$RUNNER_TEMP/h" || fail 'no session cookie'
Expand Down Expand Up @@ -474,5 +480,21 @@ jobs:
'http://127.0.0.1:18777/headless/fs/read?path=/etc/passwd')" = 403 ] \
|| fail 'the filesystem endpoint read outside its allowed roots'

kill "$serve_pid" || true
# Stopping serve by pid stops its daemon. It used to leave the daemon
# running, still holding this port and still honouring the token.
# `serve` reaps the daemon before it exits, so the port is closed the
# moment `wait` returns.
kill "$serve_pid" || fail 'serve was no longer running'
wait "$serve_pid" || true
! curl -s -o /dev/null --max-time 5 http://127.0.0.1:18777/status \
|| fail 'the daemon outlived serve'
echo 'the browser contract holds'

# SIGTERM and SIGINT to `serve`, and SIGKILL, which leaves only the
# daemon's own parent watch to stop it. Here rather than in the workspace
# test job, which runs `--lib --bins` only: this needs a `biorouterd` from
# the same tree beside `biorouter`, which the build step above provides.
- name: Stopping serve stops its daemon
env:
BIOROUTER_DISABLE_KEYRING: "true"
run: cargo test -p biorouter-cli --test serve_lifecycle
35 changes: 28 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1164,7 +1164,7 @@ Test the gate where it is: the unit tests in `agents/agent.rs`
prints a URL. The daemon serves the SPA **on its own origin**, so nothing is proxied. This
replaced a standalone `biorouter-headless` binary and its Linux tarball, both deleted
2026-08-23; release assets went 11 → 10. Design and reasoning:
[`docs/deployment/serve-decisions.md`](docs/deployment/serve-decisions.md) (SD-1..SD-8),
[`docs/deployment/serve-decisions.md`](docs/deployment/serve-decisions.md) (SD-1..SD-9),
[`serve-architecture.md`](docs/deployment/serve-architecture.md),
[`browser-access.md`](docs/deployment/browser-access.md).

Expand Down Expand Up @@ -1203,27 +1203,48 @@ replaced a standalone `biorouter-headless` binary and its Linux tarball, both de
- **The serving path.** `Settings.serve_ui` (`BIOROUTER_SERVE_UI`) →
`routes::web_ui::attach`, called **after** `check_token` in `commands/agent.rs` so the shell
and bundle sit *structurally outside* that middleware rather than being exempted by path.
The document is gated by a browser token exchanged once for an `HttpOnly; SameSite=Strict`
The document is gated by a browser token exchanged for an `HttpOnly; SameSite=Strict`
cookie; the cookie authenticates **the document only** — API routes still take
`X-Secret-Key`, so there is no CSRF surface and `check_token` needed no change.
- **`routes::shell`** holds the 16 `/headless/*` endpoints (path kept deliberately; the
renderer builds `origin + '/headless'`). They had **no authentication at all** on the old
`X-Secret-Key`, so there is no CSRF surface and `check_token` needed no change. ⚠ The
exchange does **not** consume the token — it is honoured as often as it is presented until the
daemon stops, and the cookie's value *is* the token. Deliberate, not an oversight: SD-9 in
`serve-decisions.md` records why single use was rejected. It used to be called "spent".
- **`routes::shell`** holds the 16 `/headless/*` paths — 17 handlers, since `/headless/settings`
answers both GET and POST (path kept deliberately; the renderer builds
`origin + '/headless'`). They had **no authentication at all** on the old
binary and `fs_read` had no path validation; the port confines every filesystem handler to
an allowlist and refuses credential stores by name.
- **WebSocket origins**: `routes::origin_matches_host` compares `Origin` to the request's own
`Host`. That is a same-origin test, not a wildcard, and it is what lets a browser reach the
daemon at a LAN address `is_local_origin` has never heard of.
- **`serve` owns its daemon's lifetime**, because the daemon honours the token and serves the
shell carrying its secret for as long as it runs: stopping `serve` is the only revocation.
Two layers. Every exit path after the spawn goes through `stop_daemon` (SIGTERM, a 10 s grace,
then SIGKILL and reap), with SIGINT/SIGTERM listeners installed *before* the spawn; and on
Unix the daemon is started with `biorouterd agent --exit-with-parent <serve pid>`, which
covers a SIGKILLed or crashed `serve`. ⚠ Until 2026-09 neither held — the `Child` was moved
into a `spawn_blocking` wait, so only a terminal's process-group Ctrl-C ever reached the
daemon and `kill <pid of serve>` orphaned it on the port. ⚠ The flag is opt-in and compares
`getppid()` with the pid `serve` named, **not with 1**: an orphan is re-parented to the
nearest subreaper (`systemd --user`, a container init), so `== 1` never fires there. Never
pass it from the desktop.
- ⚠ **Three traps.** The app uses a **HashRouter**, so its routes live in the fragment and
never reach the daemon — that is the only reason `/sessions/{id}` (a real API route) does
not collide with the app's own; a history router would break pages silently. The bundle must
be **root-base** (`npm run build:web` → `ui/desktop/src/web`), because Forge forces a
*relative* base and a relative bundle served at `/` breaks deep links while the landing page
looks fine. And `<exe>/../web` resolves for the packaged app and Windows zip but **not** for
deb/rpm (`/usr/bin/../web` = `/usr/web`), hence `/usr/share/biorouter/web`.
- **Tests:** `cargo test -p biorouter-server --lib routes::web_ui routes::shell`,
- **Tests:** `cargo test -p biorouter-server --lib -- routes::web_ui routes::shell` (the `--`
is required: cargo takes one filter before it and rejects a second with a usage error),
`cargo test -p biorouter-cli --lib commands::serve`, the `serve` job in
`.github/workflows/rust.yml`, and `smoke_serve` in
`scripts/smoke-test-release-artifacts.sh`.
`scripts/smoke-test-release-artifacts.sh`. The lifecycle has its own binary,
`cargo test -p biorouter-cli --test serve_lifecycle`, which runs the real `serve` and
signals it by pid; ⚠ it needs a `biorouterd` from the same tree beside `biorouter`, which
`cargo test -p biorouter-cli` does not build — run `cargo build -p biorouter-cli -p
biorouter-server` first. CI runs it in the `serve` job, because the workspace test job is
`--lib --bins` and never runs an integration binary.

### Communication Flow

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions crates/biorouter-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ tikv-jemalloc-ctl = { workspace = true, optional = true }
[target.'cfg(target_os = "windows")'.dependencies]
winapi = { version = "0.3", features = ["wincred"] }

# `serve` asks the daemon it started to stop with SIGTERM before it kills it.
[target.'cfg(unix)'.dependencies]
libc = "0.2"

[features]
default = ["jemalloc"]
# Use tuned jemalloc as the global allocator (returns freed pages to the OS).
Expand Down
6 changes: 5 additions & 1 deletion crates/biorouter-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1584,7 +1584,11 @@ enum Command {
no_token: bool,

/// Directory holding the built interface
#[arg(long, help = "Directory holding the built web interface")]
#[arg(
long,
help = "Directory holding the built web interface. Takes precedence over \
BIOROUTER_SERVE_UI; either must contain an index.html"
)]
web_dir: Option<std::path::PathBuf>,

/// Open a browser once it is ready
Expand Down
Loading
Loading