ANetBBS Changelog — Beta History (pre-v1.0.0)

Archived history of every internal beta build number (v1.0a1.1 through
v1.0b2.239) from before ANetBBS's v1.0.0 full release in August 2026.
Preserved as-is — a real record of what changed at each step during
development — but split out of the main CHANGELOG.md
once it passed 6,000 lines, to keep that file scannable for the
v1.0.0-onward history that actually matters day to day. Newest-first,
same as the main changelog.

v1.0b2.88 — Fix Synchronet-JS door games leaking temp files on every launch (July 2026)

  • FIX: found while a sysop was clearing space to get past the new disk-space check from v1.0b2.87 — a /tmp directory listing on a real, long-running install turned up over a hundred anetbbs_*_synchronet_run.js and anetbbs_*_synchronet_compat.js files, none of them ever cleaned up. Traced to anetbbs/games/door_runner.py's _build_command(): for door_synchronet games, when no real Synchronet jsexec binary is installed (the common case for most sysops, who don't run a full Synchronet install alongside ANetBBS), it falls back to a Node.js compat shim and writes two files that must persist on disk for the life of the door process — a generated synchronet_compat.js (via anetbbs/games/synchronet_compat.py's write_compat_script()) and a combined synchronet_run.js containing both the compat shim and the actual door script concatenated together (tempfile.NamedTemporaryFile(..., delete=False), since the file needs to still exist on disk after Python's own handle closes, for the forked Node.js child process to read). Both are legitimately necessary at creation time — the bug is that nothing, anywhere, ever deleted them afterward. _cleanup_session() (door_runner.py), the single function responsible for releasing a door session's resources no matter how the session ended — PTY EOF, the browser/bridge closing the connection, forced termination, or the waitpid watcher reaping a crashed child — had no reference to these paths at all; they were local variables scoped entirely inside _build_command(), already out of scope by the time any session actually ends. A second, independent leak source doubled the damage: launch_door_game()'s terminal-session code path calls _build_command() a second time purely as a pre-flight validation, "so we can show a clear error" before the real launch — that call's return value (including the two temp files it creates) was discarded entirely, so every terminal-initiated Synchronet-JS launch attempt orphaned temp files twice, once from the validation dry run and once from the real launch.
  • Fixed by giving _build_command() an optional temp_files_out list parameter — when the Node.js compat path creates its two files, it appends both paths to this list if the caller provided one (None by default, so every pre-existing caller that doesn't care about cleanup keeps working unchanged; all other game types — native binaries, Mystic, DOSBox — never append anything, since they don't create files with this lifetime problem).
  • DoorSession (the class already tracking a running door's PTY/process/bridge state) gained a temp_files list, populated from _build_command()'s collector right after a real launch succeeds. DoorSession.close() — already responsible for the exact same kind of cleanup for door_dosemu's pts symlink (self.pts_path) — now unlinks everything in temp_files the same defensive way (try/except OSError, tolerant of a file already being gone), so it's cleaned up through every single path that can end a session, not just the happy path.
  • Also added cleanup to launch_door_game()'s own early-failure branches (PTY open failure, fork failure, _build_command() itself raising) — a session that never makes it far enough to construct a DoorSession at all would otherwise still orphan any temp files created before the failure.
  • Fixed the validation-dry-run leak at its source: it now passes its own throwaway collector list and deletes everything in it in a finally block immediately after the dry run completes (whether it succeeded or raised), since that code path never uses the built command for anything beyond checking whether _build_command() raises.
  • 5 new tests in tests/test_door_synchronet_temp_cleanup.py, run against the real Node.js code path (a real game stand-in, a real door script, real Node.js execution via the actual /usr/bin/node on the test machine, skipped only if no Node.js binary is present) rather than mocked — confirms temp_files_out is populated with exactly the two real files the command actually depends on (including that the file _build_command()'s own returned command line points at is one of the tracked paths, so cleanup can never delete a file still needed to run the door), that DoorSession.close() actually deletes them from disk, and that close() doesn't raise for game types with no temp files or for a file that's already gone.

v1.0b2.87 — update.sh: disk-space check + backup rotation + fix silent backup failure (July 2026)

  • FIX/FEATURE: a real sysop reported their disk hit zero free space partway through running update.sh, corrupting the install beyond recovery. They'd accumulated a dozen old pre-update backup snapshots without realizing it — nothing had ever pruned them — and update.sh had no check for available disk space before it started writing. Investigated by reading update.sh's actual backup step directly rather than assuming the existing safety net worked as expected, and found three real, concrete gaps:
  • No disk-space check anywhere. update.sh never called df or checked free space at any point before starting Step 2 (the pre-update backup) or any later step. Added a pre-flight check requiring at least 500MB free on both /tmp (where backups land) and $INSTALL_DIR (where the update itself writes) before proceeding — on most single-disk VPS installs these are the same filesystem, but checking both costs nothing and covers a split-filesystem setup too. If either is below the threshold, the script now refuses to start at all, with a message pointing the sysop at Admin → Backups to clear old snapshots first, rather than proceeding into a disk-full corruption risk.
  • Backups accumulated forever, by design. anetbbs/web/backups_admin.py's own docstring stated this explicitly: "Those dirs accumulate forever because we don't trust ourselves to GC them automatically." That caution turned out to be the wrong tradeoff in practice — unbounded accumulation of backup snapshots (each containing a full SQLite DB copy) is exactly how a sysop who wasn't actively managing them via the admin UI ended up silently consuming disk space until an update ran the filesystem to zero. update.sh now prunes to the 3 most recent pre-update backups after each successful backup, sorted by the existing YYYYMMDDHHMMSS timestamp in the directory name. Updated the now-stale docstring in backups_admin.py to match.
  • A real silent-failure bug in the backup step itself. The .env backup was two separate, unchained statements — cp "$ENV_FILE" "$BACKUP_DIR/.env.bak" followed unconditionally by ok "Backed up .env" — meaning if the cp failed for any reason (disk full being the obvious one), the script printed a success message anyway and continued straight into the actual update, with no valid .env backup to roll back to if something then went wrong. backup_sqlite() (used for both the production and dev SQLite databases) had the same class of gap in its final cp fallback path. All three backup steps now check their real result and abort the entire update immediately if a backup genuinely can't be written, rather than silently proceeding with what looks like a safety net but isn't one. backup_sqlite() deliberately treats a backup failure on either anetbbs.db or anetbbs_dev.db as equally fatal, since some installs run their real production data against either filename via a custom DATABASE_URL and there's no reliable way for the script to know which one is actually in use.
  • Verified the pruning logic and the disk-space arithmetic directly (created real timestamped backup directories and confirmed exactly the 3 newest survive; confirmed the free-space check correctly triggers on a simulated low-space value and reports accurate real numbers against the actual filesystem) before trusting either.

v1.0b2.86 — Fix SELinux blocking nginx's reverse proxy entirely on Fedora/RHEL (July 2026)

  • FIX: found while live-debugging the v1.0b2.85 MRC fix on a real fresh Fedora install — even after that fix, web MRC still didn't connect, and it turned out the main web UI didn't fully work through nginx either (accessing the site required going straight to the app's own port). Diagnosed by walking through the actual evidence step by step rather than assuming the first fix was wrong: the MRC bridge's own log (journalctl -u anetbbs-mrc-bridge) showed a completely clean, stable upstream connection with no errors at all, which ruled out the bridge itself; the browser-side "Connection lost — reconnecting" message turned out to originate from anetbbs/static/mrc/client.js's own WebSocket reconnect logic (confirmed by reading the code — the message text is templated client-side, data.attempt/data.delayMs come from the browser's own exponential-backoff counter, not anything the Python bridge sends), meaning the browser's connection to the local bridge (via nginx's /mrcws proxy) was the thing failing, not the bridge's connection to the real MRC network. That pointed straight at nginx, and nginx's own error log confirmed it directly: connect() to 127.0.0.1:5000 failed (13: Permission denied) — repeating for every proxied request, to both the main app's port and the MRC bridge's port alike.
  • Errno 13 (EACCES) on an outbound connect() call from nginx, on a system where nginx -t passes clean and the service itself reports "active (running)," is the well-documented signature of SELinux blocking it — Fedora/RHEL/CentOS ship SELinux enforcing by default, and nginx (running under the httpd_t policy domain) is denied permission to open connections to arbitrary backend ports unless the httpd_can_network_connect boolean is explicitly turned on. This is a stock, out-of-the-box SELinux restriction, not anything specific to this app — but install.sh never touched SELinux at all, on any Fedora/RHEL install, ever.
  • This fully explains every symptom observed on the fresh Fedora install: the main web UI needing the app's raw port instead of nginx's port 80/443, and web MRC "sometimes" appearing to connect before dropping again (SELinux denials aren't perfectly deterministic under concurrent connection attempts, so an occasional connection slipping through before the policy blocks the next one produces exactly this flaky, intermittent-looking pattern).
  • Fixed in install.sh: right after generating the nginx config (same step that writes /etc/nginx/sites-available/anetbbs), detects SELinux enforcing mode via getenforce and, if enforcing, runs setsebool -P httpd_can_network_connect 1 (the -P makes it persist across reboots, not just the current session). Systems without SELinux at all (Debian/Ubuntu, the other officially supported install targets) are completely unaffected — getenforce simply doesn't exist there, so the whole check is skipped, not just a no-op branch.
  • Extended update.sh with the matching self-heal check (same getenforce/setsebool logic, gated on the boolean not already being on) so installs that predate this fix get it applied automatically on next update, following the same self-heal precedent established by the v1.0b2.85 MRC port fix in the same file.
  • This was found and fixed on the same day as, and is entirely independent of, the MRC-specific nginx port-mismatch bug fixed in v1.0b2.85 — both were real, separate bugs affecting the same fresh-install report, but this one is more fundamental on any SELinux-enforcing system: it blocks nginx's reverse proxy globally (the main app included), not just the MRC bridge specifically.

v1.0b2.85 — Fix web MRC never connecting on fresh installs (July 2026)

  • FIX: web MRC chat consistently failed to connect on fresh installs — reproduced independently on multiple separate fresh installs, including a stock Fedora box, with identical results each time. Investigated by tracing install.sh in full alongside the actual bridge/web-side connection requirements, rather than guessing from the symptom. Root cause: install.sh's generated nginx config for the location /mrcws block (install.sh line ~1473) hardcoded proxy_pass http://127.0.0.1:8080/ws;, but the MRC bridge's actual listen port is deliberately derived as WEB_PORT+1 (MRC_BRIDGE_PORT_DEFAULT=$((WEB_PORT + 1)), install.sh line 347) — 5001 on the default production-mode WEB_PORT of 5000. That derivation was introduced correctly in v1.0b2.35 to fix a different port-collision bug (the bridge and gunicorn both defaulting to 8080 in test/behind modes), and got wired correctly into .env and the bridge's own config.json at the time — but the nginx /mrcws proxy target was never updated to match, and the two pieces of port logic have been silently drifting apart ever since. The bridge process itself starts and runs with zero errors regardless (journalctl -u anetbbs-mrc-bridge shows a perfectly healthy service bound to 5001), because it's never told anything is wrong — nginx is the only thing that knows about the mismatch, and it fails the WebSocket upgrade silently from the browser's perspective, with no signal anywhere in install.sh's own post-install health checks (which only confirm the service is active, not that nginx's proxy target actually points at it). This reproduces identically on every OS and every default production-mode install, which is the wizard's first-listed and default mode — exactly matching "happens on every fresh install, regardless of environment."
  • Fixed the hardcoded port in three places: install.sh's generated nginx config (now interpolates ${MRC_BRIDGE_PORT_DEFAULT}, the same variable already used correctly everywhere else in that block), the static deploy/anetbbs-nginx.conf.template reference file (used for manual behind-mode nginx setup — corrected to 5001 with a comment tying it to WEB_PORT), and .env.example (was documenting the same stale 8080, now 5001 with an explanatory comment).
  • Extended update.sh's existing nginx auto-repair logic (which already had precedent for self-healing known-bad nginx values from old install.sh templates, e.g. a prior /mrcws path fix and a missing-block auto-insert) to detect this exact port mismatch — reads the real configured port from .env's MRC_BRIDGE_PORT (which was always correct) and patches nginx's proxy target to match if they differ. This means installs that are already carrying this bug get it fixed automatically on their next update, not just future fresh installs. Also fixed the same stale 8080/5000 literals in the "add missing /mrcws block entirely" repair branch (for sysops upgrading from before web MRC existed), which now reads the real ports from .env too instead of hardcoding defaults that may not match a customized install.
  • Confirmed via direct code tracing (not assumption) that /mrcws is handled exclusively by nginx's proxy — Flask has no route for it at all (anetbbs/web/mrc_web.py only exposes the internal /mrc/auth-check endpoint nginx's auth_request calls, never the WebSocket itself). This means test/behind install modes, which default to nginx disabled, have no working path for web MRC at all, independent of the port bug — a real gap, not something a port fix alone resolves. Added explicit warnings to both modes' install-time summaries: test mode now states plainly that web MRC won't connect without nginx (with the fix being either switching to production mode or manually proxying /mrcws), and behind mode's sample nginx block (which previously showed only the main location / proxy) now tells the sysop they need two more locations from deploy/anetbbs-nginx.conf.template, since copying the printed example alone would never cover MRC.
  • Also confirmed terminal MRC (over SSH/telnet) is entirely unaffected by any of this — anetbbs/features/mrc_chat.py's MRCChat class connects directly to 127.0.0.1:{MRC_BRIDGE_PORT} server-side (reading the same .env value that was always correct), bypassing nginx and the browser entirely.
  • Caught and fixed a bug in my own fix during testing: an early version of the update.sh auto-repair's port-extraction regex (grep -oE '[0-9]+' applied to the whole 127.0.0.1:PORT/ws; string) matched the IP address's own leading octet (127) before the actual port, since head -1 just takes whichever number appears first in the string — verified with a simulated before/after repair test before trusting it, and corrected to anchor specifically on the :PORT/ws; segment.

v1.0b2.84 — BinkP: inbound CRAM-MD5 support + poll log for inbound-delivered mail (July 2026)

  • FIX: a real downstream FidoNet peer running binkd reported CRAM-MD5 is not supported by remote when polling IN to an ANetBBS install, while the reverse direction (this install polling that peer) worked fine. Root-caused by reading the code directly rather than guessing: anetbbs/echomail/binkp_server.py — the inbound BinkP listener, which accepts connections FROM remote systems — never implemented CRAM-MD5 as the answering side at all. Its M_NUL handshake preamble (SYS/ZYZ/LOC/NDL/TIME/VER) never included the OPT CRAM-MD5-<hex-challenge> line FTS-1027 requires an answering side to offer, and its password check (binkp_server.py, both the upstream-EchomailNetwork and downstream-BinkPNode branches) only ever did a literal string comparison against M_PWD — no hmac/hashlib usage anywhere in the file. Meanwhile anetbbs/echomail/binkp.py's outbound client has a correct, previously-verified CRAM-MD5 implementation (confirmed spec-correct in an earlier, unrelated investigation into a different bug) — but it is exclusively calling-side logic (BinkPClient._connect() always initiates outbound; the class never accepts a connection), so it has no bearing on the inbound path. The two sides had simply never been given matching implementations — this wasn't a regression, CRAM-MD5 support for inbound sessions never existed. binkd's "CRAM-MD5 is not supported by remote" is its literal, correct message for exactly this condition: a caller that expects/prefers CRAM-MD5 sees no challenge offered in the preamble it receives. Fixed by generating a random 32-byte challenge per inbound session (secrets.token_bytes(32)), advertising it via OPT CRAM-MD5-<hex> in the preamble (sent before M_ADR, matching the outbound client's own ordering convention), and adding _verify_binkp_password() — checks for a CRAM-MD5- prefix on the caller's M_PWD response and verifies it via hmac.new(password, challenge_bytes, hashlib.md5).hexdigest() against the session's own challenge, falling back to the original plain-text comparison for callers that don't send a CRAM-MD5 response — so peers that don't support it keep working exactly as before. 13 new tests, including one that specifically verifies a correct-looking digest computed against the wrong challenge is rejected (the single most likely way this class of bug hides — verifying a real digest against stale/mismatched challenge bytes would silently accept it).
  • FIX: found while investigating a related report — echomail was genuinely arriving from two connected networks (messages landing correctly in message areas) but the admin Poll Log showed 0 messages for both, every time. Traced the full pipeline rather than assuming a miscount: EchomailPollLog (the model backing the Poll Log page) turned out to be written from exactly one place in the entire codebase — anetbbs/echomail/poller.py's _do_poll(), which only runs for outbound sessions (ANetBBS dialing out to a hub). The actual real-time delivery path for those networks is the inbound listener (binkp_server.py) — their hubs call IN and push mail, rather than waiting to be polled — and that path's _import_pkt_payload() (which does correctly compute and even log, at INFO level, a real imported-message count) had that count silently discarded by its caller; no EchomailPollLog row was ever created for an inbound-initiated session, success or failure. The 0s seen weren't corrupted data — they were the outbound poller honestly reporting that a later dial-out to the same network found nothing new left to pull, since the hub had already pushed everything via the inbound path moments earlier. This is a known, previously-documented gap, not a new regression: docs/CHANGELOG.md's own v1.0b2.47 entry (which added the poll-log transcript feature) explicitly noted "the inbound/server side has no equivalent session model today, noted as a natural follow-up" — this release is that follow-up. Fixed by having _handle_connection() track a real imported_total across every packet it imports during a session (previously computed and immediately thrown away), and write an EchomailPollLog row after each authenticated inbound session that received or sent anything, with accurate messages_received/messages_sent counts, a poll_type of 'receive'/'send'/'both' computed by the new pure _inbound_poll_type() helper, and the real session start/end timestamps. Only written for upstream-hub sessions (net_id set) — a downstream node polling this install as its hub has no EchomailNetwork row to log against, matching the model's existing network_id NOT NULL constraint. Wrapped in its own try/except so a logging failure can never break the actual mail exchange it's describing.

v1.0b2.83 — Network join review: detail view, sequential BinkP numbering, visible passwords, secure archive (July 2026)

  • FEATURE/FIX: the sysop processed the first real "apply to join this network" application (for ANotherNetwork) and hit three real gaps in /admin/echomail/hub/join/requests. (1) The review screen only allowed Approve/Deny off a truncated list row (join_requests.html's pending table shows BBS Name/Applicant/Email/BinkP/QWK/60-char-truncated-Notes only) — added join_request_detail() (anetbbs/web/hub_admin.py) + a new join_request_detail.html template showing every field NetworkJoinRequest actually stores (bbs_software, bbs_os, telnet_address, website_url, full untruncated notes, ip_address, user_agent, rules_ack, etc. — none of which were rendered anywhere before), with Approve/Deny/Archive actions available directly from the page, linked from both the pending and reviewed rows in the list. (2) BinkP node addresses were always whatever the applicant typed into the public form, used verbatim — approve_join_request() had a uniqueness check but no assignment logic at all. This hub is at 1200:1/1 and the first approved node's address had to be manually edited to 1200:1/2 after the fact. Added two new optional NetworkJoinConfig columns (binkp_zone, binkp_net, configurable in the join-form admin tab) — when both are set, _next_binkp_node_address() computes the next unused {zone}:{net}/N by scanning existing BinkPNode.ftn_address values with the existing anetbbs/echomail/routing.py FTN parser (which safely no-ops on non-numeric addresses, like the real domain-style one — theunderground.network — the first applicant actually submitted, and on addresses in other zone:net pairs), defaulting to node 2 when none exist yet (node 1 reserved for the hub, matching nodelist()'s existing hardcoded convention a few hundred lines down in the same file). Leaving zone/net unset preserves the exact old verbatim-address behavior — this is opt-in, not a forced migration. QWK's qwk_packet_id is completely unchanged, by design: that stays sysop/applicant-chosen, no auto-numbering wanted there. Also fixed the post-approval credentials email, which was still referencing the applicant's originally-requested BinkP address rather than the actually-assigned one — a real bug this same change surfaced, since those two can now differ. (3) Confirmed via a direct live database query that the generated node password was never actually lost — approve_join_request() correctly generates and persists it in three places (BinkPNode.password/QWKNode.password, plus NetworkJoinRequest.generated_binkp_password/generated_qwk_password) — but there was no read path anywhere in the admin UI, so once approved without SMTP configured, the sysop had no way to retrieve it short of a raw SQL query. Added a plain <code> display to both binkp_node_detail.html and qwk_node_detail.html (both already login+admin-gated, no extra masking needed) and inline next to the "view node" links in the review list's reviewed table. Also added a deliberate, sysop-triggered "Archive Application to Disk" action (archive_join_request()) that snapshots the full application to a timestamped, pretty-printed JSON file under data/network_join_archive/ — confirmed this directory has no web route pointing at it anywhere in the app (the only things ever served out of DATA_DIR are narrow, named, single-file routes like the existing infopack download), so it's genuinely unreachable except by direct filesystem/SSH access, matching the "no one else could reach it" requirement. 8 new tests covering node-number computation (empty/existing/cross-zone/non-numeric cases), end-to-end auto-numbered approval, the detail view, the password now showing in the review list, and the archive file landing on disk while staying unreachable via any URL.

v1.0b2.82 — Remove remaining box-drawing borders, refresh main menu content (July 2026)

  • CLEANUP: two prior passes (v1.0b2.12 "Widescreen: 132-col art border removal", v1.0b2.23 "Multi-screen welcome/goodbye/newuser sequences") removed the old full-rectangle ╔═╗║╚═╝ box-drawing border from most stock terminal screens in favor of the borderless block-shaded bar style used by anetbbs/features/ansi_ui.py's banner() helper — but neither pass ever touched the plain 80-column .ans files for anetbbs/screens/newuser.ans, anetbbs/screens/menus/main.ans, menus/chat.ans, and menus/game_center.ans. Those four were still carrying the old box border while their 132-col widescreen siblings and welcome.ans/goodbye.ans had already been fixed. Rebuilt all four with a small script (\xdc/\xdf block-shaded header/footer bars, \xb0 shade fill behind the centered title, \xc4 single-line separator — no \xba side walls) to exactly match the already-correct convention, generating the raw CP437 bytes directly rather than hand-editing (these files are CP437-encoded binary, not UTF-8 text — a text editor would corrupt the block/line-drawing glyphs). newuser.ans intentionally ships with no @PAUSE@ marker, matching newuser132.ans's existing (and, on inspection, deliberate) behavior: registration continues straight into security-question prompts with no separate pause screen. Confirmed via a full scan of every stock .ans file that zero box-drawing bytes (\xc9\xcd\xbb\xba\xc8\xbc) remain anywhere.
  • FIX: while rebuilding menus/main.ans, found it (and main.asc) had also drifted out of sync with the actual menu system over several past releases — missing [J] Send InterBBS IM, [K] Ebook Reader, [A] Page Sysop, [D] Dial Out, and [W] Change Password, none of which had ever been back-ported into these static hand-crafted art files after their corresponding features shipped. main132.ans was missing [K] too. All three main-menu variants (main.ans, main132.ans, main.asc) now render the complete, current 22-item hotkey list — including the new [L] Ask Anet from this release — via a small Python column-layout generator (2 columns for 80-col, 5 for 132-col, plain aligned columns for the .asc fallback) instead of hand-typed spacing, so column alignment is guaranteed consistent and future hotkey additions are a one-line list edit instead of manual retyping.

v1.0b2.81 — Ask Anet: fix CP437 mojibake + raw markdown leaking into result snippets (July 2026)

  • FIX: found live on the Pi3 terminal (the web UI was unaffected) — search result snippets in anetbbs/guru/search.py showed garbled "?" characters and raw markdown syntax instead of clean text, e.g. # TIC Processor and ***«BinkP» instead of TIC Processor and **BinkP**. Two compounding causes, both confirmed against a real screenshot of the broken terminal output: (1) the snippet() SQL call's highlight/ellipsis markers were Unicode guillemets («/») and an ellipsis () — these render fine in a browser (UTF-8) but as mojibake on a real terminal session, which this codebase runs in CP437, not UTF-8; (2) snippet() extracts text straight from the raw markdown source stored in the FTS5 index (kept as markdown deliberately, since stripping it before indexing wasn't needed for word-level matching), so literal # heading marks and **bold** markup leaked directly into the displayed snippet, unprocessed — visible for any page whose matched snippet window happened to start at or cross a heading/bold-emphasis boundary. Fixed by switching the SQL-level markers to ASCII-only (>>/<</..., chosen specifically so anetbbs/guru/render_plain.py's markdown-stripping regexes can't mistake them for real markdown and strip them too) and running every returned snippet through that same markdown-to-plain-text stripper already used for the terminal's full-page reader, rather than displaying FTS5's raw extract untouched. Also improved anetbbs/features/bbs_ui.py:show_guru()'s results list to truncate snippets on a word boundary instead of an arbitrary character cut (which was contributing to the "ragged" look reported alongside the character corruption), and made its columns responsive to terminal width via ui_width() (was a fixed 40/30-column split) so results use the extra room on a 132-col widescreen session instead of looking sparse, matching how the RSS reader and other doors already size their columns.

v1.0b2.80 — Ask Anet: fix silently-broken search on upgrading installs (July 2026)

  • FIX: found live on the Pi3 test server immediately after v1.0b2.79's first deploy — every search in the new Ask Anet guru door returned "nothing matched", even for exact words like "netmail" that are definitely present in the wiki. Root cause: wiki_pages_fts (anetbbs/guru/fts.py) is an external content FTS5 table — it stores no copy of title/body, only the inverted search index, reading the real text straight from wiki_pages on demand. count(*) against an external-content FTS5 table is a passthrough to the content table's own row count, regardless of whether the actual search index has ever been populated — a genuine SQLite FTS5 quirk, confirmed by direct reproduction (deterministic, no concurrency needed) rather than assumed. ensure_fts_index()'s original "does this need a one-time backfill rebuild" check compared that always-equal count against wiki_pages's count, so the real INSERT INTO wiki_pages_fts(wiki_pages_fts) VALUES('rebuild') command never actually ran on any install where wiki_pages already had rows before this feature ever shipped — which is every real ANetBBS install, Pi3 and live both, since the wiki has been seeded for many releases already. Direct non-MATCH reads of the table (SELECT body FROM wiki_pages_fts WHERE rowid=...) looked completely normal throughout — same passthrough behavior — which is exactly what made this look like a populated, working index right up until an actual MATCH query was tried. Fixed the detection to check sqlite_master for whether wiki_pages_fts existed before this particular call, instead of comparing row counts — that's only ever true on the one process, one time, that actually creates the table, immune to the passthrough-count trap. Also moved the whole DDL-creation-plus-rebuild sequence off SQLAlchemy's engine.begin() (a deferred transaction for sqlite that doesn't take a write lock until the first write statement) onto the raw sqlite3 driver under an explicit BEGIN IMMEDIATE, so the several separate OS processes that each independently call this at their own startup (gunicorn web workers, the telnet/SSH terminal service, binkp, etc.) can't race on the one-time rebuild — verified with a dedicated 6-process concurrency stress test in addition to the direct single-process bug reproduction that pinned down the actual root cause. 1 new regression test (simulates the exact "wiki_pages already had rows before the FTS5 table/triggers existed" upgrade scenario).

v1.0b2.79 — "Ask Anet" help guru door, terminal + web (July 2026)

  • FEATURE: a retro-styled in-BBS help assistant, a callback to the old "Lisa AI" chat door of classic BBS culture, named "Anet" for this BBS. Ask a plain-language question — "where can I view netmail", "where do I see my notifications", "how do I chat" — and it searches the wiki's help pages for a match, surfacing the right page. Available in both the terminal (new L hotkey on the main menu, "Ask Anet (Help Guru)", anetbbs/features/bbs_ui.py:show_guru()) and the web UI (/guru/, anetbbs/web/guru.py). Deliberately not an LLM or any AI model — must run unmodified on a Raspberry Pi 3, which has no spare compute for one. Retrieval-only: SQLite FTS5 (bundled with Python, zero new dependencies) searches the existing 45-page wiki, ranked by bm25, with a small hand-maintained alias dict (anetbbs/guru/aliases.py) so different phrasings ("netmail" / "PM" / "direct message") land on the same results. The personality wrapper (anetbbs/guru/personality.py) is entirely fixed template strings — no generation. A persistent, always-visible disclosure — shown before the first prompt in the terminal door and permanently at the top of the web page, never a dismissible one-time toast — explains in plain, non-jargony language that this is a smart search tool with a friendly wrapper, not a live AI chatbot; this was an explicit requirement so non-technical users aren't misled about what's actually running. The FTS5 index (anetbbs/guru/fts.py, external-content virtual table over wiki_pages) is created and kept in sync via SQL triggers scoped to title/body changes only (not view_count, which changes on every page view) inside the existing idempotent _lightweight_migrate() schema path — self-heals on fresh installs (populates naturally as wiki pages are seeded) and on upgrades of already-populated servers (one-time FTS5 rebuild command if the index doesn't match the row count). The L hotkey backfills onto existing installs automatically via the existing seed_default_menus() mechanism — no separate migration needed. 15 new tests across search/menu-wiring/web layers.

v1.0b2.78 — Terminal uploads into disk-backed file areas never showed on the web (July 2026)

  • FIX: a file uploaded via the terminal's ZMODEM upload (anetbbs/features/bbs_ui.py:_upload_terminal_file()) into a file area with a configured FileArea.storage_path showed up correctly in the terminal's own file listing but never appeared on the web UI's file-area page for the same area — confirmed live immediately after the v1.0b2.77 ZMODEM handshake fix let an upload actually succeed. Root cause: the terminal upload always saved into a single generic uploads_dir (creating a FileUpload DB row), regardless of which area was selected — the area's own storage_path was computed by the caller but never passed down or used. Meanwhile anetbbs/web/file_areas.py's _scan_area() — which both the terminal's own primary listing branch (when a storage_path exists) and the web view use — only ever scans area.storage_path on disk and has no FileUpload DB fallback at all. The file existed; it just wasn't where either of them ever looked. Fixed by branching on whether a storage_path was supplied: if so, save directly under it with the real filename and create no DB row (matching file_areas.py's own web upload route, and every other file already in that area); otherwise, the original uuid-named-file-plus-FileUpload-row behavior is preserved unchanged for areas with no disk storage at all (the "General / Top-level" case). 2 new tests — and along the way, found and worked around a real IsolatedAsyncioTestCase hang (near-zero CPU, no progress) triggered specifically by creating a Flask app inside one of its tests; a bare asyncio.run() of the identical coroutine in the identical process completes in under a second, so the tests use that instead, matching how tests/test_terminal_node_monitor.py already avoids this same class of problem.

v1.0b2.77 — Fix ZMODEM upload handshake failure with SyncTERM (July 2026)

  • FIX: every terminal file upload via ZMODEM over SSH from SyncTERM failed identically — the client logged UNEXPECTED ZRPOS received instead of ZRINIT after repeated ZRQINIT retries, then cancelled. 100% reproducible (confirmed live), which pointed at a protocol/flag mismatch rather than a timing race. This codebase already had a proven fix for the mirror-image bug on the send side (anetbbs/features/xfer.py): sz --escape causes sz to send ZSINIT to negotiate extended escaping, and SyncTERM replies with ZRINIT instead of the expected ZACK, breaking the handshake — so --escape was deliberately omitted from ZMODEM's outbound flags. The receive side (rz --escape, which sets the ESCCTL bit in our ZRINIT to request the sender escape control characters) turned out to trigger the same class of SyncTERM handshake failure in the opposite direction. Removed --escape from ZMODEM's recv_flags to mirror the already-proven send-side fix. XMODEM has no escape flag (not part of the ZMODEM family, no ZSINIT/ZRINIT negotiation); YMODEM's --escape was left untouched — no live report of a YMODEM failure, and changing it wouldn't be justified by evidence. 3 new tests.

v1.0b2.76 — Diagnostics missed the inbound-listener path entirely (July 2026)

  • DIAGNOSTIC: even the v1.0b2.75 manifest never appeared on the live server after a fresh, confirmed post-deploy %RESCAN — despite real messages continuing to land in the database. Root cause: anetbbs/echomail/binkp_server.py (the separate anetbbs-binkp systemd service, which handles inbound connections when a hub calls us rather than the other way around) has its own completely independent _receive_files()_import_pkt_payload()_parse_ftn_packet() chain that never routes through binkp.py's _import_completed() at all. Every diagnostic added in v1.0b2.71-75 was blind to any traffic arriving this way. (The v1.0b2.70/73/74 parsing fixes themselves do apply here too, since both files call the same shared _parse_ftn_packet() — only the diagnostic capture was missing.) Added matching _debug_manifest()/_debug_dump_packet() calls to this file's inbound loop, prefixed SRV:/_srv_ in the shared manifest/dump directory so captures from both paths are distinguishable. Same opt-in, zero-cost-unless-BINKP_DEBUG_DUMP_DIR-is-set gating throughout.

v1.0b2.75 — Manifest diagnostic: log-based capture confirmed unreliable in production (July 2026)

  • DIAGNOSTIC: v1.0b2.74 fixed the "Men In Black" desync trigger, confirmed against the real captured corpus (0 suspicious out of 3,751 messages) — but a different article ("SN_ALIEN" area) still corrupts on every rescan, and BINKP_DEBUG_DUMP_DIR only captured one small, unrelated packet from that delivery instead of the actual bulk content. While investigating, found that production's LOG_LEVEL silently swallows every logging-module message from this file — zero "BinkP" lines anywhere in gunicorn-error.log despite real traffic flowing through it the whole time, meaning the existing logger.info/logger.warning calls (including the debug dump's own confirmation message) can't be trusted as a diagnostic signal at all in this environment. Added _debug_manifest(): an unconditional, plain-file-I/O log (bypassing logging entirely) that records every file handed to _import_completed() — filename, size, and detected type (fts/zip/other) — regardless of whether it matches a recognized format. Same opt-in, zero-cost-unless-BINKP_DEBUG_DUMP_DIR-is-set gating as the existing dump. Root-cause fix for the SN_ALIEN trigger to follow once the manifest shows what's actually arriving.

v1.0b2.74 — Chain-validate packet boundaries; fix a second desync vector found while testing (July 2026)

  • FIX: v1.0b2.73's marker+date check still wasn't enough. A second BINKP_DEBUG_DUMP_DIR capture showed a real message body containing a byte sequence that wasn't just MSG_TYPE_2 by coincidence, but a fully well-formed date+time+null field too — almost certainly quoted/reposted old FidoNet message content embedded verbatim within the article's own prose (a BBS-history piece), not random noise. That candidate's own immediate to/from/subject fields were clean, so a single-level check accepted it; the corruption (a raw \r in a header field) only became visible one level further into what turned out to be a fake nested chain. Fix: anetbbs/echomail/binkp.py:_chain_looks_like_real_messages() now tentatively parses up to 3 levels deep before accepting a candidate boundary, rejecting it if any header field along the chain contains a raw control character — something no genuine to/from/subject ever does.
  • FIX (found while building the above): once a candidate is correctly rejected, its own bytes get treated as unparsed body content — and its routing header's attr/cost fields (2 bytes each, both commonly 0 in real traffic) can produce an adjacent zero-byte pair that the old code trusted outright as the packet's own end-of-data marker, silently truncating the rest of the packet. The real FTS-0001 terminator is always the literal last bytes of the buffer; _is_real_packet_end() now requires the candidate to actually be there instead of matching anywhere. 2 new regression tests, both reproducing the exact structures found live.