Holiday's over — revert the Sma Grodorna frog-hop title gag back to the
original /rick.mp4 fullscreen rickroll + shake/spin it replaced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Flip the seasonal master switch (SEASON_ACTIVE=false in useMidsummer) so the
Sma Grodorna theme is fully dormant — no rain/frogs/maypole/banner/palette,
regardless of any stored toggle preference — and remove the 🐸 toggle from the
sidebar. All theme code is kept; to bring it back next Midsummer, flip
SEASON_ACTIVE to true and re-add <FrogToggle /> in SidebarWindowButtons.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The player-count flapping was client clock skew: telemetry is stamped with the
game machine's DateTime.UtcNow (WebSocket.cs), and machines' clocks drift up to
~90s apart (proven: per-char offsets span -31s..+59s with steady 6s cadence; a
wrong server clock would shift all equally, so the SPREAD proves clients differ
from each other; a +59s future timestamp rules out lag). /live windowed on that
client timestamp, so characters whose clock sat near the 30s boundary blinked
in and out.
Fix: stamp each telemetry row with the server's receive-time (received_at) and
window the /live 'online' query on COALESCE(received_at, timestamp) instead of
the client timestamp. A coarse timestamp bound (10 min) is kept only for
TimescaleDB chunk pruning. Column added idempotently in init_db_async; COALESCE
falls back to the client timestamp for pre-migration rows. Verified on the live
DB: query valid, 8ms, equivalent pre-population. ~free CPU (one datetime.now()
per ~14 inserts/sec).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause of the player-count flapping: the plugin's debounced inventory
flush, combined with a fleet-wide relog wave (auto-update) phase-aligning the
60s flush timers, produced a synchronized burst of inventory forwards every
cycle. The burst flooded the single event loop + httpx pool (errors in
_do_handle_inventory_delta even though inventory-service was idle), periodically
starving telemetry ingest (cliff 116→5 rows/10s) so characters aged out of the
30s window and the count flapped.
- Global asyncio.Semaphore(8) around inventory forwarding: a burst can never
monopolize the loop; telemetry always gets through.
- Tighten the shared httpx client (max_connections=10, keepalive=5, 5s timeout)
so a stale/slow connection can't hold a slot.
Pairs with the plugin-side flush-timer jitter (2–5 min, re-rolled per tick) that
de-synchronizes the fleet at the source.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every browser broadcast ran jsonable_encoder (slow recursive encode) and then
re-serialized per client via send_json — so a payload to N browsers was
encoded N+1 times, on the same single event-loop core that the telemetry/
inventory firehose already saturates.
Now serialize ONCE with json.dumps + a datetime-aware default (_json_default
mirrors jsonable_encoder for the types that actually appear: datetime, Enum,
Decimal, set, bytes) and send the prebuilt string to every client via
send_text. Verified the wire output parses identically to the old path.
Pure backend change — no plugin, no frontend, no schema change; stdlib only
so it deploys via restart with no image rebuild / dependency churn.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per request: remove the WebAudio jingle (+ its 🔊 toggle and sound state);
replace the one-shot confetti with a continuous rain of 🌼🌸🐸🇸🇪🌿 over the
screen (MidsummerRain, gated by the theme, reduced-motion aware, leak-free);
and replace player-dot markers with frogs themselves (override the inline
dot color/border) instead of a flower-crown on top. Still toggled by the
🐸 Midsommar switch. Includes rebuilt static bundle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Accepts one legacy secret alongside the real one so existing clients keep
registering while game machines migrate to websocket_secret.txt. Remove
SHARED_SECRET_LEGACY from .env after the rollout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- SHARED_SECRET now read from env and fail-closed: unset/placeholder refuses
ALL plugin connections (constant-time compare). The old hardcoded
'your_shared_secret' in this public repo was no auth at all. Dockerfile
default removed; generate_data.py reads the env var.
- SECRET_KEY fails closed at startup (main.py and agent/auth.py) instead of
falling back to a publicly-known signing key; agent systemd unit now
requires /etc/overlord/agent.env (no '-' prefix).
- AuthMiddleware + /ws/live: replace the 172.x source-IP trust (which every
nginx-proxied internet request satisfied via docker-proxy — full session
bypass and unauthenticated in-game command injection) with
private-source AND no X-Forwarded-For, i.e. only genuinely internal
callers (overlord-agent on the host, compose-network services). Invariant
documented in nginx/overlord.conf: every tracker-bound location must set
X-Forwarded-For.
- /character-stats/test endpoints gated behind admin (they upsert real rows).
- docker-compose: bind 5432/5433 to 127.0.0.1 (both DBs were internet-
reachable; active brute-force observed in dereth-db logs).
- discord-rare-monitor: drop dead SHARED_SECRET constant.
- scripts/backup-databases.sh + docs/backups.md: nightly pg_dump of both DBs
(telemetry/spawn hypertable data excluded), 10MB canary, umask 077,
TimescaleDB restore procedure.
- Remove stray mangled-path css file from repo root.
Adversarially reviewed pre-deploy (3-lens workflow): ship verdict; deploy-
sequencing blockers addressed (secret staged before enforcement, exec bit
set, cron uses bash).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The glsa_ token on the /grafana/ location was committed to a public repo.
Verified dead: Grafana's service-account and api_key tables are empty (the
data dir is ephemeral container storage, so the SA was wiped on a past
recreate) and an arbitrary invalid bearer gets identical 200 responses —
panel embeds are actually served by anonymous Viewer auth
(GF_AUTH_ANONYMOUS_ENABLED=true). The header was a no-op; removing it
changes no behavior and removes the credential from the config.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With width:100%, the table stretched to fill the container — and the
Character column (the only one with stretchable text) absorbed all the
extra space, looking much wider than the longest name needed.
width:auto lets each column size to its content. Table now fits its
data; container still scrolls horizontally if content ever exceeds the
viewport.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two small UX improvements to the Player Dashboard table (works in both
the new-tab fullscreen page and the deprecated in-app window since they
share PlayerDashboardContent):
1. Row highlight: click anywhere on a row to highlight it (blue tint +
thin outline). Click again to unhighlight. Single selection — useful
for tracking one character down a long sorted list.
2. Character column no longer truncates: removed maxWidth/overflow/
textOverflow on the name cell. Column now sizes to the longest
character name (still no wrapping; container scrolls horizontally
if names are extreme).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The 👥 Dashboard button used to open the player table as a draggable
in-app window, which competed for screen space with the map. It now
opens in a separate browser tab as a fullscreen page so users can put
the dashboard on a second monitor.
How:
- App.tsx branches on ?view=dashboard → renders PlayerDashboardFullPage
(new file in components/) instead of the default MapLayout.
- SidebarWindowButtons.tsx: 👥 Dashboard onClick now does
window.open('/?view=dashboard', '_blank', 'noopener'). Label shows
'↗' so users know it's an external open.
- PlayerDashboardWindow.tsx refactored: extracted the sortable table
body into a reusable PlayerDashboardContent component. The old window
shell stays registered in WindowRenderer for backward compat — just
no longer reachable from the default sidebar.
- map-layout.css: new .ml-dashboard-page rules for fullscreen layout.
Each tab gets its own useLiveData + WebSocket connection (server
already handles multiple browser clients). The new tab inherits the
session cookie from the original tab — no re-login.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Docker-bridge / loopback bypass in AuthMiddleware was short-circuiting
the whole auth flow without ever decoding the session cookie. Result: /me
and other endpoints reading request.state.user got 401 for real logged-in
browsers (because nginx → docker-proxy makes them look like 172.x).
Symptom: dashboard admin UI invisible even for admin users — useCurrentUser
saw 401 from /me and treated everyone as anonymous.
Fix: in the bypass branch, still try to decode any session cookie present
and populate request.state.user. The bypass still permits anonymous
internal calls (overlord-agent's MCP tools), but real authenticated browsers
now get their user correctly resolved.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Logout: new sidebar link 'Log out (username)' that POSTs /api/logout
(clears session cookie) and navigates to /login. Visible to everyone.
Replaces 'no logout functionality' state where users could only get
out by deleting cookies manually.
Admin window: new 'Admin · Users' window (only shown when current
user.is_admin) lists all users in a table with:
- Add user (username + password + admin checkbox)
- Reset password inline per row
- Toggle admin per row
- Delete user per row (blocked for self)
Wraps the existing /api-admin/users CRUD endpoints in main.py.
Plumbing: useCurrentUser hook fetches /me on mount; apiPatch+apiDelete
helpers added to api/client.ts; new endpoint wrappers exported from
api/endpoints.ts; AdminUsersWindow.tsx registered in WindowRenderer
under id prefix 'adminusers'; CSS for admin table/form/buttons and
the muted-red logout link.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The user kept asking 'show me great rares' and Claude kept showing
Crystals/Pearls/Jewels because the rare_events table doesn't store the
tier — and Claude didn't know the distinction. Now CLAUDE.md spells out
the ~71-item common allowlist (matching discord-rare-monitor's regex)
plus example great-rare names. Includes a sample SQL query Claude can
adapt for tier filtering.
The query_telemetry_db tool was crashing with AttributeError because
exp.AlterTable doesn't exist in this sqlglot version (renamed to Alter).
Made the deny-class list build defensively via getattr and dropped any
classes that the installed sqlglot doesn't expose.
Also broadened the deny list (Alter, AlterColumn, AlterDatabase, Truncate,
Grant, Revoke, Copy) and made the toplevel allowlist tolerant of missing
classes too. The walk() return shape is also normalized in case sqlglot
versions yield (node, parent, key) tuples vs. bare nodes.
Belt-and-suspenders is fine — the GRANT-SELECT-only PG role is the real
write barrier; the parser is just a faster/friendlier reject path.
Two complementary changes after observing the model probe boundaries
(it tried mcp__claude_ai_Gmail__search_threads, then tried to delegate
to a subagent via the Agent tool, then suggested the user edit
settings.local.json to add Gmail tools):
1. claude_wrapper.py adds to --disallowed-tools:
- Agent (subagent spawning — should never delegate)
- WebFetch (already; settings.json re-allows acpedia.org only)
- Every Gmail/Calendar/Drive connector tool name we know about
2. CLAUDE.md adds a 'Non-negotiable scope rules' section:
- Be a read-only game-state QA service, nothing else
- Don't attempt tools outside your role
- Don't explain how to bypass restrictions
- Don't suggest settings.json edits
- Don't enumerate hidden tools when asked
Soft (system-prompt) + hard (CLI flag) defenses combined.
The previous commit put .claude/settings.json IN THE REPO, which would
have applied its strict deny rules to ANY Claude Code invocation from
this cwd — including the human user's interactive dev sessions on their
own machine. That's wrong; the production agent's lockdown should not
constrain the developer.
Remove the committed file and gitignore .claude/ entirely. The repo is
permission-neutral now.
Strict permissions for the production agent come from two server-only
sources:
1. CLI flags in agent/claude_wrapper.py (--allowed-tools +
--disallowed-tools, passed by the systemd-spawned subprocess only)
2. /var/lib/overlord-agent/.claude/settings.json (the agent's own HOME
— separate from any user's .claude/)
Also bumps claude_wrapper.py with the explicit --disallowed-tools list
of meta-tools (ToolSearch, Monitor, TodoWrite, TaskOutput, Skill, cron
tools, etc.) that the --allowed-tools whitelist does not block on its
own. Verified empirically: with only --allowed-tools, ToolSearch was
still callable; --disallowed-tools is required.
The agent service was running as User=erik, which meant:
- Sessions polluted erik's ~/.claude/projects/
- erik's .claude/settings.local.json (months of accumulated dev permissions
for docker/git/dotnet/etc.) was loaded by the production agent, defeating
the --allowed-tools whitelist
- Subscription rate quota mingled between human-erik's interactive Claude
Code use and the production assistant
- Theoretical access to /home/erik/.ssh, .bash_history, .gitconfig
Now:
- User=overlord-agent (system account, no shell, /var/lib/overlord-agent home)
- HOME=/var/lib/overlord-agent — claude state fully isolated from erik
- /home/erik/.claude permissions tightened to 0700 (was 0755)
- group=overlord-agent on the repo + /etc/overlord/agent.env (read-only)
Project settings:
- New strict committed .claude/settings.json: deny Bash/Read/Write/Edit/
Glob/Grep/NotebookEdit/WebSearch; allow only WebFetch(domain:acpedia.org)
- .claude/settings.local.json now gitignored (was leaking dev permissions
to the server through the deploy)
The extra ~@cpu-emulation ~@obsolete ~@swap ~@raw-io negations on top of
@system-service killed Claude Code (Node) with SIGSYS during startup.
Keep just the truly dangerous groups blocked: ~@privileged ~@reboot
~@mount. The base @system-service preset already excludes others (no
@debug, no @resources, etc. are included by default in that preset).
Claude Code is a Node app. V8 JIT requires W^X transitions via mprotect
with PROT_EXEC on JIT'd code pages. MemoryDenyWriteExecute kills the
process with SIGTRAP/abort during startup (~10ms in).
Without JIT we'd have to use --jitless mode, which destroys performance.
The other systemd hardening (ProtectSystem, ProtectHome,
InaccessiblePaths, NoNewPrivileges, capability drop, syscall filter,
PrivateTmp, etc.) still gives strong filesystem and privilege isolation.
The remaining shellcode-injection risk is theoretical — there is no
Bash/Write/Edit tool exposed for an attacker to chain into.
Also: MemoryLimit -> MemoryMax (deprecated unit form).
systemd unit now applies defense-in-depth:
- ProtectSystem=strict + ProtectHome=read-only (rest of FS sealed)
- ReadWritePaths only for ~/.claude (session JSONLs) and venv + audit log
- InaccessiblePaths blocks /etc/shadow, /etc/ssh, /root, ~/.ssh, shell history
- NoNewPrivileges + dropped capabilities (no setuid escalation, no caps)
- PrivateTmp, PrivateDevices, ProtectKernel*, MemoryDenyWriteExecute
- SystemCallFilter @system-service ~@privileged ~@debug ~@mount etc.
- RestrictAddressFamilies blocks raw/packet sockets
Application layer:
- Per-user rate limit 60/hour (configurable via AGENT_RATE_MAX)
- Per-user concurrency cap of 1 in-flight (no parallel claude burns)
- JSONL audit log of every /agent/ask to /var/log/overlord-agent/audit.jsonl
Logs username, message preview, result preview, timing, errors.
Plus secrets migration: EnvironmentFile now prefers /etc/overlord/agent.env
(root:erik 0640) over /home/erik/MosswartOverlord/.env, so even the
read-only /home doesn't expose them. Falls back to old path during
transition.
Adds an MCP tool wrapping the inventory-service /search/items endpoint
with include_all_characters=true, so questions like 'find me a bracelet
with Legendary Acid Ward on any unequipped char' resolve in ONE tool call
instead of looping get_inventory over 60+ chars (which timed out at 120s).
- agent/tools.py: search_items_global wrapper
- agent/mcp_overlord.py: register new tool with detailed schema doc
- agent/claude_wrapper.py: include in --allowed-tools whitelist;
bump timeout 120s -> 240s
- nginx/overlord.conf: bump /api/agent/ proxy timeout 180s -> 300s
- CLAUDE.md: brief Claude to USE search_items for cross-char searches
bypassPermissions ignores --allowed-tools entirely (per
permission-modes.md docs). With it, the model could call Bash, Write,
Edit, Read, etc. — confirmed by writing /tmp/owned.sh in a test.
dontAsk is the correct production headless mode: auto-DENIES anything
outside the --allowed-tools whitelist instead of prompting. Without
this, our entire MCP whitelist was effectively useless.
Claude Code rejects --session-id on a session that already exists on disk
('Session ID ... is already in use'). The first message of a conversation
must use --session-id to create; every message after must use --resume.
Detect by checking ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl. Plus a
belt-and-suspenders retry: if --session-id surprisingly fails with the
'already in use' string, automatically retry with --resume.
This was the bug that caused chat windows to fail on the second message.
Same pattern we already use for /ws/live (host-side Discord bot bypass).
Lets the new overlord-agent service call any tracker HTTP endpoint
without forging a session cookie. Safe because port 8765 is bound to
127.0.0.1 in docker-compose.yml — only the host or other compose-network
containers can reach it.
Adds an in-dashboard AI assistant that answers questions about live game
state. Designed reactively (no background loops) — every user message in
the chat window or via /api/agent/ask runs one `claude -p` invocation.
Architecture:
- New host-side FastAPI service (agent/) on 127.0.0.1:8767, OUTSIDE the
dereth-tracker Docker container because `claude` and ~/.claude
credentials live on the host.
- nginx routes /api/agent/* to the host service.
- The same browser session cookie the tracker issues authenticates
agent requests (shared SECRET_KEY).
- The agent shells out to `claude -p --session-id <uuid>` with
cwd=/home/erik/MosswartOverlord. Sessions persist as JSONL on disk
via Claude Code's built-in machinery.
- An MCP stdio server (agent/mcp_overlord.py) exposes tools to Claude:
get_live_players, get_recent_rares, query_telemetry_db (read-only,
parsed by sqlglot to reject DML/DDL), get_player_state, get_inventory,
get_inventory_search, get_combat_stats, get_equipment_cantrips,
get_quest_status, get_server_health, suitbuilder_search.
- Read-only PG role (overlord_agent_ro) is the second line of defense
on the SQL tool — even a parser bypass can't mutate.
Frontend:
- AgentWindow.tsx — draggable chat window with localStorage-pinned
session UUID, "New Chat" button, on-mount rehydration from
/agent/sessions/{id}/history (parses Claude Code's JSONL).
- Wired into WindowRenderer + Sidebar (🤖 Assistant button).
Operational:
- systemd unit (overlord-agent.service) + install.sh.
- agent/README.md documents env vars, deploy flow, smoke tests.
- nginx/overlord.conf gets a new /api/agent/ location with 180s timeout.
- CLAUDE.md gets an "Overlord Assistant Mode" section briefing the
agent on which tools to use and how to behave.
NOT YET DEPLOYED — server still needs:
1. Apply agent/sql/0001_overlord_agent_ro.sql + ALTER ROLE password
2. Add AGENT_DB_DSN to /home/erik/MosswartOverlord/.env
3. bash agent/install.sh (creates venv, installs unit, starts service)
4. sudo cp /home/erik/MosswartOverlord/nginx/overlord.conf to nginx + reload
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous commit moved inventory_delta handling to fire-and-forget
asyncio tasks. That removed the WS-loop blockage but introduced a race:
when the same character generated multiple deltas in quick succession
(mana burn, ID refresh, loot bursts), the tasks ran concurrently and
inventory-service's DELETE-then-INSERT path raced on the items table:
asyncpg.exceptions.ForeignKeyViolationError:
update or delete on table 'items' violates foreign key constraint
'item_combat_stats_item_id_fkey'
The 500 errors caused inventory_delta updates to be dropped silently
(likely the source of the 'items in wrong container' bug the user
reported earlier — every delta returning 500 means the DB never updates).
Fix: per-character asyncio.Lock — deltas for the same character serialize,
deltas for different characters still run in parallel. Restores correctness
without losing the non-blocking-WS-loop benefit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two issues causing plugin WS disconnects on heavy-loot characters:
1. inventory_delta processing was awaiting an httpx POST to inventory-
service inline within the WS receive loop. Each delta also created a
fresh httpx.AsyncClient (no connection pool reuse). When inventory-
service was slow under load, the receive loop blocked, keepalives
stopped flowing, and the connection eventually dropped (especially
for characters spamming deltas: Elliot was reconnecting ~every 4 min).
Fix: process each delta as an asyncio.create_task() — the WS receive
loop returns immediately to read the next message. Use a shared
httpx.AsyncClient with connection pooling.
2. websocket.receive_text() raises RuntimeError ("Need to call accept
first") instead of WebSocketDisconnect in some race conditions when
the connection closes mid-await. The receive loop only caught
WebSocketDisconnect, so RuntimeError propagated up as an exception
traceback in logs.
Fix: catch RuntimeError and log as a clean disconnect.
Also: log close code/reason on WebSocketDisconnect so we can tell apart
clean closes (1000/1001) from network drops (1006) etc.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The live host nginx config (/etc/nginx/sites-enabled/overlord) was not
tracked in git, leading to drift. This commit checks in a source-of-truth
copy under nginx/overlord.conf with a deploy procedure documented at the
top of the file.
Includes the proxy_read_timeout/proxy_send_timeout 1d settings for both
WebSocket location blocks (/websocket/ and /). Without these, nginx's
default 60s timeout drops idle plugin connections in a reconnect loop —
the symptom users saw was "WebSocket error … State: Aborted" every
~60s on idle characters.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The floating version badge scrolled awkwardly and wasn't necessary
now that the bind-mount/deploy issue is fixed. The existing ml-version
inside the Sidebar is sufficient.
Also removed the temporary [INV_DEBUG] console logs from useLiveData
and InventoryWindow — the inventory live-update bug is confirmed fixed.
Kept the per-character inventoryVersions fix and the cache-buster on
the refetch URL.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous commits built into static/_build/ but forgot to run the
deploy script that copies the output to static/index.html and
static/assets/. The web server serves from static/, so none of the
previous frontend changes (per-character inventory version, debug logs,
version badge) were actually reaching the browser.
This commit runs deploy-frontend.sh which copies _build/ → static/,
replacing the stale index-BHGeM5hq bundle with the current one.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>