v0.3.2 (2026-06-27)
v0.3.2 turns the shipped Tauri desktop app into a
self-contained way to use The Test Cabinet. Where
v0.3.1 made the CLI and the desktop app thin enqueue +
watch clients of a backend run queue — collapsing everything onto the single
driver execution path — it left the packaged
desktop app with nothing to talk to: without an externally-run backend it had no
catalog and no way to launch a run. v0.3.2 closes that gap. When no external
backend is configured (TCAB_BACKEND_URL unset), the desktop shell now stands up
the entire run topology itself on a local k3d cluster — from the published
GHCR service images, never building anything — and ingests the test-case catalog
the app bundles. The only host prerequisite is a running container runtime.
This keeps the v0.3.1 topology intact: there is still one execution path, and the developer workflow (a thin client of a manually-run backend) is preserved exactly — the self-hosted cluster only kicks in when no backend is pointed at. v0.3.2 also hardens the publish path so a run’s data is scrubbed of leaked provider API keys wherever it crosses to the open internet, and adds run deletion, an in-cluster observability stack, and managed-PostgreSQL deployment overlays.
This is still pre-1.0 software intended primarily for project developers.
Breaking changes
Section titled “Breaking changes”Test-case runtime caps are now authored in fractional hours
Section titled “Test-case runtime caps are now authored in fractional hours”The end-to-end manifest field
max_runtime_seconds (integer seconds) is replaced by max_runtime_hours
(float, default 1), and tcab run’s --max-runtime flag now takes hours.
Every cap is long enough that seconds added no useful precision, so authoring in
hours (e.g. 0.5, 1.5) is clearer. Hours are parsed at the edges and converted
to seconds internally, so the run pipeline (run records, the job API, the backend)
is unchanged and no contract artifacts move. All existing manifests were
converted (1800 → 0.5, 3600 → 1, 7200 → 2, and so on); a case that
still declares max_runtime_seconds must be updated.
Features
Section titled “Features”The desktop app self-hosts a bundled cluster
Section titled “The desktop app self-hosts a bundled cluster”When launched with no TCAB_BACKEND_URL, the desktop shell now bootstraps the
whole run topology on a local k3d cluster — preflight → stage the bundled inputs →
create the cluster → apply secrets + overlay → wait for rollouts → port-forward →
ingest the bundled catalog → ready. It pulls the published GHCR service images
built for the app’s own commit (it never builds images and never needs a
checkout), runs everything under a tcab-desktop namespace via a new
deployments/k8s/overlays/app overlay (the local overlay’s twin), and reaps its
port-forwards on exit. A BootGate loading screen holds the console hidden
until the cluster is ready, with a per-step tracker and retry-on-failure.
Supporting work that makes the self-hosted path usable in practice:
- Live bootstrap output. The long-running k3d/kubectl steps and the catalog ingest stream their stdout line by line into the boot gate as a live terminal tail (the catalog ingest drains the backend’s NDJSON progress feed and shows a running version count), so a slow first launch no longer looks like a hang. A captured tail also becomes the step’s error message on failure.
- Per-harness authentication from Settings. A new desktop-only Settings →
Authentication section configures how the self-contained cluster authenticates
each harness’s runs — auth method
(auto/subscription/api-key), API key, and subscription refresh — persisted to
harness-auth.json, built into the driver secrets, and re-applied on every launch. The core gained a per-harness key override (TCAB_API_KEY_<SLUG>) so harnesses sharing a provider (the OpenRouter harnesses) can hold independent keys. - Multi-arch service images. CI now publishes each service image for both
linux/amd64andlinux/arm64(each built on its own native runner, stitched into one manifest list), so the self-hosted cluster comes up on an Apple-Silicon Mac instead of stalling inImagePullBackOff. - Skip re-rendering an unchanged catalog. A whole-catalog ingest is tagged
with the client’s build commit (the new
core::COMMIT); when the next boot carries the same token the backend skips re-rendering every reference mockup, so restarts after the first no longer pause on an identical catalog. A-dirtyor commit-less build is never trusted as an identity and forces a full re-ingest.
Publish-time redaction of leaked API keys
Section titled “Publish-time redaction of leaked API keys”A run executes with a real provider API key in its container, so a model that
dumps its environment can leak that key into recorded events, a failure detail, or
a file it writes. The private backend keeps full fidelity; the exposure is only
where a run’s data is published to the open internet. A shared SecretScrubber
(core::redact) now scrubs a run’s outbound data at every public-egress surface:
- GitHub — every staged file is scrubbed before the push to the run’s public
source repo, matching the exact host env key values as well as any
sk-…-shaped token. - Cloudflare Pages — the built static output is walked and any leaked key
redacted in place before
wrangler pages deploy, since a key written into a source file can be carried through the build into an emitted asset. - R2 snapshot — the backend scrubs each per-run document (record + events) by
sk-…shape before upload, while leaving its private stored copy intact.
Matching is conservative — anchored to the sk- prefix with a length floor — so
ordinary prose and short identifiers survive, and every seam logs a warning naming
what it redacted. See results and
snapshot.
Deleting unpublished runs
Section titled “Deleting unpublished runs”A new auth-gated DELETE /runs/{id} removes a run’s record, reviews, links, and
stored media, and asks the artifact service to
drop the run’s stored tree as well (via a new DELETE /runs/{id}/artifacts, gated
by the shared control-plane service token), so deletion leaves no orphaned bytes
behind. It refuses a published run (422) — a public run is in the snapshot
and gallery and can never be deleted. The shared console (web + desktop) surfaces a
Delete run control on the run detail page, shown only for an unpublished run
the active worker produced.
In-cluster observability
Section titled “In-cluster observability”The Grafana LGTM stack moves out of the devcontainer’s docker-compose and into the
cluster as a reusable components/observability kustomize component (tcab-lgtm
Service + StatefulSet, plus a NetworkPolicy admitting the services’ and driver
Jobs’ OTLP), wired into the local, staging, prod, and Azure overlays. Local, staging,
and prod now observe telemetry through one identical stack; the dispatcher forwards
the OTLP env into every per-run driver Job so run/driver spans export too. See
observability.
Managed-PostgreSQL deployment overlays
Section titled “Managed-PostgreSQL deployment overlays”A reusable components/postgres component converts the backend and auth service
from their single-replica SQLite StatefulSet shape to stateless Deployments
wired to a managed database via Secret, applied by two thin overlays
(azure-staging, azure-prod) that point at an Azure Database for PostgreSQL
(Flexible Server) instance. The Postgres shape is defined once, so staging keeps
rehearsing prod; the base and the SQLite overlays are untouched. See
deployment.
The Pacman case is built around real seeded assets
Section titled “The Pacman case is built around real seeded assets”The end-to-end Pacman case (Fathom) used to have
the model draw all of its art in code. It now seeds a
fixed set of pre-drawn sprite-sheet assets under assets/ — the forager, the
three predators, the sonar pulse, the flare bloom, and the trench tileset, all
themselves generated by The Test Cabinet’s own asset-generation cases — that the
build is required to render the game with, while still designing its own maze.
A new specs/assets.md defines the frame layouts, compositing, and what is left to
draw in code, and the end-to-end manifest’s
assets array seeds them. The end-to-end docs and the authoring skill now spell
out that seeded assets must be requested by page-relative URLs (no leading /;
for a bundler, a relative base such as Vite’s base: './'), because a finished run
is played back from a per-run sub-path (/runs/<id>/build/) and the host’s
<base> rewrite reaches only the served HTML, never a URL the build constructs at
runtime.
Console polish
Section titled “Console polish”The models page is reworked as a table, and the consoles no longer fall back to fabricated sample test cases, runs, and models when their data source is empty: an explicit catalog load state (loading | ready | error) is threaded through the gallery context, so an unreachable or unconfigured backend now renders a distinct error rather than dummy “Carom”/“Phalanx” cases.
Desktop cluster bootstrap
Section titled “Desktop cluster bootstrap”The self-hosted path hit a string of real-world host conditions, now handled:
- Container runtime detection. A Finder/Dock-launched app inherits a
truncated
PATH, so docker/podman (and k3d’s own runtime lookup) couldn’t be found; tools are now resolved to absolute paths across the inheritedPATHplus the well-known install dirs. Preflight detects a running runtime (podman-preferred), points k3d at Podman’s host-reachable API socket viaDOCKER_HOST, and treats a rootless Podman machine as k3s-incapable (rootless cgroup v2 withholds the cpuset controller) — falling back to a working Docker when one exists instead of hanging until the create timeout. - Leftover clusters. A crash or force-quit mid-bootstrap left a registered k3d
cluster whose API server was unreachable;
ensure_clusternow recovers it — start it if only stopped, delete + recreate if it can’t be brought up, and probe the API server after start/create, recreating once if a partial cluster reports up but never answers.--waitnow carries a bounded--timeoutso a stuck bring-up fails legibly. - Port-forward conflicts. A process already holding a forward’s local port (an
editor squatting on
8787) surfaced only as an opaque healthz timeout; each port is now preflighted (reporting the offending pid vialsof), each forward is pinned to127.0.0.1, andwait_healthzrequires the backend’s{"status":"ok",…}body so a foreign listener can’t be mistaken for it. - Arena backend resolution. Desktop arena commands (Quick Match, tournaments)
now resolve the backend the same way the webview does, so they find the catalog
on the self-contained cluster instead of falling back to a
test-cases/checkout the packaged app doesn’t ship. - Boot screen. The
BootGatewas recolored from a clashing teal/pink scheme to the app’s orange/purple palette, and tool failures are ANSI-stripped so k3d’s colorized output renders as plain text.
Live run stream survives idle gaps in the desktop webview
Section titled “Live run stream survives idle gaps in the desktop webview”The GET /jobs/{id}/live NDJSON stream only emitted bytes on a harness event, so
the silent gap while cloning the case and pulling the run-container image sent
nothing for over a minute — and WKWebView (backing the Tauri app) timed the fetch
out at ~60s as TypeError: Load failed. A 15s newline heartbeat is now interleaved
into the live tail (Chromium had no idle-read timeout, so the web console never saw
this).
Orphaned jobs are reaped on a single-box restart
Section titled “Orphaned jobs are reaped on a single-box restart”If a single-box (SQLite) backend is powered off mid-run, the driver dies before
reporting and the dispatcher’s in-memory death detection doesn’t survive its own
restart, stranding the job in running forever. The backend now reconciles any
dispatched/running job to failed at startup — before the router serves, so no
driver can be mid-report — gated to SQLite so a remote backend (which can restart
while drivers keep running) never reaps live work.
Adversarial replays load in the desktop webview
Section titled “Adversarial replays load in the desktop webview”Two webview-specific failures broke every replay (quick match, proof matches, tournaments) in the desktop app:
- The replay player
fetch()ed its bundled sprite sheet, butsheet.pngfalls under Vite’s inline limit and is emitted as a base64data:URL, which WKWebView cannotfetch(). Suchdata:URLs are now decoded directly, falling back tofetch()for emitted file URLs. - The player instantiates the Foray engine wasm via
WebAssembly.instantiate, which the webview refuses without'wasm-unsafe-eval'in the CSPscript-src; an explicitscript-srcincluding it was added.
Console styling
Section titled “Console styling”Dropdowns now share uniform styling between the web and desktop consoles, and the notifications drawer’s accent glow no longer bleeds past the right viewport edge while the drawer is closed (the shadow is rendered only in the open state).