# Review: `router-admin-portal` implementation
Status: done -- review of shipped work
**What it was reviewing:** the shipped admin portal — `admin.py`,
`admin_schema.sql`, `admin/frontend/index.html`, the `dispatcher.py` mount
point, and 9 new test files — against the two documents that set its bar:
`plans/admin-portal-gap-analysis.md` (15 pre-implementation findings)
and `plans/router-admin-portal-plan-review.md` (the plan review's
4-item "don't approve as-is" list). All four of that list's items are
checked below against the actual code, not the plan's description of it.
## Verdict: the plan review's 4 required items are all genuinely fixed; one new stored-XSS gap in the dashboard itself was missed by both automated tests and the reported manual QA pass
### The 4 required fixes — all confirmed in the code, not just claimed
1. **`admin_model_overrides` reaches `routing.select_candidates`** — yes.
`dispatcher.py:1857-1877` (`_admin_deprecated_models`) reads the table and
is unioned into `exclude_models` at `dispatcher.py:774`
(`exclude_models=_open_circuits(rows, cfg) | admin_deprecated`), which
feeds `select_candidates` at `:783`. This sits inside `route()` (`:736`),
the single chokepoint every entry point calls (`route_endpoint:1637`,
`dispatch_endpoint:2873`, and all three `chat_completions` call sites at
`:2304/:2325/:2345`), so an override reaches every dispatch path, not just
`/route`. `tests/test_admin_routing_override.py` is a real integration
test, not a unit test of the API alone: it seeds two models, confirms the
better one wins, marks it deprecated through the admin endpoint, and
asserts `/route` now picks the other one — exactly the test the plan
review asked for.
2. **Auth/CSRF given an explicit decision, and the restart response-before-death mechanism specified** — half done. The restart mechanism is
fully addressed: `admin_restart_service` (`admin.py:684-693`) schedules
`_restart_service` via `BackgroundTasks` and returns `{"status":
"restarting"}` immediately, so the response flushes before `systemctl`
sends the process a signal — exactly what was asked for. But the
auth/CSRF half is not: README.md's new section says only "It has no auth
layer yet, so like the other endpoints it is reachable only from
127.0.0.1" — the same read-only-surface answer the plan review said was
being carried forward unexamined, restated rather than re-examined. There
is no CSRF token, no `Origin`/`Referer` check, and no code comment
anywhere in `admin.py` weighing that decision for a surface that now
includes `POST /api/restart-service`, `POST /api/config/{key}`, and the
three subprocess triggers. See the new finding below — it turns out this
gap is worse than CSRF alone, because the dashboard has a stored-XSS hole
that defeats even an Origin check.
3. **History is on-demand `GROUP BY`, not a sampled second database** — yes.
`_history_series` (`admin.py:85-112`) is exactly a bucketed `GROUP BY`
over `route_decisions` / `energy_observations`, no background sampling
loop, no second SQLite file, no lifecycle question. Matches the review's
recommendation exactly.
4. **`ruamel.yaml` pinned in `requirements.txt`** — yes, `ruamel.yaml==0.18.10`
with a comment explaining why pyyaml can't do the job
(`requirements.txt` diff). `admin_config_write` (`admin.py:745-778`)
round-trips through `YAML()`, validates the whole resulting config via
`RouterConfig(**store)` before writing, and takes a timestamped backup
first — comment preservation confirmed by `load_config_store` using
`YAML()` with default (round-trip) mode rather than `safe_load`.
Also confirmed from the gap analysis's other items, cheaply: no
`admin.py → dispatcher` import (`admin.py` imports only `metrics` and
`config`, matching its own docstring's contract at the top of the file);
subprocess triggers use `sys.executable` and
`asyncio.create_subprocess_exec` under a wall-clock deadline
(`admin.py:604-656`), not a blocking `subprocess.run` on the event loop; and
`pinch.relevance.enabled` is exposed as its own knob distinct from
`pinch.enabled` (`_BOOL_KNOBS`, `admin.py:271-279`).
### New finding: stored/live XSS via `task_category`, reachable from the cheapest endpoint in the service, lands in the one surface with write privileges
`TaskRequest.task_category` (`dispatcher.py:214`) is an unvalidated
`Optional[str]` — no enum, no length cap, no character filter. Supplying it
together with `task_tier` and `required_context_tokens` skips the classifier
entirely (`dispatcher.py:739-746`) and its raw value becomes
`classification.task_category`, which `persist_route_decision` writes
verbatim into `route_decisions.task_category` and fans out live to every SSE
subscriber via `events.publish_decision` (`dispatcher.py:1041`). `/route`
itself calls this on every request (`dispatcher.py:1644`) — it is
documented elsewhere in this repo as the free, quota-safe probe endpoint,
which makes it the *lowest-friction* way to get a string stored and pushed
live, not an edge case.
The admin dashboard renders that field back into the DOM in two places
without escaping it:
- `renderDecisions` (`admin/frontend/index.html:490`):
``
${d.task_category || '—'} | ``
- `addDecision`, the live SSE row-append path (`index.html:403`): identical
unescaped interpolation.
Both are `tbody.innerHTML = ...` / `insertBefore` on a freshly-built
``, so this is a direct HTML injection, not merely an attribute-quoting
issue. The same field, from the same data source, *is* escaped one panel
over in `renderCategoryBreakdown` (`index.html:577`,
`escapeHtml(cat)`) — confirming this is a missed spot rather than a
considered choice, since `escapeHtml` already exists in the file
(`index.html:884-886`) and is used correctly in `renderModels`,
`renderConfig`, and `renderWarnings`.
**Concrete reproduction:**
```bash
curl -s localhost:8080/route -H 'content-type: application/json' -d '{
"task": "x", "task_tier": 1, "required_context_tokens": 0,
"task_category": "
"
}'
```
The next time an operator has `/admin/` open — including passively, since
the live decisions table updates via SSE without a page reload — the
`onerror` fires in the page that already holds every admin capability:
`restart-service`, allowlisted `config.yaml` writes, and model-availability
overrides, all same-origin `fetch()` calls away with no additional
credential needed. This is why the auth/CSRF gap in item 2 above is worse
than it looks in isolation: an `Origin` check on the write endpoints
wouldn't stop this, because the malicious request originates from
JavaScript already running on `localhost:8080` itself.
No test in the 9 new test files touches HTML escaping or the dashboard's JS
at all (`tests/test_admin_frontend.py` only checks the file is served and
contains the Chart.js tag) — consistent with the orchestration report's "F3
Real manual QA: APPROVE" not having exercised this path.
**Fix**: run `task_category` (and `rejected_reason`, `runner_up_models`, and
any other free-text decision field) through `escapeHtml` in `renderDecisions`
and `addDecision`, matching the pattern already used three other places in
the same file. Validating `task_category` server-side against
`classifier.py`'s known category set would also close it and is probably
worth doing anyway — an unvalidated free-form override string being usable
to skip the classifier is a second, smaller thing worth a look, independent
of the rendering fix.
### Second new finding, caught live in production rather than in review: concurrent config writes actually crash, and can transiently truncate `config.yaml`
The gap analysis's F10 ("concurrent config.yaml edits are a race condition")
was accepted as deferred/"Recommended" on the theory that it's a rare,
low-stakes race. It isn't rare: `saveAllConfig()`
(`admin/frontend/index.html:778-803`) fires every allowlisted key as a
**parallel** `Promise.all` of `POST /admin/api/config/{key}` calls, so the
one-click "Save All Config" button — the documented way to use this
feature — guarantees concurrent writes on every use, not just under load.
Caught live on this host at 02:35:34-36 on 2026-08-29: two of the ten
parallel writes came back `500 Internal Server Error`
(`pinch.enabled`, `routing.default_flex_preference`), both
`TypeError: 'NoneType' object is not subscriptable` at `admin.py:332`
(`_dict_set_at`) — meaning `load_config_store` (`admin.py:336-340`) returned
`None` because it read `config.yaml` while a concurrent request's
`config_path.open("w")` (`admin.py:772`) had already truncated the file to
zero bytes and hadn't finished writing yet. Direct evidence this isn't
theoretical: one of the timestamped backups made during that window,
`config.yaml.bak.1787985334`, is itself **0 bytes** — `shutil.copyfile`
(`admin.py:771`) copied `config.yaml` mid-truncation, so the safety backup
this feature exists to provide was itself corrupted for that request.
`config.yaml` itself survived intact this time (the last writer to finish
happened to write a complete, valid file), but nothing in the current code
guarantees that outcome — the write is an in-place `open("w")` truncate,
not a write-to-temp-file-then-rename, so a crash or a slower writer mid-race
could leave `config.yaml` empty on disk with no valid backup to recover
from.
**Fix**: two independent things, either one closes most of the risk:
(1) make the write atomic — write to `config.yaml.tmp` and `os.replace()`
over the real path, so a reader/writer never observes a truncated file; (2)
serialize config writes with a lock (a module-level `threading.Lock` in
`admin.py` is enough for a single-process service) so overlapping requests
queue instead of interleaving. The frontend's `saveAllConfig()` sending
requests sequentially instead of via `Promise.all` would also avoid
triggering this in the one place it's currently guaranteed to happen, but
the server-side fix is the one that actually closes the bug — a future
`curl` script or a second browser tab hitting `/admin/api/config/*`
concurrently would reproduce it regardless of what the shipped frontend
does.
## Recommendation
Ship the fix for the XSS finding before this goes live somewhere reachable
by more than one trusted user — it's a small, mechanical change
(two `escapeHtml` calls) with an exact reproduction above to verify against.
Everything else checked against the plan review's required list is
genuinely done, not just documented as done: the F1 routing-integration test
in particular is exactly the test that review asked for, not a weaker
substitute. The auth/CSRF decision is still owed a real paragraph (even one
sentence: "accepted, personal single-operator tool, revisit if this becomes
multi-user") rather than the pre-existing read-only-surface sentence carried
forward unchanged — worth doing at the same time as the XSS fix since they're
the same conversation.