Skip to content

Commit cdac196

Browse files
author
Magpie
committed
fix(discord): validate SecretRef provider policy before env fallback
Addresses Codex review [P1] on the previous commit (validate SecretRef providers before env fallback): the prior matchesEnvFallback gate only checked source==="env" and id===DISCORD_BOT_TOKEN. A SecretRef wired to a misconfigured provider (e.g. provider source mismatch, missing non-default provider, or env provider with an allowlist that excludes the id) could still bypass operator secret-provider policy and read process.env.DISCORD_BOT_TOKEN, potentially starting the wrong bot account under operator-rejected credentials. Mirror Telegram's resolveEnvSecretRefValue policy gate (extensions/telegram/src/token.ts) inline, returning false (which now short-circuits to source=none) when: 1. The provider is configured but its source is not "env". 2. The provider is configured with an allowlist that excludes the id. 3. The provider is not configured AND is not the runtime-resolved default env-provider alias. Only refs that pass all three checks (in addition to the source/id match) fall through to process.env. Operator policy is preserved end-to-end. Refactored helper kept inline (extension-local) rather than promoted to plugin-sdk per CONTRIBUTING ("refactor-only PRs ... not accepted"). If a maintainer wants this consolidated with telegram's helper as part of a follow-up, happy to do it as a separate change. New tests (4): - env SecretRef + provider source mismatch -> source=none - env SecretRef + provider allowlist excludes id -> source=none - env SecretRef + provider not configured and not default alias -> source=none - env SecretRef + provider configured with matching allowlist -> source=env Updated changelog entry to call out the provider-policy gate. Refs #76371, #76385.
1 parent fb48a6a commit cdac196

3 files changed

Lines changed: 139 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Docs: https://docs.openclaw.ai
1313

1414
### Fixes
1515

16-
- Discord: tolerate unresolved `channels.discord.token` SecretRef objects in the externalized `@openclaw/discord` channel-startup path on 2026.5.2, so SecretRefs whose intent matches the existing `DISCORD_BOT_TOKEN` env fallback (`{ source: "env", id: "DISCORD_BOT_TOKEN" }`) fall through to the env fallback instead of crashing channel start with `unresolved SecretRef ... Resolve this command against an active gateway runtime snapshot before reading it.`. Non-env SecretRefs (file/exec/alternate env id) and account-level SecretRefs preserve operator intent — they remain configured-but-unresolved and surface a user-actionable `Discord bot token missing for account ...` error from `createDiscordRestClient` rather than silently substituting an unrelated env token. Fixes #76371. (#76385)
16+
- Discord: tolerate unresolved `channels.discord.token` SecretRef objects in the externalized `@openclaw/discord` channel-startup path on 2026.5.2, so SecretRefs whose intent matches the existing `DISCORD_BOT_TOKEN` env fallback (`{ source: "env", id: "DISCORD_BOT_TOKEN" }`) — and whose configured secrets-provider policy permits the env read (mirrors Telegram's `resolveEnvSecretRefValue` provider/source/allowlist gate) — fall through to the env fallback instead of crashing channel start with `unresolved SecretRef ... Resolve this command against an active gateway runtime snapshot before reading it.`. Non-env SecretRefs (file/exec/alternate env id), account-level SecretRefs, and any env SecretRef whose provider is missing, has the wrong source, or is excluded by an allowlist preserve operator intent — they remain configured-but-unresolved and surface a user-actionable `Discord bot token missing for account ...` error from `createDiscordRestClient` rather than silently substituting an unrelated env token. Fixes #76371. (#76385)
1717
- Gateway: preserve stack diagnostics when `chat.send` or agent attachment parsing/staging fails, improving image-send failure triage. Refs #63432. (#75135) Thanks @keen0206.
1818
- Maintainer workflow: push prepared PR heads through GitHub's verified commit API by default and require an explicit override before git-protocol pushes can publish unsigned commits. Thanks @BunsDev.
1919
- Feishu: resolve setup/status probes through the selected/default account so multi-account configs with account-scoped app credentials show as configured and probeable. Fixes #72930. Thanks @brokemac79.

extensions/discord/src/token.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,4 +198,83 @@ describe("resolveDiscordToken", () => {
198198
expect(res.token).toBe("");
199199
expect(res.source).toBe("none");
200200
});
201+
202+
it("does not fall through to env when the configured SecretRef provider is not env-source", () => {
203+
// Operator wired secrets.providers.vault as exec-sourced but referenced it
204+
// from channels.discord.token as if it were env-sourced. The provider
205+
// policy disagrees with the SecretRef's source, so the env fallback must
206+
// not silently rescue the misconfiguration.
207+
vi.stubEnv("DISCORD_BOT_TOKEN", "env-token");
208+
const cfg = {
209+
secrets: {
210+
providers: {
211+
vault: { source: "exec" },
212+
},
213+
},
214+
channels: {
215+
discord: {
216+
token: { source: "env", provider: "vault", id: "DISCORD_BOT_TOKEN" },
217+
},
218+
},
219+
} as unknown as OpenClawConfig;
220+
221+
const res = resolveDiscordToken(cfg);
222+
expect(res.token).toBe("");
223+
expect(res.source).toBe("none");
224+
});
225+
226+
it("does not fall through to env when the configured env provider's allowlist excludes the id", () => {
227+
vi.stubEnv("DISCORD_BOT_TOKEN", "env-token");
228+
const cfg = {
229+
secrets: {
230+
providers: {
231+
tight: { source: "env", allowlist: ["OTHER_TOKEN"] },
232+
},
233+
},
234+
channels: {
235+
discord: {
236+
token: { source: "env", provider: "tight", id: "DISCORD_BOT_TOKEN" },
237+
},
238+
},
239+
} as unknown as OpenClawConfig;
240+
241+
const res = resolveDiscordToken(cfg);
242+
expect(res.token).toBe("");
243+
expect(res.source).toBe("none");
244+
});
245+
246+
it("does not fall through to env when the SecretRef provider is not configured and is not the default env alias", () => {
247+
vi.stubEnv("DISCORD_BOT_TOKEN", "env-token");
248+
const cfg = {
249+
channels: {
250+
discord: {
251+
token: { source: "env", provider: "ghost", id: "DISCORD_BOT_TOKEN" },
252+
},
253+
},
254+
} as unknown as OpenClawConfig;
255+
256+
const res = resolveDiscordToken(cfg);
257+
expect(res.token).toBe("");
258+
expect(res.source).toBe("none");
259+
});
260+
261+
it("falls through to env when the configured env provider explicitly allowlists the id", () => {
262+
vi.stubEnv("DISCORD_BOT_TOKEN", "env-token");
263+
const cfg = {
264+
secrets: {
265+
providers: {
266+
envprov: { source: "env", allowlist: ["DISCORD_BOT_TOKEN", "OTHER_TOKEN"] },
267+
},
268+
},
269+
channels: {
270+
discord: {
271+
token: { source: "env", provider: "envprov", id: "DISCORD_BOT_TOKEN" },
272+
},
273+
},
274+
} as unknown as OpenClawConfig;
275+
276+
const res = resolveDiscordToken(cfg);
277+
expect(res.token).toBe("env-token");
278+
expect(res.source).toBe("env");
279+
});
201280
});

extensions/discord/src/token.ts

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract";
22
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
3+
import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth";
34
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
45
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
56
import { coerceSecretRef, normalizeSecretInputString } from "openclaw/plugin-sdk/secret-input";
@@ -25,6 +26,34 @@ export function normalizeDiscordToken(raw: unknown, _path: string): string | und
2526
return trimmed.replace(/^Bot\s+/i, "");
2627
}
2728

29+
// Returns true iff `cfg.secrets.providers[providerName]` (or, if absent, the
30+
// resolved default secrets-provider alias for env) is configured to honor a
31+
// SecretRef whose intent is `env:<providerName>:<id>` — i.e. the provider's
32+
// source is `env` and any allowlist permits `id`. This mirrors Telegram's
33+
// resolveEnvSecretRefValue policy gate (extensions/telegram/src/token.ts) so
34+
// that a Discord channel-startup env-fallback only fires when the operator-
35+
// configured SecretRef policy actually permits reading process.env[id].
36+
function envSecretRefMatchesProviderPolicy(
37+
cfg: OpenClawConfig | undefined,
38+
providerName: string,
39+
id: string,
40+
): boolean {
41+
const providerConfig = cfg?.secrets?.providers?.[providerName];
42+
if (providerConfig) {
43+
if (providerConfig.source !== "env") {
44+
return false;
45+
}
46+
if (providerConfig.allowlist && !providerConfig.allowlist.includes(id)) {
47+
return false;
48+
}
49+
return true;
50+
}
51+
// Provider not explicitly configured — only allow fallthrough when this is
52+
// the default env-provider alias the runtime would have resolved anyway.
53+
const defaultEnvAlias = resolveDefaultSecretProviderAlias({ secrets: cfg?.secrets }, "env");
54+
return providerName === defaultEnvAlias;
55+
}
56+
2857
export function resolveDiscordToken(
2958
cfg: OpenClawConfig,
3059
opts: { accountId?: string | null; envToken?: string | null } = {},
@@ -54,15 +83,38 @@ export function resolveDiscordToken(
5483
}
5584

5685
// If the top-level token is an unresolved SecretRef, only fall through to the
57-
// process.env DISCORD_BOT_TOKEN fallback when the ref's intent is identical
58-
// (env source, DISCORD_BOT_TOKEN id). For any other configured SecretRef
59-
// shape (file, exec, alternate env id) we preserve operator intent and report
60-
// the token as unavailable rather than silently substituting an unrelated env
61-
// token, which could otherwise start the wrong bot account.
62-
const topTokenRef = coerceSecretRef(topTokenValue);
86+
// process.env DISCORD_BOT_TOKEN fallback when the ref's intent matches it AND
87+
// the operator's secret-provider policy permits the env read. We check three
88+
// things, in order, and short-circuit to source=none on any miss:
89+
//
90+
// 1. ref.source === "env" — non-env refs (file, exec)
91+
// must be resolved upstream;
92+
// we will not substitute env
93+
// for a vault/file lookup.
94+
// 2. ref.id === DISCORD_DEFAULT_BOT_ENV_VAR — env refs pointing at a
95+
// different env var (e.g.
96+
// DISCORD_PROD_BOT_TOKEN)
97+
// must not be silently
98+
// substituted by the bare
99+
// DISCORD_BOT_TOKEN fallback.
100+
// 3. provider policy allows env:<provider>:<id> — mirrors Telegram's
101+
// resolveEnvSecretRefValue
102+
// gate so a misconfigured
103+
// secrets provider (wrong
104+
// source or excluded by an
105+
// allowlist) cannot bypass
106+
// operator policy via the
107+
// channel env fallback.
108+
//
109+
// Anything else preserves operator intent and surfaces a user-actionable
110+
// "Discord bot token missing" error from createDiscordRestClient instead of
111+
// crashing channel startup with the internal SecretRef contract error.
112+
const topTokenRef = coerceSecretRef(topTokenValue, cfg?.secrets?.defaults);
63113
if (topTokenRef) {
64114
const matchesEnvFallback =
65-
topTokenRef.source === "env" && topTokenRef.id === DISCORD_DEFAULT_BOT_ENV_VAR;
115+
topTokenRef.source === "env" &&
116+
topTokenRef.id === DISCORD_DEFAULT_BOT_ENV_VAR &&
117+
envSecretRefMatchesProviderPolicy(cfg, topTokenRef.provider, topTokenRef.id);
66118
if (!matchesEnvFallback) {
67119
return { token: "", source: "none" };
68120
}

0 commit comments

Comments
 (0)