Skip to content

Bump python from 3.12-slim to 3.14-slim in the docker-minor group across 1 directory - #25

Closed
dependabot[bot] wants to merge 46 commits into
mainfrom
dependabot/docker/docker-minor-6dafb4a59b
Closed

Bump python from 3.12-slim to 3.14-slim in the docker-minor group across 1 directory#25
dependabot[bot] wants to merge 46 commits into
mainfrom
dependabot/docker/docker-minor-6dafb4a59b

Conversation

@dependabot

@dependabot dependabot Bot commented on behalf of github Aug 10, 2026

Copy link
Copy Markdown

Bumps the docker-minor group with 1 update in the / directory: python.

Updates python from 3.12-slim to 3.14-slim

sm26449 and others added 30 commits August 8, 2026 00:41
Persistent terminals for your servers, in the browser. Open a shell on any of
your machines — including from a phone — and come back hours later with the
process still running and the scrollback intact.

Nothing listens on your servers: a single-file Python agent dials out to the
gateway over WebSocket, so a machine behind NAT works like one with a public IP.
Sessions live in tmux on the host, so they survive the browser closing, the
gateway restarting, and the agent being killed.

What is here:

- Persistent sessions, reattachable from several devices at once.
- Full session history recorded to a raw stream and an asciicast you can replay
  and search. Output only — what you type is never written to a transcript, so a
  password typed at an echo-off prompt cannot leak into a recording or a backup.
- Files: browse, edit, upload and download over the same agent connection, with
  atomic saves and a conflict check on modification time.
- Port forwarding: expose a service on a host at its own subdomain, authenticated
  by the gateway, without opening a port on the host.
- Telnet bastion and serial console for equipment on a host's private network.
- Fleet view with per-host metrics, folders, and running one command across
  several hosts.
- Password login plus passkeys (WebAuthn) or TOTP, with per-host "require 2FA"
  gating sessions, history, files and forwards.
- Signed agent updates: each deployment generates its own Ed25519 key at first
  boot, substitutes its public half into the agent it serves, and signs every
  update. Agents refuse anything that does not verify, refuse older versions, and
  check that a new release actually starts before replacing themselves.
- English and Romanian, chosen from the browser and switchable in settings.

What it is not: there are no roles. Anyone who gets past the login has
root-equivalent access to the whole fleet, and the gateway is a single point of
total compromise. Both are stated plainly in docs/THREAT-MODEL.md rather than
glossed over — read it before exposing this to a network.

Requirements: a machine with Docker, plus tmux and Python 3 on each host you
want to reach.
…forward epoch

External audit round (two reports) plus findings of our own while verifying them.

Fixed:
- Install-time image was `:latest` — a moving pointer, so a fresh install ran
  whatever the registry held that minute and `.prev-image` recorded nothing to
  roll back to. Pinned to the released version in all six places, with a CI gate
  so the pin follows GATEWAY_VERSION instead of rotting.
- Deleting an account left the API tokens it had issued alive until expiry (up
  to a year), while the surrounding code claimed everything tied to the account
  died with it. They are revoked now, along with its shares and forward tickets.
- `bump_forward_epoch()` was global: one account logging out broke another
  account's tunnels. Shares had already been scoped to their owner in the same
  logout handler; forward tickets had not. The account id is now signed into the
  ticket, so it cannot be rewritten to borrow another account's epoch.
- `WEBTERM_IMAGE` meant two different things: the gateway image everywhere, and
  "an image containing python3" in backup.sh/restore.sh. `upgrade.sh` sources
  .env before calling backup, so the tool container silently got the app image.
  Backup patched its own symptom once; restore never did, and there the failure
  lands after the old data has been moved aside. Renamed to WEBTERM_TOOL_IMAGE,
  with the entrypoint guard on both.
- `ci-local.sh unit` reported "✗ ruff" and "✗ suita Python" when those tools were
  simply absent — a false red costs as much as a false green.

Documented (no code change): the agent bootstrap is unauthenticated under
WEBTERM_AGENT_INSECURE — pinning starts only after the first connection. The UI
now says so next to the install command, and the README env table explains it.

Not changed, deliberately: the unencrypted auto-generated signing key (already
described in THREAT-MODEL.md, including that it is what your install does unless
you chose otherwise), cookie-only token revocation, and in-memory lockouts.
Pinning the install-time image split two things that used to coincide: `:latest`
is what main produces, `:vX.Y.Z` is what people actually install. The weekly
Trivy job kept watching `:latest`, so it guarded an image nobody runs while the
shipped one went unscanned. Target now comes from GATEWAY_VERSION — the same
source of truth as the README badge and the install pin.
The in-app notification suggested `./deploy.sh`, which swaps the image and
nothing else. But half the system runs on the host — backup.sh, restore.sh,
rollback.sh, the compose file and upgrade.sh itself — and /opt/webterm is not a
git checkout, so those stay frozen at whatever the installer put there. The
README warns about exactly this and recommends upgrade.sh; the product told you
the opposite, and skipped the backup upgrade.sh takes before touching anything.

Default is now `cd /opt/webterm && sudo ./upgrade.sh {version}`, and the README
headline says the same thing (it omitted sudo while /opt/webterm is root-owned).

A test now holds the product and the docs to the same command. Writing it turned
up a second problem: the configurability test restored UPDATE_COMMAND from a
hand-written copy of the default, so once the default changed the test would keep
putting the old value back — measuring what the test says, not what ships. It
now restores the value captured at import.
The archive covers the data volume. Everything on the host is outside it — which
is fine while the host exists, and a disaster the moment you rebuild it. The
sharpest case: the archive's encryption passphrase lives in
/etc/default/webterm-backup, on the very machine you are about to wipe. Lose it
and the encrypted archives are unrecoverable, with no fallback. The docs
mentioned "keep a copy off the server" mid-paragraph and left the rest unsaid.

RUNBOOK now carries a table of what is and is not in the archive, a
copy-off-the-server checklist, and the verified rebuild procedure: same domain
(agents store the gateway URL), boot once before restoring, and why the fleet
still accepts updates afterwards (the signing key travels in the archive, and
auto-generation refuses once hosts exist). Also the Let's Encrypt ceiling of 5
identical certificates per week, which turns repeated rebuild attempts into days
without HTTPS, and the circular dependency of administering the gateway host
only through WebTerm.

README's backup section claimed agent-signing.key was "outside the volume" while
pointing at data/agent-signing.key, which is inside it — corrected, and it now
links to the rebuild checklist.
Without tmux the agent falls back to a plain pseudo-terminal, so sessions die
with the agent or with the network — the product's central promise, inverted.
The install script warned about it, but once, in a log that scrolls past, and the
UI then said only `Backend: pty`, which means nothing to someone who does not
know what tmux is. Somebody could work for hours believing their session was
protected.

Two places now say it in words: a banner on the host page, and a "not persistent"
marker in the session status bar — the bar being where you actually look while
working, rather than a page you open once.

Not refusing to install, which was the suggested fix: on a minimal box an
ephemeral terminal is still useful, and refusing would break a valid setup. The
degradation is legitimate; hiding it was not.
Neither installer checked the ports. On a server that already runs nginx, a
control panel or another reverse proxy — the common case, not an exotic one —
`up -d` started the app, Traefik then failed to bind :80, and `set -e` stopped
the script: one container up, one not, and a message from Docker that does not
say what to do. install.sh is the production path, where that half-state costs
most, and it checked ports even later than setup.sh did.

Both now check before touching anything and name the process holding the port.
Verified: with 80/443 busy, setup.sh exits 1 having created nothing, not even
.env. Skipped when our own stack is already up, so re-running stays idempotent.

ci-local.sh: two harness bugs of the same family, where the tool fails and does
not say it was the tool.

- $OUT was created by Docker as root when a step mounted it before anything
  created it, so a second run by a non-root contributor died on `rm: Permission
  denied` and E2E then failed at "host online" for an unrelated reason. Created
  up front now, with a clear message if an old root-owned directory is in the way.
- `mobile` and `a11y` log in with an account that `e2e` creates. Run alone
  against a fresh container they wait 30s per device for a "Sign in" button that
  cannot exist, then report twenty "bugs" that have nothing to do with the
  product. They now say which step to run first. This is exactly the pattern that
  produced a false "do not launch" blocker in an external audit.

Sidebar: the four primary nav buttons (add host, fleet, status, settings) were
32x24 touch targets. The mobile audit reports small targets as `ux`, not `bug`,
so nothing blocked — but these are tapped dozens of times a day. The rest of the
list stays as reported: 44px would break a dense file row, and there the
trade-off is deliberate.
/api/search reads the *contents* of up to 500 transcripts in one request and
returns the text around each match — the most powerful read in the API, and the
only one the audit log never recorded. The endpoint's own comment says it is "the
same class as /transcript"; the gate disagreed, because it matched on the shape
of the path (an /api/sessions/ prefix plus a /transcript-style marker) rather than
on what the route does, and /api/search has neither.

That made a documented promise false. THREAT-MODEL says a new endpoint cannot
silently escape the log, and that "who accessed your instance" is answered by it.
After a stolen cookie, `q=BEGIN OPENSSH PRIVATE KEY` sweeps the whole history and
the log answers "nothing" — while /transcript, /fs/download and /preview all
leave rows. Found by an external audit reading the code.

The query is recorded too, not just the fact of a search: what was searched for is
the part that answers what the attacker was after.

tests/audit_reads_test.py refuses to be another hand-kept allowlist. It walks
api.py, finds every GET whose handler references a content-reading function in
core, and requires each to pass audit.audited_read — so the next such route fails
here rather than in someone's audit a year from now. Plus a live proof that a real
search lands in /api/audit with its query, and that plain listings stay out of it.

Writing it turned up a bug in the test itself: the readers are blocking, so they
appear as arguments to to_thread/run_in_executor, not as calls. Looking only at
call targets found zero routes — the "this test is not empty" guard caught it.
…trings

FileBrowser was the one modal out of roughly fourteen without a focus trap. Tab
leaked focus into the terminal behind it — you type into a shell you cannot see —
and Escape closed nothing, so the only way out was clicking the ✕. It announced
itself as ordinary text rather than a dialog. It now has the same trap the others
use, plus role/aria-modal, and so does the editor overlay nested inside it. The
two Escape handlers are layered rather than stacked: while the editor is open the
outer trap does nothing, so one Escape closes the editor instead of throwing away
unsaved text along with the whole panel.

Sidebar leaked in both directions. The provisioning success message was written by
hand in English while sidebar.provisionOk and sidebar.provisionCredsRemoved sat
unused in both catalogues, next to a sibling that used t() correctly. The
update-blocked tooltip and badge were hardcoded Romanian, the update-agent tooltip
hardcoded English, and a title attribute sat in English beside a correctly
translated aria-label on the same button.

The reason a host cannot update is now a stable code rather than a sentence. The
client picked its hint with /signature|unsigned/i over English server prose — the
same shape that once broke uninstall and offline-host recovery, where translating
a message moved the condition out from under the code reading it. The agent
already sent codes; the gateway wrote flowing English, and now writes
signature_missing. Unknown codes still show verbatim, so a new reason is visible
rather than hidden behind a missing translation.

Building this caught a bug the old template literal hid: agent_version and
agent_latest are nullable, and string interpolation turned them into the word
"null" in the tooltip.

The comment in the served install script had a Romanian sentence spliced into the
middle of an English one, breaking both — in the file a careful admin reads
before piping it to sh.
…at were false

Three audits, one per direction. The first finding is a regression I introduced.

The port preflight refused the recipe the README documents. README describes a
docker-compose.override.yml publishing 8080/8443 for exactly the "something else
holds 80" case, then `./setup.sh IP:8443` — and the guard checked 80/443
unconditionally, with no way past. setup.sh now asks compose which ports the
stack will actually publish, so the override is respected by construction rather
than by a second list that can drift. install.sh genuinely needs 80 and 443
(ACME HTTP-01 and TLS), so it keeps the fixed check but gains --skip-port-check
for people who front it themselves. Both error messages pointed at RUNBOOK for
running behind an existing proxy; RUNBOOK never mentions proxies.

Uninstalling could delete the user's entire crontab. `crontab -l` failing for any
transient reason — cron.deny, an unreadable spool, a wrapper writing to stderr —
yielded an empty list, and the code then wrote that empty list back, or ran
`crontab -r`. Two of the three sites are fixed here; the third is in agent/ptyd.py
and needs the offline signing key, so it is left for a signed change. Proven with
a stubbed crontab: the old shape writes an empty crontab, the new one does not
touch it.

scripts/run-tests.sh had no preflight, so a contributor without a venv got 43
lines of "timeout: failed to run command" and one with a partial venv got 31
ModuleNotFoundErrors among 330 passes — exit 1 both times, with nothing saying
the environment was the problem. ci-local.sh has had that guard for two rounds,
but CONTRIBUTING points here, through `make test`.

Romanian plurals: ten `{count}` keys had no plural forms and five binary
ternaries survived, so the UI said "1 parametri", "Rulare pe 1 hosturi" and
"20 hosturi" instead of "20 de hosturi". They now go through Intl.PluralRules
like the three families that already did. One string was still hardcoded
Romanian, in the search results.

Documentation that did not match the code: CHANGELOG claimed backups are always
encrypted while scheduled server-side ones are not; RUNBOOK claimed every deploy
records a rollback point, when only a tag change does and `make pull` skips
deploy.sh entirely; README recommended `make pull` for updates without saying it
runs no health gate; shell integration was described as opt-in when it is
opt-out and appends a line to ~/.bashrc; CONTRIBUTING said ~13 test files instead
of 42; ARCHITECTURE pointed at a SECURITY.md section that does not exist; the
backup timer's 15-minute jitter was undocumented; the disaster-recovery copy
commands lacked sudo and mkdir on root-owned 600 archives; and
WEBTERM_TRUSTED_PROXY_HOPS was documented nowhere, though the default of 1 is
wrong for the Cloudflare setup the README recommends, which silently pools every
client into one lockout bucket.
… output costs

A resilience audit attached and detached six times against a session printing
every 200ms and lost 3–9 markers — 0.6 to 1.8 seconds of output — every single
time, reproducibly. The behaviour is deliberate and stays: replaying the
unflushed window into a small terminal collides with the tmux redraw and wipes
the visible history, which is worse. What was wrong is the justification.

"Self-healing" is false. What converges is the visible screen, on the next
output. Those bytes never reach that client, and they are missing from its
scrollback with nothing to show they were dropped — while every other
discontinuity in this file writes a GAP_MARKER. And the comment on the resync
path claimed "anything pushed meanwhile is inside the tail", which cannot be
true when the tail is by construction up to 2s behind. Both now say what
actually happens; closing the gap still needs the protocol change already noted
as future work.

RUNBOOK gains two measured ceilings. A single `yes` drives the host's tmux
server to ~100% CPU, and since one tmux server backs every session on that host,
keystroke latency in the *other* sessions goes from p50 4ms to p95 113ms — while
the gateway stays flat and the transcript cap holds exactly as documented. So the
symptom is "this host feels slow", and the fix is on the host. The attach gap is
written down there too, because an operator who loses two seconds of scrollback
deserves to find it in the runbook rather than rediscover it.
…off when flapping

Four findings from a resilience audit that ran against a real agent on an
isolated test bench. All four are in the agent, so they land together in one
signed change.

Uninstalling could delete the user's entire crontab. `crontab -l` can fail while
a crontab exists — cron.deny, an unreadable spool, a wrapper writing to stderr —
and the code then treated the empty result as "nothing of yours here", writing it
back or running `crontab -r`. It now checks the exit status: if we cannot read it,
we do not write it, and we say so. Two dead lines left behind beat losing what we
never put there.

The disk metric measured only `/`. The agent keeps its log, config and liveness
file in ~/.webterm, which on any ordinary layout is a different filesystem —
/home on its own LV, $HOME on tmpfs in a container. Measured: with $HOME 100%
full the gateway calmly reported 74% and raised nothing, which is precisely when
the alert should have fired. It now reports the fuller of the two, deduplicating
when they are the same filesystem.

Backoff reset on every successful connect rather than on every connection that
lasted. A middlebox cutting idle connections below the heartbeat interval
produced a reconnect every ~30s forever, at one second apart, while the host
still showed `online` — so nobody could see it. It now resets only after a
connection survives two minutes. Six cuts at 12s take the backoff to 60s instead
of pinning it at 2s; a 300s connection brings it back down.

The scrollback ring is released when a session dies, instead of waiting for the
collector, with a best-effort malloc_trim after. Measured: ~22 MB retained per
runaway-output episode for a 2 MiB ring, on sessions that no longer existed,
25 MB to 143 MB over eight episodes with no plateau. Calling this a mitigation
rather than a proven fix: the audit could not separate the ring from glibc arena
fragmentation, and the hour-long measurement was not reproduced here. If memory
still climbs after this, the allocator is the culprit and needs a different
approach.
Two audits: a compromised host, and a clean-server install.

A compromised agent could exhaust the gateway. The session cap lived only in the
agent, and the comment beside MAX_SESSIONS_HINT said so plainly — "the limit is
enforced by the agent; we mirror it here only for a readable error" — which is
exactly the guarantee a compromised agent ignores. One heartbeat with 3000 valid
uuid4 sids produced 3000 DB rows and 6000 files, since .out and .cast are opened
in SessionHub.__init__ before any output exists. Repeated with fresh sids it
fills the disk, and then it is not that host that goes down but the whole fleet.
Adoption is now capped per host and refused visibly in the event log, and the cap
is checked before the DB lookup so the attempt costs 32 queries rather than 3000.
Agent-reported hostname, user and metrics are bounded too: they land in the hosts
row and in every host-detail response.

setup.sh died silently on any compose config failure — banner, exit 1, nothing
else. `PORTS="$(published_ports)"` under set -euo pipefail propagated the failure
and 2>/dev/null swallowed the reason, which also made the "compose too old"
fallback below unreachable. The trigger is a typo in a docker-compose.override.yml
the README asks you to write by hand: the highest typo rate on the whole path.
It now shows what compose said.

The port preflight passed in silence when both ss and netstat were missing,
returning "no holder" for "cannot tell" — producing precisely the half-installed
state the check exists to prevent. Unknown is not free, and it now says so.

The deploy.sh→upgrade.sh fix from 22c2cd8 missed two surfaces, both on the
install path: the last line install.sh prints, and the example in
.env.prod.example. The gate only looked at config.py and README, so it now covers
those too. And setup.sh offered `make token` as the only way to recover the setup
token, on machines where make is an optional prerequisite the README itself marks
as optional; it prints the make-free command when make is absent.

Writing the flood test took three tries, all my own fault, and each looked like a
hang: a missing NOT NULL column, then a fake hub without mark_lost. An exception
before db.close() leaves the non-daemon aiosqlite thread alive, so the process
never exits and the test appears stuck instead of failing with a traceback — the
class the suite's own TEST_TIMEOUT comment describes.
Three findings from the clean-server install audit.

.env was sourced with `. ./.env`, as root, by install.sh, deploy.sh and
upgrade.sh — and /etc/default/webterm-backup the same way. Compose accepts values
with spaces; bash runs them. So WEBTERM_ALERT_TO=a@x.com, b@x.com killed the
install with "command not found" after files had already been copied, and
WEBTERM_NOTE=x && touch /tmp/pwned created the file. Not hypothetical:
.env.prod.example itself shipped a value containing && with an invitation to
uncomment it. A literal KEY=VALUE parser replaces the sourcing. Proven side by
side: the old form created the marker file, the new one reads the same lines
verbatim and executes nothing.

A second install on the same machine could hijack the first one's systemd units.
Their names are fixed regardless of --dir, and /etc/default/webterm-backup holds
both the volume name and the passphrase that decrypts the archives — so a test
install would have redirected production's nightly backup to its own volume and
overwritten that passphrase, leaving the old archives undecryptable. Renaming the
units would break upgrades of existing installs, so instead the installer refuses
the collision and says whose unit it found. Reinstalling over your own directory
still works, which is the case that has to keep working. The certificate check
gained the --no-cert-check flag it never had.

Getting the setup token wrong five times locked you out for fifteen minutes,
including with the correct token, two minutes after cloning — and the 403 said
only "wrong setup token". It now counts down ("4 attempts left before a
15-minute lockout") and prints the command that retrieves the token. The lockout
itself stays: it is the brute-force defence. What was missing was telling anyone
it was coming.
…guage

The agent installer appends a line to ~/.bashrc and ~/.zshrc so shell integration
works. That was disclosed in docs/SHELL-INTEGRATION.md and in a comment inside the
script itself — neither of which is on the path an admin actually walks. The
install dialog and the README described everything else the command does and not
this. An audit reading ptyd.py as a suspicious stranger said plainly that this,
not the signing key or the threat model, was where they would have stopped. Both
places now say it, along with the variable that skips it.

Dates ignored both of the user's choices. `toLocaleString()` with no arguments
takes the BROWSER's language and the MACHINE's timezone, so a Romanian on an
en-US Chrome saw a Romanian interface with American dates, and setting
Europe/Bucharest in Settings changed nothing in the audit log, token expiry,
backups or diagnostics. Eight sites used it. A single fmtTs() applies both, and
falls back to an ISO string rather than an empty screen.

Time abbreviations were hardcoded twice over, in opposite directions: English in
lib/api.ts ("now", "d") and Romanian in three components ("z"). The same product
therefore printed "seen now" on a Romanian dashboard and "acum 3z" on an English
one. They live in the catalogue now, reachable from module scope through tStatic
for the two helpers that have no hook.

Language detection read only navigator.language, the first preference. A browser
set to `de-DE, ro;q=0.9` got English, though the user had said they read Romanian.
It walks the whole list now.

The most frequent error path in the product — a wrong password — was untranslated:
eleven HTTPExceptions on login, 2FA and re-auth now carry stable codes the client
translates, joining the mechanism that already existed for everything else.

SECURITY.md said only that "a response may take a few days". It now commits to
acknowledgement in 7 days and an assessment in 30, tells you to ping again if that
window passes, offers an email address for people without a GitHub account, and
states a safe harbour. And with blank issues disabled, someone with a plain
question had nowhere to go: there is a Discussions link next to the security one.

Two of these were caught by the build rather than by me: a `const t = setInterval`
and a `rel = (t: number)` each shadowed the translate function in their scope.
…closed

A performance audit measured what a cold load actually costs. Nothing compressed
anything — not the app, not the Caddyfile, not Traefik, whose only middleware is
HSTS — so every first visit pulled 904 KB over the wire. With browser throttling:
113 ms unthrottled, 4.7 s on Slow-4G, and 18.6 s to first paint on Regular-3G.
For a product whose headline promise is checking on a server from your phone,
that is the wrong number.

GZipMiddleware goes in the application rather than a proxy, so it covers every
deployment path including someone's own reverse proxy. Measured on the wire after
the change: 826 KB becomes 227 KB, a 3.55x reduction. The audit also overturned
the premise I gave it — the bundle is not one chunk, it is 29, with CodeMirror,
Settings, the file browser and the xterm renderers already lazy. The entry chunk
is React plus xterm plus the app, and xterm *is* the product, so compression was
the win and code-splitting is not worth chasing.

Transcripts of closed sessions were never reclaimed. archive_transcript had
exactly one caller, DELETE /api/sessions/{sid}, and purge_archive only walks the
archive directory — so the documented retention applied solely to sessions you
deleted by hand, while a session that simply closed kept both its files forever,
up to 64 MiB each. Reproduced: 120 closed sessions left 240 files that nothing
would ever touch. The janitor now moves transcripts of sessions closed longer
than WEBTERM_CLOSED_ARCHIVE_DAYS (30) into the archive, where the existing
120-day retention finishes the job. The database row stays — the history is
useful; only the bytes leave.

The compose gate caught the new variable missing from both compose files before I
did, which is exactly the failure it was written for after 22 of 28 variables
were once dropped in production.
The previous fix deleted a departing account's API tokens by created_by, which
holds an email — and update_account changes users.email without migrating it. So:
create a token, change your email, have the account deleted, and the DELETE
matches nothing. The token lives on, for up to a year. The same applies to
sessions.share_by. Fixing the deletion was treating the symptom; the defect is a
mutable key.

api_tokens and sessions now carry the account id alongside the email, through the
usual additive migration. Revocation decides on the id and falls back to the email
for rows created before it, so existing installs are covered on the next delete.
The email stays for display and for the audit trail, where it is the right thing
to show.

Test walks the reported path: create a token, change the email, confirm the token
still works, delete the account, confirm it is dead.

Also corrected a regression from the adoption cap two commits ago. The cap was
checked before the "do we already know this session" lookup, so an already-known
session counted as refused, and a host legitimately at 32 sessions could raise a
false adoption_refused alert. I had moved the check there to avoid one DB query
per reported sid — a compromised agent reporting 3000 of them would otherwise
cost 3000 round trips. Fetching the known ids once, into a set, gives both: the
cap now applies only to genuinely new sessions, and the whole loop costs one query
instead of N.
…ges in ops

A missing `alive` field killed live sessions. One loop read info["alive"] and the
other info.get("alive"); the tolerant one turned an absent field into None, which
is falsy, which is the "it died" branch — on_exit plus reap, so the real tmux
session on the host went too. A truncated message or a protocol drift became a
close command. Absent now means unknown, and nothing is touched. Both loops
handle it the same way.

Security alerts switched themselves off. ALERT_FROM read the environment
directly, but compose passes ${WEBTERM_ALERT_FROM:-}, so the variable arrives
present and empty and never falls back to SMTP_USER — and email_alerts_enabled()
went False. Anyone who configured SMTP without naming a From address stopped
receiving IP lockouts and new-IP logins, silently. It goes through _str now, which
exists for exactly this trap.

Five operational edges, each confirmed before it was touched:

- remove.sh --keep-backups kept the archives and deleted the passphrase that
  decrypts them, leaving precisely the orphans the line above it warns about.
- install.sh went silent when neither ss nor netstat exists, treating "cannot
  tell" as "port is free"; setup.sh had already been given the graceful warning.
- upgrade.sh under -y continued after a failed backup. "Do not ask me" is not
  "proceed without a safety net"; it now refuses unless --no-backup says so.
- The green "Upgrade complete" banner reported the target, not reality. deploy.sh
  execs into rollback.sh when the new image will not become healthy, and a
  successful rollback exits 0 — so upgrade.sh announced v2.1.0 on an install that
  had just returned to v2.0.0. It compares against the running image now.
- log() in the agent had no guard, so a write to stderr on a full disk raised
  through the event loop, reconciliation and updates. The agent died of its own
  logging, in the situation the log was trying to report.

Fixing the banner exposed a stub that answered "healthy" to every docker inspect,
including the one asking which image is running. A stub that coarse lets a wrong
implementation pass and makes a correct one fail — the second happened here.
The published image is amd64. That is the right scope — but an ARM user who runs
install.sh gets a manifest error from Docker and concludes the product does not
support them, when setup.sh already builds from source in about thirty seconds
and never touches the registry. Both base images are multi-arch indexes and
nothing in the build is architecture-specific, so that path works today; it just
was not written down. The agent is stdlib Python and never cared either way.
A review of the last 50 hours of fixes, run against the release repo, found three
real ones. All three are mine, from this window.

load_env silently dropped lines that sourcing had accepted: indented `KEY=`,
`export KEY=`, and a space before the `=`. The path that matters is
/etc/default/webterm-backup, a systemd EnvironmentFile — exactly the kind of file
edited by hand, where `export ` and indentation are ordinary. A lost passphrase
there means backup.sh refuses to write an unencrypted archive, which since the
last commit makes upgrade.sh die and blame the backup. Two changes from the same
window compounding into a confusing failure. It now strips leading whitespace and
an optional `export `, and says so on any line it still cannot parse, because a
variable that vanishes quietly is the whole problem.

The update-blocked tooltip rendered the i18n key. `t()` never returns empty — it
falls back to the key name — so the `|| host.update_blocked` fallback was dead
code, and an unrecognised reason showed as `sidebar.blockReason.necunoscut`. The
comment above it promised the opposite. It compares against the key now. The
reason string is also clipped: it was the one agent-controlled value in this
window that never got that treatment, and it goes straight into a DOM title.

The metrics filter did not do what its comment claimed. It said "only the keys we
understand" while accepting any key under 32 characters with a numeric value:
50,000 keys passed, about 819 KB, in every host-detail response. Worse, json.loads
accepts a bare Infinity off the wire but JSONResponse refuses to serialise it, so
an agent sending inf turned GET /api/hosts/{id} into a 500 until it behaved. Now
it is an actual whitelist of the eight keys the agent sends and the UI reads,
capped in count, finite values only.

Writing that test earned its keep immediately: the whitelist was checked against
what the agent really sends, because getting it wrong would have silently dropped
every metric in production instead of only the hostile ones.
An operations audit deployed a deliberately broken image and watched the health
gate fail, the rollback fire, and nothing happen. The container reported "Running"
rather than "Recreated" and stayed on the broken image.

deploy.sh exports WEBTERM_IMAGE with the new tag and then execs rollback.sh.
rollback.sh rewrites .env, but exec inherits the environment and compose prefers
it over the file — so `up -d app` re-resolved to the image we were rolling away
from. Verified directly: with WEBTERM_IMAGE set, `docker compose config` returns
the environment's value and ignores .env entirely. So the automatic rollback that
RUNBOOK lists as a defence layer has never worked when triggered from deploy.sh,
and it left .env and .prev-image swapped, which pointed the manual rollback at the
broken image too.

rollback.sh now unsets it before touching compose, deploy.sh unsets it before the
exec, and rollback.sh checks what is actually running before claiming success —
it used to print "the app is running <version> (healthy)" without looking. Proven
on a real stack: with the variable exported exactly as deploy.sh leaves it, the
container is now Recreated on the good image.

restore.sh had no volume guard, though backup.sh has had one for a while — and
the cost is higher here. Docker silently creates a missing volume, the restore
succeeds into it, exit 0, "restore OK", while the running app keeps its own empty
volume. Someone reads that after a disaster and believes they recovered. The name
comes from the install directory, so it is wrong by default on any machine that
is not /opt/webterm, which is exactly the RUNBOOK rebuild procedure being copied
from one host to another.

deploy.sh treated a failed pull as fatal even when the image was already local,
which blocked locally built images, air-gapped installs, and a manual rollback
onto an image that had already been pulled.
…butor docs

Three risks the reviews flagged and I had not fixed, two of them mine from the
last few hours.

archive_closed_transcripts also archived sessions in state 'lost'. That is the
*reconnectable* state — reconciliation brings it back to 'live' when the host
returns — so a host offline for longer than the threshold would have come back to
a revived session with an empty scrollback, exactly at the moment someone is
recovering from an outage. Only 'closed' now, with a test.

The `known` set loaded every session id in the instance on every heartbeat of
every host. Rows are kept forever on purpose, so the set grew without bound over
the life of the install: fixing N round trips, I had introduced a full table scan.
Scoped to the host, which is all it ever needed — sids are uuid4.

GZip ran at level 9 and Starlette compresses on the event loop, so a large file
download or a forward-proxied response would have burned the loop that multiplexes
every terminal. The measurement that justified compression was about a 904 KB
bundle; the scope is much wider. Level 6 keeps essentially the same ratio on the
bundle for a fraction of the CPU.

Contributor-facing gaps: CONTRIBUTING listed four of the gates that can block a
PR and omitted the housekeeping ones, including pip-audit, which reddens every
open PR when a CVE lands in a dependency nobody touched — worth saying out loud so
a contributor does not think it is their bug. It also said the blocking signature
check "only applies to pushes", which is not the shape of the condition: a PR from
a branch inside this repository hits it too. frontend/package.json still said
0.1.0 with no license field.

RUNBOOK's rebuild step now names WEBTERM_VOLUME, since the volume name comes from
the install directory and that procedure is copied between machines. upgrade.sh
keeps a .bak of itself, which its own promise covered and its implementation did
not — and that is the one file you need to go back to if the new one is broken.
…ter failures

The terminal effect depends only on session.id, deliberately — re-running it would
tear down and rebuild a live terminal on every language switch, which is worse
than the bug. But `t` was captured inside the key handler, which lives as long as
the session, so after switching languages the command-guard message stayed in the
old one until the terminal remounted. It goes through a ref now, the same pattern
already used for commandGuard a few lines above: read at call time, so it is
always current, and the terminal never moves.

backup.sh and restore.sh pull a helper image on every run and printed Docker's raw
error with exit 125 when the registry was unreachable — no mention of the product,
no suggestion. It matters most for the nightly timer, where a silent failure means
months of believing you have backups. They check first now and say what to do:
pre-pull it once, or point WEBTERM_TOOL_IMAGE at an image you already have.

The agent's disk metric guarded f_blocks > 0, but the total is that multiplied by
the block size, and a zero f_frsize — an exotic filesystem, a degraded mount, a
FUSE driver reporting badly — raised ZeroDivisionError in the division right
below, from a function called on every heartbeat with nothing catching it. A
missing metric is an annoyance; a dead agent is an outage. Proven with a simulated
filesystem: it now skips that mount and carries on.
Nine external audits ran against 2.0.0 before it was announced. This release is
what they found, and the tag for 2.0.0 stays where it is rather than being moved
over the fixes — a released tag should mean one thing forever.

The two findings that matter most could only be found by breaking something on
purpose: the automatic rollback had never rolled back, and restore.sh restored
into a volume nothing used while reporting success. Both were listed in the
runbook as defences. Everything else is in CHANGELOG.md, grouped by what it cost
rather than by which file changed.

Version bumped in the six places the CI gates hold together: GATEWAY_VERSION, the
README badge, package.json, and the image pins in .env.prod.example, both compose
files and install.sh.
A five-way security audit found nothing critical, and three real inconsistencies
in the product's own model — the same shape as the /api/search gap fixed earlier:
a read path that slips past a boundary every comparable path respects.

GET /api/history returned the commands executed on 2FA-marked hosts, and the
working directory with them, to anyone holding a session cookie. /transcript,
/preview, /search and /agent-log all require an open step-up window for those
hosts; this did not. It filters silently now, the way /api/search does — a 403
would turn the search into an oracle for "are there commands on host X".

GET /api/audit sat behind require_scope("read"), so an automation token could
read it. The detail column carries the full text of every command run on the
fleet, every search query, and the email and IP of each operator. Those tokens
end up in CI logs, .env files and scripts, which is exactly why every other
content read deliberately excludes them. It requires a session now.

The alert webhook accepted any string and needed no re-authentication, while
every other channel that sends data out of the instance asks for the password.
A stolen cookie could point alerts at file://, gopher://, or the cloud metadata
address. Scheme is checked and metadata addresses refused; private networks stay
allowed, because a Mattermost on the LAN is a legitimate target for self-hosted
software.

CI gained an npm audit. pip-audit covered Python and Trivy the image, but the
forty-odd frontend packages — the ones that run in every user's browser — passed
through no CVE gate at all. Shipped dependencies only: a vulnerability in a build
tool never reaches a user, and a permanently red job gets ignored like a real one.

Three of my own gates caught this work while I wrote it: the i18n catalogue
demanded translations for the two new error codes, and the token test failed
twice — once because it asserted a token *may* read the audit log, once because
reading entries off a 401 raised a KeyError that hung the suite instead of
failing it.
The section opened by comparing the product to a browser full of terminal tabs,
which is a strawman nobody was choosing instead, and then buried the real claim in
a bullet. And one of those bullets was false: "the gateway keeps no SSH
passwords/keys". True for agent hosts, which hold a token and nothing else —
untrue for SSH hosts, where a stored credential goes into the encrypted vault.
The same shape of error the audits kept turning up: a security claim that holds on
one path, written as though it holds on all of them.

It now leads with the thesis. Put a terminal in a browser and the session ends up
living in the middle, so restarting the middle loses the work; this puts the
session in tmux on the machine itself and makes the gateway a window onto it.
Then both connection modes, with the trade stated rather than implied: the agent
buys persistence and no stored login, direct SSH buys nothing to install and costs
you the session on a gateway restart plus a credential in the vault if you save
one. The agent is not overhead you pay — it is the part that makes a session
something you return to.

No competitor is named. Naming them dates badly and invites an argument about
their product instead of a decision about this one; the mechanism stated plainly
lets a reader draw their own comparison.

Every number in it was checked against the code rather than carried over.
A session can be watched by more than one client, and the roster already showed how many.
But it changed silently and carried only a count and a role: you learned that a second
client was on your terminal only if you happened to be looking at that corner of the
toolbar at that second, and even then you could not tell your own phone from a stranger.
So there was nothing to notice and nothing to act on.

- core: BrowserClient carries ip / user-agent / known / attached_at; roster() ships them
  (user-agent capped at 120 chars — it is client-controlled text and the roster is
  broadcast on every attach and every leave).
- core.announce_attach(): a new `attached` event to everyone already on the session, never
  to the client that just joined. A dead socket does not stop the announcement.
- security.ip_is_known(): reads seen_logins without writing to it. Deliberately a weak
  signal, and deliberately read-only — if the query registered the address, the first
  attacker to attach would mark himself familiar and the second time nothing would ring.
- api: both websocket paths capture the identity and announce. Guests through a share link
  always count as unfamiliar: the link was given deliberately, but the moment it is used is
  exactly what you want to know.
- email_alerts.notify_session_attach(): one message per address per 15 minutes, only for
  unfamiliar devices. An alert that fires constantly is unread, and then so is the one that
  mattered.
- frontend: `attached` raises a system notification (warning tone for unfamiliar devices,
  so it lands with the tab in the background); the roster row shows IP + a short browser
  label and a "new device" badge.

Device identity decides how loud to be, never whether to check. Nothing here lets a
recognised device skip step-up, the idle lock or 2FA — an IP and a user-agent both travel
with a stolen session cookie, so that exemption would be waved through by precisely the
attacker it appears to stop, trading the idle lock's 5-minute window for permanent access.
tests/attach_alert_test.py asserts that invariant directly, not just the happy path.

Agent unchanged (still 40) — nothing in ptyd.py was touched, so no re-signing needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… v2.0.2

The password change already required the current password. That defends against a stolen
cookie, but not against the case that actually hurts: someone who already HAS the password
— reused, leaked, guessed — rotates it and locks the owner out of their own account. Email
is the channel that attacker does not have.

- security.note_new_login() now returns whether the address was new, and login stamps the
  verdict on the session (web_sessions.device_new). It has to be frozen there: the very
  call that decides also RECORDS the address, so any check made later would answer
  "familiar" every time — the gate would look like it worked while doing nothing. Both
  login paths (password and passkey) stamp it.
- security.issue_email_challenge / consume_email_challenge: six digits, ten minutes,
  single-use, five attempts, one live challenge per (account, purpose) so re-issuing
  invalidates the previous code. The attempt counter increments and reads in one statement
  — separately, two concurrent requests read the same counter and a six-digit code with an
  unbounded guess budget is not a secret.
- email_alerts.send_account_code(): sends to the ACCOUNT's address, not the instance alert
  mailbox, synchronously, and raises if it does not leave. Deliberately not through _fire —
  that swallows errors (you would wait forever for a code that never left) and also fans
  out to the chat webhook, and a confirmation code posted in a channel confirms nothing.
- /api/account gates both password and email changes. Email is on the same gate because it
  is the recovery channel: move it unconfirmed and the next password change is "confirmed".

A code, not a link: a link is clickable by anyone who reaches the inbox, and mail scanners
open links on their own, consuming a single-use token before the owner sees it. A code typed
into the page you are already on proves inbox access AND that you started the change.

It escalates, it does not refuse. Blocking credential changes outright from an unfamiliar
address sounds strict until you are travelling, your password has just leaked, and that is
exactly when you are not allowed to rotate it. And the gate applies only when SMTP is
configured — without a mail channel, refusing would be a permanent account lockout rather
than a security measure.

tests/account_confirm_test.py (22 checks) covers the primitives and the endpoint, including
the frozen-stamp property and the no-SMTP escape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… v2.0.2

Three things, all from the same review of what shipped two commits ago.

1. "Familiar address" meant "seen once", and that defeated itself. An attacker who knew the
   password logged in once from home and was familiar on the second attempt — the gate opened
   BECAUSE he had attacked twice. Familiar now means an address with history: at least three
   logins, first seen more than 24 hours ago (security.ESTABLISHED_LOGINS / ESTABLISHED_AGE).
   The first visit from a new address already sends the new-login alert, so that 24 hours is
   not a silent window, it is the interval in which the owner can react. seen_logins gains a
   counter; the migration defaults it to 3 so an upgrade does not turn every known place
   strange overnight — those rows do represent real logins, they were just never counted.

2. Passkey enrol/delete now need a second factor. This was the hole left by the email gate:
   someone with the password could no longer rotate it, but could still enrol THEIR OWN
   passkey — a permanent, phishing-resistant key to the account — or delete the owner's. With
   TOTP on, the code from the phone is required (verify_second_factor accepts a recovery code
   just as well); without TOTP, the emailed code, and only from an unfamiliar device.

   Email is deliberately NOT accepted in place of TOTP. If it were, two-factor would be worth
   exactly as much as access to the mailbox and the phone would defend nothing. For a lost
   phone there are the ten recovery codes; if those are gone too, the server.

   _verify_second_factor moved from api.py to security.verify_second_factor so both routers
   can reach it without an import cycle.

3. app/admin.py — recovery over SSH: list, passwd, disable-2fa, logout-all. Every gate the UI
   gains is another way to lock yourself out, and a self-hosted product can afford to be
   strict in the browser precisely because this exists; shell on the server is a far higher
   bar than a mailbox, and grants nothing new to whoever already has it.

   It replaces the hand-written SQL in RUNBOOK §5, which hashed correctly but left the open
   web sessions and share links alive — you could rotate the password and leave the intruder
   logged in. It runs in a different process from the gateway, so it revokes what lives in the
   DB and says plainly that port-forward tickets need a container restart, rather than
   printing a reassuring line it cannot back up. The password is prompted for, never an
   argument, so it stays out of shell history.

Suite 37/37, exit 0. account_confirm grew to 33 checks (established-place rule, the passkey
gate, and that email cannot substitute for TOTP); attach_alert follows the new semantics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The version bump missed .env.prod.example, so a fresh production install would have pinned
the previous image while everything else said 2.0.2. CI caught it — that step exists for
exactly this — but it caught it after the tag was pushed, so the tag moves too.

Also refreshed the ./deploy.sh and ./upgrade.sh examples in the docs; they are prose and no
gate checks them, which is precisely why they drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sm26449 and others added 14 commits August 9, 2026 11:50
The "copy output" check failed on main and passed on the tag run — same commit, 1795da4.
Not a regression: the clipboard is written asynchronously from the click, and a fixed 500ms
wait was enough until a loaded runner was slower than that, at which point the read returned
the PREVIOUS contents. The markdown check in the same run passed while asserting the same
OSC_OK string, which is what pins it to the read racing the write rather than the output
being missing.

All three clipboard reads now poll until the content matches, capped at 5s. The assertion is
still on the content, so a real regression still fails it — what is no longer measured is how
fast the runner happened to be.

Only touches the E2E script; the v2.0.2 image is already built and published, so the tag
stays where it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docker-compose.prod.yml claimed that without a Cloudflare token each forward subdomain
"gets its own certificate, issued on first access", so the only cost was a slower first
load and Let's Encrypt rate limits. That is wrong, and it is the reassuring kind of wrong:
an operator reading it would conclude forwards work fine without the token.

The forward router is matched with HostRegexp. Traefik's ACME derives domain names only
from Host() / HostSNI() matchers — there is nothing to extract from a regex — and it has no
on-demand, SNI-triggered issuance. With the wildcard label collapsed (which is what happens
on HTTP-01, since only DNS-01 can issue a wildcard), that router has no certificate at all,
so forward subdomains are served Traefik's default self-signed cert: a browser warning.

README and .env.prod.example already said "the app gets TLS normally, but forwards do not".
The compose comment was the outlier, and it was the one someone editing the cert setup would
read. Now they agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…igest pins, docs

From a four-way external audit (2026-08-09). Every finding was checked against the code
first; these are the ones that survived.

Security
- webauthn_api._second_gate: wrong TOTP codes were not counted. _verify_reauth calls
  record_login_success when the password is right, which CLEARS the failure counter — so
  someone who already had the password could send (good password + guessed code) forever,
  each attempt wiping its own trace, and brute-force a six-digit code with no lockout at
  all. Own counter key now, which nothing else resets. Deliberately no "a correct code
  passes anyway during lockout" escape hatch, unlike the password path: a guesser needs one
  lucky hit, so that hatch would delete the defence it belongs to. Accepted cost: whoever
  already knows your password can block passkey management for 15 minutes. Found in code
  written the same day.
- X-Webterm-Version was served on every /api/* response, including the public /api/login
  and /api/state — while the comment above it claimed the opposite. Now it requires a
  session cookie; the version banner is only ever seen by someone logged in anyway.
- Passwords had a floor and no ceiling: a multi-megabyte body reached argon2, on a path
  that is free to repeat. Capped at 1024 — generous enough not to truncate a passphrase.

Supply chain
- traefik, docker-socket-proxy, caddy and the backup tool image floated on mutable tags
  while the Dockerfile declared a digest-pinning policy. The backup image is the worst of
  them: it runs as root over the data volume with the vault key mounted, i.e. the most
  powerful container in the system, and it accepted whatever anyone pushed to
  python:3.12-alpine. All pinned by digest.
- Dependabot watched Dockerfiles but not the compose files, which is why they drifted
  unreviewed. Added the docker-compose ecosystem.
- packages: write applied to the whole workflow, so the test job ran with a token that
  could write to the registry. Moved to the build job.

Docs
- Documented what exists but was never written down: i18n (en/ro, and how to add a third),
  webhook alerts, one-click SSH provisioning, the cert-expiry timer, the update notice, and
  remove.sh — plus five env vars missing from the table.
- Fixed real deviations: 42 → 46 suites, 74 → 75 e2e checks, RUNBOOK's sys.path pointing at
  /srv/webterm instead of /srv/webterm/gateway (it only worked because PYTHONPATH is set in
  the image), .env.prod.example naming WEBTERM_CERT_MAIN/_SANS when the code reads
  WEBTERM_CERT_LABEL_*, and a README example using --ghcr-token that install.sh deprecates.
- The counts now have a CI gate. They drifted for the same reason the version badge once
  drifted 14 patches behind: nobody updates a number that nothing checks.

tests/account_confirm_test.py grew to 35 checks, covering the lockout and that a correct
code does not slip through it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d the comment language

Follow-through on the same audit; documentation only, no behaviour change.

- README: the `curl | sudo bash` one-liner fetches from `main` — a branch that can move —
  and runs as root, which makes it the most privileged thing anyone does with this project.
  The clone + `git checkout v2.0.2` + read + run path was already supported and never
  written down. Now it is, next to the one-liner rather than hidden below it. A per-release
  checksum was the other suggestion, but a checksum fetched from the same GitHub account
  that would have been compromised proves very little; pinning to a tag and reading the
  script does.
- Dockerfile: single uvicorn worker is an INVARIANT, not a performance choice. Lockout
  counters, WebAuthn challenges and step-up windows are per-process dicts, so `--workers N`
  divides the lockout by N and breaks step-up depending on which process you land on. Said
  where someone would go to change it.
- CONTRIBUTING: much of the codebase is commented in Romanian. Rather than pretend
  otherwise or plan a rewrite that would detach every explanation from its history, the
  rule is now explicit — new comments in English, existing ones left alone.
- THREAT-MODEL: the installer's single third-party call (api.ipify.org, to warn when DNS
  points somewhere other than this machine) is now inventoried with the gateway's other
  outbound connections, where a reader would look for it.

Note for anyone running gitleaks locally: it will report three hits in frontend/dist/.
That directory is gitignored build output, absent from a CI checkout and from git — the
tracked tree scans clean, over the full history too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the mark the project has carried since the start. The reasoning, so the next person
does not undo it by accident:

- The three lines that joined the prompt to the hosts were the second-heaviest element after
  the chevron, and they were doing the least work. They are now a trail of dots that fades
  toward the prompt, which gives the mark direction: the signal leaves the prompt and grows
  toward the machines.
- Everything on the right lost weight — host dots 2.8 -> 2.1, trail opacity 0.55 — and the
  chevron thinned to 3.1 to match. Shrinking only the right half would have left the chevron
  as the sole heavy element and tipped the whole mark leftward.
- Round line caps became butt caps: same silhouette, but drawn rather than stamped.
- The chevron is now perforated by a grid of squares, with the tile gradient showing through
  the grooves. The prompt is made of cells and the hosts stay solid, which is the honest
  distinction: a terminal is made of characters, the machines at the far end are real.

Regenerated from the one SVG with rsvg-convert: icon-192, icon-512, apple-touch-icon (180),
and icon-maskable-512. The maskable one is not the same file scaled — platforms crop it to a
circle or a squircle, so it drops the rounded corners, bleeds the gradient to the edge and
scales the mark to 62% so nothing lands outside the safe zone.

Known and accepted: the grid closes back into a solid shape below roughly 48px, so in a
browser tab the mark reads as it did before. The detail lives on the PWA icons and anywhere
the mark is shown large. Drawing a separate, simpler favicon for 16px stays available.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two changes to give the terminal back the space it deserves on a small screen.

- The sidebar can be hidden on desktop, and the choice is remembered (localStorage, read
  synchronously at mount so the layout does not jump on first paint). Mobile is untouched —
  there it is already a drawer that closes.
- The way back matters more than the hiding: the ☰ button that until now was mobile-only
  appears on desktop exactly while the sidebar is collapsed. Hiding a panel with no visible
  way to bring it back is a trap, not a feature, so the E2E asserts both directions — ☰ is
  absent while the sidebar is visible, present once it is not.
- "New session" left the host row for the ⋯ menu, first item, with a separator between it
  and the administrative entries (files, edit, uninstall). In the row it only appeared on
  hover and competed with the host name and the update badge; in the menu it has full text,
  the first position, and a rule that keeps an absent-minded click off "Uninstall".

The E2E clicked `button[title="New session"]` in five places; that button now lives behind
the menu, so it went through a `newSession(page)` helper that opens ⋯ first. The button on
the HOST page is unchanged and carries no title attribute, only text — which is why the
mobile audit, which matches on text, needed nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cut as its own version rather than moving the v2.0.2 tag again: these are visible interface
changes, and a released tag that has pointed at two different images is a thing you can get
away with once, before anyone has pulled it. Not twice.

Also backfills the CHANGELOG for 2.0.2. The post-audit fixes shipped inside that release —
the TOTP brute-force cap on the passkey gate, the version header served before login, the
password ceiling, the digest pins, the CI token scope — but were never written down, so the
file claimed to be the record while missing a security fix. Corrected in place, where they
belong.

Nothing changed on the server or in the agent (still 40); this release is the interface and
the icons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…othing

Found on the live instance: production runs 2.0.2, 2.0.3 was published, and the UI said
nothing at all. The check is enabled and points at the right repo, but the repo is still
private and no WEBTERM_UPDATE_CHECK_TOKEN is set, so GitHub answers 404.

The API was already correct — on failure it returns no `update_available` key, so neither
"update available" nor "up to date" is shown, and we never claim to be current when we do
not know. But silence looks exactly like "checked, nothing new", and the reason was carried
in `error`, typed in the interface, and rendered nowhere.

Now a muted "could not check" sits next to the version, with the reason on hover. Whoever
runs a private fork can tell the difference between a quiet check and a broken one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…" button

The v2.0.3 build failed on the axe step: tests/ui_review.mjs clicks the sidebar's
"New session", which now lives behind the host ⋯ menu. Three call sites — the readiness
wait after login, openSession(), and the mobile drawer — plus two stale comments.

My mistake was the search, not the change: when I moved the button I checked three scripts
by name instead of grepping the repo for the selector, and ui_review.mjs lives in tests/,
not scripts/. Grepped exhaustively this time — every remaining occurrence of
`[title="New session"]` is now preceded by opening the menu.

Unaffected, and worth recording so nobody "fixes" them: scripts/screenshots/shots.mjs uses
the DASHBOARD button (`aria-label^="New session on …"`), and scripts/mobile-audit.mjs
matches the HOST PAGE button by text — neither of those moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two corrections found while checking the file against reality rather than reading it.

- The section I added yesterday opened with "The one-liner fetches install.sh from main",
  but the README never shows a one-liner — it is documented in install.sh's own header, for
  cloud-init and Ansible. A reader had no idea which one-liner was meant. Rewritten to stand
  on its own and say where that form lives.
- The "Files + editor" heading showed only the editor. The file-browser screenshot was
  already generated by the same script and simply unused; now the section shows both, as it
  claims to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bumps the compose-minor group with 2 updates: tecnativa/docker-socket-proxy and traefik.


Updates `tecnativa/docker-socket-proxy` from 0.1.1 to v0.5.0

Updates `traefik` from v3.6 to v3.7

---
updated-dependencies:
- dependency-name: tecnativa/docker-socket-proxy
  dependency-version: v0.5.0
  dependency-type: direct:production
  dependency-group: compose-minor
- dependency-name: traefik
  dependency-version: v3.7
  dependency-type: direct:production
  dependency-group: compose-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps node from 20-alpine to 26-alpine.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-alpine
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.19.2 to 7.3.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](docker/build-push-action@10e90e3...53b7df9)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Eleven open Dependabot PRs before going public, three of them green and merged (traefik 3.7
+ socket-proxy v0.5.0, node 26 for the frontend build, build-push-action v7). The other
eight were major bumps that fail CI — typescript 7, tailwind 4, eslint 10, python 3.14,
websockets 17 — real upgrade work, not something to merge from an automated PR.

Five closed on `@dependabot ignore this major version`. The remaining three are GROUP
updates, where that command has nothing to bind to: a group holds several dependencies, so
the bot cannot tell which major to ignore. The rule belongs in the config, where it is
explicit and does not depend on comment parsing.

What this does not cost: patch and minor updates keep coming, which is the path security
fixes actually arrive on. What it does cost: no more automatic notice when a major appears.
Worth revisiting at 2.1 — drop the ignore blocks, let the PRs show up, work through them one
at a time.

Before merging the compose bump I checked what CI does not: nothing in the pipeline ever
starts Traefik or the socket proxy, so "green" there meant "the tests that do not cover this
still pass". Ran both images in isolation with our exact flags and environment — Traefik 3.7
accepts the static configuration (the only errors were the missing acme.json and dockerproxy,
expected with no network), and socket-proxy v0.5.0 starts on the same five variables. The
frontend also builds on node 26 (v26.7.0, tsc and vite clean).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dependabot dependabot Bot added dependencies Pull requests that update a dependency file docker Pull requests that update docker code labels Aug 10, 2026
The docker_compose updater failed with private_source_authentication_failure on ghcr.io: it
was trying to update `ghcr.io/sm26449/webterm` — our own application image, referenced in
docker-compose.prod.yml — and cannot authenticate while the package is private.

Going public would make that error disappear, which is exactly why it is worth fixing
properly instead: even when it succeeds, Dependabot must not touch that pin. CI has a gate
asserting the compose image equals GATEWAY_VERSION, so a bot bump would fail the build, and
the application version belongs to the release process (upgrade.sh, the tag, the changelog),
not to a dependency updater. The authentication error was the symptom; the wrong ownership
was the problem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dependabot dependabot Bot changed the title Bump python from 3.12-slim to 3.14-slim in the docker-minor group Bump python from 3.12-slim to 3.14-slim in the docker-minor group across 1 directory Aug 10, 2026
Bumps the docker-minor group with 1 update in the / directory: python.


Updates `python` from 3.12-slim to 3.14-slim

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.14-slim
  dependency-type: direct:production
  dependency-group: docker-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
@dependabot
dependabot Bot force-pushed the dependabot/docker/docker-minor-6dafb4a59b branch from 09f867f to 4099a88 Compare August 10, 2026 07:29
@sm26449 sm26449 closed this Aug 10, 2026
@dependabot @github

dependabot Bot commented on behalf of github Aug 10, 2026

Copy link
Copy Markdown
Author

This pull request was built based on a group rule. Closing it will not ignore any of these versions in future pull requests.

To ignore these dependencies, configure ignore rules in dependabot.yml

@dependabot
dependabot Bot deleted the dependabot/docker/docker-minor-6dafb4a59b branch August 10, 2026 07:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file docker Pull requests that update docker code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant