diff --git a/CHANGELOG.md b/CHANGELOG.md index ace7e27c490..9dc113b443f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/discord/api.ts b/extensions/discord/api.ts index 603eb62141b..c0c661545fa 100644 --- a/extensions/discord/api.ts +++ b/extensions/discord/api.ts @@ -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, diff --git a/extensions/discord/src/account-inspect.ts b/extensions/discord/src/account-inspect.ts index efef8da4f8c..2f6694cdfd6 100644 --- a/extensions/discord/src/account-inspect.ts +++ b/extensions/discord/src/account-inspect.ts @@ -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; diff --git a/extensions/discord/src/accounts.test.ts b/extensions/discord/src/accounts.test.ts index 95d32b3042b..02e0fc8411b 100644 --- a/extensions/discord/src/accounts.test.ts +++ b/extensions/discord/src/accounts.test.ts @@ -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"); + }); +}); diff --git a/extensions/discord/src/accounts.ts b/extensions/discord/src/accounts.ts index 2651ca8cdff..e537076830e 100644 --- a/extensions/discord/src/accounts.ts +++ b/extensions/discord/src/accounts.ts @@ -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, }; } diff --git a/extensions/discord/src/client.test.ts b/extensions/discord/src/client.test.ts index 2698019e6aa..0f7dfe2eee9 100644 --- a/extensions/discord/src/client.test.ts +++ b/extensions/discord/src/client.test.ts @@ -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, + ); }); }); diff --git a/extensions/discord/src/client.ts b/extensions/discord/src/client.ts index 8a65ea1afc2..e27a76f15b1 100644 --- a/extensions/discord/src/client.ts +++ b/extensions/discord/src/client.ts @@ -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, }); diff --git a/extensions/discord/src/runtime-config.ts b/extensions/discord/src/runtime-config.ts new file mode 100644 index 00000000000..99d476b0d45 --- /dev/null +++ b/extensions/discord/src/runtime-config.ts @@ -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 + ); +} diff --git a/extensions/discord/src/security-audit.test.ts b/extensions/discord/src/security-audit.test.ts index d0152ae0061..21b0f6650a9 100644 --- a/extensions/discord/src/security-audit.test.ts +++ b/extensions/discord/src/security-audit.test.ts @@ -22,6 +22,7 @@ function createAccount( enabled: true, token: "t", tokenSource: "config", + tokenStatus: "available", config, }; } diff --git a/extensions/discord/src/shared.test.ts b/extensions/discord/src/shared.test.ts index 0e2209dc2e4..62721479126 100644 --- a/extensions/discord/src/shared.test.ts +++ b/extensions/discord/src/shared.test.ts @@ -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", () => { diff --git a/extensions/discord/src/shared.ts b/extensions/discord/src/shared.ts index 9c8ea6b9904..acdb136137f 100644 --- a/extensions/discord/src/shared.ts +++ b/extensions/discord/src/shared.ts @@ -156,6 +156,7 @@ export function createDiscordPluginBase(params: { configured: Boolean(account.token?.trim()), extra: { tokenSource: account.tokenSource, + tokenStatus: account.tokenStatus, }, }), }, diff --git a/extensions/discord/src/token.test.ts b/extensions/discord/src/token.test.ts index 88bea96cae2..43de0945994 100644 --- a/extensions/discord/src/token.test.ts +++ b/extensions/discord/src/token.test.ts @@ -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"); }); }); diff --git a/extensions/discord/src/token.ts b/extensions/discord/src/token.ts index 5c1e3a4a48b..2bbc6b4408a 100644 --- a/extensions/discord/src/token.ts +++ b/extensions/discord/src/token.ts @@ -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, "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" }; } diff --git a/src/gateway/channel-health-monitor.test.ts b/src/gateway/channel-health-monitor.test.ts index a1038f747b6..acc32cee6f1 100644 --- a/src/gateway/channel-health-monitor.test.ts +++ b/src/gateway/channel-health-monitor.test.ts @@ -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 } }); diff --git a/src/gateway/channel-health-monitor.ts b/src/gateway/channel-health-monitor.ts index 343ab399ed3..20a3f7728c6 100644 --- a/src/gateway/channel-health-monitor.ts +++ b/src/gateway/channel-health-monitor.ts @@ -174,6 +174,8 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann } } } + } catch (err) { + log.error?.(`health-monitor: check failed: ${String(err)}`); } finally { checkInFlight = false; }