Compare commits

...

2 Commits

Author SHA1 Message Date
Tak Hoffman
c51af21281
Fix outbound channel selection fast paths 2026-03-19 00:36:39 -05:00
Tak Hoffman
92a1ba88bd
Fix Windows hook path containment 2026-03-19 00:00:50 -05:00
6 changed files with 63 additions and 14 deletions

View File

@ -143,6 +143,24 @@ describe("resolveMessageChannelSelection", () => {
}); });
}); });
it("skips configured-channel scanning when includeConfigured is false", async () => {
const isConfigured = vi.fn(async () => true);
mocks.listChannelPlugins.mockReturnValue([makePlugin({ id: "whatsapp", isConfigured })]);
const selection = await resolveMessageChannelSelection({
cfg: {} as never,
channel: "telegram",
includeConfigured: false,
});
expect(selection).toEqual({
channel: "telegram",
configured: [],
source: "explicit",
});
expect(isConfigured).not.toHaveBeenCalled();
});
it("falls back to tool context channel when explicit channel is unknown", async () => { it("falls back to tool context channel when explicit channel is unknown", async () => {
const selection = await resolveMessageChannelSelection({ const selection = await resolveMessageChannelSelection({
cfg: {} as never, cfg: {} as never,

View File

@ -146,11 +146,15 @@ export async function resolveMessageChannelSelection(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
channel?: string | null; channel?: string | null;
fallbackChannel?: string | null; fallbackChannel?: string | null;
includeConfigured?: boolean;
}): Promise<{ }): Promise<{
channel: MessageChannelId; channel: MessageChannelId;
configured: MessageChannelId[]; configured: MessageChannelId[];
source: MessageChannelSelectionSource; source: MessageChannelSelectionSource;
}> { }> {
const includeConfigured = params.includeConfigured !== false;
const resolveConfigured = async () =>
includeConfigured ? await listConfiguredMessageChannels(params.cfg) : [];
const normalized = normalizeMessageChannel(params.channel); const normalized = normalizeMessageChannel(params.channel);
if (normalized) { if (normalized) {
const availableExplicit = resolveAvailableKnownChannel({ const availableExplicit = resolveAvailableKnownChannel({
@ -165,7 +169,7 @@ export async function resolveMessageChannelSelection(params: {
if (fallback) { if (fallback) {
return { return {
channel: fallback, channel: fallback,
configured: await listConfiguredMessageChannels(params.cfg), configured: await resolveConfigured(),
source: "tool-context-fallback", source: "tool-context-fallback",
}; };
} }
@ -176,7 +180,7 @@ export async function resolveMessageChannelSelection(params: {
} }
return { return {
channel: availableExplicit, channel: availableExplicit,
configured: await listConfiguredMessageChannels(params.cfg), configured: await resolveConfigured(),
source: "explicit", source: "explicit",
}; };
} }
@ -188,12 +192,12 @@ export async function resolveMessageChannelSelection(params: {
if (fallback) { if (fallback) {
return { return {
channel: fallback, channel: fallback,
configured: await listConfiguredMessageChannels(params.cfg), configured: await resolveConfigured(),
source: "tool-context-fallback", source: "tool-context-fallback",
}; };
} }
const configured = await listConfiguredMessageChannels(params.cfg); const configured = await resolveConfigured();
if (configured.length === 1) { if (configured.length === 1) {
return { channel: configured[0], configured, source: "single-configured" }; return { channel: configured[0], configured, source: "single-configured" };
} }

View File

@ -226,6 +226,7 @@ async function resolveChannel(
cfg, cfg,
channel: readStringParam(params, "channel"), channel: readStringParam(params, "channel"),
fallbackChannel: toolContext?.currentChannelProvider, fallbackChannel: toolContext?.currentChannelProvider,
includeConfigured: false,
}); });
if (selection.source === "tool-context-fallback") { if (selection.source === "tool-context-fallback") {
params.channel = selection.channel; params.channel = selection.channel;
@ -318,14 +319,13 @@ async function handleBroadcastAction(
throw new Error("Broadcast requires at least one target in --targets."); throw new Error("Broadcast requires at least one target in --targets.");
} }
const channelHint = readStringParam(params, "channel"); const channelHint = readStringParam(params, "channel");
const configured = await listConfiguredMessageChannels(input.cfg);
if (configured.length === 0) {
throw new Error("Broadcast requires at least one configured channel.");
}
const targetChannels = const targetChannels =
channelHint && channelHint.trim().toLowerCase() !== "all" channelHint && channelHint.trim().toLowerCase() !== "all"
? [await resolveChannel(input.cfg, { channel: channelHint }, input.toolContext)] ? [await resolveChannel(input.cfg, { channel: channelHint }, input.toolContext)]
: configured; : await listConfiguredMessageChannels(input.cfg);
if (targetChannels.length === 0) {
throw new Error("Broadcast requires at least one configured channel.");
}
const results: Array<{ const results: Array<{
channel: ChannelId; channel: ChannelId;
to: string; to: string;

View File

@ -136,6 +136,7 @@ async function resolveRequiredChannel(params: {
await resolveMessageChannelSelection({ await resolveMessageChannelSelection({
cfg: params.cfg, cfg: params.cfg,
channel: params.channel, channel: params.channel,
includeConfigured: false,
}) })
).channel; ).channel;
} }

View File

@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const originalPlatform = process.platform;
function setPlatform(value: NodeJS.Platform): void {
Object.defineProperty(process, "platform", {
configurable: true,
value,
});
}
afterEach(() => {
setPlatform(originalPlatform);
vi.restoreAllMocks();
});
describe("security scan path guards", () => {
it("uses Windows-aware containment checks for differently normalized paths", async () => {
setPlatform("win32");
const { isPathInside } = await import("./scan-paths.js");
expect(
isPathInside(String.raw`C:\Workspace\Root`, String.raw`c:\workspace\root\hooks\hook`),
).toBe(true);
expect(
isPathInside(String.raw`\\?\C:\Workspace\Root`, String.raw`C:\workspace\root\hooks\hook`),
).toBe(true);
});
});

View File

@ -1,11 +1,8 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import { isPathInside as isBoundaryPathInside } from "../infra/path-guards.js";
export function isPathInside(basePath: string, candidatePath: string): boolean { export function isPathInside(basePath: string, candidatePath: string): boolean {
const base = path.resolve(basePath); return isBoundaryPathInside(basePath, candidatePath);
const candidate = path.resolve(candidatePath);
const rel = path.relative(base, candidate);
return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel));
} }
function safeRealpathSync(filePath: string): string | null { function safeRealpathSync(filePath: string): string | null {