Stakia host: local speech, conversation controls, authenticated TLS - #3
Conversation
…sation controls Split host and firmware review to stay within Sourcery's review-size limit. Host tests cover real TLS, credentials, logging, configuration, and pinned upstream idle and startup behavior.
Reviewer's GuideThis host-side upgrade moves Stakia to a pinned standalone voice server with local speech and LM Studio defaults, adds persisted conversation controls and bounded history, and secures robot connectivity with locally generated TLS and pre-upgrade credential authentication. The implementation includes fail-closed asset/config provisioning, localhost-only administration, atomic private secret writes, extensive real-socket and patched-upstream regression tests, and CI coverage while explicitly leaving full Docker, inference, firmware, and physical bench validation for later. Sequence diagram for authenticated TLS robot connectionsequenceDiagram
participant Robot as StackChan robot
participant Server as Voice server
participant CA as Local CA trust store
Robot->>Server: TLS handshake
Server-->>Robot: Server certificate
Robot->>CA: Validate certificate and hostname
alt Certificate or hostname invalid
CA-->>Robot: Reject connection
else Certificate trusted
Robot->>Server: WebSocket upgrade with Bearer credential
Server->>Server: authorized(config, headers)
alt Credential missing or incorrect
Server-->>Robot: Reject upgrade
else Credential valid
Server-->>Robot: Accept WebSocket
Robot->>Server: Voice exchange
end
end
Flow diagram for fail-closed host provisioningflowchart TD
Start[Start host configuration] --> Assets[Validate local speech assets]
Assets -->|Missing assets| Stop1[Stop with validation error]
Assets -->|Complete| Identity[Ensure local CA certificate and robot credential]
Identity -->|Missing or mismatched identity| Stop2[Stop without fallback]
Identity --> Render[Render pinned server configuration]
Render --> Write[Atomically write private config files]
Write --> StartServer[Start secure voice server]
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 3 issues
Fixed security issues:
- Insecure WebSocket Detected. WebSocket Secure (wss) should be used for all WebSocket connections. (link)
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="bridge.py" line_range="944-949" />
<code_context>
loop = asyncio.get_event_loop()
system = _build_system_prompt()
+ request_messages = bounded_dialogue(messages or [])
+ if not request_messages:
+ request_messages = [
+ {"role": "system", "content": system},
+ {"role": "user", "content": text},
+ ]
def _stream():
resp = req.post(
LLM_API_URL,
json={
"model": LLM_MODEL,
- "messages": [
- {"role": "system", "content": system},
- {"role": "user", "content": text},
- ],
+ "messages": request_messages,
"max_tokens": LLM_MAX_TOKENS,
"temperature": 0.7,
</code_context>
<issue_to_address>
**issue (bug_risk):** When `MessageIn.messages` contains any valid message but does not include the current user turn, `_llm_prompt` sends those messages and silently ignores the required `text` argument. A request such as `content="hello"` with a system-only history therefore reaches the LLM without the user's message.
**Triggers:** When a caller supplies a non-empty but incomplete `messages` history.
**Suggested fix:** Append the current user message when the supplied history does not already contain the current turn, or reject histories that do not end in the current user content.
```suggestion
request_messages = bounded_dialogue(messages or [])
if not request_messages:
request_messages = [
{"role": "system", "content": system},
{"role": "user", "content": text},
]
elif request_messages[-1] != {"role": "user", "content": text}:
request_messages.append({"role": "user", "content": text})
```
</issue_to_address>
### Comment 2
<location path="bridge/dashboard.py" line_range="362" />
<code_context>
+ idle_minutes: int | None = Form(None),
+ idle_farewell: str | None = Form(None),
+) -> Any:
+ idle = None if idle_mode == "never" else idle_minutes
+ settings = HostSettings(
+ active_profile=profile, pause_seconds=pause_seconds,
+ idle_minutes=idle, idle_farewell=idle_farewell == "on",
+ )
+ lan_host = os.environ.get("STAKIA_LAN_HOST", "")
+ write_server_config(settings, lan_host=lan_host, path=SERVER_CONFIG_PATH)
</code_context>
<issue_to_address>
**issue (bug_risk):** When `idle_mode` is `finite` but `idle_minutes` is omitted or malformed, `idle` becomes `None` and the saved `HostSettings` silently selects Never instead of finite idle behavior. The HTML input is not marked `required`, so this state is reachable by a normal form submission or a direct POST.
**Triggers:** When the finite-idle form submission has no usable `idle_minutes` value.
**Suggested fix:** Require and validate `idle_minutes` whenever `idle_mode == "finite"`, returning a 4xx validation error instead of saving Never.
```suggestion
if idle_mode == "finite" and (
idle_minutes is None or not 1 <= idle_minutes <= 1440
):
raise HTTPException(
status_code=422,
detail="idle_minutes must be 1..1440 when idle mode is finite",
)
idle = None if idle_mode == "never" else idle_minutes
```
</issue_to_address>
### Comment 3
<location path="bridge/dashboard.py" line_range="364-369" />
<code_context>
+) -> Any:
+ idle = None if idle_mode == "never" else idle_minutes
+ settings = HostSettings(
+ active_profile=profile, pause_seconds=pause_seconds,
+ idle_minutes=idle, idle_farewell=idle_farewell == "on",
+ )
+ lan_host = os.environ.get("STAKIA_LAN_HOST", "")
+ write_server_config(settings, lan_host=lan_host, path=SERVER_CONFIG_PATH)
+ HOST_SETTINGS.save(settings)
+ return templates.TemplateResponse(
+ request, "host_settings.html", {"settings": settings, "saved": True}
</code_context>
<issue_to_address>
**issue (bug_risk):** Invalid dashboard form values and configuration failures propagate out of the POST handler as unhandled exceptions, producing an HTTP 500 response rather than a validation error or preserving a useful form state. This includes values outside the `HostSettings` bounds and failures from private-address, credential, persona, or security-identity validation.
**Triggers:** When a user submits an out-of-range setting or the local identity/persona/configuration is unavailable.
**Suggested fix:** Catch `ValueError` around settings construction and config rendering, return a 400 response with the validation error, and save only after all validation succeeds.
</issue_to_address>Sourcery assessment
Needs a human reviewer. 3 findings to address first, and this changes the trust boundary for every robot connection by replacing the existing transport with locally generated TLS and bearer-credential authentication, while also changing the container build and firmware connection path. If that decision or implementation is wrong, robots could be unable to connect or an unauthorized LAN client could gain access; reverting prevents further impact but cannot undo any access already granted or credentials already exposed.
Blocking findings: bridge.py:949, bridge/dashboard.py:362, bridge/dashboard.py:369
| request_messages = bounded_dialogue(messages or []) | ||
| if not request_messages: | ||
| request_messages = [ | ||
| {"role": "system", "content": system}, | ||
| {"role": "user", "content": text}, | ||
| ] |
There was a problem hiding this comment.
issue (bug_risk): When MessageIn.messages contains any valid message but does not include the current user turn, _llm_prompt sends those messages and silently ignores the required text argument. A request such as content="hello" with a system-only history therefore reaches the LLM without the user's message.
Triggers: When a caller supplies a non-empty but incomplete messages history.
Suggested fix: Append the current user message when the supplied history does not already contain the current turn, or reject histories that do not end in the current user content.
| request_messages = bounded_dialogue(messages or []) | |
| if not request_messages: | |
| request_messages = [ | |
| {"role": "system", "content": system}, | |
| {"role": "user", "content": text}, | |
| ] | |
| request_messages = bounded_dialogue(messages or []) | |
| if not request_messages: | |
| request_messages = [ | |
| {"role": "system", "content": system}, | |
| {"role": "user", "content": text}, | |
| ] | |
| elif request_messages[-1] != {"role": "user", "content": text}: | |
| request_messages.append({"role": "user", "content": text}) |
| idle_minutes: int | None = Form(None), | ||
| idle_farewell: str | None = Form(None), | ||
| ) -> Any: | ||
| idle = None if idle_mode == "never" else idle_minutes |
There was a problem hiding this comment.
issue (bug_risk): When idle_mode is finite but idle_minutes is omitted or malformed, idle becomes None and the saved HostSettings silently selects Never instead of finite idle behavior. The HTML input is not marked required, so this state is reachable by a normal form submission or a direct POST.
Triggers: When the finite-idle form submission has no usable idle_minutes value.
Suggested fix: Require and validate idle_minutes whenever idle_mode == "finite", returning a 4xx validation error instead of saving Never.
| idle = None if idle_mode == "never" else idle_minutes | |
| if idle_mode == "finite" and ( | |
| idle_minutes is None or not 1 <= idle_minutes <= 1440 | |
| ): | |
| raise HTTPException( | |
| status_code=422, | |
| detail="idle_minutes must be 1..1440 when idle mode is finite", | |
| ) | |
| idle = None if idle_mode == "never" else idle_minutes |
| active_profile=profile, pause_seconds=pause_seconds, | ||
| idle_minutes=idle, idle_farewell=idle_farewell == "on", | ||
| ) | ||
| lan_host = os.environ.get("STAKIA_LAN_HOST", "") | ||
| write_server_config(settings, lan_host=lan_host, path=SERVER_CONFIG_PATH) | ||
| HOST_SETTINGS.save(settings) |
There was a problem hiding this comment.
issue (bug_risk): Invalid dashboard form values and configuration failures propagate out of the POST handler as unhandled exceptions, producing an HTTP 500 response rather than a validation error or preserving a useful form state. This includes values outside the HostSettings bounds and failures from private-address, credential, persona, or security-identity validation.
Triggers: When a user submits an out-of-range setting or the local identity/persona/configuration is unavailable.
Suggested fix: Catch ValueError around settings construction and config rendering, return a 400 response with the validation error, and save only after all validation succeeds.
Address Sourcery findings: ensure incomplete history cannot drop the current message, reject blank or malformed finite idle durations, and return usable validation errors before settings are saved.
There was a problem hiding this comment.
🟡 Changes recommended
Default configuration expands an optional OpenRouter secret, setup commands omit required directories, and disabling HTTP leaves retained dashboard controls unusable.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR converts the host runtime to a locally hosted, TLS-authenticated Stakia voice service with configurable conversation behavior and regression coverage.
Changes:
- Adds local Whisper, Silero, Piper, and LM Studio/OpenRouter configuration.
- Adds persisted pause, idle, farewell, and bounded-history controls.
- Adds TLS provisioning, robot authentication, pinned Docker deployment, Windows scripts, and CI tests.
File summaries
| File | Description |
|---|---|
tests/test_upstream_startup.py |
Validates pinned startup authentication ordering. |
tests/test_upstream_secure_transport.py |
Tests patched TLS listener and credential handling. |
tests/test_upstream_idle_patch.py |
Tests patched idle behavior. |
tests/test_secure_transport.py |
Tests certificates, authentication, and permissions. |
tests/test_idle_policy.py |
Tests idle policy helpers. |
tests/test_host_settings.py |
Tests settings, profiles, and local assets. |
tests/test_dialogue_relay.py |
Tests bounded dialogue forwarding. |
tests/test_compose_local_providers.py |
Tests provider mounts and prompt content. |
scripts/start-host.ps1 |
Adds Windows host startup automation. |
scripts/start-host.bat |
Adds batch startup wrapper. |
scripts/doctor-host.ps1 |
Adds host diagnostics. |
scripts/doctor-host.bat |
Adds batch diagnostics wrapper. |
scripts/configure-host.py |
Renders validated runtime configuration. |
README.md |
Documents setup, models, security, and validation. |
docs/upgrade-plan.md |
Links current implementation documentation. |
docs/pr-2-review.md |
Records security review resolutions. |
docs/implementation-status.md |
Records implementation status and limits. |
docker/server.Dockerfile |
Builds the pinned patched server image. |
docker-compose.yml |
Configures secure local deployment and mounts. |
custom-providers/zeroclaw/zeroclaw.py |
Forwards bounded dialogue history. |
custom-providers/xiaozhi-patches/secure-transport.patch |
Adds TLS and pre-upgrade authentication. |
custom-providers/xiaozhi-patches/never-idle.patch |
Adds standalone and configurable idle behavior. |
custom-providers/stakia_transport.py |
Implements fail-closed TLS and token checks. |
custom-providers/asr/whisper_local.py |
Prevents missing-model downloads. |
config/stakia-local-prompt.txt |
Provides the local prompt template. |
bridge/templates/host_settings.html |
Adds conversation settings UI. |
bridge/templates/dashboard.html |
Embeds settings in the dashboard. |
bridge/security.py |
Provisions private certificates and credentials. |
bridge/requirements.txt |
Adds certificate-generation dependency. |
bridge/idle_policy.py |
Defines shared idle semantics. |
bridge/host_settings.py |
Persists settings and renders server config. |
bridge/dialogue.py |
Bounds portable dialogue history. |
bridge/dashboard.py |
Adds settings endpoints. |
bridge.py |
Adds local defaults and optional history forwarding. |
.gitignore |
Ignores generated runtime and security files. |
.github/workflows/stakia-checks.yml |
Adds host, TLS, upstream, and firmware checks. |
.gitattributes |
Configures patch whitespace handling. |
.env.local.example |
Documents local and optional cloud configuration. |
Review details
- Files reviewed: 37/38 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| profiles = { | ||
| "lmstudio": {"type": "openai", "base_url": os.getenv("LMSTUDIO_BASE_URL", "http://host.docker.internal:1234/v1"), | ||
| "model_name": os.getenv("LMSTUDIO_MODEL", "local-model"), "api_key": os.getenv("LMSTUDIO_API_KEY", "lm-studio")}, | ||
| "openrouter": {"type": "openai", "base_url": "https://openrouter.ai/api/v1", | ||
| "model_name": os.getenv("OPENROUTER_MODEL", "openai/gpt-4.1-mini"), "api_key": "${OPENROUTER_API_KEY}"}, |
| } | ||
| name = "LMStudio" if settings.active_profile == "lmstudio" else "OpenRouter" | ||
| timeout = None if settings.idle_minutes is None else settings.idle_minutes * 60 | ||
| persona_path = persona_path or Path(__file__).parent.parent / "personas/feisty.md" |
| raise ValueError(f"local persona is unavailable: {persona_path}") from exc | ||
| return expand_env({ | ||
| "standalone_config": True, | ||
| "server": {"ip": "0.0.0.0", "port": 8000, "http_port": 0, |
| Invoke-WebRequest https://raw.githubusercontent.com/snakers4/silero-vad/master/src/silero_vad/data/silero_vad.onnx -OutFile data/models/silero/src/silero_vad/data/silero_vad.onnx | ||
| Invoke-WebRequest https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx -OutFile data/models/piper/voice.onnx | ||
| Invoke-WebRequest https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json -OutFile data/models/piper/voice.onnx.json |
This is the host portion of the existing Stakia upgrade, split from PR #2 so both changes fit Sourcery's 150,000-character review limit. No executable changes or security tests are hidden from review. PR #2 adds the firmware on top of this branch; the complete checkout remains codex/stakia-upgrade.
The previous setup cut conversations short and depended on vendor services. This adds persisted pause, Never/finite idle, and farewell controls; local Whisper/Silero/Piper and LM Studio; explicit OpenRouter selection; bounded conversation history; and a pinned standalone voice server without vendor bootstrap or inherited cloud defaults.
Security: native TLS, a locally generated private CA, a random 256-bit robot credential checked before WebSocket upgrade, no plaintext/anonymous fallback, disabled HTTP listener, localhost-only dashboard, no header credential logging, private atomic writes for generated configs, and missing-local-asset guards.
Tests exercise real TLS trust/hostname failures, wrong/missing credentials, authenticated exchange, the patched production listener with model work substituted, pinned upstream idle/startup behavior, config, history, and secret file permissions. GitHub Actions runs these checks; firmware checks activate in the dependent PR where those files exist.
Full Docker image execution, model inference and robot bench validation remain outstanding. The source is ready for those checks, not a claim of an already flashed robot. Certificates/credential rotation requires rebuilding the paired firmware. The original research is preserved on docs/stakia-research-plan.
Merge order when bench validation is complete: this host PR first, then retarget PR #2 to main. Do not merge automatically.
Summary by Sourcery
Deploy a local-first Stakia host runtime with configurable conversation controls, bounded context, pinned speech services, and fail-closed authenticated TLS connectivity.
New Features:
Bug Fixes:
Enhancements:
Build:
CI:
Documentation:
Tests: