# SysDeck — Release Notes Author: **Jeremy Anderson** · · --- ## v0.3.0 — 2026-09-11 (AI Gateway Edition: klanker-gate integrated) *(latest release: v0.4.1 — cockpit module detection; see the last entry below)* v0.3.0 answers the operator's question: *"lets take a look at this project klanker-gate, i believe its mainly coded for a windows platform. how much work would it be to port it to arch linux and into the sysdeck as a module."* The answer surprised the premise, so this entry records both the verdict and the evidence. ### The verdict: zero source changes (it was never a Windows codebase) klanker-gate — internally "Frosty Deno" — is a **Deno 2 + TypeScript** LLM gateway by **TykoDev** (https://github.com/TykoDev/klanker-gate, Apache-2.0; **not SysDeck code** — full credit and license notes in `klanker-gate/ATTRIBUTION.md` and `THIRD_PARTY.md`). Deno runs identically on every platform. The Windows mentions in its 448-file tree are accommodations for a second-class Windows *dev* platform, not Windows-first code: - `apps/gateway/cluster.ts` — `reusePortSupported()` returns true **only for `linux` and `darwin`**. Windows is locked to single-process serving, and the failure message literally directs the operator to "Use Docker/Linux for multi-process". - `Dockerfile` — both stages are Linux images (`denoland/deno:2.9.3`, `denoland/deno:alpine-2.9.3`). The Windows comment in it is about Defender locking files on a Windows *host*. - `deploy/docker-entrypoint.sh` — a POSIX `/bin/sh` script. - `scripts/` — `.sh` and Deno `.ts` tasks; not a single `.bat`/`.ps1` in the tree. - `deno.lock` win32 entries — ordinary cross-platform lockfile records that every project using npm-native dependencies carries on every OS. So the "port" is a **packaging exercise**: install Deno + PostgreSQL, manage the process with systemd. On top of that, moving to Arch *unlocks* a feature Windows cannot have — `FROSTY_WORKERS` multi-process serving through `SO_REUSEPORT` (Linux/darwin only). ### What ships in sysdeck-0.3.0-master.tar.bz2 - `/` — the cockpit edition: **27 plugins** (new: sysdeck-klanker), 28 bridge modules. - `/web` — the SysDeck Web Edition: **29 bridge modules** (new: klanker, the AI Gateway panel). - `/web/mini-services/fester` — Fester, vendored + pre-integrated (independent version 0.2.1), unchanged from v0.2.0. - `/klanker-gate` — **klanker-gate vendored + pre-integrated** (by TykoDev — https://github.com/TykoDev/klanker-gate — independent version 0.9.0, Apache-2.0, credited in its `ATTRIBUTION.md`), including the new `arch/` packaging directory. ### Run without Cockpit — the complete runbook (0.3.0) v0.3.0 also answers: *"make better instructions on running sysdeck without cockpit with just the nextjs backend."* The web edition is fully standalone — no cockpit, no Python bridge, no systemd, no root — and the instructions now exist in three kept-in-sync forms: - **the "Run without Cockpit" panel** in the web console (system group, right under Overview): prerequisites, the one-command `make web-dev` quickstart, the granular commands, the standalone production build (bun + node-only paths), both systemd units (web + fester), the `.env` reference table, the reverse-proxy/WebSocket-gateway configs (Caddy + nginx), and a troubleshooting matrix — every command block copy-to-clipboard. - **`web/README.md`** in the master tarball — the same runbook as plain markdown. - **QUICKSTART §11** — the condensed version, plus §12 for the skin below. ### The web-edition skin for Cockpit (0.3.0) *"…or completely theme cockpit to look like the web edition 0.3.0 as demoed"* — done, without touching a single module: - `shared/sysdeck-web.css` — the skin: every plugin's `index.html` links it after the base stylesheet. It ports the Next.js console's midnight/teal design (accent `#3fc9b0`, soft-tinted badges, 10px radii, tabular numerals, teal-edged scrollbars, reduced-motion support) onto the `.sysdeck-*` / `.suite-*` vocabulary. Revert: delete the file. - `sudo make install-branding` — themes the Cockpit **shell** chrome itself (sidebar, header, login) via `/usr/share/cockpit/branding.css`, Cockpit's documented override point; covers PatternFly v4 (`pf-c-*`) and v5 (`pf-v5-*`) generations, backs up any distro `branding.css` first, `make uninstall-branding` restores it. ### The Arch packaging (`klanker-gate/arch/`) - `PKGBUILD` — self-packaging (files come from the tree the file lives in): gateway tree → `/usr/share/klanker-gate`, wrapper → `/usr/bin/klanker-gate`, env → `/etc/klanker-gate/env` (pacman `backup=()`), unit + sysusers + tmpfiles in their canonical locations. `depends=('deno>=2.9')`; postgres is an `optdepends` (remote `FROSTY_PG_URL` is equally supported). - `klanker-gate.service` — hardened systemd unit: `StateDirectory=klanker-gate`, `WorkingDirectory=/var/lib/klanker-gate` (so the permission contract's `--allow-write=data` resolves to `/var/lib/klanker-gate/data`), `ProtectSystem=full`, `PrivateTmp`, empty `CapabilityBoundingSet`. Deliberately no `MemoryDenyWriteExecute` (V8's JIT needs W^X pages). - `run.sh` — the ExecStart wrapper: one-time `deno cache --frozen` warmup into the service user's DENO_DIR, then exec with the upstream permission flags, plus `--allow-run` scoped to the Deno binary **only** when `FROSTY_WORKERS>1` — mirroring the upstream entrypoint's own escalation policy. - `INSTALL-ARCH.md` — the full runbook: `makepkg -si`, local postgres provisioning (role + database), env configuration, `systemctl enable --now`, healthcheck curls, the multi-worker bonus, the optional control-UI build, and the SysDeck wiring for both editions. ### The klanker module (both editions) - `bridge/klanker.py` — stdlib-only REST client (urllib, 4s timeout — never outliving the panel's 5s poll), 10 subcommands: `status` (healthz + version, merged + enriched), `providers`, `models`, `vkeys`, `logs --limit`, `analytics --window`, `runtime`, `service ` (systemctl wrapper for klanker-gate.service), `journal [N]` (journalctl tail, ANSI-stripped, token-masked, 32 KB cap), `localstack` (probes ollama/llama.cpp/koboldcpp/LM Studio/SGLang/vLLM `/v1/models` on this host, 0.4s each in parallel threads, returns wiring recipes + env/admin-API examples). `KLANKER_URL` (default `http://127.0.0.1:8080`) and `KLANKER_ADMIN_TOKEN` (header-only, never echoed) drive it. Connection failures are graceful JSON with a remediation hint — same contract as fester.py. - `plugins/sysdeck-klanker/` — full panel: gateway status card, stat grid (providers, virtual keys, requests 24h, spend 24h — upstream money is integer **micro-USD**, displayed as `$X.XXXX`), providers table with health dots, virtual keys table, recent-requests table, model catalog, **local stack wiring card** (live-probed backend matrix + copyable env/curl recipes), runtime topology card, service control (start/stop/restart with confirm) and a journal viewer. 5s auto-refresh that preserves the service/journal card state. - Web edition — the **AI Gateway** panel under Integrations: hybrid live/demo. It probes the gateway first (`KLANKER_URL`, 1.5s timeout, Bearer `KLANKER_ADMIN_TOKEN`); unreachable → seeded demo rows, clearly badged, with a note explaining that this sandbox has no Deno/Postgres. On a host running the gateway, it flips to `source: live` untouched. - `shared/bridge.js` — the klanker surface: 11 methods. `check-bridge-subcommands` now verifies **218 calls across 28 bridge modules** (was 216/27 — the audit pass added auth readers+certs). ### The local stack is first-class (0.3.0 follow-up) A fair question after the integration: *"it seems to be mainly for SaaS account linking, i personally only run a local stack such as ollama, llama.cpp, koboldcpp"* — can all features be met locally? Yes: the upstream provider registry has **five keyless self-hosted types** — `ollama`, `lmstudio`, `sgl`, and the generic `openai-compatible` / `anthropic-compatible` ("user supplies the base URL and an optional key", per the registry's own comment) — and llama-server/KoboldCpp/vLLM all speak the OpenAI wire. Governance, virtual keys, budgets, fallback + weighted load-balancing, model auto-discovery (`refresh-models`), the request ring and analytics are all provider-agnostic, so they work unchanged over a local stack — spend just reads ~$0. What the release adds on top: the `localstack` probe + the **Local stack wiring** cards in both editions, env/admin-API recipes with the 8080 port-collision warning (llama-server's default = the gateway's port), QUICKSTART §10.1, and a local-first re-seeded web demo dataset (ollama + llama-server + koboldcpp + lmstudio + sglang + one groq overflow row — 24h spend ≈ $0.001, all of it the cloud fallback). And the follow-up question — *"it should be easy to toggle this ai gateway on or off in case i decide to use something like pi"* — is now a first-class shell feature: **module visibility toggles** (web edition). Every sidebar row carries a power control; a disabled module vanishes from navigation and the command palette, lands in a **Disabled (N)** section for one-click re-enable, and the state persists in SQLite (`shell.disabled`, new `shell` bridge module, audited). The `POST /api/bridge` envelope also got a correctness fix: command-level `fail()` responses now pass through top-level instead of being double-wrapped, so panels actually render command errors. ### The 0.3.0 security audit (follow-up) *"do a security audit on klankergate code and the full sysdeck codebase afterwards"* — done, end to end, and the release is better for it. Four audit passes covered the vendored gateway (Deno routes, crypto, admin surface, compose), the cockpit bridge (28 helpers), the 27 plugin panels, and the web edition (bridge dispatcher, 31 modules, panels, fester service). **SysDeck code: fixed, not filed.** The two criticals in the cockpit bridge were both "the guard exists 100 lines away and this command forgot it" — `policy.py cgroup-set` wrote `/` as root (arbitrary file overwrite → one-prompt persistent root via `/etc/cron.d`), and `builder.py artifacts-clear` rmtree'd an unvalidated profile argument. Both now resolve-and-bound exactly like their siblings; the same treatment went to build-log/build-delete/ artifacts/profile-create (traversal + config injection into root-executed build configs). hwalert carried the tree's only `sudo sh -c` f-string — a literal root shell injection, latent only because the module isn't wired to a panel yet — now a direct write behind a sysfs device-path guard. The db module's start/stop/restart accepted arbitrary unit names (`db stop sshd`) and its `query` promised DDL-refusal in a comment while running any statement; both are honest now. The 8 oldest panels rendered live tool output unescaped into `innerHTML` (package names from repos, USB descriptors, fwupd metadata — the XSS→cockpit-session→bridge-RCE chain); all escape now, and the 27 manifests dropped `unsafe-eval`. The web edition bound every surface to 0.0.0.0 unauthenticated — dev server, fester service, and the port gateway's wildcard transform — so everything now pins loopback, the bridge gained a body cap + per-IP rate limit, and unexpected errors return generic text (details to the server log). **klanker-gate: audited, credited, and left unmodified.** The vendoring contract (byte-identical upstream tree, SysDeck adds only `arch/`) holds — so the audit's 10 upstream findings (3 critical: the admin API runs unauthenticated when `FROSTY_ADMIN_TOKEN` is unset, the gateway binds 0.0.0.0 by default, and `/v1/*` is open until the first virtual key exists) are documented in `klanker-gate/arch/SECURITY-UPSTREAM.md` as an advisory that can travel upstream, while the SysDeck packaging layer mitigates what packaging can: the systemd unit now refuses to start without the token (fail-closed `ExecStartPre`, opt-out via drop-in), and `INSTALL-ARCH.md` §9 carries the firewall + create-a-vkey -immediately runbook. The audit also recorded what upstream does *well* — AES-256-GCM envelope key storage, SHA-256-hashed vkeys, redacted provider views, an opt-in-only content log with deep secret redaction, and a properly sandboxed Code Mode worker. **Verified:** `make check` 254/254, `check-bridge-subcommands` 218/28 (the audit pass added auth readers+certs), python compile checks on all 8 touched bridge files, panel import checks, `bun run lint` clean, and the tarball rebuilt with new builder guards that fail the build if any of the fixes regress. ### Verification - Cockpit tree: `make check` — **all 254 tests + guards pass** with the new module (bridge subcommands cross-check, manifest consistency, version sync across all 9 surfaces now reporting 0.3.0). - Bridge offline smoke: `status`/`service status`/`journal` all return graceful structured JSON in the sandbox (no Deno, no systemd units here — exactly the degradation path designed). - Web edition: `bun run lint` clean; bridge smoke — all 7 klanker commands return `ok:true` with `source:'demo'`; panel render + mobile verified with zero page/console errors. - Master tarball rebuilt by `scripts/make-master-tarball.sh` with the vendored `klanker-gate/`; verified by extraction, file count, sha256. ### Notes - klanker-gate's version (0.9.0) is intentionally independent from SysDeck's (0.3.0), same as Fester's (0.2.1) — each mirrors its own repository's version line inside the bundle. - The gateway cannot run inside the web edition's dev sandbox (no Deno runtime, no PostgreSQL); that is what the honest DEMO badge communicates. On the operator's Arch host, `arch/INSTALL-ARCH.md` is the 5-minute path to the LIVE badge. --- ## v0.2.0 — 2026-08-20 (Master Edition: one tarball, two editions, Fester pre-integrated) v0.2.0 turns SysDeck into a single distributable that ships **both** editions with **Fester vendored and wired in**. The release answers the operator's framing directly: *"fester exists as a separate repository — it deserves its own. generate a master tarball of sysdeck with fester pre-integrated."* ### What ships in sysdeck-0.2.0-master.tar.bz2 - `/` — the cockpit edition, unchanged upstream layout: 26 plugins, bridge/, shared/, packaging, tests, docs. - `/web` — the NEW **SysDeck Web Edition**: a standalone Next.js 16 console (28 bridge modules) with real `/proc` + `/sys` collectors where the host allows, and clearly badged demo datasets where backends are absent. New panels: Overview landing view, Hardware Alerts (the orphaned bridge that never had a UI), and fully-built Mesh / Vault / Hardware Auth / Firmware. - `/web/mini-services/fester` — **Fester, vendored + pre-integrated**. Fester stays an independent project with its own version line (0.2.1); the master tarball pins a snapshot so no separate checkout is needed. ### Fester pre-integration (the cockpit side is real now) - `bridge/fester.py` — the v0.0.31 systemd-listing stub is gone. 11 real subcommands against the fester REST API (stdlib urllib, 4s timeout, `FESTER_URL` override, default `http://127.0.0.1:3010`): `status`, `metrics`, `builds`, `build `, `nodes`, `targets`, `timeline `, `sessions`, `start-build --project --targets [--no-cache] [--retries] [--fail-action]`, `cancel `, `replay `. Every subcommand degrades to actionable JSON when the service is down ("start it with `make fester-start`…"). - `plugins/sysdeck-fester/fester.js` — a real panel: service status card, stat grid (builds total/running/succeeded/failed, cache-hit rate), cluster nodes table, live+history builds table with state chips and row actions (Cancel, Replay → inline session id, Timeline → expandable event log), a start-build form built from the target catalog, 5s auto-refresh that preserves form state. - `shared/bridge.js` — the fester surface grew from 1 method to 11; `check-bridge-subcommands` now verifies **206 calls across 27 bridge modules** (was 195/26). ### Also fixed in this release - **The shipped 0.1.3 Makefile was broken.** Its recipes were indented with 8 spaces instead of tabs — GNU make rejects the file outright (`missing separator (did you mean TAB instead of 8 spaces?)`), so `make install` / `make dist` could not run from the released tarball. v0.2.0 restores tab indentation (the fix the v0.0.28 guard itself recommended) and `make -n` passes for every target. - **New Makefile targets**: `fester-start` (vendored fester service on :3010), `web-install` (bun + prisma setup for the web edition), `web-dev` (fester in the background + web console on :3000), `master` (rebuild the master tarball from the tree). - Version surfaces bumped 0.1.3 → 0.2.0 across all release surfaces (Makefile, `bridge/__init__.py`, setup.py, PKGBUILD, spec, debian/changelog, compat-manifest, metainfo). ### Verification - Cockpit tree guards: `python3 -m py_compile bridge/fester.py` clean; `node --check` clean on `fester.js` and `bridge.js`; `tests/check_bridge_subcommands.py` → 206/206 across 27 modules; `check-makefile-recipes` green (tabs restored); `make -n` parses for install / plugins / dist / master / web-dev / fester-start. - Live integration smoke against the running fester service: `start-build --project gentoo-stage3 --targets mipsel` → build `gentoo-stage3-mtw730qo` (7/7 actions, 7 CAS cache hits, 1050 ms critical path); a second build listed `running` then `cancel` → `{"ok": true}` (final state cancelled, 11 timeline events); `replay` → session `sess-tl6ax5-uvo7vh`. - Web edition end-to-end: all 28 modules clicked through with zero page errors and zero console errors; the Fester sub-app through the gateway shows live WS events, cluster nodes, and mid-flight builds; the theme engine re-tints the whole suite. - Master tarball assembled by `scripts/make-master-tarball.sh`; verified by extraction, file count, and sha256. ### Notes - Fester's version (0.2.1) is intentionally independent from SysDeck's (0.2.0) — it mirrors the separate-repo reality and lets the vendored snapshot track upstream releases without forcing a SysDeck release. - The web edition's Prisma-backed modules (mesh, vault, containers, mining, …) are demo-badged where the host lacks the backend — the badge is honest, the data shapes are real. ## v0.1.3 — 2026-08-19 (critical: import fixed with shutil.which + mkosi reads profile via temp symlink + download/manage UI) ### Theme: the import was silently empty and the build still wasn't reading the config v0.1.3 fixes two more critical bugs that v0.1.2 missed, and adds the download/manage UI the operator asked for. The operator's report: > *profile workstation still doesnt import current system pkgs. it > trys to build only 2. which is repetitive at this point.* Two root causes. Both are embarrassingly simple. ### Import bug: `from __init__ import` failed in the cockpit context `_detect_host_packages()` started with: ```python try: sys.path.insert(0, str(Path(__file__).resolve().parent)) from __init__ import DISTRO, PKG_MANAGER except Exception: DISTRO = "unknown" PKG_MANAGER = "unknown" ``` The `except Exception: PKG_MANAGER = "unknown"` was the silent killer. When the cockpit superuser channel runs the bridge helper, the Python path context is different — `from __init__ import` fails, the except clause silently sets `PKG_MANAGER = "unknown"`, and the function returns `([], "unknown")`. No error, no warning, just an empty list. The import call in `profile_import_packages` then wrote nothing (the empty-list early return), and the build used the profile's original 4 template packages — of which mkosi installed 2 (the base `iana-etc` and `filesystem`). **Fix:** use `shutil.which()` to find the binary directly: ```python pacman_bin = shutil.which("pacman") if pacman_bin: cmd = [pacman_bin, "-Qqe"] marker = "arch" ``` No import dependency. Works in any execution context. If `pacman` isn't on PATH, it tries `apt-mark`, then `dnf`. If none are found, returns `([], "unknown")` — but now the "unknown" is real, not a silent import failure. ### Build bug: `--include` doesn't replace the base config v0.1.2 added `--include ` to the mkosi command. I thought `--include` told mkosi "load this config file." It doesn't. mkosi's `--include` flag includes a **drop-in fragment** ON TOP OF the base `mkosi.conf`. The base config must still exist as `mkosi.conf` in the cwd. If it doesn't, mkosi uses defaults and the `--include` file is silently ignored. So for the operator's profile at `/etc/mkosi/mkosi.conf.d/arch-workstation.conf`: - mkosi runs in `/etc/mkosi/mkosi.conf.d/` - looks for `mkosi.conf` there — doesn't find one - uses empty defaults (2 base packages) - the `--include arch-workstation.conf` file is layered on top of nothing, effectively ignored **Fix:** create a temp directory, symlink the profile file as `mkosi.conf` inside it, and run mkosi from there: ```python def _prepare_mkosi_work_dir(profile): tmpdir = Path(tempfile.mkdtemp(prefix="sysdeck-mkosi-")) link = tmpdir / "mkosi.conf" link.symlink_to(Path(profile["path"]).resolve()) return tmpdir ``` Then `build()` sets `work_dir = tmpdir`, so mkosi's cwd is the temp dir. mkosi finds `mkosi.conf` (the symlink), follows it, reads the actual profile file. Works for ANY profile path — v0.0.x drop-ins, v0.1.0+ per-profile dirs, even profiles in random locations. The temp dir is cleaned up after the build finishes. ### New: download artifacts directly from the panel Each artifact in the Artifacts panel now has a ⬇ Download button. The implementation uses `cockpit.spawn(["cat", path], { superuser: "try", binary: true })` to read the file as a binary stream, collects the chunks into a `Blob`, creates an object URL, and triggers a browser download via a synthetic `` click. Works for files of any size (streamed, not loaded into memory all at once by the bridge — the JS side does collect chunks, but cockpit handles the transport efficiently). ### New: manage artifacts — delete per-file, clear per-profile Each artifact has a 🗑 button that calls `artifact-delete `. The Python side resolves the path safely (refuses path traversal outside `BUILDER_ARTIFACTS_DIR`) and `unlink()`s the file. Each profile's artifacts card has a 🗑 Clear all button that calls `artifacts-clear `. Removes the entire `/var/lib/sysdeck/builder/artifacts//` directory. Returns file count + bytes freed for the log. The card header now shows total size: `myarch artifacts (3, 1.2 GB)`. ### New: manage builds — delete state + log (+ optionally artifacts) Each build in the Builds table has a 🗑 button. Two-step confirm: 1. "Delete build record?" — OK = delete state + log only 2. If Cancel: "Also delete ALL artifacts for profile?" — OK = delete state + log + the profile's entire artifacts dir The Python `build-delete [--artifacts]` reads the state file FIRST (to get the profile name for artifact cleanup) before deleting it. Then deletes state + log. If `--artifacts`, also `shutil.rmtree()` the artifacts dir. ### Regression tests 11 new unit tests across two new test classes: - `TestBuilderArtifactManagement` (7 tests): - `test_artifact_delete_removes_file` - `test_artifact_delete_refuses_path_traversal` - `test_artifacts_clear_removes_all` - `test_build_delete_removes_state_and_log` - `test_build_delete_with_artifacts_flag_clears_artifacts_dir` - `test_build_delete_nonexistent_returns_error` - `test_new_subcommands_registered_in_commands` - `TestBuilderMkosiTempWorkDir` (3 tests): - `test_prepare_creates_temp_dir_with_mkosi_conf_symlink` - `test_prepare_returns_none_for_missing_profile_path` - `test_prepare_returns_none_for_nonexistent_file` Existing `test_detect_host_packages_pacman` rewritten to mock `shutil.which` instead of `__import__`. `test_build_success_path` updated to no longer expect `--include` on the command line. Total: 254 tests (was 243 in v0.1.2; +11). All pass. ### The mkosi command now ``` $ mkosi build --output myarch.raw --output-dir /var/lib/sysdeck/builder/artifacts/myarch --force # work_dir: /tmp/sysdeck-mkosi-abc123 # backend: mkosi # profile: myarch # output_dir: /var/lib/sysdeck/builder/artifacts/myarch ``` `work_dir` is the temp dir containing `mkosi.conf` → symlink to the real profile. mkosi reads the symlink, gets the real config. No `--include` needed. --- ## v0.1.2 — 2026-08-19 (critical: mkosi never read the profile — --include + auto-migrate) ### Theme: the builder had zero package awareness because mkosi never saw the config v0.1.2 fixes the critical "zero packages" bug. The operator's report was unambiguous: > *the builder absolutely does not work yet. it has zero awareness of > packages we tell it to add.* Two compounding root causes were identified and both fixed. ### Root cause 1: mkosi never read the profile config file `_backend_build_command()` for mkosi was: ```python cmd = ["mkosi", "build", "--output", name, "--output-dir", dir, "--force"] ``` No flag tells mkosi WHERE the profile config file is. mkosi's default behavior: look for a file literally named `mkosi.conf` in the cwd. If it doesn't find one, it uses EMPTY defaults — no distribution override, no packages, no output settings, nothing. For the operator's profile at `/etc/mkosi/mkosi.conf.d/arch-workstation.conf`: - `work_dir` = `/etc/mkosi/mkosi.conf.d/` (parent of the profile file) - mkosi runs in that cwd - mkosi looks for `mkosi.conf` in `/etc/mkosi/mkosi.conf.d/` - The file is named `arch-workstation.conf`, NOT `mkosi.conf` - mkosi finds no config → uses empty defaults - Zero packages installed Even for v0.1.0+ profiles at `/etc/mkosi/profiles//mkosi.conf`, the file IS named `mkosi.conf` so mkosi would find it — but only because of the directory layout, not because sysdeck was explicit about it. That's fragile. ### Fix 1: `--include ` on every mkosi build ```python cmd = ["mkosi", "build"] if ppath: cmd += ["--include", ppath] cmd += ["--output", output_name, "--output-dir", artifacts_dir, "--force"] ``` `--include` tells mkosi to explicitly load the profile config by path, regardless of its filename or location. This is the fix for "zero awareness of packages" — mkosi now ALWAYS sees the profile config, whether it's at `/etc/mkosi/profiles/myarch/mkosi.conf` (v0.1.0 layout) or `/etc/mkosi/mkosi.conf.d/arch-workstation.conf` (v0.0.x layout). ### Root cause 2: legacy `Packages=` syntax silently parsed as garbage Even when mkosi DID read the profile file (e.g. v0.1.0+ profiles with correct location), profiles created by v0.0.x used the old indented `Packages=` syntax: ```ini [Packages] Packages= linux linux-firmware systemd openssh ``` mkosi v22+ (Arch ships 25.x) only understands single-line: ```ini [Packages] Packages=linux linux-firmware systemd openssh ``` The old indented form is silently parsed as a single package name with embedded newlines (`"linux\nlinux-firmware\nsystemd\nopenssh"`), which doesn't exist in any repo — so mkosi installs NOTHING. The build "succeeds" but the image has zero of the operator's requested packages. ### Fix 2: auto-migrate legacy `Packages=` syntax before every build New `_migrate_legacy_mkosi_packages(conf_path)` function: 1. Reads the profile file 2. Detects the old indented syntax via regex 3. Extracts package names from the indented block 4. Rewrites the `Packages=` line to single-line space-separated form 5. Writes the file back IN-PLACE `build()` calls this automatically on every mkosi build, BEFORE constructing the command. The migration is logged in: - **Build state JSON** (`warnings` array): `"packages_migrated: rewrote Packages= from old indented syntax to single-line (N packages: ...)"` - **Log file header**: `# MIGRATED: rewrote Packages= from old indented syntax to single-line (N packages: ...)` If the file already uses modern syntax, the migration is a no-op (returns `{"migrated": False, "reason": "already uses single-line syntax"}`). ### Regression tests 4 new unit tests in `TestBuilderBuildPath`: - `test_migrate_rewrites_old_indented_syntax` — verifies old `Packages=\n linux\n vim\n` is rewritten to `Packages=linux vim` - `test_migrate_noop_on_modern_syntax` — verifies already-modern files are left unchanged - `test_migrate_noop_on_no_packages_section` — verifies files without `[Packages]` are left unchanged - `test_migrate_runs_during_build` — end-to-end: `build()` with a profile containing old syntax auto-migrates before mkosi runs, and the migration is recorded in state + log The existing `test_build_success_path` was extended to verify `--include` is on the command line and points at the profile file. Total: 243 tests (was 239 in v0.1.1; +4). All build-time guards pass. ### The mkosi command line now ``` $ mkosi build --include /etc/mkosi/profiles/myarch/mkosi.conf --output myarch.raw --output-dir /var/lib/sysdeck/builder/artifacts/myarch --force # work_dir: /etc/mkosi/profiles/myarch # backend: mkosi # profile: myarch # output_dir: /var/lib/sysdeck/builder/artifacts/myarch ``` Every flag sysdeck needs is on the CLI. Nothing depends on the profile's `mkosi.conf` having the right settings — sysdeck forces the config path, output name, output dir, and overwrite. --- ## v0.1.1 — 2026-08-19 (output path safety fix: CLI flags force artifacts dir) ### Theme: trusting mkosi.conf was the bug v0.1.1 is the "I should have done this in v0.1.0" release. The v0.1.0 fix added `OutputDirectory=` to the scaffolded `mkosi.conf` template, trusting mkosi to honor it. Two problems with that trust: 1. **Old v0.0.x profiles have no `OutputDirectory=`.** The operator's `arch-workstation` profile was created by v0.0.50 — it lives at `/etc/mkosi/mkosi.conf.d/arch-workstation.conf` and has no output directory setting. mkosi defaulted to writing `image.raw` into the cwd (`/etc/mkosi/mkosi.conf.d/`), a system config directory owned by root. 2. **mkosi then refused to overwrite the existing `image.raw`.** The error message — "Output path /etc/mkosi/mkosi.conf.d/image.raw exists already. (Use --force to rebuild.)" — blocked every rebuild from the panel, which had no way to pass `--force`. The operator's response was unambiguous: > *this is NOT a safe output path. fix this now.* ### The fix: don't trust the profile, force the CLI `_backend_build_command()` for mkosi was just: ```python cmd = ["mkosi", "build"] ``` It is now: ```python artifacts_dir = options.get("output_dir") or str(BUILDER_ARTIFACTS_DIR / pname) output_name = options.get("output_name") or f"{pname}.raw" cmd = [ "mkosi", "build", "--output", output_name, "--output-dir", artifacts_dir, "--force", ] ``` CLI flags override `mkosi.conf` (mkosi's documented precedence: CLI > config file). So the output path is forced to `/var/lib/sysdeck/builder/artifacts//.raw` regardless of what the profile says — or doesn't say. `--force` overwrites any existing image so rebuilds don't fail. ### Belt-and-suspenders: refuse unsafe output paths Even with the CLI force, I added a safety check in `build()` that refuses to proceed if the resolved `output_dir` is not under `/var/lib/`, `/tmp/`, `/var/tmp/`, or the configured `BUILDER_ARTIFACTS_DIR`. This blocks `/etc/`, `/usr/`, `/boot/`, `/bin/`, `/sbin/`, `/lib/`, `/root/`, `/home/`, etc. — anywhere a stray `image.raw` would corrupt the system or pollute a user's home. If an operator somehow passes `options.output_dir=/etc/something` via the JS bridge, the build is refused before `subprocess.run` is called. The error message: ``` refusing to build: output directory '/etc/mkosi/evil' is not under /var/lib/, /tmp/, or /var/tmp/. Build outputs must go to /var/lib/sysdeck/builder/artifacts// to avoid corrupting system config directories. ``` ### Legacy profile warning The operator's build was running against a v0.0.x profile in `/etc/mkosi/mkosi.conf.d/`. v0.1.0 already fixed the scaffold location for NEW profiles (they go in `/etc/mkosi/profiles//mkosi.conf`), but the OLD profile is still there. v0.1.1 doesn't refuse to build it (the output-path safety is handled), but it records a warning in both the build state JSON and the log file: ``` # WARNING: profile is in /etc/mkosi/mkosi.conf.d/ (legacy v0.0.x layout). # mkosi may silently ignore this drop-in fragment. # Migrate to /etc/mkosi/profiles//mkosi.conf for a real profile. ``` The build state JSON gets a `warnings` array so the panel can surface it in the UI too. ### Log improvement The build log header now includes the resolved `output_dir` so the operator can see exactly where the image will land before mkosi starts: ``` $ mkosi build --output arch-workstation.raw --output-dir /var/lib/sysdeck/builder/artifacts/arch-workstation --force # work_dir: /etc/mkosi/mkosi.conf.d # backend: mkosi # profile: arch-workstation # output_dir: /var/lib/sysdeck/builder/artifacts/arch-workstation ``` ### Regression tests 2 new unit tests in `TestBuilderBuildPath`: - `test_build_refuses_output_dir_under_etc` — verifies the safety check rejects `output_dir=/etc/mkosi/evil`. - `test_build_legacy_v050_profile_records_warning` — verifies building a profile in `/etc/mkosi/mkosi.conf.d/` records the legacy warning in state + log. The existing `test_build_success_path` was extended to verify the mkosi command line includes `--output`, `--output-dir`, and `--force`, and that `--output-dir` points at the per-profile artifacts dir. Total: 239 tests (was 237 in v0.1.0; +2). All build-time guards pass. ### What the operator should do 1. `sudo make uninstall && sudo make install && sudo systemctl restart cockpit.socket` 2. Delete the old image.raw that mkosi created in the wrong place: `sudo rm /etc/mkosi/mkosi.conf.d/image.raw` 3. Migrate the old profile: `sudo mkdir -p /etc/mkosi/profiles/arch-workstation && sudo mv /etc/mkosi/mkosi.conf.d/arch-workstation.conf /etc/mkosi/profiles/arch-workstation/mkosi.conf` 4. Click Build on `arch-workstation` — output will land at `/var/lib/sysdeck/builder/artifacts/arch-workstation/arch-workstation.raw` --- ## v0.1.0 — 2026-08-19 (builder profile fixup + host pkg import) ### Theme: the empty-image bug that turned out to be three bugs in a trench coat v0.1.0 is the "fix what v0.0.50 should have caught" release. The v0.0.50 fix (adding the missing `import re` to `bridge/builder.py`) unblocked the `build()` code path, and the operator immediately ran into the next layer of problems. The build log went: ``` ‣ Installing Arch Linux Packages (2) iana-etc-20260530-1 filesystem-2025.10.12-1 ‣ Generating disk image ‣ /etc/mkosi/mkosi.conf.d/image.raw size is 33.0M, consumes 32.0M. ``` Then the operator wrote: *"nice try but i think our profile build didnt work. i dont see a final tarball or image file to look at."* Two red flags in that single last line: 1. The file is `image.raw`, not `myimage.raw` (the template said `Output=myimage.raw`) 2. It's in `/etc/mkosi/mkosi.conf.d/`, not sysdeck's artifacts dir. The four packages from the scaffold template (`linux`, `linux-firmware`, `systemd`, `openssh`) were silently dropped. Three compounding bugs were to blame, and only one of them was the "obvious" one. ### Bug 1: the scaffolded profile was never read `profile-create` (since v0.0.31) wrote `/etc/mkosi/mkosi.conf.d/.conf` — a drop-in *fragment*. mkosi's drop-in semantics: `mkosi.conf.d/*.conf` files are layered on top of a *parent* `mkosi.conf`. With no parent, mkosi runs as if the fragment didn't exist. It auto-detected the host distro, defaulted to `Format=disk` with `Output=image.raw`, and used an empty `Packages=` list. That's why the output filename was `image.raw` not `myimage.raw`, and why the only packages that got installed were `iana-etc` + `filesystem` — mkosi's hardcoded Arch base. **Fix:** each profile now lives in its own directory `/etc/mkosi/profiles//mkosi.conf`. `mkosi.conf` is the only filename mkosi reads automatically from the cwd. `MKOSI_DIRS` updated to scan `/etc/mkosi/profiles` first. This also makes per-profile `mkosi.extra/`, `mkosi.pkg/`, etc. work naturally — operators can drop in supplementary files alongside the config and mkosi picks them up. ### Bug 2: the `Packages=` syntax was from mkosi v15 The template was: ```ini [Packages] Packages= linux linux-firmware systemd openssh ``` That indented-continuation form was the **old systemd-mkosi (≤v15)** syntax. Modern mkosi (v22+, what Arch ships as `mkosi 25.x`) wants either `Packages=linux linux-firmware systemd openssh` on a single line, or a separate `mkosi.pkg` file referenced via `Packages=mkosi.pkg`. The v0.0.x form was silently parsed as a single package named `"linux\nlinux-firmware\nsystemd\nopenssh"` and failed to install. **Fix:** template + writer now emit the modern single-line form. The reader accepts both forms so v0.0.x profiles migrate cleanly on first append/replace. This bug was particularly nasty because: - The writer's tests (`test_bridge_parsers.py:2216-2252`) asserted `" vim\n"` was in the file — passing the test meant emitting the *wrong* syntax. - The reader only understood the indented form, so even if you fixed the template by hand, the next `--mode=append` write would silently re-break the syntax. ### Bug 3: the artifact never reached sysdeck's artifacts directory `build()` (line 672-681 of the old code) only scanned `/var/lib/sysdeck/builder/artifacts//` for artifacts. But mkosi writes its output to the cwd (`/etc/mkosi/mkosi.conf.d/image.raw`). No copy step. Hence "i dont see a final tarball or image file to look at" from sysdeck's point of view — even though mkosi technically did produce one. **Fix:** `_MKOSI_TEMPLATE` now sets `OutputDirectory=/var/lib/sysdeck/builder/artifacts/` so mkosi writes directly there. The artifacts panel's discovery code already scanned that directory, so once mkosi writes there the panel finds it automatically. ### New feature: `profile-import-packages` Per operator request: *"import current os pkg list to profile should be an option."* Queries the host's explicitly-installed packages: - **Arch:** `pacman -Qqe` (explicitly installed; excludes deps) - **Debian:** `apt-mark showmanual` (closest analog to `pacman -Qqe`) - **Fedora:** `dnf repoquery --userinstalled --queryformat '%{name}'` Then writes the result into the profile via the existing `_write_packages` dispatch. Defaults to **append** mode (the operator usually wants to layer host packages on top of the profile's existing baseline like `linux`/`systemd`/`openssh`). Three flags: - `--mode=replace` — wipe the baseline first - `--dry-run` — return what *would* be written, don't touch the file - `--packages=` — override the host query with a JSON-encoded multiline string (useful for importing a list captured on a different host) The panel exposes a "⇩ Import host pkgs" button on every profile row. Two-step UX: dry-run preview → `window.confirm` with package count, source distro, and first 200 packages → append write. Operator can cancel cleanly without any file changes. New polkit exec paths for `pacman`/`apt-mark`/`dnf` added to `org.sysdeck.builder.modify`. ### Regression tests 7 new unit tests in `TestBuilderImportHostPackages`: - `_detect_host_packages` dispatch (pacman path + dedup) - `--packages` override end-to-end (writes the file) - `--dry-run` doesn't write - unknown profile / no args / bad mode / COMMANDS-registration error paths (Actually 8 — one of the dispatch tests splits into two methods, one for the happy path and one for dedup. The class total is 8.) 4 existing tests in `TestBuilderPackagesField` updated for the new single-line `Packages=` syntax. 1 new test (`test_mkosi_modern_single_line_input_parsed`) guards against a regression where the writer emits the new form but the reader only understands the old one — that would silently break append mode on profiles created by v0.1.0 itself. Total: 237 tests (was 228 in v0.0.50; +9). All build-time guards pass. ### Why v0.1.0 (and not v0.0.51) The bug fixes are technically backwards-incompatible: profiles created by v0.0.x live in `/etc/mkosi/mkosi.conf.d/.conf` and use the indented `Packages=` syntax. v0.1.0's reader accepts both syntaxes (safe migration), but the scaffold location is different. Operators upgrading from v0.0.x to v0.1.0 should either: 1. Move their profiles from `/etc/mkosi/mkosi.conf.d/.conf` to `/etc/mkosi/profiles//mkosi.conf`, or 2. Create a parent `/etc/mkosi/mkosi.conf` (any non-empty `[Distribution]` section will do) so the existing drop-ins start being honored. The minor version bump makes the incompatibility visible. --- ## v0.0.50 — 2026-08-19 (build path NameError fix: `import re` added to bridge/builder.py) ### Theme: the one-line fix that took 18 releases to find v0.0.50 is a one-line bugfix release. An operator reported: > *NameError: name 're' is not defined. Did you forget to import > 're'? happens right away on build for a new profile i created.* The traceback pointed at `bridge/builder.py` line 492, inside `_new_build_id()`: ```python safe_profile = re.sub(r"[^A-Za-z0-9_-]", "_", profile) ``` `re` wasn't imported at module level. The module-level imports were: ```python import json import os import shutil import subprocess import sys from pathlib import Path from typing import Any ``` No `import re`. `_new_build_id` has used `re.sub` since v0.0.31 — when the build operations were first added. The bug went undetected for 18 releases (v0.0.31 through v0.0.49) until an operator actually clicked Build on a freshly-created profile. #### Why it went undetected Three layers of defense all missed it: 1. **`python3 -m py_compile`** (the `make check` syntax check) only catches *syntax* errors. A `NameError` at call time is not a syntax error — the code is syntactically valid Python, it just references a name that isn't in scope when the function runs. 2. **Unit tests.** The existing unit tests covered `profile_create`, `profile_copy`, `profile_delete`, and the v0.0.49 package-writing helpers. None of them call `build()`, and `build()` is the only caller of `_new_build_id`. So the function that held the bug was never exercised by any test. 3. **Manual testing.** The operator workflow up to v0.0.48 was "create/copy a profile, then build it from a shell." The v0.0.49 release added the inline package-list field, which made the create-then-build flow smooth enough that the operator clicked Build in the panel for the first time — and hit the bug. The lesson: functions that are reachable only through a specific code path (here: `build()` → `_new_build_id()`) need explicit tests that exercise that path, even if the function itself looks trivial. The `make check` syntax check is necessary but not sufficient. #### The fix One line added to the module-level imports: ```python import json import os import re # ← added import shutil import subprocess import sys from pathlib import Path from typing import Any ``` Also removed the now-redundant local `import re` inside `_write_packages_vmdb2` (it was a v0.0.49 workaround — that function uses `re.compile` for the YAML-include regex, and I added a local import there instead of checking whether `re` was already module-level. It wasn't. The local import masked the missing module-level import for `_write_packages_vmdb2`'s own tests, but did nothing for `_new_build_id`). #### Regression tests 9 new unit tests in `TestBuilderBuildPath`: - **`_new_build_id` format**: asserts the build_id matches `^-(\d{14})$`. - **`_new_build_id` sanitizes unsafe chars**: profile name `myarch.v2` → `myarch_v2-` (dot replaced with `_`). - **`_new_build_id` preserves safe chars**: profile name `my-arch_profile` → `my-arch_profile-` (hyphens + underscores kept). - **`_new_build_id_re_imported_at_module_level`**: explicit `assertIn("re", dir(builder))`. This is the regression guard — if anyone ever removes the `import re` line in a future refactor, this test fails before the tarball ships. The v0.0.31-v0.0.49 bug can't recur. - **`build()` success path**: end-to-end with mocked `subprocess.run` (rc=0). Patches `BUILDER_STATE_DIR` / `BUILDER_LOGS_DIR` / `BUILDER_ARTIFACTS_DIR` to tempdirs, patches `BACKENDS` to fake a mkosi install, patches `profiles()` to return a fake profile. Verifies response shape (`build_id` / `state` / `rc` / `success` / `duration_s` / `artifacts` / `log_path`), state file written, log file written, `subprocess.run` was actually called. - **`build()` unknown profile**: returns `{error: "profile 'nonexistent' not found"}`. - **`build()` no args**: returns `{error: "profile name required"}` (no crash). - **`build()` backend not installed**: returns `{error: "backend 'mkosi' is not installed", hint: ...}`. - **`build()` non-zero returncode**: mocked `subprocess.run` returns rc=1 → build state `"failed"`, `rc=1`, `success=False`. All tests mock `subprocess.run` and the module-level state dirs; none touch real `/var/lib/` or invoke real backends. The tests run in 9 milliseconds. #### AST audit To make sure there weren't *other* latent NameErrors lurking in `builder.py`, I wrote an AST-based audit that walks every function body, collects `Name` loads, and checks each against (module-level names + function locals + builtins). The audit flagged ~50 items, but every one was a false positive: - **Comprehension locals** (`b`, `v`, `s`, `p`, `logf`) — bound by the comprehension itself. - **Tuple-unpacking targets** (`cid`, `chint`, `k`, `v`, `backend_id`, `binary`, `vargs`, `kind`) — bound by `for ... in` loops. - **Except-clause targets** (`exc`) — bound by `except ... as exc:`. - **`__file__`** — provided by Python in every module. No real undefined names. The build path is now fully exercisable by tests. #### What this release is NOT - **No JS changes.** This is a Python-only fix. The bridge.js subcommand cross-check stays at 102 calls. - **No new bridge subcommands.** `build` is an existing command; it just works now. - **No new plugin / polkit / bridge helper file.** Counts unchanged. - **No changes to profile discovery, build invocation, or the package-writing helpers.** The fix is purely the missing import. #### Process improvement The v0.0.31-v0.0.49 bug existed because no test exercised the `build()` code path. v0.0.50 adds that test coverage — 9 tests that mock `subprocess.run` and the state dirs, so they run hermetically in the `make check` suite without needing a real backend installed. Any future regression in the build path (missing imports, broken state- file writing, wrong response shape, wrong subprocess invocation) will now be caught before the tarball ships. --- ## v0.0.49 — 2026-08-19 (builder inline package list: textarea + file upload + merge mode for all 4 backends) ### Theme: close the loop on profile creation — paste the baseline apps inline v0.0.49 is a feature release for the Image Builder panel. Per user directive: *"we should allow adding a pacman -Sy applist.txt with a literal list of baseline apps for the profile being generated."* The v0.0.48 release fixed the dead-end error that operators of archiso-only or live-build-only hosts were hitting, and added the "Copy shipped profile" form. But both forms still left the operator with a half-finished profile: they scaffolded/copied the config, then had to drop to a shell to edit the package list. v0.0.49 closes that loop — the package list is now part of the creation flow. #### 1. The field Both the Create Profile and Copy shipped profile forms now include a shared `renderPackagesField(prefix, defaultMode)` block with three parts: - A `