v1.0
This commit is contained in:
1 parent
0246cbb6ba
commit
0ca4b4bf7a
89 files changed
+7042
-2480
No files matched your search
+612
@@ -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
|
||||
```
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user