Skip to content

Commit 911ac6d

Browse files
authored
fix(discord): handle SecretRef runtime status (#76987)
* fix(discord): handle SecretRef runtime status * docs(changelog): mention Discord SecretRef fix
1 parent b2fd814 commit 911ac6d

15 files changed

Lines changed: 294 additions & 40 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
4141
- Control UI/WebChat: collapse duplicate in-flight internal text sends onto the active Gateway run so rapid repeat submits do not start fresh `agent:main:main` dispatches. Fixes #75737. Thanks @dsdsddd1 and @BunsDev.
4242
- Mattermost: accept the documented `channels.mattermost.streaming` config and honor `streaming: "off"` by disabling draft preview posts. Thanks @vincentkoc.
4343
- Discord: keep progress draft boundary callbacks bound during streaming replies, so extension lint stays green while progress previews transition between assistant and reasoning blocks. Thanks @vincentkoc.
44+
- Discord: resolve SecretRef-backed bot tokens from the active runtime snapshot for named accounts and keep unresolved configured tokens from crashing status or health checks. (#76987) Thanks @joshavant.
4445
- Channels/streaming: expose `streaming.progress.label`, `labels`, `maxLines`, and `toolProgress` in bundled channel config metadata so progress draft settings appear in config, docs, and control surfaces. Thanks @vincentkoc.
4546
- Channels/streaming: normalize whitespace and case for `streaming.progress.label: "auto"` so progress draft labels keep using the built-in label pool instead of rendering a literal `auto` title. Thanks @vincentkoc.
4647
- Gateway/install: prefer supported system Node over nvm/fnm/volta/asdf/mise when regenerating managed gateway services, so `gateway install --force` no longer recreates service definitions that doctor immediately flags as version-manager-backed. Fixes #76339. Thanks @brokemac79.

extensions/discord/api.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,8 @@ export {
55
handleDiscordSubagentEnded,
66
handleDiscordSubagentSpawning,
77
} from "./src/subagent-hooks.js";
8-
export {
9-
type DiscordCredentialStatus,
10-
inspectDiscordAccount,
11-
type InspectedDiscordAccount,
12-
} from "./src/account-inspect.js";
8+
export { inspectDiscordAccount, type InspectedDiscordAccount } from "./src/account-inspect.js";
9+
export { type DiscordCredentialStatus } from "./src/token.js";
1310
export {
1411
createDiscordActionGate,
1512
listDiscordAccountIds,

extensions/discord/src/account-inspect.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ import {
1010
resolveDiscordAccountConfig,
1111
} from "./accounts.js";
1212
import type { DiscordAccountConfig, OpenClawConfig } from "./runtime-api.js";
13-
14-
export type DiscordCredentialStatus = "available" | "configured_unavailable" | "missing";
13+
import type { DiscordCredentialStatus } from "./token.js";
1514

1615
export type InspectedDiscordAccount = {
1716
accountId: string;

extensions/discord/src/accounts.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
2+
import {
3+
clearRuntimeConfigSnapshot,
4+
setRuntimeConfigSnapshot,
5+
} from "openclaw/plugin-sdk/runtime-config-snapshot";
16
import { afterEach, describe, expect, it, vi } from "vitest";
27
import {
38
createDiscordActionGate,
@@ -9,6 +14,7 @@ import {
914
} from "./accounts.js";
1015

1116
afterEach(() => {
17+
clearRuntimeConfigSnapshot();
1218
vi.unstubAllEnvs();
1319
});
1420

@@ -245,3 +251,60 @@ describe("Discord duplicate-token account filtering", () => {
245251
expect(listEnabledDiscordAccounts(cfg).map((account) => account.accountId)).toEqual(["active"]);
246252
});
247253
});
254+
255+
describe("resolveDiscordAccount runtime config selection", () => {
256+
it("resolves named account SecretRefs from the active runtime snapshot", () => {
257+
const sourceCfg = {
258+
channels: {
259+
discord: {
260+
defaultAccount: "work",
261+
accounts: {
262+
work: {
263+
name: "Work",
264+
token: { source: "env", provider: "default", id: "DISCORD_WORK_TOKEN" },
265+
},
266+
},
267+
},
268+
},
269+
} as unknown as OpenClawConfig;
270+
const runtimeCfg = {
271+
channels: {
272+
discord: {
273+
defaultAccount: "work",
274+
accounts: {
275+
work: {
276+
name: "Work",
277+
token: "Bot runtime-work-token",
278+
},
279+
},
280+
},
281+
},
282+
} as OpenClawConfig;
283+
setRuntimeConfigSnapshot(runtimeCfg, sourceCfg);
284+
285+
const resolved = resolveDiscordAccount({ cfg: sourceCfg });
286+
287+
expect(resolved.accountId).toBe("work");
288+
expect(resolved.token).toBe("runtime-work-token");
289+
expect(resolved.tokenSource).toBe("config");
290+
expect(resolved.tokenStatus).toBe("available");
291+
});
292+
293+
it("preserves configured unavailable tokens without falling through to env", () => {
294+
vi.stubEnv("DISCORD_BOT_TOKEN", "env-token");
295+
const resolved = resolveDiscordAccount({
296+
cfg: {
297+
channels: {
298+
discord: {
299+
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
300+
},
301+
},
302+
} as unknown as OpenClawConfig,
303+
accountId: "default",
304+
});
305+
306+
expect(resolved.token).toBe("");
307+
expect(resolved.tokenSource).toBe("config");
308+
expect(resolved.tokenStatus).toBe("configured_unavailable");
309+
});
310+
});

extensions/discord/src/accounts.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,16 @@ import {
1414
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
1515
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
1616
import type { DiscordAccountConfig, DiscordActionConfig, OpenClawConfig } from "./runtime-api.js";
17-
import { resolveDiscordToken } from "./token.js";
17+
import { selectDiscordRuntimeConfig } from "./runtime-config.js";
18+
import { resolveDiscordToken, type DiscordCredentialStatus } from "./token.js";
1819

1920
export type ResolvedDiscordAccount = {
2021
accountId: string;
2122
enabled: boolean;
2223
name?: string;
2324
token: string;
2425
tokenSource: "env" | "config" | "none";
26+
tokenStatus: DiscordCredentialStatus;
2527
config: DiscordAccountConfig;
2628
};
2729

@@ -100,20 +102,20 @@ export function resolveDiscordAccount(params: {
100102
cfg: OpenClawConfig;
101103
accountId?: string | null;
102104
}): ResolvedDiscordAccount {
103-
const accountId = normalizeAccountId(
104-
params.accountId ?? resolveDefaultDiscordAccountId(params.cfg),
105-
);
106-
const baseEnabled = params.cfg.channels?.discord?.enabled !== false;
107-
const merged = mergeDiscordAccountConfig(params.cfg, accountId);
105+
const cfg = selectDiscordRuntimeConfig(params.cfg);
106+
const accountId = normalizeAccountId(params.accountId ?? resolveDefaultDiscordAccountId(cfg));
107+
const baseEnabled = cfg.channels?.discord?.enabled !== false;
108+
const merged = mergeDiscordAccountConfig(cfg, accountId);
108109
const accountEnabled = merged.enabled !== false;
109110
const enabled = baseEnabled && accountEnabled;
110-
const tokenResolution = resolveDiscordToken(params.cfg, { accountId });
111+
const tokenResolution = resolveDiscordToken(cfg, { accountId });
111112
return {
112113
accountId,
113114
enabled,
114115
name: normalizeOptionalString(merged.name),
115116
token: tokenResolution.token,
116117
tokenSource: tokenResolution.source,
118+
tokenStatus: tokenResolution.tokenStatus,
117119
config: merged,
118120
};
119121
}

extensions/discord/src/client.test.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
2-
import { describe, expect, it } from "vitest";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
33
import { createDiscordRestClient } from "./client.js";
44
import type { RequestClient } from "./internal/discord.js";
55

6+
afterEach(() => {
7+
vi.unstubAllEnvs();
8+
});
9+
610
describe("createDiscordRestClient", () => {
711
const fakeRest = {} as RequestClient;
812

@@ -58,7 +62,8 @@ describe("createDiscordRestClient", () => {
5862
expect(result.account.config.retry).toMatchObject({ attempts: 7 });
5963
});
6064

61-
it("still throws when no explicit token is provided and config token is unresolved", () => {
65+
it("still fails closed when no explicit token is provided and config token is unresolved", () => {
66+
vi.stubEnv("DISCORD_BOT_TOKEN", "env-token");
6267
const cfg = {
6368
channels: {
6469
discord: {
@@ -71,6 +76,8 @@ describe("createDiscordRestClient", () => {
7176
},
7277
} as OpenClawConfig;
7378

74-
expect(() => createDiscordRestClient({ cfg, rest: fakeRest })).toThrow(/unresolved SecretRef/i);
79+
expect(() => createDiscordRestClient({ cfg, rest: fakeRest })).toThrow(
80+
/configured for account "default" is unavailable/i,
81+
);
7582
});
7683
});

extensions/discord/src/client.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,18 @@ export function resolveDiscordClientAccountContext(
5151
};
5252
}
5353

54-
function resolveToken(params: { accountId: string; fallbackToken?: string }) {
54+
function resolveToken(params: {
55+
account: ResolvedDiscordAccount;
56+
accountId: string;
57+
fallbackToken?: string;
58+
}) {
5559
const fallback = normalizeDiscordToken(params.fallbackToken, "channels.discord.token");
5660
if (!fallback) {
61+
if (params.account.tokenStatus === "configured_unavailable") {
62+
throw new Error(
63+
`Discord bot token configured for account "${params.accountId}" is unavailable; resolve SecretRefs against the active runtime snapshot before using this account.`,
64+
);
65+
}
5766
throw new Error(
5867
`Discord bot token missing for account "${params.accountId}" (set discord.accounts.${params.accountId}.token or DISCORD_BOT_TOKEN for default).`,
5968
);
@@ -92,6 +101,7 @@ function resolveAccountWithoutToken(params: {
92101
name: normalizeOptionalString(merged.name),
93102
token: "",
94103
tokenSource: "none",
104+
tokenStatus: "missing",
95105
config: merged,
96106
};
97107
}
@@ -106,6 +116,7 @@ export function createDiscordRestClient(opts: DiscordClientOpts) {
106116
const token =
107117
explicitToken ??
108118
resolveToken({
119+
account,
109120
accountId: account.accountId,
110121
fallbackToken: account.token,
111122
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {
2+
getRuntimeConfigSnapshot,
3+
getRuntimeConfigSourceSnapshot,
4+
selectApplicableRuntimeConfig,
5+
} from "openclaw/plugin-sdk/runtime-config-snapshot";
6+
import type { OpenClawConfig } from "./runtime-api.js";
7+
8+
export function selectDiscordRuntimeConfig(inputConfig: OpenClawConfig): OpenClawConfig {
9+
return (
10+
selectApplicableRuntimeConfig({
11+
inputConfig,
12+
runtimeConfig: getRuntimeConfigSnapshot(),
13+
runtimeSourceConfig: getRuntimeConfigSourceSnapshot(),
14+
}) ?? inputConfig
15+
);
16+
}

extensions/discord/src/security-audit.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ function createAccount(
2222
enabled: true,
2323
token: "t",
2424
tokenSource: "config",
25+
tokenStatus: "available",
2526
config,
2627
};
2728
}

extensions/discord/src/shared.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,27 @@ describe("createDiscordPluginBase", () => {
6262
);
6363
expect(plugin.config.isEnabled?.(workAccount, cfg)).toBe(true);
6464
});
65+
66+
it("describes unresolved SecretRef tokens without marking them startup-configured", () => {
67+
const plugin = createDiscordPluginBase({ setup: {} as never });
68+
const cfg = {
69+
channels: {
70+
discord: {
71+
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
72+
},
73+
},
74+
} as unknown as OpenClawConfig;
75+
76+
const account = plugin.config.resolveAccount(cfg, "default");
77+
const described = plugin.config.describeAccount?.(account, cfg);
78+
79+
expect(account.token).toBe("");
80+
expect(account.tokenSource).toBe("config");
81+
expect(account.tokenStatus).toBe("configured_unavailable");
82+
expect(plugin.config.isConfigured?.(account, cfg)).toBe(false);
83+
expect(described?.configured).toBe(false);
84+
expect(described?.tokenStatus).toBe("configured_unavailable");
85+
});
6586
});
6687

6788
describe("discordConfigAdapter", () => {

0 commit comments

Comments
 (0)