fix(discord): handle SecretRef runtime status (#76987)

* fix(discord): handle SecretRef runtime status

* docs(changelog): mention Discord SecretRef fix
This commit is contained in:
Josh Avant
2026-05-03 17:56:36 -05:00
committed by GitHub
parent b2fd814f91
commit 911ac6dd10
15 changed files with 294 additions and 40 deletions

View File

@@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
- 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.
- Mattermost: accept the documented `channels.mattermost.streaming` config and honor `streaming: "off"` by disabling draft preview posts. Thanks @vincentkoc.
- 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.
- 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.
- 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.
- 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.
- 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.

View File

@@ -5,11 +5,8 @@ export {
handleDiscordSubagentEnded,
handleDiscordSubagentSpawning,
} from "./src/subagent-hooks.js";
export {
type DiscordCredentialStatus,
inspectDiscordAccount,
type InspectedDiscordAccount,
} from "./src/account-inspect.js";
export { inspectDiscordAccount, type InspectedDiscordAccount } from "./src/account-inspect.js";
export { type DiscordCredentialStatus } from "./src/token.js";
export {
createDiscordActionGate,
listDiscordAccountIds,

View File

@@ -10,8 +10,7 @@ import {
resolveDiscordAccountConfig,
} from "./accounts.js";
import type { DiscordAccountConfig, OpenClawConfig } from "./runtime-api.js";
export type DiscordCredentialStatus = "available" | "configured_unavailable" | "missing";
import type { DiscordCredentialStatus } from "./token.js";
export type InspectedDiscordAccount = {
accountId: string;

View File

@@ -1,3 +1,8 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "openclaw/plugin-sdk/runtime-config-snapshot";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createDiscordActionGate,
@@ -9,6 +14,7 @@ import {
} from "./accounts.js";
afterEach(() => {
clearRuntimeConfigSnapshot();
vi.unstubAllEnvs();
});
@@ -245,3 +251,60 @@ describe("Discord duplicate-token account filtering", () => {
expect(listEnabledDiscordAccounts(cfg).map((account) => account.accountId)).toEqual(["active"]);
});
});
describe("resolveDiscordAccount runtime config selection", () => {
it("resolves named account SecretRefs from the active runtime snapshot", () => {
const sourceCfg = {
channels: {
discord: {
defaultAccount: "work",
accounts: {
work: {
name: "Work",
token: { source: "env", provider: "default", id: "DISCORD_WORK_TOKEN" },
},
},
},
},
} as unknown as OpenClawConfig;
const runtimeCfg = {
channels: {
discord: {
defaultAccount: "work",
accounts: {
work: {
name: "Work",
token: "Bot runtime-work-token",
},
},
},
},
} as OpenClawConfig;
setRuntimeConfigSnapshot(runtimeCfg, sourceCfg);
const resolved = resolveDiscordAccount({ cfg: sourceCfg });
expect(resolved.accountId).toBe("work");
expect(resolved.token).toBe("runtime-work-token");
expect(resolved.tokenSource).toBe("config");
expect(resolved.tokenStatus).toBe("available");
});
it("preserves configured unavailable tokens without falling through to env", () => {
vi.stubEnv("DISCORD_BOT_TOKEN", "env-token");
const resolved = resolveDiscordAccount({
cfg: {
channels: {
discord: {
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
},
},
} as unknown as OpenClawConfig,
accountId: "default",
});
expect(resolved.token).toBe("");
expect(resolved.tokenSource).toBe("config");
expect(resolved.tokenStatus).toBe("configured_unavailable");
});
});

View File

@@ -14,7 +14,8 @@ import {
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { DiscordAccountConfig, DiscordActionConfig, OpenClawConfig } from "./runtime-api.js";
import { resolveDiscordToken } from "./token.js";
import { selectDiscordRuntimeConfig } from "./runtime-config.js";
import { resolveDiscordToken, type DiscordCredentialStatus } from "./token.js";
export type ResolvedDiscordAccount = {
accountId: string;
@@ -22,6 +23,7 @@ export type ResolvedDiscordAccount = {
name?: string;
token: string;
tokenSource: "env" | "config" | "none";
tokenStatus: DiscordCredentialStatus;
config: DiscordAccountConfig;
};
@@ -100,20 +102,20 @@ export function resolveDiscordAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedDiscordAccount {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultDiscordAccountId(params.cfg),
);
const baseEnabled = params.cfg.channels?.discord?.enabled !== false;
const merged = mergeDiscordAccountConfig(params.cfg, accountId);
const cfg = selectDiscordRuntimeConfig(params.cfg);
const accountId = normalizeAccountId(params.accountId ?? resolveDefaultDiscordAccountId(cfg));
const baseEnabled = cfg.channels?.discord?.enabled !== false;
const merged = mergeDiscordAccountConfig(cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const tokenResolution = resolveDiscordToken(params.cfg, { accountId });
const tokenResolution = resolveDiscordToken(cfg, { accountId });
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
token: tokenResolution.token,
tokenSource: tokenResolution.source,
tokenStatus: tokenResolution.tokenStatus,
config: merged,
};
}

View File

@@ -1,8 +1,12 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDiscordRestClient } from "./client.js";
import type { RequestClient } from "./internal/discord.js";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("createDiscordRestClient", () => {
const fakeRest = {} as RequestClient;
@@ -58,7 +62,8 @@ describe("createDiscordRestClient", () => {
expect(result.account.config.retry).toMatchObject({ attempts: 7 });
});
it("still throws when no explicit token is provided and config token is unresolved", () => {
it("still fails closed when no explicit token is provided and config token is unresolved", () => {
vi.stubEnv("DISCORD_BOT_TOKEN", "env-token");
const cfg = {
channels: {
discord: {
@@ -71,6 +76,8 @@ describe("createDiscordRestClient", () => {
},
} as OpenClawConfig;
expect(() => createDiscordRestClient({ cfg, rest: fakeRest })).toThrow(/unresolved SecretRef/i);
expect(() => createDiscordRestClient({ cfg, rest: fakeRest })).toThrow(
/configured for account "default" is unavailable/i,
);
});
});

View File

@@ -51,9 +51,18 @@ export function resolveDiscordClientAccountContext(
};
}
function resolveToken(params: { accountId: string; fallbackToken?: string }) {
function resolveToken(params: {
account: ResolvedDiscordAccount;
accountId: string;
fallbackToken?: string;
}) {
const fallback = normalizeDiscordToken(params.fallbackToken, "channels.discord.token");
if (!fallback) {
if (params.account.tokenStatus === "configured_unavailable") {
throw new Error(
`Discord bot token configured for account "${params.accountId}" is unavailable; resolve SecretRefs against the active runtime snapshot before using this account.`,
);
}
throw new Error(
`Discord bot token missing for account "${params.accountId}" (set discord.accounts.${params.accountId}.token or DISCORD_BOT_TOKEN for default).`,
);
@@ -92,6 +101,7 @@ function resolveAccountWithoutToken(params: {
name: normalizeOptionalString(merged.name),
token: "",
tokenSource: "none",
tokenStatus: "missing",
config: merged,
};
}
@@ -106,6 +116,7 @@ export function createDiscordRestClient(opts: DiscordClientOpts) {
const token =
explicitToken ??
resolveToken({
account,
accountId: account.accountId,
fallbackToken: account.token,
});

View File

@@ -0,0 +1,16 @@
import {
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
selectApplicableRuntimeConfig,
} from "openclaw/plugin-sdk/runtime-config-snapshot";
import type { OpenClawConfig } from "./runtime-api.js";
export function selectDiscordRuntimeConfig(inputConfig: OpenClawConfig): OpenClawConfig {
return (
selectApplicableRuntimeConfig({
inputConfig,
runtimeConfig: getRuntimeConfigSnapshot(),
runtimeSourceConfig: getRuntimeConfigSourceSnapshot(),
}) ?? inputConfig
);
}

View File

@@ -22,6 +22,7 @@ function createAccount(
enabled: true,
token: "t",
tokenSource: "config",
tokenStatus: "available",
config,
};
}

View File

@@ -62,6 +62,27 @@ describe("createDiscordPluginBase", () => {
);
expect(plugin.config.isEnabled?.(workAccount, cfg)).toBe(true);
});
it("describes unresolved SecretRef tokens without marking them startup-configured", () => {
const plugin = createDiscordPluginBase({ setup: {} as never });
const cfg = {
channels: {
discord: {
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
},
},
} as unknown as OpenClawConfig;
const account = plugin.config.resolveAccount(cfg, "default");
const described = plugin.config.describeAccount?.(account, cfg);
expect(account.token).toBe("");
expect(account.tokenSource).toBe("config");
expect(account.tokenStatus).toBe("configured_unavailable");
expect(plugin.config.isConfigured?.(account, cfg)).toBe(false);
expect(described?.configured).toBe(false);
expect(described?.tokenStatus).toBe("configured_unavailable");
});
});
describe("discordConfigAdapter", () => {

View File

@@ -156,6 +156,7 @@ export function createDiscordPluginBase(params: {
configured: Boolean(account.token?.trim()),
extra: {
tokenSource: account.tokenSource,
tokenStatus: account.tokenStatus,
},
}),
},

View File

@@ -1,9 +1,14 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "openclaw/plugin-sdk/runtime-config-snapshot";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveDiscordToken } from "./token.js";
describe("resolveDiscordToken", () => {
afterEach(() => {
clearRuntimeConfigSnapshot();
vi.unstubAllEnvs();
});
@@ -15,6 +20,7 @@ describe("resolveDiscordToken", () => {
const res = resolveDiscordToken(cfg);
expect(res.token).toBe("cfg-token");
expect(res.source).toBe("config");
expect(res.tokenStatus).toBe("available");
});
it("uses env token when config is missing", () => {
@@ -25,6 +31,7 @@ describe("resolveDiscordToken", () => {
const res = resolveDiscordToken(cfg);
expect(res.token).toBe("env-token");
expect(res.source).toBe("env");
expect(res.tokenStatus).toBe("available");
});
it("prefers account token for non-default accounts", () => {
@@ -42,6 +49,7 @@ describe("resolveDiscordToken", () => {
const res = resolveDiscordToken(cfg, { accountId: "work" });
expect(res.token).toBe("acct-token");
expect(res.source).toBe("config");
expect(res.tokenStatus).toBe("available");
});
it("falls back to top-level token for non-default accounts without account token", () => {
@@ -58,6 +66,7 @@ describe("resolveDiscordToken", () => {
const res = resolveDiscordToken(cfg, { accountId: "work" });
expect(res.token).toBe("base-token");
expect(res.source).toBe("config");
expect(res.tokenStatus).toBe("available");
});
it("does not inherit top-level token when account token is explicitly blank", () => {
@@ -74,6 +83,7 @@ describe("resolveDiscordToken", () => {
const res = resolveDiscordToken(cfg, { accountId: "work" });
expect(res.token).toBe("");
expect(res.source).toBe("none");
expect(res.tokenStatus).toBe("missing");
});
it("resolves account token when account key casing differs from normalized id", () => {
@@ -89,9 +99,43 @@ describe("resolveDiscordToken", () => {
const res = resolveDiscordToken(cfg, { accountId: "work" });
expect(res.token).toBe("acct-token");
expect(res.source).toBe("config");
expect(res.tokenStatus).toBe("available");
});
it("throws when token is an unresolved SecretRef object", () => {
it("uses the active runtime snapshot when resolving a matching source config", () => {
const sourceCfg = {
channels: {
discord: {
accounts: {
work: {
token: { source: "env", provider: "default", id: "DISCORD_WORK_TOKEN" },
},
},
},
},
} as unknown as OpenClawConfig;
const runtimeCfg = {
channels: {
discord: {
accounts: {
work: {
token: "Bot runtime-work-token",
},
},
},
},
} as OpenClawConfig;
setRuntimeConfigSnapshot(runtimeCfg, sourceCfg);
const res = resolveDiscordToken(sourceCfg, { accountId: "work" });
expect(res.token).toBe("runtime-work-token");
expect(res.source).toBe("config");
expect(res.tokenStatus).toBe("available");
});
it("treats unresolved top-level SecretRefs as configured unavailable without env fallback", () => {
vi.stubEnv("DISCORD_BOT_TOKEN", "env-token");
const cfg = {
channels: {
discord: {
@@ -100,8 +144,31 @@ describe("resolveDiscordToken", () => {
},
} as unknown as OpenClawConfig;
expect(() => resolveDiscordToken(cfg)).toThrow(
/channels\.discord\.token: unresolved SecretRef/i,
);
const res = resolveDiscordToken(cfg);
expect(res.token).toBe("");
expect(res.source).toBe("config");
expect(res.tokenStatus).toBe("configured_unavailable");
});
it("treats unresolved account SecretRefs as configured unavailable without top-level fallback", () => {
const cfg = {
channels: {
discord: {
token: "base-token",
accounts: {
work: {
token: { source: "env", provider: "default", id: "DISCORD_WORK_TOKEN" },
},
},
},
},
} as unknown as OpenClawConfig;
const res = resolveDiscordToken(cfg, { accountId: "work" });
expect(res.token).toBe("");
expect(res.source).toBe("config");
expect(res.tokenStatus).toBe("configured_unavailable");
});
});

View File

@@ -2,50 +2,97 @@ import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import {
normalizeResolvedSecretInputString,
resolveSecretInputString,
} from "openclaw/plugin-sdk/secret-input";
import { selectDiscordRuntimeConfig } from "./runtime-config.js";
type DiscordTokenSource = "env" | "config" | "none";
export type DiscordCredentialStatus = "available" | "configured_unavailable" | "missing";
export type DiscordTokenResolution = BaseTokenResolution & {
source: DiscordTokenSource;
tokenStatus: DiscordCredentialStatus;
};
type DiscordTokenValueResolution =
| { status: "available"; value: string }
| { status: "configured_unavailable" }
| { status: "missing" };
function stripDiscordBotPrefix(token: string): string {
return token.replace(/^Bot\s+/i, "");
}
export function normalizeDiscordToken(raw: unknown, path: string): string | undefined {
const trimmed = normalizeResolvedSecretInputString({ value: raw, path });
if (!trimmed) {
return undefined;
}
return trimmed.replace(/^Bot\s+/i, "");
return stripDiscordBotPrefix(trimmed);
}
function resolveDiscordTokenValue(params: {
cfg: OpenClawConfig;
value: unknown;
path: string;
}): DiscordTokenValueResolution {
const resolved = resolveSecretInputString({
value: params.value,
path: params.path,
defaults: params.cfg.secrets?.defaults,
mode: "inspect",
});
if (resolved.status === "available") {
return {
status: "available",
value: stripDiscordBotPrefix(resolved.value),
};
}
if (resolved.status === "configured_unavailable") {
return { status: "configured_unavailable" };
}
return { status: "missing" };
}
export function resolveDiscordToken(
cfg: OpenClawConfig,
opts: { accountId?: string | null; envToken?: string | null } = {},
): DiscordTokenResolution {
const selectedCfg = selectDiscordRuntimeConfig(cfg);
const accountId = normalizeAccountId(opts.accountId);
const discordCfg = cfg?.channels?.discord;
const discordCfg = selectedCfg?.channels?.discord;
const accountCfg = resolveAccountEntry(discordCfg?.accounts, accountId);
const hasAccountToken = Boolean(
accountCfg &&
Object.prototype.hasOwnProperty.call(accountCfg as Record<string, unknown>, "token"),
);
const accountToken = normalizeDiscordToken(
(accountCfg as { token?: unknown } | undefined)?.token ?? undefined,
`channels.discord.accounts.${accountId}.token`,
);
if (accountToken) {
return { token: accountToken, source: "config" };
const accountToken = resolveDiscordTokenValue({
cfg: selectedCfg,
value: (accountCfg as { token?: unknown } | undefined)?.token,
path: `channels.discord.accounts.${accountId}.token`,
});
if (accountToken.status === "available" && accountToken.value) {
return { token: accountToken.value, source: "config", tokenStatus: "available" };
}
if (accountToken.status === "configured_unavailable") {
return { token: "", source: "config", tokenStatus: "configured_unavailable" };
}
if (hasAccountToken) {
return { token: "", source: "none" };
return { token: "", source: "none", tokenStatus: "missing" };
}
const configToken = normalizeDiscordToken(
discordCfg?.token ?? undefined,
"channels.discord.token",
);
if (configToken) {
return { token: configToken, source: "config" };
const configToken = resolveDiscordTokenValue({
cfg: selectedCfg,
value: discordCfg?.token,
path: "channels.discord.token",
});
if (configToken.status === "available" && configToken.value) {
return { token: configToken.value, source: "config", tokenStatus: "available" };
}
if (configToken.status === "configured_unavailable") {
return { token: "", source: "config", tokenStatus: "configured_unavailable" };
}
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
@@ -53,8 +100,8 @@ export function resolveDiscordToken(
? normalizeDiscordToken(opts.envToken ?? process.env.DISCORD_BOT_TOKEN, "DISCORD_BOT_TOKEN")
: undefined;
if (envToken) {
return { token: envToken, source: "env" };
return { token: envToken, source: "env", tokenStatus: "available" };
}
return { token: "", source: "none" };
return { token: "", source: "none", tokenStatus: "missing" };
}

View File

@@ -172,6 +172,25 @@ describe("channel-health-monitor", () => {
monitor.stop();
});
it("keeps running after a runtime snapshot failure", async () => {
const manager = createMockChannelManager({
getRuntimeSnapshot: vi
.fn()
.mockImplementationOnce(() => {
throw new Error("snapshot failed");
})
.mockReturnValue({ channels: {}, channelAccounts: {} }),
});
const monitor = startDefaultMonitor(manager);
await vi.advanceTimersByTimeAsync(DEFAULT_CHECK_INTERVAL_MS + 1);
await vi.advanceTimersByTimeAsync(DEFAULT_CHECK_INTERVAL_MS + 1);
expect(manager.getRuntimeSnapshot).toHaveBeenCalledTimes(2);
expect(manager.startChannel).not.toHaveBeenCalled();
monitor.stop();
});
it("accepts timing.monitorStartupGraceMs", async () => {
const manager = createMockChannelManager();
const monitor = startDefaultMonitor(manager, { timing: { monitorStartupGraceMs: 60_000 } });

View File

@@ -174,6 +174,8 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann
}
}
}
} catch (err) {
log.error?.(`health-monitor: check failed: ${String(err)}`);
} finally {
checkInFlight = false;
}