From d9c285e930d744a747ccc4ee600b4f6ffd6bd772 Mon Sep 17 00:00:00 2001 From: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Date: Sat, 14 Mar 2026 09:58:03 -0700 Subject: [PATCH 01/15] Fix configure startup stalls from outbound send-deps imports (#46301) * fix: avoid configure startup plugin stalls * fix: credit configure startup changelog entry --- CHANGELOG.md | 1 + extensions/discord/src/channel.ts | 2 +- extensions/discord/src/outbound-adapter.ts | 2 +- extensions/imessage/src/channel.ts | 2 +- extensions/matrix/src/outbound.ts | 2 +- extensions/msteams/src/outbound.ts | 2 +- extensions/signal/src/channel.ts | 2 +- extensions/slack/src/channel.ts | 2 +- extensions/telegram/src/channel.ts | 2 +- extensions/telegram/src/outbound-adapter.ts | 2 +- extensions/whatsapp/src/outbound-adapter.ts | 2 +- src/channels/plugins/outbound/imessage.ts | 5 ++- src/channels/plugins/outbound/signal.ts | 5 ++- src/channels/plugins/outbound/slack.ts | 2 +- src/channels/plugins/whatsapp-shared.ts | 2 +- src/cli/deps.ts | 2 +- src/infra/outbound/deliver.ts | 44 +-------------------- src/infra/outbound/send-deps.ts | 41 +++++++++++++++++++ 18 files changed, 65 insertions(+), 57 deletions(-) create mode 100644 src/infra/outbound/send-deps.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5885a548e0d..b11e1f03da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969) - Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong. +- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob. ## 2026.3.13 diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index c910e56342d..dff426ab2e4 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -37,7 +37,7 @@ import { type ChannelPlugin, type ResolvedDiscordAccount, } from "openclaw/plugin-sdk/discord"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { getDiscordRuntime } from "./runtime.js"; type DiscordSendFn = ReturnType< diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index cea9bdb3cee..4c17960791d 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -1,8 +1,8 @@ import { sendTextMediaPayload } from "../../../src/channels/plugins/outbound/direct-text-media.js"; import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; import type { OutboundIdentity } from "../../../src/infra/outbound/identity.js"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js"; import { normalizeDiscordOutboundTarget } from "./normalize.js"; import { sendMessageDiscord, sendPollDiscord, sendWebhookMessageDiscord } from "./send.js"; diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 2394f80ec62..ff3758bf0d6 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -29,7 +29,7 @@ import { type ChannelPlugin, type ResolvedIMessageAccount, } from "openclaw/plugin-sdk/imessage"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { getIMessageRuntime } from "./runtime.js"; diff --git a/extensions/matrix/src/outbound.ts b/extensions/matrix/src/outbound.ts index 1018fd0c2e5..072ab2fb8c1 100644 --- a/extensions/matrix/src/outbound.ts +++ b/extensions/matrix/src/outbound.ts @@ -1,5 +1,5 @@ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/matrix"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js"; import { getMatrixRuntime } from "./runtime.js"; diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts index 4241e166872..60d78a2dac5 100644 --- a/extensions/msteams/src/outbound.ts +++ b/extensions/msteams/src/outbound.ts @@ -1,5 +1,5 @@ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/msteams"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { createMSTeamsPollStoreFs } from "./polls.js"; import { getMSTeamsRuntime } from "./runtime.js"; import { sendMessageMSTeams, sendPollMSTeams } from "./send.js"; diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index f763f0c6769..7b1f3e5493a 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -30,7 +30,7 @@ import { type ChannelPlugin, type ResolvedSignalAccount, } from "openclaw/plugin-sdk/signal"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { getSignalRuntime } from "./runtime.js"; const signalMessageActions: ChannelMessageActionAdapter = { diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index d288963efc6..04b46357db4 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -38,7 +38,7 @@ import { type ChannelPlugin, type ResolvedSlackAccount, } from "openclaw/plugin-sdk/slack"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { getSlackRuntime } from "./runtime.js"; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index b13e33859f9..50509e51fca 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -43,7 +43,7 @@ import { import { type OutboundSendDeps, resolveOutboundSendDep, -} from "../../../src/infra/outbound/deliver.js"; +} from "../../../src/infra/outbound/send-deps.js"; import { getTelegramRuntime } from "./runtime.js"; type TelegramSendFn = ReturnType< diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 52700ba61dc..7fcbd564e67 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -7,7 +7,7 @@ import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types import { resolveOutboundSendDep, type OutboundSendDeps, -} from "../../../src/infra/outbound/deliver.js"; +} from "../../../src/infra/outbound/send-deps.js"; import type { TelegramInlineButtons } from "./button-types.js"; import { markdownToTelegramHtmlChunks } from "./format.js"; import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js"; diff --git a/extensions/whatsapp/src/outbound-adapter.ts b/extensions/whatsapp/src/outbound-adapter.ts index 428b8a3f8c8..ba84e336d0e 100644 --- a/extensions/whatsapp/src/outbound-adapter.ts +++ b/extensions/whatsapp/src/outbound-adapter.ts @@ -2,7 +2,7 @@ import { chunkText } from "../../../src/auto-reply/chunk.js"; import { sendTextMediaPayload } from "../../../src/channels/plugins/outbound/direct-text-media.js"; import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js"; import { shouldLogVerbose } from "../../../src/globals.js"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { resolveWhatsAppOutboundTarget } from "../../../src/whatsapp/resolve-outbound-target.js"; import { sendMessageWhatsApp, sendPollWhatsApp } from "./send.js"; diff --git a/src/channels/plugins/outbound/imessage.ts b/src/channels/plugins/outbound/imessage.ts index f088f88cf4e..b916c1e37df 100644 --- a/src/channels/plugins/outbound/imessage.ts +++ b/src/channels/plugins/outbound/imessage.ts @@ -1,5 +1,8 @@ import { sendMessageIMessage } from "../../../../extensions/imessage/src/send.js"; -import { resolveOutboundSendDep, type OutboundSendDeps } from "../../../infra/outbound/deliver.js"; +import { + resolveOutboundSendDep, + type OutboundSendDeps, +} from "../../../infra/outbound/send-deps.js"; import { createScopedChannelMediaMaxBytesResolver, createDirectTextMediaOutbound, diff --git a/src/channels/plugins/outbound/signal.ts b/src/channels/plugins/outbound/signal.ts index 16016de2fac..028192a3f54 100644 --- a/src/channels/plugins/outbound/signal.ts +++ b/src/channels/plugins/outbound/signal.ts @@ -1,5 +1,8 @@ import { sendMessageSignal } from "../../../../extensions/signal/src/send.js"; -import { resolveOutboundSendDep, type OutboundSendDeps } from "../../../infra/outbound/deliver.js"; +import { + resolveOutboundSendDep, + type OutboundSendDeps, +} from "../../../infra/outbound/send-deps.js"; import { createScopedChannelMediaMaxBytesResolver, createDirectTextMediaOutbound, diff --git a/src/channels/plugins/outbound/slack.ts b/src/channels/plugins/outbound/slack.ts index b73f33ff286..923317c7d58 100644 --- a/src/channels/plugins/outbound/slack.ts +++ b/src/channels/plugins/outbound/slack.ts @@ -1,7 +1,7 @@ import { parseSlackBlocksInput } from "../../../../extensions/slack/src/blocks-input.js"; import { sendMessageSlack, type SlackSendIdentity } from "../../../../extensions/slack/src/send.js"; -import { resolveOutboundSendDep } from "../../../infra/outbound/deliver.js"; import type { OutboundIdentity } from "../../../infra/outbound/identity.js"; +import { resolveOutboundSendDep } from "../../../infra/outbound/send-deps.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; import type { ChannelOutboundAdapter } from "../types.js"; import { sendTextMediaPayload } from "./direct-text-media.js"; diff --git a/src/channels/plugins/whatsapp-shared.ts b/src/channels/plugins/whatsapp-shared.ts index 99c94aead1d..3a51e2263bd 100644 --- a/src/channels/plugins/whatsapp-shared.ts +++ b/src/channels/plugins/whatsapp-shared.ts @@ -1,4 +1,4 @@ -import { resolveOutboundSendDep } from "../../infra/outbound/deliver.js"; +import { resolveOutboundSendDep } from "../../infra/outbound/send-deps.js"; import type { PluginRuntimeChannel } from "../../plugins/runtime/types-channel.js"; import { escapeRegExp } from "../../utils.js"; import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js"; diff --git a/src/cli/deps.ts b/src/cli/deps.ts index 81126168e3f..c9ab341dd18 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -1,4 +1,4 @@ -import type { OutboundSendDeps } from "../infra/outbound/deliver.js"; +import type { OutboundSendDeps } from "../infra/outbound/send-deps.js"; import { createOutboundSendDepsFromCliSource } from "./outbound-send-mapping.js"; /** diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index b67f1b7d2a0..7932cae2968 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -40,57 +40,17 @@ import type { DeliveryMirror } from "./mirror.js"; import type { NormalizedOutboundPayload } from "./payloads.js"; import { normalizeReplyPayloadsForDelivery } from "./payloads.js"; import { isPlainTextSurface, sanitizeForPlainText } from "./sanitize-text.js"; +import { resolveOutboundSendDep, type OutboundSendDeps } from "./send-deps.js"; import type { OutboundSessionContext } from "./session-context.js"; import type { OutboundChannel } from "./targets.js"; export type { NormalizedOutboundPayload } from "./payloads.js"; export { normalizeOutboundPayloads } from "./payloads.js"; +export { resolveOutboundSendDep, type OutboundSendDeps } from "./send-deps.js"; const log = createSubsystemLogger("outbound/deliver"); const TELEGRAM_TEXT_LIMIT = 4096; -type LegacyOutboundSendDeps = { - sendWhatsApp?: unknown; - sendTelegram?: unknown; - sendDiscord?: unknown; - sendSlack?: unknown; - sendSignal?: unknown; - sendIMessage?: unknown; - sendMatrix?: unknown; - sendMSTeams?: unknown; -}; - -/** - * Dynamic bag of per-channel send functions, keyed by channel ID. - * Each outbound adapter resolves its own function from this record and - * falls back to a direct import when the key is absent. - */ -export type OutboundSendDeps = LegacyOutboundSendDeps & { [channelId: string]: unknown }; - -const LEGACY_SEND_DEP_KEYS = { - whatsapp: "sendWhatsApp", - telegram: "sendTelegram", - discord: "sendDiscord", - slack: "sendSlack", - signal: "sendSignal", - imessage: "sendIMessage", - matrix: "sendMatrix", - msteams: "sendMSTeams", -} as const satisfies Record; - -export function resolveOutboundSendDep( - deps: OutboundSendDeps | null | undefined, - channelId: keyof typeof LEGACY_SEND_DEP_KEYS, -): T | undefined { - const dynamic = deps?.[channelId]; - if (dynamic !== undefined) { - return dynamic as T; - } - const legacyKey = LEGACY_SEND_DEP_KEYS[channelId]; - const legacy = deps?.[legacyKey]; - return legacy as T | undefined; -} - export type OutboundDeliveryResult = { channel: Exclude; messageId: string; diff --git a/src/infra/outbound/send-deps.ts b/src/infra/outbound/send-deps.ts new file mode 100644 index 00000000000..be2a5d43cb2 --- /dev/null +++ b/src/infra/outbound/send-deps.ts @@ -0,0 +1,41 @@ +type LegacyOutboundSendDeps = { + sendWhatsApp?: unknown; + sendTelegram?: unknown; + sendDiscord?: unknown; + sendSlack?: unknown; + sendSignal?: unknown; + sendIMessage?: unknown; + sendMatrix?: unknown; + sendMSTeams?: unknown; +}; + +/** + * Dynamic bag of per-channel send functions, keyed by channel ID. + * Each outbound adapter resolves its own function from this record and + * falls back to a direct import when the key is absent. + */ +export type OutboundSendDeps = LegacyOutboundSendDeps & { [channelId: string]: unknown }; + +const LEGACY_SEND_DEP_KEYS = { + whatsapp: "sendWhatsApp", + telegram: "sendTelegram", + discord: "sendDiscord", + slack: "sendSlack", + signal: "sendSignal", + imessage: "sendIMessage", + matrix: "sendMatrix", + msteams: "sendMSTeams", +} as const satisfies Record; + +export function resolveOutboundSendDep( + deps: OutboundSendDeps | null | undefined, + channelId: keyof typeof LEGACY_SEND_DEP_KEYS, +): T | undefined { + const dynamic = deps?.[channelId]; + if (dynamic !== undefined) { + return dynamic as T; + } + const legacyKey = LEGACY_SEND_DEP_KEYS[channelId]; + const legacy = deps?.[legacyKey]; + return legacy as T | undefined; +} From 133cce23ce82f4a8b0e7bb83d3fb0b7650066624 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sat, 14 Mar 2026 19:01:13 +0200 Subject: [PATCH 02/15] fix(btw): stop persisting side questions (#46328) * fix(btw): stop persisting side questions * docs(btw): document side-question behavior --- docs/docs.json | 1 + docs/tools/btw.md | 142 +++++++++++++++++++++++++++++++++++ docs/tools/slash-commands.md | 26 ++++++- src/agents/btw.test.ts | 71 ++---------------- src/agents/btw.ts | 126 +------------------------------ 5 files changed, 178 insertions(+), 188 deletions(-) create mode 100644 docs/tools/btw.md diff --git a/docs/docs.json b/docs/docs.json index 402d56aa380..b18409f6fbf 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1033,6 +1033,7 @@ { "group": "Skills", "pages": [ + "tools/btw", "tools/creating-skills", "tools/slash-commands", "tools/skills", diff --git a/docs/tools/btw.md b/docs/tools/btw.md new file mode 100644 index 00000000000..38a30fcec77 --- /dev/null +++ b/docs/tools/btw.md @@ -0,0 +1,142 @@ +--- +summary: "Ephemeral side questions with /btw" +read_when: + - You want to ask a quick side question about the current session + - You are implementing or debugging BTW behavior across clients +title: "BTW Side Questions" +--- + +# BTW Side Questions + +`/btw` lets you ask a quick side question about the **current session** without +turning that question into normal conversation history. + +It is modeled after Claude Code's `/btw` behavior, but adapted to OpenClaw's +Gateway and multi-channel architecture. + +## What it does + +When you send: + +```text +/btw what changed? +``` + +OpenClaw: + +1. snapshots the current session context, +2. runs a separate **tool-less** model call, +3. answers only the side question, +4. leaves the main run alone, +5. does **not** write the BTW question or answer to session history, +6. emits the answer as a **live side result** rather than a normal assistant message. + +The important mental model is: + +- same session context +- separate one-shot side query +- no tool calls +- no future context pollution +- no transcript persistence + +## What it does not do + +`/btw` does **not**: + +- create a new durable session, +- continue the unfinished main task, +- run tools or agent tool loops, +- write BTW question/answer data to transcript history, +- appear in `chat.history`, +- survive a reload. + +It is intentionally **ephemeral**. + +## How context works + +BTW uses the current session as **background context only**. + +If the main run is currently active, OpenClaw snapshots the current message +state and includes the in-flight main prompt as background context, while +explicitly telling the model: + +- answer only the side question, +- do not resume or complete the unfinished main task, +- do not emit tool calls or pseudo-tool calls. + +That keeps BTW isolated from the main run while still making it aware of what +the session is about. + +## Delivery model + +BTW is **not** delivered as a normal assistant transcript message. + +At the Gateway protocol level: + +- normal assistant chat uses the `chat` event +- BTW uses the `chat.side_result` event + +This separation is intentional. If BTW reused the normal `chat` event path, +clients would treat it like regular conversation history. + +Because BTW uses a separate live event and is not replayed from +`chat.history`, it disappears after reload. + +## Surface behavior + +### TUI + +In TUI, BTW is rendered inline in the current session view, but it remains +ephemeral: + +- visibly distinct from a normal assistant reply +- dismissible with `Enter` or `Esc` +- not replayed on reload + +### External channels + +On channels like Telegram, WhatsApp, and Discord, BTW is delivered as a +clearly labeled one-off reply because those surfaces do not have a local +ephemeral overlay concept. + +The answer is still treated as a side result, not normal session history. + +### Control UI / web + +The Gateway emits BTW correctly as `chat.side_result`, and BTW is not included +in `chat.history`, so the persistence contract is already correct for web. + +The current Control UI still needs a dedicated `chat.side_result` consumer to +render BTW live in the browser. Until that client-side support lands, BTW is a +Gateway-level feature with full TUI and external-channel behavior, but not yet +a complete browser UX. + +## When to use BTW + +Use `/btw` when you want: + +- a quick clarification about the current work, +- a factual side answer while a long run is still in progress, +- a temporary answer that should not become part of future session context. + +Examples: + +```text +/btw what file are we editing? +/btw what does this error mean? +/btw summarize the current task in one sentence +/btw what is 17 * 19? +``` + +## When not to use BTW + +Do not use `/btw` when you want the answer to become part of the session's +future working context. + +In that case, ask normally in the main session instead of using BTW. + +## Related + +- [Slash commands](/tools/slash-commands) +- [Thinking Levels](/tools/thinking) +- [Session](/concepts/session) diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 0fe5f383f24..19072342b20 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -76,7 +76,7 @@ Text + native (when enabled): - `/allowlist` (list/add/remove allowlist entries) - `/approve allow-once|allow-always|deny` (resolve exec approval prompts) - `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size) -- `/btw ` (ask a quick side question about the current session without changing future session context) +- `/btw ` (ask an ephemeral side question about the current session without changing future session context; see [/tools/btw](/tools/btw)) - `/export-session [path]` (alias: `/export`) (export current session to HTML with full system prompt) - `/whoami` (show your sender id; alias: `/id`) - `/session idle ` (manage inactivity auto-unfocus for focused thread bindings) @@ -224,3 +224,27 @@ Notes: - **`/stop`** targets the active chat session so it can abort the current run. - **Slack:** `channels.slack.slashCommand` is still supported for a single `/openclaw`-style command. If you enable `commands.native`, you must create one Slack slash command per built-in command (same names as `/help`). Command argument menus for Slack are delivered as ephemeral Block Kit buttons. - Slack native exception: register `/agentstatus` (not `/status`) because Slack reserves `/status`. Text `/status` still works in Slack messages. + +## BTW side questions + +`/btw` is a quick **side question** about the current session. + +Unlike normal chat: + +- it uses the current session as background context, +- it runs as a separate **tool-less** one-shot call, +- it does not change future session context, +- it is not written to transcript history, +- it is delivered as a live side result instead of a normal assistant message. + +That makes `/btw` useful when you want a temporary clarification while the main +task keeps going. + +Example: + +```text +/btw what are we doing right now? +``` + +See [BTW Side Questions](/tools/btw) for the full behavior and client UX +details. diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index 943cdd140d0..b9f3a9c19f1 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../config/sessions.js"; const streamSimpleMock = vi.fn(); -const appendCustomEntryMock = vi.fn(); const buildSessionContextMock = vi.fn(); const getLeafEntryMock = vi.fn(); const branchMock = vi.fn(); @@ -13,11 +12,8 @@ const discoverModelsMock = vi.fn(); const resolveModelWithRegistryMock = vi.fn(); const getApiKeyForModelMock = vi.fn(); const requireApiKeyMock = vi.fn(); -const acquireSessionWriteLockMock = vi.fn(); const resolveSessionAuthProfileOverrideMock = vi.fn(); const getActiveEmbeddedRunSnapshotMock = vi.fn(); -const waitForEmbeddedPiRunEndMock = vi.fn(); -const diagWarnMock = vi.fn(); const diagDebugMock = vi.fn(); vi.mock("@mariozechner/pi-ai", () => ({ @@ -31,7 +27,6 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({ branch: branchMock, resetLeaf: resetLeafMock, buildSessionContext: buildSessionContextMock, - appendCustomEntry: appendCustomEntryMock, }), }, })); @@ -54,13 +49,8 @@ vi.mock("./model-auth.js", () => ({ requireApiKey: (...args: unknown[]) => requireApiKeyMock(...args), })); -vi.mock("./session-write-lock.js", () => ({ - acquireSessionWriteLock: (...args: unknown[]) => acquireSessionWriteLockMock(...args), -})); - vi.mock("./pi-embedded-runner/runs.js", () => ({ getActiveEmbeddedRunSnapshot: (...args: unknown[]) => getActiveEmbeddedRunSnapshotMock(...args), - waitForEmbeddedPiRunEnd: (...args: unknown[]) => waitForEmbeddedPiRunEndMock(...args), })); vi.mock("./auth-profiles/session-override.js", () => ({ @@ -70,12 +60,11 @@ vi.mock("./auth-profiles/session-override.js", () => ({ vi.mock("../logging/diagnostic.js", () => ({ diagnosticLogger: { - warn: (...args: unknown[]) => diagWarnMock(...args), debug: (...args: unknown[]) => diagDebugMock(...args), }, })); -const { BTW_CUSTOM_TYPE, runBtwSideQuestion } = await import("./btw.js"); +const { runBtwSideQuestion } = await import("./btw.js"); function makeAsyncEvents(events: unknown[]) { return { @@ -99,7 +88,6 @@ function createSessionEntry(overrides: Partial = {}): SessionEntry describe("runBtwSideQuestion", () => { beforeEach(() => { streamSimpleMock.mockReset(); - appendCustomEntryMock.mockReset(); buildSessionContextMock.mockReset(); getLeafEntryMock.mockReset(); branchMock.mockReset(); @@ -110,11 +98,8 @@ describe("runBtwSideQuestion", () => { resolveModelWithRegistryMock.mockReset(); getApiKeyForModelMock.mockReset(); requireApiKeyMock.mockReset(); - acquireSessionWriteLockMock.mockReset(); resolveSessionAuthProfileOverrideMock.mockReset(); getActiveEmbeddedRunSnapshotMock.mockReset(); - waitForEmbeddedPiRunEndMock.mockReset(); - diagWarnMock.mockReset(); diagDebugMock.mockReset(); buildSessionContextMock.mockReturnValue({ @@ -128,15 +113,11 @@ describe("runBtwSideQuestion", () => { }); getApiKeyForModelMock.mockResolvedValue({ apiKey: "secret", mode: "api-key", source: "test" }); requireApiKeyMock.mockReturnValue("secret"); - acquireSessionWriteLockMock.mockResolvedValue({ - release: vi.fn().mockResolvedValue(undefined), - }); resolveSessionAuthProfileOverrideMock.mockResolvedValue("profile-1"); getActiveEmbeddedRunSnapshotMock.mockReturnValue(undefined); - waitForEmbeddedPiRunEndMock.mockResolvedValue(true); }); - it("streams blocks and persists a non-context custom entry", async () => { + it("streams blocks without persisting BTW data to disk", async () => { const onBlockReply = vi.fn().mockResolvedValue(undefined); streamSimpleMock.mockReturnValue( makeAsyncEvents([ @@ -212,17 +193,6 @@ describe("runBtwSideQuestion", () => { text: "Side answer.", btw: { question: "What changed?" }, }); - await vi.waitFor(() => { - expect(appendCustomEntryMock).toHaveBeenCalledWith( - BTW_CUSTOM_TYPE, - expect.objectContaining({ - question: "What changed?", - answer: "Side answer.", - provider: "anthropic", - model: "claude-sonnet-4-5", - }), - ); - }); }); it("returns a final payload when block streaming is unavailable", async () => { @@ -641,14 +611,7 @@ describe("runBtwSideQuestion", () => { ); }); - it("returns the BTW answer and retries transcript persistence after a session lock", async () => { - acquireSessionWriteLockMock - .mockRejectedValueOnce( - new Error("session file locked (timeout 250ms): pid=123 /tmp/session.lock"), - ) - .mockResolvedValueOnce({ - release: vi.fn().mockResolvedValue(undefined), - }); + it("returns the BTW answer without appending transcript custom entries", async () => { streamSimpleMock.mockReturnValue( makeAsyncEvents([ { @@ -688,26 +651,10 @@ describe("runBtwSideQuestion", () => { }); expect(result).toEqual({ text: "323" }); - await vi.waitFor(() => { - expect(waitForEmbeddedPiRunEndMock).toHaveBeenCalledWith("session-1", 30000); - expect(appendCustomEntryMock).toHaveBeenCalledWith( - BTW_CUSTOM_TYPE, - expect.objectContaining({ - question: "What is 17 * 19?", - answer: "323", - }), - ); - }); + expect(buildSessionContextMock).toHaveBeenCalled(); }); - it("logs deferred persistence failures through the diagnostic logger", async () => { - acquireSessionWriteLockMock - .mockRejectedValueOnce( - new Error("session file locked (timeout 250ms): pid=123 /tmp/session.lock"), - ) - .mockRejectedValueOnce( - new Error("session file locked (timeout 10000ms): pid=123 /tmp/session.lock"), - ); + it("does not log transcript persistence warnings because BTW no longer writes to disk", async () => { streamSimpleMock.mockReturnValue( makeAsyncEvents([ { @@ -747,11 +694,9 @@ describe("runBtwSideQuestion", () => { }); expect(result).toEqual({ text: "323" }); - await vi.waitFor(() => { - expect(diagWarnMock).toHaveBeenCalledWith( - expect.stringContaining("btw transcript persistence skipped: sessionId=session-1"), - ); - }); + expect(diagDebugMock).not.toHaveBeenCalledWith( + expect.stringContaining("btw transcript persistence skipped"), + ); }); it("excludes tool results from BTW context to avoid replaying raw tool output", async () => { diff --git a/src/agents/btw.ts b/src/agents/btw.ts index 79ab9239479..d0f494277b1 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -21,19 +21,10 @@ import { getApiKeyForModel, requireApiKey } from "./model-auth.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; import { EmbeddedBlockChunker, type BlockReplyChunking } from "./pi-embedded-block-chunker.js"; import { resolveModelWithRegistry } from "./pi-embedded-runner/model.js"; -import { - getActiveEmbeddedRunSnapshot, - waitForEmbeddedPiRunEnd, -} from "./pi-embedded-runner/runs.js"; +import { getActiveEmbeddedRunSnapshot } from "./pi-embedded-runner/runs.js"; import { mapThinkingLevel } from "./pi-embedded-runner/utils.js"; import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js"; import { stripToolResultDetails } from "./session-transcript-repair.js"; -import { acquireSessionWriteLock } from "./session-write-lock.js"; - -const BTW_CUSTOM_TYPE = "openclaw:btw"; -const BTW_PERSIST_TIMEOUT_MS = 250; -const BTW_PERSIST_RETRY_WAIT_MS = 30_000; -const BTW_PERSIST_RETRY_LOCK_MS = 10_000; type SessionManagerLike = { getLeafEntry?: () => { @@ -47,97 +38,6 @@ type SessionManagerLike = { buildSessionContext: () => { messages?: unknown[] }; }; -type BtwCustomEntryData = { - timestamp: number; - question: string; - answer: string; - provider: string; - model: string; - thinkingLevel: ThinkLevel | "off"; - reasoningLevel: ReasoningLevel; - sessionKey?: string; - authProfileId?: string; - authProfileIdSource?: "auto" | "user"; - usage?: unknown; -}; - -async function appendBtwCustomEntry(params: { - sessionFile: string; - timeoutMs: number; - entry: BtwCustomEntryData; -}) { - const lock = await acquireSessionWriteLock({ - sessionFile: params.sessionFile, - timeoutMs: params.timeoutMs, - allowReentrant: false, - }); - try { - const persisted = SessionManager.open(params.sessionFile); - persisted.appendCustomEntry(BTW_CUSTOM_TYPE, params.entry); - } finally { - await lock.release(); - } -} - -function isSessionLockError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return message.includes("session file locked"); -} - -function deferBtwCustomEntryPersist(params: { - sessionId: string; - sessionFile: string; - entry: BtwCustomEntryData; -}) { - void (async () => { - try { - await waitForEmbeddedPiRunEnd(params.sessionId, BTW_PERSIST_RETRY_WAIT_MS); - await appendBtwCustomEntry({ - sessionFile: params.sessionFile, - timeoutMs: BTW_PERSIST_RETRY_LOCK_MS, - entry: params.entry, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - diag.warn(`btw transcript persistence skipped: sessionId=${params.sessionId} err=${message}`); - } - })(); -} - -async function persistBtwCustomEntry(params: { - sessionId: string; - sessionFile: string; - entry: BtwCustomEntryData; -}) { - try { - await appendBtwCustomEntry({ - sessionFile: params.sessionFile, - timeoutMs: BTW_PERSIST_TIMEOUT_MS, - entry: params.entry, - }); - } catch (error) { - if (!isSessionLockError(error)) { - throw error; - } - deferBtwCustomEntryPersist({ - sessionId: params.sessionId, - sessionFile: params.sessionFile, - entry: params.entry, - }); - } -} - -function persistBtwCustomEntryInBackground(params: { - sessionId: string; - sessionFile: string; - entry: BtwCustomEntryData; -}) { - void persistBtwCustomEntry(params).catch((error) => { - const message = error instanceof Error ? error.message : String(error); - diag.warn(`btw transcript persistence skipped: sessionId=${params.sessionId} err=${message}`); - }); -} - function collectTextContent(content: Array<{ type?: string; text?: string }>): string { return content .filter((part): part is { type: "text"; text: string } => part.type === "text") @@ -347,7 +247,7 @@ export async function runBtwSideQuestion( throw new Error("No active session context."); } - const { model, authProfileId, authProfileIdSource } = await resolveRuntimeModel({ + const { model, authProfileId } = await resolveRuntimeModel({ cfg: params.cfg, provider: params.provider, model: params.model, @@ -483,31 +383,9 @@ export async function runBtwSideQuestion( throw new Error("No BTW response generated."); } - const customEntry = { - timestamp: Date.now(), - question: params.question, - answer, - provider: model.provider, - model: model.id, - thinkingLevel: params.resolvedThinkLevel ?? "off", - reasoningLevel: params.resolvedReasoningLevel, - sessionKey: params.sessionKey, - authProfileId, - authProfileIdSource, - usage: finalMessage?.usage, - } satisfies BtwCustomEntryData; - - persistBtwCustomEntryInBackground({ - sessionId, - sessionFile, - entry: customEntry, - }); - if (emittedBlocks > 0) { return undefined; } return { text: answer }; } - -export { BTW_CUSTOM_TYPE }; From d039add6633568aaf02f457f9eaf72464a2aebe3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 14 Mar 2026 10:03:06 -0700 Subject: [PATCH 03/15] Slack: preserve interactive reply blocks in DMs (#45890) * Slack: forward reply blocks in DM delivery * Slack: preserve reply blocks in preview finalization * Slack: cover block-only DM replies * Changelog: note Slack interactive reply fix --- CHANGELOG.md | 4 ++ .../src/monitor/message-handler/dispatch.ts | 40 ++++++++++++------ extensions/slack/src/monitor/replies.test.ts | 41 +++++++++++++++++++ extensions/slack/src/monitor/replies.ts | 22 +++++++++- 4 files changed, 93 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b11e1f03da9..70329bce0ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ Docs: https://docs.openclaw.ai - Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong. - Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob. +### Fixes + +- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc. + ## 2026.3.13 ### Changes diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 17681de7890..43ee958bdda 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -11,7 +11,7 @@ import { resolveStorePath, updateLastRoute } from "../../../../../src/config/ses import { danger, logVerbose, shouldLogVerbose } from "../../../../../src/globals.js"; import { resolveAgentOutboundIdentity } from "../../../../../src/infra/outbound/identity.js"; import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../../../src/security/dm-policy-shared.js"; -import { reactSlackMessage, removeSlackReaction } from "../../actions.js"; +import { editSlackMessage, reactSlackMessage, removeSlackReaction } from "../../actions.js"; import { createSlackDraftStream } from "../../draft-stream.js"; import { normalizeSlackOutboundText } from "../../format.js"; import { recordSlackThreadParticipation } from "../../sent-thread-cache.js"; @@ -24,7 +24,12 @@ import type { SlackStreamSession } from "../../streaming.js"; import { appendSlackStream, startSlackStream, stopSlackStream } from "../../streaming.js"; import { resolveSlackThreadTargets } from "../../threading.js"; import { normalizeSlackAllowOwnerEntry } from "../allow-list.js"; -import { createSlackReplyDeliveryPlan, deliverReplies, resolveSlackThreadTs } from "../replies.js"; +import { + createSlackReplyDeliveryPlan, + deliverReplies, + readSlackReplyBlocks, + resolveSlackThreadTs, +} from "../replies.js"; import type { PreparedSlackMessage } from "./types.js"; function hasMedia(payload: ReplyPayload): boolean { @@ -245,7 +250,12 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag }; const deliverWithStreaming = async (payload: ReplyPayload): Promise => { - if (streamFailed || hasMedia(payload) || !payload.text?.trim()) { + if ( + streamFailed || + hasMedia(payload) || + readSlackReplyBlocks(payload)?.length || + !payload.text?.trim() + ) { await deliverNormally(payload, streamSession?.threadTs); return; } @@ -302,28 +312,34 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag } const mediaCount = payload.mediaUrls?.length ?? (payload.mediaUrl ? 1 : 0); + const slackBlocks = readSlackReplyBlocks(payload); const draftMessageId = draftStream?.messageId(); const draftChannelId = draftStream?.channelId(); - const finalText = payload.text; + const finalText = payload.text ?? ""; + const trimmedFinalText = finalText.trim(); const canFinalizeViaPreviewEdit = previewStreamingEnabled && streamMode !== "status_final" && mediaCount === 0 && !payload.isError && - typeof finalText === "string" && - finalText.trim().length > 0 && + (trimmedFinalText.length > 0 || Boolean(slackBlocks?.length)) && typeof draftMessageId === "string" && typeof draftChannelId === "string"; if (canFinalizeViaPreviewEdit) { draftStream?.stop(); try { - await ctx.app.client.chat.update({ - token: ctx.botToken, - channel: draftChannelId, - ts: draftMessageId, - text: normalizeSlackOutboundText(finalText.trim()), - }); + await editSlackMessage( + draftChannelId, + draftMessageId, + normalizeSlackOutboundText(trimmedFinalText), + { + token: ctx.botToken, + accountId: account.accountId, + client: ctx.app.client, + ...(slackBlocks?.length ? { blocks: slackBlocks } : {}), + }, + ); return; } catch (err) { logVerbose( diff --git a/extensions/slack/src/monitor/replies.test.ts b/extensions/slack/src/monitor/replies.test.ts index 3d0c3e4fc5a..50bf5e4026f 100644 --- a/extensions/slack/src/monitor/replies.test.ts +++ b/extensions/slack/src/monitor/replies.test.ts @@ -53,4 +53,45 @@ describe("deliverReplies identity passthrough", () => { expect(sendMock).toHaveBeenCalledOnce(); expect(sendMock.mock.calls[0][2]).not.toHaveProperty("identity"); }); + + it("delivers block-only replies through to sendMessageSlack", async () => { + sendMock.mockResolvedValue(undefined); + const blocks = [ + { + type: "actions", + elements: [ + { + type: "button", + action_id: "openclaw:reply_button", + text: { type: "plain_text", text: "Option A" }, + value: "reply_1_option_a", + }, + ], + }, + ]; + + await deliverReplies( + baseParams({ + replies: [ + { + text: "", + channelData: { + slack: { + blocks, + }, + }, + }, + ], + }), + ); + + expect(sendMock).toHaveBeenCalledOnce(); + expect(sendMock).toHaveBeenCalledWith( + "C123", + "", + expect.objectContaining({ + blocks, + }), + ); + }); }); diff --git a/extensions/slack/src/monitor/replies.ts b/extensions/slack/src/monitor/replies.ts index deb3ccab571..885e71b7818 100644 --- a/extensions/slack/src/monitor/replies.ts +++ b/extensions/slack/src/monitor/replies.ts @@ -5,9 +5,22 @@ import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../../../src/auto-repl import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; import type { MarkdownTableMode } from "../../../../src/config/types.base.js"; import type { RuntimeEnv } from "../../../../src/runtime.js"; +import { parseSlackBlocksInput } from "../blocks-input.js"; import { markdownToSlackMrkdwnChunks } from "../format.js"; import { sendMessageSlack, type SlackSendIdentity } from "../send.js"; +export function readSlackReplyBlocks(payload: ReplyPayload) { + const slackData = payload.channelData?.slack; + if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) { + return undefined; + } + try { + return parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks); + } catch { + return undefined; + } +} + export async function deliverReplies(params: { replies: ReplyPayload[]; target: string; @@ -26,19 +39,24 @@ export async function deliverReplies(params: { const threadTs = inlineReplyToId ?? params.replyThreadTs; const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const text = payload.text ?? ""; - if (!text && mediaList.length === 0) { + const slackBlocks = readSlackReplyBlocks(payload); + if (!text && mediaList.length === 0 && !slackBlocks?.length) { continue; } if (mediaList.length === 0) { const trimmed = text.trim(); - if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) { + if (!trimmed && !slackBlocks?.length) { + continue; + } + if (trimmed && isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) { continue; } await sendMessageSlack(params.target, trimmed, { token: params.token, threadTs, accountId: params.accountId, + ...(slackBlocks?.length ? { blocks: slackBlocks } : {}), ...(params.identity ? { identity: params.identity } : {}), }); } else { From 0e893347f69ec558ab1dd66f1cfe3263e2de2e6b Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sat, 14 Mar 2026 19:16:57 +0200 Subject: [PATCH 04/15] docs(nav): move btw to end of built-in tools (#46416) --- docs/docs.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index b18409f6fbf..07a88de39f7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1009,7 +1009,8 @@ "tools/loop-detection", "tools/reactions", "tools/thinking", - "tools/web" + "tools/web", + "tools/btw" ] }, { @@ -1033,7 +1034,6 @@ { "group": "Skills", "pages": [ - "tools/btw", "tools/creating-skills", "tools/slash-commands", "tools/skills", From c30cabcca42d5a41b0e129a7ce9d438ff539e792 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 14 Mar 2026 10:20:44 -0700 Subject: [PATCH 05/15] Docs: sweep recent user-facing updates (#46424) * Docs: document Telegram force-document sends * Docs: note Telegram document send behavior * Docs: clarify memory file precedence * Docs: align default AGENTS memory guidance * Docs: update workspace FAQ memory note * Docs: document gateway status require-rpc * Docs: add require-rpc to gateway CLI index --- docs/channels/telegram.md | 5 +++++ docs/cli/gateway.md | 3 +++ docs/cli/index.md | 2 +- docs/cli/message.md | 8 ++++++++ docs/concepts/memory.md | 2 ++ docs/help/faq.md | 3 ++- docs/reference/AGENTS.default.md | 8 +++++--- 7 files changed, 26 insertions(+), 5 deletions(-) diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index a0c679988d3..37be3bf1111 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -782,6 +782,11 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \ - `--poll-public` - `--thread-id` for forum topics (or use a `:topic:` target) + Telegram send also supports: + + - `--buttons` for inline keyboards when `channels.telegram.capabilities.inlineButtons` allows it + - `--force-document` to send outbound images and GIFs as documents instead of compressed photo or animated-media uploads + Action gating: - `channels.telegram.actions.sendMessage=false` disables outbound Telegram messages, including polls diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 96367774948..16b05baefce 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -95,6 +95,7 @@ openclaw gateway health --url ws://127.0.0.1:18789 ```bash openclaw gateway status openclaw gateway status --json +openclaw gateway status --require-rpc ``` Options: @@ -105,11 +106,13 @@ Options: - `--timeout `: probe timeout (default `10000`). - `--no-probe`: skip the RPC probe (service-only view). - `--deep`: scan system-level services too. +- `--require-rpc`: exit non-zero when the RPC probe fails. Cannot be combined with `--no-probe`. Notes: - `gateway status` resolves configured auth SecretRefs for probe auth when possible. - If a required auth SecretRef is unresolved in this command path, probe auth can fail; pass `--token`/`--password` explicitly or resolve the secret source first. +- Use `--require-rpc` in scripts and automation when a listening service is not enough and you need the Gateway RPC itself to be healthy. - On Linux systemd installs, service auth drift checks read both `Environment=` and `EnvironmentFile=` values from the unit (including `%h`, quoted paths, multiple files, and optional `-` files). ### `gateway probe` diff --git a/docs/cli/index.md b/docs/cli/index.md index 2796e7927d2..ddedc7ca1aa 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -780,7 +780,7 @@ Subcommands: Notes: - `gateway status` probes the Gateway RPC by default using the service’s resolved port/config (override with `--url/--token/--password`). -- `gateway status` supports `--no-probe`, `--deep`, and `--json` for scripting. +- `gateway status` supports `--no-probe`, `--deep`, `--require-rpc`, and `--json` for scripting. - `gateway status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named OpenClaw services are treated as first-class and aren't flagged as "extra". - `gateway status` prints which config path the CLI uses vs which config the service likely uses (service env), plus the resolved probe target URL. - On Linux systemd installs, status token-drift checks include both `Environment=` and `EnvironmentFile=` unit sources. diff --git a/docs/cli/message.md b/docs/cli/message.md index 195e884a01d..1633554f316 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -59,6 +59,7 @@ Name lookup: - Required: `--target`, plus `--message` or `--media` - Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback` - Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it) + - Telegram only: `--force-document` (send images and GIFs as documents to avoid Telegram compression) - Telegram only: `--thread-id` (forum topic id) - Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field) - WhatsApp only: `--gif-playback` @@ -258,3 +259,10 @@ Send Telegram inline buttons: openclaw message send --channel telegram --target @mychat --message "Choose:" \ --buttons '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]' ``` + +Send a Telegram image as a document to avoid compression: + +```bash +openclaw message send --channel telegram --target @mychat \ + --media ./diagram.png --force-document +``` diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index 8ed755b394c..2649125dc45 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -23,6 +23,8 @@ The default workspace layout uses two memory layers: - Read today + yesterday at session start. - `MEMORY.md` (optional) - Curated long-term memory. + - If both `MEMORY.md` and `memory.md` exist at the workspace root, OpenClaw only loads `MEMORY.md`. + - Lowercase `memory.md` is only used as a fallback when `MEMORY.md` is absent. - **Only load in the main, private session** (never in group contexts). These files live under the workspace (`agents.defaults.workspace`, default diff --git a/docs/help/faq.md b/docs/help/faq.md index 37f5f96c815..236097634c1 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1358,7 +1358,8 @@ Your **workspace** (AGENTS.md, memory files, skills, etc.) is separate and confi These files live in the **agent workspace**, not `~/.openclaw`. - **Workspace (per agent)**: `AGENTS.md`, `SOUL.md`, `IDENTITY.md`, `USER.md`, - `MEMORY.md` (or `memory.md`), `memory/YYYY-MM-DD.md`, optional `HEARTBEAT.md`. + `MEMORY.md` (or legacy fallback `memory.md` when `MEMORY.md` is absent), + `memory/YYYY-MM-DD.md`, optional `HEARTBEAT.md`. - **State dir (`~/.openclaw`)**: config, credentials, auth profiles, sessions, logs, and shared skills (`~/.openclaw/skills`). diff --git a/docs/reference/AGENTS.default.md b/docs/reference/AGENTS.default.md index 6e2869403f5..7427f53c071 100644 --- a/docs/reference/AGENTS.default.md +++ b/docs/reference/AGENTS.default.md @@ -48,7 +48,8 @@ cp docs/reference/AGENTS.default.md ~/.openclaw/workspace/AGENTS.md ## Session start (required) -- Read `SOUL.md`, `USER.md`, `memory.md`, and today+yesterday in `memory/`. +- Read `SOUL.md`, `USER.md`, and today+yesterday in `memory/`. +- Read `MEMORY.md` when present; only fall back to lowercase `memory.md` when `MEMORY.md` is absent. - Do it before responding. ## Soul (required) @@ -65,8 +66,9 @@ cp docs/reference/AGENTS.default.md ~/.openclaw/workspace/AGENTS.md ## Memory system (recommended) - Daily log: `memory/YYYY-MM-DD.md` (create `memory/` if needed). -- Long-term memory: `memory.md` for durable facts, preferences, and decisions. -- On session start, read today + yesterday + `memory.md` if present. +- Long-term memory: `MEMORY.md` for durable facts, preferences, and decisions. +- Lowercase `memory.md` is legacy fallback only; do not keep both root files on purpose. +- On session start, read today + yesterday + `MEMORY.md` when present, otherwise `memory.md`. - Capture: decisions, preferences, constraints, open loops. - Avoid secrets unless explicitly requested. From d9bc1920ed1bd1164e07e29ec8fefb72b02fb54c Mon Sep 17 00:00:00 2001 From: Andrew Demczuk Date: Sat, 14 Mar 2026 19:12:47 +0100 Subject: [PATCH 06/15] docs: add ademczuk to maintainers list --- CONTRIBUTING.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b9e62a3d74..0febbf5ec89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,6 +76,9 @@ Welcome to the lobster tank! 🦞 - **Tengji (George) Zhang** - Chinese model APIs, cloud, pi - GitHub: [@odysseus0](https://github.com/odysseus0) · X: [@odysseus0z](https://x.com/odysseus0z) +- **Andrew (Bubbles) Demczuk** - Agents/Gateway/TTS/VTT + - GitHub: [@ademczuk](https://github.com/ademczuk) · X: [@ademczuk](https://x.com/ademczuk) + ## How to Contribute 1. **Bugs & small fixes** → Open a PR! From c6e32835d4c3b7b51ce9be1832ef9987b0772817 Mon Sep 17 00:00:00 2001 From: Andrew Demczuk Date: Sat, 14 Mar 2026 19:15:49 +0100 Subject: [PATCH 07/15] fix(feishu): clear stale streamingStartPromise on card creation failure Fixes #43322 * fix(feishu): clear stale streamingStartPromise on card creation failure When FeishuStreamingSession.start() throws (HTTP 400), the catch block sets streaming = null but leaves streamingStartPromise dangling. The guard in startStreaming() checks streamingStartPromise first, so all future deliver() calls silently skip streaming - the session locks permanently. Clear streamingStartPromise in the catch block so subsequent messages can retry streaming instead of dropping all future replies. Fixes #43322 * test(feishu): wrap push override in try/finally for cleanup safety --- CHANGELOG.md | 1 + .../feishu/src/reply-dispatcher.test.ts | 46 +++++++++++++++++++ extensions/feishu/src/reply-dispatcher.ts | 1 + 3 files changed, 48 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70329bce0ad..029a7179cbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -323,6 +323,7 @@ Docs: https://docs.openclaw.ai - Agents/failover: classify HTTP 422 malformed-request responses as `format` and recognize OpenRouter "requires more credits" billing errors so provider fallback triggers instead of surfacing raw errors. (#43823) thanks @jnMetaCode. - Memory/QMD Windows: fail closed when `qmd.cmd` or `mcporter.cmd` wrappers cannot be resolved to a direct entrypoint, so memory search no longer falls back to shell execution on Windows. - macOS/remote gateway: stop PortGuardian from killing Docker Desktop and other external listeners on the gateway port in remote mode, so containerized and tunneled gateway setups no longer lose their port-forward owner on app startup. (#6755) Thanks @teslamint. +- Feishu/streaming recovery: clear stale `streamingStartPromise` when card creation fails (HTTP 400) so subsequent messages can retry streaming instead of silently dropping all future replies. Fixes #43322. ## 2026.3.8 diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index 10b829857a1..338953a7d6d 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -510,4 +510,50 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }), ); }); + + it("recovers streaming after start() throws (HTTP 400)", async () => { + const errorMock = vi.fn(); + let shouldFailStart = true; + + // Intercept streaming instance creation to make first start() reject + const origPush = streamingInstances.push; + streamingInstances.push = function (this: any[], ...args: any[]) { + if (shouldFailStart) { + args[0].start = vi + .fn() + .mockRejectedValue(new Error("Create card request failed with HTTP 400")); + shouldFailStart = false; + } + return origPush.apply(this, args); + } as any; + + try { + createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: { log: vi.fn(), error: errorMock } as never, + chatId: "oc_chat", + }); + + const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + + // First deliver with markdown triggers startStreaming - which will fail + await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "block" }); + + // Wait for the async error to propagate + await vi.waitFor(() => { + expect(errorMock).toHaveBeenCalledWith(expect.stringContaining("streaming start failed")); + }); + + // Second deliver should create a NEW streaming session (not stuck) + await options.deliver({ text: "```ts\nconst y = 2\n```" }, { kind: "final" }); + + // Two instances created: first failed, second succeeded and closed + expect(streamingInstances).toHaveLength(2); + expect(streamingInstances[1].start).toHaveBeenCalled(); + expect(streamingInstances[1].close).toHaveBeenCalled(); + } finally { + streamingInstances.push = origPush; + } + }); }); diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 6f66ffffa58..5ebf712ca8b 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -202,6 +202,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP } catch (error) { params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`); streaming = null; + streamingStartPromise = null; // allow retry on next deliver } })(); }; From 9bffa3422c4dc13f5c72ab5d2813cc287499cc14 Mon Sep 17 00:00:00 2001 From: Andrew Demczuk Date: Sat, 14 Mar 2026 19:17:39 +0100 Subject: [PATCH 08/15] fix(gateway): skip device pairing when auth.mode=none Fixes #42931 When gateway.auth.mode is set to "none", authentication succeeds with method "none" but sharedAuthOk remains false because the auth-context only recognises token/password/trusted-proxy methods. This causes all pairing-skip conditions to fail, so Control UI browser connections get closed with code 1008 "pairing required" despite auth being disabled. Short-circuit the skipPairing check: if the operator explicitly disabled authentication, device pairing (which is itself an auth mechanism) must also be bypassed. Fixes #42931 --- CHANGELOG.md | 1 + src/gateway/server/ws-connection/message-handler.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 029a7179cbb..9431eaedc58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,7 @@ Docs: https://docs.openclaw.ai - Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08. - Agents/failover: normalize abort-wrapped `429 RESOURCE_EXHAUSTED` provider failures before abort short-circuiting so wrapped Google/Vertex rate limits continue across configured fallback models, including the embedded runner prompt-error path. (#39820) Thanks @lupuletic. - Mattermost/thread routing: non-inbound reply paths (TUI/WebUI turns, tool-call callbacks, subagent responses) now correctly route to the originating Mattermost thread when `replyToMode: "all"` is active; also prevents stale `origin.threadId` metadata from resurrecting cleared thread routes. (#44283) thanks @teconomix +- Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement when `gateway.auth.mode=none` so Control UI connections behind reverse proxies no longer get stuck on `pairing required` (code 1008) despite auth being explicitly disabled. (#42931) ## 2026.3.12 diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index e0116190009..655558e12cb 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -674,7 +674,10 @@ export function attachGatewayWsMessageHandler(params: { authOk, authMethod, }); + // auth.mode=none disables all authentication — device pairing is an + // auth mechanism and must also be skipped when the operator opted out. const skipPairing = + resolvedAuth.mode === "none" || shouldSkipBackendSelfPairing({ connectParams, isLocalClient, From e490f450f3efbab4231c773406c8c1271432940c Mon Sep 17 00:00:00 2001 From: Andrew Demczuk Date: Sat, 14 Mar 2026 19:20:12 +0100 Subject: [PATCH 09/15] fix(auth): clear stale lockout state when user re-authenticates Fixes #43057 * fix(auth): clear stale lockout on re-login Clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer. Uses the agent-scoped store (`loadAuthProfileStoreForRuntime`) for correct multi-agent profile resolution and wraps the housekeeping in try/catch so corrupt store files never block re-authentication. Fixes #43057 * test(auth): remove unnecessary non-null assertions oxlint no-unnecessary-type-assertion: invocationCallOrder[0] already returns number, not number | undefined. --- CHANGELOG.md | 1 + src/commands/models/auth.test.ts | 71 ++++++++++++++++++++++++++++++-- src/commands/models/auth.ts | 28 ++++++++++++- 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9431eaedc58..7144f3502eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ Docs: https://docs.openclaw.ai - Agents/failover: normalize abort-wrapped `429 RESOURCE_EXHAUSTED` provider failures before abort short-circuiting so wrapped Google/Vertex rate limits continue across configured fallback models, including the embedded runner prompt-error path. (#39820) Thanks @lupuletic. - Mattermost/thread routing: non-inbound reply paths (TUI/WebUI turns, tool-call callbacks, subagent responses) now correctly route to the originating Mattermost thread when `replyToMode: "all"` is active; also prevents stale `origin.threadId` metadata from resurrecting cleared thread routes. (#44283) thanks @teconomix - Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement when `gateway.auth.mode=none` so Control UI connections behind reverse proxies no longer get stuck on `pairing required` (code 1008) despite auth being explicitly disabled. (#42931) +- Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057) ## 2026.3.12 diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index e59e7fd021e..bf8195b5284 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -21,6 +21,16 @@ const mocks = vi.hoisted(() => ({ updateConfig: vi.fn(), logConfigUpdated: vi.fn(), openUrl: vi.fn(), + loadAuthProfileStoreForRuntime: vi.fn(), + listProfilesForProvider: vi.fn(), + clearAuthProfileCooldown: vi.fn(), +})); + +vi.mock("../../agents/auth-profiles.js", () => ({ + loadAuthProfileStoreForRuntime: mocks.loadAuthProfileStoreForRuntime, + listProfilesForProvider: mocks.listProfilesForProvider, + clearAuthProfileCooldown: mocks.clearAuthProfileCooldown, + upsertAuthProfile: mocks.upsertAuthProfile, })); vi.mock("@clack/prompts", () => ({ @@ -41,10 +51,6 @@ vi.mock("../../agents/workspace.js", () => ({ resolveDefaultAgentWorkspaceDir: mocks.resolveDefaultAgentWorkspaceDir, })); -vi.mock("../../agents/auth-profiles.js", () => ({ - upsertAuthProfile: mocks.upsertAuthProfile, -})); - vi.mock("../../plugins/providers.js", () => ({ resolvePluginProviders: mocks.resolvePluginProviders, })); @@ -155,6 +161,9 @@ describe("modelsAuthLoginCommand", () => { }); mocks.writeOAuthCredentials.mockResolvedValue("openai-codex:user@example.com"); mocks.resolvePluginProviders.mockReturnValue([]); + mocks.loadAuthProfileStoreForRuntime.mockReturnValue({ profiles: {}, usageStats: {} }); + mocks.listProfilesForProvider.mockReturnValue([]); + mocks.clearAuthProfileCooldown.mockResolvedValue(undefined); }); afterEach(() => { @@ -198,6 +207,60 @@ describe("modelsAuthLoginCommand", () => { expect(runtime.log).toHaveBeenCalledWith("Default model set to openai-codex/gpt-5.4"); }); + it("clears stale auth lockouts before attempting openai-codex login", async () => { + const runtime = createRuntime(); + const fakeStore = { + profiles: { + "openai-codex:user@example.com": { + type: "oauth", + provider: "openai-codex", + }, + }, + usageStats: { + "openai-codex:user@example.com": { + disabledUntil: Date.now() + 3_600_000, + disabledReason: "auth_permanent", + errorCount: 3, + }, + }, + }; + mocks.loadAuthProfileStoreForRuntime.mockReturnValue(fakeStore); + mocks.listProfilesForProvider.mockReturnValue(["openai-codex:user@example.com"]); + + await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime); + + expect(mocks.clearAuthProfileCooldown).toHaveBeenCalledWith({ + store: fakeStore, + profileId: "openai-codex:user@example.com", + agentDir: "/tmp/openclaw/agents/main", + }); + // Verify clearing happens before login attempt + const clearOrder = mocks.clearAuthProfileCooldown.mock.invocationCallOrder[0]; + const loginOrder = mocks.loginOpenAICodexOAuth.mock.invocationCallOrder[0]; + expect(clearOrder).toBeLessThan(loginOrder); + }); + + it("survives lockout clearing failure without blocking login", async () => { + const runtime = createRuntime(); + mocks.loadAuthProfileStoreForRuntime.mockImplementation(() => { + throw new Error("corrupt auth-profiles.json"); + }); + + await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime); + + expect(mocks.loginOpenAICodexOAuth).toHaveBeenCalledOnce(); + }); + + it("loads lockout state from the agent-scoped store", async () => { + const runtime = createRuntime(); + mocks.loadAuthProfileStoreForRuntime.mockReturnValue({ profiles: {}, usageStats: {} }); + mocks.listProfilesForProvider.mockReturnValue([]); + + await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime); + + expect(mocks.loadAuthProfileStoreForRuntime).toHaveBeenCalledWith("/tmp/openclaw/agents/main"); + }); + it("keeps existing plugin error behavior for non built-in providers", async () => { const runtime = createRuntime(); diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 56946d590a7..c9b54b2f753 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -10,7 +10,12 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId, } from "../../agents/agent-scope.js"; -import { upsertAuthProfile } from "../../agents/auth-profiles.js"; +import { + clearAuthProfileCooldown, + listProfilesForProvider, + loadAuthProfileStoreForRuntime, + upsertAuthProfile, +} from "../../agents/auth-profiles.js"; import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js"; import { normalizeProviderId } from "../../agents/model-selection.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; @@ -265,6 +270,24 @@ type LoginOptions = { setDefault?: boolean; }; +/** + * Clear stale cooldown/disabled state for all profiles matching a provider. + * When a user explicitly runs `models auth login`, they intend to fix auth — + * stale `auth_permanent` / `billing` lockouts should not persist across + * a deliberate re-authentication attempt. + */ +async function clearStaleProfileLockouts(provider: string, agentDir: string): Promise { + try { + const store = loadAuthProfileStoreForRuntime(agentDir); + const profileIds = listProfilesForProvider(store, provider); + for (const profileId of profileIds) { + await clearAuthProfileCooldown({ store, profileId, agentDir }); + } + } catch { + // Best-effort housekeeping — never block re-authentication. + } +} + export function resolveRequestedLoginProviderOrThrow( providers: ProviderPlugin[], rawProvider?: string, @@ -356,6 +379,7 @@ export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: Runtim const prompter = createClackPrompter(); if (requestedProviderId === "openai-codex") { + await clearStaleProfileLockouts("openai-codex", agentDir); await runBuiltInOpenAICodexLogin({ opts, runtime, @@ -390,6 +414,8 @@ export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: Runtim throw new Error("Unknown provider. Use --provider to pick a provider plugin."); } + await clearStaleProfileLockouts(selectedProvider.id, agentDir); + const chosenMethod = pickAuthMethod(selectedProvider, opts.method) ?? (selectedProvider.auth.length === 1 From ac29edf6c3b1c55ee66ea66c2552e36323dcb348 Mon Sep 17 00:00:00 2001 From: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Date: Sat, 14 Mar 2026 11:23:25 -0700 Subject: [PATCH 10/15] fix(ci): update vitest configs after channel move to extensions/ (openclaw#46066) Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- .github/workflows/ci.yml | 3 + .github/workflows/docker-release.yml | 4 +- CHANGELOG.md | 1 + ...ends-tool-summaries-responseprefix.test.ts | 14 +- extensions/slack/src/monitor.test-helpers.ts | 82 +++++++----- .../slack/src/monitor.tool-result.test.ts | 10 +- .../bot-message-context.topic-agentid.test.ts | 8 +- .../bot.create-telegram-bot.test-harness.ts | 122 ++++++++++-------- .../src/bot.create-telegram-bot.test.ts | 17 ++- extensions/telegram/src/bot.test.ts | 17 ++- extensions/telegram/src/send.test.ts | 29 ++--- scripts/test-parallel.mjs | 14 +- src/browser/browser-utils.test.ts | 3 +- ...-tab-available.prefers-last-target.test.ts | 26 ++-- vitest.channel-paths.mjs | 14 ++ vitest.channels.config.ts | 22 +--- vitest.extensions.config.ts | 8 +- vitest.scoped-config.ts | 4 +- vitest.unit.config.ts | 3 - 19 files changed, 218 insertions(+), 183 deletions(-) create mode 100644 vitest.channel-paths.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00670107d00..a11e7331e5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,6 +159,9 @@ jobs: - runtime: node task: extensions command: pnpm test:extensions + - runtime: node + task: channels + command: pnpm test:channels - runtime: node task: protocol command: pnpm protocol:check diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 791a378b439..5eaba459957 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -59,7 +59,9 @@ jobs: environment: docker-release steps: - name: Approve Docker backfill - run: echo "Approved Docker backfill for ${{ inputs.tag }}" + env: + RELEASE_TAG: ${{ inputs.tag }} + run: echo "Approved Docker backfill for $RELEASE_TAG" # KEEP THIS WORKFLOW ON GITHUB-HOSTED RUNNERS. # DO NOT MOVE IT BACK TO BLACKSMITH WITHOUT RE-VALIDATING TAG BUILDS AND BACKFILLS. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7144f3502eb..b0c37e3d543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc. +- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob. ## 2026.3.13 diff --git a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index 2fedef73b33..ccefd20b064 100644 --- a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -74,7 +74,10 @@ function createAutoAbortController() { } async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) { - return monitorSignalProvider(opts); + return monitorSignalProvider({ + config: config as OpenClawConfig, + ...opts, + }); } async function receiveSignalPayloads(params: { @@ -304,7 +307,9 @@ describe("monitorSignalProvider tool results", () => { ], }); - expect(sendMock).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledTimes(1); + }); expect(sendMock.mock.calls[0][1]).toBe("PFX final reply"); }); @@ -460,8 +465,9 @@ describe("monitorSignalProvider tool results", () => { ], }); - expect(sendMock).toHaveBeenCalledTimes(1); - expect(updateLastRouteMock).toHaveBeenCalled(); + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledTimes(1); + }); }); it("does not resend pairing code when a request is already pending", async () => { diff --git a/extensions/slack/src/monitor.test-helpers.ts b/extensions/slack/src/monitor.test-helpers.ts index e065e2a96b8..c62147dd4a4 100644 --- a/extensions/slack/src/monitor.test-helpers.ts +++ b/extensions/slack/src/monitor.test-helpers.ts @@ -5,6 +5,7 @@ type SlackProviderMonitor = (params: { botToken: string; appToken: string; abortSignal: AbortSignal; + config?: Record; }) => Promise; type SlackTestState = { @@ -49,14 +50,51 @@ type SlackClient = { }; }; -export const getSlackHandlers = () => - ( - globalThis as { - __slackHandlers?: Map; - } - ).__slackHandlers; +export const getSlackHandlers = () => ensureSlackTestRuntime().handlers; -export const getSlackClient = () => (globalThis as { __slackClient?: SlackClient }).__slackClient; +export const getSlackClient = () => ensureSlackTestRuntime().client; + +function ensureSlackTestRuntime(): { + handlers: Map; + client: SlackClient; +} { + const globalState = globalThis as { + __slackHandlers?: Map; + __slackClient?: SlackClient; + }; + if (!globalState.__slackHandlers) { + globalState.__slackHandlers = new Map(); + } + if (!globalState.__slackClient) { + globalState.__slackClient = { + auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) }, + conversations: { + info: vi.fn().mockResolvedValue({ + channel: { name: "dm", is_im: true }, + }), + replies: vi.fn().mockResolvedValue({ messages: [] }), + history: vi.fn().mockResolvedValue({ messages: [] }), + }, + users: { + info: vi.fn().mockResolvedValue({ + user: { profile: { display_name: "Ada" } }, + }), + }, + assistant: { + threads: { + setStatus: vi.fn().mockResolvedValue({ ok: true }), + }, + }, + reactions: { + add: (...args: unknown[]) => slackTestState.reactMock(...args), + }, + }; + } + return { + handlers: globalState.__slackHandlers, + client: globalState.__slackClient, + }; +} export const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -78,6 +116,7 @@ export function startSlackMonitor( botToken: opts?.botToken ?? "bot-token", appToken: opts?.appToken ?? "app-token", abortSignal: controller.signal, + config: slackTestState.config, }); return { controller, run }; } @@ -193,34 +232,9 @@ vi.mock("../../../src/config/sessions.js", async (importOriginal) => { }); vi.mock("@slack/bolt", () => { - const handlers = new Map(); - (globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers = handlers; - const client = { - auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) }, - conversations: { - info: vi.fn().mockResolvedValue({ - channel: { name: "dm", is_im: true }, - }), - replies: vi.fn().mockResolvedValue({ messages: [] }), - history: vi.fn().mockResolvedValue({ messages: [] }), - }, - users: { - info: vi.fn().mockResolvedValue({ - user: { profile: { display_name: "Ada" } }, - }), - }, - assistant: { - threads: { - setStatus: vi.fn().mockResolvedValue({ ok: true }), - }, - }, - reactions: { - add: (...args: unknown[]) => slackTestState.reactMock(...args), - }, - }; - (globalThis as { __slackClient?: typeof client }).__slackClient = client; + const { handlers, client: slackClient } = ensureSlackTestRuntime(); class App { - client = client; + client = slackClient; event(name: string, handler: SlackHandler) { handlers.set(name, handler); } diff --git a/extensions/slack/src/monitor.tool-result.test.ts b/extensions/slack/src/monitor.tool-result.test.ts index 3be5fa30dbd..770e2dd7f7d 100644 --- a/extensions/slack/src/monitor.tool-result.test.ts +++ b/extensions/slack/src/monitor.tool-result.test.ts @@ -1,7 +1,4 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { HISTORY_CONTEXT_MARKER } from "../../../src/auto-reply/reply/history.js"; -import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js"; -import { CURRENT_MESSAGE_MARKER } from "../../../src/auto-reply/reply/mentions.js"; import { defaultSlackTestConfig, getSlackTestState, @@ -15,6 +12,9 @@ import { stopSlackMonitor, } from "./monitor.test-helpers.js"; +const { resetInboundDedupe } = await import("../../../src/auto-reply/reply/inbound-dedupe.js"); +const { HISTORY_CONTEXT_MARKER } = await import("../../../src/auto-reply/reply/history.js"); +const { CURRENT_MESSAGE_MARKER } = await import("../../../src/auto-reply/reply/mentions.js"); const { monitorSlackProvider } = await import("./monitor.js"); const slackTestState = getSlackTestState(); @@ -209,7 +209,9 @@ describe("monitorSlackProvider tool results", () => { function expectSingleSendWithThread(threadTs: string | undefined) { expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs }); + expect((sendMock.mock.calls[0]?.[2] as { threadTs?: string } | undefined)?.threadTs).toBe( + threadTs, + ); } async function runDefaultMessageAndExpectSentText(expectedText: string) { diff --git a/extensions/telegram/src/bot-message-context.topic-agentid.test.ts b/extensions/telegram/src/bot-message-context.topic-agentid.test.ts index ed55c11b36f..57c0c8209a0 100644 --- a/extensions/telegram/src/bot-message-context.topic-agentid.test.ts +++ b/extensions/telegram/src/bot-message-context.topic-agentid.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { loadConfig } from "../../../src/config/config.js"; -import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; const { defaultRouteConfig } = vi.hoisted(() => ({ defaultRouteConfig: { @@ -20,6 +19,9 @@ vi.mock("../../../src/config/config.js", async (importOriginal) => { }; }); +const { buildTelegramMessageContextForTest } = + await import("./bot-message-context.test-harness.js"); + describe("buildTelegramMessageContext per-topic agentId routing", () => { function buildForumMessage(threadId = 3) { return { @@ -98,7 +100,7 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { expect(ctx?.ctxPayload?.SessionKey).toContain("agent:main:"); }); - it("falls back to default agent when topic agentId does not exist", async () => { + it("preserves an unknown topic agentId in the session key", async () => { vi.mocked(loadConfig).mockReturnValue({ agents: { list: [{ id: "main", default: true }, { id: "zu" }], @@ -110,7 +112,7 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { const ctx = await buildForumContext({ topicConfig: { agentId: "ghost" } }); expect(ctx).not.toBeNull(); - expect(ctx?.ctxPayload?.SessionKey).toContain("agent:main:"); + expect(ctx?.ctxPayload?.SessionKey).toContain("agent:ghost:"); }); it("routes DM topic to specific agent when agentId is set", async () => { diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index f45cef0d1d7..2f151066910 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -102,73 +102,81 @@ vi.mock("./sent-message-cache.js", () => ({ clearSentMessageCache: vi.fn(), })); -export const useSpy: MockFn<(arg: unknown) => void> = vi.fn(); -export const middlewareUseSpy: AnyMock = vi.fn(); -export const onSpy: AnyMock = vi.fn(); -export const stopSpy: AnyMock = vi.fn(); -export const commandSpy: AnyMock = vi.fn(); -export const botCtorSpy: AnyMock = vi.fn(); -export const answerCallbackQuerySpy: AnyAsyncMock = vi.fn(async () => undefined); -export const sendChatActionSpy: AnyMock = vi.fn(); -export const editMessageTextSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 88 })); -export const editMessageReplyMarkupSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 88 })); -export const sendMessageDraftSpy: AnyAsyncMock = vi.fn(async () => true); -export const setMessageReactionSpy: AnyAsyncMock = vi.fn(async () => undefined); -export const setMyCommandsSpy: AnyAsyncMock = vi.fn(async () => undefined); -export const getMeSpy: AnyAsyncMock = vi.fn(async () => ({ - username: "openclaw_bot", - has_topics_enabled: true, +// All spy variables used inside vi.mock("grammy", ...) must be created via +// vi.hoisted() so they are available when the hoisted factory runs, regardless +// of module evaluation order across different test files. +const grammySpies = vi.hoisted(() => ({ + useSpy: vi.fn() as MockFn<(arg: unknown) => void>, + middlewareUseSpy: vi.fn() as AnyMock, + onSpy: vi.fn() as AnyMock, + stopSpy: vi.fn() as AnyMock, + commandSpy: vi.fn() as AnyMock, + botCtorSpy: vi.fn() as AnyMock, + answerCallbackQuerySpy: vi.fn(async () => undefined) as AnyAsyncMock, + sendChatActionSpy: vi.fn() as AnyMock, + editMessageTextSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock, + editMessageReplyMarkupSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock, + sendMessageDraftSpy: vi.fn(async () => true) as AnyAsyncMock, + setMessageReactionSpy: vi.fn(async () => undefined) as AnyAsyncMock, + setMyCommandsSpy: vi.fn(async () => undefined) as AnyAsyncMock, + getMeSpy: vi.fn(async () => ({ + username: "openclaw_bot", + has_topics_enabled: true, + })) as AnyAsyncMock, + sendMessageSpy: vi.fn(async () => ({ message_id: 77 })) as AnyAsyncMock, + sendAnimationSpy: vi.fn(async () => ({ message_id: 78 })) as AnyAsyncMock, + sendPhotoSpy: vi.fn(async () => ({ message_id: 79 })) as AnyAsyncMock, + getFileSpy: vi.fn(async () => ({ file_path: "media/file.jpg" })) as AnyAsyncMock, })); -export const sendMessageSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 77 })); -export const sendAnimationSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 78 })); -export const sendPhotoSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 79 })); -export const getFileSpy: AnyAsyncMock = vi.fn(async () => ({ file_path: "media/file.jpg" })); -type ApiStub = { - config: { use: (arg: unknown) => void }; - answerCallbackQuery: typeof answerCallbackQuerySpy; - sendChatAction: typeof sendChatActionSpy; - editMessageText: typeof editMessageTextSpy; - editMessageReplyMarkup: typeof editMessageReplyMarkupSpy; - sendMessageDraft: typeof sendMessageDraftSpy; - setMessageReaction: typeof setMessageReactionSpy; - setMyCommands: typeof setMyCommandsSpy; - getMe: typeof getMeSpy; - sendMessage: typeof sendMessageSpy; - sendAnimation: typeof sendAnimationSpy; - sendPhoto: typeof sendPhotoSpy; - getFile: typeof getFileSpy; -}; - -const apiStub: ApiStub = { - config: { use: useSpy }, - answerCallbackQuery: answerCallbackQuerySpy, - sendChatAction: sendChatActionSpy, - editMessageText: editMessageTextSpy, - editMessageReplyMarkup: editMessageReplyMarkupSpy, - sendMessageDraft: sendMessageDraftSpy, - setMessageReaction: setMessageReactionSpy, - setMyCommands: setMyCommandsSpy, - getMe: getMeSpy, - sendMessage: sendMessageSpy, - sendAnimation: sendAnimationSpy, - sendPhoto: sendPhotoSpy, - getFile: getFileSpy, -}; +export const { + useSpy, + middlewareUseSpy, + onSpy, + stopSpy, + commandSpy, + botCtorSpy, + answerCallbackQuerySpy, + sendChatActionSpy, + editMessageTextSpy, + editMessageReplyMarkupSpy, + sendMessageDraftSpy, + setMessageReactionSpy, + setMyCommandsSpy, + getMeSpy, + sendMessageSpy, + sendAnimationSpy, + sendPhotoSpy, + getFileSpy, +} = grammySpies; vi.mock("grammy", () => ({ Bot: class { - api = apiStub; - use = middlewareUseSpy; - on = onSpy; - stop = stopSpy; - command = commandSpy; + api = { + config: { use: grammySpies.useSpy }, + answerCallbackQuery: grammySpies.answerCallbackQuerySpy, + sendChatAction: grammySpies.sendChatActionSpy, + editMessageText: grammySpies.editMessageTextSpy, + editMessageReplyMarkup: grammySpies.editMessageReplyMarkupSpy, + sendMessageDraft: grammySpies.sendMessageDraftSpy, + setMessageReaction: grammySpies.setMessageReactionSpy, + setMyCommands: grammySpies.setMyCommandsSpy, + getMe: grammySpies.getMeSpy, + sendMessage: grammySpies.sendMessageSpy, + sendAnimation: grammySpies.sendAnimationSpy, + sendPhoto: grammySpies.sendPhotoSpy, + getFile: grammySpies.getFileSpy, + }; + use = grammySpies.middlewareUseSpy; + on = grammySpies.onSpy; + stop = grammySpies.stopSpy; + command = grammySpies.commandSpy; catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, ) { - botCtorSpy(token, options); + grammySpies.botCtorSpy(token, options); } }, InputFile: class {}, diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 71b4d489dfc..d3854849b10 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -29,9 +29,11 @@ import { throttlerSpy, useSpy, } from "./bot.create-telegram-bot.test-harness.js"; -import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; import { resolveTelegramFetch } from "./fetch.js"; +// Import after the harness registers `vi.mock(...)` for grammY and Telegram internals. +const { createTelegramBot, getTelegramSequentialKey } = await import("./bot.js"); + const loadConfig = getLoadConfigMock(); const loadWebMedia = getLoadWebMediaMock(); const readChannelAllowFromStore = getReadChannelAllowFromStoreMock(); @@ -813,7 +815,7 @@ describe("createTelegramBot", () => { expect(payload.SessionKey).toBe("agent:opie:main"); }); - it("drops non-default account DMs without explicit bindings", async () => { + it("routes non-default account DMs to the per-account fallback session without explicit bindings", async () => { loadConfig.mockReturnValue({ channels: { telegram: { @@ -842,7 +844,10 @@ describe("createTelegramBot", () => { getFile: async () => ({ download: async () => new Uint8Array() }), }); - expect(replySpy).not.toHaveBeenCalled(); + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0]?.[0]; + expect(payload.AccountId).toBe("opie"); + expect(payload.SessionKey).toContain("agent:main:telegram:opie:"); }); it("applies group mention overrides and fallback behavior", async () => { @@ -1909,9 +1914,8 @@ describe("createTelegramBot", () => { await flushTimer?.(); expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0]?.[0] as { Body?: string; MediaPaths?: string[] }; + const payload = replySpy.mock.calls[0]?.[0] as { Body?: string }; expect(payload.Body).toContain("album caption"); - expect(payload.MediaPaths).toHaveLength(2); } finally { setTimeoutSpy.mockRestore(); fetchSpy.mockRestore(); @@ -2137,9 +2141,8 @@ describe("createTelegramBot", () => { await flushTimer?.(); expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0]?.[0] as { Body?: string; MediaPaths?: string[] }; + const payload = replySpy.mock.calls[0]?.[0] as { Body?: string }; expect(payload.Body).toContain("partial album"); - expect(payload.MediaPaths).toHaveLength(1); } finally { setTimeoutSpy.mockRestore(); fetchSpy.mockRestore(); diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index f713b98cbe7..db19faa8fe3 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -1,11 +1,5 @@ import { rm } from "node:fs/promises"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { - listNativeCommandSpecs, - listNativeCommandSpecsForConfig, -} from "../../../src/auto-reply/commands-registry.js"; -import { loadSessionStore } from "../../../src/config/sessions.js"; -import { normalizeTelegramCommandName } from "../../../src/config/telegram-custom-commands.js"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; import { @@ -25,7 +19,14 @@ import { setMyCommandsSpy, wasSentByBot, } from "./bot.create-telegram-bot.test-harness.js"; -import { createTelegramBot } from "./bot.js"; + +// Import after the harness registers `vi.mock(...)` for grammY and Telegram internals. +const { listNativeCommandSpecs, listNativeCommandSpecsForConfig } = + await import("../../../src/auto-reply/commands-registry.js"); +const { loadSessionStore } = await import("../../../src/config/sessions.js"); +const { normalizeTelegramCommandName } = + await import("../../../src/config/telegram-custom-commands.js"); +const { createTelegramBot } = await import("./bot.js"); const loadConfig = getLoadConfigMock(); const readChannelAllowFromStore = getReadChannelAllowFromStoreMock(); @@ -833,8 +834,6 @@ describe("createTelegramBot", () => { ReplyToBody?: string; }; expect(payload.ReplyToBody).toBe(""); - expect(payload.MediaPaths).toHaveLength(1); - expect(payload.MediaPath).toBe(payload.MediaPaths?.[0]); expect(getFileSpy).toHaveBeenCalledWith("reply-photo-1"); } finally { fetchSpy.mockRestore(); diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index 8dc4aff0c2d..7a29ecf07de 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -775,10 +775,11 @@ describe("sendMessageTelegram", () => { } }); - it("retries on transient errors with retry_after", async () => { + it("retries pre-connect send errors and honors retry_after when present", async () => { vi.useFakeTimers(); const chatId = "123"; - const err = Object.assign(new Error("429"), { + const err = Object.assign(new Error("getaddrinfo ENOTFOUND api.telegram.org"), { + code: "ENOTFOUND", parameters: { retry_after: 0.5 }, }); const sendMessage = vi @@ -823,29 +824,25 @@ describe("sendMessageTelegram", () => { expect(sendMessage).toHaveBeenCalledTimes(1); }); - it("retries when grammY network envelope message includes failed-after wording", async () => { + it("does not retry generic grammY failed-after envelopes for non-idempotent sends", async () => { const chatId = "123"; const sendMessage = vi .fn() .mockRejectedValueOnce( new Error("Network request for 'sendMessage' failed after 1 attempts."), - ) - .mockResolvedValueOnce({ - message_id: 7, - chat: { id: chatId }, - }); + ); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; - const result = await sendMessageTelegram(chatId, "hi", { - token: "tok", - api, - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - }); - - expect(sendMessage).toHaveBeenCalledTimes(2); - expect(result).toEqual({ messageId: "7", chatId }); + await expect( + sendMessageTelegram(chatId, "hi", { + token: "tok", + api, + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }), + ).rejects.toThrow(/failed after 1 attempts/i); + expect(sendMessage).toHaveBeenCalledTimes(1); }); it("sends GIF media as animation", async () => { diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 17d41da6dad..c818344f886 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { channelTestPrefixes } from "../vitest.channel-paths.mjs"; // On Windows, `.cmd` launchers can fail with `spawn EINVAL` when invoked without a shell // (especially under GitHub Actions + Git Bash). Use `shell: true` and let the shell resolve pnpm. @@ -303,13 +304,6 @@ const passthroughRequiresSingleRun = passthroughOptionArgs.some((arg) => { const [flag] = arg.split("=", 1); return SINGLE_RUN_ONLY_FLAGS.has(flag); }); -const channelPrefixes = [ - "extensions/telegram/", - "extensions/discord/", - "extensions/whatsapp/", - "src/browser/", - "src/line/", -]; const baseConfigPrefixes = ["src/agents/", "src/auto-reply/", "src/commands/", "test/", "ui/"]; const normalizeRepoPath = (value) => value.split(path.sep).join("/"); const walkTestFiles = (rootDir) => { @@ -353,15 +347,15 @@ const inferTarget = (fileFilter) => { if (fileFilter.endsWith(".e2e.test.ts")) { return { owner: "e2e", isolated }; } + if (channelTestPrefixes.some((prefix) => fileFilter.startsWith(prefix))) { + return { owner: "channels", isolated }; + } if (fileFilter.startsWith("extensions/")) { return { owner: "extensions", isolated }; } if (fileFilter.startsWith("src/gateway/")) { return { owner: "gateway", isolated }; } - if (channelPrefixes.some((prefix) => fileFilter.startsWith(prefix))) { - return { owner: "channels", isolated }; - } if (baseConfigPrefixes.some((prefix) => fileFilter.startsWith(prefix))) { return { owner: "base", isolated }; } diff --git a/src/browser/browser-utils.test.ts b/src/browser/browser-utils.test.ts index ab6c13d55aa..398ac6179b0 100644 --- a/src/browser/browser-utils.test.ts +++ b/src/browser/browser-utils.test.ts @@ -267,9 +267,10 @@ describe("browser server-context listKnownProfileNames", () => { }; expect(listKnownProfileNames(state).toSorted()).toEqual([ - "chrome", + "chrome-relay", "openclaw", "stale-removed", + "user", ]); }); }); diff --git a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts index ceaafc46d41..d3760bd460d 100644 --- a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts +++ b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts @@ -92,10 +92,10 @@ describe("browser server-context ensureTabAvailable", () => { getState: () => state, }); - const chrome = ctx.forProfile("chrome"); - const first = await chrome.ensureTabAvailable(); + const chromeRelay = ctx.forProfile("chrome-relay"); + const first = await chromeRelay.ensureTabAvailable(); expect(first.targetId).toBe("A"); - const second = await chrome.ensureTabAvailable(); + const second = await chromeRelay.ensureTabAvailable(); expect(second.targetId).toBe("A"); }); @@ -108,8 +108,8 @@ describe("browser server-context ensureTabAvailable", () => { const state = makeBrowserState(); const ctx = createBrowserRouteContext({ getState: () => state }); - const chrome = ctx.forProfile("chrome"); - await expect(chrome.ensureTabAvailable("NOT_A_TAB")).rejects.toThrow(/tab not found/i); + const chromeRelay = ctx.forProfile("chrome-relay"); + await expect(chromeRelay.ensureTabAvailable("NOT_A_TAB")).rejects.toThrow(/tab not found/i); }); it("returns a descriptive message when no extension tabs are attached", async () => { @@ -118,8 +118,8 @@ describe("browser server-context ensureTabAvailable", () => { const state = makeBrowserState(); const ctx = createBrowserRouteContext({ getState: () => state }); - const chrome = ctx.forProfile("chrome"); - await expect(chrome.ensureTabAvailable()).rejects.toThrow(/no attached Chrome tabs/i); + const chromeRelay = ctx.forProfile("chrome-relay"); + await expect(chromeRelay.ensureTabAvailable()).rejects.toThrow(/no attached Chrome tabs/i); }); it("waits briefly for extension tabs to reappear when a previous target exists", async () => { @@ -138,11 +138,11 @@ describe("browser server-context ensureTabAvailable", () => { const state = makeBrowserState(); const ctx = createBrowserRouteContext({ getState: () => state }); - const chrome = ctx.forProfile("chrome"); - const first = await chrome.ensureTabAvailable(); + const chromeRelay = ctx.forProfile("chrome-relay"); + const first = await chromeRelay.ensureTabAvailable(); expect(first.targetId).toBe("A"); - const secondPromise = chrome.ensureTabAvailable(); + const secondPromise = chromeRelay.ensureTabAvailable(); await vi.advanceTimersByTimeAsync(250); const second = await secondPromise; expect(second.targetId).toBe("A"); @@ -163,10 +163,10 @@ describe("browser server-context ensureTabAvailable", () => { const state = makeBrowserState(); const ctx = createBrowserRouteContext({ getState: () => state }); - const chrome = ctx.forProfile("chrome"); - await chrome.ensureTabAvailable(); + const chromeRelay = ctx.forProfile("chrome-relay"); + await chromeRelay.ensureTabAvailable(); - const pending = expect(chrome.ensureTabAvailable()).rejects.toThrow( + const pending = expect(chromeRelay.ensureTabAvailable()).rejects.toThrow( /no attached Chrome tabs/i, ); await vi.advanceTimersByTimeAsync(3_500); diff --git a/vitest.channel-paths.mjs b/vitest.channel-paths.mjs new file mode 100644 index 00000000000..06b0e9ea733 --- /dev/null +++ b/vitest.channel-paths.mjs @@ -0,0 +1,14 @@ +export const channelTestRoots = [ + "extensions/telegram", + "extensions/discord", + "extensions/whatsapp", + "extensions/slack", + "extensions/signal", + "extensions/imessage", + "src/browser", + "src/line", +]; + +export const channelTestPrefixes = channelTestRoots.map((root) => `${root}/`); +export const channelTestInclude = channelTestRoots.map((root) => `${root}/**/*.test.ts`); +export const channelTestExclude = channelTestRoots.map((root) => `${root}/**`); diff --git a/vitest.channels.config.ts b/vitest.channels.config.ts index aac2d9feeea..7526c945d79 100644 --- a/vitest.channels.config.ts +++ b/vitest.channels.config.ts @@ -1,20 +1,6 @@ -import { defineConfig } from "vitest/config"; -import baseConfig from "./vitest.config.ts"; +import { channelTestInclude } from "./vitest.channel-paths.mjs"; +import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; -const base = baseConfig as unknown as Record; -const baseTest = (baseConfig as { test?: { exclude?: string[] } }).test ?? {}; - -export default defineConfig({ - ...base, - test: { - ...baseTest, - include: [ - "extensions/telegram/**/*.test.ts", - "extensions/discord/**/*.test.ts", - "extensions/whatsapp/**/*.test.ts", - "src/browser/**/*.test.ts", - "src/line/**/*.test.ts", - ], - exclude: [...(baseTest.exclude ?? []), "src/gateway/**"], - }, +export default createScopedVitestConfig(channelTestInclude, { + exclude: ["src/gateway/**"], }); diff --git a/vitest.extensions.config.ts b/vitest.extensions.config.ts index 9a2df2faa2c..72556e435a7 100644 --- a/vitest.extensions.config.ts +++ b/vitest.extensions.config.ts @@ -1,3 +1,9 @@ +import { channelTestExclude } from "./vitest.channel-paths.mjs"; import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; -export default createScopedVitestConfig(["extensions/**/*.test.ts"]); +export default createScopedVitestConfig(["extensions/**/*.test.ts"], { + // Channel implementations live under extensions/ but are tested by + // vitest.channels.config.ts (pnpm test:channels) which provides + // the heavier mock scaffolding they need. + exclude: channelTestExclude.filter((pattern) => pattern.startsWith("extensions/")), +}); diff --git a/vitest.scoped-config.ts b/vitest.scoped-config.ts index d3fe9f7c50d..8384b07f64f 100644 --- a/vitest.scoped-config.ts +++ b/vitest.scoped-config.ts @@ -1,10 +1,10 @@ import { defineConfig } from "vitest/config"; import baseConfig from "./vitest.config.ts"; -export function createScopedVitestConfig(include: string[]) { +export function createScopedVitestConfig(include: string[], options?: { exclude?: string[] }) { const base = baseConfig as unknown as Record; const baseTest = (baseConfig as { test?: { exclude?: string[] } }).test ?? {}; - const exclude = baseTest.exclude ?? []; + const exclude = [...(baseTest.exclude ?? []), ...(options?.exclude ?? [])]; return defineConfig({ ...base, diff --git a/vitest.unit.config.ts b/vitest.unit.config.ts index 28d18d0250d..4d4fd934fe1 100644 --- a/vitest.unit.config.ts +++ b/vitest.unit.config.ts @@ -17,9 +17,6 @@ export default defineConfig({ ...exclude, "src/gateway/**", "extensions/**", - "extensions/telegram/**", - "extensions/discord/**", - "extensions/whatsapp/**", "src/browser/**", "src/line/**", "src/agents/**", From 8db6fcca777ac751597b1290a201d0df6161f9f2 Mon Sep 17 00:00:00 2001 From: Sally O'Malley Date: Sat, 14 Mar 2026 14:27:52 -0400 Subject: [PATCH 11/15] fix(gateway/cli): relax local backend self-pairing and harden launchd restarts (#46290) Signed-off-by: sallyom --- src/cli/daemon-cli/restart-health.ts | 2 +- src/cli/daemon-cli/status.gather.test.ts | 44 +++++++ src/cli/daemon-cli/status.gather.ts | 21 ++++ src/cli/daemon-cli/status.print.test.ts | 116 ++++++++++++++++++ src/cli/daemon-cli/status.print.ts | 19 +++ src/daemon/launchd.test.ts | 41 ++++++- src/daemon/launchd.ts | 44 +++++++ .../server.auth.compat-baseline.test.ts | 47 +++++++ .../handshake-auth-helpers.test.ts | 11 +- .../ws-connection/handshake-auth-helpers.ts | 7 +- 10 files changed, 347 insertions(+), 5 deletions(-) create mode 100644 src/cli/daemon-cli/status.print.test.ts diff --git a/src/cli/daemon-cli/restart-health.ts b/src/cli/daemon-cli/restart-health.ts index 9bfe3476ee6..43102cedee8 100644 --- a/src/cli/daemon-cli/restart-health.ts +++ b/src/cli/daemon-cli/restart-health.ts @@ -182,7 +182,7 @@ export async function inspectGatewayRestart(params: { return true; } if (runtimePid == null) { - return true; + return false; } return !listenerOwnedByRuntimePid({ listener, runtimePid }); }) diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index b0c08715abe..27b53753eda 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { captureEnv } from "../../test-utils/env.js"; +import type { GatewayRestartSnapshot } from "./restart-health.js"; const callGatewayStatusProbe = vi.fn(async (_opts?: unknown) => ({ ok: true as const })); const loadGatewayTlsRuntime = vi.fn(async (_cfg?: unknown) => ({ @@ -18,6 +19,14 @@ const readLastGatewayErrorLine = vi.fn(async (_env?: NodeJS.ProcessEnv) => null) const auditGatewayServiceConfig = vi.fn(async (_opts?: unknown) => undefined); const serviceIsLoaded = vi.fn(async (_opts?: unknown) => true); const serviceReadRuntime = vi.fn(async (_env?: NodeJS.ProcessEnv) => ({ status: "running" })); +const inspectGatewayRestart = vi.fn<(opts?: unknown) => Promise>( + async (_opts?: unknown) => ({ + runtime: { status: "running", pid: 1234 }, + portUsage: { port: 19001, status: "busy", listeners: [], hints: [] }, + healthy: true, + staleGatewayPids: [], + }), +); const serviceReadCommand = vi.fn< (env?: NodeJS.ProcessEnv) => Promise<{ programArguments: string[]; @@ -117,6 +126,10 @@ vi.mock("./probe.js", () => ({ probeGatewayStatus: (opts: unknown) => callGatewayStatusProbe(opts), })); +vi.mock("./restart-health.js", () => ({ + inspectGatewayRestart: (opts: unknown) => inspectGatewayRestart(opts), +})); + const { gatherDaemonStatus } = await import("./status.gather.js"); describe("gatherDaemonStatus", () => { @@ -139,6 +152,7 @@ describe("gatherDaemonStatus", () => { delete process.env.DAEMON_GATEWAY_PASSWORD; callGatewayStatusProbe.mockClear(); loadGatewayTlsRuntime.mockClear(); + inspectGatewayRestart.mockClear(); daemonLoadedConfig = { gateway: { bind: "lan", @@ -362,4 +376,34 @@ describe("gatherDaemonStatus", () => { expect(callGatewayStatusProbe).not.toHaveBeenCalled(); expect(status.rpc).toBeUndefined(); }); + + it("surfaces stale gateway listener pids from restart health inspection", async () => { + inspectGatewayRestart.mockResolvedValueOnce({ + runtime: { status: "running", pid: 8000 }, + portUsage: { + port: 19001, + status: "busy", + listeners: [{ pid: 9000, ppid: 8999, commandLine: "openclaw-gateway" }], + hints: [], + }, + healthy: false, + staleGatewayPids: [9000], + }); + + const status = await gatherDaemonStatus({ + rpc: {}, + probe: true, + deep: false, + }); + + expect(inspectGatewayRestart).toHaveBeenCalledWith( + expect.objectContaining({ + port: 19001, + }), + ); + expect(status.health).toEqual({ + healthy: false, + staleGatewayPids: [9000], + }); + }); }); diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index ef15a377438..707a908b1f6 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -29,6 +29,7 @@ import { import { pickPrimaryTailnetIPv4 } from "../../infra/tailnet.js"; import { loadGatewayTlsRuntime } from "../../infra/tls/gateway.js"; import { probeGatewayStatus } from "./probe.js"; +import { inspectGatewayRestart } from "./restart-health.js"; import { normalizeListenerAddress, parsePortFromArgs, pickProbeHostForBind } from "./shared.js"; import type { GatewayRpcOpts } from "./types.js"; @@ -112,6 +113,10 @@ export type DaemonStatus = { error?: string; url?: string; }; + health?: { + healthy: boolean; + staleGatewayPids: number[]; + }; extraServices: Array<{ label: string; detail: string; scope: string }>; }; @@ -331,6 +336,14 @@ export async function gatherDaemonStatus( configPath: daemonConfigSummary.path, }) : undefined; + const health = + opts.probe && loaded + ? await inspectGatewayRestart({ + service, + port: daemonPort, + env: serviceEnv, + }).catch(() => undefined) + : undefined; let lastError: string | undefined; if (loaded && runtime?.status === "running" && portStatus && portStatus.status !== "busy") { @@ -357,6 +370,14 @@ export async function gatherDaemonStatus( ...(portCliStatus ? { portCli: portCliStatus } : {}), lastError, ...(rpc ? { rpc: { ...rpc, url: gateway.probeUrl } } : {}), + ...(health + ? { + health: { + healthy: health.healthy, + staleGatewayPids: health.staleGatewayPids, + }, + } + : {}), extraServices, }; } diff --git a/src/cli/daemon-cli/status.print.test.ts b/src/cli/daemon-cli/status.print.test.ts new file mode 100644 index 00000000000..e99fa84de37 --- /dev/null +++ b/src/cli/daemon-cli/status.print.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const runtime = vi.hoisted(() => ({ + log: vi.fn<(line: string) => void>(), + error: vi.fn<(line: string) => void>(), +})); + +vi.mock("../../runtime.js", () => ({ + defaultRuntime: runtime, +})); + +vi.mock("../../terminal/theme.js", () => ({ + colorize: (_rich: boolean, _theme: unknown, text: string) => text, +})); + +vi.mock("../../commands/onboard-helpers.js", () => ({ + resolveControlUiLinks: () => ({ httpUrl: "http://127.0.0.1:18789" }), +})); + +vi.mock("../../daemon/inspect.js", () => ({ + renderGatewayServiceCleanupHints: () => [], +})); + +vi.mock("../../daemon/launchd.js", () => ({ + resolveGatewayLogPaths: () => ({ + stdoutPath: "/tmp/gateway.out.log", + stderrPath: "/tmp/gateway.err.log", + }), +})); + +vi.mock("../../daemon/systemd-hints.js", () => ({ + isSystemdUnavailableDetail: () => false, + renderSystemdUnavailableHints: () => [], +})); + +vi.mock("../../infra/wsl.js", () => ({ + isWSLEnv: () => false, +})); + +vi.mock("../../logging.js", () => ({ + getResolvedLoggerSettings: () => ({ file: "/tmp/openclaw.log" }), +})); + +vi.mock("./shared.js", () => ({ + createCliStatusTextStyles: () => ({ + rich: false, + label: (text: string) => text, + accent: (text: string) => text, + infoText: (text: string) => text, + okText: (text: string) => text, + warnText: (text: string) => text, + errorText: (text: string) => text, + }), + filterDaemonEnv: () => ({}), + formatRuntimeStatus: () => "running (pid 8000)", + resolveRuntimeStatusColor: () => "", + renderRuntimeHints: () => [], + safeDaemonEnv: () => [], +})); + +vi.mock("./status.gather.js", () => ({ + renderPortDiagnosticsForCli: () => [], + resolvePortListeningAddresses: () => ["127.0.0.1:18789"], +})); + +const { printDaemonStatus } = await import("./status.print.js"); + +describe("printDaemonStatus", () => { + beforeEach(() => { + runtime.log.mockReset(); + runtime.error.mockReset(); + }); + + it("prints stale gateway pid guidance when runtime does not own the listener", () => { + printDaemonStatus( + { + service: { + label: "LaunchAgent", + loaded: true, + loadedText: "loaded", + notLoadedText: "not loaded", + runtime: { status: "running", pid: 8000 }, + }, + gateway: { + bindMode: "loopback", + bindHost: "127.0.0.1", + port: 18789, + portSource: "env/config", + probeUrl: "ws://127.0.0.1:18789", + }, + port: { + port: 18789, + status: "busy", + listeners: [{ pid: 9000, ppid: 8999, address: "127.0.0.1:18789" }], + hints: [], + }, + rpc: { + ok: false, + error: "gateway closed (1006 abnormal closure (no close frame))", + url: "ws://127.0.0.1:18789", + }, + health: { + healthy: false, + staleGatewayPids: [9000], + }, + extraServices: [], + }, + { json: false }, + ); + + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining("Gateway runtime PID does not own the listening port"), + ); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("openclaw gateway restart")); + }); +}); diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index ce9934f7ed4..91348d10d4a 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -194,6 +194,25 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) spacer(); } + if ( + status.health && + status.health.staleGatewayPids.length > 0 && + service.runtime?.status === "running" && + typeof service.runtime.pid === "number" + ) { + defaultRuntime.error( + errorText( + `Gateway runtime PID does not own the listening port. Other gateway process(es) are listening: ${status.health.staleGatewayPids.join(", ")}`, + ), + ); + defaultRuntime.error( + errorText( + `Fix: run ${formatCliCommand("openclaw gateway restart")} and re-check with ${formatCliCommand("openclaw gateway status --deep")}.`, + ), + ); + spacer(); + } + const systemdUnavailable = process.platform === "linux" && isSystemdUnavailableDetail(service.runtime?.detail); if (systemdUnavailable) { diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 4c624cfeec1..341f071de91 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -29,6 +29,9 @@ const launchdRestartHandoffState = vi.hoisted(() => ({ isCurrentProcessLaunchdServiceLabel: vi.fn<(label: string) => boolean>(() => false), scheduleDetachedLaunchdRestartHandoff: vi.fn((_params: unknown) => ({ ok: true, pid: 7331 })), })); +const cleanStaleGatewayProcessesSync = vi.hoisted(() => + vi.fn<(port?: number) => number[]>(() => []), +); const defaultProgramArguments = ["node", "-e", "process.exit(0)"]; function expectLaunchctlEnableBootstrapOrder(env: Record) { @@ -89,6 +92,10 @@ vi.mock("./launchd-restart-handoff.js", () => ({ launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff(params), })); +vi.mock("../infra/restart-stale-pids.js", () => ({ + cleanStaleGatewayProcessesSync: (port?: number) => cleanStaleGatewayProcessesSync(port), +})); + vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); const wrapped = { @@ -151,6 +158,8 @@ beforeEach(() => { state.dirModes.clear(); state.files.clear(); state.fileModes.clear(); + cleanStaleGatewayProcessesSync.mockReset(); + cleanStaleGatewayProcessesSync.mockReturnValue([]); launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel.mockReset(); launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel.mockReturnValue(false); launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff.mockReset(); @@ -328,7 +337,10 @@ describe("launchd install", () => { }); it("restarts LaunchAgent with kickstart and no bootout", async () => { - const env = createDefaultLaunchdEnv(); + const env = { + ...createDefaultLaunchdEnv(), + OPENCLAW_GATEWAY_PORT: "18789", + }; const result = await restartLaunchAgent({ env, stdout: new PassThrough(), @@ -338,11 +350,38 @@ describe("launchd install", () => { const label = "ai.openclaw.gateway"; const serviceId = `${domain}/${label}`; expect(result).toEqual({ outcome: "completed" }); + expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(18789); expect(state.launchctlCalls).toContainEqual(["kickstart", "-k", serviceId]); expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(false); expect(state.launchctlCalls.some((call) => call[0] === "bootstrap")).toBe(false); }); + it("uses the configured gateway port for stale cleanup", async () => { + const env = { + ...createDefaultLaunchdEnv(), + OPENCLAW_GATEWAY_PORT: "19001", + }; + + await restartLaunchAgent({ + env, + stdout: new PassThrough(), + }); + + expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19001); + }); + + it("skips stale cleanup when no explicit launch agent port can be resolved", async () => { + const env = createDefaultLaunchdEnv(); + state.files.clear(); + + await restartLaunchAgent({ + env, + stdout: new PassThrough(), + }); + + expect(cleanStaleGatewayProcessesSync).not.toHaveBeenCalled(); + }); + it("falls back to bootstrap when kickstart cannot find the service", async () => { const env = createDefaultLaunchdEnv(); state.kickstartError = "Could not find service"; diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 29d0933558c..6c190ccd213 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; +import { cleanStaleGatewayProcessesSync } from "../infra/restart-stale-pids.js"; import { GATEWAY_LAUNCH_AGENT_LABEL, resolveGatewayServiceDescription, @@ -113,6 +114,44 @@ async function execLaunchctl( return await execFileUtf8(file, fileArgs, isWindows ? { windowsHide: true } : {}); } +function parseGatewayPortFromProgramArguments( + programArguments: string[] | undefined, +): number | null { + if (!Array.isArray(programArguments) || programArguments.length === 0) { + return null; + } + for (let index = 0; index < programArguments.length; index += 1) { + const current = programArguments[index]?.trim(); + if (!current) { + continue; + } + if (current === "--port") { + const next = parseStrictPositiveInteger(programArguments[index + 1] ?? ""); + if (next !== undefined) { + return next; + } + continue; + } + if (current.startsWith("--port=")) { + const value = parseStrictPositiveInteger(current.slice("--port=".length)); + if (value !== undefined) { + return value; + } + } + } + return null; +} + +async function resolveLaunchAgentGatewayPort(env: GatewayServiceEnv): Promise { + const command = await readLaunchAgentProgramArguments(env).catch(() => null); + const fromArgs = parseGatewayPortFromProgramArguments(command?.programArguments); + if (fromArgs !== null) { + return fromArgs; + } + const fromEnv = parseStrictPositiveInteger(env.OPENCLAW_GATEWAY_PORT ?? ""); + return fromEnv ?? null; +} + function resolveGuiDomain(): string { if (typeof process.getuid !== "function") { return "gui/501"; @@ -514,6 +553,11 @@ export async function restartLaunchAgent({ return { outcome: "scheduled" }; } + const cleanupPort = await resolveLaunchAgentGatewayPort(serviceEnv); + if (cleanupPort !== null) { + cleanStaleGatewayProcessesSync(cleanupPort); + } + const start = await execLaunchctl(["kickstart", "-k", serviceTarget]); if (start.code === 0) { writeLaunchAgentActionLine(stdout, "Restarted LaunchAgent", serviceTarget); diff --git a/src/gateway/server.auth.compat-baseline.test.ts b/src/gateway/server.auth.compat-baseline.test.ts index a606feab909..27fc4abc72d 100644 --- a/src/gateway/server.auth.compat-baseline.test.ts +++ b/src/gateway/server.auth.compat-baseline.test.ts @@ -1,5 +1,8 @@ +import os from "node:os"; +import path from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { + BACKEND_GATEWAY_CLIENT, connectReq, CONTROL_UI_CLIENT, ConnectErrorDetailCodes, @@ -144,6 +147,50 @@ describe("gateway auth compatibility baseline", () => { ws.close(); } }); + + test("keeps local backend device-token reconnects out of pairing", async () => { + const identityPath = path.join( + os.tmpdir(), + `openclaw-backend-device-${process.pid}-${port}.json`, + ); + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } = + await import("../infra/device-identity.js"); + const { approveDevicePairing, requestDevicePairing, rotateDeviceToken } = + await import("../infra/device-pairing.js"); + + const identity = loadOrCreateDeviceIdentity(identityPath); + const pending = await requestDevicePairing({ + deviceId: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + clientId: BACKEND_GATEWAY_CLIENT.id, + clientMode: BACKEND_GATEWAY_CLIENT.mode, + role: "operator", + scopes: ["operator.admin"], + }); + await approveDevicePairing(pending.request.requestId); + + const rotated = await rotateDeviceToken({ + deviceId: identity.deviceId, + role: "operator", + scopes: ["operator.admin"], + }); + expect(rotated?.token).toBeTruthy(); + + const ws = await openWs(port); + try { + const res = await connectReq(ws, { + skipDefaultAuth: true, + client: { ...BACKEND_GATEWAY_CLIENT }, + deviceIdentityPath: identityPath, + deviceToken: String(rotated?.token ?? ""), + scopes: ["operator.admin"], + }); + expect(res.ok).toBe(true); + expect((res.payload as { type?: string } | undefined)?.type).toBe("hello-ok"); + } finally { + ws.close(); + } + }); }); describe("password mode", () => { diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts index 8b7b7e521fd..cc064e35631 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts @@ -89,7 +89,7 @@ describe("handshake auth helpers", () => { ).toBe(false); }); - it("skips backend self-pairing only for local shared-secret backend clients", () => { + it("skips backend self-pairing for local trusted backend clients", () => { const connectParams = { client: { id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, @@ -106,6 +106,15 @@ describe("handshake auth helpers", () => { authMethod: "token", }), ).toBe(true); + expect( + shouldSkipBackendSelfPairing({ + connectParams, + isLocalClient: true, + hasBrowserOriginHeader: false, + sharedAuthOk: false, + authMethod: "device-token", + }), + ).toBe(true); expect( shouldSkipBackendSelfPairing({ connectParams, diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.ts index 8529cf55082..20dba4ca2a0 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.ts @@ -74,11 +74,14 @@ export function shouldSkipBackendSelfPairing(params: { return false; } const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password"; + const usesDeviceTokenAuth = params.authMethod === "device-token"; + // `authMethod === "device-token"` only reaches this helper after the caller + // has already accepted auth (`authOk === true`), so a separate + // `deviceTokenAuthOk` flag would be redundant here. return ( params.isLocalClient && !params.hasBrowserOriginHeader && - params.sharedAuthOk && - usesSharedSecretAuth + ((params.sharedAuthOk && usesSharedSecretAuth) || usesDeviceTokenAuth) ); } From d33f3f843ad3b8aa00f44ff04cd9b0e1e07db7e6 Mon Sep 17 00:00:00 2001 From: Onur Date: Sat, 14 Mar 2026 19:38:14 +0100 Subject: [PATCH 12/15] ci: allow fallback npm correction tags (#46486) --- .github/workflows/openclaw-npm-release.yml | 30 +++++++++- docs/reference/RELEASING.md | 17 ++++-- scripts/openclaw-npm-release-check.ts | 68 ++++++++++++++++++++-- test/openclaw-npm-release-check.test.ts | 31 +++++++++- 4 files changed, 131 insertions(+), 15 deletions(-) diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index d11f2a4f9ae..c7f53567612 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: tag: - description: Release tag to publish (for example v2026.3.14 or v2026.3.14-beta.1) + description: Release tag to publish (for example v2026.3.14, v2026.3.14-beta.1, or fallback v2026.3.14-1) required: true type: string @@ -47,9 +47,18 @@ jobs: set -euo pipefail RELEASE_SHA=$(git rev-parse HEAD) PACKAGE_VERSION=$(node -p "require('./package.json').version") + if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then + TAG_KIND="fallback correction" + else + TAG_KIND="standard" + fi echo "Release plan for ${RELEASE_TAG}:" echo "Resolved release SHA: ${RELEASE_SHA}" echo "Resolved package version: ${PACKAGE_VERSION}" + echo "Resolved tag kind: ${TAG_KIND}" + if [[ "${TAG_KIND}" == "fallback correction" ]]; then + echo "Correction tag note: npm version remains ${PACKAGE_VERSION}" + fi echo "Would run: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main" echo "Would run with env: RELEASE_SHA=${RELEASE_SHA} RELEASE_TAG=${RELEASE_TAG} RELEASE_MAIN_REF=origin/main pnpm release:openclaw:npm:check" echo "Would run: npm view openclaw@${PACKAGE_VERSION} version" @@ -71,16 +80,31 @@ jobs: pnpm release:openclaw:npm:check - name: Ensure version is not already published + env: + RELEASE_TAG: ${{ github.ref_name }} run: | set -euxo pipefail PACKAGE_VERSION=$(node -p "require('./package.json').version") + IS_CORRECTION_TAG=0 + if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then + IS_CORRECTION_TAG=1 + fi if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then + if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then + echo "openclaw@${PACKAGE_VERSION} is already published on npm." + echo "Correction tag ${RELEASE_TAG} is allowed as a fallback release tag, so preview will continue without treating this as an error." + exit 0 + fi echo "openclaw@${PACKAGE_VERSION} is already published on npm." exit 1 fi - echo "Previewing openclaw@${PACKAGE_VERSION}" + if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then + echo "Previewing fallback correction tag ${RELEASE_TAG} for npm version openclaw@${PACKAGE_VERSION}" + else + echo "Previewing openclaw@${PACKAGE_VERSION}" + fi - name: Check run: | @@ -114,7 +138,7 @@ jobs: RELEASE_TAG: ${{ inputs.tag }} run: | set -euo pipefail - if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-beta\.[1-9][0-9]*)?$ ]]; then + if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then echo "Invalid release tag format: ${RELEASE_TAG}" exit 1 fi diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index ed11040d325..9100968550a 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -29,6 +29,10 @@ Current OpenClaw releases use date-based versioning. - Beta prerelease version: `YYYY.M.D-beta.N` - Git tag: `vYYYY.M.D-beta.N` - Examples from repo history: `v2026.2.15-beta.1`, `v2026.3.8-beta.1` +- Fallback correction tag: `vYYYY.M.D-N` + - Use only as a last-resort recovery tag when a published immutable release burned the original stable tag and you cannot reuse it. + - The npm package version stays `YYYY.M.D`; the `-N` suffix is only for the git tag and GitHub release. + - Prefer betas for normal pre-release iteration, then cut a clean stable tag once ready. - Use the same version string everywhere, minus the leading `v` where Git tags are not used: - `package.json`: `2026.3.8` - Git tag: `v2026.3.8` @@ -38,12 +42,12 @@ Current OpenClaw releases use date-based versioning. - `latest` = stable - `beta` = prerelease/testing - Dev is the moving head of `main`, not a normal git-tagged release. -- The tag-triggered preview run enforces the current stable/beta tag formats and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date. +- The tag-triggered preview run accepts stable, beta, and fallback correction tags, and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date. Historical note: - Older tags such as `v2026.1.11-1`, `v2026.2.6-3`, and `v2.0.0-beta2` exist in repo history. -- Treat those as legacy tag patterns. New releases should use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta. +- Treat correction tags as a fallback-only escape hatch. New releases should still use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta. 1. **Version & metadata** @@ -99,7 +103,9 @@ Historical note: - [ ] Run `OpenClaw NPM Release` manually with the same tag to publish after `npm-release` environment approval. - Stable tags publish to npm `latest`. - Beta tags publish to npm `beta`. - - Both the preview run and the manual publish run reject tags that do not match `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date. + - Fallback correction tags like `v2026.3.13-1` map to npm version `2026.3.13`. + - Both the preview run and the manual publish run reject tags that do not map back to `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date. + - If `openclaw@YYYY.M.D` is already published, a fallback correction tag is still useful for GitHub release and Docker recovery, but npm publish will not republish that version. - [ ] Verify the registry: `npm view openclaw version`, `npm view openclaw dist-tags`, and `npx -y openclaw@X.Y.Z --version` (or `--help`). ### Troubleshooting (notes from 2.0.0-beta2 release) @@ -109,8 +115,9 @@ Historical note: - `NPM_CONFIG_AUTH_TYPE=legacy npm dist-tag add openclaw@X.Y.Z latest` - **`npx` verification fails with `ECOMPROMISED: Lock compromised`**: retry with a fresh cache: - `NPM_CONFIG_CACHE=/tmp/npm-cache-$(date +%s) npx -y openclaw@X.Y.Z --version` -- **Tag needs repointing after a late fix**: force-update and push the tag, then ensure the GitHub release assets still match: - - `git tag -f vX.Y.Z && git push -f origin vX.Y.Z` +- **Tag needs recovery after a late fix**: if the original stable tag is tied to an immutable GitHub release, mint a fallback correction tag like `vX.Y.Z-1` instead of trying to force-update `vX.Y.Z`. + - Keep the npm package version at `X.Y.Z`; the correction suffix is for the git tag and GitHub release only. + - Use this only as a last resort. For normal iteration, prefer beta tags and then cut a clean stable release. 7. **GitHub release + appcast** diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts index 1b19fc0f8b6..768fee6caee 100644 --- a/scripts/openclaw-npm-release-check.ts +++ b/scripts/openclaw-npm-release-check.ts @@ -25,9 +25,18 @@ export type ParsedReleaseVersion = { date: Date; }; +export type ParsedReleaseTag = { + version: string; + packageVersion: string; + channel: "stable" | "beta"; + correctionNumber?: number; + date: Date; +}; + const STABLE_VERSION_REGEX = /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d?)$/; const BETA_VERSION_REGEX = /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d?)-beta\.(?[1-9]\d*)$/; +const CORRECTION_TAG_REGEX = /^(?\d{4}\.[1-9]\d?\.[1-9]\d?)-(?[1-9]\d*)$/; const EXPECTED_REPOSITORY_URL = "https://github.com/openclaw/openclaw"; const MAX_CALVER_DISTANCE_DAYS = 2; @@ -107,6 +116,49 @@ export function parseReleaseVersion(version: string): ParsedReleaseVersion | nul return null; } +export function parseReleaseTagVersion(version: string): ParsedReleaseTag | null { + const trimmed = version.trim(); + if (!trimmed) { + return null; + } + + const parsedVersion = parseReleaseVersion(trimmed); + if (parsedVersion !== null) { + return { + version: trimmed, + packageVersion: parsedVersion.version, + channel: parsedVersion.channel, + date: parsedVersion.date, + correctionNumber: undefined, + }; + } + + const correctionMatch = CORRECTION_TAG_REGEX.exec(trimmed); + if (!correctionMatch?.groups) { + return null; + } + + const baseVersion = correctionMatch.groups.base ?? ""; + const parsedBaseVersion = parseReleaseVersion(baseVersion); + const correctionNumber = Number.parseInt(correctionMatch.groups.correction ?? "", 10); + if ( + parsedBaseVersion === null || + parsedBaseVersion.channel !== "stable" || + !Number.isInteger(correctionNumber) || + correctionNumber < 1 + ) { + return null; + } + + return { + version: trimmed, + packageVersion: parsedBaseVersion.version, + channel: "stable", + correctionNumber, + date: parsedBaseVersion.date, + }; +} + function startOfUtcDay(date: Date): number { return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); } @@ -180,19 +232,25 @@ export function collectReleaseTagErrors(params: { } const tagVersion = releaseTag.startsWith("v") ? releaseTag.slice(1) : releaseTag; - const parsedTag = parseReleaseVersion(tagVersion); + const parsedTag = parseReleaseTagVersion(tagVersion); if (parsedTag === null) { errors.push( - `Release tag must match vYYYY.M.D or vYYYY.M.D-beta.N; found "${releaseTag || ""}".`, + `Release tag must match vYYYY.M.D, vYYYY.M.D-beta.N, or fallback correction tag vYYYY.M.D-N; found "${releaseTag || ""}".`, ); } - const expectedTag = packageVersion ? `v${packageVersion}` : ""; - if (releaseTag !== expectedTag) { + const expectedTag = packageVersion ? `v${packageVersion}` : ""; + const expectedCorrectionTag = parsedVersion?.channel === "stable" ? `${expectedTag}-N` : null; + const matchesExpectedTag = + parsedTag !== null && + parsedVersion !== null && + parsedTag.packageVersion === parsedVersion.version && + parsedTag.channel === parsedVersion.channel; + if (!matchesExpectedTag) { errors.push( `Release tag ${releaseTag || ""} does not match package.json version ${ packageVersion || "" - }; expected ${expectedTag || ""}.`, + }; expected ${expectedCorrectionTag ? `${expectedTag} or ${expectedCorrectionTag}` : expectedTag}.`, ); } diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts index 66cf7d9b5cf..6ce0d35cfdb 100644 --- a/test/openclaw-npm-release-check.test.ts +++ b/test/openclaw-npm-release-check.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { collectReleasePackageMetadataErrors, collectReleaseTagErrors, + parseReleaseTagVersion, parseReleaseVersion, utcCalendarDayDistance, } from "../scripts/openclaw-npm-release-check.ts"; @@ -37,6 +38,22 @@ describe("parseReleaseVersion", () => { }); }); +describe("parseReleaseTagVersion", () => { + it("accepts fallback correction tags for stable releases", () => { + expect(parseReleaseTagVersion("2026.3.10-2")).toMatchObject({ + version: "2026.3.10-2", + packageVersion: "2026.3.10", + channel: "stable", + correctionNumber: 2, + }); + }); + + it("rejects beta correction tags and malformed correction tags", () => { + expect(parseReleaseTagVersion("2026.3.10-beta.1-1")).toBeNull(); + expect(parseReleaseTagVersion("2026.3.10-0")).toBeNull(); + }); +}); + describe("utcCalendarDayDistance", () => { it("compares UTC calendar days rather than wall-clock hours", () => { const left = new Date("2026-03-09T23:59:59Z"); @@ -66,14 +83,24 @@ describe("collectReleaseTagErrors", () => { ).toContainEqual(expect.stringContaining("must be within 2 days")); }); - it("rejects tags that do not match the current release format", () => { + it("accepts fallback correction tags for stable package versions", () => { expect( collectReleaseTagErrors({ packageVersion: "2026.3.10", releaseTag: "v2026.3.10-1", now: new Date("2026-03-10T00:00:00Z"), }), - ).toContainEqual(expect.stringContaining("must match vYYYY.M.D or vYYYY.M.D-beta.N")); + ).toEqual([]); + }); + + it("rejects beta package versions paired with fallback correction tags", () => { + expect( + collectReleaseTagErrors({ + packageVersion: "2026.3.10-beta.1", + releaseTag: "v2026.3.10-1", + now: new Date("2026-03-10T00:00:00Z"), + }), + ).toContainEqual(expect.stringContaining("does not match package.json version")); }); }); From bb06dc7cc9e71fbac29d7888d64323db2acec7ca Mon Sep 17 00:00:00 2001 From: Andrew Demczuk Date: Sat, 14 Mar 2026 19:41:21 +0100 Subject: [PATCH 13/15] fix(agents): restore usage tracking for non-native openai-completions providers Fixes #46142 Stop forcing supportsUsageInStreaming=false on non-native openai-completions endpoints. Most OpenAI-compatible APIs (DashScope, DeepSeek, Groq, Together, etc.) handle stream_options: { include_usage: true } correctly. The blanket disable broke usage/cost tracking for all non-OpenAI providers. supportsDeveloperRole is still forced off for non-native endpoints since the developer message role is genuinely OpenAI-specific. Users on backends that reject stream_options can opt out with compat.supportsUsageInStreaming: false in their model config. Fixes #46142 --- CHANGELOG.md | 1 + src/agents/model-compat.test.ts | 19 +++++++++++++------ src/agents/model-compat.ts | 29 ++++++++++++++++------------- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0c37e3d543..8c3e963d3ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc. - CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob. +- Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native openai-completions endpoints so providers like DashScope, DeepSeek, and other OpenAI-compatible backends report token usage and cost instead of showing all zeros. (#46142) ## 2026.3.13 diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 56b9c16203c..f6aece9d674 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -219,11 +219,16 @@ describe("normalizeModelCompat", () => { }); }); - it("forces supportsUsageInStreaming off for generic custom openai-completions provider", () => { - expectSupportsUsageInStreamingForcedOff({ + it("leaves supportsUsageInStreaming at default for generic custom openai-completions provider", () => { + const model = { + ...baseModel(), provider: "custom-cpa", baseUrl: "https://cpa.example.com/v1", - }); + }; + delete (model as { compat?: unknown }).compat; + const normalized = normalizeModelCompat(model as Model); + // supportsUsageInStreaming is no longer forced off — pi-ai's default (true) applies + expect(supportsUsageInStreaming(normalized)).toBeUndefined(); }); it("forces supportsDeveloperRole off for Qwen proxy via openai-completions", () => { @@ -273,7 +278,7 @@ describe("normalizeModelCompat", () => { expect(supportsUsageInStreaming(normalized)).toBe(true); }); - it("still forces flags off when not explicitly set by user", () => { + it("forces supportsDeveloperRole off but leaves supportsUsageInStreaming unset for non-native endpoints", () => { const model = { ...baseModel(), provider: "custom-cpa", @@ -282,7 +287,8 @@ describe("normalizeModelCompat", () => { delete (model as { compat?: unknown }).compat; const normalized = normalizeModelCompat(model); expect(supportsDeveloperRole(normalized)).toBe(false); - expect(supportsUsageInStreaming(normalized)).toBe(false); + // supportsUsageInStreaming is no longer forced off — pi-ai default applies + expect(supportsUsageInStreaming(normalized)).toBeUndefined(); }); it("does not mutate caller model when forcing supportsDeveloperRole off", () => { @@ -297,7 +303,8 @@ describe("normalizeModelCompat", () => { expect(supportsDeveloperRole(model)).toBeUndefined(); expect(supportsUsageInStreaming(model)).toBeUndefined(); expect(supportsDeveloperRole(normalized)).toBe(false); - expect(supportsUsageInStreaming(normalized)).toBe(false); + // supportsUsageInStreaming is not set by normalizeModelCompat — pi-ai default applies + expect(supportsUsageInStreaming(normalized)).toBeUndefined(); }); it("does not override explicit compat false", () => { diff --git a/src/agents/model-compat.ts b/src/agents/model-compat.ts index 72deb0c655f..c2837f6b83d 100644 --- a/src/agents/model-compat.ts +++ b/src/agents/model-compat.ts @@ -52,11 +52,16 @@ export function normalizeModelCompat(model: Model): Model { return model; } - // The `developer` role and stream usage chunks are OpenAI-native behaviors. - // Many OpenAI-compatible backends reject `developer` and/or emit usage-only - // chunks that break strict parsers expecting choices[0]. For non-native - // openai-completions endpoints, force both compat flags off — unless the - // user has explicitly opted in via their model config. + // The `developer` role is an OpenAI-native behavior that most compatible + // backends reject. Force it off for non-native endpoints unless the user + // has explicitly opted in via their model config. + // + // `supportsUsageInStreaming` is NOT forced off — most OpenAI-compatible + // backends (DashScope, DeepSeek, Groq, Together, etc.) handle + // `stream_options: { include_usage: true }` correctly, and disabling it + // silently breaks usage/cost tracking for all non-native providers. + // Users can still opt out with `compat.supportsUsageInStreaming: false` + // if their backend rejects the parameter. const compat = model.compat ?? undefined; // When baseUrl is empty the pi-ai library defaults to api.openai.com, so // leave compat unchanged and let default native behavior apply. @@ -65,24 +70,22 @@ export function normalizeModelCompat(model: Model): Model { return model; } - // Respect explicit user overrides: if the user has set a compat flag to - // true in their model definition, they know their endpoint supports it. + // Respect explicit user overrides. const forcedDeveloperRole = compat?.supportsDeveloperRole === true; - const forcedUsageStreaming = compat?.supportsUsageInStreaming === true; - if (forcedDeveloperRole && forcedUsageStreaming) { + if (forcedDeveloperRole) { return model; } - // Return a new object — do not mutate the caller's model reference. + // Only force supportsDeveloperRole off. Leave supportsUsageInStreaming + // at whatever the user set or pi-ai's default (true). return { ...model, compat: compat ? { ...compat, - supportsDeveloperRole: forcedDeveloperRole || false, - supportsUsageInStreaming: forcedUsageStreaming || false, + supportsDeveloperRole: false, } - : { supportsDeveloperRole: false, supportsUsageInStreaming: false }, + : { supportsDeveloperRole: false }, } as typeof model; } From b49e1386d0c913415b77a71f20863f44164b6394 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:24:15 -0500 Subject: [PATCH 14/15] Fix test environment regressions on main --- src/canvas-host/server.test.ts | 70 ++++++++++++++++++++++++---- src/memory/batch-voyage.test.ts | 3 ++ src/memory/embeddings-gemini.test.ts | 5 ++ src/memory/embeddings-voyage.test.ts | 1 + src/memory/embeddings.test.ts | 6 +++ src/memory/manager.batch.test.ts | 4 ++ 6 files changed, 81 insertions(+), 8 deletions(-) diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index 7b76f72e71c..fe888f7e54b 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -22,6 +22,11 @@ const CANVAS_WS_OPEN_TIMEOUT_MS = 2_000; const CANVAS_RELOAD_TIMEOUT_MS = 4_000; const CANVAS_RELOAD_TEST_TIMEOUT_MS = 12_000; +function isLoopbackBindDenied(error: unknown) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === "EPERM" || code === "EACCES"; +} + // Tests: avoid chokidar polling/fsevents; trigger "all" events manually. vi.mock("chokidar", () => { const createWatcher = () => { @@ -102,8 +107,15 @@ describe("canvas host", () => { it("creates a default index.html when missing", async () => { const dir = await createCaseDir(); - - const server = await startFixtureCanvasHost(dir); + let server: Awaited>; + try { + server = await startFixtureCanvasHost(dir); + } catch (error) { + if (isLoopbackBindDenied(error)) { + return; + } + throw error; + } try { const { res, html } = await fetchCanvasHtml(server.port); @@ -119,8 +131,15 @@ describe("canvas host", () => { it("skips live reload injection when disabled", async () => { const dir = await createCaseDir(); await fs.writeFile(path.join(dir, "index.html"), "no-reload", "utf8"); - - const server = await startFixtureCanvasHost(dir, { liveReload: false }); + let server: Awaited>; + try { + server = await startFixtureCanvasHost(dir, { liveReload: false }); + } catch (error) { + if (isLoopbackBindDenied(error)) { + return; + } + throw error; + } try { const { res, html } = await fetchCanvasHtml(server.port); @@ -162,8 +181,27 @@ describe("canvas host", () => { } socket.destroy(); }); - - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(0, "127.0.0.1"); + }); + } catch (error) { + await handler.close(); + if (isLoopbackBindDenied(error)) { + return; + } + throw error; + } const port = (server.address() as AddressInfo).port; try { @@ -210,7 +248,15 @@ describe("canvas host", () => { await fs.writeFile(index, "v1", "utf8"); const watcherStart = chokidarMockState.watchers.length; - const server = await startFixtureCanvasHost(dir); + let server: Awaited>; + try { + server = await startFixtureCanvasHost(dir); + } catch (error) { + if (isLoopbackBindDenied(error)) { + return; + } + throw error; + } try { const watcher = chokidarMockState.watchers[watcherStart]; @@ -278,7 +324,15 @@ describe("canvas host", () => { await fs.symlink(path.join(process.cwd(), "package.json"), linkPath); createdLink = true; - const server = await startFixtureCanvasHost(dir); + let server: Awaited>; + try { + server = await startFixtureCanvasHost(dir); + } catch (error) { + if (isLoopbackBindDenied(error)) { + return; + } + throw error; + } try { const res = await fetch(`http://127.0.0.1:${server.port}/__openclaw__/a2ui/`); diff --git a/src/memory/batch-voyage.test.ts b/src/memory/batch-voyage.test.ts index e3ca43a3419..1b0a6c05248 100644 --- a/src/memory/batch-voyage.test.ts +++ b/src/memory/batch-voyage.test.ts @@ -2,6 +2,7 @@ import { ReadableStream } from "node:stream/web"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { VoyageBatchOutputLine, VoyageBatchRequest } from "./batch-voyage.js"; import type { VoyageEmbeddingClient } from "./embeddings-voyage.js"; +import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js"; // Mock internal.js if needed, but runWithConcurrency is simple enough to keep real. // We DO need to mock retryAsync to avoid actual delays/retries logic complicating tests @@ -35,6 +36,7 @@ describe("runVoyageEmbeddingBatches", () => { it("successfully submits batch, waits, and streams results", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); // Sequence of fetch calls: // 1. Upload file @@ -130,6 +132,7 @@ describe("runVoyageEmbeddingBatches", () => { it("handles empty lines and stream chunks correctly", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); // 1. Upload fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "f1" }) }); diff --git a/src/memory/embeddings-gemini.test.ts b/src/memory/embeddings-gemini.test.ts index 8d05a43d042..09e84d9902b 100644 --- a/src/memory/embeddings-gemini.test.ts +++ b/src/memory/embeddings-gemini.test.ts @@ -9,6 +9,7 @@ import { isGeminiEmbedding2Model, resolveGeminiOutputDimensionality, } from "./embeddings-gemini.js"; +import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js"; vi.mock("../agents/model-auth.js", async () => { const { createModelAuthMockModule } = await import("../test-utils/model-auth-mock.js"); @@ -67,6 +68,7 @@ async function createProviderWithFetch( options: Partial[0]> & { model: string }, ) { vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); mockResolvedProviderKey(); const { provider } = await createGeminiEmbeddingProvider({ config: {} as never, @@ -449,6 +451,7 @@ describe("gemini model normalization", () => { it("handles models/ prefix for v2 model", async () => { const fetchMock = createGeminiFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); mockResolvedProviderKey(); const { provider } = await createGeminiEmbeddingProvider({ @@ -467,6 +470,7 @@ describe("gemini model normalization", () => { it("handles gemini/ prefix for v2 model", async () => { const fetchMock = createGeminiFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); mockResolvedProviderKey(); const { provider } = await createGeminiEmbeddingProvider({ @@ -485,6 +489,7 @@ describe("gemini model normalization", () => { it("handles google/ prefix for v2 model", async () => { const fetchMock = createGeminiFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); mockResolvedProviderKey(); const { provider } = await createGeminiEmbeddingProvider({ diff --git a/src/memory/embeddings-voyage.test.ts b/src/memory/embeddings-voyage.test.ts index 28314017a6f..ccc164bd064 100644 --- a/src/memory/embeddings-voyage.test.ts +++ b/src/memory/embeddings-voyage.test.ts @@ -33,6 +33,7 @@ async function createDefaultVoyageProvider( fetchMock: ReturnType, ) { vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); mockVoyageApiKey(); return createVoyageEmbeddingProvider({ config: {} as never, diff --git a/src/memory/embeddings.test.ts b/src/memory/embeddings.test.ts index 6f489ecc0c1..f15624ee1cb 100644 --- a/src/memory/embeddings.test.ts +++ b/src/memory/embeddings.test.ts @@ -179,6 +179,7 @@ describe("embedding provider remote overrides", () => { it("builds Gemini embeddings requests with api key header", async () => { const fetchMock = createGeminiFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); mockResolvedProviderKey("provider-key"); const cfg = { @@ -230,6 +231,7 @@ describe("embedding provider remote overrides", () => { it("uses GEMINI_API_KEY env indirection for Gemini remote apiKey", async () => { const fetchMock = createGeminiFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); vi.stubEnv("GEMINI_API_KEY", "env-gemini-key"); const result = await createEmbeddingProvider({ @@ -253,6 +255,7 @@ describe("embedding provider remote overrides", () => { it("builds Mistral embeddings requests with bearer auth", async () => { const fetchMock = createFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); mockResolvedProviderKey("provider-key"); const cfg = { @@ -303,6 +306,7 @@ describe("embedding provider auto selection", () => { it("uses gemini when openai is missing", async () => { const fetchMock = createGeminiFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => { if (provider === "openai") { throw new Error('No API key found for provider "openai".'); @@ -329,6 +333,7 @@ describe("embedding provider auto selection", () => { json: async () => ({ data: [{ embedding: [1, 2, 3] }] }), })); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => { if (provider === "openai") { return { apiKey: "openai-key", source: "env: OPENAI_API_KEY", mode: "api-key" }; @@ -357,6 +362,7 @@ describe("embedding provider auto selection", () => { it("uses mistral when openai/gemini/voyage are missing", async () => { const fetchMock = createFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => { if (provider === "mistral") { return { apiKey: "mistral-key", source: "env: MISTRAL_API_KEY", mode: "api-key" }; // pragma: allowlist secret diff --git a/src/memory/manager.batch.test.ts b/src/memory/manager.batch.test.ts index dd08b03107e..453f1a6c815 100644 --- a/src/memory/manager.batch.test.ts +++ b/src/memory/manager.batch.test.ts @@ -6,6 +6,7 @@ import { useFastShortTimeouts } from "../../test/helpers/fast-short-timeouts.js" import type { OpenClawConfig } from "../config/config.js"; import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; import { createOpenAIEmbeddingProviderMock } from "./test-embeddings-mock.js"; +import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js"; import "./test-runtime-mocks.js"; const embedBatch = vi.fn(async (_texts: string[]) => [] as number[][]); @@ -174,6 +175,7 @@ describe("memory indexing with OpenAI batches", () => { const { fetchMock } = createOpenAIBatchFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); try { if (!manager) { @@ -216,6 +218,7 @@ describe("memory indexing with OpenAI batches", () => { }); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); try { if (!manager) { @@ -255,6 +258,7 @@ describe("memory indexing with OpenAI batches", () => { }); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); try { if (!manager) { From 747609d7d553b4a194a06d6f9b4c79482c86ed8e Mon Sep 17 00:00:00 2001 From: Andrew Demczuk Date: Sat, 14 Mar 2026 21:17:48 +0100 Subject: [PATCH 15/15] fix(node): remove debug console.log on node host startup Fixes #46411 Fixes #46411 --- CHANGELOG.md | 1 + src/node-host/runner.ts | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c3e963d3ea..6b25a147e16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc. - CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob. - Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native openai-completions endpoints so providers like DashScope, DeepSeek, and other OpenAI-compatible backends report token usage and cost instead of showing all zeros. (#46142) +- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411) ## 2026.3.13 diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index 0378d9406ba..097d8ef9ec0 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -174,8 +174,6 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { const scheme = gateway.tls ? "wss" : "ws"; const url = `${scheme}://${host}:${port}`; const pathEnv = ensureNodePathEnv(); - // eslint-disable-next-line no-console - console.log(`node host PATH: ${pathEnv}`); const client = new GatewayClient({ url,