diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a376f35bc..b7e5d094139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,9 @@ Docs: https://docs.openclaw.ai ### Fixes +- Hooks/Windows: preserve Windows-aware hook path handling across plugin-managed hook loading and bundle MCP config resolution, so path aliases and canonicalization differences no longer drop hook metadata or break bundled MCP launches. +- Outbound/channels: skip full configured-channel scans when explicit channel selection already determines the target, so explicit sends and broadcasts avoid slow unrelated plugin configuration checks. +- Tlon/install: fetch `@tloncorp/api` from the pinned HTTPS tarball artifact instead of a Git transport URL so installs no longer depend on GitHub SSH access. - CLI/Ollama onboarding: keep the interactive model picker for explicit `openclaw onboard --auth-choice ollama` runs so setup still selects a default model without reintroducing pre-picker auto-pulls. (#49249) Thanks @BruceMacD. - Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev. - Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev. diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index dda0a35cd06..2aa6e7254cc 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -222,11 +222,12 @@ async function resolveChannel( params: Record, toolContext?: { currentChannelProvider?: string }, ) { + const explicitChannel = readStringParam(params, "channel"); const selection = await resolveMessageChannelSelection({ cfg, - channel: readStringParam(params, "channel"), + channel: explicitChannel, fallbackChannel: toolContext?.currentChannelProvider, - includeConfigured: false, + includeConfigured: !explicitChannel, }); if (selection.source === "tool-context-fallback") { params.channel = selection.channel; diff --git a/src/infra/outbound/message.channels.test.ts b/src/infra/outbound/message.channels.test.ts index 0e99a7af2b7..a7b9d11a22b 100644 --- a/src/infra/outbound/message.channels.test.ts +++ b/src/infra/outbound/message.channels.test.ts @@ -1,7 +1,11 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugins/types.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createMSTeamsTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { + createChannelTestPluginBase, + createMSTeamsTestPlugin, + createTestRegistry, +} from "../../test-utils/channel-plugins.js"; import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; @@ -242,6 +246,78 @@ describe("sendPoll channel normalization", () => { }); }); +describe("implicit single-channel selection", () => { + it("keeps single configured channel fallback for sendMessage when channel is omitted", async () => { + const sendText = vi.fn(async () => ({ channel: "msteams", messageId: "m1" })); + setRegistry( + createTestRegistry([ + { + pluginId: "msteams", + source: "test", + plugin: { + ...createChannelTestPluginBase({ + id: "msteams", + label: "Microsoft Teams", + docsPath: "/channels/msteams", + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + isConfigured: () => true, + }, + }), + outbound: { + ...createMSTeamsOutbound(), + sendText, + }, + }, + }, + ]), + ); + + const result = await sendMessage({ + cfg: {}, + to: "conversation:19:abc@thread.tacv2", + content: "hi", + }); + + expect(result.channel).toBe("msteams"); + expect(sendText).toHaveBeenCalled(); + }); + + it("keeps single configured channel fallback for sendPoll when channel is omitted", async () => { + setRegistry( + createTestRegistry([ + { + pluginId: "msteams", + source: "test", + plugin: { + ...createChannelTestPluginBase({ + id: "msteams", + label: "Microsoft Teams", + docsPath: "/channels/msteams", + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + isConfigured: () => true, + }, + }), + outbound: createMSTeamsOutbound({ includePoll: true }), + }, + }, + ]), + ); + + const result = await sendPoll({ + cfg: {}, + to: "conversation:19:abc@thread.tacv2", + question: "Lunch?", + options: ["Pizza", "Sushi"], + }); + + expect(result.channel).toBe("msteams"); + }); +}); + const setMattermostGatewayRegistry = () => { setRegistry( createTestRegistry([ diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index 9df211960f8..b7b1a220a69 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -132,11 +132,12 @@ async function resolveRequiredChannel(params: { cfg: OpenClawConfig; channel?: string; }): Promise { + const explicitChannel = typeof params.channel === "string" ? params.channel.trim() : ""; return ( await resolveMessageChannelSelection({ cfg: params.cfg, - channel: params.channel, - includeConfigured: false, + channel: explicitChannel || undefined, + includeConfigured: !explicitChannel, }) ).channel; } diff --git a/src/infra/path-guards.test.ts b/src/infra/path-guards.test.ts index 335d86f639e..e46a2bd5a8a 100644 --- a/src/infra/path-guards.test.ts +++ b/src/infra/path-guards.test.ts @@ -65,6 +65,7 @@ describe("isPathInside", () => { it("accepts identical and nested paths but rejects escapes", () => { expect(isPathInside("/workspace/root", "/workspace/root")).toBe(true); expect(isPathInside("/workspace/root", "/workspace/root/nested/file.txt")).toBe(true); + expect(isPathInside("/workspace/root", "/workspace/root/..cache/file.txt")).toBe(true); expect(isPathInside("/workspace/root", "/workspace/root/../escape.txt")).toBe(false); }); @@ -75,6 +76,9 @@ describe("isPathInside", () => { expect( isPathInside(String.raw`C:\workspace\root`, String.raw`C:\workspace\root\Nested\File.txt`), ).toBe(true); + expect( + isPathInside(String.raw`C:\workspace\root`, String.raw`C:\workspace\root\..cache\file.txt`), + ).toBe(true); expect( isPathInside(String.raw`C:\workspace\root`, String.raw`C:\workspace\root\..\escape.txt`), ).toBe(false); diff --git a/src/infra/path-guards.ts b/src/infra/path-guards.ts index 379801152e8..ed0565abd30 100644 --- a/src/infra/path-guards.ts +++ b/src/infra/path-guards.ts @@ -37,11 +37,20 @@ export function isPathInside(root: string, target: string): boolean { const rootForCompare = normalizeWindowsPathForComparison(path.win32.resolve(root)); const targetForCompare = normalizeWindowsPathForComparison(path.win32.resolve(target)); const relative = path.win32.relative(rootForCompare, targetForCompare); - return relative === "" || (!relative.startsWith("..") && !path.win32.isAbsolute(relative)); + return ( + relative === "" || + (relative !== ".." && + !relative.startsWith(`..\\`) && + !relative.startsWith("../") && + !path.win32.isAbsolute(relative)) + ); } const resolvedRoot = path.resolve(root); const resolvedTarget = path.resolve(target); const relative = path.relative(resolvedRoot, resolvedTarget); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + return ( + relative === "" || + (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ); } diff --git a/src/plugins/bundle-mcp.ts b/src/plugins/bundle-mcp.ts index b0960c17a93..bb870dcbd7b 100644 --- a/src/plugins/bundle-mcp.ts +++ b/src/plugins/bundle-mcp.ts @@ -327,7 +327,7 @@ export function loadEnabledBundleMcpConfig(params: { const loaded = loadBundleMcpConfig({ pluginId: record.id, - rootDir: record.rootDir, + rootDir: record.format === "bundle" ? record.source : record.rootDir, bundleFormat: record.bundleFormat, }); merged = applyMergePatch(merged, loaded.config) as BundleMcpConfig; diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 20c1fefc87d..37d43a69e43 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -299,7 +299,9 @@ describe("discoverOpenClawPlugins", () => { expect(bundle?.format).toBe("bundle"); expect(bundle?.bundleFormat).toBe("codex"); expect(bundle?.source).toBe(bundleDir); - expect(normalizePathForAssertion(bundle?.rootDir)).toBe(normalizePathForAssertion(bundleDir)); + expect(normalizePathForAssertion(bundle?.rootDir)).toBe( + normalizePathForAssertion(fs.realpathSync(bundleDir)), + ); }); it("auto-detects manifestless Claude bundles from the default layout", async () => { diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 2b1c19ad756..3efe1ccc565 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -377,8 +377,7 @@ function addCandidate(params: { if (params.seen.has(resolved)) { return; } - const lexicalRoot = path.resolve(params.rootDir); - const resolvedRoot = safeRealpathSync(params.rootDir) ?? lexicalRoot; + const resolvedRoot = safeRealpathSync(params.rootDir) ?? path.resolve(params.rootDir); if ( isUnsafePluginCandidate({ source: resolved, @@ -396,7 +395,7 @@ function addCandidate(params: { idHint: params.idHint, source: resolved, setupSource: params.setupSource, - rootDir: lexicalRoot, + rootDir: resolvedRoot, origin: params.origin, format: params.format ?? "openclaw", bundleFormat: params.bundleFormat,