Skip to content

Commit 29fec8b

Browse files
authored
fix(gateway): harden health monitor account gating (openclaw#46749)
* gateway: harden health monitor account gating * gateway: tighten health monitor account-id guard
1 parent 8aaafa0 commit 29fec8b

File tree

2 files changed

+106
-34
lines changed

2 files changed

+106
-34
lines changed

src/gateway/server-channels.test.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -259,15 +259,12 @@ describe("server-channels auto restart", () => {
259259
expect(manager.isHealthMonitorEnabled("discord", DEFAULT_ACCOUNT_ID)).toBe(false);
260260
});
261261

262-
it("uses wrapped account config health monitor overrides", () => {
262+
it("uses raw account config overrides when resolvers omit health monitor fields", () => {
263263
installTestRegistry(
264264
createTestPlugin({
265265
resolveAccount: () => ({
266266
enabled: true,
267267
configured: true,
268-
config: {
269-
healthMonitor: { enabled: false },
270-
},
271268
}),
272269
}),
273270
);
@@ -276,12 +273,57 @@ describe("server-channels auto restart", () => {
276273
loadConfig: () => ({
277274
channels: {
278275
discord: {
279-
healthMonitor: { enabled: true },
276+
accounts: {
277+
[DEFAULT_ACCOUNT_ID]: {
278+
healthMonitor: { enabled: false },
279+
},
280+
},
280281
},
281282
},
282283
}),
283284
});
284285

285286
expect(manager.isHealthMonitorEnabled("discord", DEFAULT_ACCOUNT_ID)).toBe(false);
286287
});
288+
289+
it("fails closed when account resolution throws during health monitor gating", () => {
290+
installTestRegistry(
291+
createTestPlugin({
292+
resolveAccount: () => {
293+
throw new Error("unresolved SecretRef");
294+
},
295+
}),
296+
);
297+
298+
const manager = createManager();
299+
300+
expect(manager.isHealthMonitorEnabled("discord", DEFAULT_ACCOUNT_ID)).toBe(false);
301+
});
302+
303+
it("does not treat an empty account id as the default account when matching raw overrides", () => {
304+
installTestRegistry(
305+
createTestPlugin({
306+
resolveAccount: () => ({
307+
enabled: true,
308+
configured: true,
309+
}),
310+
}),
311+
);
312+
313+
const manager = createManager({
314+
loadConfig: () => ({
315+
channels: {
316+
discord: {
317+
accounts: {
318+
default: {
319+
healthMonitor: { enabled: false },
320+
},
321+
},
322+
},
323+
},
324+
}),
325+
});
326+
327+
expect(manager.isHealthMonitorEnabled("discord", "")).toBe(true);
328+
});
287329
});

src/gateway/server-channels.ts

Lines changed: 59 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import { formatErrorMessage } from "../infra/errors.js";
77
import { resetDirectoryCache } from "../infra/outbound/target-resolver.js";
88
import type { createSubsystemLogger } from "../logging/subsystem.js";
99
import type { PluginRuntime } from "../plugins/runtime/types.js";
10-
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
10+
import { resolveAccountEntry } from "../routing/account-lookup.js";
11+
import {
12+
DEFAULT_ACCOUNT_ID,
13+
normalizeAccountId,
14+
normalizeOptionalAccountId,
15+
} from "../routing/session-key.js";
1116
import type { RuntimeEnv } from "../runtime.js";
1217

1318
const CHANNEL_RESTART_POLICY: BackoffPolicy = {
@@ -31,6 +36,16 @@ type ChannelRuntimeStore = {
3136
runtimes: Map<string, ChannelAccountSnapshot>;
3237
};
3338

39+
type HealthMonitorConfig = {
40+
healthMonitor?: {
41+
enabled?: boolean;
42+
};
43+
};
44+
45+
type ChannelHealthMonitorConfig = HealthMonitorConfig & {
46+
accounts?: Record<string, HealthMonitorConfig>;
47+
};
48+
3449
function createRuntimeStore(): ChannelRuntimeStore {
3550
return {
3651
aborts: new Map(),
@@ -120,45 +135,60 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
120135

121136
const restartKey = (channelId: ChannelId, accountId: string) => `${channelId}:${accountId}`;
122137

138+
const resolveAccountHealthMonitorOverride = (
139+
channelConfig: ChannelHealthMonitorConfig | undefined,
140+
accountId: string,
141+
): boolean | undefined => {
142+
if (!channelConfig?.accounts) {
143+
return undefined;
144+
}
145+
const direct = resolveAccountEntry(channelConfig.accounts, accountId);
146+
if (typeof direct?.healthMonitor?.enabled === "boolean") {
147+
return direct.healthMonitor.enabled;
148+
}
149+
150+
const normalizedAccountId = normalizeOptionalAccountId(accountId);
151+
if (!normalizedAccountId) {
152+
return undefined;
153+
}
154+
const matchKey = Object.keys(channelConfig.accounts).find(
155+
(key) => normalizeAccountId(key) === normalizedAccountId,
156+
);
157+
if (!matchKey) {
158+
return undefined;
159+
}
160+
return channelConfig.accounts[matchKey]?.healthMonitor?.enabled;
161+
};
162+
123163
const isHealthMonitorEnabled = (channelId: ChannelId, accountId: string): boolean => {
124164
const cfg = loadConfig();
125-
const plugin = getChannelPlugin(channelId);
126-
const resolvedAccount = plugin?.config.resolveAccount(cfg, accountId) as
127-
| {
128-
healthMonitor?: {
129-
enabled?: boolean;
130-
};
131-
config?: {
132-
healthMonitor?: {
133-
enabled?: boolean;
134-
};
135-
};
136-
}
137-
| undefined;
138-
const accountOverride = resolvedAccount?.healthMonitor?.enabled;
139-
const wrappedAccountOverride = resolvedAccount?.config?.healthMonitor?.enabled;
140-
const channelOverride = (
141-
cfg.channels?.[channelId] as
142-
| {
143-
healthMonitor?: {
144-
enabled?: boolean;
145-
};
146-
}
147-
| undefined
148-
)?.healthMonitor?.enabled;
165+
const channelConfig = cfg.channels?.[channelId] as ChannelHealthMonitorConfig | undefined;
166+
const accountOverride = resolveAccountHealthMonitorOverride(channelConfig, accountId);
167+
const channelOverride = channelConfig?.healthMonitor?.enabled;
149168

150169
if (typeof accountOverride === "boolean") {
151170
return accountOverride;
152171
}
153172

154-
if (typeof wrappedAccountOverride === "boolean") {
155-
return wrappedAccountOverride;
156-
}
157-
158173
if (typeof channelOverride === "boolean") {
159174
return channelOverride;
160175
}
161176

177+
const plugin = getChannelPlugin(channelId);
178+
if (!plugin) {
179+
return true;
180+
}
181+
try {
182+
// Probe only: health-monitor config is read directly from raw channel config above.
183+
// This call exists solely to fail closed if resolver-side config loading is broken.
184+
plugin.config.resolveAccount(cfg, accountId);
185+
} catch (err) {
186+
channelLogs[channelId].warn?.(
187+
`[${channelId}:${accountId}] health-monitor: failed to resolve account; skipping monitor (${formatErrorMessage(err)})`,
188+
);
189+
return false;
190+
}
191+
162192
return true;
163193
};
164194

0 commit comments

Comments
 (0)