From cda065b530d29a81605e8d31a52e302749197ce1 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Wed, 29 Jul 2026 07:38:55 +0200 Subject: [PATCH 1/3] feat(updates): tell signed-in operators about updates, and offer to apply them BeaconMCP cuts no releases and ships no PyPI package: the canonical install is a git clone with a venv and a systemd unit. So "is there an update?" means "is this checkout behind the upstream default branch?", and nothing in the server was answering that question. Operators found out by happening to read the repo. Adds three things. **A notice, for signed-in operators only.** A card on any /app/* page when the checkout is behind: how far, the recent commit subjects, a link to the diff, and the commands to update. GET /app/api/update requires a live session and 401s otherwise -- the exact revision a server runs is free reconnaissance for anyone who hasn't authenticated, and the card is only ever rendered to someone signed in. Dismissing it hides that revision until a newer one lands. **Instructions that match the install**, rather than assuming everyone ran deploy/install.sh. A git checkout gets its own root and its real venv pip path, plus a systemctl line only when a unit file actually exists; a container gets docker compose; a pip distribution gets the git+https URL. **Two MCP tools.** beaconmcp_check_update is read-only. beaconmcp_self_update applies: pull --ff-only, reinstall dependencies, validate the config, then restart. It requires confirm=True, refuses a dirty checkout so local edits are never discarded, and refuses a non-git install. The config validation is a hard gate, not a warning, and it is what makes this safe to run unattended: it shells out to `beaconmcp validate-config` so the *new* code parses the operator's *actual* config. If a setting was renamed or a new one is now required, the checkout is reset to where it started, dependencies are restored, and nothing is restarted -- an update that bricks the server is worse than no update. The check also diffs the incoming .env.example / beaconmcp.yaml.example against the operator's real files (not the local examples, and honouring variables already exported), so the notice can say "this update wants a variable you haven't set" *before* it is applied. The dashboard's "Update now" re-prompts for 2FA: pulling code and restarting is the most privileged thing the panel can do, so a session alone is not the right bar -- same gate as minting a token. Both are switchable: features.updates.enabled is the air-gap switch (no egress, no tools, no notice) and allow_self_update keeps the notice while forbidding the apply, for deployments where updates go through a pipeline. Also fixes __version__, which had been pinned at "0.1.0" while pyproject said 2.0.0 -- it now reads package metadata, with the real number as the source-tree fallback. Tests drive git for real against throwaway repositories: a mocked subprocess would only prove the mock agrees with itself. pip and the validation subprocess are the two steps stubbed, so the pull/validate/ roll-back orchestration is exercised without touching the interpreter running the suite. --- README.md | 3 +- beaconmcp.yaml.example | 14 + docs/configuration.md | 9 + docs/tools.md | 18 +- docs/updates.md | 95 +++ src/beaconmcp/__init__.py | 12 +- src/beaconmcp/__main__.py | 10 +- src/beaconmcp/config.py | 34 +- src/beaconmcp/dashboard/app.py | 92 +++ src/beaconmcp/dashboard/static/app.css | 194 +++++ .../dashboard/static/update_banner.js | 221 +++++ src/beaconmcp/dashboard/templates/base.html | 7 + src/beaconmcp/maintenance/__init__.py | 5 + src/beaconmcp/maintenance/tools.py | 117 +++ src/beaconmcp/server.py | 6 + src/beaconmcp/updates.py | 765 ++++++++++++++++++ tests/test_updates.py | 738 +++++++++++++++++ 17 files changed, 2333 insertions(+), 7 deletions(-) create mode 100644 docs/updates.md create mode 100644 src/beaconmcp/dashboard/static/update_banner.js create mode 100644 src/beaconmcp/maintenance/__init__.py create mode 100644 src/beaconmcp/maintenance/tools.py create mode 100644 src/beaconmcp/updates.py create mode 100644 tests/test_updates.py 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/tools.md b/docs/tools.md index 87a3bd5..9eb368d 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 +46 tools across seven 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 @@ -92,3 +93,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..f7f74c6 --- /dev/null +++ b/docs/updates.md @@ -0,0 +1,95 @@ +# 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. + +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. + +### 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. + +## 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/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 443f366..f711726 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -2180,6 +2180,8 @@ async def lifespan(_app): 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( @@ -2246,7 +2248,8 @@ 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=(), - passkey_service=None): + passkey_service=None, updates=None, + config_path=None): """Build dashboard routes if enabled. Returns [] when disabled.""" from . import dashboard if not dashboard.is_enabled(): @@ -2320,6 +2323,11 @@ def _float_env(name: str, default: float) -> float: 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 34b8f54..d627539 100644 --- a/src/beaconmcp/dashboard/app.py +++ b/src/beaconmcp/dashboard/app.py @@ -103,6 +103,13 @@ class DashboardDeps: # 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 # --------------------------------------------------------------------------- @@ -1096,6 +1103,89 @@ async def api_passkeys_auth_verify(request: Request) -> Response: ) 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: @@ -1467,6 +1557,8 @@ async def _confirm(req: ToolConfirmRequired) -> bool: ), 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"], diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index 7488464..ab3f4fb 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -2315,3 +2315,197 @@ a.cta:hover { background: var(--accent-hover); } 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; } +} diff --git a/src/beaconmcp/dashboard/static/update_banner.js b/src/beaconmcp/dashboard/static/update_banner.js new file mode 100644 index 0000000..8f5629b --- /dev/null +++ b/src/beaconmcp/dashboard/static/update_banner.js @@ -0,0 +1,221 @@ +// "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.
'; + } + }); + } + + 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; + if (dismissed(data.latest_ref)) return; + render(data); + }) + .catch(function() {}); +})(); diff --git a/src/beaconmcp/dashboard/templates/base.html b/src/beaconmcp/dashboard/templates/base.html index 3e817c0..909cfe9 100644 --- a/src/beaconmcp/dashboard/templates/base.html +++ b/src/beaconmcp/dashboard/templates/base.html @@ -14,5 +14,12 @@ {% 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/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/server.py b/src/beaconmcp/server.py index 0265178..bcb3141 100644 --- a/src/beaconmcp/server.py +++ b/src/beaconmcp/server.py @@ -12,6 +12,7 @@ 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 @@ -290,3 +291,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..b92ef3a --- /dev/null +++ b/src/beaconmcp/updates.py @@ -0,0 +1,765 @@ +"""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 + + +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) + + with _cache_lock: + cached = _cached + if cached is not None and not force: + ttl = FAILED_CHECK_TTL_SECONDS if cached.error else CHECK_TTL_SECONDS + if time.time() - cached.checked_at < ttl: + return cached + + 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( + ["sh", "-c", f"sleep {int(delay)}; systemctl restart {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. + """ + 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_updates.py b/tests/test_updates.py new file mode 100644 index 0000000..144d5ab --- /dev/null +++ b/tests/test_updates.py @@ -0,0 +1,738 @@ +"""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 + + +# --------------------------------------------------------------------------- +# 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 + + +# --------------------------------------------------------------------------- +# 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"]) From 3de276c16aa2af553c0d864c1b90278a279acd8b Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Wed, 29 Jul 2026 15:34:59 +0200 Subject: [PATCH 2/3] fix(updates): mention updates on the post-2FA screen, and stop caches pinning old assets Two gaps found by actually looking at the rendered pages. **The "You're signed in" screen said nothing.** The toast fetches its status once at page load, which on /app/login happens before the session exists -- so it 401'd and stayed empty, and signing in never re-checks because it does not reload the page. The one moment the operator is guaranteed to pass through said nothing about a pending update. login.js now re-asks once the session is created and renders a one-line mention above "Finish signing in". Deliberately not the full card: that screen has a single primary action, and on a narrow viewport a bottom-anchored card this tall would sit on top of it. The card now opts out of the auth pages entirely and shows on the landing page instead. **Browsers could keep running the previous release's JavaScript.** Starlette serves static files with ETag/Last-Modified but no Cache-Control, which leaves browsers on heuristic freshness -- a file untouched for weeks is reused for a long time without ever revalidating. That was survivable when upgrading meant running commands by hand; it is not once the server can update itself and the next page load is expected to match the new backend. This was not theoretical: it bit the browser used to verify the change, which kept executing a stale bundle across several restarts. Asset URLs now carry a fingerprint of the bundle, recomputed at start from the newest mtime in the static directory (which a git pull bumps). New bytes mean a new URL, so no cache can serve it from an old entry -- which also lets the files be cached hard instead of revalidated: ?v= present -> public, max-age=31536000, immutable ?v= absent -> no-cache (a legacy or hand-typed URL can't pin old code) /app/* pages -> no-store (per-session, and they carry the fingerprint) --- docs/updates.md | 22 +++++++ src/beaconmcp/dashboard/app.py | 62 ++++++++++++++++++- src/beaconmcp/dashboard/static/app.css | 21 +++++++ src/beaconmcp/dashboard/static/login.js | 20 ++++++ .../dashboard/static/update_banner.js | 32 +++++++--- src/beaconmcp/dashboard/templates/base.html | 6 +- src/beaconmcp/dashboard/templates/chat.html | 2 +- .../dashboard/templates/connectors.html | 2 +- src/beaconmcp/dashboard/templates/login.html | 7 ++- src/beaconmcp/dashboard/templates/tokens.html | 2 +- .../dashboard/templates/totp_refresh.html | 2 +- tests/test_updates.py | 59 ++++++++++++++++++ 12 files changed, 219 insertions(+), 18 deletions(-) diff --git a/docs/updates.md b/docs/updates.md index f7f74c6..9d8cc29 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -20,6 +20,11 @@ behind: 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. @@ -82,6 +87,23 @@ features: 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)): diff --git a/src/beaconmcp/dashboard/app.py b/src/beaconmcp/dashboard/app.py index d627539..ab7e34a 100644 --- a/src/beaconmcp/dashboard/app.py +++ b/src/beaconmcp/dashboard/app.py @@ -177,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 ) @@ -198,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" ) @@ -264,6 +270,60 @@ 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. @@ -1570,7 +1630,7 @@ async def _confirm(req: ToolConfirmRequired) -> bool: Route("/", index, methods=["GET"]), Mount( "/app/static", - app=StaticFiles(directory=_DASHBOARD_DIR / "static"), + app=_ImmutableStaticFiles(directory=_DASHBOARD_DIR / "static"), name="dashboard-static", ), ] diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index ab3f4fb..b53ece4 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -2509,3 +2509,24 @@ a.cta:hover { background: var(--accent-hover); } .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/login.js b/src/beaconmcp/dashboard/static/login.js index 5292230..5f8f00b 100644 --- a/src/beaconmcp/dashboard/static/login.js +++ b/src/beaconmcp/dashboard/static/login.js @@ -263,6 +263,26 @@ } } 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() { diff --git a/src/beaconmcp/dashboard/static/update_banner.js b/src/beaconmcp/dashboard/static/update_banner.js index 8f5629b..3008442 100644 --- a/src/beaconmcp/dashboard/static/update_banner.js +++ b/src/beaconmcp/dashboard/static/update_banner.js @@ -210,12 +210,28 @@ }); } - 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; - if (dismissed(data.latest_ref)) return; - render(data); - }) - .catch(function() {}); + // 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/templates/base.html b/src/beaconmcp/dashboard/templates/base.html index 909cfe9..3620fb4 100644 --- a/src/beaconmcp/dashboard/templates/base.html +++ b/src/beaconmcp/dashboard/templates/base.html @@ -8,8 +8,8 @@ - - + + {% block head %}{% endblock %} @@ -20,6 +20,6 @@ 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..4b0a847 100644 --- a/src/beaconmcp/dashboard/templates/chat.html +++ b/src/beaconmcp/dashboard/templates/chat.html @@ -192,5 +192,5 @@

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 41684f2..a784041 100644 --- a/src/beaconmcp/dashboard/templates/login.html +++ b/src/beaconmcp/dashboard/templates/login.html @@ -145,6 +145,9 @@

You're signed in

localhost address).

+ {# Filled in by login.js once the session exists -- see update_banner.js #} + +