This commit is contained in:
Javis committed 2026-10-04 15:31:44 +02:00
1 parent 0246cbb6ba
commit 0ca4b4bf7a
89 files changed
+7042 -2480

No files matched your search

+612
View File
@@ -0,0 +1,612 @@
# Javis — Code Audit
**Scope:** the main application `src/javis/**` (~19 k lines across 59 modules), plus
`pyproject.toml`, `requirements.txt`, `config.example.yaml` and `README.md`.
The satellite packages (`javis-slave`, `*-mcp`, `voice-ref-curator`) are only covered
where they interact with the master (API, MCP).
**Method**
1. Baseline tooling: `ruff check --select F,E9,B` (37 findings), `compileall` (clean),
`pytest` (not installed in `.venv`, so no tests ran).
2. Line-by-line review of every subsystem (core, audio, STT, TTS/voices, LLM, MCP,
API, agent, GUI).
3. Every finding below was **re-verified against the source** (and, where relevant,
against the installed library versions: `mcp 2.0.0`, `fastapi 0.141`,
`starlette 1.6`, `faster-whisper 1.2.1`, `silero-vad 6.2.1`, `PySide6 6.11`,
`qasync 0.28`). Claims that did not survive verification are listed under
[Reviewed and rejected](#4-reviewed-and-rejected-false-positives).
Line numbers in the findings refer to the audited revision `0246cbb6` (before the fixes).
**Severity scale**
| Level | Meaning |
|---|---|
| Critical | Crash or core feature broken for a typical user |
| High | Security weakness or significant malfunction |
| Medium | Robustness / correctness problem under plausible conditions |
| Low | Minor bug, hygiene, or edge case |
| Info | Design note / recommendation, no defect |
---
## 1. Architecture overview
```mermaid
flowchart LR
subgraph Input
MIC[MicStream<br/>sounddevice cb] --> WW[openWakeWord]
MIC --> VAD[Silero VAD]
VAD --> REC[record_utterance]
TXT[GUI text input] --> ORCH
SLAVE[javis-slave<br/>FastAPI /turn] --> ORCH
end
WW -->|wake| ORCH[Orchestrator<br/>state machine + turn lock]
REC --> STT[faster-whisper] --> ORCH
REC --> SPK[ECAPA speaker ID] --> ORCH
ORCH --> QUICK[QuickEngine<br/>LLM bypass]
ORCH --> MW[Middleware pipeline<br/>Privilege / HomeyGuard / Memory]
MW --> LLM[Ollama client]
LLM -->|tool calls| MCP[MCPHost<br/>stdio servers]
QUICK --> MCP
ORCH --> TTS[Piper / F5 backend] --> OUT[OutputStream]
ORCH -. EventBus .-> GUI[PySide6 + qasync GUI]
```
* **Single event loop.** Everything runs on one asyncio loop (qasync in GUI mode,
`asyncio.run` headless). Blocking work (STT, TTS, speaker ID, PCM writes) is pushed
to threads with `asyncio.to_thread`.
* **Turn serialisation.** `Orchestrator._turn_lock` serialises wake-word, PTT, typed,
remote (slave) turns, enrollment and timer announcements.
* **Middleware.** TanStack-style hooks (`on_before_llm` piped, `on_before_tool`
first-win, the rest sequential). `PrivilegeMiddleware` is the per-speaker ACL.
* **Tool routing.** Keyword heuristics pick a subset of MCP servers per turn; a
`javis__load_tools` meta-tool lets the model pull in un-routed servers.
* **GUI.** One `_MainWindow` class (defined inside `run_gui()`) receives EventBus
events through Qt signals.
---
## 2. Findings summary
| ID | Sev. | Area | Title | Status |
|---|---|---|---|---|
| [MCP-01](#mcp-01) | Critical | MCP | MCP SDK 2.x field names → empty tool schemas, tool errors reported as success | Fixed |
| [GUI-01](#gui-01) | Critical | GUI | `NameError` when rendering voice-profile cards | Fixed |
| [SEC-01](#sec-01) | High | Repo / security | Git remote URL embeds a personal access token | **Action required (owner)** |
| [AGT-01](#agt-01) | High | Agent / security | Quick commands bypass speaker privileges | Fixed |
| [MCP-02](#mcp-02) | High | MCP / security | "Allow once" approval is remembered for the whole session | Fixed |
| [MCP-03](#mcp-03) | High | MCP / security | Bare server name executes that server's *first* tool | Fixed |
| [AGT-10](#agt-10) | Medium | Agent | One invalid stored regex quick command breaks every turn | Fixed |
| [MCP-08](#mcp-08) | Medium | MCP | No handshake timeout; servers connected one after another | Fixed |
| [VOI-02](#voi-02) | Medium | Voices | Quitting during scheduled training hangs exit and frees the lock early | Fixed |
| [AGT-02](#agt-02) | Medium | Agent | Quick-command repair pairs actions with the wrong errors | Fixed |
| [AGT-03](#agt-03) | Medium | Agent | `Orchestrator.stop()` runs twice / concurrently | Fixed |
| [AGT-04](#agt-04) | Medium | Agent | State stuck in THINKING (+ endless "thinking" ticks) on early errors | Fixed |
| [AGT-05](#agt-05) | Medium | Agent / GUI | Fire-and-forget tasks without strong references | Fixed |
| [AGT-06](#agt-06) | Medium | Audio out | PCM writer thread keeps writing while the stream is closed | Fixed |
| [MCP-04](#mcp-04) | Medium | MCP | Any tool error triggers server respawn + automatic retry | Fixed |
| [MCP-05](#mcp-05) | Medium | MCP | Exit stacks closed from a different task than they were opened in | Fixed |
| [LLM-01](#llm-01) | Medium | LLM | Streaming errors swallowed → silent empty/partial replies | Fixed |
| [AUD-01](#aud-01) | Medium | Audio in | `record_utterance` can block forever (holding the turn lock) | Fixed |
| [API-01](#api-01) | Medium | API / security | Large bodies parsed before auth and size check | Fixed |
| [GUI-02](#gui-02) | Medium | GUI | Theme registry leaks every rebuilt card widget | Fixed |
| [GUI-04](#gui-04) | Medium | GUI / security | `cmd /k` command line built with `list2cmdline` | Fixed |
| [VOI-01](#voi-01) | Medium | Voices / security | Imported voice archives may carry pickle checkpoints | Fixed (skipped by default) |
| [LLM-02](#llm-02) | Low | LLM | History trimming can drop the in-progress turn | Fixed |
| [AGT-07](#agt-07) | Low | Agent | Timer announcement can leave state SPEAKING | Fixed |
| [AGT-08](#agt-08) | Low | Agent / security | Privilege check fails open on exceptions | Fixed |
| [AUD-02](#aud-02) | Low | Audio in | 30 ms chunks are zero-padded to Silero's 512-sample window | Fixed |
| [AUD-03](#aud-03) | Low | Config | `audio.sample_rate ≠ 16000` silently breaks VAD / wake word | Fixed |
| [AUD-04](#aud-04) | Low | Audio in | Mic callback can raise during shutdown | Fixed |
| [MCP-06](#mcp-06) | Low | MCP | One leaked stderr temp file per server start | Fixed |
| [API-02](#api-02) | Low | API | Pair-attempt table grows forever; internals leaked in errors | Fixed |
| [GUI-05](#gui-05) | Low | GUI | Background tasks not cancelled on quit | Fixed |
| [CORE-01](#core-01) | Low | Logging | Log file date frozen at start; file I/O on the event loop | Fixed |
| [CORE-02](#core-02) | Low | Config | Missing validation (threshold range, YAML root type) | Fixed |
| [LINT-01](#lint-01) | Low | Hygiene | Ruff F/B findings (unused imports, duplicate set items, …) | Fixed |
| [DOC-01](#doc-01) | Low | Docs | README describes XTTS; code ships Piper/F5 | Fixed |
| [DOC-02](#doc-02) | Low | Packaging | Unpinned `mcp` major version; test deps missing | Fixed |
| [AGT-09](#agt-09) | Info | Agent | Substring keyword routing over-includes tool sets | Open (recommendation) |
| [ARCH-01](#arch-01) | Info | Maintainability | 5.3 k-line GUI closure, duplicated TTS pipelines | Open (recommendation) |
| [API-03](#api-03) | Info | API / security | Plain-HTTP LAN API | Open (recommendation) |
| [MCP-07](#mcp-07) | Info | MCP / security | MCP servers inherit the full environment | Open (recommendation) |
---
## 3. Detailed findings
### MCP
#### MCP-01
**MCP SDK 2.x field names → empty tool schemas, tool errors reported as success** — *Critical*
* `mcp_client/schema_bridge.py` L12: `getattr(tool, "inputSchema", None)`
* `mcp_client/host.py` L461: `getattr(result, "isError", False)`
* `mcp_client/host.py` L261: `page.next_cursor`
The dependency is declared as `mcp>=1.2`; the venv resolved **`mcp 2.0.0`**, whose
pydantic models expose *snake_case* attributes (`input_schema`, `is_error`,
`next_cursor`) — the camelCase names are only serialisation aliases and are **not**
attributes (verified: `getattr(Tool(...), "inputSchema", "NOATTR") == "NOATTR"`).
Impact with mcp 2.x:
1. Every MCP tool is advertised to the LLM as `{"type":"object","properties":{}}` — the
model has to guess every argument name.
2. `isError` results are never prefixed with `ERROR:` → failures are shown as successes
(✓ in the UI), cached by the cross-turn tool cache, and the quick-command repair
path never triggers.
Conversely, with mcp 1.x `page.next_cursor` raises `AttributeError`, so the code only
half-works on either major version.
**Fix:** version-agnostic accessors (`input_schema`/`inputSchema`, `is_error`/`isError`,
`next_cursor`/`nextCursor`), and the dependency is now bounded `mcp>=1.2,<3`.
#### MCP-02
**"Allow once" is remembered for the whole session** — *High*
`MCPHost.call()` (L399-L420) adds `(server, tool)` to `_allow_cache` after **any**
positive approval. The GUI dialog's *Always allow* path already calls
`allow_tool()`; *Allow once* (and a spoken "ja") therefore silently behaves like
*Always allow*: subsequent calls of that tool — including ones with completely
different, possibly destructive arguments — run without asking.
**Fix:** a positive answer is now remembered only for the current turn
(`_turn_allow_cache`, cleared together with denials at every new turn); only
*Always allow* persists.
#### MCP-03
**Bare server name executes the server's first tool** — *High*
`validate_tool_name()` (schema_bridge.py L31-L57) "recovers" a tool name without `__`
that equals a server name by returning that server's **first** tool. `MCPHost.call()`
then executes it with whatever arguments the model sent. For a filesystem/Homey/Spotify
server, the first tool can be a write/actuating tool.
**Fix:** no guessing. The call returns
`ERROR: '<name>' is not a tool. Did you mean one of: …` so the model can retry with a
real name.
#### MCP-04
**Every tool exception triggers a respawn + automatic retry** — *Medium*
host.py L425-L445: any exception from `session.call_tool` (including JSON-RPC errors
such as *invalid params*, raised as `McpError`/`MCPError`) tears the server down,
respawns it and repeats the call. Non-idempotent tools (play, switch, write) can run
twice; heavy servers (`npx …`) are restarted for a simple argument error.
**Fix:** protocol errors are returned to the model immediately; reconnect + retry only
happens for transport failures (`CONNECTION_CLOSED`, closed/broken streams, `OSError`).
#### MCP-05
**Exit stacks closed from a different task than they were opened in** — *Medium*
Each server's `AsyncExitStack` is entered inside the task that ran `MCPHost.start()`
(the GUI `_bootstrap` task) and closed from another task (`_shutdown`, or the turn
task that triggers `_reconnect`). The MCP SDK uses anyio task groups/cancel scopes,
which must be exited by the task that entered them → `RuntimeError: Attempted to exit
cancel scope in a different task …`, which was logged and ignored, leaving cleanup of
the child process to chance.
**Fix:** each server is owned by a dedicated long-lived task that enters the contexts,
signals readiness, waits for a stop event, and exits the contexts itself (the pattern
recommended for the MCP Python SDK / anyio).
#### MCP-06
**One leaked stderr temp file per server start** — *Low*
The stderr `NamedTemporaryFile(delete=False)` is unlinked right after a successful
handshake while the child process still holds the handle; on Windows `unlink` fails
(swallowed), so every start/reconnect leaves a `mcp-<name>-*.stderr.log` in `%TEMP%`
that the child keeps appending to. The stderr content (useful for debugging) is lost.
**Fix:** stderr goes to `~/.javis/logs/mcp-<name>.stderr.log` (truncated per start).
#### MCP-07
**MCP servers inherit the full process environment** — *Info*
#### MCP-08
**No handshake timeout; servers connected one after another** — *Medium*
`session.initialize()` had no timeout, and `MCPHost.start()` connected servers
sequentially inside the boot `gather`. One server that hangs during start-up (e.g. an
`npx` package prompt, a dead network share) blocked "Loading models & MCP servers"
forever — and with it `orch.start()` and the whole agent. **Fix:** 120 s handshake
timeout per server, all servers connected concurrently.
### Repository
#### SEC-01
**Git remote URL embeds a personal access token** — *High*
`git remote -v` shows `https://<user>:<token>@<gitlab host>/…`. The token is stored in
plain text in `.git/config` and is printed by every `git remote -v`, CI log or support
screenshot. (The secret-bearing `config.yaml`, `mcp_servers.json` and
`spotify-config.json` are correctly git-ignored and were never committed.)
**Action for the owner:** revoke/rotate the token in GitLab, then
`git remote set-url origin https://<gitlab host>/<group>/javis.git` and let Git
Credential Manager store the credential. This was not changed automatically because it
affects your push setup.
`env = {**os.environ, **spec.env}` (L231) passes e.g. `JAVIS_HERMES_API_KEY` to every
server. This matches VS Code's behaviour and many servers need `PATH`, `APPDATA`, etc.
Treat `mcp_servers.json` as trusted code; consider an allow-list if third-party servers
are added.
### Agent / orchestrator
#### AGT-01
**Quick commands bypass speaker privileges** — *High*
`_handle_turn` computes `_privileges` from speaker ID (L1270-L1320) but calls
`_try_quick_response(tr.text)` (L1333) without them; `_try_quick_response` executes
each action via `self.mcp.call(...)` directly (L2190-L2205), never consulting
`PrivilegeMiddleware`. A guest/unknown speaker whose ACL denies `homey__*` can still
run every learned Homey quick command. The quick-command repair loop also creates a
`TurnContext` without privileges, so it runs with full rights. (Remote/slave turns do
not use quick commands, so they were not affected.)
**Fix:** privileges are passed to `_try_quick_response`; if any action is not allowed
the quick path is skipped (the LLM path then enforces the ACL), and the repair turn
inherits the speaker's privileges.
#### AGT-02
**Repair directive pairs actions with the wrong errors** — *Medium*
L2220-L2223: `zip(cmd.actions, errors)` — `errors` contains only the *failed* actions,
so with actions `[A ok, B failed]` the directive claims `A → <B's error>`. The LLM is
told to repair the wrong step.
**Fix:** failures are collected as `(action, error)` pairs.
#### AGT-03
**`stop()` runs twice / concurrently** — *Medium*
`run()` calls `stop()` in its `finally` (L660), the GUI calls it again from
`_shutdown()`, and `run_headless()` calls it again in its own `finally`. In GUI mode
both run concurrently (the loop resumes the cancelled `run()` while
`run_until_complete(_shutdown())` executes), so MCP stacks, the mic and the HTTP
clients are torn down twice in parallel.
**Fix:** `stop()` is idempotent and serialised (`_stopped` flag + lock); the main
loop task is cancelled and awaited before teardown.
#### AGT-04
**State stuck in THINKING (with endless ticks) on early errors** — *Medium*
* `submit_text` (L703-L716): `_start_thinking_cue()`, `_try_quick_response()`,
`conv.add_user()` and `run_start()` run *before* the `try/finally` that resets state.
If any raises, the state stays THINKING and the cue task loops
`while self.state == THINKING: tick; sleep(4)` — a soft tick every 4 s until another
turn happens.
* `submit_remote_turn` (L785-L800): an STT failure leaves the master in THINKING.
* `_handle_turn` leaked the cue task the same way when STT raised.
**Fix:** the whole turn body is inside `try/finally` (cue cancelled, state → IDLE).
#### AGT-05
**Fire-and-forget tasks without strong references** — *Medium*
`asyncio.ensure_future(...)`/`create_task(...)` results were discarded in ~25 places
(chimes, TTS pre-warm, background quick-suggest, `profiler.refine`, GUI actions, and
the long-lived `orch.run()`, training scheduler and API server tasks). The event loop
keeps only weak references (see the Python docs warning on `create_task`), and an
exception in such a task is only reported at garbage collection as
"Task exception was never retrieved" — e.g. a crash
#### AGT-10
**One invalid stored regex quick command breaks every turn** — *Medium*
`QuickEngine.add/update` accepted any `match_mode` and any regex — including ones
written by the LLM through `javis__quick_add`. `match()` then ran `re.search()` on every
utterance; an invalid pattern raised `re.error` on **every** voice/text turn (error
phrase, nothing works) until the entry was deleted by hand. **Fix:** modes and regexes
are validated on add/update (the LLM gets an `ERROR:` result and can retry; the GUI
shows a warning), and a bad pattern already on disk is logged and skipped. of `orch.run()` would have been
silent.
**Fix:** new `javis.core.tasks.spawn()` keeps a strong reference until completion and
logs exceptions; used everywhere a task is not awaited.
#### AGT-06
**PCM writer thread keeps writing while the stream is closed** — *Medium*
`_speak`, `_speak_stream` and `play_pcm` write audio with
`await asyncio.to_thread(_write_pcm, …)`. When the awaiting task is cancelled (turn
error, shutdown, barge-in cleanup), `to_thread` returns immediately but the **thread
keeps running**, while the `finally` block calls `stream.abort()/stop()/close()`.
PortAudio does not support closing a stream that another thread is writing to → errors,
hangs in `stop()` or native crashes. The writer code was also triplicated.
**Fix:** a single `write_pcm_blocks()` helper; on cancellation it raises a stop flag
and waits (bounded) for the writer thread to exit before the stream is closed.
#### AGT-07
**Timer announcement can leave state SPEAKING** — *Low*
`_on_timer_fire` (L1875-L1888) calls `_speak()` and then `set_state(IDLE)` without
`try/finally`. **Fix:** `finally` resets the state.
#### AGT-08
**Privilege check fails open** — *Low*
`run_before_tool` (middleware.py L1718-L1735) logs and ignores exceptions from every
middleware. For `PrivilegeMiddleware` this means *allow* on error (e.g. a malformed
`allowed_tools` value in `index.json`). **Fix:** an exception in the privilege
middleware now denies the tool.
#### AGT-09
**Substring keyword routing over-includes tool sets** — *Info*
`_route_tools` uses `k in text` for single words: `hell` ⊂ *hello*, `play` ⊂ *display*,
`ton` ⊂ *Anton*, and `möchte`/`wollen` (Emby set) match most German requests, so Emby
schemas are attached to nearly every turn. Substring matching is deliberate for German
compounds (*Wohnzimmerlicht*), so this is left as a recommendation: drop generic verbs
(`möchte`, `wollen`, `what is`) from the sets, or match short words on word boundaries
and keep substring matching for nouns ≥ 5 chars.
#### ARCH-01
**Maintainability hot-spots** — *Info*
* `gui/main_window.py` (~5.3 k lines) defines the whole window class inside
`run_gui()`, capturing `config` from the closure; it cannot be imported or tested in
isolation. Recommended split: one module per tab + a `MainWindow` class taking
`config` explicitly.
* `orchestrator.py` (~3.3 k lines) embeds ~300 lines of JSON tool schemas; `_speak`
and `_speak_stream` duplicate the synth→play pipeline. Recommended: `agent/builtin_tools.py`
and a `tts_pipeline.py` with one producer/consumer implementation fed by a queue.
### LLM
#### LLM-01
**Streaming errors swallowed** — *Medium*
`OllamaClient.chat_streaming` (L326-L362) catches every exception (connect failure,
read timeout, dropped connection), logs it at **DEBUG** and returns whatever was
received. When Ollama is down the result is an *empty* response, which `_llm_loop`
interprets as "model returned nothing" and answers with nudges/loop fallbacks instead
of reporting the outage. The two `GeneratorExit` branches are dead code (a coroutine
never sees `GeneratorExit` from `aiter_lines`, and `except Exception` cannot catch it).
**Fix:** errors are logged at WARNING; if nothing was received the exception is
re-raised, a partial reply is returned with a warning.
#### LLM-02
**History trimming can drop the in-progress turn** — *Low*
`Conversation._trim()` runs after every `add_*` call and drops whole turns from the
front until under `max_chars`. If the current turn alone exceeds the budget (large
system prompt + several 2 kB tool results), the *current* user message and the
assistant tool-call message are dropped, leaving orphaned `tool` messages and a model
that no longer sees the question. **Fix:** the newest user turn is never trimmed.
### Audio / STT
#### AUD-01
**`record_utterance` can block forever** — *Medium*
capture.py L106 iterates `mic.chunks()` without `stale_timeout`. If the input device
disappears mid-utterance (USB unplug, driver reset), no chunk ever arrives, the
coroutine never returns and `_turn_lock` stays held — PTT, typed input and timer
announcements are blocked until restart. **Fix:** a 5 s stale timeout ends the
recording with what was captured.
#### AUD-02
**Chunk size vs. Silero window** — *Low*
The mic delivers 30 ms (480 samples) chunks; `SileroVAD.process` zero-pads each to
512 samples. Silero v5/v6 accepts exactly 512 samples @ 16 kHz and keeps the last 64
samples as context for the next call, so every call's context is half zeros.
**Fix:** 32 ms chunks (512 samples) — exact Silero window; openWakeWord buffers to its
own 1280-sample frame so it is unaffected.
#### AUD-03
**Non-16 kHz sample rate silently breaks detection** — *Low*
`audio.sample_rate` is configurable, but `make_vad()` is always built for 16 kHz and
openWakeWord only supports 16 kHz. **Fix:** `validate()` forces 16000 with a warning.
#### AUD-04
**Mic callback can raise during shutdown** — *Low*
`_cb` checks `loop.is_closed()` and then calls `call_soon_threadsafe` from the
PortAudio thread; if the loop closes in between, `RuntimeError` propagates into
PortAudio. **Fix:** guarded.
### API (slave devices)
#### API-01
**Large bodies parsed before auth and size check** — *Medium*
FastAPI parses the multipart body (`UploadFile`) before running the `Depends` auth
dependency, and `/turn` reads the whole upload into memory before comparing it with
`max_upload_bytes` (routes.py L152-L160). Any host on the LAN can make the master
spool arbitrarily large bodies to disk/RAM. **Fix:** a small ASGI guard rejects
requests whose `Content-Length` exceeds the limit (413) and requires a length for
`/turn`, before any parsing.
#### API-02
**Pair-attempt table grows forever; decoder internals leaked** — *Low*
`_pair_attempts` keeps one entry per source IP forever; `/turn` echoed soundfile's
internal decoder message; `_require_device` uses `assert` (removed under `python -O`).
**Fix:** stale entries are pruned; decode errors return a generic message (details
stay in the log); explicit check instead of `assert`. Turn errors are still reported
to the (authenticated) device, truncated to 200 characters, because the satellite
shows them to the user.
#### API-03
**Plain-HTTP LAN API** — *Info*
Bearer API keys travel unencrypted and the server binds `0.0.0.0` when enabled. Keys
are hashed at rest and compared in constant time (good). Recommendation: restrict the
Windows firewall rule to the private profile/subnet, or terminate TLS in a reverse
proxy (Caddy) if the LAN is not trusted.
### GUI
#### GUI-01
**`NameError` when rendering voice-profile cards** — *Critical*
`_make_voice_card` (main_window.py L2355-L2398) uses `name` and `v` that are never
defined (ruff F821) — the card's `QVBoxLayout` and `name = profile["name"]` were lost.
As soon as one speaker profile exists, opening the Intelligence tab (which refreshes
on every activation) raises inside the Qt slot and the voice panel never renders.
**Fix:** restored the layout and the name lookup.
#### GUI-02
**Theme registry leaks every rebuilt card widget** — *Medium*
`_th()` appends `(widget, factory)` to `self._themed` forever. Memory, quick-command
and voice cards are rebuilt on every Intelligence-tab activation (≈5 entries per
card), so the list grows without bound and `retheme()` iterates thousands of deleted
widgets. **Fix:** dead entries are pruned (`shiboken6.isValid`) with an amortised
threshold.
#### GUI-04
**`cmd /k` command line built with `list2cmdline`** — *Medium*
Training/export are launched with
`Popen(["cmd", "/k", subprocess.list2cmdline(cmd)])` (L4036-L4042, L4102-L4108).
`list2cmdline` quotes for the MSVC runtime, not for `cmd.exe`: an argument without
spaces containing `&`, `|`, `^` (voice names from imported archives, the free-text
base-checkpoint path) is executed as an extra command; and the outer `Popen` list
re-escapes inner quotes (`\"`), which breaks interpreter paths with spaces.
**Fix:** every argument is double-quoted for `cmd.exe` (arguments containing `"` are
rejected) and the command string is passed verbatim.
#### GUI-05
**Background tasks not cancelled on quit** — *Low*
After `loop.run_forever()` returns, only `orch.stop()` is awaited; the training
scheduler and API server tasks stay pending → "Task was destroyed but it is pending"
and the uvicorn socket is never closed cleanly. **Fix:** tracked and cancelled.
### Voices / TTS
#### VOI-02
**Quitting during scheduled training hangs exit and frees the lock early** — *Medium*
The overnight scheduler runs `run_pipeline()` via `asyncio.to_thread`; the thread waits
on the training subprocess for hours. Cancelling the scheduler task (quit) released
the training lock immediately while the thread kept training, and at interpreter exit
Python joins executor threads — so Javis hung on quit until training finished.
**Fix:** cancellation calls `piper_train.request_stop()`, which terminates the child
process (`_run` now polls instead of blocking in `wait()`); the lock is only released
once the worker has actually finished.
#### VOI-01
**Imported voice archives may carry pickle checkpoints** — *Medium (trust)*
`.javisvoice` import (voices/io.py) correctly blocks zip-slip and oversize archives,
but it extracts `training/**` including PyTorch-Lightning `*.ckpt` files and
`base_checkpoint.txt`. Training/export later load those checkpoints (pickle-based) →
importing an archive from an untrusted source and pressing *Train* executes arbitrary
code. **Fix:** checkpoint/pickle files (`.ckpt`, `.pt`, `.pth`, `.pkl`) are skipped on
import unless explicitly allowed, with a log warning.
### Core / config / logging
#### CORE-01
**Log file date frozen at start; file I/O on the event loop** — *Low*
`log_dir / f"javis_{datetime.now():%Y%m%d}.log"` is evaluated once; a process running
for a week writes into the start-day file (size rotation only). File writes run
synchronously on the event-loop thread, and loguru's default `diagnose=True` prints
local variable values in tracebacks (e.g. dicts holding MCP server env tokens).
**Fix:** loguru's `{time:YYYYMMDD}` filename placeholder with a midnight-or-10 MB
rotation predicate, `enqueue=True` (writes on a background thread) and
`diagnose=False` on both sinks.
#### CORE-02
**Missing validation** — *Low*
`speaker_id.threshold` and `speaker_id.refine_margin` were not range-checked; a YAML
file whose root is not a mapping crashed with `AttributeError`. **Fix:** validated.
#### LINT-01
**Ruff findings** — *Low*
Unused imports (`AgentState`, `PAL_LIGHT`, `json`, `pathlib`, `csv`, `asyncio`,
`parse_qualified_name`), duplicate set literals (B033 in `_SLAVE_KEYWORDS`,
`_EMBY_KEYWORDS`, `_SEARCH_KEYWORDS`, `_RECALL_STOPWORDS`), unused loop variable,
f-string without placeholders, missing exception chaining. **Fix:** cleaned up.
### Docs / packaging
#### DOC-01
README describes Coqui **XTTS v2** and requires Python ≤ 3.11 "because of XTTS"; the
code ships **Piper** (default) and optional **F5-TTS**. **Fix:** README updated.
#### DOC-02
`mcp>=1.2` allowed a breaking major (see MCP-01); `pytest` was not installed so the
test suite could not run. **Fix:** `mcp>=1.2,<3`; `pip install -e .[dev]` documented.
---
## 4. Reviewed and rejected (false positives)
These were raised during the automated pass and rejected after checking the code:
| Claim | Why rejected |
|---|---|
| `core/events.py` is truncated | File is complete; `publish()` ends after the logging loop. |
| Global hotkey never unregistered | `run_gui` calls `hk.uninstall()` after the loop stops. |
| GUI quits without `orch.stop()` | `_shutdown()` is run with `run_until_complete`. (It ran *twice* — see AGT-03.) |
| Startup-dialog mic meter touches Qt from the audio thread | Callback only stores a float; the `QTimer` tick on the GUI thread updates the bar. |
| Timer restore schedules wrong times | Remaining time is recomputed from the absolute `fire_at`; correct. |
| `Fraction(...).limit_denominator(1000)` causes pitch shift | 44100→16000 = 160/441 exactly; common rates reduce exactly. |
| Wake-word download endpoint path traversal | Path comes from the owner's config, not the client. |
| `asyncio.set_event_loop()` deprecated | Not deprecated on 3.10–3.13; required by qasync. |
| `blocksize` counted in samples vs frames | sounddevice `blocksize` is frames; code is correct. |
| `_abort_early` is a "marker" spoken by accident | It is the intended spoken abort reason. |
---
## 5. Verification
| Check | Result |
|---|---|
| `ruff check src/javis --select F,E9,B` | 37 findings → 0 (B008 is the idiomatic FastAPI `Depends` pattern and is ignored) |
| `pytest tests` | 21 existing + 18 new regression tests ([tests/test_audit_fixes.py](../tests/test_audit_fixes.py)) — **39 passed** |
| Offscreen GUI smoke test ([tests/_retheme_check.py](../tests/_retheme_check.py)) | Window builds, theme round-trip OK, voice-profile card renders (GUI-01) |
| Live MCP smoke test (local `duckduckgo-mcp`) | 3 tools with full parameter schemas (MCP-01), bare server name rejected (MCP-03), clean stop from a different task (MCP-05), stderr log under `logs/` (MCP-06) |
| Logging | New file `javis_YYYYMMDD.log` created, enqueue sink flushes |
---
## 6. Improvements and best practices
Researched against primary documentation; ✅ = implemented in this pass,
📌 = recommendation.
| # | Topic | Best practice (source) | Javis status |
|---|---|---|---|
| 1 | Background tasks | "Save a reference to the result of [`create_task`] … The event loop only keeps weak references to tasks"; prefer `asyncio.TaskGroup` for structured concurrency ([Python docs – Creating tasks](https://docs.python.org/3/library/asyncio-task.html#creating-tasks)). | ✅ `core.tasks.spawn()` keeps references + logs failures. 📌 After moving to Python ≥ 3.11, run per-turn helpers (chimes, cue, producer) in a `TaskGroup` owned by the turn. |
| 2 | Cancellation hygiene | Use `try/finally` for clean-up and re-raise `CancelledError` ([Python docs – Task cancellation](https://docs.python.org/3/library/asyncio-task.html#task-cancellation)). | ✅ Turn bodies restructured (AGT-04), PCM writer waits for its thread (AGT-06), scheduler propagates cancellation (VOI-02). |
| 3 | MCP client lifecycle | "Let the `async with` block own the connection"; "Check `result.is_error` rather than expecting a failing tool to raise"; SDK ≥ 2.0 uses snake_case fields ([MCP – Build a client](https://modelcontextprotocol.io/docs/develop/build-client)). | ✅ Owner task per server (MCP-05), `is_error`/`input_schema` compat (MCP-01). 📌 Consider migrating to the SDK 2.x `Client` wrapper once 1.x support is dropped. |
| 4 | Tool permissions | "Be cautious with tool permissions … connect only to servers and executable commands you trust" (same source). | ✅ Allow-once is per turn (MCP-02), no guessed tools (MCP-03), privileges on quick commands (AGT-01), fail-closed ACL (AGT-08). |
| 5 | Streaming tool calls | Ollama streams content **and** tool calls since May 2025 with a new incremental tool parser ([Ollama blog – Streaming responses with tool calling](https://ollama.com/blog/streaming-tool)). | 📌 `_llm_loop` still uses `stream=false` whenever tools are attached (comment cites old Ollama behaviour). Switching tool rounds to `chat_streaming(tools=…)` would start TTS on the first sentence of tool-free answers, a noticeable latency win. Needs a test pass with the models you use. |
| 6 | Context window | Ollama notes that a context of 32 k or higher improves tool-calling quality ([same source](https://ollama.com/blog/streaming-tool)); `num_ctx` is a per-request option ([Ollama API – chat](https://docs.ollama.com/api/chat)). | 📌 Default `llm.num_ctx` is 16 384 while the comment in `config.py` recommends 32 768 for 20+ tools; `Conversation.max_chars` (24 k chars ≈ 6 k tokens) is independent of `num_ctx` — derive it from `num_ctx`. |
| 7 | `keep_alive` | Accepts a duration string (`"5m"`) or number; `0` unloads ([Ollama API – chat](https://docs.ollama.com/api/chat)). | ✅ Example config no longer suggests the invalid `"infinity"` (use `-1`). 📌 Allow duration strings in `LLMConfig.keep_alive`. |
| 8 | VAD framing | Silero VAD supports 8/16 kHz; chunks of 30+ ms ([silero-vad](https://github.com/snakers4/silero-vad)); the v5+ models take a fixed 512-sample window at 16 kHz. | ✅ 32 ms (512-sample) mic chunks (AUD-02); 16 kHz enforced (AUD-03). |
| 9 | Wake-word robustness | Feed multiples of 80 ms; enable the built-in Silero `vad_threshold` gate to cut false activations; tune the threshold per room; consider custom verifier models for known voices ([openWakeWord README](https://github.com/dscripka/openWakeWord)). | ✅ New opt-in `wake_word.vad_gate`. 📌 A verifier model trained on the household's voices would also complement speaker ID. Note: bundled openWakeWord models are CC BY-NC-SA. |
| 10 | STT vocabulary | faster-whisper accepts `hotwords` / `initial_prompt` (verified in the installed 1.2.1). | ✅ New opt-in `stt.hotwords` for names, rooms and device names. |
| 11 | Logging | `enqueue=True` makes logging non-blocking; set `diagnose=False` in production "to avoid leaking sensitive data" ([loguru API](https://loguru.readthedocs.io/en/stable/api/logger.html)). | ✅ Both applied (CORE-01). |
| 12 | LAN API | Reject oversized bodies before parsing; plain HTTP exposes bearer tokens. | ✅ Body-size guard (API-01). 📌 Scope the firewall rule to the private profile, or put Caddy/TLS in front (API-03). |
| 13 | Supply chain | Pin majors of fast-moving deps; use a lock file. | ✅ `mcp>=1.2,<3`. 📌 Add a lock (`uv lock` / `pip-compile`) and pin `fastapi`/`starlette`, `faster-whisper`, `openwakeword`. |
| 14 | CI | Lint + tests on every push would have caught GUI-01 (`ruff F821`) and MCP-01. | 📌 Add a GitLab CI job: `ruff check src/javis --select F,E9,B` + `pytest`. |
| 15 | Maintainability | Split `main_window.py` per tab and inject `config`; extract built-in tool schemas and a single TTS pipeline from `orchestrator.py` (ARCH-01). | 📌 |
| 16 | Tool routing | Drop generic verbs (`möchte`, `wollen`, `what is`) from the routing sets or match short words on word boundaries (AGT-09). | 📌 |
### New configuration options (all off by default)
```yaml
stt:
hotwords: "Javis, Esszimmer, Homey" # bias Whisper towards these words
wake_word:
vad_gate: 0.5 # wake only when openWakeWord's VAD agrees
```
+419
View File
@@ -0,0 +1,419 @@
# Homey, Emby, DuckDuckGo & Spotify MCP Servers — Code Audit
| | |
|---|---|
| **Scope** | `homey-mcp` (Python, ~1.6k lines), `emby-mcp` (Python, ~0.6k), `duckduckgo-mcp` (Python, ~0.2k), `spotify-mcp-server` (TypeScript, ~2.5k) |
| **Method** | Every file read by hand. Each finding was reproduced: offline with mocked HTTP transports, or with **read-only** live requests against the configured Homey, Emby and Spotify accounts. Nothing was switched, played, deleted or changed during the audit. |
| **Date** | 2026-10-03 |
| **Companion audits** | [AUDIT.md](AUDIT.md) (Javis), [AUDIT_SLAVE.md](AUDIT_SLAVE.md) (satellite + slave MCP) |
---
## 1. How Javis uses these servers
All four run as **stdio** subprocesses of the Javis MCP host (`mcp_servers.json`).
`homey-mcp` can additionally run as a LAN HTTP server (streamable-http / SSE + a REST
API) for other clients such as Odysseus.
| Server | Approval in `mcp_servers.json` | Credential it holds |
|---|---|---|
| homey | `ask` (default) | Homey Pro personal access token |
| emby | `allow` | Emby **admin** API key |
| duckduckgo | `allow` | — |
| spotify | `ask` | OAuth client secret + refresh token |
The Javis host treats a tool result as failed only when MCP reports `isError: true`, which
it turns into an `ERROR:` prefix. Failures that are returned as ordinary text therefore count
as **successes**: they are cached, recorded as quick commands and reported as done.
---
## 2. Findings summary
| ID | Severity | Server | Finding | Status |
|---|---|---|---|---|
| [EMB-01](#emb-01) | Critical | emby | All Emby tools fail with Javis' config: the scheme defaults to `https` while the server speaks HTTP | Fixed |
| [EMB-02](#emb-02) | High | emby | `search_items` only looks at the first 60 of 68,932 library items | Fixed |
| [EMB-03](#emb-03) | High | emby | `play_item`, `get_item`, `playback_url` call endpoints that don't exist; `list_devices` always returns `[]` | Fixed |
| [EMB-04](#emb-04) | High | emby / security | IDs inserted unencoded into URL paths, with the admin key (`123/Delete`, `../System/Restart`) | Fixed |
| [HOM-01](#hom-01) | High | homey / security | Path injection through IDs: `trigger_flow('../../system/reboot#')` reboots the Homey | Fixed |
| [HOM-02](#hom-02) | High | homey LAN / security | `AUTH_MODE=bearer` leaves the SSE endpoints `/sse` and `/messages/` unauthenticated | Fixed |
| [HOM-03](#hom-03) | Medium | homey LAN / security | DNS-rebinding protection disabled although the docs promise a Host check | Fixed |
| [HOM-04](#hom-04) | Medium | homey | Realtime events never arrive (wrong Socket.IO protocol) | Fixed |
| [HOM-05](#hom-05) | Medium | homey | Zone names resolve to the first substring hit (`Wohnzimmer` can pick `Wohnzimmer Ecke`) | Fixed |
| [HOM-06](#hom-06) | Medium | homey | `find_devices` matches ANY query word, so "esszimmer licht" returns every light | Fixed |
| [DDG-01](#ddg-01) | Medium | duckduckgo | Blocking searches run on the server's event loop | Fixed |
| [DDG-02](#ddg-02) | Medium | duckduckgo | Search failures are returned as normal text, so Javis caches them as results | Fixed |
| [SPO-01](#spo-01) | Medium | spotify | Failures are returned without `isError` in all 28 tools | Fixed |
| [SPO-02](#spo-02) | Medium | spotify | `playMusic(uri="spotify:track:…")` is sent as a context URI and rejected by Spotify | Fixed |
| [SPO-03](#spo-03) | Medium | spotify | Token refresh ignores rotated refresh tokens, has no expiry margin and can run in parallel | Fixed |
| [EMB-05](#emb-05) | Medium | emby | Failures are returned as `{"ok": false}` data instead of tool errors | Fixed |
| [HOM-07](#hom-07) | Low | homey | systemd unit cannot work (`homey-mcp@user` enabled, but not a template; `%h` is `/root`) | Fixed |
| [HOM-08](#hom-08) | Low | homey | Errors returned as data; REST returns 500 on bad JSON; non-ASCII token crashes the auth check | Fixed |
| [EMB-06](#emb-06) | Low | emby | Wrong field mapping (runtime, year, episode numbers); documented `type` filter is missing | Fixed |
| [DDG-03](#ddg-03) | Low | duckduckgo | No input validation | Fixed |
| [SPO-04](#spo-04) | Low | spotify | Credentials file written non-atomically with default permissions | Fixed |
| [SPO-05](#spo-05) | Low | spotify | IDs unencoded in raw API paths | Fixed |
| [SPO-06](#spo-06) | Low | spotify | `adjustVolume` reads one device's volume and sets another's | Fixed |
| [SPO-07](#spo-07) | Low | spotify | `3:60` durations, playlist search lines, saved-track numbering | Fixed |
| [X-01](#x-01) | Low | all | No MCP tool annotations (e.g. `control_all` can switch off every socket) | Fixed |
| [X-02](#x-02) | Low | Python servers | `mcp>=1.2` allows mcp 2.x, which removed `mcp.server.fastmcp` | Fixed (pin) |
| [X-03](#x-03) | Info | all | READMEs out of date (tool lists, defaults, paths) | Fixed |
| [X-04](#x-04) | Info | homey / emby / spotify | Broad credentials (admin Emby key, full Homey token, unused Spotify scopes) | Open (recommendation) |
| [X-05](#x-05) | Info | spotify | Biome reports format errors (CRLF line endings) — present before the audit | Open |
---
## 3. Detailed findings
### Emby
#### EMB-01
**All Emby tools fail with Javis' configuration** — *Critical*
`mcp_servers.json` sets `EMBY_HOST=10.10.10.50:9096` without a scheme. `Settings.scheme`
defaulted to `https`, so the base URL became `https://10.10.10.50:9096`. That port speaks
plain HTTP:
```
https://10.10.10.50:9096/System/Info/Public → ConnectError [SSL: WRONG_VERSION_NUMBER]
http://10.10.10.50:9096/System/Info/Public → 200 {"ServerName": …}
```
`emby-mcp/.env` (scheme `https`, port 443) is **not** read when Javis launches the server,
because pydantic-settings resolves `.env` relative to the working directory, which is
`F:\Javis`.
**Fix:** `scheme` defaults to `http` (Emby's own default). `EMBY_HOST` may now be
`host:port` or a full URL, and `EMBY_PORT` is ignored when the host already has a port.
The README explains the `.env` lookup.
#### EMB-02
**Search only sees the first 60 items** — *High*
`search_items` fetched `limit*3` items sorted by name and filtered them in Python. The library
holds 68,932 items, so almost every title was out of reach. **Fix:** server-side
`SearchTerm` plus `IncludeItemTypes`. Without the type filter Emby also returns People,
Studios and other non-media items. Verified live: the fixed search finds titles across the
whole library.
#### EMB-03
**Endpoints that don't exist** — *High*
Checked against the server's own `/openapi.json` and live requests:
| Tool | Called | Reality |
|---|---|---|
| `get_item` | `GET /Items/{id}` | only `DELETE` exists → 404 |
| `play_item` | `POST /Items/{id}/Play` | no such route; remote play is `POST /Sessions/{id}/Playing?ItemIds=…&PlayCommand=PlayNow` |
| `playback_url` | `GET /Items/{id}/StreamUrl` | no such route; streams are `/Videos/{id}/stream`, `/Audio/{id}/stream` |
| `list_devices` | `GET /Devices` expecting a list | returns `{"Items": [...]}` → tool always `[]` |
**Fix:**
- `get_item` uses `/Items?Ids=…` with detail fields.
- `play_item` picks a remote-controllable session by `session_id` or a `device` name, or the
only one there is, and errors with the list of candidates otherwise.
- `list_sessions` exposes `id`, `supports_remote_control` and `now_playing`.
- `playback_url` builds the static stream URL **without** the API key.
- `list_devices` unwraps `Items`.
#### EMB-04
**IDs inserted unencoded into URL paths** — *High (security)*
The server authenticates with the **admin** API key, and Javis approves Emby tools
automatically (`approval: allow`). The spec lists `POST /Items/{Id}/Delete`,
`/System/Restart` and `/System/Shutdown`. An item ID like `123/Delete?` in `play_item`
became `POST /Items/123/Delete`, deleting the media item. `../System/Restart#` restarted the
server. Such an ID could come from prompt injection, for example via web search results or
media metadata. **Fix:** every path parameter is percent-encoded as a single segment, and
`get_item` passes the ID as a query value. Verified: `123/Delete` now yields a harmless
lookup error.
#### EMB-05
**Failures returned as data** — *Medium*
`ping`, `playback_url`, `playback_info` and `play_item` caught exceptions and returned
`{"ok": false, "error": …}`. MCP reports those as successful results. **Fix:** they raise,
so FastMCP returns `isError: true`. `ping` keeps its `ok` flag because it is a probe.
#### EMB-06
**Field mapping** — *Low*
`RunTimeNumeric`, `SeasonNumber`, `EpisodeNumber` and `Year` are not Emby fields, so
runtime, year and episode numbers were always empty. The instructions advertised
`list_items(type='Movie')`, but no such parameter existed. A `None` name crashed the pydantic
model. **Fix:**
- Runtime is computed from `RunTimeTicks`.
- Season and episode numbers come from `ParentIndexNumber` and `IndexNumber`.
- `ProductionYear` is requested through `Fields`.
- New `item_type` parameter.
- Aliases removed.
### Homey
#### HOM-01
**Path injection through IDs** — *High (security)*
`homey_client` interpolated `device_id`, `flow_id`, `variable_id` and `capability_id` into
URL paths. httpx normalises dot segments and drops `#…` fragments. Reproduced offline:
```
trigger_flow('../../system/reboot#') → POST /api/manager/system/reboot
set_variable('../../../system/reboot#', 1) → PUT /api/system/reboot
```
**Fix:** IDs are percent-encoded single path segments (`_seg`).
#### HOM-02
**Bearer mode did not protect the SSE transport** — *High (security, LAN mode)*
The auth middleware only checked `/mcp` and the root alias. With
`HOMEY_MCP_TRANSPORT=sse` and `HOMEY_MCP_AUTH_MODE=bearer`, both `GET /sse` and
`POST /messages/` went through unauthenticated. **Fix:** in bearer mode every route
requires the token except `GET /` (discovery) and `/openapi.json`. The token is compared as
bytes, so a non-ASCII header no longer raises inside `compare_digest`.
#### HOM-03
**No DNS-rebinding protection** — *Medium (security, LAN mode)*
FastMCP's Host/Origin validation was switched off (`enable_dns_rebinding_protection=False`),
yet README and UBUNTU_SETUP promise a Host check (421) via
`HOMEY_MCP_ADVERTISED_HOST`. In the default `AUTH_MODE=none`, any web page opened in **any
browser on the LAN** could rebind its domain to the server and control the smart home over
MCP. Without a token the REST API was open the same way. **Fix:** a Host-header allow-list
covers MCP **and** REST and answers `421 Misdirected Request`. It is built from the
advertised host, `localhost`, `127.0.0.1` and `HOMEY_MCP_ALLOWED_HOSTS` (`*` turns it off).
If the bind address is a wildcard and nothing is configured, the server logs a warning.
#### HOM-04
**Realtime events never arrived** — *Medium*
The client connected with `auth={"token"}` and listened for `device.update` on the root
namespace. Athom's `homey-api` (`SocketSession.js`, `SubscriptionRegistry.js`) shows the real
protocol:
1. `handshakeClient {token, homeyId}` returns a namespace (`/api`).
2. Connect to that namespace.
3. `subscribe` to URIs such as `homey:manager:devices` and `homey:device:<id>`.
4. Events arrive named by their URI, with `(event, data)` as payload.
Verified live (read-only): the old code received 0 events. **Fix:** the client implements the
handshake and subscriptions, rebuilds them on reconnect, and buffers compact entries. The
fixed client captured 18 `capability` events in 25 s.
#### HOM-05
**Zone resolution by first substring hit** — *Medium*
`control_zone`, `get_zone_status` and the REST `zones/control` took the first zone whose
name *contained* the query. That depends on dict order: "Wohnzimmer" could switch
"Wohnzimmer Ecke". `control_zone` also matched devices of *other* zones by name substring
when the zone was embedded as an object. **Fix:** a shared `_resolve_zone` prefers an exact
match, then a prefix, then a substring (shortest name wins). Devices are matched by zone ID
only.
#### HOM-06
**`find_devices` too broad** — *Medium*
The query was split into words and a device matched if **any** word hit its name or zone.
"esszimmer licht" therefore returned every light in the house plus every Esszimmer device,
and the model then acts on one of them. **Fix:** all words must match. Only if nothing does
are partial matches returned.
#### HOM-07
**systemd unit** — *Low*
`homey-mcp.service` uses `User=%i`, but `setup-ubuntu.sh` copies it as `homey-mcp.service`
and enables `homey-mcp@$USER.service`, which fails because the template doesn't exist. In
addition, `%h` resolves to `/root` for system units: specifiers ignore `User=`. **Fix:** the
unit is documented and installed as the template `homey-mcp@.service` and uses
`/home/%i/…`.
#### HOM-08
**Error handling** — *Low*
The following returned errors as data instead of raising:
- `control_all`'s safety refusal (`{"error": …}`)
- `get_temperatures` (`[{"error": …}]`)
- `send_notification` (`{"ok": false}`)
In addition, REST handlers returned 500 on invalid JSON. **Fix:** the tools raise, and REST
returns 400.
### DuckDuckGo
#### DDG-01
**Blocking calls on the event loop** — *Medium*
The tools were plain `def` functions. FastMCP calls those directly on its event loop, and a
search took 0.5–2 s (measured). During that time the server could not answer anything else,
including cancellations. **Fix:** the tools are `async` and run `ddgs` in
`asyncio.to_thread`.
#### DDG-02
**Failures look like results** — *Medium*
Rate limits and network errors came back as `"Search failed: …"` text. The Javis host
caches non-`ERROR:` results of cacheable tools, so the failure was replayed for later
identical queries. **Fix:** failures raise `ToolError` (`isError: true`).
#### DDG-03
**Input validation** — *Low*
An empty query raised `DDGSException: query is mandatory` from deep inside the library, and
`safesearch` accepted any value. **Fix:** both are validated up front.
### Spotify
#### SPO-01
**No `isError`** — *Medium*
Every handler caught exceptions and returned `"Error …"` text as a normal result. For Javis,
"Error starting playback: 404 No active device" was a success. **Fix:** a single wrapper in
`index.ts` marks results whose text starts with `Error` and turns thrown errors into
`isError: true`. `adjustVolume`'s "No active device" messages now follow the same
convention. `verify_tools.mjs` counts `isError` as a failure.
#### SPO-02
**Track URIs played as context** — *Medium*
`playMusic` checked only `type === 'track'`. With just `uri: "spotify:track:…"`, which LLMs
often send, the track went into `context_uri` and Spotify rejected it. **Fix:** the kind is
taken from the URI; tracks and episodes go to `uris`.
#### SPO-03
**Token refresh** — *Medium*
Spotify's refresh response may contain a new `refresh_token`
([docs](https://developer.spotify.com/documentation/web-api/tutorials/refreshing-tokens)),
and the code discarded it. Refresh happened only *after* expiry, so a token could expire
mid-request. Concurrent tool calls each refreshed and wrote the file. An expired 6-month
refresh token (`invalid_grant`) produced an opaque error. **Fix:**
- Single-flight refresh, 60 s before expiry.
- A rotated refresh token is saved.
- `invalid_grant` produces the message "refresh token expired or revoked — run
`npm run auth`".
#### SPO-04 – SPO-07
- **SPO-04** `spotify-config.json` (client secret and refresh token) is now written via
temp-file + rename with mode `0600`.
- **SPO-05** Playlist and user IDs are `encodeURIComponent`-ed in raw API paths. Verified:
`../me` stays inside `playlists/…`.
- **SPO-06** `adjustVolume` reads the volume of the device it is about to change.
- **SPO-07** Display fixes:
- `formatDuration(179600)` used to print `2:60`.
- Playlist search lines printed the description as a track count.
- Saved-track numbering ignored the offset for removed items.
### Cross-cutting
#### X-01
**Tool annotations** — *Low*
All tools now carry MCP annotations:
- `readOnlyHint` for lookups.
- `destructiveHint` for:
- Homey's `control_zone`, `control_all` and `trigger_flow`.
- Spotify's removals, playlist edits and reordering.
- `openWorldHint` for web and Spotify calls.
The Javis host now asks before any tool marked `destructiveHint` (and not `readOnlyHint`),
even on servers with `approval: allow`. In practice this covers the satellite power tools.
Turn it off with `mcp.confirm_destructive: false`.
#### X-02
**Dependency pins** — *Low*
`mcp[cli]>=1.2` would install mcp 2.x, which no longer ships `mcp.server.fastmcp`. All three
servers now pin `mcp[cli]>=1.13,<2`. `ddgs>=9.0` is pinned for the renamed package.
#### X-03
**Docs** — *Info*
The READMEs listed 8 of 21 Homey tools, claimed DuckDuckGo "instant answers", gave Emby's
default scheme as https and referenced `d:\Javis` paths. All four READMEs are updated.
#### X-04
**Least privilege** — *Info*
- The Emby admin API key can delete media and restart or shut down the server.
- The Homey personal access token usually carries all scopes.
- Spotify requests `user-read-email`, which no tool uses.
📌 See §6.
#### X-05
**Biome formatting** — *Info*
`npx biome check` reports 8 format errors caused by CRLF line endings. These existed before
the audit. `biome lint` and `tsc` are clean.
---
## 4. Reviewed and rejected
| Claim | Why rejected |
|---|---|
| Spotify `handleSpotifyRequest` swallows real errors ("Unexpected token") | The SDK validates the HTTP status before parsing; only successful non-JSON bodies (player endpoints) reach the parser. |
| Homey `ping` should raise | It is a connectivity probe; `ok: false` with the reason is the intended result. |
---
## 5. Verification
| Check | Result |
|---|---|
| `homey-mcp/.venv` → `pytest tests/test_homey_mcp.py` | 9 passed |
| `emby-mcp/.venv` → `pytest tests/test_emby_mcp.py` | 8 passed |
| `duckduckgo-mcp/.venv` → `pytest tests/test_duckduckgo_mcp.py` | 4 passed |
| `ruff check …/src` + new tests (`F,E9,B`) | clean |
| Spotify `tsc --noEmit`, `biome lint`, `npm run build` | clean |
| Live, read-only: Emby | ping, search, `get_item`, `playback_url` (no key in URL), users, sessions, devices all OK; crafted ID `123/Delete` → harmless lookup error |
| Live, read-only: Homey realtime | handshake + subscriptions, 18 events in 25 s (before: 0) |
| Live, read-only: Spotify over stdio | 28 tools (12 read-only, 5 destructive); search OK; bad and crafted playlist IDs → `isError: true`, path stays encoded |
The new tests:
- **Homey:**
- Encoded IDs.
- Zone ranking.
- All-words search.
- `control_zone` scoped by ID.
- `control_all` refusal.
- Compact events.
- Bearer protection of `/sse` and `/messages/` and the root alias.
- Host guard (421).
- Annotations.
- **Emby:**
- Base-URL forms.
- Encoded IDs.
- Server-side search parameters.
- `get_item` via `/Items?Ids`.
- Session-targeted play.
- Device unwrapping.
- Key-free stream URL.
- Annotations.
- **DuckDuckGo:**
- Search runs in a worker thread.
- Rate limits become `ToolError`.
- Input validation.
- Annotations.
---
## 6. Improvements and best practices
✅ = applied, 📌 = recommendation.
| Source | Guidance | Status |
|---|---|---|
| [MCP spec — Tools](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) | Tool execution failures belong in the result with `isError: true`. | ✅ All four servers (EMB-05, HOM-08, DDG-02, SPO-01) |
| MCP spec — Tools | Annotations describe behaviour; a human should be able to deny invocations. | ✅ Annotations everywhere. ✅ The Javis host asks before `destructiveHint` tools even on `approval: allow` servers (`mcp.confirm_destructive`, default on). |
| [MCP spec — Transports, security warning](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports) | Servers **MUST** validate `Origin`/Host against DNS rebinding, **SHOULD** bind to localhost when local, **SHOULD** authenticate all connections. | ✅ Host guard + auth on every MCP route (HOM-02/03). 📌 Prefer `HOMEY_MCP_AUTH_MODE=bearer` wherever the client can send a header. |
| [MCP — Security best practices](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices) | Local servers: use stdio or require a token on HTTP. Scope minimisation: least-privilege tokens. | ✅ Javis uses stdio for all four. 📌 Homey: create a PAT with only the scopes the tools need (devices, zones, flows, logic, notifications). Emby: consider a restricted user's token instead of the admin API key, or keep Emby on `approval: ask`. Spotify: drop the unused `user-read-email` scope at the next `npm run auth`. |
| OWASP Injection / path traversal guidance | Never build URLs by concatenating untrusted input; encode path parameters. | ✅ Homey, Emby, Spotify (HOM-01, EMB-04, SPO-05) |
| [Spotify — Refreshing tokens](https://developer.spotify.com/documentation/web-api/tutorials/refreshing-tokens) | Store a rotated refresh token; `invalid_grant` means re-authorize; refresh tokens last 6 months. | ✅ SPO-03 |
| Athom `homey-api` (`SocketSession.js`, `SubscriptionRegistry.js`) | Handshake → namespace → URI subscriptions; re-subscribe after reconnect. | ✅ HOM-04 |
| Emby server OpenAPI (`/openapi.json`) | Use documented routes (`/Sessions/{Id}/Playing`, `/Items?Ids=`, `SearchTerm`). | ✅ EMB-02/03 |
| Python asyncio / FastMCP | Never block the event loop; offload sync I/O to threads. | ✅ DDG-01 |
| OWASP LLM prompt-injection guidance | Treat tool output from the web as untrusted data. | ✅ DuckDuckGo server instructions say so. 📌 Same idea for media and device names returned by Emby and Homey. |
| systemd.exec(5) | Specifiers like `%h` describe the service manager's user, not `User=`. | ✅ HOM-07 |
Observations:
- `mcp` 2.x removed FastMCP, so these servers stay on 1.x until they are ported to the new
`MCPServer` API.
- Still open:
- Least-privilege credentials (X-04).
- CRLF line endings in the Spotify sources (X-05).
+386
View File
@@ -0,0 +1,386 @@
# Javis Slave & Slave-MCP — Code Audit
**Scope:** [javis-slave](../javis-slave) (satellite app: touch GUI + headless/Raspberry Pi
mode, ~3.6 k lines) and [javis-slave-mcp](../javis-slave-mcp) (MCP server exposing the
satellites' controls to the master, ~1 k lines), including `pyproject.toml`,
`setup-pi.sh`, the systemd unit and the READMEs.
**Method** — same as [AUDIT.md](AUDIT.md): every file read line by line, `ruff
--select F,E9,B` baseline (8 findings), and each claim reproduced where possible:
* the MCP tool failure was reproduced by calling the tool through FastMCP
(`mcp` 1.29.0 in `javis-slave-mcp/.venv`);
* the unauthenticated control API was reproduced with FastAPI's `TestClient`;
* the Windows volume bug was reproduced in a scratch venv with the current `pycaw`
(20260927, comtypes 1.4.17).
Line numbers refer to the audited revision `0246cbb6`.
---
## 1. Architecture
```mermaid
flowchart LR
subgraph Satellite["javis-slave (tablet / Raspberry Pi)"]
MIC[MicStream + resampler] --> WW[openWakeWord]
MIC --> REC[record_utterance + VAD]
ENG[AudioEngine] --> SPK[play_pcm]
CTRL["Control API :8766<br/>/mcp/v1/*"] --> SYS[volume / brightness / power]
UI[Touch UI or headless log]
end
WW --> ENG
REC --> ENG
ENG -- "POST /api/v1/turn (Bearer key)" --> MASTER[Javis master :8765]
ENG -- "GET /api/v1/config (60 s poll)" --> MASTER
MCP[javis-slave-mcp<br/>FastMCP stdio] -- HTTP --> CTRL
MASTER -- spawns --> MCP
CTRL -- "pass-through /api/v1/slaves" --> MASTER
```
* The satellite owns the wake word and end-pointing; the master does STT → LLM → TTS
and returns WAV.
* The control API is a FastAPI app served by uvicorn inside the same event loop
(qasync in GUI mode, `asyncio.run` headless).
* `javis-slave-mcp` is spawned by the master's MCP host and calls each configured
satellite's control API.
---
## 2. Findings summary
| ID | Sev. | Component | Title | Status |
|---|---|---|---|---|
| [MCP-S1](#mcp-s1) | Critical | slave-mcp | Every device tool fails: `asyncio.run()` inside the server's event loop | Fixed |
| [SLV-01](#slv-01) | Critical | slave / security | Control API (power, mic, volume, master pass-through) has no authentication | Fixed |
| [SLV-02](#slv-02) | High | slave / Windows | Volume control broken with current `pycaw` | Fixed |
| [SLV-03](#slv-03) | Medium | slave | Turn upload / pairing retried after a read timeout → duplicate turn, burnt code | Fixed |
| [SLV-04](#slv-04) | Medium | slave | Failed engine start leaks the open microphone (ALSA: busy forever) | Fixed |
| [SLV-05](#slv-05) | Medium | slave | Playback thread keeps writing while the stream is closed | Fixed |
| [SLV-06](#slv-06) | Medium | slave / security | Master-supplied wake-word name used as a file path | Fixed |
| [SLV-07](#slv-07) | Medium | slave | Corrupt config silently replaced → device_id and pairing lost | Fixed |
| [SLV-08](#slv-08) | Medium | slave / security | `slave_config.json` (API key) world-readable on Linux | Fixed |
| [MCP-S2](#mcp-s2) | Medium | slave-mcp | `slave_get_ip_address` looks up the alias instead of the device UUID | Fixed |
| [MCP-S3](#mcp-s3) | Medium | slave-mcp | Power tools not marked destructive | Fixed (annotations) |
| [SLV-09](#slv-09) | Low | slave / Windows | Power commands report success regardless of exit code | Fixed |
| [SLV-10](#slv-10) | Low | slave | Retry counter double-counted; misleading "re-pair manually" error | Fixed |
| [SLV-11](#slv-11) | Low | slave | 30 ms chunks zero-padded to Silero's 512-sample window | Fixed |
| [SLV-12](#slv-12) | Low | slave | Mic callback can raise during shutdown | Fixed |
| [SLV-13](#slv-13) | Low | slave | Fire-and-forget tasks without strong references | Fixed |
| [SLV-14](#slv-14) | Low | slave / GUI | Transcript bubbles render LLM text as rich text | Fixed |
| [MCP-S4](#mcp-s4) | Low | slave-mcp | `JAVIS_SLAVE_DEVICES` parsing fragile | Fixed |
| [MCP-S5](#mcp-s5) | Low | slave-mcp | 25 copy-pasted tool bodies, client per call, README drift | Fixed |
| [LINT-S](#lint-s) | Low | both | Ruff F/B findings | Fixed |
| [SLV-15](#slv-15) | Info | slave | `windows_control.py` is an unused copy of `system_control.py` | Open (delete?) |
| [SLV-16](#slv-16) | Info | slave | uvicorn's signal capture overrides the headless SIGTERM handler | Fixed (pin) |
| [SLV-17](#slv-17) | Info | slave / security | Plain HTTP; the satellite does not authenticate the master | Open (recommendation) |
| [SLV-18](#slv-18) | Info | Pi deployment | systemd unit has no sandboxing | Open (recommendation) |
---
## 3. Detailed findings
### javis-slave-mcp
#### MCP-S1
**Every device tool fails: `asyncio.run()` inside the server's event loop** — *Critical*
All 24 device tools are plain `def` functions that call
`asyncio.run(_slaves[id].get_status())` (server.py L319–L816). FastMCP 1.x calls a
synchronous tool **directly on its running event loop**
(`FuncMetadata.call_fn_with_arg_validation`: `return fn(**args)`), and `asyncio.run`
refuses to start a second loop there. Reproduced:
```
slave_get_volume → ERROR: asyncio.run() cannot be called from a running event loop
```
So "mach die Küche leiser" could never work; the master only ever got `ERROR:` back.
**Fix:** all tools are `async def` and await the client directly; one `_run()` helper
raises failures as `ToolError`, which FastMCP returns as a result with
`isError: true` (the master's MCP host turns that into its usual `ERROR:` text).
#### MCP-S2
**`slave_get_ip_address` looks up the alias instead of the device UUID** — *Medium*
`get_slave_ip(device_id)` passes the MCP alias (`"localhost"`, `"kitchen"`) to
`/mcp/v1/master/slaves/{id}/ip`, but the master keys satellites by their UUID, so the
lookup always returned 404. **Fix:** the tool first reads the satellite's real
`device_id` from `/mcp/v1/status`.
#### MCP-S3
**Power tools not marked destructive** — *Medium*
`slave_shutdown`, `slave_restart_system`, `slave_sleep` are indistinguishable from
read-only tools, and the shipped `mcp_servers.json` sets `"approval": "allow"` for this
server, so a mis-heard request can power off a satellite without confirmation.
**Fix:** MCP tool annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`)
on every tool. The master's MCP host now asks for approval for `destructiveHint`
tools even on `allow` servers (`mcp.confirm_destructive`, default `true`; added in the
review pass after [AUDIT_MCP.md](AUDIT_MCP.md)).
#### MCP-S4
**`JAVIS_SLAVE_DEVICES` parsing fragile** — *Low*
Keys were not stripped (`"a=…, kitchen=…"` registered `" kitchen"`), and one malformed
entry aborted parsing of all following ones. **Fix:** per-entry parsing with stripped
keys and a warning per bad entry; optional token per entry.
#### MCP-S5
**Duplication and drift** — *Low*
24 copies of the same try/`asyncio.run`/except block, a new `httpx.AsyncClient` per
call, unused imports, and README entries that don't match the code
(`slave_sleep` has no `delay`; brightness steps default to 5, not 10).
**Fix:** one shared client per satellite (`trust_env=False`, so the token is never sent
through an environment proxy), one `_run()` helper, README corrected.
### javis-slave
#### SLV-01
**Control API has no authentication** — *Critical*
`build_http_app` (http_server.py) registers `/mcp/v1/power/{sleep,shutdown,restart}`,
mic mute, volume, brightness, engine restart and the master pass-through
`/mcp/v1/master/slaves` without any auth, and `api_host` defaults to `0.0.0.0`.
Reproduced: `POST /mcp/v1/power/cancel` from an unauthenticated client → **200**.
* Anyone on the LAN can power off / reboot the satellite, silence its microphone or
read the master's full device list (names, rooms, IPs) through the pass-through,
which uses the satellite's own master credentials.
* Even on loopback, any web page open on the device can trigger them: a body-less
`POST` is a CORS "simple request", so the browser sends it without a preflight
(classic CSRF).
**Fix:**
* each satellite gets a random `control_token` (generated on first start, stored in
its config; `--control-token` sets a household-wide one, `--show-control-token`
prints it, the settings dialog shows it);
* every endpoint except `/mcp/v1/health` requires `Authorization: Bearer <token>`
(constant-time compare);
* zero-config compatibility: callers on **loopback** may omit the token but must send
`X-Javis-Control: 1`. A browser cannot add that header cross-origin without a
preflight, which this server never grants, so CSRF stays blocked;
* `javis-slave-mcp` sends the header always and the token from `JAVIS_SLAVE_TOKEN`
or per device.
#### SLV-02
**Volume control broken with current `pycaw`** — *High*
`VolumeController` calls `AudioUtilities.GetSpeakers().Activate(IAudioEndpointVolume._iid_, 0, None)`
and uses the result directly. Since pycaw 2024 `GetSpeakers()` returns an
`AudioDevice` wrapper **without** `Activate` (it exposes `.EndpointVolume`); on older
pycaw the returned `IUnknown` pointer also needed a `cast` to `IAudioEndpointVolume`.
Reproduced: `AttributeError: 'AudioDevice' object has no attribute 'Activate'` → the
controller disables itself and every volume endpoint fails on Windows.
**Fix:** use `EndpointVolume` when present, else the documented
`cast(Activate(..., CLSCTX_ALL, None), POINTER(IAudioEndpointVolume))`. The endpoint is
resolved per call, so it follows default-device changes and no COM pointer is shared
between `asyncio.to_thread` workers (calling from workers was verified to work).
#### SLV-03
**Turn upload / pairing retried after a read timeout** — *Medium*
`_retryable_request` retries on any `httpx.HTTPError`, including `ReadTimeout` /
`RemoteProtocolError`, i.e. after the master already received the request.
`send_turn` (`retries=1`) then re-runs the whole STT → LLM → tools turn ("add milk to
the list" twice), and `pair` re-submits a single-use code that was already consumed
(the user sees "code invalid" although pairing succeeded on the master).
**Fix:** non-idempotent calls only retry when the connection could not be established
(`ConnectError`, `ConnectTimeout`).
#### SLV-04
**Failed engine start leaks the open microphone** — *Medium*
`AudioEngine.start()` opens the mic, then builds the VAD and loads/downloads the wake
model. If the latter fails (custom model not on the master, ONNX error), the started
`InputStream` is never closed. Both the GUI and headless runners retry every 10 s with
a new engine: each retry opens another stream; on a Raspberry Pi with an ALSA `hw:`
device the second open fails with "Device or resource busy", so the satellite never
recovers without a restart. **Fix:** `start()` stops everything it opened on failure.
#### SLV-05
**Playback thread keeps writing while the stream is closed** — *Medium*
Same defect as master AGT-06: `play_pcm` awaits `asyncio.to_thread(_writer)`. Restart,
re-pair or shutdown during a reply cancels the engine task; `to_thread` returns at
once while the thread keeps calling `stream.write()`, and the `finally` block calls
`stop()/close()` concurrently. **Fix:** cancellation-safe block writer that stops and
joins the writer thread before the stream is closed.
#### SLV-06
**Master-supplied wake-word name used as a file path** — *Medium*
`_load_wake` downloads to `config_dir()/models/f"{name}.onnx"` with `name` taken from
the master's `/config` response. Over plain HTTP (SLV-17), a spoofed or compromised
master could send `../../.config/autostart/x` and write files outside the models
folder. **Fix:** names containing path separators or `..` are rejected.
#### SLV-07
**Corrupt config silently replaced** — *Medium*
`load_config()` swallows any parse error and returns fresh defaults, including a
**new random `device_id`**; both entry points immediately `save_config()` it. A
truncated `slave_config.json` (SD card + power loss — `tmp.replace` without `fsync`
is not durable) therefore wipes the pairing and the satellite reappears as a new device.
**Fix:** unreadable files are preserved as `slave_config.json.corrupt` with an error
log; saves `fsync` before the atomic replace; values from JSON are type-checked.
#### SLV-08
**`slave_config.json` (API key) world-readable on Linux** — *Medium*
The file holds the master API key (and now the control token) but is created with the
default umask (0644): every local user on the Pi can read it. **Fix:** `chmod 600` on
POSIX after every write.
#### SLV-09
**Windows power commands report success regardless of exit code** — *Low*
`subprocess.run(["shutdown", ...], check=False)` → always `True`, e.g.
`/mcp/v1/power/cancel` said "cancelled" when nothing was pending (exit 1116), and a
console window flashed in GUI mode. **Fix:** shared runner with timeout,
`CREATE_NO_WINDOW` and return-code check (exit 1116 "no shutdown in progress" counts as
success for *cancel*). `sleep` is started without waiting: `SetSuspendState` only
returns after resume, which held the HTTP reply until the machine woke up.
#### SLV-10
**Retry counter double-counted; misleading error** — *Low*
`_retryable_request` already calls `mark_disconnected()` per attempt; `_handle_turn`
and `_config_poll_loop` called it again, so the counter grew twice as fast. After 10
the UI said "Maximale Wiederverbindungsversuche erreicht. Bitte manuell neu koppeln."
although polling continues and recovers automatically. **Fix:** single bookkeeping
point; the message now says reconnection keeps being attempted.
#### SLV-11
**30 ms chunks zero-padded** — *Low*
Same as master AUD-02. **Fix:** 32 ms (512 samples @ 16 kHz).
#### SLV-12
**Mic callback can raise during shutdown** — *Low*
Same as master AUD-04. **Fix:** guarded.
#### SLV-13
**Fire-and-forget tasks** — *Low*
`ensure_future()` results discarded for chimes, wake-model swaps, pairing, mute,
HTTP-server start, engine retry. **Fix:** small `spawn()` helper keeping references
and logging failures.
#### SLV-14
**Transcript bubbles render LLM text as rich text** — *Low*
`QLabel(f"🤖 {reply_text}")` uses `Qt::AutoText`; replies containing HTML (e.g.
echoed from web search results) are rendered, including `<img src="file:…">`.
**Fix:** `Qt.PlainText` for user/assistant/error bubbles.
#### SLV-15
**`windows_control.py` is an unused copy** — *Info*
It differs from `system_control.py` only in the logger import and is imported nowhere.
Recommend deleting it (left in place pending your confirmation).
#### SLV-16
**uvicorn overrides the headless SIGTERM handler** — *Info*
`Server.serve()` installs its own SIGINT/SIGTERM handlers after the headless runner
registered `stop.set`. uvicorn ≥ 0.29 re-raises captured signals when it exits, so
shutdown still works — older versions (the spec allowed `>=0.24`) would leave
`systemctl stop` hanging until SIGKILL. **Fix:** `uvicorn>=0.29`.
#### SLV-17
**Plain HTTP; the satellite does not authenticate the master** — *Info*
API key, transcripts and reply audio travel unencrypted, and anything answering on the
master's IP is trusted (wake-word settings, model downloads). 📌 Run the master behind
TLS (Caddy) or keep satellites on a trusted VLAN; SLV-06 limits the worst case.
#### SLV-18
**systemd unit has no sandboxing** — *Info*
📌 Consider `ProtectSystem=full`, `ProtectHome=read-only`, `PrivateTmp=yes`,
`ReadWritePaths=/opt/javis-slave/config` (sudo for `shutdown` keeps working;
`NoNewPrivileges=yes` would not). Not applied: it can't be tested here, and
`ProtectHome` breaks installs under `/home`; see §6 for the options that must stay off.
#### LINT-S
Unused imports (`SlaveConfig`, `Any`, `Context`, `Field`), missing exception chaining
(B904 ×2), f-strings without placeholders (F541 ×2). **Fixed.**
---
## 4. Reviewed and rejected
| Claim | Why rejected |
|---|---|
| Stateful resampler drops/duplicates samples between blocks | Phase carry-over and the `_last` sample are handled correctly; output length tracks the ratio. |
| `MicStream.stop()` can block a consumer forever | `_drain_and_sentinel` evicts until the sentinel fits. |
| Mute race between API and UI | `_mute_lock` + `_unmuted` event serialise it. |
| Pairing code brute force from the satellite side | Codes are validated and rate-limited on the master (see master API audit). |
---
## 5. Verification
| Check | Result |
|---|---|
| `ruff check javis-slave/src javis-slave-mcp/src --select F,E9,B` | clean (was 8 findings) |
| `pytest tests` (main venv) | 59 passed, 1 skipped (`test_slave_mcp.py` needs mcp 1.x) |
| `javis-slave-mcp/.venv` → `pytest tests/test_slave_mcp.py` | 5 passed |
| Offscreen GUI smoke (`SlaveWindow`, bubbles, settings dialog) | plain-text bubbles, token field masked |
New regression tests:
* [tests/test_slave_audit.py](../tests/test_slave_audit.py):
* Config: a corrupt file is kept as `.corrupt`, loading is type-checked, and the token round-trips.
* Control API:
* LAN callers need the Bearer token.
* Loopback callers need `X-Javis-Control`.
* Plain loopback requests (CSRF) and empty tokens are refused.
* `/docs` is gone.
* Master client: `send_turn` does not retry after `ReadTimeout`, but retries a `ConnectError` once.
* Audio:
* A failed engine start releases the mic.
* Wake-model names with path parts are rejected before any download.
* Cancelled playback stops writing before the stream can be closed.
* System control: the pycaw `EndpointVolume` path and Windows exit-code handling.
* [tests/test_slave_mcp.py](../tests/test_slave_mcp.py):
* Env parsing skips bad entries and never puts the token in `repr`.
* A tool called through `FastMCP.call_tool` on a running loop resolves the real UUID and sends both auth headers.
* 401 responses and unknown devices become tool errors.
* Power tools carry `destructiveHint`.
No test calls a real power, volume or brightness endpoint. The controllers are faked, or the
auth middleware rejects the request before the handler runs.
---
## 6. Improvements and best practices
Researched after the fix pass; ✅ = applied, 📌 = recommendation.
| Source | Guidance | Status |
|---|---|---|
| [MCP spec — Tools](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) | Tool execution failures belong in the result with `isError: true`. Protocol errors are for unknown tools and bad arguments. | ✅ `ToolError` from `_run()` |
| MCP spec — Tools | Annotations describe behaviour, but clients **MUST** treat them as untrusted unless the server is trusted. A human **SHOULD** be able to deny invocations. | ✅ Annotations on all 23 tools. ✅ The master's MCP host asks before `destructiveHint` tools, even on `approval: allow` servers. |
| MCP spec — Tools, security | Servers **MUST** validate inputs and implement access control, and **SHOULD** rate-limit. | ✅ FastAPI `Query(ge/le)` bounds + auth. 📌 No rate limit on power endpoints yet. |
| [OWASP CSRF cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) | Custom request headers are a valid CSRF defence for APIs because they force a CORS preflight. Never change state via `GET`. Compare secrets in constant time. | ✅ `X-Javis-Control` on loopback, no CORS middleware, all state changes use `POST`, `hmac.compare_digest`. 📌 Optionally also reject `Sec-Fetch-Site: cross-site`. |
| [uvicorn 0.29.0 release](https://github.com/encode/uvicorn/releases/tag/0.29.0) | "Cooperative signal handling" (#1600): captured signals are re-raised after shutdown. | ✅ `uvicorn>=0.29` |
| [pycaw README](https://github.com/AndreMiras/pycaw) | Current API: `AudioUtilities.GetSpeakers().EndpointVolume`. | ✅ Used, with an `Activate`+`cast` fallback for old pycaw |
| [systemd.exec(5)](https://man7.org/linux/man-pages/man5/systemd.exec.5.html) | Enable `ProtectSystem=` for all long-running services and `ProtectHome=` for network-facing ones. `NoNewPrivileges=` blocks setuid, `ProtectKernelTunables=` makes `/sys` read-only, and `PrivateDevices=` removes physical devices. | 📌 Safe set for this unit: `ProtectSystem=full`, `PrivateTmp=yes`, `ProtectHome=read-only` (only for `/opt` installs). These must stay **off**: `NoNewPrivileges` (sudo power control), `ProtectKernelTunables` (backlight), `PrivateDevices` (audio). |
| systemd.exec(5), *Environment=* | Environment variables are not suitable for secrets: they are visible via D-Bus and inherited by child processes. | 📌 For remote satellites, prefer `"token"` in `~/.javis/slave_mcp_config.json` (keep it 0600) over `JAVIS_SLAVE_TOKEN` in `mcp_servers.json`. |
| Python `hmac.compare_digest` / `secrets.token_urlsafe` | Use constant-time comparison and CSPRNG tokens. | ✅ 24-byte URL-safe token, constant-time check |
Observations (no action):
* `mcp` 1.29 with the current `pydantic-settings` prints an `IncompleteFieldDefinitionWarning`
(`lifespan`) when `FastMCP()` is constructed. This is an upstream SDK issue and harmless.
* `mcp` 2.x removed `mcp.server.fastmcp`, so `javis-slave-mcp` now pins `mcp>=1.10,<2.0`
(tool annotations need ≥ 1.10). The master's MCP host already supports both.
* Still open:
* SLV-15: delete `windows_control.py`?
* SLV-17: TLS.
* SLV-18: sandboxing.
* Rate limiting.