fix(gateway): strip SecretRef apiKey from messages.tts.providers before talk.config hands it to speech providers#73111
Conversation
Greptile SummaryThis PR mirrors the fix from #72496 for the parallel Confidence Score: 5/5Safe to merge — targeted fix with no logic regressions and a well-scoped regression test. The change is a conservative strip-only helper that short-circuits on the no-mutation path (returning the original reference). It precisely mirrors the existing No files require special attention. Reviews (1): Last reviewed commit: "fix(gateway): strip SecretRef apiKey fro..." | Re-trigger Greptile |
54160f7 to
14a0fea
Compare
…re talk.config hands it to speech providers The fix in openclaw#72496 stripped unresolved SecretRef wrappers from the talk.providers side of `talk.config` before handing the value into `speechProvider.resolveTalkConfig({ talkProviderConfig })`, but the same RPC also forwards `baseTtsConfig: messages.tts` down the same call. Strict provider resolvers (ElevenLabs/OpenAI) read the active provider's apiKey out of `baseTtsConfig.providers[id]` and call the same `normalizeResolvedSecretInputString` helper that throws on an unresolved wrapper, so `talk.config` still errors out for any operator that pins `messages.tts.providers.<id>.apiKey: { source, provider, id }` as a SecretRef — even after upgrading to a build that contains the openclaw#72496 fix. The user-visible regression is the same as openclaw#72496: iOS / macOS / Control UI Talk overlays never learn the configured provider through the discovery handshake and silently fall back to local AVSpeechSynthesizer. The workaround until this lands is "switch messages.tts.providers.<id>.apiKey to a literal string or `${ENV_VAR}` while keeping talk.providers.<id>.apiKey as a SecretRef" — visibly partial. Apply the same defensive strip pattern to the messages.tts side: walk `baseTtsConfig.providers` and reuse the existing `stripUnresolvedSecretApiKey` helper on each provider entry before passing the base TTS config into `resolveTalkConfig`. The provider resolvers' strict-secret call sites then see `apiKey: undefined` and skip past without throwing, mirroring the talk.providers path that openclaw#72496 unblocked. Adds a regression test that mirrors openclaw#72496's strict-resolver fixture but configures the SecretRef on `messages.tts.providers.<id>.apiKey` instead of `talk.providers.<id>.apiKey`. The new test fails without this patch with the exact `unresolved SecretRef "file:..."` error reported against production gateways. Fixes openclaw#73109. Refs openclaw#72496. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…viders to block prototype pollution Aisle security review on openclaw#73111 flagged a prototype-pollution surface in `stripUnresolvedSecretInputsFromBaseTtsProviders`: the helper rebuilds the providers map with `cleaned: Record<string, unknown> = {}` and assigns dynamic keys from `Object.entries(providers)`. An operator config payload that carries `messages.tts.providers.__proto__` (or `constructor`/`prototype`) would mutate Object.prototype on every following plain-object property lookup downstream — provider ids come from operator config and are not validated upstream against a safe character set. Switch the local map to `Object.create(null)` so the dynamic `cleaned[providerId] = ...` write becomes a normal own-property assignment that cannot reach Object.prototype. Adds a regression test that posts a `messages.tts.providers.__proto__` config entry with a hostile `polluted` payload and asserts that `({}).polluted` stays `undefined` after the talk.config call returns. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
15619c4 to
aa7941a
Compare
…rs before talk.config hands them to speech providers (openclaw#73111) Closes the gap left by openclaw#72496 on the parallel `messages.tts.providers.<id>` site. After openclaw#72496 landed, `talk.config` still threw `unresolved SecretRef` whenever an operator pinned a TTS apiKey or token as a SecretRef on the messages.tts side — same user-facing symptom (iOS / macOS / Control UI Talk overlays falling back to local AVSpeechSynthesizer). Adds `stripUnresolvedSecretInputsFromBaseTtsProviders` in `src/gateway/server-methods/talk.ts` that walks each entry in `messages.tts.providers` and strips any unresolved SecretRef wrappers from the configured secret-input keys (`apiKey`, `token`) before handing the base TTS config down to `speechProvider.resolveTalkConfig`. Mirrors the `talk.providers` strip pattern from openclaw#72496. Hardening: rebuilds the providers map with `Object.create(null)` instead of `{}` so an operator-config payload carrying `messages.tts.providers.__proto__` (or `constructor`/`prototype`) cannot mutate Object.prototype via the dynamic `cleaned[providerId] = ...` assignment. Caught by Aisle security review. Adds three regression tests covering: SecretRef apiKey on messages.tts (the original bug), SecretRef token on messages.tts (Peter's generalization), and `__proto__`-keyed providers (Aisle hardening). All pass; full CI green (57/57) on the rebased branch. Fixes openclaw#73109. Refs openclaw#72496. Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rs before talk.config hands them to speech providers (openclaw#73111) Closes the gap left by openclaw#72496 on the parallel `messages.tts.providers.<id>` site. After openclaw#72496 landed, `talk.config` still threw `unresolved SecretRef` whenever an operator pinned a TTS apiKey or token as a SecretRef on the messages.tts side — same user-facing symptom (iOS / macOS / Control UI Talk overlays falling back to local AVSpeechSynthesizer). Adds `stripUnresolvedSecretInputsFromBaseTtsProviders` in `src/gateway/server-methods/talk.ts` that walks each entry in `messages.tts.providers` and strips any unresolved SecretRef wrappers from the configured secret-input keys (`apiKey`, `token`) before handing the base TTS config down to `speechProvider.resolveTalkConfig`. Mirrors the `talk.providers` strip pattern from openclaw#72496. Hardening: rebuilds the providers map with `Object.create(null)` instead of `{}` so an operator-config payload carrying `messages.tts.providers.__proto__` (or `constructor`/`prototype`) cannot mutate Object.prototype via the dynamic `cleaned[providerId] = ...` assignment. Caught by Aisle security review. Adds three regression tests covering: SecretRef apiKey on messages.tts (the original bug), SecretRef token on messages.tts (Peter's generalization), and `__proto__`-keyed providers (Aisle hardening). All pass; full CI green (57/57) on the rebased branch. Fixes openclaw#73109. Refs openclaw#72496. Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rs before talk.config hands them to speech providers (openclaw#73111) Closes the gap left by openclaw#72496 on the parallel `messages.tts.providers.<id>` site. After openclaw#72496 landed, `talk.config` still threw `unresolved SecretRef` whenever an operator pinned a TTS apiKey or token as a SecretRef on the messages.tts side — same user-facing symptom (iOS / macOS / Control UI Talk overlays falling back to local AVSpeechSynthesizer). Adds `stripUnresolvedSecretInputsFromBaseTtsProviders` in `src/gateway/server-methods/talk.ts` that walks each entry in `messages.tts.providers` and strips any unresolved SecretRef wrappers from the configured secret-input keys (`apiKey`, `token`) before handing the base TTS config down to `speechProvider.resolveTalkConfig`. Mirrors the `talk.providers` strip pattern from openclaw#72496. Hardening: rebuilds the providers map with `Object.create(null)` instead of `{}` so an operator-config payload carrying `messages.tts.providers.__proto__` (or `constructor`/`prototype`) cannot mutate Object.prototype via the dynamic `cleaned[providerId] = ...` assignment. Caught by Aisle security review. Adds three regression tests covering: SecretRef apiKey on messages.tts (the original bug), SecretRef token on messages.tts (Peter's generalization), and `__proto__`-keyed providers (Aisle hardening). All pass; full CI green (57/57) on the rebased branch. Fixes openclaw#73109. Refs openclaw#72496. Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rs before talk.config hands them to speech providers (openclaw#73111) Closes the gap left by openclaw#72496 on the parallel `messages.tts.providers.<id>` site. After openclaw#72496 landed, `talk.config` still threw `unresolved SecretRef` whenever an operator pinned a TTS apiKey or token as a SecretRef on the messages.tts side — same user-facing symptom (iOS / macOS / Control UI Talk overlays falling back to local AVSpeechSynthesizer). Adds `stripUnresolvedSecretInputsFromBaseTtsProviders` in `src/gateway/server-methods/talk.ts` that walks each entry in `messages.tts.providers` and strips any unresolved SecretRef wrappers from the configured secret-input keys (`apiKey`, `token`) before handing the base TTS config down to `speechProvider.resolveTalkConfig`. Mirrors the `talk.providers` strip pattern from openclaw#72496. Hardening: rebuilds the providers map with `Object.create(null)` instead of `{}` so an operator-config payload carrying `messages.tts.providers.__proto__` (or `constructor`/`prototype`) cannot mutate Object.prototype via the dynamic `cleaned[providerId] = ...` assignment. Caught by Aisle security review. Adds three regression tests covering: SecretRef apiKey on messages.tts (the original bug), SecretRef token on messages.tts (Peter's generalization), and `__proto__`-keyed providers (Aisle hardening). All pass; full CI green (57/57) on the rebased branch. Fixes openclaw#73109. Refs openclaw#72496. Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rs before talk.config hands them to speech providers (openclaw#73111) Closes the gap left by openclaw#72496 on the parallel `messages.tts.providers.<id>` site. After openclaw#72496 landed, `talk.config` still threw `unresolved SecretRef` whenever an operator pinned a TTS apiKey or token as a SecretRef on the messages.tts side — same user-facing symptom (iOS / macOS / Control UI Talk overlays falling back to local AVSpeechSynthesizer). Adds `stripUnresolvedSecretInputsFromBaseTtsProviders` in `src/gateway/server-methods/talk.ts` that walks each entry in `messages.tts.providers` and strips any unresolved SecretRef wrappers from the configured secret-input keys (`apiKey`, `token`) before handing the base TTS config down to `speechProvider.resolveTalkConfig`. Mirrors the `talk.providers` strip pattern from openclaw#72496. Hardening: rebuilds the providers map with `Object.create(null)` instead of `{}` so an operator-config payload carrying `messages.tts.providers.__proto__` (or `constructor`/`prototype`) cannot mutate Object.prototype via the dynamic `cleaned[providerId] = ...` assignment. Caught by Aisle security review. Adds three regression tests covering: SecretRef apiKey on messages.tts (the original bug), SecretRef token on messages.tts (Peter's generalization), and `__proto__`-keyed providers (Aisle hardening). All pass; full CI green (57/57) on the rebased branch. Fixes openclaw#73109. Refs openclaw#72496. Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rs before talk.config hands them to speech providers (openclaw#73111) Closes the gap left by openclaw#72496 on the parallel `messages.tts.providers.<id>` site. After openclaw#72496 landed, `talk.config` still threw `unresolved SecretRef` whenever an operator pinned a TTS apiKey or token as a SecretRef on the messages.tts side — same user-facing symptom (iOS / macOS / Control UI Talk overlays falling back to local AVSpeechSynthesizer). Adds `stripUnresolvedSecretInputsFromBaseTtsProviders` in `src/gateway/server-methods/talk.ts` that walks each entry in `messages.tts.providers` and strips any unresolved SecretRef wrappers from the configured secret-input keys (`apiKey`, `token`) before handing the base TTS config down to `speechProvider.resolveTalkConfig`. Mirrors the `talk.providers` strip pattern from openclaw#72496. Hardening: rebuilds the providers map with `Object.create(null)` instead of `{}` so an operator-config payload carrying `messages.tts.providers.__proto__` (or `constructor`/`prototype`) cannot mutate Object.prototype via the dynamic `cleaned[providerId] = ...` assignment. Caught by Aisle security review. Adds three regression tests covering: SecretRef apiKey on messages.tts (the original bug), SecretRef token on messages.tts (Peter's generalization), and `__proto__`-keyed providers (Aisle hardening). All pass; full CI green (57/57) on the rebased branch. Fixes openclaw#73109. Refs openclaw#72496. Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Closes the gap left by #72496 on the parallel
messages.tts.providers.<id>.apiKeysite. After #72496 landed,talk.configstill throwsunresolved SecretRefwhenever an operator pins their TTS apiKey as a SecretRef on the messages.tts side — visible in production with the exact same user-facing symptom #72496 was filed for (iOS / macOS / Control UI Talk overlays silently fall back to local AVSpeechSynthesizer because the discovery handshake errors out).Reproduction on a build that contains
8ce4f8fc84:The throwing call site is the strict-resolver
normalizeResolvedSecretInputStringinvocation inside (e.g.)extensions/elevenlabs/speech-provider.ts::normalizeElevenLabsProviderConfig, which readsraw?.apiKeystraight offbaseTtsConfig.providers.elevenlabsand calls the strict normalizer on it — exactly the same shape as the bug #72496 fixed fortalk.providers.Detailed write-up at #73109.
Fix
Mirror #72496's approach. Add
stripUnresolvedSecretApiKeysFromBaseTtsProvidersinsrc/gateway/server-methods/talk.ts, walking each entry ofbaseTtsConfig.providersand applying the existingstripUnresolvedSecretApiKeyhelper. Apply at theresolveTalkResponseFromConfigcall site so the base TTS config handed down tospeechProvider.resolveTalkConfig({ baseTtsConfig })no longer carries unresolved SecretRef wrappers onapiKey.The strip is conservative — it only mutates when at least one provider entry's
apiKeywas a non-string, non-undefined value (i.e. a SecretRef-shaped object). All other entries pass through unchanged, including ones that already carry resolved string keys.Files
src/gateway/server-methods/talk.ts— newstripUnresolvedSecretApiKeysFromBaseTtsProviders(base)helper plus the call at the existingresolveTalkResponseFromConfigsite (line 376), upstream ofspeechProvider.resolveTalkConfig({ baseTtsConfig }). Doc comment cross-links talk.config RPC fails with "unresolved SecretRef" for talk.providers.*.apiKey, breaks Talk Mode discovery #72496 so the relationship between the two patches is visible at the seam.src/gateway/server.talk-config.test.ts— newit(\"does not throw when SecretRef apiKey on messages.tts.providers flows through a strict provider resolver\", ...)regression. Mirrors talk.config RPC fails with "unresolved SecretRef" for talk.providers.*.apiKey, breaks Talk Mode discovery #72496's strict-resolver fixture but configures the SecretRef onmessages.tts.providers.<id>.apiKeyinstead oftalk.providers.<id>.apiKey. Verified the test fails on the parent commit (the production-reported error) and passes on this branch.Test plan
pnpm exec vitest run src/gateway/server.talk-config.test.ts— 10/10 pass on this branch (including the new regression)origin/mainwith the exact production-reportedunresolved SecretRef \"file:secrets:/skills/elevenlabs\"errormessages.tts.providers.elevenlabs.apiKey: { source, provider, id }SecretRef returns a cleantalk.configresponse (provider: \"elevenlabs\", full resolved config, redacted apiKey for read-scope callers)mutated === false, returning the originalbasereference)baseTtsConfig.providersvia a different shape that would need an additional strip passRelated
🤖 Generated with Claude Code