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
Closed
Bump python from 3.12-slim to 3.14-slim in the docker-minor group across 1 directory#25dependabot[bot] wants to merge 46 commits into
dependabot[bot] wants to merge 46 commits into
Conversation
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>
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>
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>
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
Bot
force-pushed
the
dependabot/docker/docker-minor-6dafb4a59b
branch
from
August 10, 2026 07:29
09f867f to
4099a88
Compare
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bumps the docker-minor group with 1 update in the / directory: python.
Updates
pythonfrom 3.12-slim to 3.14-slim