fix(slack): tolerate unresolved channel SecretRef on outbound send path#68954
Conversation
Greptile SummaryThis PR fixes a regression introduced in The fix adds an opt-in Confidence Score: 5/5Safe to merge — targeted, opt-in change with regression tests covering both strict and tolerant modes. The fix is well-scoped: the relaxation is strictly opt-in (default behavior unchanged for all other callers), the only opt-in site already has an explicit boot-resolved token override and an existing 'missing token' guard, and 3 new test cases pin both directions of the new contract. No shared SDK surface, schemas, or runtime snapshot machinery is touched. No files require special attention. Reviews (1): Last reviewed commit: "Slack/send: tolerate unresolved channel ..." | Re-trigger Greptile |
2a14841 to
571cd35
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 571cd35ec6
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
571cd35 to
c4055d3
Compare
|
👋 Hey @openperf! I was having trouble mirroring these errors at first. Editing this comment in review but the fix is working for me! FWIW I agree with:
And am using the configurations: {
"secrets": {
"providers": {
"filemain": {
"source": "file",
"path": "~/.openclaw/token.out",
"mode": "singleValue"
}
}
},
"channels": {
"slack": {
"accounts": {
"default": {
"botToken": {
"source": "file",
"provider": "filemain",
"id": "value"
},
"appToken": "xapp-1-example"
}
},
"enabled": true,
"mode": "socket"
}
}
}Edit: I'm now curious too for why these secrets aren't resolved at runtime but this approach seems alright for an immediate fix! I'm concerned other callsites might require a similar patch but am unsure of this at the moment ⏳
|
|
Hey @zimeg — yeah, that's expected. With a clean boot the secrets runtime resolves I dug into the upstream snapshot-pollution path while writing this up. The trigger is So a single transient secret-provider failure during a reload (1Password CLI slow exec, To verify this fix, the new contract tests pin both directions of the opt-in directly: pnpm test extensions/slack/src/accounts.test.ts # 11/11 For end-to-end, the easiest reliable handle I've found is to force a refresh failure |
🔒 Aisle Security AnalysisWe found 1 potential security issue(s) in this PR:
1. 🟠 Slack token env-var fallback can override configured SecretRef due to overly-strict isSecretRef check (tolerant mode)
DescriptionIn However, the guard uses Impact:
Vulnerable code: const blockBotEnv = tolerantMode && isSecretRef(merged.botToken);
...
const envBot = baseAllowEnv && !blockBotEnv
? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN)
: undefined;
const configBot = tolerantMode
? normalizeSecretInputString(merged.botToken)
: resolveSlackBotToken(...);
const botToken = configBot ?? envBot;And if (Object.keys(value).length !== 3) return false;RecommendationMake env-fallback blocking depend on whether a secret input is configured, not on the narrow Options:
import { coerceSecretRef } from "openclaw/plugin-sdk/secret-input";
const botRef = tolerantMode ? coerceSecretRef(merged.botToken) : null;
const blockBotEnv = tolerantMode && botRef !== null;
const configBot = tolerantMode ? normalizeSecretInputString(merged.botToken) : ...;
const blockBotEnv = tolerantMode && merged.botToken !== undefined && !configBot;Also consider adding tests for:
This ensures Analyzed PR: #68954 at commit Last updated on: 2026-04-20T07:00:55Z |
85fcf64 to
2742f2d
Compare
2742f2d to
fa8877d
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fa8877de8d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const blockBotEnv = tolerantMode && isSecretRef(merged.botToken); | ||
| const blockAppEnv = tolerantMode && isSecretRef(merged.appToken); | ||
| const blockUserEnv = tolerantMode && isSecretRef(merged.userToken); |
There was a problem hiding this comment.
Detect legacy SecretRef shapes before allowing env fallback
In tolerant mode, env fallback is blocked only when isSecretRef(merged.*Token) is true, but isSecretRef requires an exact {source, provider, id} object. The secret-resolution path (coerceSecretRef) still treats legacy {source, id} refs as configured unresolved secrets, so those inputs now bypass block*Env, produce undefined from normalizeSecretInputString, and then silently fall back to SLACK_*_TOKEN. That is a behavior change from the previous strict resolver (which threw) and reintroduces the credential-confusion case this change is trying to prevent for legacy config/snapshot shapes.
Useful? React with 👍 / 👎.
fa8877d to
a0dc171
Compare
… when explicit bot token is provided
…stently - Add CHANGELOG.md entry for openclaw#68954 (missing from original PR). - Use tolerantMode local consistently in resolveSlackAccount (S1). - Add inline comment to resolveSlackSendContext documenting the strict-mode invariant: params.cfg is the scoped channel-dispatch config (F2).
a0dc171 to
f13c639
Compare
|
Merged via squash.
Thanks @openperf! |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f13c639c26
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const blockBotEnv = tolerantMode && isSecretRef(merged.botToken); | ||
| const blockAppEnv = tolerantMode && isSecretRef(merged.appToken); | ||
| const blockUserEnv = tolerantMode && isSecretRef(merged.userToken); | ||
| const envBot = | ||
| baseAllowEnv && !blockBotEnv ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) : undefined; |
There was a problem hiding this comment.
Block env fallback for legacy SecretRef token shapes
In tolerant mode, env fallback is only blocked when isSecretRef(merged.*Token) is true, but isSecretRef requires an exact {source, provider, id} object. The secret-resolution path still treats legacy {source, id} objects as configured unresolved refs (via coerceSecretRef), so for default-account sends those inputs now become undefined through normalizeSecretInputString and then incorrectly fall through to SLACK_*_TOKEN. This is a behavior regression from the previous strict path (which threw) and can cause credential confusion when legacy snapshot/config shapes are present.
Useful? React with 👍 / 👎.
…th (openclaw#68954) Merged via squash. Prepared head SHA: f13c639 Co-authored-by: openperf <80630709+openperf@users.noreply.github.com> Co-authored-by: openperf <80630709+openperf@users.noreply.github.com> Reviewed-by: @openperf
…th (openclaw#68954) Merged via squash. Prepared head SHA: f13c639 Co-authored-by: openperf <80630709+openperf@users.noreply.github.com> Co-authored-by: openperf <80630709+openperf@users.noreply.github.com> Reviewed-by: @openperf
…th (openclaw#68954) Merged via squash. Prepared head SHA: f13c639 Co-authored-by: openperf <80630709+openperf@users.noreply.github.com> Co-authored-by: openperf <80630709+openperf@users.noreply.github.com> Reviewed-by: @openperf
…th (openclaw#68954) Merged via squash. Prepared head SHA: f13c639 Co-authored-by: openperf <80630709+openperf@users.noreply.github.com> Co-authored-by: openperf <80630709+openperf@users.noreply.github.com> Reviewed-by: @openperf
…th (openclaw#68954) Merged via squash. Prepared head SHA: f13c639 Co-authored-by: openperf <80630709+openperf@users.noreply.github.com> Co-authored-by: openperf <80630709+openperf@users.noreply.github.com> Reviewed-by: @openperf
…th (openclaw#68954) Merged via squash. Prepared head SHA: f13c639 Co-authored-by: openperf <80630709+openperf@users.noreply.github.com> Co-authored-by: openperf <80630709+openperf@users.noreply.github.com> Reviewed-by: @openperf
Summary
Problem: On runtimes where the cfg snapshot returned by
loadConfig()retains an unresolvedSecretRefobject onchannels.slack.accounts.<id>.botToken, every Slack outbound reply throwschannels.slack.accounts.<id>.botToken: unresolved SecretRef "<source>:<provider>:<id>". Resolve this command against an active gateway runtime snapshot before reading it.The error is thrown deep insidesendMessageSlack(extensions/slack/src/send.ts:325) before the explicit, boot-resolvedopts.tokenis ever consulted atextensions/slack/src/send.ts:329. Reactions (reactions.add) and inbound dispatch keep working because they reuse the boot-timectx.app.clientand never re-entersendMessageSlack. Independently confirmed by three reporters acrossfile-source secrets,exec-source secrets (1Password CLI wrapper, macOS arm64), and the original ticket.Root Cause:
sendMessageSlackcallsresolveSlackAccount({ cfg })withcfg = opts.cfg ?? loadConfig().resolveSlackAccount(extensions/slack/src/accounts.ts:61-72) eagerly invokes the strict resolversresolveSlackBotToken / resolveSlackAppToken / resolveSlackUserTokenonmerged.botToken / appToken / userToken. Those resolvers route throughassertSecretInputResolved(src/config/types.secrets.ts:137-140), which throws on anySecretRefobject that survived in the runtime snapshot. The strict throw is correct at boot-time provider initialization (where there is no fallback) but is a false positive on the send path becausesendMessageSlackalready has a boot-resolved explicit override (opts.token, threaded fromctx.botToken) and only consultsaccount.botTokenas a defensive fallback. The upstream condition under which the cfg snapshot ends up holding an unresolvedSecretRef(rather than the resolved string the secrets-runtime activation produces at boot) is environment-dependent and not fully nailed down here — likely candidates includefinalizeRuntimeSnapshotWritere-pinning the raw cfg when a refresh handler returns false, transient secret-provider failures during a config-write reload, and OAuth token refreshes that touchauth.profiles.*and trigger a snapshot reload. The reporters' A/B between2026.4.14and2026.4.15is deterministic on long-running gateways with active OAuth profiles or external secret providers; the fix here is intentionally scoped to the slack-send consumer side and does not attempt to address the wider snapshot-pollution path.Fix: Add an opt-in
tolerateUnresolvedSecretsflag toresolveSlackAccount. When set, the function readsbotToken / appToken / userTokenthroughnormalizeSecretInputString(gentle: returnsundefinedforSecretRefobjects) instead of the strict resolvers (which throw).sendMessageSlackopts in to the tolerant read, with an inline comment explaining why: the explicitopts.tokencovers the actual auth, and the existingif (!fallback) throw "Slack bot token missing for account ..."inresolveTokenstill emits a clean, actionable error if neither explicit nor fallback is available. The flag is opt-in only so all other callers (provider boot inextensions/slack/src/monitor/provider.ts:271,listEnabledSlackAccounts, account inspection helpers, etc.) preserve the strict behavior — boot-time providers without an override still surface unresolved channel SecretRefs loudly. This addresses the failure at the consumer side directly: regardless of why the snapshot holds an unresolvedSecretRef,sendMessageSlackno longer fails when it has a valid explicit token.What changed:
extensions/slack/src/accounts.ts: addtolerateUnresolvedSecrets?: booleanparameter toresolveSlackAccount; gate the per-account credential reads on the flag, usingnormalizeSecretInputString(fromopenclaw/plugin-sdk/secret-input) when set; documented the contract on the new parameter. Per-credential env-var fallback gating (isSecretRef(merged.<field>)) ensures that a configured-but-unresolvedSecretRefcannot be silently overridden by a straySLACK_*_TOKENprocess env var, while credentials that are unset entirely still allow env fallback so legitimate env-only setups (no per-account config token) keep working. Also, in the same file'smergeSlackAccountConfighelper, drop the redundant| undefinedconstituents from the two existingas ...casts oncfg.channels?.slack/cfg.channels?.slack?.accounts— optional chaining already producesT | undefined, so the explicit| undefinedadds nothing while triggeringtypescript-eslint(no-redundant-type-constituents)onceaccounts.tsenters the staged set. No runtime behavior change; the receivingresolveMergedAccountConfigparameter type isTConfig | undefinedso the narrower cast remains assignable.extensions/slack/src/send.ts:sendMessageSlacknow passestolerateUnresolvedSecrets: truewhen resolving the account snapshot, with an inline comment explaining why the tolerant read is safe here.extensions/slack/src/accounts.test.ts: extend the existing test file with a focuseddescribe("resolveSlackAccount tolerateUnresolvedSecrets", …)block (6 cases) — strict-by-default still throws on unresolvedSecretRef; tolerant-mode returnsundefinedcredentials withbotTokenSource: "none"while preserving non-credential account info; tolerant-mode preserves resolved string credentials unchanged; CWE-287 guard blocksSLACK_*_TOKENenv fallback when all three credentials are configured as SecretRef; env-only setups (no config token) continue to useSLACK_*_TOKENenv fallback; per-credential isolation (unresolvedSecretRefonbotTokendoes not affectSLACK_APP_TOKENfallback whenappTokenis unset). ThecfgWithUnresolvedBotTokenReffixture is cast throughunknowntoOpenClawConfig(with a comment explaining why), becauseSlackAccountConfig.botTokenis statically typed asstringwhile the runtime snapshot can still hold an unresolvedSecretRefobject — exactly the shape this PR is fixing.What did NOT change (scope boundary):
assertSecretInputResolved,normalizeResolvedSecretInputString,normalizeSecretInputString, or any othersrc/config/types.secrets.tssemantics. The strict / inspect contract is preserved.src/secrets/runtime.ts),loadConfig,loadPinnedRuntimeConfig,setRuntimeConfigSnapshot, orfinalizeRuntimeSnapshotWrite. The wider snapshot-pollution path is intentionally out of scope and belongs in a separate change.resolveSlackBotToken / resolveSlackAppToken / resolveSlackUserToken(extensions/slack/src/token.ts); the strict resolvers are still the default for every caller that does not opt into tolerant mode (provider boot,listEnabledSlackAccounts, account inspection helpers).mergeSlackAccountConfigcleanup is type-only (drops a redundant union constituent); it does not change the function signature, return type, runtime behavior, or callers' contract.openclaw/plugin-sdk/*, to channel-config zod schemas, or to any documentation. CHANGELOG entry intentionally omitted from this patch and can be added on merge per maintainer preference.{ cfg, accountId }and get the existing strict behavior.Reproduction
The failure mode at the slack send path is deterministic — when
loadConfig()returns a cfg with aSecretRefobject onchannels.slack.accounts.<id>.botToken,resolveSlackAccountstrict-throws the exact error message in the issue. What is environment-dependent is the upstream condition that causes the runtime snapshot to retain that unresolvedSecretRefrather than the resolved string the secrets-runtime activation produces at boot. There is no single one-line minimal repro that triggers the snapshot-pollution path independently of the real-world setups in the issue thread; the verification paths below cover both "did the fix change the consumer-side behavior correctly" and "does the fix unblock the reported scenario end-to-end."Path A — direct contract verification (most reliable, environment-independent)
The 6 new cases in
extensions/slack/src/accounts.test.tspin the new contract directly without needing to reproduce the snapshot-pollution upstream:SecretRef-shaped cfg with the path-tagged error message.undefinedcredentials withbotTokenSource: "none"for unresolvedSecretRefwhile preserving non-credential account info (accountId,allowFrom, etc.).SecretRef,SLACK_*_TOKENenv fallbacks are all blocked in tolerant mode.SLACK_*_TOKENenv fallback in tolerant mode.SecretRefonbotTokenblocksSLACK_BOT_TOKENonly;SLACK_APP_TOKENstill falls back whenappTokenis unset.Run with:
pnpm test extensions/slack/src/accounts.test.ts.Path B — end-to-end on a long-running gateway (matches the reporters' setups)
Configure Slack on
2026.4.15with the bot token sourced through aSecretRef, for example:Boot the gateway with a working secret provider (the secrets-runtime activation will resolve
botTokeninto the snapshot at boot, so the very first reply succeeds).Trigger a config-write reload that exercises the snapshot-pollution upstream — for example, let an OAuth token refresh write to
auth.profiles.*, runtouch ~/.openclaw/openclaw.json, or invoke a UI / wizard flow that bumpsmeta.lastTouchedAt. (@oclilbuddy's repro usedexec-source 1Password CLI which exhibits this on its own through transient resolver delays.)Send a message after the reload settles. On
2026.4.15the reply now throws the unresolved-SecretReferror; the typing reaction (reactions.add) still succeeds, exposing the asymmetry between the boot-resolvedctx.app.client(works) and theloadConfig()-drivensendMessageSlackpath (fails).With this PR applied:
chat.postMessagesucceeds becausesendMessageSlackfalls through to the explicitopts.token(the boot-resolved monitor context token) instead of strict-throwing on the snapshot-sideSecretRef.Independent reproduction footprints (full thread in #68237):
file-source secrets, Linux.exec-source secrets via 1Password CLI wrapper, macOS 26.4.1 / arm64 / Node 25.9.0.2026.4.14 (323493f)works,2026.4.15 (041266a)fails with the same config; A/B is deterministic acrossnpm i -g openclaw@…flips on long-running gateways.Risk / Mitigation
resolveSlackAccountwere globally relaxed, a future caller without a fallback could end up sendingundefinedas a Slack token and surface a confusing failure mode (or worse, leak the rawSecretRefobject label into a log line). Letting the env-var fallback fire when a configuredSecretReffails to resolve would also be a credential-confusion vector (CWE-287) on misconfigured deployments where a straySLACK_*_TOKENenv var exists.sendMessageSlack, which already has an explicit, boot-resolved override and an existingif (!token) throw "Slack bot token missing for account ..."guard atextensions/slack/src/send.ts(the existingresolveToken). If both explicit and fallback are absent, the user-facing error is the existing actionable "missing token" message, not the silent raw-SecretRefshape.SecretRef, the correspondingSLACK_*_TOKENenv var is blocked from silently impersonating it. Credentials that are unset entirely (legitimate env-only setups, e.g.extensions/slack/src/channel.ts:543invokingsendMessageSlackwithoutopts.token) continue to use the existing env fallback. This satisfies both the CWE-287 concern and preserves backwards-compatible env-only behavior.extensions/slack/src/accounts.test.ts) pin all directions of the contract: strict mode still throws with the path-tagged error message; tolerant mode returnsundefinedcredentials andbotTokenSource: "none"; tolerant mode does not regress on already-resolved string credentials; env fallback is blocked per-credential when configured asSecretRef; env fallback still fires when no credential is configured.accounts.ts,send.ts,accounts.test.ts); no shared SDK surface, no public type, no schema, no zod model, no runtime snapshot, no IPC / wire protocol, no plugin contract is touched. CODEOWNERS-restricted security paths are not touched.typescript-eslint(no-redundant-type-constituents)which previously triggered on the pre-existingmergeSlackAccountConfigcasts. Targeted vitest run inextensions/slack/src/accounts.test.tsis green (11 / 11, with 6 new cases).extensions/slack/src/send.blocks.test.tsandextensions/slack/src/send.upload.test.tsare green (22 / 22) so the existing send-path mocks (blocks.test-helpers.tsmocksresolveSlackAccountand is unaffected by the new optional parameter) continue to work.pnpm tsgo:extensions:testpasses on the touched files (the test fixture'sas unknown as OpenClawConfigcast is the same pattern asextensions/slack/src/action-runtime.test.tsand other slack tests that need to construct shapes the schema rejects).Change Type (select all)
Scope (select all touched areas)
accounts.test.ts)Linked Issue/PR
Fixes #68237