Skip to content

fix(gateway): use runtime config for secret-backed talk#73286

Merged
steipete merged 3 commits into
mainfrom
fix/messages-tts-secretref
Apr 28, 2026
Merged

fix(gateway): use runtime config for secret-backed talk#73286
steipete merged 3 commits into
mainfrom
fix/messages-tts-secretref

Conversation

@steipete

Copy link
Copy Markdown
Contributor

Summary

  • Problem: talk.config and channel logout could hand provider/channel callbacks the source config snapshot, leaving SecretRef wrappers unresolved on runtime paths.
  • Why it matters: Talk overlays can miss configured SecretRef-backed speech providers and fall back to local speech; channel logout can fail for SecretRef-backed account tokens.
  • What changed: pass active runtime config to provider/channel execution, expose the shared runtime snapshot selector to speech-core, and keep readback payloads source-shaped/redacted for UI.
  • What did NOT change (scope boundary): no config migration, no new provider behavior, no secret disclosure in talk.config read scope.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

Root Cause (if applicable)

  • Root cause: source/file snapshots preserve SecretRef markers for UI/editing, but provider/channel callbacks need the active runtime snapshot with secrets already materialized.
  • Missing detection / guardrail: existing Talk websocket coverage only proved the request did not throw after stripping unresolved wrappers; it did not prove strict providers received runtime-resolved messages.tts credentials.
  • Contributing context: speech-core helpers can be called with either the active source snapshot or runtime snapshot, so the runtime selection logic belongs in the shared runtime snapshot seam.

Regression Test Plan (if applicable)

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file: src/gateway/server-methods/talk.test.ts, src/gateway/server-methods/channels.start.test.ts, extensions/speech-core/src/tts.test.ts, src/gateway/server.talk-config.test.ts
  • Scenario the test should lock in: strict Talk provider resolvers receive runtime-resolved messages.tts.providers.<id>.apiKey and runtime timeout; channel logout receives runtime config; read-scope talk.config stays redacted.
  • Why this is the smallest reliable guardrail: handler-level tests exercise the exact callback payloads without the slower websocket harness, while the websocket file keeps protocol/readback coverage.
  • Existing test that already covers this (if any): prior websocket Talk tests covered redaction/schema shape, but not runtime-resolved base TTS config.
  • If no new test is added, why not: N/A

User-visible / Behavior Changes

Talk Mode now discovers SecretRef-backed speech providers through talk.config instead of falling back to local speech when the provider resolver requires a concrete runtime API key.

Diagram (if applicable)

Before:
Talk overlay -> talk.config -> source snapshot SecretRef -> strict provider rejects/unconfigured

After:
Talk overlay -> talk.config -> runtime snapshot credential -> provider resolves -> redacted UI payload

Security Impact (required)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? Yes
  • New/changed network calls? No
  • Command/tool execution surface changed? No
  • Data access scope changed? No
  • If any Yes, explain risk + mitigation: provider callbacks now receive the runtime-resolved config they already receive on execution paths; talk.config read-scope responses restore the source SecretRef shape and pass through existing redaction so credential material is not returned to clients.

Repro + Verification

Environment

  • OS: macOS local, Blacksmith Testbox Linux runner
  • Runtime/container: Node/pnpm repo wrapper
  • Model/provider: N/A
  • Integration/channel (if any): Talk TTS, channel logout
  • Relevant config (redacted): messages.tts.providers.<id>.apiKey as SecretRef

Steps

  1. Configure Talk + messages.tts.providers.<id>.apiKey through a SecretRef.
  2. Call talk.config with read scope against a strict provider resolver.
  3. Call channel logout for a plugin account whose token is runtime-resolved.

Expected

  • Provider/channel callbacks see runtime-resolved config.
  • talk.config response remains redacted for read scope.

Actual

  • Before this patch, source snapshots could hand unresolved SecretRef wrappers into provider/channel callbacks.

Evidence

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Human Verification (required)

  • Verified scenarios: pnpm test src/gateway/server-methods/talk.test.ts src/gateway/server.talk-config.test.ts; Blacksmith Testbox pnpm test src/gateway/server-methods/talk.test.ts src/gateway/server.talk-config.test.ts; Blacksmith Testbox pnpm check:changed.
  • Edge cases checked: read-scope redaction, runtime timeout selection, strict SecretRef normalization, channel logout runtime config, __proto__ provider-key hardening.
  • What you did not verify: live third-party TTS provider call with a real API key.

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? Yes
  • Config/env changes? No
  • Migration needed? No
  • If yes, exact upgrade steps: N/A

Risks and Mitigations

  • Risk: provider callbacks receive resolved runtime credentials on a config-discovery path.
    • Mitigation: this matches execution-path expectations, and response construction restores source-shaped SecretRef values before existing redaction runs.

@aisle-research-bot

aisle-research-bot Bot commented Apr 28, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

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

# Severity Title
1 🟠 High Channel plugin logout handler receives full resolved runtime config (possible secret exposure)
2 🟡 Medium DoS in talk.config when base TTS provider config contains unresolved SecretRef token
1. 🟠 Channel plugin logout handler receives full resolved runtime config (possible secret exposure)
Property Value
Severity High
CWE CWE-200
Location src/gateway/server-methods/channels.ts:394-444

Description

The channels.logout gateway handler now passes the active resolved runtime config (context.getRuntimeConfig()) into logoutChannelAccount(), which forwards it directly to the channel plugin's gateway.logoutAccount callback.

Implications:

  • context.getRuntimeConfig() is the runtime snapshot with secrets substituted (not SecretRef markers).
  • logoutAccount is plugin-provided code; it is invoked with cfg: OpenClawConfig (the entire config object), not a channel-scoped view.
  • A third-party/untrusted channel plugin can read and exfiltrate unrelated secrets from other channels/providers contained in the runtime config.

Vulnerable flow:

  • Input: resolved runtime config via context.getRuntimeConfig()
  • Sink: passed to plugin callback plugin.gateway.logoutAccount({ cfg: ... }) without redaction/minimization

Recommendation

Minimize secrets passed to plugins.

Options (prefer least-privilege):

  1. Pass only the channel-specific config needed for logout, rather than the full OpenClawConfig.
  2. If the plugin API requires OpenClawConfig, pass a redacted/sanitized clone that removes unrelated namespaces and/or secret values.
  3. Introduce a helper to select and scope config for plugins, e.g.:
const runtimeCfg = context.getRuntimeConfig();
const channelCfg = {
  channels: {
    [channelId]: runtimeCfg.channels?.[channelId],
  },
};

await logoutChannelAccount({
  channelId,
  accountId,
  cfg: channelCfg as OpenClawConfig,
  context,
  plugin,
});

If channel plugins genuinely must access other parts of config, document that as a trust boundary and ensure only trusted plugins can be installed/enabled.

2. 🟡 DoS in talk.config when base TTS provider config contains unresolved SecretRef token
Property Value
Severity Medium
CWE CWE-248
Location src/gateway/server-methods/talk.ts:365-444

Description

talk.config calls provider resolveTalkConfig with baseTtsConfig derived from messages.tts. A recent refactor now strips only apiKey SecretRef wrappers from messages.tts.providers.*, but no longer strips other secret fields such as token.

Impact:

  • If messages.tts.providers.<id>.token is configured as a SecretRef in the source config and runtime config does not contain a resolved messages.tts section, resolveTalkResponseFromConfig will pass the unresolved token object through as part of baseTtsConfig.
  • Strict speech providers normalize both apiKey and token using normalizeResolvedSecretInputString (strict mode), which throws on unresolved SecretRef objects.
  • This can crash/abort the talk.config request (and potentially other Talk flows that resolve talk config), creating a denial-of-service for clients relying on this endpoint.

Evidence of strict token normalization in a speech provider:

  • extensions/volcengine/speech-provider.ts calls normalizeResolvedSecretInputString({ value: raw?.token, path: "messages.tts.providers.volcengine.token" }), which throws if raw?.token is a SecretRef.

Vulnerable code (only strips apiKey from base providers; token may remain a SecretRef object):

const selectedBaseTts =
  Object.keys(runtimeBaseTts).length > 0
    ? runtimeBaseTts
    : stripUnresolvedSecretApiKeysFromBaseTtsProviders(sourceBaseTts);
...
function stripUnresolvedSecretApiKeyFromRecord(config: Record<string, unknown>) {
  if (config.apiKey === undefined || typeof config.apiKey === "string") {
    return config;
  }
  const { apiKey: _omit, ...rest } = config;
  return rest;
}

Recommendation

Strip all secret-input fields that may be SecretRef wrappers from messages.tts.providers.* before passing baseTtsConfig into provider resolvers, not just apiKey.

For example, restore the previous behavior by stripping both apiKey and token (and consider a shared allowlist for other credential keys used by providers):

const BASE_TTS_PROVIDER_SECRET_INPUT_KEYS = ["apiKey", "token"] as const;

function stripUnresolvedSecretInputsFromRecord(config: Record<string, unknown>) {
  let next: Record<string, unknown> | undefined;
  for (const key of BASE_TTS_PROVIDER_SECRET_INPUT_KEYS) {
    const v = config[key];
    if (v === undefined || typeof v === "string") continue;
    next ??= { ...config };
    delete next[key];
  }
  return next ?? config;
}

function stripUnresolvedSecretInputsFromBaseTtsProviders(base: Record<string, unknown>) {// keep the existing null-prototype hardening
  ...
  const next = stripUnresolvedSecretInputsFromRecord(cfg);
  ...
}

Additionally, consider applying this stripping regardless of whether runtimeBaseTts is non-empty, unless you can guarantee it never contains unresolved SecretRef values.


Analyzed PR: #73286 at commit 10a5c84

Last updated on: 2026-04-28T05:09:10Z

@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation gateway Gateway runtime labels Apr 28, 2026
@cursor

cursor Bot commented Apr 28, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Adjusts secret-sensitive config plumbing so provider/channel callbacks receive runtime-resolved credentials instead of source snapshots; mistakes here could break Talk/channel flows or accidentally change what config view is used. Response payloads remain source-shaped/redacted, but this still touches authentication/token handling paths.

Overview
Fixes cases where talk.config and channels.logout could pass source config snapshots (containing unresolved SecretRef wrappers) into provider/channel callbacks by switching those call sites to use context.getRuntimeConfig() / runtime snapshot selection.

Refactors shared runtime selection into selectApplicableRuntimeConfig() (exported via plugin-sdk/runtime-config-snapshot) and updates speech-core TTS config resolution to use it instead of ad-hoc snapshot comparisons.

Adds/updates unit and suite tests to assert strict Talk provider resolvers receive runtime-resolved messages.tts secrets and runtime timeoutMs, and that channel logout callbacks see runtime tokens; also updates docs and changelog guidance about using runtime snapshots for execution paths.

Reviewed by Cursor Bugbot for commit 10a5c84. Bugbot is set up for automated code reviews on this repo. Configure here.

@openclaw-barnacle openclaw-barnacle Bot added size: M maintainer Maintainer-authored PR labels Apr 28, 2026
@steipete steipete force-pushed the fix/messages-tts-secretref branch from e33be98 to 10a5c84 Compare April 28, 2026 05:01
@greptile-apps

greptile-apps Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes two related SecretRef-resolution bugs: channels.logout now passes context.getRuntimeConfig() instead of the file snapshot so channel plugin callbacks receive runtime-resolved credentials, and talk.config now selects runtimeBaseTts when available so strict TTS provider resolvers get materialized API keys rather than unresolved SecretRef wrappers. The refactor consolidates resolveTtsRuntimeConfig in speech-core onto the shared selectApplicableRuntimeConfig seam and exports it from the plugin-SDK surface.

Confidence Score: 4/5

Safe to merge with awareness of the token-stripping regression in the fallback path.

Both headline bugs are correctly fixed and covered by new tests. The only finding is a P2 regression: the replacement for stripUnresolvedSecretInputsFromProviderConfig only strips apiKey, not token, in the fallback path used when runtimeBaseTts is empty. The primary runtime path is unaffected, but the removed websocket test explicitly covered the token case — the new unit tests do not.

src/gateway/server-methods/talk.ts — specifically the stripUnresolvedSecretApiKeyFromRecord fallback and whether token stripping should be restored there.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/gateway/server-methods/talk.ts
Line: 436-444

Comment:
**`token` no longer stripped in the fallback path**

The old `stripUnresolvedSecretInputsFromProviderConfig` stripped both `apiKey` and `token` (via `BASE_TTS_PROVIDER_SECRET_INPUT_KEYS`). The replacement `stripUnresolvedSecretApiKeyFromRecord` only handles `apiKey`. The fallback branch in `resolveTalkResponseFromConfig` (when `Object.keys(runtimeBaseTts).length === 0`) now passes an unresolved `token` SecretRef directly to `speechProvider.resolveTalkConfig`. If a provider reads `baseTtsConfig.providers[id].token` and passes it to `normalizeResolvedSecretInputString`, it will throw — exactly the scenario the removed websocket test verified (that test explicitly set both `apiKey` and `token` as SecretRefs and confirmed no throw).

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "refactor(gateway): clarify talk config r..." | Re-trigger Greptile

Comment on lines +436 to +444
function stripUnresolvedSecretApiKeyFromRecord(
config: Record<string, unknown>,
): Record<string, unknown> {
if (config.apiKey === undefined || typeof config.apiKey === "string") {
return config;
}
const { apiKey: _omit, ...rest } = config;
return rest;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 token no longer stripped in the fallback path

The old stripUnresolvedSecretInputsFromProviderConfig stripped both apiKey and token (via BASE_TTS_PROVIDER_SECRET_INPUT_KEYS). The replacement stripUnresolvedSecretApiKeyFromRecord only handles apiKey. The fallback branch in resolveTalkResponseFromConfig (when Object.keys(runtimeBaseTts).length === 0) now passes an unresolved token SecretRef directly to speechProvider.resolveTalkConfig. If a provider reads baseTtsConfig.providers[id].token and passes it to normalizeResolvedSecretInputString, it will throw — exactly the scenario the removed websocket test verified (that test explicitly set both apiKey and token as SecretRefs and confirmed no throw).

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server-methods/talk.ts
Line: 436-444

Comment:
**`token` no longer stripped in the fallback path**

The old `stripUnresolvedSecretInputsFromProviderConfig` stripped both `apiKey` and `token` (via `BASE_TTS_PROVIDER_SECRET_INPUT_KEYS`). The replacement `stripUnresolvedSecretApiKeyFromRecord` only handles `apiKey`. The fallback branch in `resolveTalkResponseFromConfig` (when `Object.keys(runtimeBaseTts).length === 0`) now passes an unresolved `token` SecretRef directly to `speechProvider.resolveTalkConfig`. If a provider reads `baseTtsConfig.providers[id].token` and passes it to `normalizeResolvedSecretInputString`, it will throw — exactly the scenario the removed websocket test verified (that test explicitly set both `apiKey` and `token` as SecretRefs and confirmed no throw).

How can I resolve this? If you propose a fix, please make it concise.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e33be98dd2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/gateway/server-methods/talk.ts Outdated
Comment on lines +439 to +443
if (config.apiKey === undefined || typeof config.apiKey === "string") {
return config;
}
const { apiKey: _omit, ...rest } = config;
return rest;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Strip unresolved token refs before calling talk resolvers

stripUnresolvedSecretApiKeyFromRecord now removes only apiKey, but stripUnresolvedSecretApiKeysFromBaseTtsProviders uses it for messages.tts.providers.* entries that can also carry token SecretRefs. If a speech plugin’s resolveTalkConfig normalizes baseTtsConfig.providers.<id>.token with strict helpers, talk.config can throw on unresolved wrappers (especially when runtime config does not provide a resolved token for that provider), which regresses the previous behavior that stripped both secret fields from the base TTS map.

Useful? React with 👍 / 👎.

@steipete steipete merged commit 526372e into main Apr 28, 2026
68 checks passed
@steipete steipete deleted the fix/messages-tts-secretref branch April 28, 2026 05:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Improvements or additions to documentation gateway Gateway runtime maintainer Maintainer-authored PR size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

talk.config still throws on SecretRef apiKey when configured under messages.tts.providers.<id>.apiKey (gap left by #72496)

1 participant