diff --git a/README.md b/README.md index f002531..638c01b 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,8 @@ install. Both are covered in [Installation](docs/installation.md). |-------|--------------| | [Installation](docs/installation.md) | Requirements, Docker, systemd install, config wizard, reverse proxy, updates | | [Configuration](docs/configuration.md) | The two config files, every YAML key that matters, where to run the server | -| [Tools](docs/tools.md) | The 44 MCP tools, grouped by module | +| [Tools](docs/tools.md) | The 46 MCP tools, grouped by module | +| [Updates](docs/updates.md) | The update notice, the self-update tools, and how to turn both off | | [Client setup](docs/clients.md) | Assistant, ChatGPT, Gemini, Mistral, VS Code, Cursor, OpenCode | | [Security](docs/security.md) | What to review before approving a tool call, token handling, TOTP hygiene | | [Dashboard](docs/dashboard.md) | The optional `/app/*` web panel: login, API tokens, Gemini chat | diff --git a/beaconmcp.yaml.example b/beaconmcp.yaml.example index db590ed..3ceda3b 100644 --- a/beaconmcp.yaml.example +++ b/beaconmcp.yaml.example @@ -230,6 +230,20 @@ features: public_url: https://mcp.example.com # used to generate MCP URLs in the UI mcp_mode: local # "local" (default) or "remote" + # Update notifications. When enabled, the server periodically compares + # this checkout against the upstream default branch and shows a notice + # in the dashboard (signed-in operators only), with instructions matched + # to how BeaconMCP was installed here. Also exposes the + # beaconmcp_check_update / beaconmcp_self_update MCP tools. + updates: + # Set to false on an air-gapped or change-controlled deployment: the + # server then never contacts the git remote at all. + enabled: true + # Set to false to keep the notification but forbid applying it from + # the dashboard or over MCP -- appropriate when updates go through a + # deployment pipeline. Manual instructions are still shown. + allow_self_update: true + # Free-form infrastructure context exposed as an MCP resource. Edit freely: # the LLM reads this to understand your topology, naming conventions, and # operational notes. diff --git a/docs/configuration.md b/docs/configuration.md index ab2e1b3..9f1b14b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -70,3 +70,12 @@ Tailscale IP, a VPN address, a bastion. Everything else about the panel — enabling it, the tokens page, cost tracking, the confirmation modal — is in [dashboard.md](dashboard.md). + +## Updates + +| Key | Notes | +|-----|-------| +| `features.updates.enabled` | Default `true`. Compares this checkout against the upstream default branch and shows a notice to signed-in operators. Set to `false` on an air-gapped or change-controlled box: the server then never contacts the git remote, and the `beaconmcp_*_update` MCP tools are not registered. | +| `features.updates.allow_self_update` | Default `true`. Set to `false` to keep the notification but forbid applying it from the dashboard or over MCP — the right setting when deploys go through a pipeline. Manual instructions are still shown. | + +See [updates.md](updates.md) for the notice, the MCP tools, and what the self-update does. diff --git a/docs/dashboard.md b/docs/dashboard.md index 162513b..d27239f 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -2,8 +2,8 @@ Optional web panel served by BeaconMCP on the same origin as the MCP endpoint (`https:///app/...`). Three pages: -- **`/app/login`** — exchanges a client id + client secret + TOTP code for an MCP bearer, and stores it in a 90-day HttpOnly session cookie. Removes the need to issue `curl` requests from a phone. -- **`/app/chat`** — multi-conversation chat with Gemini 2.5 Flash/Pro (stable) or Gemini 3 Flash / 3.1 Pro (preview, Google allowlist required). **Requires `GEMINI_API_KEY`.** +- **`/app/login`** — exchanges a client id + client secret + a second factor (TOTP code or passkey) for an MCP bearer, and stores it in a 90-day HttpOnly session cookie. Removes the need to issue `curl` requests from a phone. +- **`/app/chat`** — multi-conversation chat with Gemini 3.6 Flash / 3.5 Flash-Lite (GA) or Gemini 3.1 Pro (preview, Google allowlist required). **Requires `GEMINI_API_KEY`.** - **`/app/tokens`** — generates named bearers so external MCP clients (Gemini web, ChatGPT, Assistant Desktop) can be wired up without the OAuth dance. **Works without `GEMINI_API_KEY`.** ## Enabling @@ -36,9 +36,32 @@ or `(chat: disabled, tokens only)` when the Gemini key is missing, or `disabled` ## Flow 1. Navigate to `https:///` on any device — the root path redirects to `/app/login`. -2. Enter client id + client secret + current TOTP code (once per 90-day window). -3. Land on `/app/chat` (or `/app/tokens` in tokens-only mode) with the conversation history restored. -4. Every 24 hours the underlying MCP bearer expires. The dashboard prompts only for a fresh TOTP code on `/app/refresh`; the client id and secret remain encrypted server-side. +2. Enter client id + client secret, then the current TOTP code — or use a passkey instead, from the + link under the code boxes (see [Passkeys](#passkeys)). Enter validates as soon as the + sixth digit lands. +3. A confirmation screen shows when the MCP access and the signed-in session expire, offers to + register a passkey on this device, and waits for **Finish signing in**. +4. Land on `/app/chat` (or `/app/tokens` in tokens-only mode) with the conversation history restored. +5. Every 24 hours the underlying MCP bearer expires. The dashboard prompts only for a fresh TOTP code on `/app/refresh`; the client id and secret remain encrypted server-side. + +With JavaScript disabled the page falls back to the previous behaviour: a plain form POST that +redirects straight to the landing page, no confirmation screen and no passkeys. + +## Passkeys + +A passkey replaces the 6-digit code, not the client secret — signing in still needs both factors. +Enrol one from the confirmation screen right after a normal sign-in, then on the next visit use +**Use a passkey instead** under the code boxes. + +The same option is on the OAuth consent page (`/oauth/authorize`), which now also confirms before +redirecting: it shows when the granted access expires, offers passkey enrolment, and only mints the +authorization code when you press **Finish signing in**. + +Registered passkeys are listed at the bottom of `/app/tokens` with their label, when they were added +and when they were last used, each with a **Remove** button. + +Caveats — hostname binding, the HTTPS/loopback requirement, and why you should keep your TOTP seed — +are covered in [security.md](security.md#passkeys). ## Tokens page @@ -63,9 +86,12 @@ Constraints: ## Chat — models and thinking -- **Gemini 2.5 Flash / Pro** — available on every AI Studio key. **Used by default** (`gemini-2.5-flash`). -- **Gemini 3 Flash / 3.1 Pro (preview)** — gated by a Google allowlist. Without allowlist access, the dashboard surfaces a clear message pointing back to 2.5. -- **Thinking effort** — dropdown with `minimal` / `low` / `medium` / `high`. `gemini-2.5-pro` cannot disable thinking, so `minimal` is clamped to the 128-token floor automatically. +- **Gemini 3.6 Flash** — GA, available on every AI Studio key. **Used by default** (`gemini-3.6-flash`). +- **Gemini 3.5 Flash-Lite** — GA, the cheap high-throughput option (`gemini-3.5-flash-lite`), 5× cheaper in and out than 3.6 Flash. +- **Gemini 3.1 Pro (preview)** — gated by a Google allowlist. Without allowlist access, the dashboard surfaces a clear message pointing back to the two GA models. +- **Thinking effort** — dropdown with `minimal` / `low` / `medium` / `high`. The Gemini 3 family takes a `thinking_level` enum; the token-budget mapping in `_BUDGET_BY_EFFORT` (and the 128-token floor `gemini-2.5-pro` needed) only applies to models outside it, and is kept for conversations that predate the switch. + +Gemini 2.5 Flash / Pro and `gemini-3-flash-preview` were retired from the picker when 3.6 Flash and 3.5 Flash-Lite went GA (2026-07-21). A conversation sitting on one of them is moved to the closest current model by the schema-6 migration; `messages.model` is left alone, so the transcript keeps naming whichever model actually wrote each reply, and `usage.py` keeps their rates so old turns are never re-priced. - **Markdown rendering** — the client parses headings (`#`–`######`), ordered and unordered lists, blockquotes, horizontal rules, code fences (with `lang-*` class), inline code, bold/italic/strikethrough, and HTTP(S) links. ## Mandatory confirmation for dangerous tools @@ -74,13 +100,13 @@ The model's input is untrusted: a log line, a config file, or a web-search resul **Arbitrary code execution** — `ssh_run`, `proxmox_run`, and the primitives that become code execution in one hop (`proxmox_write_file`, `proxmox_upload_file`, `proxmox_download_file`, `proxmox_delete_transfer`). Writing `~/.ssh/authorized_keys` or a file under `/etc/cron.d` is exactly as good as a shell, which is why the file tools sit alongside the exec tools. -**Destructive or irreversible** — `vm_bulk_action`, `proxmox_vm_stop`, `proxmox_vm_restart`, `proxmox_vm_migrate`, `proxmox_snapshot_rollback`, `proxmox_snapshot_delete`, `proxmox_backup_restore`, `bmc_power_off`, `bmc_power_reset`. +**Destructive or irreversible** — `vm_bulk_action`, `proxmox_vm_stop`, `proxmox_vm_restart`, `proxmox_vm_migrate`, `proxmox_snapshot_rollback`, `proxmox_snapshot_delete`, `proxmox_backup_restore`, `bmc_power_off`, `bmc_power_reset`, and `beaconmcp_self_update` with `confirm=True` — which pulls new code, reinstalls dependencies and restarts the service, i.e. replaces the very process enforcing this gate. Three call shapes are let through without a modal, because they don't change anything: - `ssh_run` / `proxmox_run` carrying only `exec_id=` — that's read-only polling of an already-approved session. - `proxmox_snapshot_create` / `_rollback` / `_delete` called with `dry_run=True` — they only report what they *would* do. The exemption is limited to those three by name, never inferred from the argument: an undeclared `dry_run` is silently dropped during argument validation, so trusting it would let `ssh_run(command=..., dry_run=True)` past the modal and then run for real. -- `proxmox_vm_config` without `updates` — the read shape of a read-or-write tool. +- `proxmox_vm_config` without `updates`, and `beaconmcp_self_update` without `confirm=True` — the read shape of a read-or-write tool. Reading the argument is sound for these two because both parameters are declared by the tool, so what the gate reads is what the tool acts on; that is precisely what makes it unsound for an undeclared `dry_run`. When Gemini fires a gated call: @@ -91,6 +117,38 @@ When Gemini fires a gated call: The allow-list is hard-coded in `src/beaconmcp/dashboard/chat.py` (`_NEEDS_CONFIRMATION`, `_CONFIRM_WHEN_ARG_PRESENT`, `_tool_call_requires_confirmation`). Only the integrated chat enforces this gate; external MCP clients (Assistant Desktop, Gemini CLI, ChatGPT MCP) must enable their own per-call approval mode (see the **Security** section of the root README). +## Interactive panels (MCP Apps) + +Tools that carry `_meta.ui.resourceUri` — `proxmox_vm_panel`, `proxmox_logs_panel`, `cluster_overview_interactive` — render as a live interface in the chat instead of a block of JSON. The dashboard implements both halves of the [MCP Apps extension](https://modelcontextprotocol.io/extensions/apps/overview): it announces `io.modelcontextprotocol/ui` when it opens its MCP session, and it plays host to the `ui://` document over `postMessage`. + +The extension is declared through `ClientCapabilities.extensions`, a field mcp only types in 2.0. The `<2` pin is not in the way: the model accepts extra fields, so the capability serialises under the name the spec gives it and the server reads the same JSON either way (`dashboard/mcp_bridge.py`). + +### How a panel is isolated + +The document is served by `/app/api/mcp/panel` and framed with `sandbox="allow-scripts"` and **no** `allow-same-origin`. That puts it on an opaque origin: it cannot read the session cookie, cannot read the CSRF token, cannot reach into the parent page. Its response also carries its own `Content-Security-Policy` — `default-src 'none'`, `connect-src 'none'`, `frame-ancestors 'self'` — so it cannot open a socket of its own either. + +What is left is `postMessage` to the parent. Every tool call a panel makes therefore goes through `/app/api/mcp/call`, which is session-authenticated and CSRF-protected, and where the policy below is applied. + +### What a panel may call on its own + +A panel button is a labelled control a human clicked, so the approval modal — which exists because the *model's* input is untrusted — would restate the click rather than check it. Panel calls are therefore not gated. What is *not* granted is a blanket exemption for anything running in a frame: a `ui://` document is HTML the server wrote, and this dashboard is a general MCP host, so a blanket rule would hand every connected server a way around the gate it is documented to be subject to. + +The exemption is a closed list, enforced server-side in `panel_call_allowed()`: + +- `proxmox_vm_start` / `_stop` / `_restart` — one guest per call, visible in the panel, reversible from it. +- `proxmox_vm_config`, but only when every key in `updates` is sizing (`cores`, `sockets`, `memory`, `balloon`, `cpulimit`, `cpuunits`). Exempting the tool itself would exempt `hookscript`, raw QEMU `args` and device passthrough along with it. +- Everything that was never gated in the first place — the read-only tools, including the three panel tools themselves. + +Anything else is refused with `403 confirmation_required`, and the panel shows the reason. It is refused rather than prompted because there is no turn in flight to hang a modal on — and because the panel already has a way through: `ui/message` hands the request to the model, which puts it back under the modal where it belongs. + +### Model context + +A panel that acts on the cluster pushes the fresh state back with `ui/update-model-context`. The page holds the latest update per panel and sends it with the next message, labelled as coming from the panel rather than from the operator. Without it, stopping a VM from the panel would leave the next turn believing it still runs — the button's result goes to the iframe, not into the conversation. + +### Reopening from history + +Only the `ui://` URI is stored with the tool call, never the snapshot behind it. A panel in an older conversation renders as an **Open panel** button; clicking it mounts the frame and refetches. Live figures in a panel that has been sitting in the transcript for a week would be worse than a short spinner. + ## Stored data SQLite at `/opt/beaconmcp/dashboard.db` (WAL mode). Five tables: @@ -118,14 +176,16 @@ Setting a variable to `0` disables that window. When a cap is exceeded, the next The chat footer shows a compact `5H XX% · 7D XX%` line updated after every turn via an SSE `usage_update` event. Clicking the bar opens a modal with progress bars, the 5 h reset time, a "rolling 7-day window" label, and a refresh button. -Rates used (USD per 1 M tokens, aligned with the public Google AI Studio pricing on 2026-04-17): +Rates used (USD per 1 M tokens, aligned with the public Google AI Studio pricing on 2026-07-29). The retired rows are kept because the ledger re-prices stored turns, and dropping a rate would silently re-bill that history at the fallback model's price: | Model | Input | Cached | Output | |-------|-------|--------|--------| -| `gemini-2.5-flash` | $0.30 | $0.03 | $2.50 | -| `gemini-2.5-pro` (≤200k / >200k) | $1.25 / $2.50 | $0.125 / $0.25 | $10.00 / $15.00 | -| `gemini-3-flash-preview` | $0.50 | $0.05 | $3.00 | +| `gemini-3.6-flash` | $1.50 | $0.15 | $7.50 | +| `gemini-3.5-flash-lite` | $0.30 | $0.03 | $2.50 | | `gemini-3.1-pro-preview` (≤200k / >200k) | $2.00 / $4.00 | $0.20 / $0.40 | $12.00 / $18.00 | +| `gemini-2.5-flash` *(retired)* | $0.30 | $0.03 | $2.50 | +| `gemini-2.5-pro` *(retired, ≤200k / >200k)* | $1.25 / $2.50 | $0.125 / $0.25 | $10.00 / $15.00 | +| `gemini-3-flash-preview` *(retired)* | $0.50 | $0.05 | $3.00 | Constants live in `src/beaconmcp/dashboard/usage.py` — update them when Google adjusts its prices. diff --git a/docs/security.md b/docs/security.md index 7730f6b..ee92b29 100644 --- a/docs/security.md +++ b/docs/security.md @@ -21,7 +21,7 @@ stop` was meant. The integrated chat at `/app/chat` forces a human confirmation for every code-execution tool (`ssh_run`, `proxmox_run`, `proxmox_write_file` and the transfer tools) and every destructive one (`vm_bulk_action`, `proxmox_vm_stop`, snapshot rollback/delete, backup restore, `bmc_power_off`, -`bmc_power_reset`). Writing a guest file counts as code execution: `~/.ssh/authorized_keys` and +`bmc_power_reset`, and `beaconmcp_self_update` with `confirm=True`). Writing a guest file counts as code execution: `~/.ssh/authorized_keys` and `/etc/cron.d/` are one hop from a shell. Skipping the modal is reserved for calls that cannot change anything — polling by `exec_id` alone, `dry_run=True` on the snapshot tools that implement it, and the read shape of `proxmox_vm_config`. The full list is in @@ -29,6 +29,14 @@ it, and the read shape of `proxmox_vm_config`. The full list is in confirmation card even when you're clicking through fast. No answer within 5 minutes counts as a refusal. +The interactive panels are the one place a gated tool runs without that modal, and only for a +closed list: starting, stopping and restarting a single guest, and resizing its CPU or memory. +That is a different question from the one the modal answers — the modal exists because the model's +input is untrusted, and a panel button is a human click on a labelled control. Everything else a +panel asks for is refused outright, including any `proxmox_vm_config` key that is not sizing. The +boundary is enforced server-side, not in the frame: +[dashboard.md](dashboard.md#interactive-panels-mcp-apps). + ## Tokens A `/app/tokens` bearer grants arbitrary shell access on your Proxmox nodes for its full lifetime @@ -56,6 +64,51 @@ Unattended services (scheduled jobs, CI pipelines) sometimes genuinely need mach case, with its required precautions, is covered separately in [totp-automation.md](totp-automation.md). Read it end to end before deciding. +## Passkeys + +A passkey (WebAuthn) replaces the **TOTP factor**, never the client secret. Both login pages keep the +same two-factor shape: + +1. `client_id` + `client_secret` +2. a 6-digit code **or** a passkey assertion + +That ordering is what makes a stolen passkey worthless on its own, and it is also a practical +constraint: the dashboard session encrypts the client secret so it can re-mint MCP bearers later, so +a fully usernameless login could not build a working session anyway. + +Where they work: + +| Page | Sign in with a passkey | Enrol a passkey | +|------|------------------------|-----------------| +| `/app/login` | Link under the 2FA step | On the post-2FA screen | +| `/oauth/authorize` | Link under the 2FA step | On the approval screen | + +Credentials are stored in the dashboard database (`passkeys` table): a credential id, a **public** +key and a signature counter. Nothing secret leaves the authenticator. + +Things worth knowing before you enrol: + +- **Passkeys are bound to the hostname.** The relying-party ID is derived from the request host, so + a credential registered on `beacon.example` will not work on `beacon.internal` or on a raw IP that + differs from the one used at registration. Settle on your public hostname first. +- **A secure context is required.** Browsers only expose the WebAuthn API over HTTPS or on loopback. + On a plain-HTTP LAN deployment the passkey buttons are hidden and TOTP stays the only path in. +- **Keep TOTP working.** Passkeys are an alternative, not a replacement: losing every enrolled device + must not lock you out. The authenticator seed remains the recovery path. +- **Dynamically-registered clients delegate**, exactly like TOTP: a client created through the DCR + bootstrap is authorized by its *owner's* passkeys, so the second factor never leaves the owner. +- Registrations, revocations and passkey sign-ins are recorded in the audit log + (`dashboard.passkey.*`, `auth.passkey.*`, and `login`/`authorize` events tagged `via=passkey`). + +Passkey ceremonies are rate-limited per IP by the same limiter that guards `/app/login`, and a TOTP +lockout also blocks the passkey path for that client. + +Manage enrolled credentials from `/app/tokens`, or drop them all for a client with: + +```sql +DELETE FROM passkeys WHERE client_id = 'beaconmcp_...'; +``` + ## Audit trail `server.audit_log` records tool calls, dashboard logins, OAuth authorizations and client revocations diff --git a/docs/tools.md b/docs/tools.md index 87a3bd5..364158a 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -1,8 +1,9 @@ # MCP tools -44 tools across six modules. The infrastructure modules are only registered when the matching +49 tools across eight modules. The infrastructure modules are only registered when the matching capability is configured, so an SSH-only deployment exposes the 2 SSH tools and nothing else from -Proxmox or BMC. `security_end_session` is always registered, whatever the topology. +Proxmox or BMC. `security_end_session` and the two maintenance tools are always registered, +whatever the topology. Long-running commands (`proxmox_run`, `ssh_run`) are synchronous by default. Pass `wait=False` to start one in the background and get an `exec_id` back, then call the same tool with `exec_id=` to @@ -50,6 +51,45 @@ tools below exist for when you already know what you're looking at. | `proxmox_backup_list` | List vzdump archives on a storage pool. | | `proxmox_backup_restore` | Restore from an archive. | +## Proxmox — interactive panels (3) + +| Tool | Description | +|------|-------------| +| `proxmox_vm_panel` | Control panel for one guest: live CPU/RAM/disk, power buttons, CPU and memory fields. | +| `proxmox_logs_panel` | Scrollable syslog and task viewer for one node, with level and substring filters. | +| `cluster_overview_interactive` | Cluster dashboard: node pressure, searchable guest table with inline start/stop, storage bars. | + +These three ship a UI. They use the [MCP Apps extension](https://modelcontextprotocol.io/extensions/apps/overview) +(`io.modelcontextprotocol/ui`): the tool carries `_meta.ui.resourceUri` pointing at a `ui://` +resource, a self-contained HTML document served as `text/html;profile=mcp-app` that the client +renders in a sandboxed iframe and talks to over JSON-RPC on `postMessage`. + +The panels live in `src/beaconmcp/proxmox/apps/`. They share `bridge.js` (the JSON-RPC client) +and `panel.css` (the look), spliced in at the `` marker when the resource is +read, so each one still ships as a single document. + +**They hold no cluster access of their own.** Every button issues an ordinary `tools/call` for +the tools listed elsewhere in this document — `proxmox_vm_start`, `_stop`, `_restart`, `_config` +— which the host relays, and which the host decides whether to approve. A panel is a nicer way +to issue the call, not a channel that bypasses the host. + +What the host does with that call is the host's policy. BeaconMCP's own dashboard lets a panel +drive the guest lifecycle unattended (a labelled button is the click) but refuses anything in its +confirmation list, and refuses `proxmox_vm_config` for any key that is not sizing — see +[the dashboard guide](dashboard.md#interactive-panels-mcp-apps). Other hosts set their own line. + +After an action, a panel pushes the fresh state back into the conversation with +`ui/update-model-context`. Without it the model keeps whatever the tool returned when the panel +opened: stop a VM from the panel and the next turn still thinks it is running, because the +button's result goes to the iframe, not to the model. Where the host supports `ui/message`, the +VM panel also offers a button that asks the model to investigate the guest, and the cluster +dashboard one that asks it to open a specific VM's panel. Both features are gated on the host +advertising the capability and are simply hidden when it does not. + +Clients that did not negotiate the extension ignore `_meta.ui` and show the tool's return value, +which is the same snapshot as data. Nothing breaks; you just don't get the frame. BeaconMCP's own +`/app/chat` negotiates it and renders the panels inline. + ## Proxmox — system and files (9) | Tool | Description | @@ -92,3 +132,16 @@ to that device. | Tool | Description | |------|-------------| | `security_end_session` | Revoke the bearer token used for the current request, ~8 s after responding. Call it as the last step of a task to shrink the window in which a stolen token can be replayed — never mid-task, or the next call gets a 401. | + +## Maintenance (2) + +Registered on every deployment shape, unless `features.updates.enabled` is `false`. + +| Tool | Description | +|------|-------------| +| `beaconmcp_check_update` | Read-only. Reports the running version, how many commits this install is behind upstream, the changelog, any `.env` / `beaconmcp.yaml` settings the new revision knows about that this install has not set, and the exact commands that would update *this* install (git checkout, pip install and container each get different ones). Cached for a few hours. | +| `beaconmcp_self_update` | Applies the update: `git pull --ff-only` → reinstall the package and its dependencies → **validate the config against the new code** → schedule a restart. Requires `confirm=True`. Refuses on a dirty checkout or a non-git install. If the new revision cannot load the current config, everything is rolled back and nothing restarts. Hidden when `features.updates.allow_self_update` is `false`. | + +The restart is deferred a few seconds so the tool result reaches the caller before the process dies. +Show the user `beaconmcp_check_update` output — especially any new configuration — and get an +explicit go-ahead before calling `beaconmcp_self_update`. diff --git a/docs/updates.md b/docs/updates.md new file mode 100644 index 0000000..47d213c --- /dev/null +++ b/docs/updates.md @@ -0,0 +1,121 @@ +# Updates + +BeaconMCP publishes no releases and no PyPI package: the canonical install is a `git clone` at +`/opt/beaconmcp` with a venv and a systemd unit. So "is there an update?" means **is this checkout +behind the upstream default branch?** + +The server answers that question itself, tells signed-in operators, and can apply the update. + +## The notice + +Signed in to the dashboard, a card appears bottom-right on any `/app/*` page when the checkout is +behind: + +- how far behind, and the revision range (`9f496cb → abc1234`); +- the last few commit subjects, and a link to the full diff on GitHub; +- **any configuration the new revision knows about that you have not set** — new `.env` variables, + new `beaconmcp.yaml` settings; +- the exact commands to update *this* install; +- an **Update now** button, when an automatic update is possible. + +Dismissing it hides that specific revision; the card returns when a newer one lands. + +On the **"You're signed in"** screen — the moment the session is created, one click before the panel +— you get a one-line mention instead of the full card. That screen has a single primary action, and +on a narrow viewport a bottom-anchored card this tall would sit right on top of it. The card itself +opts out of the auth pages entirely and shows on the landing page. + +The endpoint behind it (`GET /app/api/update`) requires a live session and returns `401` otherwise. +That is deliberate: the exact revision a server runs is free reconnaissance for anyone who has not +authenticated, and the card is only ever rendered to someone signed in. + +## Instructions match your install + +Detection is not a guess about how you *should* have installed it: + +| Detected | What you are told | +|----------|-------------------| +| git checkout | `cd ` → `git pull --ff-only` → `/bin/pip install -e .` (the real venv path, when there is one) → `systemctl restart beaconmcp` if a unit file exists | +| container | `docker compose pull` → `docker compose up -d` | +| pip distribution | `pip install --upgrade 'beaconmcp @ git+https://github.com/Showdown76py/BeaconMCP.git'` | +| unknown | Re-run `deploy/install.sh` from a checkout | + +## MCP tools + +Two tools, registered on every deployment shape: + +- **`beaconmcp_check_update`** — read-only. Version, commits behind, changelog, new configuration, + and the commands for this install. Cached for a few hours. +- **`beaconmcp_self_update`** — applies it. Requires `confirm=True`. + +Ask your assistant to "check whether BeaconMCP has an update" and it will read the changelog and any +new settings back to you before touching anything. + +## What the self-update actually does + +In order, stopping at the first failure: + +1. **Preflight** — refuses on a non-git install, and refuses when the checkout has uncommitted + changes. Local edits are never discarded. +2. **`git pull --ff-only`** — a fast-forward or nothing. No merges, no rebases. +3. **Reinstall** — `pip install -e .` in the detected venv, so new or bumped dependencies land. +4. **Validate the config** — runs `beaconmcp validate-config` in a subprocess, so the *new* code + parses your *actual* configuration. +5. **Restart** — `systemctl restart`, deferred a few seconds so the response reaches you first. + +Step 4 is a hard gate, and it is the reason this is safe to run unattended. If the new revision +cannot load your config — a setting was renamed, a new one is now required — the checkout is reset +to exactly where it started, dependencies are restored, **nothing is restarted**, and the error from +the validator is handed back to you. An update that bricks the server is worse than no update. + +Only one update runs at a time. The dashboard button and the MCP tool reach the same code, and two +`git pull` / `pip install` runs in one checkout would fight over `index.lock`. A second caller is +told one is already in progress rather than queued behind a pip that may take minutes. + +### From the dashboard + +The **Update now** button asks for a fresh 2FA code before it runs. Pulling code and restarting the +process is the most privileged thing the panel can do, so a session alone is not enough — same bar +as minting an API token. + +## Turning it off + +```yaml +features: + updates: + enabled: true # false: never contact the remote, no tools, no notice + allow_self_update: true # false: keep the notice, forbid applying it +``` + +`enabled: false` is the air-gap switch. `allow_self_update: false` is for deployments where updates +go through a pipeline: operators still see that one is available, with instructions, but neither the +dashboard button nor the MCP tool exists. + +## Stale assets after an update + +A server that can update itself must not leave browsers running the previous release's JavaScript. +Starlette serves static files with `ETag`/`Last-Modified` but no `Cache-Control`, which puts +browsers on *heuristic* freshness: a file untouched for weeks is reused for a long time without ever +revalidating. + +Asset URLs therefore carry a fingerprint of the bundle (`app.css?v=6a69fedf`), recomputed at each +start from the newest mtime in the static directory — which a `git pull` bumps. New bytes mean a new +URL, so no cache can satisfy the request from an old entry: + +| Response | `Cache-Control` | +|----------|-----------------| +| Asset with `?v=` | `public, max-age=31536000, immutable` | +| Asset without | `no-cache` (revalidate every time — a legacy or hand-typed URL can never pin stale code) | +| Any `/app/*` page or API reply | `no-store` (per-session, and it carries the fingerprint) | + +## Audit trail + +Every attempt is logged (see [security.md](security.md#audit-trail)): + +| Event | When | +|-------|------| +| `dashboard.update.start` / `dashboard.update.finish` | Update applied from the panel | +| `maintenance.self_update.start` / `maintenance.self_update.finish` | Update applied over MCP | + +The `finish` events carry `ok`, `from_ref`, `to_ref` and `rolled_back`, so a rollback is visible in +the log without reading the tool output. diff --git a/pyproject.toml b/pyproject.toml index a18ee64..2421871 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,9 @@ dependencies = [ "python-dotenv", "pyyaml", "pyotp", + # Passkeys (WebAuthn) on the login / authorize pages. Imported lazily: + # the auth pages fall back to TOTP-only when it is missing. + "webauthn>=2,<4", "qrcode", "cryptography>=42", "jinja2>=3", diff --git a/src/beaconmcp/__init__.py b/src/beaconmcp/__init__.py index 3dc1f76..6364084 100644 --- a/src/beaconmcp/__init__.py +++ b/src/beaconmcp/__init__.py @@ -1 +1,11 @@ -__version__ = "0.1.0" +"""BeaconMCP -- remote MCP server for Proxmox VE and BMC infrastructure.""" + +try: # pragma: no cover - trivial + from importlib.metadata import version as _version + + __version__ = _version("beaconmcp") +except Exception: # noqa: BLE001 - running straight from an uninstalled tree + # Fallback when the package was never pip-installed. Keep in sync with + # [project].version in pyproject.toml. (The old hard-coded "0.1.0" had + # drifted three majors behind it.) + __version__ = "2.0.0" diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index 5232f7a..f711726 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -320,6 +320,18 @@ async def check_bmc() -> None: else: print(" - No BMC devices configured.") + # Only the library half can be checked here: credential storage depends + # on the running server's database, which doctor does not open. + print("\nPasskeys (WebAuthn):") + from .dashboard.passkeys import webauthn_installed + + if webauthn_installed(): + print(" ✓ 'webauthn' package importable.") + print(" Browsers additionally require HTTPS or a loopback host.") + else: + print(" ✗ 'webauthn' package is missing - login pages offer TOTP only.") + print(" Fix with: pip install 'webauthn>=2,<4' (or reinstall BeaconMCP)") + print("\nPreflight check complete.") @@ -431,9 +443,630 @@ def _cmd_auth(args): sys.exit(1) +# --------------------------------------------------------------------------- +# /oauth/authorize page assets +# +# Kept as plain module-level strings rather than inlined in the f-string that +# builds the page: CSS and JS are brace-dense, and doubling every one of them +# to survive `str.format` made the block unreadable and easy to break. +# --------------------------------------------------------------------------- + +_AUTHORIZE_CSS = """ +:root { + --accent: oklch(0.68 0.17 48); + --accent-soft: oklch(0.68 0.17 48 / 0.12); + --accent-softer: oklch(0.68 0.17 48 / 0.06); + --accent-border: oklch(0.68 0.17 48 / 0.35); + --accent-hover: oklch(0.62 0.18 48); + --accent-fg: #fff; + --bg: oklch(0.99 0.004 70); + --bg-soft: oklch(0.975 0.005 70); + --bg-elev: #fff; + --fg: oklch(0.22 0.01 70); + --fg-mid: oklch(0.42 0.008 70); + --fg-muted: oklch(0.55 0.008 70); + --fg-faint: oklch(0.7 0.006 70); + --border: oklch(0.92 0.006 70); + --border-strong: oklch(0.86 0.008 70); + --border-subtle: oklch(0.95 0.005 70); + --danger: oklch(0.58 0.19 25); + --danger-soft: oklch(0.58 0.19 25 / 0.1); + --success: oklch(0.62 0.12 155); + --success-soft: oklch(0.62 0.12 155 / 0.12); + --shadow: 0 1px 2px rgba(20,14,8,0.04), 0 4px 20px rgba(20,14,8,0.06); + --font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); +} +[data-theme="dark"] { + --bg: oklch(0.16 0.008 60); + --bg-soft: oklch(0.19 0.008 60); + --bg-elev: oklch(0.21 0.009 60); + --fg: oklch(0.95 0.006 70); + --fg-mid: oklch(0.78 0.008 70); + --fg-muted: oklch(0.62 0.01 70); + --fg-faint: oklch(0.45 0.008 70); + --border: oklch(0.28 0.009 60); + --border-strong: oklch(0.36 0.01 60); + --border-subtle: oklch(0.24 0.008 60); + --accent: oklch(0.75 0.17 50); + --accent-soft: oklch(0.75 0.17 50 / 0.16); + --accent-softer: oklch(0.75 0.17 50 / 0.08); + --accent-border: oklch(0.75 0.17 50 / 0.4); + --accent-hover: oklch(0.82 0.17 50); + --accent-fg: oklch(0.12 0.008 60); + --danger: oklch(0.68 0.19 25); + --success: oklch(0.72 0.14 155); + --shadow: 0 1px 2px rgba(0,0,0,0.3), 0 4px 20px rgba(0,0,0,0.4); +} +* { box-sizing: border-box; } +html, body { + margin: 0; padding: 0; + font-family: var(--font); + font-size: 15px; + color: var(--fg); + background: var(--bg); + -webkit-font-smoothing: antialiased; +} +body { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; +} +.auth-card { + width: 100%; max-width: 380px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 16px; + padding: 32px; + box-shadow: var(--shadow); + animation: rise 400ms var(--ease-out) both; +} +@keyframes rise { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} +.auth-brand { display: flex; align-items: center; margin-bottom: 26px; } +.auth-brand .name { font-weight: 600; font-size: 15px; letter-spacing: -0.01em; } +h1 { margin: 0 0 4px; font-size: 22px; font-weight: 600; letter-spacing: -0.015em; } +.sub { margin: 0 0 18px; font-size: 13.5px; color: var(--fg-muted); } +.sub strong { color: var(--fg); font-weight: 600; } +.banner { + padding: 10px 14px; + border-radius: 10px; + font-size: 13px; + margin: 0 0 14px; + background: var(--danger-soft); + color: var(--danger); + border: 1px solid color-mix(in oklab, var(--danger) 35%, var(--border)); +} +.banner-success { + background: var(--success-soft); + color: var(--success); + border-color: color-mix(in oklab, var(--success) 35%, var(--border)); +} +.toast-banner { + background: var(--accent-softer); + border: 1px solid var(--accent-border); + color: var(--fg); + border-radius: 10px; + padding: 9px 12px; + font-size: 12.5px; + margin-bottom: 16px; + display: flex; align-items: center; gap: 8px; +} +.toast-banner .dot { + width: 6px; height: 6px; + border-radius: 50%; background: var(--accent); + flex-shrink: 0; +} +.toast-banner b { font-family: var(--font-mono); margin-left: 2px; } +.totp-inputs { + display: flex; gap: 8px; justify-content: space-between; + margin: 8px 0 18px; +} +.totp-inputs input { + width: 100%; aspect-ratio: 1 / 1.15; + text-align: center; + font-size: 24px; font-weight: 600; + font-family: var(--font-mono); + background: var(--bg-soft); + border: 1px solid var(--border-strong); + border-radius: 10px; + color: var(--fg); + outline: none; + transition: border-color 160ms var(--ease-out), box-shadow 160ms var(--ease-out); +} +.totp-inputs input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 4px var(--accent-soft); +} +.totp-inputs input.filled { + background: var(--accent-softer); + border-color: var(--accent-border); +} +.btn-primary { + width: 100%; + padding: 12px 16px; + border: 0; border-radius: 10px; + background: var(--accent); color: var(--accent-fg); + font-family: var(--font); font-weight: 600; font-size: 14.5px; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + transition: background 180ms var(--ease-out), transform 100ms var(--ease-out); + box-shadow: 0 4px 14px oklch(0.68 0.17 48 / 0.28), inset 0 1px 0 rgba(255,255,255,0.2); +} +.btn-primary:hover:not(:disabled) { background: var(--accent-hover); } +.btn-primary:active:not(:disabled) { transform: translateY(1px); } +.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; } +.btn-ghost { + width: 100%; + padding: 11px 16px; + border: 1px solid var(--border-strong); + border-radius: 10px; + background: var(--bg-soft); + color: var(--fg); + font-family: var(--font); font-weight: 550; font-size: 14px; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + transition: border-color 160ms var(--ease-out), background 160ms var(--ease-out); +} +.btn-ghost:hover:not(:disabled) { + border-color: var(--accent-border); + background: var(--accent-softer); +} +.btn-ghost:disabled { opacity: 0.55; cursor: not-allowed; } +.btn-ghost svg { flex-shrink: 0; } +.btn-primary.is-loading, .btn-ghost.is-loading { + position: relative; overflow: hidden; cursor: progress; opacity: 1; +} +.btn-primary.is-loading::after, .btn-ghost.is-loading::after { + content: ""; + position: absolute; inset: 0; + background: linear-gradient(100deg, transparent 20%, rgba(255,255,255,0.38) 50%, transparent 80%); + transform: translateX(-100%); + animation: shimmer-sweep 1150ms var(--ease-out) infinite; + pointer-events: none; +} +.btn-ghost.is-loading::after { + background: linear-gradient(100deg, transparent 20%, var(--accent-soft) 50%, transparent 80%); +} +@keyframes shimmer-sweep { to { transform: translateX(100%); } } +.btn-primary.is-loading .btn-icon { opacity: 0; } +@media (prefers-reduced-motion: reduce) { + .btn-primary.is-loading::after, .btn-ghost.is-loading::after { + animation: none; opacity: 0.25; + } +} +.alt-divider { + display: flex; align-items: center; gap: 12px; + margin: 18px 0 14px; + color: var(--fg-faint); + font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; +} +.alt-divider::before, .alt-divider::after { + content: ""; flex: 1; height: 1px; background: var(--border); +} +.hint { + margin: 10px 0 0; + font-size: 12.5px; line-height: 1.5; + color: var(--fg-muted); text-align: center; +} +.success-mark { + width: 42px; height: 42px; + border-radius: 50%; + display: grid; place-items: center; + margin-bottom: 16px; + color: var(--success); + background: var(--success-soft); + border: 1px solid color-mix(in oklab, var(--success) 35%, var(--border)); + animation: pop-in 320ms var(--ease-out) both; +} +@keyframes pop-in { + from { opacity: 0; transform: scale(0.82); } + to { opacity: 1; transform: scale(1); } +} +.expiry-card { + margin: 4px 0 0; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-soft); +} +.expiry-row { + display: flex; align-items: baseline; justify-content: space-between; + gap: 12px; padding: 5px 0; +} +.expiry-row + .expiry-row { border-top: 1px solid var(--border-subtle); } +.expiry-row dt { font-size: 12.5px; color: var(--fg-muted); } +.expiry-row dd { + margin: 0; font-size: 13px; font-weight: 550; + color: var(--fg); text-align: right; +} +#passkey-add-block { margin-top: 18px; } +#finalize-form { margin-top: 20px; } +""" + + +_AUTHORIZE_THEME_JS = """ +(function() { + try { + var raw = localStorage.getItem("beaconmcp-ui-state"); + var s = raw ? JSON.parse(raw) : {}; + var t = s.theme || "auto"; + var dark = t === "dark" || (t === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches); + document.documentElement.setAttribute("data-theme", dark ? "dark" : "light"); + } catch (e) {} +})(); +""" + + +# Self-contained: /oauth/authorize is reachable even when the dashboard (and +# its /app/static bundle) is disabled, so the WebAuthn plumbing is duplicated +# here rather than imported from static/webauthn.js. +_AUTHORIZE_JS = """ +(function() { + "use strict"; + + var card = document.querySelector(".auth-card"); + var stepVerify = document.getElementById("step-verify"); + var stepDone = document.getElementById("step-done"); + var form = document.getElementById("authorize-form"); + var totpHidden = document.getElementById("totp"); + var verifyBtn = document.getElementById("verify-btn"); + var container = document.getElementById("totp-inputs"); + var inputs = container ? container.querySelectorAll("input") : []; + var verifyError = document.getElementById("verify-error"); + var doneError = document.getElementById("done-error"); + var doneOk = document.getElementById("done-ok"); + var passkeyBlock = document.getElementById("passkey-block"); + var passkeyBtn = document.getElementById("passkey-btn"); + var passkeyAddBlock = document.getElementById("passkey-add-block"); + var addPasskeyBtn = document.getElementById("add-passkey-btn"); + var ticketInput = document.getElementById("ticket"); + var finalizeForm = document.getElementById("finalize-form"); + var finishBtn = document.getElementById("finish-btn"); + + var passkeysEnabled = card && card.dataset.passkeys === "true"; + var clientId = card ? card.dataset.clientId : ""; + var busy = false; + + function b64urlToBuf(value) { + var s = String(value).replace(/-/g, "+").replace(/_/g, "/"); + while (s.length % 4) s += "="; + var bin = window.atob(s); + var bytes = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes.buffer; + } + function bufToB64url(buf) { + var bytes = new Uint8Array(buf), bin = ""; + for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return window.btoa(bin).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, ""); + } + function descriptors(list) { + return (list || []).map(function(d) { + var out = { type: d.type || "public-key", id: b64urlToBuf(d.id) }; + if (d.transports && d.transports.length) out.transports = d.transports; + return out; + }); + } + function webauthnSupported() { + return !!(window.PublicKeyCredential && navigator.credentials && + navigator.credentials.create && navigator.credentials.get); + } + function describeError(err) { + if (!err) return "Passkey request failed."; + if (err.name === "NotAllowedError") return "Passkey prompt cancelled or timed out."; + if (err.name === "InvalidStateError") return "This device already has a passkey for this client."; + if (err.name === "SecurityError") return "Passkeys need a secure origin (HTTPS or localhost)."; + return err.message || String(err); + } + function postJson(url, body) { + return fetch(url, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json", "X-BeaconMCP-Mode": "json" }, + body: JSON.stringify(body || {}) + }).then(function(res) { + return res.json().catch(function() { return {}; }).then(function(data) { + return { ok: res.ok, data: data }; + }); + }); + } + function show(el, message) { + if (!el) return; + el.textContent = message || ""; + el.hidden = !message; + } + function setLoading(btn, loading, label) { + if (!btn) return; + var labelEl = btn.querySelector(".btn-label"); + if (loading) { + if (labelEl && label) { + if (!btn.dataset.idleLabel) btn.dataset.idleLabel = labelEl.textContent; + labelEl.textContent = label; + } + btn.classList.add("is-loading"); + btn.disabled = true; + } else { + if (labelEl && btn.dataset.idleLabel) { + labelEl.textContent = btn.dataset.idleLabel; + delete btn.dataset.idleLabel; + } + btn.classList.remove("is-loading"); + } + } + function formatDateTime(epochSeconds) { + if (!epochSeconds || !isFinite(epochSeconds)) return "\\u2014"; + var d = new Date(epochSeconds * 1000); + var sameDay = d.toDateString() === new Date().toDateString(); + var time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + if (sameDay) return "today at " + time; + return d.toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) + " at " + time; + } + function formatRelative(epochSeconds) { + var secs = epochSeconds - (Date.now() / 1000); + if (secs <= 0) return "expired"; + var hours = secs / 3600; + if (hours < 1) return "in " + Math.max(1, Math.round(secs / 60)) + " min"; + if (hours < 48) return "in " + Math.round(hours) + " h"; + return "in " + Math.round(hours / 24) + " days"; + } + + // --- TOTP boxes --- + function collectTotp() { + var s = ""; + inputs.forEach(function(i) { s += (i.value || "").replace(/\\D/g, ""); }); + return s; + } + function refresh() { + var v = collectTotp(); + if (totpHidden) totpHidden.value = v; + if (verifyBtn) verifyBtn.disabled = busy || v.length !== 6; + } + function clearTotp() { + inputs.forEach(function(i) { i.value = ""; i.classList.remove("filled"); }); + refresh(); + if (inputs[0]) inputs[0].focus(); + } + + inputs.forEach(function(inp, i) { + inp.addEventListener("input", function(e) { + var v = (e.target.value || "").replace(/\\D/g, ""); + e.target.value = v.slice(0, 1); + if (v) { + e.target.classList.add("filled"); + if (inputs[i + 1]) inputs[i + 1].focus(); + } else { + e.target.classList.remove("filled"); + } + refresh(); + }); + inp.addEventListener("keydown", function(e) { + if (e.key === "Enter") { + e.preventDefault(); + refresh(); + if (!busy && collectTotp().length === 6) submitTotp(); + return; + } + if (e.key === "Backspace" && !e.target.value && inputs[i - 1]) { + inputs[i - 1].focus(); + inputs[i - 1].value = ""; + inputs[i - 1].classList.remove("filled"); + refresh(); + } + }); + inp.addEventListener("paste", function(e) { + e.preventDefault(); + var src = e.clipboardData || window.clipboardData; + var pasted = ((src && src.getData("text")) || "").replace(/\\D/g, "").slice(0, 6); + pasted.split("").forEach(function(ch, k) { + if (inputs[k]) { inputs[k].value = ch; inputs[k].classList.add("filled"); } + }); + if (inputs[Math.min(pasted.length, 5)]) inputs[Math.min(pasted.length, 5)].focus(); + refresh(); + }); + }); + + // --- approved screen --- + function showDone(payload) { + if (ticketInput) ticketInput.value = payload.ticket || ""; + var accessEl = document.getElementById("access-expiry"); + if (accessEl) { + accessEl.textContent = formatDateTime(payload.access_expires_at) + + " (" + formatRelative(payload.access_expires_at) + ")"; + } + var ticketEl = document.getElementById("ticket-expiry"); + if (ticketEl) ticketEl.textContent = formatRelative(payload.ticket_expires_at); + if (passkeyAddBlock) { + var canAdd = payload.passkeys_enabled && webauthnSupported(); + passkeyAddBlock.hidden = !canAdd; + // Server says passkeys are on but the browser won't expose the API: + // that is an insecure origin, and silently hiding the button just + // makes people wonder where it went. + var addNote = document.getElementById("passkey-add-unsupported"); + if (addNote) addNote.hidden = canAdd || !payload.passkeys_enabled; + } + if (stepVerify) stepVerify.hidden = true; + if (stepDone) stepDone.hidden = false; + if (finishBtn) finishBtn.focus(); + } + + function submitTotp() { + if (busy || !form) return; + var code = collectTotp(); + if (code.length !== 6) return; + busy = true; + show(verifyError, ""); + setLoading(verifyBtn, true, "Verifying\\u2026"); + + var body = new URLSearchParams(new FormData(form)); + body.set("totp", code); + fetch("/oauth/authorize", { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-BeaconMCP-Mode": "json" + }, + body: body.toString() + }).then(function(res) { + return res.json().catch(function() { return {}; }).then(function(data) { + return { ok: res.ok, data: data }; + }); + }).then(function(res) { + busy = false; + setLoading(verifyBtn, false); + if (res.ok && res.data && res.data.ok) { showDone(res.data); return; } + show(verifyError, (res.data && res.data.error) || "Authorization failed."); + clearTotp(); + }).catch(function() { + busy = false; + setLoading(verifyBtn, false); + show(verifyError, "Network error. Try again."); + refresh(); + }); + } + + if (form) { + form.addEventListener("submit", function(e) { + e.preventDefault(); + submitTotp(); + }); + } + + // --- passkey instead of a code --- + if (passkeyBlock) { + var canUse = passkeysEnabled && webauthnSupported(); + passkeyBlock.hidden = !canUse; + var note = document.getElementById("passkey-unsupported"); + if (note) note.hidden = canUse || !passkeysEnabled; + } + + if (passkeyBtn) { + passkeyBtn.addEventListener("click", function() { + if (busy || !form) return; + busy = true; + show(verifyError, ""); + setLoading(passkeyBtn, true, "Waiting for your passkey\\u2026"); + if (verifyBtn) verifyBtn.disabled = true; + + var params = {}; + new FormData(form).forEach(function(v, k) { params[k] = v; }); + postJson("/oauth/passkey/options", { client_id: clientId }).then(function(res) { + if (!res.ok || !res.data.ok) { + throw new Error(res.data.error || "Passkey sign-in unavailable."); + } + var options = Object.assign({}, res.data.options); + options.challenge = b64urlToBuf(res.data.options.challenge); + options.allowCredentials = descriptors(res.data.options.allowCredentials); + return navigator.credentials.get({ publicKey: options }).then(function(cred) { + if (!cred) throw new Error("No passkey was selected."); + return postJson("/oauth/passkey/verify", { + state: res.data.state, + params: params, + credential: { + id: cred.id, + rawId: bufToB64url(cred.rawId), + type: cred.type, + clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}, + response: { + clientDataJSON: bufToB64url(cred.response.clientDataJSON), + authenticatorData: bufToB64url(cred.response.authenticatorData), + signature: bufToB64url(cred.response.signature), + userHandle: cred.response.userHandle ? bufToB64url(cred.response.userHandle) : null + } + } + }); + }); + }).then(function(res) { + busy = false; + setLoading(passkeyBtn, false); + refresh(); + if (res.ok && res.data && res.data.ok) { showDone(res.data); return; } + show(verifyError, (res.data && res.data.error) || "Passkey rejected."); + }).catch(function(err) { + busy = false; + setLoading(passkeyBtn, false); + refresh(); + show(verifyError, describeError(err)); + }); + }); + } + + // --- enrol a passkey right after approving --- + if (addPasskeyBtn) { + addPasskeyBtn.addEventListener("click", function() { + show(doneError, ""); + show(doneOk, ""); + setLoading(addPasskeyBtn, true, "Follow your device prompt\\u2026"); + var ticket = ticketInput ? ticketInput.value : ""; + + postJson("/oauth/passkey/register/options", { ticket: ticket }).then(function(res) { + if (!res.ok || !res.data.ok) { + throw new Error(res.data.error || "Could not start registration."); + } + var options = Object.assign({}, res.data.options); + options.challenge = b64urlToBuf(res.data.options.challenge); + options.user = Object.assign({}, res.data.options.user, { + id: b64urlToBuf(res.data.options.user.id) + }); + options.excludeCredentials = descriptors(res.data.options.excludeCredentials); + return navigator.credentials.create({ publicKey: options }).then(function(cred) { + if (!cred) throw new Error("No credential was created."); + var payload = { + id: cred.id, + rawId: bufToB64url(cred.rawId), + type: cred.type, + clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}, + response: { + clientDataJSON: bufToB64url(cred.response.clientDataJSON), + attestationObject: bufToB64url(cred.response.attestationObject) + } + }; + if (cred.response.getTransports) { + try { payload.response.transports = cred.response.getTransports(); } catch (e) {} + } + return postJson("/oauth/passkey/register/verify", { + ticket: ticket, state: res.data.state, credential: payload + }); + }); + }).then(function(res) { + setLoading(addPasskeyBtn, false); + if (res.ok && res.data && res.data.ok) { + show(doneOk, "Passkey \\u201c" + res.data.passkey.label + + "\\u201d registered. Next time you can skip the 2FA code."); + addPasskeyBtn.disabled = true; + var hint = document.getElementById("passkey-add-hint"); + if (hint) hint.hidden = true; + return; + } + show(doneError, (res.data && res.data.error) || "Registration failed."); + }).catch(function(err) { + setLoading(addPasskeyBtn, false); + show(doneError, describeError(err)); + }); + }); + } + + if (finalizeForm) { + finalizeForm.addEventListener("submit", function() { + setLoading(finishBtn, true, "Redirecting\\u2026"); + }); + } + + if (inputs[0] && !inputs[0].disabled && stepDone && stepDone.hidden) inputs[0].focus(); +})(); +""" + + def _run_http(mcp, host: str, port: int): """Run the MCP server over Streamable HTTP with OAuth client credentials.""" + import datetime as _dt import html + import secrets import uvicorn from contextlib import asynccontextmanager from starlette.applications import Starlette @@ -512,6 +1145,35 @@ async def dispatch(self, request: Request, call_next): ) token_store = TokenStore(db_path=tokens_db, named_token_ttl=named_token_ttl) code_store = CodeStore() + + # Shared dashboard SQLite handle. Three features live in it -- sessions, + # DCR bootstrap slugs and passkeys -- and /oauth/authorize wants the + # passkey table even on deployments that keep the dashboard off, so the + # handle is opened here rather than inside the dashboard builder. + from . import dashboard as _dashboard_mod + from .dashboard import passkeys as passkeys_mod + from .dashboard.db import Database as _Database + + shared_database = None + _database_required = ( + _dashboard_mod.is_enabled() or config.server.allow_dynamic_registration + ) + try: + shared_database = _Database() + except Exception as exc: # noqa: BLE001 + if _database_required: + raise + # Dashboard off and no writable database: passkeys are simply not + # offered. Never a reason to refuse to boot the MCP server. + logging.getLogger("beaconmcp").warning( + "passkey storage unavailable (%s); passkeys disabled", exc, + ) + + passkey_service = passkeys_mod.PasskeyService( + passkeys_mod.PasskeyStore(shared_database) + if shared_database is not None + else None + ) # Share the TokenStore with MCP tools so security_end_session can revoke # the caller's bearer without an import cycle. auth.register_token_store(token_store) @@ -637,10 +1299,84 @@ def _validate_authorize_params( None, ) - def _render_authorize_form( - normalized: dict[str, str], error: str | None = None, locked: bool = False + # Approvals that cleared 2FA but have not been turned into an + # authorization code yet. The operator sits on the confirmation screen + # (session lifetime, optional passkey enrolment) for as long as they + # like; the *code* is only minted when they press "Finish", so its + # 60 s OAuth 2.1 lifetime is never burned by a human reading a page. + # + # In-memory and single-use on purpose: a ticket that survives a restart + # would be a standing authorization nobody asked for. + pending_authorizations: dict[str, tuple[dict[str, str], float]] = {} + PENDING_AUTHORIZATION_TTL = 600.0 + + def _prune_pending() -> None: + now = time.time() + for key in [k for k, (_, exp) in pending_authorizations.items() if now > exp]: + del pending_authorizations[key] + + def _issue_pending(normalized: dict[str, str]) -> tuple[str, float]: + _prune_pending() + ticket = secrets.token_urlsafe(32) + expires_at = time.time() + PENDING_AUTHORIZATION_TTL + pending_authorizations[ticket] = (dict(normalized), expires_at) + return ticket, expires_at + + def _consume_pending(ticket: str) -> dict[str, str] | None: + _prune_pending() + entry = pending_authorizations.pop(ticket, None) + if entry is None: + return None + params, expires_at = entry + return params if time.time() <= expires_at else None + + def _peek_pending(ticket: str) -> dict[str, str] | None: + """Read a ticket without spending it (passkey enrolment mid-screen).""" + _prune_pending() + entry = pending_authorizations.get(ticket) + if entry is None: + return None + params, expires_at = entry + return params if time.time() <= expires_at else None + + def _passkey_owner(client_id: str) -> str: + """Client whose passkeys guard ``client_id``. + + Mirrors the TOTP delegation in :meth:`ClientStore.check_totp`: a + dynamically-registered client (ChatGPT and friends) has no second + factor of its own, so the human owner's passkeys authorize it. + """ + client = client_store.get(client_id) + if client is not None and client.owner_client_id: + return client.owner_client_id + return client_id + + def _authorize_passkeys_enabled(request: Request, client_id: str) -> bool: + if passkey_service is None or not passkey_service.available: + return False + return passkeys_mod.is_secure_context(request) + + def _wants_json_authorize(request: Request) -> bool: + return request.headers.get("x-beaconmcp-mode", "") == "json" + + def _authorize_page( + normalized: dict[str, str], + *, + request: Request, + error: str | None = None, + locked: bool = False, + ticket: str | None = None, + access_expires_at: float | None = None, + ticket_expires_at: float | None = None, ) -> HTMLResponse: - client_name = client_store.get_name(normalized["client_id"]) or normalized["client_id"] + """Render the two-panel authorize page. + + ``ticket`` switches it to the post-2FA panel. That panel is rendered + server-side too, so the flow still completes with JavaScript off: + the confirmation screen is a plain form posting the ticket back. + """ + client_id = normalized["client_id"] + client_name = client_store.get_name(client_id) or client_id hidden = "\n".join( f'' for k, v in normalized.items() @@ -649,273 +1385,198 @@ def _render_authorize_form( banner = "" if locked: banner = ( - '" + '' ) elif error: - banner = f'' + banner = f'' disabled = "disabled" if locked else "" - # Self-contained page: /oauth/authorize is reachable even when the - # dashboard (and its /app/static bundle) is disabled. Styles/JS live - # inline; same design tokens as the dashboard auth pages. + approved = ticket is not None + passkeys_on = _authorize_passkeys_enabled(request, client_id) + + def _fmt(ts: float | None) -> str: + if not ts: + return "—" + return html.escape( + _dt.datetime.fromtimestamp(ts).strftime("%d %b %Y at %H:%M") + ) + page = f""" -BeaconMCP · Two-factor +BeaconMCP · Authorize - - + + -
+
BeaconMCP
-

Authorize access

-

Enter the 6-digit code from your authenticator to grant access to {html.escape(client_name)}.

- {banner} -
- - Client: {html.escape(normalized["client_id"])} -
-
+ +
+

Authorize access

+

Enter the 6-digit code from your authenticator to grant access to {html.escape(client_name)}.

+ {banner} +
+ + Client: {html.escape(client_id)} +
+ + {hidden} -
- - - - - - +
+ + + + + + +
+ + + + + +
+ +
+ +

Two-factor confirmed

+

You're about to grant {html.escape(client_name)} access to this BeaconMCP server.

+ + + +
+
+
Access expires
+
{_fmt(access_expires_at)}
+
+
+
This approval expires
+
{_fmt(ticket_expires_at)}
+
+
+

The client will have to go through two-factor again once its access expires.

+ + - - - + + +
+ + +
+
- + """ return HTMLResponse(page) + def _authorize_redirect(normalized: dict[str, str]) -> Response: + """Mint the authorization code and bounce back to the OAuth client.""" + redirect_uri = normalized["redirect_uri"] + code = code_store.issue( + normalized["client_id"], + redirect_uri, + normalized["code_challenge"], + normalized["code_challenge_method"], + ) + query = {"code": code} + if normalized["state"]: + query["state"] = normalized["state"] + parsed = urlparse(redirect_uri) + sep = "&" if parsed.query else "?" + location = f"{redirect_uri}{sep}{urlencode(query)}" + return Response(status_code=302, headers={"Location": location}) + + def _authorize_approved( + request: Request, normalized: dict[str, str], *, as_json: bool = False, + ) -> Response: + """Second factor cleared: hand the operator the confirmation screen. + + ``as_json`` forces the JSON shape for callers that are JSON-only by + construction (the passkey endpoints), independently of whether the + client remembered to send the mode header. + """ + ticket, ticket_expires_at = _issue_pending(normalized) + access_expires_at = time.time() + TokenStore.TOKEN_TTL + if as_json or _wants_json_authorize(request): + return JSONResponse({ + "ok": True, + "ticket": ticket, + "ticket_expires_at": ticket_expires_at, + "access_expires_at": access_expires_at, + "passkeys_enabled": _authorize_passkeys_enabled( + request, normalized["client_id"], + ), + }) + return _authorize_page( + normalized, + request=request, + ticket=ticket, + ticket_expires_at=ticket_expires_at, + access_expires_at=access_expires_at, + ) + + def _authorize_failed( + request: Request, + normalized: dict[str, str], + message: str, + *, + status: int = 401, + locked: bool = False, + ) -> Response: + if _wants_json_authorize(request): + return JSONResponse( + {"ok": False, "error": message, "locked": locked}, + status_code=status, + ) + return _authorize_page( + normalized, request=request, error=message, locked=locked, + ) + async def oauth_authorize_get(request: Request) -> Response: normalized, err = _validate_authorize_params(dict(request.query_params)) if err is not None: return err - return _render_authorize_form( - normalized, locked=totp_locked(normalized["client_id"]) + return _authorize_page( + normalized, + request=request, + locked=totp_locked(normalized["client_id"]), ) async def oauth_authorize_post(request: Request) -> Response: @@ -927,7 +1588,11 @@ async def oauth_authorize_post(request: Request) -> Response: client_id = normalized["client_id"] if totp_locked(client_id): - return _render_authorize_form(normalized, locked=True) + return _authorize_failed( + request, normalized, + "Too many attempts. Try again in 5 minutes.", + status=429, locked=True, + ) code_totp = body.get("totp", "") totp_result = client_store.check_totp(client_id, code_totp) @@ -941,9 +1606,9 @@ async def oauth_authorize_post(request: Request) -> Response: reason=totp_result.value, ip=client_ip(request, tuple(config.server.trusted_proxies)), ) - return _render_authorize_form( - normalized, - error=( + return _authorize_failed( + request, normalized, + ( TOTP_REPLAY_MESSAGE if totp_result is TotpResult.REPLAY else "Incorrect code. Check that your device clock is in sync." @@ -952,24 +1617,195 @@ async def oauth_authorize_post(request: Request) -> Response: ) totp_record_success(client_id) audit.emit( - "auth.authorize.ok", client_id=client_id, + "auth.authorize.2fa", client_id=client_id, ip=client_ip(request, tuple(config.server.trusted_proxies)), ) + return _authorize_approved(request, normalized) - redirect_uri = normalized["redirect_uri"] - code = code_store.issue( - client_id, - redirect_uri, - normalized["code_challenge"], - normalized["code_challenge_method"], + async def oauth_authorize_finalize(request: Request) -> Response: + """Turn an approved ticket into an authorization code and redirect.""" + form = await request.form() + ticket_raw = form.get("ticket", "") + ticket = ticket_raw.strip() if isinstance(ticket_raw, str) else "" + normalized = _consume_pending(ticket) if ticket else None + if normalized is None: + return JSONResponse( + { + "error": "invalid_request", + "error_description": ( + "This approval expired or was already used. " + "Start the authorization again." + ), + }, + status_code=400, + ) + audit.emit( + "auth.authorize.ok", client_id=normalized["client_id"], + ip=client_ip(request, tuple(config.server.trusted_proxies)), + ) + return _authorize_redirect(normalized) + + # --- passkeys on the authorize page ---------------------------------- + + def _authorize_passkey_guard(request: Request) -> Response | None: + """Rate-limit + availability gate shared by the passkey endpoints.""" + if passkey_service is None or not passkey_service.available: + return JSONResponse( + {"ok": False, "error": "Passkeys are not available here."}, + status_code=503, + ) + ip = client_ip(request, tuple(config.server.trusted_proxies)) + if not _login_limiter.check(ip): + retry = _login_limiter.retry_after(ip) + return JSONResponse( + { + "ok": False, + "error": f"Too many attempts. Retry in {retry}s.", + }, + status_code=429, + ) + return None + + async def _authorize_json_body(request: Request) -> dict: + try: + data = await request.json() + except Exception: # noqa: BLE001 + return {} + return data if isinstance(data, dict) else {} + + async def oauth_passkey_options(request: Request) -> Response: + blocked = _authorize_passkey_guard(request) + if blocked is not None: + return blocked + body = await _authorize_json_body(request) + client_id = str(body.get("client_id") or "").strip() + if not client_id or not client_store.exists(client_id): + return JSONResponse( + {"ok": False, "error": "Unknown client."}, status_code=400, + ) + if totp_locked(client_id): + return JSONResponse( + {"ok": False, "error": "Too many attempts. Try again in 5 minutes."}, + status_code=429, + ) + try: + options, state = passkey_service.authentication_options( + request, client_id=_passkey_owner(client_id), + ) + except passkeys_mod.PasskeyError as exc: + return JSONResponse( + {"ok": False, "error": str(exc)}, status_code=400, + ) + return JSONResponse({"ok": True, "options": options, "state": state}) + + async def oauth_passkey_verify(request: Request) -> Response: + blocked = _authorize_passkey_guard(request) + if blocked is not None: + return blocked + body = await _authorize_json_body(request) + raw_params = body.get("params") + if not isinstance(raw_params, dict): + return JSONResponse( + {"ok": False, "error": "invalid_request"}, status_code=400, + ) + params = {k: v for k, v in raw_params.items() if isinstance(v, str)} + normalized, err = _validate_authorize_params(params) + if err is not None: + return JSONResponse( + {"ok": False, "error": "Invalid authorization request."}, + status_code=400, + ) + client_id = normalized["client_id"] + if totp_locked(client_id): + return JSONResponse( + {"ok": False, "error": "Too many attempts. Try again in 5 minutes."}, + status_code=429, + ) + credential = body.get("credential") + state = str(body.get("state") or "") + if not isinstance(credential, dict) or not state: + return JSONResponse( + {"ok": False, "error": "Malformed passkey response."}, + status_code=400, + ) + try: + record = passkey_service.verify_authentication( + request, state=state, credential=credential, + ) + except passkeys_mod.PasskeyError as exc: + audit.emit( + "auth.authorize.fail", client_id=client_id, + reason=f"passkey:{exc}", + ip=client_ip(request, tuple(config.server.trusted_proxies)), + ) + return JSONResponse({"ok": False, "error": str(exc)}, status_code=401) + # The challenge was minted for the seed owner; make sure the + # credential that answered it really guards *this* client. + if record.client_id != _passkey_owner(client_id): + return JSONResponse( + {"ok": False, "error": "This passkey belongs to another client."}, + status_code=401, + ) + totp_record_success(client_id) + audit.emit( + "auth.authorize.2fa", client_id=client_id, via="passkey", + ip=client_ip(request, tuple(config.server.trusted_proxies)), + ) + return _authorize_approved(request, normalized, as_json=True) + + async def oauth_passkey_register_options(request: Request) -> Response: + blocked = _authorize_passkey_guard(request) + if blocked is not None: + return blocked + body = await _authorize_json_body(request) + normalized = _peek_pending(str(body.get("ticket") or "")) + if normalized is None: + return JSONResponse( + {"ok": False, "error": "This approval expired. Start again."}, + status_code=400, + ) + owner = _passkey_owner(normalized["client_id"]) + try: + options, state = passkey_service.registration_options( + request, + client_id=owner, + client_name=client_store.get_name(owner) or owner, + ) + except passkeys_mod.PasskeyError as exc: + return JSONResponse({"ok": False, "error": str(exc)}, status_code=400) + return JSONResponse({"ok": True, "options": options, "state": state}) + + async def oauth_passkey_register_verify(request: Request) -> Response: + blocked = _authorize_passkey_guard(request) + if blocked is not None: + return blocked + body = await _authorize_json_body(request) + normalized = _peek_pending(str(body.get("ticket") or "")) + if normalized is None: + return JSONResponse( + {"ok": False, "error": "This approval expired. Start again."}, + status_code=400, + ) + credential = body.get("credential") + state = str(body.get("state") or "") + if not isinstance(credential, dict) or not state: + return JSONResponse( + {"ok": False, "error": "Malformed passkey response."}, + status_code=400, + ) + try: + record = passkey_service.verify_registration( + request, state=state, credential=credential, + ) + except passkeys_mod.PasskeyError as exc: + return JSONResponse({"ok": False, "error": str(exc)}, status_code=400) + audit.emit( + "auth.passkey.register.ok", + client_id=record.client_id, label=record.label, + ) + return JSONResponse( + {"ok": True, "passkey": record.to_json()}, status_code=201, ) - query = {"code": code} - if normalized["state"]: - query["state"] = normalized["state"] - parsed = urlparse(redirect_uri) - sep = "&" if parsed.query else "?" - location = f"{redirect_uri}{sep}{urlencode(query)}" - return Response(status_code=302, headers={"Location": location}) async def oauth_token(request: Request) -> Response: ip = client_ip(request, tuple(config.server.trusted_proxies)) @@ -1085,6 +1921,7 @@ async def auth_middleware(request: Request, call_next): "/metrics", "/oauth/token", "/oauth/authorize", + "/oauth/authorize/finalize", "/oauth/register", "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource", @@ -1101,6 +1938,14 @@ async def auth_middleware(request: Request, call_next): ): return await call_next(request) + # Passkey ceremonies for /oauth/authorize. They are part of signing + # in, so they cannot require a bearer -- there isn't one yet. Their + # own guards are the WebAuthn signature, the TOTP lockout and the + # per-IP login limiter; enrolment additionally demands an approval + # ticket, which is only handed out after a second factor passed. + if path.startswith("/oauth/passkey/"): + return await call_next(request) + # Dashboard routes have their own session-based auth. if path.startswith("/app/"): return await call_next(request) @@ -1149,10 +1994,8 @@ async def auth_middleware(request: Request, call_next): # OAuth Dynamic Client Registration plumbing. Only engaged when both # the feature flag is set AND the dashboard is enabled (the slug store - # lives in the dashboard's SQLite db). - from . import dashboard as _dashboard_mod + # lives in the dashboard's SQLite db, opened above). dyn_reg_store = None - shared_database = None if config.server.allow_dynamic_registration: if not _dashboard_mod.is_enabled(): print( @@ -1162,9 +2005,8 @@ async def auth_middleware(request: Request, call_next): file=sys.stderr, ) sys.exit(1) - from .dashboard.db import Database as _Database from .dashboard.dyn_reg import DynamicSlugStore as _DynamicSlugStore - shared_database = _Database() + assert shared_database is not None # required path: creation raised otherwise dyn_reg_store = _DynamicSlugStore(shared_database) async def dcr_protected_resource_metadata(request: Request) -> Response: @@ -1337,6 +2179,9 @@ async def lifespan(_app): dyn_reg=dyn_reg_store, shared_database=shared_database, login_limiter=_login_limiter, trusted_proxies=tuple(config.server.trusted_proxies), + passkey_service=passkey_service, + updates=config.features.updates, + config_path=config.source_path, ) app = Starlette( @@ -1348,6 +2193,20 @@ async def lifespan(_app): Route("/.well-known/oauth-protected-resource/mcp", protected_resource_metadata), Route("/oauth/authorize", oauth_authorize_get, methods=["GET"]), Route("/oauth/authorize", oauth_authorize_post, methods=["POST"]), + Route( + "/oauth/authorize/finalize", + oauth_authorize_finalize, methods=["POST"], + ), + Route("/oauth/passkey/options", oauth_passkey_options, methods=["POST"]), + Route("/oauth/passkey/verify", oauth_passkey_verify, methods=["POST"]), + Route( + "/oauth/passkey/register/options", + oauth_passkey_register_options, methods=["POST"], + ), + Route( + "/oauth/passkey/register/verify", + oauth_passkey_register_verify, methods=["POST"], + ), Route("/oauth/token", oauth_token, methods=["POST"]), Route("/oauth/register", oauth_register, methods=["POST"]), *dcr_routes, @@ -1373,6 +2232,13 @@ async def lifespan(_app): print(f"Dashboard: http://{host}:{port}/app/login (chat: {chat_status})") else: print("Dashboard: disabled (BEACONMCP_DASHBOARD_ENABLED=false)") + # The login pages hide their passkey buttons when this is off, with no + # visible explanation -- so say it here, where the operator can see it. + passkey_reason = passkey_service.unavailable_reason + if passkey_reason is None: + print("Passkeys: enabled (also needs HTTPS, or a loopback host)") + else: + print(f"Passkeys: disabled - {passkey_reason}") if n_clients == 0: print("\nNo clients registered. Create one with: beaconmcp auth create --name 'My Client'") uvicorn.run(app, host=host, port=port, log_level="info") @@ -1381,7 +2247,9 @@ async def lifespan(_app): def _build_dashboard_routes(client_store, token_store, totp_locked, totp_record_failure, totp_record_success, *, dyn_reg=None, shared_database=None, - login_limiter=None, trusted_proxies=()): + login_limiter=None, trusted_proxies=(), + passkey_service=None, updates=None, + config_path=None): """Build dashboard routes if enabled. Returns [] when disabled.""" from . import dashboard if not dashboard.is_enabled(): @@ -1454,6 +2322,12 @@ def _float_env(name: str, default: float) -> float: dyn_reg=dyn_reg, login_limiter=login_limiter, trusted_proxies=trusted_proxies, + passkeys=passkey_service, + updates_enabled=updates.enabled if updates is not None else True, + allow_self_update=( + updates.allow_self_update if updates is not None else True + ), + config_path=config_path, ) return build_dashboard_routes(deps) diff --git a/src/beaconmcp/config.py b/src/beaconmcp/config.py index 14f1272..c7f89fd 100644 --- a/src/beaconmcp/config.py +++ b/src/beaconmcp/config.py @@ -168,10 +168,26 @@ class DashboardConfig: mcp_mode: str = "local" # "local" | "remote" +@dataclass +class UpdatesConfig: + """Update checking and self-update. + + ``enabled`` is the network-egress switch: turning it off means the + server never contacts the git remote, which is what an air-gapped or + change-controlled deployment wants. ``allow_self_update`` keeps the + check but removes the ability to apply one from the dashboard or over + MCP -- appropriate when updates go through a deployment pipeline. + """ + + enabled: bool = True + allow_self_update: bool = True + + @dataclass class FeaturesConfig: dashboard: DashboardConfig = field(default_factory=DashboardConfig) ssh_enabled: bool = True + updates: UpdatesConfig = field(default_factory=UpdatesConfig) @dataclass @@ -183,6 +199,10 @@ class Config: features: FeaturesConfig verify_ssl: bool infrastructure: dict + #: YAML file this config was loaded from, or ``None`` on the legacy + #: env-var path. The self-update flow needs it to re-validate the + #: operator's *actual* config against newly pulled code. + source_path: Path | None = None # --- Loading ---------------------------------------------------------- @@ -255,7 +275,7 @@ def _from_yaml(cls, path: Path) -> Config: if not isinstance(raw, dict): raise ConfigError(f"{path}: top-level YAML must be a mapping.") resolved = _resolve_env_refs(raw, path=path) - return cls._build(resolved) + return cls._build(resolved, source_path=path) @classmethod def _from_legacy_env(cls) -> Config: @@ -328,7 +348,7 @@ def _from_legacy_env(cls) -> Config: return cls._build(raw) @classmethod - def _build(cls, raw: dict) -> Config: + def _build(cls, raw: dict, *, source_path: Path | None = None) -> Config: proxmox_raw = raw.get("proxmox") or {} nodes_raw = proxmox_raw.get("nodes") or [] @@ -554,9 +574,14 @@ def _build(cls, raw: dict) -> Config: public_url=dash_raw.get("public_url"), mcp_mode=(dash_raw.get("mcp_mode") or "local").strip().lower(), ) + updates_raw = feat_raw.get("updates") or {} features = FeaturesConfig( dashboard=dashboard, ssh_enabled=_bool((feat_raw.get("ssh") or {}).get("enabled", True)), + updates=UpdatesConfig( + enabled=_bool(updates_raw.get("enabled", True)), + allow_self_update=_bool(updates_raw.get("allow_self_update", True)), + ), ) # Cross-capability validation ---------------------------------------- @@ -597,6 +622,7 @@ def _build(cls, raw: dict) -> Config: features=features, verify_ssl=_bool(proxmox_raw.get("verify_ssl", False)), infrastructure=raw.get("infrastructure") or {}, + source_path=source_path, ) # --- Accessors -------------------------------------------------------- @@ -748,6 +774,10 @@ def mask(value: str) -> str: "mcp_mode": self.features.dashboard.mcp_mode, }, "ssh_enabled": self.features.ssh_enabled, + "updates": { + "enabled": self.features.updates.enabled, + "allow_self_update": self.features.updates.allow_self_update, + }, }, "infrastructure": self.infrastructure, } diff --git a/src/beaconmcp/dashboard/app.py b/src/beaconmcp/dashboard/app.py index 97ffec2..699c01d 100644 --- a/src/beaconmcp/dashboard/app.py +++ b/src/beaconmcp/dashboard/app.py @@ -51,6 +51,7 @@ ) from .db import Database from .dyn_reg import DynamicSlugStore, SLUG_TTL_SECONDS +from .passkeys import PasskeyError, PasskeyService, is_secure_context from .session import SESSION_TTL_SECONDS, Session, SessionStore from .usage import UsageMeter, UsageStore @@ -98,6 +99,17 @@ class DashboardDeps: # tests/embedding paths can skip the limiter entirely. login_limiter: object | None = None trusted_proxies: tuple[str, ...] = () + # WebAuthn ceremonies for the passkey login/registration flow. When + # unset (or reporting ``available is False``), the login page hides + # every passkey affordance and the TOTP path is the only way in. + passkeys: PasskeyService | None = None + # Update notifications. ``updates_enabled`` gates the check (and with + # it any network egress); ``allow_self_update`` gates the "Update now" + # button. ``config_path`` is the YAML the update flow re-validates + # against freshly pulled code. + updates_enabled: bool = True + allow_self_update: bool = True + config_path: Path | None = None # --------------------------------------------------------------------------- @@ -121,8 +133,17 @@ def _set_session_cookie(response: Response, session_id: str, secure: bool) -> No ) -def _set_csrf_cookie(response: Response, secure: bool) -> str: - token = csrf.issue_token() +def _set_csrf_cookie( + response: Response, secure: bool, token: str | None = None, +) -> str: + """Issue (or re-issue) the double-submit CSRF cookie. + + ``token`` lets a caller mint the value first so it can also hand it to + the client out-of-band -- the JSON login does that, because rotating + the cookie without telling the page would leave its in-DOM token stale + and every follow-up fetch would 403. + """ + token = token or csrf.issue_token() response.set_cookie( csrf.CSRF_COOKIE, token, @@ -156,6 +177,7 @@ def _render( if not token: token = csrf.issue_token() context["csrf_token"] = token + context["asset_v"] = ASSET_VERSION response = _TEMPLATES.TemplateResponse( request, template, context, status_code=status_code ) @@ -177,6 +199,11 @@ def _render( def _apply_security_headers(response: Response) -> None: response.headers.setdefault("X-Frame-Options", "DENY") response.headers.setdefault("X-Content-Type-Options", "nosniff") + # Panel pages and API replies are per-session and must not be reused -- + # by a shared cache, or by the back button after a sign-out. It also + # keeps the asset fingerprints these pages embed from going stale. + # setdefault, so the SSE stream keeps its own directives. + response.headers.setdefault("Cache-Control", "no-store") response.headers.setdefault( "Referrer-Policy", "strict-origin-when-cross-origin" ) @@ -243,6 +270,75 @@ def _totp_error(result: TotpResult, invalid_message: str) -> str: return TOTP_REPLAY_MESSAGE if result is TotpResult.REPLAY else invalid_message +def _compute_asset_version() -> str: + """Fingerprint the static bundle, for cache-busting query strings. + + Starlette serves static files with ``ETag``/``Last-Modified`` but no + ``Cache-Control``, which leaves browsers on *heuristic* freshness: an + asset untouched for weeks is reused for a long time without ever + revalidating. That was survivable when upgrading meant an operator + running commands by hand; now that the server can update itself, the + next page load would happily keep executing the previous release's + JavaScript against a new backend. + + Stamping the URLs with a fingerprint fixes it deterministically -- new + bytes mean a new URL, which no cache can satisfy from an old entry -- + and lets the files themselves be cached hard (see + :class:`_ImmutableStaticFiles`). Newest mtime in the directory is + enough: a ``git pull`` rewrites the files it changes. + """ + try: + newest = max( + p.stat().st_mtime + for p in (_DASHBOARD_DIR / "static").iterdir() + if p.is_file() + ) + except (OSError, ValueError): + return "0" + return format(int(newest), "x") + + +#: Computed once per process: a restart is exactly when the bundle can change. +ASSET_VERSION = _compute_asset_version() + + +class _ImmutableStaticFiles(StaticFiles): + """StaticFiles for URLs that carry a content fingerprint. + + Safe to cache hard *because* the query string changes whenever the + bytes do. Requests without a version (a hand-typed URL, an old cached + page) fall back to revalidate-every-time so they can never pin stale + code. + """ + + def file_response(self, full_path, stat_result, scope, *args, **kwargs) -> Response: + response = super().file_response( + full_path, stat_result, scope, *args, **kwargs + ) + query = scope.get("query_string") or b"" + versioned = b"v=" in query + response.headers.setdefault( + "Cache-Control", + "public, max-age=31536000, immutable" if versioned else "no-cache", + ) + return response + + +def _wants_json(request: Request) -> bool: + """True when the caller is the login page's fetch() rather than a form POST. + + The login form still works with JavaScript disabled -- it posts and gets + a redirect, exactly as before. The enhanced flow (shimmer, then the + post-2FA screen offering to enrol a passkey) needs to stay on the page, + so it opts in explicitly with this header. + """ + return request.headers.get("x-beaconmcp-mode", "") == "json" + + +def _passkeys_ready(deps: DashboardDeps) -> bool: + return deps.passkeys is not None and deps.passkeys.available + + # --------------------------------------------------------------------------- # Route factories # --------------------------------------------------------------------------- @@ -278,8 +374,72 @@ async def login_get(request: Request) -> Response: next=request.query_params.get("next", ""), banner=None, locked=False, + passkeys_enabled=_passkeys_ready(deps), + secure_context=is_secure_context(request), ) + def _login_success_payload( + request: Request, session: Session, *, next_url: str, bearer_ttl: int, + ) -> dict[str, Any]: + """Data the post-2FA screen renders: expiry + passkey affordances.""" + return { + "ok": True, + "next": next_url, + "client_id": session.client_id, + "client_name": ( + deps.client_store.get_name(session.client_id) # type: ignore[attr-defined] + or session.client_id + ), + # Two different clocks, and users care about both: the MCP bearer + # is what actually stops working (a 2FA re-prompt at /app/refresh), + # while the signed-in cookie is what saves them the full login. + "bearer_expires_at": time.time() + bearer_ttl, + "session_expires_at": session.expires_at, + "passkeys_enabled": _passkeys_ready(deps), + "secure_context": is_secure_context(request), + "passkey_count": ( + deps.passkeys.store.count_for_client(session.client_id) + if _passkeys_ready(deps) else 0 + ), + } + + def _start_session( + request: Request, + *, + client_id: str, + client_secret: str, + next_url: str, + as_json: bool | None = None, + ) -> tuple[Session, int, Response]: + """Mint the MCP bearer + session row and attach the cookies. + + Shared by the TOTP login and the passkey login so the two paths + cannot drift on cookie flags or bearer lifetime. + """ + bearer, ttl = deps.token_store.issue(client_id) # type: ignore[attr-defined] + ua = request.headers.get("user-agent", "")[:200] + session = deps.session_store.create( + client_id=client_id, + client_secret=client_secret, + mcp_bearer=bearer, + bearer_ttl_seconds=ttl, + user_agent=ua, + ) + secure = _is_secure(request) + new_csrf = csrf.issue_token() + if as_json if as_json is not None else _wants_json(request): + payload = _login_success_payload( + request, session, next_url=next_url, bearer_ttl=ttl, + ) + payload["csrf_token"] = new_csrf + response: Response = JSONResponse(payload) + else: + response = RedirectResponse(next_url, status_code=303) + _set_session_cookie(response, session.session_id, secure) + _set_csrf_cookie(response, secure, new_csrf) + _apply_security_headers(response) + return session, ttl, response + async def login_post(request: Request) -> Response: if not await csrf.verify(request): return JSONResponse({"error": "csrf"}, status_code=403) @@ -292,14 +452,23 @@ async def login_post(request: Request) -> Response: ip = _client_ip(request, deps.trusted_proxies) if not limiter.check(ip): # type: ignore[attr-defined] retry = limiter.retry_after(ip) # type: ignore[attr-defined] + message = ( + f"Too many attempts from this address. Retry in {retry}s." + ) + if _wants_json(request): + return _json( + {"ok": False, "error": message, "locked": True}, status=429, + ) return _render( "login.html", request, client_id="", next="", - banner=f"Too many attempts from this address. Retry in {retry}s.", + banner=message, locked=True, status_code=429, + passkeys_enabled=_passkeys_ready(deps), + secure_context=is_secure_context(request), ) form = await request.form() @@ -316,6 +485,10 @@ def _v(name: str) -> str: next_url = _default_landing() def _fail(message: str, *, status: int = 400, locked: bool = False) -> Response: + if _wants_json(request): + return _json( + {"ok": False, "error": message, "locked": locked}, status=status, + ) return _render( "login.html", request, @@ -324,6 +497,8 @@ def _fail(message: str, *, status: int = 400, locked: bool = False) -> Response: banner=message, locked=locked, status_code=status, + passkeys_enabled=_passkeys_ready(deps), + secure_context=is_secure_context(request), ) if not client_id or not client_secret or not totp: @@ -356,21 +531,12 @@ def _fail(message: str, *, status: int = 400, locked: bool = False) -> Response: deps.totp_record_success(client_id) audit.emit("dashboard.login.ok", client_id=client_id) - bearer, ttl = deps.token_store.issue(client_id) # type: ignore[attr-defined] - ua = request.headers.get("user-agent", "")[:200] - session = deps.session_store.create( + _, _, response = _start_session( + request, client_id=client_id, client_secret=client_secret, - mcp_bearer=bearer, - bearer_ttl_seconds=ttl, - user_agent=ua, + next_url=next_url, ) - - secure = _is_secure(request) - response = RedirectResponse(next_url, status_code=303) - _set_session_cookie(response, session.session_id, secure) - _set_csrf_cookie(response, secure) - _apply_security_headers(response) return response async def refresh_get(request: Request) -> Response: @@ -772,6 +938,314 @@ async def tokens_revoke(request: Request) -> Response: ) return RedirectResponse("/app/tokens", status_code=303) + # --- Passkeys (WebAuthn) --------------------------------------------- + + def _passkey_service() -> PasskeyService | Response: + if not _passkeys_ready(deps): + return _json({"error": "passkeys_unavailable"}, status=503) + assert deps.passkeys is not None + return deps.passkeys + + async def api_passkeys_list(request: Request) -> Response: + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + service = _passkey_service() + if isinstance(service, Response): + return service + return _json({ + "passkeys": [ + p.to_json() for p in service.store.list_for_client(session.client_id) + ], + }) + + async def api_passkeys_register_options(request: Request) -> Response: + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + service = _passkey_service() + if isinstance(service, Response): + return service + try: + options, state = service.registration_options( + request, + client_id=session.client_id, + client_name=( + deps.client_store.get_name(session.client_id) # type: ignore[attr-defined] + or session.client_id + ), + session_id=session.session_id, + ) + except PasskeyError as exc: + return _json({"error": str(exc)}, status=400) + return _json({"options": options, "state": state}) + + async def api_passkeys_register_verify(request: Request) -> Response: + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + service = _passkey_service() + if isinstance(service, Response): + return service + body = await _read_json(request) + credential = body.get("credential") + state = str(body.get("state") or "") + if not isinstance(credential, dict) or not state: + return _json({"error": "invalid_request"}, status=400) + label_raw = body.get("label") + try: + record = service.verify_registration( + request, + state=state, + credential=credential, + label=label_raw if isinstance(label_raw, str) else None, + session_id=session.session_id, + ) + except PasskeyError as exc: + audit.emit( + "dashboard.passkey.register.fail", + client_id=session.client_id, reason=str(exc), + ) + return _json({"error": str(exc)}, status=400) + audit.emit( + "dashboard.passkey.register.ok", + client_id=session.client_id, label=record.label, + ) + return _json({"ok": True, "passkey": record.to_json()}, status=201) + + async def api_passkeys_delete(request: Request) -> Response: + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + service = _passkey_service() + if isinstance(service, Response): + return service + body = await _read_json(request) + credential_id = str(body.get("credential_id") or "").strip() + if not credential_id: + return _json({"error": "invalid_request"}, status=400) + if not service.store.delete(credential_id, session.client_id): + return _json({"error": "not_found"}, status=404) + audit.emit("dashboard.passkey.revoke", client_id=session.client_id) + return _json({"ok": True}) + + async def passkeys_remove(request: Request) -> Response: + """Form-POST twin of the JSON delete, for the tokens page. + + The tokens page is deliberately JS-free for destructive actions, so + removing a passkey posts a plain form and lands back on the page. + """ + session = _load_session(request, deps) + if not session: + return RedirectResponse("/app/login", status_code=302) + if not _bearer_live(deps, session): + return RedirectResponse("/app/refresh?next=/app/tokens", status_code=302) + if not await csrf.verify(request): + return JSONResponse({"error": "csrf"}, status_code=403) + if _passkeys_ready(deps): + form = await request.form() + raw = form.get("credential_id", "") + credential_id = (raw if isinstance(raw, str) else "").strip() + assert deps.passkeys is not None + if credential_id and deps.passkeys.store.delete( + credential_id, session.client_id, + ): + audit.emit("dashboard.passkey.revoke", client_id=session.client_id) + return RedirectResponse("/app/tokens", status_code=303) + + def _passkey_login_rate_limited(request: Request) -> Response | None: + """Apply the login limiter to the unauthenticated passkey endpoints. + + These take a client_id/client_secret pair, so they are just as much + a credential-stuffing surface as /app/login itself. + """ + limiter = deps.login_limiter + if limiter is None: + return None + from ..ratelimit import client_ip as _client_ip # local: avoid import cycle + ip = _client_ip(request, deps.trusted_proxies) + if limiter.check(ip): # type: ignore[attr-defined] + return None + retry = limiter.retry_after(ip) # type: ignore[attr-defined] + return _json( + { + "ok": False, + "error": f"Too many attempts from this address. Retry in {retry}s.", + }, + status=429, + ) + + async def api_passkeys_auth_options(request: Request) -> Response: + """Start a passkey sign-in. Requires the client credentials first. + + The passkey replaces the TOTP factor only: without a valid + client_id/client_secret we will not even disclose whether a client + has credentials registered. + """ + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + limited = _passkey_login_rate_limited(request) + if limited is not None: + return limited + service = _passkey_service() + if isinstance(service, Response): + return service + body = await _read_json(request) + client_id = str(body.get("client_id") or "").strip() + client_secret = str(body.get("client_secret") or "") + if not client_id or not client_secret: + return _json({"ok": False, "error": "Missing credentials."}, status=400) + if not deps.client_store.verify(client_id, client_secret): # type: ignore[attr-defined] + return _json({"ok": False, "error": "Invalid credentials."}, status=401) + try: + options, state = service.authentication_options( + request, client_id=client_id, + ) + except PasskeyError as exc: + return _json({"ok": False, "error": str(exc)}, status=400) + return _json({"ok": True, "options": options, "state": state}) + + async def api_passkeys_auth_verify(request: Request) -> Response: + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + limited = _passkey_login_rate_limited(request) + if limited is not None: + return limited + service = _passkey_service() + if isinstance(service, Response): + return service + body = await _read_json(request) + client_id = str(body.get("client_id") or "").strip() + client_secret = str(body.get("client_secret") or "") + state = str(body.get("state") or "") + credential = body.get("credential") + if not client_id or not client_secret or not state: + return _json({"ok": False, "error": "Missing credentials."}, status=400) + if not isinstance(credential, dict): + return _json({"ok": False, "error": "Malformed passkey response."}, status=400) + # Re-verify the secret: the options call proved it once, but the + # state token alone must never be enough to mint a session. + if not deps.client_store.verify(client_id, client_secret): # type: ignore[attr-defined] + return _json({"ok": False, "error": "Invalid credentials."}, status=401) + + try: + record = service.verify_authentication( + request, state=state, credential=credential, + ) + except PasskeyError as exc: + audit.emit( + "dashboard.login.fail", client_id=client_id, + reason=f"passkey:{exc}", + ) + return _json({"ok": False, "error": str(exc)}, status=401) + if record.client_id != client_id: + return _json({"ok": False, "error": "Invalid credentials."}, status=401) + + next_url = str(body.get("next") or "").strip() + if not next_url.startswith("/app/"): + next_url = _default_landing() + # A passkey sign-in is a clean second factor: clear any TOTP + # lockout the same way a correct code would. + deps.totp_record_success(client_id) + audit.emit("dashboard.login.ok", client_id=client_id, via="passkey") + _, _, response = _start_session( + request, + client_id=client_id, + client_secret=client_secret, + next_url=next_url, + as_json=True, + ) + return response + + # --- Update notifications -------------------------------------------- + + async def api_update_status(request: Request) -> Response: + """Update status for the signed-in operator. + + Deliberately session-gated: an anonymous visitor learning the exact + revision a server runs is free reconnaissance, and the banner is + only ever rendered to someone already signed in. + """ + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not deps.updates_enabled: + return _json({"enabled": False, "available": False}) + + from .. import updates as updates_mod + + force = request.query_params.get("force") == "1" + # The check shells out to git (network); keep it off the event loop. + info = await asyncio.to_thread( + updates_mod.check_for_update, force=force, config_path=deps.config_path, + ) + payload = info.to_json() + payload["enabled"] = True + payload["self_update_allowed"] = deps.allow_self_update + if not deps.allow_self_update: + payload["can_self_update"] = False + return _json(payload) + + async def api_update_apply(request: Request) -> Response: + """Apply an update from the dashboard, behind a fresh 2FA code. + + Pulling code and restarting the process is the most privileged + thing this panel can do, so it is gated exactly like minting a + token: a session is not enough, the operator re-proves the second + factor at the moment of the action. + """ + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + if not (deps.updates_enabled and deps.allow_self_update): + return _json( + { + "ok": False, + "error": "Self-update is disabled on this server " + "(features.updates.allow_self_update).", + }, + status=403, + ) + + body = await _read_json(request) + totp = str(body.get("totp") or "").strip() + if not totp: + return _json({"ok": False, "error": "2FA code is required."}, status=400) + if deps.totp_locked(session.client_id): + return _json( + {"ok": False, "error": "Too many 2FA attempts; try again in 5 minutes."}, + status=429, + ) + totp_result = _check_totp(deps, session.client_id, totp) + if totp_result is not TotpResult.OK: + return _json( + {"ok": False, "error": _totp_error(totp_result, "Invalid 2FA code.")}, + status=401, + ) + deps.totp_record_success(session.client_id) + + from .. import updates as updates_mod + + audit.emit("dashboard.update.start", client_id=session.client_id) + result = await asyncio.to_thread( + updates_mod.apply_update, config_path=deps.config_path, + ) + audit.emit( + "dashboard.update.finish", client_id=session.client_id, + ok=result.ok, from_ref=result.from_ref, to_ref=result.to_ref, + rolled_back=result.rolled_back, + ) + updates_mod.invalidate_cache() + return _json(result.to_json(), status=200 if result.ok else 500) + async def chat_get(request: Request) -> Response: session = _load_session(request, deps) if not session: @@ -927,6 +1401,7 @@ async def api_chat_stream(request: Request) -> Response: user_text = str(body.get("content") or "").strip() override_model = body.get("model") override_effort = body.get("effort") + app_context = _sanitize_app_context(body.get("app_context")) if not conv_id or not user_text: return _json({"error": "invalid_request"}, status=400) @@ -1000,6 +1475,7 @@ async def _confirm(req: ToolConfirmRequired) -> bool: mcp_url=_resolve_mcp_url(request, deps), mcp_mode=deps.mcp_mode, confirm_tool=_confirm, + app_context=app_context, ) try: @@ -1021,6 +1497,11 @@ async def _confirm(req: ToolConfirmRequired) -> bool: yield _sse("tool_result", { "id": event.id, "status": event.status, "preview": event.preview, "duration_ms": event.duration_ms, + # Present only for tools that ship an MCP Apps + # panel: {resourceUri, result}. The browser + # mounts the iframe and pushes ``result`` into + # it as ui/notifications/tool-result. + "ui": event.ui, }) elif isinstance(event, UsageAccumulated): # Engine-internal event: keep the last one the @@ -1108,6 +1589,144 @@ async def _confirm(req: ToolConfirmRequired) -> bool: _apply_security_headers(response) return response + # ----- MCP Apps host --------------------------------------------------- + # + # The chat page is an MCP Apps host: a tool that carries + # ``_meta.ui.resourceUri`` gets its ``ui://`` document rendered in a + # sandboxed iframe instead of showing raw JSON. Two routes serve that + # frame -- one hands it the document, one relays the tool calls its + # buttons make. Both are same-origin and session-authenticated, which + # the frame itself is not: it runs under ``sandbox="allow-scripts"`` + # with no ``allow-same-origin``, so its origin is opaque, it carries no + # cookies, and it cannot read the CSRF token. postMessage to its parent + # is the only way out, which is what makes the parent page the place + # where policy is decided. + + async def api_mcp_panel(request: Request) -> Response: + """Serve a ``ui://`` document for framing, under its own CSP.""" + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + uri = (request.query_params.get("uri") or "").strip() + if not uri.startswith("ui://"): + return _json({"error": "invalid_uri"}, status=400) + + from . import mcp_bridge # local: keeps the mcp import off test paths + + mcp_url = _resolve_mcp_url(request, deps) + html = mcp_bridge.cache_get(mcp_url, uri) + if html is None: + try: + async with mcp_bridge.open_session( + mcp_url, session.mcp_bearer or "", + ) as mcp_session: + html = await mcp_bridge.read_ui_resource(mcp_session, uri) + except mcp_bridge.UiResourceError as exc: + return _json( + {"error": "resource_unavailable", "message": str(exc)}, + status=502, + ) + except Exception as exc: # noqa: BLE001 + return _json( + {"error": "mcp_unavailable", "message": str(exc)}, + status=502, + ) + mcp_bridge.cache_put(mcp_url, uri, html) + + response = HTMLResponse(html) + # Deliberately NOT _apply_security_headers: that sets + # X-Frame-Options: DENY and a CSP whose script-src 'self' would + # kill the panel's inline bridge. This document needs the opposite + # of both -- framable by us, inline script allowed, and nothing + # else. connect-src 'none' is the important line: a panel talks to + # the cluster through its parent, never directly. + response.headers["Content-Security-Policy"] = ( + "default-src 'none'; " + "script-src 'unsafe-inline'; " + "style-src 'unsafe-inline'; " + "img-src data:; " + "font-src data:; " + "connect-src 'none'; " + "form-action 'none'; " + "base-uri 'none'; " + "frame-src 'none'; " + "object-src 'none'; " + "frame-ancestors 'self'" + ) + response.headers["X-Frame-Options"] = "SAMEORIGIN" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Cache-Control"] = "no-store" + response.headers["Referrer-Policy"] = "no-referrer" + return response + + async def api_mcp_call(request: Request) -> Response: + """Relay a panel's ``tools/call`` to the MCP server.""" + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + + body = await _read_json(request) + name = str(body.get("name") or "").strip() + args = body.get("arguments") + if not name or (args is not None and not isinstance(args, dict)): + return _json({"error": "invalid_request"}, status=400) + args = args or {} + + from .chat import panel_call_allowed + + if not panel_call_allowed(name, args): + # Not a modal, because there is no turn in flight to hang one + # on. The panel's way through is ui/message: hand the request + # to the model and let it meet the approval gate there. + audit.emit( + "dashboard.panel_call_refused", + tool=name, + client_id=session.client_id, + args=audit.compact_args(args), + ) + return _json( + { + "error": "confirmation_required", + "message": ( + f"{name} needs explicit approval and cannot be called " + "straight from a panel. Ask the assistant to run it." + ), + }, + status=403, + ) + + from . import mcp_bridge + + start = time.monotonic() + try: + async with mcp_bridge.open_session( + _resolve_mcp_url(request, deps), session.mcp_bearer or "", + ) as mcp_session: + result = await mcp_session.call_tool(name, args) + except Exception as exc: # noqa: BLE001 + audit.emit( + "dashboard.panel_call", + tool=name, status="error", + client_id=session.client_id, + args=audit.compact_args(args), + ) + return _json( + {"error": "tool_failed", "message": str(exc)}, status=502, + ) + + wire = mcp_bridge.call_result_to_wire(result) + audit.emit( + "dashboard.panel_call", + tool=name, + status="error" if wire.get("isError") else "ok", + duration_ms=round((time.monotonic() - start) * 1000, 1), + client_id=session.client_id, + args=audit.compact_args(args), + ) + return _json({"result": wire}) + return [ Route("/app/login", login_get, methods=["GET"]), Route("/app/login", login_post, methods=["POST"]), @@ -1131,11 +1750,34 @@ async def _confirm(req: ToolConfirmRequired) -> bool: Route("/app/api/conversations/{conv_id}", api_conv_delete, methods=["DELETE"]), Route("/app/api/chat/stream", api_chat_stream, methods=["POST"]), Route("/app/api/chat/confirm", api_chat_confirm, methods=["POST"]), + Route("/app/api/mcp/panel", api_mcp_panel, methods=["GET"]), + Route("/app/api/mcp/call", api_mcp_call, methods=["POST"]), Route("/app/api/usage", api_usage, methods=["GET"]), + Route("/app/api/passkeys", api_passkeys_list, methods=["GET"]), + Route( + "/app/api/passkeys/register/options", + api_passkeys_register_options, methods=["POST"], + ), + Route( + "/app/api/passkeys/register/verify", + api_passkeys_register_verify, methods=["POST"], + ), + Route("/app/api/passkeys/delete", api_passkeys_delete, methods=["POST"]), + Route("/app/passkeys/remove", passkeys_remove, methods=["POST"]), + Route("/app/api/update", api_update_status, methods=["GET"]), + Route("/app/api/update/apply", api_update_apply, methods=["POST"]), + Route( + "/app/api/passkeys/auth/options", + api_passkeys_auth_options, methods=["POST"], + ), + Route( + "/app/api/passkeys/auth/verify", + api_passkeys_auth_verify, methods=["POST"], + ), Route("/", index, methods=["GET"]), Mount( "/app/static", - app=StaticFiles(directory=_DASHBOARD_DIR / "static"), + app=_ImmutableStaticFiles(directory=_DASHBOARD_DIR / "static"), name="dashboard-static", ), ] @@ -1251,6 +1893,21 @@ def _expires_label(expires_at: float) -> str: locked=deps.totp_locked(session.client_id), chat_enabled=deps.engine is not None, dcr_enabled=deps.dyn_reg is not None, + passkeys_enabled=_passkeys_ready(deps), + passkeys=( + [ + { + "credential_id": p.credential_id, + "label": p.label, + "created_human": _human_time(p.created_at), + "last_used_human": ( + _human_time(p.last_used_at) if p.last_used_at else None + ), + } + for p in deps.passkeys.store.list_for_client(session.client_id) + ] + if _passkeys_ready(deps) else [] + ), ) @@ -1281,6 +1938,39 @@ def _resolve_mcp_url(request: Request, deps: DashboardDeps) -> str: return f"http://127.0.0.1:{port}/mcp" +_MAX_APP_CONTEXT_ENTRIES = 8 + + +def _sanitize_app_context(raw: Any) -> list[dict[str, Any]]: + """Normalise the ``app_context`` a chat page sends with a turn. + + These are ``ui/update-model-context`` payloads the panels pushed, held + in the page and replayed with the next message. The page is trusted no + more than any other client input here: we keep the shape, drop the + rest, and cap how many rides along so an open dashboard cannot grow the + prompt without bound. + """ + if not isinstance(raw, list): + return [] + out: list[dict[str, Any]] = [] + for item in raw[:_MAX_APP_CONTEXT_ENTRIES]: + if not isinstance(item, dict): + continue + tool = str(item.get("tool") or "").strip()[:64] + text = item.get("text") + structured = item.get("structured") + if not isinstance(text, str): + text = None + if text is None and structured is None: + continue + out.append({ + "tool": tool or "panel", + "text": text, + "structured": structured, + }) + return out + + def _sse(event: str, data: Any) -> bytes: payload = json.dumps(data, ensure_ascii=False) return f"event: {event}\ndata: {payload}\n\n".encode("utf-8") diff --git a/src/beaconmcp/dashboard/chat.py b/src/beaconmcp/dashboard/chat.py index d57618f..efac73b 100644 --- a/src/beaconmcp/dashboard/chat.py +++ b/src/beaconmcp/dashboard/chat.py @@ -103,6 +103,10 @@ class ToolCallEnd: status: str # ok | error preview: str duration_ms: int + # Set only when the tool declares an MCP Apps panel. Carries the + # ``ui://`` URI plus the full ``CallToolResult`` -- the panel needs the + # whole payload, not the 500-char ``preview`` the tool card shows. + ui: dict[str, Any] | None = None @dataclass @@ -188,8 +192,20 @@ class UsageAccumulated: # ``proxmox_vm_config`` returns the config without ``updates`` and rewrites # it with them, so gating the whole tool would put a modal in front of # every read. Map: tool name -> arg whose presence makes the call mutate. +# +# ``beaconmcp_self_update`` belongs here for the same reason and is the +# sharper case: with ``confirm=False`` it only previews, but with +# ``confirm=True`` it pulls new code, reinstalls dependencies and restarts +# the service. That is the single most consequential call this server +# exposes, and nothing stops an injected instruction from asking for it. +# +# Reading the argument is sound here, unlike the ``dry_run`` case below: +# ``confirm`` is a parameter the tool actually declares, so what we read is +# what the tool will act on. The trap that note describes is an argument the +# tool does *not* declare, which pydantic drops during validation. _CONFIRM_WHEN_ARG_PRESENT: dict[str, str] = { "proxmox_vm_config": "updates", + "beaconmcp_self_update": "confirm", } # Tools that actually implement a ``dry_run`` parameter and return a @@ -242,6 +258,97 @@ def _tool_call_requires_confirmation(name: str, args: Any) -> bool: return True +# Tools a ui:// panel is allowed to call by itself, without the modal. +# +# The gate above exists because the *model* decides those calls and the +# model's input is untrusted. A panel button is not that: it is a labelled +# control a human clicked, and putting "Approve stopping VM 104?" in front +# of a button that says "Stop" restates the click rather than checking it. +# +# What the exemption is NOT is "calls from an iframe skip the gate". The +# panel document is HTML the *server* wrote, not something the operator +# typed, and this dashboard is a general MCP host -- any server an operator +# connects can ship a ui:// resource. A blanket exemption would hand every +# such server a way around the approval it is documented to be subject to. +# So the list is closed and enumerated: guest lifecycle, one guest per +# call, visible in the panel and reversible from it. +# +# There is a deliberate escape hatch for everything else. A panel that +# wants a shell sends ``ui/message``, which puts the request back on the +# model's path -- and therefore back under the modal it belongs to. That is +# why the relay refuses out-of-list tools outright instead of prompting: +# the prompt already exists, one hop further along. +_PANEL_CALL_EXEMPT: frozenset[str] = frozenset({ + "proxmox_vm_start", + "proxmox_vm_stop", + "proxmox_vm_restart", +}) + +# ``proxmox_vm_config`` cannot go in the set above, because ``updates`` is an +# open-ended guest config: exempting the tool would exempt ``hookscript``, +# raw QEMU ``args`` and device passthrough along with it. The panels edit +# sizing, so sizing is what is exempt -- any other key falls back to the +# normal gate and is refused. +_PANEL_CONFIG_KEYS: frozenset[str] = frozenset({ + "cores", "sockets", "memory", "balloon", "cpulimit", "cpuunits", +}) + + +def panel_call_allowed(name: str, args: Any) -> bool: + """Return True when a panel may issue this ``tools/call`` unattended.""" + if name in _PANEL_CALL_EXEMPT: + return True + if name == "proxmox_vm_config": + updates = args.get("updates") if isinstance(args, dict) else None + if not updates: + return True # reading a config, not writing one + return isinstance(updates, dict) and set(updates).issubset(_PANEL_CONFIG_KEYS) + return not _tool_call_requires_confirmation(name, args) + + +# Cap on what one panel's ``ui/update-model-context`` may inject, so a +# cluster dashboard holding a few hundred guests cannot quietly become the +# bulk of the prompt. The panels send a sentence plus a small object; this +# is a backstop, not a working limit. +_APP_CONTEXT_MAX_CHARS = 4000 + + +def format_app_context(entries: Any) -> str: + """Render panel context updates as one labelled block for the model. + + Marked as coming from a panel rather than from the operator: the text + is written by the ``ui://`` document, so the model should weigh it as + tool output, which is exactly what it is. + """ + if not isinstance(entries, list) or not entries: + return "" + lines: list[str] = [] + for entry in entries: + if not isinstance(entry, dict): + continue + tool = str(entry.get("tool") or "panel") + parts: list[str] = [] + text = entry.get("text") + if isinstance(text, str) and text.strip(): + parts.append(text.strip()) + structured = entry.get("structured") + if structured is not None: + parts.append(_json.dumps(structured, ensure_ascii=False, default=str)) + if not parts: + continue + body = "\n".join(parts) + if len(body) > _APP_CONTEXT_MAX_CHARS: + body = body[: _APP_CONTEXT_MAX_CHARS - 1] + "…" + lines.append(f"[{tool}] {body}") + if not lines: + return "" + return ( + "State reported by the interactive panels open in this conversation " + "(the user acted in the panel; these calls did not go through you):\n" + + "\n".join(lines) + ) + + # --------------------------------------------------------------------------- # Turn input # --------------------------------------------------------------------------- @@ -266,6 +373,13 @@ class TurnInput: # must return ``True`` for approve / ``False`` for reject. If # ``None``, confirmation-gated tools auto-reject. confirm_tool: Callable[[ToolConfirmRequired], Awaitable[bool]] | None = None + # Latest ``ui/update-model-context`` payload from each open panel, as + # ``{"tool": name, "text": str | None, "structured": Any}``. Folded into + # this turn's user content so the model sees what the operator did in + # the frame -- without it, stopping a VM from the panel leaves the next + # turn still believing it runs, because the button's tools/call result + # went to the iframe rather than into the conversation. + app_context: list[dict[str, Any]] = field(default_factory=list) # --------------------------------------------------------------------------- @@ -339,6 +453,12 @@ def assemble_assistant_message( tc.status = event.status tc.preview = event.preview tc.duration_ms = event.duration_ms + if isinstance(event.ui, dict): + # Only the URI is persisted, not the payload behind it. A + # cluster snapshot is large and goes stale the moment the + # turn ends; reopening the panel from history refetches + # rather than replaying what the cluster looked like then. + tc.ui_resource_uri = event.ui.get("resourceUri") # UsageAccumulated / ToolConfirmRequired / ErrorEvent don't # contribute to the persisted message body. @@ -408,7 +528,7 @@ def _build_thinking_config(model: str, effort: str): ) @staticmethod - def _build_contents(history, user_text): + def _build_contents(history, user_text, app_context=None): from google.genai import types # type: ignore contents: list = [] for msg in history: @@ -422,6 +542,15 @@ def _build_contents(history, user_text): role="model", parts=[types.Part(text=msg.content)], )) + block = format_app_context(app_context) + if block: + # Its own user turn rather than a prefix on the operator's + # message: the operator did not write this, and a model that + # quotes their message back should not quote panel telemetry + # as if they had typed it. + contents.append(types.Content( + role="user", parts=[types.Part(text=block)], + )) contents.append(types.Content( role="user", parts=[types.Part(text=user_text)], @@ -484,7 +613,9 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: usage_total_output = 0 emitted_visible_text = False - contents = self._build_contents(turn.history, turn.user_text) + contents = self._build_contents( + turn.history, turn.user_text, turn.app_context, + ) thinking = self._build_thinking_config(turn.model, turn.effort) if turn.mcp_mode == "remote": @@ -526,10 +657,16 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: # ``session.call_tool()``, and feed the results back in a new # ``generate_content_stream`` call until no function_call remains. try: - from mcp.client.session import ClientSession # type: ignore from mcp.client.streamable_http import ( # type: ignore streamablehttp_client, ) + + from .mcp_bridge import ( + AppsClientSession, + call_result_to_wire, + ui_resource_uri, + ui_resource_uris_by_tool, + ) except ImportError as e: yield ErrorEvent(code="sdk_missing", message=str(e)) return @@ -552,7 +689,10 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: timeout=30, sse_read_timeout=300, ) as (read_stream, write_stream, _get_session_id): - async with ClientSession(read_stream, write_stream) as session: + # Apps-aware session: it declares io.modelcontextprotocol/ui at + # initialize, which is what lets the dashboard render the ui:// + # panels its own tools point at instead of showing their JSON. + async with AppsClientSession(read_stream, write_stream) as session: try: init_result = await session.initialize() except Exception as e: # noqa: BLE001 @@ -598,6 +738,11 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: ) return + # tool name -> ui:// panel, from each tool's _meta.ui. + # Read once per turn; the tool list does not change + # under us mid-turn. + panel_uris = ui_resource_uris_by_tool(mcp_tools) + function_decls = [ _mcp_tool_to_declaration(t, types) for t in mcp_tools ] @@ -854,6 +999,7 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: ) continue + ui: dict[str, Any] | None = None try: result = await session.call_tool(name, args) payload = _mcp_call_result_to_response(result) @@ -862,6 +1008,17 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: if getattr(result, "isError", False) else "ok" ) + # A result-level _meta.ui wins over the one + # the tool declared, per the Apps spec. + uri = ( + ui_resource_uri(getattr(result, "meta", None)) + or panel_uris.get(name) + ) + if uri and status == "ok": + ui = { + "resourceUri": uri, + "result": call_result_to_wire(result), + } except Exception as e: # noqa: BLE001 payload = {"error": str(e)} status = "error" @@ -871,6 +1028,7 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: id=fc_id, status=status, preview=_short_preview(payload), duration_ms=duration, + ui=ui, ) response_parts.append( @@ -1160,9 +1318,9 @@ def _classify_error(exc: BaseException, model: str) -> tuple[str, str]: ( f"Your Gemini key does not have access to {model} " "(Google allowlist required for preview models). Switch " - "to gemini-2.5-flash or gemini-2.5-pro via the dropdown " - "in the bottom-left — those are available on every AI " - "Studio key." + "to gemini-3.6-flash or gemini-3.5-flash-lite via the " + "dropdown in the bottom-left — those are GA and " + "available on every AI Studio key." ), ) return ( diff --git a/src/beaconmcp/dashboard/conversations.py b/src/beaconmcp/dashboard/conversations.py index 64aa015..d665a8d 100644 --- a/src/beaconmcp/dashboard/conversations.py +++ b/src/beaconmcp/dashboard/conversations.py @@ -18,15 +18,19 @@ VALID_EFFORTS = ("minimal", "low", "medium", "high") -# Stable Gemini 2.5 ship first (wider access), then Gemini 3 preview -# variants for users on the allowlist. +# GA models first (available on every AI Studio key), then the preview +# variant, which needs Google allowlist access. +# +# Retired here but still priced in ``usage.py``: gemini-2.5-flash, +# gemini-2.5-pro and gemini-3-flash-preview. Conversations sitting on one +# of those are moved forward by the schema-6 migration in ``db.py``; +# ``messages.model`` keeps naming whichever model actually wrote the reply. VALID_MODELS = ( - "gemini-2.5-flash", - "gemini-2.5-pro", - "gemini-3-flash-preview", + "gemini-3.6-flash", + "gemini-3.5-flash-lite", "gemini-3.1-pro-preview", ) -DEFAULT_MODEL = "gemini-2.5-flash" +DEFAULT_MODEL = "gemini-3.6-flash" DEFAULT_EFFORT = "low" @@ -52,6 +56,10 @@ class ToolCall: status: str = "pending" # pending | ok | error preview: str | None = None duration_ms: int | None = None + # ``ui://`` panel this tool declared, when it ships one (MCP Apps). + # Enough to re-open the frame from history; the snapshot behind it is + # deliberately not stored -- see assemble_assistant_message. + ui_resource_uri: str | None = None def to_json(self) -> dict[str, Any]: return asdict(self) @@ -110,6 +118,7 @@ def _decode_tool_calls(raw: str | None) -> list[ToolCall]: status=item.get("status", "ok"), preview=item.get("preview"), duration_ms=item.get("duration_ms"), + ui_resource_uri=item.get("ui_resource_uri"), ) ) return out diff --git a/src/beaconmcp/dashboard/db.py b/src/beaconmcp/dashboard/db.py index 7607758..8689d71 100644 --- a/src/beaconmcp/dashboard/db.py +++ b/src/beaconmcp/dashboard/db.py @@ -19,7 +19,7 @@ def db_path() -> Path: return Path(override) if override else DEFAULT_DB_PATH -_LATEST_VERSION = 4 +_LATEST_VERSION = 6 def _migrate(conn: sqlite3.Connection) -> None: @@ -49,7 +49,7 @@ def _migrate(conn: sqlite3.Connection) -> None: id TEXT PRIMARY KEY, client_id TEXT NOT NULL, title TEXT, - model TEXT NOT NULL DEFAULT 'gemini-3-flash-preview', + model TEXT NOT NULL DEFAULT 'gemini-3.6-flash', thinking_effort TEXT NOT NULL DEFAULT 'low', created_at REAL NOT NULL, updated_at REAL NOT NULL @@ -147,6 +147,51 @@ def _migrate(conn: sqlite3.Connection) -> None: """ ) + if version < 5: + # WebAuthn credentials, one row per registered passkey. The public + # key and signature counter are all we need to verify assertions; + # nothing here is secret (a public key is public), but the table + # still lives in the 0600 dashboard database because knowing which + # credentials a client owns is useful to an attacker. + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS passkeys ( + credential_id TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + public_key BLOB NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + transports TEXT, + label TEXT NOT NULL, + created_at REAL NOT NULL, + last_used_at REAL, + backed_up INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_passkeys_client + ON passkeys(client_id, created_at DESC); + """ + ) + + if version < 6: + # Gemini 2.5 and gemini-3-flash-preview left the model picker when + # 3.6 Flash and 3.5 Flash-Lite went GA. ``conversations.model`` is + # the model the *next* turn will use, so a conversation left on a + # retired id would fail validation and silently fall back; move it + # to the closest current model instead. + # + # ``messages.model`` is deliberately NOT rewritten: it records + # which model actually produced a reply, and that is history, not + # configuration. Migration 2 rewrote it because those were renames + # of the same model; these are substitutions. + for old, new in ( + ("gemini-2.5-flash", "gemini-3.6-flash"), + ("gemini-3-flash-preview", "gemini-3.6-flash"), + ("gemini-2.5-pro", "gemini-3.1-pro-preview"), + ): + conn.execute( + "UPDATE conversations SET model = ? WHERE model = ?", + (new, old), + ) + conn.execute(f"PRAGMA user_version = {_LATEST_VERSION}") conn.commit() diff --git a/src/beaconmcp/dashboard/mcp_bridge.py b/src/beaconmcp/dashboard/mcp_bridge.py new file mode 100644 index 0000000..648d8f3 --- /dev/null +++ b/src/beaconmcp/dashboard/mcp_bridge.py @@ -0,0 +1,212 @@ +"""MCP client plumbing shared by the chat engine and the MCP Apps host. + +The dashboard wears two hats. It is an MCP *client*: every turn opens a +``ClientSession`` against ``/mcp`` with the operator's bearer and runs the +tool loop. It is also an MCP Apps *host*: when a tool it just called points +at a ``ui://`` resource, it fetches that document and renders it in a +sandboxed iframe, then speaks the postMessage dialect to it. + +Both hats need the same three things, which is what lives here: a session +that declares the Apps extension, a way to read a ``ui://`` document, and +the ``_meta.ui.resourceUri`` lookup that ties a tool to its panel. + +**On the mcp 1.x pin.** A client announces Apps support through +``ClientCapabilities.extensions``, a field that only exists in mcp 2.0 -- +which is why this looked blocked behind the ``<2`` pin. It is not: +``ClientCapabilities`` is declared ``extra="allow"``, so the field rides +onto the wire under exactly the name 2.0 will emit, and the server reads +the same JSON either way. The pin costs us the typed attribute, not the +capability. +""" + +from __future__ import annotations + +import time +from contextlib import asynccontextmanager +from typing import Any, AsyncIterator + +from mcp import types +from mcp.client.session import ClientSession +from mcp.client.streamable_http import streamablehttp_client +from pydantic import AnyUrl + +# Extension identifier and content type from the MCP Apps spec (SEP-1865). +UI_EXTENSION_ID = "io.modelcontextprotocol/ui" +APP_MIME_TYPE = "text/html;profile=mcp-app" + +# postMessage dialect version the host speaks with the iframe. Matches the +# PROTOCOL_VERSION the panels' bridge.js sends in ui/initialize. +UI_PROTOCOL_VERSION = "2026-01-26" + +_UI_CAPABILITY: dict[str, Any] = {"mimeTypes": [APP_MIME_TYPE]} + + +class AppsClientSession(ClientSession): + """A ``ClientSession`` that declares the MCP Apps extension. + + ``ClientSession.initialize()`` builds its own ``ClientCapabilities`` and + takes no hook for extra fields, so rather than reimplement it -- version + negotiation, the ``notifications/initialized`` follow-up, the capability + bookkeeping -- we tag the outgoing ``InitializeRequest`` on its way past. + Everything else stays the SDK's business. + """ + + async def send_request(self, request, *args, **kwargs): # type: ignore[override] + root = getattr(request, "root", None) + if isinstance(root, types.InitializeRequest): + # extra="allow" on ClientCapabilities: the attribute lands in + # __pydantic_extra__ and serialises as a real field. + setattr( + root.params.capabilities, + "extensions", + {UI_EXTENSION_ID: dict(_UI_CAPABILITY)}, + ) + return await super().send_request(request, *args, **kwargs) + + +@asynccontextmanager +async def open_session( + mcp_url: str, + bearer: str, + *, + timeout: int = 30, + sse_read_timeout: int = 300, +) -> AsyncIterator[ClientSession]: + """Open an initialized Apps-aware session against ``mcp_url``. + + ``streamablehttp_client`` (no underscore) is the variant that threads + ``headers`` through every request; the other one drops the bearer and + the handshake 401s on loopback. Same reason as in ``chat.py``. + """ + async with streamablehttp_client( + mcp_url, + headers={"Authorization": f"Bearer {bearer}"}, + timeout=timeout, + sse_read_timeout=sse_read_timeout, + ) as (read_stream, write_stream, _session_id): + async with AppsClientSession(read_stream, write_stream) as session: + await session.initialize() + yield session + + +def ui_resource_uri(meta: Any) -> str | None: + """Return the ``ui://`` panel a ``_meta`` block points at, if any. + + Accepts the ``_meta`` of a tool declaration or of a ``CallToolResult``; + the spec puts the pointer in the same place on both, and a result-level + one wins when present. + """ + if not isinstance(meta, dict): + return None + ui = meta.get("ui") + if not isinstance(ui, dict): + return None + uri = ui.get("resourceUri") + if isinstance(uri, str) and uri.startswith("ui://"): + return uri + return None + + +def ui_resource_uris_by_tool(tools: Any) -> dict[str, str]: + """Map tool name -> panel URI over a ``list_tools()`` result.""" + out: dict[str, str] = {} + for tool in tools or []: + name = getattr(tool, "name", "") + uri = ui_resource_uri(getattr(tool, "meta", None)) + if name and uri: + out[name] = uri + return out + + +def call_result_to_wire(result: Any) -> dict[str, Any]: + """Flatten a ``CallToolResult`` into the JSON the iframe expects. + + ``ui/notifications/tool-result`` carries a standard ``CallToolResult``, + and the panels' ``unwrap()`` reads ``structuredContent`` first and falls + back to parsing ``content[0].text``. We keep both so a panel renders + whichever the tool happened to produce. + """ + content: list[dict[str, Any]] = [] + for item in getattr(result, "content", None) or []: + text = getattr(item, "text", None) + if text is not None: + content.append({"type": "text", "text": text}) + continue + mime = getattr(item, "mimeType", None) + content.append({ + "type": getattr(item, "type", "resource") or "resource", + "mimeType": mime or "application/octet-stream", + }) + wire: dict[str, Any] = { + "content": content, + "isError": bool(getattr(result, "isError", False)), + } + structured = getattr(result, "structuredContent", None) + if structured is not None: + wire["structuredContent"] = structured + return wire + + +class UiResourceError(Exception): + """A ``ui://`` read failed, or came back as something we won't frame.""" + + +async def read_ui_resource(session: ClientSession, uri: str) -> str: + """Read a ``ui://`` document, refusing anything that is not an app. + + The MIME check is the gate on the panel route: it is what stops the + endpoint from being used as a general resource proxy that renders, say, + ``beaconmcp://infrastructure`` as HTML inside the page. + """ + if not uri.startswith("ui://"): + raise UiResourceError("not a ui:// resource") + try: + result = await session.read_resource(AnyUrl(uri)) + except Exception as exc: # noqa: BLE001 + raise UiResourceError(str(exc)) from exc + + for item in getattr(result, "contents", None) or []: + text = getattr(item, "text", None) + if text is None: + continue + mime = (getattr(item, "mimeType", None) or "").replace(" ", "") + if mime != APP_MIME_TYPE: + raise UiResourceError(f"unexpected mime type {mime or '(none)'}") + return text + raise UiResourceError("resource has no text content") + + +# --------------------------------------------------------------------------- +# Document cache +# --------------------------------------------------------------------------- + +# Panel documents are static assets read off the server's disk, so a page +# with four panels on it should not mean four round-trips through the MCP +# handshake. Keyed by (mcp_url, uri) because one dashboard build can point +# at different servers across restarts; short TTL so a `beaconmcp update` +# that ships a new panel is picked up without a dashboard restart. +_CACHE_TTL_SECONDS = 300.0 +_CACHE_MAX_ENTRIES = 32 +_cache: dict[tuple[str, str], tuple[float, str]] = {} + + +def cache_get(mcp_url: str, uri: str) -> str | None: + entry = _cache.get((mcp_url, uri)) + if entry is None: + return None + stored_at, html = entry + if time.monotonic() - stored_at > _CACHE_TTL_SECONDS: + _cache.pop((mcp_url, uri), None) + return None + return html + + +def cache_put(mcp_url: str, uri: str, html: str) -> None: + if len(_cache) >= _CACHE_MAX_ENTRIES: + oldest = min(_cache, key=lambda k: _cache[k][0]) + _cache.pop(oldest, None) + _cache[(mcp_url, uri)] = (time.monotonic(), html) + + +def cache_clear() -> None: + _cache.clear() diff --git a/src/beaconmcp/dashboard/passkeys.py b/src/beaconmcp/dashboard/passkeys.py new file mode 100644 index 0000000..d80370f --- /dev/null +++ b/src/beaconmcp/dashboard/passkeys.py @@ -0,0 +1,598 @@ +"""Passkey (WebAuthn) support for the BeaconMCP login pages. + +A passkey stands in for the TOTP factor, never for the client secret. The +flow on both login pages stays two-factor: + +1. ``client_id`` + ``client_secret`` -- something you know / have stored. +2. Either a 6-digit TOTP code **or** a WebAuthn assertion -- something you + have on your device. + +That ordering is deliberate. The dashboard session record encrypts the +client secret so it can re-mint MCP bearers later, so a purely +"usernameless" passkey login could not build a usable session anyway. +Keeping the secret as the first factor also means a stolen passkey alone +is worthless. + +Credentials live in the dashboard SQLite database (``passkeys`` table); +challenges live in memory with a short TTL, since a challenge that +survives a restart is a replay window, not a feature. + +The ``webauthn`` package (py_webauthn) is an optional dependency: when it +is missing, :class:`PasskeyService` reports ``available is False`` and +every login page simply hides its passkey affordances instead of erroring. +""" + +from __future__ import annotations + +import base64 +import json +import secrets +import threading +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: # pragma: no cover - typing only + from starlette.requests import Request + + from .db import Database + +try: + import webauthn as _webauthn + from webauthn.helpers.structs import ( + AuthenticatorSelectionCriteria, + AuthenticatorTransport, + PublicKeyCredentialDescriptor, + ResidentKeyRequirement, + UserVerificationRequirement, + ) +except ImportError: # pragma: no cover - exercised only on slim installs + _webauthn = None # type: ignore[assignment] + AuthenticatorSelectionCriteria = None # type: ignore[assignment,misc] + AuthenticatorTransport = None # type: ignore[assignment,misc] + PublicKeyCredentialDescriptor = None # type: ignore[assignment,misc] + ResidentKeyRequirement = None # type: ignore[assignment,misc] + UserVerificationRequirement = None # type: ignore[assignment,misc] + + +#: How long a registration/authentication challenge stays usable. The spec +#: suggests a couple of minutes; the browser prompt itself times out at 60 s. +CHALLENGE_TTL_SECONDS = 180 + +#: Relying-party display name shown in the OS passkey prompt. +RP_NAME = "BeaconMCP" + +#: Cap on credentials per client. High enough for phone + laptop + a +#: hardware key or two, low enough that the allowCredentials list stays sane. +MAX_PASSKEYS_PER_CLIENT = 10 + + +class PasskeyError(Exception): + """User-facing passkey failure. The message is safe to render.""" + + +def webauthn_installed() -> bool: + """True when the optional ``webauthn`` dependency is importable.""" + return _webauthn is not None + + +def b64url_encode(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def b64url_decode(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + return base64.urlsafe_b64decode(value + padding) + + +# --------------------------------------------------------------------------- +# Relying-party identity, derived from the live request +# --------------------------------------------------------------------------- + +def _forwarded_host(request: "Request") -> str: + host = request.headers.get("x-forwarded-host") or request.headers.get("host") + if not host: + host = request.url.netloc or "localhost" + # A proxy chain may append entries; the first one is the client-facing host. + return host.split(",")[0].strip() + + +def rp_id_for(request: "Request") -> str: + """Return the WebAuthn RP ID (effective domain, no scheme, no port). + + Derived from the request rather than configured, so a deployment that + moves behind a new hostname keeps working without a config edit. The + trade-off is that credentials are scoped to the hostname they were + registered under -- which is exactly the WebAuthn security model. + """ + host = _forwarded_host(request) + if host.startswith("["): # IPv6 literal: [::1]:8420 + closing = host.find("]") + if closing != -1: + return host[1:closing] + return host.rsplit(":", 1)[0] if ":" in host else host + + +def origin_for(request: "Request") -> str: + """Return the origin the browser will put in ``clientDataJSON``.""" + scheme = request.headers.get("x-forwarded-proto") or request.url.scheme + scheme = scheme.split(",")[0].strip() + return f"{scheme}://{_forwarded_host(request)}" + + +def is_secure_context(request: "Request") -> bool: + """True when the browser will expose ``navigator.credentials``. + + WebAuthn is gated on a secure context: HTTPS, or a loopback host. A + plain-HTTP LAN deployment silently has no passkey API at all, so the + UI needs to know before it offers the button. + """ + scheme = ( + request.headers.get("x-forwarded-proto") or request.url.scheme + ).split(",")[0].strip() + if scheme == "https": + return True + return rp_id_for(request) in ("localhost", "127.0.0.1", "::1") + + +# --------------------------------------------------------------------------- +# Storage +# --------------------------------------------------------------------------- + +@dataclass +class PasskeyRecord: + credential_id: str # base64url + client_id: str + public_key: bytes + sign_count: int + transports: list[str] + label: str + created_at: float + last_used_at: float | None + backed_up: bool + + def to_json(self) -> dict[str, Any]: + return { + "credential_id": self.credential_id, + "label": self.label, + "created_at": self.created_at, + "last_used_at": self.last_used_at, + "backed_up": self.backed_up, + "transports": self.transports, + } + + +class PasskeyStore: + """Thin wrapper around the ``passkeys`` table.""" + + def __init__(self, database: "Database") -> None: + self._db = database + + @staticmethod + def _row_to_record(row: Any) -> PasskeyRecord: + try: + transports = json.loads(row["transports"] or "[]") + except (TypeError, ValueError): + transports = [] + return PasskeyRecord( + credential_id=row["credential_id"], + client_id=row["client_id"], + public_key=row["public_key"], + sign_count=row["sign_count"], + transports=[t for t in transports if isinstance(t, str)], + label=row["label"], + created_at=row["created_at"], + last_used_at=row["last_used_at"], + backed_up=bool(row["backed_up"]), + ) + + def add( + self, + *, + credential_id: str, + client_id: str, + public_key: bytes, + sign_count: int, + transports: list[str], + label: str, + backed_up: bool, + ) -> PasskeyRecord: + now = time.time() + self._db.conn().execute( + """ + INSERT INTO passkeys ( + credential_id, client_id, public_key, sign_count, + transports, label, created_at, last_used_at, backed_up + ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?) + """, + ( + credential_id, + client_id, + public_key, + sign_count, + json.dumps(transports), + label, + now, + 1 if backed_up else 0, + ), + ) + return PasskeyRecord( + credential_id=credential_id, + client_id=client_id, + public_key=public_key, + sign_count=sign_count, + transports=transports, + label=label, + created_at=now, + last_used_at=None, + backed_up=backed_up, + ) + + def list_for_client(self, client_id: str) -> list[PasskeyRecord]: + rows = self._db.conn().execute( + """ + SELECT credential_id, client_id, public_key, sign_count, transports, + label, created_at, last_used_at, backed_up + FROM passkeys WHERE client_id = ? + ORDER BY created_at DESC + """, + (client_id,), + ).fetchall() + return [self._row_to_record(r) for r in rows] + + def get(self, credential_id: str) -> PasskeyRecord | None: + row = self._db.conn().execute( + """ + SELECT credential_id, client_id, public_key, sign_count, transports, + label, created_at, last_used_at, backed_up + FROM passkeys WHERE credential_id = ? + """, + (credential_id,), + ).fetchone() + return self._row_to_record(row) if row is not None else None + + def count_for_client(self, client_id: str) -> int: + row = self._db.conn().execute( + "SELECT COUNT(*) AS n FROM passkeys WHERE client_id = ?", + (client_id,), + ).fetchone() + return int(row["n"]) if row is not None else 0 + + def touch(self, credential_id: str, sign_count: int) -> None: + self._db.conn().execute( + "UPDATE passkeys SET sign_count = ?, last_used_at = ? " + "WHERE credential_id = ?", + (sign_count, time.time(), credential_id), + ) + + def delete(self, credential_id: str, client_id: str) -> bool: + cur = self._db.conn().execute( + "DELETE FROM passkeys WHERE credential_id = ? AND client_id = ?", + (credential_id, client_id), + ) + return bool(cur.rowcount) + + def delete_all_for_client(self, client_id: str) -> int: + cur = self._db.conn().execute( + "DELETE FROM passkeys WHERE client_id = ?", (client_id,) + ) + return cur.rowcount or 0 + + +# --------------------------------------------------------------------------- +# Challenges +# --------------------------------------------------------------------------- + +@dataclass +class _Challenge: + purpose: str # "register" | "authenticate" + challenge: bytes + client_id: str + session_id: str | None + expires_at: float + + +class ChallengeStore: + """In-memory, single-use challenge store. + + Deliberately not persisted: a challenge that outlives the process is a + replay window. A restart mid-ceremony just makes the user click again. + """ + + def __init__(self, ttl_seconds: int = CHALLENGE_TTL_SECONDS) -> None: + self._ttl = ttl_seconds + self._items: dict[str, _Challenge] = {} + self._lock = threading.Lock() + + def issue( + self, + *, + purpose: str, + challenge: bytes, + client_id: str, + session_id: str | None = None, + ) -> str: + state = secrets.token_urlsafe(24) + with self._lock: + self._prune_locked() + self._items[state] = _Challenge( + purpose=purpose, + challenge=challenge, + client_id=client_id, + session_id=session_id, + expires_at=time.time() + self._ttl, + ) + return state + + def consume(self, state: str, purpose: str) -> _Challenge | None: + with self._lock: + self._prune_locked() + item = self._items.pop(state, None) + if item is None or item.purpose != purpose: + return None + if time.time() > item.expires_at: + return None + return item + + def _prune_locked(self) -> None: + now = time.time() + for key in [k for k, v in self._items.items() if now > v.expires_at]: + del self._items[key] + + +# --------------------------------------------------------------------------- +# Ceremony orchestration +# --------------------------------------------------------------------------- + +def default_label(user_agent: str) -> str: + """Best-effort human label for a freshly registered credential. + + Users never get asked to name their passkey during login -- one more + field in the middle of a sign-in is friction for no security gain -- so + we guess from the User-Agent and let them recognise it later. + """ + ua = (user_agent or "").lower() + if "iphone" in ua or "ipad" in ua or "ios" in ua: + return "iPhone / iPad" + if "android" in ua: + return "Android" + if "mac os" in ua or "macintosh" in ua: + return "Mac" + if "windows" in ua: + return "Windows" + if "linux" in ua: + return "Linux" + return "Passkey" + + +def _transport_descriptors(transports: list[str]) -> list[Any] | None: + """Map stored transport strings onto the library enum, dropping unknowns.""" + if not transports or AuthenticatorTransport is None: + return None + out = [] + for t in transports: + try: + out.append(AuthenticatorTransport(t)) + except ValueError: + continue + return out or None + + +class PasskeyService: + """Generates and verifies WebAuthn ceremonies against a :class:`PasskeyStore`.""" + + def __init__(self, store: PasskeyStore | None) -> None: + self._store = store + self._challenges = ChallengeStore() + + @property + def available(self) -> bool: + """True when passkeys can actually be used (library + storage present).""" + return _webauthn is not None and self._store is not None + + @property + def unavailable_reason(self) -> str | None: + """Why passkeys are off, or ``None`` when they work. + + The login pages hide their passkey affordances silently -- there is + nothing an anonymous visitor could do about it anyway -- so the + operator needs to be able to ask the question somewhere. This backs + the startup banner and ``beaconmcp doctor``. + """ + if _webauthn is None: + return ( + "the 'webauthn' Python package is missing " + "(pip install 'webauthn>=2,<4', or reinstall BeaconMCP)" + ) + if self._store is None: + return "no writable dashboard database to store credentials in" + return None + + @property + def store(self) -> PasskeyStore: + if self._store is None: + raise PasskeyError("Passkeys are not available on this deployment.") + return self._store + + def _require(self) -> None: + if _webauthn is None: + raise PasskeyError( + "Passkey support requires the 'webauthn' package. " + "Reinstall BeaconMCP to pull it in." + ) + if self._store is None: + raise PasskeyError("Passkeys are not available on this deployment.") + + # --- registration ---------------------------------------------------- + + def registration_options( + self, + request: "Request", + *, + client_id: str, + client_name: str, + session_id: str | None = None, + ) -> tuple[dict[str, Any], str]: + """Return ``(options_dict, state)`` for ``navigator.credentials.create``.""" + self._require() + existing = self.store.list_for_client(client_id) + if len(existing) >= MAX_PASSKEYS_PER_CLIENT: + raise PasskeyError( + f"This client already has {MAX_PASSKEYS_PER_CLIENT} passkeys. " + "Remove one before adding another." + ) + options = _webauthn.generate_registration_options( + rp_id=rp_id_for(request), + rp_name=RP_NAME, + user_id=client_id.encode("utf-8"), + user_name=client_id, + user_display_name=client_name or client_id, + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.PREFERRED, + user_verification=UserVerificationRequirement.PREFERRED, + ), + # Stops the authenticator offering to overwrite a passkey the + # client already registered on this device. + exclude_credentials=[ + PublicKeyCredentialDescriptor( + id=b64url_decode(p.credential_id), + transports=_transport_descriptors(p.transports), + ) + for p in existing + ], + ) + state = self._challenges.issue( + purpose="register", + challenge=options.challenge, + client_id=client_id, + session_id=session_id, + ) + return json.loads(_webauthn.options_to_json(options)), state + + def verify_registration( + self, + request: "Request", + *, + state: str, + credential: dict[str, Any], + label: str | None = None, + session_id: str | None = None, + ) -> PasskeyRecord: + """Verify a ``navigator.credentials.create`` result and persist it.""" + self._require() + pending = self._challenges.consume(state, "register") + if pending is None: + raise PasskeyError( + "This passkey request expired. Start the registration again." + ) + if pending.session_id is not None and pending.session_id != session_id: + raise PasskeyError("This passkey request belongs to another session.") + + try: + verified = _webauthn.verify_registration_response( + credential=credential, + expected_challenge=pending.challenge, + expected_rp_id=rp_id_for(request), + expected_origin=origin_for(request), + ) + except Exception as exc: # noqa: BLE001 - library raises many subtypes + raise PasskeyError(f"Passkey registration rejected: {exc}") from exc + + credential_id = b64url_encode(verified.credential_id) + if self.store.get(credential_id) is not None: + raise PasskeyError("This passkey is already registered.") + + transports = [] + raw_transports = (credential.get("response") or {}).get("transports") + if isinstance(raw_transports, list): + transports = [t for t in raw_transports if isinstance(t, str)] + + return self.store.add( + credential_id=credential_id, + client_id=pending.client_id, + public_key=verified.credential_public_key, + sign_count=verified.sign_count, + transports=transports, + label=(label or "").strip()[:60] + or default_label(request.headers.get("user-agent", "")), + backed_up=bool(getattr(verified, "credential_backed_up", False)), + ) + + # --- authentication -------------------------------------------------- + + def authentication_options( + self, request: "Request", *, client_id: str, + ) -> tuple[dict[str, Any], str]: + """Return ``(options_dict, state)`` for ``navigator.credentials.get``.""" + self._require() + credentials = self.store.list_for_client(client_id) + if not credentials: + raise PasskeyError( + "No passkey is registered for this client. Sign in with your " + "2FA code once, then add one." + ) + options = _webauthn.generate_authentication_options( + rp_id=rp_id_for(request), + allow_credentials=[ + PublicKeyCredentialDescriptor( + id=b64url_decode(p.credential_id), + transports=_transport_descriptors(p.transports), + ) + for p in credentials + ], + user_verification=UserVerificationRequirement.PREFERRED, + ) + state = self._challenges.issue( + purpose="authenticate", + challenge=options.challenge, + client_id=client_id, + ) + return json.loads(_webauthn.options_to_json(options)), state + + def verify_authentication( + self, request: "Request", *, state: str, credential: dict[str, Any], + ) -> PasskeyRecord: + """Verify a ``navigator.credentials.get`` result. + + Returns the credential that signed the challenge. The caller is + responsible for turning that into a session -- this module never + touches bearers or cookies. + """ + self._require() + pending = self._challenges.consume(state, "authenticate") + if pending is None: + raise PasskeyError( + "This passkey request expired. Try signing in again." + ) + + raw_id = credential.get("id") or credential.get("rawId") + if not isinstance(raw_id, str) or not raw_id: + raise PasskeyError("Malformed passkey response.") + record = self.store.get(raw_id) + if record is None: + raise PasskeyError("Unknown passkey.") + # The challenge was issued for one client; a credential belonging to + # another must never satisfy it, even if both are valid on their own. + if record.client_id != pending.client_id: + raise PasskeyError("This passkey belongs to a different client.") + + try: + verified = _webauthn.verify_authentication_response( + credential=credential, + expected_challenge=pending.challenge, + expected_rp_id=rp_id_for(request), + expected_origin=origin_for(request), + credential_public_key=record.public_key, + credential_current_sign_count=record.sign_count, + ) + except Exception as exc: # noqa: BLE001 - library raises many subtypes + # This also covers the clone signal: py_webauthn refuses any + # assertion whose signature counter did not move forward, as + # long as either side maintains one (platform authenticators + # commonly pin it at 0, and that stays legal). + raise PasskeyError(f"Passkey rejected: {exc}") from exc + + self.store.touch(record.credential_id, verified.new_sign_count) + record.sign_count = verified.new_sign_count + record.last_used_at = time.time() + return record diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index a9020c4..2c14ba5 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -762,6 +762,96 @@ body.chat-page { } .tool-card.open .tool-chev { transform: rotate(180deg); } +/* MCP Apps panels ------------------------------------------------- */ + +.panel { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-elev); + overflow: hidden; + animation: tool-in 350ms var(--ease-out) both; +} + +.panel-head { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px 8px 12px; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-soft); +} +.panel-icon { display: flex; color: var(--accent); } +.panel-name { + font-family: var(--font-mono); + font-size: 12px; + color: var(--fg-mid); +} +.panel-spacer { flex: 1; } + +.panel-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--fg-faint); + cursor: pointer; + transition: color 150ms var(--ease-out), background 150ms var(--ease-out); +} +.panel-btn:hover { color: var(--fg); background: var(--bg-softer); } + +.panel-frame { display: block; } +.panel-iframe { + display: block; + width: 100%; + border: 0; + background: var(--bg-elev); + transition: height 200ms var(--ease-out); +} + +.panel-open { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 18px 12px; + border: 0; + background: transparent; + color: var(--fg-muted); + font: inherit; + font-size: 13px; + cursor: pointer; +} +.panel-open:hover { background: var(--bg-soft); color: var(--fg); } + +.panel-note { + padding: 8px 12px; + border-top: 1px solid var(--border-subtle); + font-size: 12px; + color: var(--fg-mid); +} +.panel-note[data-kind="error"] { color: var(--danger); background: var(--danger-soft); } +.panel-note[data-kind="warn"] { color: var(--warn); background: var(--warn-soft); } + +/* Fullscreen is a host decision, so it is the host that resizes -- the + panel only asks via ui/request-display-mode. */ +.panel[data-mode="fullscreen"] { + position: fixed; + inset: 16px; + z-index: 60; + display: flex; + flex-direction: column; + border-radius: var(--radius); + box-shadow: var(--shadow-lg); +} +.panel[data-mode="fullscreen"] .panel-frame { flex: 1; min-height: 0; } +.panel[data-mode="fullscreen"] .panel-iframe { height: 100%; } +body.panel-fullscreen { overflow: hidden; } + /* Confirmation card ---------------------------------------------- */ .confirm-card { @@ -2168,3 +2258,365 @@ a.cta:hover { background: var(--accent-hover); } /* Shared icon sprite placement */ .icon-sprite { position: absolute; width: 0; height: 0; } + +/* =============================================================== + PASSKEYS + POST-2FA SCREEN (login.html) + =============================================================== */ + +/* Shimmer: a highlight sweeping across the button while a request is in + flight. Prefers a real animation over a spinner so the button keeps its + size and the label can carry the status text. */ +.btn-primary.is-loading, +.btn-ghost.is-loading { + position: relative; + overflow: hidden; + cursor: progress; + opacity: 1; +} +.btn-primary.is-loading::after, +.btn-ghost.is-loading::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 100deg, + transparent 20%, + rgba(255, 255, 255, 0.38) 50%, + transparent 80% + ); + transform: translateX(-100%); + animation: shimmer-sweep 1150ms var(--ease-out) infinite; + pointer-events: none; +} +.btn-ghost.is-loading::after { + background: linear-gradient( + 100deg, + transparent 20%, + var(--accent-soft) 50%, + transparent 80% + ); +} +@keyframes shimmer-sweep { + to { transform: translateX(100%); } +} +/* The trailing chevron/check is noise once the button says "Verifying…". */ +.btn-primary.is-loading .btn-icon { opacity: 0; } +@media (prefers-reduced-motion: reduce) { + .btn-primary.is-loading::after, + .btn-ghost.is-loading::after { animation: none; opacity: 0.25; } +} + +.btn-ghost { + width: 100%; + padding: 11px 16px; + border: 1px solid var(--border-strong); + border-radius: 10px; + background: var(--bg-soft); + color: var(--fg); + font-family: var(--font); font-weight: 550; font-size: 14px; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + transition: border-color 160ms var(--ease-out), background 160ms var(--ease-out), + color 160ms var(--ease-out); +} +.btn-ghost:hover:not(:disabled) { + border-color: var(--accent-border); + background: var(--accent-softer); + color: var(--fg); +} +.btn-ghost:disabled { opacity: 0.55; cursor: not-allowed; } +.btn-ghost svg { flex-shrink: 0; } + +.alt-divider { + display: flex; align-items: center; gap: 12px; + margin: 18px 0 14px; + color: var(--fg-faint); + font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; +} +.alt-divider::before, +.alt-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} + +.auth-card .hint { + margin: 10px 0 0; + font-size: 12.5px; + line-height: 1.5; + color: var(--fg-muted); + text-align: center; +} + +.success-mark { + width: 42px; height: 42px; + border-radius: 50%; + display: grid; place-items: center; + margin-bottom: 16px; + color: var(--success); + background: var(--success-soft); + border: 1px solid color-mix(in oklab, var(--success) 35%, var(--border)); + animation: pop-in 320ms var(--ease-out) both; +} +@keyframes pop-in { + from { opacity: 0; transform: scale(0.82); } + to { opacity: 1; transform: scale(1); } +} + +.expiry-card { + margin: 4px 0 0; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-soft); +} +.expiry-row { + display: flex; align-items: baseline; justify-content: space-between; + gap: 12px; + padding: 5px 0; +} +.expiry-row + .expiry-row { border-top: 1px solid var(--border-subtle); } +.expiry-row dt { + font-size: 12.5px; + color: var(--fg-muted); +} +.expiry-row dd { + margin: 0; + font-size: 13px; font-weight: 550; + color: var(--fg); + text-align: right; +} + +#passkey-add-block { margin-top: 18px; } +#step-3 .btn-primary { margin-top: 20px; } +#step-3 .banner { margin-bottom: 14px; } + +/* Registered passkeys list (dashboard "API tokens" page) */ +.passkey-row { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; + padding: 10px 0; +} +.passkey-row + .passkey-row { border-top: 1px solid var(--border-subtle); } +.passkey-row .passkey-meta { min-width: 0; } +.passkey-row .passkey-name { + font-size: 13.5px; font-weight: 550; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.passkey-row .passkey-sub { font-size: 12px; color: var(--fg-muted); } + +/* =============================================================== + UPDATE TOAST (every /app page, signed-in only) + =============================================================== */ + +.update-toast { + position: fixed; + right: 18px; bottom: 18px; + z-index: 60; + width: min(380px, calc(100vw - 36px)); + max-height: min(72vh, 640px); + overflow-y: auto; + padding: 16px 18px; + background: var(--bg-elev); + border: 1px solid var(--accent-border); + border-radius: 14px; + box-shadow: var(--shadow-lg, 0 8px 32px rgba(20,14,8,0.16)); + font-size: 13px; + animation: ut-rise 320ms var(--ease-out) both; +} +@keyframes ut-rise { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.update-toast .ut-head { + display: flex; align-items: center; gap: 8px; + font-size: 14px; +} +.update-toast .ut-dot { + width: 7px; height: 7px; border-radius: 50%; + background: var(--accent); + flex-shrink: 0; + box-shadow: 0 0 0 3px var(--accent-soft); +} +.update-toast .ut-x { + margin-left: auto; + background: transparent; border: 0; padding: 2px; + color: var(--fg-faint); cursor: pointer; line-height: 0; + border-radius: 6px; +} +.update-toast .ut-x:hover { color: var(--fg); background: var(--bg-soft); } + +.update-toast .ut-sub { + margin: 6px 0 12px; + color: var(--fg-muted); +} +.update-toast code { + font-family: var(--font-mono); + font-size: 11.5px; + background: var(--bg-soft); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 1px 4px; +} + +.update-toast .ut-log { + list-style: none; + margin: 0 0 12px; padding: 0; + display: grid; gap: 4px; +} +.update-toast .ut-log li { + color: var(--fg-mid); + font-size: 12.5px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.update-toast .ut-log .ut-more { color: var(--fg-faint); } + +.update-toast .ut-warn { + margin: 0 0 12px; + padding: 9px 11px; + border-radius: 9px; + background: var(--danger-soft); + border: 1px solid color-mix(in oklab, var(--danger) 30%, var(--border)); + color: var(--fg); + display: grid; gap: 4px; + font-size: 12.5px; +} +.update-toast .ut-warn strong { color: var(--danger); } + +.update-toast .ut-steps-head { + display: flex; align-items: center; gap: 8px; + font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.05em; + color: var(--fg-faint); + margin-bottom: 6px; +} +.update-toast .ut-copy { + margin-left: auto; + background: transparent; + border: 1px solid var(--border-strong); + border-radius: 6px; + padding: 2px 8px; + font: 500 11px var(--font); + color: var(--fg-mid); cursor: pointer; + text-transform: none; letter-spacing: 0; +} +.update-toast .ut-copy:hover { color: var(--fg); border-color: var(--accent-border); } + +.update-toast .ut-steps { + margin: 0 0 12px; + padding: 10px 12px; + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: 9px; + overflow-x: auto; +} +.update-toast .ut-steps code { + background: transparent; border: 0; padding: 0; + white-space: pre; + font-size: 11.5px; + color: var(--fg-mid); +} + +.update-toast .ut-block { + margin: 0 0 12px; + font-size: 12px; + color: var(--fg-muted); +} + +.update-toast .ut-actions { + display: flex; align-items: center; gap: 10px; +} +.update-toast .ut-primary { + padding: 8px 14px; + border: 0; border-radius: 8px; + background: var(--accent); color: var(--accent-fg); + font: 600 13px var(--font); + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; + position: relative; overflow: hidden; +} +.update-toast .ut-primary:hover:not(:disabled) { background: var(--accent-hover); } +.update-toast .ut-primary:disabled { opacity: 0.75; cursor: progress; } +.update-toast .ut-primary.is-loading::after { + content: ""; + position: absolute; inset: 0; + background: linear-gradient(100deg, transparent 20%, rgba(255,255,255,0.38) 50%, transparent 80%); + transform: translateX(-100%); + animation: shimmer-sweep 1150ms var(--ease-out) infinite; +} +.update-toast .ut-link { + color: var(--fg-mid); font-size: 12.5px; text-decoration: none; + border-bottom: 1px solid var(--border-strong); +} +.update-toast .ut-link:hover { color: var(--accent); border-color: var(--accent-border); } + +.update-toast .ut-result { margin-top: 12px; } +.update-toast .ut-label { + display: block; + font-size: 12px; color: var(--fg-mid); margin-bottom: 6px; +} +.update-toast .ut-totp-row { display: flex; gap: 8px; } +.update-toast .ut-totp-row input { + flex: 1; min-width: 0; + padding: 8px 10px; + background: var(--bg-soft); + border: 1px solid var(--border-strong); + border-radius: 8px; + color: var(--fg); + font-family: var(--font-mono); + letter-spacing: 0.18em; + outline: none; +} +.update-toast .ut-totp-row input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} +.update-toast .ut-note { + margin: 8px 0 0; + font-size: 11.5px; line-height: 1.5; color: var(--fg-muted); +} +.update-toast .ut-ok, +.update-toast .ut-err { + padding: 9px 11px; + border-radius: 9px; + font-size: 12.5px; + line-height: 1.5; +} +.update-toast .ut-ok { + background: var(--success-soft); + border: 1px solid color-mix(in oklab, var(--success) 32%, var(--border)); +} +.update-toast .ut-err { + background: var(--danger-soft); + border: 1px solid color-mix(in oklab, var(--danger) 32%, var(--border)); +} + +@media (max-width: 560px) { + .update-toast { right: 10px; left: 10px; bottom: 10px; width: auto; } +} +@media (prefers-reduced-motion: reduce) { + .update-toast { animation: none; } + .update-toast .ut-primary.is-loading::after { animation: none; opacity: 0.25; } +} + +/* One-line update mention on the post-2FA screen. The full toast opts out + of the auth pages (see update_banner.js) so it can't sit on top of + "Finish signing in" on a narrow viewport. */ +.auth-card .update-note { + display: block; + margin-top: 18px; + padding: 9px 12px; + border: 1px solid var(--accent-border); + border-radius: 9px; + background: var(--accent-softer); + color: var(--fg-mid); + font-size: 12.5px; + line-height: 1.45; + text-decoration: none; + transition: background 160ms var(--ease-out), color 160ms var(--ease-out); +} +.auth-card .update-note:hover { + background: var(--accent-soft); + color: var(--fg); +} diff --git a/src/beaconmcp/dashboard/static/chat.js b/src/beaconmcp/dashboard/static/chat.js index d7dcc45..9b13eea 100644 --- a/src/beaconmcp/dashboard/static/chat.js +++ b/src/beaconmcp/dashboard/static/chat.js @@ -14,17 +14,25 @@ const VALID_EFFORTS = JSON.parse(root.dataset.validEfforts || "[]"); // --- Model catalog (mirrors src/beaconmcp/dashboard/conversations.py) --- const MODEL_CATALOG = { + "gemini-3.6-flash": { + shortName: "3.6 Flash", name: "Gemini 3.6 Flash", group: "Flash", preview: false, + }, + "gemini-3.5-flash-lite": { + shortName: "3.5 Lite", name: "Gemini 3.5 Flash-Lite", group: "Flash", preview: false, + }, + "gemini-3.1-pro-preview": { + shortName: "3.1 Pro", name: "Gemini 3.1 Pro", group: "Pro", preview: true, + }, + // Retired from the picker but still rendered on stored messages, which + // record whichever model actually wrote the reply. "gemini-2.5-flash": { - shortName: "2.5 Flash", name: "Gemini 2.5 Flash", group: "Gemini 2", preview: false, + shortName: "2.5 Flash", name: "Gemini 2.5 Flash", group: "Legacy", preview: false, }, "gemini-2.5-pro": { - shortName: "2.5 Pro", name: "Gemini 2.5 Pro", group: "Gemini 2", preview: false, + shortName: "2.5 Pro", name: "Gemini 2.5 Pro", group: "Legacy", preview: false, }, "gemini-3-flash-preview": { - shortName: "3 Flash", name: "Gemini 3 Flash", group: "Gemini 3", preview: true, - }, - "gemini-3.1-pro-preview": { - shortName: "3.1 Pro", name: "Gemini 3.1 Pro", group: "Gemini 3", preview: true, + shortName: "3 Flash", name: "Gemini 3 Flash", group: "Legacy", preview: true, }, }; @@ -300,6 +308,10 @@ const state = { abortController: null, model: DEFAULT_MODEL, effort: DEFAULT_EFFORT, + // Latest ui/update-model-context from each open panel, keyed by tool-call + // id so a panel that pushes twice overwrites itself rather than piling up. + // Drained into the next turn, then cleared. + appContext: new Map(), }; const el = { @@ -567,6 +579,7 @@ function toolNeedsConfirm(name, args) { } function renderMessages() { + destroyAllPanels("conversation re-rendered"); el.messages.innerHTML = ""; if (!state.messages.length) { el.messages.append(renderEmptyState()); @@ -609,6 +622,7 @@ function renderMessage(m) { const card = renderToolCard(tc, m.id); toolCardMap.set(tc.id, card); row.append(card); + if (tc.ui_resource_uri) attachPanel(card, tc, null); } if (!m.streaming) { @@ -774,6 +788,426 @@ function scrollToBottom() { el.messages.scrollTop = el.messages.scrollHeight; } +// ---------------- MCP Apps host ---------------- +// +// A tool that carries _meta.ui.resourceUri gets its ui:// document rendered +// here instead of its JSON. The document is served by /app/api/mcp/panel and +// framed with sandbox="allow-scripts" and NO allow-same-origin, so it runs on +// an opaque origin: no cookies, no CSRF token, no reach into this page. Its +// only way out is postMessage to us, which is why every policy decision -- +// which tools it may call, whether it may talk to the model -- is made on +// this side of the boundary and never inside the frame. +// +// The dialect is JSON-RPC 2.0 (MCP Apps, SEP-1865). We implement the host +// half by hand rather than porting the SDK's AppBridge: it is TypeScript and +// this dashboard ships JS with no build step. + +const UI_PROTOCOL_VERSION = "2026-01-26"; +const HOST_INFO = { name: "beaconmcp-dashboard", version: "1.0.0" }; +const PANEL_MAX_HEIGHT = 640; +const PANEL_MIN_HEIGHT = 140; + +const panels = new Set(); + +function currentTheme() { + return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light"; +} + +// The panels are told our palette under the spec's standardized names, so +// they sit inside the dashboard's theme rather than next to it. Computed +// values, not var() references: the frame cannot resolve our variables. +function hostStyleVariables() { + const cs = getComputedStyle(document.documentElement); + const v = (name) => cs.getPropertyValue(name).trim(); + const map = { + "--color-background-primary": v("--bg-elev") || v("--bg"), + "--color-background-secondary": v("--bg-soft"), + "--color-background-tertiary": v("--bg-softer"), + "--color-text-primary": v("--fg"), + "--color-text-secondary": v("--fg-mid"), + "--color-text-tertiary": v("--fg-muted"), + "--color-text-danger": v("--danger"), + "--color-text-success": v("--success"), + "--color-text-warning": v("--warn"), + "--color-border-primary": v("--border"), + "--color-border-secondary": v("--border-subtle"), + "--color-ring-primary": v("--accent"), + "--font-sans": v("--font"), + "--font-mono": v("--font-mono"), + "--border-radius-sm": v("--radius-sm"), + "--border-radius-md": v("--radius"), + }; + for (const key of Object.keys(map)) if (!map[key]) delete map[key]; + return map; +} + +function hostContext(panel) { + return { + theme: currentTheme(), + styles: { variables: hostStyleVariables() }, + displayMode: panel.displayMode, + availableDisplayModes: ["inline", "fullscreen"], + containerDimensions: { maxHeight: PANEL_MAX_HEIGHT }, + locale: navigator.language || "en-US", + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + userAgent: HOST_INFO.name, + platform: "web", + deviceCapabilities: { + touch: matchMedia("(pointer: coarse)").matches, + hover: matchMedia("(hover: hover)").matches, + }, + toolInfo: { tool: { name: panel.toolName } }, + }; +} + +// Capabilities we actually implement. Anything absent here is a method the +// panels are expected to hide rather than call -- ui/open-link for instance +// is not advertised, and the relay rejects it. +function hostCapabilities() { + return { + serverTools: {}, + updateModelContext: { text: {}, structuredContent: {} }, + message: { text: {} }, + }; +} + +async function relayToolCall(name, args) { + const res = await fetch("/app/api/mcp/call", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "X-CSRF-Token": csrfToken(), + }, + body: JSON.stringify({ name, arguments: args || {} }), + credentials: "same-origin", + }); + if (res.status === 401) { + window.location.href = "/app/refresh?next=/app/chat"; + throw new Error("unauthorized"); + } + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const err = new Error(data.message || data.error || `tool call failed (${res.status})`); + err.refused = data.error === "confirmation_required"; + throw err; + } + return data.result; +} + +function createPanel({ callId, toolName, toolArgs, uri, result }) { + const panel = { + callId, + toolName, + toolArgs: toolArgs || {}, + uri, + result: result || null, + displayMode: "inline", + frame: null, + ready: false, + root: null, + note: null, + }; + + const note = h("div", { class: "panel-note", hidden: true }); + const wrap = h("div", { class: "panel-frame" }); + + const fullscreenBtn = h("button", { + type: "button", class: "panel-btn", "aria-label": "Toggle fullscreen", + onClick: () => setDisplayMode(panel, panel.displayMode === "fullscreen" ? "inline" : "fullscreen"), + }, [icon("expand", 13)]); + + const closeBtn = h("button", { + type: "button", class: "panel-btn", "aria-label": "Close panel", + onClick: () => destroyPanel(panel, "closed by user"), + }, [icon("x", 13)]); + + const root = h("div", { class: "panel", dataset: { mode: "inline" } }, [ + h("div", { class: "panel-head" }, [ + h("div", { class: "panel-icon" }, [icon("gauge", 13)]), + h("span", { class: "panel-name", text: toolName }), + h("span", { class: "panel-spacer" }), + fullscreenBtn, + closeBtn, + ]), + wrap, + note, + ]); + + panel.root = root; + panel.note = note; + panel.wrap = wrap; + return panel; +} + +function panelSetNote(panel, message, kind) { + if (!message) { + panel.note.hidden = true; + panel.note.textContent = ""; + return; + } + panel.note.hidden = false; + panel.note.textContent = message; + panel.note.dataset.kind = kind || "info"; +} + +function mountPanelFrame(panel) { + if (panel.frame) return; + const frame = h("iframe", { + class: "panel-iframe", + // No allow-same-origin: the document must stay on an opaque origin. + // Adding it would let the frame read this page's cookies and call the + // dashboard API directly, which is precisely what the relay exists to + // prevent. + sandbox: "allow-scripts allow-forms", + referrerpolicy: "no-referrer", + title: `${panel.toolName} panel`, + src: `/app/api/mcp/panel?uri=${encodeURIComponent(panel.uri)}`, + style: { height: `${PANEL_MIN_HEIGHT}px` }, + }); + frame.addEventListener("error", () => { + panelSetNote(panel, "The panel document could not be loaded.", "error"); + }); + panel.frame = frame; + panel.wrap.innerHTML = ""; + panel.wrap.append(frame); + panels.add(panel); +} + +function destroyPanel(panel, reason) { + if (panel.frame && panel.ready) { + // Best effort: the frame is about to go away, so we do not wait for the + // acknowledgement the spec allows it to send. + postToPanel(panel, { + jsonrpc: "2.0", id: `teardown-${panel.callId}`, + method: "ui/resource-teardown", params: { reason: reason || "teardown" }, + }); + } + panels.delete(panel); + panel.ready = false; + panel.frame = null; + if (panel.displayMode === "fullscreen") document.body.classList.remove("panel-fullscreen"); + panel.root.remove(); +} + +function destroyAllPanels(reason) { + for (const panel of [...panels]) destroyPanel(panel, reason || "conversation changed"); +} + +function setDisplayMode(panel, mode) { + const next = mode === "fullscreen" ? "fullscreen" : "inline"; + panel.displayMode = next; + panel.root.dataset.mode = next; + document.body.classList.toggle("panel-fullscreen", next === "fullscreen"); + if (next === "inline" && panel.frame) { + panel.frame.style.height = `${panel.lastHeight || PANEL_MIN_HEIGHT}px`; + } else if (panel.frame) { + panel.frame.style.height = ""; + } + notifyPanel(panel, "ui/notifications/host-context-changed", { displayMode: next }); + return next; +} + +function postToPanel(panel, message) { + if (!panel.frame || !panel.frame.contentWindow) return; + // "*" because the frame is sandboxed onto an opaque origin, which cannot + // be named as a targetOrigin. Confidentiality comes from the frame being + // a document we served and nobody else being able to receive this. + panel.frame.contentWindow.postMessage(message, "*"); +} + +function notifyPanel(panel, method, params) { + if (!panel.ready) return; + postToPanel(panel, { jsonrpc: "2.0", method, params }); +} + +function replyToPanel(panel, id, result) { + postToPanel(panel, { jsonrpc: "2.0", id, result }); +} + +function replyErrorToPanel(panel, id, message, code) { + postToPanel(panel, { + jsonrpc: "2.0", id, error: { code: code || -32000, message }, + }); +} + +// Push the tool result the panel renders from. On a live turn we already +// have it; reopening a panel from history refetches instead, because the +// snapshot a panel was built on is stale by then and showing month-old CPU +// figures in a live-looking dashboard is worse than a short spinner. +async function deliverToolResult(panel) { + notifyPanel(panel, "ui/notifications/tool-input", { arguments: panel.toolArgs }); + let result = panel.result; + if (!result) { + try { + result = await relayToolCall(panel.toolName, panel.toolArgs); + panel.result = result; + } catch (err) { + notifyPanel(panel, "ui/notifications/tool-cancelled", { + reason: err.message || "could not refresh the panel", + }); + panelSetNote(panel, err.message || "Could not refresh the panel.", "error"); + return; + } + } + notifyPanel(panel, "ui/notifications/tool-result", result); +} + +async function handlePanelRequest(panel, msg) { + const { id, method, params } = msg; + switch (method) { + case "ui/initialize": { + panel.ready = true; + replyToPanel(panel, id, { + protocolVersion: UI_PROTOCOL_VERSION, + hostInfo: HOST_INFO, + hostCapabilities: hostCapabilities(), + hostContext: hostContext(panel), + }); + return; + } + case "ping": + replyToPanel(panel, id, {}); + return; + case "tools/call": { + const name = params && params.name; + const args = (params && params.arguments) || {}; + if (!name) { + replyErrorToPanel(panel, id, "tools/call needs a name", -32602); + return; + } + try { + replyToPanel(panel, id, await relayToolCall(name, args)); + } catch (err) { + if (err.refused) panelSetNote(panel, err.message, "warn"); + replyErrorToPanel(panel, id, err.message || "tool call failed"); + } + return; + } + case "ui/update-model-context": { + const text = (params && Array.isArray(params.content)) + ? params.content.filter((c) => c && c.type === "text").map((c) => c.text).join("\n") + : null; + state.appContext.set(panel.callId, { + tool: panel.toolName, + text: text || null, + structured: (params && params.structuredContent) ?? null, + }); + replyToPanel(panel, id, {}); + return; + } + case "ui/message": { + const content = params && params.content; + const blocks = Array.isArray(content) ? content : [content]; + const text = blocks + .filter((c) => c && c.type === "text" && typeof c.text === "string") + .map((c) => c.text).join("\n").trim(); + if (!text) { + replyErrorToPanel(panel, id, "Invalid message format", -32602); + return; + } + if (state.streaming) { + replyErrorToPanel(panel, id, "A turn is already running. Try again once it finishes."); + return; + } + replyToPanel(panel, id, {}); + // The panel asked the model to do something. It goes in as an ordinary + // user turn, which is what puts anything dangerous it leads to back + // under the approval modal. + sendUserText(text).catch((err) => console.error(err)); + return; + } + case "ui/request-display-mode": { + const mode = setDisplayMode(panel, params && params.mode); + replyToPanel(panel, id, { mode }); + return; + } + default: + replyErrorToPanel(panel, id, `Unsupported method: ${method}`, -32601); + } +} + +function handlePanelNotification(panel, msg) { + switch (msg.method) { + case "ui/notifications/initialized": + deliverToolResult(panel).catch((err) => console.error(err)); + break; + case "ui/notifications/size-changed": { + const height = Number(msg.params && msg.params.height) || 0; + if (!panel.frame || !height) break; + panel.lastHeight = Math.min(PANEL_MAX_HEIGHT, Math.max(PANEL_MIN_HEIGHT, Math.ceil(height))); + if (panel.displayMode === "inline") panel.frame.style.height = `${panel.lastHeight}px`; + break; + } + case "notifications/message": + console.debug("[panel]", panel.toolName, msg.params); + break; + } +} + +window.addEventListener("message", (event) => { + const msg = event.data; + if (!msg || msg.jsonrpc !== "2.0") return; + for (const panel of [...panels]) { + if (!panel.frame || !panel.frame.isConnected) { panels.delete(panel); continue; } + // Identity by window handle, not by origin: a sandboxed frame reports + // origin "null", which is a value any other opaque context also has. + if (event.source !== panel.frame.contentWindow) continue; + if (msg.method && msg.id != null) handlePanelRequest(panel, msg).catch((e) => console.error(e)); + else if (msg.method) handlePanelNotification(panel, msg); + return; + } +}); + +// Follow the dashboard's own light/dark switch. +new MutationObserver(() => { + for (const panel of panels) { + notifyPanel(panel, "ui/notifications/host-context-changed", { + theme: currentTheme(), + styles: { variables: hostStyleVariables() }, + }); + } +}).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); + +document.addEventListener("keydown", (e) => { + if (e.key !== "Escape") return; + for (const panel of panels) { + if (panel.displayMode === "fullscreen") { setDisplayMode(panel, "inline"); break; } + } +}); + +/** + * Insert the panel a tool call points at, right after its tool card. + * + * ``result`` is present on a live turn and absent when the conversation is + * reloaded from history -- in that case the panel is a click away rather + * than mounted eagerly, so opening an old thread does not fan out a tool + * call per panel it happens to contain. + */ +function attachPanel(afterNode, tc, uiPayload) { + const uri = (uiPayload && uiPayload.resourceUri) || tc.ui_resource_uri; + if (!uri || !afterNode || !afterNode.parentNode) return; + if (afterNode.nextSibling && afterNode.nextSibling.classList?.contains("panel")) return; + + const panel = createPanel({ + callId: tc.id, + toolName: tc.name, + toolArgs: tc.args || {}, + uri, + result: uiPayload && uiPayload.result, + }); + afterNode.parentNode.insertBefore(panel.root, afterNode.nextSibling); + + if (panel.result) { + mountPanelFrame(panel); + } else { + panel.wrap.append(h("button", { + type: "button", class: "panel-open", + onClick: (e) => { e.currentTarget.remove(); mountPanelFrame(panel); }, + }, [icon("gauge", 14), h("span", { text: "Open panel" })])); + } +} + // ---------------- Usage footer + modal ---------------- const stateUsage = { last: null, lastLoadedAt: 0 }; @@ -959,7 +1393,21 @@ async function submit() { } const text = el.composer.value.trim(); if (!text) return; + el.composer.value = ""; + autogrow(); + try { + await sendUserText(text); + } catch (err) { + // Creating the conversation can fail before the message is ever shown. + // Put what they typed back rather than swallowing it. + if (!el.composer.value) { el.composer.value = text; autogrow(); } + throw err; + } +} +// Also the entry point for ui/message: a panel that asks the model to look +// into something produces exactly the turn the operator would have typed. +async function sendUserText(text) { if (!state.active) await createConversation(); // Empty-state becomes inner list. @@ -978,9 +1426,6 @@ async function submit() { inner.append(renderMessage(optimistic)); scrollToBottom(); - el.composer.value = ""; - autogrow(); - await streamTurn(text, inner); } @@ -1034,6 +1479,10 @@ async function streamTurn(userText, inner) { content: userText, model: state.model, effort: state.effort, + // Whatever the open panels have pushed since the last turn. Sent + // once and dropped: the spec says only the latest update needs to + // reach the model, and it is already in the transcript afterwards. + app_context: appContextPayload(), }), credentials: "same-origin", signal: controller.signal, @@ -1101,6 +1550,13 @@ async function streamTurn(userText, inner) { } } +function appContextPayload() { + if (!state.appContext.size) return []; + const payload = [...state.appContext.values()]; + state.appContext.clear(); + return payload; +} + function parseSseFrame(chunk) { let event = null; const dataLines = []; @@ -1181,9 +1637,13 @@ function handleEvent({ event, data }, assistantMsg, row, toolCardMap, thinkingCt entry.tc.status = data.status; entry.tc.preview = data.preview; entry.tc.duration_ms = data.duration_ms; + if (data.ui && data.ui.resourceUri) entry.tc.ui_resource_uri = data.ui.resourceUri; const fresh = renderToolCard(entry.tc, assistantMsg.id); entry.card.replaceWith(fresh); entry.card = fresh; + // After the swap: the panel is a sibling of the card, so replacing + // the card would otherwise tear down a freshly mounted iframe. + if (data.ui) attachPanel(fresh, entry.tc, data.ui); break; } case "session_expired": { diff --git a/src/beaconmcp/dashboard/static/login.js b/src/beaconmcp/dashboard/static/login.js index b1f2fce..5f8f00b 100644 --- a/src/beaconmcp/dashboard/static/login.js +++ b/src/beaconmcp/dashboard/static/login.js @@ -1,10 +1,22 @@ -// Two-step login: Client ID / Secret → TOTP. -// Submits all three fields together once the 6-digit code is entered. +// Three-step login. +// +// 1. Client ID / Secret +// 2. TOTP code, or a passkey instead +// 3. Signed in: session lifetime, optional passkey enrolment, finish +// +// Step 3 only exists when JavaScript is on: the form still posts normally +// and gets a 303 to the landing page when it isn't, so the whole passkey +// layer is progressive enhancement over a flow that already worked. (function() { + "use strict"; + var form = document.getElementById("login-form"); if (!form) return; + + var card = document.querySelector(".auth-card"); var s1 = document.getElementById("step-1"); var s2 = document.getElementById("step-2"); + var s3 = document.getElementById("step-3"); var toggle = document.getElementById("toggle-pw"); var pw = document.getElementById("client_secret"); var cid = document.getElementById("client_id"); @@ -14,6 +26,112 @@ var verifyBtn = document.getElementById("verify-btn"); var inputs = document.querySelectorAll("#totp-inputs input"); var verifiedLabel = document.getElementById("client-verified"); + var csrfInput = document.getElementById("csrf-token"); + var step2Error = document.getElementById("step-2-error"); + var step3Error = document.getElementById("step-3-error"); + var step3Ok = document.getElementById("step-3-ok"); + var passkeyLoginBlock = document.getElementById("passkey-login-block"); + var passkeyLoginBtn = document.getElementById("passkey-login-btn"); + var passkeyAddBlock = document.getElementById("passkey-add-block"); + var addPasskeyBtn = document.getElementById("add-passkey-btn"); + var finishBtn = document.getElementById("finish-btn"); + + var passkeysEnabled = card && card.dataset.passkeysEnabled === "true"; + var secureContext = card && card.dataset.secureContext === "true"; + var passkeysUsable = passkeysEnabled && secureContext && + window.BeaconPasskeys && window.BeaconPasskeys.supported(); + + var session = null; // payload returned by the successful login + var busy = false; + + if (passkeyLoginBlock && !passkeysUsable) passkeyLoginBlock.hidden = true; + // Server offers passkeys but the browser won't expose the API (insecure + // origin): say so, rather than silently dropping the button. + var passkeyUnsupported = document.getElementById("passkey-unsupported"); + if (passkeyUnsupported) { + passkeyUnsupported.hidden = passkeysUsable || !passkeysEnabled; + } + + // --- small helpers ---------------------------------------------------- + + function csrf() { + return csrfInput ? csrfInput.value : ""; + } + + function postJson(url, body) { + return fetch(url, { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrf(), + "X-BeaconMCP-Mode": "json", + }, + body: JSON.stringify(body || {}), + }).then(function(res) { + return res.json().catch(function() { return {}; }).then(function(data) { + return { ok: res.ok, status: res.status, data: data }; + }); + }); + } + + function showError(el, message) { + if (!el) return; + el.textContent = message; + el.hidden = !message; + } + + function showOk(el, message) { + if (!el) return; + el.textContent = message; + el.hidden = !message; + } + + // Shimmer: the button keeps its width, gains a sweeping highlight and + // swaps its label. Kept as a class so the CSS owns the animation. + function setLoading(btn, loading, label) { + if (!btn) return; + var labelEl = btn.querySelector(".btn-label"); + if (loading) { + if (labelEl && label) { + if (!btn.dataset.idleLabel) btn.dataset.idleLabel = labelEl.textContent; + labelEl.textContent = label; + } + btn.classList.add("is-loading"); + btn.disabled = true; + } else { + if (labelEl && btn.dataset.idleLabel) { + labelEl.textContent = btn.dataset.idleLabel; + delete btn.dataset.idleLabel; + } + btn.classList.remove("is-loading"); + } + } + + function formatDateTime(epochSeconds) { + if (!epochSeconds || !isFinite(epochSeconds)) return "—"; + var d = new Date(epochSeconds * 1000); + var today = new Date(); + var sameDay = d.toDateString() === today.toDateString(); + var time = d.toLocaleTimeString(undefined, { + hour: "2-digit", minute: "2-digit", + }); + if (sameDay) return "today at " + time; + return d.toLocaleDateString(undefined, { + day: "numeric", month: "short", year: "numeric", + }) + " at " + time; + } + + function formatRelative(epochSeconds) { + var secs = epochSeconds - (Date.now() / 1000); + if (secs <= 0) return "expired"; + var hours = secs / 3600; + if (hours < 1) return "in " + Math.max(1, Math.round(secs / 60)) + " min"; + if (hours < 48) return "in " + Math.round(hours) + " h"; + return "in " + Math.round(hours / 24) + " days"; + } + + // --- step 1 -> step 2 ------------------------------------------------- if (toggle) { toggle.addEventListener("click", function() { @@ -24,13 +142,14 @@ function goStep2() { if (!cid.value.trim() || !pw.value) return; if (verifiedLabel) verifiedLabel.textContent = cid.value.trim(); + showError(step2Error, ""); s1.hidden = true; s2.hidden = false; setTimeout(function() { if (inputs[0]) inputs[0].focus(); }, 40); } if (toStep2) toStep2.addEventListener("click", goStep2); - // Allow Enter in step-1 inputs to advance rather than submit. + // Enter in step-1 advances rather than submitting a half-filled form. [cid, pw].forEach(function(el) { if (!el) return; el.addEventListener("keydown", function(e) { @@ -46,15 +165,18 @@ s1.hidden = false; }); + // --- TOTP boxes ------------------------------------------------------- + function collectTotp() { var s = ""; inputs.forEach(function(i) { s += (i.value || "").replace(/\D/g, ""); }); return s; } + function refresh() { var v = collectTotp(); totpHidden.value = v; - verifyBtn.disabled = v.length !== 6; + verifyBtn.disabled = busy || v.length !== 6; } inputs.forEach(function(inp, i) { @@ -70,6 +192,14 @@ refresh(); }); inp.addEventListener("keydown", function(e) { + // Enter validates as soon as the six digits are in — no reaching + // for the mouse after typing the last one. + if (e.key === "Enter") { + e.preventDefault(); + refresh(); + if (!busy && collectTotp().length === 6) submitTotp(); + return; + } if (e.key === "Backspace" && !e.target.value && inputs[i - 1]) { inputs[i - 1].focus(); inputs[i - 1].value = ""; @@ -89,10 +219,221 @@ }); }); + function clearTotp() { + inputs.forEach(function(i) { i.value = ""; i.classList.remove("filled"); }); + totpHidden.value = ""; + refresh(); + if (inputs[0]) inputs[0].focus(); + } + + // --- step 3 ----------------------------------------------------------- + + function showStep3(payload) { + session = payload; + // Signing in rotates the CSRF cookie; without picking the new value up + // every follow-up fetch (passkey enrolment) would 403. + if (payload.csrf_token && csrfInput) csrfInput.value = payload.csrf_token; + s1.hidden = true; + s2.hidden = true; + s3.hidden = false; + + var who = document.getElementById("success-client"); + if (who) who.textContent = payload.client_name || payload.client_id || "—"; + + var bearerEl = document.getElementById("bearer-expiry"); + if (bearerEl) { + bearerEl.textContent = formatDateTime(payload.bearer_expires_at) + + " (" + formatRelative(payload.bearer_expires_at) + ")"; + } + var sessionEl = document.getElementById("session-expiry"); + if (sessionEl) sessionEl.textContent = formatDateTime(payload.session_expires_at); + + if (passkeyAddBlock) { + var canAdd = !!(payload.passkeys_enabled && payload.secure_context && + window.BeaconPasskeys && window.BeaconPasskeys.supported()); + passkeyAddBlock.hidden = !canAdd; + var addNote = document.getElementById("passkey-add-unsupported"); + if (addNote) addNote.hidden = canAdd || !payload.passkeys_enabled; + var hint = document.getElementById("passkey-add-hint"); + if (canAdd && hint && payload.passkey_count > 0) { + hint.textContent = payload.passkey_count === 1 + ? "1 passkey already registered. Add another for a second device." + : payload.passkey_count + " passkeys already registered. " + + "Add another for a second device."; + } + } + if (finishBtn) finishBtn.focus(); + mentionUpdate(); + } + + // The toast in base.html fetched its status before this session existed, + // so it came back 401 and stayed empty. Now that we're signed in, ask + // again -- a pending update is worth knowing about here, one click before + // entering the panel. Kept to a single line: this screen already has a + // primary action and the full card (with commands) waits on the landing + // page. + function mentionUpdate() { + var note = document.getElementById("update-note"); + if (!note || !window.BeaconUpdates) return; + window.BeaconUpdates.check().then(function(data) { + if (!data) return; + var behind = data.behind === 1 + ? "1 commit behind" : data.behind + " commits behind"; + note.textContent = "An update is available — " + behind + + " " + (data.branch || "main") + ". Details after signing in."; + note.hidden = false; + }); + } + + function finish() { + var next = (session && session.next) || "/app/tokens"; + setLoading(finishBtn, true, "Opening…"); + window.location.href = next; + } + + if (finishBtn) finishBtn.addEventListener("click", finish); + + // --- TOTP submit ------------------------------------------------------ + + function submitTotp() { + if (busy) return; + var code = collectTotp(); + if (code.length !== 6) return; + busy = true; + showError(step2Error, ""); + setLoading(verifyBtn, true, "Verifying…"); + + var body = new URLSearchParams(); + body.set("csrf_token", csrf()); + body.set("client_id", cid.value.trim()); + body.set("client_secret", pw.value); + body.set("totp", code); + var nextField = form.querySelector('input[name="next"]'); + if (nextField) body.set("next", nextField.value); + + fetch("/app/login", { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-CSRF-Token": csrf(), + "X-BeaconMCP-Mode": "json", + }, + body: body.toString(), + }).then(function(res) { + return res.json().catch(function() { return {}; }).then(function(data) { + return { ok: res.ok, data: data }; + }); + }).then(function(res) { + busy = false; + setLoading(verifyBtn, false); + if (res.ok && res.data && res.data.ok) { + showStep3(res.data); + return; + } + showError(step2Error, (res.data && res.data.error) || "Sign-in failed."); + clearTotp(); + }).catch(function() { + busy = false; + setLoading(verifyBtn, false); + showError(step2Error, "Network error. Try again."); + refresh(); + }); + } + form.addEventListener("submit", function(e) { + e.preventDefault(); if (totpHidden.value.length !== 6) { - e.preventDefault(); goStep2(); + return; } + submitTotp(); }); + + // --- passkey sign-in (step 2) ---------------------------------------- + + if (passkeyLoginBtn) { + passkeyLoginBtn.addEventListener("click", function() { + if (busy) return; + var clientId = cid.value.trim(); + var secret = pw.value; + if (!clientId || !secret) { + showError(step2Error, "Enter your client credentials first."); + return; + } + busy = true; + showError(step2Error, ""); + setLoading(passkeyLoginBtn, true, "Waiting for your passkey…"); + verifyBtn.disabled = true; + + var nextField = form.querySelector('input[name="next"]'); + postJson("/app/api/passkeys/auth/options", { + client_id: clientId, + client_secret: secret, + }).then(function(res) { + if (!res.ok || !res.data.ok) { + throw new Error(res.data.error || "Passkey sign-in unavailable."); + } + return window.BeaconPasskeys.authenticate(res.data.options) + .then(function(assertion) { + return postJson("/app/api/passkeys/auth/verify", { + client_id: clientId, + client_secret: secret, + state: res.data.state, + credential: assertion, + next: nextField ? nextField.value : "", + }); + }); + }).then(function(res) { + busy = false; + setLoading(passkeyLoginBtn, false); + refresh(); + if (res.ok && res.data && res.data.ok) { + showStep3(res.data); + return; + } + showError(step2Error, (res.data && res.data.error) || "Passkey rejected."); + }).catch(function(err) { + busy = false; + setLoading(passkeyLoginBtn, false); + refresh(); + showError(step2Error, window.BeaconPasskeys.describeError(err)); + }); + }); + } + + // --- passkey enrolment (step 3) -------------------------------------- + + if (addPasskeyBtn) { + addPasskeyBtn.addEventListener("click", function() { + showError(step3Error, ""); + showOk(step3Ok, ""); + setLoading(addPasskeyBtn, true, "Follow your device prompt…"); + + postJson("/app/api/passkeys/register/options", {}).then(function(res) { + if (!res.ok) throw new Error(res.data.error || "Could not start registration."); + return window.BeaconPasskeys.register(res.data.options) + .then(function(attestation) { + return postJson("/app/api/passkeys/register/verify", { + state: res.data.state, + credential: attestation, + }); + }); + }).then(function(res) { + setLoading(addPasskeyBtn, false); + if (res.ok && res.data && res.data.ok) { + showOk(step3Ok, "Passkey “" + res.data.passkey.label + + "” registered. Next time you can skip the 2FA code."); + addPasskeyBtn.disabled = true; + var hint = document.getElementById("passkey-add-hint"); + if (hint) hint.hidden = true; + return; + } + showError(step3Error, (res.data && res.data.error) || "Registration failed."); + }).catch(function(err) { + setLoading(addPasskeyBtn, false); + showError(step3Error, window.BeaconPasskeys.describeError(err)); + }); + }); + } })(); diff --git a/src/beaconmcp/dashboard/static/update_banner.js b/src/beaconmcp/dashboard/static/update_banner.js new file mode 100644 index 0000000..3008442 --- /dev/null +++ b/src/beaconmcp/dashboard/static/update_banner.js @@ -0,0 +1,237 @@ +// "An update is available" toast, shown on every /app page to a signed-in +// operator. The endpoint is session-authenticated, so a 401 (login page, +// signed out) simply leaves the toast hidden -- no branching needed here. +(function() { + "use strict"; + + var root = document.getElementById("update-toast"); + if (!root) return; + + var DISMISS_KEY = "beaconmcp-update-dismissed"; + var state = null; + + function csrfToken() { + var m = document.cookie.match(/(?:^|;\s*)beaconmcp_csrf_token=([^;]+)/); + return m ? decodeURIComponent(m[1]) : ""; + } + + function dismissed(ref) { + try { + return window.localStorage.getItem(DISMISS_KEY) === ref; + } catch (e) { + return false; + } + } + + function remember(ref) { + try { + window.localStorage.setItem(DISMISS_KEY, ref); + } catch (e) {} + } + + function esc(value) { + return String(value == null ? "" : value) + .replace(/&/g, "&").replace(//g, ">") + .replace(/"/g, """); + } + + function plural(n, one, many) { + return n + " " + (n === 1 ? one : many); + } + + function render(data) { + state = data; + var commits = data.commits || []; + var cfg = data.config || {}; + var newEnv = cfg.new_env_vars || []; + var newKeys = cfg.new_config_keys || []; + + var html = '' + + '
' + + '' + + 'Update available' + + '' + + '
' + + '

' + + esc(plural(data.behind, "commit", "commits")) + " behind " + + '' + esc(data.branch || "main") + '' + + (data.current_ref && data.latest_ref + ? ' · ' + esc(data.current_ref) + ' → ' + esc(data.latest_ref) + '' + : "") + + '

'; + + if (commits.length) { + html += '
    '; + commits.slice(0, 4).forEach(function(c) { + html += '
  • ' + esc(c.sha) + ' ' + esc(c.subject) + '
  • '; + }); + if (commits.length > 4) { + html += '
  • + ' + + esc(plural(commits.length - 4, "more commit", "more commits")) + '
  • '; + } + html += '
'; + } + + if (newEnv.length || newKeys.length) { + html += '
Needs configuration'; + if (newEnv.length) { + html += '
New .env variables: ' + + newEnv.map(function(v) { return '' + esc(v) + ''; }).join(", ") + + '
'; + } + if (newKeys.length) { + html += '
New beaconmcp.yaml settings: ' + + newKeys.slice(0, 6).map(function(v) { return '' + esc(v) + ''; }).join(", ") + + (newKeys.length > 6 ? ", …" : "") + + '
'; + } + html += '
'; + } + + var steps = (data.instructions || []).join("\n"); + html += '
' + + 'To update this ' + esc(data.install_kind) + ' install' + + '' + + '
' + + '
' + esc(steps) + '
'; + + if (data.blockers && data.blockers.length) { + html += '

Automatic update unavailable: ' + + esc(data.blockers.join("; ")) + '

'; + } + + html += '
'; + if (data.can_self_update && data.self_update_allowed) { + html += ''; + } + if (data.compare_url) { + html += 'View changes'; + } + html += '
'; + html += ''; + + root.innerHTML = html; + root.hidden = false; + + var close = document.getElementById("ut-close"); + if (close) { + close.addEventListener("click", function() { + remember(data.latest_ref); + root.hidden = true; + }); + } + var copy = document.getElementById("ut-copy"); + if (copy) { + copy.addEventListener("click", function() { + navigator.clipboard.writeText(steps).then(function() { + copy.textContent = "Copied"; + setTimeout(function() { copy.textContent = "Copy"; }, 1600); + }, function() {}); + }); + } + var go = document.getElementById("ut-go"); + if (go) go.addEventListener("click", askForCode); + } + + // Applying an update pulls code and restarts the process -- gated on a + // fresh 2FA code, the same bar as minting a token. + function askForCode() { + var box = document.getElementById("ut-result"); + if (!box) return; + box.hidden = false; + box.innerHTML = '' + + '' + + '
' + + '' + + '' + + '
' + + '

Pulls the new code, reinstalls dependencies, ' + + 're-validates your config, then restarts. If the new code can\'t load ' + + 'your config it rolls back and does not restart.

'; + var input = document.getElementById("ut-totp"); + var confirm = document.getElementById("ut-confirm"); + if (input) input.focus(); + if (input) { + input.addEventListener("keydown", function(e) { + if (e.key === "Enter") { e.preventDefault(); run(); } + }); + } + if (confirm) confirm.addEventListener("click", run); + } + + function run() { + var input = document.getElementById("ut-totp"); + var confirm = document.getElementById("ut-confirm"); + var box = document.getElementById("ut-result"); + var code = input ? (input.value || "").replace(/\D/g, "") : ""; + if (code.length !== 6) { + if (input) input.focus(); + return; + } + if (confirm) { + confirm.classList.add("is-loading"); + confirm.disabled = true; + var label = confirm.querySelector(".btn-label"); + if (label) label.textContent = "Updating…"; + } + fetch("/app/api/update/apply", { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken(), + }, + body: JSON.stringify({ totp: code }), + }).then(function(res) { + return res.json().catch(function() { return {}; }); + }).then(function(data) { + if (!box) return; + if (data.ok) { + var tail = data.restart_scheduled + ? " The server restarts in " + data.restart_in_seconds + + "s — this page will be briefly unreachable." + : ""; + box.innerHTML = '
Updated. ' + + esc(data.message || "") + esc(tail) + '
'; + remember(state && state.latest_ref); + } else { + box.innerHTML = '
Update failed. ' + + esc(data.message || data.error || "Unknown error.") + '
'; + } + }).catch(function() { + if (box) { + box.innerHTML = '
Network error while updating.
'; + } + }); + } + + // Resolves to the payload when an undismissed update exists, else null. + function check() { + return fetch("/app/api/update", { credentials: "same-origin" }) + .then(function(res) { return res.ok ? res.json() : null; }) + .then(function(data) { + if (!data || !data.enabled || !data.available) return null; + return data; + }) + .catch(function() { return null; }); + } + + // Exposed so the login page can mention an update the moment the session + // exists -- its own fetch below already ran (and 401'd) before sign-in. + window.BeaconUpdates = { check: check, dismissed: dismissed }; + + // The auth pages opt out of the toast itself: they are single-purpose + // screens, and on a narrow viewport a bottom-anchored card this tall + // would sit right on top of their primary button. login.js renders a + // one-line mention instead, and the full card shows on the landing page. + if (document.body.classList.contains("auth-page")) return; + + check().then(function(data) { + if (data && !dismissed(data.latest_ref)) render(data); + }); +})(); diff --git a/src/beaconmcp/dashboard/static/webauthn.js b/src/beaconmcp/dashboard/static/webauthn.js new file mode 100644 index 0000000..1a079ba --- /dev/null +++ b/src/beaconmcp/dashboard/static/webauthn.js @@ -0,0 +1,119 @@ +// Minimal WebAuthn plumbing shared by the dashboard auth pages. +// +// The server speaks base64url (that's what py_webauthn's options_to_json +// emits and what its verifiers parse); the browser API speaks ArrayBuffer. +// Everything here is that translation, plus the two ceremony wrappers. +window.BeaconPasskeys = (function() { + "use strict"; + + function supported() { + return !!(window.PublicKeyCredential && navigator.credentials && + navigator.credentials.create && navigator.credentials.get); + } + + function b64urlToBuf(value) { + var s = String(value).replace(/-/g, "+").replace(/_/g, "/"); + while (s.length % 4) s += "="; + var bin = window.atob(s); + var bytes = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes.buffer; + } + + function bufToB64url(buf) { + var bytes = new Uint8Array(buf); + var bin = ""; + for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return window.btoa(bin) + .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + function decodeDescriptors(list) { + return (list || []).map(function(d) { + var out = { type: d.type || "public-key", id: b64urlToBuf(d.id) }; + if (d.transports && d.transports.length) out.transports = d.transports; + return out; + }); + } + + // navigator.credentials.create() — returns a JSON-safe attestation. + function register(options) { + var publicKey = Object.assign({}, options); + publicKey.challenge = b64urlToBuf(options.challenge); + publicKey.user = Object.assign({}, options.user, { + id: b64urlToBuf(options.user.id), + }); + publicKey.excludeCredentials = decodeDescriptors(options.excludeCredentials); + return navigator.credentials.create({ publicKey: publicKey }).then(function(cred) { + if (!cred) throw new Error("No credential was created."); + var response = cred.response; + var out = { + id: cred.id, + rawId: bufToB64url(cred.rawId), + type: cred.type, + clientExtensionResults: cred.getClientExtensionResults + ? cred.getClientExtensionResults() : {}, + response: { + clientDataJSON: bufToB64url(response.clientDataJSON), + attestationObject: bufToB64url(response.attestationObject), + }, + }; + if (cred.authenticatorAttachment) { + out.authenticatorAttachment = cred.authenticatorAttachment; + } + if (response.getTransports) { + try { out.response.transports = response.getTransports(); } catch (e) {} + } + return out; + }); + } + + // navigator.credentials.get() — returns a JSON-safe assertion. + function authenticate(options) { + var publicKey = Object.assign({}, options); + publicKey.challenge = b64urlToBuf(options.challenge); + publicKey.allowCredentials = decodeDescriptors(options.allowCredentials); + return navigator.credentials.get({ publicKey: publicKey }).then(function(cred) { + if (!cred) throw new Error("No passkey was selected."); + var response = cred.response; + return { + id: cred.id, + rawId: bufToB64url(cred.rawId), + type: cred.type, + clientExtensionResults: cred.getClientExtensionResults + ? cred.getClientExtensionResults() : {}, + response: { + clientDataJSON: bufToB64url(response.clientDataJSON), + authenticatorData: bufToB64url(response.authenticatorData), + signature: bufToB64url(response.signature), + userHandle: response.userHandle ? bufToB64url(response.userHandle) : null, + }, + }; + }); + } + + // The browser throws opaque DOMExceptions; turn the ones users actually + // hit into something actionable and let the rest through as-is. + function describeError(err) { + if (!err) return "Passkey request failed."; + if (err.name === "NotAllowedError") { + return "Passkey prompt cancelled or timed out."; + } + if (err.name === "InvalidStateError") { + return "This device already has a passkey registered for this client."; + } + if (err.name === "SecurityError") { + return "Passkeys need a secure origin (HTTPS or localhost)."; + } + return err.message || String(err); + } + + return { + supported: supported, + register: register, + authenticate: authenticate, + describeError: describeError, + b64urlToBuf: b64urlToBuf, + bufToB64url: bufToB64url, + }; +})(); diff --git a/src/beaconmcp/dashboard/templates/base.html b/src/beaconmcp/dashboard/templates/base.html index 3e817c0..3620fb4 100644 --- a/src/beaconmcp/dashboard/templates/base.html +++ b/src/beaconmcp/dashboard/templates/base.html @@ -8,11 +8,18 @@ - - + + {% block head %}{% endblock %} {% block body %}{% endblock %} + + {# Update notice. Populated by a session-authenticated fetch, so it stays + invisible on the login page and to anyone signed out. Anchored to the + viewport rather than the flow: /app/chat is a full-height grid and a + banner in the document flow would push its layout around. #} + + diff --git a/src/beaconmcp/dashboard/templates/chat.html b/src/beaconmcp/dashboard/templates/chat.html index a984238..4104c4c 100644 --- a/src/beaconmcp/dashboard/templates/chat.html +++ b/src/beaconmcp/dashboard/templates/chat.html @@ -30,6 +30,7 @@ +
Rolling 7-day window
- + {% endblock %} diff --git a/src/beaconmcp/dashboard/templates/connectors.html b/src/beaconmcp/dashboard/templates/connectors.html index 20f3ee9..c8811fe 100644 --- a/src/beaconmcp/dashboard/templates/connectors.html +++ b/src/beaconmcp/dashboard/templates/connectors.html @@ -132,5 +132,5 @@

OAuth connectors

- + {% endblock %} diff --git a/src/beaconmcp/dashboard/templates/login.html b/src/beaconmcp/dashboard/templates/login.html index 0aa7c94..a784041 100644 --- a/src/beaconmcp/dashboard/templates/login.html +++ b/src/beaconmcp/dashboard/templates/login.html @@ -2,13 +2,15 @@ {% block title %}Sign in · BeaconMCP{% endblock %} {% block body_class %}auth-page{% endblock %} {% block body %} -
+
BeaconMCP
- + {% if next %}{% endif %} {# Step 1 — Client ID + Client Secret #} @@ -58,7 +60,7 @@

Sign in

- {# Step 2 — TOTP #} + {# Step 2 — TOTP, or a passkey instead #}
+ +
@@ -84,12 +88,73 @@

Two-factor

+ + {% if passkeys_enabled %} +
+
or
+ +

Skip the 6-digit code with a passkey registered on this device.

+
+ + {% endif %} + + {# Step 3 — signed in: session lifetime, passkey enrolment, finish #} +
- + + {% endblock %} diff --git a/src/beaconmcp/dashboard/templates/tokens.html b/src/beaconmcp/dashboard/templates/tokens.html index 121fe70..1e149be 100644 --- a/src/beaconmcp/dashboard/templates/tokens.html +++ b/src/beaconmcp/dashboard/templates/tokens.html @@ -259,6 +259,39 @@

Static bearer tokens

+ {% if passkeys_enabled %} +
+
Passkeys
+ {% if passkeys %} +
+ {% for p in passkeys %} +
+
+
{{ p.label }}
+
+ Added {{ p.created_human }}{% if p.last_used_human %} · last used {{ p.last_used_human }}{% else %} · never used{% endif %} +
+
+
+ + + +
+
+ {% endfor %} +
+ {% else %} +

+ No passkey registered. Add one right after your next sign-in to skip + the 2FA code on this device. +

+ {% endif %} +
+ {% endif %} +

Concrete setup per client is in the By platform tab.

@@ -778,5 +811,5 @@

Other MCP-HTTP clients

- + {% endblock %} diff --git a/src/beaconmcp/dashboard/templates/totp_refresh.html b/src/beaconmcp/dashboard/templates/totp_refresh.html index d7d95ac..b552fd0 100644 --- a/src/beaconmcp/dashboard/templates/totp_refresh.html +++ b/src/beaconmcp/dashboard/templates/totp_refresh.html @@ -40,5 +40,5 @@

Two-factor

- + {% endblock %} diff --git a/src/beaconmcp/dashboard/usage.py b/src/beaconmcp/dashboard/usage.py index e8fd0f5..d83bb65 100644 --- a/src/beaconmcp/dashboard/usage.py +++ b/src/beaconmcp/dashboard/usage.py @@ -37,11 +37,27 @@ # Pricing # --------------------------------------------------------------------------- -# Rates are in USD per 1M tokens. Pulled from Google AI Studio pricing -# page on 2026-04-17. Keys with ``_hi`` suffixes apply when the prompt +# Rates are in USD per 1M tokens. Pulled from the Google AI Studio pricing +# page on 2026-07-29. Keys with ``_hi`` suffixes apply when the prompt # token count exceeds :data:`_TIER_THRESHOLD`; only the Pro models have # a high tier in the public rate card. +# +# The retired entries at the bottom are deliberate. A model leaving +# ``VALID_MODELS`` does not erase the turns it billed, and ``cost_usd`` is +# called again whenever a stored turn is re-priced -- dropping the rates +# would silently re-bill that history at the fallback model's price. _PRICING: dict[str, dict[str, float]] = { + "gemini-3.6-flash": { + "input": 1.50, "cached": 0.15, "output": 7.50, + }, + "gemini-3.5-flash-lite": { + "input": 0.30, "cached": 0.03, "output": 2.50, + }, + "gemini-3.1-pro-preview": { + "input": 2.00, "cached": 0.20, "output": 12.00, + "input_hi": 4.00, "cached_hi": 0.40, "output_hi": 18.00, + }, + # --- no longer selectable, kept so old turns keep their real price --- "gemini-2.5-flash": { "input": 0.30, "cached": 0.03, "output": 2.50, }, @@ -52,10 +68,6 @@ "gemini-3-flash-preview": { "input": 0.50, "cached": 0.05, "output": 3.00, }, - "gemini-3.1-pro-preview": { - "input": 2.00, "cached": 0.20, "output": 12.00, - "input_hi": 4.00, "cached_hi": 0.40, "output_hi": 18.00, - }, } # Prompt tokens above this count trigger the Pro models' "long prompt" @@ -82,7 +94,7 @@ def cost_usd( cached_tokens: int, output_tokens: int, ) -> float: - rates = _PRICING.get(model) or _PRICING["gemini-2.5-flash"] + rates = _PRICING.get(model) or _PRICING["gemini-3.6-flash"] use_hi = prompt_tokens > _TIER_THRESHOLD and "input_hi" in rates in_rate = rates["input_hi"] if use_hi else rates["input"] out_rate = rates["output_hi"] if use_hi else rates["output"] diff --git a/src/beaconmcp/maintenance/__init__.py b/src/beaconmcp/maintenance/__init__.py new file mode 100644 index 0000000..1d488b2 --- /dev/null +++ b/src/beaconmcp/maintenance/__init__.py @@ -0,0 +1,5 @@ +"""Self-maintenance tools: update checking and applying.""" + +from .tools import register_maintenance_tools + +__all__ = ["register_maintenance_tools"] diff --git a/src/beaconmcp/maintenance/tools.py b/src/beaconmcp/maintenance/tools.py new file mode 100644 index 0000000..ec6364c --- /dev/null +++ b/src/beaconmcp/maintenance/tools.py @@ -0,0 +1,117 @@ +"""MCP tools for keeping the BeaconMCP server itself up to date.""" + +from __future__ import annotations + +from pathlib import Path + +from mcp.server.fastmcp import FastMCP + +from .. import audit, updates +from ..auth import current_client_id +from ..config import UpdatesConfig + + +def register_maintenance_tools( + mcp: FastMCP, + settings: UpdatesConfig | None = None, + *, + config_path: Path | None = None, +) -> None: + """Register ``beaconmcp_check_update`` and ``beaconmcp_self_update``. + + ``settings.enabled`` gates the whole module (no network egress at all); + ``settings.allow_self_update`` keeps the check but refuses to apply. + """ + settings = settings or UpdatesConfig() + if not settings.enabled: + return + + @mcp.tool() + def beaconmcp_check_update() -> dict: + """Check whether a newer BeaconMCP revision is available. + + Reports the running version, how many commits this install is + behind the upstream default branch, the changelog between the two, + and — importantly — any configuration the new revision knows about + that this install has not set yet (new ``.env`` variables, new + ``beaconmcp.yaml`` settings). + + Also returns the exact shell commands that would update *this* + install, which differ between a git checkout, a pip install and a + container. + + Read-only and safe to call at any time: it fetches git objects but + never modifies the working tree. Results are cached for a few hours; + this returns the cached answer when it is still fresh. + """ + info = updates.check_for_update(config_path=config_path) + payload = info.to_json() + payload["self_update_allowed"] = settings.allow_self_update + if info.can_self_update and not settings.allow_self_update: + payload["can_self_update"] = False + payload["blockers"] = [ + *payload.get("blockers", []), + "self-update is disabled by features.updates.allow_self_update", + ] + return payload + + if not settings.allow_self_update: + return + + @mcp.tool() + def beaconmcp_self_update(confirm: bool = False, restart: bool = True) -> dict: + """Update this BeaconMCP server to the latest upstream revision. + + Runs, in order: ``git pull --ff-only`` → reinstall the Python + package and its dependencies → **validate the configuration against + the new code** → schedule a service restart. + + The configuration check is a hard gate. If the new revision cannot + load the operator's config (because a setting was renamed, or a new + one is now required), the checkout is rolled back to exactly where + it started, dependencies are restored, and nothing is restarted. The + return value says so explicitly. + + Requires ``confirm=True``. Call ``beaconmcp_check_update`` first and + show the user what is about to change — including any new config + variables — before asking them to confirm. + + Refuses to run when the checkout has uncommitted changes, or when + this is not a git install; ``beaconmcp_check_update`` reports those + blockers in advance along with manual instructions. + + The restart is deliberately deferred a few seconds so this response + reaches you before the process dies. After that, expect the server + to be briefly unreachable. + """ + if not confirm: + info = updates.check_for_update(config_path=config_path) + return { + "ok": False, + "applied": False, + "reason": "confirmation_required", + "message": ( + "This will pull new code, reinstall dependencies and " + "restart the server. Review the pending changes, then " + "call again with confirm=True." + ), + "pending": info.to_json(), + } + + client_id = current_client_id() + audit.emit("maintenance.self_update.start", client_id=client_id) + result = updates.apply_update(restart=restart, config_path=config_path) + audit.emit( + "maintenance.self_update.finish", + client_id=client_id, + ok=result.ok, + from_ref=result.from_ref, + to_ref=result.to_ref, + rolled_back=result.rolled_back, + ) + # The next check must not serve a stale "update available". + updates.invalidate_cache() + + payload = result.to_json() + payload["applied"] = result.ok + return payload diff --git a/src/beaconmcp/proxmox/apps/bridge.js b/src/beaconmcp/proxmox/apps/bridge.js new file mode 100644 index 0000000..008d6c1 --- /dev/null +++ b/src/beaconmcp/proxmox/apps/bridge.js @@ -0,0 +1,176 @@ +// Shared MCP Apps client bridge. +// +// JSON-RPC 2.0 over postMessage to the host, per the ext-apps spec. Injected +// into each ui:// document by panel.py in place of the +// marker, so the panels stay single self-contained resources. +// +// Deliberately small and dependency-free: an app resource is preloaded by the +// host before the tool even runs, so every kilobyte is paid up front. + +const MCPApp = (() => { + "use strict"; + + const PROTOCOL_VERSION = "2026-01-26"; + + let nextId = 0; + const pending = new Map(); + let hostCapabilities = {}; + let onToolResult = null; + let onHostContext = null; + let ready = false; + + function post(message) { + window.parent.postMessage(message, "*"); + } + + function request(method, params) { + const id = ++nextId; + post({ jsonrpc: "2.0", id, method, params }); + return new Promise((resolve, reject) => pending.set(id, { resolve, reject })); + } + + function notify(method, params) { + post({ jsonrpc: "2.0", method, params }); + } + + // The host owns the iframe height, so it has to be told when the content + // reflows -- otherwise the panel renders into a fixed sliver. + function reportSize() { + notify("ui/notifications/size-changed", { + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + }); + } + + // A CallToolResult carries the dict in structuredContent, but a host that + // strips it still sends the JSON as text -- fall back rather than blank out. + function unwrap(result) { + if (result && result.structuredContent) return result.structuredContent; + const first = result && result.content && result.content[0]; + if (first && first.type === "text") { + try { return JSON.parse(first.text); } catch { return null; } + } + return null; + } + + async function callTool(name, args) { + const result = await request("tools/call", { name, arguments: args }); + const data = unwrap(result); + if (result && result.isError) { + throw new Error((data && data.error) || "tool call failed"); + } + if (data && data.error) throw new Error(data.error); + return data; + } + + // Both of these are gated on host capabilities. Calling one the host did not + // advertise gets an error back, so check first and no-op quietly: a panel + // that works everywhere beats one that throws on a stricter host. + function updateModelContext(structuredContent, text) { + if (!hostCapabilities.updateModelContext) return Promise.resolve(false); + return request("ui/update-model-context", { + content: text ? [{ type: "text", text }] : undefined, + structuredContent, + }).then(() => true, () => false); + } + + function sendMessage(text) { + if (!hostCapabilities.message) return Promise.resolve(false); + return request("ui/message", { + role: "user", + content: [{ type: "text", text }], + }).then((r) => !(r && r.isError), () => false); + } + + function requestDisplayMode(mode) { + return request("ui/request-display-mode", { mode }).then( + (r) => (r && r.mode) || null, + () => null, + ); + } + + window.addEventListener("message", (event) => { + const message = event.data; + if (!message || message.jsonrpc !== "2.0") return; + + if (message.id != null && pending.has(message.id)) { + const { resolve, reject } = pending.get(message.id); + pending.delete(message.id); + if (message.error) reject(new Error(message.error.message || "request failed")); + else resolve(message.result); + return; + } + + switch (message.method) { + case "ui/notifications/tool-result": + if (onToolResult) onToolResult(unwrap(message.params)); + break; + case "ui/notifications/host-context-changed": + if (onHostContext) onHostContext(message.params); + break; + case "ui/resource-teardown": + post({ jsonrpc: "2.0", id: message.id, result: {} }); + break; + } + }); + + function applyHostContext(ctx) { + if (!ctx) return; + if (ctx.theme) document.documentElement.dataset.theme = ctx.theme; + const vars = ctx.styles && ctx.styles.variables; + if (vars) { + for (const [key, value] of Object.entries(vars)) { + document.documentElement.style.setProperty(key, value); + } + } + } + + /** + * Perform the ui/initialize handshake. + * + * Params are flat: appInfo / appCapabilities / protocolVersion. Nesting the + * capabilities or sending clientInfo instead of appInfo fails the host's + * schema check, and a rejected handshake is silent -- the host simply never + * answers, so the app never sends `initialized` and the host never delivers + * the tool result. See @modelcontextprotocol/ext-apps App.connect(). + * + * `onFail` is called if the host never completes the handshake, so the panel + * can say so instead of sitting on a spinner forever. + */ + function connect({ name, version = "1.0.0", onResult, onContext, onFail }) { + onToolResult = onResult; + onHostContext = (ctx) => { applyHostContext(ctx); if (onContext) onContext(ctx); }; + + setTimeout(() => { + if (!ready && onFail) { + onFail(new Error("The host did not complete the ui/initialize handshake.")); + } + }, 5000); + + return request("ui/initialize", { + appInfo: { name, version }, + appCapabilities: { availableDisplayModes: ["inline", "fullscreen"] }, + protocolVersion: PROTOCOL_VERSION, + }).then((result) => { + ready = true; + hostCapabilities = (result && result.hostCapabilities) || {}; + applyHostContext(result && result.hostContext); + notify("ui/notifications/initialized", {}); + reportSize(); + return result; + }).catch((err) => { + if (onFail) onFail(err); + throw err; + }); + } + + return { + connect, + callTool, + updateModelContext, + sendMessage, + requestDisplayMode, + reportSize, + hostSupports: (name) => Boolean(hostCapabilities[name]), + }; +})(); diff --git a/src/beaconmcp/proxmox/apps/cluster_panel.html b/src/beaconmcp/proxmox/apps/cluster_panel.html new file mode 100644 index 0000000..c89f853 --- /dev/null +++ b/src/beaconmcp/proxmox/apps/cluster_panel.html @@ -0,0 +1,292 @@ + + + +Cluster dashboard + + + +
Loading…
+ +
+
+
+

Cluster

+
+
+ +
+ +
+ +
Guests
+
+
+ +
+
+ +
+
+
+ + + + +
+ + diff --git a/src/beaconmcp/proxmox/apps/logs_panel.html b/src/beaconmcp/proxmox/apps/logs_panel.html new file mode 100644 index 0000000..907b565 --- /dev/null +++ b/src/beaconmcp/proxmox/apps/logs_panel.html @@ -0,0 +1,194 @@ + + + +Log viewer + + + +
Loading…
+ +
+
+
+

+
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ +
+ + diff --git a/src/beaconmcp/proxmox/apps/panel.css b/src/beaconmcp/proxmox/apps/panel.css new file mode 100644 index 0000000..5d31a81 --- /dev/null +++ b/src/beaconmcp/proxmox/apps/panel.css @@ -0,0 +1,96 @@ +/* Shared look for the ui:// panels. Injected alongside bridge.js by panel.py. + + Every colour is a host variable with a local fallback. A host that sends + hostContext.styles.variables (the spec's standardized names, which + bridge.js writes onto :root) drives the palette; one that sends nothing + but a theme falls through to the values below. Both halves of the theme + read the same variable so the host's choice wins either way -- the dark + block only changes what happens when it stays silent. */ + +:root { + --bg: var(--color-background-primary, #ffffff); + --fg: var(--color-text-primary, #1a1a1a); + --muted: var(--color-text-tertiary, #6b7280); + --line: var(--color-border-primary, #e5e7eb); + --accent: var(--color-ring-primary, #2563eb); + --danger: var(--color-text-danger, #dc2626); + --ok: var(--color-text-success, #16a34a); + --warn: var(--color-text-warning, #d97706); + --track: var(--color-background-tertiary, #f3f4f6); +} +:root[data-theme="dark"] { + --bg: var(--color-background-primary, #1c1c1c); + --fg: var(--color-text-primary, #ededed); + --muted: var(--color-text-tertiary, #9ca3af); + --line: var(--color-border-primary, #333333); + --accent: var(--color-ring-primary, #60a5fa); + --danger: var(--color-text-danger, #f87171); + --ok: var(--color-text-success, #4ade80); + --warn: var(--color-text-warning, #fbbf24); + --track: var(--color-background-tertiary, #2a2a2a); +} + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 14px; + background: var(--bg); + color: var(--fg); + font: 13px/1.45 var(--font-sans, system-ui, -apple-system, "Segoe UI", sans-serif); +} + +h1 { font-size: 15px; margin: 0; font-weight: 600; } +.sub { color: var(--muted); font-size: 12px; margin-top: 2px; } +header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } + +.badge { + padding: 2px 9px; border-radius: 999px; font-size: 11px; font-weight: 600; + text-transform: uppercase; letter-spacing: .04em; white-space: nowrap; + border: 1px solid var(--line); color: var(--muted); +} +.badge[data-status="running"], .badge[data-status="online"], .badge[data-status="OK"] { color: var(--ok); border-color: currentColor; } +.badge[data-status="paused"] { color: var(--accent); border-color: currentColor; } +.badge[data-status="unreachable"], .badge[data-status="unknown"] { color: var(--danger); border-color: currentColor; } + +.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 14px 0; } +.metric { border: 1px solid var(--line); border-radius: 8px; padding: 9px 10px; } +.metric .label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; } +.metric .value { font-size: 15px; font-weight: 600; margin-top: 3px; font-variant-numeric: tabular-nums; } + +.bar { height: 4px; border-radius: 2px; background: var(--track); margin-top: 7px; overflow: hidden; } +.bar > i { display: block; height: 100%; background: var(--accent); } +.bar > i[data-level="warn"] { background: var(--warn); } +.bar > i[data-level="high"] { background: var(--danger); } + +fieldset { border: 0; margin: 0; padding: 0; } +fieldset[disabled] { opacity: .55; } +.row { display: flex; flex-wrap: wrap; gap: 7px; align-items: flex-end; } + +button { + font: inherit; padding: 6px 13px; border-radius: 6px; cursor: pointer; + border: 1px solid var(--line); background: transparent; color: var(--fg); +} +button:hover:not(:disabled) { border-color: var(--accent); } +button:disabled { cursor: not-allowed; opacity: .45; } +button.danger:hover:not(:disabled) { border-color: var(--danger); color: var(--danger); } + +label { display: block; font-size: 11px; color: var(--muted); margin-bottom: 3px; } +input, select { + font: inherit; padding: 5px 7px; border-radius: 6px; + border: 1px solid var(--line); background: var(--bg); color: var(--fg); +} + +hr { border: 0; border-top: 1px solid var(--line); margin: 14px 0; } +.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 8px; } + +.note { margin-top: 12px; padding: 8px 10px; border-radius: 6px; font-size: 12px; border: 1px solid var(--line); } +.note[data-kind="error"], #loading[data-kind="error"] { color: var(--danger); border-color: currentColor; } +.note[data-kind="ok"] { color: var(--ok); border-color: currentColor; } + +table { border-collapse: collapse; width: 100%; font-size: 12px; } +th { text-align: left; font-weight: 600; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; padding: 5px 8px; } +td { padding: 5px 8px; border-top: 1px solid var(--line); } +tbody tr:hover { background: var(--track); } + +[hidden] { display: none !important; } diff --git a/src/beaconmcp/proxmox/apps/vm_panel.html b/src/beaconmcp/proxmox/apps/vm_panel.html new file mode 100644 index 0000000..29fe698 --- /dev/null +++ b/src/beaconmcp/proxmox/apps/vm_panel.html @@ -0,0 +1,215 @@ + + + +VM control panel + + +
Loading…
+ +
+
+
+

+
+
+ +
+ +
+
+
CPU
+
+
+
+
+
Memory
+
+
+
+
+
Disk
+
+
+
+
+
Uptime
+
+
+
+ +
+
Power
+
+ + + + + +
+ +
+ +
Resources
+
+
+ + +
+
+ + +
+ +
+
+ + +
+ + diff --git a/src/beaconmcp/proxmox/panel.py b/src/beaconmcp/proxmox/panel.py new file mode 100644 index 0000000..0dd1670 --- /dev/null +++ b/src/beaconmcp/proxmox/panel.py @@ -0,0 +1,222 @@ +"""MCP Apps panels. + +The MCP Apps extension (`io.modelcontextprotocol/ui`) lets a tool carry a +reference to an interactive UI: `_meta.ui.resourceUri` points at a `ui://` +resource served as `text/html;profile=mcp-app`, which the host renders in a +sandboxed iframe and talks to over JSON-RPC on `postMessage`. + +The wire format is all this module needs, so it runs on mcp 1.x. The `Apps` +extension class that wraps it lives in mcp 2.0 and requires `MCPServer`; the +two knobs it sets -- `meta=` on the tool and `mime_type=` on the resource -- +are already on `FastMCP`. + +Hosts that did not negotiate Apps ignore `_meta.ui` and just show the tool's +return value, which is why every panel tool returns its full snapshot as data +rather than a "see the panel" placeholder. + +Each panel is one HTML file under ``apps/``. They share ``bridge.js`` (the +JSON-RPC client) and ``panel.css`` (the look), spliced in at the +```` marker so what ships to the host stays a single +self-contained document. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from .aggregators import ( + _collect_node_summaries, + _collect_storage_summaries, + _collect_vm_summaries, +) +from .client import ProxmoxClient + +APP_MIME_TYPE = "text/html;profile=mcp-app" + +VM_PANEL_URI = "ui://beaconmcp/vm-panel.html" +LOGS_PANEL_URI = "ui://beaconmcp/logs-panel.html" +CLUSTER_PANEL_URI = "ui://beaconmcp/cluster-panel.html" + +_APPS_DIR = Path(__file__).parent / "apps" +_RUNTIME_MARKER = "" + +_MB = 1048576 +_GB = 1073741824 + + +def _read_app(name: str) -> str: + """Load a panel document with the shared CSS and bridge spliced in.""" + html = (_APPS_DIR / name).read_text(encoding="utf-8") + runtime = ( + f"" + f"" + ) + return html.replace(_RUNTIME_MARKER, runtime) + + +def _vm_snapshot(client: ProxmoxClient, node: str, vmid: int) -> dict[str, Any]: + """Everything the VM panel renders, in one pass over both guest types.""" + for vm_type in ("qemu", "lxc"): + data = client.get(node, f"nodes/{node}/{vm_type}/{vmid}/status/current") + if isinstance(data, dict) and "error" in data: + if "does not exist" in str(data["error"]).lower(): + continue + return data + if not isinstance(data, dict) or not data.get("status"): + continue + + # QEMU reports disk=0 unless the guest agent is answering, so a zero + # here means "unknown", not "empty". LXC reports it for real. + disk_used = data.get("disk") or 0 + conf = client.get(node, f"nodes/{node}/{vm_type}/{vmid}/config") + if not isinstance(conf, dict) or "error" in conf: + conf = {} + + return { + "node": node, + "vmid": vmid, + "type": vm_type, + "name": data.get("name", ""), + "status": data.get("status"), + "cpu_pct": round(data.get("cpu", 0) * 100, 1), + "cpus": data.get("cpus"), + "mem_used_mb": round(data.get("mem", 0) / _MB), + "mem_max_mb": round(data.get("maxmem", 0) / _MB), + "disk_used_gb": round(disk_used / _GB, 1) if disk_used else None, + "disk_max_gb": round(data.get("maxdisk", 0) / _GB, 1), + "uptime_h": round(data.get("uptime", 0) / 3600, 1), + "cores": conf.get("cores"), + "memory_mb": conf.get("memory"), + } + + return {"error": f"VM/CT {vmid} not found on node '{node}'. Check the VMID and node name."} + + +def register_panel_tools(mcp: FastMCP, client: ProxmoxClient) -> None: + @mcp.tool( + meta={"ui": {"resourceUri": VM_PANEL_URI, "visibility": ["model", "app"]}}, + ) + def proxmox_vm_panel(node: str, vmid: int) -> dict[str, Any]: + """Open an interactive control panel for one VM or container. + + Renders live CPU / RAM / disk state with buttons for start, stop and + restart, and fields to change the CPU core count and memory. Use this + instead of proxmox_vm_status when the user wants to *act* on a guest + rather than just read its numbers, or when they ask to "manage", + "control" or "open" a VM. + + The panel drives the ordinary tools (proxmox_vm_start / _stop / + _restart / _config), so every action it takes goes through the same + approval the client applies to any other tool call. + + Returns: {node, vmid, type, name, status, cpu_pct, cpus, mem_used_mb, + mem_max_mb, disk_used_gb, disk_max_gb, uptime_h, cores, memory_mb}. + ``disk_used_gb`` is null when the guest does not report it. + """ + return _vm_snapshot(client, node, vmid) + + @mcp.tool( + meta={"ui": {"resourceUri": LOGS_PANEL_URI, "visibility": ["model", "app"]}}, + ) + def proxmox_logs_panel(node: str, source: str = "syslog", limit: int = 200) -> dict[str, Any]: + """Open a scrollable, filterable log and task viewer for one node. + + Prefer this over proxmox_get_logs whenever the user wants to *read* + logs rather than have them summarised: the panel keeps every line, + highlights errors and warnings, filters as you type, and can switch + between the syslog and the Proxmox task list without another turn. + + Args: + source: 'syslog' for system logs, 'tasks' for the task history. + limit: Lines to fetch, capped at 500 by the Proxmox API. + + Returns: {node, source, entries: [...]}. For syslog each entry is + {text, level} where level is error/warn/info, guessed from the line. + For tasks each entry is {upid, type, status, user, starttime, endtime, + level}. + """ + limit = min(limit, 500) + + if source == "tasks": + data = client.get(node, f"nodes/{node}/tasks", limit=limit) + if isinstance(data, dict) and "error" in data: + return data + entries = [ + { + "upid": t.get("upid"), + "type": t.get("type"), + "status": t.get("status"), + "user": t.get("user"), + "starttime": t.get("starttime"), + "endtime": t.get("endtime"), + # Proxmox writes "OK" for success and a message otherwise; + # a still-running task has no status yet. + "level": "info" if t.get("status") in ("OK", None) else "error", + } + for t in (data if isinstance(data, list) else []) + ] + return {"node": node, "source": "tasks", "entries": entries} + + data = client.get(node, f"nodes/{node}/syslog", limit=limit) + if isinstance(data, dict) and "error" in data: + return data + entries = [ + {"text": line, "level": _syslog_level(line)} + for line in (entry.get("t", "") for entry in (data if isinstance(data, list) else [])) + ] + return {"node": node, "source": "syslog", "entries": entries} + + @mcp.tool( + meta={"ui": {"resourceUri": CLUSTER_PANEL_URI, "visibility": ["model", "app"]}}, + ) + def cluster_overview_interactive(include_storage: bool = True) -> dict[str, Any]: + """Open an interactive cluster dashboard: nodes, guests and storage. + + The same data as cluster_overview, but rendered as a browsable panel: + nodes with CPU and memory pressure, a searchable guest table with + per-row start/stop, and storage pools with usage bars. Use it when the + user wants to look around the cluster rather than ask one question + about it. + + Returns: {nodes: [...], vms: [...], total_vms, storage: [...]}. + """ + nodes = _collect_node_summaries(client) + vms, total_vms = _collect_vm_summaries(client) + out: dict[str, Any] = {"nodes": nodes, "vms": vms, "total_vms": total_vms} + if include_storage: + out["storage"] = _collect_storage_summaries(client) + return out + + _register_app_resource(mcp, VM_PANEL_URI, "vm-panel", "VM control panel", "vm_panel.html") + _register_app_resource(mcp, LOGS_PANEL_URI, "logs-panel", "Log viewer", "logs_panel.html") + _register_app_resource( + mcp, CLUSTER_PANEL_URI, "cluster-panel", "Cluster dashboard", "cluster_panel.html", + ) + + +# Cheap keyword scan. Proxmox hands us raw journald text with no severity +# field, so the alternative to guessing is showing every line flat -- which is +# the thing this panel exists to fix. Over-flagging a line is harmless; the +# filter box is there for when it gets noisy. +_ERROR_WORDS = ("error", "fail", "fatal", "critical", "panic", "segfault", "refused", "timeout") +_WARN_WORDS = ("warn", "deprecat", "retry", "degraded", "unable") + + +def _syslog_level(line: str) -> str: + lowered = line.lower() + if any(word in lowered for word in _ERROR_WORDS): + return "error" + if any(word in lowered for word in _WARN_WORDS): + return "warn" + return "info" + + +def _register_app_resource( + mcp: FastMCP, uri: str, name: str, title: str, filename: str, +) -> None: + @mcp.resource(uri, name=name, title=title, mime_type=APP_MIME_TYPE) + def _app() -> str: + return _read_app(filename) diff --git a/src/beaconmcp/server.py b/src/beaconmcp/server.py index 0265178..967a0ad 100644 --- a/src/beaconmcp/server.py +++ b/src/beaconmcp/server.py @@ -12,9 +12,11 @@ from .bmc import build_registry as build_bmc_registry from .bmc import register_bmc_tools from .config import Config +from .maintenance import register_maintenance_tools from .proxmox.aggregators import register_aggregator_tools from .proxmox.client import ProxmoxClient from .proxmox.monitoring import register_monitoring_tools +from .proxmox.panel import register_panel_tools from .proxmox.system import register_system_tools from .proxmox.vms import register_vm_tools from .security.tools import register_security_tools @@ -281,6 +283,7 @@ def beaconmcp_context() -> str: if config.pve_nodes: register_monitoring_tools(mcp, proxmox_client) register_vm_tools(mcp, proxmox_client) + register_panel_tools(mcp, proxmox_client) register_system_tools(mcp, proxmox_client, ssh_client if config.ssh and config.ssh.hosts else None) # Aggregators ride on top of the Proxmox client and opportunistically # pull BMC facts when the registry is non-empty. @@ -290,3 +293,8 @@ def beaconmcp_context() -> str: if bmc_registry: register_bmc_tools(mcp, bmc_registry) register_security_tools(mcp) +# Not tied to any infrastructure capability: keeping the server itself +# current is useful on every deployment shape. +register_maintenance_tools( + mcp, config.features.updates, config_path=config.source_path, +) diff --git a/src/beaconmcp/updates.py b/src/beaconmcp/updates.py new file mode 100644 index 0000000..47f9c1e --- /dev/null +++ b/src/beaconmcp/updates.py @@ -0,0 +1,821 @@ +"""Update detection and self-update for BeaconMCP. + +BeaconMCP ships no PyPI package and cuts no releases: the canonical install +is a ``git clone`` at ``/opt/beaconmcp`` with a venv and a systemd unit (see +``deploy/install.sh``). So "is there an update?" means *is this checkout +behind the remote default branch?*, not "is there a newer version string". + +Three things live here: + +* :func:`detect_installation` -- how this server was installed, so the + advice we give matches reality instead of assuming everyone ran the + install script. +* :func:`check_for_update` -- a cached, fail-soft, read-only check. It also + diffs the *new* ``.env.example`` / ``beaconmcp.yaml.example`` against the + operator's actual files, which is how we can say "this update wants a + variable you haven't set" before they apply it. +* :func:`apply_update` -- pull, reinstall dependencies, **validate the + config**, and roll back if the new revision cannot load it. Restarting + into a config that refuses to parse would take the server down with no + one at the keyboard, so validation is a hard gate, not a warning. + +Nothing here ever raises into a caller: an air-gapped box, a missing git +binary or a detached HEAD all degrade to "couldn't check", never to a +broken dashboard or a failed tool call. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import sys +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +_REPO_URL = "https://github.com/Showdown76py/BeaconMCP" + +#: How long a successful check stays fresh. Updates are not urgent and the +#: check shells out to git, so once every few hours is plenty. +CHECK_TTL_SECONDS = 6 * 3600 +#: Failures are retried sooner -- a transient DNS blip shouldn't mean six +#: hours of "unknown". +FAILED_CHECK_TTL_SECONDS = 15 * 60 + +#: Ceiling on any git/pip subprocess. `pip install -e .` on a cold cache is +#: the slow one; the rest finish in well under a second. +_GIT_TIMEOUT = 60 +_PIP_TIMEOUT = 900 + + +def current_version() -> str: + """Installed version string, preferring package metadata.""" + try: + from importlib.metadata import version + + return version("beaconmcp") + except Exception: # noqa: BLE001 - not installed as a distribution + from . import __version__ + + return __version__ + + +# --------------------------------------------------------------------------- +# Installation shape +# --------------------------------------------------------------------------- + +@dataclass +class Installation: + """How this particular server was installed.""" + + #: "git" (clone, the documented install), "pip" (installed as a + #: distribution from a URL/wheel), "docker", or "unknown". + kind: str + #: Root of the git checkout, when there is one. + root: Path | None + #: Interpreter running us -- also the venv's python when there is a venv. + python: str + #: Virtualenv prefix, or None when running against a system interpreter. + venv: Path | None + #: True when the package is imported straight from the checkout. + editable: bool + #: True when the process was started by systemd. + under_systemd: bool + #: systemd unit to restart, when we can name one. + service: str | None + #: True when running inside a container. + in_container: bool + + def to_json(self) -> dict[str, Any]: + return { + "kind": self.kind, + "root": str(self.root) if self.root else None, + "python": self.python, + "venv": str(self.venv) if self.venv else None, + "editable": self.editable, + "under_systemd": self.under_systemd, + "service": self.service, + "in_container": self.in_container, + } + + +def _package_root() -> Path: + """Directory holding ``src/beaconmcp`` -- i.e. the repo root when cloned.""" + # ...//src/beaconmcp/updates.py -> parents[2] == + return Path(__file__).resolve().parents[2] + + +def _detect_service() -> str | None: + """Name the systemd unit, if one is installed for us.""" + for candidate in ( + "/etc/systemd/system/beaconmcp.service", + "/lib/systemd/system/beaconmcp.service", + "/usr/lib/systemd/system/beaconmcp.service", + ): + if Path(candidate).is_file(): + return "beaconmcp" + return None + + +def detect_installation() -> Installation: + """Inspect the runtime to work out how BeaconMCP got here.""" + root = _package_root() + is_git = (root / ".git").exists() + venv = Path(sys.prefix) if sys.prefix != sys.base_prefix else None + # systemd exports INVOCATION_ID to every unit it starts; it is the one + # signal that does not require guessing at pid 1 or parsing /proc. + under_systemd = bool(os.environ.get("INVOCATION_ID")) + in_container = ( + Path("/.dockerenv").exists() + or os.environ.get("container") is not None + ) + + if is_git: + kind = "git" + elif in_container: + kind = "docker" + else: + try: + from importlib.metadata import distribution + + distribution("beaconmcp") + kind = "pip" + except Exception: # noqa: BLE001 + kind = "unknown" + + return Installation( + kind=kind, + root=root if is_git else None, + python=sys.executable, + venv=venv, + editable=is_git, + under_systemd=under_systemd, + service=_detect_service(), + in_container=in_container, + ) + + +# --------------------------------------------------------------------------- +# git plumbing +# --------------------------------------------------------------------------- + +def _git(root: Path, *args: str, timeout: int = _GIT_TIMEOUT) -> tuple[int, str, str]: + """Run a git command in ``root``. Never raises.""" + if not shutil.which("git"): + return 127, "", "git is not installed" + try: + proc = subprocess.run( + ["git", *args], + cwd=str(root), + capture_output=True, + text=True, + timeout=timeout, + # Never let git try to prompt for credentials: on a private + # remote it would hang until the timeout instead of failing. + env={**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": ""}, + ) + return proc.returncode, proc.stdout.strip(), proc.stderr.strip() + except subprocess.TimeoutExpired: + return 124, "", f"git {' '.join(args)} timed out" + except OSError as exc: + return 1, "", str(exc) + + +def _default_branch(root: Path) -> str: + """Remote default branch name, falling back to ``main``.""" + code, out, _ = _git(root, "symbolic-ref", "--short", "refs/remotes/origin/HEAD") + if code == 0 and out.startswith("origin/"): + return out.split("/", 1)[1] + # Not every clone has origin/HEAD set (shallow clones, older git). + code, out, _ = _git(root, "remote", "show", "origin") + if code == 0: + match = re.search(r"HEAD branch:\s*(\S+)", out) + if match: + return match.group(1) + return "main" + + +def working_tree_dirty(root: Path) -> bool: + """True when tracked files have uncommitted modifications.""" + code, out, _ = _git(root, "status", "--porcelain", "--untracked-files=no") + return code == 0 and bool(out) + + +# --------------------------------------------------------------------------- +# Config drift: what the new revision wants that the operator hasn't set +# --------------------------------------------------------------------------- + +_ENV_ASSIGNMENT = re.compile(r"^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=") + + +def _env_names(text: str) -> list[str]: + """Variable names assigned in a dotenv-style file (comments included). + + Comments count on purpose: ``.env.example`` documents optional settings + as ``# GEMINI_API_KEY=`` and those are exactly the ones an operator + wants to hear about after an update. + """ + names: list[str] = [] + for raw in text.splitlines(): + line = raw.lstrip() + if line.startswith("#"): + line = line.lstrip("#").lstrip() + match = _ENV_ASSIGNMENT.match(line) + if match: + names.append(match.group(1)) + return names + + +def _yaml_paths(text: str) -> set[str]: + """Dotted key paths in a YAML document, list items collapsed away.""" + try: + import yaml + + data = yaml.safe_load(text) + except Exception: # noqa: BLE001 - malformed example, nothing to diff + return set() + + paths: set[str] = set() + + def walk(node: Any, prefix: str) -> None: + if isinstance(node, dict): + for key, value in node.items(): + path = f"{prefix}.{key}" if prefix else str(key) + paths.add(path) + walk(value, path) + elif isinstance(node, list): + # Sequence entries are instances (nodes, hosts, devices), not + # settings -- their *shape* is what matters, so recurse without + # adding an index to the path. + for item in node: + walk(item, prefix) + + walk(data, "") + return paths + + +@dataclass +class ConfigDrift: + """Settings the incoming revision knows about and this install does not.""" + + new_env_vars: list[str] = field(default_factory=list) + new_config_keys: list[str] = field(default_factory=list) + + @property + def empty(self) -> bool: + return not self.new_env_vars and not self.new_config_keys + + def to_json(self) -> dict[str, Any]: + return { + "new_env_vars": self.new_env_vars, + "new_config_keys": self.new_config_keys, + } + + +def _read_local(root: Path, *names: str) -> str | None: + for name in names: + path = root / name + if path.is_file(): + try: + return path.read_text(encoding="utf-8", errors="replace") + except OSError: + return None + return None + + +def config_drift(root: Path, ref: str, config_path: Path | None = None) -> ConfigDrift: + """Diff the example files at ``ref`` against what this install actually has. + + Deliberately compares against the operator's *real* files rather than + the local examples: someone who set ``GEMINI_API_KEY`` before it was + documented should not be told to set it again. + """ + drift = ConfigDrift() + + code, new_env, _ = _git(root, "show", f"{ref}:.env.example") + if code == 0: + local_env = _read_local(root, ".env") or "" + known = set(_env_names(local_env)) | set(os.environ) + for name in _env_names(new_env): + if name not in known and name not in drift.new_env_vars: + drift.new_env_vars.append(name) + + code, new_yaml, _ = _git(root, "show", f"{ref}:beaconmcp.yaml.example") + if code == 0: + local_yaml = None + if config_path and config_path.is_file(): + try: + local_yaml = config_path.read_text(encoding="utf-8", errors="replace") + except OSError: + local_yaml = None + if local_yaml is None: + local_yaml = _read_local(root, "beaconmcp.yaml") or "" + have = _yaml_paths(local_yaml) + # Anything the operator already configured, plus its ancestors, is + # "known"; only genuinely new leaves are worth reporting. + for path in sorted(_yaml_paths(new_yaml) - have): + if any(p.startswith(path + ".") for p in have): + continue # a parent of something already configured + drift.new_config_keys.append(path) + + return drift + + +# --------------------------------------------------------------------------- +# Update check +# --------------------------------------------------------------------------- + +@dataclass +class UpdateInfo: + """Result of one update check. Always renderable, even on failure.""" + + checked_at: float + available: bool = False + error: str | None = None + version: str = "" + install_kind: str = "unknown" + branch: str | None = None + current_ref: str | None = None + latest_ref: str | None = None + behind: int = 0 + commits: list[dict[str, str]] = field(default_factory=list) + drift: ConfigDrift = field(default_factory=ConfigDrift) + instructions: list[str] = field(default_factory=list) + can_self_update: bool = False + blockers: list[str] = field(default_factory=list) + repo_url: str = _REPO_URL + + def to_json(self) -> dict[str, Any]: + return { + "checked_at": self.checked_at, + "available": self.available, + "error": self.error, + "version": self.version, + "install_kind": self.install_kind, + "branch": self.branch, + "current_ref": self.current_ref, + "latest_ref": self.latest_ref, + "behind": self.behind, + "commits": self.commits, + "config": self.drift.to_json(), + "instructions": self.instructions, + "can_self_update": self.can_self_update, + "blockers": self.blockers, + "repo_url": self.repo_url, + "compare_url": ( + f"{self.repo_url}/compare/{self.current_ref}...{self.latest_ref}" + if self.current_ref and self.latest_ref and self.available + else None + ), + } + + +def manual_instructions(install: Installation) -> list[str]: + """Shell commands that update *this* install, in order.""" + if install.kind == "git" and install.root: + root = install.root + pip = ( + str(install.venv / "bin" / "pip") + if install.venv and (install.venv / "bin" / "pip").exists() + else f"{install.python} -m pip" + ) + steps = [f"cd {root}", "git pull --ff-only", f"{pip} install -e ."] + if install.service: + steps.append(f"systemctl restart {install.service}") + return steps + if install.kind == "docker": + return [ + "docker compose pull", + "docker compose up -d", + "# (or: docker pull && docker compose up -d)", + ] + if install.kind == "pip": + pip = f"{install.python} -m pip" + steps = [f"{pip} install --upgrade 'beaconmcp @ git+{_REPO_URL}.git'"] + if install.service: + steps.append(f"systemctl restart {install.service}") + return steps + return [ + "# Could not determine how BeaconMCP was installed here.", + "# Re-run the installer from a checkout: bash deploy/install.sh", + ] + + +def _self_update_blockers(install: Installation, root: Path | None) -> list[str]: + """Reasons ``apply_update`` would refuse, as operator-facing sentences.""" + blockers: list[str] = [] + if install.kind != "git" or root is None: + blockers.append( + f"this is a {install.kind} install, and automatic updates only " + "support a git checkout" + ) + return blockers + if not shutil.which("git"): + blockers.append("the git binary is not on PATH") + if working_tree_dirty(root): + blockers.append( + "the checkout has uncommitted changes -- commit or stash them " + "first so the update cannot discard your work" + ) + return blockers + + +_cache_lock = threading.Lock() +_cached: UpdateInfo | None = None + +#: Serializes the git work. Two entry points can reach this concurrently -- +#: the dashboard button and the MCP tool -- and two ``git pull`` / +#: ``pip install`` runs in one checkout would fight over index.lock and +#: could leave a half-applied tree. ``_apply_lock`` is never waited on: a +#: second updater is told one is already running rather than queueing +#: behind a pip that may take minutes. +_apply_lock = threading.Lock() +#: Held across an uncached check so N dashboard tabs opening at once cause +#: one ``git fetch``, not N. Waiters get the result the winner cached. +_check_lock = threading.RLock() + + +def check_for_update( + *, + force: bool = False, + config_path: Path | None = None, + install: Installation | None = None, +) -> UpdateInfo: + """Return update status, using a cached result when it is still fresh. + + Read-only: it fetches git objects (which never touches the working tree) + and shells out to ``git show``. Failures are captured in + :attr:`UpdateInfo.error`, never raised. + + ``install`` overrides autodetection; passing one also bypasses the + cache, since the cache is keyed on "this server" and nothing else. + """ + global _cached + + if install is not None: + return _check_uncached(config_path=config_path, install=install) + + def _fresh() -> UpdateInfo | None: + with _cache_lock: + cached = _cached + if cached is None: + return None + ttl = FAILED_CHECK_TTL_SECONDS if cached.error else CHECK_TTL_SECONDS + return cached if time.time() - cached.checked_at < ttl else None + + if not force: + hit = _fresh() + if hit is not None: + return hit + + with _check_lock: + # Someone may have refreshed it while we waited for the lock; a + # forced check still runs, since that is the point of forcing. + if not force: + hit = _fresh() + if hit is not None: + return hit + info = _check_uncached(config_path=config_path) + with _cache_lock: + _cached = info + return info + + +def cached_update() -> UpdateInfo | None: + """Last check result, without triggering a new one.""" + with _cache_lock: + return _cached + + +def invalidate_cache() -> None: + global _cached + with _cache_lock: + _cached = None + + +def _check_uncached( + *, config_path: Path | None = None, install: Installation | None = None, +) -> UpdateInfo: + install = install or detect_installation() + info = UpdateInfo( + checked_at=time.time(), + version=current_version(), + install_kind=install.kind, + instructions=manual_instructions(install), + ) + + root = install.root + if install.kind != "git" or root is None: + info.error = ( + f"cannot check automatically: this is a {install.kind} install, " + "not a git checkout" + ) + info.blockers = _self_update_blockers(install, root) + return info + + code, head, err = _git(root, "rev-parse", "--short", "HEAD") + if code != 0: + info.error = f"could not read the local revision ({err or 'git failed'})" + return info + info.current_ref = head + + branch = _default_branch(root) + info.branch = branch + + code, _, err = _git(root, "fetch", "--quiet", "origin", branch) + if code != 0: + info.error = f"could not reach the remote ({err or 'git fetch failed'})" + info.blockers = _self_update_blockers(install, root) + return info + + remote_ref = f"origin/{branch}" + code, latest, err = _git(root, "rev-parse", "--short", remote_ref) + if code != 0: + info.error = f"could not read {remote_ref} ({err or 'git failed'})" + return info + info.latest_ref = latest + + code, count, _ = _git(root, "rev-list", "--count", f"HEAD..{remote_ref}") + info.behind = int(count) if code == 0 and count.isdigit() else 0 + info.available = info.behind > 0 + + if not info.available: + return info + + code, log, _ = _git( + root, "log", "--no-merges", "--max-count=20", + "--pretty=format:%h\x1f%s\x1f%aI", f"HEAD..{remote_ref}", + ) + if code == 0 and log: + for line in log.splitlines(): + parts = line.split("\x1f") + if len(parts) == 3: + info.commits.append( + {"sha": parts[0], "subject": parts[1], "date": parts[2]} + ) + + info.drift = config_drift(root, remote_ref, config_path) + info.blockers = _self_update_blockers(install, root) + info.can_self_update = not info.blockers + return info + + +# --------------------------------------------------------------------------- +# Applying an update +# --------------------------------------------------------------------------- + +@dataclass +class UpdateStep: + name: str + ok: bool + detail: str = "" + + def to_json(self) -> dict[str, Any]: + return {"step": self.name, "ok": self.ok, "detail": self.detail} + + +@dataclass +class UpdateResult: + ok: bool + steps: list[UpdateStep] = field(default_factory=list) + from_ref: str | None = None + to_ref: str | None = None + rolled_back: bool = False + restart_scheduled: bool = False + restart_in_seconds: int = 0 + message: str = "" + drift: ConfigDrift = field(default_factory=ConfigDrift) + + def to_json(self) -> dict[str, Any]: + return { + "ok": self.ok, + "steps": [s.to_json() for s in self.steps], + "from_ref": self.from_ref, + "to_ref": self.to_ref, + "rolled_back": self.rolled_back, + "restart_scheduled": self.restart_scheduled, + "restart_in_seconds": self.restart_in_seconds, + "message": self.message, + "config": self.drift.to_json(), + } + + +def _pip_command(install: Installation) -> list[str]: + if install.venv: + for candidate in ( + install.venv / "bin" / "pip", + install.venv / "Scripts" / "pip.exe", + ): + if candidate.exists(): + return [str(candidate)] + return [install.python, "-m", "pip"] + + +def _run(cmd: list[str], cwd: Path, timeout: int) -> tuple[int, str]: + """Run a command, returning ``(returncode, combined output)``.""" + try: + proc = subprocess.run( + cmd, cwd=str(cwd), capture_output=True, text=True, timeout=timeout, + ) + except subprocess.TimeoutExpired: + return 124, f"{' '.join(cmd)} timed out after {timeout}s" + except OSError as exc: + return 1, str(exc) + output = (proc.stdout or "") + (proc.stderr or "") + return proc.returncode, output.strip() + + +def _tail(text: str, limit: int = 1500) -> str: + """Keep the end of a command's output -- that's where errors are.""" + text = text.strip() + return text if len(text) <= limit else "…" + text[-limit:] + + +def _schedule_restart(service: str, delay: int) -> bool: + """Restart the unit after ``delay`` seconds, detached from this process. + + A direct ``systemctl restart`` would kill us mid-response, so the caller + would never learn whether the update worked. Detaching and sleeping lets + the tool result (or the HTTP response) reach the client first. + """ + if not shutil.which("systemctl"): + return False + try: + subprocess.Popen( + # Values go through argv, never interpolated into the script: + # `service` is a literal today, but a future change that made it + # configurable must not turn this into a shell injection. + [ + "sh", "-c", 'sleep "$1"; systemctl restart "$2"', + "sh", str(int(delay)), service, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + return True + except OSError: + return False + + +def apply_update( + *, + restart: bool = True, + restart_delay: int = 5, + config_path: Path | None = None, + install: Installation | None = None, +) -> UpdateResult: + """Pull, reinstall dependencies, validate the config, then restart. + + The config validation is a **gate**: if the new revision cannot load the + operator's configuration (a newly required setting, a renamed key), the + checkout is rolled back to where it started and nothing is restarted. + An unattended update that bricks the server is worse than no update. + """ + if not _apply_lock.acquire(blocking=False): + busy = UpdateResult(ok=False) + busy.message = ( + "An update is already running on this server. Wait for it to " + "finish before starting another one." + ) + busy.steps.append(UpdateStep("preflight", False, busy.message)) + return busy + try: + return _apply_update_locked( + restart=restart, + restart_delay=restart_delay, + config_path=config_path, + install=install, + ) + finally: + _apply_lock.release() + + +def _apply_update_locked( + *, + restart: bool, + restart_delay: int, + config_path: Path | None, + install: Installation | None, +) -> UpdateResult: + install = install or detect_installation() + result = UpdateResult(ok=False) + + root = install.root + blockers = _self_update_blockers(install, root) + if blockers or root is None: + result.message = "Refusing to update: " + "; ".join(blockers) + result.steps.append(UpdateStep("preflight", False, result.message)) + return result + result.steps.append(UpdateStep("preflight", True, "git checkout is clean")) + + code, from_ref, _ = _git(root, "rev-parse", "HEAD") + if code != 0: + result.message = "Could not read the current revision." + result.steps.append(UpdateStep("read-head", False, result.message)) + return result + result.from_ref = from_ref[:12] + + branch = _default_branch(root) + code, out, err = _git(root, "pull", "--ff-only", "origin", branch) + if code != 0: + detail = _tail(err or out) + result.message = ( + f"git pull failed: {detail}. Nothing was changed." + ) + result.steps.append(UpdateStep("git-pull", False, detail)) + return result + code, to_ref, _ = _git(root, "rev-parse", "HEAD") + result.to_ref = to_ref[:12] if code == 0 else None + result.steps.append( + UpdateStep("git-pull", True, f"{result.from_ref} -> {result.to_ref}") + ) + + if result.from_ref == result.to_ref: + result.ok = True + result.message = "Already up to date; nothing to do." + return result + + def _rollback(reason: str) -> UpdateResult: + code, out, err = _git(root, "reset", "--hard", from_ref) + rolled = code == 0 + if rolled: + # Put the dependency set back too, so a half-applied update + # doesn't leave newer libraries against older code. + _run([*_pip_command(install), "install", "-e", "."], root, _PIP_TIMEOUT) + result.rolled_back = rolled + result.steps.append( + UpdateStep( + "rollback", rolled, + f"restored {result.from_ref}" if rolled else _tail(err or out), + ) + ) + result.ok = False + result.message = reason + ( + " The checkout was rolled back and the server was NOT restarted." + if rolled + else " ROLLBACK FAILED -- fix the checkout by hand before restarting." + ) + return result + + code, out = _run( + [*_pip_command(install), "install", "-e", "."], root, _PIP_TIMEOUT, + ) + if code != 0: + result.steps.append(UpdateStep("pip-install", False, _tail(out))) + return _rollback(f"Dependency install failed: {_tail(out, 400)}.") + result.steps.append(UpdateStep("pip-install", True, "dependencies up to date")) + + # Config gate. Run in a subprocess so the *new* code parses the config, + # not the copy this process imported at boot. + validate = [install.python, "-m", "beaconmcp", "validate-config"] + if config_path: + validate += ["--config", str(config_path)] + code, out = _run(validate, root, _GIT_TIMEOUT) + if code != 0: + result.steps.append(UpdateStep("validate-config", False, _tail(out))) + return _rollback( + f"The new revision cannot load your configuration: {_tail(out, 600)}" + ) + result.steps.append(UpdateStep("validate-config", True, "config still loads")) + + result.drift = config_drift(root, "HEAD", config_path) + result.ok = True + + if restart and install.service: + scheduled = _schedule_restart(install.service, restart_delay) + result.restart_scheduled = scheduled + result.restart_in_seconds = restart_delay if scheduled else 0 + result.steps.append( + UpdateStep( + "restart", scheduled, + f"systemctl restart {install.service} in {restart_delay}s" + if scheduled else "could not schedule a restart (no systemctl)", + ) + ) + + bits = [f"Updated {result.from_ref} -> {result.to_ref}."] + if result.restart_scheduled: + bits.append( + f"The service restarts in {restart_delay}s to run the new code." + ) + elif install.service: + bits.append(f"Restart it with: systemctl restart {install.service}") + else: + bits.append("Restart the server process to run the new code.") + if result.drift.new_env_vars: + bits.append( + "New environment variables you may need to set in .env: " + + ", ".join(result.drift.new_env_vars) + ) + if result.drift.new_config_keys: + bits.append( + "New beaconmcp.yaml settings are available: " + + ", ".join(result.drift.new_config_keys[:10]) + ) + result.message = " ".join(bits) + return result diff --git a/tests/test_dashboard_chat.py b/tests/test_dashboard_chat.py index f4f1d85..b57e6d7 100644 --- a/tests/test_dashboard_chat.py +++ b/tests/test_dashboard_chat.py @@ -175,11 +175,11 @@ def test_conv_create_and_list(app_and_client): r = client.post( "/app/api/conversations", headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, - content=json.dumps({"model": "gemini-3-flash-preview", "effort": "medium"}), + content=json.dumps({"model": "gemini-3.5-flash-lite", "effort": "medium"}), ) assert r.status_code == 201 conv = r.json()["conversation"] - assert conv["model"] == "gemini-3-flash-preview" + assert conv["model"] == "gemini-3.5-flash-lite" assert conv["thinking_effort"] == "medium" r = client.get("/app/api/conversations") @@ -848,7 +848,8 @@ def test_chat_page_renders_after_login(app_and_client): r = client.get("/app/chat") assert r.status_code == 200 assert "chat-root" in r.text - assert "gemini-3-flash-preview" in r.text + assert "gemini-3.6-flash" in r.text + assert "gemini-3.5-flash-lite" in r.text assert "gemini-3.1-pro-preview" in r.text @@ -899,3 +900,70 @@ def test_needs_confirmation_includes_run_tools(): assert "ssh_exec_get_result" not in _NEEDS_CONFIRMATION assert not _tool_call_requires_confirmation("proxmox_list_nodes", {}) assert not _tool_call_requires_confirmation("cluster_overview", {}) + + +def test_self_update_needs_confirmation_when_applied(): + """Applying an update is the most consequential call this server has. + + ``beaconmcp_self_update(confirm=True)`` pulls new code, reinstalls + dependencies and restarts the service. Nothing stops an injected + instruction from asking for it, so it must reach the modal -- while the + ``confirm=False`` preview shape stays a plain read. + """ + from beaconmcp.dashboard.chat import _tool_call_requires_confirmation + + assert _tool_call_requires_confirmation("beaconmcp_self_update", {"confirm": True}) + assert not _tool_call_requires_confirmation("beaconmcp_self_update", {"confirm": False}) + assert not _tool_call_requires_confirmation("beaconmcp_self_update", {}) + # Checking is read-only and must not raise a modal. + assert not _tool_call_requires_confirmation("beaconmcp_check_update", {}) + + +def test_every_registered_tool_is_gated_or_deliberately_not(): + """Guard against a new tool quietly landing outside the gate. + + The list below is the reviewed set of tools that may run unattended: + reads, and calls that only add (create/clone/start/backup). A new tool + name showing up here means someone must decide which side it is on -- + which is exactly how ``beaconmcp_self_update`` slipped through when the + self-update tools were added. + """ + import pathlib + import re + + from beaconmcp.dashboard.chat import ( + _CONFIRM_WHEN_ARG_PRESENT, + _NEEDS_CONFIRMATION, + ) + + src_root = pathlib.Path(__file__).parent.parent / "src" / "beaconmcp" + registered: set[str] = set() + for path in src_root.rglob("*.py"): + registered.update( + re.findall( + r"@mcp\.tool\((?:[^)]*|\s*\n(?:.*\n)*?\s*)\)\s*\n\s*(?:async )?def (\w+)", + path.read_text(), + ) + ) + assert "ssh_run" in registered, "tool discovery regex stopped matching" + + ungated_by_design = { + "beaconmcp_check_update", + "bmc_get_event_log", "bmc_health_status", "bmc_list_devices", + "bmc_power_on", "bmc_power_status", "bmc_server_info", + "cluster_health", "cluster_overview", "cluster_overview_interactive", + "proxmox_backup_create", "proxmox_backup_list", + "proxmox_get_logs", "proxmox_get_tasks", + "proxmox_list_nodes", "proxmox_list_transfers", "proxmox_list_vms", + "proxmox_logs_panel", "proxmox_network_config", "proxmox_node_status", + "proxmox_read_file", "proxmox_snapshot_create", "proxmox_snapshot_list", + "proxmox_storage_status", "proxmox_vm_clone", "proxmox_vm_create", + "proxmox_vm_panel", "proxmox_vm_start", "proxmox_vm_status", + "security_end_session", "ssh_list_sessions", "vm_find", + } + gated = set(_NEEDS_CONFIRMATION) | set(_CONFIRM_WHEN_ARG_PRESENT) + unclassified = registered - gated - ungated_by_design + assert not unclassified, ( + "new tool(s) outside the confirmation gate; decide and list them: " + f"{sorted(unclassified)}" + ) diff --git a/tests/test_dashboard_mcp_apps.py b/tests/test_dashboard_mcp_apps.py new file mode 100644 index 0000000..86722d9 --- /dev/null +++ b/tests/test_dashboard_mcp_apps.py @@ -0,0 +1,560 @@ +"""MCP Apps support in the dashboard chat (#35). + +The dashboard is both the MCP client that negotiates the extension and the +host that renders the ``ui://`` documents. These tests pin the parts a +refactor could quietly break without any visible symptom: the capability on +the wire, the headers that decide whether a panel can be framed at all and +what it may reach from inside the frame, and the allow-list that says which +tool calls a panel may make on its own. +""" + +from __future__ import annotations + +import json +import os +import sys +from contextlib import asynccontextmanager +from pathlib import Path + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from beaconmcp.dashboard import mcp_bridge +from beaconmcp.dashboard.app import DashboardDeps, build_dashboard_routes +from beaconmcp.dashboard.chat import ( + FakeChatEngine, + FakeScript, + ToolCallEnd, + ToolCallStart, + assemble_assistant_message, + format_app_context, + panel_call_allowed, +) +from beaconmcp.dashboard.confirmations import ConfirmationStore +from beaconmcp.dashboard.conversations import ConversationStore +from beaconmcp.dashboard.csrf import CSRF_COOKIE +from beaconmcp.dashboard.db import Database +from beaconmcp.dashboard.session import SessionStore + +from test_dashboard_chat import FakeClientStore, FakeTokenStore # noqa: E402 + + +PANEL_URI = "ui://beaconmcp/vm-panel.html" +PANEL_HTML = "vm" + + +# --------------------------------------------------------------------------- +# Client half: capability negotiation and _meta.ui plumbing +# --------------------------------------------------------------------------- + +@pytest.mark.anyio +async def test_initialize_declares_the_apps_extension(monkeypatch): + """The extension rides on ``ClientCapabilities`` even on mcp 1.x. + + ``extensions`` only becomes a typed field in mcp 2.0, which is what made + this look blocked behind the ``<2`` pin. It is not: the model allows + extras, so the field serialises under the name the spec gives it. If a + future SDK bump makes ``ClientCapabilities`` strict, this fails here + rather than silently dropping the capability at runtime. + """ + from mcp import types + from mcp.client.session import ClientSession + + captured: dict = {} + + async def fake_send_request(self, request, result_type, **kwargs): + captured["request"] = request + return None + + monkeypatch.setattr(ClientSession, "send_request", fake_send_request) + + session = object.__new__(mcp_bridge.AppsClientSession) + request = types.ClientRequest( + types.InitializeRequest( + params=types.InitializeRequestParams( + protocolVersion=types.LATEST_PROTOCOL_VERSION, + capabilities=types.ClientCapabilities(), + clientInfo=types.Implementation(name="t", version="1"), + ), + ) + ) + await session.send_request(request, types.InitializeResult) + + wire = captured["request"].model_dump(by_alias=True, exclude_none=True) + extensions = wire["params"]["capabilities"]["extensions"] + assert extensions == { + "io.modelcontextprotocol/ui": { + "mimeTypes": ["text/html;profile=mcp-app"], + } + } + + +@pytest.mark.anyio +async def test_non_initialize_requests_pass_through_untouched(monkeypatch): + """Only the handshake is rewritten; every other request goes as-is.""" + from mcp import types + from mcp.client.session import ClientSession + + captured: dict = {} + + async def fake_send_request(self, request, result_type, **kwargs): + captured["request"] = request + return None + + monkeypatch.setattr(ClientSession, "send_request", fake_send_request) + + session = object.__new__(mcp_bridge.AppsClientSession) + original = types.ClientRequest(types.ListToolsRequest(method="tools/list")) + await session.send_request(original, types.ListToolsResult) + + assert captured["request"] is original + assert captured["request"].model_dump(by_alias=True, exclude_none=True) == { + "method": "tools/list", + } + + +def test_ui_resource_uri_extraction(): + assert mcp_bridge.ui_resource_uri({"ui": {"resourceUri": PANEL_URI}}) == PANEL_URI + assert mcp_bridge.ui_resource_uri(None) is None + assert mcp_bridge.ui_resource_uri({"ui": {}}) is None + # A non-ui:// target is not a panel. Honouring one would let a tool + # point the host's iframe at an arbitrary URL. + assert mcp_bridge.ui_resource_uri({"ui": {"resourceUri": "https://evil/x"}}) is None + assert mcp_bridge.ui_resource_uri({"ui": {"resourceUri": "file:///etc/passwd"}}) is None + + +def test_ui_resource_uris_by_tool(): + class _Tool: + def __init__(self, name, meta): + self.name = name + self.meta = meta + + mapping = mcp_bridge.ui_resource_uris_by_tool([ + _Tool("proxmox_vm_panel", {"ui": {"resourceUri": PANEL_URI}}), + _Tool("proxmox_list_vms", None), + ]) + assert mapping == {"proxmox_vm_panel": PANEL_URI} + + +class _FakeResourceContent: + def __init__(self, text, mime): + self.text = text + self.mimeType = mime + + +class _FakeReadResult: + def __init__(self, contents): + self.contents = contents + + +class _FakeSession: + def __init__(self, contents): + self._contents = contents + + async def read_resource(self, uri): + return _FakeReadResult(self._contents) + + +@pytest.mark.anyio +async def test_read_ui_resource_accepts_an_app_document(): + session = _FakeSession([_FakeResourceContent(PANEL_HTML, "text/html;profile=mcp-app")]) + assert await mcp_bridge.read_ui_resource(session, PANEL_URI) == PANEL_HTML + + +@pytest.mark.anyio +async def test_read_ui_resource_refuses_a_plain_resource(): + """The MIME check is what stops this being a generic resource proxy.""" + session = _FakeSession([_FakeResourceContent("nodes: []", "text/plain")]) + with pytest.raises(mcp_bridge.UiResourceError): + await mcp_bridge.read_ui_resource(session, PANEL_URI) + + +@pytest.mark.anyio +async def test_read_ui_resource_refuses_a_non_ui_scheme(): + session = _FakeSession([_FakeResourceContent("x", "text/html;profile=mcp-app")]) + with pytest.raises(mcp_bridge.UiResourceError): + await mcp_bridge.read_ui_resource(session, "beaconmcp://infrastructure") + + +# --------------------------------------------------------------------------- +# The panel allow-list -- the decision #35 asked to be made explicitly +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("name,args", [ + ("proxmox_vm_panel", {"node": "pve1", "vmid": 104}), + ("cluster_overview_interactive", {}), + ("proxmox_vm_start", {"node": "pve1", "vmid": 104}), + ("proxmox_vm_stop", {"node": "pve1", "vmid": 104}), + ("proxmox_vm_restart", {"node": "pve1", "vmid": 104}), + ("proxmox_vm_config", {"node": "pve1", "vmid": 104, "updates": {"cores": 4}}), +]) +def test_panel_may_drive_guest_lifecycle(name, args): + """A labelled button on one guest is the click; a modal would restate it.""" + assert panel_call_allowed(name, args) is True + + +@pytest.mark.parametrize("name,args", [ + ("ssh_run", {"host": "pve1", "command": "rm -rf /"}), + ("proxmox_run", {"node": "pve1", "vmid": 104, "command": "id"}), + ("proxmox_write_file", {"node": "pve1", "vmid": 104, "path": "/root/.ssh/authorized_keys"}), + ("vm_bulk_action", {"vmids": [1, 2, 3], "action": "stop"}), + ("proxmox_snapshot_rollback", {"node": "pve1", "vmid": 104, "snapname": "s"}), + ("proxmox_backup_restore", {"node": "pve1", "vmid": 104}), + ("bmc_power_off", {"device_id": "rack1"}), + # updates is an open-ended guest config. Exempting the tool wholesale + # would exempt hookscript, raw QEMU args and device passthrough with it. + ("proxmox_vm_config", {"node": "pve1", "vmid": 104, + "updates": {"hookscript": "local:snippets/x.sh"}}), + ("proxmox_vm_config", {"node": "pve1", "vmid": 104, + "updates": {"cores": 4, "args": "-device x"}}), + # Pulls new code, reinstalls dependencies and restarts the service. + ("beaconmcp_self_update", {"confirm": True}), +]) +def test_panel_may_not_reach_the_gated_tools(name, args): + """The exemption is a closed list, not "iframe calls skip the gate". + + A ui:// document is HTML the server wrote. Letting one through the gate + by virtue of being in a frame would hand every connected MCP server a + way around the approval it is documented to be subject to. + """ + assert panel_call_allowed(name, args) is False + + +# --------------------------------------------------------------------------- +# Model context from panels +# --------------------------------------------------------------------------- + +def test_format_app_context_labels_the_source(): + block = format_app_context([ + {"tool": "proxmox_vm_panel", "text": "VM 104 is now stopped.", "structured": {"status": "stopped"}}, + ]) + assert "proxmox_vm_panel" in block + assert "VM 104 is now stopped." in block + assert '"status": "stopped"' in block + # The model has to know this did not come from the operator's keyboard. + assert "did not go through you" in block + + +def test_format_app_context_empty(): + assert format_app_context([]) == "" + assert format_app_context(None) == "" + assert format_app_context([{"tool": "x"}]) == "" + + +def test_format_app_context_truncates(): + block = format_app_context([ + {"tool": "cluster", "text": "x" * 10_000, "structured": None}, + ]) + assert len(block) < 5_000 + + +def test_assemble_persists_only_the_panel_uri(): + """The snapshot is not stored -- reopening refetches instead.""" + content, tool_calls, _ = assemble_assistant_message([ + ToolCallStart(id="1", name="proxmox_vm_panel", args={"node": "pve1", "vmid": 104}), + ToolCallEnd( + id="1", status="ok", preview="{}", duration_ms=12, + ui={"resourceUri": PANEL_URI, "result": {"structuredContent": {"vmid": 104}}}, + ), + ]) + assert tool_calls[0].ui_resource_uri == PANEL_URI + assert "result" not in tool_calls[0].to_json() + + +# --------------------------------------------------------------------------- +# Host routes +# --------------------------------------------------------------------------- + +@pytest.fixture() +def engine(): + return FakeChatEngine(FakeScript(events=[ + ToolCallStart(id="fc_0", name="proxmox_vm_panel", args={"node": "pve1", "vmid": 104}), + ToolCallEnd( + id="fc_0", status="ok", preview='{"vmid": 104}', duration_ms=7, + ui={ + "resourceUri": PANEL_URI, + "result": {"content": [], "isError": False, + "structuredContent": {"vmid": 104, "status": "running"}}, + }, + ), + ])) + + +@pytest.fixture() +def deps(tmp_path, engine): + db = Database(tmp_path / "dashboard.db") + return DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + conversations=ConversationStore(db), + engine=engine, + confirmations=ConfirmationStore(), + ) + + +@pytest.fixture() +def client(deps): + mcp_bridge.cache_clear() + app = Starlette(routes=build_dashboard_routes(deps)) + return TestClient(app, follow_redirects=False) + + +def _login(client): + r = client.get("/app/login") + csrf = r.cookies.get(CSRF_COOKIE) + r = client.post("/app/login", data={ + "csrf_token": csrf, "client_id": "c", + "client_secret": "s", "totp": "123456", "remember": "on", + }) + assert r.status_code == 303 + return client.cookies.get(CSRF_COOKIE) + + +class _StubSession: + """Stands in for a live MCP session in the two host routes.""" + + def __init__(self): + self.calls: list[tuple[str, dict]] = [] + + async def call_tool(self, name, args): + self.calls.append((name, args)) + + class _Result: + content = [] + isError = False + structuredContent = {"ok": True} + + return _Result() + + +@pytest.fixture() +def stub_mcp(monkeypatch): + stub = _StubSession() + + @asynccontextmanager + async def fake_open_session(url, bearer, **kwargs): + yield stub + + async def fake_read(session, uri): + if uri != PANEL_URI: + raise mcp_bridge.UiResourceError("unknown resource") + return PANEL_HTML + + monkeypatch.setattr(mcp_bridge, "open_session", fake_open_session) + monkeypatch.setattr(mcp_bridge, "read_ui_resource", fake_read) + return stub + + +# --- panel document --------------------------------------------------------- + +def test_panel_route_requires_a_session(client, stub_mcp): + r = client.get(f"/app/api/mcp/panel?uri={PANEL_URI}") + assert r.status_code == 401 + + +def test_panel_route_rejects_a_non_ui_uri(client, stub_mcp): + _login(client) + r = client.get("/app/api/mcp/panel?uri=beaconmcp://infrastructure") + assert r.status_code == 400 + assert r.json()["error"] == "invalid_uri" + + +def test_panel_route_serves_the_document_with_its_own_csp(client, stub_mcp): + """Framable by us, inline script allowed, and no way out of the frame. + + The default dashboard headers are the opposite on both counts -- + ``X-Frame-Options: DENY`` would stop the panel rendering at all and + ``script-src 'self'`` would kill the inline bridge -- so this route + setting its own is load-bearing, not tidiness. + """ + _login(client) + r = client.get(f"/app/api/mcp/panel?uri={PANEL_URI}") + assert r.status_code == 200 + assert r.text == PANEL_HTML + + csp = r.headers["content-security-policy"] + assert "frame-ancestors 'self'" in csp + assert "script-src 'unsafe-inline'" in csp + # The panel reaches the cluster through its parent, never directly. + assert "connect-src 'none'" in csp + assert "default-src 'none'" in csp + assert r.headers["x-frame-options"] == "SAMEORIGIN" + assert "DENY" not in r.headers["x-frame-options"] + + +def test_panel_route_reports_an_unreadable_resource(client, stub_mcp): + _login(client) + r = client.get("/app/api/mcp/panel?uri=ui://beaconmcp/nope.html") + assert r.status_code == 502 + assert r.json()["error"] == "resource_unavailable" + + +def test_panel_documents_are_cached(client, stub_mcp, monkeypatch): + _login(client) + reads: list[str] = [] + + async def counting_read(session, uri): + reads.append(uri) + return PANEL_HTML + + monkeypatch.setattr(mcp_bridge, "read_ui_resource", counting_read) + for _ in range(3): + assert client.get(f"/app/api/mcp/panel?uri={PANEL_URI}").status_code == 200 + assert len(reads) == 1 + + +# --- tools/call relay ------------------------------------------------------- + +def test_relay_requires_a_session(client, stub_mcp): + r = client.post( + "/app/api/mcp/call", + headers={"Content-Type": "application/json"}, + content=json.dumps({"name": "proxmox_vm_stop", "arguments": {}}), + ) + assert r.status_code == 401 + + +def test_relay_requires_csrf(client, stub_mcp): + _login(client) + r = client.post( + "/app/api/mcp/call", + headers={"Content-Type": "application/json"}, + content=json.dumps({"name": "proxmox_vm_stop", "arguments": {}}), + ) + assert r.status_code == 403 + assert r.json()["error"] == "csrf" + + +def test_relay_runs_an_allowed_tool(client, stub_mcp): + csrf = _login(client) + r = client.post( + "/app/api/mcp/call", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({ + "name": "proxmox_vm_stop", "arguments": {"node": "pve1", "vmid": 104}, + }), + ) + assert r.status_code == 200 + assert r.json()["result"]["structuredContent"] == {"ok": True} + assert stub_mcp.calls == [("proxmox_vm_stop", {"node": "pve1", "vmid": 104})] + + +def test_relay_refuses_a_gated_tool_without_calling_it(client, stub_mcp): + csrf = _login(client) + r = client.post( + "/app/api/mcp/call", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({ + "name": "ssh_run", "arguments": {"host": "pve1", "command": "id"}, + }), + ) + assert r.status_code == 403 + assert r.json()["error"] == "confirmation_required" + assert stub_mcp.calls == [] + + +def test_relay_rejects_a_malformed_body(client, stub_mcp): + csrf = _login(client) + r = client.post( + "/app/api/mcp/call", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({"name": "", "arguments": {}}), + ) + assert r.status_code == 400 + + +# --- turn plumbing ---------------------------------------------------------- + +def _parse_sse(text): + events = [] + for frame in text.strip().split("\n\n"): + ev, data = None, [] + for line in frame.split("\n"): + if line.startswith("event:"): + ev = line[6:].strip() + elif line.startswith("data:"): + data.append(line[5:].strip()) + events.append((ev, json.loads("\n".join(data)) if data else {})) + return events + + +def test_tool_result_frame_carries_the_panel(client, deps): + """Without this the browser never learns there is a frame to mount.""" + csrf = _login(client) + r = client.post( + "/app/api/conversations", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content="{}", + ) + conv_id = r.json()["conversation"]["id"] + + r = client.post( + "/app/api/chat/stream", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({"conversation_id": conv_id, "content": "open vm 104"}), + ) + results = [d for e, d in _parse_sse(r.text) if e == "tool_result"] + assert results and results[0]["ui"]["resourceUri"] == PANEL_URI + assert results[0]["ui"]["result"]["structuredContent"]["status"] == "running" + + stored = deps.conversations.list_messages(conv_id)[1].tool_calls[0] + assert stored.ui_resource_uri == PANEL_URI + + +def test_app_context_reaches_the_turn(client, engine): + csrf = _login(client) + r = client.post( + "/app/api/conversations", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content="{}", + ) + conv_id = r.json()["conversation"]["id"] + + client.post( + "/app/api/chat/stream", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({ + "conversation_id": conv_id, + "content": "et maintenant ?", + "app_context": [ + {"tool": "proxmox_vm_panel", "text": "VM 104 is now stopped.", + "structured": {"status": "stopped"}}, + "junk", + {"tool": "x"}, + ], + }), + ) + assert engine.calls[0].app_context == [ + {"tool": "proxmox_vm_panel", "text": "VM 104 is now stopped.", + "structured": {"status": "stopped"}}, + ] + + +def test_app_context_entry_count_is_capped(client, engine): + csrf = _login(client) + r = client.post( + "/app/api/conversations", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content="{}", + ) + conv_id = r.json()["conversation"]["id"] + client.post( + "/app/api/chat/stream", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({ + "conversation_id": conv_id, + "content": "ping", + "app_context": [ + {"tool": f"p{i}", "text": "x"} for i in range(50) + ], + }), + ) + assert len(engine.calls[0].app_context) == 8 diff --git a/tests/test_dashboard_unit.py b/tests/test_dashboard_unit.py index 65091af..13f9908 100644 --- a/tests/test_dashboard_unit.py +++ b/tests/test_dashboard_unit.py @@ -261,7 +261,8 @@ def test_classify_error_preview_model_permission_denied(): ) code, msg = _classify_error(err, "gemini-3-flash-preview") assert code == "model_access_denied" - assert "gemini-2.5" in msg + # The way out must name models the picker still offers. + assert "gemini-3.6-flash" in msg assert "gemini-3-flash-preview" in msg @@ -693,19 +694,83 @@ def test_migration_v1_to_v2_renames_gemini_models(tmp_path): conn.commit() conn.close() - # Opening via Database() should run migration v2. + # Opening via Database() runs every pending migration, so c1 is first + # renamed to gemini-3-flash-preview by v2 and then moved forward to the + # current Flash by v6. db = Database(path) rows = db.conn().execute( "SELECT id, model FROM conversations ORDER BY id" ).fetchall() - assert dict(rows[0]) == {"id": "c1", "model": "gemini-3-flash-preview"} + assert dict(rows[0]) == {"id": "c1", "model": "gemini-3.6-flash"} assert dict(rows[1]) == {"id": "c2", "model": "gemini-3.1-pro-preview"} + # v2 renamed the same model; v6 substitutes a different one, so the + # message keeps naming whichever model actually wrote the reply. msg = db.conn().execute("SELECT model FROM messages WHERE id='m1'").fetchone() assert msg["model"] == "gemini-3-flash-preview" # user_version reflects the migration (latest schema version). + from beaconmcp.dashboard.db import _LATEST_VERSION + ver = db.conn().execute("PRAGMA user_version").fetchone()[0] - assert ver == 4 + assert ver == _LATEST_VERSION + + +def test_migration_v6_moves_retired_models_forward(tmp_path): + """A conversation left on a retired model must still be usable. + + ``conversations.model`` is what the next turn runs on. Leaving a + retired id there would fail ``VALID_MODELS`` and silently fall back, + which reads as the picker forgetting the operator's choice. + """ + from beaconmcp.dashboard.conversations import VALID_MODELS + + db = Database(tmp_path / "d.db") + conn = db.conn() + for cid, model in ( + ("c1", "gemini-2.5-flash"), + ("c2", "gemini-2.5-pro"), + ("c3", "gemini-3-flash-preview"), + ("c4", "gemini-3.1-pro-preview"), + ): + conn.execute( + "INSERT INTO conversations (id, client_id, title, model, " + "thinking_effort, created_at, updated_at) VALUES (?,?,?,?,?,?,?)", + (cid, "cli", None, model, "low", 0, 0), + ) + conn.execute("PRAGMA user_version = 5") + conn.commit() + + from beaconmcp.dashboard.db import _migrate + + _migrate(conn) + rows = conn.execute( + "SELECT id, model FROM conversations ORDER BY id" + ).fetchall() + assert [r["model"] for r in rows] == [ + "gemini-3.6-flash", + "gemini-3.1-pro-preview", + "gemini-3.6-flash", + "gemini-3.1-pro-preview", + ] + assert all(r["model"] in VALID_MODELS for r in rows) + + +def test_every_offered_model_has_a_price(): + """A model in the picker with no rate would bill at the fallback's.""" + from beaconmcp.dashboard.conversations import DEFAULT_MODEL, VALID_MODELS + from beaconmcp.dashboard.usage import _PRICING + + assert DEFAULT_MODEL in VALID_MODELS + for model in VALID_MODELS: + assert model in _PRICING, model + + +def test_retired_models_keep_their_rates(): + """Old turns must not be re-priced at the current model's rate.""" + from beaconmcp.dashboard.usage import _PRICING + + for model in ("gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-flash-preview"): + assert model in _PRICING, model def test_short_ciphertext_decryption_returns_none(store): diff --git a/tests/test_dashboard_usage.py b/tests/test_dashboard_usage.py index dfa02c3..acbf1b0 100644 --- a/tests/test_dashboard_usage.py +++ b/tests/test_dashboard_usage.py @@ -113,10 +113,30 @@ def test_cost_unknown_model_falls_back_to_flash(): "gemini-unknown-9", prompt_tokens=1_000_000, cached_tokens=0, output_tokens=0, ) - # Same rate as gemini-2.5-flash input. + # Same rate as gemini-3.6-flash input, the current default. + assert cost == pytest.approx(1.50) + + +def test_cost_retired_model_keeps_its_own_rate(): + """A turn billed on 2.5 Flash must not be re-priced at 3.6 Flash.""" + cost = UsageMeter.cost_usd( + "gemini-2.5-flash", + prompt_tokens=1_000_000, cached_tokens=0, output_tokens=0, + ) assert cost == pytest.approx(0.30) +def test_cost_new_flash_models(): + assert UsageMeter.cost_usd( + "gemini-3.6-flash", + prompt_tokens=1_000_000, cached_tokens=0, output_tokens=1_000_000, + ) == pytest.approx(1.50 + 7.50) + assert UsageMeter.cost_usd( + "gemini-3.5-flash-lite", + prompt_tokens=1_000_000, cached_tokens=0, output_tokens=1_000_000, + ) == pytest.approx(0.30 + 2.50) + + def test_cost_cached_over_prompt_is_clamped(): # Defensive: if Gemini ever reports more cached tokens than prompt # tokens, the billable_input floor is 0 (not negative). diff --git a/tests/test_mcp_apps_panel.py b/tests/test_mcp_apps_panel.py new file mode 100644 index 0000000..ee9b699 --- /dev/null +++ b/tests/test_mcp_apps_panel.py @@ -0,0 +1,247 @@ +"""Tests for the MCP Apps panels. + +The value here is the wire format: a host only renders a panel if the tool +carries `_meta.ui.resourceUri` and the resource comes back under the mcp-app +MIME type, so these assert what goes out on the wire rather than the Python +objects behind it. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +import pytest +from mcp.server.fastmcp import FastMCP + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from beaconmcp.proxmox.panel import ( + APP_MIME_TYPE, + CLUSTER_PANEL_URI, + LOGS_PANEL_URI, + VM_PANEL_URI, + _syslog_level, + _vm_snapshot, + register_panel_tools, +) + +_NOT_FOUND = {"error": "Configuration file 'nodes/pve1/qemu/100.conf' does not exist"} + +_QEMU_STATUS = { + "status": "running", + "name": "web-101", + "cpu": 0.1234, + "cpus": 4, + "mem": 2 * 1048576 * 1024, + "maxmem": 4 * 1048576 * 1024, + # QEMU reports 0 unless the guest agent answers. + "disk": 0, + "maxdisk": 32 * 1073741824, + "uptime": 7200, +} + +_PANELS = [ + ("proxmox_vm_panel", VM_PANEL_URI), + ("proxmox_logs_panel", LOGS_PANEL_URI), + ("cluster_overview_interactive", CLUSTER_PANEL_URI), +] + + +class _Client: + """Minimal ProxmoxClient stand-in keyed on the path suffix.""" + + def __init__(self, responses: dict[str, Any] | None = None) -> None: + self.responses = responses or {} + self.calls: list[str] = [] + self.kwargs: list[dict[str, Any]] = [] + self.configured_nodes = ["pve1"] + + def get(self, node: str, path: str, **kwargs: Any) -> Any: + self.calls.append(path) + self.kwargs.append(kwargs) + for suffix, value in self.responses.items(): + if path.endswith(suffix): + return value + return _NOT_FOUND + + +def _server(client: Any) -> FastMCP: + mcp = FastMCP("test") + register_panel_tools(mcp, client) + return mcp + + +async def _tool(mcp: FastMCP, name: str) -> Any: + return next(t for t in await mcp.list_tools() if t.name == name) + + +# --- wire format ------------------------------------------------------------ + + +@pytest.mark.parametrize("name,uri", _PANELS) +async def test_tool_points_at_its_panel(name: str, uri: str) -> None: + tool = await _tool(_server(_Client()), name) + assert tool.meta == {"ui": {"resourceUri": uri, "visibility": ["model", "app"]}} + + +@pytest.mark.parametrize("name,uri", _PANELS) +async def test_advertised_resource_resolves_as_an_app(name: str, uri: str) -> None: + """A resourceUri that 404s on resources/read renders as a blank frame.""" + mcp = _server(_Client()) + listed = next(r for r in await mcp.list_resources() if str(r.uri) == uri) + assert listed.mimeType == APP_MIME_TYPE + + contents = list(await mcp.read_resource(uri)) + assert contents, f"{uri} is advertised but reads back empty" + + +@pytest.mark.parametrize("name,uri", _PANELS) +async def test_shared_runtime_is_spliced_in(name: str, uri: str) -> None: + """The marker must be gone and both halves of the runtime present. + + A panel that ships with the literal still in it has no + bridge and no styles: it loads, does nothing, and never speaks to the host. + """ + html = list(await _server(_Client()).read_resource(uri))[0].content + + assert "" not in html + assert "const MCPApp" in html, "bridge.js missing" + assert "--font-sans" in html, "panel.css missing" + + +async def test_handshake_params_are_flat() -> None: + """ui/initialize takes appInfo / appCapabilities / protocolVersion, flat. + + Nesting them under `capabilities`, or sending `clientInfo` instead of + `appInfo`, fails the host's schema check -- and a rejected handshake is + silent: the host just never replies, so the app never sends `initialized` + and never receives the tool result. The panel then sits on "Loading..." + with nothing in the console to explain it. + """ + html = list(await _server(_Client()).read_resource(VM_PANEL_URI))[0].content + params = html.split('request("ui/initialize", {', 1)[1].split("})", 1)[0] + + assert "appInfo:" in params + assert "appCapabilities:" in params + assert "protocolVersion:" in params + assert "clientInfo" not in params + assert "capabilities: {" not in params + + +# --- VM snapshot ------------------------------------------------------------ + + +def test_qemu_disk_usage_is_null_not_zero() -> None: + """0 from QEMU means "no guest agent", which must not render as an empty bar.""" + client = _Client({"qemu/100/status/current": _QEMU_STATUS, "qemu/100/config": {"cores": 4, "memory": 4096}}) + + snap = _vm_snapshot(client, "pve1", 100) + + assert snap["disk_used_gb"] is None + assert snap["disk_max_gb"] == 32.0 + + +def test_qemu_snapshot_maps_status_and_config() -> None: + client = _Client({"qemu/100/status/current": _QEMU_STATUS, "qemu/100/config": {"cores": 4, "memory": 4096}}) + + assert _vm_snapshot(client, "pve1", 100) == { + "node": "pve1", "vmid": 100, "type": "qemu", "name": "web-101", + "status": "running", "cpu_pct": 12.3, "cpus": 4, + "mem_used_mb": 2048, "mem_max_mb": 4096, + "disk_used_gb": None, "disk_max_gb": 32.0, "uptime_h": 2.0, + "cores": 4, "memory_mb": 4096, + } + + +def test_falls_through_to_lxc_when_no_qemu_guest() -> None: + client = _Client({ + "lxc/200/status/current": { + "status": "running", "name": "ct", "cpu": 0, "cpus": 1, + "mem": 0, "maxmem": 536870912, + "disk": 5 * 1073741824, "maxdisk": 10 * 1073741824, "uptime": 0, + }, + "lxc/200/config": {"cores": 1, "memory": 512}, + }) + + snap = _vm_snapshot(client, "pve1", 200) + + assert snap["type"] == "lxc" + assert snap["disk_used_gb"] == 5.0 + + +def test_missing_guest_reports_an_error() -> None: + assert "not found" in _vm_snapshot(_Client(), "pve1", 999)["error"] + + +def test_unreadable_config_still_renders_live_state() -> None: + """A config read can fail on its own; the panel should degrade, not blank out.""" + client = _Client({ + "qemu/100/status/current": _QEMU_STATUS, + "qemu/100/config": {"error": "connection refused"}, + }) + + snap = _vm_snapshot(client, "pve1", 100) + + assert snap["status"] == "running" + assert snap["cores"] is None + + +def test_transport_error_is_not_mistaken_for_a_missing_guest() -> None: + """Only a "does not exist" error means "try the other guest type".""" + client = _Client({"qemu/100/status/current": {"error": "connection refused"}}) + + snap = _vm_snapshot(client, "pve1", 100) + + assert snap["error"] == "connection refused" + assert not any("lxc" in call for call in client.calls) + + +# --- logs panel ------------------------------------------------------------- + + +@pytest.mark.parametrize( + "line,expected", + [ + ("kernel: EXT4-fs error (device sda1)", "error"), + ("corosync: connection refused to node pve2", "error"), + ("pvedaemon: deprecated option 'foo'", "warn"), + ("systemd[1]: Started Daily apt upgrade.", "info"), + ], +) +def test_syslog_lines_are_classified(line: str, expected: str) -> None: + assert _syslog_level(line) == expected + + +async def test_logs_panel_labels_syslog_lines() -> None: + client = _Client({"syslog": [{"t": "kernel: I/O error"}, {"t": "systemd: Started thing"}]}) + tool = await _server(client).call_tool("proxmox_logs_panel", {"node": "pve1"}) + + entries = tool[1]["entries"] + assert [e["level"] for e in entries] == ["error", "info"] + assert entries[0]["text"] == "kernel: I/O error" + + +async def test_logs_panel_flags_failed_tasks() -> None: + """Proxmox writes "OK" on success; anything else is the failure message.""" + client = _Client({"tasks": [ + {"upid": "UPID:1", "type": "vzdump", "status": "OK", "user": "root@pam"}, + {"upid": "UPID:2", "type": "qmstart", "status": "unable to start VM", "user": "root@pam"}, + {"upid": "UPID:3", "type": "qmigrate", "status": None, "user": "root@pam"}, + ]}) + + result = await _server(client).call_tool( + "proxmox_logs_panel", {"node": "pve1", "source": "tasks"}, + ) + + # A running task has no status yet and must not be painted as a failure. + assert [e["level"] for e in result[1]["entries"]] == ["info", "error", "info"] + + +async def test_logs_panel_caps_the_line_count() -> None: + """The Proxmox API rejects anything above 500, and the panel's box has no max.""" + client = _Client({"syslog": []}) + await _server(client).call_tool("proxmox_logs_panel", {"node": "pve1", "limit": 9000}) + + assert client.kwargs[0]["limit"] == 500 diff --git a/tests/test_passkeys.py b/tests/test_passkeys.py new file mode 100644 index 0000000..06279ac --- /dev/null +++ b/tests/test_passkeys.py @@ -0,0 +1,808 @@ +"""Passkey (WebAuthn) tests. + +Drives the real ceremonies end to end against a software authenticator +built here from ``cryptography`` primitives: a fake that only returned +canned dicts would prove nothing, since every interesting failure mode +(wrong origin, replayed challenge, foreign credential) lives inside the +signature verification. + +Run with:: + + pytest tests/test_passkeys.py -v +""" + +from __future__ import annotations + +import hashlib +import json +import os +import struct +import sys +import time +from pathlib import Path + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +pytest.importorskip("webauthn", reason="passkey support is an optional extra") +cbor2 = pytest.importorskip("cbor2") + +from cryptography.hazmat.primitives import hashes # noqa: E402 +from cryptography.hazmat.primitives.asymmetric import ec # noqa: E402 +from cryptography.hazmat.primitives.asymmetric.utils import ( # noqa: E402 + decode_dss_signature, + encode_dss_signature, +) + +from beaconmcp.auth import TotpResult # noqa: E402 +from beaconmcp.dashboard.app import ( # noqa: E402 + DashboardDeps, + SESSION_COOKIE, + build_dashboard_routes, +) +from beaconmcp.dashboard.csrf import CSRF_COOKIE # noqa: E402 +from beaconmcp.dashboard.db import Database # noqa: E402 +from beaconmcp.dashboard.passkeys import ( # noqa: E402 + ChallengeStore, + PasskeyError, + PasskeyService, + PasskeyStore, + b64url_decode, + b64url_encode, + default_label, +) +from beaconmcp.dashboard.session import SessionStore # noqa: E402 + + +ORIGIN = "https://beacon.example" +RP_ID = "beacon.example" + + +# --------------------------------------------------------------------------- +# Software authenticator +# --------------------------------------------------------------------------- + +class SoftwareAuthenticator: + """A minimal ES256 authenticator: enough to produce valid ceremonies.""" + + AAGUID = b"\x00" * 16 + + def __init__(self) -> None: + self.key = ec.generate_private_key(ec.SECP256R1()) + self.credential_id = os.urandom(32) + self.sign_count = 0 + + # --- helpers --------------------------------------------------------- + + def _cose_key(self) -> bytes: + numbers = self.key.public_key().public_numbers() + return cbor2.dumps({ + 1: 2, # kty: EC2 + 3: -7, # alg: ES256 + -1: 1, # crv: P-256 + -2: numbers.x.to_bytes(32, "big"), + -3: numbers.y.to_bytes(32, "big"), + }) + + @staticmethod + def _client_data(kind: str, challenge: str, origin: str) -> bytes: + return json.dumps( + { + "type": kind, + "challenge": challenge, + "origin": origin, + "crossOrigin": False, + }, + separators=(",", ":"), + ).encode("utf-8") + + def _auth_data(self, rp_id: str, flags: int, attested: bool) -> bytes: + data = hashlib.sha256(rp_id.encode("utf-8")).digest() + data += bytes([flags]) + data += struct.pack(">I", self.sign_count) + if attested: + key = self._cose_key() + data += self.AAGUID + data += struct.pack(">H", len(self.credential_id)) + data += self.credential_id + data += key + return data + + # --- ceremonies ------------------------------------------------------ + + def create(self, options: dict, *, origin: str = ORIGIN, rp_id: str = RP_ID) -> dict: + client_data = self._client_data( + "webauthn.create", options["challenge"], origin, + ) + # UP | UV | AT + auth_data = self._auth_data(rp_id, 0x01 | 0x04 | 0x40, attested=True) + attestation = cbor2.dumps({ + "fmt": "none", "attStmt": {}, "authData": auth_data, + }) + return { + "id": b64url_encode(self.credential_id), + "rawId": b64url_encode(self.credential_id), + "type": "public-key", + "clientExtensionResults": {}, + "response": { + "clientDataJSON": b64url_encode(client_data), + "attestationObject": b64url_encode(attestation), + "transports": ["internal"], + }, + } + + def get(self, options: dict, *, origin: str = ORIGIN, rp_id: str = RP_ID) -> dict: + self.sign_count += 1 + client_data = self._client_data("webauthn.get", options["challenge"], origin) + auth_data = self._auth_data(rp_id, 0x01 | 0x04, attested=False) + payload = auth_data + hashlib.sha256(client_data).digest() + raw = self.key.sign(payload, ec.ECDSA(hashes.SHA256())) + # Re-encode so the DER is canonical whatever the backend produced. + r, s = decode_dss_signature(raw) + signature = encode_dss_signature(r, s) + return { + "id": b64url_encode(self.credential_id), + "rawId": b64url_encode(self.credential_id), + "type": "public-key", + "clientExtensionResults": {}, + "response": { + "clientDataJSON": b64url_encode(client_data), + "authenticatorData": b64url_encode(auth_data), + "signature": b64url_encode(signature), + "userHandle": None, + }, + } + + +class FakeRequest: + """Just the surface :mod:`beaconmcp.dashboard.passkeys` reads.""" + + def __init__(self, host: str = RP_ID, scheme: str = "https") -> None: + self.headers = {"host": host, "x-forwarded-proto": scheme} + + class _URL: + netloc = host + + self.url = _URL() + self.url.scheme = scheme # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture() +def store(tmp_path) -> PasskeyStore: + return PasskeyStore(Database(tmp_path / "dashboard.db")) + + +@pytest.fixture() +def service(store) -> PasskeyService: + return PasskeyService(store) + + +@pytest.fixture() +def enrolled(service): + """A client with one registered passkey, plus its authenticator.""" + request = FakeRequest() + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test Client", + ) + record = service.verify_registration( + request, state=state, credential=auth.create(options), + ) + return auth, record + + +# --------------------------------------------------------------------------- +# RP identity +# --------------------------------------------------------------------------- + +def test_rp_id_strips_port_and_scheme(): + from beaconmcp.dashboard.passkeys import origin_for, rp_id_for + + req = FakeRequest(host="beacon.example:8420") + assert rp_id_for(req) == "beacon.example" + assert origin_for(req) == "https://beacon.example:8420" + + +def test_rp_id_handles_ipv6_literal(): + from beaconmcp.dashboard.passkeys import rp_id_for + + assert rp_id_for(FakeRequest(host="[::1]:8420")) == "::1" + + +def test_secure_context_requires_https_or_loopback(): + from beaconmcp.dashboard.passkeys import is_secure_context + + assert is_secure_context(FakeRequest(host="beacon.example", scheme="https")) + assert is_secure_context(FakeRequest(host="localhost:8420", scheme="http")) + assert not is_secure_context(FakeRequest(host="192.168.1.5:8420", scheme="http")) + + +def test_forwarded_host_wins_over_host(): + from beaconmcp.dashboard.passkeys import rp_id_for + + req = FakeRequest(host="internal:8420") + req.headers["x-forwarded-host"] = "public.example" + assert rp_id_for(req) == "public.example" + + +def test_default_label_from_user_agent(): + assert default_label("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)") == "iPhone / iPad" + assert default_label("Mozilla/5.0 (Windows NT 10.0)") == "Windows" + assert default_label("") == "Passkey" + + +# --------------------------------------------------------------------------- +# Challenge store +# --------------------------------------------------------------------------- + +def test_challenge_is_single_use(): + challenges = ChallengeStore() + state = challenges.issue( + purpose="authenticate", challenge=b"abc", client_id="c1", + ) + assert challenges.consume(state, "authenticate") is not None + assert challenges.consume(state, "authenticate") is None + + +def test_challenge_purpose_must_match(): + challenges = ChallengeStore() + state = challenges.issue(purpose="register", challenge=b"abc", client_id="c1") + assert challenges.consume(state, "authenticate") is None + + +def test_challenge_expires(): + challenges = ChallengeStore(ttl_seconds=-1) + state = challenges.issue(purpose="register", challenge=b"abc", client_id="c1") + assert challenges.consume(state, "register") is None + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + +def test_registration_round_trip(service, store): + request = FakeRequest() + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test Client", + ) + assert options["rp"]["id"] == RP_ID + assert b64url_decode(options["user"]["id"]) == b"beaconmcp_test" + + record = service.verify_registration( + request, state=state, credential=auth.create(options), label="My laptop", + ) + assert record.client_id == "beaconmcp_test" + assert record.label == "My laptop" + assert record.transports == ["internal"] + assert store.count_for_client("beaconmcp_test") == 1 + + +def test_registration_rejects_wrong_origin(service): + request = FakeRequest() + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test Client", + ) + with pytest.raises(PasskeyError): + service.verify_registration( + request, + state=state, + credential=auth.create(options, origin="https://evil.example"), + ) + + +def test_registration_state_bound_to_session(service): + request = FakeRequest() + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test", + session_id="session-a", + ) + with pytest.raises(PasskeyError, match="another session"): + service.verify_registration( + request, state=state, credential=auth.create(options), + session_id="session-b", + ) + + +def test_registration_excludes_known_credentials(service, enrolled): + _, record = enrolled + options, _ = service.registration_options( + FakeRequest(), client_id="beaconmcp_test", client_name="Test", + ) + assert [c["id"] for c in options["excludeCredentials"]] == [record.credential_id] + + +def test_registration_label_defaults_from_user_agent(service): + request = FakeRequest() + request.headers["user-agent"] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)" + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test", + ) + record = service.verify_registration( + request, state=state, credential=auth.create(options), + ) + assert record.label == "Mac" + + +# --------------------------------------------------------------------------- +# Authentication +# --------------------------------------------------------------------------- + +def test_authentication_round_trip(service, enrolled, store): + auth, record = enrolled + request = FakeRequest() + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + assert [c["id"] for c in options["allowCredentials"]] == [record.credential_id] + + verified = service.verify_authentication( + request, state=state, credential=auth.get(options), + ) + assert verified.client_id == "beaconmcp_test" + assert store.get(record.credential_id).last_used_at is not None + + +def test_authentication_without_credentials_is_refused(service): + with pytest.raises(PasskeyError, match="No passkey"): + service.authentication_options(FakeRequest(), client_id="nobody") + + +def test_authentication_rejects_replayed_challenge(service, enrolled): + auth, _ = enrolled + request = FakeRequest() + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + assertion = auth.get(options) + service.verify_authentication(request, state=state, credential=assertion) + with pytest.raises(PasskeyError, match="expired"): + service.verify_authentication(request, state=state, credential=assertion) + + +def test_authentication_rejects_foreign_credential(service, enrolled): + """A challenge minted for client A must not accept client B's passkey.""" + auth_a, _ = enrolled + request = FakeRequest() + + other = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_other", client_name="Other", + ) + service.verify_registration( + request, state=state, credential=other.create(options), + ) + + options, state = service.authentication_options( + request, client_id="beaconmcp_other", + ) + # Sign the *other* client's challenge with the first client's key. + with pytest.raises(PasskeyError, match="different client"): + service.verify_authentication( + request, state=state, credential=auth_a.get(options), + ) + + +def test_authentication_rejects_wrong_rp_id(service, enrolled): + auth, _ = enrolled + request = FakeRequest() + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + with pytest.raises(PasskeyError): + service.verify_authentication( + request, state=state, + credential=auth.get(options, rp_id="evil.example"), + ) + + +def test_sign_counter_regression_is_rejected(service, enrolled, store): + auth, record = enrolled + request = FakeRequest() + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + service.verify_authentication(request, state=state, credential=auth.get(options)) + + # Roll the authenticator's counter back, as a cloned key would. + auth.sign_count = 0 + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + with pytest.raises(PasskeyError, match="sign count"): + service.verify_authentication( + request, state=state, credential=auth.get(options), + ) + + +# --------------------------------------------------------------------------- +# Storage +# --------------------------------------------------------------------------- + +def test_delete_is_scoped_to_owner(store, service, enrolled): + _, record = enrolled + assert store.delete(record.credential_id, "someone_else") is False + assert store.delete(record.credential_id, "beaconmcp_test") is True + assert store.count_for_client("beaconmcp_test") == 0 + + +def test_service_without_store_reports_unavailable(): + service = PasskeyService(None) + assert service.available is False + assert "database" in service.unavailable_reason + with pytest.raises(PasskeyError): + service.authentication_options(FakeRequest(), client_id="x") + + +def test_working_service_has_no_unavailable_reason(service): + assert service.available is True + assert service.unavailable_reason is None + + +def test_missing_library_is_reported_with_a_fix(monkeypatch, store): + """The banner/doctor message must name the package and the command.""" + import beaconmcp.dashboard.passkeys as mod + + monkeypatch.setattr(mod, "_webauthn", None) + assert mod.webauthn_installed() is False + reason = PasskeyService(store).unavailable_reason + assert "webauthn" in reason and "pip install" in reason + + +# --------------------------------------------------------------------------- +# Dashboard routes +# --------------------------------------------------------------------------- + +class FakeClientStore: + def __init__(self): + self.clients = { + "beaconmcp_test": { + "secret": "sk_test", "name": "Test Client", "totp": "123456", + } + } + + def verify(self, client_id, secret): + c = self.clients.get(client_id) + return bool(c and c["secret"] == secret) + + def check_totp(self, client_id, code): + c = self.clients.get(client_id) + return TotpResult.OK if (c and c["totp"] == code) else TotpResult.INVALID + + def get_name(self, client_id): + c = self.clients.get(client_id) + return c["name"] if c else None + + +class FakeTokenStore: + def __init__(self): + self._tokens: dict[str, str] = {} + self._n = 0 + + def issue(self, client_id, name=None): + self._n += 1 + token = f"bearer_{self._n}" + self._tokens[token] = client_id + return token, 86400 + + def validate(self, token): + return self._tokens.get(token) + + def revoke(self, token): + self._tokens.pop(token, None) + return True + + def list_named(self, client_id): + return [] + + +@pytest.fixture() +def web(tmp_path, monkeypatch): + monkeypatch.setenv("BEACONMCP_DASHBOARD_DB", str(tmp_path / "dashboard.db")) + db = Database(tmp_path / "dashboard.db") + passkey_store = PasskeyStore(db) + deps = DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + passkeys=PasskeyService(passkey_store), + ) + app = Starlette(routes=build_dashboard_routes(deps)) + client = TestClient( + app, follow_redirects=False, base_url=ORIGIN, + headers={"x-forwarded-proto": "https"}, + ) + return client, deps, passkey_store + + +def _csrf(client) -> str: + client.get("/app/login") + return client.cookies.get(CSRF_COOKIE) + + +def _json_headers(token: str) -> dict[str, str]: + return {"X-CSRF-Token": token, "X-BeaconMCP-Mode": "json"} + + +def _login(client) -> str: + """Sign in with TOTP and return the *rotated* CSRF token.""" + token = _csrf(client) + res = client.post( + "/app/login", + data={ + "csrf_token": token, "client_id": "beaconmcp_test", + "client_secret": "sk_test", "totp": "123456", + }, + headers=_json_headers(token), + ) + assert res.status_code == 200, res.text + return res.json()["csrf_token"] + + +def test_login_json_mode_returns_expiry_and_passkey_state(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/login", + data={ + "csrf_token": token, "client_id": "beaconmcp_test", + "client_secret": "sk_test", "totp": "123456", + }, + headers=_json_headers(token), + ) + assert res.status_code == 200 + body = res.json() + assert body["ok"] is True + assert body["client_name"] == "Test Client" + assert body["bearer_expires_at"] > time.time() + assert body["session_expires_at"] > body["bearer_expires_at"] + assert body["passkeys_enabled"] is True + assert body["passkey_count"] == 0 + assert client.cookies.get(SESSION_COOKIE) + + +def test_login_form_mode_still_redirects(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/login", + data={ + "csrf_token": token, "client_id": "beaconmcp_test", + "client_secret": "sk_test", "totp": "123456", + }, + ) + assert res.status_code == 303 + assert res.headers["location"] == "/app/tokens" + + +def test_login_json_mode_reports_errors_as_json(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/login", + data={ + "csrf_token": token, "client_id": "beaconmcp_test", + "client_secret": "sk_test", "totp": "000000", + }, + headers=_json_headers(token), + ) + assert res.status_code == 401 + assert res.json()["ok"] is False + assert "2FA" in res.json()["error"] + + +def _register_passkey(client, token) -> SoftwareAuthenticator: + auth = SoftwareAuthenticator() + res = client.post( + "/app/api/passkeys/register/options", json={}, headers=_json_headers(token), + ) + assert res.status_code == 200, res.text + body = res.json() + res = client.post( + "/app/api/passkeys/register/verify", + json={"state": body["state"], "credential": auth.create(body["options"])}, + headers=_json_headers(token), + ) + assert res.status_code == 201, res.text + return auth + + +def test_register_then_sign_in_with_passkey(web): + client, _, passkey_store = web + token = _login(client) + auth = _register_passkey(client, token) + assert passkey_store.count_for_client("beaconmcp_test") == 1 + + listed = client.get("/app/api/passkeys", headers=_json_headers(token)).json() + assert len(listed["passkeys"]) == 1 + + # Fresh browser: credentials + passkey, no TOTP anywhere. + fresh = TestClient( + client.app, follow_redirects=False, base_url=ORIGIN, + headers={"x-forwarded-proto": "https"}, + ) + token2 = _csrf(fresh) + res = fresh.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "sk_test"}, + headers=_json_headers(token2), + ) + assert res.status_code == 200, res.text + body = res.json() + res = fresh.post( + "/app/api/passkeys/auth/verify", + json={ + "client_id": "beaconmcp_test", + "client_secret": "sk_test", + "state": body["state"], + "credential": auth.get(body["options"]), + }, + headers=_json_headers(token2), + ) + assert res.status_code == 200, res.text + assert res.json()["ok"] is True + assert res.json()["passkey_count"] == 1 + assert fresh.cookies.get(SESSION_COOKIE) + + +def test_passkey_auth_requires_valid_client_secret(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "wrong"}, + headers=_json_headers(token), + ) + assert res.status_code == 401 + assert res.json()["error"] == "Invalid credentials." + + +def test_passkey_auth_verify_rechecks_the_secret(web): + """The state token alone must never be enough to mint a session.""" + client, _, _ = web + token = _login(client) + auth = _register_passkey(client, token) + body = client.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "sk_test"}, + headers=_json_headers(token), + ).json() + res = client.post( + "/app/api/passkeys/auth/verify", + json={ + "client_id": "beaconmcp_test", + "client_secret": "wrong", + "state": body["state"], + "credential": auth.get(body["options"]), + }, + headers=_json_headers(token), + ) + assert res.status_code == 401 + + +def test_passkey_endpoints_require_csrf(web): + client, _, _ = web + _csrf(client) + res = client.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "sk_test"}, + ) + assert res.status_code == 403 + + +def test_passkey_registration_requires_a_session(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/api/passkeys/register/options", json={}, headers=_json_headers(token), + ) + assert res.status_code == 401 + + +def test_passkey_delete_scoped_to_session_client(web): + client, _, passkey_store = web + token = _login(client) + _register_passkey(client, token) + credential_id = passkey_store.list_for_client("beaconmcp_test")[0].credential_id + res = client.post( + "/app/api/passkeys/delete", + json={"credential_id": credential_id}, + headers=_json_headers(token), + ) + assert res.status_code == 200 + assert passkey_store.count_for_client("beaconmcp_test") == 0 + + +def test_tokens_page_lists_and_removes_passkeys(web): + client, _, passkey_store = web + token = _login(client) + _register_passkey(client, token) + + page = client.get("/app/tokens").text + assert "Passkeys" in page + credential_id = passkey_store.list_for_client("beaconmcp_test")[0].credential_id + assert credential_id in page + assert "never used" in page + + res = client.post( + "/app/passkeys/remove", + data={"csrf_token": token, "credential_id": credential_id}, + ) + assert res.status_code == 303 + assert res.headers["location"] == "/app/tokens" + assert passkey_store.count_for_client("beaconmcp_test") == 0 + + +def test_passkeys_remove_requires_csrf(web): + client, _, passkey_store = web + token = _login(client) + _register_passkey(client, token) + credential_id = passkey_store.list_for_client("beaconmcp_test")[0].credential_id + res = client.post( + "/app/passkeys/remove", + data={"csrf_token": "wrong", "credential_id": credential_id}, + ) + assert res.status_code == 403 + assert passkey_store.count_for_client("beaconmcp_test") == 1 + + +def test_routes_report_unavailable_without_a_service(tmp_path, monkeypatch): + monkeypatch.setenv("BEACONMCP_DASHBOARD_DB", str(tmp_path / "d.db")) + db = Database(tmp_path / "d.db") + deps = DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + passkeys=None, + ) + client = TestClient( + Starlette(routes=build_dashboard_routes(deps)), + follow_redirects=False, base_url=ORIGIN, + ) + client.get("/app/login") + token = client.cookies.get(CSRF_COOKIE) + res = client.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "sk_test"}, + headers=_json_headers(token), + ) + assert res.status_code == 503 + + +def test_login_page_hides_passkeys_when_unavailable(tmp_path, monkeypatch): + monkeypatch.setenv("BEACONMCP_DASHBOARD_DB", str(tmp_path / "d2.db")) + db = Database(tmp_path / "d2.db") + deps = DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + passkeys=None, + ) + client = TestClient( + Starlette(routes=build_dashboard_routes(deps)), base_url=ORIGIN, + ) + body = client.get("/app/login").text + assert 'data-passkeys-enabled="false"' in body + assert "passkey-login-btn" not in body diff --git a/tests/test_updates.py b/tests/test_updates.py new file mode 100644 index 0000000..655217a --- /dev/null +++ b/tests/test_updates.py @@ -0,0 +1,913 @@ +"""Update detection / self-update tests. + +git operations run for real against throwaway repositories built in +``tmp_path``: the whole point of this module is that it drives git +correctly, and a mocked ``subprocess.run`` would only prove the mock +agrees with itself. The two genuinely unsafe steps -- ``pip install`` and +the config validation subprocess -- are the ones we stub, so the tests +exercise the *orchestration* (pull, validate, roll back) without touching +the interpreter this suite runs in. + +Run with:: + + pytest tests/test_updates.py -v +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from beaconmcp import updates # noqa: E402 +from beaconmcp.auth import TotpResult # noqa: E402 +from beaconmcp.config import UpdatesConfig # noqa: E402 +from beaconmcp.dashboard.app import ( # noqa: E402 + DashboardDeps, + build_dashboard_routes, +) +from beaconmcp.dashboard.csrf import CSRF_COOKIE # noqa: E402 +from beaconmcp.dashboard.db import Database # noqa: E402 +from beaconmcp.dashboard.session import SessionStore # noqa: E402 +from beaconmcp.updates import Installation # noqa: E402 + + +pytestmark = pytest.mark.skipif( + shutil.which("git") is None, reason="git is required for update tests" +) + + +# --------------------------------------------------------------------------- +# git fixtures +# --------------------------------------------------------------------------- + +def _run(*args: str, cwd: Path) -> None: + subprocess.run( + list(args), cwd=str(cwd), check=True, + capture_output=True, text=True, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + ) + + +def _commit(repo: Path, message: str, files: dict[str, str] | None = None) -> None: + for name, content in (files or {}).items(): + (repo / name).write_text(content, encoding="utf-8") + _run("git", "add", name, cwd=repo) + _run("git", "commit", "--allow-empty", "-m", message, cwd=repo) + + +@pytest.fixture() +def upstream(tmp_path: Path) -> Path: + """An 'upstream' repo standing in for GitHub.""" + repo = tmp_path / "upstream" + repo.mkdir() + _run("git", "init", "--initial-branch=main", cwd=repo) + _run("git", "config", "user.email", "t@example.com", cwd=repo) + _run("git", "config", "user.name", "Test", cwd=repo) + _commit(repo, "initial", { + ".env.example": "BEACONMCP_SESSION_KEY=\n# GEMINI_API_KEY=\n", + "beaconmcp.yaml.example": "server:\n port: 8420\n", + "pyproject.toml": "[project]\nname = 'x'\n", + }) + return repo + + +@pytest.fixture() +def checkout(tmp_path: Path, upstream: Path) -> Path: + """A clone of ``upstream``, i.e. what /opt/beaconmcp looks like.""" + local = tmp_path / "local" + _run("git", "clone", str(upstream), str(local), cwd=tmp_path) + _run("git", "config", "user.email", "t@example.com", cwd=local) + _run("git", "config", "user.name", "Test", cwd=local) + return local + + +def _install(root: Path) -> Installation: + return Installation( + kind="git", root=root, python=sys.executable, venv=None, + editable=True, under_systemd=False, service=None, in_container=False, + ) + + +def _advance(upstream: Path, message: str, files: dict[str, str] | None = None) -> None: + """Push a new commit upstream so the checkout falls behind.""" + _commit(upstream, message, files) + + +# --------------------------------------------------------------------------- +# Installation detection +# --------------------------------------------------------------------------- + +def test_detects_this_checkout_as_git(): + install = updates.detect_installation() + assert install.kind == "git" + assert install.root is not None and (install.root / ".git").exists() + + +def test_systemd_detected_from_invocation_id(monkeypatch): + monkeypatch.setenv("INVOCATION_ID", "deadbeef") + assert updates.detect_installation().under_systemd is True + monkeypatch.delenv("INVOCATION_ID") + assert updates.detect_installation().under_systemd is False + + +def test_current_version_is_not_the_stale_placeholder(): + # __init__ used to hard-code 0.1.0 while pyproject said 2.0.0. + assert updates.current_version() != "0.1.0" + + +# --------------------------------------------------------------------------- +# Instructions per install kind +# --------------------------------------------------------------------------- + +def test_git_instructions_include_pull_and_install(tmp_path): + steps = updates.manual_instructions(_install(tmp_path)) + assert any("git pull" in s for s in steps) + assert any("install -e ." in s for s in steps) + assert not any("systemctl" in s for s in steps) + + +def test_git_instructions_add_restart_when_a_service_exists(tmp_path): + install = _install(tmp_path) + install.service = "beaconmcp" + steps = updates.manual_instructions(install) + assert steps[-1] == "systemctl restart beaconmcp" + + +def test_git_instructions_prefer_the_venv_pip(tmp_path): + venv = tmp_path / "venv" + (venv / "bin").mkdir(parents=True) + (venv / "bin" / "pip").write_text("#!/bin/sh\n") + install = _install(tmp_path) + install.venv = venv + steps = updates.manual_instructions(install) + assert str(venv / "bin" / "pip") in " ".join(steps) + + +def test_docker_instructions_do_not_mention_pip(): + install = Installation( + kind="docker", root=None, python=sys.executable, venv=None, + editable=False, under_systemd=False, service=None, in_container=True, + ) + steps = updates.manual_instructions(install) + assert any("docker" in s for s in steps) + assert not any("pip install" in s for s in steps) + + +def test_pip_instructions_point_at_the_git_url(): + install = Installation( + kind="pip", root=None, python="/usr/bin/python3", venv=None, + editable=False, under_systemd=False, service=None, in_container=False, + ) + steps = updates.manual_instructions(install) + assert any("git+https://github.com/Showdown76py/BeaconMCP" in s for s in steps) + + +# --------------------------------------------------------------------------- +# Check +# --------------------------------------------------------------------------- + +def test_up_to_date_checkout_reports_no_update(checkout): + info = updates.check_for_update(install=_install(checkout)) + assert info.error is None + assert info.available is False + assert info.behind == 0 + assert info.current_ref == info.latest_ref + + +def test_behind_checkout_reports_commits_and_log(checkout, upstream): + _advance(upstream, "feat: shiny thing") + _advance(upstream, "fix: subtle bug") + info = updates.check_for_update(install=_install(checkout)) + assert info.available is True + assert info.behind == 2 + assert [c["subject"] for c in info.commits] == [ + "fix: subtle bug", "feat: shiny thing", + ] + assert info.can_self_update is True + assert info.blockers == [] + assert info.to_json()["compare_url"].endswith( + f"{info.current_ref}...{info.latest_ref}" + ) + + +def test_dirty_checkout_blocks_self_update(checkout, upstream): + _advance(upstream, "feat: thing") + (checkout / "pyproject.toml").write_text("[project]\nname = 'edited'\n") + info = updates.check_for_update(install=_install(checkout)) + assert info.available is True + assert info.can_self_update is False + assert any("uncommitted" in b for b in info.blockers) + + +def test_non_git_install_reports_why_it_cannot_check(): + install = Installation( + kind="pip", root=None, python=sys.executable, venv=None, + editable=False, under_systemd=False, service=None, in_container=False, + ) + info = updates.check_for_update(install=install) + assert info.available is False + assert "pip install" in (info.error or "") or "not a git checkout" in (info.error or "") + assert info.can_self_update is False + + +def test_unreachable_remote_degrades_to_an_error(checkout, tmp_path): + _run("git", "remote", "set-url", "origin", str(tmp_path / "gone"), cwd=checkout) + info = updates.check_for_update(install=_install(checkout)) + assert info.available is False + assert info.error and "remote" in info.error + + +def test_check_result_is_cached(monkeypatch): + updates.invalidate_cache() + calls = [] + + def fake(**kwargs): + calls.append(1) + return updates.UpdateInfo(checked_at=time.time()) + + monkeypatch.setattr(updates, "_check_uncached", fake) + updates.check_for_update() + updates.check_for_update() + assert len(calls) == 1 + updates.check_for_update(force=True) + assert len(calls) == 2 + updates.invalidate_cache() + + +# --------------------------------------------------------------------------- +# Config drift +# --------------------------------------------------------------------------- + +def test_env_names_include_commented_examples(): + text = "A=1\n# B=\nexport C=3\n#not_a_var\n" + assert updates._env_names(text) == ["A", "B", "C"] + + +def test_yaml_paths_are_dotted_and_ignore_list_indices(): + paths = updates._yaml_paths("a:\n b: 1\nlist:\n - x: 1\n") + assert "a" in paths and "a.b" in paths + assert "list" in paths and "list.x" in paths + + +def test_drift_reports_new_env_var(checkout, upstream, monkeypatch): + monkeypatch.delenv("NEW_SECRET", raising=False) + (checkout / ".env").write_text("BEACONMCP_SESSION_KEY=abc\n") + _advance(upstream, "feat: new secret", { + ".env.example": "BEACONMCP_SESSION_KEY=\nNEW_SECRET=\n", + }) + info = updates.check_for_update(install=_install(checkout)) + assert info.drift.new_env_vars == ["NEW_SECRET"] + + +def test_drift_ignores_vars_already_set_in_the_environment( + checkout, upstream, monkeypatch, +): + monkeypatch.setenv("NEW_SECRET", "already-there") + (checkout / ".env").write_text("BEACONMCP_SESSION_KEY=abc\n") + _advance(upstream, "feat: new secret", { + ".env.example": "BEACONMCP_SESSION_KEY=\nNEW_SECRET=\n", + }) + info = updates.check_for_update(install=_install(checkout)) + assert info.drift.new_env_vars == [] + + +def test_drift_reports_new_yaml_settings(checkout, upstream): + (checkout / "beaconmcp.yaml").write_text("server:\n port: 8420\n") + _advance(upstream, "feat: knob", { + "beaconmcp.yaml.example": "server:\n port: 8420\nfeatures:\n updates:\n enabled: true\n", + }) + info = updates.check_for_update(install=_install(checkout)) + assert "features" in info.drift.new_config_keys + assert "features.updates.enabled" in info.drift.new_config_keys + + +def test_drift_is_empty_when_nothing_new(checkout, upstream): + (checkout / "beaconmcp.yaml").write_text("server:\n port: 8420\n") + (checkout / ".env").write_text("BEACONMCP_SESSION_KEY=abc\nGEMINI_API_KEY=x\n") + _advance(upstream, "docs: typo") + info = updates.check_for_update(install=_install(checkout)) + assert info.drift.empty + + +# --------------------------------------------------------------------------- +# Apply +# --------------------------------------------------------------------------- + +@pytest.fixture() +def stub_side_effects(monkeypatch): + """Stub pip + validate-config; git stays real. + + Returns the recorded command list plus a dict the test mutates to make + a given step fail. + """ + recorded: list[list[str]] = [] + outcomes = {"pip": 0, "validate": 0} + + def fake_run(cmd, cwd, timeout): + recorded.append(list(cmd)) + if "pip" in " ".join(cmd): + return outcomes["pip"], "pip output" + if "validate-config" in cmd: + return outcomes["validate"], "config error: BEACONMCP_NEW_KEY is required" + return 0, "" + + monkeypatch.setattr(updates, "_run", fake_run) + monkeypatch.setattr(updates, "_schedule_restart", lambda service, delay: True) + return recorded, outcomes + + +def test_apply_refuses_a_dirty_checkout(checkout, upstream, stub_side_effects): + _advance(upstream, "feat: thing") + (checkout / "pyproject.toml").write_text("[project]\nname = 'edited'\n") + result = updates.apply_update(install=_install(checkout)) + assert result.ok is False + assert "uncommitted" in result.message + assert result.steps[0].name == "preflight" and result.steps[0].ok is False + + +def test_apply_refuses_a_non_git_install(stub_side_effects): + install = Installation( + kind="docker", root=None, python=sys.executable, venv=None, + editable=False, under_systemd=False, service=None, in_container=True, + ) + result = updates.apply_update(install=install) + assert result.ok is False + assert "docker install" in result.message + + +def test_apply_is_a_noop_when_already_current(checkout, stub_side_effects): + result = updates.apply_update(install=_install(checkout)) + assert result.ok is True + assert "Already up to date" in result.message + + +def test_apply_pulls_installs_validates_and_restarts( + checkout, upstream, stub_side_effects, +): + recorded, _ = stub_side_effects + _advance(upstream, "feat: shiny") + install = _install(checkout) + install.service = "beaconmcp" + + result = updates.apply_update(install=install, restart_delay=3) + + assert result.ok is True + assert result.rolled_back is False + assert result.from_ref != result.to_ref + names = [s.name for s in result.steps] + assert names == [ + "preflight", "git-pull", "pip-install", "validate-config", "restart", + ] + # The pull actually moved the checkout. + head = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=str(checkout), + capture_output=True, text=True, + ).stdout.strip() + assert head.startswith(result.to_ref) + assert result.restart_scheduled is True + assert result.restart_in_seconds == 3 + assert any("validate-config" in c for cmd in recorded for c in cmd) + + +def test_apply_rolls_back_when_the_new_code_rejects_the_config( + checkout, upstream, stub_side_effects, +): + recorded, outcomes = stub_side_effects + outcomes["validate"] = 1 + _advance(upstream, "feat: needs a new setting") + before = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=str(checkout), + capture_output=True, text=True, + ).stdout.strip() + + result = updates.apply_update(install=_install(checkout)) + + assert result.ok is False + assert result.rolled_back is True + assert "cannot load your configuration" in result.message + assert "was NOT restarted" in result.message + assert "BEACONMCP_NEW_KEY" in result.message + after = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=str(checkout), + capture_output=True, text=True, + ).stdout.strip() + assert after == before, "checkout must be back where it started" + + +def test_apply_rolls_back_when_dependencies_fail( + checkout, upstream, stub_side_effects, +): + _, outcomes = stub_side_effects + outcomes["pip"] = 1 + _advance(upstream, "feat: new dep") + before = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=str(checkout), + capture_output=True, text=True, + ).stdout.strip() + + result = updates.apply_update(install=_install(checkout)) + + assert result.ok is False + assert result.rolled_back is True + assert "Dependency install failed" in result.message + after = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=str(checkout), + capture_output=True, text=True, + ).stdout.strip() + assert after == before + + +def test_apply_reports_new_config_after_success( + checkout, upstream, stub_side_effects, +): + (checkout / ".env").write_text("BEACONMCP_SESSION_KEY=abc\n") + _advance(upstream, "feat: new knob", { + ".env.example": "BEACONMCP_SESSION_KEY=\nBEACONMCP_NEW_THING=\n", + }) + result = updates.apply_update(install=_install(checkout)) + assert result.ok is True + assert result.drift.new_env_vars == ["BEACONMCP_NEW_THING"] + assert "BEACONMCP_NEW_THING" in result.message + + +def test_apply_without_a_service_tells_you_to_restart( + checkout, upstream, stub_side_effects, +): + _advance(upstream, "feat: thing") + result = updates.apply_update(install=_install(checkout)) + assert result.ok is True + assert result.restart_scheduled is False + assert "Restart the server process" in result.message + + +# --------------------------------------------------------------------------- +# Concurrency +# +# Two entry points reach this code: the dashboard button and the MCP tool. +# Two `git pull` / `pip install` runs in one checkout would fight over +# index.lock and could leave a half-applied tree. +# --------------------------------------------------------------------------- + +def test_a_second_update_is_refused_while_one_runs(checkout, upstream, monkeypatch): + import threading + + _advance(upstream, "feat: thing") + started = threading.Event() + release = threading.Event() + second: dict[str, object] = {} + + real_run = updates._run + + def blocking_run(cmd, cwd, timeout): + if "pip" in " ".join(cmd): + started.set() + release.wait(10) + return 0, "pip output" + if "validate-config" in cmd: + return 0, "" + return real_run(cmd, cwd, timeout) + + monkeypatch.setattr(updates, "_run", blocking_run) + monkeypatch.setattr(updates, "_schedule_restart", lambda service, delay: True) + + install = _install(checkout) + first: dict[str, object] = {} + + def run_first(): + first["result"] = updates.apply_update(install=install) + + t = threading.Thread(target=run_first) + t.start() + try: + assert started.wait(10), "first update never reached the pip step" + # A second caller must be told, not queued behind a long pip run. + second["result"] = updates.apply_update(install=install) + finally: + release.set() + t.join(15) + + busy = second["result"] + assert busy.ok is False + assert "already running" in busy.message + assert busy.from_ref is None, "the refused caller must not touch git" + assert first["result"].ok is True + + +def test_the_lock_is_released_after_a_failure(checkout, upstream, stub_side_effects): + _, outcomes = stub_side_effects + outcomes["validate"] = 1 + _advance(upstream, "feat: thing") + first = updates.apply_update(install=_install(checkout)) + assert first.ok is False and first.rolled_back is True + # Second call must run, not report "already running". + second = updates.apply_update(install=_install(checkout)) + assert "already running" not in second.message + + +def test_concurrent_checks_fetch_once(monkeypatch): + import threading + + updates.invalidate_cache() + calls = [] + gate = threading.Event() + + def slow_check(**kwargs): + calls.append(1) + gate.wait(5) + return updates.UpdateInfo(checked_at=time.time()) + + monkeypatch.setattr(updates, "_check_uncached", slow_check) + threads = [ + threading.Thread(target=lambda: updates.check_for_update()) + for _ in range(4) + ] + for t in threads: + t.start() + time.sleep(0.4) + gate.set() + for t in threads: + t.join(10) + + assert len(calls) == 1, f"expected one fetch, got {len(calls)}" + updates.invalidate_cache() + + +def test_restart_command_passes_values_through_argv(monkeypatch): + """No shell interpolation, so a future configurable unit name is safe.""" + seen = {} + + class FakePopen: + def __init__(self, cmd, **kwargs): + seen["cmd"] = cmd + + monkeypatch.setattr(updates.shutil, "which", lambda name: "/bin/systemctl") + monkeypatch.setattr(updates.subprocess, "Popen", FakePopen) + assert updates._schedule_restart("beaconmcp; rm -rf /", 5) is True + cmd = seen["cmd"] + # The dangerous string is an argument, never part of the script. + assert "beaconmcp; rm -rf /" in cmd + assert "rm -rf" not in cmd[2] + assert cmd[2] == 'sleep "$1"; systemctl restart "$2"' + + +# --------------------------------------------------------------------------- +# Dashboard endpoints +# --------------------------------------------------------------------------- + +class FakeClientStore: + def __init__(self): + self.clients = { + "beaconmcp_test": { + "secret": "sk_test", "name": "Test Client", "totp": "123456", + } + } + + def verify(self, client_id, secret): + c = self.clients.get(client_id) + return bool(c and c["secret"] == secret) + + def check_totp(self, client_id, code): + c = self.clients.get(client_id) + return TotpResult.OK if (c and c["totp"] == code) else TotpResult.INVALID + + def get_name(self, client_id): + c = self.clients.get(client_id) + return c["name"] if c else None + + +class FakeTokenStore: + def __init__(self): + self._tokens: dict[str, str] = {} + + def issue(self, client_id, name=None): + token = f"bearer_{len(self._tokens)}" + self._tokens[token] = client_id + return token, 86400 + + def validate(self, token): + return self._tokens.get(token) + + def revoke(self, token): + self._tokens.pop(token, None) + return True + + def list_named(self, client_id): + return [] + + +def _make_client(tmp_path, monkeypatch, **overrides): + monkeypatch.setenv("BEACONMCP_DASHBOARD_DB", str(tmp_path / "d.db")) + db = Database(tmp_path / "d.db") + deps = DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + **overrides, + ) + client = TestClient( + Starlette(routes=build_dashboard_routes(deps)), follow_redirects=False, + ) + return client, deps + + +def _sign_in(client) -> str: + client.get("/app/login") + token = client.cookies.get(CSRF_COOKIE) + res = client.post( + "/app/login", + data={ + "csrf_token": token, "client_id": "beaconmcp_test", + "client_secret": "sk_test", "totp": "123456", + }, + headers={"X-CSRF-Token": token, "X-BeaconMCP-Mode": "json"}, + ) + assert res.status_code == 200, res.text + return res.json()["csrf_token"] + + +def test_update_status_requires_a_session(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch) + assert client.get("/app/api/update").status_code == 401 + + +def test_update_status_returns_the_check(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch) + _sign_in(client) + monkeypatch.setattr( + updates, "check_for_update", + lambda **kw: updates.UpdateInfo( + checked_at=time.time(), available=True, behind=3, branch="main", + current_ref="aaaaaaa", latest_ref="bbbbbbb", install_kind="git", + can_self_update=True, instructions=["git pull --ff-only"], + ), + ) + body = client.get("/app/api/update").json() + assert body["enabled"] is True + assert body["available"] is True and body["behind"] == 3 + assert body["self_update_allowed"] is True + + +def test_update_status_is_inert_when_disabled(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch, updates_enabled=False) + _sign_in(client) + body = client.get("/app/api/update").json() + assert body == {"enabled": False, "available": False} + + +def test_status_hides_self_update_when_not_allowed(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch, allow_self_update=False) + _sign_in(client) + monkeypatch.setattr( + updates, "check_for_update", + lambda **kw: updates.UpdateInfo( + checked_at=time.time(), available=True, can_self_update=True, + ), + ) + body = client.get("/app/api/update").json() + assert body["can_self_update"] is False + assert body["self_update_allowed"] is False + + +def test_apply_requires_csrf(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch) + _sign_in(client) + res = client.post("/app/api/update/apply", json={"totp": "123456"}) + assert res.status_code == 403 + + +def test_apply_requires_a_fresh_totp(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch) + token = _sign_in(client) + res = client.post( + "/app/api/update/apply", json={"totp": "000000"}, + headers={"X-CSRF-Token": token}, + ) + assert res.status_code == 401 + assert "2FA" in res.json()["error"] + + res = client.post( + "/app/api/update/apply", json={}, + headers={"X-CSRF-Token": token}, + ) + assert res.status_code == 400 + + +def test_apply_refused_when_self_update_is_disabled(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch, allow_self_update=False) + token = _sign_in(client) + res = client.post( + "/app/api/update/apply", json={"totp": "123456"}, + headers={"X-CSRF-Token": token}, + ) + assert res.status_code == 403 + assert "disabled" in res.json()["error"] + + +def test_apply_runs_the_update_with_a_valid_code(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch) + token = _sign_in(client) + seen = {} + + def fake_apply(**kwargs): + seen.update(kwargs) + return updates.UpdateResult( + ok=True, from_ref="aaaaaaa", to_ref="bbbbbbb", + restart_scheduled=True, restart_in_seconds=5, message="Updated.", + ) + + monkeypatch.setattr(updates, "apply_update", fake_apply) + res = client.post( + "/app/api/update/apply", json={"totp": "123456"}, + headers={"X-CSRF-Token": token}, + ) + assert res.status_code == 200, res.text + body = res.json() + assert body["ok"] is True and body["restart_scheduled"] is True + assert "config_path" in seen + + +def test_failed_apply_surfaces_a_500(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch) + token = _sign_in(client) + monkeypatch.setattr( + updates, "apply_update", + lambda **kw: updates.UpdateResult( + ok=False, rolled_back=True, message="rolled back", + ), + ) + res = client.post( + "/app/api/update/apply", json={"totp": "123456"}, + headers={"X-CSRF-Token": token}, + ) + assert res.status_code == 500 + assert res.json()["rolled_back"] is True + + +def test_every_page_carries_the_toast_and_its_script(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch) + body = client.get("/app/login").text + assert 'id="update-toast"' in body + assert "/app/static/update_banner.js" in body + + +def test_signed_in_screen_has_a_slot_for_the_update_mention(tmp_path, monkeypatch): + """The toast opts out of the auth pages; login.js fills this instead.""" + client, _ = _make_client(tmp_path, monkeypatch) + assert 'id="update-note"' in client.get("/app/login").text + + +# --------------------------------------------------------------------------- +# Asset cache busting +# +# A server that updates itself must not leave browsers executing the +# previous release's JavaScript, which is what Starlette's default +# (ETag/Last-Modified but no Cache-Control -> heuristic freshness) allows. +# --------------------------------------------------------------------------- + +def test_asset_urls_carry_a_fingerprint(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch) + body = client.get("/app/login").text + for asset in ("app.css", "theme.js", "update_banner.js", "login.js"): + assert f"/app/static/{asset}?v=" in body, asset + + +def test_fingerprint_changes_when_an_asset_changes(tmp_path, monkeypatch): + from beaconmcp.dashboard import app as dashboard_app + + before = dashboard_app._compute_asset_version() + static_dir = dashboard_app._DASHBOARD_DIR / "static" + target = static_dir / "app.css" + original = target.stat() + # Push past the newest file in the directory, not just past this one: + # the fingerprint is the directory maximum, so bumping a file that is + # not currently the newest changes nothing and the assertion below + # would fail for a reason that has nothing to do with fingerprinting. + newest = max(p.stat().st_mtime for p in static_dir.iterdir() if p.is_file()) + try: + os.utime(target, (original.st_atime, newest + 120)) + assert dashboard_app._compute_asset_version() != before + finally: + os.utime(target, (original.st_atime, original.st_mtime)) + assert dashboard_app._compute_asset_version() == before + + +def test_versioned_assets_are_cacheable_forever(tmp_path, monkeypatch): + from beaconmcp.dashboard.app import ASSET_VERSION + + client, _ = _make_client(tmp_path, monkeypatch) + res = client.get(f"/app/static/app.css?v={ASSET_VERSION}") + assert res.status_code == 200 + assert "immutable" in res.headers["cache-control"] + + +def test_unversioned_assets_must_revalidate(tmp_path, monkeypatch): + """A hand-typed or legacy URL can never pin stale code.""" + client, _ = _make_client(tmp_path, monkeypatch) + res = client.get("/app/static/app.css") + assert res.status_code == 200 + assert res.headers["cache-control"] == "no-cache" + + +def test_pages_and_api_are_never_cached(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch) + assert client.get("/app/login").headers["cache-control"] == "no-store" + _sign_in(client) + assert client.get("/app/api/update").headers["cache-control"] == "no-store" + + +# --------------------------------------------------------------------------- +# MCP tools +# --------------------------------------------------------------------------- + +class _RecordingMCP: + """Minimal stand-in for FastMCP that just collects registrations.""" + + def __init__(self): + self.tools: dict[str, object] = {} + + def tool(self, *args, **kwargs): + def decorator(func): + self.tools[func.__name__] = func + return func + + return decorator + + +def test_tools_are_not_registered_when_updates_are_disabled(): + from beaconmcp.maintenance import register_maintenance_tools + + mcp = _RecordingMCP() + register_maintenance_tools(mcp, UpdatesConfig(enabled=False)) + assert mcp.tools == {} + + +def test_only_the_check_tool_when_self_update_is_disabled(): + from beaconmcp.maintenance import register_maintenance_tools + + mcp = _RecordingMCP() + register_maintenance_tools(mcp, UpdatesConfig(allow_self_update=False)) + assert set(mcp.tools) == {"beaconmcp_check_update"} + + +def test_both_tools_by_default(): + from beaconmcp.maintenance import register_maintenance_tools + + mcp = _RecordingMCP() + register_maintenance_tools(mcp, UpdatesConfig()) + assert set(mcp.tools) == {"beaconmcp_check_update", "beaconmcp_self_update"} + + +def test_self_update_tool_requires_confirmation(monkeypatch): + from beaconmcp.maintenance import register_maintenance_tools + + mcp = _RecordingMCP() + register_maintenance_tools(mcp, UpdatesConfig()) + monkeypatch.setattr( + updates, "check_for_update", + lambda **kw: updates.UpdateInfo(checked_at=time.time(), available=True), + ) + called = [] + monkeypatch.setattr( + updates, "apply_update", + lambda **kw: called.append(1) or updates.UpdateResult(ok=True), + ) + + out = mcp.tools["beaconmcp_self_update"]() + assert out["ok"] is False + assert out["reason"] == "confirmation_required" + assert out["pending"]["available"] is True + assert called == [], "must not touch the checkout without confirm=True" + + out = mcp.tools["beaconmcp_self_update"](confirm=True) + assert called == [1] + assert out["applied"] is True + + +def test_check_tool_reports_the_self_update_policy(monkeypatch): + from beaconmcp.maintenance import register_maintenance_tools + + mcp = _RecordingMCP() + register_maintenance_tools(mcp, UpdatesConfig(allow_self_update=False)) + monkeypatch.setattr( + updates, "check_for_update", + lambda **kw: updates.UpdateInfo( + checked_at=time.time(), available=True, can_self_update=True, + ), + ) + out = mcp.tools["beaconmcp_check_update"]() + assert out["can_self_update"] is False + assert any("allow_self_update" in b for b in out["blockers"])