Skip to content

talk.config RPC fails with "unresolved SecretRef" for talk.providers.*.apiKey, breaks Talk Mode discovery #72496

@omarshahine

Description

@omarshahine

Summary

talk.config (the Talk-mode discovery RPC that iOS / macOS / Control UI Talk overlays call on startup to learn the configured TTS provider) throws unresolved SecretRef when talk.providers.<id>.apiKey is configured as a SecretRef object. The credential itself resolves correctly — talk.realtime.session and talk.speak both work end-to-end with the same SecretRef — but the discovery handshake fails, so clients never learn what provider is configured and silently fall back to local on-device TTS (AVSpeechSynthesizer / equivalent), producing the classic "robot voice" symptom even though everything is configured correctly.

talk.providers.*.apiKey is officially documented as a supported SecretRef target in docs/reference/secretref-credential-surface.md and listed in docs/.generated/config-baseline.core.json. So this is a discovery / redaction-path bug, not a deliberate restriction.

Environment

  • OpenClaw: v2026.4.24 (also reproduced on v2026.4.21–v2026.4.24)
  • Platform: macOS (Apple Silicon)
  • Affected surfaces: iOS Talk, macOS native Talk overlay, Control UI Talk (browser/PWA)
  • Secrets backend: file-backed (~/.openclaw/secrets.json, secrets.providers.secrets.source: file)
  • Install: standard npm-installed gateway under macOS LaunchAgent

Reproduction

  1. Configure file-backed secrets with an ElevenLabs (or OpenAI Realtime) API key:

    {
      "secrets": {
        "providers": {
          "secrets": { "source": "file", "path": "~/.openclaw/secrets.json", "mode": "json" }
        },
        "defaults": { "file": "secrets" }
      }
    }
  2. Configure Talk to use a SecretRef for the provider apiKey:

    {
      "talk": {
        "provider": "elevenlabs",
        "interruptOnSpeech": true,
        "providers": {
          "elevenlabs": {
            "voiceId": "<voice-id>",
            "modelId": "eleven_v3",
            "outputFormat": "mp3_44100_128",
            "apiKey": {
              "source": "file",
              "provider": "secrets",
              "id": "/skills/elevenlabs"
            }
          }
        }
      }
    }
  3. Restart the gateway and call the discovery RPC:

    openclaw gateway call talk.config

    Observed:

    Gateway call failed: GatewayClientRequestError: Error: talk.providers.elevenlabs.apiKey: unresolved SecretRef "file:secrets:/skills/elevenlabs". Resolve this command against an active gateway runtime snapshot before reading it.
    
  4. Confirm the credential itself is healthy:

    openclaw gateway call talk.speak --params '{"text":"hello"}'
    # → returns valid audioBase64 (ElevenLabs MP3, ID3 / Lavf encoder header)
    
    openclaw gateway call talk.realtime.session
    # → returns valid OpenAI Realtime ephemeral key (ek_...) when provider=openai

    So the SecretRef IS resolved by the runtime — only the talk.config discovery / redaction path fails.

  5. Open the iOS app or macOS native Talk overlay → robot voice (local AVSpeechSynthesizer fallback).

Expected behavior

talk.config should successfully redact and return the configured Talk provider snapshot regardless of whether talk.providers.<id>.apiKey is a literal string or a SecretRef object. The redaction path should treat unresolved SecretRef wrappers the same way it treats string secrets — render them as __OPENCLAW_REDACTED__ rather than throwing.

Actual behavior

talk.config fails fast → iOS / macOS / Control UI Talk clients receive no provider snapshot → silent fallback to local on-device TTS. No surfaced error in the client UI; users see the macOS Advanced > Talk Voice panel show the right config but hear the wrong voice.

Likely cause

The talk.config handler in src/gateway/server-methods/talk.ts reads from readConfigFileSnapshot() (raw file, no SecretRef resolution) and then calls redactConfigObject(...) from src/config/redact-snapshot.ts on the talk payload. Somewhere down that path (likely via assertSecretInputResolved in src/config/types.secrets.ts), the redactor encounters the unresolved SecretRef wrapper and throws createUnresolvedSecretInputError instead of redacting the value.

This is the same family of regressions documented in:

None of those track talk.config + talk.providers.*.apiKey specifically.

Suggested fix

In the talk.config handler / redactConfigObject path, treat unresolved SecretRef objects the same as resolved string secrets for the purpose of redaction — emit __OPENCLAW_REDACTED__ (or a structured { redacted: true } marker) instead of asserting resolution. The discovery RPC inherently runs on the raw config snapshot, so unresolved SecretRefs are an expected input shape there, not an error condition.

Optional: extend the redaction visitor with explicit SecretRef handling (one branch above the assertion) to keep the strict assertion intact for paths where unresolved refs really are a bug.

Workaround

Replace the SecretRef with a literal apiKey string or ${ENV_VAR} interpolation under talk.providers.<id>.apiKey. Both surfaces (iOS + macOS Talk) start working immediately. Downside: literal in openclaw.json, or another env var to manage in the LaunchAgent plist — neither is great for a documented SecretRef target.

Repro environment notes (no PII)

Voice IDs, API keys, and account-specific paths are placeholders in the snippets above. The bug reproduces with any non-empty SecretRef pointing at a valid ElevenLabs (or OpenAI) key, on a clean LaunchAgent-managed gateway with file-backed secrets.

Metadata

Metadata

Assignees

Labels

maintainerMaintainer-authored PR

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions