Commit graph

117 commits

Author SHA1 Message Date
mwiegand
712ccc9861
docs(modals): plan errata — 3 verbatim-code defects + 3 inserted tasks
The URL-addressable modals plan shipped in 14 commits. Three places
where the plan's verbatim code didn't survive contact with the codebase
(has_request_context guard, LEFT4ME_ROOT-aware fixture, save-handler
direct-bind) are now documented at the top of the plan, with commit
references for the fixes. Also notes the inserted tasks 8.5/8.5b/9b
and the Task 6 design refinement (close-event single state sink) so a
future re-executor sees the actual shipped pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:03:10 +02:00
mwiegand
d05d00449f
docs(modals): implementation plan for URL-addressable modals pilot
10-task TDD plan: context processor + partial → editor template →
GET /files/edit route → modal slot + script stub → modal-router.js
(click+fetch+show → close+popstate+dismiss → bootstrap) → CM6 re-init
→ files-overlay.js wiring → remove inline dialog + Chromium matrix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:09:59 +02:00
mwiegand
fcab4b0b72
docs(modals): URL-addressable modals design (pilot: file editor)
Spec for the swift3-style ?modal=<path> pattern: same route renders full page
or layoutless fragment based on an HX-Modal header, ~50-line JS module owns
URL+history, HTMX owns fetch+swap, native <dialog> owns show/hide. Pilot
migrates the file editor's open/render flow only — save flow stays AJAX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:03:46 +02:00
mwiegand
ebf6d2ebc6
plan(textarea-editor-v2): bite-sized TDD implementation plan
15 tasks covering: editor-src scaffold, vocab generator, srccfg
StreamLanguage mode, light/dark themes, autocomplete source, editor-entry
façade, esbuild build script + first bundle, tokens.css + editor.css,
editor.js glue (mount + submit-capture + __filesEditor alias),
_editor_assets.html partial, form-contract pytest pre-wiring gate,
template wiring with GET-asserts-markup TDD, files-overlay.js bridge
swap, Playwright e2e (autocomplete-accept + copy regression), docs +
final smoke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:41:26 +02:00
mwiegand
43d4104cef
fix(spec): use legacy-modes/shell for bash language
@codemirror/lang-bash is not an official package. cm6's official path
to bash highlighting is @codemirror/legacy-modes/mode/shell wrapped in
StreamLanguage.define(), matching the same mechanism we use for the
custom srccfg mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:36:50 +02:00
mwiegand
778f98dedf
spec(textarea-editor-v2): commit CodeMirror 6 design
Approved 2026-05-17. Supersedes 2026-05-16-textarea-code-editor-design.md.

Architecture: CodeMirror 6 (bundled via esbuild, committed to
static/vendor/). Form-bridge: submit-time copy (cm6 owns the doc;
capture-phase submit handler writes textarea.value once; JSON-save
path calls controller.getValue()). srccfg grammar via StreamLanguage;
bash via @codemirror/lang-bash; autocomplete via @codemirror/autocomplete.
Vocab generated from the existing repo-root ./cvar_list (2196 entries).
Theme follows the site's prefers-color-scheme model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:35:27 +02:00
mwiegand
db5b2810a9
spec(textarea-editor): handoff after contenteditable rollback
Briefs the next brainstorming session with: what we built, the four
contenteditable failure modes that made it unshippable, what's still
in the repo (Playwright harness, dev-server, original spec/plan as
historical reference), the decision pending (CodeMirror 6 vs
textarea-overlay), inputs to load, and an explicit "don't restart
this cycle" caveat against trying a third contenteditable variant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:57:33 +02:00
mwiegand
10cf0da3d2
fix(editor): capture-phase keydown + popup leak + cache warmup
Addresses Critical #1 + Important #2/#3/#4 from the Task 9 code review.

CRITICAL — Tab/Enter were stolen by CodeJar before the popup handler
saw them. CodeJar registers its keydown listener during construction
(line ~159), so it ran first in bubble order: Tab handler
preventDefaulted and inserted 2 spaces, Enter handler preventDefaulted
+ stopPropagation'd (with leading indent), so the popup-accept either
ran on corrupted state or never fired at all. Fix: register the popup
listener with {capture: true} and call stopPropagation on the keys we
own — that way capture phase fires before CodeJar's bubble listener
and the key is fully consumed by the popup while it's visible. Normal
typing (popup hidden) early-returns without stopPropagation, so
CodeJar's tab-indent + enter-preserve-indent still work when there's
no autocomplete to accept.

IMPORTANT — destroy() leaked the popup <ul> into document.body. Each
mount/destroy cycle (e.g. modal close/reopen) left an orphan popup.
Fix: pop.remove() in destroy().

IMPORTANT — async refreshPopup could race in stale renders if the
first keystroke fired the vocab fetch and the second keystroke
captured a different ctx before the fetch resolved. Fix: warm the
cache with a fire-and-forget loadVocab(language) at mount, so the
first user keystroke hits cache. Eliminates the only realistic window
for the race.

IMPORTANT — acceptCompletion's Range.setStart could throw
IndexSizeError on pathological state (caret inside a tokenized span
where the fragment isn't fully upstream). Fix: try/catch the entire
DOM mutation block, log + dismiss on failure. Plus an inline comment
documenting the single-text-node invariant the current grammars hold.

Plan source updated for the capture-phase fix (most important for
future regeneration); the other fixes are smaller and only mirrored
into the actual code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:49:23 +02:00
mwiegand
4bace3ab5a
plan(textarea-editor): fix stale jar reference in autocomplete
The Task 9 plan template used the captured \`jar\` closure variable in
acceptCompletion, which becomes stale after setLanguage's
tear-down-and-remount. Same class of bug Task 4's review caught and
fixed. Update the plan to match the correct implementation.
2026-05-16 20:42:16 +02:00
mwiegand
c6f10e632d
test(blueprint): also assert prism.css is referenced in editor assets
The plan template (and verbatim implementation) listed five of the six
editor asset URLs in the structural test — vendor/prism.css was
omitted. If a future change drops the Prism stylesheet from the
partial, syntax tokens lose their color rules silently and the test
still passes. Add the missing assertion and update the plan to match.

Addresses Minor #1 from the Task 6 code review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:35:44 +02:00
mwiegand
04f9a4d6a2
fix(editor): narrow findFilenameInput scope + dispatch input from setValue
Addresses two Minor follow-ups from the Task 4 code review:

- findFilenameInput previously included `body` in its closest() selector,
  meaning any "auto" textarea outside a modal would walk all the way up
  and pick up the files-editor modal's filename input from elsewhere in
  the document. Drop `body` so out-of-modal "auto" usage degrades
  cleanly to "plain".
- setValue now dispatches an `input` event on the textarea after
  writing, matching the onUpdate mirror. Task 10 wires the files-editor
  modal to call setValue when loading file content — without this fix,
  textarea-listening code (e.g. unsaved-changes indicators) wouldn't
  see programmatic loads. Now setValue and user typing produce the
  same observable side effects.

Plan source block updated to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:35:47 +02:00
mwiegand
e058b45ff2
plan(textarea-editor): consolidate Task 4 editor.js into one block
The plan had Step 1 (initial widget) + Step 2 (setLanguage patch); the
implementation merges them into one final file. Update the plan to
show the final file verbatim so a future regeneration produces the
same output. Step 2 in the plan is renumbered to 'Manual verification
note' (just the deferred-to-Task-6 sentence) for completeness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:30:22 +02:00
mwiegand
cdcb7e4853
style(editor): visible light-mode popup active state + plan sync
Addresses Important #1 + Minor #2/#3 from the Task 3 re-review:

- --color-bg-popover-active light value: #f3f4f6 → #e5e7eb. The prior
  value was within ~1.05:1 luminance of the white surface — keyboard
  navigation through the autocomplete list had no visible focus
  indicator in light mode. e5e7eb (Tailwind gray-200) clears that.
- Drop dead fallback hexes on the four guaranteed tokens
  (--color-string/-keyword/-number/-bg-popover-active). They never
  fired post-fix and only produced a dark-mode-only palette if
  tokens.css somehow failed to load — i.e. they were misleading.
- Plan source block (Task 3 Step 2) replaced with the post-fix CSS
  verbatim + a new Step 2b that documents the tokens.css additions
  alongside the editor.css template, so a fresh regeneration
  produces the same file.

Deferred: cross-cutting --font-mono token (Minor #4 — would touch 7+
sites outside Task 3's scope).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:27:07 +02:00
mwiegand
f9c8518212
style(editor): theme-aware syntax tokens + match textarea metrics
Addresses Important #1 + #2 from the Task 3 code review:

- Adds --color-string, --color-keyword, --color-number,
  --color-bg-popover-active to tokens.css in both the :root and dark
  blocks. GitHub-style palette tuned for legibility on each theme's
  surface.
- Updates .editor-code to use the same padding tokens, font-family
  stack, font-size, and line-height as the existing textarea rule so
  the contenteditable doesn't visibly jump when the widget mounts.
- Drops the caret-color override (browser default adapts to system
  theme — no token needed).

Plan source block updated to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:22:27 +02:00
mwiegand
db1a255223
fix(editor): drop dead -? from srccfg number regex
The `\b` word boundary anchor prevents the optional minus from ever
matching from positions where signed numbers naturally appear (` -1`,
`(-1`, `=-1` all word-boundary-from-non-word and the `-?` fires zero
chars). Negative numbers are tokenised via the operator class instead,
which is the consistent behaviour the grammar already exhibits.
Plan source block updated to match so a fresh regeneration produces the
same file.

Addresses Minor #1 from the Task 2 code review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:15:34 +02:00
mwiegand
4a1b6d5fac
plan(textarea-editor): make Prism bash-grammar grep minifier-safe
Spec reviewer caught that the literal 'Prism.languages.bash' string
doesn't appear in the minified prism.js (minifier renames Prism→e).
Switch the verification grep to match either the bash shebang token
or any languages.bash assignment; both survive minification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:10:56 +02:00
mwiegand
f5ac61d99b
plan(textarea-editor): fix CodeJar download URL (v4.x at dist/codejar.js)
Task 1 implementation discovered that codejar v4.0.0 ships its
browser bundle at /dist/codejar.js, not the package root. The vendor
README already records the correct URL; this patch keeps the plan
itself accurate for future regeneration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:05:23 +02:00
mwiegand
1ec5e80a73
plan(textarea-editor): use curl-based vendoring (subagent-executable)
The original Task 1 instructed a human to click through prismjs.com's
configurator UI — a step a subagent can't perform. Replace with direct
curl from jsdelivr for both Prism components (core + clike + bash
concatenated) and CodeJar, plus a sed-based ESM-export strip and a
window-global shim. Updates the vendor README template accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:00:48 +02:00
mwiegand
9618109f0f
plan(textarea-editor): 12-task TDD implementation plan
Vendors Prism + CodeJar, builds the editor widget incrementally
(mount/sync → highlighting → autocomplete → files-editor integration),
scaffolds Playwright + writes the e2e editor test. Form-contract Python
tests guard each call-site wiring step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:04:56 +02:00
mwiegand
b19b00e706
spec(textarea-editor): adopt dedicated editor.css, simplify vocab sourcing
CSS lives in a dedicated stylesheet loaded only by the editor-assets partial,
not folded into components.css — keeps the editor's footprint isolated from
the global widget styles. Drop the two-stage vocab sourcing in favor of a
single cvarlist/cmdlist dump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:52:50 +02:00
mwiegand
bef6f0cdd9
spec(textarea-editor): syntax highlighting + autocomplete via CodeJar + Prism
Upgrade blueprint config, overlay script, and files-editor textareas with a
reusable vanilla-JS editor. Textarea stays as value carrier so form POST and
files-overlay.js fetch paths are untouched. Seed cvar vocabulary from the
existing l4d2-server-cvar-reference.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:33:10 +02:00
mwiegand
0c552082dc
spec(tz-aware-datetime): correct speculative l4d2host carve-out
The previous version implied l4d2host has tz patterns to defer. An
inventory grep showed it has no datetime usage at all (no `from datetime`
import anywhere in the tree). Replace the bullet with the verified
finding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:01:25 +02:00
mwiegand
99b528e563
spec(tz-aware-datetime): design for UtcDateTime migration
Validated design for the migration framed by the 2026-05-16 handoff doc.
Two findings shaped this design: (1) DateTime(timezone=True) is a no-op
on SQLite per the round-trip spike, so the fix must live in app code;
(2) every byte on disk is provably UTC (no datetime.now() / utcnow() /
CURRENT_TIMESTAMP / func.now() anywhere), so a result-side tzinfo stamp
is correct, not optimistic.

The chosen approach: a UtcDateTime TypeDecorator that raises on naive
bind and stamps tzinfo=UTC on read. Single PR, two commits (test-first
for clean bisect). No DDL change, no Alembic migration, no on-disk
data transform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:53:22 +02:00
mwiegand
b04bcbce7c
spec(tz-aware-datetime): handoff for the naive-datetime cleanup
Sets up the next session to migrate models.py DateTime columns to
timezone=True and remove the defensive .replace(tzinfo=None) shell.
Surfaces evidence and open questions (SQLAlchemy/SQLite round-trip
behaviour, existing data migration, pw_changed_at marker semantics)
rather than pre-baking an implementation plan that could bury false
premises.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:21:24 +02:00
mwiegand
fdcefcfec6
plan(timeago-shared-display): nine-task TDD migration to a Jinja filter
Lays out the file-by-file migration from the current three time-display
styles to the unified timeago filter from the design spec. TDD ordering
with tests-first, per-task commits, line-numbered locators, and an
explicit verification pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:05:42 +02:00
mwiegand
f3cd981957
spec(timeago-shared-display): one Jinja filter for all user-facing datetimes
Unify three coexisting time-display styles (raw datetime repr, bespoke
inline math, route-side humanize_delta) behind a single timeago Jinja
filter returning a <time> element with relative label and UTC tooltip.
Symmetric past/future ladder with second precision and day-month-year
fallback >7d. Naive-datetime DB-column cleanup tracked as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:59:15 +02:00
mwiegand
2adf42655e
plan(server-log-current-invocation): scope server log to last unit start
Today the Server Log panel shows the last 200 lines of the unit's entire
journal — mixing the current run with leftovers from prior starts. Filter
on systemd's per-(re)start InvocationID so the panel begins at the most
recent start, idles with keepalives when the unit has never run, and
force-disconnects on restart so the SSE client reconnects to the new run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:31:53 +02:00
mwiegand
49992b3a26
refactor(repo): uv workspace + hatchling + layout restructure
Migrate from pip-install-e + setuptools to a uv workspace with a
committed uv.lock for deterministic deps. Switch both members to
hatchling, and move package sources into nested standard layout
(l4d2host/l4d2host/, l4d2web/l4d2web/) so builds work from a
read-only source tree — setuptools wrote egg-info to source under
the old layout, which broke uv sync on the root-owned /opt/left4me/src.

Local dev install: `pip install -e ./l4d2host -e ./l4d2web` -> `uv sync`.
.envrc switches from `layout python python3.13` to `use uv`. Python
pinned to 3.13 via .python-version.

l4d2web now declares its cross-dep on l4d2host explicitly via
[tool.uv.sources] (workspace = true). l4d2web/alembic.ini and
l4d2web/alembic/ stay at the project root (standard alembic layout).

Test fixes:
- tests/__init__.py added to both test dirs so pytest doesn't shadow
  l4d2host as a namespace package via outer-dir walk.
- 3 CWD-relative paths in tests (l4d2web/static/css/{tokens,layout}.css
  and js/sse.js) anchored to Path(__file__) so they survive layout
  changes.
- Two test_install.py tests now monkeypatch HOME to tmp_path so they
  stop silently mutating ~/.steam/sdk32 on every run.

628 tests pass under sandboxed `uv run pytest`.

Per docs/superpowers/plans/2026-05-15-uv-workspace-execution.md;
prereq for the ckn-bw bundle's uv-sync action (queued).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 22:04:29 +02:00
mwiegand
b13d164931
spec(uv-workspace): handoff for the venv-chain → uv workspace migration
Queued for a future agent: collapse the 5-action venv chain in ckn-bw
(create_venv + pip_upgrade + pip_install [the tempdir-copy dance] +
alembic_upgrade + seed_overlays) into 3 actions backed by a uv
workspace at the left4me repo root and a single `uv sync --frozen`
driven by a committed uv.lock.

Handoff is self-contained: spike test for the source-cleanliness
assumption, fallback to Medium scope if that fails, concrete file
edits in both repos, migration order, verification matrix, and risks.
Independent of the just-shipped deployment-responsibility reshape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:16:38 +02:00
mwiegand
450f9f1591
deploy/docs+cleanup: describe symlink model; drop stale scripts/ tracked paths
deploy/README.md: rewrite intro to reflect that deploy/files/ and
deploy/scripts/ are the canonical sources of truth (not examples), with
hardening drop-ins explicitly listed; reference fixtures in
files/usr/local/lib/systemd/system/ noted as such.

spec: add ## Status block marking the deployment-responsibility migration
shipped 2026-05-15.

Cleanup: remove the old scripts/{libexec,sbin,tests}/ paths that were
still tracked after the 2834ad4 move to deploy/scripts/. The content
is already present at deploy/scripts/; these entries were a tracking
artifact from an incomplete git mv.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 19:48:59 +02:00
mwiegand
2834ad4911
deploy: move scripts/{libexec,sbin}/ into deploy/scripts/
Layout consistency: everything ckn-bw deploys to the host now lives
under deploy/. ckn-bw's install_left4me_scripts copy-action goes away
in lockstep with this commit and is replaced by target-side symlinks.

Also updates all path references in docs, tests (conftest.py parents[]
depth, test_overlay_helper.py HELPER_SOURCE), and deploy/README.md.

Part of 2026-05-15-deployment-responsibility-design.md migration step 4.
2026-05-15 19:38:42 +02:00
mwiegand
55d5ab4017
plan(deployment-responsibility): mark Task 3 done 2026-05-15 19:30:35 +02:00
mwiegand
672fd9660b
plan(deployment-responsibility): five-task migration with sysctl canary
Implementation plan for 2026-05-15-deployment-responsibility-design.md.
Bite-sized steps per task; each task ends with both repos committed
and ovh.left4me idempotent. Tasks: (1) sysctl consolidation canary,
(2) hardening drop-ins, (3) sudoers symlink, (4) scripts relocation
+ symlinks, (5) cleanup + docs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:57:45 +02:00
mwiegand
ddf97b3a05
spec(deployment-responsibility): mark handoff resolved by the design doc
Brainstorm happened; design at 2026-05-15-deployment-responsibility-design.md.
Handoff doc stays as the historical framing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:51:12 +02:00
mwiegand
c446f6c8eb
spec(deployment-responsibility): design — symlink hardening drop-ins, sudoers, sysctl, helpers
Conservative reshape coming out of the brainstorm: application-shape
static artifacts move to left4me/deploy/ and are delivered to the
target via bw symlink items pointing into /opt/left4me/src/deploy/...
(safe because the runtime-state relocation made the checkout
root-owned). Per-host shape — base unit bodies, slice CPU pinning,
env templates, nginx/timers/nftables metadata — stays bw-managed in
ckn-bw.

Moves: hardening drop-ins (new), sudoers (dedup mirror), sysctl
drop-in (dedup mirror + absorb ptrace_scope metadata entry),
privileged scripts (relocate scripts/ to deploy/scripts/, replace
install-action with symlinks).

Five-step migration with sysctl consolidation as the canary, then
hardening drop-ins, sudoers, scripts, cleanup. Lands before the
build-overlay-unit refactor so that work can ship its hardening
drop-in inline using this pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:48:13 +02:00
mwiegand
434ee20339
refactor(deploy): venv + steam now under /var/lib/left4me
Sync deployment references for the runtime state relocation
shipped via ckn-bw (commit 6fae2fd). /opt/left4me/ is now a
root-owned deploy-artifact root (just src/); .venv and steamcmd
live at /var/lib/left4me/{.venv,steam}.

Touches:
- deploy/files/.../left4me-web.service: PATH + ExecStart
- deploy/files/.../left4me-workshop-refresh.service: WorkingDirectory
  (was /opt/left4me, now /opt/left4me/src to match the web unit),
  PATH, ExecStart
- scripts/sbin/left4me wrapper: flask path
- deploy/tests/test_example_units.py: PATH + ExecStart assertions
  for the web unit; also fix a pre-existing broken assertion that
  read "Environment=PATH=..." (the unit has Environment=HOME=...
  PATH=... on one line, so "Environment=PATH=" was never present)
  - now reads just "PATH=..."
- deploy/README.md: paths
- l4d2host/tests/test_cli.py: LEFT4ME_STEAMCMD fixture path

Design + as-shipped record:
docs/superpowers/specs/2026-05-15-runtime-state-relocation-design.md.
The original (narrower) prereq spec at
docs/superpowers/specs/2026-05-15-handoff-noneditable-install.md
is marked superseded with a pointer to what shipped + why the
scope grew (setuptools writes egg-info to source during PEP 517
build prep).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:56:32 +02:00
mwiegand
ff2b5c4c5a
spec(noneditable-install): handoff for the install refactor prereq
Self-contained spec for the next agent to land the editable→
non-editable install switch and the root-ownership flip on
/opt/left4me/src. Prereq for the deployment-responsibility brainstorm:
target-side symlinks from /etc/... into the checkout's deploy/files/
only become safe once the checkout is unwritable by the left4me user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:53:19 +02:00
mwiegand
15c620f95c
spec(deployment-responsibility): handoff for brainstorming the deploy split
The hardening refactor + uid-collapse make the "what does left4me own
vs. ckn-bw own" question more pointed. The 2026-05-06 deployment
design already framed this: deploy/files/ in left4me mirrors target
paths, configmgmt integrates. Some artifacts have drifted into the
ckn-bw reactor since (systemd unit emissions, sysctl defaults); the
brainstorming session reconciles.

Sequenced after uid-collapse. Self-contained for a fresh Claude
session to read cold via superpowers:brainstorming.

Session-handoff updated to point at this as the next-next queued work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:56:38 +02:00
mwiegand
8971b23617
refactor(sandbox): collapse l4d2-sandbox user into left4me
The hardening refactor that just landed closes the same-uid attack
surface (FS view, ptrace, /proc visibility, signals) for the web +
gameserver units via systemd directives plus system-wide
kernel.yama.ptrace_scope=2. Keeping the script-sandbox on a separate
uid was the inconsistent half-step — defense-in-depth only, with
build-time-idmap complexity attached. One principle wins: harden
once, share the uid.

scripts/libexec/left4me-script-sandbox: drop the idmap block (uid
lookups, STAGING setup, cleanup_staging trap, mount --bind
--map-users), switch User=/Group= to left4me, point BindPaths at
\$OVERLAY_DIR directly. Header comment updated to reflect
hardening-not-uid as the same-uid defense. nsenter self-wrap kept —
it's about mount-namespace escape, not uid.

Tests + comments + companion docs updated. Build-time-idmap and
overlay-idmap plans marked SUPERSEDED; user-uid-split spec revised
to "1 user is correct"; one-line update notes on the hardening
specs and the build-overlay-unit-design.

Companion ckn-bw commit removes the l4d2-sandbox user + group and
tightens /var/lib/left4me from 0711 → 0755 (the traverse-only mode
was specifically for the sandbox uid).
2026-05-15 15:50:57 +02:00
mwiegand
146cb01450
plan(uid-collapse): drop l4d2-sandbox user; handoff to next session
Approved-but-not-executed plan to collapse the two-user model
(left4me + l4d2-sandbox) into one. The build-time-idmap that
translates sandbox writes back to left4me uid becomes a no-op when
source uid == target uid, so it's removed along with ~30 lines of
helper plumbing. Hardening already covers the same-uid attack
surface the sandbox uid was defending against, so collapsing makes
the architecture consistent with the web/server hardening-only
decision.

Plan: docs/superpowers/plans/2026-05-15-uid-collapse.md
Handoff: docs/superpowers/specs/2026-05-15-session-handoff.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:39:51 +02:00
mwiegand
f5f8db84ef
spec(session-handoff): hardening refactor landed and verified on left4.me
12-task subagent-driven refactor complete. left4me-server@1: 7.5 → 1.3
systemd-analyze. left4me-web: 8.7 → 4.1. All 6 Test 8 attack vectors
blocked post-deploy. One acceptable SECCOMP audit line per gameserver
restart (Breakpad's ptrace fork, blocked by design). Test tooling
(gdb, seccomp, libseccomp-dev) apt-removed from left4.me. uid-split
spec marked superseded.

No queued follow-up. Adjacent work: build-overlay-unit refactor and
the deferred drop-in / configmgmt-responsibility reshape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:17:06 +02:00
mwiegand
f615d0de75
spec(user-uid-split): mark superseded by the hardening refactor
The 1/2/3-user question is answered: stay at 2 (left4me + l4d2-sandbox).
The defenses that motivated a 3-user split (cross-uid ptrace,
cross-server contamination, web-side reach into gameserver state,
DB/env exposure to srcds) are closed by the systemd hardening
composition: PrivateUsers + PrivatePIDs + TemporaryFileSystem +
SystemCallFilter=~@debug + empty CapabilityBoundingSet.

The residual filesystem-ACL surface (mode 0640 root:left4me on DB and
web.env) is noted as a separate concern — covered for the current
deployment shape, revisit if shape changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:59:13 +02:00
mwiegand
37309ba399
spec(hardening-test-plan): fix four bugs surfaced by executor
Four corrections noted by the test plan's executor in commit 461b8d0:

- PID-lookup race: pgrep+head can pick the wrong instance. Replace
  with systemctl show -p MainPID --value left4me-server@N.service.
- gdb-from-host ptrace check: nsenter into only the mount namespace
  with root caps bypasses the SECCOMP filter, so the test is a false
  positive. Replace with systemd-run-with-same-directives probe, or
  syscall-filter inspection.
- D5 pgrep pattern: 'srcds_linux.*\@2' doesn't match because @N is
  in the unit name, not argv. Use systemctl show -p MainPID.
- scmp_sys_resolver is in the seccomp package on Debian 13, not
  libseccomp-dev.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:58:46 +02:00
mwiegand
7c64910c90
spec(hardening-refactor): resolve emitter open items
Verified during plan execution that the ckn-bw systemd-bundle emitter
handles tuples and empty values as expected. SocketBindAllow port
range hard-coded since systemd directive variable substitution is not
universal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:39:11 +02:00
mwiegand
b1293f9952
plan(hardening-refactor): implementation plan against the proven composition
12 tasks across left4me + ckn-bw: emitter verification, three Python
constants in the systemd_units reactor, spread into both managed units,
sysctl drop-in, annotated reference units, four spec bug fixes, mark
uid-split spec superseded, cross-repo push, bw apply + verify on host,
apt-remove test tooling. Each task has bite-sized steps with exact
commands and expected output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:25:25 +02:00
mwiegand
81dc29a9c3
spec(hardening-refactor): revise design — inline-in-reactor, defer drop-in reshape
Going back to the inline-in-reactor shape: hardening directives land in
ckn-bw's systemd_units reactor as shared Python dicts (HARDENING_COMMON
+ HARDENING_SERVER + HARDENING_WEB), spread into each unit's Service
block. Educational reference units in deploy/files/.../*.service stay
and get per-directive comments. Operator discipline hand-syncs the
reference to the reactor; no CI drift test.

The broader responsibility reshape — hardening drop-ins living in
left4me with ckn-bw as a thin file-shipper — is worth pursuing as a
separate dedicated session, not bundled into this refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:16:02 +02:00
mwiegand
3256ed2ab1
spec(hardening-refactor): design — drop-ins owned by left4me, ckn-bw deploys
Hardening composition is application knowledge (which paths to bind, that
srcds is i386, what breaks sudo). It belongs in the left4me repo as
drop-in .conf files under deploy/files/etc/systemd/system/<unit>.d/.
ckn-bw shrinks: keeps the base units in its reactor, removes the
hardening keys, ships the drop-ins to /etc/systemd/system/. Existing
educational reference units in deploy/files/.../*.service are deleted in
favor of the drop-ins, which carry per-directive comments. Broader
configmgmt-responsibility reshape (base units leaving the reactor)
deliberately deferred to a future session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:05:38 +02:00
mwiegand
152c313315
spec(session-handoff): point next session at hardening-refactor plan
The prior handoff pointed this session at running the test plan; that's
done (commit 461b8d0). Update the handoff to point the next session at
writing docs/superpowers/plans/2026-MM-DD-hardening-refactor.md against
the proven composition, including the two amendments (x86 arch,
PrivatePIDs) and the MDW permanent exclusion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:43:37 +02:00
mwiegand
461b8d028f
spec(hardening): test plan executed on left4.me — results recorded
Ran the 11-test plan against left4me-server@1 (canary) and left4me-web
on left4.me / Debian 13 / systemd 257. Cleaned up all unit drop-ins;
kept the Test 9 sysctl (kernel.yama.ptrace_scope=2) per spec.

Outcomes:
- server@1 systemd-analyze: 7.5 EXPOSED → 1.3 OK
- left4me-web systemd-analyze: 8.7 EXPOSED → 4.1 OK
- All 8 attack vectors in Test 8 (D1.a-c, D2.a-c, D3, D5) blocked
- Test 6 (MemoryDenyWriteExecute) fails as predicted — Source engine
  i386 .so files have text relocations; exclude from final composition.
- Test 11 (24-48h soak) skipped per operator decision.

Two amendments to the spec's proposed composition required for the
refactor:
- SystemCallArchitectures=native x86 (not bare 'native') — srcds_linux
  is i386, the kernel kills every native-only call.
- PrivatePIDs=true added — ProtectProc=invisible alone cannot hide
  gunicorn from srcds because both run as uid 980; PrivatePIDs gives
  each instance its own PID namespace and closes D2.b.

Spec bugs surfaced and documented in the "Output" section: PID lookup
via pgrep (race vs. instance), Test 4/10 gdb-from-host doesn't
actually exercise the unit's SECCOMP filter, Test 8 D5 pgrep pattern
won't match. Tooling note corrected: scmp_sys_resolver is in
'seccomp' package, not 'libseccomp-dev'.

Next session: write docs/superpowers/plans/2026-MM-DD-hardening-refactor.md
against the proven composition; supersede the uid-split spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:39:50 +02:00
mwiegand
1df811e62a
spec(hardening): threat model + defenses survey + test plan; pivot handoff
Reframe the queued uid-split decision into a broader hardening analysis.
Audit found the same-uid attack surface (DB readable from srcds, ptrace
allowed, RCON stored plaintext) is closable by either uid split or
systemd directive composition; the three specs ground that choice in a
threat model, survey the defenses, and lay out a self-contained test
plan to run on left4.me next. uid-split spec deferred pending results.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:07:40 +02:00