Skip to content

feat(bridge-auth): send X-Admin-Token on all /xiaozhi/admin/* calls#152

Merged
BrettKinny merged 1 commit into
mainfrom
audit/bridge-admin-auth
Jun 6, 2026
Merged

feat(bridge-auth): send X-Admin-Token on all /xiaozhi/admin/* calls#152
BrettKinny merged 1 commit into
mainfrom
audit/bridge-admin-auth

Conversation

@BrettKinny

Copy link
Copy Markdown
Owner

Admin-auth epic — part 4 of 4 (bridge caller, the scattered one). Pairs with the permissive /xiaozhi/admin/* middleware (#149). Off main. Completes the foundation.

Adds a _xiaozhi_admin_headers() helper (reads DOTTY_ADMIN_TOKEN into module-level _ADMIN_TOKEN) to both bridge.py and bridge/dashboard.py, threaded through every admin call site:

  • bridge.py (5 POSTs): _dispatch_abort / _dispatch_set_state / _dispatch_set_toggle, plus the _dashboard_abort_device / _dashboard_inject_to_device helpers.
  • dashboard.py (3 sites): _xiaozhi_device_count + _xiaozhi_list_songs (urllib GETs → urllib.request.Request with headers) and play_song's play-asset POST.

Calls to dotty-behaviour (vision photo proxy, perception getters) deliberately do not get the header. Header only sent when the token is set — no-op until the enforcement flip.

Tests: new tests/test_admin_headers.py (6 cases) — both helpers set/unset + an integration through _dispatch_abort capturing the headers kwarg. Full tests/ 71 passed; ruff clean.

Epic status

Foundation complete across all 4 services: #149 (server middleware) · #150 (behaviour) · #151 (pi-ext) · this (bridge).

Next — the enforcement flip (separate ops step, when you're ready): set the same DOTTY_ADMIN_TOKEN in all 4 container envs, deploy callers first (bridge/behaviour/pi-ext), xiaozhi last. Until then everything is a no-op.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings June 5, 2026 10:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR completes the bridge-side wiring for the admin-auth epic by conditionally attaching an X-Admin-Token header (sourced from DOTTY_ADMIN_TOKEN) to bridge→xiaozhi /xiaozhi/admin/* requests, staying a no-op until the coordinated enforcement flip.

Changes:

  • Add _xiaozhi_admin_headers() helpers (backed by module-level _ADMIN_TOKEN) in bridge.py and bridge/dashboard.py.
  • Thread the helper through all updated bridge/dashboard /xiaozhi/admin/* HTTP call sites (requests + urllib).
  • Add unit tests covering header helper behavior and one integration path through _dispatch_abort.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
bridge.py Adds admin-token helper and applies it to bridge-side admin POST dispatchers.
bridge/dashboard.py Adds admin-token helper and applies it to dashboard admin GET/POST calls (urllib + requests).
tests/test_admin_headers.py Adds tests validating helper behavior and header propagation via _dispatch_abort.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread bridge.py
Comment on lines +169 to +176
_ADMIN_TOKEN = os.environ.get("DOTTY_ADMIN_TOKEN", "").strip()


def _xiaozhi_admin_headers() -> dict:
"""X-Admin-Token header for /xiaozhi/admin/* requests when DOTTY_ADMIN_TOKEN
is set (matches the xiaozhi-server middleware); empty dict otherwise, so the
bridge is a no-op until the coordinated enforcement flip."""
return {"X-Admin-Token": _ADMIN_TOKEN} if _ADMIN_TOKEN else {}
Comment thread bridge.py
_ADMIN_TOKEN = os.environ.get("DOTTY_ADMIN_TOKEN", "").strip()


def _xiaozhi_admin_headers() -> dict:
Admin-auth epic, part 4 of 4 (bridge caller — the scattered one). Pairs with the
permissive /xiaozhi/admin/* middleware (part 1). Adds a `_xiaozhi_admin_headers()`
helper (reads DOTTY_ADMIN_TOKEN into a module-level `_ADMIN_TOKEN`) to both
bridge.py and bridge/dashboard.py, threaded through every admin call site:

- bridge.py: _dispatch_abort / _dispatch_set_state / _dispatch_set_toggle and the
  dashboard _dashboard_abort_device / _dashboard_inject_to_device helpers (5 POSTs).
- dashboard.py: _xiaozhi_device_count + _xiaozhi_list_songs (urllib GETs, now via
  urllib.request.Request with headers) and play_song's play-asset POST (3 sites).

Calls to dotty-behaviour (vision photo proxy, perception getters) are deliberately
NOT given the header. Header only sent when the token is set — no-op until the flip.

Tests: new tests/test_admin_headers.py (6 cases) — both helpers set/unset, and an
integration through _dispatch_abort capturing the headers kwarg. Full tests/ 71
passed; ruff clean.

Completes the foundation: #149 (server) + #150 (behaviour) + #151 (pi-ext) + this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@BrettKinny BrettKinny force-pushed the audit/bridge-admin-auth branch from e579b98 to 30706cc Compare June 6, 2026 11:17
@BrettKinny BrettKinny merged commit 0214e96 into main Jun 6, 2026
9 checks passed
@BrettKinny BrettKinny deleted the audit/bridge-admin-auth branch June 6, 2026 11:18
BrettKinny added a commit that referenced this pull request Jun 11, 2026
…docs)

The X-Admin-Token chain (#149-#152) shipped code-complete but
operationally dead: five services read DOTTY_ADMIN_TOKEN, zero config
surfaces provided it, so every deploy silently stayed permissive and
the admin routes remained unauthenticated.

- make setup: create .env from .env.example if missing; generate a
  32-byte hex token when no DOTTY_ADMIN_TOKEN line exists (idempotent;
  an existing line — even deliberately empty — is respected)
- docker-compose.yml.template + compose.all-in-one.yml: pass
  ${DOTTY_ADMIN_TOKEN:-} to xiaozhi-server
- dotty-pi/docker-compose.yml: add optional env_file (the container had
  no env mechanism at all, so adminFetch could never receive the token)
- bridge/ + dotty-behaviour/ compose: document the token among the
  deploy-dir .env secrets
- .env.example + SETUP.md §10: enable-everywhere-or-nowhere semantics
- CHANGELOG: record the #149-#152 chain (previously absent) + this fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BrettKinny added a commit that referenced this pull request Jun 11, 2026
…ADMIN_TOKEN end-to-end (#158)

* feat(auth): plumb DOTTY_ADMIN_TOKEN end-to-end (env, compose, setup, docs)

The X-Admin-Token chain (#149-#152) shipped code-complete but
operationally dead: five services read DOTTY_ADMIN_TOKEN, zero config
surfaces provided it, so every deploy silently stayed permissive and
the admin routes remained unauthenticated.

- make setup: create .env from .env.example if missing; generate a
  32-byte hex token when no DOTTY_ADMIN_TOKEN line exists (idempotent;
  an existing line — even deliberately empty — is respected)
- docker-compose.yml.template + compose.all-in-one.yml: pass
  ${DOTTY_ADMIN_TOKEN:-} to xiaozhi-server
- dotty-pi/docker-compose.yml: add optional env_file (the container had
  no env mechanism at all, so adminFetch could never receive the token)
- bridge/ + dotty-behaviour/ compose: document the token among the
  deploy-dir .env secrets
- .env.example + SETUP.md §10: enable-everywhere-or-nowhere semantics
- CHANGELOG: record the #149-#152 chain (previously absent) + this fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(kid-mode): wire shared content_filter into the live voice path (#157)

Lift the pure blocked-content core (three tier regexes + the kid-safe
replacement) out of bridge/text.py into the shared
custom-providers/textUtils.py — already the canonical safety-constants
home both containers import — and add content_filter_match() plus a
sentence-buffered filter_tts_stream() wrapper. The bridge keeps its
metrics/safety-ring/logging wrapper on top, behaviour unchanged.

Both live providers now run TTS-bound output through the filter, gated
on kid_mode:

- streaming decision (per the issue's open question): buffer to
  _SENTENCE_BOUNDARY — the tier patterns are single-word regexes, so a
  blocked term can never straddle a sentence boundary; nothing reaches
  TTS before its sentence is checked, and spoken latency is unchanged
  (the TTS layer buffers to sentences anyway)
- on a hit the rest of the turn is replaced with the cheerful redirect;
  mid-turn replacements drop the redirect's leading emoji to preserve
  the one-emoji-per-reply firmware contract
- kid_mode off is a transparent passthrough (zero behaviour change)
- OpenAICompat's emoji-prefix enforcement runs before the filter, so
  the leading-glyph contract survives filtering

Tests: tests/test_voice_content_filter.py (16 cases — core matcher,
stream wrapper incl. chunk-straddling terms, bridge wrapper parity, and
both providers hit/clean/kid-off, mirroring the #146 pattern); plus a
guard that the tier regexes exist in only one file. docs/faq.md updated
to describe the filter honestly as a weak, bypassable backstop.

Still open on #157 before release sign-off: on-device red-team
verification (bench-pending).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants