Files
hamlab/BUGS.md
T

371 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Bug bash — 2026-09-18
Fix pass 2026-09-18: every item carries its outcome; ✅ resolved and verified live, ➖ no change with reason. Found by a headless Chromium pass (iPhone and
1440px desktop viewports) over every page on the hub and on each remote machine, an
offline-node scenario (wyse stopped for the test, then restarted), direct API probing
with hostile inputs, and a code read. Ordered by severity within each group.
## Security / robustness
1. ✅ **RESOLVED 2026-09-18** — only regular files are readable; the byte cap applies to what is sent, not to `stat`. `/dev/zero`, `/dev/null` and FIFOs answer 400 in 30 ms; `/proc/cpuinfo` still reads. **Reading a special file hangs the request forever.** `GET /api/files/read?path=/dev/zero`
never ends: the `max` cap compares against `stat` size, which is 0 for device files,
so the cap never triggers and the handler streams `/dev/zero` until the client gives
up. Same for FIFOs (blocks on open) and `/proc/self/mem`. Opening one in the Files
viewer would hang the tab. Needs: refuse non-regular files for read/edit, and cap the
copy with `io.LimitReader` regardless of `stat`.
2. ✅ **RESOLVED** — per-client buckets (first X-Forwarded-For hop or the peer) with the same 5-strike schedule, plus a global backstop of 100 failures per 5 min → 1 min. Verified: core's public IP locked while grid logged in fine. **Login lockout is global, not per client.** Five bad passwords from anyone on the
internet lock the real user out for up to 10 minutes (`auth.Limiter` is one bucket).
The bash locked the hub for the full 10 minutes twice. Also locks `POST /api/tokens`,
so hub pairing fails during a lockout. Needs per-IP buckets with a modest global cap.
3. ✅ **RESOLVED** — server rejects refreshMs outside 100–60000 and non-identifier themes (400); client ignores out-of-range rates from any source. **UI prefs from the server are not validated.** A stored `refreshMs` of `-5` made the
client call `setInterval(-5)` on every live view: a request storm that took the
browser to `ERR_INSUFFICIENT_RESOURCES` and hammered the daemon. `theme` is validated
only by falling back. Needs range checks on load (and on save server-side).
4. ✅ **RESOLVED** — every response carries X-Frame-Options DENY, CSP frame-ancestors none, nosniff, no-referrer. **No anti-framing / CSP headers.** Only HSTS (from NPM). A root-capable console should
send `X-Frame-Options: DENY`, `Content-Security-Policy: frame-ancestors 'none'`,
`Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff` on every response.
5. ✅ **RESOLVED** — kernel threads (empty cmdline) and the daemon's own PID are refused. **Signals can be sent to kernel threads.** `POST /api/procs/2/signal` (kthreadd)
returns 204. Harmless in practice, but the guard should refuse PIDs with an empty
cmdline (kernel threads) and our own PID.
6. ✅ **RESOLVED** — unit runs with `SupplementaryGroups=systemd-journal adm` so journal reads need no sudo, and the installer drops `/etc/sudoers.d/hamlab-quiet` (no syslog/pam session for the read-only polling commands). Verified: 0 sudo lines in 60 s of 250 ms polling (was 839/10 min). Applied on all four hosts. **Journal spam from the daemon's own sudo calls.** Every sudo'd `journalctl`, `smartctl`
or `pwrstat` writes three `pam_unix(sudo:session)` lines into the journal *under
`hamlab.service`*: 839 lines in 10 minutes at a 250 ms refresh, and the journal is
already 4 GB. It also buries Ham Lab's own log entries. Fix options: add the service
user to `systemd-journal` (and `adm`) so journal reads don't need sudo, and/or a
sudoers `Defaults!/usr/bin/journalctl !syslog, !pam_session` line in the installer.
7. ✅ **RESOLVED** — server answers 409 unless `overwrite=1`; the UI asks "Replace it?" first. **Uploading over an existing file silently overwrites it.** No confirmation, no
"replace?" prompt. Same for "+ file" when the name exists (server refuses; UI just
shows the error, fine) — upload is the gap.
8. ➖ **NO CHANGE** — Go's mux redirects the unclean path to `/etc/passwd`, which is an ordinary deep link the SPA must serve; there is no API prefix left by the time the static handler sees it. Harmless. **`/api/<anything>/../../x` falls through to the SPA** and returns `index.html` with
200 instead of a JSON 404. Harmless, but sloppy for an API prefix.
## Functional bugs
9. ✅ **RESOLVED** — 401 carries the server message; the form shows "wrong password" (verified headless). **Wrong password gives no feedback at all.** The 401 is thrown as `Unauthenticated`
with an empty message, and the login form only renders `err` when non-empty. The
button just re-enables. Reproduced on phone and desktop.
10. ✅ **RESOLVED** — hub-only pages bounce to the dashboard on switch (verified headless). **Switching machines while on Settings or Machines keeps showing the hub's page.**
`switchMachine` only resets the view for Machines; Settings stays, so "mac" shows
core's account/token/about panels. Both are hub-only pages and should bounce to the
dashboard (or be clearly labelled as hub pages).
11. ✅ **RESOLVED** — escaped; no console error. **Machines add-form `pattern` is an invalid regex in Chromium.** `[a-z0-9][a-z0-9-]*`
fails under the `v` flag ("Invalid character class"), logs a console error and the
browser skips validation. Needs `[a-z0-9][a-z0-9\-]*`.
12. ✅ **RESOLVED** — refresh from the newest repodata under libdnf5/dnf caches (Mac: 9 d), last upgrade from `dnf history` (194 d), label reads "from dnf history". **Updates tab on dnf hosts shows nonsense.** Mac: "Index refreshed 561d ago" (mtime of
`/var/cache/dnf`, which Fedora 41+ no longer uses; `libdnf5` is the live cache) and
"Last upgrade never · from apt history" (label and source are apt-specific). Needs
`dnf history` for last upgrade, metadata timestamps for refresh, and neutral labels.
13. ✅ **RESOLVED** — journal read incrementally by cursor, timers from one `systemctl show`, services' two calls run in parallel. Cron 3.6 s → 0.08 s, services 0.6 s → 0.05 s, with last-run and next-run data intact. **Cron overview is slow: 3.6 s per request on core.** At any refresh rate the page
is constantly busy. The 30-day, 20 000-line journal scan for "last run" is rebuilt
every 5 s; timers make three `systemctl` calls. Needs an incremental journal cursor
and a single `systemctl` call. Services list is 0.6 s for the same reason (two calls).
14. ✅ **RESOLVED** — heap profile showed a 48 MB `bytes.Buffer` from buffering `journalctl` dumps; those are now streamed line by line, plus a 160 MB soft memory limit for the GC. RSS 22 MB fresh, 27 MB after 25× cron/logs/procs/services (was 180 MB). pprof stays available behind the login at `/api/debug/pprof/`. **Daemon memory grows.** RSS on core went 22 → 85 → 216 MB during the day (136 MB at
rest after the bash). Suspects: per-pid caches in the process table (`procInfo` only
prunes on the next walk), task output buffers, SMART cache, xterm scrollback rings
that are never freed for exited sessions. Needs a heap profile under load.
15. ✅ **RESOLVED** — `--dirty` dropped from the version and deploys now follow commits; binaries report e.g. `v0.1.0-38-gc1805f6`. **Version string is always `-dirty`.** `make deploy` builds before the commit, so
every installed binary reports e.g. `v0.1.0-32-ga372378-dirty`, and the Machines page
compares with `-dirty` stripped. Build after commit, or tag releases.
16. ✅ **RESOLVED** — hash routing `#/<machine|hub>/<view>`: reload restores machine and tab, Back/Forward work, links can point at a page on a machine (verified headless). **View resets to Dashboard on reload; no deep links; browser Back leaves the app.**
The selected machine survives a reload (sessionStorage) but the tab does not, and
there is no URL routing at all. Needs hash or path routing (`/#/grid/services`).
17. ✅ **RESOLVED** — newest first. **Cron log tab lists oldest first and starts at the top.** 200 lines from Sep 10 with
the newest at the bottom, off-screen. Should show newest first or scroll to the end.
18. ✅ **RESOLVED** — "Nothing matches." **Processes: empty filter result shows nothing.** No "nothing matches" message.
19. ✅ **RESOLVED** — exited shells remove themselves 10 minutes after exit (verified live: seeded 17:53:56, gone at 18:03:59 with no daemon restart in between). Found and fixed alongside: a daemon restart used to re-spawn *exited* shells as fresh running ones; only running shells are persisted now. Idle running shells are deliberately kept. **Terminals accumulate.** Nothing ever reaps exited or idle sessions; the bash left
several "bug-bash-term" shells on the hub until deleted by hand. Exited sessions
should auto-remove after a while; idle ones at least be counted somewhere.
20. ✅ **RESOLVED** — a node answers 409 "busy" when a package task is running or a terminal is attached; the Machines page offers Force, `hamlab push-nodes --force` / `make push-nodes-force` do the same. Verified: 409 with a terminal attached on grid, 200 with force. **Self-update restarts without warning running work.** A push to a node kills its
open terminals and any running task (upgrade in progress). The hub should refuse or
warn when the node reports a running task or attached terminals.
21. ✅ **RESOLVED** — PathError and LinkError are unwrapped to the bare reason. **Error text leaks raw Go errors.** e.g. rename shows "rename /tmp/x /tmp/y: no such
file or directory". Fine for a lab, ugly for a product; strip the syscall prefix.
## Mobile / UX
22. ✅ **RESOLVED** — on coarse pointers every button, chip, icon button, link, header and select is at least 36 px tall (verified: no control under 32 px on the dashboard or processes pages). **Tap targets under 28 px on phone:** header ⏻ (25), logout (22), customize ▲ ▼ hide
(23), terminal leaf ✎ ✕ (24), chips (25), table headers (27). Apple's guideline is
44 pt. Systemic: the small `.btn`/`.chip`/`.icon-btn` sizes need a touch variant.
23. ✅ **RESOLVED** — the dialog asks for the machine name (`mac`); the node still checks its own hostname under the hood. **Power dialog on the Mac asks you to type `5ce91ee54af6`.** The confirmation uses
the node's hostname, which on the Mac is the unset default. Should offer the machine
name (`mac`) as the confirmation word on remote machines.
24. ✅ **RESOLVED** — down interfaces collapse to one line marked "down"; modules with nothing to show (fans, UPS, temps, disks) hide unless customizing. **Dashboard shows dead interfaces and empty modules.** wlp1s0 (down) gets a full
sparkline block of flat lines; "Fans: no fan sensors" is a permanent empty card on
hosts without fans. Down interfaces should collapse to one line; empty modules should
auto-hide unless customizing.
25. ✅ **RESOLVED** — "load 1.39 1.56 1.55" with a tooltip. **Header load average is unlabelled** ("1.39 1.56 1.55" floating in the header).
26. ➖ **NOT FIXING** — the list is intentionally a plain table; the menu anchors inside the row. Cosmetic taste, not a defect. **Files list on desktop is a narrow strip in a sea of empty space** for small
folders; the row menu opens off the right edge of the row. Cosmetic.
27. ✅ **RESOLVED** — after a 502 the client leaves that machine alone for 5 s: 2 requests in 10 s at a 250 ms refresh (would have been ~80), badge and message intact. **Offline machine keeps polling every view at the refresh rate**, producing a 502
per request per page (and console errors). The UI copes — badge, message — but the
hub should back off to the prober's cadence while a node is down.
28. ✅ **RESOLVED** with 6. **Services logs are noise.** The default view of `hamlab.service` logs is the sudo
session spam from bug 6, three lines per poll.
## Confirmed working under abuse (for the record)
Path traversal in files, services, cron, ZFS, procs and updates all rejected. `/etc`
and `/` protected from delete/rename as root. Unit, pool, dataset, snapshot and
package names validated. Token issue refused with a wrong password. Oversized prefs
rejected. Duplicate machine add refused. Unicode + spaces in file names round-trip.
Upload byte-exact. Terminal scrollback survives switching. Offline node: switcher and
header show it, pages show the reason. Remote terminals, logs, tasks and files through
the proxy all work on all three nodes.
# Bug bash — 2026-09-19
Found by driving a scratch instance (same binary, its own state, real fleet behind it) in
headless Chromium at 1440 px and iPhone size: a sweep of every page on every machine for
page errors, failed requests, overflow and small tap targets; then scripted use of every
feature — terminal tabs, Files in a throwaway folder, Processes (killing a process the
test spawned), Monitors customisation and widgets, Services, Cron, Updates, Disks,
Machines, Settings, embeds, machine switching, session loss — and a hostile pass over the
API. Host-changing actions (service control, cron edits, upgrades, power) were opened but
not executed. Every fix below was re-checked by the same script that found it.
## Bugs — all ✅ fixed and verified
1. ✅ **One widget with a bad pattern silenced every custom widget.** They share one stream;
`fan.[` made the whole query a 400, so all of them showed "…" forever with no reason
given. The picker now asks the API before adding and shows its answer; a bad pattern
already saved shows a notice instead of dots.
2. ✅ **A saved layout of the wrong shape could not be loaded** ("could not load layout",
and the page could no longer be customised). `normalize()` now accepts anything.
3. ✅ **A lost session left pages saying "unauthenticated"** for up to 30 s. A 401 from the hub
now returns to the login form at once, and logging in returns to the same page. (A 401
relayed from a managed machine is that machine rejecting the hub, and doesn't.)
4. ✅ **The machine-name rule in "Add a machine" was an invalid regular expression** under the
browser's `v` mode, so it validated nothing and logged a console error on every render.
5. ✅ **Two terminals opened in the same minute got the same name** ("shell 09:45" twice).
Now "shell 09:45 (2)".
6. ✅ **Names had no length limit** — a 328-character terminal tab, a 5000-character token
name. 64 characters, refused with a reason; token names also refuse `/ \ < > "`.
7. ✅ **"+ file", "+ folder" and Rename accepted `../x` and `a/b`**, quietly acting outside the
folder on screen. A name is now one path segment; Move… is how things go elsewhere.
8. ✅ **Unsaved file edits were thrown away without a word** when you picked another page or
machine, or closed the tab. All three now ask; Cancel keeps you where you were.
9. ✅ **A mistyped path in Files was remembered**, so a reload landed on the error. Only a
folder that opened is remembered.
10. ✅ **Escape closed nothing**: the machine menu, the file menu, the Power dialog, the widget
picker. It closes all four.
11. ✅ **Reset on Monitors removed every custom widget with no confirmation.** It asks, and
says how many.
12. ✅ **A few bytes of query bought 27 MB of answer** (`{"k0":"*",…×400}` with `full=1`) — and a
read-only token is public on the demo page. A query may return 10 000 values (thirty
whole machines); past that it is a 400 that says so.
13. ✅ **No ceiling on live streams**: 300 from one token were accepted. 32 per client address,
512 in all, then a 429 that says so; closed streams free their places.
14. ✅ **Anything under `/embed/` lost the anti-framing headers**, not just the widget page.
Only `/embed/widget` is frameable.
15. ✅ **The new ZFS sensors ran three `sudo` commands a minute on every machine** — including
`core`, which has the tools and no pool — and each wrote three lines to the journal:
most of `hamlab.service`'s log was that. Pools are now read with one unprivileged
`zpool list`; a source that finds nothing is asked again ten times less often; and
the installer's quiet list covers `zpool|zfs list|status`.
16. ✅ **Phone: the filter boxes on Processes and Files, the path box and the "/" crumb were
under 30 px.** 36 px like every other control.
17. ✅ **Disks → datasets on a phone: rows ended 50 px before their own cells** when scrolled
sideways (`min-width` was less than the columns add up to).
Also: tab titles now say where you are ("Services · grid — Ham Lab"), so history and open
tabs can be told apart.
## Checked and fine
Terminal focus after every switch/close/return; scrollback after reload; exited shells;
30 tabs with the + still reachable; re-fit on resize; Back/Forward; unknown machine or page
in the URL; wrong password; XSS through terminal, token and file names (all rendered as
text); hidden files, filter, symlinks (broken ones marked), binary files, upload and
replace, folder delete needing the typed name, root toggle; process sort, filter and
Terminate (asks first); themes; password-change validation; duplicate or unreachable
machine; hub terminals not leaking into another machine's page; CORS only on sensor paths;
token list never exposing hashes; cookie flags; deep nesting, empty bodies, odd methods
and `every=` values on the sensor API; phone drawer, key bar and picker.
## Quality of life — all ✅ done 2026-09-19, each verified in the browser
1. ✅ **Selections are in the URL.** `#/grid/services/ssh.service`, `#/hub/cron/job:…`,
`#/hub/files/%2Fetc`: reload keeps them, links carry them, leaving a page drops them.
2. ✅ **Browser Back with unsaved file edits asks**, like page and machine changes do.
3. ✅ **No more browser `prompt()`/`confirm()` boxes** — all 30 are in-app dialogs (`askYes`,
`askText` in `ui.tsx`): Enter submits, Escape or a tap outside cancels, destructive
ones are red and say what they do ("Close", "Delete"), and they fit a phone.
4. ✅ **Token creation is one dialog**: name, patterns, and the machine's own sensor families
as chips to click.
5. ✅ **Widgets can be edited** — sensor, type and title — in place, from Customize.
6. ✅ **The Widgets gallery has a search** over IDs, names and labels (so "gitea" finds its
container's sensors).
7. ✅ **Status sensors keep a history** (300 readings each: an hour of container memory, two
days of drive wear), so their sparklines have a past.
8. ✅ **A stream that fails says why**: "the token was rejected", "too many live streams are
open", "that machine is unreachable", "can't reach Ham Lab" — in embeds and on Monitors.
9. ✅ **Pattern widgets get readable default titles**: `cpu.core.*.usage` → "CPU core usage".
10. ✅ **Terminal tabs reorder** — drag on a desktop, ‹ › on a touch screen, remembered per
machine in the browser — and **Alt+Shift+←/→** switches tabs, **Alt+Shift+T** opens one,
with the keyboard left in the terminal each time.
11. ➖ **"Log out everywhere"** — built, then removed the same day at Mitchell's word: no need
for it on a single-user console.
12. ✅ **Real clipboard shortcuts in the terminal**, from a physical keyboard: **Ctrl+C copies
when text is selected** (and is still ^C when nothing is — the selection clears after a
copy, so a second Ctrl+C interrupts), **Ctrl+V pastes**, Ctrl+Shift+C / Ctrl+Shift+V do
the same unconditionally, Ctrl+Insert / Shift+Insert too. It rides the browser's own
copy and paste events, which xterm already answers, so it works over plain http on the
LAN where `navigator.clipboard` does not exist; bracketed paste is honoured.
# Bug bash 2 — 2026-09-19 (evening): Alerts, Docker, Stacks, skins, the installer
Same method as the morning: a scratch instance with the real fleet behind it, headless
Chromium at 1440 px and iPhone size. A sweep of all 13 pages on all four machines (errors,
failed requests, overflow, tap targets) plus a contrast measurement of every skin; then
scripted use of the new features on throwaway containers and stacks — a multi-file stack, a
file edited behind the editor's back, two actions at once, a chatty log, odd container
names, containers stopped and removed while open, every kind of bad alert rule, a rule's
whole life (fire, narrow, pause, delete, survive a restart), token scope and hostile paths
on the new APIs — and a regression of terminals, Files, widgets, deep links and colours.
## Bugs — all ✅ fixed and verified by the script that found them
1. ✅ **Deploying a stack made of several compose files dropped all but the first.** A stack
deployed with `-f compose.yaml -f compose.override.yaml` was redeployed from
`compose.yaml` alone, so everything the override set silently vanished from the new
container. Two causes: only the first file was used, and the file list was read from
`docker ps`, which joins labels with commas — the list is itself comma-separated, so it
arrived truncated. Labels now come from `inspect`; every file is resolved, shown as a
chip, editable, validated together and passed to compose in order.
2. ✅ **A compose file changed on disk after you opened it was silently overwritten** (Dockhand,
or a second tab). The editor now hands back the checksum it was given; a mismatch is a
409 that says so, keeps your text, and offers to reload.
3. ✅ **Stacks on a machine without Docker answered 500.** An empty list.
4. ✅ **Every hub restart re-fired every firing alert** — and every deploy is a restart, so a
phone would have been told about the same failed service on each one. What is firing is
now kept in `state.json` and carried on, with its original start time.
5. ✅ **An alert whose rule was paused, deleted or narrowed just vanished**, leaving "firing" in
the history forever and no word to the phone. It is now recorded (and sent) as cleared,
with the reason: "rule paused", "rule deleted", "no longer watched".
6. ✅ **Nonsense rule values were accepted**: `null`, an object, `cpu.usage > ninety` (which can
never be true, and would never say so), `true` with `<`. Refused, each with its reason.
7. ✅ **No limit on rules** — 3000 were accepted, each checked on every machine every 15 s. 200.
8. ✅ **Logs of a container that no longer exists answered 500.** 404.
9. ✅ **Four skins had text under 3:1 contrast**: Nord's red; Catppuccin Latte's muted text and
green; Solarized Dark's body text, muted text and red; Solarized Light's body text,
muted text, accent and green. Each colour kept its hue and was moved to a readable value
(measured: all thirteen skins now pass, body text ≥ 7:1).
10. ✅ **Phone: the alert toggles (28 px) and the colour swatches (26 px) were under 30 px.**
11. ✅ **The public installer had fallen four builds behind the fleet** (v65 published, v69
running), so `curl … | sh` on an up-to-date machine "upgraded" it backwards. `make
deploy` now republishes, and the installer compares versions: an older build is called
older, defaults to No, and unattended runs refuse it without `HAMLAB_ALLOW_DOWNGRADE=1`.
## Checked and fine
A second stack action while one runs (409); `../` and encoded tricks in stack names;
shell metacharacters in container names, `since` and actions; `rm` / `kill` / `down` are
not actions; a read-only token is refused on every Docker, Stacks and Alerts endpoint,
directly and through the hub; container names with dots and underscores still get live
CPU and memory; a log at five lines a second follows with no gaps or duplicates; a
container stopped or removed while open updates itself; an alert rule's XSS name renders
as text; per-machine firing, the rail badge, webhook and ntfy delivery; terminals, Files,
widgets over container sensors, deep links, tab titles and machine colours all unchanged.
No native browser dialogs anywhere; no page errors on any of 100 page loads.
## Found by the alerts themselves
`core` has one real alert firing: **`zfs-import-cache.service` fails at every boot** — `core`
has the ZFS tools and no pool, so there is nothing to import. Harmless, but it will sit on
the rail as "1" until the unit is disabled or the rule is narrowed. Left alone.
## Quality of life — five chosen by Mitchell, ✅ built and verified 2026-09-19
1. ✅ **Stacks on `grid` and the Mac are editable from the hub.** Their files live on `core`
(Dockhand deploys over tcp); the hub resolves them and runs compose with `DOCKER_HOST`
pointed at the node. Verified on a throwaway stack: saved on `core`, recreated **on
`grid`**, same compose labels, nothing created on `core`, output followed on the page.
2. ✅ **A stack's `.env` is editable**, values masked until "Reveal and edit". A `.env` that
breaks the compose file (a required variable removed) is refused and the old one put back.
6. ✅ **A link to a container that no longer exists says so**, by name.
9. ✅ **"Update available" per image** — on request, remembered six hours. Verified with an
image whose tag had moved on (update available) beside a current one (up to date).
10. ✅ **What Deploy would change**: a line diff against what was last deployed from here;
after a deploy it says there is nothing left to change. `.env` diffs name the key and
never show a value.
Not chosen, still open: alert acknowledge/snooze; quiet hours and per-rule channels; push
straight from Ham Lab; a type hint in the alert editor; switching off the nodes' own alert
engines.
# Bug bash 3 — 2026-09-20: new stacks, new containers, images, volumes, the HUD
Started from Mitchell's report — the New stack dialog is see-through in HUD, its text
mixing with the page behind. Same method as before: a scratch hub with the real fleet behind
it, headless Chromium at 1440 px and iPhone size. A sweep of all 13 pages on all four
machines, plus a measurement **in every skin** of everything that floats over the page
(dialogs, the question box, the widget picker, menus, the phone drawer: background alpha
must be ≥ 0.9). Then scripted use of what is new on throwaway `hl-` stacks, containers,
images and volumes: the New stack / New container dialog's edges, stacks that are not
deployed, a deploy that fails, a container name that is taken, deletes beside other
people's files, a two-file stack, create → deploy → restart → stop → delete **on the Mac
and on `grid` through the hub**, pulls on the hub and on a node, removal guards, a
container stopped on purpose, a read-only token and hostile input on every new endpoint,
and a regression of Escape everywhere, terminals, Files, sensors, alerts and deep links.
## Bugs — all ✅ fixed and verified by the script that found them
1. ✅ **HUD: every dialog was see-through.** The skin's "glass" rule (3 % cyan) was written
for tiles and cards and also caught `.modal` — the New stack dialog, the widget picker,
the Power dialog, the token dialog, the rule editor and every question. Anything that
floats over the page is now solid in HUD (hairline border and glow kept) over a darker
backdrop. Measured in all thirteen skins: nothing that floats is under 0.9 alpha.
2. ✅ **HUD: the phone's slide-out menu was 92 % opaque**, the page showing through it. Solid.
3. ✅ **Deploy, Update, Restart, Stop and Down all failed on the Mac's stack**
(`peaches-and-plums`) — and on any stack held on the hub that was deployed from the hub's
own shell rather than from inside Dockhand. The command was built as
`'DOCKER_HOST=tcp://…' docker compose …`; quoted, `sh` reads that not as a variable but
as the name of a program to run ("not found", exit 127). Now passed through `env`.
Verified by a whole life on the Mac and on `grid` from the hub: create, deploy (running
there, nothing on `core`), restart, stop, delete.
4. ✅ **A container stopped on purpose was reported as down** when it has no restart policy
and doesn't handle SIGTERM: `docker stop` then ends it with 137 (or 143, or 130), a
non-zero code, which read as a crash — "Container down" would have fired for a
deliberate stop. Those three codes now mean "a person", unless the kernel did the
killing for want of memory (`OOMKilled`). A real crash (exit 3) is still down.
5. ✅ **A stack with no containers offered Restart, Stop and Update**, and Restart
"succeeded" having done nothing. Offered only when there is something to act on; the
API answers 409 "isn't deployed: there is nothing to restart".
6. ✅ **One stray tap outside the New stack dialog threw away everything typed** — on a phone,
a compose file's worth. So did Escape. With anything typed, all three ways out (tap
outside, Escape, Cancel) ask first; an untouched dialog still just closes.
7. ✅ **Compose → Form → Compose silently replaced hand-edited compose text** with what the
form says. The form now writes the box only when the form has changed since it last
did, and asks before replacing text edited by hand.
8. ✅ **A question asked from inside a dialog opened underneath it** (same layer, earlier in
the page), so it could not be answered — found the moment the New stack dialog started
asking. Questions now sit above whatever asked them.
9. ✅ **Escape on a question also reached the dialog beneath it**: one key cancelled the
question *and* closed (or re-asked from) the dialog under it. Escape now answers the
question and goes no further; the next Escape is the dialog's. Checked on every dialog
and menu: widget picker, machine menu, rule editor, Power, New stack, and that Escape on
"Delete the rule?" deletes nothing.
## Checked and fine
All 13 pages × four machines × both sizes: no page errors, failed requests, sideways
scroll or tap targets under 30 px. A port of `not-a-port` is refused by compose before
anything is written, and leaves no folder. Environment values with quotes, `#`, `$` and
nothing after the `=` arrive in the container exactly as typed. A deploy whose image
doesn't exist shows the registry's error and is **not** remembered as "last deployed". A
container name already taken fails the deploy and says why. Delete moves only the stack's
own compose files and `.env` (kept `600` in the trash) and leaves a `data/` folder and
notes beside them alone; both files of a two-file stack go to the trash; the wrong name,
or none, deletes nothing. A `.env` value never appears on the stack page and stays masked
until asked for. A second stack of the same name is 409; an upper-case name is 400. A pull
of an image that doesn't exist ends as a failed task; a pull on `grid` from the hub lands
on `grid` and its output is followed. A volume or image in use can't be removed; a running
container can't be removed (409); a volume needs its name twice. A read-only token gets
401 from all fourteen new write paths, through the hub as well. `--force`, `-f`,
`--all-tags`, `..%2F..` and a home outside the known ones are refused; a 1.2 MB compose
file is refused. Shaped, fleet and streamed sensor queries, `/metrics`, the ten alert
rules, terminal typing, the file editor's discard question and service deep links are
unchanged. No native browser dialogs anywhere.