diff --git a/scripts/stamp-distribution.sh b/scripts/stamp-distribution.sh index c59478970e0..0f6030bdece 100755 --- a/scripts/stamp-distribution.sh +++ b/scripts/stamp-distribution.sh @@ -37,6 +37,14 @@ if [ ! -d "$PKG_DIR" ]; then exit 1 fi +# Full commit SHA of the tree being packaged. Baked alongside DISTRIBUTION so +# the dashboard bundle-freshness guard can compare it against the build-id +# stamped into dist/ (see src/kiro_crew/dashboard/stale_bundle_guard.py). +# Empty-safe: git may be unavailable in some packaging sandboxes (a staged copy +# is not a git repo), in which case COMMIT stays "" and the guard skips +# silently rather than false-warning. +COMMIT="$(git -C "$PKG_DIR" rev-parse HEAD 2>/dev/null || true)" + cat > "$PKG_DIR/_build_info.py" < $PKG_DIR/_build_info.py" +echo "Stamped distribution=$DIST commit=${COMMIT:-} -> $PKG_DIR/_build_info.py" diff --git a/src/kiro_crew/beacon.py b/src/kiro_crew/beacon.py index 95a176788f6..f3b4f15dc5f 100644 --- a/src/kiro_crew/beacon.py +++ b/src/kiro_crew/beacon.py @@ -180,6 +180,15 @@ except ImportError: _BAKED_DISTRIBUTION = "" +# COMMIT is a newer field of the same generated module: a _build_info.py stamped +# before the bundle-freshness guard existed carries DISTRIBUTION but not COMMIT, +# so a missing name (which ``from ... import`` also raises as ImportError) must +# not unbind _BAKED_DISTRIBUTION. Kept as its own import for that reason. +try: + from ._build_info import COMMIT as _BAKED_COMMIT # type: ignore[import-not-found] +except ImportError: + _BAKED_COMMIT = "" + # Fallback when a version string carries no parseable release number. UNKNOWN_VERSION = "unknown" @@ -251,6 +260,18 @@ def baked_distribution() -> str: return raw if raw in KNOWN_DISTRIBUTIONS else "" +def baked_commit() -> str: + """Return the git commit SHA stamped into the package tree, or "". + + Reads the module-level :data:`_BAKED_COMMIT` (the seam tests patch) so the + dashboard bundle-freshness guard can compare the running backend's build + commit against the one recorded in ``dist/build-id.json``. Empty in a source + checkout (no ``_build_info.py``) or when git was unavailable at packaging + time, in which case the guard skips silently rather than false-warning. + """ + return str(_BAKED_COMMIT or "").strip() + + def distribution() -> str: """Return the build's distribution channel, clamped to the known set. diff --git a/src/kiro_crew/dashboard/stale_bundle_guard.py b/src/kiro_crew/dashboard/stale_bundle_guard.py new file mode 100644 index 00000000000..f4feda231a6 --- /dev/null +++ b/src/kiro_crew/dashboard/stale_bundle_guard.py @@ -0,0 +1,142 @@ +"""Startup guard that WARNS when the served SPA bundle is stale. + +The gateway serves the dashboard from a gitignored, build-copied ``dist/`` +(``handlers/core.py``). Nothing verifies that the served bundle was built from +the same tree as the running backend, so a restart that did NOT rebuild/copy +the frontend keeps serving an OLD bundle — the dashboard renders (assets are +*present*), so the gap is silent until a behavioral test fails. + +This guard is the FRESHNESS counterpart to ``stale_asset_watchdog.py``'s +PRESENCE check, and it deliberately does NOT share that watchdog's response: + + * The vanish watchdog fires when assets are *gone* — the process can serve + nothing useful, so it shuts down and lets a supervisor restart a fresh one. + * This guard fires when assets are *present but stale* — the dashboard still + works, and a restart alone would serve the *same* stale ``dist`` (a restart + does not rebuild the frontend). Shutting down would loop forever. So it + only logs a WARNING with rebuild guidance. + +Identity comes from a build-id stamped into ``dist/build-id.json`` at +``vite build`` time (see ``website/vite.config.ts`` ``buildIdPlugin``), reusing +the exact ``${version}-${sha}`` scheme ``swVersionPlugin`` already computes. The +backend's own build commit comes from the baked ``_build_info.py`` in a packaged +install (``beacon.baked_commit()``), falling back to ``git rev-parse HEAD`` in a +source checkout. + +The check is best-effort and conservative: any unknown side of the comparison +(no ``build-id.json``, a dist that predates this feature, an unknown backend +commit) means it SKIPS silently rather than false-warning. The only path that +warns is a confident mismatch between two known commits. +""" + +from __future__ import annotations + +import json +import logging +import subprocess +from pathlib import Path + +from kiro_crew import beacon, platform_compat +from kiro_crew.dashboard.handlers.core import _DIST_DIR + +logger = logging.getLogger(__name__) + +# Build-id stamp emitted into dist/ by website/vite.config.ts:buildIdPlugin and +# staged into the package by the same `cp -R website/dist ...` every packaging +# path runs. +_BUILD_ID_PATH = _DIST_DIR / "build-id.json" + + +def _read_dist_commit() -> tuple[str, str] | None: + """Return ``(commit, build_id)`` from ``dist/build-id.json``, or None. + + None on any of: the file is absent (dist predates this feature, or a dev + build that skipped the stamp), unreadable, not valid JSON, or carries no + non-empty ``commit`` (git was unavailable when the frontend was built). Each + of these is a "cannot verify" case, not a staleness signal. + """ + try: + raw = _BUILD_ID_PATH.read_text(encoding="utf-8") + except OSError: + return None + try: + data = json.loads(raw) + except (ValueError, TypeError): + logger.debug("Bundle freshness: build-id.json is not valid JSON — skipping.") + return None + if not isinstance(data, dict): + return None + commit = str(data.get("commit") or "").strip() + build_id = str(data.get("buildId") or "").strip() + if not commit: + return None + return commit, build_id + + +def _backend_commit() -> str: + """Return the running backend's build commit, or "". + + Prefers the baked ``_build_info.COMMIT`` (authoritative in a packaged + install; a running copy cannot change it). Falls back to ``git rev-parse + HEAD`` for a source/dev checkout where ``_build_info.py`` is absent — git + resolved through ``trusted_system_bin`` (fixed system dirs, never ``PATH``, + which can lead with agent-writable directories where a planted shim would + run with the gateway's environment). Returns "" if none of these is + available, which makes the caller skip. + """ + baked = beacon.baked_commit() + if baked: + return baked + git_bin = platform_compat.trusted_system_bin("git") + if git_bin is None: + return "" + try: + result = subprocess.run( + [git_bin, "rev-parse", "HEAD"], + cwd=Path(__file__).resolve().parent, + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, subprocess.SubprocessError): + return "" + if result.returncode != 0: + return "" + return result.stdout.strip() + + +def check_bundle_freshness() -> None: + """Warn (once, at startup) if the served SPA bundle looks stale. + + Best-effort and conservative — never raises, never shuts down. Warns only + on a confident commit mismatch between the dist stamp and the backend; every + "cannot verify" case (missing stamp, unknown backend commit) skips silently + so a transitional dist or a git-less packaging path never false-alarms. + """ + try: + dist = _read_dist_commit() + if dist is None: + logger.debug("Bundle freshness: no dist/build-id.json commit to compare — skipping.") + return + dist_commit, dist_build_id = dist + + backend_commit = _backend_commit() + if not backend_commit: + logger.debug("Bundle freshness: backend build commit unknown — skipping.") + return + + if dist_commit != backend_commit: + logger.warning( + "Dashboard SPA bundle is STALE: the served frontend is build " + "%s (commit %s) but this backend is running commit %s. A restart " + "will not fix this — it re-serves the same dist. Rebuild and " + "restage the frontend: `cd website && npm run build`, copy " + "website/dist into src/kiro_crew/static/dist, then restart.", + dist_build_id or dist_commit[:7], + dist_commit[:7], + backend_commit[:7], + ) + except Exception: + # A freshness advisory must never break startup. Swallow everything and + # note it at debug level for diagnosis. + logger.debug("Bundle freshness check failed — ignoring.", exc_info=True) diff --git a/src/kiro_crew/slack/gateway.py b/src/kiro_crew/slack/gateway.py index f0ae9a5968c..5261b6ad8c0 100644 --- a/src/kiro_crew/slack/gateway.py +++ b/src/kiro_crew/slack/gateway.py @@ -148,6 +148,7 @@ run_stale_asset_watchdog, shutdown_exit_code, ) +from kiro_crew.dashboard.stale_bundle_guard import check_bundle_freshness from kiro_crew.dashboard.state import ( SUBAGENT_BATCH_COMPLETION_PREFIX, SUBAGENT_COMPLETION_PREFIX, @@ -11617,6 +11618,18 @@ async def _start_bg_session() -> None: self._background_tasks.add(_watchdog) _watchdog.add_done_callback(self._background_tasks.discard) + # Bundle-freshness guard: a one-shot WARN (never a shutdown) if the + # served SPA dist was built from a different commit than this backend — + # the present-but-stale case the vanish watchdog above cannot catch. A + # restart would re-serve the same stale dist, so warning (not shutting + # down) is the correct response. Best-effort; never raises. Run on the + # subprocess executor: the dev-checkout fallback shells out to + # `git rev-parse` (up to 5s if git wedges), which must not block the + # event loop. + await asyncio.get_running_loop().run_in_executor( + subprocess_executor(), check_bundle_freshness + ) + print("👻 Kiro Crew gateway starting…") print(f"\n{DATA_WARNING}\n") diff --git a/test/test_spawn_audit.py b/test/test_spawn_audit.py index 8e6f21db749..c1bc3ce920d 100644 --- a/test/test_spawn_audit.py +++ b/test/test_spawn_audit.py @@ -1070,6 +1070,16 @@ def _is_bundled_skill_asset(path: Path) -> bool: "dashboard/handlers_system.py::_scan_mcp_processes", "dashboard/handlers_system.py::_get_static_system_info", "dashboard/port_reclaim.py::_listeners_on_port", + # Bundle-freshness startup probe: fixed `git rev-parse HEAD` list-argv + # (no shell=True) with git PINNED via trusted_system_bin (fixed system + # dirs, never PATH — a planted shim in an agent-writable PATH dir cannot + # be selected; absent trusted git → skip, no spawn). cwd is the module's + # OWN install directory (Path(__file__).parent), never a request- or + # agent-supplied path. Runs once at dashboard startup, only in a + # source/dev checkout where the baked _build_info commit is absent, and + # its output is compared (read-only) against dist/build-id.json to log + # a staleness warning. No agent input reaches command, args, or cwd. + "dashboard/stale_bundle_guard.py::_backend_commit", "env.py::_run", "env.py::activate_mise", # Node bootstrap: runs the bundled ``ensure-node.sh`` (a fixed `bash diff --git a/test/test_stale_bundle_guard.py b/test/test_stale_bundle_guard.py new file mode 100644 index 00000000000..e580ae63849 --- /dev/null +++ b/test/test_stale_bundle_guard.py @@ -0,0 +1,251 @@ +"""Tests for the dashboard bundle-freshness guard. + +The guard is the FRESHNESS counterpart to the stale-asset (vanish) watchdog: it +warns when the served ``dist`` was built from a different commit than the running +backend, and — unlike the vanish watchdog — never shuts down. These tests pin +that warn-only contract and the conservative "cannot verify → skip silently" +behaviour on every unknown side of the comparison. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from unittest.mock import patch + + +def _write_build_id(tmp_path: Path, commit: str, build_id: str = "1.0.0-abc1234") -> Path: + dist = tmp_path / "dist" + dist.mkdir(exist_ok=True) + path = dist / "build-id.json" + path.write_text( + json.dumps({"buildId": build_id, "commit": commit, "builtAt": "2026-08-13T00:00:00Z"}), + encoding="utf-8", + ) + return path + + +def test_warns_on_commit_mismatch(tmp_path: Path, caplog): + """A dist built from a different commit than the backend → WARNING.""" + from kiro_crew.dashboard import stale_bundle_guard as mod + + build_id_path = _write_build_id(tmp_path, commit="a" * 40, build_id="1.0.0-aaaaaaa") + + caplog.set_level(logging.DEBUG, logger="kiro_crew.dashboard.stale_bundle_guard") + with ( + patch.object(mod, "_BUILD_ID_PATH", build_id_path), + patch.object(mod, "_backend_commit", return_value="b" * 40), + ): + mod.check_bundle_freshness() + + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + msg = warnings[0].getMessage() + assert "STALE" in msg + # Names both the served build-id and the backend commit so an operator can + # tell which side is old. + assert "1.0.0-aaaaaaa" in msg + assert "bbbbbbb" in msg + + +def test_silent_when_commits_match(tmp_path: Path, caplog): + """Matching commits → no warning (the healthy case).""" + from kiro_crew.dashboard import stale_bundle_guard as mod + + build_id_path = _write_build_id(tmp_path, commit="c" * 40) + + caplog.set_level(logging.DEBUG, logger="kiro_crew.dashboard.stale_bundle_guard") + with ( + patch.object(mod, "_BUILD_ID_PATH", build_id_path), + patch.object(mod, "_backend_commit", return_value="c" * 40), + ): + mod.check_bundle_freshness() + + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + +def test_skips_when_build_id_missing(tmp_path: Path, caplog): + """No dist/build-id.json (dist predates this feature) → skip silently.""" + from kiro_crew.dashboard import stale_bundle_guard as mod + + missing = tmp_path / "dist" / "build-id.json" # never created + + caplog.set_level(logging.DEBUG, logger="kiro_crew.dashboard.stale_bundle_guard") + with ( + patch.object(mod, "_BUILD_ID_PATH", missing), + patch.object(mod, "_backend_commit", return_value="d" * 40), + ): + mod.check_bundle_freshness() + + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + +def test_skips_when_backend_commit_unknown(tmp_path: Path, caplog): + """Backend commit unknown (source build, no git) → skip silently.""" + from kiro_crew.dashboard import stale_bundle_guard as mod + + build_id_path = _write_build_id(tmp_path, commit="e" * 40) + + caplog.set_level(logging.DEBUG, logger="kiro_crew.dashboard.stale_bundle_guard") + with ( + patch.object(mod, "_BUILD_ID_PATH", build_id_path), + patch.object(mod, "_backend_commit", return_value=""), + ): + mod.check_bundle_freshness() + + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + +def test_skips_when_dist_commit_empty(tmp_path: Path, caplog): + """build-id.json present but commit is "" (git-less frontend build) → skip. + + The stamp is written but the git SHA was unavailable at build time, so there + is no identity to compare — treat as "cannot verify", not a mismatch. + """ + from kiro_crew.dashboard import stale_bundle_guard as mod + + build_id_path = _write_build_id(tmp_path, commit="", build_id="1.0.0") + + caplog.set_level(logging.DEBUG, logger="kiro_crew.dashboard.stale_bundle_guard") + with ( + patch.object(mod, "_BUILD_ID_PATH", build_id_path), + patch.object(mod, "_backend_commit", return_value="f" * 40), + ): + mod.check_bundle_freshness() + + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + +def test_skips_on_malformed_build_id_json(tmp_path: Path, caplog): + """A corrupt/truncated build-id.json must not warn or raise.""" + from kiro_crew.dashboard import stale_bundle_guard as mod + + dist = tmp_path / "dist" + dist.mkdir() + bad = dist / "build-id.json" + bad.write_text("{not valid json", encoding="utf-8") + + caplog.set_level(logging.DEBUG, logger="kiro_crew.dashboard.stale_bundle_guard") + with ( + patch.object(mod, "_BUILD_ID_PATH", bad), + patch.object(mod, "_backend_commit", return_value="a" * 40), + ): + mod.check_bundle_freshness() + + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + +def test_check_never_raises(tmp_path: Path): + """Any internal failure is swallowed — a freshness advisory can't break startup.""" + from kiro_crew.dashboard import stale_bundle_guard as mod + + build_id_path = _write_build_id(tmp_path, commit="a" * 40) + + def _boom() -> str: + raise RuntimeError("backend commit lookup exploded") + + with ( + patch.object(mod, "_BUILD_ID_PATH", build_id_path), + patch.object(mod, "_backend_commit", side_effect=_boom), + ): + # Must not propagate. + mod.check_bundle_freshness() + + +# ── _backend_commit resolution ── + + +def test_backend_commit_prefers_baked_over_git(): + """A packaged install's baked commit wins over the git fallback.""" + from kiro_crew.dashboard import stale_bundle_guard as mod + + with ( + patch("kiro_crew.beacon.baked_commit", return_value="1" * 40) as baked, + patch("subprocess.run") as run, + ): + assert mod._backend_commit() == "1" * 40 + baked.assert_called_once() + # Baked value short-circuits before any git subprocess. + run.assert_not_called() + + +def test_backend_commit_falls_back_to_git_in_source_checkout(): + """With no baked commit, resolve via `git rev-parse HEAD` (dev/source mode). + + git itself is pinned through ``trusted_system_bin`` — the argv must carry + the resolved absolute path, never a bare ``git`` for PATH to answer. + """ + from kiro_crew.dashboard import stale_bundle_guard as mod + + class _Result: + returncode = 0 + stdout = "9" * 40 + "\n" + + with ( + patch("kiro_crew.beacon.baked_commit", return_value=""), + patch.object(mod.platform_compat, "trusted_system_bin", return_value="/usr/bin/git"), + patch("subprocess.run", return_value=_Result()) as run, + ): + assert mod._backend_commit() == "9" * 40 + run.assert_called_once() + assert run.call_args.args[0][0] == "/usr/bin/git" + + +def test_backend_commit_empty_when_git_not_in_trusted_dirs(): + """git absent from the fixed system dirs → "" (skip), and no spawn at all. + + The PATH may still find a git (mise/homebrew/an agent-planted shim) — the + guard must not fall back to it. + """ + from kiro_crew.dashboard import stale_bundle_guard as mod + + with ( + patch("kiro_crew.beacon.baked_commit", return_value=""), + patch.object(mod.platform_compat, "trusted_system_bin", return_value=None), + patch("subprocess.run") as run, + ): + assert mod._backend_commit() == "" + run.assert_not_called() + + +def test_backend_commit_empty_when_git_unavailable(): + """No baked commit and git failing → "" so the guard skips.""" + from kiro_crew.dashboard import stale_bundle_guard as mod + + with ( + patch("kiro_crew.beacon.baked_commit", return_value=""), + patch.object(mod.platform_compat, "trusted_system_bin", return_value="/usr/bin/git"), + patch("subprocess.run", side_effect=OSError("git not found")), + ): + assert mod._backend_commit() == "" + + +def test_backend_commit_empty_when_git_returns_nonzero(): + """git rev-parse exiting non-zero (not a repo) → "" so the guard skips.""" + from kiro_crew.dashboard import stale_bundle_guard as mod + + class _Result: + returncode = 128 + stdout = "" + + with ( + patch("kiro_crew.beacon.baked_commit", return_value=""), + patch.object(mod.platform_compat, "trusted_system_bin", return_value="/usr/bin/git"), + patch("subprocess.run", return_value=_Result()), + ): + assert mod._backend_commit() == "" + + +# ── beacon.baked_commit accessor ── + + +def test_beacon_baked_commit_reads_binding(monkeypatch): + """baked_commit reflects the module-level _BAKED_COMMIT binding.""" + from kiro_crew import beacon + + monkeypatch.setattr(beacon, "_BAKED_COMMIT", " " + "a" * 40 + " ") + assert beacon.baked_commit() == "a" * 40 + + monkeypatch.setattr(beacon, "_BAKED_COMMIT", "") + assert beacon.baked_commit() == "" diff --git a/website/vite.config.ts b/website/vite.config.ts index a32c7a06ea7..7aacdf59743 100644 --- a/website/vite.config.ts +++ b/website/vite.config.ts @@ -302,6 +302,51 @@ function swVersionPlugin(): Plugin { } } +/** + * Post-build plugin: writes dist/build-id.json stamping the build identity of + * the SPA bundle. Runs during `vite build` only (not the dev server), and the + * file is staged into the Python package by the same `cp -R website/dist ...` + * every packaging path already runs. + * + * WHY: the gateway serves a gitignored, build-copied `dist/`. Nothing verifies + * that the served bundle matches the backend it belongs to, so a restart that + * did not rebuild/copy the frontend serves an OLD bundle silently. The backend + * reads this stamp at startup and warns (never shuts down) when the dist was + * built from a different commit than the running backend — see + * src/kiro_crew/dashboard/stale_bundle_guard.py. + * + * The `buildId`/`commit` reuse the exact identity swVersionPlugin computes + * (`${pkg.version}-${gitShortSha}`), with the same git-unavailable tolerance: + * if git is unavailable, `commit` is "" and the backend guard skips silently + * rather than false-warning. + */ +function buildIdPlugin(): Plugin { + return { + name: 'kirocrew-build-id', + apply: 'build', + closeBundle() { + const outPath = path.resolve(__dirname, 'dist/build-id.json') + // Full SHA for the equality check; short SHA for the human-facing id, + // matching swVersionPlugin's `${version}-${shortSha}` scheme. Falls back + // to version alone if git is unavailable (CI edge case) — an empty + // commit tells the backend guard to skip rather than warn. + let sha = '' + try { sha = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim() } catch {} + const buildId = sha ? `${pkg.version}-${sha.slice(0, 7)}` : pkg.version + try { + writeFileSync( + outPath, + JSON.stringify({ buildId, commit: sha, builtAt: new Date().toISOString() }, null, 2) + '\n', + ) + } catch (e: unknown) { + // dist/ missing (library mode, test builds) is the only tolerated case; + // anything else is a real bug — surface it. + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e + } + }, + } +} + /** * Edition-extension seam: resolves the virtual module `virtual:kirocrew-edition` * — imported once by `src/extensions.ts` — to a downstream edition's own @@ -606,7 +651,7 @@ function appWindowUrls(): Plugin { } export default defineConfig({ - plugins: [react(), tokenProxyPlugin(), appImportMapPlugin(), vendorRuntimePlugin(), excalidrawFontsPlugin(), swVersionPlugin(), editionExtensionPlugin(), bundleReportPlugin(), appWindowUrls(), precompressPlugin()], + plugins: [react(), tokenProxyPlugin(), appImportMapPlugin(), vendorRuntimePlugin(), excalidrawFontsPlugin(), swVersionPlugin(), buildIdPlugin(), editionExtensionPlugin(), bundleReportPlugin(), appWindowUrls(), precompressPlugin()], resolve: { alias: { '@': path.resolve(__dirname, './src'),