Skip to content

Stakia host: local speech, conversation controls, authenticated TLS - #3

Merged
rustyorb merged 2 commits into
mainfrom
codex/stakia-host-runtime
Sep 17, 2026
Merged

rustyorb merged 2 commits into
mainfrom
codex/stakia-host-runtime

Conversation

@rustyorb

@rustyorb rustyorb commented Sep 17, 2026 •

Copy link
Copy Markdown
Owner

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:

  • Add a self-hosted voice runtime using local Whisper, Silero VAD, Piper, and LM Studio, with OpenRouter available only through explicit selection.
  • Add persisted dashboard controls for speech pause duration, Never or finite conversation idle behavior, and optional farewell prompts.
  • Add locally provisioned TLS and authenticated WebSocket connectivity for the robot, with a private CA, generated credential, and fail-closed transport settings.

Bug Fixes:

  • Prevent local speech startup from downloading missing models or silently falling back to cloud services.
  • Bound forwarded conversation history while preserving recent multi-turn context and preventing duplicate current turns.
  • Disable plaintext, anonymous, HTTP, vendor-bootstrap, and inherited cloud-runtime fallbacks.

Enhancements:

  • Pin the standalone upstream voice server and provide reproducible local configuration, startup, diagnostic, and Docker workflows.
  • Restrict the dashboard to localhost and protect generated security and configuration files with private atomic writes.

Build:

  • Add a Docker build for the pinned upstream voice server with local speech dependencies and applied runtime patches.

CI:

  • Add GitHub Actions regression checks covering host behavior, TLS and authentication, pinned upstream idle/startup behavior, and conditional firmware validations.

Documentation:

  • Update installation, local model provisioning, security rotation, validation scope, and implementation-status documentation for the self-hosted deployment.

Tests:

  • Add tests for persisted settings, local asset validation, bounded dialogue forwarding, idle policies, secret file permissions, and real TLS hostname, trust, authentication, and listener behavior.

…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.
Copilot AI lite review requested due to automatic review settings September 17, 2026 12:07
@sourcery-ai

sourcery-ai Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

This 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 connection

sequenceDiagram
    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
Loading

Flow diagram for fail-closed host provisioning

flowchart 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]
Loading

File-Level Changes

Change Details Files
Replaces the vendor/cloud-oriented runtime with a pinned, locally hosted speech and voice pipeline.
  • Builds the upstream voice server from a pinned commit and applies local idle and transport patches.
  • Selects local Silero VAD, Whisper ASR, Piper TTS, and LM Studio by default, with OpenRouter as an explicit alternative.
  • Validates required model assets and disables automatic speech-model downloads.
  • Removes OTA, manager, cloud speech, and inherited prompt-context paths from the active configuration.
README.md
docker-compose.yml
docker/server.Dockerfile
config/stakia-local-prompt.txt
custom-providers/asr/whisper_local.py
custom-providers/piper_local/piper_local.py
custom-providers/zeroclaw/zeroclaw.py
custom-providers/xiaozhi-patches/never-idle.patch
bridge/host_settings.py
scripts/configure-host.py
tests/test_compose_local_providers.py
tests/test_host_settings.py
Adds persisted conversation controls and bounded dialogue forwarding.
  • Persists validated model profile, speech pause, finite-or-Never idle timeout, and farewell settings.
  • Renders those settings into regenerated upstream server configuration and exposes them through the localhost dashboard.
  • Bounds forwarded history to one system message plus the most recent portable user/assistant turns.
  • Patches both upstream silence-close paths so Never disables conversation idle closure while finite idle and farewell behavior remain independent.
bridge/dashboard.py
bridge/templates/dashboard.html
bridge/templates/host_settings.html
bridge/host_settings.py
bridge/dialogue.py
bridge/idle_policy.py
bridge.py
custom-providers/zeroclaw/zeroclaw.py
custom-providers/xiaozhi-patches/never-idle.patch
tests/test_dialogue_relay.py
tests/test_idle_policy.py
tests/test_upstream_idle_patch.py
Implements fail-closed native TLS and per-robot authentication for the LAN WebSocket endpoint.
  • Generates and reuses a private CA, hostname-bound server certificate, and random 256-bit robot credential.
  • Requires bearer authentication before WebSocket upgrade and TLS 1.2 or newer, with no plaintext, anonymous, or HTTP listener fallback.
  • Binds the dashboard to localhost, publishes only the secure voice port, validates private LAN addresses, and avoids credential/header logging.
  • Writes security material and generated configurations atomically with private permissions and rejects incomplete or mismatched identities.
bridge/security.py
custom-providers/stakia_transport.py
custom-providers/xiaozhi-patches/secure-transport.patch
docker-compose.yml
bridge.py
tests/test_secure_transport.py
tests/test_upstream_secure_transport.py
tests/test_upstream_startup.py
Adds regression coverage and CI for host, upstream, security, and conditional firmware validation.
  • Runs Python host/TLS/upstream tests against a pinned upstream checkout in GitHub Actions.
  • Exercises real certificate trust and hostname failures, missing or incorrect credentials, authenticated exchange, production listener behavior, idle handling, config rendering, history bounds, and secret file modes.
  • Conditionally runs native firmware helper, geometry, factory preparation, and pinned firmware patch checks when firmware files are present.
  • Documents implementation status, validation limits, merge order, credential/certificate rotation, and outstanding Docker or bench validation.
.github/workflows/stakia-checks.yml
docs/implementation-status.md
docs/pr-2-review.md
docs/upgrade-plan.md
README.md
.gitattributes
.gitignore

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread bridge.py Outdated
Comment on lines +944 to +949
request_messages = bounded_dialogue(messages or [])
if not request_messages:
request_messages = [
{"role": "system", "content": system},
{"role": "user", "content": text},
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
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})

Comment thread bridge/dashboard.py Outdated
idle_minutes: int | None = Form(None),
idle_farewell: str | None = Form(None),
) -> Any:
idle = None if idle_mode == "never" else idle_minutes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
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

Comment thread bridge/dashboard.py Outdated
Comment on lines +364 to +369
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.
@rustyorb
rustyorb merged commit afdcd8a into main Sep 17, 2026
3 checks passed
@rustyorb
rustyorb deleted the codex/stakia-host-runtime branch September 17, 2026 12:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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.

Comment thread bridge/host_settings.py
Comment on lines +82 to +86
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}"},
Comment thread bridge/host_settings.py
}
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"
Comment thread bridge/host_settings.py
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,
Comment thread README.md
Comment on lines +41 to +43
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants