Skip to content

fix(gateway): talk.config no longer throws on SecretRef apiKey (#72496)#72584

Merged
omarshahine merged 1 commit into
mainfrom
fix/72496-talk-config-secretref
Apr 27, 2026
Merged

fix(gateway): talk.config no longer throws on SecretRef apiKey (#72496)#72584
omarshahine merged 1 commit into
mainfrom
fix/72496-talk-config-secretref

Conversation

@omarshahine

Copy link
Copy Markdown
Contributor

Summary

Fixes #72496. talk.config was throwing unresolved SecretRef whenever talk.providers.<id>.apiKey was configured as a SecretRef object, so iOS / macOS / Control UI Talk overlays never received a provider snapshot and silently fell back to local on-device TTS (the "robot voice" symptom). The credential itself was healthy — talk.realtime.session and talk.speak both worked with the same SecretRef — so this was purely a discovery-path bug.

Root cause: resolveTalkResponseFromConfig in src/gateway/server-methods/talk.ts handed the source-snapshot's talkProviderConfig (with the SecretRef wrapper still on apiKey) to speechProvider.resolveTalkConfig({...}). ElevenLabs's resolver (and OpenAI's, and any provider that uses the strict secret-input helpers) then called normalizeResolvedSecretInputString on the wrapper, which threw on the unresolved SecretRef.

Fix

  • Prefer the runtime-resolved provider config when calling resolveTalkConfig (substituted strings in production paths).
  • Strip the apiKey field if it's still a SecretRef wrapper before handing it to the provider, so strict secret-input helpers never see an unresolved wrapper.
  • Restore the source-shaped apiKey (the SecretRef) onto resolved.config in the response so the UI keeps the SecretRef context. Existing redaction sentinels the value when includeSecrets: false.

Adds a regression test that registers a speech provider mirroring ElevenLabs/OpenAI's strict resolver behavior and drives a SecretRef apiKey through talk.config for both read-only and includeSecrets: true callers.

Test plan

  • pnpm test src/gateway/server.talk-config.test.ts — 9/9 pass; new test fails on unresolved SecretRef without the fix
  • pnpm check:changed (typecheck core/core-tests, lint, import cycles, guards) — green
  • pnpm test:changed — green
  • Manual: file-backed SecretRef ElevenLabs apiKey → openclaw gateway call talk.config returns provider snapshot → iOS/macOS Talk overlays use ElevenLabs voice instead of AVSpeechSynthesizer

🤖 Generated with Claude Code

@aisle-research-bot

aisle-research-bot Bot commented Apr 27, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

We found 1 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Resolved talk provider apiKey passed to plugins during talk.config for read-scope callers (confused deputy secret exposure)
1. 🟡 Resolved talk provider apiKey passed to plugins during talk.config for read-scope callers (confused deputy secret exposure)
Property Value
Severity Medium
CWE CWE-522
Location src/gateway/server-methods/talk.ts:363-385

Description

In talk.config when includeSecrets=false, the gateway still computes a providerInputConfig that prefers runtimeResolved provider config, which is described as “already-substituted secrets”, and passes it into speechProvider.resolveTalkConfig.

This creates a confused-deputy secret exposure / secret-usage issue:

  • Input: a client with only operator.read can call talk.config (per method scopes and tests).
  • Secret resolution: runtimeConfig/runtimeResolved may contain the provider apiKey as a resolved string (e.g., from env/secret store).
  • Sink: that resolved apiKey is handed to third-party/bundled provider plugins via speechProvider.resolveTalkConfig({ talkProviderConfig: providerInputConfig, ... }).
  • Even if the response is later redacted, the secret has already crossed the trust boundary into plugin code. A plugin could log/exfiltrate the key, or perform network calls using it, purely because a read-scope client invoked talk.config.

Vulnerable flow (simplified):

  • read-scope caller → talk.configresolveTalkResponseFromConfig(includeSecrets=false)runtimeResolved.config (resolved secrets) → resolveTalkConfig(talkProviderConfig=...) (plugin-controlled code).

Vulnerable code:

const runtimeProviderConfig = runtimeResolved?.config ?? {};// Prefer runtime-resolved provider config (already-substituted secrets)
const providerInputConfig = stripUnresolvedSecretApiKey(
  Object.keys(runtimeProviderConfig).length > 0 ? runtimeProviderConfig : sourceProviderConfig,
);
const resolvedConfig =
  speechProvider?.resolveTalkConfig?.({
    ...,
    talkProviderConfig: providerInputConfig,
  }) ?? providerInputConfig;

Although stripUnresolvedSecretApiKey removes wrapper SecretRef values, it explicitly allows string api keys through; so resolved secrets will still be passed to plugins.

Recommendation

Avoid passing resolved secrets to provider plugins when servicing talk.config requests from clients that lack secret scope.

Options (choose based on intended trust model):

  1. Best isolation: when includeSecrets=false, always pass a provider config with apiKey removed/undefined to resolveTalkConfig:
const providerInputConfig = {
  ...(Object.keys(runtimeProviderConfig).length > 0 ? runtimeProviderConfig : sourceProviderConfig),
  apiKey: undefined,
};
  1. If plugins need SecretRef context but not material: pass the source config (SecretRef wrapper) and update strict helpers/providers to tolerate wrappers in discovery paths (do not require resolved secret for config discovery).

  2. If calling resolveTalkConfig can trigger outbound calls: consider skipping resolveTalkConfig entirely unless the client has operator.talk.secrets, or ensure resolveTalkConfig is pure (no network/logging) and enforce this contract.

In all cases, keep redaction for responses, but also prevent secret propagation into plugin code on unprivileged requests.


Analyzed PR: #72584 at commit 783f44c

Last updated on: 2026-04-27T16:01:45Z

@openclaw-barnacle openclaw-barnacle Bot added gateway Gateway runtime size: S maintainer Maintainer-authored PR labels Apr 27, 2026
@greptile-apps

greptile-apps Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a bug where talk.config threw unresolved SecretRef when a provider's apiKey was configured as a SecretRef object, because the source config (with the raw SecretRef wrapper) was passed directly to strict secret-input helpers inside resolveTalkConfig. The fix prefers the runtime-resolved provider config (with substituted secrets), strips any remaining SecretRef wrapper from apiKey before passing it to provider resolvers, and then restores the source-shaped apiKey onto the response so the UI retains SecretRef context and redaction can operate normally. A regression test covering both read-only and includeSecrets: true callers is included.

Confidence Score: 5/5

Safe to merge — targeted fix for a well-understood discovery-path bug with a solid regression test.

The change is narrow, well-commented, and does not touch any write paths or actual secret-resolution logic. The regression test drives the exact failure scenario (strict resolver + SecretRef apiKey) for both permission levels. No issues found.

No files require special attention.

Reviews (1): Last reviewed commit: "fix(gateway): redact SecretRef apiKey th..." | Re-trigger Greptile

@omarshahine

Copy link
Copy Markdown
Contributor Author

Live verification on Lobster (2026-04-27)

Fast-patched the PR build onto Lobster's gateway and walked the full reproduction → fix → smoke → restore cycle against the issue's file:secrets:/skills/elevenlabs SecretRef shape.

Stage Config Dist talk.config result
Baseline env-interp ${ELEVENLABS_API_KEY} released ✅ returned (bug not exercised)
Repro SecretRef file:secrets:/skills/elevenlabs released (pre-fix) Error: talk.providers.elevenlabs.apiKey: unresolved SecretRef "file:secrets:/skills/elevenlabs" — issue reproduced
Fix SecretRef same as above PR #72584 ✅ returned snapshot; resolved.config.apiKey sentinel-redacted as {source: __OPENCLAW_REDACTED__, provider: __OPENCLAW_REDACTED__, id: __OPENCLAW_REDACTED__}
Smoke SecretRef same as above PR #72584 talk.speak returned a valid ElevenLabs MP3 (audioBase64 ID3/Lavf header) — no regression
Restore env-interp restored PR #72584 ✅ returned (no regression on the working path)

Pre-fix error matches issue #72496 verbatim; post-fix talk.config round-trips cleanly with the SecretRef apiKey, and talk.speak continues to render real ElevenLabs audio under the same SecretRef. Ready to land.

…owing

The talk.config discovery RPC was handing the source-snapshot's
talkProviderConfig (with the unresolved SecretRef wrapper still on
apiKey) to speechProvider.resolveTalkConfig. ElevenLabs/OpenAI's
strict normalizeResolvedSecretInputString helper threw 'unresolved
SecretRef' there, so iOS / macOS / Control UI Talk overlays never
learned the configured provider and silently fell back to local
AVSpeechSynthesizer ('robot voice') even though talk.realtime.session
and talk.speak both worked end-to-end with the same SecretRef.

Prefer the runtime-resolved provider config when calling
resolveTalkConfig, strip the apiKey field if it's still a SecretRef
wrapper at the call site, and restore the source-shaped apiKey onto
the response so the UI keeps the SecretRef context. Redaction strips
the value when includeSecrets=false.

Adds a regression test using a strict resolver speech provider that
mirrors ElevenLabs/OpenAI behavior so the path stays covered for
SecretRef apiKeys.

Fixes #72496

Thanks @omarshahine
@omarshahine omarshahine force-pushed the fix/72496-talk-config-secretref branch from 733f8e5 to 783f44c Compare April 27, 2026 15:59
@omarshahine omarshahine merged commit 8ce4f8f into main Apr 27, 2026
4 checks passed
@omarshahine omarshahine deleted the fix/72496-talk-config-secretref branch April 27, 2026 15:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gateway Gateway runtime maintainer Maintainer-authored PR size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant