Skip to content

Commit 16b275a

Browse files
authored
fix: warn when configured channel plugins cannot load (#96397)
* fix(gateway): warn for blocked configured channel plugins * fix(gateway): preserve configured channel warning source
1 parent b1fae75 commit 16b275a

5 files changed

Lines changed: 228 additions & 1 deletion

File tree

src/gateway/server-startup-log.test.ts

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,28 @@
11
// Startup log tests cover security warnings, model detail formatting, plugin
22
// summaries, bind URLs, ANSI output, and dangerous config reporting.
3-
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
import { stripAnsi } from "../../packages/terminal-core/src/ansi.js";
55
import { formatAgentModelStartupDetails, logGatewayStartup } from "./server-startup-log.js";
66

7+
const pluginRegistryMocks = vi.hoisted(() => ({
8+
loadPluginManifestRegistryForPluginRegistry: vi.fn(),
9+
}));
10+
11+
vi.mock("../plugins/plugin-registry.js", async (importOriginal) => ({
12+
...(await importOriginal<typeof import("../plugins/plugin-registry.js")>()),
13+
loadPluginManifestRegistryForPluginRegistry:
14+
pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry,
15+
}));
16+
717
describe("gateway startup log", () => {
18+
beforeEach(() => {
19+
pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry.mockReset();
20+
pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({
21+
plugins: [],
22+
diagnostics: [],
23+
});
24+
});
25+
826
afterEach(() => {
927
vi.useRealTimers();
1028
});
@@ -51,6 +69,150 @@ describe("gateway startup log", () => {
5169
expect(warn).not.toHaveBeenCalled();
5270
});
5371

72+
it("warns when a configured channel plugin is blocked from startup", async () => {
73+
pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({
74+
plugins: [
75+
{
76+
id: "slack",
77+
origin: "global",
78+
channels: ["slack"],
79+
enabledByDefault: false,
80+
},
81+
],
82+
diagnostics: [],
83+
});
84+
const info = vi.fn();
85+
const warn = vi.fn();
86+
87+
await logGatewayStartup({
88+
cfg: {
89+
channels: {
90+
slack: {
91+
enabled: true,
92+
botToken: "configured",
93+
},
94+
},
95+
},
96+
bindHost: "127.0.0.1",
97+
loadedPluginIds: [],
98+
port: 18789,
99+
log: { info, warn },
100+
isNixMode: false,
101+
});
102+
103+
expect(warn.mock.calls).toEqual([
104+
[
105+
'configured channel warning: channels.slack: channel is configured, but external plugin "slack" is installed without explicit trust. Add plugins.entries.slack.enabled=true. Fix plugin enablement before relying on setup guidance for this channel.',
106+
],
107+
]);
108+
});
109+
110+
it("warns when a configured channel has no owning plugin", async () => {
111+
const info = vi.fn();
112+
const warn = vi.fn();
113+
114+
await logGatewayStartup({
115+
cfg: {
116+
channels: {
117+
"missing-chat": {
118+
enabled: true,
119+
token: "configured",
120+
},
121+
},
122+
},
123+
bindHost: "127.0.0.1",
124+
loadedPluginIds: [],
125+
port: 18789,
126+
log: { info, warn },
127+
isNixMode: false,
128+
});
129+
130+
expect(warn.mock.calls).toEqual([
131+
[
132+
"configured channel warning: channels.missing-chat is configured but no channel plugin is installed or loadable (no-channel-owner). Run `openclaw doctor --fix` or install the channel plugin before relying on this channel.",
133+
],
134+
]);
135+
});
136+
137+
it("sanitizes configured channel ids in startup warnings", async () => {
138+
const unsafeChannelId = `slack${String.fromCharCode(0x1b)}[31m`;
139+
pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({
140+
plugins: [
141+
{
142+
id: "slack",
143+
origin: "global",
144+
channels: [unsafeChannelId],
145+
enabledByDefault: false,
146+
},
147+
],
148+
diagnostics: [],
149+
});
150+
const info = vi.fn();
151+
const warn = vi.fn();
152+
153+
await logGatewayStartup({
154+
cfg: {
155+
channels: {
156+
[unsafeChannelId]: {
157+
enabled: true,
158+
botToken: "configured",
159+
},
160+
},
161+
},
162+
bindHost: "127.0.0.1",
163+
loadedPluginIds: [],
164+
port: 18789,
165+
log: { info, warn },
166+
isNixMode: false,
167+
});
168+
169+
expect(warn.mock.calls[0]?.[0]).toContain("channels.slack: channel is configured");
170+
expect(warn.mock.calls[0]?.[0]).not.toContain(String.fromCharCode(0x1b));
171+
});
172+
173+
it("does not warn when startup activation enables the configured channel owner", async () => {
174+
pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({
175+
plugins: [
176+
{
177+
id: "openclaw-modern-chat",
178+
origin: "global",
179+
channels: ["legacy-chat"],
180+
enabledByDefault: false,
181+
},
182+
],
183+
diagnostics: [],
184+
});
185+
const info = vi.fn();
186+
const warn = vi.fn();
187+
188+
await logGatewayStartup({
189+
cfg: {
190+
channels: {
191+
"legacy-chat": {
192+
enabled: true,
193+
token: "configured",
194+
},
195+
},
196+
},
197+
activationSourceConfig: {
198+
plugins: {
199+
entries: {
200+
"openclaw-modern-chat": {
201+
enabled: true,
202+
},
203+
},
204+
},
205+
},
206+
bindHost: "127.0.0.1",
207+
loadedPluginIds: [],
208+
port: 18789,
209+
log: { info, warn },
210+
isNixMode: false,
211+
});
212+
213+
expect(warn).not.toHaveBeenCalled();
214+
});
215+
54216
it("logs configured model thinking and fast mode defaults with the startup model", async () => {
55217
const info = vi.fn();
56218
const warn = vi.fn();

src/gateway/server-startup-log.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Produces the compact ready banner with resolved model and safety state.
33
import { normalizeSortedUniqueStringEntries } from "@openclaw/normalization-core/string-normalization";
44
import chalk from "chalk";
5+
import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js";
56
import { resolveDefaultAgentId, resolveAgentConfig } from "../agents/agent-scope.js";
67
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
78
import { formatFastModeValue, resolveFastModeState } from "../agents/fast-mode.js";
@@ -29,6 +30,7 @@ type StartupThinkLevel =
2930
/** Emit startup summary lines after Gateway bind and plugin loading complete. */
3031
export async function logGatewayStartup(params: {
3132
cfg: OpenClawConfig;
33+
activationSourceConfig?: OpenClawConfig;
3234
bindHost: string;
3335
bindHosts?: string[];
3436
port: number;
@@ -64,6 +66,13 @@ export async function logGatewayStartup(params: {
6466
params.log.info("gateway: running in Nix mode (config managed externally)");
6567
}
6668

69+
for (const warning of await collectConfiguredChannelStartupWarnings({
70+
cfg: params.cfg,
71+
activationSourceConfig: params.activationSourceConfig,
72+
})) {
73+
params.log.warn(warning);
74+
}
75+
6776
const enabledDangerousFlags =
6877
collectEnabledInsecureOrDangerousFlagsFromCurrentSnapshot(params.cfg) ??
6978
(await import("../security/dangerous-config-flags.js")).collectEnabledInsecureOrDangerousFlags(
@@ -165,6 +174,53 @@ export function formatAgentModelStartupDetails(params: {
165174
return `thinking=${thinking}, fast=${formatFastModeValue(fast.mode)}`;
166175
}
167176

177+
async function collectConfiguredChannelStartupWarnings(params: {
178+
cfg: OpenClawConfig;
179+
activationSourceConfig?: OpenClawConfig;
180+
}): Promise<string[]> {
181+
const [blockerModule, presencePolicyModule, pluginRegistryModule] = await Promise.all([
182+
import("../commands/doctor/shared/channel-plugin-blockers.js"),
183+
import("../plugins/channel-presence-policy.js"),
184+
import("../plugins/plugin-registry.js"),
185+
]);
186+
const manifestRegistry = pluginRegistryModule.loadPluginManifestRegistryForPluginRegistry({
187+
config: params.cfg,
188+
env: process.env,
189+
includeDisabled: true,
190+
});
191+
const hits = blockerModule.scanConfiguredChannelPluginBlockers(
192+
params.cfg,
193+
process.env,
194+
params.activationSourceConfig,
195+
);
196+
const blockerWarnings = blockerModule
197+
.collectConfiguredChannelPluginBlockerWarnings(hits)
198+
.map((warning) => `configured channel warning: ${warning.replace(/^[-]\s*/u, "")}`);
199+
const missingOwnerWarnings = presencePolicyModule
200+
.resolveConfiguredChannelPresencePolicy({
201+
config: params.cfg,
202+
activationSourceConfig: params.activationSourceConfig,
203+
includePersistedAuthState: false,
204+
manifestRecords: manifestRegistry.plugins,
205+
})
206+
.filter((entry) => !entry.effective && entry.blockedReasons.includes("no-channel-owner"))
207+
.map(formatConfiguredChannelMissingOwnerStartupWarning);
208+
return [...blockerWarnings, ...missingOwnerWarnings];
209+
}
210+
211+
function formatConfiguredChannelMissingOwnerStartupWarning(entry: {
212+
channelId: string;
213+
blockedReasons: readonly string[];
214+
}): string {
215+
const channelId = sanitizeForLog(entry.channelId);
216+
const reasons = normalizeSortedUniqueStringEntries(entry.blockedReasons).join(", ");
217+
return (
218+
`configured channel warning: channels.${channelId} is configured but no channel plugin ` +
219+
`is installed or loadable (${reasons}). Run \`openclaw doctor --fix\` or install the ` +
220+
"channel plugin before relying on this channel."
221+
);
222+
}
223+
168224
/** Format plugin count/list and optional startup duration for the ready log line. */
169225
function formatReadyDetails(
170226
loadedPluginIds: readonly string[],

src/gateway/server-startup-post-attach.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,11 @@ describe("startGatewayPostAttachRuntime", () => {
388388
expect(hoisted.setInternalHooksEnabled).not.toHaveBeenCalled();
389389
expect(hoisted.logGatewayStartup).toHaveBeenCalledTimes(1);
390390
expect(firstStartupLog().loadedPluginIds).toEqual(["beta", "alpha"]);
391+
expect(hoisted.logGatewayStartup).toHaveBeenCalledWith(
392+
expect.objectContaining({
393+
activationSourceConfig: { hooks: { internal: { enabled: false } } },
394+
}),
395+
);
391396
expect(log.info).toHaveBeenCalledWith("gateway ready");
392397
expect(hoisted.scheduleRestartAbortedMainSessionRecovery).toHaveBeenCalledWith({
393398
cfg: { hooks: { internal: { enabled: false } } },
@@ -2240,6 +2245,7 @@ function createPostAttachParams(overrides: Partial<PostAttachParams> = {}): Post
22402245
error: vi.fn(),
22412246
},
22422247
gatewayPluginConfigAtStart: { hooks: { internal: { enabled: false } } } as never,
2248+
activationSourceConfig: { hooks: { internal: { enabled: false } } } as never,
22432249
pluginRegistry: {
22442250
plugins: [
22452251
{ id: "beta", status: "loaded" },

src/gateway/server-startup-post-attach.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1096,6 +1096,7 @@ export async function startGatewayPostAttachRuntime(
10961096
debug?: (msg: string) => void;
10971097
};
10981098
gatewayPluginConfigAtStart: OpenClawConfig;
1099+
activationSourceConfig: OpenClawConfig;
10991100
pluginRegistry: ReturnType<typeof loadOpenClawPlugins>;
11001101
defaultWorkspaceDir: string;
11011102
deps: CliDeps;
@@ -1176,6 +1177,7 @@ export async function startGatewayPostAttachRuntime(
11761177
const startupLogPromise = measureStartup(params.startupTrace, "post-attach.log", () =>
11771178
runtimeDeps.logGatewayStartup({
11781179
cfg: params.cfgAtStart,
1180+
activationSourceConfig: params.activationSourceConfig,
11791181
bindHost: params.bindHost,
11801182
bindHosts: params.bindHosts,
11811183
port: params.port,

src/gateway/server.impl.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1640,6 +1640,7 @@ export async function startGatewayServer(
16401640
controlUiBasePath,
16411641
logTailscale,
16421642
gatewayPluginConfigAtStart,
1643+
activationSourceConfig: startupActivationSourceConfig,
16431644
pluginRegistry,
16441645
defaultWorkspaceDir,
16451646
deps,

0 commit comments

Comments
 (0)