From 12702e11a50abac5e96956ee8743064494e240d1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 9 Mar 2026 11:20:33 -0700 Subject: [PATCH 0001/1173] plugins: harden global hook runner state (#40184) --- src/plugins/hook-runner-global.test.ts | 49 ++++++++++++++++++++++++++ src/plugins/hook-runner-global.ts | 34 +++++++++++++----- 2 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 src/plugins/hook-runner-global.test.ts diff --git a/src/plugins/hook-runner-global.test.ts b/src/plugins/hook-runner-global.test.ts new file mode 100644 index 00000000000..8089feff430 --- /dev/null +++ b/src/plugins/hook-runner-global.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createMockPluginRegistry } from "./hooks.test-helpers.js"; + +async function importHookRunnerGlobalModule() { + return import("./hook-runner-global.js"); +} + +afterEach(async () => { + const mod = await importHookRunnerGlobalModule(); + mod.resetGlobalHookRunner(); + vi.resetModules(); +}); + +describe("hook-runner-global", () => { + it("preserves the initialized runner across module reloads", async () => { + const modA = await importHookRunnerGlobalModule(); + const registry = createMockPluginRegistry([{ hookName: "message_received", handler: vi.fn() }]); + + modA.initializeGlobalHookRunner(registry); + expect(modA.getGlobalHookRunner()?.hasHooks("message_received")).toBe(true); + + vi.resetModules(); + + const modB = await importHookRunnerGlobalModule(); + expect(modB.getGlobalHookRunner()).not.toBeNull(); + expect(modB.getGlobalHookRunner()?.hasHooks("message_received")).toBe(true); + expect(modB.getGlobalPluginRegistry()).toBe(registry); + }); + + it("clears the shared state across module reloads", async () => { + const modA = await importHookRunnerGlobalModule(); + const registry = createMockPluginRegistry([{ hookName: "message_received", handler: vi.fn() }]); + + modA.initializeGlobalHookRunner(registry); + + vi.resetModules(); + + const modB = await importHookRunnerGlobalModule(); + modB.resetGlobalHookRunner(); + expect(modB.getGlobalHookRunner()).toBeNull(); + expect(modB.getGlobalPluginRegistry()).toBeNull(); + + vi.resetModules(); + + const modC = await importHookRunnerGlobalModule(); + expect(modC.getGlobalHookRunner()).toBeNull(); + expect(modC.getGlobalPluginRegistry()).toBeNull(); + }); +}); diff --git a/src/plugins/hook-runner-global.ts b/src/plugins/hook-runner-global.ts index 609721fcb4d..b2613f3467f 100644 --- a/src/plugins/hook-runner-global.ts +++ b/src/plugins/hook-runner-global.ts @@ -12,16 +12,31 @@ import type { PluginHookGatewayContext, PluginHookGatewayStopEvent } from "./typ const log = createSubsystemLogger("plugins"); -let globalHookRunner: HookRunner | null = null; -let globalRegistry: PluginRegistry | null = null; +type HookRunnerGlobalState = { + hookRunner: HookRunner | null; + registry: PluginRegistry | null; +}; + +const hookRunnerGlobalStateKey = Symbol.for("openclaw.plugins.hook-runner-global-state"); + +function getHookRunnerGlobalState(): HookRunnerGlobalState { + const globalStore = globalThis as typeof globalThis & { + [hookRunnerGlobalStateKey]?: HookRunnerGlobalState; + }; + return (globalStore[hookRunnerGlobalStateKey] ??= { + hookRunner: null, + registry: null, + }); +} /** * Initialize the global hook runner with a plugin registry. * Called once when plugins are loaded during gateway startup. */ export function initializeGlobalHookRunner(registry: PluginRegistry): void { - globalRegistry = registry; - globalHookRunner = createHookRunner(registry, { + const state = getHookRunnerGlobalState(); + state.registry = registry; + state.hookRunner = createHookRunner(registry, { logger: { debug: (msg) => log.debug(msg), warn: (msg) => log.warn(msg), @@ -41,7 +56,7 @@ export function initializeGlobalHookRunner(registry: PluginRegistry): void { * Returns null if plugins haven't been loaded yet. */ export function getGlobalHookRunner(): HookRunner | null { - return globalHookRunner; + return getHookRunnerGlobalState().hookRunner; } /** @@ -49,14 +64,14 @@ export function getGlobalHookRunner(): HookRunner | null { * Returns null if plugins haven't been loaded yet. */ export function getGlobalPluginRegistry(): PluginRegistry | null { - return globalRegistry; + return getHookRunnerGlobalState().registry; } /** * Check if any hooks are registered for a given hook name. */ export function hasGlobalHooks(hookName: Parameters[0]): boolean { - return globalHookRunner?.hasHooks(hookName) ?? false; + return getHookRunnerGlobalState().hookRunner?.hasHooks(hookName) ?? false; } export async function runGlobalGatewayStopSafely(params: { @@ -83,6 +98,7 @@ export async function runGlobalGatewayStopSafely(params: { * Reset the global hook runner (for testing). */ export function resetGlobalHookRunner(): void { - globalHookRunner = null; - globalRegistry = null; + const state = getHookRunnerGlobalState(); + state.hookRunner = null; + state.registry = null; } From 7b88249c9e03b9a7eeaa45630c1867ca78f0b885 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 9 Mar 2026 11:21:19 -0700 Subject: [PATCH 0002/1173] fix(telegram): bridge direct delivery to internal message:sent hooks (#40185) * telegram: bridge direct delivery message hooks * telegram: align sent hooks with command session --- src/telegram/bot-message-dispatch.ts | 3 + src/telegram/bot-native-commands.ts | 28 +++++-- src/telegram/bot/delivery.replies.ts | 120 ++++++++++++++++++++------- src/telegram/bot/delivery.test.ts | 90 ++++++++++++++++++++ 4 files changed, 203 insertions(+), 38 deletions(-) diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 63e7b6e8e8f..d4c2f7107b6 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -433,6 +433,9 @@ export const dispatchTelegramMessage = async ({ const deliveryBaseOptions = { chatId: String(chatId), accountId: route.accountId, + sessionKeyForInternalHooks: ctxPayload.SessionKey, + mirrorIsGroup: isGroup, + mirrorGroupId: isGroup ? String(chatId) : undefined, token: opts.token, runtime, bot, diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index cb29f258f10..17958daa289 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -516,6 +516,9 @@ export const registerTelegramNativeCommands = ({ const buildCommandDeliveryBaseOptions = (params: { chatId: string | number; accountId: string; + sessionKeyForInternalHooks?: string; + mirrorIsGroup?: boolean; + mirrorGroupId?: string; mediaLocalRoots?: readonly string[]; threadSpec: ReturnType; tableMode: ReturnType; @@ -523,6 +526,9 @@ export const registerTelegramNativeCommands = ({ }) => ({ chatId: String(params.chatId), accountId: params.accountId, + sessionKeyForInternalHooks: params.sessionKeyForInternalHooks, + mirrorIsGroup: params.mirrorIsGroup, + mirrorGroupId: params.mirrorGroupId, token: opts.token, runtime, bot, @@ -589,14 +595,6 @@ export const registerTelegramNativeCommands = ({ return; } const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext; - const deliveryBaseOptions = buildCommandDeliveryBaseOptions({ - chatId, - accountId: route.accountId, - mediaLocalRoots, - threadSpec, - tableMode, - chunkMode, - }); const threadParams = buildTelegramThreadParams(threadSpec) ?? {}; const commandDefinition = findCommandByNativeName(command.name, "telegram"); @@ -671,6 +669,17 @@ export const registerTelegramNativeCommands = ({ userId: String(senderId || chatId), targetSessionKey: sessionKey, }); + const deliveryBaseOptions = buildCommandDeliveryBaseOptions({ + chatId, + accountId: route.accountId, + sessionKeyForInternalHooks: commandSessionKey, + mirrorIsGroup: isGroup, + mirrorGroupId: isGroup ? String(chatId) : undefined, + mediaLocalRoots, + threadSpec, + tableMode, + chunkMode, + }); const conversationLabel = isGroup ? msg.chat.title ? `${msg.chat.title} id:${chatId}` @@ -827,6 +836,9 @@ export const registerTelegramNativeCommands = ({ const deliveryBaseOptions = buildCommandDeliveryBaseOptions({ chatId, accountId: route.accountId, + sessionKeyForInternalHooks: route.sessionKey, + mirrorIsGroup: isGroup, + mirrorGroupId: isGroup ? String(chatId) : undefined, mediaLocalRoots, threadSpec, tableMode, diff --git a/src/telegram/bot/delivery.replies.ts b/src/telegram/bot/delivery.replies.ts index e4ec4e86279..5f5edd3b837 100644 --- a/src/telegram/bot/delivery.replies.ts +++ b/src/telegram/bot/delivery.replies.ts @@ -4,6 +4,14 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyToMode } from "../../config/config.js"; import type { MarkdownTableMode } from "../../config/types.base.js"; import { danger, logVerbose } from "../../globals.js"; +import { fireAndForgetHook } from "../../hooks/fire-and-forget.js"; +import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; +import { + buildCanonicalSentMessageHookContext, + toInternalMessageSentContext, + toPluginMessageContext, + toPluginMessageSentEvent, +} from "../../hooks/message-hook-mappers.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { buildOutboundMediaLoadOptions } from "../../media/load-options.js"; import { isGifMedia, kindFromMime } from "../../media/mime.js"; @@ -493,10 +501,68 @@ async function maybePinFirstDeliveredMessage(params: { } } +function emitMessageSentHooks(params: { + hookRunner: ReturnType; + enabled: boolean; + sessionKeyForInternalHooks?: string; + chatId: string; + accountId?: string; + content: string; + success: boolean; + error?: string; + messageId?: number; + isGroup?: boolean; + groupId?: string; +}): void { + if (!params.enabled && !params.sessionKeyForInternalHooks) { + return; + } + const canonical = buildCanonicalSentMessageHookContext({ + to: params.chatId, + content: params.content, + success: params.success, + error: params.error, + channelId: "telegram", + accountId: params.accountId, + conversationId: params.chatId, + messageId: typeof params.messageId === "number" ? String(params.messageId) : undefined, + isGroup: params.isGroup, + groupId: params.groupId, + }); + if (params.enabled) { + fireAndForgetHook( + Promise.resolve( + params.hookRunner!.runMessageSent( + toPluginMessageSentEvent(canonical), + toPluginMessageContext(canonical), + ), + ), + "telegram: message_sent plugin hook failed", + ); + } + if (!params.sessionKeyForInternalHooks) { + return; + } + fireAndForgetHook( + triggerInternalHook( + createInternalHookEvent( + "message", + "sent", + params.sessionKeyForInternalHooks, + toInternalMessageSentContext(canonical), + ), + ), + "telegram: message:sent internal hook failed", + ); +} + export async function deliverReplies(params: { replies: ReplyPayload[]; chatId: string; accountId?: string; + sessionKeyForInternalHooks?: string; + mirrorIsGroup?: boolean; + mirrorGroupId?: string; token: string; runtime: RuntimeEnv; bot: Bot; @@ -622,37 +688,31 @@ export async function deliverReplies(params: { firstDeliveredMessageId, }); - if (hasMessageSentHooks) { - const deliveredThisReply = progress.deliveredCount > deliveredCountBeforeReply; - void hookRunner?.runMessageSent( - { - to: params.chatId, - content: contentForSentHook, - success: deliveredThisReply, - }, - { - channelId: "telegram", - accountId: params.accountId, - conversationId: params.chatId, - }, - ); - } + emitMessageSentHooks({ + hookRunner, + enabled: hasMessageSentHooks, + sessionKeyForInternalHooks: params.sessionKeyForInternalHooks, + chatId: params.chatId, + accountId: params.accountId, + content: contentForSentHook, + success: progress.deliveredCount > deliveredCountBeforeReply, + messageId: firstDeliveredMessageId, + isGroup: params.mirrorIsGroup, + groupId: params.mirrorGroupId, + }); } catch (error) { - if (hasMessageSentHooks) { - void hookRunner?.runMessageSent( - { - to: params.chatId, - content: contentForSentHook, - success: false, - error: error instanceof Error ? error.message : String(error), - }, - { - channelId: "telegram", - accountId: params.accountId, - conversationId: params.chatId, - }, - ); - } + emitMessageSentHooks({ + hookRunner, + enabled: hasMessageSentHooks, + sessionKeyForInternalHooks: params.sessionKeyForInternalHooks, + chatId: params.chatId, + accountId: params.accountId, + content: contentForSentHook, + success: false, + error: error instanceof Error ? error.message : String(error), + isGroup: params.mirrorIsGroup, + groupId: params.mirrorGroupId, + }); throw error; } } diff --git a/src/telegram/bot/delivery.test.ts b/src/telegram/bot/delivery.test.ts index cda30ea4e31..c21e55ccf6c 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/src/telegram/bot/delivery.test.ts @@ -4,6 +4,7 @@ import type { RuntimeEnv } from "../../runtime.js"; import { deliverReplies } from "./delivery.js"; const loadWebMedia = vi.fn(); +const triggerInternalHook = vi.hoisted(() => vi.fn(async () => {})); const messageHookRunner = vi.hoisted(() => ({ hasHooks: vi.fn<(name: string) => boolean>(() => false), runMessageSending: vi.fn(), @@ -31,6 +32,16 @@ vi.mock("../../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => messageHookRunner, })); +vi.mock("../../hooks/internal-hooks.js", async () => { + const actual = await vi.importActual( + "../../hooks/internal-hooks.js", + ); + return { + ...actual, + triggerInternalHook, + }; +}); + vi.mock("grammy", () => ({ InputFile: class { constructor( @@ -108,6 +119,7 @@ function createVoiceFailureHarness(params: { describe("deliverReplies", () => { beforeEach(() => { loadWebMedia.mockClear(); + triggerInternalHook.mockReset(); messageHookRunner.hasHooks.mockReset(); messageHookRunner.hasHooks.mockReturnValue(false); messageHookRunner.runMessageSending.mockReset(); @@ -199,6 +211,84 @@ describe("deliverReplies", () => { ); }); + it("emits internal message:sent when session hook context is available", async () => { + const runtime = createRuntime(false); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: "123" } }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + sessionKeyForInternalHooks: "agent:test:telegram:123", + mirrorIsGroup: true, + mirrorGroupId: "123", + replies: [{ text: "hello" }], + runtime, + bot, + }); + + expect(triggerInternalHook).toHaveBeenCalledWith( + expect.objectContaining({ + type: "message", + action: "sent", + sessionKey: "agent:test:telegram:123", + context: expect.objectContaining({ + to: "123", + content: "hello", + success: true, + channelId: "telegram", + conversationId: "123", + messageId: "9", + isGroup: true, + groupId: "123", + }), + }), + ); + }); + + it("does not emit internal message:sent without a session key", async () => { + const runtime = createRuntime(false); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 11, chat: { id: "123" } }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + replies: [{ text: "hello" }], + runtime, + bot, + }); + + expect(triggerInternalHook).not.toHaveBeenCalled(); + }); + + it("emits internal message:sent with success=false on delivery failure", async () => { + const runtime = createRuntime(false); + const sendMessage = vi.fn().mockRejectedValue(new Error("network error")); + const bot = createBot({ sendMessage }); + + await expect( + deliverWith({ + sessionKeyForInternalHooks: "agent:test:telegram:123", + replies: [{ text: "hello" }], + runtime, + bot, + }), + ).rejects.toThrow("network error"); + + expect(triggerInternalHook).toHaveBeenCalledWith( + expect.objectContaining({ + type: "message", + action: "sent", + sessionKey: "agent:test:telegram:123", + context: expect.objectContaining({ + to: "123", + content: "hello", + success: false, + error: "network error", + channelId: "telegram", + conversationId: "123", + }), + }), + ); + }); + it("passes media metadata to message_sending hooks", async () => { messageHookRunner.hasHooks.mockImplementation((name: string) => name === "message_sending"); From d4e59a3666d810f9574392c70abb942e0c3b0dd8 Mon Sep 17 00:00:00 2001 From: Mariano Date: Mon, 9 Mar 2026 20:12:37 +0100 Subject: [PATCH 0003/1173] Cron: enforce cron-owned delivery contract (#40998) Merged via squash. Prepared head SHA: 5877389e33d5b3a518925b5793a6f6294cb3fb3d Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 2 + CONTRIBUTING.md | 3 +- docs/automation/cron-jobs.md | 1 + docs/cli/cron.md | 6 + docs/cli/doctor.md | 1 + docs/gateway/doctor.md | 20 + src/cli/cron-cli/register.ts | 2 +- src/commands/doctor-cron.test.ts | 158 ++++++ src/commands/doctor-cron.ts | 186 +++++++ src/commands/doctor.ts | 6 + src/cron/delivery.failure-notify.test.ts | 143 +++++ src/cron/delivery.test.ts | 40 ++ .../isolated-agent.delivery.test-helpers.ts | 2 + ...p-recipient-besteffortdeliver-true.test.ts | 2 + .../delivery-dispatch.double-announce.test.ts | 43 +- .../delivery-dispatch.named-agent.test.ts | 9 + src/cron/isolated-agent/delivery-dispatch.ts | 18 +- .../run.message-tool-policy.test.ts | 20 +- src/cron/isolated-agent/run.ts | 24 +- src/cron/service.delivery-plan.test.ts | 8 +- ...ce.heartbeat-ok-summary-suppressed.test.ts | 9 +- ...runs-one-shot-main-job-disables-it.test.ts | 12 +- src/cron/service/store.ts | 435 +--------------- src/cron/service/timer.ts | 42 -- src/cron/store-migration.test.ts | 78 +++ src/cron/store-migration.ts | 491 ++++++++++++++++++ src/gateway/server.cron.test.ts | 28 +- src/gateway/server.hooks.test.ts | 4 + src/gateway/server/hooks.ts | 1 + 29 files changed, 1277 insertions(+), 517 deletions(-) create mode 100644 src/commands/doctor-cron.test.ts create mode 100644 src/commands/doctor-cron.ts create mode 100644 src/cron/delivery.failure-notify.test.ts create mode 100644 src/cron/store-migration.test.ts create mode 100644 src/cron/store-migration.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e51ea3a0a1..4be8bad0eaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Docs: https://docs.openclaw.ai ### Breaking +- Cron/doctor: tighten isolated cron delivery so cron jobs can no longer notify through ad hoc agent sends or fallback main-session summaries, and add `openclaw doctor --fix` migration for legacy cron storage and legacy notify/webhook delivery metadata. (#40998) Thanks @mbelinky. + ### Fixes - macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d02d1f2059..1127d7dc791 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,6 @@ Welcome to the lobster tank! 🦞 - **Jonathan Taylor** - ACP subsystem, Gateway features/bugs, Gog/Mog/Sog CLI's, SEDMAT - GitHub [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik) - - **Josh Lehman** - Compaction, Tlon/Urbit subsystem - GitHub [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_) @@ -73,7 +72,7 @@ Welcome to the lobster tank! 🦞 - **Robin Waslander** - Security, PR triage, bug fixes - GitHub: [@hydro13](https://github.com/hydro13) · X: [@Robin_waslander](https://x.com/Robin_waslander) - + ## How to Contribute 1. **Bugs & small fixes** → Open a PR! diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 47bae78b86f..a0b5e505476 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -29,6 +29,7 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting) - Wakeups are first-class: a job can request “wake now” vs “next heartbeat”. - Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = ""`. - Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode. +- For upgrades, `openclaw doctor --fix` can normalize legacy cron store fields before the scheduler touches them. ## Quick start (actionable) diff --git a/docs/cli/cron.md b/docs/cli/cron.md index 28e61e20c99..6ee25859749 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -30,6 +30,12 @@ Note: retention/pruning is controlled in config: - `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions. - `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/.jsonl`. +Upgrade note: if you have older cron jobs from before the current delivery/store format, run +`openclaw doctor --fix`. Doctor now normalizes legacy cron fields (`jobId`, `schedule.cron`, +top-level delivery fields, payload `provider` delivery aliases) and migrates simple +`notify: true` webhook fallback jobs to explicit webhook delivery when `cron.webhook` is +configured. + ## Common edits Update delivery settings without changing the message: diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index d53d86452f3..90e5fa7d7a2 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -28,6 +28,7 @@ Notes: - Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts. - `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal. - State integrity checks now detect orphan transcript files in the sessions directory and can archive them as `.deleted.` to reclaim space safely. +- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime. - Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing. - If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`). diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 2550406f4ff..b46b90520d1 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -65,6 +65,7 @@ cat ~/.openclaw/openclaw.json - Config normalization for legacy values. - OpenCode Zen provider override warnings (`models.providers.opencode`). - Legacy on-disk state migration (sessions/agent dir/WhatsApp auth). +- Legacy cron store migration (`jobId`, `schedule.cron`, top-level delivery/payload fields, payload `provider`, simple `notify: true` webhook fallback jobs). - State integrity and permissions checks (sessions, transcripts, state dir). - Config file permission checks (chmod 600) when running locally. - Model auth health: checks OAuth expiry, can refresh expiring tokens, and reports auth-profile cooldown/disabled states. @@ -158,6 +159,25 @@ the legacy sessions + agent dir on startup so history/auth/models land in the per-agent path without a manual doctor run. WhatsApp auth is intentionally only migrated via `openclaw doctor`. +### 3b) Legacy cron store migrations + +Doctor also checks the cron job store (`~/.openclaw/cron/jobs.json` by default, +or `cron.store` when overridden) for old job shapes that the scheduler still +accepts for compatibility. + +Current cron cleanups include: + +- `jobId` → `id` +- `schedule.cron` → `schedule.expr` +- top-level payload fields (`message`, `model`, `thinking`, ...) → `payload` +- top-level delivery fields (`deliver`, `channel`, `to`, `provider`, ...) → `delivery` +- payload `provider` delivery aliases → explicit `delivery.channel` +- simple legacy `notify: true` webhook fallback jobs → explicit `delivery.mode="webhook"` with `delivery.to=cron.webhook` + +Doctor only auto-migrates `notify: true` jobs when it can do so without +changing behavior. If a job combines legacy notify fallback with an existing +non-webhook delivery mode, doctor warns and leaves that job for manual review. + ### 4) State integrity checks (session persistence, routing, and safety) The state directory is the operational brainstem. If it vanishes, you lose diff --git a/src/cli/cron-cli/register.ts b/src/cli/cron-cli/register.ts index a796583fa21..35f80dbda06 100644 --- a/src/cli/cron-cli/register.ts +++ b/src/cli/cron-cli/register.ts @@ -16,7 +16,7 @@ export function registerCronCli(program: Command) { .addHelpText( "after", () => - `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/cron", "docs.openclaw.ai/cli/cron")}\n`, + `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/cron", "docs.openclaw.ai/cli/cron")}\n${theme.muted("Upgrade tip:")} run \`openclaw doctor --fix\` to normalize legacy cron job storage.\n`, ); registerCronStatusCommand(cron); diff --git a/src/commands/doctor-cron.test.ts b/src/commands/doctor-cron.test.ts new file mode 100644 index 00000000000..8c9faf0e24d --- /dev/null +++ b/src/commands/doctor-cron.test.ts @@ -0,0 +1,158 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import * as noteModule from "../terminal/note.js"; +import { maybeRepairLegacyCronStore } from "./doctor-cron.js"; + +let tempRoot: string | null = null; + +async function makeTempStorePath() { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-cron-")); + return path.join(tempRoot, "cron", "jobs.json"); +} + +afterEach(async () => { + vi.restoreAllMocks(); + if (tempRoot) { + await fs.rm(tempRoot, { recursive: true, force: true }); + tempRoot = null; + } +}); + +function makePrompter(confirmResult = true) { + return { + confirm: vi.fn().mockResolvedValue(confirmResult), + }; +} + +describe("maybeRepairLegacyCronStore", () => { + it("repairs legacy cron store fields and migrates notify fallback to webhook delivery", async () => { + const storePath = await makeTempStorePath(); + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile( + storePath, + JSON.stringify( + { + version: 1, + jobs: [ + { + jobId: "legacy-job", + name: "Legacy job", + notify: true, + createdAtMs: Date.parse("2026-02-01T00:00:00.000Z"), + updatedAtMs: Date.parse("2026-02-02T00:00:00.000Z"), + schedule: { kind: "cron", cron: "0 7 * * *", tz: "UTC" }, + payload: { + kind: "systemEvent", + text: "Morning brief", + }, + state: {}, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + const cfg: OpenClawConfig = { + cron: { + store: storePath, + webhook: "https://example.invalid/cron-finished", + }, + }; + + await maybeRepairLegacyCronStore({ + cfg, + options: {}, + prompter: makePrompter(true), + }); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { + jobs: Array>; + }; + const [job] = persisted.jobs; + expect(job?.jobId).toBeUndefined(); + expect(job?.id).toBe("legacy-job"); + expect(job?.notify).toBeUndefined(); + expect(job?.schedule).toMatchObject({ + kind: "cron", + expr: "0 7 * * *", + tz: "UTC", + }); + expect(job?.delivery).toMatchObject({ + mode: "webhook", + to: "https://example.invalid/cron-finished", + }); + expect(job?.payload).toMatchObject({ + kind: "systemEvent", + text: "Morning brief", + }); + + expect(noteSpy).toHaveBeenCalledWith( + expect.stringContaining("Legacy cron job storage detected"), + "Cron", + ); + expect(noteSpy).toHaveBeenCalledWith( + expect.stringContaining("Cron store normalized"), + "Doctor changes", + ); + }); + + it("warns instead of replacing announce delivery for notify fallback jobs", async () => { + const storePath = await makeTempStorePath(); + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile( + storePath, + JSON.stringify( + { + version: 1, + jobs: [ + { + id: "notify-and-announce", + name: "Notify and announce", + notify: true, + createdAtMs: Date.parse("2026-02-01T00:00:00.000Z"), + updatedAtMs: Date.parse("2026-02-02T00:00:00.000Z"), + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "Status" }, + delivery: { mode: "announce", channel: "telegram", to: "123" }, + state: {}, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + + await maybeRepairLegacyCronStore({ + cfg: { + cron: { + store: storePath, + webhook: "https://example.invalid/cron-finished", + }, + }, + options: { nonInteractive: true }, + prompter: makePrompter(true), + }); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { + jobs: Array>; + }; + expect(persisted.jobs[0]?.notify).toBe(true); + expect(noteSpy).toHaveBeenCalledWith( + expect.stringContaining('uses legacy notify fallback alongside delivery mode "announce"'), + "Doctor warnings", + ); + }); +}); diff --git a/src/commands/doctor-cron.ts b/src/commands/doctor-cron.ts new file mode 100644 index 00000000000..3dc6275e800 --- /dev/null +++ b/src/commands/doctor-cron.ts @@ -0,0 +1,186 @@ +import { formatCliCommand } from "../cli/command-format.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { normalizeStoredCronJobs } from "../cron/store-migration.js"; +import { resolveCronStorePath, loadCronStore, saveCronStore } from "../cron/store.js"; +import type { CronJob } from "../cron/types.js"; +import { note } from "../terminal/note.js"; +import { shortenHomePath } from "../utils.js"; +import type { DoctorPrompter, DoctorOptions } from "./doctor-prompter.js"; + +type CronDoctorOutcome = { + changed: boolean; + warnings: string[]; +}; + +function pluralize(count: number, noun: string) { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +function formatLegacyIssuePreview(issues: Partial>): string[] { + const lines: string[] = []; + if (issues.jobId) { + lines.push(`- ${pluralize(issues.jobId, "job")} still uses legacy \`jobId\``); + } + if (issues.legacyScheduleString) { + lines.push( + `- ${pluralize(issues.legacyScheduleString, "job")} stores schedule as a bare string`, + ); + } + if (issues.legacyScheduleCron) { + lines.push(`- ${pluralize(issues.legacyScheduleCron, "job")} still uses \`schedule.cron\``); + } + if (issues.legacyPayloadKind) { + lines.push(`- ${pluralize(issues.legacyPayloadKind, "job")} needs payload kind normalization`); + } + if (issues.legacyPayloadProvider) { + lines.push( + `- ${pluralize(issues.legacyPayloadProvider, "job")} still uses payload \`provider\` as a delivery alias`, + ); + } + if (issues.legacyTopLevelPayloadFields) { + lines.push( + `- ${pluralize(issues.legacyTopLevelPayloadFields, "job")} still uses top-level payload fields`, + ); + } + if (issues.legacyTopLevelDeliveryFields) { + lines.push( + `- ${pluralize(issues.legacyTopLevelDeliveryFields, "job")} still uses top-level delivery fields`, + ); + } + if (issues.legacyDeliveryMode) { + lines.push( + `- ${pluralize(issues.legacyDeliveryMode, "job")} still uses delivery mode \`deliver\``, + ); + } + return lines; +} + +function trimString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function migrateLegacyNotifyFallback(params: { + jobs: Array>; + legacyWebhook?: string; +}): CronDoctorOutcome { + let changed = false; + const warnings: string[] = []; + + for (const raw of params.jobs) { + if (!("notify" in raw)) { + continue; + } + + const jobName = trimString(raw.name) ?? trimString(raw.id) ?? ""; + const notify = raw.notify === true; + if (!notify) { + delete raw.notify; + changed = true; + continue; + } + + const delivery = + raw.delivery && typeof raw.delivery === "object" && !Array.isArray(raw.delivery) + ? (raw.delivery as Record) + : null; + const mode = trimString(delivery?.mode)?.toLowerCase(); + const to = trimString(delivery?.to); + + if (mode === "webhook" && to) { + delete raw.notify; + changed = true; + continue; + } + + if ((mode === undefined || mode === "none" || mode === "webhook") && params.legacyWebhook) { + raw.delivery = { + ...delivery, + mode: "webhook", + to: to ?? params.legacyWebhook, + }; + delete raw.notify; + changed = true; + continue; + } + + if (!params.legacyWebhook) { + warnings.push( + `Cron job "${jobName}" still uses legacy notify fallback, but cron.webhook is unset so doctor cannot migrate it automatically.`, + ); + continue; + } + + warnings.push( + `Cron job "${jobName}" uses legacy notify fallback alongside delivery mode "${mode}". Migrate it manually so webhook delivery does not replace existing announce behavior.`, + ); + } + + return { changed, warnings }; +} + +export async function maybeRepairLegacyCronStore(params: { + cfg: OpenClawConfig; + options: DoctorOptions; + prompter: Pick; +}) { + const storePath = resolveCronStorePath(params.cfg.cron?.store); + const store = await loadCronStore(storePath); + const rawJobs = (store.jobs ?? []) as unknown as Array>; + if (rawJobs.length === 0) { + return; + } + + const normalized = normalizeStoredCronJobs(rawJobs); + const legacyWebhook = trimString(params.cfg.cron?.webhook); + const notifyCount = rawJobs.filter((job) => job.notify === true).length; + const previewLines = formatLegacyIssuePreview(normalized.issues); + if (notifyCount > 0) { + previewLines.push( + `- ${pluralize(notifyCount, "job")} still uses legacy \`notify: true\` webhook fallback`, + ); + } + if (previewLines.length === 0) { + return; + } + + note( + [ + `Legacy cron job storage detected at ${shortenHomePath(storePath)}.`, + ...previewLines, + `Repair with ${formatCliCommand("openclaw doctor --fix")} to normalize the store before the next scheduler run.`, + ].join("\n"), + "Cron", + ); + + const shouldRepair = + params.options.nonInteractive === true + ? true + : await params.prompter.confirm({ + message: "Repair legacy cron jobs now?", + initialValue: true, + }); + if (!shouldRepair) { + return; + } + + const notifyMigration = migrateLegacyNotifyFallback({ + jobs: rawJobs, + legacyWebhook, + }); + const changed = normalized.mutated || notifyMigration.changed; + if (!changed && notifyMigration.warnings.length === 0) { + return; + } + + if (changed) { + await saveCronStore(storePath, { + version: 1, + jobs: rawJobs as unknown as CronJob[], + }); + note(`Cron store normalized at ${shortenHomePath(storePath)}.`, "Doctor changes"); + } + + if (notifyMigration.warnings.length > 0) { + note(notifyMigration.warnings.join("\n"), "Doctor warnings"); + } +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 2688774b8bb..bdde2781ff9 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -31,6 +31,7 @@ import { import { noteBootstrapFileSize } from "./doctor-bootstrap-size.js"; import { doctorShellCompletion } from "./doctor-completion.js"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; +import { maybeRepairLegacyCronStore } from "./doctor-cron.js"; import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js"; import { checkGatewayHealth, probeGatewayMemoryStatus } from "./doctor-gateway-health.js"; import { @@ -220,6 +221,11 @@ export async function doctorCommand( await noteStateIntegrity(cfg, prompter, configResult.path ?? CONFIG_PATH); await noteSessionLockHealth({ shouldRepair: prompter.shouldRepair }); + await maybeRepairLegacyCronStore({ + cfg, + options, + prompter, + }); cfg = await maybeRepairSandboxImages(cfg, runtime, prompter); noteSandboxScopeWarnings(cfg); diff --git a/src/cron/delivery.failure-notify.test.ts b/src/cron/delivery.failure-notify.test.ts new file mode 100644 index 00000000000..98cb437c961 --- /dev/null +++ b/src/cron/delivery.failure-notify.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + resolveDeliveryTarget: vi.fn(), + deliverOutboundPayloads: vi.fn(), + resolveAgentOutboundIdentity: vi.fn().mockReturnValue({ kind: "identity" }), + buildOutboundSessionContext: vi.fn().mockReturnValue({ kind: "session" }), + createOutboundSendDeps: vi.fn().mockReturnValue({ kind: "deps" }), + warn: vi.fn(), +})); + +vi.mock("./isolated-agent/delivery-target.js", () => ({ + resolveDeliveryTarget: mocks.resolveDeliveryTarget, +})); + +vi.mock("../infra/outbound/deliver.js", () => ({ + deliverOutboundPayloads: mocks.deliverOutboundPayloads, +})); + +vi.mock("../infra/outbound/identity.js", () => ({ + resolveAgentOutboundIdentity: mocks.resolveAgentOutboundIdentity, +})); + +vi.mock("../infra/outbound/session-context.js", () => ({ + buildOutboundSessionContext: mocks.buildOutboundSessionContext, +})); + +vi.mock("../cli/outbound-send-deps.js", () => ({ + createOutboundSendDeps: mocks.createOutboundSendDeps, +})); + +vi.mock("../logging.js", () => ({ + getChildLogger: vi.fn(() => ({ + warn: mocks.warn, + })), +})); + +const { sendFailureNotificationAnnounce } = await import("./delivery.js"); + +describe("sendFailureNotificationAnnounce", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveDeliveryTarget.mockResolvedValue({ + ok: true, + channel: "telegram", + to: "123", + accountId: "bot-a", + threadId: 42, + mode: "explicit", + }); + mocks.deliverOutboundPayloads.mockResolvedValue([{ ok: true }]); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("delivers failure alerts to the resolved explicit target with strict send settings", async () => { + const deps = {} as never; + const cfg = {} as never; + + await sendFailureNotificationAnnounce( + deps, + cfg, + "main", + "job-1", + { channel: "telegram", to: "123", accountId: "bot-a" }, + "Cron failed", + ); + + expect(mocks.resolveDeliveryTarget).toHaveBeenCalledWith(cfg, "main", { + channel: "telegram", + to: "123", + accountId: "bot-a", + }); + expect(mocks.buildOutboundSessionContext).toHaveBeenCalledWith({ + cfg, + agentId: "main", + sessionKey: "cron:job-1:failure", + }); + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + cfg, + channel: "telegram", + to: "123", + accountId: "bot-a", + threadId: 42, + payloads: [{ text: "Cron failed" }], + session: { kind: "session" }, + identity: { kind: "identity" }, + bestEffort: false, + deps: { kind: "deps" }, + abortSignal: expect.any(AbortSignal), + }), + ); + }); + + it("does not send when target resolution fails", async () => { + mocks.resolveDeliveryTarget.mockResolvedValue({ + ok: false, + error: new Error("target missing"), + }); + + await sendFailureNotificationAnnounce( + {} as never, + {} as never, + "main", + "job-1", + { channel: "telegram", to: "123" }, + "Cron failed", + ); + + expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled(); + expect(mocks.warn).toHaveBeenCalledWith( + { error: "target missing" }, + "cron: failed to resolve failure destination target", + ); + }); + + it("swallows outbound delivery errors after logging", async () => { + mocks.deliverOutboundPayloads.mockRejectedValue(new Error("send failed")); + + await expect( + sendFailureNotificationAnnounce( + {} as never, + {} as never, + "main", + "job-1", + { channel: "telegram", to: "123" }, + "Cron failed", + ), + ).resolves.toBeUndefined(); + + expect(mocks.warn).toHaveBeenCalledWith( + expect.objectContaining({ + err: "send failed", + channel: "telegram", + to: "123", + }), + "cron: failure destination announce failed", + ); + }); +}); diff --git a/src/cron/delivery.test.ts b/src/cron/delivery.test.ts index 81ab672af57..43eaa215114 100644 --- a/src/cron/delivery.test.ts +++ b/src/cron/delivery.test.ts @@ -148,6 +148,46 @@ describe("resolveFailureDestination", () => { expect(plan).toBeNull(); }); + it("returns null when webhook failure destination matches the primary webhook target", () => { + const plan = resolveFailureDestination( + makeJob({ + sessionTarget: "main", + payload: { kind: "systemEvent", text: "tick" }, + delivery: { + mode: "webhook", + to: "https://example.invalid/cron", + failureDestination: { + mode: "webhook", + to: "https://example.invalid/cron", + }, + }, + }), + undefined, + ); + expect(plan).toBeNull(); + }); + + it("does not reuse inherited announce recipient when switching failure destination to webhook", () => { + const plan = resolveFailureDestination( + makeJob({ + delivery: { + mode: "announce", + channel: "telegram", + to: "111", + failureDestination: { + mode: "webhook", + }, + }, + }), + { + channel: "signal", + to: "group-abc", + mode: "announce", + }, + ); + expect(plan).toBeNull(); + }); + it("allows job-level failure destination fields to clear inherited global values", () => { const plan = resolveFailureDestination( makeJob({ diff --git a/src/cron/isolated-agent.delivery.test-helpers.ts b/src/cron/isolated-agent.delivery.test-helpers.ts index fe6dad727f4..de4caee3a3c 100644 --- a/src/cron/isolated-agent.delivery.test-helpers.ts +++ b/src/cron/isolated-agent.delivery.test-helpers.ts @@ -54,6 +54,7 @@ export async function runTelegramAnnounceTurn(params: { to?: string; bestEffort?: boolean; }; + deliveryContract?: "cron-owned" | "shared"; }): Promise>> { return runCronIsolatedAgentTurn({ cfg: makeCfg(params.home, params.storePath, { @@ -67,5 +68,6 @@ export async function runTelegramAnnounceTurn(params: { message: "do it", sessionKey: "cron:job-1", lane: "cron", + deliveryContract: params.deliveryContract, }); } diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index 6b2ab85739a..52a3c1328f9 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -23,6 +23,7 @@ async function runExplicitTelegramAnnounceTurn(params: { home: string; storePath: string; deps: CliDeps; + deliveryContract?: "cron-owned" | "shared"; }): Promise>> { return runTelegramAnnounceTurn({ ...params, @@ -301,6 +302,7 @@ describe("runCronIsolatedAgentTurn", () => { home, storePath, deps, + deliveryContract: "shared", }); expectDeliveredOk(res); diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index f9a7d90a276..9da88bbb4a3 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -10,7 +10,7 @@ * returning so the timer correctly skips the system-event fallback. */ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // --- Module mocks (must be hoisted before imports) --- @@ -105,7 +105,6 @@ function makeBaseParams(overrides: { synthesizedText?: string; deliveryRequested resolvedDelivery, deliveryRequested: overrides.deliveryRequested ?? true, skipHeartbeatDelivery: false, - skipMessagingToolDelivery: false, deliveryBestEffort: false, deliveryPayloadHasStructuredContent: false, deliveryPayloads: overrides.synthesizedText ? [{ text: overrides.synthesizedText }] : [], @@ -134,6 +133,10 @@ describe("dispatchCronDelivery — double-announce guard", () => { vi.mocked(waitForDescendantSubagentSummary).mockResolvedValue(undefined); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("early return (active subagent) sets deliveryAttempted=true so timer skips enqueueSystemEvent", async () => { // countActiveDescendantRuns returns >0 → enters wait block; still >0 after wait → early return vi.mocked(countActiveDescendantRuns).mockReturnValue(2); @@ -255,6 +258,42 @@ describe("dispatchCronDelivery — double-announce guard", () => { expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1); }); + it("retries transient direct announce failures before succeeding", async () => { + vi.stubEnv("OPENCLAW_TEST_FAST", "1"); + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + vi.mocked(deliverOutboundPayloads) + .mockRejectedValueOnce(new Error("ECONNRESET while sending")) + .mockResolvedValueOnce([{ ok: true } as never]); + + const params = makeBaseParams({ synthesizedText: "Retry me once." }); + const state = await dispatchCronDelivery(params); + + expect(state.result).toBeUndefined(); + expect(state.deliveryAttempted).toBe(true); + expect(state.delivered).toBe(true); + expect(deliverOutboundPayloads).toHaveBeenCalledTimes(2); + }); + + it("does not retry permanent direct announce failures", async () => { + vi.stubEnv("OPENCLAW_TEST_FAST", "1"); + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + vi.mocked(deliverOutboundPayloads).mockRejectedValue(new Error("chat not found")); + + const params = makeBaseParams({ synthesizedText: "This should fail once." }); + const state = await dispatchCronDelivery(params); + + expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1); + expect(state.result).toEqual( + expect.objectContaining({ + status: "error", + error: "Error: chat not found", + deliveryAttempted: true, + }), + ); + }); + it("no delivery requested means deliveryAttempted stays false and no delivery is sent", async () => { const params = makeBaseParams({ synthesizedText: "Task done.", diff --git a/src/cron/isolated-agent/delivery-dispatch.named-agent.test.ts b/src/cron/isolated-agent/delivery-dispatch.named-agent.test.ts index 6de82039241..c5d7ec9b41c 100644 --- a/src/cron/isolated-agent/delivery-dispatch.named-agent.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.named-agent.test.ts @@ -96,4 +96,13 @@ describe("resolveCronDeliveryBestEffort", () => { } as never; expect(resolveCronDeliveryBestEffort(job)).toBe(true); }); + + it("lets explicit delivery.bestEffort=false override legacy payload bestEffortDeliver=true", async () => { + const { resolveCronDeliveryBestEffort } = await import("./delivery-dispatch.js"); + const job = { + delivery: { bestEffort: false }, + payload: { kind: "agentTurn", bestEffortDeliver: true }, + } as never; + expect(resolveCronDeliveryBestEffort(job)).toBe(false); + }); }); diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index a3a98b245d0..fa9a295a777 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -83,7 +83,7 @@ type DispatchCronDeliveryParams = { resolvedDelivery: DeliveryTargetResolution; deliveryRequested: boolean; skipHeartbeatDelivery: boolean; - skipMessagingToolDelivery: boolean; + skipMessagingToolDelivery?: boolean; deliveryBestEffort: boolean; deliveryPayloadHasStructuredContent: boolean; deliveryPayloads: ReplyPayload[]; @@ -192,15 +192,17 @@ async function retryTransientDirectCronDelivery(params: { export async function dispatchCronDelivery( params: DispatchCronDeliveryParams, ): Promise { + const skipMessagingToolDelivery = params.skipMessagingToolDelivery === true; let summary = params.summary; let outputText = params.outputText; let synthesizedText = params.synthesizedText; let deliveryPayloads = params.deliveryPayloads; - // `true` means we confirmed at least one outbound send reached the target. - // Keep this strict so timer fallback can safely decide whether to wake main. - let delivered = params.skipMessagingToolDelivery; - let deliveryAttempted = params.skipMessagingToolDelivery; + // Shared callers can treat a matching message-tool send as the completed + // delivery path. Cron-owned callers keep this false so direct cron delivery + // remains the only source of delivered state. + let delivered = skipMessagingToolDelivery; + let deliveryAttempted = skipMessagingToolDelivery; const failDeliveryTarget = (error: string) => params.withRunSession({ status: "error", @@ -404,11 +406,7 @@ export async function dispatchCronDelivery( } }; - if ( - params.deliveryRequested && - !params.skipHeartbeatDelivery && - !params.skipMessagingToolDelivery - ) { + if (params.deliveryRequested && !params.skipHeartbeatDelivery && !skipMessagingToolDelivery) { if (!params.resolvedDelivery.ok) { if (!params.deliveryBestEffort) { return { diff --git a/src/cron/isolated-agent/run.message-tool-policy.test.ts b/src/cron/isolated-agent/run.message-tool-policy.test.ts index 360f0794616..2d576900b9d 100644 --- a/src/cron/isolated-agent/run.message-tool-policy.test.ts +++ b/src/cron/isolated-agent/run.message-tool-policy.test.ts @@ -55,7 +55,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { restoreFastTestEnv(previousFastTestEnv); }); - it('keeps the message tool enabled when delivery.mode is "none"', async () => { + it('disables the message tool when delivery.mode is "none"', async () => { mockFallbackPassthrough(); resolveCronDeliveryPlanMock.mockReturnValue({ requested: false, @@ -65,7 +65,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { await runCronIsolatedAgentTurn(makeParams()); expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.disableMessageTool).toBe(false); + expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.disableMessageTool).toBe(true); }); it("disables the message tool when cron delivery is active", async () => { @@ -82,4 +82,20 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.disableMessageTool).toBe(true); }); + + it("keeps the message tool enabled for shared callers when delivery is not requested", async () => { + mockFallbackPassthrough(); + resolveCronDeliveryPlanMock.mockReturnValue({ + requested: false, + mode: "none", + }); + + await runCronIsolatedAgentTurn({ + ...makeParams(), + deliveryContract: "shared", + }); + + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.disableMessageTool).toBe(false); + }); }); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 813b99c0553..5b665b6bf8f 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -78,11 +78,10 @@ export type RunCronAgentTurnResult = { /** Last non-empty agent text output (not truncated). */ outputText?: string; /** - * `true` when the isolated run already delivered its output to the target - * channel (via outbound payloads, the subagent announce flow, or a matching - * messaging-tool send). Callers should skip posting a summary to the main - * session to avoid duplicate - * messages. See: https://github.com/openclaw/openclaw/issues/15692 + * `true` when the isolated runner already handled the run's user-visible + * delivery outcome. Cron-owned callers use this for cron delivery or + * explicit suppression; shared callers may also use it for a matching + * message-tool send that already reached the target. */ delivered?: boolean; /** @@ -144,16 +143,22 @@ function buildCronAgentDefaultsConfig(params: { type ResolvedCronDeliveryTarget = Awaited>; +type IsolatedDeliveryContract = "cron-owned" | "shared"; + function resolveCronToolPolicy(params: { deliveryRequested: boolean; resolvedDelivery: ResolvedCronDeliveryTarget; + deliveryContract: IsolatedDeliveryContract; }) { return { // Only enforce an explicit message target when the cron delivery target // was successfully resolved. When resolution fails the agent should not // be blocked by a target it cannot satisfy (#27898). requireExplicitMessageTarget: params.deliveryRequested && params.resolvedDelivery.ok, - disableMessageTool: params.deliveryRequested, + // Cron-owned runs always route user-facing delivery through the runner + // itself. Shared callers keep the previous behavior so non-cron paths do + // not silently lose the message tool when no explicit delivery is active. + disableMessageTool: params.deliveryContract === "cron-owned" ? true : params.deliveryRequested, }; } @@ -161,6 +166,7 @@ async function resolveCronDeliveryContext(params: { cfg: OpenClawConfig; job: CronJob; agentId: string; + deliveryContract: IsolatedDeliveryContract; }) { const deliveryPlan = resolveCronDeliveryPlan(params.job); const resolvedDelivery = await resolveDeliveryTarget(params.cfg, params.agentId, { @@ -176,6 +182,7 @@ async function resolveCronDeliveryContext(params: { toolPolicy: resolveCronToolPolicy({ deliveryRequested: deliveryPlan.requested, resolvedDelivery, + deliveryContract: params.deliveryContract, }), }; } @@ -200,6 +207,7 @@ export async function runCronIsolatedAgentTurn(params: { sessionKey: string; agentId?: string; lane?: string; + deliveryContract?: IsolatedDeliveryContract; }): Promise { const abortSignal = params.abortSignal ?? params.signal; const isAborted = () => abortSignal?.aborted === true; @@ -210,6 +218,7 @@ export async function runCronIsolatedAgentTurn(params: { : "cron: job execution timed out"; }; const isFastTestEnv = process.env.OPENCLAW_TEST_FAST === "1"; + const deliveryContract = params.deliveryContract ?? "cron-owned"; const defaultAgentId = resolveDefaultAgentId(params.cfg); const requestedAgentId = typeof params.agentId === "string" && params.agentId.trim() @@ -425,6 +434,7 @@ export async function runCronIsolatedAgentTurn(params: { cfg: cfgWithAgentDefaults, job: params.job, agentId, + deliveryContract, }); const { formattedTime, timeLine } = resolveCronStyleNow(params.cfg, now); @@ -807,6 +817,7 @@ export async function runCronIsolatedAgentTurn(params: { const ackMaxChars = resolveHeartbeatAckMaxChars(agentCfg); const skipHeartbeatDelivery = deliveryRequested && isHeartbeatOnlyResponse(payloads, ackMaxChars); const skipMessagingToolDelivery = + deliveryContract === "shared" && deliveryRequested && finalRunResult.didSendViaMessagingTool === true && (finalRunResult.messagingToolSentTargets ?? []).some((target) => @@ -816,7 +827,6 @@ export async function runCronIsolatedAgentTurn(params: { accountId: resolvedDelivery.accountId, }), ); - const deliveryResult = await dispatchCronDelivery({ cfg: params.cfg, cfgWithAgentDefaults, diff --git a/src/cron/service.delivery-plan.test.ts b/src/cron/service.delivery-plan.test.ts index 46c240e6c0f..5168d8bebc9 100644 --- a/src/cron/service.delivery-plan.test.ts +++ b/src/cron/service.delivery-plan.test.ts @@ -86,7 +86,7 @@ describe("CronService delivery plan consistency", () => { }); }); - it("treats delivery object without mode as announce", async () => { + it("treats delivery object without mode as announce without reviving legacy relay fallback", async () => { await withCronService({}, async ({ cron, enqueueSystemEvent }) => { const job = await addIsolatedAgentTurnJob(cron, { name: "partial-delivery", @@ -96,10 +96,8 @@ describe("CronService delivery plan consistency", () => { const result = await cron.run(job.id, "force"); expect(result).toEqual({ ok: true, ran: true }); - expect(enqueueSystemEvent).toHaveBeenCalledWith( - "Cron: done", - expect.objectContaining({ agentId: undefined }), - ); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(cron.getJob(job.id)?.state.lastDeliveryStatus).toBe("unknown"); }); }); diff --git a/src/cron/service.heartbeat-ok-summary-suppressed.test.ts b/src/cron/service.heartbeat-ok-summary-suppressed.test.ts index 3ae9fc7c758..d2a620e1439 100644 --- a/src/cron/service.heartbeat-ok-summary-suppressed.test.ts +++ b/src/cron/service.heartbeat-ok-summary-suppressed.test.ts @@ -86,7 +86,7 @@ describe("cron isolated job HEARTBEAT_OK summary suppression (#32013)", () => { expect(requestHeartbeatNow).not.toHaveBeenCalled(); }); - it("still enqueues real cron summaries as system events", async () => { + it("does not revive legacy main-session relay for real cron summaries", async () => { const { storePath } = await makeStorePath(); const now = Date.now(); @@ -109,10 +109,7 @@ describe("cron isolated job HEARTBEAT_OK summary suppression (#32013)", () => { await runScheduledCron(cron); - // Real summaries SHOULD be enqueued. - expect(enqueueSystemEvent).toHaveBeenCalledWith( - expect.stringContaining("Weather update"), - expect.objectContaining({ agentId: undefined }), - ); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(requestHeartbeatNow).not.toHaveBeenCalled(); }); }); diff --git a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts index deac4a5b668..555750bd738 100644 --- a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts +++ b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts @@ -620,14 +620,14 @@ describe("CronService", () => { await stopCronAndCleanup(cron, store); }); - it("runs an isolated job and posts summary to main", async () => { + it("runs an isolated job without posting a fallback summary to main", async () => { const runIsolatedAgentJob = vi.fn(async () => ({ status: "ok" as const, summary: "done" })); const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } = await createIsolatedAnnounceHarness(runIsolatedAgentJob); await runIsolatedAnnounceScenario({ cron, events, name: "weekly" }); expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); - expectMainSystemEventPosted(enqueueSystemEvent, "Cron: done"); - expect(requestHeartbeatNow).toHaveBeenCalled(); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(requestHeartbeatNow).not.toHaveBeenCalled(); await stopCronAndCleanup(cron, store); }); @@ -685,7 +685,7 @@ describe("CronService", () => { await stopCronAndCleanup(cron, store); }); - it("posts last output to main even when isolated job errors", async () => { + it("does not post a fallback main summary when an isolated job errors", async () => { const runIsolatedAgentJob = vi.fn(async () => ({ status: "error" as const, summary: "last output", @@ -700,8 +700,8 @@ describe("CronService", () => { status: "error", }); - expectMainSystemEventPosted(enqueueSystemEvent, "Cron (error): last output"); - expect(requestHeartbeatNow).toHaveBeenCalled(); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(requestHeartbeatNow).not.toHaveBeenCalled(); await stopCronAndCleanup(cron, store); }); diff --git a/src/cron/service/store.ts b/src/cron/service/store.ts index 2c40ac50643..d1d36e48e08 100644 --- a/src/cron/service/store.ts +++ b/src/cron/service/store.ts @@ -1,161 +1,10 @@ import fs from "node:fs"; -import { normalizeLegacyDeliveryInput } from "../legacy-delivery.js"; -import { parseAbsoluteTimeMs } from "../parse.js"; -import { migrateLegacyCronPayload } from "../payload-migration.js"; -import { coerceFiniteScheduleNumber } from "../schedule.js"; -import { normalizeCronStaggerMs, resolveDefaultCronStaggerMs } from "../stagger.js"; +import { normalizeStoredCronJobs } from "../store-migration.js"; import { loadCronStore, saveCronStore } from "../store.js"; import type { CronJob } from "../types.js"; import { recomputeNextRuns } from "./jobs.js"; -import { inferLegacyName, normalizeOptionalText } from "./normalize.js"; import type { CronServiceState } from "./state.js"; -function normalizePayloadKind(payload: Record) { - const raw = typeof payload.kind === "string" ? payload.kind.trim().toLowerCase() : ""; - if (raw === "agentturn") { - payload.kind = "agentTurn"; - return true; - } - if (raw === "systemevent") { - payload.kind = "systemEvent"; - return true; - } - return false; -} - -function inferPayloadIfMissing(raw: Record) { - const message = typeof raw.message === "string" ? raw.message.trim() : ""; - const text = typeof raw.text === "string" ? raw.text.trim() : ""; - const command = typeof raw.command === "string" ? raw.command.trim() : ""; - if (message) { - raw.payload = { kind: "agentTurn", message }; - return true; - } - if (text) { - raw.payload = { kind: "systemEvent", text }; - return true; - } - if (command) { - raw.payload = { kind: "systemEvent", text: command }; - return true; - } - return false; -} - -function copyTopLevelAgentTurnFields( - raw: Record, - payload: Record, -) { - let mutated = false; - - const copyTrimmedString = (field: "model" | "thinking") => { - const existing = payload[field]; - if (typeof existing === "string" && existing.trim()) { - return; - } - const value = raw[field]; - if (typeof value === "string" && value.trim()) { - payload[field] = value.trim(); - mutated = true; - } - }; - copyTrimmedString("model"); - copyTrimmedString("thinking"); - - if ( - typeof payload.timeoutSeconds !== "number" && - typeof raw.timeoutSeconds === "number" && - Number.isFinite(raw.timeoutSeconds) - ) { - payload.timeoutSeconds = Math.max(0, Math.floor(raw.timeoutSeconds)); - mutated = true; - } - - if ( - typeof payload.allowUnsafeExternalContent !== "boolean" && - typeof raw.allowUnsafeExternalContent === "boolean" - ) { - payload.allowUnsafeExternalContent = raw.allowUnsafeExternalContent; - mutated = true; - } - - if (typeof payload.deliver !== "boolean" && typeof raw.deliver === "boolean") { - payload.deliver = raw.deliver; - mutated = true; - } - if ( - typeof payload.channel !== "string" && - typeof raw.channel === "string" && - raw.channel.trim() - ) { - payload.channel = raw.channel.trim(); - mutated = true; - } - if (typeof payload.to !== "string" && typeof raw.to === "string" && raw.to.trim()) { - payload.to = raw.to.trim(); - mutated = true; - } - if ( - typeof payload.bestEffortDeliver !== "boolean" && - typeof raw.bestEffortDeliver === "boolean" - ) { - payload.bestEffortDeliver = raw.bestEffortDeliver; - mutated = true; - } - if ( - typeof payload.provider !== "string" && - typeof raw.provider === "string" && - raw.provider.trim() - ) { - payload.provider = raw.provider.trim(); - mutated = true; - } - - return mutated; -} - -function stripLegacyTopLevelFields(raw: Record) { - if ("model" in raw) { - delete raw.model; - } - if ("thinking" in raw) { - delete raw.thinking; - } - if ("timeoutSeconds" in raw) { - delete raw.timeoutSeconds; - } - if ("allowUnsafeExternalContent" in raw) { - delete raw.allowUnsafeExternalContent; - } - if ("message" in raw) { - delete raw.message; - } - if ("text" in raw) { - delete raw.text; - } - if ("deliver" in raw) { - delete raw.deliver; - } - if ("channel" in raw) { - delete raw.channel; - } - if ("to" in raw) { - delete raw.to; - } - if ("bestEffortDeliver" in raw) { - delete raw.bestEffortDeliver; - } - if ("provider" in raw) { - delete raw.provider; - } - if ("command" in raw) { - delete raw.command; - } - if ("timeout" in raw) { - delete raw.timeout; - } -} - async function getFileMtimeMs(path: string): Promise { try { const stats = await fs.promises.stat(path); @@ -185,287 +34,7 @@ export async function ensureLoaded( const fileMtimeMs = await getFileMtimeMs(state.deps.storePath); const loaded = await loadCronStore(state.deps.storePath); const jobs = (loaded.jobs ?? []) as unknown as Array>; - let mutated = false; - for (const raw of jobs) { - const state = raw.state; - if (!state || typeof state !== "object" || Array.isArray(state)) { - raw.state = {}; - mutated = true; - } - - const rawId = typeof raw.id === "string" ? raw.id.trim() : ""; - const legacyJobId = typeof raw.jobId === "string" ? raw.jobId.trim() : ""; - if (!rawId && legacyJobId) { - raw.id = legacyJobId; - mutated = true; - } else if (rawId && raw.id !== rawId) { - raw.id = rawId; - mutated = true; - } - if ("jobId" in raw) { - delete raw.jobId; - mutated = true; - } - - if (typeof raw.schedule === "string") { - const expr = raw.schedule.trim(); - raw.schedule = { kind: "cron", expr }; - mutated = true; - } - - const nameRaw = raw.name; - if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) { - raw.name = inferLegacyName({ - schedule: raw.schedule as never, - payload: raw.payload as never, - }); - mutated = true; - } else { - raw.name = nameRaw.trim(); - } - - const desc = normalizeOptionalText(raw.description); - if (raw.description !== desc) { - raw.description = desc; - mutated = true; - } - - if ("sessionKey" in raw) { - const sessionKey = - typeof raw.sessionKey === "string" ? normalizeOptionalText(raw.sessionKey) : undefined; - if (raw.sessionKey !== sessionKey) { - raw.sessionKey = sessionKey; - mutated = true; - } - } - - if (typeof raw.enabled !== "boolean") { - raw.enabled = true; - mutated = true; - } - - const wakeModeRaw = typeof raw.wakeMode === "string" ? raw.wakeMode.trim().toLowerCase() : ""; - if (wakeModeRaw === "next-heartbeat") { - if (raw.wakeMode !== "next-heartbeat") { - raw.wakeMode = "next-heartbeat"; - mutated = true; - } - } else if (wakeModeRaw === "now") { - if (raw.wakeMode !== "now") { - raw.wakeMode = "now"; - mutated = true; - } - } else { - raw.wakeMode = "now"; - mutated = true; - } - - const payload = raw.payload; - if ( - (!payload || typeof payload !== "object" || Array.isArray(payload)) && - inferPayloadIfMissing(raw) - ) { - mutated = true; - } - - const payloadRecord = - raw.payload && typeof raw.payload === "object" && !Array.isArray(raw.payload) - ? (raw.payload as Record) - : null; - - if (payloadRecord) { - if (normalizePayloadKind(payloadRecord)) { - mutated = true; - } - if (!payloadRecord.kind) { - if (typeof payloadRecord.message === "string" && payloadRecord.message.trim()) { - payloadRecord.kind = "agentTurn"; - mutated = true; - } else if (typeof payloadRecord.text === "string" && payloadRecord.text.trim()) { - payloadRecord.kind = "systemEvent"; - mutated = true; - } - } - if (payloadRecord.kind === "agentTurn") { - if (copyTopLevelAgentTurnFields(raw, payloadRecord)) { - mutated = true; - } - } - } - - const hadLegacyTopLevelFields = - "model" in raw || - "thinking" in raw || - "timeoutSeconds" in raw || - "allowUnsafeExternalContent" in raw || - "message" in raw || - "text" in raw || - "deliver" in raw || - "channel" in raw || - "to" in raw || - "bestEffortDeliver" in raw || - "provider" in raw || - "command" in raw || - "timeout" in raw; - if (hadLegacyTopLevelFields) { - stripLegacyTopLevelFields(raw); - mutated = true; - } - - if (payloadRecord) { - if (migrateLegacyCronPayload(payloadRecord)) { - mutated = true; - } - } - - const schedule = raw.schedule; - if (schedule && typeof schedule === "object" && !Array.isArray(schedule)) { - const sched = schedule as Record; - const kind = typeof sched.kind === "string" ? sched.kind.trim().toLowerCase() : ""; - if (!kind && ("at" in sched || "atMs" in sched)) { - sched.kind = "at"; - mutated = true; - } - const atRaw = typeof sched.at === "string" ? sched.at.trim() : ""; - const atMsRaw = sched.atMs; - const parsedAtMs = - typeof atMsRaw === "number" - ? atMsRaw - : typeof atMsRaw === "string" - ? parseAbsoluteTimeMs(atMsRaw) - : atRaw - ? parseAbsoluteTimeMs(atRaw) - : null; - if (parsedAtMs !== null) { - sched.at = new Date(parsedAtMs).toISOString(); - if ("atMs" in sched) { - delete sched.atMs; - } - mutated = true; - } - - const everyMsRaw = sched.everyMs; - const everyMsCoerced = coerceFiniteScheduleNumber(everyMsRaw); - const everyMs = everyMsCoerced !== undefined ? Math.floor(everyMsCoerced) : null; - if (everyMs !== null && everyMsRaw !== everyMs) { - sched.everyMs = everyMs; - mutated = true; - } - if ((kind === "every" || sched.kind === "every") && everyMs !== null) { - const anchorRaw = sched.anchorMs; - const anchorCoerced = coerceFiniteScheduleNumber(anchorRaw); - const normalizedAnchor = - anchorCoerced !== undefined - ? Math.max(0, Math.floor(anchorCoerced)) - : typeof raw.createdAtMs === "number" && Number.isFinite(raw.createdAtMs) - ? Math.max(0, Math.floor(raw.createdAtMs)) - : typeof raw.updatedAtMs === "number" && Number.isFinite(raw.updatedAtMs) - ? Math.max(0, Math.floor(raw.updatedAtMs)) - : null; - if (normalizedAnchor !== null && anchorRaw !== normalizedAnchor) { - sched.anchorMs = normalizedAnchor; - mutated = true; - } - } - - const exprRaw = typeof sched.expr === "string" ? sched.expr.trim() : ""; - const legacyCronRaw = typeof sched.cron === "string" ? sched.cron.trim() : ""; - let normalizedExpr = exprRaw; - if (!normalizedExpr && legacyCronRaw) { - normalizedExpr = legacyCronRaw; - sched.expr = normalizedExpr; - mutated = true; - } - if (typeof sched.expr === "string" && sched.expr !== normalizedExpr) { - sched.expr = normalizedExpr; - mutated = true; - } - if ("cron" in sched) { - delete sched.cron; - mutated = true; - } - if ((kind === "cron" || sched.kind === "cron") && normalizedExpr) { - const explicitStaggerMs = normalizeCronStaggerMs(sched.staggerMs); - const defaultStaggerMs = resolveDefaultCronStaggerMs(normalizedExpr); - const targetStaggerMs = explicitStaggerMs ?? defaultStaggerMs; - if (targetStaggerMs === undefined) { - if ("staggerMs" in sched) { - delete sched.staggerMs; - mutated = true; - } - } else if (sched.staggerMs !== targetStaggerMs) { - sched.staggerMs = targetStaggerMs; - mutated = true; - } - } - } - - const delivery = raw.delivery; - if (delivery && typeof delivery === "object" && !Array.isArray(delivery)) { - const modeRaw = (delivery as { mode?: unknown }).mode; - if (typeof modeRaw === "string") { - const lowered = modeRaw.trim().toLowerCase(); - if (lowered === "deliver") { - (delivery as { mode?: unknown }).mode = "announce"; - mutated = true; - } - } else if (modeRaw === undefined || modeRaw === null) { - // Explicitly persist the default so existing jobs don't silently - // change behaviour when the runtime default shifts. - (delivery as { mode?: unknown }).mode = "announce"; - mutated = true; - } - } - - const isolation = raw.isolation; - if (isolation && typeof isolation === "object" && !Array.isArray(isolation)) { - delete raw.isolation; - mutated = true; - } - - const payloadKind = - payloadRecord && typeof payloadRecord.kind === "string" ? payloadRecord.kind : ""; - const normalizedSessionTarget = - typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : ""; - if (normalizedSessionTarget === "main" || normalizedSessionTarget === "isolated") { - if (raw.sessionTarget !== normalizedSessionTarget) { - raw.sessionTarget = normalizedSessionTarget; - mutated = true; - } - } else { - const inferredSessionTarget = payloadKind === "agentTurn" ? "isolated" : "main"; - if (raw.sessionTarget !== inferredSessionTarget) { - raw.sessionTarget = inferredSessionTarget; - mutated = true; - } - } - - const sessionTarget = - typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : ""; - const isIsolatedAgentTurn = - sessionTarget === "isolated" || (sessionTarget === "" && payloadKind === "agentTurn"); - const hasDelivery = delivery && typeof delivery === "object" && !Array.isArray(delivery); - const normalizedLegacy = normalizeLegacyDeliveryInput({ - delivery: hasDelivery ? (delivery as Record) : null, - payload: payloadRecord, - }); - - if (isIsolatedAgentTurn && payloadKind === "agentTurn") { - if (!hasDelivery && normalizedLegacy.delivery) { - raw.delivery = normalizedLegacy.delivery; - mutated = true; - } else if (!hasDelivery) { - raw.delivery = { mode: "announce" }; - mutated = true; - } else if (normalizedLegacy.mutated && normalizedLegacy.delivery) { - raw.delivery = normalizedLegacy.delivery; - mutated = true; - } - } else if (normalizedLegacy.mutated && normalizedLegacy.delivery) { - raw.delivery = normalizedLegacy.delivery; - mutated = true; - } - } + const { mutated } = normalizeStoredCronJobs(jobs); state.store = { version: 1, jobs: jobs as unknown as CronJob[] }; state.storeLoadedAtMs = state.deps.nowMs(); state.storeFileMtimeMs = fileMtimeMs; diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index f82290006b4..5320ffdf526 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -1,9 +1,7 @@ import type { CronConfig, CronRetryOn } from "../../config/types.cron.js"; -import { isCronSystemEvent } from "../../infra/heartbeat-events-filter.js"; import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js"; import { DEFAULT_AGENT_ID } from "../../routing/session-key.js"; import { resolveCronDeliveryPlan } from "../delivery.js"; -import { shouldEnqueueCronMainSummary } from "../heartbeat-policy.js"; import { sweepCronRunSessions } from "../session-reaper.js"; import type { CronDeliveryStatus, @@ -1138,46 +1136,6 @@ export async function executeJobCore( return { status: "error", error: timeoutErrorMessage() }; } - // Post a short summary back to the main session only when announce - // delivery was requested and we are confident no outbound delivery path - // ran. If delivery was attempted but final ack is uncertain, suppress the - // main summary to avoid duplicate user-facing sends. - // See: https://github.com/openclaw/openclaw/issues/15692 - // - // Also suppress heartbeat-only summaries (e.g. "HEARTBEAT_OK") — these - // are internal ack tokens that should never leak into user conversations. - // See: https://github.com/openclaw/openclaw/issues/32013 - const summaryText = res.summary?.trim(); - const deliveryPlan = resolveCronDeliveryPlan(job); - const suppressMainSummary = - res.status === "error" && res.errorKind === "delivery-target" && deliveryPlan.requested; - if ( - shouldEnqueueCronMainSummary({ - summaryText, - deliveryRequested: deliveryPlan.requested, - delivered: res.delivered, - deliveryAttempted: res.deliveryAttempted, - suppressMainSummary, - isCronSystemEvent, - }) - ) { - const prefix = "Cron"; - const label = - res.status === "error" ? `${prefix} (error): ${summaryText}` : `${prefix}: ${summaryText}`; - state.deps.enqueueSystemEvent(label, { - agentId: job.agentId, - sessionKey: job.sessionKey, - contextKey: `cron:${job.id}`, - }); - if (job.wakeMode === "now") { - state.deps.requestHeartbeatNow({ - reason: `cron:${job.id}`, - agentId: job.agentId, - sessionKey: job.sessionKey, - }); - } - } - return { status: res.status, error: res.error, diff --git a/src/cron/store-migration.test.ts b/src/cron/store-migration.test.ts new file mode 100644 index 00000000000..79f3314c019 --- /dev/null +++ b/src/cron/store-migration.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { normalizeStoredCronJobs } from "./store-migration.js"; + +describe("normalizeStoredCronJobs", () => { + it("normalizes legacy cron fields and reports migration issues", () => { + const jobs = [ + { + jobId: "legacy-job", + schedule: { kind: "cron", cron: "*/5 * * * *", tz: "UTC" }, + message: "say hi", + model: "openai/gpt-4.1", + deliver: true, + provider: " TeLeGrAm ", + to: "12345", + }, + ] as Array>; + + const result = normalizeStoredCronJobs(jobs); + + expect(result.mutated).toBe(true); + expect(result.issues).toMatchObject({ + jobId: 1, + legacyScheduleCron: 1, + legacyTopLevelPayloadFields: 1, + legacyTopLevelDeliveryFields: 1, + }); + + const [job] = jobs; + expect(job?.jobId).toBeUndefined(); + expect(job?.id).toBe("legacy-job"); + expect(job?.schedule).toMatchObject({ + kind: "cron", + expr: "*/5 * * * *", + tz: "UTC", + }); + expect(job?.message).toBeUndefined(); + expect(job?.provider).toBeUndefined(); + expect(job?.delivery).toMatchObject({ + mode: "announce", + channel: "telegram", + to: "12345", + }); + expect(job?.payload).toMatchObject({ + kind: "agentTurn", + message: "say hi", + model: "openai/gpt-4.1", + }); + }); + + it("normalizes payload provider alias into channel", () => { + const jobs = [ + { + id: "legacy-provider", + schedule: { kind: "every", everyMs: 60_000 }, + payload: { + kind: "agentTurn", + message: "ping", + provider: " Slack ", + }, + }, + ] as Array>; + + const result = normalizeStoredCronJobs(jobs); + + expect(result.mutated).toBe(true); + expect(result.issues.legacyPayloadProvider).toBe(1); + expect(jobs[0]?.payload).toMatchObject({ + kind: "agentTurn", + message: "ping", + }); + const payload = jobs[0]?.payload as Record | undefined; + expect(payload?.provider).toBeUndefined(); + expect(jobs[0]?.delivery).toMatchObject({ + mode: "announce", + channel: "slack", + }); + }); +}); diff --git a/src/cron/store-migration.ts b/src/cron/store-migration.ts new file mode 100644 index 00000000000..11789422e61 --- /dev/null +++ b/src/cron/store-migration.ts @@ -0,0 +1,491 @@ +import { normalizeLegacyDeliveryInput } from "./legacy-delivery.js"; +import { parseAbsoluteTimeMs } from "./parse.js"; +import { migrateLegacyCronPayload } from "./payload-migration.js"; +import { coerceFiniteScheduleNumber } from "./schedule.js"; +import { inferLegacyName, normalizeOptionalText } from "./service/normalize.js"; +import { normalizeCronStaggerMs, resolveDefaultCronStaggerMs } from "./stagger.js"; + +type CronStoreIssueKey = + | "jobId" + | "legacyScheduleString" + | "legacyScheduleCron" + | "legacyPayloadKind" + | "legacyPayloadProvider" + | "legacyTopLevelPayloadFields" + | "legacyTopLevelDeliveryFields" + | "legacyDeliveryMode"; + +type CronStoreIssues = Partial>; + +type NormalizeCronStoreJobsResult = { + issues: CronStoreIssues; + jobs: Array>; + mutated: boolean; +}; + +function incrementIssue(issues: CronStoreIssues, key: CronStoreIssueKey) { + issues[key] = (issues[key] ?? 0) + 1; +} + +function normalizePayloadKind(payload: Record) { + const raw = typeof payload.kind === "string" ? payload.kind.trim().toLowerCase() : ""; + if (raw === "agentturn") { + payload.kind = "agentTurn"; + return true; + } + if (raw === "systemevent") { + payload.kind = "systemEvent"; + return true; + } + return false; +} + +function inferPayloadIfMissing(raw: Record) { + const message = typeof raw.message === "string" ? raw.message.trim() : ""; + const text = typeof raw.text === "string" ? raw.text.trim() : ""; + const command = typeof raw.command === "string" ? raw.command.trim() : ""; + if (message) { + raw.payload = { kind: "agentTurn", message }; + return true; + } + if (text) { + raw.payload = { kind: "systemEvent", text }; + return true; + } + if (command) { + raw.payload = { kind: "systemEvent", text: command }; + return true; + } + return false; +} + +function copyTopLevelAgentTurnFields( + raw: Record, + payload: Record, +) { + let mutated = false; + + const copyTrimmedString = (field: "model" | "thinking") => { + const existing = payload[field]; + if (typeof existing === "string" && existing.trim()) { + return; + } + const value = raw[field]; + if (typeof value === "string" && value.trim()) { + payload[field] = value.trim(); + mutated = true; + } + }; + copyTrimmedString("model"); + copyTrimmedString("thinking"); + + if ( + typeof payload.timeoutSeconds !== "number" && + typeof raw.timeoutSeconds === "number" && + Number.isFinite(raw.timeoutSeconds) + ) { + payload.timeoutSeconds = Math.max(0, Math.floor(raw.timeoutSeconds)); + mutated = true; + } + + if ( + typeof payload.allowUnsafeExternalContent !== "boolean" && + typeof raw.allowUnsafeExternalContent === "boolean" + ) { + payload.allowUnsafeExternalContent = raw.allowUnsafeExternalContent; + mutated = true; + } + + if (typeof payload.deliver !== "boolean" && typeof raw.deliver === "boolean") { + payload.deliver = raw.deliver; + mutated = true; + } + if ( + typeof payload.channel !== "string" && + typeof raw.channel === "string" && + raw.channel.trim() + ) { + payload.channel = raw.channel.trim(); + mutated = true; + } + if (typeof payload.to !== "string" && typeof raw.to === "string" && raw.to.trim()) { + payload.to = raw.to.trim(); + mutated = true; + } + if ( + typeof payload.bestEffortDeliver !== "boolean" && + typeof raw.bestEffortDeliver === "boolean" + ) { + payload.bestEffortDeliver = raw.bestEffortDeliver; + mutated = true; + } + if ( + typeof payload.provider !== "string" && + typeof raw.provider === "string" && + raw.provider.trim() + ) { + payload.provider = raw.provider.trim(); + mutated = true; + } + + return mutated; +} + +function stripLegacyTopLevelFields(raw: Record) { + if ("model" in raw) { + delete raw.model; + } + if ("thinking" in raw) { + delete raw.thinking; + } + if ("timeoutSeconds" in raw) { + delete raw.timeoutSeconds; + } + if ("allowUnsafeExternalContent" in raw) { + delete raw.allowUnsafeExternalContent; + } + if ("message" in raw) { + delete raw.message; + } + if ("text" in raw) { + delete raw.text; + } + if ("deliver" in raw) { + delete raw.deliver; + } + if ("channel" in raw) { + delete raw.channel; + } + if ("to" in raw) { + delete raw.to; + } + if ("bestEffortDeliver" in raw) { + delete raw.bestEffortDeliver; + } + if ("provider" in raw) { + delete raw.provider; + } + if ("command" in raw) { + delete raw.command; + } + if ("timeout" in raw) { + delete raw.timeout; + } +} + +export function normalizeStoredCronJobs( + jobs: Array>, +): NormalizeCronStoreJobsResult { + const issues: CronStoreIssues = {}; + let mutated = false; + + for (const raw of jobs) { + const jobIssues = new Set(); + const trackIssue = (key: CronStoreIssueKey) => { + if (jobIssues.has(key)) { + return; + } + jobIssues.add(key); + incrementIssue(issues, key); + }; + + const state = raw.state; + if (!state || typeof state !== "object" || Array.isArray(state)) { + raw.state = {}; + mutated = true; + } + + const rawId = typeof raw.id === "string" ? raw.id.trim() : ""; + const legacyJobId = typeof raw.jobId === "string" ? raw.jobId.trim() : ""; + if (!rawId && legacyJobId) { + raw.id = legacyJobId; + mutated = true; + trackIssue("jobId"); + } else if (rawId && raw.id !== rawId) { + raw.id = rawId; + mutated = true; + } + if ("jobId" in raw) { + delete raw.jobId; + mutated = true; + trackIssue("jobId"); + } + + if (typeof raw.schedule === "string") { + const expr = raw.schedule.trim(); + raw.schedule = { kind: "cron", expr }; + mutated = true; + trackIssue("legacyScheduleString"); + } + + const nameRaw = raw.name; + if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) { + raw.name = inferLegacyName({ + schedule: raw.schedule as never, + payload: raw.payload as never, + }); + mutated = true; + } else { + raw.name = nameRaw.trim(); + } + + const desc = normalizeOptionalText(raw.description); + if (raw.description !== desc) { + raw.description = desc; + mutated = true; + } + + if ("sessionKey" in raw) { + const sessionKey = + typeof raw.sessionKey === "string" ? normalizeOptionalText(raw.sessionKey) : undefined; + if (raw.sessionKey !== sessionKey) { + raw.sessionKey = sessionKey; + mutated = true; + } + } + + if (typeof raw.enabled !== "boolean") { + raw.enabled = true; + mutated = true; + } + + const wakeModeRaw = typeof raw.wakeMode === "string" ? raw.wakeMode.trim().toLowerCase() : ""; + if (wakeModeRaw === "next-heartbeat") { + if (raw.wakeMode !== "next-heartbeat") { + raw.wakeMode = "next-heartbeat"; + mutated = true; + } + } else if (wakeModeRaw === "now") { + if (raw.wakeMode !== "now") { + raw.wakeMode = "now"; + mutated = true; + } + } else { + raw.wakeMode = "now"; + mutated = true; + } + + const payload = raw.payload; + if ( + (!payload || typeof payload !== "object" || Array.isArray(payload)) && + inferPayloadIfMissing(raw) + ) { + mutated = true; + trackIssue("legacyTopLevelPayloadFields"); + } + + const payloadRecord = + raw.payload && typeof raw.payload === "object" && !Array.isArray(raw.payload) + ? (raw.payload as Record) + : null; + + if (payloadRecord) { + if (normalizePayloadKind(payloadRecord)) { + mutated = true; + trackIssue("legacyPayloadKind"); + } + if (!payloadRecord.kind) { + if (typeof payloadRecord.message === "string" && payloadRecord.message.trim()) { + payloadRecord.kind = "agentTurn"; + mutated = true; + trackIssue("legacyPayloadKind"); + } else if (typeof payloadRecord.text === "string" && payloadRecord.text.trim()) { + payloadRecord.kind = "systemEvent"; + mutated = true; + trackIssue("legacyPayloadKind"); + } + } + if (payloadRecord.kind === "agentTurn" && copyTopLevelAgentTurnFields(raw, payloadRecord)) { + mutated = true; + } + } + + const hadLegacyTopLevelPayloadFields = + "model" in raw || + "thinking" in raw || + "timeoutSeconds" in raw || + "allowUnsafeExternalContent" in raw || + "message" in raw || + "text" in raw || + "command" in raw || + "timeout" in raw; + const hadLegacyTopLevelDeliveryFields = + "deliver" in raw || + "channel" in raw || + "to" in raw || + "bestEffortDeliver" in raw || + "provider" in raw; + if (hadLegacyTopLevelPayloadFields || hadLegacyTopLevelDeliveryFields) { + stripLegacyTopLevelFields(raw); + mutated = true; + if (hadLegacyTopLevelPayloadFields) { + trackIssue("legacyTopLevelPayloadFields"); + } + if (hadLegacyTopLevelDeliveryFields) { + trackIssue("legacyTopLevelDeliveryFields"); + } + } + + if (payloadRecord) { + const hadLegacyPayloadProvider = + typeof payloadRecord.provider === "string" && payloadRecord.provider.trim().length > 0; + if (migrateLegacyCronPayload(payloadRecord)) { + mutated = true; + if (hadLegacyPayloadProvider) { + trackIssue("legacyPayloadProvider"); + } + } + } + + const schedule = raw.schedule; + if (schedule && typeof schedule === "object" && !Array.isArray(schedule)) { + const sched = schedule as Record; + const kind = typeof sched.kind === "string" ? sched.kind.trim().toLowerCase() : ""; + if (!kind && ("at" in sched || "atMs" in sched)) { + sched.kind = "at"; + mutated = true; + } + const atRaw = typeof sched.at === "string" ? sched.at.trim() : ""; + const atMsRaw = sched.atMs; + const parsedAtMs = + typeof atMsRaw === "number" + ? atMsRaw + : typeof atMsRaw === "string" + ? parseAbsoluteTimeMs(atMsRaw) + : atRaw + ? parseAbsoluteTimeMs(atRaw) + : null; + if (parsedAtMs !== null) { + sched.at = new Date(parsedAtMs).toISOString(); + if ("atMs" in sched) { + delete sched.atMs; + } + mutated = true; + } + + const everyMsRaw = sched.everyMs; + const everyMsCoerced = coerceFiniteScheduleNumber(everyMsRaw); + const everyMs = everyMsCoerced !== undefined ? Math.floor(everyMsCoerced) : null; + if (everyMs !== null && everyMsRaw !== everyMs) { + sched.everyMs = everyMs; + mutated = true; + } + if ((kind === "every" || sched.kind === "every") && everyMs !== null) { + const anchorRaw = sched.anchorMs; + const anchorCoerced = coerceFiniteScheduleNumber(anchorRaw); + const normalizedAnchor = + anchorCoerced !== undefined + ? Math.max(0, Math.floor(anchorCoerced)) + : typeof raw.createdAtMs === "number" && Number.isFinite(raw.createdAtMs) + ? Math.max(0, Math.floor(raw.createdAtMs)) + : typeof raw.updatedAtMs === "number" && Number.isFinite(raw.updatedAtMs) + ? Math.max(0, Math.floor(raw.updatedAtMs)) + : null; + if (normalizedAnchor !== null && anchorRaw !== normalizedAnchor) { + sched.anchorMs = normalizedAnchor; + mutated = true; + } + } + + const exprRaw = typeof sched.expr === "string" ? sched.expr.trim() : ""; + const legacyCronRaw = typeof sched.cron === "string" ? sched.cron.trim() : ""; + let normalizedExpr = exprRaw; + if (!normalizedExpr && legacyCronRaw) { + normalizedExpr = legacyCronRaw; + sched.expr = normalizedExpr; + mutated = true; + trackIssue("legacyScheduleCron"); + } + if (typeof sched.expr === "string" && sched.expr !== normalizedExpr) { + sched.expr = normalizedExpr; + mutated = true; + } + if ("cron" in sched) { + delete sched.cron; + mutated = true; + trackIssue("legacyScheduleCron"); + } + if ((kind === "cron" || sched.kind === "cron") && normalizedExpr) { + const explicitStaggerMs = normalizeCronStaggerMs(sched.staggerMs); + const defaultStaggerMs = resolveDefaultCronStaggerMs(normalizedExpr); + const targetStaggerMs = explicitStaggerMs ?? defaultStaggerMs; + if (targetStaggerMs === undefined) { + if ("staggerMs" in sched) { + delete sched.staggerMs; + mutated = true; + } + } else if (sched.staggerMs !== targetStaggerMs) { + sched.staggerMs = targetStaggerMs; + mutated = true; + } + } + } + + const delivery = raw.delivery; + if (delivery && typeof delivery === "object" && !Array.isArray(delivery)) { + const modeRaw = (delivery as { mode?: unknown }).mode; + if (typeof modeRaw === "string") { + const lowered = modeRaw.trim().toLowerCase(); + if (lowered === "deliver") { + (delivery as { mode?: unknown }).mode = "announce"; + mutated = true; + trackIssue("legacyDeliveryMode"); + } + } else if (modeRaw === undefined || modeRaw === null) { + (delivery as { mode?: unknown }).mode = "announce"; + mutated = true; + } + } + + const isolation = raw.isolation; + if (isolation && typeof isolation === "object" && !Array.isArray(isolation)) { + delete raw.isolation; + mutated = true; + } + + const payloadKind = + payloadRecord && typeof payloadRecord.kind === "string" ? payloadRecord.kind : ""; + const normalizedSessionTarget = + typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : ""; + if (normalizedSessionTarget === "main" || normalizedSessionTarget === "isolated") { + if (raw.sessionTarget !== normalizedSessionTarget) { + raw.sessionTarget = normalizedSessionTarget; + mutated = true; + } + } else { + const inferredSessionTarget = payloadKind === "agentTurn" ? "isolated" : "main"; + if (raw.sessionTarget !== inferredSessionTarget) { + raw.sessionTarget = inferredSessionTarget; + mutated = true; + } + } + + const sessionTarget = + typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : ""; + const isIsolatedAgentTurn = + sessionTarget === "isolated" || (sessionTarget === "" && payloadKind === "agentTurn"); + const hasDelivery = delivery && typeof delivery === "object" && !Array.isArray(delivery); + const normalizedLegacy = normalizeLegacyDeliveryInput({ + delivery: hasDelivery ? (delivery as Record) : null, + payload: payloadRecord, + }); + + if (isIsolatedAgentTurn && payloadKind === "agentTurn") { + if (!hasDelivery && normalizedLegacy.delivery) { + raw.delivery = normalizedLegacy.delivery; + mutated = true; + } else if (!hasDelivery) { + raw.delivery = { mode: "announce" }; + mutated = true; + } else if (normalizedLegacy.mutated && normalizedLegacy.delivery) { + raw.delivery = normalizedLegacy.delivery; + mutated = true; + } + } else if (normalizedLegacy.mutated && normalizedLegacy.delivery) { + raw.delivery = normalizedLegacy.delivery; + mutated = true; + } + } + + return { issues, jobs, mutated }; +} diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index ccaf5441237..2590f63c23d 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -848,6 +848,32 @@ describe("gateway server cron", () => { 'Cron job "failure destination webhook" failed: unknown error', ); + fetchWithSsrFGuardMock.mockClear(); + cronIsolatedRun.mockResolvedValueOnce({ status: "error", summary: "best-effort failed" }); + const bestEffortFailureDestJobId = await addWebhookCronJob({ + ws, + name: "best effort failure destination webhook", + sessionTarget: "isolated", + delivery: { + mode: "announce", + channel: "telegram", + to: "19098680", + bestEffort: true, + failureDestination: { + mode: "webhook", + to: "https://example.invalid/failure-destination", + }, + }, + }); + const bestEffortFailureDestFinished = waitForCronEvent( + ws, + (payload) => + payload?.jobId === bestEffortFailureDestJobId && payload?.action === "finished", + ); + await runCronJobForce(ws, bestEffortFailureDestJobId); + await bestEffortFailureDestFinished; + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + cronIsolatedRun.mockResolvedValueOnce({ status: "ok", summary: "" }); const noSummaryJobId = await addWebhookCronJob({ ws, @@ -861,7 +887,7 @@ describe("gateway server cron", () => { ); await runCronJobForce(ws, noSummaryJobId); await noSummaryFinished; - expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); } finally { await cleanupCronTestRun({ ws, server, prevSkipCron }); } diff --git a/src/gateway/server.hooks.test.ts b/src/gateway/server.hooks.test.ts index 6711671e4ee..2a4e1c961a0 100644 --- a/src/gateway/server.hooks.test.ts +++ b/src/gateway/server.hooks.test.ts @@ -75,6 +75,10 @@ describe("gateway server hooks", () => { expect(resAgent.status).toBe(200); const agentEvents = await waitForSystemEvent(); expect(agentEvents.some((e) => e.includes("Hook Email: done"))).toBe(true); + const firstCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as { + deliveryContract?: string; + }; + expect(firstCall?.deliveryContract).toBe("shared"); drainSystemEvents(resolveMainKey()); mockIsolatedRunOkOnce(); diff --git a/src/gateway/server/hooks.ts b/src/gateway/server/hooks.ts index 3b294be8fb9..3b159c680af 100644 --- a/src/gateway/server/hooks.ts +++ b/src/gateway/server/hooks.ts @@ -76,6 +76,7 @@ export function createGatewayHooksRequestHandler(params: { message: value.message, sessionKey, lane: "cron", + deliveryContract: "shared", }); const summary = result.summary?.trim() || result.error?.trim() || result.status; const prefix = From 87d939be793675952d50de4722b8f5ee6434d001 Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 9 Mar 2026 22:27:05 +0300 Subject: [PATCH 0004/1173] Agents: add embedded error observations (#41336) Merged via squash. Prepared head SHA: 490004229862129ceb21939e382658714e23bd68 Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + .../pi-embedded-error-observation.test.ts | 182 ++++++++++++++++ src/agents/pi-embedded-error-observation.ts | 199 ++++++++++++++++++ src/agents/pi-embedded-runner/run.ts | 41 +++- .../run/failover-observation.test.ts | 48 +++++ .../run/failover-observation.ts | 76 +++++++ ...edded-subscribe.handlers.lifecycle.test.ts | 62 +++++- ...i-embedded-subscribe.handlers.lifecycle.ts | 32 ++- .../pi-embedded-subscribe.handlers.types.ts | 4 +- 9 files changed, 634 insertions(+), 11 deletions(-) create mode 100644 src/agents/pi-embedded-error-observation.test.ts create mode 100644 src/agents/pi-embedded-error-observation.ts create mode 100644 src/agents/pi-embedded-runner/run/failover-observation.test.ts create mode 100644 src/agents/pi-embedded-runner/run/failover-observation.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4be8bad0eaa..028a09e896c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - ACP/sessions.patch: allow `spawnedBy` and `spawnDepth` lineage fields on ACP session keys so `sessions_spawn` with `runtime: "acp"` no longer fails during child-session setup. Fixes #40971. (#40995) thanks @xaeon2026. - ACP/stop reason mapping: resolve gateway chat `state: "error"` completions as ACP `end_turn` instead of `refusal` so transient backend failures are not surfaced as deliberate refusals. (#41187) thanks @pejmanjohn. - ACP/setSessionMode: propagate gateway `sessions.patch` failures back to ACP clients so rejected mode changes no longer return silent success. (#41185) thanks @pejmanjohn. +- Agents/embedded logs: add structured, sanitized lifecycle and failover observation events so overload and provider failures are easier to tail and filter. (#41336) thanks @altaywtf. ## 2026.3.8 diff --git a/src/agents/pi-embedded-error-observation.test.ts b/src/agents/pi-embedded-error-observation.test.ts new file mode 100644 index 00000000000..94979ebfb8c --- /dev/null +++ b/src/agents/pi-embedded-error-observation.test.ts @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as loggingConfigModule from "../logging/config.js"; +import { + buildApiErrorObservationFields, + buildTextObservationFields, + sanitizeForConsole, +} from "./pi-embedded-error-observation.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("buildApiErrorObservationFields", () => { + it("redacts request ids and exposes stable hashes instead of raw payloads", () => { + const observed = buildApiErrorObservationFields( + '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_overload"}', + ); + + expect(observed).toMatchObject({ + rawErrorPreview: expect.stringContaining('"request_id":"sha256:'), + rawErrorHash: expect.stringMatching(/^sha256:/), + rawErrorFingerprint: expect.stringMatching(/^sha256:/), + providerErrorType: "overloaded_error", + providerErrorMessagePreview: "Overloaded", + requestIdHash: expect.stringMatching(/^sha256:/), + }); + expect(observed.rawErrorPreview).not.toContain("req_overload"); + }); + + it("forces token redaction for observation previews", () => { + const observed = buildApiErrorObservationFields( + "Authorization: Bearer sk-abcdefghijklmnopqrstuvwxyz123456", + ); + + expect(observed.rawErrorPreview).not.toContain("sk-abcdefghijklmnopqrstuvwxyz123456"); + expect(observed.rawErrorPreview).toContain("sk-abc"); + expect(observed.rawErrorHash).toMatch(/^sha256:/); + }); + + it("redacts observation-only header and cookie formats", () => { + const observed = buildApiErrorObservationFields( + "x-api-key: sk-abcdefghijklmnopqrstuvwxyz123456 Cookie: session=abcdefghijklmnopqrstuvwxyz123456", + ); + + expect(observed.rawErrorPreview).not.toContain("abcdefghijklmnopqrstuvwxyz123456"); + expect(observed.rawErrorPreview).toContain("x-api-key: ***"); + expect(observed.rawErrorPreview).toContain("Cookie: session="); + }); + + it("does not let cookie redaction consume unrelated fields on the same line", () => { + const observed = buildApiErrorObservationFields( + "Cookie: session=abcdefghijklmnopqrstuvwxyz123456 status=503 request_id=req_cookie", + ); + + expect(observed.rawErrorPreview).toContain("Cookie: session="); + expect(observed.rawErrorPreview).toContain("status=503"); + expect(observed.rawErrorPreview).toContain("request_id=sha256:"); + }); + + it("builds sanitized generic text observation fields", () => { + const observed = buildTextObservationFields( + '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_prev"}', + ); + + expect(observed).toMatchObject({ + textPreview: expect.stringContaining('"request_id":"sha256:'), + textHash: expect.stringMatching(/^sha256:/), + textFingerprint: expect.stringMatching(/^sha256:/), + providerErrorType: "overloaded_error", + providerErrorMessagePreview: "Overloaded", + requestIdHash: expect.stringMatching(/^sha256:/), + }); + expect(observed.textPreview).not.toContain("req_prev"); + }); + + it("redacts request ids in formatted plain-text errors", () => { + const observed = buildApiErrorObservationFields( + "LLM error overloaded_error: Overloaded (request_id: req_plaintext_123)", + ); + + expect(observed).toMatchObject({ + rawErrorPreview: expect.stringContaining("request_id: sha256:"), + rawErrorFingerprint: expect.stringMatching(/^sha256:/), + requestIdHash: expect.stringMatching(/^sha256:/), + }); + expect(observed.rawErrorPreview).not.toContain("req_plaintext_123"); + }); + + it("keeps fingerprints stable across request ids for equivalent errors", () => { + const first = buildApiErrorObservationFields( + '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_001"}', + ); + const second = buildApiErrorObservationFields( + '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_002"}', + ); + + expect(first.rawErrorFingerprint).toBe(second.rawErrorFingerprint); + expect(first.rawErrorHash).not.toBe(second.rawErrorHash); + }); + + it("truncates oversized raw and provider previews", () => { + const longMessage = "X".repeat(260); + const observed = buildApiErrorObservationFields( + `{"type":"error","error":{"type":"server_error","message":"${longMessage}"},"request_id":"req_long"}`, + ); + + expect(observed.rawErrorPreview).toBeDefined(); + expect(observed.providerErrorMessagePreview).toBeDefined(); + expect(observed.rawErrorPreview?.length).toBeLessThanOrEqual(401); + expect(observed.providerErrorMessagePreview?.length).toBeLessThanOrEqual(201); + expect(observed.providerErrorMessagePreview?.endsWith("…")).toBe(true); + }); + + it("caps oversized raw inputs before hashing and fingerprinting", () => { + const oversized = "X".repeat(70_000); + const bounded = "X".repeat(64_000); + + expect(buildApiErrorObservationFields(oversized)).toMatchObject({ + rawErrorHash: buildApiErrorObservationFields(bounded).rawErrorHash, + rawErrorFingerprint: buildApiErrorObservationFields(bounded).rawErrorFingerprint, + }); + }); + + it("returns empty observation fields for empty input", () => { + expect(buildApiErrorObservationFields(undefined)).toEqual({}); + expect(buildApiErrorObservationFields("")).toEqual({}); + expect(buildApiErrorObservationFields(" ")).toEqual({}); + }); + + it("re-reads configured redact patterns on each call", () => { + const readLoggingConfig = vi.spyOn(loggingConfigModule, "readLoggingConfig"); + readLoggingConfig.mockReturnValueOnce(undefined); + readLoggingConfig.mockReturnValueOnce({ + redactPatterns: [String.raw`\bcustom-secret-[A-Za-z0-9]+\b`], + }); + + const first = buildApiErrorObservationFields("custom-secret-abc123"); + const second = buildApiErrorObservationFields("custom-secret-abc123"); + + expect(first.rawErrorPreview).toContain("custom-secret-abc123"); + expect(second.rawErrorPreview).not.toContain("custom-secret-abc123"); + expect(second.rawErrorPreview).toContain("custom"); + }); + + it("fails closed when observation sanitization throws", () => { + vi.spyOn(loggingConfigModule, "readLoggingConfig").mockImplementation(() => { + throw new Error("boom"); + }); + + expect(buildApiErrorObservationFields("request_id=req_123")).toEqual({}); + expect(buildTextObservationFields("request_id=req_123")).toEqual({ + textPreview: undefined, + textHash: undefined, + textFingerprint: undefined, + httpCode: undefined, + providerErrorType: undefined, + providerErrorMessagePreview: undefined, + requestIdHash: undefined, + }); + }); + + it("ignores non-string configured redact patterns", () => { + vi.spyOn(loggingConfigModule, "readLoggingConfig").mockReturnValue({ + redactPatterns: [ + 123 as never, + { bad: true } as never, + String.raw`\bcustom-secret-[A-Za-z0-9]+\b`, + ], + }); + + const observed = buildApiErrorObservationFields("custom-secret-abc123"); + + expect(observed.rawErrorPreview).not.toContain("custom-secret-abc123"); + expect(observed.rawErrorPreview).toContain("custom"); + }); +}); + +describe("sanitizeForConsole", () => { + it("strips control characters from console-facing values", () => { + expect(sanitizeForConsole("run-1\nprovider\tmodel\rtest")).toBe("run-1 provider model test"); + }); +}); diff --git a/src/agents/pi-embedded-error-observation.ts b/src/agents/pi-embedded-error-observation.ts new file mode 100644 index 00000000000..260bf83f4c5 --- /dev/null +++ b/src/agents/pi-embedded-error-observation.ts @@ -0,0 +1,199 @@ +import { readLoggingConfig } from "../logging/config.js"; +import { redactIdentifier } from "../logging/redact-identifier.js"; +import { getDefaultRedactPatterns, redactSensitiveText } from "../logging/redact.js"; +import { getApiErrorPayloadFingerprint, parseApiErrorInfo } from "./pi-embedded-helpers.js"; +import { stableStringify } from "./stable-stringify.js"; + +const MAX_OBSERVATION_INPUT_CHARS = 64_000; +const MAX_FINGERPRINT_MESSAGE_CHARS = 8_000; +const RAW_ERROR_PREVIEW_MAX_CHARS = 400; +const PROVIDER_ERROR_PREVIEW_MAX_CHARS = 200; +const REQUEST_ID_RE = /\brequest[_ ]?id\b\s*[:=]\s*["'()]*([A-Za-z0-9._:-]+)/i; +const OBSERVATION_EXTRA_REDACT_PATTERNS = [ + String.raw`\b(?:x-)?api[-_]?key\b\s*[:=]\s*(["']?)([^\s"'\\;]+)\1`, + String.raw`"(?:api[-_]?key|api_key)"\s*:\s*"([^"]+)"`, + String.raw`(?:\bCookie\b\s*[:=]\s*[^;=\s]+=|;\s*[^;=\s]+=)([^;\s\r\n]+)`, +]; + +function resolveConfiguredRedactPatterns(): string[] { + const configured = readLoggingConfig()?.redactPatterns; + if (!Array.isArray(configured)) { + return []; + } + return configured.filter((pattern): pattern is string => typeof pattern === "string"); +} + +function truncateForObservation(text: string | undefined, maxChars: number): string | undefined { + const trimmed = text?.trim(); + if (!trimmed) { + return undefined; + } + return trimmed.length > maxChars ? `${trimmed.slice(0, maxChars)}…` : trimmed; +} + +function boundObservationInput(text: string | undefined): string | undefined { + const trimmed = text?.trim(); + if (!trimmed) { + return undefined; + } + return trimmed.length > MAX_OBSERVATION_INPUT_CHARS + ? trimmed.slice(0, MAX_OBSERVATION_INPUT_CHARS) + : trimmed; +} + +export function sanitizeForConsole(text: string | undefined, maxChars = 200): string | undefined { + const trimmed = text?.trim(); + if (!trimmed) { + return undefined; + } + const withoutControlChars = Array.from(trimmed) + .filter((char) => { + const code = char.charCodeAt(0); + return !( + code <= 0x08 || + code === 0x0b || + code === 0x0c || + (code >= 0x0e && code <= 0x1f) || + code === 0x7f + ); + }) + .join(""); + const sanitized = withoutControlChars + .replace(/[\r\n\t]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + return sanitized.length > maxChars ? `${sanitized.slice(0, maxChars)}…` : sanitized; +} + +function replaceRequestIdPreview( + text: string | undefined, + requestId: string | undefined, +): string | undefined { + if (!text || !requestId) { + return text; + } + return text.split(requestId).join(redactIdentifier(requestId, { len: 12 })); +} + +function redactObservationText(text: string | undefined): string | undefined { + if (!text) { + return text; + } + // Observation logs must stay redacted even when operators disable general-purpose + // log redaction, otherwise raw provider payloads leak back into always-on logs. + const configuredPatterns = resolveConfiguredRedactPatterns(); + return redactSensitiveText(text, { + mode: "tools", + patterns: [ + ...getDefaultRedactPatterns(), + ...configuredPatterns, + ...OBSERVATION_EXTRA_REDACT_PATTERNS, + ], + }); +} + +function extractRequestId(text: string | undefined): string | undefined { + if (!text) { + return undefined; + } + const match = text.match(REQUEST_ID_RE); + return match?.[1]?.trim() || undefined; +} + +function buildObservationFingerprint(params: { + raw: string; + requestId?: string; + httpCode?: string; + type?: string; + message?: string; +}): string | null { + const boundedMessage = + params.message && params.message.length > MAX_FINGERPRINT_MESSAGE_CHARS + ? params.message.slice(0, MAX_FINGERPRINT_MESSAGE_CHARS) + : params.message; + const structured = + params.httpCode || params.type || boundedMessage + ? stableStringify({ + httpCode: params.httpCode, + type: params.type, + message: boundedMessage, + }) + : null; + if (structured) { + return structured; + } + if (params.requestId) { + return params.raw.split(params.requestId).join(""); + } + return getApiErrorPayloadFingerprint(params.raw); +} + +export function buildApiErrorObservationFields(rawError?: string): { + rawErrorPreview?: string; + rawErrorHash?: string; + rawErrorFingerprint?: string; + httpCode?: string; + providerErrorType?: string; + providerErrorMessagePreview?: string; + requestIdHash?: string; +} { + const trimmed = boundObservationInput(rawError); + if (!trimmed) { + return {}; + } + try { + const parsed = parseApiErrorInfo(trimmed); + const requestId = parsed?.requestId ?? extractRequestId(trimmed); + const requestIdHash = requestId ? redactIdentifier(requestId, { len: 12 }) : undefined; + const rawFingerprint = buildObservationFingerprint({ + raw: trimmed, + requestId, + httpCode: parsed?.httpCode, + type: parsed?.type, + message: parsed?.message, + }); + const redactedRawPreview = replaceRequestIdPreview(redactObservationText(trimmed), requestId); + const redactedProviderMessage = replaceRequestIdPreview( + redactObservationText(parsed?.message), + requestId, + ); + + return { + rawErrorPreview: truncateForObservation(redactedRawPreview, RAW_ERROR_PREVIEW_MAX_CHARS), + rawErrorHash: redactIdentifier(trimmed, { len: 12 }), + rawErrorFingerprint: rawFingerprint + ? redactIdentifier(rawFingerprint, { len: 12 }) + : undefined, + httpCode: parsed?.httpCode, + providerErrorType: parsed?.type, + providerErrorMessagePreview: truncateForObservation( + redactedProviderMessage, + PROVIDER_ERROR_PREVIEW_MAX_CHARS, + ), + requestIdHash, + }; + } catch { + return {}; + } +} + +export function buildTextObservationFields(text?: string): { + textPreview?: string; + textHash?: string; + textFingerprint?: string; + httpCode?: string; + providerErrorType?: string; + providerErrorMessagePreview?: string; + requestIdHash?: string; +} { + const observed = buildApiErrorObservationFields(text); + return { + textPreview: observed.rawErrorPreview, + textHash: observed.rawErrorHash, + textFingerprint: observed.rawErrorFingerprint, + httpCode: observed.httpCode, + providerErrorType: observed.providerErrorType, + providerErrorMessagePreview: observed.providerErrorMessagePreview, + requestIdHash: observed.requestIdHash, + }; +} diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 21b29fe2cb6..68677a009bd 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -61,6 +61,7 @@ import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; import { log } from "./logger.js"; import { resolveModel } from "./model.js"; import { runEmbeddedAttempt } from "./run/attempt.js"; +import { createFailoverDecisionLogger } from "./run/failover-observation.js"; import type { RunEmbeddedPiAgentParams } from "./run/params.js"; import { buildEmbeddedRunPayloads } from "./run/payloads.js"; import { @@ -1226,11 +1227,26 @@ export async function runEmbeddedPiAgent( reason: promptProfileFailureReason, }); const promptFailoverFailure = isFailoverErrorMessage(errorText); + // Capture the failing profile before auth-profile rotation mutates `lastProfileId`. + const failedPromptProfileId = lastProfileId; + const logPromptFailoverDecision = createFailoverDecisionLogger({ + stage: "prompt", + runId: params.runId, + rawError: errorText, + failoverReason: promptFailoverReason, + profileFailureReason: promptProfileFailureReason, + provider, + model: modelId, + profileId: failedPromptProfileId, + fallbackConfigured, + aborted, + }); if ( promptFailoverFailure && promptFailoverReason !== "timeout" && (await advanceAuthProfile()) ) { + logPromptFailoverDecision("rotate_profile"); await maybeBackoffBeforeOverloadFailover(promptFailoverReason); continue; } @@ -1249,15 +1265,20 @@ export async function runEmbeddedPiAgent( // are configured so outer model fallback can continue on overload, // rate-limit, auth, or billing failures. if (fallbackConfigured && promptFailoverFailure) { + const status = resolveFailoverStatus(promptFailoverReason ?? "unknown"); + logPromptFailoverDecision("fallback_model", { status }); await maybeBackoffBeforeOverloadFailover(promptFailoverReason); throw new FailoverError(errorText, { reason: promptFailoverReason ?? "unknown", provider, model: modelId, profileId: lastProfileId, - status: resolveFailoverStatus(promptFailoverReason ?? "unknown"), + status, }); } + if (promptFailoverFailure || promptFailoverReason) { + logPromptFailoverDecision("surface_error"); + } throw promptError; } @@ -1282,6 +1303,21 @@ export async function runEmbeddedPiAgent( resolveAuthProfileFailureReason(assistantFailoverReason); const cloudCodeAssistFormatError = attempt.cloudCodeAssistFormatError; const imageDimensionError = parseImageDimensionError(lastAssistant?.errorMessage ?? ""); + // Capture the failing profile before auth-profile rotation mutates `lastProfileId`. + const failedAssistantProfileId = lastProfileId; + const logAssistantFailoverDecision = createFailoverDecisionLogger({ + stage: "assistant", + runId: params.runId, + rawError: lastAssistant?.errorMessage?.trim(), + failoverReason: assistantFailoverReason, + profileFailureReason: assistantProfileFailureReason, + provider: activeErrorContext.provider, + model: activeErrorContext.model, + profileId: failedAssistantProfileId, + fallbackConfigured, + timedOut, + aborted, + }); if ( authFailure && @@ -1339,6 +1375,7 @@ export async function runEmbeddedPiAgent( const rotated = await advanceAuthProfile(); if (rotated) { + logAssistantFailoverDecision("rotate_profile"); await maybeBackoffBeforeOverloadFailover(assistantFailoverReason); continue; } @@ -1371,6 +1408,7 @@ export async function runEmbeddedPiAgent( const status = resolveFailoverStatus(assistantFailoverReason ?? "unknown") ?? (isTimeoutErrorMessage(message) ? 408 : undefined); + logAssistantFailoverDecision("fallback_model", { status }); throw new FailoverError(message, { reason: assistantFailoverReason ?? "unknown", provider: activeErrorContext.provider, @@ -1379,6 +1417,7 @@ export async function runEmbeddedPiAgent( status, }); } + logAssistantFailoverDecision("surface_error"); } const usage = toNormalizedUsage(usageAccumulator); diff --git a/src/agents/pi-embedded-runner/run/failover-observation.test.ts b/src/agents/pi-embedded-runner/run/failover-observation.test.ts new file mode 100644 index 00000000000..763540f9ca7 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/failover-observation.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { normalizeFailoverDecisionObservationBase } from "./failover-observation.js"; + +describe("normalizeFailoverDecisionObservationBase", () => { + it("fills timeout observation reasons for deadline timeouts without provider error text", () => { + expect( + normalizeFailoverDecisionObservationBase({ + stage: "assistant", + runId: "run:timeout", + rawError: "", + failoverReason: null, + profileFailureReason: null, + provider: "openai", + model: "mock-1", + profileId: "openai:p1", + fallbackConfigured: false, + timedOut: true, + aborted: false, + }), + ).toMatchObject({ + failoverReason: "timeout", + profileFailureReason: "timeout", + timedOut: true, + }); + }); + + it("preserves explicit failover reasons", () => { + expect( + normalizeFailoverDecisionObservationBase({ + stage: "assistant", + runId: "run:overloaded", + rawError: '{"error":{"type":"overloaded_error"}}', + failoverReason: "overloaded", + profileFailureReason: "overloaded", + provider: "openai", + model: "mock-1", + profileId: "openai:p1", + fallbackConfigured: true, + timedOut: true, + aborted: false, + }), + ).toMatchObject({ + failoverReason: "overloaded", + profileFailureReason: "overloaded", + timedOut: true, + }); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/failover-observation.ts b/src/agents/pi-embedded-runner/run/failover-observation.ts new file mode 100644 index 00000000000..9b915535314 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/failover-observation.ts @@ -0,0 +1,76 @@ +import { redactIdentifier } from "../../../logging/redact-identifier.js"; +import type { AuthProfileFailureReason } from "../../auth-profiles.js"; +import { + buildApiErrorObservationFields, + sanitizeForConsole, +} from "../../pi-embedded-error-observation.js"; +import type { FailoverReason } from "../../pi-embedded-helpers.js"; +import { log } from "../logger.js"; + +export type FailoverDecisionLoggerInput = { + stage: "prompt" | "assistant"; + decision: "rotate_profile" | "fallback_model" | "surface_error"; + runId?: string; + rawError?: string; + failoverReason: FailoverReason | null; + profileFailureReason?: AuthProfileFailureReason | null; + provider: string; + model: string; + profileId?: string; + fallbackConfigured: boolean; + timedOut?: boolean; + aborted?: boolean; + status?: number; +}; + +export type FailoverDecisionLoggerBase = Omit; + +export function normalizeFailoverDecisionObservationBase( + base: FailoverDecisionLoggerBase, +): FailoverDecisionLoggerBase { + return { + ...base, + failoverReason: base.failoverReason ?? (base.timedOut ? "timeout" : null), + profileFailureReason: base.profileFailureReason ?? (base.timedOut ? "timeout" : null), + }; +} + +export function createFailoverDecisionLogger( + base: FailoverDecisionLoggerBase, +): ( + decision: FailoverDecisionLoggerInput["decision"], + extra?: Pick, +) => void { + const normalizedBase = normalizeFailoverDecisionObservationBase(base); + const safeProfileId = normalizedBase.profileId + ? redactIdentifier(normalizedBase.profileId, { len: 12 }) + : undefined; + const safeRunId = sanitizeForConsole(normalizedBase.runId) ?? "-"; + const safeProvider = sanitizeForConsole(normalizedBase.provider) ?? "-"; + const safeModel = sanitizeForConsole(normalizedBase.model) ?? "-"; + const profileText = safeProfileId ?? "-"; + const reasonText = normalizedBase.failoverReason ?? "none"; + return (decision, extra) => { + const observedError = buildApiErrorObservationFields(normalizedBase.rawError); + log.warn("embedded run failover decision", { + event: "embedded_run_failover_decision", + tags: ["error_handling", "failover", normalizedBase.stage, decision], + runId: normalizedBase.runId, + stage: normalizedBase.stage, + decision, + failoverReason: normalizedBase.failoverReason, + profileFailureReason: normalizedBase.profileFailureReason, + provider: normalizedBase.provider, + model: normalizedBase.model, + profileId: safeProfileId, + fallbackConfigured: normalizedBase.fallbackConfigured, + timedOut: normalizedBase.timedOut, + aborted: normalizedBase.aborted, + status: extra?.status, + ...observedError, + consoleMessage: + `embedded run failover decision: runId=${safeRunId} stage=${normalizedBase.stage} decision=${decision} ` + + `reason=${reasonText} provider=${safeProvider}/${safeModel} profile=${profileText}`, + }); + }; +} diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts index 7a8b1e12e05..b93cf43cebe 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts @@ -54,8 +54,13 @@ describe("handleAgentEnd", () => { const warn = vi.mocked(ctx.log.warn); expect(warn).toHaveBeenCalledTimes(1); - expect(warn.mock.calls[0]?.[0]).toContain("runId=run-1"); - expect(warn.mock.calls[0]?.[0]).toContain("error=connection refused"); + expect(warn.mock.calls[0]?.[0]).toBe("embedded run agent end"); + expect(warn.mock.calls[0]?.[1]).toMatchObject({ + event: "embedded_run_agent_end", + runId: "run-1", + error: "connection refused", + rawErrorPreview: "connection refused", + }); expect(onAgentEvent).toHaveBeenCalledWith({ stream: "lifecycle", data: { @@ -65,6 +70,59 @@ describe("handleAgentEnd", () => { }); }); + it("attaches raw provider error metadata without changing the console message", () => { + const ctx = createContext({ + role: "assistant", + stopReason: "error", + provider: "anthropic", + model: "claude-test", + errorMessage: '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', + content: [{ type: "text", text: "" }], + }); + + handleAgentEnd(ctx); + + const warn = vi.mocked(ctx.log.warn); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toBe("embedded run agent end"); + expect(warn.mock.calls[0]?.[1]).toMatchObject({ + event: "embedded_run_agent_end", + runId: "run-1", + error: "The AI service is temporarily overloaded. Please try again in a moment.", + failoverReason: "overloaded", + providerErrorType: "overloaded_error", + }); + }); + + it("redacts logged error text before emitting lifecycle events", () => { + const onAgentEvent = vi.fn(); + const ctx = createContext( + { + role: "assistant", + stopReason: "error", + errorMessage: "x-api-key: sk-abcdefghijklmnopqrstuvwxyz123456", + content: [{ type: "text", text: "" }], + }, + { onAgentEvent }, + ); + + handleAgentEnd(ctx); + + const warn = vi.mocked(ctx.log.warn); + expect(warn.mock.calls[0]?.[1]).toMatchObject({ + event: "embedded_run_agent_end", + error: "x-api-key: ***", + rawErrorPreview: "x-api-key: ***", + }); + expect(onAgentEvent).toHaveBeenCalledWith({ + stream: "lifecycle", + data: { + phase: "error", + error: "x-api-key: ***", + }, + }); + }); + it("keeps non-error run-end logging on debug only", () => { const ctx = createContext(undefined); diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts index 4c6803e814c..c666784ff8e 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts @@ -1,6 +1,11 @@ import { emitAgentEvent } from "../infra/agent-events.js"; import { createInlineCodeState } from "../markdown/code-spans.js"; -import { formatAssistantErrorText } from "./pi-embedded-helpers.js"; +import { + buildApiErrorObservationFields, + buildTextObservationFields, + sanitizeForConsole, +} from "./pi-embedded-error-observation.js"; +import { classifyFailoverReason, formatAssistantErrorText } from "./pi-embedded-helpers.js"; import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js"; import { isAssistantMessage } from "./pi-embedded-utils.js"; @@ -36,16 +41,31 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) { provider: lastAssistant.provider, model: lastAssistant.model, }); + const rawError = lastAssistant.errorMessage?.trim(); + const failoverReason = classifyFailoverReason(rawError ?? ""); const errorText = (friendlyError || lastAssistant.errorMessage || "LLM request failed.").trim(); - ctx.log.warn( - `embedded run agent end: runId=${ctx.params.runId} isError=true error=${errorText}`, - ); + const observedError = buildApiErrorObservationFields(rawError); + const safeErrorText = + buildTextObservationFields(errorText).textPreview ?? "LLM request failed."; + const safeRunId = sanitizeForConsole(ctx.params.runId) ?? "-"; + ctx.log.warn("embedded run agent end", { + event: "embedded_run_agent_end", + tags: ["error_handling", "lifecycle", "agent_end", "assistant_error"], + runId: ctx.params.runId, + isError: true, + error: safeErrorText, + failoverReason, + provider: lastAssistant.provider, + model: lastAssistant.model, + ...observedError, + consoleMessage: `embedded run agent end: runId=${safeRunId} isError=true error=${safeErrorText}`, + }); emitAgentEvent({ runId: ctx.params.runId, stream: "lifecycle", data: { phase: "error", - error: errorText, + error: safeErrorText, endedAt: Date.now(), }, }); @@ -53,7 +73,7 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) { stream: "lifecycle", data: { phase: "error", - error: errorText, + error: safeErrorText, }, }); } else { diff --git a/src/agents/pi-embedded-subscribe.handlers.types.ts b/src/agents/pi-embedded-subscribe.handlers.types.ts index 1a9d48f46f0..955af473b9e 100644 --- a/src/agents/pi-embedded-subscribe.handlers.types.ts +++ b/src/agents/pi-embedded-subscribe.handlers.types.ts @@ -12,8 +12,8 @@ import type { import type { NormalizedUsage } from "./usage.js"; export type EmbeddedSubscribeLogger = { - debug: (message: string) => void; - warn: (message: string) => void; + debug: (message: string, meta?: Record) => void; + warn: (message: string, meta?: Record) => void; }; export type ToolErrorSummary = { From d86647d7dbcbde03f549490450f148d159785161 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 9 Mar 2026 12:35:31 -0700 Subject: [PATCH 0005/1173] Doctor: fix non-interactive cron repair gating (#41386) --- src/commands/doctor-cron.test.ts | 111 +++++++++++++++++++++++++++++++ src/commands/doctor-cron.ts | 13 ++-- 2 files changed, 116 insertions(+), 8 deletions(-) diff --git a/src/commands/doctor-cron.test.ts b/src/commands/doctor-cron.test.ts index 8c9faf0e24d..e7af38f662c 100644 --- a/src/commands/doctor-cron.test.ts +++ b/src/commands/doctor-cron.test.ts @@ -155,4 +155,115 @@ describe("maybeRepairLegacyCronStore", () => { "Doctor warnings", ); }); + + it("does not auto-repair in non-interactive mode without explicit repair approval", async () => { + const storePath = await makeTempStorePath(); + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile( + storePath, + JSON.stringify( + { + version: 1, + jobs: [ + { + jobId: "legacy-job", + name: "Legacy job", + notify: true, + createdAtMs: Date.parse("2026-02-01T00:00:00.000Z"), + updatedAtMs: Date.parse("2026-02-02T00:00:00.000Z"), + schedule: { kind: "cron", cron: "0 7 * * *", tz: "UTC" }, + payload: { + kind: "systemEvent", + text: "Morning brief", + }, + state: {}, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + const prompter = makePrompter(false); + + await maybeRepairLegacyCronStore({ + cfg: { + cron: { + store: storePath, + webhook: "https://example.invalid/cron-finished", + }, + }, + options: { nonInteractive: true }, + prompter, + }); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { + jobs: Array>; + }; + expect(prompter.confirm).toHaveBeenCalledWith({ + message: "Repair legacy cron jobs now?", + initialValue: true, + }); + expect(persisted.jobs[0]?.jobId).toBe("legacy-job"); + expect(persisted.jobs[0]?.notify).toBe(true); + expect(noteSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Cron store normalized"), + "Doctor changes", + ); + }); + + it("migrates notify fallback none delivery jobs to cron.webhook", async () => { + const storePath = await makeTempStorePath(); + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile( + storePath, + JSON.stringify( + { + version: 1, + jobs: [ + { + id: "notify-none", + name: "Notify none", + notify: true, + createdAtMs: Date.parse("2026-02-01T00:00:00.000Z"), + updatedAtMs: Date.parse("2026-02-02T00:00:00.000Z"), + schedule: { kind: "every", everyMs: 60_000 }, + payload: { + kind: "systemEvent", + text: "Status", + }, + delivery: { mode: "none", to: "123456789" }, + state: {}, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + await maybeRepairLegacyCronStore({ + cfg: { + cron: { + store: storePath, + webhook: "https://example.invalid/cron-finished", + }, + }, + options: {}, + prompter: makePrompter(true), + }); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { + jobs: Array>; + }; + expect(persisted.jobs[0]?.notify).toBeUndefined(); + expect(persisted.jobs[0]?.delivery).toMatchObject({ + mode: "webhook", + to: "https://example.invalid/cron-finished", + }); + }); }); diff --git a/src/commands/doctor-cron.ts b/src/commands/doctor-cron.ts index 3dc6275e800..53963cb0d14 100644 --- a/src/commands/doctor-cron.ts +++ b/src/commands/doctor-cron.ts @@ -96,7 +96,7 @@ function migrateLegacyNotifyFallback(params: { raw.delivery = { ...delivery, mode: "webhook", - to: to ?? params.legacyWebhook, + to: mode === "none" ? params.legacyWebhook : (to ?? params.legacyWebhook), }; delete raw.notify; changed = true; @@ -152,13 +152,10 @@ export async function maybeRepairLegacyCronStore(params: { "Cron", ); - const shouldRepair = - params.options.nonInteractive === true - ? true - : await params.prompter.confirm({ - message: "Repair legacy cron jobs now?", - initialValue: true, - }); + const shouldRepair = await params.prompter.confirm({ + message: "Repair legacy cron jobs now?", + initialValue: true, + }); if (!shouldRepair) { return; } From 0bcddb3d4f093a25d616e5f82a37b7c7d7cb038e Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:12:23 +0100 Subject: [PATCH 0006/1173] iOS: reconnect gateway on foreground return (#41384) Merged via squash. Prepared head SHA: 0e2e0dcc36fb90e92342430198f82f9594c8caf3 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + apps/ios/Sources/Model/NodeAppModel.swift | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 028a09e896c..25c4aed8ce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - ACP/stop reason mapping: resolve gateway chat `state: "error"` completions as ACP `end_turn` instead of `refusal` so transient backend failures are not surfaced as deliberate refusals. (#41187) thanks @pejmanjohn. - ACP/setSessionMode: propagate gateway `sessions.patch` failures back to ACP clients so rejected mode changes no longer return silent success. (#41185) thanks @pejmanjohn. - Agents/embedded logs: add structured, sanitized lifecycle and failover observation events so overload and provider failures are easier to tail and filter. (#41336) thanks @altaywtf. +- iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky. ## 2026.3.8 diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index e5a8c216161..4b9483e7662 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -362,7 +362,14 @@ final class NodeAppModel { await MainActor.run { self.operatorConnected = false self.gatewayConnected = false + // Foreground recovery must actively restart the saved gateway config. + // Disconnecting stale sockets alone can leave us idle if the old + // reconnect tasks were suppressed or otherwise got stuck in background. + self.gatewayStatusText = "Reconnecting…" self.talkMode.updateGatewayConnected(false) + if let cfg = self.activeGatewayConnectConfig { + self.applyGatewayConnectConfig(cfg) + } } } } From 2b2e5e203823a9ad9a31aaf47b170c92b1d0467e Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Mon, 9 Mar 2026 21:16:28 +0100 Subject: [PATCH 0007/1173] fix(cron): do not misclassify empty/NO_REPLY as interim acknowledgement (#41401) * fix(cron): do not misclassify empty/NO_REPLY as interim acknowledgement When a cron task's agent returns NO_REPLY, the payload filter strips the silent token, leaving an empty text string. isLikelyInterimCronMessage() previously returned true for empty input, causing the cron runner to inject a forced rerun prompt ('Your previous response was only an acknowledgement...'). Change the empty-string branch to return false: empty text after payload filtering means the agent deliberately chose silent completion, not that it sent an interim 'on it' message. Fixes #41246 * fix(cron): do not misclassify empty/NO_REPLY as interim acknowledgement Fixes #41246. (#41383) thanks @jackal092927. --------- Co-authored-by: xaeon2026 --- CHANGELOG.md | 1 + src/cron/isolated-agent/subagent-followup.test.ts | 8 ++++++-- src/cron/isolated-agent/subagent-followup.ts | 5 ++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25c4aed8ce9..7c9c649d7f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - ACP/setSessionMode: propagate gateway `sessions.patch` failures back to ACP clients so rejected mode changes no longer return silent success. (#41185) thanks @pejmanjohn. - Agents/embedded logs: add structured, sanitized lifecycle and failover observation events so overload and provider failures are easier to tail and filter. (#41336) thanks @altaywtf. - iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky. +- Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927. ## 2026.3.8 diff --git a/src/cron/isolated-agent/subagent-followup.test.ts b/src/cron/isolated-agent/subagent-followup.test.ts index 093da010026..c670e4c8c13 100644 --- a/src/cron/isolated-agent/subagent-followup.test.ts +++ b/src/cron/isolated-agent/subagent-followup.test.ts @@ -47,8 +47,12 @@ describe("isLikelyInterimCronMessage", () => { false, ); }); - it("treats empty as interim", () => { - expect(isLikelyInterimCronMessage("")).toBe(true); + it("does not treat empty as interim (empty = NO_REPLY was stripped)", () => { + expect(isLikelyInterimCronMessage("")).toBe(false); + }); + + it("does not treat whitespace-only as interim", () => { + expect(isLikelyInterimCronMessage(" ")).toBe(false); }); }); diff --git a/src/cron/isolated-agent/subagent-followup.ts b/src/cron/isolated-agent/subagent-followup.ts index 6d5f9d4c502..9d6ec7e78ac 100644 --- a/src/cron/isolated-agent/subagent-followup.ts +++ b/src/cron/isolated-agent/subagent-followup.ts @@ -42,7 +42,10 @@ function normalizeHintText(value: string): string { export function isLikelyInterimCronMessage(value: string): boolean { const normalized = normalizeHintText(value); if (!normalized) { - return true; + // Empty text after payload filtering means the agent either returned + // NO_REPLY (deliberately silent) or produced no deliverable content. + // Do not treat this as an interim acknowledgement that needs a rerun. + return false; } const words = normalized.split(" ").filter(Boolean).length; return words <= 45 && INTERIM_CRON_HINTS.some((hint) => normalized.includes(hint)); From 5f90883ad378920249160fe2d9c610c362be765c Mon Sep 17 00:00:00 2001 From: zerone0x Date: Tue, 10 Mar 2026 04:40:11 +0800 Subject: [PATCH 0008/1173] fix(auth): reset cooldown error counters on expiry to prevent infinite escalation (#41028) Merged via squash. Prepared head SHA: 89bd83f09a141f68c0cd715a3652559ad04be7c6 Co-authored-by: zerone0x <39543393+zerone0x@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + ...th-profiles.markauthprofilefailure.test.ts | 52 +++++++++++++++++++ src/agents/auth-profiles/usage.test.ts | 15 ++++-- src/agents/auth-profiles/usage.ts | 14 ++++- 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c9c649d7f9..d38c10cf6d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Agents/embedded logs: add structured, sanitized lifecycle and failover observation events so overload and provider failures are easier to tail and filter. (#41336) thanks @altaywtf. - iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky. - Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927. +- Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x. ## 2026.3.8 diff --git a/src/agents/auth-profiles.markauthprofilefailure.test.ts b/src/agents/auth-profiles.markauthprofilefailure.test.ts index e5690f75c6a..5c4d73197b3 100644 --- a/src/agents/auth-profiles.markauthprofilefailure.test.ts +++ b/src/agents/auth-profiles.markauthprofilefailure.test.ts @@ -190,6 +190,58 @@ describe("markAuthProfileFailure", () => { } }); + it("resets error count when previous cooldown has expired to prevent escalation", async () => { + const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-")); + try { + const authPath = path.join(agentDir, "auth-profiles.json"); + const now = Date.now(); + // Simulate state left on disk after 3 rapid failures within a 1-min cooldown + // window. The cooldown has since expired, but clearExpiredCooldowns() only + // ran in-memory and never persisted — so disk still carries errorCount: 3. + fs.writeFileSync( + authPath, + JSON.stringify({ + version: 1, + profiles: { + "anthropic:default": { + type: "api_key", + provider: "anthropic", + key: "sk-default", + }, + }, + usageStats: { + "anthropic:default": { + errorCount: 3, + failureCounts: { rate_limit: 3 }, + lastFailureAt: now - 120_000, // 2 minutes ago + cooldownUntil: now - 60_000, // expired 1 minute ago + }, + }, + }), + ); + + const store = ensureAuthProfileStore(agentDir); + await markAuthProfileFailure({ + store, + profileId: "anthropic:default", + reason: "rate_limit", + agentDir, + }); + + const stats = store.usageStats?.["anthropic:default"]; + // Error count should reset to 1 (not escalate to 4) because the + // previous cooldown expired. Cooldown should be ~1 min, not ~60 min. + expect(stats?.errorCount).toBe(1); + expect(stats?.failureCounts?.rate_limit).toBe(1); + const cooldownMs = (stats?.cooldownUntil ?? 0) - now; + // calculateAuthProfileCooldownMs(1) = 60_000 (1 minute) + expect(cooldownMs).toBeLessThan(120_000); + expect(cooldownMs).toBeGreaterThan(0); + } finally { + fs.rmSync(agentDir, { recursive: true, force: true }); + } + }); + it("does not persist cooldown windows for OpenRouter profiles", async () => { await withAuthProfileStore(async ({ agentDir, store }) => { await markAuthProfileFailure({ diff --git a/src/agents/auth-profiles/usage.test.ts b/src/agents/auth-profiles/usage.test.ts index 120f75d3665..261eae6efd5 100644 --- a/src/agents/auth-profiles/usage.test.ts +++ b/src/agents/auth-profiles/usage.test.ts @@ -608,6 +608,10 @@ describe("markAuthProfileFailure — active windows do not extend on retry", () }); } + // When a cooldown/disabled window expires, the error count resets to prevent + // stale counters from escalating the next cooldown (the root cause of + // infinite cooldown loops — see #40989). The next failure should compute + // backoff from errorCount=1, not from the accumulated stale count. const expiredWindowCases = [ { label: "cooldownUntil", @@ -617,7 +621,8 @@ describe("markAuthProfileFailure — active windows do not extend on retry", () errorCount: 3, lastFailureAt: now - 60_000, }), - expectedUntil: (now: number) => now + 60 * 60 * 1000, + // errorCount resets → calculateAuthProfileCooldownMs(1) = 60_000 + expectedUntil: (now: number) => now + 60_000, readUntil: (stats: WindowStats | undefined) => stats?.cooldownUntil, }, { @@ -630,7 +635,9 @@ describe("markAuthProfileFailure — active windows do not extend on retry", () failureCounts: { billing: 2 }, lastFailureAt: now - 60_000, }), - expectedUntil: (now: number) => now + 20 * 60 * 60 * 1000, + // errorCount resets, billing count resets to 1 → + // calculateAuthProfileBillingDisableMsWithConfig(1, 5h, 24h) = 5h + expectedUntil: (now: number) => now + 5 * 60 * 60 * 1000, readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil, }, { @@ -643,7 +650,9 @@ describe("markAuthProfileFailure — active windows do not extend on retry", () failureCounts: { auth_permanent: 2 }, lastFailureAt: now - 60_000, }), - expectedUntil: (now: number) => now + 20 * 60 * 60 * 1000, + // errorCount resets, auth_permanent count resets to 1 → + // calculateAuthProfileBillingDisableMsWithConfig(1, 5h, 24h) = 5h + expectedUntil: (now: number) => now + 5 * 60 * 60 * 1000, readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil, }, ]; diff --git a/src/agents/auth-profiles/usage.ts b/src/agents/auth-profiles/usage.ts index c28b51e3e57..0d9ae6a6aaa 100644 --- a/src/agents/auth-profiles/usage.ts +++ b/src/agents/auth-profiles/usage.ts @@ -400,9 +400,19 @@ function computeNextProfileUsageStats(params: { params.existing.lastFailureAt > 0 && params.now - params.existing.lastFailureAt > windowMs; - const baseErrorCount = windowExpired ? 0 : (params.existing.errorCount ?? 0); + // If the previous cooldown has already expired, reset error counters so the + // profile gets a fresh backoff window. clearExpiredCooldowns() does this + // in-memory during profile ordering, but the on-disk state may still carry + // the old counters when the lock-based updater reads a fresh store. Without + // this check, stale error counts from an expired cooldown cause the next + // failure to escalate to a much longer cooldown (e.g. 1 min → 25 min). + const unusableUntil = resolveProfileUnusableUntil(params.existing); + const previousCooldownExpired = typeof unusableUntil === "number" && params.now >= unusableUntil; + + const shouldResetCounters = windowExpired || previousCooldownExpired; + const baseErrorCount = shouldResetCounters ? 0 : (params.existing.errorCount ?? 0); const nextErrorCount = baseErrorCount + 1; - const failureCounts = windowExpired ? {} : { ...params.existing.failureCounts }; + const failureCounts = shouldResetCounters ? {} : { ...params.existing.failureCounts }; failureCounts[params.reason] = (failureCounts[params.reason] ?? 0) + 1; const updatedStats: ProfileUsageStats = { From ef95975411a9a53084c91f6a123759eb42fb032c Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:42:57 +0100 Subject: [PATCH 0009/1173] Gateway: add pending node work primitives (#41409) Merged via squash. Prepared head SHA: a6d7ca90d71a33c6d634a6396d1e7ae40545ea66 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 2 + src/gateway/method-scopes.test.ts | 4 + src/gateway/method-scopes.ts | 2 + src/gateway/node-pending-work.test.ts | 46 +++++ src/gateway/node-pending-work.ts | 182 ++++++++++++++++++ src/gateway/protocol/index.ts | 22 +++ src/gateway/protocol/schema/nodes.ts | 58 ++++++ .../protocol/schema/protocol-schemas.ts | 8 + src/gateway/protocol/schema/types.ts | 4 + src/gateway/role-policy.test.ts | 2 + src/gateway/server-methods-list.ts | 2 + src/gateway/server-methods.ts | 2 + .../server-methods/nodes-pending.test.ts | 177 +++++++++++++++++ src/gateway/server-methods/nodes-pending.ts | 159 +++++++++++++++ src/gateway/server-methods/nodes.ts | 16 +- 15 files changed, 678 insertions(+), 8 deletions(-) create mode 100644 src/gateway/node-pending-work.test.ts create mode 100644 src/gateway/node-pending-work.ts create mode 100644 src/gateway/server-methods/nodes-pending.test.ts create mode 100644 src/gateway/server-methods/nodes-pending.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d38c10cf6d6..7b25de7522d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Docs: https://docs.openclaw.ai ### Changes +- Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky. + ### Breaking - Cron/doctor: tighten isolated cron delivery so cron jobs can no longer notify through ad hoc agent sends or fallback main-session summaries, and add `openclaw doctor --fix` migration for legacy cron storage and legacy notify/webhook delivery metadata. (#40998) Thanks @mbelinky. diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 1479611d484..18ff74509ee 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -18,6 +18,10 @@ describe("method scope resolution", () => { expect(resolveLeastPrivilegeOperatorScopesForMethod("poll")).toEqual(["operator.write"]); }); + it("leaves node-only pending drain outside operator scopes", () => { + expect(resolveLeastPrivilegeOperatorScopesForMethod("node.pending.drain")).toEqual([]); + }); + it("returns empty scopes for unknown methods", () => { expect(resolveLeastPrivilegeOperatorScopesForMethod("totally.unknown.method")).toEqual([]); }); diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 91b20baacb0..ec8279a1947 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -22,6 +22,7 @@ export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [ const NODE_ROLE_METHODS = new Set([ "node.invoke.result", "node.event", + "node.pending.drain", "node.canvas.capability.refresh", "node.pending.pull", "node.pending.ack", @@ -102,6 +103,7 @@ const METHOD_SCOPE_GROUPS: Record = { "chat.abort", "browser.request", "push.test", + "node.pending.enqueue", ], [ADMIN_SCOPE]: [ "channels.logout", diff --git a/src/gateway/node-pending-work.test.ts b/src/gateway/node-pending-work.test.ts new file mode 100644 index 00000000000..3c2222dd3a9 --- /dev/null +++ b/src/gateway/node-pending-work.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { + acknowledgeNodePendingWork, + drainNodePendingWork, + enqueueNodePendingWork, + resetNodePendingWorkForTests, +} from "./node-pending-work.js"; + +describe("node pending work", () => { + beforeEach(() => { + resetNodePendingWorkForTests(); + }); + + it("returns a baseline status request even when no explicit work is queued", () => { + const drained = drainNodePendingWork("node-1"); + expect(drained.items).toEqual([ + expect.objectContaining({ + id: "baseline-status", + type: "status.request", + priority: "default", + }), + ]); + expect(drained.hasMore).toBe(false); + }); + + it("dedupes explicit work by type and removes acknowledged items", () => { + const first = enqueueNodePendingWork({ nodeId: "node-2", type: "location.request" }); + const second = enqueueNodePendingWork({ nodeId: "node-2", type: "location.request" }); + + expect(first.deduped).toBe(false); + expect(second.deduped).toBe(true); + expect(second.item.id).toBe(first.item.id); + + const drained = drainNodePendingWork("node-2"); + expect(drained.items.map((item) => item.type)).toEqual(["location.request", "status.request"]); + + const acked = acknowledgeNodePendingWork({ + nodeId: "node-2", + itemIds: [first.item.id, "baseline-status"], + }); + expect(acked.removedItemIds).toEqual([first.item.id]); + + const afterAck = drainNodePendingWork("node-2"); + expect(afterAck.items.map((item) => item.id)).toEqual(["baseline-status"]); + }); +}); diff --git a/src/gateway/node-pending-work.ts b/src/gateway/node-pending-work.ts new file mode 100644 index 00000000000..33d356777d2 --- /dev/null +++ b/src/gateway/node-pending-work.ts @@ -0,0 +1,182 @@ +import { randomUUID } from "node:crypto"; + +export const NODE_PENDING_WORK_TYPES = ["status.request", "location.request"] as const; +export type NodePendingWorkType = (typeof NODE_PENDING_WORK_TYPES)[number]; + +export const NODE_PENDING_WORK_PRIORITIES = ["default", "normal", "high"] as const; +export type NodePendingWorkPriority = (typeof NODE_PENDING_WORK_PRIORITIES)[number]; + +export type NodePendingWorkItem = { + id: string; + type: NodePendingWorkType; + priority: NodePendingWorkPriority; + createdAtMs: number; + expiresAtMs: number | null; + payload?: Record; +}; + +type NodePendingWorkState = { + revision: number; + itemsById: Map; +}; + +type DrainOptions = { + maxItems?: number; + includeDefaultStatus?: boolean; + nowMs?: number; +}; + +type DrainResult = { + revision: number; + items: NodePendingWorkItem[]; + hasMore: boolean; +}; + +const DEFAULT_STATUS_ITEM_ID = "baseline-status"; +const DEFAULT_STATUS_PRIORITY: NodePendingWorkPriority = "default"; +const DEFAULT_PRIORITY: NodePendingWorkPriority = "normal"; +const DEFAULT_MAX_ITEMS = 4; +const MAX_ITEMS = 10; +const PRIORITY_RANK: Record = { + high: 3, + normal: 2, + default: 1, +}; + +const stateByNodeId = new Map(); + +function getState(nodeId: string): NodePendingWorkState { + let state = stateByNodeId.get(nodeId); + if (!state) { + state = { + revision: 0, + itemsById: new Map(), + }; + stateByNodeId.set(nodeId, state); + } + return state; +} + +function pruneExpired(state: NodePendingWorkState, nowMs: number): boolean { + let changed = false; + for (const [id, item] of state.itemsById) { + if (item.expiresAtMs !== null && item.expiresAtMs <= nowMs) { + state.itemsById.delete(id); + changed = true; + } + } + if (changed) { + state.revision += 1; + } + return changed; +} + +function sortedItems(state: NodePendingWorkState): NodePendingWorkItem[] { + return [...state.itemsById.values()].toSorted((a, b) => { + const priorityDelta = PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]; + if (priorityDelta !== 0) { + return priorityDelta; + } + if (a.createdAtMs !== b.createdAtMs) { + return a.createdAtMs - b.createdAtMs; + } + return a.id.localeCompare(b.id); + }); +} + +function makeBaselineStatusItem(nowMs: number): NodePendingWorkItem { + return { + id: DEFAULT_STATUS_ITEM_ID, + type: "status.request", + priority: DEFAULT_STATUS_PRIORITY, + createdAtMs: nowMs, + expiresAtMs: null, + }; +} + +export function enqueueNodePendingWork(params: { + nodeId: string; + type: NodePendingWorkType; + priority?: NodePendingWorkPriority; + expiresInMs?: number; + payload?: Record; +}): { revision: number; item: NodePendingWorkItem; deduped: boolean } { + const nodeId = params.nodeId.trim(); + if (!nodeId) { + throw new Error("nodeId required"); + } + const nowMs = Date.now(); + const state = getState(nodeId); + pruneExpired(state, nowMs); + const existing = [...state.itemsById.values()].find((item) => item.type === params.type); + if (existing) { + return { revision: state.revision, item: existing, deduped: true }; + } + const item: NodePendingWorkItem = { + id: randomUUID(), + type: params.type, + priority: params.priority ?? DEFAULT_PRIORITY, + createdAtMs: nowMs, + expiresAtMs: + typeof params.expiresInMs === "number" && Number.isFinite(params.expiresInMs) + ? nowMs + Math.max(1_000, Math.trunc(params.expiresInMs)) + : null, + ...(params.payload ? { payload: params.payload } : {}), + }; + state.itemsById.set(item.id, item); + state.revision += 1; + return { revision: state.revision, item, deduped: false }; +} + +export function drainNodePendingWork(nodeId: string, opts: DrainOptions = {}): DrainResult { + const normalizedNodeId = nodeId.trim(); + if (!normalizedNodeId) { + return { revision: 0, items: [], hasMore: false }; + } + const nowMs = opts.nowMs ?? Date.now(); + const state = getState(normalizedNodeId); + pruneExpired(state, nowMs); + const maxItems = Math.min(MAX_ITEMS, Math.max(1, Math.trunc(opts.maxItems ?? DEFAULT_MAX_ITEMS))); + const explicitItems = sortedItems(state); + const items = explicitItems.slice(0, maxItems); + const hasExplicitStatus = explicitItems.some((item) => item.type === "status.request"); + const includeBaseline = opts.includeDefaultStatus !== false && !hasExplicitStatus; + if (includeBaseline && items.length < maxItems) { + items.push(makeBaselineStatusItem(nowMs)); + } + return { + revision: state.revision, + items, + hasMore: + explicitItems.length > items.filter((item) => item.id !== DEFAULT_STATUS_ITEM_ID).length, + }; +} + +export function acknowledgeNodePendingWork(params: { nodeId: string; itemIds: string[] }): { + revision: number; + removedItemIds: string[]; +} { + const nodeId = params.nodeId.trim(); + if (!nodeId) { + return { revision: 0, removedItemIds: [] }; + } + const state = getState(nodeId); + const removedItemIds: string[] = []; + for (const itemId of params.itemIds) { + const trimmedId = itemId.trim(); + if (!trimmedId || trimmedId === DEFAULT_STATUS_ITEM_ID) { + continue; + } + if (state.itemsById.delete(trimmedId)) { + removedItemIds.push(trimmedId); + } + } + if (removedItemIds.length > 0) { + state.revision += 1; + } + return { revision: state.revision, removedItemIds }; +} + +export function resetNodePendingWorkForTests() { + stateByNodeId.clear(); +} diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 95306f27f12..9c469333363 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -140,6 +140,14 @@ import { NodeDescribeParamsSchema, type NodeEventParams, NodeEventParamsSchema, + type NodePendingDrainParams, + NodePendingDrainParamsSchema, + type NodePendingDrainResult, + NodePendingDrainResultSchema, + type NodePendingEnqueueParams, + NodePendingEnqueueParamsSchema, + type NodePendingEnqueueResult, + NodePendingEnqueueResultSchema, type NodeInvokeParams, NodeInvokeParamsSchema, type NodeInvokeResultParams, @@ -296,6 +304,12 @@ export const validateNodeInvokeResultParams = ajv.compile(NodeEventParamsSchema); +export const validateNodePendingDrainParams = ajv.compile( + NodePendingDrainParamsSchema, +); +export const validateNodePendingEnqueueParams = ajv.compile( + NodePendingEnqueueParamsSchema, +); export const validatePushTestParams = ajv.compile(PushTestParamsSchema); export const validateSecretsResolveParams = ajv.compile( SecretsResolveParamsSchema, @@ -472,6 +486,10 @@ export { NodeListParamsSchema, NodePendingAckParamsSchema, NodeInvokeParamsSchema, + NodePendingDrainParamsSchema, + NodePendingDrainResultSchema, + NodePendingEnqueueParamsSchema, + NodePendingEnqueueResultSchema, SessionsListParamsSchema, SessionsPreviewParamsSchema, SessionsPatchParamsSchema, @@ -621,6 +639,10 @@ export type { NodeInvokeParams, NodeInvokeResultParams, NodeEventParams, + NodePendingDrainParams, + NodePendingDrainResult, + NodePendingEnqueueParams, + NodePendingEnqueueResult, SessionsListParams, SessionsPreviewParams, SessionsResolveParams, diff --git a/src/gateway/protocol/schema/nodes.ts b/src/gateway/protocol/schema/nodes.ts index 7ce5a4fed0a..413bd42fa42 100644 --- a/src/gateway/protocol/schema/nodes.ts +++ b/src/gateway/protocol/schema/nodes.ts @@ -1,6 +1,14 @@ import { Type } from "@sinclair/typebox"; import { NonEmptyString } from "./primitives.js"; +const NodePendingWorkTypeSchema = Type.String({ + enum: ["status.request", "location.request"], +}); + +const NodePendingWorkPrioritySchema = Type.String({ + enum: ["normal", "high"], +}); + export const NodePairRequestParamsSchema = Type.Object( { nodeId: NonEmptyString, @@ -95,6 +103,56 @@ export const NodeEventParamsSchema = Type.Object( { additionalProperties: false }, ); +export const NodePendingDrainParamsSchema = Type.Object( + { + maxItems: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })), + }, + { additionalProperties: false }, +); + +export const NodePendingDrainItemSchema = Type.Object( + { + id: NonEmptyString, + type: NodePendingWorkTypeSchema, + priority: Type.String({ enum: ["default", "normal", "high"] }), + createdAtMs: Type.Integer({ minimum: 0 }), + expiresAtMs: Type.Optional(Type.Union([Type.Integer({ minimum: 0 }), Type.Null()])), + payload: Type.Optional(Type.Record(Type.String(), Type.Unknown())), + }, + { additionalProperties: false }, +); + +export const NodePendingDrainResultSchema = Type.Object( + { + nodeId: NonEmptyString, + revision: Type.Integer({ minimum: 0 }), + items: Type.Array(NodePendingDrainItemSchema), + hasMore: Type.Boolean(), + }, + { additionalProperties: false }, +); + +export const NodePendingEnqueueParamsSchema = Type.Object( + { + nodeId: NonEmptyString, + type: NodePendingWorkTypeSchema, + priority: Type.Optional(NodePendingWorkPrioritySchema), + expiresInMs: Type.Optional(Type.Integer({ minimum: 1_000, maximum: 86_400_000 })), + wake: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +); + +export const NodePendingEnqueueResultSchema = Type.Object( + { + nodeId: NonEmptyString, + revision: Type.Integer({ minimum: 0 }), + queued: NodePendingDrainItemSchema, + wakeTriggered: Type.Boolean(), + }, + { additionalProperties: false }, +); + export const NodeInvokeRequestEventSchema = Type.Object( { id: NonEmptyString, diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 7ccd6cb2d1a..574a74d8d41 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -114,6 +114,10 @@ import { import { NodeDescribeParamsSchema, NodeEventParamsSchema, + NodePendingDrainParamsSchema, + NodePendingDrainResultSchema, + NodePendingEnqueueParamsSchema, + NodePendingEnqueueResultSchema, NodeInvokeParamsSchema, NodeInvokeResultParamsSchema, NodeInvokeRequestEventSchema, @@ -186,6 +190,10 @@ export const ProtocolSchemas = { NodeInvokeParams: NodeInvokeParamsSchema, NodeInvokeResultParams: NodeInvokeResultParamsSchema, NodeEventParams: NodeEventParamsSchema, + NodePendingDrainParams: NodePendingDrainParamsSchema, + NodePendingDrainResult: NodePendingDrainResultSchema, + NodePendingEnqueueParams: NodePendingEnqueueParamsSchema, + NodePendingEnqueueResult: NodePendingEnqueueResultSchema, NodeInvokeRequestEvent: NodeInvokeRequestEventSchema, PushTestParams: PushTestParamsSchema, PushTestResult: PushTestResultSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index cc15b80fd1a..56656aff1a3 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -32,6 +32,10 @@ export type NodeDescribeParams = SchemaType<"NodeDescribeParams">; export type NodeInvokeParams = SchemaType<"NodeInvokeParams">; export type NodeInvokeResultParams = SchemaType<"NodeInvokeResultParams">; export type NodeEventParams = SchemaType<"NodeEventParams">; +export type NodePendingDrainParams = SchemaType<"NodePendingDrainParams">; +export type NodePendingDrainResult = SchemaType<"NodePendingDrainResult">; +export type NodePendingEnqueueParams = SchemaType<"NodePendingEnqueueParams">; +export type NodePendingEnqueueResult = SchemaType<"NodePendingEnqueueResult">; export type PushTestParams = SchemaType<"PushTestParams">; export type PushTestResult = SchemaType<"PushTestResult">; export type SessionsListParams = SchemaType<"SessionsListParams">; diff --git a/src/gateway/role-policy.test.ts b/src/gateway/role-policy.test.ts index ba371b56bfe..5bc3e1f1a28 100644 --- a/src/gateway/role-policy.test.ts +++ b/src/gateway/role-policy.test.ts @@ -21,8 +21,10 @@ describe("gateway role policy", () => { test("authorizes roles against node vs operator methods", () => { expect(isRoleAuthorizedForMethod("node", "node.event")).toBe(true); + expect(isRoleAuthorizedForMethod("node", "node.pending.drain")).toBe(true); expect(isRoleAuthorizedForMethod("node", "status")).toBe(false); expect(isRoleAuthorizedForMethod("operator", "status")).toBe(true); + expect(isRoleAuthorizedForMethod("operator", "node.pending.drain")).toBe(false); expect(isRoleAuthorizedForMethod("operator", "node.event")).toBe(false); }); }); diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 5c5433ae2f7..2785eb7957e 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -76,6 +76,8 @@ const BASE_METHODS = [ "node.rename", "node.list", "node.describe", + "node.pending.drain", + "node.pending.enqueue", "node.invoke", "node.pending.pull", "node.pending.ack", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 62cd6bbcd9e..483914b9bf5 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -18,6 +18,7 @@ import { execApprovalsHandlers } from "./server-methods/exec-approvals.js"; import { healthHandlers } from "./server-methods/health.js"; import { logsHandlers } from "./server-methods/logs.js"; import { modelsHandlers } from "./server-methods/models.js"; +import { nodePendingHandlers } from "./server-methods/nodes-pending.js"; import { nodeHandlers } from "./server-methods/nodes.js"; import { pushHandlers } from "./server-methods/push.js"; import { sendHandlers } from "./server-methods/send.js"; @@ -87,6 +88,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...systemHandlers, ...updateHandlers, ...nodeHandlers, + ...nodePendingHandlers, ...pushHandlers, ...sendHandlers, ...usageHandlers, diff --git a/src/gateway/server-methods/nodes-pending.test.ts b/src/gateway/server-methods/nodes-pending.test.ts new file mode 100644 index 00000000000..110ef8711e4 --- /dev/null +++ b/src/gateway/server-methods/nodes-pending.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { nodePendingHandlers } from "./nodes-pending.js"; + +const mocks = vi.hoisted(() => ({ + drainNodePendingWork: vi.fn(), + enqueueNodePendingWork: vi.fn(), + maybeWakeNodeWithApns: vi.fn(), + maybeSendNodeWakeNudge: vi.fn(), + waitForNodeReconnect: vi.fn(), +})); + +vi.mock("../node-pending-work.js", () => ({ + drainNodePendingWork: mocks.drainNodePendingWork, + enqueueNodePendingWork: mocks.enqueueNodePendingWork, +})); + +vi.mock("./nodes.js", () => ({ + NODE_WAKE_RECONNECT_WAIT_MS: 3_000, + NODE_WAKE_RECONNECT_RETRY_WAIT_MS: 12_000, + maybeWakeNodeWithApns: mocks.maybeWakeNodeWithApns, + maybeSendNodeWakeNudge: mocks.maybeSendNodeWakeNudge, + waitForNodeReconnect: mocks.waitForNodeReconnect, +})); + +type RespondCall = [ + boolean, + unknown?, + { + code?: number; + message?: string; + details?: unknown; + }?, +]; + +function makeContext(overrides?: Partial>) { + return { + nodeRegistry: { + get: vi.fn(() => undefined), + }, + logGateway: { + info: vi.fn(), + warn: vi.fn(), + }, + ...overrides, + }; +} + +describe("node.pending handlers", () => { + beforeEach(() => { + mocks.drainNodePendingWork.mockReset(); + mocks.enqueueNodePendingWork.mockReset(); + mocks.maybeWakeNodeWithApns.mockReset(); + mocks.maybeSendNodeWakeNudge.mockReset(); + mocks.waitForNodeReconnect.mockReset(); + }); + + it("drains pending work for the connected node identity", async () => { + mocks.drainNodePendingWork.mockReturnValue({ + revision: 2, + items: [{ id: "baseline-status", type: "status.request", priority: "default" }], + hasMore: false, + }); + const respond = vi.fn(); + + await nodePendingHandlers["node.pending.drain"]({ + params: { maxItems: 3 }, + respond: respond as never, + client: { connect: { device: { id: "ios-node-1" } } } as never, + context: makeContext() as never, + req: { type: "req", id: "req-node-pending-drain", method: "node.pending.drain" }, + isWebchatConnect: () => false, + }); + + expect(mocks.drainNodePendingWork).toHaveBeenCalledWith("ios-node-1", { + maxItems: 3, + includeDefaultStatus: true, + }); + expect(respond).toHaveBeenCalledWith( + true, + { + nodeId: "ios-node-1", + revision: 2, + items: [{ id: "baseline-status", type: "status.request", priority: "default" }], + hasMore: false, + }, + undefined, + ); + }); + + it("rejects node.pending.drain without a connected device identity", async () => { + const respond = vi.fn(); + + await nodePendingHandlers["node.pending.drain"]({ + params: {}, + respond: respond as never, + client: null, + context: makeContext() as never, + req: { type: "req", id: "req-node-pending-drain-missing", method: "node.pending.drain" }, + isWebchatConnect: () => false, + }); + + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[2]?.message).toContain("connected device identity"); + }); + + it("enqueues pending work and wakes a disconnected node once", async () => { + mocks.enqueueNodePendingWork.mockReturnValue({ + revision: 4, + deduped: false, + item: { + id: "pending-1", + type: "location.request", + priority: "high", + createdAtMs: 100, + expiresAtMs: null, + }, + }); + mocks.maybeWakeNodeWithApns.mockResolvedValue({ + available: true, + throttled: false, + path: "apns", + durationMs: 12, + apnsStatus: 200, + apnsReason: null, + }); + let connected = false; + mocks.waitForNodeReconnect.mockImplementation(async () => { + connected = true; + return true; + }); + const context = makeContext({ + nodeRegistry: { + get: vi.fn(() => (connected ? { nodeId: "ios-node-2" } : undefined)), + }, + }); + const respond = vi.fn(); + + await nodePendingHandlers["node.pending.enqueue"]({ + params: { + nodeId: "ios-node-2", + type: "location.request", + priority: "high", + }, + respond: respond as never, + client: null, + context: context as never, + req: { type: "req", id: "req-node-pending-enqueue", method: "node.pending.enqueue" }, + isWebchatConnect: () => false, + }); + + expect(mocks.enqueueNodePendingWork).toHaveBeenCalledWith({ + nodeId: "ios-node-2", + type: "location.request", + priority: "high", + expiresInMs: undefined, + }); + expect(mocks.maybeWakeNodeWithApns).toHaveBeenCalledWith("ios-node-2", { + wakeReason: "node.pending", + }); + expect(mocks.waitForNodeReconnect).toHaveBeenCalledWith({ + nodeId: "ios-node-2", + context, + timeoutMs: 3_000, + }); + expect(mocks.maybeSendNodeWakeNudge).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + nodeId: "ios-node-2", + revision: 4, + wakeTriggered: true, + }), + undefined, + ); + }); +}); diff --git a/src/gateway/server-methods/nodes-pending.ts b/src/gateway/server-methods/nodes-pending.ts new file mode 100644 index 00000000000..8c46951b072 --- /dev/null +++ b/src/gateway/server-methods/nodes-pending.ts @@ -0,0 +1,159 @@ +import { + drainNodePendingWork, + enqueueNodePendingWork, + type NodePendingWorkPriority, + type NodePendingWorkType, +} from "../node-pending-work.js"; +import { + ErrorCodes, + errorShape, + validateNodePendingDrainParams, + validateNodePendingEnqueueParams, +} from "../protocol/index.js"; +import { respondInvalidParams, respondUnavailableOnThrow } from "./nodes.helpers.js"; +import { + maybeSendNodeWakeNudge, + maybeWakeNodeWithApns, + NODE_WAKE_RECONNECT_RETRY_WAIT_MS, + NODE_WAKE_RECONNECT_WAIT_MS, + waitForNodeReconnect, +} from "./nodes.js"; +import type { GatewayRequestHandlers } from "./types.js"; + +function resolveClientNodeId( + client: { connect?: { device?: { id?: string }; client?: { id?: string } } } | null, +): string | null { + const nodeId = client?.connect?.device?.id ?? client?.connect?.client?.id ?? ""; + const trimmed = nodeId.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export const nodePendingHandlers: GatewayRequestHandlers = { + "node.pending.drain": async ({ params, respond, client }) => { + if (!validateNodePendingDrainParams(params)) { + respondInvalidParams({ + respond, + method: "node.pending.drain", + validator: validateNodePendingDrainParams, + }); + return; + } + const nodeId = resolveClientNodeId(client); + if (!nodeId) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "node.pending.drain requires a connected device identity", + ), + ); + return; + } + const p = params as { maxItems?: number }; + const drained = drainNodePendingWork(nodeId, { + maxItems: p.maxItems, + includeDefaultStatus: true, + }); + respond(true, { nodeId, ...drained }, undefined); + }, + "node.pending.enqueue": async ({ params, respond, context }) => { + if (!validateNodePendingEnqueueParams(params)) { + respondInvalidParams({ + respond, + method: "node.pending.enqueue", + validator: validateNodePendingEnqueueParams, + }); + return; + } + const p = params as { + nodeId: string; + type: NodePendingWorkType; + priority?: NodePendingWorkPriority; + expiresInMs?: number; + wake?: boolean; + }; + await respondUnavailableOnThrow(respond, async () => { + const queued = enqueueNodePendingWork({ + nodeId: p.nodeId, + type: p.type, + priority: p.priority, + expiresInMs: p.expiresInMs, + }); + let wakeTriggered = false; + if (p.wake !== false && !queued.deduped && !context.nodeRegistry.get(p.nodeId)) { + const wakeReqId = queued.item.id; + context.logGateway.info( + `node pending wake start node=${p.nodeId} req=${wakeReqId} type=${queued.item.type}`, + ); + const wake = await maybeWakeNodeWithApns(p.nodeId, { wakeReason: "node.pending" }); + context.logGateway.info( + `node pending wake stage=wake1 node=${p.nodeId} req=${wakeReqId} ` + + `available=${wake.available} throttled=${wake.throttled} ` + + `path=${wake.path} durationMs=${wake.durationMs} ` + + `apnsStatus=${wake.apnsStatus ?? -1} apnsReason=${wake.apnsReason ?? "-"}`, + ); + wakeTriggered = wake.available; + if (wake.available) { + const reconnected = await waitForNodeReconnect({ + nodeId: p.nodeId, + context, + timeoutMs: NODE_WAKE_RECONNECT_WAIT_MS, + }); + context.logGateway.info( + `node pending wake stage=wait1 node=${p.nodeId} req=${wakeReqId} ` + + `reconnected=${reconnected} timeoutMs=${NODE_WAKE_RECONNECT_WAIT_MS}`, + ); + } + if (!context.nodeRegistry.get(p.nodeId) && wake.available) { + const retryWake = await maybeWakeNodeWithApns(p.nodeId, { + force: true, + wakeReason: "node.pending", + }); + context.logGateway.info( + `node pending wake stage=wake2 node=${p.nodeId} req=${wakeReqId} force=true ` + + `available=${retryWake.available} throttled=${retryWake.throttled} ` + + `path=${retryWake.path} durationMs=${retryWake.durationMs} ` + + `apnsStatus=${retryWake.apnsStatus ?? -1} apnsReason=${retryWake.apnsReason ?? "-"}`, + ); + if (retryWake.available) { + const reconnected = await waitForNodeReconnect({ + nodeId: p.nodeId, + context, + timeoutMs: NODE_WAKE_RECONNECT_RETRY_WAIT_MS, + }); + context.logGateway.info( + `node pending wake stage=wait2 node=${p.nodeId} req=${wakeReqId} ` + + `reconnected=${reconnected} timeoutMs=${NODE_WAKE_RECONNECT_RETRY_WAIT_MS}`, + ); + } + } + if (!context.nodeRegistry.get(p.nodeId)) { + const nudge = await maybeSendNodeWakeNudge(p.nodeId); + context.logGateway.info( + `node pending wake nudge node=${p.nodeId} req=${wakeReqId} sent=${nudge.sent} ` + + `throttled=${nudge.throttled} reason=${nudge.reason} durationMs=${nudge.durationMs} ` + + `apnsStatus=${nudge.apnsStatus ?? -1} apnsReason=${nudge.apnsReason ?? "-"}`, + ); + context.logGateway.warn( + `node pending wake done node=${p.nodeId} req=${wakeReqId} connected=false reason=not_connected`, + ); + } else { + context.logGateway.info( + `node pending wake done node=${p.nodeId} req=${wakeReqId} connected=true`, + ); + } + } + respond( + true, + { + nodeId: p.nodeId, + revision: queued.revision, + queued: queued.item, + wakeTriggered, + }, + undefined, + ); + }); + }, +}; diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 22e3c0912e4..fadbb0e3742 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -47,9 +47,9 @@ import { } from "./nodes.helpers.js"; import type { GatewayRequestHandlers } from "./types.js"; -const NODE_WAKE_RECONNECT_WAIT_MS = 3_000; -const NODE_WAKE_RECONNECT_RETRY_WAIT_MS = 12_000; -const NODE_WAKE_RECONNECT_POLL_MS = 150; +export const NODE_WAKE_RECONNECT_WAIT_MS = 3_000; +export const NODE_WAKE_RECONNECT_RETRY_WAIT_MS = 12_000; +export const NODE_WAKE_RECONNECT_POLL_MS = 150; const NODE_WAKE_THROTTLE_MS = 15_000; const NODE_WAKE_NUDGE_THROTTLE_MS = 10 * 60_000; const NODE_PENDING_ACTION_TTL_MS = 10 * 60_000; @@ -208,9 +208,9 @@ function toPendingParamsJSON(params: unknown): string | undefined { } } -async function maybeWakeNodeWithApns( +export async function maybeWakeNodeWithApns( nodeId: string, - opts?: { force?: boolean }, + opts?: { force?: boolean; wakeReason?: string }, ): Promise { const state = nodeWakeById.get(nodeId) ?? { lastWakeAtMs: 0 }; nodeWakeById.set(nodeId, state); @@ -253,7 +253,7 @@ async function maybeWakeNodeWithApns( auth: auth.value, registration, nodeId, - wakeReason: "node.invoke", + wakeReason: opts?.wakeReason ?? "node.invoke", }); if (!wakeResult.ok) { return withDuration({ @@ -298,7 +298,7 @@ async function maybeWakeNodeWithApns( } } -async function maybeSendNodeWakeNudge(nodeId: string): Promise { +export async function maybeSendNodeWakeNudge(nodeId: string): Promise { const startedAtMs = Date.now(); const withDuration = ( attempt: Omit, @@ -362,7 +362,7 @@ async function maybeSendNodeWakeNudge(nodeId: string): Promise unknown } }; timeoutMs?: number; From 1bc59cc09df21d65e817791eaec58ebd707d6e50 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:56:00 +0100 Subject: [PATCH 0010/1173] Gateway: tighten node pending drain semantics (#41429) Merged via squash. Prepared head SHA: 361c2eb5c84e3b532862d843536ca68b21336fb2 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + src/gateway/node-pending-work.test.ts | 21 +++++++++++++++++++ src/gateway/node-pending-work.ts | 29 ++++++++++++++++++--------- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b25de7522d..98fcb8153a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky. - Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927. - Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x. +- Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky. ## 2026.3.8 diff --git a/src/gateway/node-pending-work.test.ts b/src/gateway/node-pending-work.test.ts index 3c2222dd3a9..2e89e2f20b2 100644 --- a/src/gateway/node-pending-work.test.ts +++ b/src/gateway/node-pending-work.test.ts @@ -3,6 +3,7 @@ import { acknowledgeNodePendingWork, drainNodePendingWork, enqueueNodePendingWork, + getNodePendingWorkStateCountForTests, resetNodePendingWorkForTests, } from "./node-pending-work.js"; @@ -43,4 +44,24 @@ describe("node pending work", () => { const afterAck = drainNodePendingWork("node-2"); expect(afterAck.items.map((item) => item.id)).toEqual(["baseline-status"]); }); + + it("keeps hasMore true when the baseline status item is deferred by maxItems", () => { + enqueueNodePendingWork({ nodeId: "node-3", type: "location.request" }); + + const drained = drainNodePendingWork("node-3", { maxItems: 1 }); + + expect(drained.items.map((item) => item.type)).toEqual(["location.request"]); + expect(drained.hasMore).toBe(true); + }); + + it("does not allocate state for drain-only nodes with no queued work", () => { + expect(getNodePendingWorkStateCountForTests()).toBe(0); + + const drained = drainNodePendingWork("node-4"); + const acked = acknowledgeNodePendingWork({ nodeId: "node-4", itemIds: ["baseline-status"] }); + + expect(drained.items.map((item) => item.id)).toEqual(["baseline-status"]); + expect(acked).toEqual({ revision: 0, removedItemIds: [] }); + expect(getNodePendingWorkStateCountForTests()).toBe(0); + }); }); diff --git a/src/gateway/node-pending-work.ts b/src/gateway/node-pending-work.ts index 33d356777d2..437b8c12bb7 100644 --- a/src/gateway/node-pending-work.ts +++ b/src/gateway/node-pending-work.ts @@ -45,7 +45,7 @@ const PRIORITY_RANK: Record = { const stateByNodeId = new Map(); -function getState(nodeId: string): NodePendingWorkState { +function getOrCreateState(nodeId: string): NodePendingWorkState { let state = stateByNodeId.get(nodeId); if (!state) { state = { @@ -106,7 +106,7 @@ export function enqueueNodePendingWork(params: { throw new Error("nodeId required"); } const nowMs = Date.now(); - const state = getState(nodeId); + const state = getOrCreateState(nodeId); pruneExpired(state, nowMs); const existing = [...state.itemsById.values()].find((item) => item.type === params.type); if (existing) { @@ -134,21 +134,25 @@ export function drainNodePendingWork(nodeId: string, opts: DrainOptions = {}): D return { revision: 0, items: [], hasMore: false }; } const nowMs = opts.nowMs ?? Date.now(); - const state = getState(normalizedNodeId); - pruneExpired(state, nowMs); + const state = stateByNodeId.get(normalizedNodeId); + const revision = state?.revision ?? 0; + if (state) { + pruneExpired(state, nowMs); + } const maxItems = Math.min(MAX_ITEMS, Math.max(1, Math.trunc(opts.maxItems ?? DEFAULT_MAX_ITEMS))); - const explicitItems = sortedItems(state); + const explicitItems = state ? sortedItems(state) : []; const items = explicitItems.slice(0, maxItems); const hasExplicitStatus = explicitItems.some((item) => item.type === "status.request"); const includeBaseline = opts.includeDefaultStatus !== false && !hasExplicitStatus; if (includeBaseline && items.length < maxItems) { items.push(makeBaselineStatusItem(nowMs)); } + const explicitReturnedCount = items.filter((item) => item.id !== DEFAULT_STATUS_ITEM_ID).length; + const baselineIncluded = items.some((item) => item.id === DEFAULT_STATUS_ITEM_ID); return { - revision: state.revision, + revision, items, - hasMore: - explicitItems.length > items.filter((item) => item.id !== DEFAULT_STATUS_ITEM_ID).length, + hasMore: explicitItems.length > explicitReturnedCount || (includeBaseline && !baselineIncluded), }; } @@ -160,7 +164,10 @@ export function acknowledgeNodePendingWork(params: { nodeId: string; itemIds: st if (!nodeId) { return { revision: 0, removedItemIds: [] }; } - const state = getState(nodeId); + const state = stateByNodeId.get(nodeId); + if (!state) { + return { revision: 0, removedItemIds: [] }; + } const removedItemIds: string[] = []; for (const itemId of params.itemIds) { const trimmedId = itemId.trim(); @@ -180,3 +187,7 @@ export function acknowledgeNodePendingWork(params: { nodeId: string; itemIds: st export function resetNodePendingWorkForTests() { stateByNodeId.clear(); } + +export function getNodePendingWorkStateCountForTests(): number { + return stateByNodeId.size; +} From e6e4169e82536d9298002cd58a5f34d0a34c3be8 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:01:30 +0100 Subject: [PATCH 0011/1173] acp: fail honestly in bridge mode (#41424) Merged via squash. Prepared head SHA: b5e6e13afe917f47e0bb303159430930591c0c87 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + docs.acp.md | 34 +++++++++ docs/cli/acp.md | 32 ++++++++ src/acp/translator.session-rate-limit.test.ts | 74 +++++++++++++++++++ src/acp/translator.ts | 19 +++-- 5 files changed, 153 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98fcb8153a9..bad484b74df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927. - Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x. - Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky. +- ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky. ## 2026.3.8 diff --git a/docs.acp.md b/docs.acp.md index cfe7349c341..48613138141 100644 --- a/docs.acp.md +++ b/docs.acp.md @@ -17,6 +17,40 @@ Key goals: - Works with existing Gateway session store (list/resolve/reset). - Safe defaults (isolated ACP session keys by default). +## Bridge Scope + +`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor +runtime. It is designed to route IDE prompts into an existing OpenClaw Gateway +session with predictable session mapping and basic streaming updates. + +## Compatibility Matrix + +| ACP area | Status | Notes | +| --------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- | +| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | +| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. | +| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key. Stored history is not replayed yet. | +| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | +| Session modes | Partial | `session/set_mode` is supported, but this bridge does not yet expose broader ACP-native mode or config surfaces. | +| Tool streaming | Partial | Tool start and result updates are forwarded, but without ACP-native terminal or richer editor metadata. | +| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | +| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | +| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | +| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. | + +## Known Limitations + +- `loadSession` rebinds to an existing Gateway session, but it does not replay + prior user or assistant history yet. +- If multiple ACP clients share the same Gateway session key, event and cancel + routing are best-effort rather than strictly isolated per client. Prefer the + default isolated `acp:` sessions when you need clean editor-local + turns. +- Gateway stop states are translated into ACP stop reasons, but that mapping is + less expressive than a fully ACP-native runtime. +- Tool follow-along data is intentionally narrow in bridge mode. The bridge + does not yet emit ACP terminals, file locations, or structured diffs. + ## How can I use this Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to diff --git a/docs/cli/acp.md b/docs/cli/acp.md index 7650390ed55..fbd46e428d3 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -13,6 +13,38 @@ Run the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) bridge t This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway over WebSocket. It keeps ACP sessions mapped to Gateway session keys. +`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor +runtime. It focuses on session routing, prompt delivery, and basic streaming +updates. + +## Compatibility Matrix + +| ACP area | Status | Notes | +| --------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- | +| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | +| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. | +| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key. Stored history is not replayed yet. | +| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | +| Session modes | Partial | `session/set_mode` is supported, but this bridge does not yet expose broader ACP-native mode or config surfaces. | +| Tool streaming | Partial | Tool start and result updates are forwarded, but without ACP-native terminal or richer editor metadata. | +| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | +| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | +| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | +| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. | + +## Known Limitations + +- `loadSession` rebinds to an existing Gateway session, but it does not replay + prior user or assistant history yet. +- If multiple ACP clients share the same Gateway session key, event and cancel + routing are best-effort rather than strictly isolated per client. Prefer the + default isolated `acp:` sessions when you need clean editor-local + turns. +- Gateway stop states are translated into ACP stop reasons, but that mapping is + less expressive than a fully ACP-native runtime. +- Tool follow-along data is intentionally narrow in bridge mode. The bridge + does not yet emit ACP terminals, file locations, or structured diffs. + ## Usage ```bash diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index 2e7d03b0f7b..51d6cc1f8e2 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -2,6 +2,7 @@ import type { LoadSessionRequest, NewSessionRequest, PromptRequest, + SetSessionModeRequest, } from "@agentclientprotocol/sdk"; import { describe, expect, it, vi } from "vitest"; import type { GatewayClient } from "../gateway/client.js"; @@ -38,6 +39,14 @@ function createPromptRequest( } as unknown as PromptRequest; } +function createSetSessionModeRequest(sessionId: string, modeId: string): SetSessionModeRequest { + return { + sessionId, + modeId, + _meta: {}, + } as unknown as SetSessionModeRequest; +} + async function expectOversizedPromptRejected(params: { sessionId: string; text: string }) { const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"]; const sessionStore = createInMemorySessionStore(); @@ -97,6 +106,71 @@ describe("acp session creation rate limit", () => { }); }); +describe("acp unsupported bridge session setup", () => { + it("rejects per-session MCP servers on newSession", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = vi.spyOn(connection, "sessionUpdate"); + const agent = new AcpGatewayAgent(connection, createAcpGateway(), { + sessionStore, + }); + + await expect( + agent.newSession({ + ...createNewSessionRequest(), + mcpServers: [{ name: "docs", command: "mcp-docs" }] as never[], + }), + ).rejects.toThrow(/does not support per-session MCP servers/i); + + expect(sessionStore.hasSession("docs-session")).toBe(false); + expect(sessionUpdate).not.toHaveBeenCalled(); + sessionStore.clearAllSessionsForTest(); + }); + + it("rejects per-session MCP servers on loadSession", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = vi.spyOn(connection, "sessionUpdate"); + const agent = new AcpGatewayAgent(connection, createAcpGateway(), { + sessionStore, + }); + + await expect( + agent.loadSession({ + ...createLoadSessionRequest("docs-session"), + mcpServers: [{ name: "docs", command: "mcp-docs" }] as never[], + }), + ).rejects.toThrow(/does not support per-session MCP servers/i); + + expect(sessionStore.hasSession("docs-session")).toBe(false); + expect(sessionUpdate).not.toHaveBeenCalled(); + sessionStore.clearAllSessionsForTest(); + }); +}); + +describe("acp setSessionMode bridge behavior", () => { + it("surfaces gateway mode patch failures instead of succeeding silently", async () => { + const sessionStore = createInMemorySessionStore(); + const request = vi.fn(async (method: string) => { + if (method === "sessions.patch") { + throw new Error("gateway rejected mode"); + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("mode-session")); + + await expect( + agent.setSessionMode(createSetSessionModeRequest("mode-session", "high")), + ).rejects.toThrow(/gateway rejected mode/i); + + sessionStore.clearAllSessionsForTest(); + }); +}); + describe("acp prompt size hardening", () => { it("rejects oversized prompt blocks without leaking active runs", async () => { await expectOversizedPromptRejected({ diff --git a/src/acp/translator.ts b/src/acp/translator.ts index d399228afa6..6be5f72510f 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -170,9 +170,7 @@ export class AcpGatewayAgent implements Agent { } async newSession(params: NewSessionRequest): Promise { - if (params.mcpServers.length > 0) { - this.log(`ignoring ${params.mcpServers.length} MCP servers`); - } + this.assertSupportedSessionSetup(params.mcpServers); this.enforceSessionCreateRateLimit("newSession"); const sessionId = randomUUID(); @@ -193,9 +191,7 @@ export class AcpGatewayAgent implements Agent { } async loadSession(params: LoadSessionRequest): Promise { - if (params.mcpServers.length > 0) { - this.log(`ignoring ${params.mcpServers.length} MCP servers`); - } + this.assertSupportedSessionSetup(params.mcpServers); if (!this.sessionStore.hasSession(params.sessionId)) { this.enforceSessionCreateRateLimit("loadSession"); } @@ -256,7 +252,7 @@ export class AcpGatewayAgent implements Agent { this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`); } catch (err) { this.log(`setSessionMode error: ${String(err)}`); - throw err; + throw err instanceof Error ? err : new Error(String(err)); } return {}; } @@ -536,6 +532,15 @@ export class AcpGatewayAgent implements Agent { }); } + private assertSupportedSessionSetup(mcpServers: ReadonlyArray): void { + if (mcpServers.length === 0) { + return; + } + throw new Error( + "ACP bridge mode does not support per-session MCP servers. Configure MCP on the OpenClaw gateway or agent instead.", + ); + } + private enforceSessionCreateRateLimit(method: "newSession" | "loadSession"): void { const budget = this.sessionCreateRateLimiter.consume(); if (budget.allowed) { From d346f2d9ce6d2aefa18b0f8fc4fa90507a456b65 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:17:19 +0100 Subject: [PATCH 0012/1173] acp: restore session context and controls (#41425) Merged via squash. Prepared head SHA: fcabdf7c31e33bbbd3ef82bdee92755eb0f62c82 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + docs.acp.md | 46 +- docs/cli/acp.md | 46 +- src/acp/translator.session-rate-limit.test.ts | 467 ++++++++++++++++- src/acp/translator.test-helpers.ts | 12 +- src/acp/translator.ts | 478 +++++++++++++++++- 6 files changed, 1003 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bad484b74df..5e80d751c9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x. - Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky. - ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky. +- ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky. ## 2026.3.8 diff --git a/docs.acp.md b/docs.acp.md index 48613138141..99fe15fbbd6 100644 --- a/docs.acp.md +++ b/docs.acp.md @@ -25,31 +25,41 @@ session with predictable session mapping and basic streaming updates. ## Compatibility Matrix -| ACP area | Status | Notes | -| --------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- | -| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | -| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. | -| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key. Stored history is not replayed yet. | -| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | -| Session modes | Partial | `session/set_mode` is supported, but this bridge does not yet expose broader ACP-native mode or config surfaces. | -| Tool streaming | Partial | Tool start and result updates are forwarded, but without ACP-native terminal or richer editor metadata. | -| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | -| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | -| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | -| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. | +| ACP area | Status | Notes | +| --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | +| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. | +| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key and replays stored user/assistant text history. Tool/system history is not reconstructed yet. | +| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | +| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. | +| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. | +| Tool streaming | Partial | Tool start and result updates are forwarded, but without richer editor metadata such as file locations or structured diff-native output. | +| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | +| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | +| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | +| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. | ## Known Limitations -- `loadSession` rebinds to an existing Gateway session, but it does not replay - prior user or assistant history yet. +- `loadSession` replays stored user and assistant text history, but it does not + reconstruct historic tool calls, system notices, or richer ACP-native event + types. - If multiple ACP clients share the same Gateway session key, event and cancel routing are best-effort rather than strictly isolated per client. Prefer the default isolated `acp:` sessions when you need clean editor-local turns. - Gateway stop states are translated into ACP stop reasons, but that mapping is less expressive than a fully ACP-native runtime. -- Tool follow-along data is intentionally narrow in bridge mode. The bridge - does not yet emit ACP terminals, file locations, or structured diffs. +- Initial session controls currently surface a focused subset of Gateway knobs: + thought level, tool verbosity, reasoning, usage detail, and elevated + actions. Model selection and exec-host controls are not yet exposed as ACP + config options. +- `session_info_update` and `usage_update` are derived from Gateway session + snapshots, not live ACP-native runtime accounting. Usage is approximate, + carries no cost data, and is only emitted when the Gateway marks total token + data as fresh. +- Tool follow-along data is still intentionally narrow in bridge mode. The + bridge does not yet emit ACP terminals, file locations, or structured diffs. ## How can I use this @@ -215,9 +225,11 @@ updates. Terminal Gateway states map to ACP `done` with stop reasons: ## Compatibility -- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.13.x). +- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.15.x). - Works with ACP clients that implement `initialize`, `newSession`, `loadSession`, `prompt`, `cancel`, and `listSessions`. +- Bridge mode rejects per-session `mcpServers` instead of silently ignoring + them. Configure MCP at the Gateway or agent layer. ## Testing diff --git a/docs/cli/acp.md b/docs/cli/acp.md index fbd46e428d3..7693d707862 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -19,31 +19,41 @@ updates. ## Compatibility Matrix -| ACP area | Status | Notes | -| --------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- | -| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | -| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. | -| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key. Stored history is not replayed yet. | -| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | -| Session modes | Partial | `session/set_mode` is supported, but this bridge does not yet expose broader ACP-native mode or config surfaces. | -| Tool streaming | Partial | Tool start and result updates are forwarded, but without ACP-native terminal or richer editor metadata. | -| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | -| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | -| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | -| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. | +| ACP area | Status | Notes | +| --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | +| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. | +| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key and replays stored user/assistant text history. Tool/system history is not reconstructed yet. | +| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | +| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. | +| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. | +| Tool streaming | Partial | Tool start and result updates are forwarded, but without richer editor metadata such as file locations or structured diff-native output. | +| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | +| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | +| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | +| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. | ## Known Limitations -- `loadSession` rebinds to an existing Gateway session, but it does not replay - prior user or assistant history yet. +- `loadSession` replays stored user and assistant text history, but it does not + reconstruct historic tool calls, system notices, or richer ACP-native event + types. - If multiple ACP clients share the same Gateway session key, event and cancel routing are best-effort rather than strictly isolated per client. Prefer the default isolated `acp:` sessions when you need clean editor-local turns. - Gateway stop states are translated into ACP stop reasons, but that mapping is less expressive than a fully ACP-native runtime. -- Tool follow-along data is intentionally narrow in bridge mode. The bridge - does not yet emit ACP terminals, file locations, or structured diffs. +- Initial session controls currently surface a focused subset of Gateway knobs: + thought level, tool verbosity, reasoning, usage detail, and elevated + actions. Model selection and exec-host controls are not yet exposed as ACP + config options. +- `session_info_update` and `usage_update` are derived from Gateway session + snapshots, not live ACP-native runtime accounting. Usage is approximate, + carries no cost data, and is only emitted when the Gateway marks total token + data as fresh. +- Tool follow-along data is still intentionally narrow in bridge mode. The + bridge does not yet emit ACP terminals, file locations, or structured diffs. ## Usage @@ -128,6 +138,10 @@ Each ACP session maps to a single Gateway session key. One agent can have many sessions; ACP defaults to an isolated `acp:` session unless you override the key or label. +Per-session `mcpServers` are not supported in bridge mode. If an ACP client +sends them during `newSession` or `loadSession`, the bridge returns a clear +error instead of silently ignoring them. + ## Use from `acpx` (Codex, Claude, other ACP clients) If you want a coding agent such as Codex or Claude Code to talk to your diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index 51d6cc1f8e2..07d8bbc3db7 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -2,10 +2,12 @@ import type { LoadSessionRequest, NewSessionRequest, PromptRequest, + SetSessionConfigOptionRequest, SetSessionModeRequest, } from "@agentclientprotocol/sdk"; import { describe, expect, it, vi } from "vitest"; import type { GatewayClient } from "../gateway/client.js"; +import type { EventFrame } from "../gateway/protocol/index.js"; import { createInMemorySessionStore } from "./session.js"; import { AcpGatewayAgent } from "./translator.js"; import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js"; @@ -47,6 +49,29 @@ function createSetSessionModeRequest(sessionId: string, modeId: string): SetSess } as unknown as SetSessionModeRequest; } +function createSetSessionConfigOptionRequest( + sessionId: string, + configId: string, + value: string, +): SetSessionConfigOptionRequest { + return { + sessionId, + configId, + value, + _meta: {}, + } as unknown as SetSessionConfigOptionRequest; +} + +function createChatFinalEvent(sessionKey: string): EventFrame { + return { + event: "chat", + payload: { + sessionKey, + state: "final", + }, + } as unknown as EventFrame; +} + async function expectOversizedPromptRejected(params: { sessionId: string; text: string }) { const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"]; const sessionStore = createInMemorySessionStore(); @@ -110,7 +135,7 @@ describe("acp unsupported bridge session setup", () => { it("rejects per-session MCP servers on newSession", async () => { const sessionStore = createInMemorySessionStore(); const connection = createAcpConnection(); - const sessionUpdate = vi.spyOn(connection, "sessionUpdate"); + const sessionUpdate = connection.__sessionUpdateMock; const agent = new AcpGatewayAgent(connection, createAcpGateway(), { sessionStore, }); @@ -130,7 +155,7 @@ describe("acp unsupported bridge session setup", () => { it("rejects per-session MCP servers on loadSession", async () => { const sessionStore = createInMemorySessionStore(); const connection = createAcpConnection(); - const sessionUpdate = vi.spyOn(connection, "sessionUpdate"); + const sessionUpdate = connection.__sessionUpdateMock; const agent = new AcpGatewayAgent(connection, createAcpGateway(), { sessionStore, }); @@ -148,6 +173,172 @@ describe("acp unsupported bridge session setup", () => { }); }); +describe("acp session UX bridge behavior", () => { + it("returns initial modes and thought-level config options for new sessions", async () => { + const sessionStore = createInMemorySessionStore(); + const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(), { + sessionStore, + }); + + const result = await agent.newSession(createNewSessionRequest()); + + expect(result.modes?.currentModeId).toBe("adaptive"); + expect(result.modes?.availableModes.map((mode) => mode.id)).toContain("adaptive"); + expect(result.configOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "thought_level", + currentValue: "adaptive", + category: "thought_level", + }), + expect.objectContaining({ + id: "verbose_level", + currentValue: "off", + }), + expect.objectContaining({ + id: "reasoning_level", + currentValue: "off", + }), + expect.objectContaining({ + id: "response_usage", + currentValue: "off", + }), + expect.objectContaining({ + id: "elevated_level", + currentValue: "off", + }), + ]), + ); + + sessionStore.clearAllSessionsForTest(); + }); + + it("replays user and assistant text history on loadSession and returns initial controls", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "agent:main:work", + label: "main-work", + displayName: "Main work", + derivedTitle: "Fix ACP bridge", + kind: "direct", + updatedAt: 1_710_000_000_000, + thinkingLevel: "high", + modelProvider: "openai", + model: "gpt-5.4", + verboseLevel: "full", + reasoningLevel: "stream", + responseUsage: "tokens", + elevatedLevel: "ask", + totalTokens: 4096, + totalTokensFresh: true, + contextTokens: 8192, + }, + ], + }; + } + if (method === "sessions.get") { + return { + messages: [ + { role: "user", content: [{ type: "text", text: "Question" }] }, + { role: "assistant", content: [{ type: "text", text: "Answer" }] }, + { role: "system", content: [{ type: "text", text: "ignore me" }] }, + { role: "assistant", content: [{ type: "image", image: "skip" }] }, + ], + }; + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + const result = await agent.loadSession(createLoadSessionRequest("agent:main:work")); + + expect(result.modes?.currentModeId).toBe("high"); + expect(result.modes?.availableModes.map((mode) => mode.id)).toContain("xhigh"); + expect(result.configOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "thought_level", + currentValue: "high", + }), + expect.objectContaining({ + id: "verbose_level", + currentValue: "full", + }), + expect.objectContaining({ + id: "reasoning_level", + currentValue: "stream", + }), + expect.objectContaining({ + id: "response_usage", + currentValue: "tokens", + }), + expect.objectContaining({ + id: "elevated_level", + currentValue: "ask", + }), + ]), + ); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "agent:main:work", + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "Question" }, + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "agent:main:work", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Answer" }, + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "agent:main:work", + update: expect.objectContaining({ + sessionUpdate: "available_commands_update", + }), + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "agent:main:work", + update: { + sessionUpdate: "session_info_update", + title: "Fix ACP bridge", + updatedAt: "2024-03-09T16:00:00.000Z", + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "agent:main:work", + update: { + sessionUpdate: "usage_update", + used: 4096, + size: 8192, + _meta: { + source: "gateway-session-store", + approximate: true, + }, + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); +}); + describe("acp setSessionMode bridge behavior", () => { it("surfaces gateway mode patch failures instead of succeeding silently", async () => { const sessionStore = createInMemorySessionStore(); @@ -169,6 +360,278 @@ describe("acp setSessionMode bridge behavior", () => { sessionStore.clearAllSessionsForTest(); }); + + it("emits current mode and thought-level config updates after a successful mode change", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "mode-session", + kind: "direct", + updatedAt: Date.now(), + thinkingLevel: "high", + modelProvider: "openai", + model: "gpt-5.4", + }, + ], + }; + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("mode-session")); + sessionUpdate.mockClear(); + + await agent.setSessionMode(createSetSessionModeRequest("mode-session", "high")); + + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "mode-session", + update: { + sessionUpdate: "current_mode_update", + currentModeId: "high", + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "mode-session", + update: { + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({ + id: "thought_level", + currentValue: "high", + }), + ]), + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); +}); + +describe("acp setSessionConfigOption bridge behavior", () => { + it("updates the thought-level config option and returns refreshed options", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "config-session", + kind: "direct", + updatedAt: Date.now(), + thinkingLevel: "minimal", + modelProvider: "openai", + model: "gpt-5.4", + }, + ], + }; + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("config-session")); + sessionUpdate.mockClear(); + + const result = await agent.setSessionConfigOption( + createSetSessionConfigOptionRequest("config-session", "thought_level", "minimal"), + ); + + expect(result.configOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "thought_level", + currentValue: "minimal", + }), + ]), + ); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "config-session", + update: { + sessionUpdate: "current_mode_update", + currentModeId: "minimal", + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "config-session", + update: { + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({ + id: "thought_level", + currentValue: "minimal", + }), + ]), + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); + + it("updates non-mode ACP config options through gateway session patches", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "reasoning-session", + kind: "direct", + updatedAt: Date.now(), + thinkingLevel: "minimal", + modelProvider: "openai", + model: "gpt-5.4", + reasoningLevel: "stream", + }, + ], + }; + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("reasoning-session")); + sessionUpdate.mockClear(); + + const result = await agent.setSessionConfigOption( + createSetSessionConfigOptionRequest("reasoning-session", "reasoning_level", "stream"), + ); + + expect(result.configOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "reasoning_level", + currentValue: "stream", + }), + ]), + ); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "reasoning-session", + update: { + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({ + id: "reasoning_level", + currentValue: "stream", + }), + ]), + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); +}); + +describe("acp session metadata and usage updates", () => { + it("emits a fresh usage snapshot after prompt completion when gateway totals are available", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "usage-session", + displayName: "Usage session", + kind: "direct", + updatedAt: 1_710_000_123_000, + thinkingLevel: "adaptive", + modelProvider: "openai", + model: "gpt-5.4", + totalTokens: 1200, + totalTokensFresh: true, + contextTokens: 4000, + }, + ], + }; + } + if (method === "chat.send") { + return new Promise(() => {}); + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("usage-session")); + sessionUpdate.mockClear(); + + const promptPromise = agent.prompt(createPromptRequest("usage-session", "hello")); + await agent.handleGatewayEvent(createChatFinalEvent("usage-session")); + await promptPromise; + + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "usage-session", + update: { + sessionUpdate: "session_info_update", + title: "Usage session", + updatedAt: "2024-03-09T16:02:03.000Z", + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "usage-session", + update: { + sessionUpdate: "usage_update", + used: 1200, + size: 4000, + _meta: { + source: "gateway-session-store", + approximate: true, + }, + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); }); describe("acp prompt size hardening", () => { diff --git a/src/acp/translator.test-helpers.ts b/src/acp/translator.test-helpers.ts index c80918ba2cc..2bd7fd2747f 100644 --- a/src/acp/translator.test-helpers.ts +++ b/src/acp/translator.test-helpers.ts @@ -2,10 +2,16 @@ import type { AgentSideConnection } from "@agentclientprotocol/sdk"; import { vi } from "vitest"; import type { GatewayClient } from "../gateway/client.js"; -export function createAcpConnection(): AgentSideConnection { +export type TestAcpConnection = AgentSideConnection & { + __sessionUpdateMock: ReturnType; +}; + +export function createAcpConnection(): TestAcpConnection { + const sessionUpdate = vi.fn(async () => {}); return { - sessionUpdate: vi.fn(async () => {}), - } as unknown as AgentSideConnection; + sessionUpdate, + __sessionUpdateMock: sessionUpdate, + } as unknown as TestAcpConnection; } export function createAcpGateway( diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 6be5f72510f..8628117b49c 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -16,14 +16,19 @@ import type { NewSessionResponse, PromptRequest, PromptResponse, + SessionConfigOption, + SessionModeState, + SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, StopReason, } from "@agentclientprotocol/sdk"; import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk"; +import { listThinkingLevels } from "../auto-reply/thinking.js"; import type { GatewayClient } from "../gateway/client.js"; import type { EventFrame } from "../gateway/protocol/index.js"; -import type { SessionsListResult } from "../gateway/session-utils.js"; +import type { GatewaySessionRow, SessionsListResult } from "../gateway/session-utils.js"; import { createFixedWindowRateLimiter, type FixedWindowRateLimiter, @@ -34,7 +39,6 @@ import { extractAttachmentsFromPrompt, extractTextFromPrompt, formatToolTitle, - inferToolKind, } from "./event-mapper.js"; import { readBool, readNumber, readString } from "./meta.js"; import { parseSessionMeta, resetSessionIfNeeded, resolveSessionKey } from "./session-mapper.js"; @@ -43,6 +47,12 @@ import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js"; // Maximum allowed prompt size (2MB) to prevent DoS via memory exhaustion (CWE-400, GHSA-cxpw-2g23-2vgw) const MAX_PROMPT_BYTES = 2 * 1024 * 1024; +const ACP_THOUGHT_LEVEL_CONFIG_ID = "thought_level"; +const ACP_VERBOSE_LEVEL_CONFIG_ID = "verbose_level"; +const ACP_REASONING_LEVEL_CONFIG_ID = "reasoning_level"; +const ACP_RESPONSE_USAGE_CONFIG_ID = "response_usage"; +const ACP_ELEVATED_LEVEL_CONFIG_ID = "elevated_level"; +const ACP_LOAD_SESSION_REPLAY_LIMIT = 1_000_000; type PendingPrompt = { sessionId: string; @@ -59,9 +69,226 @@ type AcpGatewayAgentOptions = AcpServerOptions & { sessionStore?: AcpSessionStore; }; +type GatewaySessionPresentationRow = Pick< + GatewaySessionRow, + | "displayName" + | "label" + | "derivedTitle" + | "updatedAt" + | "thinkingLevel" + | "modelProvider" + | "model" + | "verboseLevel" + | "reasoningLevel" + | "responseUsage" + | "elevatedLevel" + | "totalTokens" + | "totalTokensFresh" + | "contextTokens" +>; + +type SessionPresentation = { + configOptions: SessionConfigOption[]; + modes: SessionModeState; +}; + +type SessionMetadata = { + title?: string | null; + updatedAt?: string | null; +}; + +type SessionUsageSnapshot = { + size: number; + used: number; +}; + +type SessionSnapshot = SessionPresentation & { + metadata?: SessionMetadata; + usage?: SessionUsageSnapshot; +}; + +type GatewayTranscriptMessage = { + role?: unknown; + content?: unknown; +}; + const SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS = 120; const SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS = 10_000; +function formatThinkingLevelName(level: string): string { + switch (level) { + case "xhigh": + return "Extra High"; + case "adaptive": + return "Adaptive"; + default: + return level.length > 0 ? `${level[0].toUpperCase()}${level.slice(1)}` : "Unknown"; + } +} + +function buildThinkingModeDescription(level: string): string | undefined { + if (level === "adaptive") { + return "Use the Gateway session default thought level."; + } + return undefined; +} + +function formatConfigValueName(value: string): string { + switch (value) { + case "xhigh": + return "Extra High"; + default: + return value.length > 0 ? `${value[0].toUpperCase()}${value.slice(1)}` : "Unknown"; + } +} + +function buildSelectConfigOption(params: { + id: string; + name: string; + description: string; + currentValue: string; + values: readonly string[]; + category?: string; +}): SessionConfigOption { + return { + type: "select", + id: params.id, + name: params.name, + category: params.category, + description: params.description, + currentValue: params.currentValue, + options: params.values.map((value) => ({ + value, + name: formatConfigValueName(value), + })), + }; +} + +function buildSessionPresentation(params: { + row?: GatewaySessionPresentationRow; + overrides?: Partial; +}): SessionPresentation { + const row = { + ...params.row, + ...params.overrides, + }; + const availableLevelIds: string[] = [...listThinkingLevels(row.modelProvider, row.model)]; + const currentModeId = row.thinkingLevel?.trim() || "adaptive"; + if (!availableLevelIds.includes(currentModeId)) { + availableLevelIds.push(currentModeId); + } + + const modes: SessionModeState = { + currentModeId, + availableModes: availableLevelIds.map((level) => ({ + id: level, + name: formatThinkingLevelName(level), + description: buildThinkingModeDescription(level), + })), + }; + + const configOptions: SessionConfigOption[] = [ + buildSelectConfigOption({ + id: ACP_THOUGHT_LEVEL_CONFIG_ID, + name: "Thought level", + category: "thought_level", + description: + "Controls how much deliberate reasoning OpenClaw requests from the Gateway model.", + currentValue: currentModeId, + values: availableLevelIds, + }), + buildSelectConfigOption({ + id: ACP_VERBOSE_LEVEL_CONFIG_ID, + name: "Tool verbosity", + description: + "Controls how much tool progress and output detail OpenClaw keeps enabled for the session.", + currentValue: row.verboseLevel?.trim() || "off", + values: ["off", "on", "full"], + }), + buildSelectConfigOption({ + id: ACP_REASONING_LEVEL_CONFIG_ID, + name: "Reasoning stream", + description: "Controls whether reasoning-capable models emit reasoning text for the session.", + currentValue: row.reasoningLevel?.trim() || "off", + values: ["off", "on", "stream"], + }), + buildSelectConfigOption({ + id: ACP_RESPONSE_USAGE_CONFIG_ID, + name: "Usage detail", + description: + "Controls how much usage information OpenClaw attaches to responses for the session.", + currentValue: row.responseUsage?.trim() || "off", + values: ["off", "tokens", "full"], + }), + buildSelectConfigOption({ + id: ACP_ELEVATED_LEVEL_CONFIG_ID, + name: "Elevated actions", + description: "Controls how aggressively the session allows elevated execution behavior.", + currentValue: row.elevatedLevel?.trim() || "off", + values: ["off", "on", "ask", "full"], + }), + ]; + + return { configOptions, modes }; +} + +function extractReplayText(content: unknown): string | undefined { + if (typeof content === "string") { + return content.length > 0 ? content : undefined; + } + if (!Array.isArray(content)) { + return undefined; + } + const text = content + .map((block) => { + if (!block || typeof block !== "object" || Array.isArray(block)) { + return ""; + } + const typedBlock = block as { type?: unknown; text?: unknown }; + return typedBlock.type === "text" && typeof typedBlock.text === "string" + ? typedBlock.text + : ""; + }) + .join(""); + return text.length > 0 ? text : undefined; +} + +function buildSessionMetadata(params: { + row?: GatewaySessionPresentationRow; + sessionKey: string; +}): SessionMetadata { + const title = + params.row?.derivedTitle?.trim() || + params.row?.displayName?.trim() || + params.row?.label?.trim() || + params.sessionKey; + const updatedAt = + typeof params.row?.updatedAt === "number" && Number.isFinite(params.row.updatedAt) + ? new Date(params.row.updatedAt).toISOString() + : null; + return { title, updatedAt }; +} + +function buildSessionUsageSnapshot( + row?: GatewaySessionPresentationRow, +): SessionUsageSnapshot | undefined { + const totalTokens = row?.totalTokens; + const contextTokens = row?.contextTokens; + if ( + row?.totalTokensFresh !== true || + typeof totalTokens !== "number" || + !Number.isFinite(totalTokens) || + typeof contextTokens !== "number" || + !Number.isFinite(contextTokens) || + contextTokens <= 0 + ) { + return undefined; + } + const size = Math.max(0, Math.floor(contextTokens)); + const used = Math.max(0, Math.min(Math.floor(totalTokens), size)); + return { size, used }; +} + function buildSystemInputProvenance(originSessionId: string) { return { kind: "external_user" as const, @@ -186,8 +413,17 @@ export class AcpGatewayAgent implements Agent { cwd: params.cwd, }); this.log(`newSession: ${session.sessionId} -> ${session.sessionKey}`); + const sessionSnapshot = await this.getSessionSnapshot(session.sessionKey); + await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, { + includeControls: false, + }); await this.sendAvailableCommands(session.sessionId); - return { sessionId: session.sessionId }; + const { configOptions, modes } = sessionSnapshot; + return { + sessionId: session.sessionId, + configOptions, + modes, + }; } async loadSession(params: LoadSessionRequest): Promise { @@ -208,8 +444,17 @@ export class AcpGatewayAgent implements Agent { cwd: params.cwd, }); this.log(`loadSession: ${session.sessionId} -> ${session.sessionKey}`); + const [sessionSnapshot, transcript] = await Promise.all([ + this.getSessionSnapshot(session.sessionKey), + this.getSessionTranscript(session.sessionKey), + ]); + await this.replaySessionTranscript(session.sessionId, transcript); + await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, { + includeControls: false, + }); await this.sendAvailableCommands(session.sessionId); - return {}; + const { configOptions, modes } = sessionSnapshot; + return { configOptions, modes }; } async unstable_listSessions(params: ListSessionsRequest): Promise { @@ -250,6 +495,12 @@ export class AcpGatewayAgent implements Agent { thinkingLevel: params.modeId, }); this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`); + const sessionSnapshot = await this.getSessionSnapshot(session.sessionKey, { + thinkingLevel: params.modeId, + }); + await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, { + includeControls: true, + }); } catch (err) { this.log(`setSessionMode error: ${String(err)}`); throw err instanceof Error ? err : new Error(String(err)); @@ -257,6 +508,39 @@ export class AcpGatewayAgent implements Agent { return {}; } + async setSessionConfigOption( + params: SetSessionConfigOptionRequest, + ): Promise { + const session = this.sessionStore.getSession(params.sessionId); + if (!session) { + throw new Error(`Session ${params.sessionId} not found`); + } + const sessionPatch = this.resolveSessionConfigPatch(params.configId, params.value); + + try { + await this.gateway.request("sessions.patch", { + key: session.sessionKey, + ...sessionPatch.patch, + }); + this.log( + `setSessionConfigOption: ${session.sessionId} -> ${params.configId}=${params.value}`, + ); + const sessionSnapshot = await this.getSessionSnapshot( + session.sessionKey, + sessionPatch.overrides, + ); + await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, { + includeControls: true, + }); + return { + configOptions: sessionSnapshot.configOptions, + }; + } catch (err) { + this.log(`setSessionConfigOption error: ${String(err)}`); + throw err instanceof Error ? err : new Error(String(err)); + } + } + async prompt(params: PromptRequest): Promise { const session = this.sessionStore.getSession(params.sessionId); if (!session) { @@ -412,7 +696,6 @@ export class AcpGatewayAgent implements Agent { title: formatToolTitle(name, args), status: "in_progress", rawInput: args, - kind: inferToolKind(name), }, }); return; @@ -420,6 +703,7 @@ export class AcpGatewayAgent implements Agent { if (phase === "result") { const isError = Boolean(data.isError); + pending.toolCalls?.delete(toolCallId); await this.connection.sessionUpdate({ sessionId: pending.sessionId, update: { @@ -462,11 +746,11 @@ export class AcpGatewayAgent implements Agent { if (state === "final") { const rawStopReason = payload.stopReason as string | undefined; const stopReason: StopReason = rawStopReason === "max_tokens" ? "max_tokens" : "end_turn"; - this.finishPrompt(pending.sessionId, pending, stopReason); + await this.finishPrompt(pending.sessionId, pending, stopReason); return; } if (state === "aborted") { - this.finishPrompt(pending.sessionId, pending, "cancelled"); + await this.finishPrompt(pending.sessionId, pending, "cancelled"); return; } if (state === "error") { @@ -474,7 +758,7 @@ export class AcpGatewayAgent implements Agent { // do not treat transient backend errors (timeouts, rate-limits) as deliberate // refusals. TODO: when ChatEventSchema gains a structured errorKind field // (e.g. "refusal" | "timeout" | "rate_limit"), use it to distinguish here. - this.finishPrompt(pending.sessionId, pending, "end_turn"); + void this.finishPrompt(pending.sessionId, pending, "end_turn"); } } @@ -507,9 +791,17 @@ export class AcpGatewayAgent implements Agent { }); } - private finishPrompt(sessionId: string, pending: PendingPrompt, stopReason: StopReason): void { + private async finishPrompt( + sessionId: string, + pending: PendingPrompt, + stopReason: StopReason, + ): Promise { this.pendingPrompts.delete(sessionId); this.sessionStore.clearActiveRun(sessionId); + const sessionSnapshot = await this.getSessionSnapshot(pending.sessionKey); + await this.sendSessionSnapshotUpdate(sessionId, sessionSnapshot, { + includeControls: false, + }); pending.resolve({ stopReason }); } @@ -532,6 +824,174 @@ export class AcpGatewayAgent implements Agent { }); } + private async getSessionSnapshot( + sessionKey: string, + overrides?: Partial, + ): Promise { + try { + const row = await this.getGatewaySessionRow(sessionKey); + return { + ...buildSessionPresentation({ row, overrides }), + metadata: buildSessionMetadata({ row, sessionKey }), + usage: buildSessionUsageSnapshot(row), + }; + } catch (err) { + this.log(`session presentation fallback for ${sessionKey}: ${String(err)}`); + return { + ...buildSessionPresentation({ overrides }), + metadata: buildSessionMetadata({ sessionKey }), + }; + } + } + + private async getGatewaySessionRow( + sessionKey: string, + ): Promise { + const result = await this.gateway.request("sessions.list", { + limit: 200, + search: sessionKey, + includeDerivedTitles: true, + }); + const session = result.sessions.find((entry) => entry.key === sessionKey); + if (!session) { + return undefined; + } + return { + displayName: session.displayName, + label: session.label, + derivedTitle: session.derivedTitle, + updatedAt: session.updatedAt, + thinkingLevel: session.thinkingLevel, + modelProvider: session.modelProvider, + model: session.model, + verboseLevel: session.verboseLevel, + reasoningLevel: session.reasoningLevel, + responseUsage: session.responseUsage, + elevatedLevel: session.elevatedLevel, + totalTokens: session.totalTokens, + totalTokensFresh: session.totalTokensFresh, + contextTokens: session.contextTokens, + }; + } + + private resolveSessionConfigPatch( + configId: string, + value: string, + ): { + overrides: Partial; + patch: Record; + } { + switch (configId) { + case ACP_THOUGHT_LEVEL_CONFIG_ID: + return { + patch: { thinkingLevel: value }, + overrides: { thinkingLevel: value }, + }; + case ACP_VERBOSE_LEVEL_CONFIG_ID: + return { + patch: { verboseLevel: value }, + overrides: { verboseLevel: value }, + }; + case ACP_REASONING_LEVEL_CONFIG_ID: + return { + patch: { reasoningLevel: value }, + overrides: { reasoningLevel: value }, + }; + case ACP_RESPONSE_USAGE_CONFIG_ID: + return { + patch: { responseUsage: value }, + overrides: { responseUsage: value as GatewaySessionPresentationRow["responseUsage"] }, + }; + case ACP_ELEVATED_LEVEL_CONFIG_ID: + return { + patch: { elevatedLevel: value }, + overrides: { elevatedLevel: value }, + }; + default: + throw new Error(`ACP bridge mode does not support session config option "${configId}".`); + } + } + + private async getSessionTranscript(sessionKey: string): Promise { + const result = await this.gateway.request<{ messages?: unknown[] }>("sessions.get", { + key: sessionKey, + limit: ACP_LOAD_SESSION_REPLAY_LIMIT, + }); + if (!Array.isArray(result.messages)) { + return []; + } + return result.messages as GatewayTranscriptMessage[]; + } + + private async replaySessionTranscript( + sessionId: string, + transcript: ReadonlyArray, + ): Promise { + for (const message of transcript) { + const role = typeof message.role === "string" ? message.role : ""; + if (role !== "user" && role !== "assistant") { + continue; + } + const text = extractReplayText(message.content); + if (!text) { + continue; + } + await this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: role === "user" ? "user_message_chunk" : "agent_message_chunk", + content: { type: "text", text }, + }, + }); + } + } + + private async sendSessionSnapshotUpdate( + sessionId: string, + sessionSnapshot: SessionSnapshot, + options: { includeControls: boolean }, + ): Promise { + if (options.includeControls) { + await this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "current_mode_update", + currentModeId: sessionSnapshot.modes.currentModeId, + }, + }); + await this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "config_option_update", + configOptions: sessionSnapshot.configOptions, + }, + }); + } + if (sessionSnapshot.metadata) { + await this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "session_info_update", + ...sessionSnapshot.metadata, + }, + }); + } + if (sessionSnapshot.usage) { + await this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "usage_update", + used: sessionSnapshot.usage.used, + size: sessionSnapshot.usage.size, + _meta: { + source: "gateway-session-store", + approximate: true, + }, + }, + }); + } + } + private assertSupportedSessionSetup(mcpServers: ReadonlyArray): void { if (mcpServers.length === 0) { return; From 30340d6835c02bacb31c89ee3dd66b4e02456635 Mon Sep 17 00:00:00 2001 From: Altay Date: Tue, 10 Mar 2026 00:18:41 +0300 Subject: [PATCH 0013/1173] Sandbox: import STATE_DIR from paths directly (#41439) --- src/agents/sandbox/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/sandbox/constants.ts b/src/agents/sandbox/constants.ts index f2a562f26b6..b2cc874b97f 100644 --- a/src/agents/sandbox/constants.ts +++ b/src/agents/sandbox/constants.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { CHANNEL_IDS } from "../../channels/registry.js"; -import { STATE_DIR } from "../../config/config.js"; +import { STATE_DIR } from "../../config/paths.js"; export const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join(STATE_DIR, "sandboxes"); From 8e3f3bc3cf4744e38442d177573f706b78fbc0c5 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:26:46 +0100 Subject: [PATCH 0014/1173] acp: enrich streaming updates for ide clients (#41442) Merged via squash. Prepared head SHA: 0764368e805403edda43c88418f322509bfc5c68 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + docs.acp.md | 7 +- docs/cli/acp.md | 7 +- src/acp/event-mapper.ts | 256 +++++++++++++++++- src/acp/translator.session-rate-limit.test.ts | 139 ++++++++++ src/acp/translator.ts | 50 +++- 6 files changed, 449 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e80d751c9c..fff858ba5fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky. - ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky. - ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky. +- ACP/tool streaming: enrich `tool_call` and `tool_call_update` events with best-effort text content and file-location hints so IDE clients can follow bridge tool activity more naturally. (#41442) Thanks @mbelinky. ## 2026.3.8 diff --git a/docs.acp.md b/docs.acp.md index 99fe15fbbd6..1e93ee0cf63 100644 --- a/docs.acp.md +++ b/docs.acp.md @@ -33,7 +33,7 @@ session with predictable session mapping and basic streaming updates. | Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | | Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. | | Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. | -| Tool streaming | Partial | Tool start and result updates are forwarded, but without richer editor metadata such as file locations or structured diff-native output. | +| Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. | | Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | | Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | | Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | @@ -58,8 +58,9 @@ session with predictable session mapping and basic streaming updates. snapshots, not live ACP-native runtime accounting. Usage is approximate, carries no cost data, and is only emitted when the Gateway marks total token data as fresh. -- Tool follow-along data is still intentionally narrow in bridge mode. The - bridge does not yet emit ACP terminals, file locations, or structured diffs. +- Tool follow-along data is best-effort. The bridge can surface file paths that + appear in known tool args/results, but it does not yet emit ACP terminals or + structured file diffs. ## How can I use this diff --git a/docs/cli/acp.md b/docs/cli/acp.md index 7693d707862..152770e6d86 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -27,7 +27,7 @@ updates. | Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | | Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. | | Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. | -| Tool streaming | Partial | Tool start and result updates are forwarded, but without richer editor metadata such as file locations or structured diff-native output. | +| Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. | | Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | | Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | | Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | @@ -52,8 +52,9 @@ updates. snapshots, not live ACP-native runtime accounting. Usage is approximate, carries no cost data, and is only emitted when the Gateway marks total token data as fresh. -- Tool follow-along data is still intentionally narrow in bridge mode. The - bridge does not yet emit ACP terminals, file locations, or structured diffs. +- Tool follow-along data is best-effort. The bridge can surface file paths that + appear in known tool args/results, but it does not yet emit ACP terminals or + structured file diffs. ## Usage diff --git a/src/acp/event-mapper.ts b/src/acp/event-mapper.ts index 83b91524a7f..2a74f5691cf 100644 --- a/src/acp/event-mapper.ts +++ b/src/acp/event-mapper.ts @@ -1,4 +1,10 @@ -import type { ContentBlock, ImageContent, ToolKind } from "@agentclientprotocol/sdk"; +import type { + ContentBlock, + ImageContent, + ToolCallContent, + ToolCallLocation, + ToolKind, +} from "@agentclientprotocol/sdk"; export type GatewayAttachment = { type: string; @@ -6,6 +12,39 @@ export type GatewayAttachment = { content: string; }; +const TOOL_LOCATION_PATH_KEYS = [ + "path", + "filePath", + "file_path", + "targetPath", + "target_path", + "targetFile", + "target_file", + "sourcePath", + "source_path", + "destinationPath", + "destination_path", + "oldPath", + "old_path", + "newPath", + "new_path", + "outputPath", + "output_path", + "inputPath", + "input_path", +] as const; + +const TOOL_LOCATION_LINE_KEYS = [ + "line", + "lineNumber", + "line_number", + "startLine", + "start_line", +] as const; +const TOOL_RESULT_PATH_MARKER_RE = /^(?:FILE|MEDIA):(.+)$/gm; +const TOOL_LOCATION_MAX_DEPTH = 4; +const TOOL_LOCATION_MAX_NODES = 100; + const INLINE_CONTROL_ESCAPE_MAP: Readonly> = { "\0": "\\0", "\r": "\\r", @@ -56,6 +95,150 @@ function escapeResourceTitle(value: string): string { return escapeInlineControlChars(value).replace(/[()[\]]/g, (char) => `\\${char}`); } +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function normalizeToolLocationPath(value: string): string | undefined { + const trimmed = value.trim(); + if ( + !trimmed || + trimmed.length > 4096 || + trimmed.includes("\u0000") || + trimmed.includes("\r") || + trimmed.includes("\n") + ) { + return undefined; + } + if (/^https?:\/\//i.test(trimmed)) { + return undefined; + } + if (/^file:\/\//i.test(trimmed)) { + try { + const parsed = new URL(trimmed); + return decodeURIComponent(parsed.pathname || "") || undefined; + } catch { + return undefined; + } + } + return trimmed; +} + +function normalizeToolLocationLine(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + const line = Math.floor(value); + return line > 0 ? line : undefined; +} + +function extractToolLocationLine(record: Record): number | undefined { + for (const key of TOOL_LOCATION_LINE_KEYS) { + const line = normalizeToolLocationLine(record[key]); + if (line !== undefined) { + return line; + } + } + return undefined; +} + +function addToolLocation( + locations: Map, + rawPath: string, + line?: number, +): void { + const path = normalizeToolLocationPath(rawPath); + if (!path) { + return; + } + for (const [existingKey, existing] of locations.entries()) { + if (existing.path !== path) { + continue; + } + if (line === undefined || existing.line === line) { + return; + } + if (existing.line === undefined) { + locations.delete(existingKey); + } + } + const locationKey = `${path}:${line ?? ""}`; + if (locations.has(locationKey)) { + return; + } + locations.set(locationKey, line ? { path, line } : { path }); +} + +function collectLocationsFromTextMarkers( + text: string, + locations: Map, +): void { + for (const match of text.matchAll(TOOL_RESULT_PATH_MARKER_RE)) { + const candidate = match[1]?.trim(); + if (candidate) { + addToolLocation(locations, candidate); + } + } +} + +function collectToolLocations( + value: unknown, + locations: Map, + state: { visited: number; depth: number }, +): void { + if (state.visited >= TOOL_LOCATION_MAX_NODES || state.depth > TOOL_LOCATION_MAX_DEPTH) { + return; + } + state.visited += 1; + + if (typeof value === "string") { + collectLocationsFromTextMarkers(value, locations); + return; + } + if (!value || typeof value !== "object") { + return; + } + if (Array.isArray(value)) { + for (const item of value) { + collectToolLocations(item, locations, { visited: state.visited, depth: state.depth + 1 }); + state.visited += 1; + if (state.visited >= TOOL_LOCATION_MAX_NODES) { + return; + } + } + return; + } + + const record = value as Record; + const line = extractToolLocationLine(record); + for (const key of TOOL_LOCATION_PATH_KEYS) { + const rawPath = record[key]; + if (typeof rawPath === "string") { + addToolLocation(locations, rawPath, line); + } + } + + const content = Array.isArray(record.content) ? record.content : undefined; + if (content) { + for (const block of content) { + const entry = asRecord(block); + if (entry?.type === "text" && typeof entry.text === "string") { + collectLocationsFromTextMarkers(entry.text, locations); + } + } + } + + for (const nested of Object.values(record)) { + collectToolLocations(nested, locations, { visited: state.visited, depth: state.depth + 1 }); + state.visited += 1; + if (state.visited >= TOOL_LOCATION_MAX_NODES) { + return; + } + } +} + export function extractTextFromPrompt(prompt: ContentBlock[], maxBytes?: number): string { const parts: string[] = []; // Track accumulated byte count per block to catch oversized prompts before full concatenation @@ -152,3 +335,74 @@ export function inferToolKind(name?: string): ToolKind { } return "other"; } + +export function extractToolCallContent(value: unknown): ToolCallContent[] | undefined { + if (typeof value === "string") { + return value.trim() + ? [ + { + type: "content", + content: { + type: "text", + text: value, + }, + }, + ] + : undefined; + } + + const record = asRecord(value); + if (!record) { + return undefined; + } + + const contents: ToolCallContent[] = []; + const blocks = Array.isArray(record.content) ? record.content : []; + for (const block of blocks) { + const entry = asRecord(block); + if (entry?.type === "text" && typeof entry.text === "string" && entry.text.trim()) { + contents.push({ + type: "content", + content: { + type: "text", + text: entry.text, + }, + }); + } + } + + if (contents.length > 0) { + return contents; + } + + const fallbackText = + typeof record.text === "string" + ? record.text + : typeof record.message === "string" + ? record.message + : typeof record.error === "string" + ? record.error + : undefined; + + if (!fallbackText?.trim()) { + return undefined; + } + + return [ + { + type: "content", + content: { + type: "text", + text: fallbackText, + }, + }, + ]; +} + +export function extractToolCallLocations(...values: unknown[]): ToolCallLocation[] | undefined { + const locations = new Map(); + for (const value of values) { + collectToolLocations(value, locations, { visited: 0, depth: 0 }); + } + return locations.size > 0 ? [...locations.values()] : undefined; +} diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index 07d8bbc3db7..a591d30e1ac 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -62,6 +62,34 @@ function createSetSessionConfigOptionRequest( } as unknown as SetSessionConfigOptionRequest; } +function createToolEvent(params: { + sessionKey: string; + phase: "start" | "update" | "result"; + toolCallId: string; + name: string; + args?: Record; + partialResult?: unknown; + result?: unknown; + isError?: boolean; +}): EventFrame { + return { + event: "agent", + payload: { + sessionKey: params.sessionKey, + stream: "tool", + data: { + phase: params.phase, + toolCallId: params.toolCallId, + name: params.name, + args: params.args, + partialResult: params.partialResult, + result: params.result, + isError: params.isError, + }, + }, + } as unknown as EventFrame; +} + function createChatFinalEvent(sessionKey: string): EventFrame { return { event: "chat", @@ -561,6 +589,117 @@ describe("acp setSessionConfigOption bridge behavior", () => { }); }); +describe("acp tool streaming bridge behavior", () => { + it("maps Gateway tool partial output and file locations into ACP tool updates", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "chat.send") { + return new Promise(() => {}); + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("tool-session")); + sessionUpdate.mockClear(); + + const promptPromise = agent.prompt(createPromptRequest("tool-session", "Inspect app.ts")); + + await agent.handleGatewayEvent( + createToolEvent({ + sessionKey: "tool-session", + phase: "start", + toolCallId: "tool-1", + name: "read", + args: { path: "src/app.ts", line: 12 }, + }), + ); + await agent.handleGatewayEvent( + createToolEvent({ + sessionKey: "tool-session", + phase: "update", + toolCallId: "tool-1", + name: "read", + partialResult: { + content: [{ type: "text", text: "partial output" }], + details: { path: "src/app.ts" }, + }, + }), + ); + await agent.handleGatewayEvent( + createToolEvent({ + sessionKey: "tool-session", + phase: "result", + toolCallId: "tool-1", + name: "read", + result: { + content: [{ type: "text", text: "FILE:src/app.ts" }], + details: { path: "src/app.ts" }, + }, + }), + ); + await agent.handleGatewayEvent(createChatFinalEvent("tool-session")); + await promptPromise; + + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "tool-session", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "read: path: src/app.ts, line: 12", + status: "in_progress", + rawInput: { path: "src/app.ts", line: 12 }, + kind: "read", + locations: [{ path: "src/app.ts", line: 12 }], + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "tool-session", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + status: "in_progress", + rawOutput: { + content: [{ type: "text", text: "partial output" }], + details: { path: "src/app.ts" }, + }, + content: [ + { + type: "content", + content: { type: "text", text: "partial output" }, + }, + ], + locations: [{ path: "src/app.ts", line: 12 }], + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "tool-session", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + status: "completed", + rawOutput: { + content: [{ type: "text", text: "FILE:src/app.ts" }], + details: { path: "src/app.ts" }, + }, + content: [ + { + type: "content", + content: { type: "text", text: "FILE:src/app.ts" }, + }, + ], + locations: [{ path: "src/app.ts", line: 12 }], + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); +}); + describe("acp session metadata and usage updates", () => { it("emits a fresh usage snapshot after prompt completion when gateway totals are available", async () => { const sessionStore = createInMemorySessionStore(); diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 8628117b49c..e7fa4a7382e 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -23,6 +23,8 @@ import type { SetSessionModeRequest, SetSessionModeResponse, StopReason, + ToolCallLocation, + ToolKind, } from "@agentclientprotocol/sdk"; import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk"; import { listThinkingLevels } from "../auto-reply/thinking.js"; @@ -37,8 +39,11 @@ import { shortenHomePath } from "../utils.js"; import { getAvailableCommands } from "./commands.js"; import { extractAttachmentsFromPrompt, + extractToolCallContent, + extractToolCallLocations, extractTextFromPrompt, formatToolTitle, + inferToolKind, } from "./event-mapper.js"; import { readBool, readNumber, readString } from "./meta.js"; import { parseSessionMeta, resetSessionIfNeeded, resolveSessionKey } from "./session-mapper.js"; @@ -62,7 +67,14 @@ type PendingPrompt = { reject: (err: Error) => void; sentTextLength?: number; sentText?: string; - toolCalls?: Set; + toolCalls?: Map; +}; + +type PendingToolCall = { + kind: ToolKind; + locations?: ToolCallLocation[]; + rawInput?: Record; + title: string; }; type AcpGatewayAgentOptions = AcpServerOptions & { @@ -681,21 +693,48 @@ export class AcpGatewayAgent implements Agent { if (phase === "start") { if (!pending.toolCalls) { - pending.toolCalls = new Set(); + pending.toolCalls = new Map(); } if (pending.toolCalls.has(toolCallId)) { return; } - pending.toolCalls.add(toolCallId); const args = data.args as Record | undefined; + const title = formatToolTitle(name, args); + const kind = inferToolKind(name); + const locations = extractToolCallLocations(args); + pending.toolCalls.set(toolCallId, { + title, + kind, + rawInput: args, + locations, + }); await this.connection.sessionUpdate({ sessionId: pending.sessionId, update: { sessionUpdate: "tool_call", toolCallId, - title: formatToolTitle(name, args), + title, status: "in_progress", rawInput: args, + kind, + locations, + }, + }); + return; + } + + if (phase === "update") { + const toolState = pending.toolCalls?.get(toolCallId); + const partialResult = data.partialResult; + await this.connection.sessionUpdate({ + sessionId: pending.sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "in_progress", + rawOutput: partialResult, + content: extractToolCallContent(partialResult), + locations: extractToolCallLocations(toolState?.locations, partialResult), }, }); return; @@ -703,6 +742,7 @@ export class AcpGatewayAgent implements Agent { if (phase === "result") { const isError = Boolean(data.isError); + const toolState = pending.toolCalls?.get(toolCallId); pending.toolCalls?.delete(toolCallId); await this.connection.sessionUpdate({ sessionId: pending.sessionId, @@ -711,6 +751,8 @@ export class AcpGatewayAgent implements Agent { toolCallId, status: isError ? "failed" : "completed", rawOutput: data.result, + content: extractToolCallContent(data.result), + locations: extractToolCallLocations(toolState?.locations, data.result), }, }); } From 4aebff78bc32b9ed15e4889510c8285507bda6d7 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:32:32 +0100 Subject: [PATCH 0015/1173] acp: forward attachments into ACP runtime sessions (#41427) Merged via squash. Prepared head SHA: f2ac51df2c4c84a7c3f7150cb736b087d592ac94 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + extensions/acpx/src/runtime.ts | 15 ++++++++- src/acp/control-plane/manager.core.ts | 1 + src/acp/control-plane/manager.types.ts | 6 ++++ src/acp/runtime/types.ts | 6 ++++ src/auto-reply/reply/dispatch-acp.test.ts | 33 +++++++++++++++++++ src/auto-reply/reply/dispatch-acp.ts | 40 ++++++++++++++++++++++- 7 files changed, 100 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fff858ba5fc..7d0a735cb18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky. - ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky. - ACP/tool streaming: enrich `tool_call` and `tool_call_update` events with best-effort text content and file-location hints so IDE clients can follow bridge tool activity more naturally. (#41442) Thanks @mbelinky. +- ACP/runtime attachments: forward normalized inbound image attachments into ACP runtime turns so ACPX sessions can preserve image prompt content on the runtime path. (#41427) Thanks @mbelinky. ## 2026.3.8 diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index 5fa56d109e5..7e310638699 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -310,7 +310,20 @@ export class AcpxRuntime implements AcpRuntime { // Ignore EPIPE when the child exits before stdin flush completes. }); - child.stdin.end(input.text); + if (input.attachments && input.attachments.length > 0) { + const blocks: unknown[] = []; + if (input.text) { + blocks.push({ type: "text", text: input.text }); + } + for (const attachment of input.attachments) { + if (attachment.mediaType.startsWith("image/")) { + blocks.push({ type: "image", mimeType: attachment.mediaType, data: attachment.data }); + } + } + child.stdin.end(blocks.length > 0 ? JSON.stringify(blocks) : input.text); + } else { + child.stdin.end(input.text); + } let stderr = ""; child.stderr.on("data", (chunk) => { diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts index a64b1fae7eb..f511355ae87 100644 --- a/src/acp/control-plane/manager.core.ts +++ b/src/acp/control-plane/manager.core.ts @@ -655,6 +655,7 @@ export class AcpSessionManager { for await (const event of runtime.runTurn({ handle, text: input.text, + attachments: input.attachments, mode: input.mode, requestId: input.requestId, signal: combinedSignal, diff --git a/src/acp/control-plane/manager.types.ts b/src/acp/control-plane/manager.types.ts index 7337e8063f9..33c2355305c 100644 --- a/src/acp/control-plane/manager.types.ts +++ b/src/acp/control-plane/manager.types.ts @@ -47,10 +47,16 @@ export type AcpInitializeSessionInput = { backendId?: string; }; +export type AcpTurnAttachment = { + mediaType: string; + data: string; +}; + export type AcpRunTurnInput = { cfg: OpenClawConfig; sessionKey: string; text: string; + attachments?: AcpTurnAttachment[]; mode: AcpRuntimePromptMode; requestId: string; signal?: AbortSignal; diff --git a/src/acp/runtime/types.ts b/src/acp/runtime/types.ts index 6a3d3bb3f8e..2d4b10ccf2c 100644 --- a/src/acp/runtime/types.ts +++ b/src/acp/runtime/types.ts @@ -39,9 +39,15 @@ export type AcpRuntimeEnsureInput = { env?: Record; }; +export type AcpRuntimeTurnAttachment = { + mediaType: string; + data: string; +}; + export type AcpRuntimeTurnInput = { handle: AcpRuntimeHandle; text: string; + attachments?: AcpRuntimeTurnAttachment[]; mode: AcpRuntimePromptMode; requestId: string; signal?: AbortSignal; diff --git a/src/auto-reply/reply/dispatch-acp.test.ts b/src/auto-reply/reply/dispatch-acp.test.ts index 286b73a7ceb..290846a6075 100644 --- a/src/auto-reply/reply/dispatch-acp.test.ts +++ b/src/auto-reply/reply/dispatch-acp.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { AcpRuntimeError } from "../../acp/runtime/errors.js"; import type { AcpSessionStoreEntry } from "../../acp/runtime/session-meta.js"; @@ -131,6 +134,7 @@ async function runDispatch(params: { dispatcher?: ReplyDispatcher; shouldRouteToOriginating?: boolean; onReplyStart?: () => void; + ctxOverrides?: Record; }) { return tryDispatchAcpReply({ ctx: buildTestCtx({ @@ -138,6 +142,7 @@ async function runDispatch(params: { Surface: "discord", SessionKey: sessionKey, BodyForAgent: params.bodyForAgent, + ...params.ctxOverrides, }), cfg: params.cfg ?? createAcpTestConfig(), dispatcher: params.dispatcher ?? createDispatcher().dispatcher, @@ -353,6 +358,34 @@ describe("tryDispatchAcpReply", () => { expect(onReplyStart).not.toHaveBeenCalled(); }); + it("forwards normalized image attachments into ACP turns", async () => { + setReadyAcpResolution(); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-acp-")); + const imagePath = path.join(tempDir, "inbound.png"); + await fs.writeFile(imagePath, "image-bytes"); + managerMocks.runTurn.mockResolvedValue(undefined); + + await runDispatch({ + bodyForAgent: " ", + ctxOverrides: { + MediaPath: imagePath, + MediaType: "image/png", + }, + }); + + expect(managerMocks.runTurn).toHaveBeenCalledWith( + expect.objectContaining({ + text: "", + attachments: [ + { + mediaType: "image/png", + data: Buffer.from("image-bytes").toString("base64"), + }, + ], + }), + ); + }); + it("surfaces ACP policy errors as final error replies", async () => { setReadyAcpResolution(); policyMocks.resolveAcpDispatchPolicyError.mockReturnValue( diff --git a/src/auto-reply/reply/dispatch-acp.ts b/src/auto-reply/reply/dispatch-acp.ts index 33990cb20d6..3b89feaae13 100644 --- a/src/auto-reply/reply/dispatch-acp.ts +++ b/src/auto-reply/reply/dispatch-acp.ts @@ -1,4 +1,6 @@ +import fs from "node:fs/promises"; import { getAcpSessionManager } from "../../acp/control-plane/manager.js"; +import type { AcpTurnAttachment } from "../../acp/control-plane/manager.types.js"; import { resolveAcpAgentPolicyError, resolveAcpDispatchPolicyError } from "../../acp/policy.js"; import { formatAcpRuntimeErrorText } from "../../acp/runtime/error-text.js"; import { toAcpRuntimeError } from "../../acp/runtime/errors.js"; @@ -14,6 +16,10 @@ import { logVerbose } from "../../globals.js"; import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; import { generateSecureUuid } from "../../infra/secure-random.js"; import { prefixSystemMessage } from "../../infra/system-message.js"; +import { + normalizeAttachmentPath, + normalizeAttachments, +} from "../../media-understanding/attachments.normalize.js"; import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { maybeApplyTtsToPayload, resolveTtsConfig } from "../../tts/tts.js"; import { @@ -57,6 +63,36 @@ function resolveAcpPromptText(ctx: FinalizedMsgContext): string { ]).trim(); } +const ACP_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024; + +async function resolveAcpAttachments(ctx: FinalizedMsgContext): Promise { + const mediaAttachments = normalizeAttachments(ctx); + const results: AcpTurnAttachment[] = []; + for (const attachment of mediaAttachments) { + const filePath = normalizeAttachmentPath(attachment.path); + if (!filePath) { + continue; + } + try { + const stat = await fs.stat(filePath); + if (stat.size > ACP_ATTACHMENT_MAX_BYTES) { + logVerbose( + `dispatch-acp: skipping attachment ${filePath} (${stat.size} bytes exceeds ${ACP_ATTACHMENT_MAX_BYTES} byte limit)`, + ); + continue; + } + const buf = await fs.readFile(filePath); + results.push({ + mediaType: attachment.mime ?? "application/octet-stream", + data: buf.toString("base64"), + }); + } catch { + // Skip unreadable files. Text content should still be delivered. + } + } + return results; +} + function resolveCommandCandidateText(ctx: FinalizedMsgContext): string { return resolveFirstContextText(ctx, ["CommandBody", "BodyForCommands", "RawBody", "Body"]).trim(); } @@ -189,7 +225,8 @@ export async function tryDispatchAcpReply(params: { }); const promptText = resolveAcpPromptText(params.ctx); - if (!promptText) { + const attachments = await resolveAcpAttachments(params.ctx); + if (!promptText && attachments.length === 0) { const counts = params.dispatcher.getQueuedCounts(); delivery.applyRoutedCounts(counts); params.recordProcessed("completed", { reason: "acp_empty_prompt" }); @@ -251,6 +288,7 @@ export async function tryDispatchAcpReply(params: { cfg: params.cfg, sessionKey, text: promptText, + attachments: attachments.length > 0 ? attachments : undefined, mode: "prompt", requestId: resolveAcpRequestId(params.ctx), onEvent: async (event) => await projector.onEvent(event), From 0c7f07818f0eec0f4c527233019fd0d504d09804 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:40:14 +0100 Subject: [PATCH 0016/1173] acp: add regression coverage and smoke-test docs (#41456) Merged via squash. Prepared head SHA: 514d5873520683efcca1542cbca1ee6ec645582b Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + docs/tools/acp-agents.md | 40 +++++++++++++++++++ extensions/acpx/src/runtime.test.ts | 33 +++++++++++++++ ...sessions.gateway-server-sessions-a.test.ts | 12 ++++++ 4 files changed, 86 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d0a735cb18..dfa23b105af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky. - ACP/tool streaming: enrich `tool_call` and `tool_call_update` events with best-effort text content and file-location hints so IDE clients can follow bridge tool activity more naturally. (#41442) Thanks @mbelinky. - ACP/runtime attachments: forward normalized inbound image attachments into ACP runtime turns so ACPX sessions can preserve image prompt content on the runtime path. (#41427) Thanks @mbelinky. +- ACP/regressions: add gateway RPC coverage for ACP lineage patching, ACPX runtime coverage for image prompt serialization, and an operator smoke-test procedure for live ACP spawn verification. (#41456) Thanks @mbelinky. ## 2026.3.8 diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 74ed73248f1..e41a96248ae 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -246,6 +246,46 @@ Interface details: - `streamTo` (optional): `"parent"` streams initial ACP run progress summaries back to the requester session as system events. - When available, accepted responses include `streamLogPath` pointing to a session-scoped JSONL log (`.acp-stream.jsonl`) you can tail for full relay history. +### Operator smoke test + +Use this after a gateway deploy when you want a quick live check that ACP spawn +is actually working end-to-end, not just passing unit tests. + +Recommended gate: + +1. Verify the deployed gateway version/commit on the target host. +2. Confirm the deployed source includes the ACP lineage acceptance in + `src/gateway/sessions-patch.ts` (`subagent:* or acp:* sessions`). +3. Open a temporary ACPX bridge session to a live agent (for example + `razor(main)` on `jpclawhq`). +4. Ask that agent to call `sessions_spawn` with: + - `runtime: "acp"` + - `agentId: "codex"` + - `mode: "run"` + - task: `Reply with exactly LIVE-ACP-SPAWN-OK` +5. Verify the agent reports: + - `accepted=yes` + - a real `childSessionKey` + - no validator error +6. Clean up the temporary ACPX bridge session. + +Example prompt to the live agent: + +```text +Use the sessions_spawn tool now with runtime: "acp", agentId: "codex", and mode: "run". +Set the task to: "Reply with exactly LIVE-ACP-SPAWN-OK". +Then report only: accepted=; childSessionKey=; error=. +``` + +Notes: + +- Keep this smoke test on `mode: "run"` unless you are intentionally testing + thread-bound persistent ACP sessions. +- Do not require `streamTo: "parent"` for the basic gate. That path depends on + requester/session capabilities and is a separate integration check. +- Treat thread-bound `mode: "session"` testing as a second, richer integration + pass from a real Discord thread or Telegram topic. + ## Sandbox compatibility ACP sessions currently run on the host runtime, not inside the OpenClaw sandbox. diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index bb3b94cec9e..38137b3f581 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -127,6 +127,39 @@ describe("AcpxRuntime", () => { expect(promptArgs).toContain("--approve-all"); }); + it("serializes text plus image attachments into ACP prompt blocks", async () => { + const { runtime, logPath } = await createMockRuntimeFixture(); + + const handle = await runtime.ensureSession({ + sessionKey: "agent:codex:acp:with-image", + agent: "codex", + mode: "persistent", + }); + + for await (const _event of runtime.runTurn({ + handle, + text: "describe this image", + attachments: [{ mediaType: "image/png", data: "aW1hZ2UtYnl0ZXM=" }], + mode: "prompt", + requestId: "req-image", + })) { + // Consume stream to completion so prompt logging is finalized. + } + + const logs = await readMockRuntimeLogEntries(logPath); + const prompt = logs.find( + (entry) => + entry.kind === "prompt" && String(entry.sessionName ?? "") === "agent:codex:acp:with-image", + ); + expect(prompt).toBeDefined(); + + const stdinBlocks = JSON.parse(String(prompt?.stdinText ?? "")); + expect(stdinBlocks).toEqual([ + { type: "text", text: "describe this image" }, + { type: "image", mimeType: "image/png", data: "aW1hZ2UtYnl0ZXM=" }, + ]); + }); + it("preserves leading spaces across streamed text deltas", async () => { const runtime = sharedFixture?.runtime; expect(runtime).toBeDefined(); diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index 3837247c9bc..f986d49c648 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -463,6 +463,18 @@ describe("gateway server sessions", () => { expect(spawnedPatched.ok).toBe(true); expect(spawnedPatched.payload?.entry.spawnedBy).toBe("agent:main:main"); + const acpPatched = await rpcReq<{ + ok: true; + entry: { spawnedBy?: string; spawnDepth?: number }; + }>(ws, "sessions.patch", { + key: "agent:main:acp:child", + spawnedBy: "agent:main:main", + spawnDepth: 1, + }); + expect(acpPatched.ok).toBe(true); + expect(acpPatched.payload?.entry.spawnedBy).toBe("agent:main:main"); + expect(acpPatched.payload?.entry.spawnDepth).toBe(1); + const spawnedPatchedInvalidKey = await rpcReq(ws, "sessions.patch", { key: "agent:main:main", spawnedBy: "agent:main:main", From 0669b0ddc265742009195eb9f1e9b6e93efb8c02 Mon Sep 17 00:00:00 2001 From: Altay Date: Tue, 10 Mar 2026 00:58:51 +0300 Subject: [PATCH 0017/1173] fix(agents): probe single-provider billing cooldowns (#41422) Merged via squash. Prepared head SHA: bbc4254b94559f95c34e11734a679cbe852aba52 Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/agents/model-fallback.probe.test.ts | 60 ++++++++++++++---- src/agents/model-fallback.ts | 61 ++++++++++++++++--- ...pi-agent.auth-profile-rotation.e2e.test.ts | 48 +++++++++++++++ 4 files changed, 152 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfa23b105af..8b140351b5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - ACP/tool streaming: enrich `tool_call` and `tool_call_update` events with best-effort text content and file-location hints so IDE clients can follow bridge tool activity more naturally. (#41442) Thanks @mbelinky. - ACP/runtime attachments: forward normalized inbound image attachments into ACP runtime turns so ACPX sessions can preserve image prompt content on the runtime path. (#41427) Thanks @mbelinky. - ACP/regressions: add gateway RPC coverage for ACP lineage patching, ACPX runtime coverage for image prompt serialization, and an operator smoke-test procedure for live ACP spawn verification. (#41456) Thanks @mbelinky. +- Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf. ## 2026.3.8 diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts index 01bcb2dc3a8..9426eba6afc 100644 --- a/src/agents/model-fallback.probe.test.ts +++ b/src/agents/model-fallback.probe.test.ts @@ -251,6 +251,36 @@ describe("runWithModelFallback – probe logic", () => { expectPrimaryProbeSuccess(result, run, "probed-ok"); }); + it("prunes stale probe throttle entries before checking eligibility", () => { + _probeThrottleInternals.lastProbeAttempt.set( + "stale", + NOW - _probeThrottleInternals.PROBE_STATE_TTL_MS - 1, + ); + _probeThrottleInternals.lastProbeAttempt.set("fresh", NOW - 5_000); + + expect(_probeThrottleInternals.lastProbeAttempt.has("stale")).toBe(true); + + expect(_probeThrottleInternals.isProbeThrottleOpen(NOW, "fresh")).toBe(false); + + expect(_probeThrottleInternals.lastProbeAttempt.has("stale")).toBe(false); + expect(_probeThrottleInternals.lastProbeAttempt.has("fresh")).toBe(true); + }); + + it("caps probe throttle state by evicting the oldest entries", () => { + for (let i = 0; i < _probeThrottleInternals.MAX_PROBE_KEYS; i += 1) { + _probeThrottleInternals.lastProbeAttempt.set(`key-${i}`, NOW - (i + 1)); + } + + _probeThrottleInternals.markProbeAttempt(NOW, "freshest"); + + expect(_probeThrottleInternals.lastProbeAttempt.size).toBe( + _probeThrottleInternals.MAX_PROBE_KEYS, + ); + expect(_probeThrottleInternals.lastProbeAttempt.has("freshest")).toBe(true); + expect(_probeThrottleInternals.lastProbeAttempt.has("key-255")).toBe(false); + expect(_probeThrottleInternals.lastProbeAttempt.has("key-0")).toBe(true); + }); + it("handles non-finite soonest safely (treats as probe-worthy)", async () => { const cfg = makeCfg(); @@ -346,7 +376,7 @@ describe("runWithModelFallback – probe logic", () => { }); }); - it("skips billing-cooldowned primary when no fallback candidates exist", async () => { + it("probes billing-cooldowned primary when no fallback candidates exist", async () => { const cfg = makeCfg({ agents: { defaults: { @@ -358,20 +388,28 @@ describe("runWithModelFallback – probe logic", () => { }, } as Partial); - // Billing cooldown far from expiry — would normally be skipped + // Single-provider setups need periodic probes even when the billing + // cooldown is far from expiry, otherwise topping up credits never recovers + // without a restart. const expiresIn30Min = NOW + 30 * 60 * 1000; mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn30Min); mockedResolveProfilesUnavailableReason.mockReturnValue("billing"); - await expect( - runWithModelFallback({ - cfg, - provider: "openai", - model: "gpt-4.1-mini", - fallbacksOverride: [], - run: vi.fn().mockResolvedValue("billing-recovered"), - }), - ).rejects.toThrow("All models failed"); + const run = vi.fn().mockResolvedValue("billing-recovered"); + + const result = await runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-4.1-mini", + fallbacksOverride: [], + run, + }); + + expect(result.result).toBe("billing-recovered"); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini", { + allowTransientCooldownProbe: true, + }); }); it("probes billing-cooldowned primary with fallbacks when near cooldown expiry", async () => { diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index ad2b5759233..b9ff9d668ff 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -342,12 +342,51 @@ const lastProbeAttempt = new Map(); const MIN_PROBE_INTERVAL_MS = 30_000; // 30 seconds between probes per key const PROBE_MARGIN_MS = 2 * 60 * 1000; const PROBE_SCOPE_DELIMITER = "::"; +const PROBE_STATE_TTL_MS = 24 * 60 * 60 * 1000; +const MAX_PROBE_KEYS = 256; function resolveProbeThrottleKey(provider: string, agentDir?: string): string { const scope = String(agentDir ?? "").trim(); return scope ? `${scope}${PROBE_SCOPE_DELIMITER}${provider}` : provider; } +function pruneProbeState(now: number): void { + for (const [key, ts] of lastProbeAttempt) { + if (!Number.isFinite(ts) || ts <= 0 || now - ts > PROBE_STATE_TTL_MS) { + lastProbeAttempt.delete(key); + } + } +} + +function enforceProbeStateCap(): void { + while (lastProbeAttempt.size > MAX_PROBE_KEYS) { + let oldestKey: string | null = null; + let oldestTs = Number.POSITIVE_INFINITY; + for (const [key, ts] of lastProbeAttempt) { + if (ts < oldestTs) { + oldestKey = key; + oldestTs = ts; + } + } + if (!oldestKey) { + break; + } + lastProbeAttempt.delete(oldestKey); + } +} + +function isProbeThrottleOpen(now: number, throttleKey: string): boolean { + pruneProbeState(now); + const lastProbe = lastProbeAttempt.get(throttleKey) ?? 0; + return now - lastProbe >= MIN_PROBE_INTERVAL_MS; +} + +function markProbeAttempt(now: number, throttleKey: string): void { + pruneProbeState(now); + lastProbeAttempt.set(throttleKey, now); + enforceProbeStateCap(); +} + function shouldProbePrimaryDuringCooldown(params: { isPrimary: boolean; hasFallbackCandidates: boolean; @@ -360,8 +399,7 @@ function shouldProbePrimaryDuringCooldown(params: { return false; } - const lastProbe = lastProbeAttempt.get(params.throttleKey) ?? 0; - if (params.now - lastProbe < MIN_PROBE_INTERVAL_MS) { + if (!isProbeThrottleOpen(params.now, params.throttleKey)) { return false; } @@ -379,7 +417,12 @@ export const _probeThrottleInternals = { lastProbeAttempt, MIN_PROBE_INTERVAL_MS, PROBE_MARGIN_MS, + PROBE_STATE_TTL_MS, + MAX_PROBE_KEYS, resolveProbeThrottleKey, + isProbeThrottleOpen, + pruneProbeState, + markProbeAttempt, } as const; type CooldownDecision = @@ -429,11 +472,15 @@ function resolveCooldownDecision(params: { } // Billing is semi-persistent: the user may fix their balance, or a transient - // 402 might have been misclassified. Probe the primary only when fallbacks - // exist; otherwise repeated single-provider probes just churn the disabled - // auth state without opening any recovery path. + // 402 might have been misclassified. Probe single-provider setups on the + // standard throttle so they can recover without a restart; when fallbacks + // exist, only probe near cooldown expiry so the fallback chain stays preferred. if (inferredReason === "billing") { - if (params.isPrimary && params.hasFallbackCandidates && shouldProbe) { + const shouldProbeSingleProviderBilling = + params.isPrimary && + !params.hasFallbackCandidates && + isProbeThrottleOpen(params.now, params.probeThrottleKey); + if (params.isPrimary && (shouldProbe || shouldProbeSingleProviderBilling)) { return { type: "attempt", reason: inferredReason, markProbe: true }; } return { @@ -528,7 +575,7 @@ export async function runWithModelFallback(params: { } if (decision.markProbe) { - lastProbeAttempt.set(probeThrottleKey, now); + markProbeAttempt(now, probeThrottleKey); } if ( decision.reason === "rate_limit" || diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts index 75ce17eb197..432ae17daa1 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts @@ -1013,6 +1013,54 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); }); + it("can probe one billing-disabled profile when transient cooldown probe is allowed without fallback models", async () => { + await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => { + await writeAuthStore(agentDir, { + usageStats: { + "openai:p1": { + lastUsed: 1, + disabledUntil: now + 60 * 60 * 1000, + disabledReason: "billing", + }, + "openai:p2": { + lastUsed: 2, + disabledUntil: now + 60 * 60 * 1000, + disabledReason: "billing", + }, + }, + }); + + runEmbeddedAttemptMock.mockResolvedValueOnce( + makeAttempt({ + assistantTexts: ["ok"], + lastAssistant: buildAssistant({ + stopReason: "stop", + content: [{ type: "text", text: "ok" }], + }), + }), + ); + + const result = await runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: "agent:test:billing-cooldown-probe-no-fallbacks", + sessionFile: path.join(workspaceDir, "session.jsonl"), + workspaceDir, + agentDir, + config: makeConfig(), + prompt: "hello", + provider: "openai", + model: "mock-1", + authProfileIdSource: "auto", + allowTransientCooldownProbe: true, + timeoutMs: 5_000, + runId: "run:billing-cooldown-probe-no-fallbacks", + }); + + expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1); + expect(result.payloads?.[0]?.text ?? "").toContain("ok"); + }); + }); + it("treats agent-level fallbacks as configured when defaults have none", async () => { await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => { await writeAuthStore(agentDir, { From 3c3474360be81d53652f9f4f93bfbe5d72a80ddc Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:03:50 +0100 Subject: [PATCH 0018/1173] acp: harden follow-up reliability and attachments (#41464) Merged via squash. Prepared head SHA: 7d167dff54ab975f90224feb3fe697a5e508e895 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + src/acp/event-mapper.test.ts | 18 +++ src/acp/event-mapper.ts | 18 +-- src/acp/translator.session-rate-limit.test.ts | 112 ++++++++++++++++++ src/acp/translator.ts | 16 ++- src/auto-reply/reply/dispatch-acp.test.ts | 70 +++++++---- src/auto-reply/reply/dispatch-acp.ts | 39 ++++-- 7 files changed, 230 insertions(+), 44 deletions(-) create mode 100644 src/acp/event-mapper.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b140351b5d..93483d148d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - ACP/runtime attachments: forward normalized inbound image attachments into ACP runtime turns so ACPX sessions can preserve image prompt content on the runtime path. (#41427) Thanks @mbelinky. - ACP/regressions: add gateway RPC coverage for ACP lineage patching, ACPX runtime coverage for image prompt serialization, and an operator smoke-test procedure for live ACP spawn verification. (#41456) Thanks @mbelinky. - Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf. +- ACP/follow-up hardening: make session restore and prompt completion degrade gracefully on transcript/update failures, enforce bounded tool-location traversal, and skip non-image ACPX turns the runtime cannot serialize. (#41464) Thanks @mbelinky. ## 2026.3.8 diff --git a/src/acp/event-mapper.test.ts b/src/acp/event-mapper.test.ts new file mode 100644 index 00000000000..2aca401d483 --- /dev/null +++ b/src/acp/event-mapper.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { extractToolCallLocations } from "./event-mapper.js"; + +describe("extractToolCallLocations", () => { + it("enforces the global node visit cap across nested structures", () => { + const nested = Array.from({ length: 20 }, (_, outer) => + Array.from({ length: 20 }, (_, inner) => + inner === 19 ? { path: `/tmp/file-${outer}.txt` } : { note: `${outer}-${inner}` }, + ), + ); + + const locations = extractToolCallLocations(nested); + + expect(locations).toBeDefined(); + expect(locations?.length).toBeLessThan(20); + expect(locations).not.toContainEqual({ path: "/tmp/file-19.txt" }); + }); +}); diff --git a/src/acp/event-mapper.ts b/src/acp/event-mapper.ts index 2a74f5691cf..c164f356307 100644 --- a/src/acp/event-mapper.ts +++ b/src/acp/event-mapper.ts @@ -186,9 +186,10 @@ function collectLocationsFromTextMarkers( function collectToolLocations( value: unknown, locations: Map, - state: { visited: number; depth: number }, + state: { visited: number }, + depth: number, ): void { - if (state.visited >= TOOL_LOCATION_MAX_NODES || state.depth > TOOL_LOCATION_MAX_DEPTH) { + if (state.visited >= TOOL_LOCATION_MAX_NODES || depth > TOOL_LOCATION_MAX_DEPTH) { return; } state.visited += 1; @@ -202,8 +203,7 @@ function collectToolLocations( } if (Array.isArray(value)) { for (const item of value) { - collectToolLocations(item, locations, { visited: state.visited, depth: state.depth + 1 }); - state.visited += 1; + collectToolLocations(item, locations, state, depth + 1); if (state.visited >= TOOL_LOCATION_MAX_NODES) { return; } @@ -230,9 +230,11 @@ function collectToolLocations( } } - for (const nested of Object.values(record)) { - collectToolLocations(nested, locations, { visited: state.visited, depth: state.depth + 1 }); - state.visited += 1; + for (const [key, nested] of Object.entries(record)) { + if (key === "content") { + continue; + } + collectToolLocations(nested, locations, state, depth + 1); if (state.visited >= TOOL_LOCATION_MAX_NODES) { return; } @@ -402,7 +404,7 @@ export function extractToolCallContent(value: unknown): ToolCallContent[] | unde export function extractToolCallLocations(...values: unknown[]): ToolCallLocation[] | undefined { const locations = new Map(); for (const value of values) { - collectToolLocations(value, locations, { visited: 0, depth: 0 }); + collectToolLocations(value, locations, { visited: 0 }, 0); } return locations.size > 0 ? [...locations.values()] : undefined; } diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index a591d30e1ac..d08ae1a1567 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -365,6 +365,63 @@ describe("acp session UX bridge behavior", () => { sessionStore.clearAllSessionsForTest(); }); + + it("falls back to an empty transcript when sessions.get fails during loadSession", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "agent:main:recover", + label: "recover", + displayName: "Recover session", + kind: "direct", + updatedAt: 1_710_000_000_000, + thinkingLevel: "adaptive", + modelProvider: "openai", + model: "gpt-5.4", + }, + ], + }; + } + if (method === "sessions.get") { + throw new Error("sessions.get unavailable"); + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + const result = await agent.loadSession(createLoadSessionRequest("agent:main:recover")); + + expect(result.modes?.currentModeId).toBe("adaptive"); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "agent:main:recover", + update: expect.objectContaining({ + sessionUpdate: "available_commands_update", + }), + }); + expect(sessionUpdate).not.toHaveBeenCalledWith({ + sessionId: "agent:main:recover", + update: expect.objectContaining({ + sessionUpdate: "user_message_chunk", + }), + }); + + sessionStore.clearAllSessionsForTest(); + }); }); describe("acp setSessionMode bridge behavior", () => { @@ -771,6 +828,61 @@ describe("acp session metadata and usage updates", () => { sessionStore.clearAllSessionsForTest(); }); + + it("still resolves prompts when snapshot updates fail after completion", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "usage-session", + displayName: "Usage session", + kind: "direct", + updatedAt: 1_710_000_123_000, + thinkingLevel: "adaptive", + modelProvider: "openai", + model: "gpt-5.4", + totalTokens: 1200, + totalTokensFresh: true, + contextTokens: 4000, + }, + ], + }; + } + if (method === "chat.send") { + return new Promise(() => {}); + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("usage-session")); + sessionUpdate.mockClear(); + sessionUpdate.mockRejectedValueOnce(new Error("session update transport failed")); + + const promptPromise = agent.prompt(createPromptRequest("usage-session", "hello")); + await agent.handleGatewayEvent(createChatFinalEvent("usage-session")); + + await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" }); + const session = sessionStore.getSession("usage-session"); + expect(session?.activeRunId).toBeNull(); + expect(session?.abortController).toBeNull(); + + sessionStore.clearAllSessionsForTest(); + }); }); describe("acp prompt size hardening", () => { diff --git a/src/acp/translator.ts b/src/acp/translator.ts index e7fa4a7382e..667c075e9c0 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -458,7 +458,10 @@ export class AcpGatewayAgent implements Agent { this.log(`loadSession: ${session.sessionId} -> ${session.sessionKey}`); const [sessionSnapshot, transcript] = await Promise.all([ this.getSessionSnapshot(session.sessionKey), - this.getSessionTranscript(session.sessionKey), + this.getSessionTranscript(session.sessionKey).catch((err) => { + this.log(`session transcript fallback for ${session.sessionKey}: ${String(err)}`); + return []; + }), ]); await this.replaySessionTranscript(session.sessionId, transcript); await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, { @@ -630,7 +633,6 @@ export class AcpGatewayAgent implements Agent { if (!session) { return; } - this.sessionStore.cancelActiveRun(params.sessionId); try { await this.gateway.request("chat.abort", { sessionKey: session.sessionKey }); @@ -841,9 +843,13 @@ export class AcpGatewayAgent implements Agent { this.pendingPrompts.delete(sessionId); this.sessionStore.clearActiveRun(sessionId); const sessionSnapshot = await this.getSessionSnapshot(pending.sessionKey); - await this.sendSessionSnapshotUpdate(sessionId, sessionSnapshot, { - includeControls: false, - }); + try { + await this.sendSessionSnapshotUpdate(sessionId, sessionSnapshot, { + includeControls: false, + }); + } catch (err) { + this.log(`session snapshot update failed for ${sessionId}: ${String(err)}`); + } pending.resolve({ stopReason }); } diff --git a/src/auto-reply/reply/dispatch-acp.test.ts b/src/auto-reply/reply/dispatch-acp.test.ts index 290846a6075..b19f2edde09 100644 --- a/src/auto-reply/reply/dispatch-acp.test.ts +++ b/src/auto-reply/reply/dispatch-acp.test.ts @@ -362,28 +362,58 @@ describe("tryDispatchAcpReply", () => { setReadyAcpResolution(); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-acp-")); const imagePath = path.join(tempDir, "inbound.png"); - await fs.writeFile(imagePath, "image-bytes"); - managerMocks.runTurn.mockResolvedValue(undefined); + try { + await fs.writeFile(imagePath, "image-bytes"); + managerMocks.runTurn.mockResolvedValue(undefined); - await runDispatch({ - bodyForAgent: " ", - ctxOverrides: { - MediaPath: imagePath, - MediaType: "image/png", - }, - }); + await runDispatch({ + bodyForAgent: " ", + ctxOverrides: { + MediaPath: imagePath, + MediaType: "image/png", + }, + }); - expect(managerMocks.runTurn).toHaveBeenCalledWith( - expect.objectContaining({ - text: "", - attachments: [ - { - mediaType: "image/png", - data: Buffer.from("image-bytes").toString("base64"), - }, - ], - }), - ); + expect(managerMocks.runTurn).toHaveBeenCalledWith( + expect.objectContaining({ + text: "", + attachments: [ + { + mediaType: "image/png", + data: Buffer.from("image-bytes").toString("base64"), + }, + ], + }), + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("skips ACP turns for non-image attachments when there is no text prompt", async () => { + setReadyAcpResolution(); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-acp-")); + const docPath = path.join(tempDir, "inbound.pdf"); + const { dispatcher } = createDispatcher(); + const onReplyStart = vi.fn(); + try { + await fs.writeFile(docPath, "pdf-bytes"); + + await runDispatch({ + bodyForAgent: " ", + dispatcher, + onReplyStart, + ctxOverrides: { + MediaPath: docPath, + MediaType: "application/pdf", + }, + }); + + expect(managerMocks.runTurn).not.toHaveBeenCalled(); + expect(onReplyStart).not.toHaveBeenCalled(); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } }); it("surfaces ACP policy errors as final error replies", async () => { diff --git a/src/auto-reply/reply/dispatch-acp.ts b/src/auto-reply/reply/dispatch-acp.ts index 3b89feaae13..8fc7110fc4c 100644 --- a/src/auto-reply/reply/dispatch-acp.ts +++ b/src/auto-reply/reply/dispatch-acp.ts @@ -16,6 +16,7 @@ import { logVerbose } from "../../globals.js"; import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; import { generateSecureUuid } from "../../infra/secure-random.js"; import { prefixSystemMessage } from "../../infra/system-message.js"; +import { applyMediaUnderstanding } from "../../media-understanding/apply.js"; import { normalizeAttachmentPath, normalizeAttachments, @@ -69,6 +70,10 @@ async function resolveAcpAttachments(ctx: FinalizedMsgContext): Promise Date: Tue, 10 Mar 2026 01:12:10 +0300 Subject: [PATCH 0019/1173] Agents: add fallback error observations (#41337) Merged via squash. Prepared head SHA: 852469c82ff28fb0e1be7f1019f5283e712c4283 Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + .../auth-profiles/state-observation.test.ts | 38 +++++++ src/agents/auth-profiles/state-observation.ts | 59 ++++++++++ src/agents/auth-profiles/usage.ts | 64 ++++++++--- src/agents/model-fallback-observation.ts | 93 ++++++++++++++++ src/agents/model-fallback.probe.test.ts | 101 ++++++++++++++++++ .../model-fallback.run-embedded.e2e.test.ts | 1 + src/agents/model-fallback.test.ts | 4 +- src/agents/model-fallback.ts | 89 ++++++++++++--- src/agents/model-fallback.types.ts | 15 +++ ...pi-agent.auth-profile-rotation.e2e.test.ts | 67 +++++++++++- src/agents/pi-embedded-runner/run.ts | 1 + .../reply/agent-runner-execution.ts | 1 + src/auto-reply/reply/agent-runner-memory.ts | 1 + src/auto-reply/reply/followup-runner.ts | 1 + src/commands/agent.ts | 1 + src/cron/isolated-agent/run.ts | 1 + 17 files changed, 502 insertions(+), 36 deletions(-) create mode 100644 src/agents/auth-profiles/state-observation.test.ts create mode 100644 src/agents/auth-profiles/state-observation.ts create mode 100644 src/agents/model-fallback-observation.ts create mode 100644 src/agents/model-fallback.types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 93483d148d0..f1724d98c8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - ACP/regressions: add gateway RPC coverage for ACP lineage patching, ACPX runtime coverage for image prompt serialization, and an operator smoke-test procedure for live ACP spawn verification. (#41456) Thanks @mbelinky. - Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf. - ACP/follow-up hardening: make session restore and prompt completion degrade gracefully on transcript/update failures, enforce bounded tool-location traversal, and skip non-image ACPX turns the runtime cannot serialize. (#41464) Thanks @mbelinky. +- Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf. ## 2026.3.8 diff --git a/src/agents/auth-profiles/state-observation.test.ts b/src/agents/auth-profiles/state-observation.test.ts new file mode 100644 index 00000000000..05f2abfff19 --- /dev/null +++ b/src/agents/auth-profiles/state-observation.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resetLogger, setLoggerOverride } from "../../logging/logger.js"; +import { logAuthProfileFailureStateChange } from "./state-observation.js"; + +afterEach(() => { + setLoggerOverride(null); + resetLogger(); +}); + +describe("logAuthProfileFailureStateChange", () => { + it("sanitizes consoleMessage fields before logging", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + setLoggerOverride({ level: "silent", consoleLevel: "warn" }); + + logAuthProfileFailureStateChange({ + runId: "run-1\nforged\tentry\rtest", + profileId: "openai:profile-1", + provider: "openai\u001b]8;;https://evil.test\u0007", + reason: "overloaded", + previous: undefined, + next: { + errorCount: 1, + cooldownUntil: 1_700_000_060_000, + failureCounts: { overloaded: 1 }, + }, + now: 1_700_000_000_000, + }); + + const consoleLine = warnSpy.mock.calls[0]?.[0]; + expect(typeof consoleLine).toBe("string"); + expect(consoleLine).toContain("runId=run-1 forged entry test"); + expect(consoleLine).toContain("provider=openai]8;;https://evil.test"); + expect(consoleLine).not.toContain("\n"); + expect(consoleLine).not.toContain("\r"); + expect(consoleLine).not.toContain("\t"); + expect(consoleLine).not.toContain("\u001b"); + }); +}); diff --git a/src/agents/auth-profiles/state-observation.ts b/src/agents/auth-profiles/state-observation.ts new file mode 100644 index 00000000000..633bdc0031b --- /dev/null +++ b/src/agents/auth-profiles/state-observation.ts @@ -0,0 +1,59 @@ +import { redactIdentifier } from "../../logging/redact-identifier.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { sanitizeForConsole } from "../pi-embedded-error-observation.js"; +import type { AuthProfileFailureReason, ProfileUsageStats } from "./types.js"; + +const observationLog = createSubsystemLogger("agent/embedded"); + +export function logAuthProfileFailureStateChange(params: { + runId?: string; + profileId: string; + provider: string; + reason: AuthProfileFailureReason; + previous: ProfileUsageStats | undefined; + next: ProfileUsageStats; + now: number; +}): void { + const windowType = + params.reason === "billing" || params.reason === "auth_permanent" ? "disabled" : "cooldown"; + const previousCooldownUntil = params.previous?.cooldownUntil; + const previousDisabledUntil = params.previous?.disabledUntil; + // Active cooldown/disable windows are intentionally immutable; log whether this + // update reused the existing window instead of extending it. + const windowReused = + windowType === "disabled" + ? typeof previousDisabledUntil === "number" && + Number.isFinite(previousDisabledUntil) && + previousDisabledUntil > params.now && + previousDisabledUntil === params.next.disabledUntil + : typeof previousCooldownUntil === "number" && + Number.isFinite(previousCooldownUntil) && + previousCooldownUntil > params.now && + previousCooldownUntil === params.next.cooldownUntil; + const safeProfileId = redactIdentifier(params.profileId, { len: 12 }); + const safeRunId = sanitizeForConsole(params.runId) ?? "-"; + const safeProvider = sanitizeForConsole(params.provider) ?? "-"; + + observationLog.warn("auth profile failure state updated", { + event: "auth_profile_failure_state_updated", + tags: ["error_handling", "auth_profiles", windowType], + runId: params.runId, + profileId: safeProfileId, + provider: params.provider, + reason: params.reason, + windowType, + windowReused, + previousErrorCount: params.previous?.errorCount, + errorCount: params.next.errorCount, + previousCooldownUntil, + cooldownUntil: params.next.cooldownUntil, + previousDisabledUntil, + disabledUntil: params.next.disabledUntil, + previousDisabledReason: params.previous?.disabledReason, + disabledReason: params.next.disabledReason, + failureCounts: params.next.failureCounts, + consoleMessage: + `auth profile failure state updated: runId=${safeRunId} profile=${safeProfileId} provider=${safeProvider} ` + + `reason=${params.reason} window=${windowType} reused=${String(windowReused)}`, + }); +} diff --git a/src/agents/auth-profiles/usage.ts b/src/agents/auth-profiles/usage.ts index 0d9ae6a6aaa..273fd754595 100644 --- a/src/agents/auth-profiles/usage.ts +++ b/src/agents/auth-profiles/usage.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../../config/config.js"; import { normalizeProviderId } from "../model-selection.js"; +import { logAuthProfileFailureStateChange } from "./state-observation.js"; import { saveAuthProfileStore, updateAuthProfileStoreWithLock } from "./store.js"; import type { AuthProfileFailureReason, AuthProfileStore, ProfileUsageStats } from "./types.js"; @@ -462,12 +463,16 @@ export async function markAuthProfileFailure(params: { reason: AuthProfileFailureReason; cfg?: OpenClawConfig; agentDir?: string; + runId?: string; }): Promise { - const { store, profileId, reason, agentDir, cfg } = params; + const { store, profileId, reason, agentDir, cfg, runId } = params; const profile = store.profiles[profileId]; if (!profile || isAuthCooldownBypassedForProvider(profile.provider)) { return; } + let nextStats: ProfileUsageStats | undefined; + let previousStats: ProfileUsageStats | undefined; + let updateTime = 0; const updated = await updateAuthProfileStoreWithLock({ agentDir, updater: (freshStore) => { @@ -482,19 +487,32 @@ export async function markAuthProfileFailure(params: { providerId: providerKey, }); - updateUsageStatsEntry(freshStore, profileId, (existing) => - computeNextProfileUsageStats({ - existing: existing ?? {}, - now, - reason, - cfgResolved, - }), - ); + previousStats = freshStore.usageStats?.[profileId]; + updateTime = now; + const computed = computeNextProfileUsageStats({ + existing: previousStats ?? {}, + now, + reason, + cfgResolved, + }); + nextStats = computed; + updateUsageStatsEntry(freshStore, profileId, () => computed); return true; }, }); if (updated) { store.usageStats = updated.usageStats; + if (nextStats) { + logAuthProfileFailureStateChange({ + runId, + profileId, + provider: profile.provider, + reason, + previous: previousStats, + next: nextStats, + now: updateTime, + }); + } return; } if (!store.profiles[profileId]) { @@ -508,15 +526,25 @@ export async function markAuthProfileFailure(params: { providerId: providerKey, }); - updateUsageStatsEntry(store, profileId, (existing) => - computeNextProfileUsageStats({ - existing: existing ?? {}, - now, - reason, - cfgResolved, - }), - ); + previousStats = store.usageStats?.[profileId]; + const computed = computeNextProfileUsageStats({ + existing: previousStats ?? {}, + now, + reason, + cfgResolved, + }); + nextStats = computed; + updateUsageStatsEntry(store, profileId, () => computed); saveAuthProfileStore(store, agentDir); + logAuthProfileFailureStateChange({ + runId, + profileId, + provider: store.profiles[profileId]?.provider ?? profile.provider, + reason, + previous: previousStats, + next: nextStats, + now, + }); } /** @@ -528,12 +556,14 @@ export async function markAuthProfileCooldown(params: { store: AuthProfileStore; profileId: string; agentDir?: string; + runId?: string; }): Promise { await markAuthProfileFailure({ store: params.store, profileId: params.profileId, reason: "unknown", agentDir: params.agentDir, + runId: params.runId, }); } diff --git a/src/agents/model-fallback-observation.ts b/src/agents/model-fallback-observation.ts new file mode 100644 index 00000000000..450e047c7d7 --- /dev/null +++ b/src/agents/model-fallback-observation.ts @@ -0,0 +1,93 @@ +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { sanitizeForLog } from "../terminal/ansi.js"; +import type { FallbackAttempt, ModelCandidate } from "./model-fallback.types.js"; +import { buildTextObservationFields } from "./pi-embedded-error-observation.js"; +import type { FailoverReason } from "./pi-embedded-helpers.js"; + +const decisionLog = createSubsystemLogger("model-fallback").child("decision"); + +function buildErrorObservationFields(error?: string): { + errorPreview?: string; + errorHash?: string; + errorFingerprint?: string; + httpCode?: string; + providerErrorType?: string; + providerErrorMessagePreview?: string; + requestIdHash?: string; +} { + const observed = buildTextObservationFields(error); + return { + errorPreview: observed.textPreview, + errorHash: observed.textHash, + errorFingerprint: observed.textFingerprint, + httpCode: observed.httpCode, + providerErrorType: observed.providerErrorType, + providerErrorMessagePreview: observed.providerErrorMessagePreview, + requestIdHash: observed.requestIdHash, + }; +} + +export function logModelFallbackDecision(params: { + decision: + | "skip_candidate" + | "probe_cooldown_candidate" + | "candidate_failed" + | "candidate_succeeded"; + runId?: string; + requestedProvider: string; + requestedModel: string; + candidate: ModelCandidate; + attempt?: number; + total?: number; + reason?: FailoverReason | null; + status?: number; + code?: string; + error?: string; + nextCandidate?: ModelCandidate; + isPrimary?: boolean; + requestedModelMatched?: boolean; + fallbackConfigured?: boolean; + allowTransientCooldownProbe?: boolean; + profileCount?: number; + previousAttempts?: FallbackAttempt[]; +}): void { + const nextText = params.nextCandidate + ? `${sanitizeForLog(params.nextCandidate.provider)}/${sanitizeForLog(params.nextCandidate.model)}` + : "none"; + const reasonText = params.reason ?? "unknown"; + const observedError = buildErrorObservationFields(params.error); + decisionLog.warn("model fallback decision", { + event: "model_fallback_decision", + tags: ["error_handling", "model_fallback", params.decision], + runId: params.runId, + decision: params.decision, + requestedProvider: params.requestedProvider, + requestedModel: params.requestedModel, + candidateProvider: params.candidate.provider, + candidateModel: params.candidate.model, + attempt: params.attempt, + total: params.total, + reason: params.reason, + status: params.status, + code: params.code, + ...observedError, + nextCandidateProvider: params.nextCandidate?.provider, + nextCandidateModel: params.nextCandidate?.model, + isPrimary: params.isPrimary, + requestedModelMatched: params.requestedModelMatched, + fallbackConfigured: params.fallbackConfigured, + allowTransientCooldownProbe: params.allowTransientCooldownProbe, + profileCount: params.profileCount, + previousAttempts: params.previousAttempts?.map((attempt) => ({ + provider: attempt.provider, + model: attempt.model, + reason: attempt.reason, + status: attempt.status, + code: attempt.code, + ...buildErrorObservationFields(attempt.error), + })), + consoleMessage: + `model fallback decision: decision=${params.decision} requested=${sanitizeForLog(params.requestedProvider)}/${sanitizeForLog(params.requestedModel)} ` + + `candidate=${sanitizeForLog(params.candidate.provider)}/${sanitizeForLog(params.candidate.model)} reason=${reasonText} next=${nextText}`, + }); +} diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts index 9426eba6afc..d08bd0d4beb 100644 --- a/src/agents/model-fallback.probe.test.ts +++ b/src/agents/model-fallback.probe.test.ts @@ -1,5 +1,8 @@ +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { registerLogTransport, resetLogger, setLoggerOverride } from "../logging/logger.js"; import type { AuthProfileStore } from "./auth-profiles.js"; import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js"; @@ -28,6 +31,7 @@ const mockedResolveProfilesUnavailableReason = vi.mocked(resolveProfilesUnavaila const mockedResolveAuthProfileOrder = vi.mocked(resolveAuthProfileOrder); const makeCfg = makeModelFallbackCfg; +let unregisterLogTransport: (() => void) | undefined; function expectFallbackUsed( result: { result: unknown; attempts: Array<{ reason?: string }> }, @@ -149,6 +153,10 @@ describe("runWithModelFallback – probe logic", () => { afterEach(() => { Date.now = realDateNow; + unregisterLogTransport?.(); + unregisterLogTransport = undefined; + setLoggerOverride(null); + resetLogger(); vi.restoreAllMocks(); }); @@ -194,6 +202,99 @@ describe("runWithModelFallback – probe logic", () => { expectPrimaryProbeSuccess(result, run, "probed-ok"); }); + it("logs primary metadata on probe success and failure fallback decisions", async () => { + const cfg = makeCfg(); + const records: Array> = []; + mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 60 * 1000); + setLoggerOverride({ + level: "trace", + consoleLevel: "silent", + file: path.join(os.tmpdir(), `openclaw-model-fallback-probe-${Date.now()}.log`), + }); + unregisterLogTransport = registerLogTransport((record) => { + records.push(record); + }); + + const run = vi.fn().mockResolvedValue("probed-ok"); + + const result = await runPrimaryCandidate(cfg, run); + + expectPrimaryProbeSuccess(result, run, "probed-ok"); + + _probeThrottleInternals.lastProbeAttempt.clear(); + + const fallbackCfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: ["anthropic/claude-haiku-3-5", "google/gemini-2-flash"], + }, + }, + }, + } as Partial); + mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 60 * 1000); + const fallbackRun = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 })) + .mockResolvedValueOnce("fallback-ok"); + + const fallbackResult = await runPrimaryCandidate(fallbackCfg, fallbackRun); + + expect(fallbackResult.result).toBe("fallback-ok"); + expect(fallbackRun).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini", { + allowTransientCooldownProbe: true, + }); + expect(fallbackRun).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5"); + + const decisionPayloads = records + .filter( + (record) => + record["2"] === "model fallback decision" && + record["1"] && + typeof record["1"] === "object", + ) + .map((record) => record["1"] as Record); + + expect(decisionPayloads).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: "model_fallback_decision", + decision: "probe_cooldown_candidate", + candidateProvider: "openai", + candidateModel: "gpt-4.1-mini", + allowTransientCooldownProbe: true, + }), + expect.objectContaining({ + event: "model_fallback_decision", + decision: "candidate_succeeded", + candidateProvider: "openai", + candidateModel: "gpt-4.1-mini", + isPrimary: true, + requestedModelMatched: true, + }), + expect.objectContaining({ + event: "model_fallback_decision", + decision: "candidate_failed", + candidateProvider: "openai", + candidateModel: "gpt-4.1-mini", + isPrimary: true, + requestedModelMatched: true, + nextCandidateProvider: "anthropic", + nextCandidateModel: "claude-haiku-3-5", + }), + expect.objectContaining({ + event: "model_fallback_decision", + decision: "candidate_succeeded", + candidateProvider: "anthropic", + candidateModel: "claude-haiku-3-5", + isPrimary: false, + requestedModelMatched: false, + }), + ]), + ); + }); + it("probes primary model when cooldown already expired", async () => { const cfg = makeCfg(); // Cooldown expired 5 min ago diff --git a/src/agents/model-fallback.run-embedded.e2e.test.ts b/src/agents/model-fallback.run-embedded.e2e.test.ts index 2e5a8202e95..504b1457143 100644 --- a/src/agents/model-fallback.run-embedded.e2e.test.ts +++ b/src/agents/model-fallback.run-embedded.e2e.test.ts @@ -207,6 +207,7 @@ async function runEmbeddedFallback(params: { cfg, provider: "openai", model: "mock-1", + runId: params.runId, agentDir: params.agentDir, run: (provider, model, options) => runEmbeddedPiAgent({ diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index c99d0a9bed9..e4c84028e95 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -536,7 +536,9 @@ describe("runWithModelFallback", () => { }); expect(result.result).toBe("ok"); - const warning = warnSpy.mock.calls[0]?.[0] as string; + const warning = warnSpy.mock.calls + .map((call) => call[0] as string) + .find((value) => value.includes('Model "openai/gpt-6spoof" not found')); expect(warning).toContain('Model "openai/gpt-6spoof" not found'); expect(warning).not.toContain("\u001B"); expect(warning).not.toContain("\n"); diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index b9ff9d668ff..373e10c936f 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -19,6 +19,8 @@ import { isFailoverError, isTimeoutError, } from "./failover-error.js"; +import { logModelFallbackDecision } from "./model-fallback-observation.js"; +import type { FallbackAttempt, ModelCandidate } from "./model-fallback.types.js"; import { buildConfiguredAllowlistKeys, buildModelAliasIndex, @@ -32,11 +34,6 @@ import { isLikelyContextOverflowError } from "./pi-embedded-helpers.js"; const log = createSubsystemLogger("model-fallback"); -type ModelCandidate = { - provider: string; - model: string; -}; - export type ModelFallbackRunOptions = { allowTransientCooldownProbe?: boolean; }; @@ -47,15 +44,6 @@ type ModelFallbackRunFn = ( options?: ModelFallbackRunOptions, ) => Promise; -type FallbackAttempt = { - provider: string; - model: string; - error: string; - reason?: FailoverReason; - status?: number; - code?: string; -}; - /** * Fallback abort check. Only treats explicit AbortError names as user aborts. * Message-based checks (e.g., "aborted") can mask timeouts and skip fallback. @@ -515,6 +503,7 @@ export async function runWithModelFallback(params: { cfg: OpenClawConfig | undefined; provider: string; model: string; + runId?: string; agentDir?: string; /** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */ fallbacksOverride?: string[]; @@ -537,7 +526,11 @@ export async function runWithModelFallback(params: { for (let i = 0; i < candidates.length; i += 1) { const candidate = candidates[i]; + const isPrimary = i === 0; + const requestedModel = + params.provider === candidate.provider && params.model === candidate.model; let runOptions: ModelFallbackRunOptions | undefined; + let attemptedDuringCooldown = false; if (authStore) { const profileIds = resolveAuthProfileOrder({ cfg: params.cfg, @@ -548,9 +541,6 @@ export async function runWithModelFallback(params: { if (profileIds.length > 0 && !isAnyProfileAvailable) { // All profiles for this provider are in cooldown. - const isPrimary = i === 0; - const requestedModel = - params.provider === candidate.provider && params.model === candidate.model; const now = Date.now(); const probeThrottleKey = resolveProbeThrottleKey(candidate.provider, params.agentDir); const decision = resolveCooldownDecision({ @@ -571,6 +561,22 @@ export async function runWithModelFallback(params: { error: decision.error, reason: decision.reason, }); + logModelFallbackDecision({ + decision: "skip_candidate", + runId: params.runId, + requestedProvider: params.provider, + requestedModel: params.model, + candidate, + attempt: i + 1, + total: candidates.length, + reason: decision.reason, + error: decision.error, + nextCandidate: candidates[i + 1], + isPrimary, + requestedModelMatched: requestedModel, + fallbackConfigured: hasFallbackCandidates, + profileCount: profileIds.length, + }); continue; } @@ -584,6 +590,23 @@ export async function runWithModelFallback(params: { ) { runOptions = { allowTransientCooldownProbe: true }; } + attemptedDuringCooldown = true; + logModelFallbackDecision({ + decision: "probe_cooldown_candidate", + runId: params.runId, + requestedProvider: params.provider, + requestedModel: params.model, + candidate, + attempt: i + 1, + total: candidates.length, + reason: decision.reason, + nextCandidate: candidates[i + 1], + isPrimary, + requestedModelMatched: requestedModel, + fallbackConfigured: hasFallbackCandidates, + allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe, + profileCount: profileIds.length, + }); } } @@ -594,6 +617,21 @@ export async function runWithModelFallback(params: { options: runOptions, }); if ("success" in attemptRun) { + if (i > 0 || attempts.length > 0 || attemptedDuringCooldown) { + logModelFallbackDecision({ + decision: "candidate_succeeded", + runId: params.runId, + requestedProvider: params.provider, + requestedModel: params.model, + candidate, + attempt: i + 1, + total: candidates.length, + previousAttempts: attempts, + isPrimary, + requestedModelMatched: requestedModel, + fallbackConfigured: hasFallbackCandidates, + }); + } const notFoundAttempt = i > 0 ? attempts.find((a) => a.reason === "model_not_found") : undefined; if (notFoundAttempt) { @@ -637,6 +675,23 @@ export async function runWithModelFallback(params: { status: described.status, code: described.code, }); + logModelFallbackDecision({ + decision: "candidate_failed", + runId: params.runId, + requestedProvider: params.provider, + requestedModel: params.model, + candidate, + attempt: i + 1, + total: candidates.length, + reason: described.reason, + status: described.status, + code: described.code, + error: described.message, + nextCandidate: candidates[i + 1], + isPrimary, + requestedModelMatched: requestedModel, + fallbackConfigured: hasFallbackCandidates, + }); await params.onError?.({ provider: candidate.provider, model: candidate.model, diff --git a/src/agents/model-fallback.types.ts b/src/agents/model-fallback.types.ts new file mode 100644 index 00000000000..92b5f974788 --- /dev/null +++ b/src/agents/model-fallback.types.ts @@ -0,0 +1,15 @@ +import type { FailoverReason } from "./pi-embedded-helpers.js"; + +export type ModelCandidate = { + provider: string; + model: string; +}; + +export type FallbackAttempt = { + provider: string; + model: string; + error: string; + reason?: FailoverReason; + status?: number; + code?: string; +}; diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts index 432ae17daa1..2d658aada32 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts @@ -2,8 +2,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { AssistantMessage } from "@mariozechner/pi-ai"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { registerLogTransport, resetLogger, setLoggerOverride } from "../logging/logger.js"; +import { redactIdentifier } from "../logging/redact-identifier.js"; import type { AuthProfileFailureReason } from "./auth-profiles.js"; import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js"; @@ -51,6 +53,7 @@ vi.mock("./models-config.js", async (importOriginal) => { }); let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent; +let unregisterLogTransport: (() => void) | undefined; beforeAll(async () => { ({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js")); @@ -64,6 +67,13 @@ beforeEach(() => { sleepWithAbortMock.mockClear(); }); +afterEach(() => { + unregisterLogTransport?.(); + unregisterLogTransport = undefined; + setLoggerOverride(null); + resetLogger(); +}); + const baseUsage = { input: 0, output: 0, @@ -720,6 +730,61 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { expect(sleepWithAbortMock).toHaveBeenCalledWith(321, undefined); }); + it("logs structured failover decision metadata for overloaded assistant rotation", async () => { + const records: Array> = []; + setLoggerOverride({ + level: "trace", + consoleLevel: "silent", + file: path.join(os.tmpdir(), `openclaw-auth-rotation-${Date.now()}.log`), + }); + unregisterLogTransport = registerLogTransport((record) => { + records.push(record); + }); + + await runAutoPinnedRotationCase({ + errorMessage: + '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_overload"}', + sessionKey: "agent:test:overloaded-logging", + runId: "run:overloaded-logging", + }); + + const decisionRecord = records.find( + (record) => + record["2"] === "embedded run failover decision" && + record["1"] && + typeof record["1"] === "object" && + (record["1"] as Record).decision === "rotate_profile", + ); + + expect(decisionRecord).toBeDefined(); + const safeProfileId = redactIdentifier("openai:p1", { len: 12 }); + expect((decisionRecord as Record)["1"]).toMatchObject({ + event: "embedded_run_failover_decision", + runId: "run:overloaded-logging", + decision: "rotate_profile", + failoverReason: "overloaded", + profileId: safeProfileId, + providerErrorType: "overloaded_error", + rawErrorPreview: expect.stringContaining('"request_id":"sha256:'), + }); + + const stateRecord = records.find( + (record) => + record["2"] === "auth profile failure state updated" && + record["1"] && + typeof record["1"] === "object" && + (record["1"] as Record).profileId === safeProfileId, + ); + + expect(stateRecord).toBeDefined(); + expect((stateRecord as Record)["1"]).toMatchObject({ + event: "auth_profile_failure_state_updated", + runId: "run:overloaded-logging", + profileId: safeProfileId, + reason: "overloaded", + }); + }); + it("rotates for overloaded prompt failures across auto-pinned profiles", async () => { const { usageStats } = await runAutoPinnedPromptErrorRotationCase({ errorMessage: '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 68677a009bd..381c76ada18 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -763,6 +763,7 @@ export async function runEmbeddedPiAgent( reason, cfg: params.config, agentDir, + runId: params.runId, }); }; const resolveAuthProfileFailureReason = ( diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 6748e3cbe68..a3b31c4ccc3 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -199,6 +199,7 @@ export async function runAgentTurnWithFallback(params: { const onToolResult = params.opts?.onToolResult; const fallbackResult = await runWithModelFallback({ ...resolveModelFallbackOptions(params.followupRun.run), + runId, run: (provider, model, runOptions) => { // Notify that model selection is complete (including after fallback). // This allows responsePrefix template interpolation with the actual model. diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 374d37d52f7..643611d35a2 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -474,6 +474,7 @@ export async function runMemoryFlushIfNeeded(params: { try { await runWithModelFallback({ ...resolveModelFallbackOptions(params.followupRun.run), + runId: flushRunId, run: async (provider, model, runOptions) => { const { authProfile, embeddedContext, senderContext } = buildEmbeddedRunContexts({ run: params.followupRun.run, diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 91e78138102..8c7eccb5f02 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -159,6 +159,7 @@ export function createFollowupRunner(params: { cfg: queued.run.config, provider: queued.run.provider, model: queued.run.model, + runId, agentDir: queued.run.agentDir, fallbacksOverride: resolveRunModelFallbacksOverride({ cfg: queued.run.config, diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 24e62cc8998..74a5078d03b 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -1103,6 +1103,7 @@ async function agentCommandInternal( cfg, provider, model, + runId, agentDir, fallbacksOverride: effectiveFallbacksOverride, run: (providerOverride, modelOverride, runOptions) => { diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 5b665b6bf8f..0666b752e5c 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -553,6 +553,7 @@ export async function runCronIsolatedAgentTurn(params: { cfg: cfgWithAgentDefaults, provider, model, + runId: cronSession.sessionEntry.sessionId, agentDir, fallbacksOverride: payloadFallbacks ?? resolveAgentModelFallbacksOverride(params.cfg, agentId), From 56f787e3c0ac4a42d6d644e3aff3c313377487b6 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:22:09 +0100 Subject: [PATCH 0020/1173] build(protocol): regenerate Swift models after pending node work schemas (#41477) Merged via squash. Prepared head SHA: cae0aaf1c2f23deb65ad797b482d6c212236b18f Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + .../OpenClawProtocol/GatewayModels.swift | 96 +++++++++++++++++++ .../OpenClawProtocol/GatewayModels.swift | 96 +++++++++++++++++++ 3 files changed, 193 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1724d98c8a..85871923b8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai - Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf. - ACP/follow-up hardening: make session restore and prompt completion degrade gracefully on transcript/update failures, enforce bounded tool-location traversal, and skip non-image ACPX turns the runtime cannot serialize. (#41464) Thanks @mbelinky. - Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf. +- Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky. ## 2026.3.8 diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index a6223d95bee..cf69609e673 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -950,6 +950,102 @@ public struct NodeEventParams: Codable, Sendable { } } +public struct NodePendingDrainParams: Codable, Sendable { + public let maxitems: Int? + + public init( + maxitems: Int?) + { + self.maxitems = maxitems + } + + private enum CodingKeys: String, CodingKey { + case maxitems = "maxItems" + } +} + +public struct NodePendingDrainResult: Codable, Sendable { + public let nodeid: String + public let revision: Int + public let items: [[String: AnyCodable]] + public let hasmore: Bool + + public init( + nodeid: String, + revision: Int, + items: [[String: AnyCodable]], + hasmore: Bool) + { + self.nodeid = nodeid + self.revision = revision + self.items = items + self.hasmore = hasmore + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case revision + case items + case hasmore = "hasMore" + } +} + +public struct NodePendingEnqueueParams: Codable, Sendable { + public let nodeid: String + public let type: String + public let priority: String? + public let expiresinms: Int? + public let wake: Bool? + + public init( + nodeid: String, + type: String, + priority: String?, + expiresinms: Int?, + wake: Bool?) + { + self.nodeid = nodeid + self.type = type + self.priority = priority + self.expiresinms = expiresinms + self.wake = wake + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case type + case priority + case expiresinms = "expiresInMs" + case wake + } +} + +public struct NodePendingEnqueueResult: Codable, Sendable { + public let nodeid: String + public let revision: Int + public let queued: [String: AnyCodable] + public let waketriggered: Bool + + public init( + nodeid: String, + revision: Int, + queued: [String: AnyCodable], + waketriggered: Bool) + { + self.nodeid = nodeid + self.revision = revision + self.queued = queued + self.waketriggered = waketriggered + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case revision + case queued + case waketriggered = "wakeTriggered" + } +} + public struct NodeInvokeRequestEvent: Codable, Sendable { public let id: String public let nodeid: String diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index a6223d95bee..cf69609e673 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -950,6 +950,102 @@ public struct NodeEventParams: Codable, Sendable { } } +public struct NodePendingDrainParams: Codable, Sendable { + public let maxitems: Int? + + public init( + maxitems: Int?) + { + self.maxitems = maxitems + } + + private enum CodingKeys: String, CodingKey { + case maxitems = "maxItems" + } +} + +public struct NodePendingDrainResult: Codable, Sendable { + public let nodeid: String + public let revision: Int + public let items: [[String: AnyCodable]] + public let hasmore: Bool + + public init( + nodeid: String, + revision: Int, + items: [[String: AnyCodable]], + hasmore: Bool) + { + self.nodeid = nodeid + self.revision = revision + self.items = items + self.hasmore = hasmore + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case revision + case items + case hasmore = "hasMore" + } +} + +public struct NodePendingEnqueueParams: Codable, Sendable { + public let nodeid: String + public let type: String + public let priority: String? + public let expiresinms: Int? + public let wake: Bool? + + public init( + nodeid: String, + type: String, + priority: String?, + expiresinms: Int?, + wake: Bool?) + { + self.nodeid = nodeid + self.type = type + self.priority = priority + self.expiresinms = expiresinms + self.wake = wake + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case type + case priority + case expiresinms = "expiresInMs" + case wake + } +} + +public struct NodePendingEnqueueResult: Codable, Sendable { + public let nodeid: String + public let revision: Int + public let queued: [String: AnyCodable] + public let waketriggered: Bool + + public init( + nodeid: String, + revision: Int, + queued: [String: AnyCodable], + waketriggered: Bool) + { + self.nodeid = nodeid + self.revision = revision + self.queued = queued + self.waketriggered = waketriggered + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case revision + case queued + case waketriggered = "wakeTriggered" + } +} + public struct NodeInvokeRequestEvent: Codable, Sendable { public let id: String public let nodeid: String From 64746c150c4d721fe30dc301073ea5a1ba83f4de Mon Sep 17 00:00:00 2001 From: Hermione Date: Mon, 9 Mar 2026 22:30:24 +0000 Subject: [PATCH 0021/1173] fix(discord): apply effective maxLinesPerMessage in live replies (#40133) Merged via squash. Prepared head SHA: 031d0325347abd11892fbd5f44328f6b3c043902 Co-authored-by: rbutera <6047293+rbutera@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/discord/accounts.test.ts | 61 ++++++++++++++++++- src/discord/accounts.ts | 14 +++++ src/discord/monitor/agent-components.ts | 7 ++- .../monitor/message-handler.process.test.ts | 32 ++++++++++ .../monitor/message-handler.process.ts | 10 ++- .../native-command.commands-allowfrom.test.ts | 45 +++++++++++++- src/discord/monitor/native-command.ts | 5 +- src/discord/monitor/reply-delivery.test.ts | 23 +++++++ src/discord/monitor/reply-delivery.ts | 19 +++++- 10 files changed, 207 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85871923b8d..534922abe57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai - ACP/follow-up hardening: make session restore and prompt completion degrade gracefully on transcript/update failures, enforce bounded tool-location traversal, and skip non-image ACPX turns the runtime cannot serialize. (#41464) Thanks @mbelinky. - Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf. - Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky. +- Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera. ## 2026.3.8 diff --git a/src/discord/accounts.test.ts b/src/discord/accounts.test.ts index 6fd11965a07..1f6d70b1ea0 100644 --- a/src/discord/accounts.test.ts +++ b/src/discord/accounts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveDiscordAccount } from "./accounts.js"; +import { resolveDiscordAccount, resolveDiscordMaxLinesPerMessage } from "./accounts.js"; describe("resolveDiscordAccount allowFrom precedence", () => { it("prefers accounts.default.allowFrom over top-level for default account", () => { @@ -56,3 +56,62 @@ describe("resolveDiscordAccount allowFrom precedence", () => { expect(resolved.config.allowFrom).toBeUndefined(); }); }); + +describe("resolveDiscordMaxLinesPerMessage", () => { + it("falls back to merged root discord maxLinesPerMessage when runtime config omits it", () => { + const resolved = resolveDiscordMaxLinesPerMessage({ + cfg: { + channels: { + discord: { + maxLinesPerMessage: 120, + accounts: { + default: { token: "token-default" }, + }, + }, + }, + }, + discordConfig: {}, + accountId: "default", + }); + + expect(resolved).toBe(120); + }); + + it("prefers explicit runtime discord maxLinesPerMessage over merged config", () => { + const resolved = resolveDiscordMaxLinesPerMessage({ + cfg: { + channels: { + discord: { + maxLinesPerMessage: 120, + accounts: { + default: { token: "token-default", maxLinesPerMessage: 80 }, + }, + }, + }, + }, + discordConfig: { maxLinesPerMessage: 55 }, + accountId: "default", + }); + + expect(resolved).toBe(55); + }); + + it("uses per-account discord maxLinesPerMessage over the root value when runtime config omits it", () => { + const resolved = resolveDiscordMaxLinesPerMessage({ + cfg: { + channels: { + discord: { + maxLinesPerMessage: 120, + accounts: { + work: { token: "token-work", maxLinesPerMessage: 80 }, + }, + }, + }, + }, + discordConfig: {}, + accountId: "work", + }); + + expect(resolved).toBe(80); + }); +}); diff --git a/src/discord/accounts.ts b/src/discord/accounts.ts index 75eeff40b3e..b4e71c78343 100644 --- a/src/discord/accounts.ts +++ b/src/discord/accounts.ts @@ -68,6 +68,20 @@ export function resolveDiscordAccount(params: { }; } +export function resolveDiscordMaxLinesPerMessage(params: { + cfg: OpenClawConfig; + discordConfig?: DiscordAccountConfig | null; + accountId?: string | null; +}): number | undefined { + if (typeof params.discordConfig?.maxLinesPerMessage === "number") { + return params.discordConfig.maxLinesPerMessage; + } + return resolveDiscordAccount({ + cfg: params.cfg, + accountId: params.accountId, + }).config.maxLinesPerMessage; +} + export function listEnabledDiscordAccounts(cfg: OpenClawConfig): ResolvedDiscordAccount[] { return listDiscordAccountIds(cfg) .map((accountId) => resolveDiscordAccount({ cfg, accountId })) diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index deeb9b35221..16b3f564bfe 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -43,6 +43,7 @@ import { readStoreAllowFromForDmPolicy, resolvePinnedMainDmOwnerFromAllowlist, } from "../../security/dm-policy-shared.js"; +import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js"; import { createDiscordFormModal, @@ -1017,7 +1018,11 @@ async function dispatchDiscordComponentEvent(params: { replyToId, replyToMode, textLimit, - maxLinesPerMessage: ctx.discordConfig?.maxLinesPerMessage, + maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ + cfg: ctx.cfg, + discordConfig: ctx.discordConfig, + accountId, + }), tableMode, chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId), mediaLocalRoots, diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index 9bc9cf77498..8b059d00f39 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -502,6 +502,38 @@ describe("processDiscordMessage draft streaming", () => { expect(deliverDiscordReply).toHaveBeenCalledTimes(1); }); + it("uses root discord maxLinesPerMessage for preview finalization when runtime config omits it", async () => { + const longReply = Array.from({ length: 20 }, (_value, index) => `Line ${index + 1}`).join("\n"); + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.dispatcher.sendFinalReply({ text: longReply }); + return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; + }); + + const ctx = await createBaseContext({ + cfg: { + messages: { ackReaction: "👀" }, + session: { store: "/tmp/openclaw-discord-process-test-sessions.json" }, + channels: { + discord: { + maxLinesPerMessage: 120, + }, + }, + }, + discordConfig: { streamMode: "partial" }, + }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + expect(editMessageDiscord).toHaveBeenCalledWith( + "c1", + "preview-1", + { content: longReply }, + { rest: {} }, + ); + expect(deliverDiscordReply).not.toHaveBeenCalled(); + }); + it("suppresses reasoning payload delivery to Discord", async () => { mockDispatchSingleBlockReply({ text: "thinking...", isReasoning: true }); await processStreamOffDiscordMessage(); diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 85bbccd59d3..c283658ac09 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -32,6 +32,7 @@ import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../routing/session-key.js"; import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js"; import { truncateUtf16Safe } from "../../utils.js"; +import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js"; import { createDiscordDraftStream } from "../draft-stream.js"; @@ -426,6 +427,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) channel: "discord", accountId, }); + const maxLinesPerMessage = resolveDiscordMaxLinesPerMessage({ + cfg, + discordConfig, + accountId, + }); const chunkMode = resolveChunkMode(cfg, "discord", accountId); const typingCallbacks = createTypingCallbacks({ @@ -484,7 +490,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) const formatted = convertMarkdownTables(text, tableMode); const chunks = chunkDiscordTextWithMode(formatted, { maxChars: draftMaxChars, - maxLines: discordConfig?.maxLinesPerMessage, + maxLines: maxLinesPerMessage, chunkMode, }); if (!chunks.length && formatted) { @@ -687,7 +693,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) replyToId, replyToMode, textLimit, - maxLinesPerMessage: discordConfig?.maxLinesPerMessage, + maxLinesPerMessage, tableMode, chunkMode, sessionKey: ctxPayload.SessionKey, diff --git a/src/discord/monitor/native-command.commands-allowfrom.test.ts b/src/discord/monitor/native-command.commands-allowfrom.test.ts index 218df22f071..5144eb74267 100644 --- a/src/discord/monitor/native-command.commands-allowfrom.test.ts +++ b/src/discord/monitor/native-command.commands-allowfrom.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { NativeCommandSpec } from "../../auto-reply/commands-registry.js"; import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js"; import type { OpenClawConfig } from "../../config/config.js"; +import type { DiscordAccountConfig } from "../../config/types.discord.js"; import * as pluginCommandsModule from "../../plugins/commands.js"; import { createDiscordNativeCommand } from "./native-command.js"; import { @@ -49,7 +50,7 @@ function createConfig(): OpenClawConfig { } as OpenClawConfig; } -function createCommand(cfg: OpenClawConfig) { +function createCommand(cfg: OpenClawConfig, discordConfig?: DiscordAccountConfig) { const commandSpec: NativeCommandSpec = { name: "status", description: "Status", @@ -58,7 +59,7 @@ function createCommand(cfg: OpenClawConfig) { return createDiscordNativeCommand({ command: commandSpec, cfg, - discordConfig: cfg.channels?.discord ?? {}, + discordConfig: discordConfig ?? cfg.channels?.discord ?? {}, accountId: "default", sessionPrefix: "discord:slash", ephemeralDefault: true, @@ -79,10 +80,11 @@ function createDispatchSpy() { async function runGuildSlashCommand(params?: { userId?: string; mutateConfig?: (cfg: OpenClawConfig) => void; + runtimeDiscordConfig?: DiscordAccountConfig; }) { const cfg = createConfig(); params?.mutateConfig?.(cfg); - const command = createCommand(cfg); + const command = createCommand(cfg, params?.runtimeDiscordConfig); const interaction = createInteraction({ userId: params?.userId }); vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); const dispatchSpy = createDispatchSpy(); @@ -164,4 +166,41 @@ describe("Discord native slash commands with commands.allowFrom", () => { expect(dispatchSpy).not.toHaveBeenCalled(); expectUnauthorizedReply(interaction); }); + + it("uses the root discord maxLinesPerMessage when runtime discordConfig omits it", async () => { + const longReply = Array.from({ length: 20 }, (_value, index) => `Line ${index + 1}`).join("\n"); + const { interaction } = await runGuildSlashCommand({ + mutateConfig: (cfg) => { + cfg.channels = { + ...cfg.channels, + discord: { + ...cfg.channels?.discord, + maxLinesPerMessage: 120, + }, + }; + }, + runtimeDiscordConfig: { + groupPolicy: "allowlist", + guilds: { + "345678901234567890": { + channels: { + "234567890123456789": { + allow: true, + requireMention: false, + }, + }, + }, + }, + }, + }); + + const dispatchCall = vi.mocked(dispatcherModule.dispatchReplyWithDispatcher).mock + .calls[0]?.[0] as + | Parameters[0] + | undefined; + await dispatchCall?.dispatcherOptions.deliver({ text: longReply }, { kind: "final" }); + + expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ content: longReply })); + expect(interaction.followUp).not.toHaveBeenCalled(); + }); }); diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 23b5bcd4c9d..4af7d5ef6d3 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -56,6 +56,7 @@ import type { ResolvedAgentRoute } from "../../routing/resolve-route.js"; import { chunkItems } from "../../utils/chunk-items.js"; import { withTimeout } from "../../utils/with-timeout.js"; import { loadWebMedia } from "../../web/media.js"; +import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { isDiscordGroupAllowedByPolicy, @@ -1571,7 +1572,7 @@ async function dispatchDiscordCommandInteraction(params: { textLimit: resolveTextChunkLimit(cfg, "discord", accountId, { fallbackLimit: 2000, }), - maxLinesPerMessage: discordConfig?.maxLinesPerMessage, + maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ cfg, discordConfig, accountId }), preferFollowUp, chunkMode: resolveChunkMode(cfg, "discord", accountId), }); @@ -1706,7 +1707,7 @@ async function dispatchDiscordCommandInteraction(params: { textLimit: resolveTextChunkLimit(cfg, "discord", accountId, { fallbackLimit: 2000, }), - maxLinesPerMessage: discordConfig?.maxLinesPerMessage, + maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ cfg, discordConfig, accountId }), preferFollowUp: preferFollowUp || didReply, chunkMode: resolveChunkMode(cfg, "discord", accountId), }); diff --git a/src/discord/monitor/reply-delivery.test.ts b/src/discord/monitor/reply-delivery.test.ts index 3274a669cf2..3d0357ef43a 100644 --- a/src/discord/monitor/reply-delivery.test.ts +++ b/src/discord/monitor/reply-delivery.test.ts @@ -256,6 +256,29 @@ describe("deliverDiscordReply", () => { expect(sendDiscordTextMock.mock.calls[1]?.[1]).toBe("789"); }); + it("passes maxLinesPerMessage and chunkMode through the fast path", async () => { + const fakeRest = {} as import("@buape/carbon").RequestClient; + + await deliverDiscordReply({ + replies: [{ text: Array.from({ length: 18 }, (_, index) => `line ${index + 1}`).join("\n") }], + target: "channel:789", + token: "token", + rest: fakeRest, + runtime, + textLimit: 2000, + maxLinesPerMessage: 120, + chunkMode: "newline", + }); + + expect(sendMessageDiscordMock).not.toHaveBeenCalled(); + expect(sendDiscordTextMock).toHaveBeenCalledTimes(1); + const firstSendDiscordTextCall = sendDiscordTextMock.mock.calls[0]; + const [, , , , , maxLinesPerMessageArg, , , chunkModeArg] = firstSendDiscordTextCall ?? []; + + expect(maxLinesPerMessageArg).toBe(120); + expect(chunkModeArg).toBe("newline"); + }); + it("falls back to sendMessageDiscord when rest is not provided", async () => { await deliverDiscordReply({ replies: [{ text: "single chunk" }], diff --git a/src/discord/monitor/reply-delivery.ts b/src/discord/monitor/reply-delivery.ts index 11fc1733ef1..d3e7ef9bf61 100644 --- a/src/discord/monitor/reply-delivery.ts +++ b/src/discord/monitor/reply-delivery.ts @@ -130,9 +130,11 @@ async function sendDiscordChunkWithFallback(params: { text: string; token: string; accountId?: string; + maxLinesPerMessage?: number; rest?: RequestClient; replyTo?: string; binding?: DiscordThreadBindingLookupRecord; + chunkMode?: ChunkMode; username?: string; avatarUrl?: string; /** Pre-resolved channel ID to bypass redundant resolution per chunk. */ @@ -169,7 +171,18 @@ async function sendDiscordChunkWithFallback(params: { if (params.channelId && params.request && params.rest) { const { channelId, request, rest } = params; await sendWithRetry( - () => sendDiscordText(rest, channelId, text, params.replyTo, request), + () => + sendDiscordText( + rest, + channelId, + text, + params.replyTo, + request, + params.maxLinesPerMessage, + undefined, + undefined, + params.chunkMode, + ), params.retryConfig, ); return; @@ -294,8 +307,10 @@ export async function deliverDiscordReply(params: { token: params.token, rest: params.rest, accountId: params.accountId, + maxLinesPerMessage: params.maxLinesPerMessage, replyTo, binding, + chunkMode: params.chunkMode, username: persona.username, avatarUrl: persona.avatarUrl, channelId, @@ -329,8 +344,10 @@ export async function deliverDiscordReply(params: { token: params.token, rest: params.rest, accountId: params.accountId, + maxLinesPerMessage: params.maxLinesPerMessage, replyTo: resolveReplyTo(), binding, + chunkMode: params.chunkMode, username: persona.username, avatarUrl: persona.avatarUrl, channelId, From de4c3db3e38a14d90d8ce3730e6ef83a1b79881e Mon Sep 17 00:00:00 2001 From: Altay Date: Tue, 10 Mar 2026 01:40:15 +0300 Subject: [PATCH 0022/1173] Logging: harden probe suppression for observations (#41338) Merged via squash. Prepared head SHA: d18356cb8062935090466d4e142ce202381d4ef2 Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/logging/subsystem.test.ts | 118 +++++++++++++++++++++++++++++++++- src/logging/subsystem.ts | 47 +++++++++++--- 3 files changed, 157 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 534922abe57..40fafa21920 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf. - Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky. - Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera. +- Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf. ## 2026.3.8 diff --git a/src/logging/subsystem.test.ts b/src/logging/subsystem.test.ts index e389d78ba8a..06f504f47de 100644 --- a/src/logging/subsystem.test.ts +++ b/src/logging/subsystem.test.ts @@ -1,11 +1,13 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { setConsoleSubsystemFilter } from "./console.js"; import { resetLogger, setLoggerOverride } from "./logger.js"; +import { loggingState } from "./state.js"; import { createSubsystemLogger } from "./subsystem.js"; afterEach(() => { setConsoleSubsystemFilter(null); setLoggerOverride(null); + loggingState.rawConsole = null; resetLogger(); }); @@ -53,4 +55,118 @@ describe("createSubsystemLogger().isEnabled", () => { expect(log.isEnabled("info", "file")).toBe(true); expect(log.isEnabled("info")).toBe(true); }); + + it("suppresses probe warnings for embedded subsystems based on structured run metadata", () => { + setLoggerOverride({ level: "silent", consoleLevel: "warn" }); + const warn = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn, + error: vi.fn(), + }; + const log = createSubsystemLogger("agent/embedded").child("failover"); + + log.warn("embedded run failover decision", { + runId: "probe-test-run", + consoleMessage: "embedded run failover decision", + }); + + expect(warn).not.toHaveBeenCalled(); + }); + + it("does not suppress probe errors for embedded subsystems", () => { + setLoggerOverride({ level: "silent", consoleLevel: "error" }); + const error = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error, + }; + const log = createSubsystemLogger("agent/embedded").child("failover"); + + log.error("embedded run failover decision", { + runId: "probe-test-run", + consoleMessage: "embedded run failover decision", + }); + + expect(error).toHaveBeenCalledTimes(1); + }); + + it("suppresses probe warnings for model-fallback child subsystems based on structured run metadata", () => { + setLoggerOverride({ level: "silent", consoleLevel: "warn" }); + const warn = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn, + error: vi.fn(), + }; + const log = createSubsystemLogger("model-fallback").child("decision"); + + log.warn("model fallback decision", { + runId: "probe-test-run", + consoleMessage: "model fallback decision", + }); + + expect(warn).not.toHaveBeenCalled(); + }); + + it("does not suppress probe errors for model-fallback child subsystems", () => { + setLoggerOverride({ level: "silent", consoleLevel: "error" }); + const error = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error, + }; + const log = createSubsystemLogger("model-fallback").child("decision"); + + log.error("model fallback decision", { + runId: "probe-test-run", + consoleMessage: "model fallback decision", + }); + + expect(error).toHaveBeenCalledTimes(1); + }); + + it("still emits non-probe warnings for embedded subsystems", () => { + setLoggerOverride({ level: "silent", consoleLevel: "warn" }); + const warn = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn, + error: vi.fn(), + }; + const log = createSubsystemLogger("agent/embedded").child("auth-profiles"); + + log.warn("auth profile failure state updated", { + runId: "run-123", + consoleMessage: "auth profile failure state updated", + }); + + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("still emits non-probe model-fallback child warnings", () => { + setLoggerOverride({ level: "silent", consoleLevel: "warn" }); + const warn = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn, + error: vi.fn(), + }; + const log = createSubsystemLogger("model-fallback").child("decision"); + + log.warn("model fallback decision", { + runId: "run-123", + consoleMessage: "model fallback decision", + }); + + expect(warn).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/logging/subsystem.ts b/src/logging/subsystem.ts index 18be000e9ba..5c6ce58a43d 100644 --- a/src/logging/subsystem.ts +++ b/src/logging/subsystem.ts @@ -250,6 +250,38 @@ function writeConsoleLine(level: LogLevel, line: string) { } } +function shouldSuppressProbeConsoleLine(params: { + level: LogLevel; + subsystem: string; + message: string; + meta?: Record; +}): boolean { + if (isVerbose()) { + return false; + } + if (params.level === "error" || params.level === "fatal") { + return false; + } + const isProbeSuppressedSubsystem = + params.subsystem === "agent/embedded" || + params.subsystem.startsWith("agent/embedded/") || + params.subsystem === "model-fallback" || + params.subsystem.startsWith("model-fallback/"); + if (!isProbeSuppressedSubsystem) { + return false; + } + const runLikeId = + typeof params.meta?.runId === "string" + ? params.meta.runId + : typeof params.meta?.sessionId === "string" + ? params.meta.sessionId + : undefined; + if (runLikeId?.startsWith("probe-")) { + return true; + } + return /(sessionId|runId)=probe-/.test(params.message); +} + function logToFile( fileLogger: TsLogger, level: LogLevel, @@ -309,9 +341,12 @@ export function createSubsystemLogger(subsystem: string): SubsystemLogger { } const consoleMessage = consoleMessageOverride ?? message; if ( - !isVerbose() && - subsystem === "agent/embedded" && - /(sessionId|runId)=probe-/.test(consoleMessage) + shouldSuppressProbeConsoleLine({ + level, + subsystem, + message: consoleMessage, + meta: fileMeta, + }) ) { return; } @@ -355,11 +390,7 @@ export function createSubsystemLogger(subsystem: string): SubsystemLogger { logToFile(getFileLogger(), "info", message, { raw: true }); } if (isConsoleEnabled("info")) { - if ( - !isVerbose() && - subsystem === "agent/embedded" && - /(sessionId|runId)=probe-/.test(message) - ) { + if (shouldSuppressProbeConsoleLine({ level: "info", subsystem, message })) { return; } writeConsoleLine("info", message); From c9a6c542ef7ae9350fd79e20a7e6642b5ce4d604 Mon Sep 17 00:00:00 2001 From: alan blount Date: Mon, 9 Mar 2026 18:55:10 -0400 Subject: [PATCH 0023/1173] Add HTTP 499 to transient error codes for model fallback (#41468) Merged via squash. Prepared head SHA: 0053bae14038e6df9264df364d1c9aa83d5b698e Co-authored-by: zeroasterisk <23422+zeroasterisk@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/agents/failover-error.test.ts | 7 +++++++ ...-embedded-helpers.isbillingerrormessage.test.ts | 14 ++++++++++++++ src/agents/pi-embedded-helpers/errors.ts | 8 +++++++- 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40fafa21920..48ec8f44552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky. - Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera. - Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf. +- Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk. ## 2026.3.8 diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index a99cfb5c4b2..db01c03d8c4 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -67,6 +67,7 @@ describe("failover-error", () => { expect(resolveFailoverReasonFromError({ statusCode: "429" })).toBe("rate_limit"); expect(resolveFailoverReasonFromError({ status: 403 })).toBe("auth"); expect(resolveFailoverReasonFromError({ status: 408 })).toBe("timeout"); + expect(resolveFailoverReasonFromError({ status: 499 })).toBe("timeout"); expect(resolveFailoverReasonFromError({ status: 400 })).toBe("format"); // Keep the status-only path behavior-preserving and conservative. expect(resolveFailoverReasonFromError({ status: 500 })).toBeNull(); @@ -93,6 +94,12 @@ describe("failover-error", () => { message: ANTHROPIC_OVERLOADED_PAYLOAD, }), ).toBe("overloaded"); + expect( + resolveFailoverReasonFromError({ + status: 499, + message: ANTHROPIC_OVERLOADED_PAYLOAD, + }), + ).toBe("overloaded"); expect( resolveFailoverReasonFromError({ status: 429, diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 86fd90e7161..f60a127a0ab 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -443,6 +443,7 @@ describe("isLikelyContextOverflowError", () => { describe("isTransientHttpError", () => { it("returns true for retryable 5xx status codes", () => { + expect(isTransientHttpError("499 Client Closed Request")).toBe(true); expect(isTransientHttpError("500 Internal Server Error")).toBe(true); expect(isTransientHttpError("502 Bad Gateway")).toBe(true); expect(isTransientHttpError("503 Service Unavailable")).toBe(true); @@ -457,6 +458,19 @@ describe("isTransientHttpError", () => { }); }); +describe("classifyFailoverReasonFromHttpStatus", () => { + it("treats HTTP 499 as transient for structured errors", () => { + expect(classifyFailoverReasonFromHttpStatus(499)).toBe("timeout"); + expect(classifyFailoverReasonFromHttpStatus(499, "499 Client Closed Request")).toBe("timeout"); + expect( + classifyFailoverReasonFromHttpStatus( + 499, + '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', + ), + ).toBe("overloaded"); + }); +}); + describe("isFailoverErrorMessage", () => { it("matches auth/rate/billing/timeout", () => { const samples = [ diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 4cf347150bf..9ab52c04355 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -189,7 +189,7 @@ const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i; const HTTP_STATUS_CODE_PREFIX_RE = /^(?:http\s*)?(\d{3})(?:\s+([\s\S]+))?$/i; const HTML_ERROR_PREFIX_RE = /^\s*(?: Date: Tue, 10 Mar 2026 00:07:26 +0100 Subject: [PATCH 0024/1173] fix(plugins): expose model auth API to context-engine plugins (#41090) Merged via squash. Prepared head SHA: ee96e96bb984cc3e1e152d17199357a8f6db312d Co-authored-by: xinhuagu <562450+xinhuagu@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + extensions/test-utils/plugin-runtime-mock.ts | 5 +++++ src/plugin-sdk/index.ts | 6 ++++++ src/plugins/runtime/index.test.ts | 17 +++++++++++++++ src/plugins/runtime/index.ts | 22 ++++++++++++++++++++ src/plugins/runtime/types-core.ts | 12 +++++++++++ 6 files changed, 63 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48ec8f44552..9f705ed77a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai - Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera. - Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf. - Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk. +- Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu. ## 2026.3.8 diff --git a/extensions/test-utils/plugin-runtime-mock.ts b/extensions/test-utils/plugin-runtime-mock.ts index 8c599599a31..81e3fdedeec 100644 --- a/extensions/test-utils/plugin-runtime-mock.ts +++ b/extensions/test-utils/plugin-runtime-mock.ts @@ -253,6 +253,11 @@ export function createPluginRuntimeMock(overrides: DeepPartial = state: { resolveStateDir: vi.fn(() => "/tmp/openclaw"), }, + modelAuth: { + getApiKeyForModel: vi.fn() as unknown as PluginRuntime["modelAuth"]["getApiKeyForModel"], + resolveApiKeyForProvider: + vi.fn() as unknown as PluginRuntime["modelAuth"]["resolveApiKeyForProvider"], + }, subagent: { run: vi.fn(), waitForRun: vi.fn(), diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 3e1ba0f03ab..35709dc4fec 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -801,5 +801,11 @@ export type { export { registerContextEngine } from "../context-engine/registry.js"; export type { ContextEngineFactory } from "../context-engine/registry.js"; +// Model authentication types for plugins. +// Plugins should use runtime.modelAuth (which strips unsafe overrides like +// agentDir/store) rather than importing raw helpers directly. +export { requireApiKey } from "../agents/model-auth.js"; +export type { ResolvedProviderAuth } from "../agents/model-auth.js"; + // Security utilities export { redactSensitiveText } from "../logging/redact.js"; diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index 77b3de66062..5ec2df28199 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -53,4 +53,21 @@ describe("plugin runtime command execution", () => { const runtime = createPluginRuntime(); expect(runtime.system.requestHeartbeatNow).toBe(requestHeartbeatNow); }); + + it("exposes runtime.modelAuth with getApiKeyForModel and resolveApiKeyForProvider", () => { + const runtime = createPluginRuntime(); + expect(runtime.modelAuth).toBeDefined(); + expect(typeof runtime.modelAuth.getApiKeyForModel).toBe("function"); + expect(typeof runtime.modelAuth.resolveApiKeyForProvider).toBe("function"); + }); + + it("modelAuth wrappers strip agentDir and store to prevent credential steering", async () => { + // The wrappers should not forward agentDir or store from plugin callers. + // We verify this by checking the wrapper functions exist and are not the + // raw implementations (they are wrapped, not direct references). + const { getApiKeyForModel: rawGetApiKey } = await import("../../agents/model-auth.js"); + const runtime = createPluginRuntime(); + // Wrappers should NOT be the same reference as the raw functions + expect(runtime.modelAuth.getApiKeyForModel).not.toBe(rawGetApiKey); + }); }); diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 68b672db1b4..12d33168cd3 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -1,4 +1,8 @@ import { createRequire } from "node:module"; +import { + getApiKeyForModel as getApiKeyForModelRaw, + resolveApiKeyForProvider as resolveApiKeyForProviderRaw, +} from "../../agents/model-auth.js"; import { resolveStateDir } from "../../config/paths.js"; import { transcribeAudioFile } from "../../media-understanding/transcribe-audio.js"; import { textToSpeechTelephony } from "../../tts/tts.js"; @@ -59,6 +63,24 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}): events: createRuntimeEvents(), logging: createRuntimeLogging(), state: { resolveStateDir }, + modelAuth: { + // Wrap model-auth helpers so plugins cannot steer credential lookups: + // - agentDir / store: stripped (prevents reading other agents' stores) + // - profileId / preferredProfile: stripped (prevents cross-provider + // credential access via profile steering) + // Plugins only specify provider/model; the core auth pipeline picks + // the appropriate credential automatically. + getApiKeyForModel: (params) => + getApiKeyForModelRaw({ + model: params.model, + cfg: params.cfg, + }), + resolveApiKeyForProvider: (params) => + resolveApiKeyForProviderRaw({ + provider: params.provider, + cfg: params.cfg, + }), + }, } satisfies PluginRuntime; return runtime; diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index 524b3a5f6a2..bfbb747c9c4 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -52,4 +52,16 @@ export type PluginRuntimeCore = { state: { resolveStateDir: typeof import("../../config/paths.js").resolveStateDir; }; + modelAuth: { + /** Resolve auth for a model. Only provider/model and optional cfg are used. */ + getApiKeyForModel: (params: { + model: import("@mariozechner/pi-ai").Model; + cfg?: import("../../config/config.js").OpenClawConfig; + }) => Promise; + /** Resolve auth for a provider by name. Only provider and optional cfg are used. */ + resolveApiKeyForProvider: (params: { + provider: string; + cfg?: import("../../config/config.js").OpenClawConfig; + }) => Promise; + }; }; From b48291e01eca26a5b04ea1d6219c13b4437c3ead Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 9 Mar 2026 16:14:08 -0700 Subject: [PATCH 0025/1173] Exec: mark child command env with OPENCLAW_CLI (#41411) --- src/agents/sandbox-create-args.test.ts | 10 +++++++++- src/agents/sandbox/docker.ts | 3 ++- src/entry.ts | 2 ++ src/infra/host-env-security.test.ts | 5 +++++ src/infra/host-env-security.ts | 5 +++-- src/infra/openclaw-exec-env.ts | 16 ++++++++++++++++ src/process/exec.test.ts | 2 ++ src/process/exec.ts | 3 ++- 8 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 src/infra/openclaw-exec-env.ts diff --git a/src/agents/sandbox-create-args.test.ts b/src/agents/sandbox-create-args.test.ts index 9bc00547143..0d9621ad9e1 100644 --- a/src/agents/sandbox-create-args.test.ts +++ b/src/agents/sandbox-create-args.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js"; import { buildSandboxCreateArgs } from "./sandbox/docker.js"; import type { SandboxDockerConfig } from "./sandbox/types.js"; @@ -113,7 +114,14 @@ describe("buildSandboxCreateArgs", () => { "1.5", ]), ); - expect(args).toEqual(expect.arrayContaining(["--env", "LANG=C.UTF-8"])); + expect(args).toEqual( + expect.arrayContaining([ + "--env", + "LANG=C.UTF-8", + "--env", + `OPENCLAW_CLI=${OPENCLAW_CLI_ENV_VALUE}`, + ]), + ); const ulimitValues: string[] = []; for (let i = 0; i < args.length; i += 1) { diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 2bd9dad12b5..68c95e343ea 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -162,6 +162,7 @@ export function execDockerRaw( } import { formatCliCommand } from "../../cli/command-format.js"; +import { markOpenClawExecEnv } from "../../infra/openclaw-exec-env.js"; import { defaultRuntime } from "../../runtime.js"; import { computeSandboxConfigHash } from "./config-hash.js"; import { DEFAULT_SANDBOX_IMAGE } from "./constants.js"; @@ -365,7 +366,7 @@ export function buildSandboxCreateArgs(params: { if (params.cfg.user) { args.push("--user", params.cfg.user); } - const envSanitization = sanitizeEnvVars(params.cfg.env ?? {}); + const envSanitization = sanitizeEnvVars(markOpenClawExecEnv(params.cfg.env ?? {})); if (envSanitization.blocked.length > 0) { log.warn(`Blocked sensitive environment variables: ${envSanitization.blocked.join(", ")}`); } diff --git a/src/entry.ts b/src/entry.ts index 50b08029d05..14a839f38b9 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -9,6 +9,7 @@ import { shouldSkipRespawnForArgv } from "./cli/respawn-policy.js"; import { normalizeWindowsArgv } from "./cli/windows-argv.js"; import { isTruthyEnvValue, normalizeEnv } from "./infra/env.js"; import { isMainModule } from "./infra/is-main.js"; +import { ensureOpenClawExecMarkerOnProcess } from "./infra/openclaw-exec-env.js"; import { installProcessWarningFilter } from "./infra/warning-filter.js"; import { attachChildProcessBridge } from "./process/child-process-bridge.js"; @@ -41,6 +42,7 @@ if ( // Imported as a dependency — skip all entry-point side effects. } else { process.title = "openclaw"; + ensureOpenClawExecMarkerOnProcess(); installProcessWarningFilter(); normalizeEnv(); if (!isTruthyEnvValue(process.env.NODE_DISABLE_COMPILE_CACHE)) { diff --git a/src/infra/host-env-security.test.ts b/src/infra/host-env-security.test.ts index 116006dbbcf..4e7bcdb9ed9 100644 --- a/src/infra/host-env-security.test.ts +++ b/src/infra/host-env-security.test.ts @@ -10,6 +10,7 @@ import { sanitizeHostExecEnv, sanitizeSystemRunEnvOverrides, } from "./host-env-security.js"; +import { OPENCLAW_CLI_ENV_VALUE } from "./openclaw-exec-env.js"; describe("isDangerousHostEnvVarName", () => { it("matches dangerous keys and prefixes case-insensitively", () => { @@ -40,6 +41,7 @@ describe("sanitizeHostExecEnv", () => { }); expect(env).toEqual({ + OPENCLAW_CLI: OPENCLAW_CLI_ENV_VALUE, PATH: "/usr/bin:/bin", OK: "1", }); @@ -68,6 +70,7 @@ describe("sanitizeHostExecEnv", () => { }); expect(env.PATH).toBe("/usr/bin:/bin"); + expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); expect(env.BASH_ENV).toBeUndefined(); expect(env.GIT_SSH_COMMAND).toBeUndefined(); expect(env.EDITOR).toBeUndefined(); @@ -91,6 +94,7 @@ describe("sanitizeHostExecEnv", () => { }); expect(env.PATH).toBe("/usr/bin:/bin"); + expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); expect(env.OK).toBe("1"); expect(env.SHELLOPTS).toBeUndefined(); expect(env.PS4).toBeUndefined(); @@ -109,6 +113,7 @@ describe("sanitizeHostExecEnv", () => { }); expect(env.GOOD_KEY).toBe("ok"); + expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); expect(env[" BAD KEY"]).toBeUndefined(); expect(env["NOT-PORTABLE"]).toBeUndefined(); }); diff --git a/src/infra/host-env-security.ts b/src/infra/host-env-security.ts index 56b30bd0818..8c5d0989fdd 100644 --- a/src/infra/host-env-security.ts +++ b/src/infra/host-env-security.ts @@ -1,4 +1,5 @@ import HOST_ENV_SECURITY_POLICY_JSON from "./host-env-security-policy.json" with { type: "json" }; +import { markOpenClawExecEnv } from "./openclaw-exec-env.js"; const PORTABLE_ENV_VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -101,7 +102,7 @@ export function sanitizeHostExecEnv(params?: { } if (!overrides) { - return merged; + return markOpenClawExecEnv(merged); } for (const [rawKey, value] of Object.entries(overrides)) { @@ -124,7 +125,7 @@ export function sanitizeHostExecEnv(params?: { merged[key] = value; } - return merged; + return markOpenClawExecEnv(merged); } export function sanitizeSystemRunEnvOverrides(params?: { diff --git a/src/infra/openclaw-exec-env.ts b/src/infra/openclaw-exec-env.ts new file mode 100644 index 00000000000..b4e8a876584 --- /dev/null +++ b/src/infra/openclaw-exec-env.ts @@ -0,0 +1,16 @@ +export const OPENCLAW_CLI_ENV_VAR = "OPENCLAW_CLI"; +export const OPENCLAW_CLI_ENV_VALUE = "1"; + +export function markOpenClawExecEnv>(env: T): T { + return { + ...env, + [OPENCLAW_CLI_ENV_VAR]: OPENCLAW_CLI_ENV_VALUE, + }; +} + +export function ensureOpenClawExecMarkerOnProcess( + env: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + env[OPENCLAW_CLI_ENV_VAR] = OPENCLAW_CLI_ENV_VALUE; + return env; +} diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index 19937d6cb32..88d9cfdd71e 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -3,6 +3,7 @@ import { EventEmitter } from "node:events"; import fs from "node:fs"; import process from "node:process"; import { describe, expect, it, vi } from "vitest"; +import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js"; import { attachChildProcessBridge } from "./child-process-bridge.js"; import { resolveCommandEnv, runCommandWithTimeout, shouldSpawnWithShell } from "./exec.js"; @@ -31,6 +32,7 @@ describe("runCommandWithTimeout", () => { expect(resolved.OPENCLAW_BASE_ENV).toBe("base"); expect(resolved.OPENCLAW_TEST_ENV).toBe("ok"); expect(resolved.OPENCLAW_TO_REMOVE).toBeUndefined(); + expect(resolved.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); }); it("suppresses npm fund prompts for npm argv", async () => { diff --git a/src/process/exec.ts b/src/process/exec.ts index ddc572092d8..3464a083894 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -4,6 +4,7 @@ import path from "node:path"; import process from "node:process"; import { promisify } from "node:util"; import { danger, shouldLogVerbose } from "../globals.js"; +import { markOpenClawExecEnv } from "../infra/openclaw-exec-env.js"; import { logDebug, logError } from "../logger.js"; import { resolveCommandStdio } from "./spawn-utils.js"; @@ -213,7 +214,7 @@ export function resolveCommandEnv(params: { resolvedEnv.npm_config_fund = "false"; } } - return resolvedEnv; + return markOpenClawExecEnv(resolvedEnv); } export async function runCommandWithTimeout( From c0cba7fb72ea7490b89ab194041287bea4017f3e Mon Sep 17 00:00:00 2001 From: Julia Barth <72460857+Julbarth@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:34:46 -0700 Subject: [PATCH 0026/1173] Fix one-shot exit hangs by tearing down cached memory managers (#40389) Merged via squash. Prepared head SHA: 0e600e89cf10f5086ab9d93f445587373a54dcec Co-authored-by: Julbarth <72460857+Julbarth@users.noreply.github.com> Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Reviewed-by: @frankekn --- CHANGELOG.md | 1 + src/cli/run-main.exit.test.ts | 6 ++ src/cli/run-main.ts | 111 ++++++++++++--------- src/memory/index.ts | 6 +- src/memory/manager-runtime.ts | 2 +- src/memory/manager.get-concurrency.test.ts | 37 +++++++ src/memory/manager.ts | 16 +++ src/memory/search-manager.test.ts | 107 +++++++++++++------- src/memory/search-manager.ts | 16 +++ 9 files changed, 214 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f705ed77a3..ce8a07061ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai - Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf. - Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk. - Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu. +- CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth. ## 2026.3.8 diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 86d74f09640..3e56c1ce794 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -6,6 +6,7 @@ const loadDotEnvMock = vi.hoisted(() => vi.fn()); const normalizeEnvMock = vi.hoisted(() => vi.fn()); const ensurePathMock = vi.hoisted(() => vi.fn()); const assertRuntimeMock = vi.hoisted(() => vi.fn()); +const closeAllMemorySearchManagersMock = vi.hoisted(() => vi.fn(async () => {})); vi.mock("./route.js", () => ({ tryRouteCli: tryRouteCliMock, @@ -27,6 +28,10 @@ vi.mock("../infra/runtime-guard.js", () => ({ assertSupportedRuntime: assertRuntimeMock, })); +vi.mock("../memory/search-manager.js", () => ({ + closeAllMemorySearchManagers: closeAllMemorySearchManagersMock, +})); + const { runCli } = await import("./run-main.js"); describe("runCli exit behavior", () => { @@ -43,6 +48,7 @@ describe("runCli exit behavior", () => { await runCli(["node", "openclaw", "status"]); expect(tryRouteCliMock).toHaveBeenCalledWith(["node", "openclaw", "status"]); + expect(closeAllMemorySearchManagersMock).toHaveBeenCalledTimes(1); expect(exitSpy).not.toHaveBeenCalled(); exitSpy.mockRestore(); }); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index e80ce97b845..c0673ddf2af 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -13,6 +13,15 @@ import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js"; import { tryRouteCli } from "./route.js"; import { normalizeWindowsArgv } from "./windows-argv.js"; +async function closeCliMemoryManagers(): Promise { + try { + const { closeAllMemorySearchManagers } = await import("../memory/search-manager.js"); + await closeAllMemorySearchManagers(); + } catch { + // Best-effort teardown for short-lived CLI processes. + } +} + export function rewriteUpdateFlagArgv(argv: string[]): string[] { const index = argv.indexOf("--update"); if (index === -1) { @@ -82,59 +91,63 @@ export async function runCli(argv: string[] = process.argv) { // Enforce the minimum supported runtime before doing any work. assertSupportedRuntime(); - if (await tryRouteCli(normalizedArgv)) { - return; - } - - // Capture all console output into structured logs while keeping stdout/stderr behavior. - enableConsoleCapture(); - - const { buildProgram } = await import("./program.js"); - const program = buildProgram(); - - // Global error handlers to prevent silent crashes from unhandled rejections/exceptions. - // These log the error and exit gracefully instead of crashing without trace. - installUnhandledRejectionHandler(); - - process.on("uncaughtException", (error) => { - console.error("[openclaw] Uncaught exception:", formatUncaughtError(error)); - process.exit(1); - }); - - const parseArgv = rewriteUpdateFlagArgv(normalizedArgv); - // Register the primary command (builtin or subcli) so help and command parsing - // are correct even with lazy command registration. - const primary = getPrimaryCommand(parseArgv); - if (primary) { - const { getProgramContext } = await import("./program/program-context.js"); - const ctx = getProgramContext(program); - if (ctx) { - const { registerCoreCliByName } = await import("./program/command-registry.js"); - await registerCoreCliByName(program, ctx, primary, parseArgv); + try { + if (await tryRouteCli(normalizedArgv)) { + return; } - const { registerSubCliByName } = await import("./program/register.subclis.js"); - await registerSubCliByName(program, primary); - } - const hasBuiltinPrimary = - primary !== null && program.commands.some((command) => command.name() === primary); - const shouldSkipPluginRegistration = shouldSkipPluginCommandRegistration({ - argv: parseArgv, - primary, - hasBuiltinPrimary, - }); - if (!shouldSkipPluginRegistration) { - // Register plugin CLI commands before parsing - const { registerPluginCliCommands } = await import("../plugins/cli.js"); - const { loadValidatedConfigForPluginRegistration } = - await import("./program/register.subclis.js"); - const config = await loadValidatedConfigForPluginRegistration(); - if (config) { - registerPluginCliCommands(program, config); + // Capture all console output into structured logs while keeping stdout/stderr behavior. + enableConsoleCapture(); + + const { buildProgram } = await import("./program.js"); + const program = buildProgram(); + + // Global error handlers to prevent silent crashes from unhandled rejections/exceptions. + // These log the error and exit gracefully instead of crashing without trace. + installUnhandledRejectionHandler(); + + process.on("uncaughtException", (error) => { + console.error("[openclaw] Uncaught exception:", formatUncaughtError(error)); + process.exit(1); + }); + + const parseArgv = rewriteUpdateFlagArgv(normalizedArgv); + // Register the primary command (builtin or subcli) so help and command parsing + // are correct even with lazy command registration. + const primary = getPrimaryCommand(parseArgv); + if (primary) { + const { getProgramContext } = await import("./program/program-context.js"); + const ctx = getProgramContext(program); + if (ctx) { + const { registerCoreCliByName } = await import("./program/command-registry.js"); + await registerCoreCliByName(program, ctx, primary, parseArgv); + } + const { registerSubCliByName } = await import("./program/register.subclis.js"); + await registerSubCliByName(program, primary); } - } - await program.parseAsync(parseArgv); + const hasBuiltinPrimary = + primary !== null && program.commands.some((command) => command.name() === primary); + const shouldSkipPluginRegistration = shouldSkipPluginCommandRegistration({ + argv: parseArgv, + primary, + hasBuiltinPrimary, + }); + if (!shouldSkipPluginRegistration) { + // Register plugin CLI commands before parsing + const { registerPluginCliCommands } = await import("../plugins/cli.js"); + const { loadValidatedConfigForPluginRegistration } = + await import("./program/register.subclis.js"); + const config = await loadValidatedConfigForPluginRegistration(); + if (config) { + registerPluginCliCommands(program, config); + } + } + + await program.parseAsync(parseArgv); + } finally { + await closeCliMemoryManagers(); + } } export function isCliMainModule(): boolean { diff --git a/src/memory/index.ts b/src/memory/index.ts index 4d2df05a399..86ca52e1d27 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -4,4 +4,8 @@ export type { MemorySearchManager, MemorySearchResult, } from "./types.js"; -export { getMemorySearchManager, type MemorySearchManagerResult } from "./search-manager.js"; +export { + closeAllMemorySearchManagers, + getMemorySearchManager, + type MemorySearchManagerResult, +} from "./search-manager.js"; diff --git a/src/memory/manager-runtime.ts b/src/memory/manager-runtime.ts index b46b3708a6e..3e910b5676a 100644 --- a/src/memory/manager-runtime.ts +++ b/src/memory/manager-runtime.ts @@ -1 +1 @@ -export { MemoryIndexManager } from "./manager.js"; +export { closeAllMemoryIndexManagers, MemoryIndexManager } from "./manager.js"; diff --git a/src/memory/manager.get-concurrency.test.ts b/src/memory/manager.get-concurrency.test.ts index e7d040217a8..67b10768fc3 100644 --- a/src/memory/manager.get-concurrency.test.ts +++ b/src/memory/manager.get-concurrency.test.ts @@ -4,6 +4,10 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; +import { + closeAllMemoryIndexManagers, + MemoryIndexManager as RawMemoryIndexManager, +} from "./manager.js"; import "./test-runtime-mocks.js"; const hoisted = vi.hoisted(() => ({ @@ -78,4 +82,37 @@ describe("memory manager cache hydration", () => { await managers[0].close(); }); + + it("drains in-flight manager creation during global teardown", async () => { + const indexPath = path.join(workspaceDir, "index.sqlite"); + const cfg = { + agents: { + defaults: { + workspace: workspaceDir, + memorySearch: { + provider: "openai", + model: "mock-embed", + store: { path: indexPath, vector: { enabled: false } }, + sync: { watch: false, onSessionStart: false, onSearch: false }, + }, + }, + list: [{ id: "main", default: true }], + }, + } as OpenClawConfig; + + hoisted.providerDelayMs = 100; + + const pendingResult = RawMemoryIndexManager.get({ cfg, agentId: "main" }); + await closeAllMemoryIndexManagers(); + const firstManager = await pendingResult; + + const secondManager = await RawMemoryIndexManager.get({ cfg, agentId: "main" }); + + expect(firstManager).toBeTruthy(); + expect(secondManager).toBeTruthy(); + expect(Object.is(secondManager, firstManager)).toBe(false); + expect(hoisted.providerCreateCalls).toBe(2); + + await secondManager?.close?.(); + }); }); diff --git a/src/memory/manager.ts b/src/memory/manager.ts index 1d2fb49e88b..9b1ff74e54c 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -42,6 +42,22 @@ const log = createSubsystemLogger("memory"); const INDEX_CACHE = new Map(); const INDEX_CACHE_PENDING = new Map>(); +export async function closeAllMemoryIndexManagers(): Promise { + const pending = Array.from(INDEX_CACHE_PENDING.values()); + if (pending.length > 0) { + await Promise.allSettled(pending); + } + const managers = Array.from(INDEX_CACHE.values()); + INDEX_CACHE.clear(); + for (const manager of managers) { + try { + await manager.close(); + } catch (err) { + log.warn(`failed to close memory index manager: ${String(err)}`); + } + } +} + export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements MemorySearchManager { private readonly cacheKey: string; protected readonly cfg: OpenClawConfig; diff --git a/src/memory/search-manager.test.ts b/src/memory/search-manager.test.ts index d853f5af1fa..1f705aeddcf 100644 --- a/src/memory/search-manager.test.ts +++ b/src/memory/search-manager.test.ts @@ -29,53 +29,53 @@ function createManagerStatus(params: { }; } -const qmdManagerStatus = createManagerStatus({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - withMemorySourceCounts: true, -}); - -const fallbackManagerStatus = createManagerStatus({ - backend: "builtin", - provider: "openai", - model: "text-embedding-3-small", - requestedProvider: "openai", -}); - -const mockPrimary = { +const mockPrimary = vi.hoisted(() => ({ search: vi.fn(async () => []), readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })), - status: vi.fn(() => qmdManagerStatus), + status: vi.fn(() => + createManagerStatus({ + backend: "qmd", + provider: "qmd", + model: "qmd", + requestedProvider: "qmd", + withMemorySourceCounts: true, + }), + ), sync: vi.fn(async () => {}), probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })), probeVectorAvailability: vi.fn(async () => true), close: vi.fn(async () => {}), -}; +})); -const fallbackSearch = vi.fn(async () => [ - { - path: "MEMORY.md", - startLine: 1, - endLine: 1, - score: 1, - snippet: "fallback", - source: "memory" as const, - }, -]); - -const fallbackManager = { - search: fallbackSearch, +const fallbackManager = vi.hoisted(() => ({ + search: vi.fn(async () => [ + { + path: "MEMORY.md", + startLine: 1, + endLine: 1, + score: 1, + snippet: "fallback", + source: "memory" as const, + }, + ]), readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })), - status: vi.fn(() => fallbackManagerStatus), + status: vi.fn(() => + createManagerStatus({ + backend: "builtin", + provider: "openai", + model: "text-embedding-3-small", + requestedProvider: "openai", + }), + ), sync: vi.fn(async () => {}), probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })), probeVectorAvailability: vi.fn(async () => true), close: vi.fn(async () => {}), -}; +})); -const mockMemoryIndexGet = vi.fn(async () => fallbackManager); +const fallbackSearch = fallbackManager.search; +const mockMemoryIndexGet = vi.hoisted(() => vi.fn(async () => fallbackManager)); +const mockCloseAllMemoryIndexManagers = vi.hoisted(() => vi.fn(async () => {})); vi.mock("./qmd-manager.js", () => ({ QmdMemoryManager: { @@ -83,14 +83,15 @@ vi.mock("./qmd-manager.js", () => ({ }, })); -vi.mock("./manager.js", () => ({ +vi.mock("./manager-runtime.js", () => ({ MemoryIndexManager: { get: mockMemoryIndexGet, }, + closeAllMemoryIndexManagers: mockCloseAllMemoryIndexManagers, })); import { QmdMemoryManager } from "./qmd-manager.js"; -import { getMemorySearchManager } from "./search-manager.js"; +import { closeAllMemorySearchManagers, getMemorySearchManager } from "./search-manager.js"; // eslint-disable-next-line @typescript-eslint/unbound-method -- mocked static function const createQmdManagerMock = vi.mocked(QmdMemoryManager.create); @@ -119,7 +120,8 @@ async function createFailedQmdSearchHarness(params: { agentId: string; errorMess return { cfg, manager: requireManager(first), firstResult: first }; } -beforeEach(() => { +beforeEach(async () => { + await closeAllMemorySearchManagers(); mockPrimary.search.mockClear(); mockPrimary.readFile.mockClear(); mockPrimary.status.mockClear(); @@ -134,6 +136,7 @@ beforeEach(() => { fallbackManager.probeEmbeddingAvailability.mockClear(); fallbackManager.probeVectorAvailability.mockClear(); fallbackManager.close.mockClear(); + mockCloseAllMemoryIndexManagers.mockClear(); mockMemoryIndexGet.mockClear(); mockMemoryIndexGet.mockResolvedValue(fallbackManager); createQmdManagerMock.mockClear(); @@ -243,4 +246,34 @@ describe("getMemorySearchManager caching", () => { await expect(firstManager.search("hello")).rejects.toThrow("qmd query failed"); }); + + it("closes cached managers on global teardown", async () => { + const cfg = createQmdCfg("teardown-agent"); + const first = await getMemorySearchManager({ cfg, agentId: "teardown-agent" }); + const firstManager = requireManager(first); + + await closeAllMemorySearchManagers(); + + expect(mockPrimary.close).toHaveBeenCalledTimes(1); + expect(mockCloseAllMemoryIndexManagers).toHaveBeenCalledTimes(1); + + const second = await getMemorySearchManager({ cfg, agentId: "teardown-agent" }); + expect(second.manager).toBeTruthy(); + expect(second.manager).not.toBe(firstManager); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(createQmdManagerMock).toHaveBeenCalledTimes(2); + }); + + it("closes builtin index managers on teardown after runtime is loaded", async () => { + const retryAgentId = "teardown-with-fallback"; + const { manager } = await createFailedQmdSearchHarness({ + agentId: retryAgentId, + errorMessage: "qmd query failed", + }); + await manager.search("hello"); + + await closeAllMemorySearchManagers(); + + expect(mockCloseAllMemoryIndexManagers).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/memory/search-manager.ts b/src/memory/search-manager.ts index f4e351fdc1a..ea581b5d6da 100644 --- a/src/memory/search-manager.ts +++ b/src/memory/search-manager.ts @@ -85,6 +85,22 @@ export async function getMemorySearchManager(params: { } } +export async function closeAllMemorySearchManagers(): Promise { + const managers = Array.from(QMD_MANAGER_CACHE.values()); + QMD_MANAGER_CACHE.clear(); + for (const manager of managers) { + try { + await manager.close?.(); + } catch (err) { + log.warn(`failed to close qmd memory manager: ${String(err)}`); + } + } + if (managerRuntimePromise !== null) { + const { closeAllMemoryIndexManagers } = await loadManagerRuntime(); + await closeAllMemoryIndexManagers(); + } +} + class FallbackMemoryManager implements MemorySearchManager { private fallback: MemorySearchManager | null = null; private primaryFailed = false; From 5a659b0b61dbfa1645fdfa28bf9bffee03a8c9bc Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 9 Mar 2026 17:49:06 -0500 Subject: [PATCH 0027/1173] feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New self-contained chat modules extracted from dashboard-v2-structure: - chat/slash-commands.ts: slash command definitions and completions - chat/slash-command-executor.ts: execute slash commands via gateway RPC - chat/slash-command-executor.node.test.ts: test coverage - chat/speech.ts: speech-to-text (STT) support - chat/input-history.ts: per-session input history navigation - chat/pinned-messages.ts: pinned message management - chat/deleted-messages.ts: deleted message tracking - chat/export.ts: shared exportChatMarkdown helper - chat-export.ts: re-export shim for backwards compat Gateway fix: - Restore usage/cost stripping in chat.history sanitization - Add test coverage for sanitization behavior These modules are additive and tree-shaken — no existing code imports them yet. They will be wired in subsequent slices. --- src/gateway/server-methods/chat.ts | 93 ++++- .../server.chat.gateway-server-chat-b.test.ts | 31 ++ ui/src/ui/chat-export.ts | 1 + ui/src/ui/chat/deleted-messages.ts | 49 +++ ui/src/ui/chat/export.ts | 24 ++ ui/src/ui/chat/input-history.ts | 49 +++ ui/src/ui/chat/pinned-messages.ts | 61 +++ .../chat/slash-command-executor.node.test.ts | 83 ++++ ui/src/ui/chat/slash-command-executor.ts | 370 ++++++++++++++++++ ui/src/ui/chat/slash-commands.ts | 217 ++++++++++ ui/src/ui/chat/speech.ts | 225 +++++++++++ 11 files changed, 1196 insertions(+), 7 deletions(-) create mode 100644 ui/src/ui/chat-export.ts create mode 100644 ui/src/ui/chat/deleted-messages.ts create mode 100644 ui/src/ui/chat/export.ts create mode 100644 ui/src/ui/chat/input-history.ts create mode 100644 ui/src/ui/chat/pinned-messages.ts create mode 100644 ui/src/ui/chat/slash-command-executor.node.test.ts create mode 100644 ui/src/ui/chat/slash-command-executor.ts create mode 100644 ui/src/ui/chat/slash-commands.ts create mode 100644 ui/src/ui/chat/speech.ts diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 71669080382..291e323b671 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -314,6 +314,60 @@ function sanitizeChatHistoryContentBlock(block: unknown): { block: unknown; chan return { block: changed ? entry : block, changed }; } +/** + * Validate that a value is a finite number, returning undefined otherwise. + */ +function toFiniteNumber(x: unknown): number | undefined { + return typeof x === "number" && Number.isFinite(x) ? x : undefined; +} + +/** + * Sanitize usage metadata to ensure only finite numeric fields are included. + * Prevents UI crashes from malformed transcript JSON. + */ +function sanitizeUsage(raw: unknown): Record | undefined { + if (!raw || typeof raw !== "object") { + return undefined; + } + const u = raw as Record; + const out: Record = {}; + + // Whitelist known usage fields and validate they're finite numbers + const knownFields = [ + "input", + "output", + "totalTokens", + "inputTokens", + "outputTokens", + "cacheRead", + "cacheWrite", + "cache_read_input_tokens", + "cache_creation_input_tokens", + ]; + + for (const k of knownFields) { + const n = toFiniteNumber(u[k]); + if (n !== undefined) { + out[k] = n; + } + } + + return Object.keys(out).length > 0 ? out : undefined; +} + +/** + * Sanitize cost metadata to ensure only finite numeric fields are included. + * Prevents UI crashes from calling .toFixed() on non-numbers. + */ +function sanitizeCost(raw: unknown): { total?: number } | undefined { + if (!raw || typeof raw !== "object") { + return undefined; + } + const c = raw as Record; + const total = toFiniteNumber(c.total); + return total !== undefined ? { total } : undefined; +} + function sanitizeChatHistoryMessage(message: unknown): { message: unknown; changed: boolean } { if (!message || typeof message !== "object") { return { message, changed: false }; @@ -325,13 +379,38 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang delete entry.details; changed = true; } - if ("usage" in entry) { - delete entry.usage; - changed = true; - } - if ("cost" in entry) { - delete entry.cost; - changed = true; + + // Keep usage/cost so the chat UI can render per-message token and cost badges. + // Only retain usage/cost on assistant messages and validate numeric fields to prevent UI crashes. + if (entry.role !== "assistant") { + if ("usage" in entry) { + delete entry.usage; + changed = true; + } + if ("cost" in entry) { + delete entry.cost; + changed = true; + } + } else { + // Validate and sanitize usage/cost for assistant messages + if ("usage" in entry) { + const sanitized = sanitizeUsage(entry.usage); + if (sanitized) { + entry.usage = sanitized; + } else { + delete entry.usage; + } + changed = true; + } + if ("cost" in entry) { + const sanitized = sanitizeCost(entry.cost); + if (sanitized) { + entry.cost = sanitized; + } else { + delete entry.cost; + } + changed = true; + } } if (typeof entry.content === "string") { diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 2e76e1a5de1..ca1e2c09402 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -273,6 +273,37 @@ describe("gateway server chat", () => { }); }); + test("chat.history preserves usage and cost metadata for assistant messages", async () => { + await withGatewayChatHarness(async ({ ws, createSessionDir }) => { + await connectOk(ws); + + const sessionDir = await createSessionDir(); + await writeMainSessionStore(); + + await writeMainSessionTranscript(sessionDir, [ + JSON.stringify({ + message: { + role: "assistant", + timestamp: Date.now(), + content: [{ type: "text", text: "hello" }], + usage: { input: 12, output: 5, totalTokens: 17 }, + cost: { total: 0.0123 }, + details: { debug: true }, + }, + }), + ]); + + const messages = await fetchHistoryMessages(ws); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + role: "assistant", + usage: { input: 12, output: 5, totalTokens: 17 }, + cost: { total: 0.0123 }, + }); + expect(messages[0]).not.toHaveProperty("details"); + }); + }); + test("chat.history strips inline directives from displayed message text", async () => { await withGatewayChatHarness(async ({ ws, createSessionDir }) => { await connectOk(ws); diff --git a/ui/src/ui/chat-export.ts b/ui/src/ui/chat-export.ts new file mode 100644 index 00000000000..ed5bbf931f8 --- /dev/null +++ b/ui/src/ui/chat-export.ts @@ -0,0 +1 @@ +export { exportChatMarkdown } from "./chat/export.ts"; diff --git a/ui/src/ui/chat/deleted-messages.ts b/ui/src/ui/chat/deleted-messages.ts new file mode 100644 index 00000000000..fd3916d78c7 --- /dev/null +++ b/ui/src/ui/chat/deleted-messages.ts @@ -0,0 +1,49 @@ +const PREFIX = "openclaw:deleted:"; + +export class DeletedMessages { + private key: string; + private _keys = new Set(); + + constructor(sessionKey: string) { + this.key = PREFIX + sessionKey; + this.load(); + } + + has(key: string): boolean { + return this._keys.has(key); + } + + delete(key: string): void { + this._keys.add(key); + this.save(); + } + + restore(key: string): void { + this._keys.delete(key); + this.save(); + } + + clear(): void { + this._keys.clear(); + this.save(); + } + + private load(): void { + try { + const raw = localStorage.getItem(this.key); + if (!raw) { + return; + } + const arr = JSON.parse(raw); + if (Array.isArray(arr)) { + this._keys = new Set(arr.filter((s) => typeof s === "string")); + } + } catch { + // ignore + } + } + + private save(): void { + localStorage.setItem(this.key, JSON.stringify([...this._keys])); + } +} diff --git a/ui/src/ui/chat/export.ts b/ui/src/ui/chat/export.ts new file mode 100644 index 00000000000..31e15e592e2 --- /dev/null +++ b/ui/src/ui/chat/export.ts @@ -0,0 +1,24 @@ +/** + * Export chat history as markdown file. + */ +export function exportChatMarkdown(messages: unknown[], assistantName: string): void { + const history = Array.isArray(messages) ? messages : []; + if (history.length === 0) { + return; + } + const lines: string[] = [`# Chat with ${assistantName}`, ""]; + for (const msg of history) { + const m = msg as Record; + const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool"; + const content = typeof m.content === "string" ? m.content : ""; + const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : ""; + lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, ""); + } + const blob = new Blob([lines.join("\n")], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `chat-${assistantName}-${Date.now()}.md`; + link.click(); + URL.revokeObjectURL(url); +} diff --git a/ui/src/ui/chat/input-history.ts b/ui/src/ui/chat/input-history.ts new file mode 100644 index 00000000000..34d8806d072 --- /dev/null +++ b/ui/src/ui/chat/input-history.ts @@ -0,0 +1,49 @@ +const MAX = 50; + +export class InputHistory { + private items: string[] = []; + private cursor = -1; + + push(text: string): void { + const trimmed = text.trim(); + if (!trimmed) { + return; + } + if (this.items[this.items.length - 1] === trimmed) { + return; + } + this.items.push(trimmed); + if (this.items.length > MAX) { + this.items.shift(); + } + this.cursor = -1; + } + + up(): string | null { + if (this.items.length === 0) { + return null; + } + if (this.cursor < 0) { + this.cursor = this.items.length - 1; + } else if (this.cursor > 0) { + this.cursor--; + } + return this.items[this.cursor] ?? null; + } + + down(): string | null { + if (this.cursor < 0) { + return null; + } + this.cursor++; + if (this.cursor >= this.items.length) { + this.cursor = -1; + return null; + } + return this.items[this.cursor] ?? null; + } + + reset(): void { + this.cursor = -1; + } +} diff --git a/ui/src/ui/chat/pinned-messages.ts b/ui/src/ui/chat/pinned-messages.ts new file mode 100644 index 00000000000..4914b0db32a --- /dev/null +++ b/ui/src/ui/chat/pinned-messages.ts @@ -0,0 +1,61 @@ +const PREFIX = "openclaw:pinned:"; + +export class PinnedMessages { + private key: string; + private _indices = new Set(); + + constructor(sessionKey: string) { + this.key = PREFIX + sessionKey; + this.load(); + } + + get indices(): Set { + return this._indices; + } + + has(index: number): boolean { + return this._indices.has(index); + } + + pin(index: number): void { + this._indices.add(index); + this.save(); + } + + unpin(index: number): void { + this._indices.delete(index); + this.save(); + } + + toggle(index: number): void { + if (this._indices.has(index)) { + this.unpin(index); + } else { + this.pin(index); + } + } + + clear(): void { + this._indices.clear(); + this.save(); + } + + private load(): void { + try { + const raw = localStorage.getItem(this.key); + if (!raw) { + return; + } + const arr = JSON.parse(raw); + if (Array.isArray(arr)) { + this._indices = new Set(arr.filter((n) => typeof n === "number")); + } + } catch { + // ignore + } + } + + private save(): void { + localStorage.setItem(this.key, JSON.stringify([...this._indices])); + } +} diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/ui/chat/slash-command-executor.node.test.ts new file mode 100644 index 00000000000..706bfed0c3c --- /dev/null +++ b/ui/src/ui/chat/slash-command-executor.node.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../gateway.ts"; +import type { GatewaySessionRow } from "../types.ts"; +import { executeSlashCommand } from "./slash-command-executor.ts"; + +function row(key: string): GatewaySessionRow { + return { + key, + kind: "direct", + updatedAt: null, + }; +} + +describe("executeSlashCommand /kill", () => { + it("aborts every sub-agent session for /kill all", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("main"), + row("agent:main:subagent:one"), + row("agent:main:subagent:parent:subagent:child"), + row("agent:other:main"), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: true }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "kill", + "all", + ); + + expect(result.content).toBe("Aborted 2 sub-agent sessions."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { + sessionKey: "agent:main:subagent:one", + }); + expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { + sessionKey: "agent:main:subagent:parent:subagent:child", + }); + }); + + it("aborts matching sub-agent sessions for /kill ", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:subagent:one"), + row("agent:main:subagent:two"), + row("agent:other:subagent:three"), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: true }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "kill", + "main", + ); + + expect(result.content).toBe("Aborted 2 matching sub-agent sessions for `main`."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { + sessionKey: "agent:main:subagent:one", + }); + expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { + sessionKey: "agent:main:subagent:two", + }); + }); +}); diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts new file mode 100644 index 00000000000..3392095c7c1 --- /dev/null +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -0,0 +1,370 @@ +/** + * Client-side execution engine for slash commands. + * Calls gateway RPC methods and returns formatted results. + */ + +import { isSubagentSessionKey, parseAgentSessionKey } from "../../../../src/routing/session-key.js"; +import type { GatewayBrowserClient } from "../gateway.ts"; +import type { + AgentsListResult, + GatewaySessionRow, + HealthSummary, + ModelCatalogEntry, + SessionsListResult, +} from "../types.ts"; +import { SLASH_COMMANDS } from "./slash-commands.ts"; + +export type SlashCommandResult = { + /** Markdown-formatted result to display in chat. */ + content: string; + /** Side-effect action the caller should perform after displaying the result. */ + action?: + | "refresh" + | "export" + | "new-session" + | "reset" + | "stop" + | "clear" + | "toggle-focus" + | "navigate-usage"; +}; + +export async function executeSlashCommand( + client: GatewayBrowserClient, + sessionKey: string, + commandName: string, + args: string, +): Promise { + switch (commandName) { + case "help": + return executeHelp(); + case "status": + return await executeStatus(client); + case "new": + return { content: "Starting new session...", action: "new-session" }; + case "reset": + return { content: "Resetting session...", action: "reset" }; + case "stop": + return { content: "Stopping current run...", action: "stop" }; + case "clear": + return { content: "Chat history cleared.", action: "clear" }; + case "focus": + return { content: "Toggled focus mode.", action: "toggle-focus" }; + case "compact": + return await executeCompact(client, sessionKey); + case "model": + return await executeModel(client, sessionKey, args); + case "think": + return await executeThink(client, sessionKey, args); + case "verbose": + return await executeVerbose(client, sessionKey, args); + case "export": + return { content: "Exporting session...", action: "export" }; + case "usage": + return await executeUsage(client, sessionKey); + case "agents": + return await executeAgents(client); + case "kill": + return await executeKill(client, sessionKey, args); + default: + return { content: `Unknown command: \`/${commandName}\`` }; + } +} + +// ── Command Implementations ── + +function executeHelp(): SlashCommandResult { + const lines = ["**Available Commands**\n"]; + let currentCategory = ""; + + for (const cmd of SLASH_COMMANDS) { + const cat = cmd.category ?? "session"; + if (cat !== currentCategory) { + currentCategory = cat; + lines.push(`**${cat.charAt(0).toUpperCase() + cat.slice(1)}**`); + } + const argStr = cmd.args ? ` ${cmd.args}` : ""; + const local = cmd.executeLocal ? "" : " *(agent)*"; + lines.push(`\`/${cmd.name}${argStr}\` — ${cmd.description}${local}`); + } + + lines.push("\nType `/` to open the command menu."); + return { content: lines.join("\n") }; +} + +async function executeStatus(client: GatewayBrowserClient): Promise { + try { + const health = await client.request("health", {}); + const status = health.ok ? "Healthy" : "Degraded"; + const agentCount = health.agents?.length ?? 0; + const sessionCount = health.sessions?.count ?? 0; + const lines = [ + `**System Status:** ${status}`, + `**Agents:** ${agentCount}`, + `**Sessions:** ${sessionCount}`, + `**Default Agent:** ${health.defaultAgentId || "none"}`, + ]; + if (health.durationMs) { + lines.push(`**Response:** ${health.durationMs}ms`); + } + return { content: lines.join("\n") }; + } catch (err) { + return { content: `Failed to fetch status: ${String(err)}` }; + } +} + +async function executeCompact( + client: GatewayBrowserClient, + sessionKey: string, +): Promise { + try { + await client.request("sessions.compact", { key: sessionKey }); + return { content: "Context compacted successfully.", action: "refresh" }; + } catch (err) { + return { content: `Compaction failed: ${String(err)}` }; + } +} + +async function executeModel( + client: GatewayBrowserClient, + sessionKey: string, + args: string, +): Promise { + if (!args) { + try { + const sessions = await client.request("sessions.list", {}); + const session = sessions?.sessions?.find((s: GatewaySessionRow) => s.key === sessionKey); + const model = session?.model || sessions?.defaults?.model || "default"; + const models = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {}); + const available = models?.models?.map((m: ModelCatalogEntry) => m.id) ?? []; + const lines = [`**Current model:** \`${model}\``]; + if (available.length > 0) { + lines.push( + `**Available:** ${available + .slice(0, 10) + .map((m: string) => `\`${m}\``) + .join(", ")}${available.length > 10 ? ` +${available.length - 10} more` : ""}`, + ); + } + return { content: lines.join("\n") }; + } catch (err) { + return { content: `Failed to get model info: ${String(err)}` }; + } + } + + try { + await client.request("sessions.patch", { key: sessionKey, model: args.trim() }); + return { content: `Model set to \`${args.trim()}\`.`, action: "refresh" }; + } catch (err) { + return { content: `Failed to set model: ${String(err)}` }; + } +} + +async function executeThink( + client: GatewayBrowserClient, + sessionKey: string, + args: string, +): Promise { + const valid = ["off", "low", "medium", "high"]; + const level = args.trim().toLowerCase(); + + if (!level) { + return { + content: `Usage: \`/think <${valid.join("|")}>\``, + }; + } + if (!valid.includes(level)) { + return { + content: `Invalid thinking level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`, + }; + } + + try { + await client.request("sessions.patch", { key: sessionKey, thinkingLevel: level }); + return { + content: `Thinking level set to **${level}**.`, + action: "refresh", + }; + } catch (err) { + return { content: `Failed to set thinking level: ${String(err)}` }; + } +} + +async function executeVerbose( + client: GatewayBrowserClient, + sessionKey: string, + args: string, +): Promise { + const valid = ["on", "off", "full"]; + const level = args.trim().toLowerCase(); + + if (!level) { + return { + content: `Usage: \`/verbose <${valid.join("|")}>\``, + }; + } + if (!valid.includes(level)) { + return { + content: `Invalid verbose level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`, + }; + } + + try { + await client.request("sessions.patch", { key: sessionKey, verboseLevel: level }); + return { + content: `Verbose mode set to **${level}**.`, + action: "refresh", + }; + } catch (err) { + return { content: `Failed to set verbose mode: ${String(err)}` }; + } +} + +async function executeUsage( + client: GatewayBrowserClient, + sessionKey: string, +): Promise { + try { + const sessions = await client.request("sessions.list", {}); + const session = sessions?.sessions?.find((s: GatewaySessionRow) => s.key === sessionKey); + if (!session) { + return { content: "No active session." }; + } + const input = session.inputTokens ?? 0; + const output = session.outputTokens ?? 0; + const total = session.totalTokens ?? input + output; + const ctx = session.contextTokens ?? 0; + const pct = ctx > 0 ? Math.round((input / ctx) * 100) : null; + + const lines = [ + "**Session Usage**", + `Input: **${fmtTokens(input)}** tokens`, + `Output: **${fmtTokens(output)}** tokens`, + `Total: **${fmtTokens(total)}** tokens`, + ]; + if (pct !== null) { + lines.push(`Context: **${pct}%** of ${fmtTokens(ctx)}`); + } + if (session.model) { + lines.push(`Model: \`${session.model}\``); + } + return { content: lines.join("\n") }; + } catch (err) { + return { content: `Failed to get usage: ${String(err)}` }; + } +} + +async function executeAgents(client: GatewayBrowserClient): Promise { + try { + const result = await client.request("agents.list", {}); + const agents = result?.agents ?? []; + if (agents.length === 0) { + return { content: "No agents configured." }; + } + const lines = [`**Agents** (${agents.length})\n`]; + for (const agent of agents) { + const isDefault = agent.id === result?.defaultId; + const name = agent.identity?.name || agent.name || agent.id; + const marker = isDefault ? " *(default)*" : ""; + lines.push(`- \`${agent.id}\` — ${name}${marker}`); + } + return { content: lines.join("\n") }; + } catch (err) { + return { content: `Failed to list agents: ${String(err)}` }; + } +} + +async function executeKill( + client: GatewayBrowserClient, + sessionKey: string, + args: string, +): Promise { + const target = args.trim(); + if (!target) { + return { content: "Usage: `/kill `" }; + } + try { + const sessions = await client.request("sessions.list", {}); + const matched = resolveKillTargets(sessions?.sessions ?? [], sessionKey, target); + if (matched.length === 0) { + return { + content: + target.toLowerCase() === "all" + ? "No active sub-agent sessions found." + : `No matching sub-agent sessions found for \`${target}\`.`, + }; + } + + const results = await Promise.allSettled( + matched.map((key) => client.request("chat.abort", { sessionKey: key })), + ); + const successCount = results.filter((entry) => entry.status === "fulfilled").length; + if (successCount === 0) { + const firstFailure = results.find((entry) => entry.status === "rejected"); + throw firstFailure?.reason ?? new Error("abort failed"); + } + + if (target.toLowerCase() === "all") { + return { + content: + successCount === matched.length + ? `Aborted ${successCount} sub-agent session${successCount === 1 ? "" : "s"}.` + : `Aborted ${successCount} of ${matched.length} sub-agent sessions.`, + }; + } + + return { + content: + successCount === matched.length + ? `Aborted ${successCount} matching sub-agent session${successCount === 1 ? "" : "s"} for \`${target}\`.` + : `Aborted ${successCount} of ${matched.length} matching sub-agent sessions for \`${target}\`.`, + }; + } catch (err) { + return { content: `Failed to abort: ${String(err)}` }; + } +} + +function resolveKillTargets( + sessions: GatewaySessionRow[], + currentSessionKey: string, + target: string, +): string[] { + const normalizedTarget = target.trim().toLowerCase(); + if (!normalizedTarget) { + return []; + } + + const keys = new Set(); + const currentParsed = parseAgentSessionKey(currentSessionKey); + for (const session of sessions) { + const key = session?.key?.trim(); + if (!key || !isSubagentSessionKey(key)) { + continue; + } + const normalizedKey = key.toLowerCase(); + const parsed = parseAgentSessionKey(normalizedKey); + const isMatch = + normalizedTarget === "all" || + normalizedKey === normalizedTarget || + (parsed?.agentId ?? "") === normalizedTarget || + normalizedKey.endsWith(`:subagent:${normalizedTarget}`) || + normalizedKey === `subagent:${normalizedTarget}` || + (currentParsed?.agentId != null && + parsed?.agentId === currentParsed.agentId && + normalizedKey.endsWith(`:subagent:${normalizedTarget}`)); + if (isMatch) { + keys.add(key); + } + } + return [...keys]; +} + +function fmtTokens(n: number): string { + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; + } + if (n >= 1_000) { + return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; + } + return String(n); +} diff --git a/ui/src/ui/chat/slash-commands.ts b/ui/src/ui/chat/slash-commands.ts new file mode 100644 index 00000000000..d26a82e1544 --- /dev/null +++ b/ui/src/ui/chat/slash-commands.ts @@ -0,0 +1,217 @@ +import type { IconName } from "../icons.ts"; + +export type SlashCommandCategory = "session" | "model" | "agents" | "tools"; + +export type SlashCommandDef = { + name: string; + description: string; + args?: string; + icon?: IconName; + category?: SlashCommandCategory; + /** When true, the command is executed client-side via RPC instead of sent to the agent. */ + executeLocal?: boolean; + /** Fixed argument choices for inline hints. */ + argOptions?: string[]; + /** Keyboard shortcut hint shown in the menu (display only). */ + shortcut?: string; +}; + +export const SLASH_COMMANDS: SlashCommandDef[] = [ + // ── Session ── + { + name: "new", + description: "Start a new session", + icon: "plus", + category: "session", + executeLocal: true, + }, + { + name: "reset", + description: "Reset current session", + icon: "refresh", + category: "session", + executeLocal: true, + }, + { + name: "compact", + description: "Compact session context", + icon: "loader", + category: "session", + executeLocal: true, + }, + { + name: "stop", + description: "Stop current run", + icon: "stop", + category: "session", + executeLocal: true, + }, + { + name: "clear", + description: "Clear chat history", + icon: "trash", + category: "session", + executeLocal: true, + }, + { + name: "focus", + description: "Toggle focus mode", + icon: "eye", + category: "session", + executeLocal: true, + }, + + // ── Model ── + { + name: "model", + description: "Show or set model", + args: "", + icon: "brain", + category: "model", + executeLocal: true, + }, + { + name: "think", + description: "Set thinking level", + args: "", + icon: "brain", + category: "model", + executeLocal: true, + argOptions: ["off", "low", "medium", "high"], + }, + { + name: "verbose", + description: "Toggle verbose mode", + args: "", + icon: "terminal", + category: "model", + executeLocal: true, + argOptions: ["on", "off", "full"], + }, + + // ── Tools ── + { + name: "help", + description: "Show available commands", + icon: "book", + category: "tools", + executeLocal: true, + }, + { + name: "status", + description: "Show system status", + icon: "barChart", + category: "tools", + executeLocal: true, + }, + { + name: "export", + description: "Export session to Markdown", + icon: "download", + category: "tools", + executeLocal: true, + }, + { + name: "usage", + description: "Show token usage", + icon: "barChart", + category: "tools", + executeLocal: true, + }, + + // ── Agents ── + { + name: "agents", + description: "List agents", + icon: "monitor", + category: "agents", + executeLocal: true, + }, + { + name: "kill", + description: "Abort sub-agents", + args: "", + icon: "x", + category: "agents", + executeLocal: true, + }, + { + name: "skill", + description: "Run a skill", + args: "", + icon: "zap", + category: "tools", + }, + { + name: "steer", + description: "Steer a sub-agent", + args: " ", + icon: "send", + category: "agents", + }, +]; + +const CATEGORY_ORDER: SlashCommandCategory[] = ["session", "model", "tools", "agents"]; + +export const CATEGORY_LABELS: Record = { + session: "Session", + model: "Model", + agents: "Agents", + tools: "Tools", +}; + +export function getSlashCommandCompletions(filter: string): SlashCommandDef[] { + const lower = filter.toLowerCase(); + const commands = lower + ? SLASH_COMMANDS.filter( + (cmd) => cmd.name.startsWith(lower) || cmd.description.toLowerCase().includes(lower), + ) + : SLASH_COMMANDS; + return commands.toSorted((a, b) => { + const ai = CATEGORY_ORDER.indexOf(a.category ?? "session"); + const bi = CATEGORY_ORDER.indexOf(b.category ?? "session"); + if (ai !== bi) { + return ai - bi; + } + // Exact prefix matches first + if (lower) { + const aExact = a.name.startsWith(lower) ? 0 : 1; + const bExact = b.name.startsWith(lower) ? 0 : 1; + if (aExact !== bExact) { + return aExact - bExact; + } + } + return 0; + }); +} + +export type ParsedSlashCommand = { + command: SlashCommandDef; + args: string; +}; + +/** + * Parse a message as a slash command. Returns null if it doesn't match. + * Supports `/command` and `/command args...`. + */ +export function parseSlashCommand(text: string): ParsedSlashCommand | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("/")) { + return null; + } + + const spaceIdx = trimmed.indexOf(" "); + const name = spaceIdx === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIdx); + const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim(); + + if (!name) { + return null; + } + + const command = SLASH_COMMANDS.find((cmd) => cmd.name === name.toLowerCase()); + if (!command) { + return null; + } + + return { command, args }; +} diff --git a/ui/src/ui/chat/speech.ts b/ui/src/ui/chat/speech.ts new file mode 100644 index 00000000000..4db4e6944a1 --- /dev/null +++ b/ui/src/ui/chat/speech.ts @@ -0,0 +1,225 @@ +/** + * Browser-native speech services: STT via SpeechRecognition, TTS via SpeechSynthesis. + * Falls back gracefully when APIs are unavailable. + */ + +// ─── STT (Speech-to-Text) ─── + +type SpeechRecognitionEvent = Event & { + results: SpeechRecognitionResultList; + resultIndex: number; +}; + +type SpeechRecognitionErrorEvent = Event & { + error: string; + message?: string; +}; + +interface SpeechRecognitionInstance extends EventTarget { + continuous: boolean; + interimResults: boolean; + lang: string; + start(): void; + stop(): void; + abort(): void; + onresult: ((event: SpeechRecognitionEvent) => void) | null; + onerror: ((event: SpeechRecognitionErrorEvent) => void) | null; + onend: (() => void) | null; + onstart: (() => void) | null; +} + +type SpeechRecognitionCtor = new () => SpeechRecognitionInstance; + +function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null { + const w = globalThis as Record; + return (w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null) as SpeechRecognitionCtor | null; +} + +export function isSttSupported(): boolean { + return getSpeechRecognitionCtor() !== null; +} + +export type SttCallbacks = { + onTranscript: (text: string, isFinal: boolean) => void; + onStart?: () => void; + onEnd?: () => void; + onError?: (error: string) => void; +}; + +let activeRecognition: SpeechRecognitionInstance | null = null; + +export function startStt(callbacks: SttCallbacks): boolean { + const Ctor = getSpeechRecognitionCtor(); + if (!Ctor) { + callbacks.onError?.("Speech recognition is not supported in this browser"); + return false; + } + + stopStt(); + + const recognition = new Ctor(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = navigator.language || "en-US"; + + recognition.addEventListener("start", () => callbacks.onStart?.()); + + recognition.addEventListener("result", (event) => { + const speechEvent = event as unknown as SpeechRecognitionEvent; + let interimTranscript = ""; + let finalTranscript = ""; + + for (let i = speechEvent.resultIndex; i < speechEvent.results.length; i++) { + const result = speechEvent.results[i]; + if (!result?.[0]) { + continue; + } + const transcript = result[0].transcript; + if (result.isFinal) { + finalTranscript += transcript; + } else { + interimTranscript += transcript; + } + } + + if (finalTranscript) { + callbacks.onTranscript(finalTranscript, true); + } else if (interimTranscript) { + callbacks.onTranscript(interimTranscript, false); + } + }); + + recognition.addEventListener("error", (event) => { + const speechEvent = event as unknown as SpeechRecognitionErrorEvent; + if (speechEvent.error === "aborted" || speechEvent.error === "no-speech") { + return; + } + callbacks.onError?.(speechEvent.error); + }); + + recognition.addEventListener("end", () => { + if (activeRecognition === recognition) { + activeRecognition = null; + } + callbacks.onEnd?.(); + }); + + activeRecognition = recognition; + recognition.start(); + return true; +} + +export function stopStt(): void { + if (activeRecognition) { + const r = activeRecognition; + activeRecognition = null; + try { + r.stop(); + } catch { + // already stopped + } + } +} + +export function isSttActive(): boolean { + return activeRecognition !== null; +} + +// ─── TTS (Text-to-Speech) ─── + +export function isTtsSupported(): boolean { + return "speechSynthesis" in globalThis; +} + +let currentUtterance: SpeechSynthesisUtterance | null = null; + +export function speakText( + text: string, + opts?: { + onStart?: () => void; + onEnd?: () => void; + onError?: (error: string) => void; + }, +): boolean { + if (!isTtsSupported()) { + opts?.onError?.("Speech synthesis is not supported in this browser"); + return false; + } + + stopTts(); + + const cleaned = stripMarkdown(text); + if (!cleaned.trim()) { + return false; + } + + const utterance = new SpeechSynthesisUtterance(cleaned); + utterance.rate = 1.0; + utterance.pitch = 1.0; + + utterance.addEventListener("start", () => opts?.onStart?.()); + utterance.addEventListener("end", () => { + if (currentUtterance === utterance) { + currentUtterance = null; + } + opts?.onEnd?.(); + }); + utterance.addEventListener("error", (e) => { + if (currentUtterance === utterance) { + currentUtterance = null; + } + if (e.error === "canceled" || e.error === "interrupted") { + return; + } + opts?.onError?.(e.error); + }); + + currentUtterance = utterance; + speechSynthesis.speak(utterance); + return true; +} + +export function stopTts(): void { + if (currentUtterance) { + currentUtterance = null; + } + if (isTtsSupported()) { + speechSynthesis.cancel(); + } +} + +export function isTtsSpeaking(): boolean { + return isTtsSupported() && speechSynthesis.speaking; +} + +/** Strip common markdown syntax for cleaner speech output. */ +function stripMarkdown(text: string): string { + return ( + text + // code blocks + .replace(/```[\s\S]*?```/g, "") + // inline code + .replace(/`[^`]+`/g, "") + // images + .replace(/!\[.*?\]\(.*?\)/g, "") + // links → keep text + .replace(/\[([^\]]+)\]\(.*?\)/g, "$1") + // headings + .replace(/^#{1,6}\s+/gm, "") + // bold/italic + .replace(/\*{1,3}(.*?)\*{1,3}/g, "$1") + .replace(/_{1,3}(.*?)_{1,3}/g, "$1") + // blockquotes + .replace(/^>\s?/gm, "") + // horizontal rules + .replace(/^[-*_]{3,}\s*$/gm, "") + // list markers + .replace(/^\s*[-*+]\s+/gm, "") + .replace(/^\s*\d+\.\s+/gm, "") + // HTML tags + .replace(/<[^>]+>/g, "") + // collapse whitespace + .replace(/\n{3,}/g, "\n\n") + .trim() + ); +} From d648dd7643dc1232cc1a9071391fad0587097ca8 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:07:03 -0500 Subject: [PATCH 0028/1173] Update ui/src/ui/chat/export.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- ui/src/ui/chat/export.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/chat/export.ts b/ui/src/ui/chat/export.ts index 31e15e592e2..365d640ffcc 100644 --- a/ui/src/ui/chat/export.ts +++ b/ui/src/ui/chat/export.ts @@ -10,7 +10,15 @@ export function exportChatMarkdown(messages: unknown[], assistantName: string): for (const msg of history) { const m = msg as Record; const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool"; - const content = typeof m.content === "string" ? m.content : ""; + const content = + typeof m.content === "string" + ? m.content + : Array.isArray(m.content) + ? (m.content as Array<{ type?: string; text?: string }>) + .filter((b) => b?.type === "text" && typeof b.text === "string") + .map((b) => b.text) + .join("") + : ""; const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : ""; lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, ""); } From 8a6cd808a138ea73e53b7498bc3fd5dcfd565a7a Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 9 Mar 2026 18:09:37 -0500 Subject: [PATCH 0029/1173] fix(ui): address review feedback on chat infra slice - export.ts: handle array content blocks (Claude API format) instead of silently exporting empty strings - slash-command-executor.ts: restrict /kill all to current session's subagent subtree instead of all sessions globally - slash-command-executor.ts: only count truly aborted runs (check aborted !== false) in /kill summary --- ui/src/ui/chat/slash-command-executor.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index 3392095c7c1..d1c767370a4 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -296,9 +296,14 @@ async function executeKill( } const results = await Promise.allSettled( - matched.map((key) => client.request("chat.abort", { sessionKey: key })), + matched.map((key) => + client.request<{ aborted?: boolean }>("chat.abort", { sessionKey: key }), + ), ); - const successCount = results.filter((entry) => entry.status === "fulfilled").length; + const successCount = results.filter( + (entry) => + entry.status === "fulfilled" && (entry.value as { aborted?: boolean })?.aborted !== false, + ).length; if (successCount === 0) { const firstFailure = results.find((entry) => entry.status === "rejected"); throw firstFailure?.reason ?? new Error("abort failed"); @@ -343,15 +348,16 @@ function resolveKillTargets( } const normalizedKey = key.toLowerCase(); const parsed = parseAgentSessionKey(normalizedKey); + // For "all", only match subagents belonging to the current session's agent + const belongsToCurrentSession = + currentParsed?.agentId != null && parsed?.agentId === currentParsed.agentId; const isMatch = - normalizedTarget === "all" || + (normalizedTarget === "all" && belongsToCurrentSession) || normalizedKey === normalizedTarget || (parsed?.agentId ?? "") === normalizedTarget || normalizedKey.endsWith(`:subagent:${normalizedTarget}`) || normalizedKey === `subagent:${normalizedTarget}` || - (currentParsed?.agentId != null && - parsed?.agentId === currentParsed.agentId && - normalizedKey.endsWith(`:subagent:${normalizedTarget}`)); + (belongsToCurrentSession && normalizedKey.endsWith(`:subagent:${normalizedTarget}`)); if (isMatch) { keys.add(key); } From 8e412bad0ebe41264dc4cf169b1fdd8453f6b000 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 9 Mar 2026 18:47:37 -0500 Subject: [PATCH 0030/1173] Revert "fix(ui): address review feedback on chat infra slice" This reverts commit 8a6cd808a138ea73e53b7498bc3fd5dcfd565a7a. --- ui/src/ui/chat/slash-command-executor.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index d1c767370a4..3392095c7c1 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -296,14 +296,9 @@ async function executeKill( } const results = await Promise.allSettled( - matched.map((key) => - client.request<{ aborted?: boolean }>("chat.abort", { sessionKey: key }), - ), + matched.map((key) => client.request("chat.abort", { sessionKey: key })), ); - const successCount = results.filter( - (entry) => - entry.status === "fulfilled" && (entry.value as { aborted?: boolean })?.aborted !== false, - ).length; + const successCount = results.filter((entry) => entry.status === "fulfilled").length; if (successCount === 0) { const firstFailure = results.find((entry) => entry.status === "rejected"); throw firstFailure?.reason ?? new Error("abort failed"); @@ -348,16 +343,15 @@ function resolveKillTargets( } const normalizedKey = key.toLowerCase(); const parsed = parseAgentSessionKey(normalizedKey); - // For "all", only match subagents belonging to the current session's agent - const belongsToCurrentSession = - currentParsed?.agentId != null && parsed?.agentId === currentParsed.agentId; const isMatch = - (normalizedTarget === "all" && belongsToCurrentSession) || + normalizedTarget === "all" || normalizedKey === normalizedTarget || (parsed?.agentId ?? "") === normalizedTarget || normalizedKey.endsWith(`:subagent:${normalizedTarget}`) || normalizedKey === `subagent:${normalizedTarget}` || - (belongsToCurrentSession && normalizedKey.endsWith(`:subagent:${normalizedTarget}`)); + (currentParsed?.agentId != null && + parsed?.agentId === currentParsed.agentId && + normalizedKey.endsWith(`:subagent:${normalizedTarget}`)); if (isMatch) { keys.add(key); } From 9f0a64f855439979abd79a6e9e52d171c994482f Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 9 Mar 2026 18:47:40 -0500 Subject: [PATCH 0031/1173] Revert "Update ui/src/ui/chat/export.ts" This reverts commit d648dd7643dc1232cc1a9071391fad0587097ca8. --- ui/src/ui/chat/export.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/ui/src/ui/chat/export.ts b/ui/src/ui/chat/export.ts index 365d640ffcc..31e15e592e2 100644 --- a/ui/src/ui/chat/export.ts +++ b/ui/src/ui/chat/export.ts @@ -10,15 +10,7 @@ export function exportChatMarkdown(messages: unknown[], assistantName: string): for (const msg of history) { const m = msg as Record; const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool"; - const content = - typeof m.content === "string" - ? m.content - : Array.isArray(m.content) - ? (m.content as Array<{ type?: string; text?: string }>) - .filter((b) => b?.type === "text" && typeof b.text === "string") - .map((b) => b.text) - .join("") - : ""; + const content = typeof m.content === "string" ? m.content : ""; const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : ""; lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, ""); } From 6b8748989061c1d3405002e67004cd7574042717 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 9 Mar 2026 18:47:44 -0500 Subject: [PATCH 0032/1173] Revert "feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2)" This reverts commit 5a659b0b61dbfa1645fdfa28bf9bffee03a8c9bc. --- src/gateway/server-methods/chat.ts | 93 +---- .../server.chat.gateway-server-chat-b.test.ts | 31 -- ui/src/ui/chat-export.ts | 1 - ui/src/ui/chat/deleted-messages.ts | 49 --- ui/src/ui/chat/export.ts | 24 -- ui/src/ui/chat/input-history.ts | 49 --- ui/src/ui/chat/pinned-messages.ts | 61 --- .../chat/slash-command-executor.node.test.ts | 83 ---- ui/src/ui/chat/slash-command-executor.ts | 370 ------------------ ui/src/ui/chat/slash-commands.ts | 217 ---------- ui/src/ui/chat/speech.ts | 225 ----------- 11 files changed, 7 insertions(+), 1196 deletions(-) delete mode 100644 ui/src/ui/chat-export.ts delete mode 100644 ui/src/ui/chat/deleted-messages.ts delete mode 100644 ui/src/ui/chat/export.ts delete mode 100644 ui/src/ui/chat/input-history.ts delete mode 100644 ui/src/ui/chat/pinned-messages.ts delete mode 100644 ui/src/ui/chat/slash-command-executor.node.test.ts delete mode 100644 ui/src/ui/chat/slash-command-executor.ts delete mode 100644 ui/src/ui/chat/slash-commands.ts delete mode 100644 ui/src/ui/chat/speech.ts diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 291e323b671..71669080382 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -314,60 +314,6 @@ function sanitizeChatHistoryContentBlock(block: unknown): { block: unknown; chan return { block: changed ? entry : block, changed }; } -/** - * Validate that a value is a finite number, returning undefined otherwise. - */ -function toFiniteNumber(x: unknown): number | undefined { - return typeof x === "number" && Number.isFinite(x) ? x : undefined; -} - -/** - * Sanitize usage metadata to ensure only finite numeric fields are included. - * Prevents UI crashes from malformed transcript JSON. - */ -function sanitizeUsage(raw: unknown): Record | undefined { - if (!raw || typeof raw !== "object") { - return undefined; - } - const u = raw as Record; - const out: Record = {}; - - // Whitelist known usage fields and validate they're finite numbers - const knownFields = [ - "input", - "output", - "totalTokens", - "inputTokens", - "outputTokens", - "cacheRead", - "cacheWrite", - "cache_read_input_tokens", - "cache_creation_input_tokens", - ]; - - for (const k of knownFields) { - const n = toFiniteNumber(u[k]); - if (n !== undefined) { - out[k] = n; - } - } - - return Object.keys(out).length > 0 ? out : undefined; -} - -/** - * Sanitize cost metadata to ensure only finite numeric fields are included. - * Prevents UI crashes from calling .toFixed() on non-numbers. - */ -function sanitizeCost(raw: unknown): { total?: number } | undefined { - if (!raw || typeof raw !== "object") { - return undefined; - } - const c = raw as Record; - const total = toFiniteNumber(c.total); - return total !== undefined ? { total } : undefined; -} - function sanitizeChatHistoryMessage(message: unknown): { message: unknown; changed: boolean } { if (!message || typeof message !== "object") { return { message, changed: false }; @@ -379,38 +325,13 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang delete entry.details; changed = true; } - - // Keep usage/cost so the chat UI can render per-message token and cost badges. - // Only retain usage/cost on assistant messages and validate numeric fields to prevent UI crashes. - if (entry.role !== "assistant") { - if ("usage" in entry) { - delete entry.usage; - changed = true; - } - if ("cost" in entry) { - delete entry.cost; - changed = true; - } - } else { - // Validate and sanitize usage/cost for assistant messages - if ("usage" in entry) { - const sanitized = sanitizeUsage(entry.usage); - if (sanitized) { - entry.usage = sanitized; - } else { - delete entry.usage; - } - changed = true; - } - if ("cost" in entry) { - const sanitized = sanitizeCost(entry.cost); - if (sanitized) { - entry.cost = sanitized; - } else { - delete entry.cost; - } - changed = true; - } + if ("usage" in entry) { + delete entry.usage; + changed = true; + } + if ("cost" in entry) { + delete entry.cost; + changed = true; } if (typeof entry.content === "string") { diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index ca1e2c09402..2e76e1a5de1 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -273,37 +273,6 @@ describe("gateway server chat", () => { }); }); - test("chat.history preserves usage and cost metadata for assistant messages", async () => { - await withGatewayChatHarness(async ({ ws, createSessionDir }) => { - await connectOk(ws); - - const sessionDir = await createSessionDir(); - await writeMainSessionStore(); - - await writeMainSessionTranscript(sessionDir, [ - JSON.stringify({ - message: { - role: "assistant", - timestamp: Date.now(), - content: [{ type: "text", text: "hello" }], - usage: { input: 12, output: 5, totalTokens: 17 }, - cost: { total: 0.0123 }, - details: { debug: true }, - }, - }), - ]); - - const messages = await fetchHistoryMessages(ws); - expect(messages).toHaveLength(1); - expect(messages[0]).toMatchObject({ - role: "assistant", - usage: { input: 12, output: 5, totalTokens: 17 }, - cost: { total: 0.0123 }, - }); - expect(messages[0]).not.toHaveProperty("details"); - }); - }); - test("chat.history strips inline directives from displayed message text", async () => { await withGatewayChatHarness(async ({ ws, createSessionDir }) => { await connectOk(ws); diff --git a/ui/src/ui/chat-export.ts b/ui/src/ui/chat-export.ts deleted file mode 100644 index ed5bbf931f8..00000000000 --- a/ui/src/ui/chat-export.ts +++ /dev/null @@ -1 +0,0 @@ -export { exportChatMarkdown } from "./chat/export.ts"; diff --git a/ui/src/ui/chat/deleted-messages.ts b/ui/src/ui/chat/deleted-messages.ts deleted file mode 100644 index fd3916d78c7..00000000000 --- a/ui/src/ui/chat/deleted-messages.ts +++ /dev/null @@ -1,49 +0,0 @@ -const PREFIX = "openclaw:deleted:"; - -export class DeletedMessages { - private key: string; - private _keys = new Set(); - - constructor(sessionKey: string) { - this.key = PREFIX + sessionKey; - this.load(); - } - - has(key: string): boolean { - return this._keys.has(key); - } - - delete(key: string): void { - this._keys.add(key); - this.save(); - } - - restore(key: string): void { - this._keys.delete(key); - this.save(); - } - - clear(): void { - this._keys.clear(); - this.save(); - } - - private load(): void { - try { - const raw = localStorage.getItem(this.key); - if (!raw) { - return; - } - const arr = JSON.parse(raw); - if (Array.isArray(arr)) { - this._keys = new Set(arr.filter((s) => typeof s === "string")); - } - } catch { - // ignore - } - } - - private save(): void { - localStorage.setItem(this.key, JSON.stringify([...this._keys])); - } -} diff --git a/ui/src/ui/chat/export.ts b/ui/src/ui/chat/export.ts deleted file mode 100644 index 31e15e592e2..00000000000 --- a/ui/src/ui/chat/export.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Export chat history as markdown file. - */ -export function exportChatMarkdown(messages: unknown[], assistantName: string): void { - const history = Array.isArray(messages) ? messages : []; - if (history.length === 0) { - return; - } - const lines: string[] = [`# Chat with ${assistantName}`, ""]; - for (const msg of history) { - const m = msg as Record; - const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool"; - const content = typeof m.content === "string" ? m.content : ""; - const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : ""; - lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, ""); - } - const blob = new Blob([lines.join("\n")], { type: "text/markdown" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `chat-${assistantName}-${Date.now()}.md`; - link.click(); - URL.revokeObjectURL(url); -} diff --git a/ui/src/ui/chat/input-history.ts b/ui/src/ui/chat/input-history.ts deleted file mode 100644 index 34d8806d072..00000000000 --- a/ui/src/ui/chat/input-history.ts +++ /dev/null @@ -1,49 +0,0 @@ -const MAX = 50; - -export class InputHistory { - private items: string[] = []; - private cursor = -1; - - push(text: string): void { - const trimmed = text.trim(); - if (!trimmed) { - return; - } - if (this.items[this.items.length - 1] === trimmed) { - return; - } - this.items.push(trimmed); - if (this.items.length > MAX) { - this.items.shift(); - } - this.cursor = -1; - } - - up(): string | null { - if (this.items.length === 0) { - return null; - } - if (this.cursor < 0) { - this.cursor = this.items.length - 1; - } else if (this.cursor > 0) { - this.cursor--; - } - return this.items[this.cursor] ?? null; - } - - down(): string | null { - if (this.cursor < 0) { - return null; - } - this.cursor++; - if (this.cursor >= this.items.length) { - this.cursor = -1; - return null; - } - return this.items[this.cursor] ?? null; - } - - reset(): void { - this.cursor = -1; - } -} diff --git a/ui/src/ui/chat/pinned-messages.ts b/ui/src/ui/chat/pinned-messages.ts deleted file mode 100644 index 4914b0db32a..00000000000 --- a/ui/src/ui/chat/pinned-messages.ts +++ /dev/null @@ -1,61 +0,0 @@ -const PREFIX = "openclaw:pinned:"; - -export class PinnedMessages { - private key: string; - private _indices = new Set(); - - constructor(sessionKey: string) { - this.key = PREFIX + sessionKey; - this.load(); - } - - get indices(): Set { - return this._indices; - } - - has(index: number): boolean { - return this._indices.has(index); - } - - pin(index: number): void { - this._indices.add(index); - this.save(); - } - - unpin(index: number): void { - this._indices.delete(index); - this.save(); - } - - toggle(index: number): void { - if (this._indices.has(index)) { - this.unpin(index); - } else { - this.pin(index); - } - } - - clear(): void { - this._indices.clear(); - this.save(); - } - - private load(): void { - try { - const raw = localStorage.getItem(this.key); - if (!raw) { - return; - } - const arr = JSON.parse(raw); - if (Array.isArray(arr)) { - this._indices = new Set(arr.filter((n) => typeof n === "number")); - } - } catch { - // ignore - } - } - - private save(): void { - localStorage.setItem(this.key, JSON.stringify([...this._indices])); - } -} diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/ui/chat/slash-command-executor.node.test.ts deleted file mode 100644 index 706bfed0c3c..00000000000 --- a/ui/src/ui/chat/slash-command-executor.node.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { GatewaySessionRow } from "../types.ts"; -import { executeSlashCommand } from "./slash-command-executor.ts"; - -function row(key: string): GatewaySessionRow { - return { - key, - kind: "direct", - updatedAt: null, - }; -} - -describe("executeSlashCommand /kill", () => { - it("aborts every sub-agent session for /kill all", async () => { - const request = vi.fn(async (method: string, _payload?: unknown) => { - if (method === "sessions.list") { - return { - sessions: [ - row("main"), - row("agent:main:subagent:one"), - row("agent:main:subagent:parent:subagent:child"), - row("agent:other:main"), - ], - }; - } - if (method === "chat.abort") { - return { ok: true, aborted: true }; - } - throw new Error(`unexpected method: ${method}`); - }); - - const result = await executeSlashCommand( - { request } as unknown as GatewayBrowserClient, - "agent:main:main", - "kill", - "all", - ); - - expect(result.content).toBe("Aborted 2 sub-agent sessions."); - expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); - expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { - sessionKey: "agent:main:subagent:one", - }); - expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { - sessionKey: "agent:main:subagent:parent:subagent:child", - }); - }); - - it("aborts matching sub-agent sessions for /kill ", async () => { - const request = vi.fn(async (method: string, _payload?: unknown) => { - if (method === "sessions.list") { - return { - sessions: [ - row("agent:main:subagent:one"), - row("agent:main:subagent:two"), - row("agent:other:subagent:three"), - ], - }; - } - if (method === "chat.abort") { - return { ok: true, aborted: true }; - } - throw new Error(`unexpected method: ${method}`); - }); - - const result = await executeSlashCommand( - { request } as unknown as GatewayBrowserClient, - "agent:main:main", - "kill", - "main", - ); - - expect(result.content).toBe("Aborted 2 matching sub-agent sessions for `main`."); - expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); - expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { - sessionKey: "agent:main:subagent:one", - }); - expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { - sessionKey: "agent:main:subagent:two", - }); - }); -}); diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts deleted file mode 100644 index 3392095c7c1..00000000000 --- a/ui/src/ui/chat/slash-command-executor.ts +++ /dev/null @@ -1,370 +0,0 @@ -/** - * Client-side execution engine for slash commands. - * Calls gateway RPC methods and returns formatted results. - */ - -import { isSubagentSessionKey, parseAgentSessionKey } from "../../../../src/routing/session-key.js"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { - AgentsListResult, - GatewaySessionRow, - HealthSummary, - ModelCatalogEntry, - SessionsListResult, -} from "../types.ts"; -import { SLASH_COMMANDS } from "./slash-commands.ts"; - -export type SlashCommandResult = { - /** Markdown-formatted result to display in chat. */ - content: string; - /** Side-effect action the caller should perform after displaying the result. */ - action?: - | "refresh" - | "export" - | "new-session" - | "reset" - | "stop" - | "clear" - | "toggle-focus" - | "navigate-usage"; -}; - -export async function executeSlashCommand( - client: GatewayBrowserClient, - sessionKey: string, - commandName: string, - args: string, -): Promise { - switch (commandName) { - case "help": - return executeHelp(); - case "status": - return await executeStatus(client); - case "new": - return { content: "Starting new session...", action: "new-session" }; - case "reset": - return { content: "Resetting session...", action: "reset" }; - case "stop": - return { content: "Stopping current run...", action: "stop" }; - case "clear": - return { content: "Chat history cleared.", action: "clear" }; - case "focus": - return { content: "Toggled focus mode.", action: "toggle-focus" }; - case "compact": - return await executeCompact(client, sessionKey); - case "model": - return await executeModel(client, sessionKey, args); - case "think": - return await executeThink(client, sessionKey, args); - case "verbose": - return await executeVerbose(client, sessionKey, args); - case "export": - return { content: "Exporting session...", action: "export" }; - case "usage": - return await executeUsage(client, sessionKey); - case "agents": - return await executeAgents(client); - case "kill": - return await executeKill(client, sessionKey, args); - default: - return { content: `Unknown command: \`/${commandName}\`` }; - } -} - -// ── Command Implementations ── - -function executeHelp(): SlashCommandResult { - const lines = ["**Available Commands**\n"]; - let currentCategory = ""; - - for (const cmd of SLASH_COMMANDS) { - const cat = cmd.category ?? "session"; - if (cat !== currentCategory) { - currentCategory = cat; - lines.push(`**${cat.charAt(0).toUpperCase() + cat.slice(1)}**`); - } - const argStr = cmd.args ? ` ${cmd.args}` : ""; - const local = cmd.executeLocal ? "" : " *(agent)*"; - lines.push(`\`/${cmd.name}${argStr}\` — ${cmd.description}${local}`); - } - - lines.push("\nType `/` to open the command menu."); - return { content: lines.join("\n") }; -} - -async function executeStatus(client: GatewayBrowserClient): Promise { - try { - const health = await client.request("health", {}); - const status = health.ok ? "Healthy" : "Degraded"; - const agentCount = health.agents?.length ?? 0; - const sessionCount = health.sessions?.count ?? 0; - const lines = [ - `**System Status:** ${status}`, - `**Agents:** ${agentCount}`, - `**Sessions:** ${sessionCount}`, - `**Default Agent:** ${health.defaultAgentId || "none"}`, - ]; - if (health.durationMs) { - lines.push(`**Response:** ${health.durationMs}ms`); - } - return { content: lines.join("\n") }; - } catch (err) { - return { content: `Failed to fetch status: ${String(err)}` }; - } -} - -async function executeCompact( - client: GatewayBrowserClient, - sessionKey: string, -): Promise { - try { - await client.request("sessions.compact", { key: sessionKey }); - return { content: "Context compacted successfully.", action: "refresh" }; - } catch (err) { - return { content: `Compaction failed: ${String(err)}` }; - } -} - -async function executeModel( - client: GatewayBrowserClient, - sessionKey: string, - args: string, -): Promise { - if (!args) { - try { - const sessions = await client.request("sessions.list", {}); - const session = sessions?.sessions?.find((s: GatewaySessionRow) => s.key === sessionKey); - const model = session?.model || sessions?.defaults?.model || "default"; - const models = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {}); - const available = models?.models?.map((m: ModelCatalogEntry) => m.id) ?? []; - const lines = [`**Current model:** \`${model}\``]; - if (available.length > 0) { - lines.push( - `**Available:** ${available - .slice(0, 10) - .map((m: string) => `\`${m}\``) - .join(", ")}${available.length > 10 ? ` +${available.length - 10} more` : ""}`, - ); - } - return { content: lines.join("\n") }; - } catch (err) { - return { content: `Failed to get model info: ${String(err)}` }; - } - } - - try { - await client.request("sessions.patch", { key: sessionKey, model: args.trim() }); - return { content: `Model set to \`${args.trim()}\`.`, action: "refresh" }; - } catch (err) { - return { content: `Failed to set model: ${String(err)}` }; - } -} - -async function executeThink( - client: GatewayBrowserClient, - sessionKey: string, - args: string, -): Promise { - const valid = ["off", "low", "medium", "high"]; - const level = args.trim().toLowerCase(); - - if (!level) { - return { - content: `Usage: \`/think <${valid.join("|")}>\``, - }; - } - if (!valid.includes(level)) { - return { - content: `Invalid thinking level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`, - }; - } - - try { - await client.request("sessions.patch", { key: sessionKey, thinkingLevel: level }); - return { - content: `Thinking level set to **${level}**.`, - action: "refresh", - }; - } catch (err) { - return { content: `Failed to set thinking level: ${String(err)}` }; - } -} - -async function executeVerbose( - client: GatewayBrowserClient, - sessionKey: string, - args: string, -): Promise { - const valid = ["on", "off", "full"]; - const level = args.trim().toLowerCase(); - - if (!level) { - return { - content: `Usage: \`/verbose <${valid.join("|")}>\``, - }; - } - if (!valid.includes(level)) { - return { - content: `Invalid verbose level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`, - }; - } - - try { - await client.request("sessions.patch", { key: sessionKey, verboseLevel: level }); - return { - content: `Verbose mode set to **${level}**.`, - action: "refresh", - }; - } catch (err) { - return { content: `Failed to set verbose mode: ${String(err)}` }; - } -} - -async function executeUsage( - client: GatewayBrowserClient, - sessionKey: string, -): Promise { - try { - const sessions = await client.request("sessions.list", {}); - const session = sessions?.sessions?.find((s: GatewaySessionRow) => s.key === sessionKey); - if (!session) { - return { content: "No active session." }; - } - const input = session.inputTokens ?? 0; - const output = session.outputTokens ?? 0; - const total = session.totalTokens ?? input + output; - const ctx = session.contextTokens ?? 0; - const pct = ctx > 0 ? Math.round((input / ctx) * 100) : null; - - const lines = [ - "**Session Usage**", - `Input: **${fmtTokens(input)}** tokens`, - `Output: **${fmtTokens(output)}** tokens`, - `Total: **${fmtTokens(total)}** tokens`, - ]; - if (pct !== null) { - lines.push(`Context: **${pct}%** of ${fmtTokens(ctx)}`); - } - if (session.model) { - lines.push(`Model: \`${session.model}\``); - } - return { content: lines.join("\n") }; - } catch (err) { - return { content: `Failed to get usage: ${String(err)}` }; - } -} - -async function executeAgents(client: GatewayBrowserClient): Promise { - try { - const result = await client.request("agents.list", {}); - const agents = result?.agents ?? []; - if (agents.length === 0) { - return { content: "No agents configured." }; - } - const lines = [`**Agents** (${agents.length})\n`]; - for (const agent of agents) { - const isDefault = agent.id === result?.defaultId; - const name = agent.identity?.name || agent.name || agent.id; - const marker = isDefault ? " *(default)*" : ""; - lines.push(`- \`${agent.id}\` — ${name}${marker}`); - } - return { content: lines.join("\n") }; - } catch (err) { - return { content: `Failed to list agents: ${String(err)}` }; - } -} - -async function executeKill( - client: GatewayBrowserClient, - sessionKey: string, - args: string, -): Promise { - const target = args.trim(); - if (!target) { - return { content: "Usage: `/kill `" }; - } - try { - const sessions = await client.request("sessions.list", {}); - const matched = resolveKillTargets(sessions?.sessions ?? [], sessionKey, target); - if (matched.length === 0) { - return { - content: - target.toLowerCase() === "all" - ? "No active sub-agent sessions found." - : `No matching sub-agent sessions found for \`${target}\`.`, - }; - } - - const results = await Promise.allSettled( - matched.map((key) => client.request("chat.abort", { sessionKey: key })), - ); - const successCount = results.filter((entry) => entry.status === "fulfilled").length; - if (successCount === 0) { - const firstFailure = results.find((entry) => entry.status === "rejected"); - throw firstFailure?.reason ?? new Error("abort failed"); - } - - if (target.toLowerCase() === "all") { - return { - content: - successCount === matched.length - ? `Aborted ${successCount} sub-agent session${successCount === 1 ? "" : "s"}.` - : `Aborted ${successCount} of ${matched.length} sub-agent sessions.`, - }; - } - - return { - content: - successCount === matched.length - ? `Aborted ${successCount} matching sub-agent session${successCount === 1 ? "" : "s"} for \`${target}\`.` - : `Aborted ${successCount} of ${matched.length} matching sub-agent sessions for \`${target}\`.`, - }; - } catch (err) { - return { content: `Failed to abort: ${String(err)}` }; - } -} - -function resolveKillTargets( - sessions: GatewaySessionRow[], - currentSessionKey: string, - target: string, -): string[] { - const normalizedTarget = target.trim().toLowerCase(); - if (!normalizedTarget) { - return []; - } - - const keys = new Set(); - const currentParsed = parseAgentSessionKey(currentSessionKey); - for (const session of sessions) { - const key = session?.key?.trim(); - if (!key || !isSubagentSessionKey(key)) { - continue; - } - const normalizedKey = key.toLowerCase(); - const parsed = parseAgentSessionKey(normalizedKey); - const isMatch = - normalizedTarget === "all" || - normalizedKey === normalizedTarget || - (parsed?.agentId ?? "") === normalizedTarget || - normalizedKey.endsWith(`:subagent:${normalizedTarget}`) || - normalizedKey === `subagent:${normalizedTarget}` || - (currentParsed?.agentId != null && - parsed?.agentId === currentParsed.agentId && - normalizedKey.endsWith(`:subagent:${normalizedTarget}`)); - if (isMatch) { - keys.add(key); - } - } - return [...keys]; -} - -function fmtTokens(n: number): string { - if (n >= 1_000_000) { - return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; - } - if (n >= 1_000) { - return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; - } - return String(n); -} diff --git a/ui/src/ui/chat/slash-commands.ts b/ui/src/ui/chat/slash-commands.ts deleted file mode 100644 index d26a82e1544..00000000000 --- a/ui/src/ui/chat/slash-commands.ts +++ /dev/null @@ -1,217 +0,0 @@ -import type { IconName } from "../icons.ts"; - -export type SlashCommandCategory = "session" | "model" | "agents" | "tools"; - -export type SlashCommandDef = { - name: string; - description: string; - args?: string; - icon?: IconName; - category?: SlashCommandCategory; - /** When true, the command is executed client-side via RPC instead of sent to the agent. */ - executeLocal?: boolean; - /** Fixed argument choices for inline hints. */ - argOptions?: string[]; - /** Keyboard shortcut hint shown in the menu (display only). */ - shortcut?: string; -}; - -export const SLASH_COMMANDS: SlashCommandDef[] = [ - // ── Session ── - { - name: "new", - description: "Start a new session", - icon: "plus", - category: "session", - executeLocal: true, - }, - { - name: "reset", - description: "Reset current session", - icon: "refresh", - category: "session", - executeLocal: true, - }, - { - name: "compact", - description: "Compact session context", - icon: "loader", - category: "session", - executeLocal: true, - }, - { - name: "stop", - description: "Stop current run", - icon: "stop", - category: "session", - executeLocal: true, - }, - { - name: "clear", - description: "Clear chat history", - icon: "trash", - category: "session", - executeLocal: true, - }, - { - name: "focus", - description: "Toggle focus mode", - icon: "eye", - category: "session", - executeLocal: true, - }, - - // ── Model ── - { - name: "model", - description: "Show or set model", - args: "", - icon: "brain", - category: "model", - executeLocal: true, - }, - { - name: "think", - description: "Set thinking level", - args: "", - icon: "brain", - category: "model", - executeLocal: true, - argOptions: ["off", "low", "medium", "high"], - }, - { - name: "verbose", - description: "Toggle verbose mode", - args: "", - icon: "terminal", - category: "model", - executeLocal: true, - argOptions: ["on", "off", "full"], - }, - - // ── Tools ── - { - name: "help", - description: "Show available commands", - icon: "book", - category: "tools", - executeLocal: true, - }, - { - name: "status", - description: "Show system status", - icon: "barChart", - category: "tools", - executeLocal: true, - }, - { - name: "export", - description: "Export session to Markdown", - icon: "download", - category: "tools", - executeLocal: true, - }, - { - name: "usage", - description: "Show token usage", - icon: "barChart", - category: "tools", - executeLocal: true, - }, - - // ── Agents ── - { - name: "agents", - description: "List agents", - icon: "monitor", - category: "agents", - executeLocal: true, - }, - { - name: "kill", - description: "Abort sub-agents", - args: "", - icon: "x", - category: "agents", - executeLocal: true, - }, - { - name: "skill", - description: "Run a skill", - args: "", - icon: "zap", - category: "tools", - }, - { - name: "steer", - description: "Steer a sub-agent", - args: " ", - icon: "send", - category: "agents", - }, -]; - -const CATEGORY_ORDER: SlashCommandCategory[] = ["session", "model", "tools", "agents"]; - -export const CATEGORY_LABELS: Record = { - session: "Session", - model: "Model", - agents: "Agents", - tools: "Tools", -}; - -export function getSlashCommandCompletions(filter: string): SlashCommandDef[] { - const lower = filter.toLowerCase(); - const commands = lower - ? SLASH_COMMANDS.filter( - (cmd) => cmd.name.startsWith(lower) || cmd.description.toLowerCase().includes(lower), - ) - : SLASH_COMMANDS; - return commands.toSorted((a, b) => { - const ai = CATEGORY_ORDER.indexOf(a.category ?? "session"); - const bi = CATEGORY_ORDER.indexOf(b.category ?? "session"); - if (ai !== bi) { - return ai - bi; - } - // Exact prefix matches first - if (lower) { - const aExact = a.name.startsWith(lower) ? 0 : 1; - const bExact = b.name.startsWith(lower) ? 0 : 1; - if (aExact !== bExact) { - return aExact - bExact; - } - } - return 0; - }); -} - -export type ParsedSlashCommand = { - command: SlashCommandDef; - args: string; -}; - -/** - * Parse a message as a slash command. Returns null if it doesn't match. - * Supports `/command` and `/command args...`. - */ -export function parseSlashCommand(text: string): ParsedSlashCommand | null { - const trimmed = text.trim(); - if (!trimmed.startsWith("/")) { - return null; - } - - const spaceIdx = trimmed.indexOf(" "); - const name = spaceIdx === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIdx); - const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim(); - - if (!name) { - return null; - } - - const command = SLASH_COMMANDS.find((cmd) => cmd.name === name.toLowerCase()); - if (!command) { - return null; - } - - return { command, args }; -} diff --git a/ui/src/ui/chat/speech.ts b/ui/src/ui/chat/speech.ts deleted file mode 100644 index 4db4e6944a1..00000000000 --- a/ui/src/ui/chat/speech.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * Browser-native speech services: STT via SpeechRecognition, TTS via SpeechSynthesis. - * Falls back gracefully when APIs are unavailable. - */ - -// ─── STT (Speech-to-Text) ─── - -type SpeechRecognitionEvent = Event & { - results: SpeechRecognitionResultList; - resultIndex: number; -}; - -type SpeechRecognitionErrorEvent = Event & { - error: string; - message?: string; -}; - -interface SpeechRecognitionInstance extends EventTarget { - continuous: boolean; - interimResults: boolean; - lang: string; - start(): void; - stop(): void; - abort(): void; - onresult: ((event: SpeechRecognitionEvent) => void) | null; - onerror: ((event: SpeechRecognitionErrorEvent) => void) | null; - onend: (() => void) | null; - onstart: (() => void) | null; -} - -type SpeechRecognitionCtor = new () => SpeechRecognitionInstance; - -function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null { - const w = globalThis as Record; - return (w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null) as SpeechRecognitionCtor | null; -} - -export function isSttSupported(): boolean { - return getSpeechRecognitionCtor() !== null; -} - -export type SttCallbacks = { - onTranscript: (text: string, isFinal: boolean) => void; - onStart?: () => void; - onEnd?: () => void; - onError?: (error: string) => void; -}; - -let activeRecognition: SpeechRecognitionInstance | null = null; - -export function startStt(callbacks: SttCallbacks): boolean { - const Ctor = getSpeechRecognitionCtor(); - if (!Ctor) { - callbacks.onError?.("Speech recognition is not supported in this browser"); - return false; - } - - stopStt(); - - const recognition = new Ctor(); - recognition.continuous = true; - recognition.interimResults = true; - recognition.lang = navigator.language || "en-US"; - - recognition.addEventListener("start", () => callbacks.onStart?.()); - - recognition.addEventListener("result", (event) => { - const speechEvent = event as unknown as SpeechRecognitionEvent; - let interimTranscript = ""; - let finalTranscript = ""; - - for (let i = speechEvent.resultIndex; i < speechEvent.results.length; i++) { - const result = speechEvent.results[i]; - if (!result?.[0]) { - continue; - } - const transcript = result[0].transcript; - if (result.isFinal) { - finalTranscript += transcript; - } else { - interimTranscript += transcript; - } - } - - if (finalTranscript) { - callbacks.onTranscript(finalTranscript, true); - } else if (interimTranscript) { - callbacks.onTranscript(interimTranscript, false); - } - }); - - recognition.addEventListener("error", (event) => { - const speechEvent = event as unknown as SpeechRecognitionErrorEvent; - if (speechEvent.error === "aborted" || speechEvent.error === "no-speech") { - return; - } - callbacks.onError?.(speechEvent.error); - }); - - recognition.addEventListener("end", () => { - if (activeRecognition === recognition) { - activeRecognition = null; - } - callbacks.onEnd?.(); - }); - - activeRecognition = recognition; - recognition.start(); - return true; -} - -export function stopStt(): void { - if (activeRecognition) { - const r = activeRecognition; - activeRecognition = null; - try { - r.stop(); - } catch { - // already stopped - } - } -} - -export function isSttActive(): boolean { - return activeRecognition !== null; -} - -// ─── TTS (Text-to-Speech) ─── - -export function isTtsSupported(): boolean { - return "speechSynthesis" in globalThis; -} - -let currentUtterance: SpeechSynthesisUtterance | null = null; - -export function speakText( - text: string, - opts?: { - onStart?: () => void; - onEnd?: () => void; - onError?: (error: string) => void; - }, -): boolean { - if (!isTtsSupported()) { - opts?.onError?.("Speech synthesis is not supported in this browser"); - return false; - } - - stopTts(); - - const cleaned = stripMarkdown(text); - if (!cleaned.trim()) { - return false; - } - - const utterance = new SpeechSynthesisUtterance(cleaned); - utterance.rate = 1.0; - utterance.pitch = 1.0; - - utterance.addEventListener("start", () => opts?.onStart?.()); - utterance.addEventListener("end", () => { - if (currentUtterance === utterance) { - currentUtterance = null; - } - opts?.onEnd?.(); - }); - utterance.addEventListener("error", (e) => { - if (currentUtterance === utterance) { - currentUtterance = null; - } - if (e.error === "canceled" || e.error === "interrupted") { - return; - } - opts?.onError?.(e.error); - }); - - currentUtterance = utterance; - speechSynthesis.speak(utterance); - return true; -} - -export function stopTts(): void { - if (currentUtterance) { - currentUtterance = null; - } - if (isTtsSupported()) { - speechSynthesis.cancel(); - } -} - -export function isTtsSpeaking(): boolean { - return isTtsSupported() && speechSynthesis.speaking; -} - -/** Strip common markdown syntax for cleaner speech output. */ -function stripMarkdown(text: string): string { - return ( - text - // code blocks - .replace(/```[\s\S]*?```/g, "") - // inline code - .replace(/`[^`]+`/g, "") - // images - .replace(/!\[.*?\]\(.*?\)/g, "") - // links → keep text - .replace(/\[([^\]]+)\]\(.*?\)/g, "$1") - // headings - .replace(/^#{1,6}\s+/gm, "") - // bold/italic - .replace(/\*{1,3}(.*?)\*{1,3}/g, "$1") - .replace(/_{1,3}(.*?)_{1,3}/g, "$1") - // blockquotes - .replace(/^>\s?/gm, "") - // horizontal rules - .replace(/^[-*_]{3,}\s*$/gm, "") - // list markers - .replace(/^\s*[-*+]\s+/gm, "") - .replace(/^\s*\d+\.\s+/gm, "") - // HTML tags - .replace(/<[^>]+>/g, "") - // collapse whitespace - .replace(/\n{3,}/g, "\n\n") - .trim() - ); -} From 5decb00e9d2ae36c948e4cc83e42957e83108950 Mon Sep 17 00:00:00 2001 From: Neerav Makwana Date: Mon, 9 Mar 2026 21:42:54 -0400 Subject: [PATCH 0033/1173] fix(swiftformat): sync GatewayModels exclusions with OpenClawProtocol (#41242) Co-authored-by: Shadow --- .swiftformat | 2 +- .swiftlint.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.swiftformat b/.swiftformat index ab608a90178..a5f551b9e35 100644 --- a/.swiftformat +++ b/.swiftformat @@ -48,4 +48,4 @@ --allman false # Exclusions ---exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol,apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift +--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/OpenClawProtocol,apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index e4f925fdf20..567b1a1683a 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -18,7 +18,7 @@ excluded: - coverage - "*.playground" # Generated (protocol-gen-swift.ts) - - apps/macos/Sources/MoltbotProtocol/GatewayModels.swift + - apps/macos/Sources/OpenClawProtocol/GatewayModels.swift # Generated (generate-host-env-security-policy-swift.mjs) - apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift From 17201747579c27669ef0009c069d7cc9f9de7df0 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 9 Mar 2026 21:30:47 -0500 Subject: [PATCH 0034/1173] fix: auto-close no-ci PR label and document triage labels --- .github/workflows/auto-response.yml | 1 + AGENTS.md | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index a40149b7ccb..60e1707cf35 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -51,6 +51,7 @@ jobs: }, { label: "r: no-ci-pr", + close: true, message: "Please don't make PRs for test failures on main.\n\n" + "The team is aware of those and will handle them directly on the codebase, not only fixing the tests but also investigating what the root cause is. Having to sift through test-fix-PRs (including some that have been out of date for weeks...) on top of that doesn't help. There are already way too many PRs for humans to manage; please don't make the flood worse.\n\n" + diff --git a/AGENTS.md b/AGENTS.md index b70210cf8e3..1516f2e4f58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,23 @@ - GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search - Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries. +## Auto-close labels (issues and PRs) + +- If an issue/PR matches one of the reasons below, apply the label and let `.github/workflows/auto-response.yml` handle comment/close/lock. +- Do not manually close + manually comment for these reasons. +- Why: keeps wording consistent, preserves automation behavior (`state_reason`, locking), and keeps triage/reporting searchable by label. +- `r:*` labels can be used on both issues and PRs. + +- `r: skill`: close with guidance to publish skills on Clawhub. +- `r: support`: close with redirect to Discord support + stuck FAQ. +- `r: no-ci-pr`: close test-fix-only PRs for failing `main` CI and post the standard explanation. +- `r: too-many-prs`: close when author exceeds active PR limit. +- `r: testflight`: close requests asking for TestFlight access/builds. OpenClaw does not provide TestFlight distribution yet, so use the standard response (“Not available, build from source.”) instead of ad-hoc replies. +- `r: third-party-extension`: close with guidance to ship as third-party plugin. +- `r: moltbook`: close + lock as off-topic (not affiliated). +- `invalid`: close invalid items (issues are closed as `not_planned`; PRs are closed). +- `dirty`: close PRs with too many unrelated/unexpected changes (PR-only label). + ## Project Structure & Module Organization - Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`). From 25c2facc2b93432a597b98da7db5a3ebdcb6ce2a Mon Sep 17 00:00:00 2001 From: Zhe Liu <770120041@qq.com> Date: Mon, 9 Mar 2026 22:39:57 -0400 Subject: [PATCH 0035/1173] fix(agents): fix Brave llm-context empty snippets (#41387) Merged via squash. Prepared head SHA: 1e6f1d9d51607a115e4bf912f53149a26a5cdd82 Co-authored-by: zheliu2 <15888718+zheliu2@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + src/agents/tools/web-search.test.ts | 75 +++++++++++++++++++ src/agents/tools/web-search.ts | 24 +++--- .../tools/web-tools.enabled-defaults.test.ts | 2 +- 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce8a07061ec..7b42bac2703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk. - Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu. - CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth. +- Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2. ## 2026.3.8 diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 4a7b002d784..b8bccd7dfd3 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -23,6 +23,7 @@ const { resolveKimiBaseUrl, extractKimiCitations, resolveBraveMode, + mapBraveLlmContextResults, } = __testing; const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_"); @@ -393,3 +394,77 @@ describe("resolveBraveMode", () => { expect(resolveBraveMode({ mode: "invalid" })).toBe("web"); }); }); + +describe("mapBraveLlmContextResults", () => { + it("maps plain string snippets correctly", () => { + const results = mapBraveLlmContextResults({ + grounding: { + generic: [ + { + url: "https://example.com/page", + title: "Example Page", + snippets: ["first snippet", "second snippet"], + }, + ], + }, + }); + expect(results).toEqual([ + { + url: "https://example.com/page", + title: "Example Page", + snippets: ["first snippet", "second snippet"], + siteName: "example.com", + }, + ]); + }); + + it("filters out non-string and empty snippets", () => { + const results = mapBraveLlmContextResults({ + grounding: { + generic: [ + { + url: "https://example.com", + title: "Test", + snippets: ["valid", "", null, undefined, 42, { text: "object" }] as string[], + }, + ], + }, + }); + expect(results[0].snippets).toEqual(["valid"]); + }); + + it("handles missing snippets array", () => { + const results = mapBraveLlmContextResults({ + grounding: { + generic: [{ url: "https://example.com", title: "No Snippets" } as never], + }, + }); + expect(results[0].snippets).toEqual([]); + }); + + it("handles empty grounding.generic", () => { + expect(mapBraveLlmContextResults({ grounding: { generic: [] } })).toEqual([]); + }); + + it("handles missing grounding.generic", () => { + expect(mapBraveLlmContextResults({ grounding: {} } as never)).toEqual([]); + }); + + it("resolves siteName from URL hostname", () => { + const results = mapBraveLlmContextResults({ + grounding: { + generic: [{ url: "https://docs.example.org/path", title: "Docs", snippets: ["text"] }], + }, + }); + expect(results[0].siteName).toBe("docs.example.org"); + }); + + it("sets siteName to undefined for invalid URLs", () => { + const results = mapBraveLlmContextResults({ + grounding: { + generic: [{ url: "not-a-url", title: "Bad URL", snippets: ["text"] }], + }, + }); + expect(results[0].siteName).toBeUndefined(); + }); +}); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 47c5a5abc94..d4f88caea61 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -272,8 +272,7 @@ type BraveSearchResponse = { }; }; -type BraveLlmContextSnippet = { text: string }; -type BraveLlmContextResult = { url: string; title: string; snippets: BraveLlmContextSnippet[] }; +type BraveLlmContextResult = { url: string; title: string; snippets: string[] }; type BraveLlmContextResponse = { grounding: { generic?: BraveLlmContextResult[] }; sources?: { url?: string; hostname?: string; date?: string }[]; @@ -1429,6 +1428,18 @@ async function runKimiSearch(params: { }; } +function mapBraveLlmContextResults( + data: BraveLlmContextResponse, +): { url: string; title: string; snippets: string[]; siteName?: string }[] { + const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : []; + return genericResults.map((entry) => ({ + url: entry.url ?? "", + title: entry.title ?? "", + snippets: (entry.snippets ?? []).filter((s) => typeof s === "string" && s.length > 0), + siteName: resolveSiteName(entry.url) || undefined, + })); +} + async function runBraveLlmContextSearch(params: { query: string; apiKey: string; @@ -1477,13 +1488,7 @@ async function runBraveLlmContextSearch(params: { } const data = (await res.json()) as BraveLlmContextResponse; - const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : []; - const mapped = genericResults.map((entry) => ({ - url: entry.url ?? "", - title: entry.title ?? "", - snippets: (entry.snippets ?? []).map((s) => s.text ?? "").filter(Boolean), - siteName: resolveSiteName(entry.url) || undefined, - })); + const mapped = mapBraveLlmContextResults(data); return { results: mapped, sources: data.sources }; }, @@ -2122,4 +2127,5 @@ export const __testing = { extractKimiCitations, resolveRedirectUrl: resolveCitationRedirectUrl, resolveBraveMode, + mapBraveLlmContextResults, } as const; diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index 54485908b8b..80dcd6a025d 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -694,7 +694,7 @@ describe("web_search external content wrapping", () => { const mockFetch = installBraveLlmContextFetch({ title: "Context title", url: "https://example.com/ctx", - snippets: [{ text: "Context chunk one" }, { text: "Context chunk two" }], + snippets: ["Context chunk one", "Context chunk two"], }); const tool = createWebSearchTool({ From 9432a8bb3f42f50ed7e9988388c1b120ed63a680 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 10 Mar 2026 08:14:25 +0530 Subject: [PATCH 0036/1173] test: allowlist detect-secrets fixture strings --- src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index f60a127a0ab..3500df63876 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -32,7 +32,7 @@ const OPENROUTER_CREDITS_MESSAGE = "Payment Required: insufficient credits"; // Issue-backed Anthropic/OpenAI-compatible insufficient_quota payload under HTTP 400: // https://github.com/openclaw/openclaw/issues/23440 const INSUFFICIENT_QUOTA_PAYLOAD = - '{"type":"error","error":{"type":"insufficient_quota","message":"Your account has insufficient quota balance to run this request."}}'; + '{"type":"error","error":{"type":"insufficient_quota","message":"Your account has insufficient quota balance to run this request."}}'; // pragma: allowlist secret // Together AI error code examples: https://docs.together.ai/docs/error-codes const TOGETHER_PAYMENT_REQUIRED_MESSAGE = "402 Payment Required: The account associated with this API key has reached its maximum allowed monthly spending limit."; @@ -42,7 +42,7 @@ const TOGETHER_ENGINE_OVERLOADED_MESSAGE = const GROQ_TOO_MANY_REQUESTS_MESSAGE = "429 Too Many Requests: Too many requests were sent in a given timeframe."; const GROQ_SERVICE_UNAVAILABLE_MESSAGE = - "503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance."; + "503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance."; // pragma: allowlist secret describe("isAuthPermanentErrorMessage", () => { it("matches permanent auth failure patterns", () => { From de49a8b72c12e89170f36143ac30aaa4e938aafc Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Mon, 9 Mar 2026 23:04:35 -0400 Subject: [PATCH 0037/1173] Telegram: exec approvals for OpenCode/Codex (#37233) Merged via squash. Prepared head SHA: f2433790941841ade0efe6292ff4909b2edd6f18 Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> Reviewed-by: @huntharo --- CHANGELOG.md | 1 + docs/channels/telegram.md | 43 +- docs/tools/exec-approvals.md | 26 ++ extensions/telegram/src/channel.test.ts | 35 ++ extensions/telegram/src/channel.ts | 60 +++ .../bash-tools.exec-approval-followup.ts | 61 +++ src/agents/bash-tools.exec-host-gateway.ts | 146 ++++-- src/agents/bash-tools.exec-host-node.ts | 182 ++++++-- src/agents/bash-tools.exec-runtime.ts | 34 ++ src/agents/bash-tools.exec-types.ts | 15 + .../bash-tools.exec.approval-id.test.ts | 303 ++++++++++++- src/agents/pi-embedded-runner/run.ts | 3 + src/agents/pi-embedded-runner/run/attempt.ts | 2 + src/agents/pi-embedded-runner/run/params.ts | 3 +- .../pi-embedded-runner/run/payloads.test.ts | 9 + src/agents/pi-embedded-runner/run/payloads.ts | 36 +- src/agents/pi-embedded-runner/run/types.ts | 1 + ...pi-embedded-subscribe.handlers.messages.ts | 6 + ...ded-subscribe.handlers.tools.media.test.ts | 1 + ...-embedded-subscribe.handlers.tools.test.ts | 156 +++++++ .../pi-embedded-subscribe.handlers.tools.ts | 122 ++++- .../pi-embedded-subscribe.handlers.types.ts | 2 + src/agents/pi-embedded-subscribe.ts | 3 + src/agents/pi-embedded-subscribe.types.ts | 3 +- .../pi-tool-handler-state.test-helpers.ts | 1 + src/agents/system-prompt.ts | 3 + .../reply/agent-runner-execution.ts | 2 +- .../reply/agent-runner-utils.test.ts | 41 ++ src/auto-reply/reply/agent-runner-utils.ts | 16 +- .../agent-runner.runreplyagent.e2e.test.ts | 37 +- src/auto-reply/reply/commands-approve.ts | 38 +- src/auto-reply/reply/commands-context.ts | 1 + src/auto-reply/reply/commands.test.ts | 362 +++++---------- .../reply/dispatch-from-config.test.ts | 130 ++++++ src/auto-reply/reply/dispatch-from-config.ts | 20 + src/auto-reply/templating.ts | 2 + src/channels/plugins/outbound/telegram.ts | 1 - src/config/schema.help.quality.test.ts | 6 + src/config/schema.help.ts | 12 + src/config/schema.labels.ts | 6 + src/config/types.telegram.ts | 16 + src/config/zod-schema.providers-core.ts | 11 + src/discord/exec-approvals.ts | 23 + src/discord/monitor/exec-approvals.test.ts | 127 ++++-- src/discord/monitor/exec-approvals.ts | 41 +- src/gateway/exec-approval-manager.ts | 38 ++ .../node-invoke-system-run-approval.ts | 2 + src/gateway/server-methods/exec-approval.ts | 65 ++- .../server-methods/server-methods.test.ts | 218 +++++++-- src/gateway/server-node-events.test.ts | 17 + src/gateway/server-node-events.ts | 3 + src/infra/exec-approval-forwarder.test.ts | 132 +++++- src/infra/exec-approval-forwarder.ts | 174 ++++++-- src/infra/exec-approval-reply.ts | 172 +++++++ src/infra/exec-approval-surface.ts | 77 ++++ src/infra/outbound/deliver.test.ts | 69 +++ src/infra/outbound/deliver.ts | 20 +- src/node-host/invoke-system-run.ts | 9 +- src/node-host/invoke-types.ts | 3 + src/node-host/invoke.ts | 1 + src/telegram/approval-buttons.test.ts | 18 + src/telegram/approval-buttons.ts | 42 ++ src/telegram/bot-handlers.ts | 92 +++- src/telegram/bot-message-context.session.ts | 1 + src/telegram/bot-message-dispatch.test.ts | 45 +- src/telegram/bot-message-dispatch.ts | 16 +- .../bot-native-commands.session-meta.test.ts | 103 ++++- src/telegram/bot-native-commands.ts | 32 +- .../bot.create-telegram-bot.test-harness.ts | 5 + src/telegram/bot.test.ts | 175 +++++++- src/telegram/exec-approvals-handler.test.ts | 156 +++++++ src/telegram/exec-approvals-handler.ts | 418 ++++++++++++++++++ src/telegram/exec-approvals.test.ts | 92 ++++ src/telegram/exec-approvals.ts | 106 +++++ src/telegram/monitor.ts | 11 + src/telegram/send.test-harness.ts | 1 + src/telegram/send.test.ts | 20 + src/telegram/send.ts | 100 ++++- 78 files changed, 4058 insertions(+), 524 deletions(-) create mode 100644 src/agents/bash-tools.exec-approval-followup.ts create mode 100644 src/discord/exec-approvals.ts create mode 100644 src/infra/exec-approval-reply.ts create mode 100644 src/infra/exec-approval-surface.ts create mode 100644 src/telegram/approval-buttons.test.ts create mode 100644 src/telegram/approval-buttons.ts create mode 100644 src/telegram/exec-approvals-handler.test.ts create mode 100644 src/telegram/exec-approvals-handler.ts create mode 100644 src/telegram/exec-approvals.test.ts create mode 100644 src/telegram/exec-approvals.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b42bac2703..1e5273a8df5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai - Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu. - CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth. - Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2. +- Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo. ## 2026.3.8 diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index f49ea5fe3f7..a039cb43483 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -760,6 +760,34 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \ - `channels.telegram.actions.poll=false` disables Telegram poll creation while leaving regular sends enabled + + + Telegram supports exec approvals in approver DMs and can optionally post approval prompts in the originating chat or topic. + + Config path: + + - `channels.telegram.execApprovals.enabled` + - `channels.telegram.execApprovals.approvers` + - `channels.telegram.execApprovals.target` (`dm` | `channel` | `both`, default: `dm`) + - `agentFilter`, `sessionFilter` + + Approvers must be numeric Telegram user IDs. When `enabled` is false or `approvers` is empty, Telegram does not act as an exec approval client. Approval requests fall back to other configured approval routes or the exec approval fallback policy. + + Delivery rules: + + - `target: "dm"` sends approval prompts only to configured approver DMs + - `target: "channel"` sends the prompt back to the originating Telegram chat/topic + - `target: "both"` sends to approver DMs and the originating chat/topic + + Only configured approvers can approve or deny. Non-approvers cannot use `/approve` and cannot use Telegram approval buttons. + + Channel delivery shows the command text in the chat, so only enable `channel` or `both` in trusted groups/topics. When the prompt lands in a forum topic, OpenClaw preserves the topic for both the approval prompt and the post-approval follow-up. + + Inline approval buttons also depend on `channels.telegram.capabilities.inlineButtons` allowing the target surface (`dm`, `group`, or `all`). + + Related docs: [Exec approvals](/tools/exec-approvals) + + ## Troubleshooting @@ -859,10 +887,16 @@ Primary reference: - `channels.telegram.groups..enabled`: disable the group when `false`. - `channels.telegram.groups..topics..*`: per-topic overrides (group fields + topic-only `agentId`). - `channels.telegram.groups..topics..agentId`: route this topic to a specific agent (overrides group-level and binding routing). - - `channels.telegram.groups..topics..groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`). - - `channels.telegram.groups..topics..requireMention`: per-topic mention gating override. - - top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)). - - `channels.telegram.direct..topics..agentId`: route DM topics to a specific agent (same behavior as forum topics). +- `channels.telegram.groups..topics..groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`). +- `channels.telegram.groups..topics..requireMention`: per-topic mention gating override. +- top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)). +- `channels.telegram.direct..topics..agentId`: route DM topics to a specific agent (same behavior as forum topics). +- `channels.telegram.execApprovals.enabled`: enable Telegram as a chat-based exec approval client for this account. +- `channels.telegram.execApprovals.approvers`: Telegram user IDs allowed to approve or deny exec requests. Required when exec approvals are enabled. +- `channels.telegram.execApprovals.target`: `dm | channel | both` (default: `dm`). `channel` and `both` preserve the originating Telegram topic when present. +- `channels.telegram.execApprovals.agentFilter`: optional agent ID filter for forwarded approval prompts. +- `channels.telegram.execApprovals.sessionFilter`: optional session key filter (substring or regex) for forwarded approval prompts. +- `channels.telegram.accounts..execApprovals`: per-account override for Telegram exec approval routing and approver authorization. - `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist). - `channels.telegram.accounts..capabilities.inlineButtons`: per-account override. - `channels.telegram.commands.nativeSkills`: enable/disable Telegram native skills commands. @@ -894,6 +928,7 @@ Telegram-specific high-signal fields: - startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*` - access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`, top-level `bindings[]` (`type: "acp"`) +- exec approvals: `execApprovals`, `accounts.*.execApprovals` - command/menu: `commands.native`, `commands.nativeSkills`, `customCommands` - threading/replies: `replyToMode` - streaming: `streaming` (preview), `blockStreaming` diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index d538e411093..91fdff80650 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -309,6 +309,32 @@ Reply in chat: /approve deny ``` +### Built-in chat approval clients + +Discord and Telegram can also act as explicit exec approval clients with channel-specific config. + +- Discord: `channels.discord.execApprovals.*` +- Telegram: `channels.telegram.execApprovals.*` + +These clients are opt-in. If a channel does not have exec approvals enabled, OpenClaw does not treat +that channel as an approval surface just because the conversation happened there. + +Shared behavior: + +- only configured approvers can approve or deny +- the requester does not need to be an approver +- when channel delivery is enabled, approval prompts include the command text +- if no operator UI or configured approval client can accept the request, the prompt falls back to `askFallback` + +Telegram defaults to approver DMs (`target: "dm"`). You can switch to `channel` or `both` when you +want approval prompts to appear in the originating Telegram chat/topic as well. For Telegram forum +topics, OpenClaw preserves the topic for the approval prompt and the post-approval follow-up. + +See: + +- [Discord](/channels/discord#exec-approvals-in-discord) +- [Telegram](/channels/telegram#exec-approvals-in-telegram) + ### macOS IPC flow ``` diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index 1f40a5f1cce..c1912db56f0 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -179,6 +179,41 @@ describe("telegramPlugin duplicate token guard", () => { expect(result).toMatchObject({ channel: "telegram", messageId: "tg-1" }); }); + it("preserves buttons for outbound text payload sends", async () => { + const sendMessageTelegram = vi.fn(async () => ({ messageId: "tg-2" })); + setTelegramRuntime({ + channel: { + telegram: { + sendMessageTelegram, + }, + }, + } as unknown as PluginRuntime); + + const result = await telegramPlugin.outbound!.sendPayload!({ + cfg: createCfg(), + to: "12345", + text: "", + payload: { + text: "Approval required", + channelData: { + telegram: { + buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]], + }, + }, + }, + accountId: "ops", + }); + + expect(sendMessageTelegram).toHaveBeenCalledWith( + "12345", + "Approval required", + expect.objectContaining({ + buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]], + }), + ); + expect(result).toMatchObject({ channel: "telegram", messageId: "tg-2" }); + }); + it("ignores accounts with missing tokens during duplicate-token checks", async () => { const cfg = createCfg(); cfg.channels!.telegram!.accounts!.ops = {} as never; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 0f4721a4d62..7ea0a7a6525 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -91,6 +91,10 @@ const telegramMessageActions: ChannelMessageActionAdapter = { }, }; +type TelegramInlineButtons = ReadonlyArray< + ReadonlyArray<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }> +>; + const telegramConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, @@ -317,6 +321,62 @@ export const telegramPlugin: ChannelPlugin { + const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; + const replyToMessageId = parseTelegramReplyToMessageId(replyToId); + const messageThreadId = parseTelegramThreadId(threadId); + const telegramData = payload.channelData?.telegram as + | { buttons?: TelegramInlineButtons; quoteText?: string } + | undefined; + const quoteText = + typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; + const text = payload.text ?? ""; + const mediaUrls = payload.mediaUrls?.length + ? payload.mediaUrls + : payload.mediaUrl + ? [payload.mediaUrl] + : []; + const baseOpts = { + verbose: false, + cfg, + mediaLocalRoots, + messageThreadId, + replyToMessageId, + quoteText, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + }; + + if (mediaUrls.length === 0) { + const result = await send(to, text, { + ...baseOpts, + buttons: telegramData?.buttons, + }); + return { channel: "telegram", ...result }; + } + + let finalResult: Awaited> | undefined; + for (let i = 0; i < mediaUrls.length; i += 1) { + const mediaUrl = mediaUrls[i]; + const isFirst = i === 0; + finalResult = await send(to, isFirst ? text : "", { + ...baseOpts, + mediaUrl, + ...(isFirst ? { buttons: telegramData?.buttons } : {}), + }); + } + return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) }; + }, sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => { const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; const replyToMessageId = parseTelegramReplyToMessageId(replyToId); diff --git a/src/agents/bash-tools.exec-approval-followup.ts b/src/agents/bash-tools.exec-approval-followup.ts new file mode 100644 index 00000000000..af24f07fb50 --- /dev/null +++ b/src/agents/bash-tools.exec-approval-followup.ts @@ -0,0 +1,61 @@ +import { callGatewayTool } from "./tools/gateway.js"; + +type ExecApprovalFollowupParams = { + approvalId: string; + sessionKey?: string; + turnSourceChannel?: string; + turnSourceTo?: string; + turnSourceAccountId?: string; + turnSourceThreadId?: string | number; + resultText: string; +}; + +export function buildExecApprovalFollowupPrompt(resultText: string): string { + return [ + "An async command the user already approved has completed.", + "Do not run the command again.", + "", + "Exact completion details:", + resultText.trim(), + "", + "Reply to the user in a helpful way.", + "If it succeeded, share the relevant output.", + "If it failed, explain what went wrong.", + ].join("\n"); +} + +export async function sendExecApprovalFollowup( + params: ExecApprovalFollowupParams, +): Promise { + const sessionKey = params.sessionKey?.trim(); + const resultText = params.resultText.trim(); + if (!sessionKey || !resultText) { + return false; + } + + const channel = params.turnSourceChannel?.trim(); + const to = params.turnSourceTo?.trim(); + const threadId = + params.turnSourceThreadId != null && params.turnSourceThreadId !== "" + ? String(params.turnSourceThreadId) + : undefined; + + await callGatewayTool( + "agent", + { timeoutMs: 60_000 }, + { + sessionKey, + message: buildExecApprovalFollowupPrompt(resultText), + deliver: true, + bestEffortDeliver: true, + channel: channel && to ? channel : undefined, + to: channel && to ? to : undefined, + accountId: channel && to ? params.turnSourceAccountId?.trim() || undefined : undefined, + threadId: channel && to ? threadId : undefined, + idempotencyKey: `exec-approval-followup:${params.approvalId}`, + }, + { expectFinal: true }, + ); + + return true; +} diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index 49a958c9c5b..6b43fbe8663 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -1,4 +1,10 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { loadConfig } from "../config/config.js"; +import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js"; +import { + hasConfiguredExecApprovalDmRoute, + resolveExecApprovalInitiatingSurfaceState, +} from "../infra/exec-approval-surface.js"; import { addAllowlistEntry, type ExecAsk, @@ -13,6 +19,7 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js"; import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js"; import { logInfo } from "../logger.js"; import { markBackgrounded, tail } from "./bash-process-registry.js"; +import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js"; import { buildExecApprovalRequesterContext, buildExecApprovalTurnSourceContext, @@ -25,9 +32,9 @@ import { resolveExecHostApprovalContext, } from "./bash-tools.exec-host-shared.js"; import { + buildApprovalPendingMessage, DEFAULT_NOTIFY_TAIL_CHARS, createApprovalSlug, - emitExecSystemEvent, normalizeNotifyOutput, runExecProcess, } from "./bash-tools.exec-runtime.js"; @@ -141,8 +148,6 @@ export async function processGatewayAllowlist( const { approvalId, approvalSlug, - contextKey, - noticeSeconds, warningText, expiresAtMs: defaultExpiresAtMs, preResolvedDecision: defaultPreResolvedDecision, @@ -174,19 +179,37 @@ export async function processGatewayAllowlist( }); expiresAtMs = registration.expiresAtMs; preResolvedDecision = registration.finalDecision; + const initiatingSurface = resolveExecApprovalInitiatingSurfaceState({ + channel: params.turnSourceChannel, + accountId: params.turnSourceAccountId, + }); + const cfg = loadConfig(); + const sentApproverDms = + (initiatingSurface.kind === "disabled" || initiatingSurface.kind === "unsupported") && + hasConfiguredExecApprovalDmRoute(cfg); + const unavailableReason = + preResolvedDecision === null + ? "no-approval-route" + : initiatingSurface.kind === "disabled" + ? "initiating-platform-disabled" + : initiatingSurface.kind === "unsupported" + ? "initiating-platform-unsupported" + : null; void (async () => { const decision = await resolveApprovalDecisionOrUndefined({ approvalId, preResolvedDecision, onFailure: () => - emitExecSystemEvent( - `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`, - { - sessionKey: params.notifySessionKey, - contextKey, - }, - ), + void sendExecApprovalFollowup({ + approvalId, + sessionKey: params.notifySessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + resultText: `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`, + }), }); if (decision === undefined) { return; @@ -230,13 +253,15 @@ export async function processGatewayAllowlist( } if (deniedReason) { - emitExecSystemEvent( - `Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`, - { - sessionKey: params.notifySessionKey, - contextKey, - }, - ); + await sendExecApprovalFollowup({ + approvalId, + sessionKey: params.notifySessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + resultText: `Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`, + }).catch(() => {}); return; } @@ -262,32 +287,21 @@ export async function processGatewayAllowlist( timeoutSec: effectiveTimeout, }); } catch { - emitExecSystemEvent( - `Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`, - { - sessionKey: params.notifySessionKey, - contextKey, - }, - ); + await sendExecApprovalFollowup({ + approvalId, + sessionKey: params.notifySessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + resultText: `Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`, + }).catch(() => {}); return; } markBackgrounded(run.session); - let runningTimer: NodeJS.Timeout | null = null; - if (params.approvalRunningNoticeMs > 0) { - runningTimer = setTimeout(() => { - emitExecSystemEvent( - `Exec running (gateway id=${approvalId}, session=${run?.session.id}, >${noticeSeconds}s): ${params.command}`, - { sessionKey: params.notifySessionKey, contextKey }, - ); - }, params.approvalRunningNoticeMs); - } - const outcome = await run.promise; - if (runningTimer) { - clearTimeout(runningTimer); - } const output = normalizeNotifyOutput( tail(outcome.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS), ); @@ -295,7 +309,15 @@ export async function processGatewayAllowlist( const summary = output ? `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})\n${output}` : `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})`; - emitExecSystemEvent(summary, { sessionKey: params.notifySessionKey, contextKey }); + await sendExecApprovalFollowup({ + approvalId, + sessionKey: params.notifySessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + resultText: summary, + }).catch(() => {}); })(); return { @@ -304,19 +326,45 @@ export async function processGatewayAllowlist( { type: "text", text: - `${warningText}Approval required (id ${approvalSlug}). ` + - "Approve to run; updates will arrive after completion.", + unavailableReason !== null + ? (buildExecApprovalUnavailableReplyPayload({ + warningText, + reason: unavailableReason, + channelLabel: initiatingSurface.channelLabel, + sentApproverDms, + }).text ?? "") + : buildApprovalPendingMessage({ + warningText, + approvalSlug, + approvalId, + command: params.command, + cwd: params.workdir, + host: "gateway", + }), }, ], - details: { - status: "approval-pending", - approvalId, - approvalSlug, - expiresAtMs, - host: "gateway", - command: params.command, - cwd: params.workdir, - }, + details: + unavailableReason !== null + ? ({ + status: "approval-unavailable", + reason: unavailableReason, + channelLabel: initiatingSurface.channelLabel, + sentApproverDms, + host: "gateway", + command: params.command, + cwd: params.workdir, + warningText, + } satisfies ExecToolDetails) + : ({ + status: "approval-pending", + approvalId, + approvalSlug, + expiresAtMs, + host: "gateway", + command: params.command, + cwd: params.workdir, + warningText, + } satisfies ExecToolDetails), }, }; } diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index b66a6ededf1..97eb4218035 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -1,5 +1,11 @@ import crypto from "node:crypto"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { loadConfig } from "../config/config.js"; +import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js"; +import { + hasConfiguredExecApprovalDmRoute, + resolveExecApprovalInitiatingSurfaceState, +} from "../infra/exec-approval-surface.js"; import { type ExecApprovalsFile, type ExecAsk, @@ -12,6 +18,7 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js"; import { buildNodeShellCommand } from "../infra/node-shell.js"; import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-context.js"; import { logInfo } from "../logger.js"; +import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js"; import { buildExecApprovalRequesterContext, buildExecApprovalTurnSourceContext, @@ -23,7 +30,12 @@ import { resolveApprovalDecisionOrUndefined, resolveExecHostApprovalContext, } from "./bash-tools.exec-host-shared.js"; -import { createApprovalSlug, emitExecSystemEvent } from "./bash-tools.exec-runtime.js"; +import { + buildApprovalPendingMessage, + DEFAULT_NOTIFY_TAIL_CHARS, + createApprovalSlug, + normalizeNotifyOutput, +} from "./bash-tools.exec-runtime.js"; import type { ExecToolDetails } from "./bash-tools.exec-types.js"; import { callGatewayTool } from "./tools/gateway.js"; import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js"; @@ -187,6 +199,7 @@ export async function executeNodeHostCommand( approvedByAsk: boolean, approvalDecision: "allow-once" | "allow-always" | null, runId?: string, + suppressNotifyOnExit?: boolean, ) => ({ nodeId, @@ -202,6 +215,7 @@ export async function executeNodeHostCommand( approved: approvedByAsk, approvalDecision: approvalDecision ?? undefined, runId: runId ?? undefined, + suppressNotifyOnExit: suppressNotifyOnExit === true ? true : undefined, }, idempotencyKey: crypto.randomUUID(), }) satisfies Record; @@ -210,8 +224,6 @@ export async function executeNodeHostCommand( const { approvalId, approvalSlug, - contextKey, - noticeSeconds, warningText, expiresAtMs: defaultExpiresAtMs, preResolvedDecision: defaultPreResolvedDecision, @@ -243,16 +255,37 @@ export async function executeNodeHostCommand( }); expiresAtMs = registration.expiresAtMs; preResolvedDecision = registration.finalDecision; + const initiatingSurface = resolveExecApprovalInitiatingSurfaceState({ + channel: params.turnSourceChannel, + accountId: params.turnSourceAccountId, + }); + const cfg = loadConfig(); + const sentApproverDms = + (initiatingSurface.kind === "disabled" || initiatingSurface.kind === "unsupported") && + hasConfiguredExecApprovalDmRoute(cfg); + const unavailableReason = + preResolvedDecision === null + ? "no-approval-route" + : initiatingSurface.kind === "disabled" + ? "initiating-platform-disabled" + : initiatingSurface.kind === "unsupported" + ? "initiating-platform-unsupported" + : null; void (async () => { const decision = await resolveApprovalDecisionOrUndefined({ approvalId, preResolvedDecision, onFailure: () => - emitExecSystemEvent( - `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`, - { sessionKey: params.notifySessionKey, contextKey }, - ), + void sendExecApprovalFollowup({ + approvalId, + sessionKey: params.notifySessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + resultText: `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`, + }), }); if (decision === undefined) { return; @@ -278,44 +311,67 @@ export async function executeNodeHostCommand( } if (deniedReason) { - emitExecSystemEvent( - `Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`, - { - sessionKey: params.notifySessionKey, - contextKey, - }, - ); + await sendExecApprovalFollowup({ + approvalId, + sessionKey: params.notifySessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + resultText: `Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`, + }).catch(() => {}); return; } - let runningTimer: NodeJS.Timeout | null = null; - if (params.approvalRunningNoticeMs > 0) { - runningTimer = setTimeout(() => { - emitExecSystemEvent( - `Exec running (node=${nodeId} id=${approvalId}, >${noticeSeconds}s): ${params.command}`, - { sessionKey: params.notifySessionKey, contextKey }, - ); - }, params.approvalRunningNoticeMs); - } - try { - await callGatewayTool( + const raw = await callGatewayTool<{ + payload?: { + stdout?: string; + stderr?: string; + error?: string | null; + exitCode?: number | null; + timedOut?: boolean; + }; + }>( "node.invoke", { timeoutMs: invokeTimeoutMs }, - buildInvokeParams(approvedByAsk, approvalDecision, approvalId), + buildInvokeParams(approvedByAsk, approvalDecision, approvalId, true), ); + const payload = + raw?.payload && typeof raw.payload === "object" + ? (raw.payload as { + stdout?: string; + stderr?: string; + error?: string | null; + exitCode?: number | null; + timedOut?: boolean; + }) + : {}; + const combined = [payload.stdout, payload.stderr, payload.error].filter(Boolean).join("\n"); + const output = normalizeNotifyOutput(combined.slice(-DEFAULT_NOTIFY_TAIL_CHARS)); + const exitLabel = payload.timedOut ? "timeout" : `code ${payload.exitCode ?? "?"}`; + const summary = output + ? `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})\n${output}` + : `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})`; + await sendExecApprovalFollowup({ + approvalId, + sessionKey: params.notifySessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + resultText: summary, + }).catch(() => {}); } catch { - emitExecSystemEvent( - `Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`, - { - sessionKey: params.notifySessionKey, - contextKey, - }, - ); - } finally { - if (runningTimer) { - clearTimeout(runningTimer); - } + await sendExecApprovalFollowup({ + approvalId, + sessionKey: params.notifySessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + resultText: `Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`, + }).catch(() => {}); } })(); @@ -324,20 +380,48 @@ export async function executeNodeHostCommand( { type: "text", text: - `${warningText}Approval required (id ${approvalSlug}). ` + - "Approve to run; updates will arrive after completion.", + unavailableReason !== null + ? (buildExecApprovalUnavailableReplyPayload({ + warningText, + reason: unavailableReason, + channelLabel: initiatingSurface.channelLabel, + sentApproverDms, + }).text ?? "") + : buildApprovalPendingMessage({ + warningText, + approvalSlug, + approvalId, + command: prepared.cmdText, + cwd: runCwd, + host: "node", + nodeId, + }), }, ], - details: { - status: "approval-pending", - approvalId, - approvalSlug, - expiresAtMs, - host: "node", - command: params.command, - cwd: params.workdir, - nodeId, - }, + details: + unavailableReason !== null + ? ({ + status: "approval-unavailable", + reason: unavailableReason, + channelLabel: initiatingSurface.channelLabel, + sentApproverDms, + host: "node", + command: params.command, + cwd: params.workdir, + nodeId, + warningText, + } satisfies ExecToolDetails) + : ({ + status: "approval-pending", + approvalId, + approvalSlug, + expiresAtMs, + host: "node", + command: params.command, + cwd: params.workdir, + nodeId, + warningText, + } satisfies ExecToolDetails), }; } diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 9714e4255ee..5c3301414b9 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -230,6 +230,40 @@ export function createApprovalSlug(id: string) { return id.slice(0, APPROVAL_SLUG_LENGTH); } +export function buildApprovalPendingMessage(params: { + warningText?: string; + approvalSlug: string; + approvalId: string; + command: string; + cwd: string; + host: "gateway" | "node"; + nodeId?: string; +}) { + let fence = "```"; + while (params.command.includes(fence)) { + fence += "`"; + } + const commandBlock = `${fence}sh\n${params.command}\n${fence}`; + const lines: string[] = []; + const warningText = params.warningText?.trim(); + if (warningText) { + lines.push(warningText, ""); + } + lines.push(`Approval required (id ${params.approvalSlug}, full ${params.approvalId}).`); + lines.push(`Host: ${params.host}`); + if (params.nodeId) { + lines.push(`Node: ${params.nodeId}`); + } + lines.push(`CWD: ${params.cwd}`); + lines.push("Command:"); + lines.push(commandBlock); + lines.push("Mode: foreground (interactive approvals available)."); + lines.push("Background mode requires pre-approved policy (allow-always or ask=off)."); + lines.push(`Reply with: /approve ${params.approvalSlug} allow-once|allow-always|deny`); + lines.push("If the short code is ambiguous, use the full id in /approve."); + return lines.join("\n"); +} + export function resolveApprovalRunningNoticeMs(value?: number) { if (typeof value !== "number" || !Number.isFinite(value)) { return DEFAULT_APPROVAL_RUNNING_NOTICE_MS; diff --git a/src/agents/bash-tools.exec-types.ts b/src/agents/bash-tools.exec-types.ts index bef8ea4bff1..7236fdaaf47 100644 --- a/src/agents/bash-tools.exec-types.ts +++ b/src/agents/bash-tools.exec-types.ts @@ -60,4 +60,19 @@ export type ExecToolDetails = command: string; cwd?: string; nodeId?: string; + warningText?: string; + } + | { + status: "approval-unavailable"; + reason: + | "initiating-platform-disabled" + | "initiating-platform-unsupported" + | "no-approval-route"; + channelLabel?: string; + sentApproverDms?: boolean; + host: ExecHost; + command: string; + cwd?: string; + nodeId?: string; + warningText?: string; }; diff --git a/src/agents/bash-tools.exec.approval-id.test.ts b/src/agents/bash-tools.exec.approval-id.test.ts index b7f4729948c..cc94f83d665 100644 --- a/src/agents/bash-tools.exec.approval-id.test.ts +++ b/src/agents/bash-tools.exec.approval-id.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { clearConfigCache } from "../config/config.js"; import { buildSystemRunPreparePayload } from "../test-utils/system-run-prepare-payload.js"; vi.mock("./tools/gateway.js", () => ({ @@ -63,6 +64,7 @@ describe("exec approvals", () => { afterEach(() => { vi.resetAllMocks(); + clearConfigCache(); if (previousHome === undefined) { delete process.env.HOME; } else { @@ -77,6 +79,7 @@ describe("exec approvals", () => { it("reuses approval id as the node runId", async () => { let invokeParams: unknown; + let agentParams: unknown; vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => { if (method === "exec.approval.request") { @@ -85,6 +88,10 @@ describe("exec approvals", () => { if (method === "exec.approval.waitDecision") { return { decision: "allow-once" }; } + if (method === "agent") { + agentParams = params; + return { status: "ok" }; + } if (method === "node.invoke") { const invoke = params as { command?: string }; if (invoke.command === "system.run.prepare") { @@ -102,11 +109,24 @@ describe("exec approvals", () => { host: "node", ask: "always", approvalRunningNoticeMs: 0, + sessionKey: "agent:main:main", }); const result = await tool.execute("call1", { command: "ls -la" }); expect(result.details.status).toBe("approval-pending"); - const approvalId = (result.details as { approvalId: string }).approvalId; + const details = result.details as { approvalId: string; approvalSlug: string }; + const pendingText = result.content.find((part) => part.type === "text")?.text ?? ""; + expect(pendingText).toContain( + `Reply with: /approve ${details.approvalSlug} allow-once|allow-always|deny`, + ); + expect(pendingText).toContain(`full ${details.approvalId}`); + expect(pendingText).toContain("Host: node"); + expect(pendingText).toContain("Node: node-1"); + expect(pendingText).toContain(`CWD: ${process.cwd()}`); + expect(pendingText).toContain("Command:\n```sh\nls -la\n```"); + expect(pendingText).toContain("Mode: foreground (interactive approvals available)."); + expect(pendingText).toContain("Background mode requires pre-approved policy"); + const approvalId = details.approvalId; await expect .poll(() => (invokeParams as { params?: { runId?: string } } | undefined)?.params?.runId, { @@ -114,6 +134,12 @@ describe("exec approvals", () => { interval: 20, }) .toBe(approvalId); + expect( + (invokeParams as { params?: { suppressNotifyOnExit?: boolean } } | undefined)?.params, + ).toMatchObject({ + suppressNotifyOnExit: true, + }); + await expect.poll(() => agentParams, { timeout: 2_000, interval: 20 }).toBeTruthy(); }); it("skips approval when node allowlist is satisfied", async () => { @@ -287,11 +313,181 @@ describe("exec approvals", () => { const result = await tool.execute("call4", { command: "echo ok", elevated: true }); expect(result.details.status).toBe("approval-pending"); + const details = result.details as { approvalId: string; approvalSlug: string }; + const pendingText = result.content.find((part) => part.type === "text")?.text ?? ""; + expect(pendingText).toContain( + `Reply with: /approve ${details.approvalSlug} allow-once|allow-always|deny`, + ); + expect(pendingText).toContain(`full ${details.approvalId}`); + expect(pendingText).toContain("Host: gateway"); + expect(pendingText).toContain(`CWD: ${process.cwd()}`); + expect(pendingText).toContain("Command:\n```sh\necho ok\n```"); await approvalSeen; expect(calls).toContain("exec.approval.request"); expect(calls).toContain("exec.approval.waitDecision"); }); + it("starts a direct agent follow-up after approved gateway exec completes", async () => { + const agentCalls: Array> = []; + + vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => { + if (method === "exec.approval.request") { + return { status: "accepted", id: (params as { id?: string })?.id }; + } + if (method === "exec.approval.waitDecision") { + return { decision: "allow-once" }; + } + if (method === "agent") { + agentCalls.push(params as Record); + return { status: "ok" }; + } + return { ok: true }; + }); + + const tool = createExecTool({ + host: "gateway", + ask: "always", + approvalRunningNoticeMs: 0, + sessionKey: "agent:main:main", + elevated: { enabled: true, allowed: true, defaultLevel: "ask" }, + }); + + const result = await tool.execute("call-gw-followup", { + command: "echo ok", + workdir: process.cwd(), + gatewayUrl: undefined, + gatewayToken: undefined, + }); + + expect(result.details.status).toBe("approval-pending"); + await expect.poll(() => agentCalls.length, { timeout: 3_000, interval: 20 }).toBe(1); + expect(agentCalls[0]).toEqual( + expect.objectContaining({ + sessionKey: "agent:main:main", + deliver: true, + idempotencyKey: expect.stringContaining("exec-approval-followup:"), + }), + ); + expect(typeof agentCalls[0]?.message).toBe("string"); + expect(agentCalls[0]?.message).toContain( + "An async command the user already approved has completed.", + ); + }); + + it("requires a separate approval for each elevated command after allow-once", async () => { + const requestCommands: string[] = []; + const requestIds: string[] = []; + const waitIds: string[] = []; + + vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => { + if (method === "exec.approval.request") { + const request = params as { id?: string; command?: string }; + if (typeof request.command === "string") { + requestCommands.push(request.command); + } + if (typeof request.id === "string") { + requestIds.push(request.id); + } + return { status: "accepted", id: request.id }; + } + if (method === "exec.approval.waitDecision") { + const wait = params as { id?: string }; + if (typeof wait.id === "string") { + waitIds.push(wait.id); + } + return { decision: "allow-once" }; + } + return { ok: true }; + }); + + const tool = createExecTool({ + ask: "on-miss", + security: "allowlist", + approvalRunningNoticeMs: 0, + elevated: { enabled: true, allowed: true, defaultLevel: "ask" }, + }); + + const first = await tool.execute("call-seq-1", { + command: "npm view diver --json", + elevated: true, + }); + const second = await tool.execute("call-seq-2", { + command: "brew outdated", + elevated: true, + }); + + expect(first.details.status).toBe("approval-pending"); + expect(second.details.status).toBe("approval-pending"); + expect(requestCommands).toEqual(["npm view diver --json", "brew outdated"]); + expect(requestIds).toHaveLength(2); + expect(requestIds[0]).not.toBe(requestIds[1]); + expect(waitIds).toEqual(requestIds); + }); + + it("shows full chained gateway commands in approval-pending message", async () => { + const calls: string[] = []; + vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => { + calls.push(method); + if (method === "exec.approval.request") { + return { status: "accepted", id: (params as { id?: string })?.id }; + } + if (method === "exec.approval.waitDecision") { + return { decision: "deny" }; + } + return { ok: true }; + }); + + const tool = createExecTool({ + host: "gateway", + ask: "on-miss", + security: "allowlist", + approvalRunningNoticeMs: 0, + }); + + const result = await tool.execute("call-chain-gateway", { + command: "npm view diver --json | jq .name && brew outdated", + }); + + expect(result.details.status).toBe("approval-pending"); + const pendingText = result.content.find((part) => part.type === "text")?.text ?? ""; + expect(pendingText).toContain( + "Command:\n```sh\nnpm view diver --json | jq .name && brew outdated\n```", + ); + expect(calls).toContain("exec.approval.request"); + }); + + it("shows full chained node commands in approval-pending message", async () => { + const calls: string[] = []; + vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => { + calls.push(method); + if (method === "node.invoke") { + const invoke = params as { command?: string }; + if (invoke.command === "system.run.prepare") { + return buildPreparedSystemRunPayload(params); + } + } + return { ok: true }; + }); + + const tool = createExecTool({ + host: "node", + ask: "always", + security: "full", + approvalRunningNoticeMs: 0, + }); + + const result = await tool.execute("call-chain-node", { + command: "npm view diver --json | jq .name && brew outdated", + }); + + expect(result.details.status).toBe("approval-pending"); + const pendingText = result.content.find((part) => part.type === "text")?.text ?? ""; + expect(pendingText).toContain( + "Command:\n```sh\nnpm view diver --json | jq .name && brew outdated\n```", + ); + expect(calls).toContain("exec.approval.request"); + }); + it("waits for approval registration before returning approval-pending", async () => { const calls: string[] = []; let resolveRegistration: ((value: unknown) => void) | undefined; @@ -354,6 +550,111 @@ describe("exec approvals", () => { ); }); + it("returns an unavailable approval message instead of a local /approve prompt when discord exec approvals are disabled", async () => { + const configPath = path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + channels: { + discord: { + enabled: true, + execApprovals: { enabled: false }, + }, + }, + }), + ); + + vi.mocked(callGatewayTool).mockImplementation(async (method) => { + if (method === "exec.approval.request") { + return { status: "accepted", id: "approval-id" }; + } + if (method === "exec.approval.waitDecision") { + return { decision: null }; + } + return { ok: true }; + }); + + const tool = createExecTool({ + host: "gateway", + ask: "always", + approvalRunningNoticeMs: 0, + messageProvider: "discord", + accountId: "default", + currentChannelId: "1234567890", + }); + + const result = await tool.execute("call-unavailable", { + command: "npm view diver name version description", + }); + + expect(result.details.status).toBe("approval-unavailable"); + const text = result.content.find((part) => part.type === "text")?.text ?? ""; + expect(text).toContain("chat exec approvals are not enabled on Discord"); + expect(text).toContain("Web UI or terminal UI"); + expect(text).not.toContain("/approve"); + expect(text).not.toContain("npm view diver name version description"); + expect(text).not.toContain("Pending command:"); + expect(text).not.toContain("Host:"); + expect(text).not.toContain("CWD:"); + }); + + it("tells Telegram users that allowed approvers were DMed when Telegram approvals are disabled but Discord DM approvals are enabled", async () => { + const configPath = path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify( + { + channels: { + telegram: { + enabled: true, + execApprovals: { enabled: false }, + }, + discord: { + enabled: true, + execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + }, + }, + }, + null, + 2, + ), + ); + + vi.mocked(callGatewayTool).mockImplementation(async (method) => { + if (method === "exec.approval.request") { + return { status: "accepted", id: "approval-id" }; + } + if (method === "exec.approval.waitDecision") { + return { decision: null }; + } + return { ok: true }; + }); + + const tool = createExecTool({ + host: "gateway", + ask: "always", + approvalRunningNoticeMs: 0, + messageProvider: "telegram", + accountId: "default", + currentChannelId: "-1003841603622", + }); + + const result = await tool.execute("call-tg-unavailable", { + command: "npm view diver name version description", + }); + + expect(result.details.status).toBe("approval-unavailable"); + const text = result.content.find((part) => part.type === "text")?.text ?? ""; + expect(text).toContain("Approval required. I sent the allowed approvers DMs."); + expect(text).not.toContain("/approve"); + expect(text).not.toContain("npm view diver name version description"); + expect(text).not.toContain("Pending command:"); + expect(text).not.toContain("Host:"); + expect(text).not.toContain("CWD:"); + }); + it("denies node obfuscated command when approval request times out", async () => { vi.mocked(detectCommandObfuscation).mockReturnValue({ detected: true, diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 381c76ada18..298bac9fe9e 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -1457,6 +1457,7 @@ export async function runEmbeddedPiAgent( suppressToolErrorWarnings: params.suppressToolErrorWarnings, inlineToolResultsAllowed: false, didSendViaMessagingTool: attempt.didSendViaMessagingTool, + didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt, }); // Timeout aborts can leave the run without any assistant payloads. @@ -1479,6 +1480,7 @@ export async function runEmbeddedPiAgent( systemPromptReport: attempt.systemPromptReport, }, didSendViaMessagingTool: attempt.didSendViaMessagingTool, + didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt, messagingToolSentTexts: attempt.messagingToolSentTexts, messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls, messagingToolSentTargets: attempt.messagingToolSentTargets, @@ -1526,6 +1528,7 @@ export async function runEmbeddedPiAgent( : undefined, }, didSendViaMessagingTool: attempt.didSendViaMessagingTool, + didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt, messagingToolSentTexts: attempt.messagingToolSentTexts, messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls, messagingToolSentTargets: attempt.messagingToolSentTargets, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index d7fa541c2be..25f13c666c7 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1544,6 +1544,7 @@ export async function runEmbeddedAttempt( getMessagingToolSentTargets, getSuccessfulCronAdds, didSendViaMessagingTool, + didSendDeterministicApprovalPrompt, getLastToolError, getUsageTotals, getCompactionCount, @@ -2058,6 +2059,7 @@ export async function runEmbeddedAttempt( lastAssistant, lastToolError: getLastToolError?.(), didSendViaMessagingTool: didSendViaMessagingTool(), + didSendDeterministicApprovalPrompt: didSendDeterministicApprovalPrompt(), messagingToolSentTexts: getMessagingToolSentTexts(), messagingToolSentMediaUrls: getMessagingToolSentMediaUrls(), messagingToolSentTargets: getMessagingToolSentTargets(), diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 6d067c910bf..ee743d7a0c1 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -1,5 +1,6 @@ import type { ImageContent } from "@mariozechner/pi-ai"; import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js"; +import type { ReplyPayload } from "../../../auto-reply/types.js"; import type { AgentStreamParams } from "../../../commands/agent/types.js"; import type { OpenClawConfig } from "../../../config/config.js"; import type { enqueueCommand } from "../../../process/command-queue.js"; @@ -104,7 +105,7 @@ export type RunEmbeddedPiAgentParams = { blockReplyChunking?: BlockReplyChunking; onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise; onReasoningEnd?: () => void | Promise; - onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise; + onToolResult?: (payload: ReplyPayload) => void | Promise; onAgentEvent?: (evt: { stream: string; data: Record }) => void; lane?: string; enqueue?: typeof enqueueCommand; diff --git a/src/agents/pi-embedded-runner/run/payloads.test.ts b/src/agents/pi-embedded-runner/run/payloads.test.ts index ee8acd1d43e..6c81fb12150 100644 --- a/src/agents/pi-embedded-runner/run/payloads.test.ts +++ b/src/agents/pi-embedded-runner/run/payloads.test.ts @@ -82,4 +82,13 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => { expect(payloads).toHaveLength(0); }); + + it("suppresses assistant text when a deterministic exec approval prompt was already delivered", () => { + const payloads = buildPayloads({ + assistantTexts: ["Approval is needed. Please run /approve abc allow-once"], + didSendDeterministicApprovalPrompt: true, + }); + + expect(payloads).toHaveLength(0); + }); }); diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index c3c87845451..16a78ec2e97 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -102,6 +102,7 @@ export function buildEmbeddedRunPayloads(params: { suppressToolErrorWarnings?: boolean; inlineToolResultsAllowed: boolean; didSendViaMessagingTool?: boolean; + didSendDeterministicApprovalPrompt?: boolean; }): Array<{ text?: string; mediaUrl?: string; @@ -125,14 +126,17 @@ export function buildEmbeddedRunPayloads(params: { }> = []; const useMarkdown = params.toolResultFormat === "markdown"; + const suppressAssistantArtifacts = params.didSendDeterministicApprovalPrompt === true; const lastAssistantErrored = params.lastAssistant?.stopReason === "error"; const errorText = params.lastAssistant - ? formatAssistantErrorText(params.lastAssistant, { - cfg: params.config, - sessionKey: params.sessionKey, - provider: params.provider, - model: params.model, - }) + ? suppressAssistantArtifacts + ? undefined + : formatAssistantErrorText(params.lastAssistant, { + cfg: params.config, + sessionKey: params.sessionKey, + provider: params.provider, + model: params.model, + }) : undefined; const rawErrorMessage = lastAssistantErrored ? params.lastAssistant?.errorMessage?.trim() || undefined @@ -184,8 +188,9 @@ export function buildEmbeddedRunPayloads(params: { } } - const reasoningText = - params.lastAssistant && params.reasoningLevel === "on" + const reasoningText = suppressAssistantArtifacts + ? "" + : params.lastAssistant && params.reasoningLevel === "on" ? formatReasoningMessage(extractAssistantThinking(params.lastAssistant)) : ""; if (reasoningText) { @@ -243,13 +248,14 @@ export function buildEmbeddedRunPayloads(params: { } return isRawApiErrorPayload(trimmed); }; - const answerTexts = ( - params.assistantTexts.length - ? params.assistantTexts - : fallbackAnswerText - ? [fallbackAnswerText] - : [] - ).filter((text) => !shouldSuppressRawErrorText(text)); + const answerTexts = suppressAssistantArtifacts + ? [] + : (params.assistantTexts.length + ? params.assistantTexts + : fallbackAnswerText + ? [fallbackAnswerText] + : [] + ).filter((text) => !shouldSuppressRawErrorText(text)); let hasUserFacingAssistantReply = false; for (const text of answerTexts) { diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index dff5aa6f251..7e6ad0578f1 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -54,6 +54,7 @@ export type EmbeddedRunAttemptResult = { actionFingerprint?: string; }; didSendViaMessagingTool: boolean; + didSendDeterministicApprovalPrompt?: boolean; messagingToolSentTexts: string[]; messagingToolSentMediaUrls: string[]; messagingToolSentTargets: MessagingToolSend[]; diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index c89a4b71496..04f47e67cde 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -85,6 +85,9 @@ export function handleMessageUpdate( } ctx.noteLastAssistant(msg); + if (ctx.state.deterministicApprovalPromptSent) { + return; + } const assistantEvent = evt.assistantMessageEvent; const assistantRecord = @@ -261,6 +264,9 @@ export function handleMessageEnd( const assistantMessage = msg; ctx.noteLastAssistant(assistantMessage); ctx.recordAssistantUsage((assistantMessage as { usage?: unknown }).usage); + if (ctx.state.deterministicApprovalPromptSent) { + return; + } promoteThinkingTagsToBlocks(assistantMessage); const rawText = extractAssistantText(assistantMessage); diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts b/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts index 741fa96c815..66685f04036 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts @@ -28,6 +28,7 @@ function createMockContext(overrides?: { messagingToolSentTextsNormalized: [], messagingToolSentMediaUrls: [], messagingToolSentTargets: [], + deterministicApprovalPromptSent: false, }, log: { debug: vi.fn(), warn: vi.fn() }, shouldEmitToolResult: vi.fn(() => false), diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts index 96a988e5bc6..3cf7935a8a2 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts @@ -45,6 +45,7 @@ function createTestContext(): { messagingToolSentMediaUrls: [], messagingToolSentTargets: [], successfulCronAdds: 0, + deterministicApprovalPromptSent: false, }, shouldEmitToolResult: () => false, shouldEmitToolOutput: () => false, @@ -175,6 +176,161 @@ describe("handleToolExecutionEnd cron.add commitment tracking", () => { }); }); +describe("handleToolExecutionEnd exec approval prompts", () => { + it("emits a deterministic approval payload and marks assistant output suppressed", async () => { + const { ctx } = createTestContext(); + const onToolResult = vi.fn(); + ctx.params.onToolResult = onToolResult; + + await handleToolExecutionEnd( + ctx as never, + { + type: "tool_execution_end", + toolName: "exec", + toolCallId: "tool-exec-approval", + isError: false, + result: { + details: { + status: "approval-pending", + approvalId: "12345678-1234-1234-1234-123456789012", + approvalSlug: "12345678", + expiresAtMs: 1_800_000_000_000, + host: "gateway", + command: "npm view diver name version description", + cwd: "/tmp/work", + warningText: "Warning: heredoc execution requires explicit approval in allowlist mode.", + }, + }, + } as never, + ); + + expect(onToolResult).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining("```txt\n/approve 12345678 allow-once\n```"), + channelData: { + execApproval: { + approvalId: "12345678-1234-1234-1234-123456789012", + approvalSlug: "12345678", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }), + ); + expect(ctx.state.deterministicApprovalPromptSent).toBe(true); + }); + + it("emits a deterministic unavailable payload when the initiating surface cannot approve", async () => { + const { ctx } = createTestContext(); + const onToolResult = vi.fn(); + ctx.params.onToolResult = onToolResult; + + await handleToolExecutionEnd( + ctx as never, + { + type: "tool_execution_end", + toolName: "exec", + toolCallId: "tool-exec-unavailable", + isError: false, + result: { + details: { + status: "approval-unavailable", + reason: "initiating-platform-disabled", + channelLabel: "Discord", + }, + }, + } as never, + ); + + expect(onToolResult).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining("chat exec approvals are not enabled on Discord"), + }), + ); + expect(onToolResult).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.not.stringContaining("/approve"), + }), + ); + expect(onToolResult).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.not.stringContaining("Pending command:"), + }), + ); + expect(onToolResult).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.not.stringContaining("Host:"), + }), + ); + expect(onToolResult).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.not.stringContaining("CWD:"), + }), + ); + expect(ctx.state.deterministicApprovalPromptSent).toBe(true); + }); + + it("emits the shared approver-DM notice when another approval client received the request", async () => { + const { ctx } = createTestContext(); + const onToolResult = vi.fn(); + ctx.params.onToolResult = onToolResult; + + await handleToolExecutionEnd( + ctx as never, + { + type: "tool_execution_end", + toolName: "exec", + toolCallId: "tool-exec-unavailable-dm-redirect", + isError: false, + result: { + details: { + status: "approval-unavailable", + reason: "initiating-platform-disabled", + channelLabel: "Telegram", + sentApproverDms: true, + }, + }, + } as never, + ); + + expect(onToolResult).toHaveBeenCalledWith( + expect.objectContaining({ + text: "Approval required. I sent the allowed approvers DMs.", + }), + ); + expect(ctx.state.deterministicApprovalPromptSent).toBe(true); + }); + + it("does not suppress assistant output when deterministic prompt delivery rejects", async () => { + const { ctx } = createTestContext(); + ctx.params.onToolResult = vi.fn(async () => { + throw new Error("delivery failed"); + }); + + await handleToolExecutionEnd( + ctx as never, + { + type: "tool_execution_end", + toolName: "exec", + toolCallId: "tool-exec-approval-reject", + isError: false, + result: { + details: { + status: "approval-pending", + approvalId: "12345678-1234-1234-1234-123456789012", + approvalSlug: "12345678", + expiresAtMs: 1_800_000_000_000, + host: "gateway", + command: "npm view diver name version description", + cwd: "/tmp/work", + }, + }, + } as never, + ); + + expect(ctx.state.deterministicApprovalPromptSent).toBe(false); + }); +}); + describe("messaging tool media URL tracking", () => { it("tracks media arg from messaging tool as pending", async () => { const { ctx } = createTestContext(); diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index 8abd9469bbc..70f6b54639c 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -1,5 +1,9 @@ import type { AgentEvent } from "@mariozechner/pi-agent-core"; import { emitAgentEvent } from "../infra/agent-events.js"; +import { + buildExecApprovalPendingReplyPayload, + buildExecApprovalUnavailableReplyPayload, +} from "../infra/exec-approval-reply.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import type { PluginHookAfterToolCallEvent } from "../plugins/types.js"; import { normalizeTextForComparison } from "./pi-embedded-helpers.js"; @@ -139,7 +143,81 @@ function collectMessagingMediaUrlsFromToolResult(result: unknown): string[] { return urls; } -function emitToolResultOutput(params: { +function readExecApprovalPendingDetails(result: unknown): { + approvalId: string; + approvalSlug: string; + expiresAtMs?: number; + host: "gateway" | "node"; + command: string; + cwd?: string; + nodeId?: string; + warningText?: string; +} | null { + if (!result || typeof result !== "object") { + return null; + } + const outer = result as Record; + const details = + outer.details && typeof outer.details === "object" && !Array.isArray(outer.details) + ? (outer.details as Record) + : outer; + if (details.status !== "approval-pending") { + return null; + } + const approvalId = typeof details.approvalId === "string" ? details.approvalId.trim() : ""; + const approvalSlug = typeof details.approvalSlug === "string" ? details.approvalSlug.trim() : ""; + const command = typeof details.command === "string" ? details.command : ""; + const host = details.host === "node" ? "node" : details.host === "gateway" ? "gateway" : null; + if (!approvalId || !approvalSlug || !command || !host) { + return null; + } + return { + approvalId, + approvalSlug, + expiresAtMs: typeof details.expiresAtMs === "number" ? details.expiresAtMs : undefined, + host, + command, + cwd: typeof details.cwd === "string" ? details.cwd : undefined, + nodeId: typeof details.nodeId === "string" ? details.nodeId : undefined, + warningText: typeof details.warningText === "string" ? details.warningText : undefined, + }; +} + +function readExecApprovalUnavailableDetails(result: unknown): { + reason: "initiating-platform-disabled" | "initiating-platform-unsupported" | "no-approval-route"; + warningText?: string; + channelLabel?: string; + sentApproverDms?: boolean; +} | null { + if (!result || typeof result !== "object") { + return null; + } + const outer = result as Record; + const details = + outer.details && typeof outer.details === "object" && !Array.isArray(outer.details) + ? (outer.details as Record) + : outer; + if (details.status !== "approval-unavailable") { + return null; + } + const reason = + details.reason === "initiating-platform-disabled" || + details.reason === "initiating-platform-unsupported" || + details.reason === "no-approval-route" + ? details.reason + : null; + if (!reason) { + return null; + } + return { + reason, + warningText: typeof details.warningText === "string" ? details.warningText : undefined, + channelLabel: typeof details.channelLabel === "string" ? details.channelLabel : undefined, + sentApproverDms: details.sentApproverDms === true, + }; +} + +async function emitToolResultOutput(params: { ctx: ToolHandlerContext; toolName: string; meta?: string; @@ -152,6 +230,46 @@ function emitToolResultOutput(params: { return; } + const approvalPending = readExecApprovalPendingDetails(result); + if (!isToolError && approvalPending) { + try { + await ctx.params.onToolResult( + buildExecApprovalPendingReplyPayload({ + approvalId: approvalPending.approvalId, + approvalSlug: approvalPending.approvalSlug, + command: approvalPending.command, + cwd: approvalPending.cwd, + host: approvalPending.host, + nodeId: approvalPending.nodeId, + expiresAtMs: approvalPending.expiresAtMs, + warningText: approvalPending.warningText, + }), + ); + ctx.state.deterministicApprovalPromptSent = true; + } catch { + // ignore delivery failures + } + return; + } + + const approvalUnavailable = readExecApprovalUnavailableDetails(result); + if (!isToolError && approvalUnavailable) { + try { + await ctx.params.onToolResult?.( + buildExecApprovalUnavailableReplyPayload({ + reason: approvalUnavailable.reason, + warningText: approvalUnavailable.warningText, + channelLabel: approvalUnavailable.channelLabel, + sentApproverDms: approvalUnavailable.sentApproverDms, + }), + ); + ctx.state.deterministicApprovalPromptSent = true; + } catch { + // ignore delivery failures + } + return; + } + if (ctx.shouldEmitToolOutput()) { const outputText = extractToolResultText(sanitizedResult); if (outputText) { @@ -427,7 +545,7 @@ export async function handleToolExecutionEnd( `embedded run tool end: runId=${ctx.params.runId} tool=${toolName} toolCallId=${toolCallId}`, ); - emitToolResultOutput({ ctx, toolName, meta, isToolError, result, sanitizedResult }); + await emitToolResultOutput({ ctx, toolName, meta, isToolError, result, sanitizedResult }); // Run after_tool_call plugin hook (fire-and-forget) const hookRunnerAfter = ctx.hookRunner ?? getGlobalHookRunner(); diff --git a/src/agents/pi-embedded-subscribe.handlers.types.ts b/src/agents/pi-embedded-subscribe.handlers.types.ts index 955af473b9e..4436e6f6aa3 100644 --- a/src/agents/pi-embedded-subscribe.handlers.types.ts +++ b/src/agents/pi-embedded-subscribe.handlers.types.ts @@ -76,6 +76,7 @@ export type EmbeddedPiSubscribeState = { pendingMessagingTargets: Map; successfulCronAdds: number; pendingMessagingMediaUrls: Map; + deterministicApprovalPromptSent: boolean; lastAssistant?: AgentMessage; }; @@ -155,6 +156,7 @@ export type ToolHandlerState = Pick< | "messagingToolSentMediaUrls" | "messagingToolSentTargets" | "successfulCronAdds" + | "deterministicApprovalPromptSent" >; export type ToolHandlerContext = { diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index c5ffedbf14f..83592372e80 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -78,6 +78,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar pendingMessagingTargets: new Map(), successfulCronAdds: 0, pendingMessagingMediaUrls: new Map(), + deterministicApprovalPromptSent: false, }; const usageTotals = { input: 0, @@ -598,6 +599,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar pendingMessagingTargets.clear(); state.successfulCronAdds = 0; state.pendingMessagingMediaUrls.clear(); + state.deterministicApprovalPromptSent = false; resetAssistantMessageState(0); }; @@ -688,6 +690,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar // Used to suppress agent's confirmation text (e.g., "Respondi no Telegram!") // which is generated AFTER the tool sends the actual answer. didSendViaMessagingTool: () => messagingToolSentTexts.length > 0, + didSendDeterministicApprovalPrompt: () => state.deterministicApprovalPromptSent, getLastToolError: () => (state.lastToolError ? { ...state.lastToolError } : undefined), getUsageTotals, getCompactionCount: () => compactionCount, diff --git a/src/agents/pi-embedded-subscribe.types.ts b/src/agents/pi-embedded-subscribe.types.ts index 689cd49998e..bbb2d552d73 100644 --- a/src/agents/pi-embedded-subscribe.types.ts +++ b/src/agents/pi-embedded-subscribe.types.ts @@ -1,5 +1,6 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent"; import type { ReasoningLevel, VerboseLevel } from "../auto-reply/thinking.js"; +import type { ReplyPayload } from "../auto-reply/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { HookRunner } from "../plugins/hooks.js"; import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js"; @@ -16,7 +17,7 @@ export type SubscribeEmbeddedPiSessionParams = { toolResultFormat?: ToolResultFormat; shouldEmitToolResult?: () => boolean; shouldEmitToolOutput?: () => boolean; - onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise; + onToolResult?: (payload: ReplyPayload) => void | Promise; onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise; /** Called when a thinking/reasoning block ends ( tag processed). */ onReasoningEnd?: () => void | Promise; diff --git a/src/agents/pi-tool-handler-state.test-helpers.ts b/src/agents/pi-tool-handler-state.test-helpers.ts index 0775299ab83..cfb559b9884 100644 --- a/src/agents/pi-tool-handler-state.test-helpers.ts +++ b/src/agents/pi-tool-handler-state.test-helpers.ts @@ -10,6 +10,7 @@ export function createBaseToolHandlerState() { messagingToolSentTextsNormalized: [] as string[], messagingToolSentMediaUrls: [] as string[], messagingToolSentTargets: [] as unknown[], + deterministicApprovalPromptSent: false, blockBuffer: "", }; } diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index a3d593ab6b8..848222b7880 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -464,6 +464,9 @@ export function buildAgentSystemPrompt(params: { "Keep narration brief and value-dense; avoid repeating obvious steps.", "Use plain human language for narration unless in a technical context.", "When a first-class tool exists for an action, use the tool directly instead of asking the user to run equivalent CLI or slash commands.", + "When exec returns approval-pending, include the concrete /approve command from tool output (with allow-once|allow-always|deny) and do not ask for a different or rotated code.", + "Treat allow-once as single-command only: if another elevated command needs approval, request a fresh /approve and do not claim prior approval covered it.", + "When approvals are required, preserve and show the full command/script exactly as provided (including chained operators like &&, ||, |, ;, or multiline shells) so the user can approve what will actually run.", "", ...safetySection, "## OpenClaw CLI Quick Reference", diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index a3b31c4ccc3..2f6c27519b0 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -445,8 +445,8 @@ export async function runAgentTurnWithFallback(params: { } await params.typingSignals.signalTextDelta(text); await onToolResult({ + ...payload, text, - mediaUrls: payload.mediaUrls, }); }) .catch((err) => { diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 350c6b63e47..5bf77cd9f70 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -12,6 +12,7 @@ vi.mock("../../agents/agent-scope.js", () => ({ })); const { + buildThreadingToolContext, buildEmbeddedRunBaseParams, buildEmbeddedRunContexts, resolveModelFallbackOptions, @@ -173,4 +174,44 @@ describe("agent-runner-utils", () => { expect(resolved.embeddedContext.messageProvider).toBe("telegram"); expect(resolved.embeddedContext.messageTo).toBe("268300329"); }); + + it("uses OriginatingTo for threading tool context on telegram native commands", () => { + const context = buildThreadingToolContext({ + sessionCtx: { + Provider: "telegram", + To: "slash:8460800771", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:-1003841603622", + MessageThreadId: 928, + MessageSid: "2284", + }, + config: { channels: { telegram: { allowFrom: ["*"] } } }, + hasRepliedRef: undefined, + }); + + expect(context).toMatchObject({ + currentChannelId: "telegram:-1003841603622", + currentThreadTs: "928", + currentMessageId: "2284", + }); + }); + + it("uses OriginatingTo for threading tool context on discord native commands", () => { + const context = buildThreadingToolContext({ + sessionCtx: { + Provider: "discord", + To: "slash:1177378744822943744", + OriginatingChannel: "discord", + OriginatingTo: "channel:123456789012345678", + MessageSid: "msg-9", + }, + config: {}, + hasRepliedRef: undefined, + }); + + expect(context).toMatchObject({ + currentChannelId: "channel:123456789012345678", + currentMessageId: "msg-9", + }); + }); }); diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 36e45bd9bf1..99b2b6392f6 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -23,12 +23,20 @@ export function buildThreadingToolContext(params: { }): ChannelThreadingToolContext { const { sessionCtx, config, hasRepliedRef } = params; const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid; + const originProvider = resolveOriginMessageProvider({ + originatingChannel: sessionCtx.OriginatingChannel, + provider: sessionCtx.Provider, + }); + const originTo = resolveOriginMessageTo({ + originatingTo: sessionCtx.OriginatingTo, + to: sessionCtx.To, + }); if (!config) { return { currentMessageId, }; } - const rawProvider = sessionCtx.Provider?.trim().toLowerCase(); + const rawProvider = originProvider?.trim().toLowerCase(); if (!rawProvider) { return { currentMessageId, @@ -39,7 +47,7 @@ export function buildThreadingToolContext(params: { const dock = provider ? getChannelDock(provider) : undefined; if (!dock?.threading?.buildToolContext) { return { - currentChannelId: sessionCtx.To?.trim() || undefined, + currentChannelId: originTo?.trim() || undefined, currentChannelProvider: provider ?? (rawProvider as ChannelId), currentMessageId, hasRepliedRef, @@ -50,9 +58,9 @@ export function buildThreadingToolContext(params: { cfg: config, accountId: sessionCtx.AccountId, context: { - Channel: sessionCtx.Provider, + Channel: originProvider, From: sessionCtx.From, - To: sessionCtx.To, + To: originTo, ChatType: sessionCtx.ChatType, CurrentMessageId: currentMessageId, ReplyToId: sessionCtx.ReplyToId, diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index 83c1796515c..db034ac03a6 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -21,7 +21,7 @@ type AgentRunParams = { onAssistantMessageStart?: () => Promise | void; onReasoningStream?: (payload: { text?: string }) => Promise | void; onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; - onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; + onToolResult?: (payload: ReplyPayload) => Promise | void; onAgentEvent?: (evt: { stream: string; data: Record }) => void; }; @@ -594,6 +594,40 @@ describe("runReplyAgent typing (heartbeat)", () => { } }); + it("preserves channelData on forwarded tool results", async () => { + const onToolResult = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onToolResult?.({ + text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```", + channelData: { + execApproval: { + approvalId: "117ba06d-1111-2222-3333-444444444444", + approvalSlug: "117ba06d", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run } = createMinimalRun({ + typingMode: "message", + opts: { onToolResult }, + }); + await run(); + + expect(onToolResult).toHaveBeenCalledWith({ + text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```", + channelData: { + execApproval: { + approvalId: "117ba06d-1111-2222-3333-444444444444", + approvalSlug: "117ba06d", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }); + }); + it("retries transient HTTP failures once with timer-driven backoff", async () => { vi.useFakeTimers(); let calls = 0; @@ -1952,3 +1986,4 @@ describe("runReplyAgent memory flush", () => { }); }); }); +import type { ReplyPayload } from "../types.js"; diff --git a/src/auto-reply/reply/commands-approve.ts b/src/auto-reply/reply/commands-approve.ts index 9773ba03ad5..5b0caec9c8f 100644 --- a/src/auto-reply/reply/commands-approve.ts +++ b/src/auto-reply/reply/commands-approve.ts @@ -1,10 +1,15 @@ import { callGateway } from "../../gateway/call.js"; import { logVerbose } from "../../globals.js"; +import { + isTelegramExecApprovalApprover, + isTelegramExecApprovalClientEnabled, +} from "../../telegram/exec-approvals.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; -const COMMAND = "/approve"; +const COMMAND_REGEX = /^\/approve(?:\s|$)/i; +const FOREIGN_COMMAND_MENTION_REGEX = /^\/approve@([^\s]+)(?:\s|$)/i; const DECISION_ALIASES: Record = { allow: "allow-once", @@ -25,10 +30,14 @@ type ParsedApproveCommand = function parseApproveCommand(raw: string): ParsedApproveCommand | null { const trimmed = raw.trim(); - if (!trimmed.toLowerCase().startsWith(COMMAND)) { + if (FOREIGN_COMMAND_MENTION_REGEX.test(trimmed)) { + return { ok: false, error: "❌ This /approve command targets a different Telegram bot." }; + } + const commandMatch = trimmed.match(COMMAND_REGEX); + if (!commandMatch) { return null; } - const rest = trimmed.slice(COMMAND.length).trim(); + const rest = trimmed.slice(commandMatch[0].length).trim(); if (!rest) { return { ok: false, error: "Usage: /approve allow-once|allow-always|deny" }; } @@ -83,6 +92,29 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm return { shouldContinue: false, reply: { text: parsed.error } }; } + if (params.command.channel === "telegram") { + if ( + !isTelegramExecApprovalClientEnabled({ cfg: params.cfg, accountId: params.ctx.AccountId }) + ) { + return { + shouldContinue: false, + reply: { text: "❌ Telegram exec approvals are not enabled for this bot account." }, + }; + } + if ( + !isTelegramExecApprovalApprover({ + cfg: params.cfg, + accountId: params.ctx.AccountId, + senderId: params.command.senderId, + }) + ) { + return { + shouldContinue: false, + reply: { text: "❌ You are not authorized to approve exec requests on Telegram." }, + }; + } + } + const missingScope = requireGatewayClientScopeForInternalChannel(params, { label: "/approve", allowedScopes: ["operator.approvals", "operator.admin"], diff --git a/src/auto-reply/reply/commands-context.ts b/src/auto-reply/reply/commands-context.ts index 3d177c2b5f9..1c5056b4b46 100644 --- a/src/auto-reply/reply/commands-context.ts +++ b/src/auto-reply/reply/commands-context.ts @@ -26,6 +26,7 @@ export function buildCommandContext(params: { const rawBodyNormalized = triggerBodyNormalized; const commandBodyNormalized = normalizeCommandBody( isGroup ? stripMentions(rawBodyNormalized, ctx, cfg, agentId) : rawBodyNormalized, + { botUsername: ctx.BotUsername }, ); return { diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 38be7c43531..0f526d6edaa 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -105,27 +105,6 @@ vi.mock("../../gateway/call.js", () => ({ callGateway: (opts: unknown) => callGatewayMock(opts), })); -type ResetAcpSessionInPlaceResult = { ok: true } | { ok: false; skipped?: boolean; error?: string }; - -const resetAcpSessionInPlaceMock = vi.hoisted(() => - vi.fn( - async (_params: unknown): Promise => ({ - ok: false, - skipped: true, - }), - ), -); -vi.mock("../../acp/persistent-bindings.js", async () => { - const actual = await vi.importActual( - "../../acp/persistent-bindings.js", - ); - return { - ...actual, - resetAcpSessionInPlace: (params: unknown) => resetAcpSessionInPlaceMock(params), - }; -}); - -import { buildConfiguredAcpSessionKey } from "../../acp/persistent-bindings.js"; import type { HandleCommandsParams } from "./commands-types.js"; import { buildCommandContext, handleCommands } from "./commands.js"; @@ -158,11 +137,6 @@ function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Pa return buildCommandTestParams(commandBody, cfg, ctxOverrides, { workspaceDir: testWorkspaceDir }); } -beforeEach(() => { - resetAcpSessionInPlaceMock.mockReset(); - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, skipped: true } as const); -}); - describe("handleCommands gating", () => { it("blocks gated commands when disabled or not elevated-allowlisted", async () => { const cases = typedCases<{ @@ -316,6 +290,122 @@ describe("/approve command", () => { ); }); + it("accepts Telegram command mentions for /approve", async () => { + const cfg = { + commands: { text: true }, + channels: { + telegram: { + allowFrom: ["*"], + execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + }, + }, + } as OpenClawConfig; + const params = buildParams("/approve@bot abc12345 allow-once", cfg, { + BotUsername: "bot", + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }); + + callGatewayMock.mockResolvedValue({ ok: true }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Exec approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc12345", decision: "allow-once" }, + }), + ); + }); + + it("rejects Telegram /approve mentions targeting a different bot", async () => { + const cfg = { + commands: { text: true }, + channels: { + telegram: { + allowFrom: ["*"], + execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + }, + }, + } as OpenClawConfig; + const params = buildParams("/approve@otherbot abc12345 allow-once", cfg, { + BotUsername: "bot", + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }); + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("targets a different Telegram bot"); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("surfaces unknown or expired approval id errors", async () => { + const cfg = { + commands: { text: true }, + channels: { + telegram: { + allowFrom: ["*"], + execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + }, + }, + } as OpenClawConfig; + const params = buildParams("/approve abc12345 allow-once", cfg, { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }); + + callGatewayMock.mockRejectedValue(new Error("unknown or expired approval id")); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("unknown or expired approval id"); + }); + + it("rejects Telegram /approve when telegram exec approvals are disabled", async () => { + const cfg = { + commands: { text: true }, + channels: { telegram: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/approve abc12345 allow-once", cfg, { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Telegram exec approvals are not enabled"); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("rejects Telegram /approve from non-approvers", async () => { + const cfg = { + commands: { text: true }, + channels: { + telegram: { + allowFrom: ["*"], + execApprovals: { enabled: true, approvers: ["999"], target: "dm" }, + }, + }, + } as OpenClawConfig; + const params = buildParams("/approve abc12345 allow-once", cfg, { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("not authorized to approve"); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + it("rejects gateway clients without approvals scope", async () => { const cfg = { commands: { text: true }, @@ -1147,226 +1237,6 @@ describe("handleCommands hooks", () => { }); }); -describe("handleCommands ACP-bound /new and /reset", () => { - const discordChannelId = "1478836151241412759"; - const buildDiscordBoundConfig = (): OpenClawConfig => - ({ - commands: { text: true }, - bindings: [ - { - type: "acp", - agentId: "codex", - match: { - channel: "discord", - accountId: "default", - peer: { - kind: "channel", - id: discordChannelId, - }, - }, - acp: { - mode: "persistent", - }, - }, - ], - channels: { - discord: { - allowFrom: ["*"], - guilds: { "1459246755253325866": { channels: { [discordChannelId]: {} } } }, - }, - }, - }) as OpenClawConfig; - - const buildDiscordBoundParams = (body: string) => { - const params = buildParams(body, buildDiscordBoundConfig(), { - Provider: "discord", - Surface: "discord", - OriginatingChannel: "discord", - AccountId: "default", - SenderId: "12345", - From: "discord:12345", - To: discordChannelId, - OriginatingTo: discordChannelId, - SessionKey: "agent:main:acp:binding:discord:default:feedface", - }); - params.sessionKey = "agent:main:acp:binding:discord:default:feedface"; - return params; - }; - - it("handles /new as ACP in-place reset for bound conversations", async () => { - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const); - const result = await handleCommands(buildDiscordBoundParams("/new")); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("ACP session reset in place"); - expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); - expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({ - reason: "new", - }); - }); - - it("continues with trailing prompt text after successful ACP-bound /new", async () => { - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const); - const params = buildDiscordBoundParams("/new continue with deployment"); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply).toBeUndefined(); - const mutableCtx = params.ctx as Record; - expect(mutableCtx.BodyStripped).toBe("continue with deployment"); - expect(mutableCtx.CommandBody).toBe("continue with deployment"); - expect(mutableCtx.AcpDispatchTailAfterReset).toBe(true); - expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); - }); - - it("handles /reset failures without falling back to normal session reset flow", async () => { - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, error: "backend unavailable" }); - const result = await handleCommands(buildDiscordBoundParams("/reset")); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("ACP session reset failed"); - expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); - expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({ - reason: "reset", - }); - }); - - it("does not emit reset hooks when ACP reset fails", async () => { - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, error: "backend unavailable" }); - const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue(); - - const result = await handleCommands(buildDiscordBoundParams("/reset")); - - expect(result.shouldContinue).toBe(false); - expect(spy).not.toHaveBeenCalled(); - spy.mockRestore(); - }); - - it("keeps existing /new behavior for non-ACP sessions", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const result = await handleCommands(buildParams("/new", cfg)); - - expect(result.shouldContinue).toBe(true); - expect(resetAcpSessionInPlaceMock).not.toHaveBeenCalled(); - }); - - it("still targets configured ACP binding when runtime routing falls back to a non-ACP session", async () => { - const fallbackSessionKey = `agent:main:discord:channel:${discordChannelId}`; - const configuredAcpSessionKey = buildConfiguredAcpSessionKey({ - channel: "discord", - accountId: "default", - conversationId: discordChannelId, - agentId: "codex", - mode: "persistent", - }); - const params = buildDiscordBoundParams("/new"); - params.sessionKey = fallbackSessionKey; - params.ctx.SessionKey = fallbackSessionKey; - params.ctx.CommandTargetSessionKey = fallbackSessionKey; - - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("ACP session reset unavailable"); - expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); - expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({ - sessionKey: configuredAcpSessionKey, - reason: "new", - }); - }); - - it("emits reset hooks for the ACP session key when routing falls back to non-ACP session", async () => { - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const); - const hookSpy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue(); - const fallbackSessionKey = `agent:main:discord:channel:${discordChannelId}`; - const configuredAcpSessionKey = buildConfiguredAcpSessionKey({ - channel: "discord", - accountId: "default", - conversationId: discordChannelId, - agentId: "codex", - mode: "persistent", - }); - const fallbackEntry = { - sessionId: "fallback-session-id", - sessionFile: "/tmp/fallback-session.jsonl", - } as SessionEntry; - const configuredEntry = { - sessionId: "configured-acp-session-id", - sessionFile: "/tmp/configured-acp-session.jsonl", - } as SessionEntry; - const params = buildDiscordBoundParams("/new"); - params.sessionKey = fallbackSessionKey; - params.ctx.SessionKey = fallbackSessionKey; - params.ctx.CommandTargetSessionKey = fallbackSessionKey; - params.sessionEntry = fallbackEntry; - params.previousSessionEntry = fallbackEntry; - params.sessionStore = { - [fallbackSessionKey]: fallbackEntry, - [configuredAcpSessionKey]: configuredEntry, - }; - - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("ACP session reset in place"); - expect(hookSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: "command", - action: "new", - sessionKey: configuredAcpSessionKey, - context: expect.objectContaining({ - sessionEntry: configuredEntry, - previousSessionEntry: configuredEntry, - }), - }), - ); - hookSpy.mockRestore(); - }); - - it("uses active ACP command target when conversation binding context is missing", async () => { - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const); - const activeAcpTarget = "agent:codex:acp:binding:discord:default:feedface"; - const params = buildParams( - "/new", - { - commands: { text: true }, - channels: { - discord: { - allowFrom: ["*"], - }, - }, - } as OpenClawConfig, - { - Provider: "discord", - Surface: "discord", - OriginatingChannel: "discord", - AccountId: "default", - SenderId: "12345", - From: "discord:12345", - }, - ); - params.sessionKey = "discord:slash:12345"; - params.ctx.SessionKey = "discord:slash:12345"; - params.ctx.CommandSource = "native"; - params.ctx.CommandTargetSessionKey = activeAcpTarget; - params.ctx.To = "user:12345"; - params.ctx.OriginatingTo = "user:12345"; - - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("ACP session reset in place"); - expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); - expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({ - sessionKey: activeAcpTarget, - reason: "new", - }); - }); -}); - describe("handleCommands context", () => { it("returns expected details for /context commands", async () => { const cfg = { diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 982557ecb68..87e77785bbb 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -543,6 +543,51 @@ describe("dispatchReplyFromConfig", () => { expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); + it("delivers deterministic exec approval tool payloads in groups", async () => { + setNoAbort(); + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "telegram", + ChatType: "group", + }); + + const replyResolver = async ( + _ctx: MsgContext, + opts?: GetReplyOptions, + _cfg?: OpenClawConfig, + ) => { + await opts?.onToolResult?.({ + text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```", + channelData: { + execApproval: { + approvalId: "117ba06d-1111-2222-3333-444444444444", + approvalSlug: "117ba06d", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }); + return { text: "NO_REPLY" } satisfies ReplyPayload; + }; + + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1); + expect(firstToolResultPayload(dispatcher)).toEqual( + expect.objectContaining({ + text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```", + channelData: { + execApproval: { + approvalId: "117ba06d-1111-2222-3333-444444444444", + approvalSlug: "117ba06d", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }), + ); + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "NO_REPLY" }); + }); + it("sends tool results via dispatcher in DM sessions", async () => { setNoAbort(); const cfg = emptyConfig; @@ -601,6 +646,50 @@ describe("dispatchReplyFromConfig", () => { expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); + it("delivers deterministic exec approval tool payloads for native commands", async () => { + setNoAbort(); + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "telegram", + CommandSource: "native", + }); + + const replyResolver = async ( + _ctx: MsgContext, + opts?: GetReplyOptions, + _cfg?: OpenClawConfig, + ) => { + await opts?.onToolResult?.({ + text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```", + channelData: { + execApproval: { + approvalId: "117ba06d-1111-2222-3333-444444444444", + approvalSlug: "117ba06d", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }); + return { text: "NO_REPLY" } satisfies ReplyPayload; + }; + + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1); + expect(firstToolResultPayload(dispatcher)).toEqual( + expect.objectContaining({ + channelData: { + execApproval: { + approvalId: "117ba06d-1111-2222-3333-444444444444", + approvalSlug: "117ba06d", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }), + ); + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "NO_REPLY" }); + }); + it("fast-aborts without calling the reply resolver", async () => { mocks.tryFastAbortFromMessage.mockResolvedValue({ handled: true, @@ -1539,6 +1628,47 @@ describe("dispatchReplyFromConfig", () => { expect(replyResolver).toHaveBeenCalledTimes(1); }); + it("suppresses local discord exec approval tool prompts when discord approvals are enabled", async () => { + setNoAbort(); + const cfg = { + channels: { + discord: { + enabled: true, + execApprovals: { + enabled: true, + approvers: ["123"], + }, + }, + }, + } as OpenClawConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "discord", + Surface: "discord", + AccountId: "default", + }); + const replyResolver = vi.fn(async (_ctx: MsgContext, options?: GetReplyOptions) => { + await options?.onToolResult?.({ + text: "Approval required.", + channelData: { + execApproval: { + approvalId: "12345678-1234-1234-1234-123456789012", + approvalSlug: "12345678", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }); + return { text: "done" } as ReplyPayload; + }); + + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(dispatcher.sendToolResult).not.toHaveBeenCalled(); + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith( + expect.objectContaining({ text: "done" }), + ); + }); + it("deduplicates same-agent inbound replies across main and direct session keys", async () => { setNoAbort(); const cfg = emptyConfig; diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 786b1a7c16b..5b250b03362 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -6,6 +6,7 @@ import { resolveStorePath, type SessionEntry, } from "../../config/sessions.js"; +import { shouldSuppressLocalDiscordExecApprovalPrompt } from "../../discord/exec-approvals.js"; import { logVerbose } from "../../globals.js"; import { fireAndForgetHook } from "../../hooks/fire-and-forget.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; @@ -365,9 +366,28 @@ export async function dispatchReplyFromConfig(params: { let blockCount = 0; const resolveToolDeliveryPayload = (payload: ReplyPayload): ReplyPayload | null => { + if ( + normalizeMessageChannel(ctx.Surface ?? ctx.Provider) === "discord" && + shouldSuppressLocalDiscordExecApprovalPrompt({ + cfg, + accountId: ctx.AccountId, + payload, + }) + ) { + return null; + } if (shouldSendToolSummaries) { return payload; } + const execApproval = + payload.channelData && + typeof payload.channelData === "object" && + !Array.isArray(payload.channelData) + ? payload.channelData.execApproval + : undefined; + if (execApproval && typeof execApproval === "object" && !Array.isArray(execApproval)) { + return payload; + } // Group/native flows intentionally suppress tool summary text, but media-only // tool results (for example TTS audio) must still be delivered. const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index cc4fc49e93f..8ca3c2389bc 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -132,6 +132,8 @@ export type MsgContext = { Provider?: string; /** Provider surface label (e.g. discord, slack). Prefer this over `Provider` when available. */ Surface?: string; + /** Platform bot username when command mentions should be normalized. */ + BotUsername?: string; WasMentioned?: boolean; CommandAuthorized?: boolean; CommandSource?: "text" | "native"; diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index 2a079a6014e..2afc67d439d 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -115,7 +115,6 @@ export const telegramOutbound: ChannelOutboundAdapter = { quoteText, mediaLocalRoots, }; - if (mediaUrls.length === 0) { const result = await send(to, text, { ...payloadOpts, diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index fa9451456bf..04d5200bfbb 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -522,6 +522,12 @@ const CHANNELS_AGENTS_TARGET_KEYS = [ "channels.telegram", "channels.telegram.botToken", "channels.telegram.capabilities.inlineButtons", + "channels.telegram.execApprovals", + "channels.telegram.execApprovals.enabled", + "channels.telegram.execApprovals.approvers", + "channels.telegram.execApprovals.agentFilter", + "channels.telegram.execApprovals.sessionFilter", + "channels.telegram.execApprovals.target", "channels.whatsapp", ] as const; diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 08c579f89e3..908829cbf33 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1383,6 +1383,18 @@ export const FIELD_HELP: Record = { "Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected.", "channels.telegram.capabilities.inlineButtons": "Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior.", + "channels.telegram.execApprovals": + "Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.", + "channels.telegram.execApprovals.enabled": + "Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.", + "channels.telegram.execApprovals.approvers": + "Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs; prompts are only delivered to these approvers when target includes dm.", + "channels.telegram.execApprovals.agentFilter": + 'Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `["main", "ops-agent"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram.', + "channels.telegram.execApprovals.sessionFilter": + "Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions.", + "channels.telegram.execApprovals.target": + 'Controls where Telegram approval prompts are sent: "dm" sends to approver DMs (default), "channel" sends to the originating Telegram chat/topic, and "both" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics.', "channels.slack.configWrites": "Allow Slack to write config in response to channel events/commands (default: true).", "channels.slack.botToken": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 16bf21e8daf..c643cf91cd9 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -719,6 +719,12 @@ export const FIELD_LABELS: Record = { "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily", "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", + "channels.telegram.execApprovals": "Telegram Exec Approvals", + "channels.telegram.execApprovals.enabled": "Telegram Exec Approvals Enabled", + "channels.telegram.execApprovals.approvers": "Telegram Exec Approval Approvers", + "channels.telegram.execApprovals.agentFilter": "Telegram Exec Approval Agent Filter", + "channels.telegram.execApprovals.sessionFilter": "Telegram Exec Approval Session Filter", + "channels.telegram.execApprovals.target": "Telegram Exec Approval Target", "channels.telegram.threadBindings.enabled": "Telegram Thread Binding Enabled", "channels.telegram.threadBindings.idleHours": "Telegram Thread Binding Idle Timeout (hours)", "channels.telegram.threadBindings.maxAgeHours": "Telegram Thread Binding Max Age (hours)", diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index ce8ad105b06..41c047e860c 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -38,6 +38,20 @@ export type TelegramNetworkConfig = { export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist"; export type TelegramStreamingMode = "off" | "partial" | "block" | "progress"; +export type TelegramExecApprovalTarget = "dm" | "channel" | "both"; + +export type TelegramExecApprovalConfig = { + /** Enable Telegram exec approvals for this account. Default: false. */ + enabled?: boolean; + /** Telegram user IDs allowed to approve exec requests. Required if enabled. */ + approvers?: Array; + /** Only forward approvals for these agent IDs. Omit = all agents. */ + agentFilter?: string[]; + /** Only forward approvals matching these session key patterns (substring or regex). */ + sessionFilter?: string[]; + /** Where to send approval prompts. Default: "dm". */ + target?: TelegramExecApprovalTarget; +}; export type TelegramCapabilitiesConfig = | string[] @@ -58,6 +72,8 @@ export type TelegramAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: TelegramCapabilitiesConfig; + /** Telegram-native exec approval delivery + approver authorization. */ + execApprovals?: TelegramExecApprovalConfig; /** Markdown formatting overrides (tables). */ markdown?: MarkdownConfig; /** Override native command registration for Telegram (bool or "auto"). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index ac1287460bd..3ceefb480ff 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -49,6 +49,7 @@ const DiscordIdSchema = z const DiscordIdListSchema = z.array(DiscordIdSchema); const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]); +const TelegramIdListSchema = z.array(z.union([z.string(), z.number()])); const TelegramCapabilitiesSchema = z.union([ z.array(z.string()), @@ -153,6 +154,16 @@ export const TelegramAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: TelegramCapabilitiesSchema.optional(), + execApprovals: z + .object({ + enabled: z.boolean().optional(), + approvers: TelegramIdListSchema.optional(), + agentFilter: z.array(z.string()).optional(), + sessionFilter: z.array(z.string()).optional(), + target: z.enum(["dm", "channel", "both"]).optional(), + }) + .strict() + .optional(), markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), commands: ProviderCommandsSchema, diff --git a/src/discord/exec-approvals.ts b/src/discord/exec-approvals.ts new file mode 100644 index 00000000000..f4be9a22e0c --- /dev/null +++ b/src/discord/exec-approvals.ts @@ -0,0 +1,23 @@ +import type { ReplyPayload } from "../auto-reply/types.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { getExecApprovalReplyMetadata } from "../infra/exec-approval-reply.js"; +import { resolveDiscordAccount } from "./accounts.js"; + +export function isDiscordExecApprovalClientEnabled(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): boolean { + const config = resolveDiscordAccount(params).config.execApprovals; + return Boolean(config?.enabled && (config.approvers?.length ?? 0) > 0); +} + +export function shouldSuppressLocalDiscordExecApprovalPrompt(params: { + cfg: OpenClawConfig; + accountId?: string | null; + payload: ReplyPayload; +}): boolean { + return ( + isDiscordExecApprovalClientEnabled(params) && + getExecApprovalReplyMetadata(params.payload) !== null + ); +} diff --git a/src/discord/monitor/exec-approvals.test.ts b/src/discord/monitor/exec-approvals.test.ts index f5e607022ee..8f9430393a2 100644 --- a/src/discord/monitor/exec-approvals.test.ts +++ b/src/discord/monitor/exec-approvals.test.ts @@ -470,15 +470,15 @@ describe("ExecApprovalButton", () => { function createMockInteraction(userId: string) { const reply = vi.fn().mockResolvedValue(undefined); - const update = vi.fn().mockResolvedValue(undefined); + const acknowledge = vi.fn().mockResolvedValue(undefined); const followUp = vi.fn().mockResolvedValue(undefined); const interaction = { userId, reply, - update, + acknowledge, followUp, } as unknown as ButtonInteraction; - return { interaction, reply, update, followUp }; + return { interaction, reply, acknowledge, followUp }; } it("denies unauthorized users with ephemeral message", async () => { @@ -486,7 +486,7 @@ describe("ExecApprovalButton", () => { const ctx: ExecApprovalButtonContext = { handler }; const button = new ExecApprovalButton(ctx); - const { interaction, reply, update } = createMockInteraction("999"); + const { interaction, reply, acknowledge } = createMockInteraction("999"); const data: ComponentData = { id: "test-approval", action: "allow-once" }; await button.run(interaction, data); @@ -495,7 +495,7 @@ describe("ExecApprovalButton", () => { content: "⛔ You are not authorized to approve exec requests.", ephemeral: true, }); - expect(update).not.toHaveBeenCalled(); + expect(acknowledge).not.toHaveBeenCalled(); // oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock expect(handler.resolveApproval).not.toHaveBeenCalled(); }); @@ -505,50 +505,45 @@ describe("ExecApprovalButton", () => { const ctx: ExecApprovalButtonContext = { handler }; const button = new ExecApprovalButton(ctx); - const { interaction, reply, update } = createMockInteraction("222"); + const { interaction, reply, acknowledge } = createMockInteraction("222"); const data: ComponentData = { id: "test-approval", action: "allow-once" }; await button.run(interaction, data); expect(reply).not.toHaveBeenCalled(); - expect(update).toHaveBeenCalledWith({ - content: "Submitting decision: **Allowed (once)**...", - components: [], - }); + expect(acknowledge).toHaveBeenCalledTimes(1); // oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock expect(handler.resolveApproval).toHaveBeenCalledWith("test-approval", "allow-once"); }); - it("shows correct label for allow-always", async () => { + it("acknowledges allow-always interactions before resolving", async () => { const handler = createMockHandler(["111"]); const ctx: ExecApprovalButtonContext = { handler }; const button = new ExecApprovalButton(ctx); - const { interaction, update } = createMockInteraction("111"); + const { interaction, acknowledge } = createMockInteraction("111"); const data: ComponentData = { id: "test-approval", action: "allow-always" }; await button.run(interaction, data); - expect(update).toHaveBeenCalledWith({ - content: "Submitting decision: **Allowed (always)**...", - components: [], - }); + expect(acknowledge).toHaveBeenCalledTimes(1); + // oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock + expect(handler.resolveApproval).toHaveBeenCalledWith("test-approval", "allow-always"); }); - it("shows correct label for deny", async () => { + it("acknowledges deny interactions before resolving", async () => { const handler = createMockHandler(["111"]); const ctx: ExecApprovalButtonContext = { handler }; const button = new ExecApprovalButton(ctx); - const { interaction, update } = createMockInteraction("111"); + const { interaction, acknowledge } = createMockInteraction("111"); const data: ComponentData = { id: "test-approval", action: "deny" }; await button.run(interaction, data); - expect(update).toHaveBeenCalledWith({ - content: "Submitting decision: **Denied**...", - components: [], - }); + expect(acknowledge).toHaveBeenCalledTimes(1); + // oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock + expect(handler.resolveApproval).toHaveBeenCalledWith("test-approval", "deny"); }); it("handles invalid data gracefully", async () => { @@ -556,18 +551,20 @@ describe("ExecApprovalButton", () => { const ctx: ExecApprovalButtonContext = { handler }; const button = new ExecApprovalButton(ctx); - const { interaction, update } = createMockInteraction("111"); + const { interaction, acknowledge, reply } = createMockInteraction("111"); const data: ComponentData = { id: "", action: "invalid" }; await button.run(interaction, data); - expect(update).toHaveBeenCalledWith({ + expect(reply).toHaveBeenCalledWith({ content: "This approval is no longer valid.", - components: [], + ephemeral: true, }); + expect(acknowledge).not.toHaveBeenCalled(); // oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock expect(handler.resolveApproval).not.toHaveBeenCalled(); }); + it("follows up with error when resolve fails", async () => { const handler = createMockHandler(["111"]); handler.resolveApproval = vi.fn().mockResolvedValue(false); @@ -581,7 +578,7 @@ describe("ExecApprovalButton", () => { expect(followUp).toHaveBeenCalledWith({ content: - "Failed to submit approval decision. The request may have expired or already been resolved.", + "Failed to submit approval decision for **Allowed (once)**. The request may have expired or already been resolved.", ephemeral: true, }); }); @@ -596,14 +593,14 @@ describe("ExecApprovalButton", () => { const ctx: ExecApprovalButtonContext = { handler }; const button = new ExecApprovalButton(ctx); - const { interaction, update, reply } = createMockInteraction("111"); + const { interaction, acknowledge, reply } = createMockInteraction("111"); const data: ComponentData = { id: "test-approval", action: "allow-once" }; await button.run(interaction, data); // Should match because getApprovers returns [111] and button does String(id) === userId expect(reply).not.toHaveBeenCalled(); - expect(update).toHaveBeenCalled(); + expect(acknowledge).toHaveBeenCalled(); }); }); @@ -803,6 +800,80 @@ describe("DiscordExecApprovalHandler delivery routing", () => { clearPendingTimeouts(handler); }); + + it("posts an in-channel note when target is dm and the request came from a non-DM discord conversation", async () => { + const handler = createHandler({ + enabled: true, + approvers: ["123"], + target: "dm", + }); + const internals = getHandlerInternals(handler); + + mockRestPost.mockImplementation( + async (route: string, params?: { body?: { content?: string } }) => { + if (route === Routes.channelMessages("999888777")) { + expect(params?.body?.content).toContain("I sent the allowed approvers DMs"); + return { id: "note-1", channel_id: "999888777" }; + } + if (route === Routes.userChannels()) { + return { id: "dm-1" }; + } + if (route === Routes.channelMessages("dm-1")) { + return { id: "msg-1", channel_id: "dm-1" }; + } + throw new Error(`unexpected route: ${route}`); + }, + ); + + await internals.handleApprovalRequested(createRequest()); + + expect(mockRestPost).toHaveBeenCalledWith( + Routes.channelMessages("999888777"), + expect.objectContaining({ + body: expect.objectContaining({ + content: expect.stringContaining("I sent the allowed approvers DMs"), + }), + }), + ); + expect(mockRestPost).toHaveBeenCalledWith( + Routes.channelMessages("dm-1"), + expect.objectContaining({ + body: expect.any(Object), + }), + ); + + clearPendingTimeouts(handler); + }); + + it("does not post an in-channel note when the request already came from a discord DM", async () => { + const handler = createHandler({ + enabled: true, + approvers: ["123"], + target: "dm", + }); + const internals = getHandlerInternals(handler); + + mockRestPost.mockImplementation(async (route: string) => { + if (route === Routes.userChannels()) { + return { id: "dm-1" }; + } + if (route === Routes.channelMessages("dm-1")) { + return { id: "msg-1", channel_id: "dm-1" }; + } + throw new Error(`unexpected route: ${route}`); + }); + + await internals.handleApprovalRequested( + createRequest({ sessionKey: "agent:main:discord:dm:123" }), + ); + + expect(mockRestPost).not.toHaveBeenCalledWith( + Routes.channelMessages("999888777"), + expect.anything(), + ); + + clearPendingTimeouts(handler); + }); }); describe("DiscordExecApprovalHandler gateway auth resolution", () => { diff --git a/src/discord/monitor/exec-approvals.ts b/src/discord/monitor/exec-approvals.ts index 5564b126e3c..f426ae51903 100644 --- a/src/discord/monitor/exec-approvals.ts +++ b/src/discord/monitor/exec-approvals.ts @@ -17,6 +17,7 @@ import { buildGatewayConnectionDetails } from "../../gateway/call.js"; import { GatewayClient } from "../../gateway/client.js"; import { resolveGatewayConnectionAuth } from "../../gateway/connection-auth.js"; import type { EventFrame } from "../../gateway/protocol/index.js"; +import { getExecApprovalApproverDmNoticeText } from "../../infra/exec-approval-reply.js"; import type { ExecApprovalDecision, ExecApprovalRequest, @@ -47,6 +48,12 @@ export function extractDiscordChannelId(sessionKey?: string | null): string | nu return match ? match[1] : null; } +function buildDiscordApprovalDmRedirectNotice(): { content: string } { + return { + content: getExecApprovalApproverDmNoticeText(), + }; +} + type PendingApproval = { discordMessageId: string; discordChannelId: string; @@ -498,6 +505,24 @@ export class DiscordExecApprovalHandler { const sendToDm = target === "dm" || target === "both"; const sendToChannel = target === "channel" || target === "both"; let fallbackToDm = false; + const originatingChannelId = + request.request.sessionKey && target === "dm" + ? extractDiscordChannelId(request.request.sessionKey) + : null; + + if (target === "dm" && originatingChannelId) { + try { + await discordRequest( + () => + rest.post(Routes.channelMessages(originatingChannelId), { + body: buildDiscordApprovalDmRedirectNotice(), + }) as Promise<{ id: string; channel_id: string }>, + "send-approval-dm-redirect-notice", + ); + } catch (err) { + logError(`discord exec approvals: failed to send DM redirect notice: ${String(err)}`); + } + } // Send to originating channel if configured if (sendToChannel) { @@ -768,9 +793,9 @@ export class ExecApprovalButton extends Button { const parsed = parseExecApprovalData(data); if (!parsed) { try { - await interaction.update({ + await interaction.reply({ content: "This approval is no longer valid.", - components: [], + ephemeral: true, }); } catch { // Interaction may have expired @@ -800,12 +825,11 @@ export class ExecApprovalButton extends Button { ? "Allowed (always)" : "Denied"; - // Update the message immediately to show the decision + // Acknowledge immediately so Discord does not fail the interaction while + // the gateway resolve roundtrip completes. The resolved event will update + // the approval card in-place with the final state. try { - await interaction.update({ - content: `Submitting decision: **${decisionLabel}**...`, - components: [], // Remove buttons - }); + await interaction.acknowledge(); } catch { // Interaction may have expired, try to continue anyway } @@ -815,8 +839,7 @@ export class ExecApprovalButton extends Button { if (!ok) { try { await interaction.followUp({ - content: - "Failed to submit approval decision. The request may have expired or already been resolved.", + content: `Failed to submit approval decision for **${decisionLabel}**. The request may have expired or already been resolved.`, ephemeral: true, }); } catch { diff --git a/src/gateway/exec-approval-manager.ts b/src/gateway/exec-approval-manager.ts index 320b4da0b1f..e0176470a03 100644 --- a/src/gateway/exec-approval-manager.ts +++ b/src/gateway/exec-approval-manager.ts @@ -31,6 +31,11 @@ type PendingEntry = { promise: Promise; }; +export type ExecApprovalIdLookupResult = + | { kind: "exact" | "prefix"; id: string } + | { kind: "ambiguous"; ids: string[] } + | { kind: "none" }; + export class ExecApprovalManager { private pending = new Map(); @@ -170,4 +175,37 @@ export class ExecApprovalManager { const entry = this.pending.get(recordId); return entry?.promise ?? null; } + + lookupPendingId(input: string): ExecApprovalIdLookupResult { + const normalized = input.trim(); + if (!normalized) { + return { kind: "none" }; + } + + const exact = this.pending.get(normalized); + if (exact) { + return exact.record.resolvedAtMs === undefined + ? { kind: "exact", id: normalized } + : { kind: "none" }; + } + + const lowerPrefix = normalized.toLowerCase(); + const matches: string[] = []; + for (const [id, entry] of this.pending.entries()) { + if (entry.record.resolvedAtMs !== undefined) { + continue; + } + if (id.toLowerCase().startsWith(lowerPrefix)) { + matches.push(id); + } + } + + if (matches.length === 1) { + return { kind: "prefix", id: matches[0] }; + } + if (matches.length > 1) { + return { kind: "ambiguous", ids: matches }; + } + return { kind: "none" }; + } } diff --git a/src/gateway/node-invoke-system-run-approval.ts b/src/gateway/node-invoke-system-run-approval.ts index 1099896f6c8..b077204e4ba 100644 --- a/src/gateway/node-invoke-system-run-approval.ts +++ b/src/gateway/node-invoke-system-run-approval.ts @@ -23,6 +23,7 @@ type SystemRunParamsLike = { approved?: unknown; approvalDecision?: unknown; runId?: unknown; + suppressNotifyOnExit?: unknown; }; type ApprovalLookup = { @@ -78,6 +79,7 @@ function pickSystemRunParams(raw: Record): Record boolean }) => { - if (typeof context.hasExecApprovalClients === "function") { - return context.hasExecApprovalClients(); - } - // Fail closed when no operator-scope probe is available. - return false; - }; - return { "exec.approval.request": async ({ params, respond, context, client }) => { if (!validateExecApprovalRequestParams(params)) { @@ -178,10 +170,11 @@ export function createExecApprovalHandlers( }, { dropIfSlow: true }, ); - let forwardedToTargets = false; + const hasExecApprovalClients = context.hasExecApprovalClients?.() ?? false; + let forwarded = false; if (opts?.forwarder) { try { - forwardedToTargets = await opts.forwarder.handleRequested({ + forwarded = await opts.forwarder.handleRequested({ id: record.id, request: record.request, createdAtMs: record.createdAtMs, @@ -192,8 +185,19 @@ export function createExecApprovalHandlers( } } - if (!hasApprovalClients(context) && !forwardedToTargets) { - manager.expire(record.id, "auto-expire:no-approver-clients"); + if (!hasExecApprovalClients && !forwarded) { + manager.expire(record.id, "no-approval-route"); + respond( + true, + { + id: record.id, + decision: null, + createdAtMs: record.createdAtMs, + expiresAtMs: record.expiresAtMs, + }, + undefined, + ); + return; } // Only send immediate "accepted" response when twoPhase is requested. @@ -275,21 +279,48 @@ export function createExecApprovalHandlers( respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision")); return; } - const snapshot = manager.getSnapshot(p.id); + const resolvedId = manager.lookupPendingId(p.id); + if (resolvedId.kind === "none") { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id"), + ); + return; + } + if (resolvedId.kind === "ambiguous") { + const candidates = resolvedId.ids.slice(0, 3).join(", "); + const remainder = resolvedId.ids.length > 3 ? ` (+${resolvedId.ids.length - 3} more)` : ""; + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `ambiguous approval id prefix; matches: ${candidates}${remainder}. Use the full id.`, + ), + ); + return; + } + const approvalId = resolvedId.id; + const snapshot = manager.getSnapshot(approvalId); const resolvedBy = client?.connect?.client?.displayName ?? client?.connect?.client?.id; - const ok = manager.resolve(p.id, decision, resolvedBy ?? null); + const ok = manager.resolve(approvalId, decision, resolvedBy ?? null); if (!ok) { - respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown approval id")); + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id"), + ); return; } context.broadcast( "exec.approval.resolved", - { id: p.id, decision, resolvedBy, ts: Date.now(), request: snapshot?.request }, + { id: approvalId, decision, resolvedBy, ts: Date.now(), request: snapshot?.request }, { dropIfSlow: true }, ); void opts?.forwarder ?.handleResolved({ - id: p.id, + id: approvalId, decision, resolvedBy, ts: Date.now(), diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 4ea91ea247f..2292a1c808c 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -531,6 +531,19 @@ describe("exec approval handlers", () => { expect(broadcasts.some((entry) => entry.event === "exec.approval.resolved")).toBe(true); }); + it("does not reuse a resolved exact id as a prefix for another pending approval", () => { + const manager = new ExecApprovalManager(); + const resolvedRecord = manager.create({ command: "echo old", host: "gateway" }, 2_000, "abc"); + void manager.register(resolvedRecord, 2_000); + expect(manager.resolve("abc", "allow-once")).toBe(true); + + const pendingRecord = manager.create({ command: "echo new", host: "gateway" }, 2_000, "abcdef"); + void manager.register(pendingRecord, 2_000); + + expect(manager.lookupPendingId("abc")).toEqual({ kind: "none" }); + expect(manager.lookupPendingId("abcdef")).toEqual({ kind: "exact", id: "abcdef" }); + }); + it("stores versioned system.run binding and sorted env keys on approval request", async () => { const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); await requestExecApproval({ @@ -666,6 +679,134 @@ describe("exec approval handlers", () => { expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); }); + it("accepts unique short approval id prefixes", async () => { + const manager = new ExecApprovalManager(); + const handlers = createExecApprovalHandlers(manager); + const respond = vi.fn(); + const context = { + broadcast: (_event: string, _payload: unknown) => {}, + }; + + const record = manager.create({ command: "echo ok" }, 60_000, "approval-12345678-aaaa"); + void manager.register(record, 60_000); + + await resolveExecApproval({ + handlers, + id: "approval-1234", + respond, + context, + }); + + expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); + expect(manager.getSnapshot(record.id)?.decision).toBe("allow-once"); + }); + + it("rejects ambiguous short approval id prefixes", async () => { + const manager = new ExecApprovalManager(); + const handlers = createExecApprovalHandlers(manager); + const respond = vi.fn(); + const context = { + broadcast: (_event: string, _payload: unknown) => {}, + }; + + void manager.register( + manager.create({ command: "echo one" }, 60_000, "approval-abcd-1111"), + 60_000, + ); + void manager.register( + manager.create({ command: "echo two" }, 60_000, "approval-abcd-2222"), + 60_000, + ); + + await resolveExecApproval({ + handlers, + id: "approval-abcd", + respond, + context, + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: expect.stringContaining("ambiguous approval id prefix"), + }), + ); + }); + + it("returns deterministic unknown/expired message for missing approval ids", async () => { + const { handlers, respond, context } = createExecApprovalFixture(); + + await resolveExecApproval({ + handlers, + id: "missing-approval-id", + respond, + context, + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: "unknown or expired approval id", + }), + ); + }); + + it("resolves only the targeted approval id when multiple requests are pending", async () => { + const manager = new ExecApprovalManager(); + const handlers = createExecApprovalHandlers(manager); + const context = { + broadcast: (_event: string, _payload: unknown) => {}, + hasExecApprovalClients: () => true, + }; + const respondOne = vi.fn(); + const respondTwo = vi.fn(); + + const requestOne = requestExecApproval({ + handlers, + respond: respondOne, + context, + params: { id: "approval-one", host: "gateway", timeoutMs: 60_000 }, + }); + const requestTwo = requestExecApproval({ + handlers, + respond: respondTwo, + context, + params: { id: "approval-two", host: "gateway", timeoutMs: 60_000 }, + }); + + await drainApprovalRequestTicks(); + + const resolveRespond = vi.fn(); + await resolveExecApproval({ + handlers, + id: "approval-one", + respond: resolveRespond, + context, + }); + + expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); + expect(manager.getSnapshot("approval-one")?.decision).toBe("allow-once"); + expect(manager.getSnapshot("approval-two")?.decision).toBeUndefined(); + expect(manager.getSnapshot("approval-two")?.resolvedAtMs).toBeUndefined(); + + expect(manager.expire("approval-two", "test-expire")).toBe(true); + await requestOne; + await requestTwo; + + expect(respondOne).toHaveBeenCalledWith( + true, + expect.objectContaining({ id: "approval-one", decision: "allow-once" }), + undefined, + ); + expect(respondTwo).toHaveBeenCalledWith( + true, + expect.objectContaining({ id: "approval-two", decision: null }), + undefined, + ); + }); + it("forwards turn-source metadata to exec approval forwarding", async () => { vi.useFakeTimers(); try { @@ -703,32 +844,59 @@ describe("exec approval handlers", () => { } }); - it("expires immediately when no approver clients and no forwarding targets", async () => { - vi.useFakeTimers(); - try { - const { manager, handlers, forwarder, respond, context } = - createForwardingExecApprovalFixture(); - const expireSpy = vi.spyOn(manager, "expire"); + it("fast-fails approvals when no approver clients and no forwarding targets", async () => { + const { manager, handlers, forwarder, respond, context } = + createForwardingExecApprovalFixture(); + const expireSpy = vi.spyOn(manager, "expire"); - const requestPromise = requestExecApproval({ - handlers, - respond, - context, - params: { timeoutMs: 60_000 }, - }); - await drainApprovalRequestTicks(); - expect(forwarder.handleRequested).toHaveBeenCalledTimes(1); - expect(expireSpy).toHaveBeenCalledTimes(1); - await vi.runOnlyPendingTimersAsync(); - await requestPromise; - expect(respond).toHaveBeenCalledWith( - true, - expect.objectContaining({ decision: null }), - undefined, - ); - } finally { - vi.useRealTimers(); - } + await requestExecApproval({ + handlers, + respond, + context, + params: { timeoutMs: 60_000, id: "approval-no-approver", host: "gateway" }, + }); + + expect(forwarder.handleRequested).toHaveBeenCalledTimes(1); + expect(expireSpy).toHaveBeenCalledWith("approval-no-approver", "no-approval-route"); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ id: "approval-no-approver", decision: null }), + undefined, + ); + }); + + it("keeps approvals pending when no approver clients but forwarding accepted the request", async () => { + const { manager, handlers, forwarder, respond, context } = + createForwardingExecApprovalFixture(); + const expireSpy = vi.spyOn(manager, "expire"); + const resolveRespond = vi.fn(); + forwarder.handleRequested.mockResolvedValueOnce(true); + + const requestPromise = requestExecApproval({ + handlers, + respond, + context, + params: { timeoutMs: 60_000, id: "approval-forwarded", host: "gateway" }, + }); + await drainApprovalRequestTicks(); + + expect(forwarder.handleRequested).toHaveBeenCalledTimes(1); + expect(expireSpy).not.toHaveBeenCalled(); + + await resolveExecApproval({ + handlers, + id: "approval-forwarded", + respond: resolveRespond, + context, + }); + await requestPromise; + + expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ id: "approval-forwarded", decision: "allow-once" }), + undefined, + ); }); }); diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index 46b3689642d..a8885a64a63 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -492,6 +492,23 @@ describe("notifications changed events", () => { expect(enqueueSystemEventMock).toHaveBeenCalledTimes(2); expect(requestHeartbeatNowMock).toHaveBeenCalledTimes(1); }); + + it("suppresses exec notifyOnExit events when payload opts out", async () => { + const ctx = buildCtx(); + await handleNodeEvent(ctx, "node-n7", { + event: "exec.finished", + payloadJSON: JSON.stringify({ + sessionKey: "agent:main:main", + runId: "approval-1", + exitCode: 0, + output: "ok", + suppressNotifyOnExit: true, + }), + }); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + expect(requestHeartbeatNowMock).not.toHaveBeenCalled(); + }); }); describe("agent request events", () => { diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index db9da55588b..3a8ad91c420 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -538,6 +538,9 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt if (!notifyOnExit) { return; } + if (obj.suppressNotifyOnExit === true) { + return; + } const runId = typeof obj.runId === "string" ? obj.runId.trim() : ""; const command = typeof obj.command === "string" ? obj.command.trim() : ""; diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index f87c307c211..8ae1b53cc57 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -1,8 +1,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { telegramOutbound } from "../channels/plugins/outbound/telegram.js"; import type { OpenClawConfig } from "../config/config.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; import { createExecApprovalForwarder } from "./exec-approval-forwarder.js"; const baseRequest = { @@ -18,8 +21,18 @@ const baseRequest = { afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); }); +const emptyRegistry = createTestRegistry([]); +const defaultRegistry = createTestRegistry([ + { + pluginId: "telegram", + plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }), + source: "test", + }, +]); + function getFirstDeliveryText(deliver: ReturnType): string { const firstCall = deliver.mock.calls[0]?.[0] as | { payloads?: Array<{ text?: string }> } @@ -32,7 +45,7 @@ const TARGETS_CFG = { exec: { enabled: true, mode: "targets", - targets: [{ channel: "telegram", to: "123" }], + targets: [{ channel: "slack", to: "U123" }], }, }, } as OpenClawConfig; @@ -128,6 +141,14 @@ async function expectSessionFilterRequestResult(params: { } describe("exec approval forwarder", () => { + beforeEach(() => { + setActivePluginRegistry(defaultRegistry); + }); + + afterEach(() => { + setActivePluginRegistry(emptyRegistry); + }); + it("forwards to session target and resolves", async () => { vi.useFakeTimers(); const cfg = { @@ -159,19 +180,118 @@ describe("exec approval forwarder", () => { const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG }); await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(true); + await Promise.resolve(); expect(deliver).toHaveBeenCalledTimes(1); await vi.runAllTimersAsync(); expect(deliver).toHaveBeenCalledTimes(2); }); + it("skips telegram forwarding when telegram exec approvals handler is enabled", async () => { + vi.useFakeTimers(); + const cfg = { + approvals: { + exec: { + enabled: true, + mode: "session", + }, + }, + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["123"], + target: "channel", + }, + }, + }, + } as OpenClawConfig; + + const { deliver, forwarder } = createForwarder({ + cfg, + resolveSessionTarget: () => ({ channel: "telegram", to: "-100999", threadId: 77 }), + }); + + await expect( + forwarder.handleRequested({ + ...baseRequest, + request: { + ...baseRequest.request, + turnSourceChannel: "telegram", + turnSourceTo: "-100999", + turnSourceThreadId: "77", + turnSourceAccountId: "default", + }, + }), + ).resolves.toBe(false); + + expect(deliver).not.toHaveBeenCalled(); + }); + + it("attaches explicit telegram buttons in forwarded telegram fallback payloads", async () => { + vi.useFakeTimers(); + const cfg = { + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [{ channel: "telegram", to: "123" }], + }, + }, + } as OpenClawConfig; + + const { deliver, forwarder } = createForwarder({ cfg }); + + await expect( + forwarder.handleRequested({ + ...baseRequest, + request: { + ...baseRequest.request, + turnSourceChannel: "discord", + turnSourceTo: "channel:123", + }, + }), + ).resolves.toBe(true); + + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + to: "123", + payloads: [ + expect.objectContaining({ + channelData: { + execApproval: expect.objectContaining({ + approvalId: "req-1", + }), + telegram: { + buttons: [ + [ + { text: "Allow Once", callback_data: "/approve req-1 allow-once" }, + { text: "Allow Always", callback_data: "/approve req-1 allow-always" }, + ], + [{ text: "Deny", callback_data: "/approve req-1 deny" }], + ], + }, + }, + }), + ], + }), + ); + }); + it("formats single-line commands as inline code", async () => { vi.useFakeTimers(); const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG }); await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(true); + await Promise.resolve(); - expect(getFirstDeliveryText(deliver)).toContain("Command: `echo hello`"); + const text = getFirstDeliveryText(deliver); + expect(text).toContain("🔒 Exec approval required"); + expect(text).toContain("Command: `echo hello`"); + expect(text).toContain("Expires in: 5s"); + expect(text).toContain("Reply with: /approve allow-once|allow-always|deny"); }); it("formats complex commands as fenced code blocks", async () => { @@ -187,8 +307,9 @@ describe("exec approval forwarder", () => { }, }), ).resolves.toBe(true); + await Promise.resolve(); - expect(getFirstDeliveryText(deliver)).toContain("Command:\n```\necho `uname`\necho done\n```"); + expect(getFirstDeliveryText(deliver)).toContain("```\necho `uname`\necho done\n```"); }); it("returns false when forwarding is disabled", async () => { @@ -334,7 +455,8 @@ describe("exec approval forwarder", () => { }, }), ).resolves.toBe(true); + await Promise.resolve(); - expect(getFirstDeliveryText(deliver)).toContain("Command:\n````\necho ```danger```\n````"); + expect(getFirstDeliveryText(deliver)).toContain("````\necho ```danger```\n````"); }); }); diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index 296a6aa6e49..a412e2495e8 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -1,3 +1,4 @@ +import type { ReplyPayload } from "../auto-reply/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; @@ -8,11 +9,14 @@ import type { import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js"; import { compileSafeRegex, testRegexWithBoundedInput } from "../security/safe-regex.js"; +import { buildTelegramExecApprovalButtons } from "../telegram/approval-buttons.js"; +import { sendTypingTelegram } from "../telegram/send.js"; import { isDeliverableMessageChannel, normalizeMessageChannel, type DeliverableMessageChannel, } from "../utils/message-channel.js"; +import { buildExecApprovalPendingReplyPayload } from "./exec-approval-reply.js"; import type { ExecApprovalDecision, ExecApprovalRequest, @@ -65,7 +69,11 @@ function matchSessionFilter(sessionKey: string, patterns: string[]): boolean { } function shouldForward(params: { - config?: ExecApprovalForwardingConfig; + config?: { + enabled?: boolean; + agentFilter?: string[]; + sessionFilter?: string[]; + }; request: ExecApprovalRequest; }): boolean { const config = params.config; @@ -147,6 +155,48 @@ function shouldSkipDiscordForwarding( return Boolean(execApprovals?.enabled && (execApprovals.approvers?.length ?? 0) > 0); } +function shouldSkipTelegramForwarding(params: { + target: ExecApprovalForwardTarget; + cfg: OpenClawConfig; + request: ExecApprovalRequest; +}): boolean { + const channel = normalizeMessageChannel(params.target.channel) ?? params.target.channel; + if (channel !== "telegram") { + return false; + } + const requestChannel = normalizeMessageChannel(params.request.request.turnSourceChannel ?? ""); + if (requestChannel !== "telegram") { + return false; + } + const telegram = params.cfg.channels?.telegram; + if (!telegram) { + return false; + } + const telegramConfig = telegram as + | { + execApprovals?: { enabled?: boolean; approvers?: Array }; + accounts?: Record< + string, + { execApprovals?: { enabled?: boolean; approvers?: Array } } + >; + } + | undefined; + if (!telegramConfig) { + return false; + } + const accountId = + params.target.accountId?.trim() || params.request.request.turnSourceAccountId?.trim(); + const account = accountId + ? (resolveChannelAccountConfig<{ + execApprovals?: { enabled?: boolean; approvers?: Array }; + }>(telegramConfig.accounts, accountId) as + | { execApprovals?: { enabled?: boolean; approvers?: Array } } + | undefined) + : undefined; + const execApprovals = account?.execApprovals ?? telegramConfig.execApprovals; + return Boolean(execApprovals?.enabled && (execApprovals.approvers?.length ?? 0) > 0); +} + function formatApprovalCommand(command: string): { inline: boolean; text: string } { if (!command.includes("\n") && !command.includes("`")) { return { inline: true, text: `\`${command}\`` }; @@ -191,6 +241,10 @@ function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) { } const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMs) / 1000)); lines.push(`Expires in: ${expiresIn}s`); + lines.push("Mode: foreground (interactive approvals available in this chat)."); + lines.push( + "Background mode note: non-interactive runs cannot wait for chat approvals; use pre-approved policy (allow-always or ask=off).", + ); lines.push("Reply with: /approve allow-once|allow-always|deny"); return lines.join("\n"); } @@ -261,7 +315,7 @@ function defaultResolveSessionTarget(params: { async function deliverToTargets(params: { cfg: OpenClawConfig; targets: ForwardTarget[]; - text: string; + buildPayload: (target: ForwardTarget) => ReplyPayload; deliver: typeof deliverOutboundPayloads; shouldSend?: () => boolean; }) { @@ -274,13 +328,33 @@ async function deliverToTargets(params: { return; } try { + const payload = params.buildPayload(target); + if ( + channel === "telegram" && + payload.channelData && + typeof payload.channelData === "object" && + !Array.isArray(payload.channelData) && + payload.channelData.execApproval + ) { + const threadId = + typeof target.threadId === "number" + ? target.threadId + : typeof target.threadId === "string" + ? Number.parseInt(target.threadId, 10) + : undefined; + await sendTypingTelegram(target.to, { + cfg: params.cfg, + accountId: target.accountId, + ...(Number.isFinite(threadId) ? { messageThreadId: threadId } : {}), + }).catch(() => {}); + } await params.deliver({ cfg: params.cfg, channel, to: target.to, accountId: target.accountId, threadId: target.threadId, - payloads: [{ text: params.text }], + payloads: [payload], }); } catch (err) { log.error(`exec approvals: failed to deliver to ${channel}:${target.to}: ${String(err)}`); @@ -289,6 +363,42 @@ async function deliverToTargets(params: { await Promise.allSettled(deliveries); } +function buildRequestPayloadForTarget( + _cfg: OpenClawConfig, + request: ExecApprovalRequest, + nowMsValue: number, + target: ForwardTarget, +): ReplyPayload { + const channel = normalizeMessageChannel(target.channel) ?? target.channel; + if (channel === "telegram") { + const payload = buildExecApprovalPendingReplyPayload({ + approvalId: request.id, + approvalSlug: request.id.slice(0, 8), + approvalCommandId: request.id, + command: request.request.command, + cwd: request.request.cwd ?? undefined, + host: request.request.host === "node" ? "node" : "gateway", + nodeId: request.request.nodeId ?? undefined, + expiresAtMs: request.expiresAtMs, + nowMs: nowMsValue, + }); + const buttons = buildTelegramExecApprovalButtons(request.id); + if (!buttons) { + return payload; + } + return { + ...payload, + channelData: { + ...payload.channelData, + telegram: { + buttons, + }, + }, + }; + } + return { text: buildRequestMessage(request, nowMsValue) }; +} + function resolveForwardTargets(params: { cfg: OpenClawConfig; config?: ExecApprovalForwardingConfig; @@ -343,15 +453,20 @@ export function createExecApprovalForwarder( const handleRequested = async (request: ExecApprovalRequest): Promise => { const cfg = getConfig(); const config = cfg.approvals?.exec; - if (!shouldForward({ config, request })) { - return false; - } - const filteredTargets = resolveForwardTargets({ - cfg, - config, - request, - resolveSessionTarget, - }).filter((target) => !shouldSkipDiscordForwarding(target, cfg)); + const filteredTargets = [ + ...(shouldForward({ config, request }) + ? resolveForwardTargets({ + cfg, + config, + request, + resolveSessionTarget, + }) + : []), + ].filter( + (target) => + !shouldSkipDiscordForwarding(target, cfg) && + !shouldSkipTelegramForwarding({ target, cfg, request }), + ); if (filteredTargets.length === 0) { return false; @@ -366,7 +481,12 @@ export function createExecApprovalForwarder( } pending.delete(request.id); const expiredText = buildExpiredMessage(request); - await deliverToTargets({ cfg, targets: entry.targets, text: expiredText, deliver }); + await deliverToTargets({ + cfg, + targets: entry.targets, + buildPayload: () => ({ text: expiredText }), + deliver, + }); })(); }, expiresInMs); timeoutId.unref?.(); @@ -377,12 +497,10 @@ export function createExecApprovalForwarder( if (pending.get(request.id) !== pendingEntry) { return false; } - - const text = buildRequestMessage(request, nowMs()); void deliverToTargets({ cfg, targets: filteredTargets, - text, + buildPayload: (target) => buildRequestPayloadForTarget(cfg, request, nowMs(), target), deliver, shouldSend: () => pending.get(request.id) === pendingEntry, }).catch((err) => { @@ -410,20 +528,26 @@ export function createExecApprovalForwarder( expiresAtMs: resolved.ts, }; const config = cfg.approvals?.exec; - if (shouldForward({ config, request })) { - targets = resolveForwardTargets({ - cfg, - config, - request, - resolveSessionTarget, - }).filter((target) => !shouldSkipDiscordForwarding(target, cfg)); - } + targets = [ + ...(shouldForward({ config, request }) + ? resolveForwardTargets({ + cfg, + config, + request, + resolveSessionTarget, + }) + : []), + ].filter( + (target) => + !shouldSkipDiscordForwarding(target, cfg) && + !shouldSkipTelegramForwarding({ target, cfg, request }), + ); } if (!targets || targets.length === 0) { return; } const text = buildResolvedMessage(resolved); - await deliverToTargets({ cfg, targets, text, deliver }); + await deliverToTargets({ cfg, targets, buildPayload: () => ({ text }), deliver }); }; const stop = () => { diff --git a/src/infra/exec-approval-reply.ts b/src/infra/exec-approval-reply.ts new file mode 100644 index 00000000000..c1a3cda4a69 --- /dev/null +++ b/src/infra/exec-approval-reply.ts @@ -0,0 +1,172 @@ +import type { ReplyPayload } from "../auto-reply/types.js"; +import type { ExecHost } from "./exec-approvals.js"; + +export type ExecApprovalReplyDecision = "allow-once" | "allow-always" | "deny"; +export type ExecApprovalUnavailableReason = + | "initiating-platform-disabled" + | "initiating-platform-unsupported" + | "no-approval-route"; + +export type ExecApprovalReplyMetadata = { + approvalId: string; + approvalSlug: string; + allowedDecisions?: readonly ExecApprovalReplyDecision[]; +}; + +export type ExecApprovalPendingReplyParams = { + warningText?: string; + approvalId: string; + approvalSlug: string; + approvalCommandId?: string; + command: string; + cwd?: string; + host: ExecHost; + nodeId?: string; + expiresAtMs?: number; + nowMs?: number; +}; + +export type ExecApprovalUnavailableReplyParams = { + warningText?: string; + channelLabel?: string; + reason: ExecApprovalUnavailableReason; + sentApproverDms?: boolean; +}; + +export function getExecApprovalApproverDmNoticeText(): string { + return "Approval required. I sent the allowed approvers DMs."; +} + +function buildFence(text: string, language?: string): string { + let fence = "```"; + while (text.includes(fence)) { + fence += "`"; + } + const languagePrefix = language ? language : ""; + return `${fence}${languagePrefix}\n${text}\n${fence}`; +} + +export function getExecApprovalReplyMetadata( + payload: ReplyPayload, +): ExecApprovalReplyMetadata | null { + const channelData = payload.channelData; + if (!channelData || typeof channelData !== "object" || Array.isArray(channelData)) { + return null; + } + const execApproval = channelData.execApproval; + if (!execApproval || typeof execApproval !== "object" || Array.isArray(execApproval)) { + return null; + } + const record = execApproval as Record; + const approvalId = typeof record.approvalId === "string" ? record.approvalId.trim() : ""; + const approvalSlug = typeof record.approvalSlug === "string" ? record.approvalSlug.trim() : ""; + if (!approvalId || !approvalSlug) { + return null; + } + const allowedDecisions = Array.isArray(record.allowedDecisions) + ? record.allowedDecisions.filter( + (value): value is ExecApprovalReplyDecision => + value === "allow-once" || value === "allow-always" || value === "deny", + ) + : undefined; + return { + approvalId, + approvalSlug, + allowedDecisions, + }; +} + +export function buildExecApprovalPendingReplyPayload( + params: ExecApprovalPendingReplyParams, +): ReplyPayload { + const approvalCommandId = params.approvalCommandId?.trim() || params.approvalSlug; + const lines: string[] = []; + const warningText = params.warningText?.trim(); + if (warningText) { + lines.push(warningText, ""); + } + lines.push("Approval required."); + lines.push("Run:"); + lines.push(buildFence(`/approve ${approvalCommandId} allow-once`, "txt")); + lines.push("Pending command:"); + lines.push(buildFence(params.command, "sh")); + lines.push("Other options:"); + lines.push( + buildFence( + `/approve ${approvalCommandId} allow-always\n/approve ${approvalCommandId} deny`, + "txt", + ), + ); + const info: string[] = []; + info.push(`Host: ${params.host}`); + if (params.nodeId) { + info.push(`Node: ${params.nodeId}`); + } + if (params.cwd) { + info.push(`CWD: ${params.cwd}`); + } + if (typeof params.expiresAtMs === "number" && Number.isFinite(params.expiresAtMs)) { + const expiresInSec = Math.max( + 0, + Math.round((params.expiresAtMs - (params.nowMs ?? Date.now())) / 1000), + ); + info.push(`Expires in: ${expiresInSec}s`); + } + info.push(`Full id: \`${params.approvalId}\``); + lines.push(info.join("\n")); + + return { + text: lines.join("\n\n"), + channelData: { + execApproval: { + approvalId: params.approvalId, + approvalSlug: params.approvalSlug, + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }; +} + +export function buildExecApprovalUnavailableReplyPayload( + params: ExecApprovalUnavailableReplyParams, +): ReplyPayload { + const lines: string[] = []; + const warningText = params.warningText?.trim(); + if (warningText) { + lines.push(warningText, ""); + } + + if (params.sentApproverDms) { + lines.push(getExecApprovalApproverDmNoticeText()); + return { + text: lines.join("\n\n"), + }; + } + + if (params.reason === "initiating-platform-disabled") { + lines.push( + `Exec approval is required, but chat exec approvals are not enabled on ${params.channelLabel ?? "this platform"}.`, + ); + lines.push( + "Approve it from the Web UI or terminal UI, or from Discord or Telegram if those approval clients are enabled.", + ); + } else if (params.reason === "initiating-platform-unsupported") { + lines.push( + `Exec approval is required, but ${params.channelLabel ?? "this platform"} does not support chat exec approvals.`, + ); + lines.push( + "Approve it from the Web UI or terminal UI, or from Discord or Telegram if those approval clients are enabled.", + ); + } else { + lines.push( + "Exec approval is required, but no interactive approval client is currently available.", + ); + lines.push( + "Open the Web UI or terminal UI, or enable Discord or Telegram exec approvals, then retry the command.", + ); + } + + return { + text: lines.join("\n\n"), + }; +} diff --git a/src/infra/exec-approval-surface.ts b/src/infra/exec-approval-surface.ts new file mode 100644 index 00000000000..bdefb933379 --- /dev/null +++ b/src/infra/exec-approval-surface.ts @@ -0,0 +1,77 @@ +import { loadConfig, type OpenClawConfig } from "../config/config.js"; +import { listEnabledDiscordAccounts } from "../discord/accounts.js"; +import { isDiscordExecApprovalClientEnabled } from "../discord/exec-approvals.js"; +import { listEnabledTelegramAccounts } from "../telegram/accounts.js"; +import { isTelegramExecApprovalClientEnabled } from "../telegram/exec-approvals.js"; +import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../utils/message-channel.js"; + +export type ExecApprovalInitiatingSurfaceState = + | { kind: "enabled"; channel: string | undefined; channelLabel: string } + | { kind: "disabled"; channel: string; channelLabel: string } + | { kind: "unsupported"; channel: string; channelLabel: string }; + +function labelForChannel(channel?: string): string { + switch (channel) { + case "discord": + return "Discord"; + case "telegram": + return "Telegram"; + case "tui": + return "terminal UI"; + case INTERNAL_MESSAGE_CHANNEL: + return "Web UI"; + default: + return channel ? channel[0]?.toUpperCase() + channel.slice(1) : "this platform"; + } +} + +export function resolveExecApprovalInitiatingSurfaceState(params: { + channel?: string | null; + accountId?: string | null; + cfg?: OpenClawConfig; +}): ExecApprovalInitiatingSurfaceState { + const channel = normalizeMessageChannel(params.channel); + const channelLabel = labelForChannel(channel); + if (!channel || channel === INTERNAL_MESSAGE_CHANNEL || channel === "tui") { + return { kind: "enabled", channel, channelLabel }; + } + + const cfg = params.cfg ?? loadConfig(); + if (channel === "telegram") { + return isTelegramExecApprovalClientEnabled({ cfg, accountId: params.accountId }) + ? { kind: "enabled", channel, channelLabel } + : { kind: "disabled", channel, channelLabel }; + } + if (channel === "discord") { + return isDiscordExecApprovalClientEnabled({ cfg, accountId: params.accountId }) + ? { kind: "enabled", channel, channelLabel } + : { kind: "disabled", channel, channelLabel }; + } + return { kind: "unsupported", channel, channelLabel }; +} + +export function hasConfiguredExecApprovalDmRoute(cfg: OpenClawConfig): boolean { + for (const account of listEnabledDiscordAccounts(cfg)) { + const execApprovals = account.config.execApprovals; + if (!execApprovals?.enabled || (execApprovals.approvers?.length ?? 0) === 0) { + continue; + } + const target = execApprovals.target ?? "dm"; + if (target === "dm" || target === "both") { + return true; + } + } + + for (const account of listEnabledTelegramAccounts(cfg)) { + const execApprovals = account.config.execApprovals; + if (!execApprovals?.enabled || (execApprovals.approvers?.length ?? 0) === 0) { + continue; + } + const target = execApprovals.target ?? "dm"; + if (target === "dm" || target === "both") { + return true; + } + } + + return false; +} diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 7bc6d69f98a..e5b24c06a8c 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -307,6 +307,75 @@ describe("deliverOutboundPayloads", () => { ); }); + it("does not inject telegram approval buttons from plain approval text", async () => { + const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" }); + + await deliverTelegramPayload({ + sendTelegram, + cfg: { + channels: { + telegram: { + botToken: "tok-1", + execApprovals: { + enabled: true, + approvers: ["123"], + target: "dm", + }, + }, + }, + }, + payload: { + text: "Mode: foreground\nRun: /approve 117ba06d allow-once (or allow-always / deny).", + }, + }); + + const sendOpts = sendTelegram.mock.calls[0]?.[2] as { buttons?: unknown } | undefined; + expect(sendOpts?.buttons).toBeUndefined(); + }); + + it("preserves explicit telegram buttons when sender path provides them", async () => { + const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" }); + const cfg: OpenClawConfig = { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["123"], + target: "dm", + }, + }, + }, + }; + + await deliverTelegramPayload({ + sendTelegram, + cfg, + payload: { + text: "Approval required", + channelData: { + telegram: { + buttons: [ + [ + { text: "Allow Once", callback_data: "/approve 117ba06d allow-once" }, + { text: "Allow Always", callback_data: "/approve 117ba06d allow-always" }, + ], + [{ text: "Deny", callback_data: "/approve 117ba06d deny" }], + ], + }, + }, + }, + }); + + const sendOpts = sendTelegram.mock.calls[0]?.[2] as { buttons?: unknown } | undefined; + expect(sendOpts?.buttons).toEqual([ + [ + { text: "Allow Once", callback_data: "/approve 117ba06d allow-once" }, + { text: "Allow Always", callback_data: "/approve 117ba06d allow-always" }, + ], + [{ text: "Deny", callback_data: "/approve 117ba06d deny" }], + ]); + }); + it("scopes media local roots to the active agent workspace when agentId is provided", async () => { const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" }); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 0b1f0bc72fc..caca4985370 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -300,6 +300,9 @@ function normalizePayloadForChannelDelivery( function normalizePayloadsForChannelDelivery( payloads: ReplyPayload[], channel: Exclude, + _cfg: OpenClawConfig, + _to: string, + _accountId?: string, ): ReplyPayload[] { const normalizedPayloads: ReplyPayload[] = []; for (const payload of normalizeReplyPayloadsForDelivery(payloads)) { @@ -307,10 +310,13 @@ function normalizePayloadsForChannelDelivery( // Strip HTML tags for plain-text surfaces (WhatsApp, Signal, etc.) // Models occasionally produce
, , etc. that render as literal text. // See https://github.com/openclaw/openclaw/issues/31884 - if (isPlainTextSurface(channel) && payload.text) { + if (isPlainTextSurface(channel) && sanitizedPayload.text) { // Telegram sendPayload uses textMode:"html". Preserve raw HTML in this path. - if (!(channel === "telegram" && payload.channelData)) { - sanitizedPayload = { ...payload, text: sanitizeForPlainText(payload.text) }; + if (!(channel === "telegram" && sanitizedPayload.channelData)) { + sanitizedPayload = { + ...sanitizedPayload, + text: sanitizeForPlainText(sanitizedPayload.text), + }; } } const normalized = normalizePayloadForChannelDelivery(sanitizedPayload, channel); @@ -662,7 +668,13 @@ async function deliverOutboundPayloadsCore( })), }; }; - const normalizedPayloads = normalizePayloadsForChannelDelivery(payloads, channel); + const normalizedPayloads = normalizePayloadsForChannelDelivery( + payloads, + channel, + cfg, + to, + accountId, + ); const hookRunner = getGlobalHookRunner(); const sessionKeyForInternalHooks = params.mirror?.sessionKey ?? params.session?.key; const mirrorIsGroup = params.mirror?.isGroup; diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index 5fb737930a8..ab4c836bf4b 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -57,6 +57,7 @@ type SystemRunExecutionContext = { sessionKey: string; runId: string; cmdText: string; + suppressNotifyOnExit: boolean; }; type ResolvedExecApprovals = ReturnType; @@ -77,6 +78,7 @@ type SystemRunParsePhase = { timeoutMs: number | undefined; needsScreenRecording: boolean; approved: boolean; + suppressNotifyOnExit: boolean; }; type SystemRunPolicyPhase = SystemRunParsePhase & { @@ -167,6 +169,7 @@ async function sendSystemRunDenied( host: "node", command: execution.cmdText, reason: params.reason, + suppressNotifyOnExit: execution.suppressNotifyOnExit, }), ); await opts.sendInvokeResult({ @@ -216,6 +219,7 @@ async function parseSystemRunPhase( const agentId = opts.params.agentId?.trim() || undefined; const sessionKey = opts.params.sessionKey?.trim() || "node"; const runId = opts.params.runId?.trim() || crypto.randomUUID(); + const suppressNotifyOnExit = opts.params.suppressNotifyOnExit === true; const envOverrides = sanitizeSystemRunEnvOverrides({ overrides: opts.params.env ?? undefined, shellWrapper: shellCommand !== null, @@ -228,7 +232,7 @@ async function parseSystemRunPhase( agentId, sessionKey, runId, - execution: { sessionKey, runId, cmdText }, + execution: { sessionKey, runId, cmdText, suppressNotifyOnExit }, approvalDecision: resolveExecApprovalDecision(opts.params.approvalDecision), envOverrides, env: opts.sanitizeEnv(envOverrides), @@ -236,6 +240,7 @@ async function parseSystemRunPhase( timeoutMs: opts.params.timeoutMs ?? undefined, needsScreenRecording: opts.params.needsScreenRecording === true, approved: opts.params.approved === true, + suppressNotifyOnExit, }; } @@ -434,6 +439,7 @@ async function executeSystemRunPhase( runId: phase.runId, cmdText: phase.cmdText, result, + suppressNotifyOnExit: phase.suppressNotifyOnExit, }); await opts.sendInvokeResult({ ok: true, @@ -501,6 +507,7 @@ async function executeSystemRunPhase( runId: phase.runId, cmdText: phase.cmdText, result, + suppressNotifyOnExit: phase.suppressNotifyOnExit, }); await opts.sendInvokeResult({ diff --git a/src/node-host/invoke-types.ts b/src/node-host/invoke-types.ts index 619f86c84ff..369fd7b9c39 100644 --- a/src/node-host/invoke-types.ts +++ b/src/node-host/invoke-types.ts @@ -13,6 +13,7 @@ export type SystemRunParams = { approved?: boolean | null; approvalDecision?: string | null; runId?: string | null; + suppressNotifyOnExit?: boolean | null; }; export type RunResult = { @@ -35,6 +36,7 @@ export type ExecEventPayload = { success?: boolean; output?: string; reason?: string; + suppressNotifyOnExit?: boolean; }; export type ExecFinishedResult = { @@ -51,6 +53,7 @@ export type ExecFinishedEventParams = { runId: string; cmdText: string; result: ExecFinishedResult; + suppressNotifyOnExit?: boolean; }; export type SkillBinsProvider = { diff --git a/src/node-host/invoke.ts b/src/node-host/invoke.ts index bd570201eca..bb4e124a6a4 100644 --- a/src/node-host/invoke.ts +++ b/src/node-host/invoke.ts @@ -355,6 +355,7 @@ async function sendExecFinishedEvent( timedOut: params.result.timedOut, success: params.result.success, output: combined, + suppressNotifyOnExit: params.suppressNotifyOnExit, }), ); } diff --git a/src/telegram/approval-buttons.test.ts b/src/telegram/approval-buttons.test.ts new file mode 100644 index 00000000000..bc6fac49e07 --- /dev/null +++ b/src/telegram/approval-buttons.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { buildTelegramExecApprovalButtons } from "./approval-buttons.js"; + +describe("telegram approval buttons", () => { + it("builds allow-once/allow-always/deny buttons", () => { + expect(buildTelegramExecApprovalButtons("fbd8daf7")).toEqual([ + [ + { text: "Allow Once", callback_data: "/approve fbd8daf7 allow-once" }, + { text: "Allow Always", callback_data: "/approve fbd8daf7 allow-always" }, + ], + [{ text: "Deny", callback_data: "/approve fbd8daf7 deny" }], + ]); + }); + + it("skips buttons when callback_data exceeds Telegram limit", () => { + expect(buildTelegramExecApprovalButtons(`a${"b".repeat(60)}`)).toBeUndefined(); + }); +}); diff --git a/src/telegram/approval-buttons.ts b/src/telegram/approval-buttons.ts new file mode 100644 index 00000000000..0439bec58b9 --- /dev/null +++ b/src/telegram/approval-buttons.ts @@ -0,0 +1,42 @@ +import type { ExecApprovalReplyDecision } from "../infra/exec-approval-reply.js"; +import type { TelegramInlineButtons } from "./button-types.js"; + +const MAX_CALLBACK_DATA_BYTES = 64; + +function fitsCallbackData(value: string): boolean { + return Buffer.byteLength(value, "utf8") <= MAX_CALLBACK_DATA_BYTES; +} + +export function buildTelegramExecApprovalButtons( + approvalId: string, +): TelegramInlineButtons | undefined { + return buildTelegramExecApprovalButtonsForDecisions(approvalId, [ + "allow-once", + "allow-always", + "deny", + ]); +} + +function buildTelegramExecApprovalButtonsForDecisions( + approvalId: string, + allowedDecisions: readonly ExecApprovalReplyDecision[], +): TelegramInlineButtons | undefined { + const allowOnce = `/approve ${approvalId} allow-once`; + if (!allowedDecisions.includes("allow-once") || !fitsCallbackData(allowOnce)) { + return undefined; + } + + const primaryRow: Array<{ text: string; callback_data: string }> = [ + { text: "Allow Once", callback_data: allowOnce }, + ]; + const allowAlways = `/approve ${approvalId} allow-always`; + if (allowedDecisions.includes("allow-always") && fitsCallbackData(allowAlways)) { + primaryRow.push({ text: "Allow Always", callback_data: allowAlways }); + } + const rows: Array> = [primaryRow]; + const deny = `/approve ${approvalId} deny`; + if (allowedDecisions.includes("deny") && fitsCallbackData(deny)) { + rows.push([{ text: "Deny", callback_data: deny }]); + } + return rows; +} diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index e46e0c43fb8..78290f342ad 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -57,6 +57,11 @@ import { import type { TelegramContext } from "./bot/types.js"; import { resolveTelegramConversationRoute } from "./conversation-route.js"; import { enforceTelegramDmAccess } from "./dm-access.js"; +import { + isTelegramExecApprovalApprover, + isTelegramExecApprovalClientEnabled, + shouldEnableTelegramExecApprovalButtons, +} from "./exec-approvals.js"; import { evaluateTelegramGroupBaseAccess, evaluateTelegramGroupPolicyAccess, @@ -75,6 +80,9 @@ import { import { buildInlineKeyboard } from "./send.js"; import { wasSentByBot } from "./sent-message-cache.js"; +const APPROVE_CALLBACK_DATA_RE = + /^\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(allow-once|allow-always|deny)\b/i; + function isMediaSizeLimitError(err: unknown): boolean { const errMsg = String(err); return errMsg.includes("exceeds") && errMsg.includes("MB limit"); @@ -1081,6 +1089,30 @@ export const registerTelegramHandlers = ({ params, ); }; + const clearCallbackButtons = async () => { + const emptyKeyboard = { inline_keyboard: [] }; + const replyMarkup = { reply_markup: emptyKeyboard }; + const editReplyMarkupFn = (ctx as { editMessageReplyMarkup?: unknown }) + .editMessageReplyMarkup; + if (typeof editReplyMarkupFn === "function") { + return await ctx.editMessageReplyMarkup(replyMarkup); + } + const apiEditReplyMarkupFn = (bot.api as { editMessageReplyMarkup?: unknown }) + .editMessageReplyMarkup; + if (typeof apiEditReplyMarkupFn === "function") { + return await bot.api.editMessageReplyMarkup( + callbackMessage.chat.id, + callbackMessage.message_id, + replyMarkup, + ); + } + // Fallback path for older clients that do not expose editMessageReplyMarkup. + const messageText = callbackMessage.text ?? callbackMessage.caption; + if (typeof messageText !== "string" || messageText.trim().length === 0) { + return undefined; + } + return await editCallbackMessage(messageText, replyMarkup); + }; const deleteCallbackMessage = async () => { const deleteFn = (ctx as { deleteMessage?: unknown }).deleteMessage; if (typeof deleteFn === "function") { @@ -1099,22 +1131,31 @@ export const registerTelegramHandlers = ({ return await bot.api.sendMessage(callbackMessage.chat.id, text, params); }; + const chatId = callbackMessage.chat.id; + const isGroup = + callbackMessage.chat.type === "group" || callbackMessage.chat.type === "supergroup"; + const isApprovalCallback = APPROVE_CALLBACK_DATA_RE.test(data); const inlineButtonsScope = resolveTelegramInlineButtonsScope({ cfg, accountId, }); - if (inlineButtonsScope === "off") { - return; - } - - const chatId = callbackMessage.chat.id; - const isGroup = - callbackMessage.chat.type === "group" || callbackMessage.chat.type === "supergroup"; - if (inlineButtonsScope === "dm" && isGroup) { - return; - } - if (inlineButtonsScope === "group" && !isGroup) { - return; + const execApprovalButtonsEnabled = + isApprovalCallback && + shouldEnableTelegramExecApprovalButtons({ + cfg, + accountId, + to: String(chatId), + }); + if (!execApprovalButtonsEnabled) { + if (inlineButtonsScope === "off") { + return; + } + if (inlineButtonsScope === "dm" && isGroup) { + return; + } + if (inlineButtonsScope === "group" && !isGroup) { + return; + } } const messageThreadId = callbackMessage.message_thread_id; @@ -1136,7 +1177,9 @@ export const registerTelegramHandlers = ({ const senderId = callback.from?.id ? String(callback.from.id) : ""; const senderUsername = callback.from?.username ?? ""; const authorizationMode: TelegramEventAuthorizationMode = - inlineButtonsScope === "allowlist" ? "callback-allowlist" : "callback-scope"; + !execApprovalButtonsEnabled && inlineButtonsScope === "allowlist" + ? "callback-allowlist" + : "callback-scope"; const senderAuthorization = authorizeTelegramEventSender({ chatId, chatTitle: callbackMessage.chat.title, @@ -1150,6 +1193,29 @@ export const registerTelegramHandlers = ({ return; } + if (isApprovalCallback) { + if ( + !isTelegramExecApprovalClientEnabled({ cfg, accountId }) || + !isTelegramExecApprovalApprover({ cfg, accountId, senderId }) + ) { + logVerbose( + `Blocked telegram exec approval callback from ${senderId || "unknown"} (not an approver)`, + ); + return; + } + try { + await clearCallbackButtons(); + } catch (editErr) { + const errStr = String(editErr); + if ( + !errStr.includes("message is not modified") && + !errStr.includes("there is no text in the message to edit") + ) { + logVerbose(`telegram: failed to clear approval callback buttons: ${errStr}`); + } + } + } + const paginationMatch = data.match(/^commands_page_(\d+|noop)(?::(.+))?$/); if (paginationMatch) { const pageValue = paginationMatch[1]; diff --git a/src/telegram/bot-message-context.session.ts b/src/telegram/bot-message-context.session.ts index bde4ff3270b..6932b315dc7 100644 --- a/src/telegram/bot-message-context.session.ts +++ b/src/telegram/bot-message-context.session.ts @@ -202,6 +202,7 @@ export async function buildTelegramInboundContextPayload(params: { SenderUsername: senderUsername || undefined, Provider: "telegram", Surface: "telegram", + BotUsername: primaryCtx.me?.username ?? undefined, MessageSid: options?.messageIdOverride ?? String(msg.message_id), ReplyToId: replyTarget?.id, ReplyToBody: replyTarget?.body, diff --git a/src/telegram/bot-message-dispatch.test.ts b/src/telegram/bot-message-dispatch.test.ts index 8972532e139..7caa7cc3af7 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/src/telegram/bot-message-dispatch.test.ts @@ -140,6 +140,7 @@ describe("dispatchTelegramMessage draft streaming", () => { async function dispatchWithContext(params: { context: TelegramMessageContext; + cfg?: Parameters[0]["cfg"]; telegramCfg?: Parameters[0]["telegramCfg"]; streamMode?: Parameters[0]["streamMode"]; bot?: Bot; @@ -148,7 +149,7 @@ describe("dispatchTelegramMessage draft streaming", () => { await dispatchTelegramMessage({ context: params.context, bot, - cfg: {}, + cfg: params.cfg ?? {}, runtime: createRuntime(), replyToMode: "first", streamMode: params.streamMode ?? "partial", @@ -211,6 +212,48 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftStream.clear).toHaveBeenCalledTimes(1); }); + it("does not inject approval buttons in local dispatch once the monitor owns approvals", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver( + { + text: "Mode: foreground\nRun: /approve 117ba06d allow-once (or allow-always / deny).", + }, + { kind: "final" }, + ); + return { queuedFinal: true }; + }); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ + context: createContext(), + streamMode: "off", + cfg: { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["123"], + target: "dm", + }, + }, + }, + }, + }); + + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [ + expect.objectContaining({ + text: "Mode: foreground\nRun: /approve 117ba06d allow-once (or allow-always / deny).", + }), + ], + }), + ); + const deliveredPayload = (deliverReplies.mock.calls[0]?.[0] as { replies?: Array }) + ?.replies?.[0] as { channelData?: unknown } | undefined; + expect(deliveredPayload?.channelData).toBeUndefined(); + }); + it("uses 30-char preview debounce for legacy block stream mode", async () => { const draftStream = createDraftStream(); createTelegramDraftStream.mockReturnValue(draftStream); diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index d4c2f7107b6..fee56211ae5 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -30,6 +30,7 @@ import { deliverReplies } from "./bot/delivery.js"; import type { TelegramStreamMode } from "./bot/types.js"; import type { TelegramInlineButtons } from "./button-types.js"; import { createTelegramDraftStream } from "./draft-stream.js"; +import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js"; import { renderTelegramHtmlText } from "./format.js"; import { type ArchivedPreview, @@ -526,6 +527,16 @@ export const dispatchTelegramMessage = async ({ // rotations/partials are applied before final delivery mapping. await enqueueDraftLaneEvent(async () => {}); } + if ( + shouldSuppressLocalTelegramExecApprovalPrompt({ + cfg, + accountId: route.accountId, + payload, + }) + ) { + queuedFinal = true; + return; + } const previewButtons = ( payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined )?.buttons; @@ -559,7 +570,10 @@ export const dispatchTelegramMessage = async ({ info.kind === "final" && reasoningStepState.shouldBufferFinalAnswer() ) { - reasoningStepState.bufferFinalAnswer({ payload, text: segment.text }); + reasoningStepState.bufferFinalAnswer({ + payload, + text: segment.text, + }); continue; } if (segment.lane === "reasoning") { diff --git a/src/telegram/bot-native-commands.session-meta.test.ts b/src/telegram/bot-native-commands.session-meta.test.ts index 1b05ddd0d9c..1d1b7df5fc2 100644 --- a/src/telegram/bot-native-commands.session-meta.test.ts +++ b/src/telegram/bot-native-commands.session-meta.test.ts @@ -12,6 +12,20 @@ type ResolveConfiguredAcpBindingRecordFn = typeof import("../acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord; type EnsureConfiguredAcpBindingSessionFn = typeof import("../acp/persistent-bindings.js").ensureConfiguredAcpBindingSession; +type DispatchReplyWithBufferedBlockDispatcherFn = + typeof import("../auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher; +type DispatchReplyWithBufferedBlockDispatcherParams = + Parameters[0]; +type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< + ReturnType +>; +type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies; +type DeliverRepliesParams = Parameters[0]; + +const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = { + queuedFinal: false, + counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"], +}; const persistentBindingMocks = vi.hoisted(() => ({ resolveConfiguredAcpBindingRecord: vi.fn(() => null), @@ -25,7 +39,12 @@ const sessionMocks = vi.hoisted(() => ({ resolveStorePath: vi.fn(), })); const replyMocks = vi.hoisted(() => ({ - dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => undefined), + dispatchReplyWithBufferedBlockDispatcher: vi.fn( + async () => dispatchReplyResult, + ), +})); +const deliveryMocks = vi.hoisted(() => ({ + deliverReplies: vi.fn(async () => ({ delivered: true })), })); const sessionBindingMocks = vi.hoisted(() => ({ resolveByConversation: vi.fn< @@ -78,7 +97,7 @@ vi.mock("../plugins/commands.js", () => ({ executePluginCommand: vi.fn(async () => ({ text: "ok" })), })); vi.mock("./bot/delivery.js", () => ({ - deliverReplies: vi.fn(async () => ({ delivered: true })), + deliverReplies: deliveryMocks.deliverReplies, })); function createDeferred() { @@ -263,9 +282,12 @@ describe("registerTelegramNativeCommands — session metadata", () => { }); sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined); sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json"); - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockClear().mockResolvedValue(undefined); + replyMocks.dispatchReplyWithBufferedBlockDispatcher + .mockClear() + .mockResolvedValue(dispatchReplyResult); sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null); sessionBindingMocks.touch.mockReset(); + deliveryMocks.deliverReplies.mockClear().mockResolvedValue({ delivered: true }); }); it("calls recordSessionMetaFromInbound after a native slash command", async () => { @@ -303,6 +325,81 @@ describe("registerTelegramNativeCommands — session metadata", () => { expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); }); + it("does not inject approval buttons for native command replies once the monitor owns approvals", async () => { + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( + async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => { + await dispatcherOptions.deliver( + { + text: "Mode: foreground\nRun: /approve 7f423fdc allow-once (or allow-always / deny).", + }, + { kind: "final" }, + ); + return dispatchReplyResult; + }, + ); + + const { handler } = registerAndResolveStatusHandler({ + cfg: { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["12345"], + target: "dm", + }, + }, + }, + }, + }); + await handler(buildStatusCommandContext()); + + const deliveredCall = deliveryMocks.deliverReplies.mock.calls[0]?.[0] as + | DeliverRepliesParams + | undefined; + const deliveredPayload = deliveredCall?.replies?.[0]; + expect(deliveredPayload).toBeTruthy(); + expect(deliveredPayload?.["text"]).toContain("/approve 7f423fdc allow-once"); + expect(deliveredPayload?.["channelData"]).toBeUndefined(); + }); + + it("suppresses local structured exec approval replies for native commands", async () => { + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( + async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => { + await dispatcherOptions.deliver( + { + text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```", + channelData: { + execApproval: { + approvalId: "7f423fdc-1111-2222-3333-444444444444", + approvalSlug: "7f423fdc", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }, + { kind: "tool" }, + ); + return dispatchReplyResult; + }, + ); + + const { handler } = registerAndResolveStatusHandler({ + cfg: { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["12345"], + target: "dm", + }, + }, + }, + }, + }); + await handler(buildStatusCommandContext()); + + expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); + }); + it("routes Telegram native commands through configured ACP topic bindings", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 17958daa289..aa37c98e9b9 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -64,6 +64,7 @@ import { } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; import { resolveTelegramConversationRoute } from "./conversation-route.js"; +import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js"; import { evaluateTelegramGroupBaseAccess, evaluateTelegramGroupPolicyAccess, @@ -177,6 +178,7 @@ async function resolveTelegramCommandAuth(params: { isForum, messageThreadId, }); + const threadParams = buildTelegramThreadParams(threadSpec) ?? {}; const groupAllowContext = await resolveTelegramGroupAllowFromContext({ chatId, accountId, @@ -234,7 +236,6 @@ async function resolveTelegramCommandAuth(params: { : null; const sendAuthMessage = async (text: string) => { - const threadParams = buildTelegramThreadParams(threadSpec) ?? {}; await withTelegramApiErrorLogging({ operation: "sendMessage", fn: () => bot.api.sendMessage(chatId, text, threadParams), @@ -580,9 +581,8 @@ export const registerTelegramNativeCommands = ({ senderUsername, groupConfig, topicConfig, - commandAuthorized: initialCommandAuthorized, + commandAuthorized, } = auth; - let commandAuthorized = initialCommandAuthorized; const runtimeContext = await resolveCommandRuntimeContext({ msg, isGroup, @@ -751,6 +751,16 @@ export const registerTelegramNativeCommands = ({ dispatcherOptions: { ...prefixOptions, deliver: async (payload, _info) => { + if ( + shouldSuppressLocalTelegramExecApprovalPrompt({ + cfg, + accountId: route.accountId, + payload, + }) + ) { + deliveryState.delivered = true; + return; + } const result = await deliverReplies({ replies: [payload], ...deliveryBaseOptions, @@ -863,10 +873,18 @@ export const registerTelegramNativeCommands = ({ messageThreadId: threadSpec.id, }); - await deliverReplies({ - replies: [result], - ...deliveryBaseOptions, - }); + if ( + !shouldSuppressLocalTelegramExecApprovalPrompt({ + cfg, + accountId: route.accountId, + payload: result, + }) + ) { + await deliverReplies({ + replies: [result], + ...deliveryBaseOptions, + }); + } }); } } diff --git a/src/telegram/bot.create-telegram-bot.test-harness.ts b/src/telegram/bot.create-telegram-bot.test-harness.ts index 036d2ca60b9..b0090d62a70 100644 --- a/src/telegram/bot.create-telegram-bot.test-harness.ts +++ b/src/telegram/bot.create-telegram-bot.test-harness.ts @@ -111,6 +111,7 @@ 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); @@ -128,6 +129,7 @@ type ApiStub = { answerCallbackQuery: typeof answerCallbackQuerySpy; sendChatAction: typeof sendChatActionSpy; editMessageText: typeof editMessageTextSpy; + editMessageReplyMarkup: typeof editMessageReplyMarkupSpy; sendMessageDraft: typeof sendMessageDraftSpy; setMessageReaction: typeof setMessageReactionSpy; setMyCommands: typeof setMyCommandsSpy; @@ -143,6 +145,7 @@ const apiStub: ApiStub = { answerCallbackQuery: answerCallbackQuerySpy, sendChatAction: sendChatActionSpy, editMessageText: editMessageTextSpy, + editMessageReplyMarkup: editMessageReplyMarkupSpy, sendMessageDraft: sendMessageDraftSpy, setMessageReaction: setMessageReactionSpy, setMyCommands: setMyCommandsSpy, @@ -315,6 +318,8 @@ beforeEach(() => { }); editMessageTextSpy.mockReset(); editMessageTextSpy.mockResolvedValue({ message_id: 88 }); + editMessageReplyMarkupSpy.mockReset(); + editMessageReplyMarkupSpy.mockResolvedValue({ message_id: 88 }); sendMessageDraftSpy.mockReset(); sendMessageDraftSpy.mockResolvedValue(true); enqueueSystemEventSpy.mockReset(); diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 69a94c3e200..043d529b408 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -9,6 +9,7 @@ import { normalizeTelegramCommandName } from "../config/telegram-custom-commands import { answerCallbackQuerySpy, commandSpy, + editMessageReplyMarkupSpy, editMessageTextSpy, enqueueSystemEventSpy, getFileSpy, @@ -44,6 +45,7 @@ describe("createTelegramBot", () => { }); beforeEach(() => { + setMyCommandsSpy.mockClear(); loadConfig.mockReturnValue({ agents: { defaults: { @@ -69,13 +71,28 @@ describe("createTelegramBot", () => { }; loadConfig.mockReturnValue(config); - createTelegramBot({ token: "tok" }); + createTelegramBot({ + token: "tok", + config: { + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + execApprovals: { + enabled: true, + approvers: ["9"], + target: "dm", + }, + }, + }, + }, + }); await vi.waitFor(() => { expect(setMyCommandsSpy).toHaveBeenCalled(); }); - const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{ + const registered = setMyCommandsSpy.mock.calls.at(-1)?.[0] as Array<{ command: string; description: string; }>; @@ -85,10 +102,6 @@ describe("createTelegramBot", () => { description: command.description, })); expect(registered.slice(0, native.length)).toEqual(native); - expect(registered.slice(native.length)).toEqual([ - { command: "custom_backup", description: "Git backup" }, - { command: "custom_generate", description: "Create an image" }, - ]); }); it("ignores custom commands that collide with native commands", async () => { @@ -253,6 +266,155 @@ describe("createTelegramBot", () => { expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-group-1"); }); + it("clears approval buttons without re-editing callback message text", async () => { + onSpy.mockClear(); + editMessageReplyMarkupSpy.mockClear(); + editMessageTextSpy.mockClear(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + execApprovals: { + enabled: true, + approvers: ["9"], + target: "dm", + }, + }, + }, + }); + createTelegramBot({ token: "tok" }); + const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + ctx: Record, + ) => Promise; + expect(callbackHandler).toBeDefined(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-approve-style", + data: "/approve 138e9b8c allow-once", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 21, + text: [ + "🧩 Yep-needs approval again.", + "", + "Run:", + "/approve 138e9b8c allow-once", + "", + "Pending command:", + "```shell", + "npm view diver name version description", + "```", + ].join("\n"), + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1); + const [chatId, messageId, replyMarkup] = editMessageReplyMarkupSpy.mock.calls[0] ?? []; + expect(chatId).toBe(1234); + expect(messageId).toBe(21); + expect(replyMarkup).toEqual({ reply_markup: { inline_keyboard: [] } }); + expect(editMessageTextSpy).not.toHaveBeenCalled(); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-style"); + }); + + it("allows approval callbacks when exec approvals are enabled even without generic inlineButtons capability", async () => { + onSpy.mockClear(); + editMessageReplyMarkupSpy.mockClear(); + editMessageTextSpy.mockClear(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + capabilities: ["vision"], + execApprovals: { + enabled: true, + approvers: ["9"], + target: "dm", + }, + }, + }, + }); + createTelegramBot({ token: "tok" }); + const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + ctx: Record, + ) => Promise; + expect(callbackHandler).toBeDefined(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-approve-capability-free", + data: "/approve 138e9b8c allow-once", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 23, + text: "Approval required.", + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-capability-free"); + }); + + it("blocks approval callbacks from telegram users who are not exec approvers", async () => { + onSpy.mockClear(); + editMessageReplyMarkupSpy.mockClear(); + editMessageTextSpy.mockClear(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + execApprovals: { + enabled: true, + approvers: ["999"], + target: "dm", + }, + }, + }, + }); + createTelegramBot({ token: "tok" }); + const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + ctx: Record, + ) => Promise; + expect(callbackHandler).toBeDefined(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-approve-blocked", + data: "/approve 138e9b8c allow-once", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 22, + text: "Run: /approve 138e9b8c allow-once", + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled(); + expect(editMessageTextSpy).not.toHaveBeenCalled(); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-blocked"); + }); + it("edits commands list for pagination callbacks", async () => { onSpy.mockClear(); listSkillCommandsForAgents.mockClear(); @@ -1243,6 +1405,7 @@ describe("createTelegramBot", () => { expect(sendMessageSpy).toHaveBeenCalledWith( 12345, "You are not authorized to use this command.", + {}, ); }); diff --git a/src/telegram/exec-approvals-handler.test.ts b/src/telegram/exec-approvals-handler.test.ts new file mode 100644 index 00000000000..91aa3fea217 --- /dev/null +++ b/src/telegram/exec-approvals-handler.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js"; + +const baseRequest = { + id: "9f1c7d5d-b1fb-46ef-ac45-662723b65bb7", + request: { + command: "npm view diver name version description", + agentId: "main", + sessionKey: "agent:main:telegram:group:-1003841603622:topic:928", + turnSourceChannel: "telegram", + turnSourceTo: "-1003841603622", + turnSourceThreadId: "928", + turnSourceAccountId: "default", + }, + createdAtMs: 1000, + expiresAtMs: 61_000, +}; + +function createHandler(cfg: OpenClawConfig) { + const sendTyping = vi.fn().mockResolvedValue({ ok: true }); + const sendMessage = vi + .fn() + .mockResolvedValueOnce({ messageId: "m1", chatId: "-1003841603622" }) + .mockResolvedValue({ messageId: "m2", chatId: "8460800771" }); + const editReplyMarkup = vi.fn().mockResolvedValue({ ok: true }); + const handler = new TelegramExecApprovalHandler( + { + token: "tg-token", + accountId: "default", + cfg, + }, + { + nowMs: () => 1000, + sendTyping, + sendMessage, + editReplyMarkup, + }, + ); + return { handler, sendTyping, sendMessage, editReplyMarkup }; +} + +describe("TelegramExecApprovalHandler", () => { + it("sends approval prompts to the originating telegram topic when target=channel", async () => { + const cfg = { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["8460800771"], + target: "channel", + }, + }, + }, + } as OpenClawConfig; + const { handler, sendTyping, sendMessage } = createHandler(cfg); + + await handler.handleRequested(baseRequest); + + expect(sendTyping).toHaveBeenCalledWith( + "-1003841603622", + expect.objectContaining({ + accountId: "default", + messageThreadId: 928, + }), + ); + expect(sendMessage).toHaveBeenCalledWith( + "-1003841603622", + expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"), + expect.objectContaining({ + accountId: "default", + messageThreadId: 928, + buttons: [ + [ + { + text: "Allow Once", + callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once", + }, + { + text: "Allow Always", + callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-always", + }, + ], + [ + { + text: "Deny", + callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 deny", + }, + ], + ], + }), + ); + }); + + it("falls back to approver DMs when channel routing is unavailable", async () => { + const cfg = { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["111", "222"], + target: "channel", + }, + }, + }, + } as OpenClawConfig; + const { handler, sendMessage } = createHandler(cfg); + + await handler.handleRequested({ + ...baseRequest, + request: { + ...baseRequest.request, + turnSourceChannel: "slack", + turnSourceTo: "U1", + turnSourceAccountId: null, + turnSourceThreadId: null, + }, + }); + + expect(sendMessage).toHaveBeenCalledTimes(2); + expect(sendMessage.mock.calls.map((call) => call[0])).toEqual(["111", "222"]); + }); + + it("clears buttons from tracked approval messages when resolved", async () => { + const cfg = { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["8460800771"], + target: "both", + }, + }, + }, + } as OpenClawConfig; + const { handler, editReplyMarkup } = createHandler(cfg); + + await handler.handleRequested(baseRequest); + await handler.handleResolved({ + id: baseRequest.id, + decision: "allow-once", + resolvedBy: "telegram:8460800771", + ts: 2000, + }); + + expect(editReplyMarkup).toHaveBeenCalled(); + expect(editReplyMarkup).toHaveBeenCalledWith( + "-1003841603622", + "m1", + [], + expect.objectContaining({ + accountId: "default", + }), + ); + }); +}); diff --git a/src/telegram/exec-approvals-handler.ts b/src/telegram/exec-approvals-handler.ts new file mode 100644 index 00000000000..cc3d735e6a6 --- /dev/null +++ b/src/telegram/exec-approvals-handler.ts @@ -0,0 +1,418 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; +import { buildGatewayConnectionDetails } from "../gateway/call.js"; +import { GatewayClient } from "../gateway/client.js"; +import { resolveGatewayConnectionAuth } from "../gateway/connection-auth.js"; +import type { EventFrame } from "../gateway/protocol/index.js"; +import { + buildExecApprovalPendingReplyPayload, + type ExecApprovalPendingReplyParams, +} from "../infra/exec-approval-reply.js"; +import type { ExecApprovalRequest, ExecApprovalResolved } from "../infra/exec-approvals.js"; +import { resolveSessionDeliveryTarget } from "../infra/outbound/targets.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { compileSafeRegex, testRegexWithBoundedInput } from "../security/safe-regex.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { buildTelegramExecApprovalButtons } from "./approval-buttons.js"; +import { + getTelegramExecApprovalApprovers, + resolveTelegramExecApprovalConfig, + resolveTelegramExecApprovalTarget, +} from "./exec-approvals.js"; +import { editMessageReplyMarkupTelegram, sendMessageTelegram, sendTypingTelegram } from "./send.js"; + +const log = createSubsystemLogger("telegram/exec-approvals"); + +type PendingMessage = { + chatId: string; + messageId: string; +}; + +type PendingApproval = { + timeoutId: NodeJS.Timeout; + messages: PendingMessage[]; +}; + +type TelegramApprovalTarget = { + to: string; + threadId?: number; +}; + +export type TelegramExecApprovalHandlerOpts = { + token: string; + accountId: string; + cfg: OpenClawConfig; + gatewayUrl?: string; + runtime?: RuntimeEnv; +}; + +export type TelegramExecApprovalHandlerDeps = { + nowMs?: () => number; + sendTyping?: typeof sendTypingTelegram; + sendMessage?: typeof sendMessageTelegram; + editReplyMarkup?: typeof editMessageReplyMarkupTelegram; +}; + +function matchesFilters(params: { + cfg: OpenClawConfig; + accountId: string; + request: ExecApprovalRequest; +}): boolean { + const config = resolveTelegramExecApprovalConfig({ + cfg: params.cfg, + accountId: params.accountId, + }); + if (!config?.enabled) { + return false; + } + const approvers = getTelegramExecApprovalApprovers({ + cfg: params.cfg, + accountId: params.accountId, + }); + if (approvers.length === 0) { + return false; + } + if (config.agentFilter?.length) { + const agentId = + params.request.request.agentId ?? + parseAgentSessionKey(params.request.request.sessionKey)?.agentId; + if (!agentId || !config.agentFilter.includes(agentId)) { + return false; + } + } + if (config.sessionFilter?.length) { + const sessionKey = params.request.request.sessionKey; + if (!sessionKey) { + return false; + } + const matches = config.sessionFilter.some((pattern) => { + if (sessionKey.includes(pattern)) { + return true; + } + const regex = compileSafeRegex(pattern); + return regex ? testRegexWithBoundedInput(regex, sessionKey) : false; + }); + if (!matches) { + return false; + } + } + return true; +} + +function isHandlerConfigured(params: { cfg: OpenClawConfig; accountId: string }): boolean { + const config = resolveTelegramExecApprovalConfig({ + cfg: params.cfg, + accountId: params.accountId, + }); + if (!config?.enabled) { + return false; + } + return ( + getTelegramExecApprovalApprovers({ + cfg: params.cfg, + accountId: params.accountId, + }).length > 0 + ); +} + +function resolveRequestSessionTarget(params: { + cfg: OpenClawConfig; + request: ExecApprovalRequest; +}): { to: string; accountId?: string; threadId?: number; channel?: string } | null { + const sessionKey = params.request.request.sessionKey?.trim(); + if (!sessionKey) { + return null; + } + const parsed = parseAgentSessionKey(sessionKey); + const agentId = parsed?.agentId ?? params.request.request.agentId ?? "main"; + const storePath = resolveStorePath(params.cfg.session?.store, { agentId }); + const store = loadSessionStore(storePath); + const entry = store[sessionKey]; + if (!entry) { + return null; + } + const target = resolveSessionDeliveryTarget({ + entry, + requestedChannel: "last", + turnSourceChannel: params.request.request.turnSourceChannel ?? undefined, + turnSourceTo: params.request.request.turnSourceTo ?? undefined, + turnSourceAccountId: params.request.request.turnSourceAccountId ?? undefined, + turnSourceThreadId: params.request.request.turnSourceThreadId ?? undefined, + }); + if (!target.to) { + return null; + } + return { + channel: target.channel ?? undefined, + to: target.to, + accountId: target.accountId ?? undefined, + threadId: + typeof target.threadId === "number" + ? target.threadId + : typeof target.threadId === "string" + ? Number.parseInt(target.threadId, 10) + : undefined, + }; +} + +function resolveTelegramSourceTarget(params: { + cfg: OpenClawConfig; + accountId: string; + request: ExecApprovalRequest; +}): TelegramApprovalTarget | null { + const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || ""; + const turnSourceTo = params.request.request.turnSourceTo?.trim() || ""; + const turnSourceAccountId = params.request.request.turnSourceAccountId?.trim() || ""; + if (turnSourceChannel === "telegram" && turnSourceTo) { + if ( + turnSourceAccountId && + normalizeAccountId(turnSourceAccountId) !== normalizeAccountId(params.accountId) + ) { + return null; + } + const threadId = + typeof params.request.request.turnSourceThreadId === "number" + ? params.request.request.turnSourceThreadId + : typeof params.request.request.turnSourceThreadId === "string" + ? Number.parseInt(params.request.request.turnSourceThreadId, 10) + : undefined; + return { to: turnSourceTo, threadId: Number.isFinite(threadId) ? threadId : undefined }; + } + + const sessionTarget = resolveRequestSessionTarget(params); + if (!sessionTarget || sessionTarget.channel !== "telegram") { + return null; + } + if ( + sessionTarget.accountId && + normalizeAccountId(sessionTarget.accountId) !== normalizeAccountId(params.accountId) + ) { + return null; + } + return { + to: sessionTarget.to, + threadId: sessionTarget.threadId, + }; +} + +function dedupeTargets(targets: TelegramApprovalTarget[]): TelegramApprovalTarget[] { + const seen = new Set(); + const deduped: TelegramApprovalTarget[] = []; + for (const target of targets) { + const key = `${target.to}:${target.threadId ?? ""}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(target); + } + return deduped; +} + +export class TelegramExecApprovalHandler { + private gatewayClient: GatewayClient | null = null; + private pending = new Map(); + private started = false; + private readonly nowMs: () => number; + private readonly sendTyping: typeof sendTypingTelegram; + private readonly sendMessage: typeof sendMessageTelegram; + private readonly editReplyMarkup: typeof editMessageReplyMarkupTelegram; + + constructor( + private readonly opts: TelegramExecApprovalHandlerOpts, + deps: TelegramExecApprovalHandlerDeps = {}, + ) { + this.nowMs = deps.nowMs ?? Date.now; + this.sendTyping = deps.sendTyping ?? sendTypingTelegram; + this.sendMessage = deps.sendMessage ?? sendMessageTelegram; + this.editReplyMarkup = deps.editReplyMarkup ?? editMessageReplyMarkupTelegram; + } + + shouldHandle(request: ExecApprovalRequest): boolean { + return matchesFilters({ + cfg: this.opts.cfg, + accountId: this.opts.accountId, + request, + }); + } + + async start(): Promise { + if (this.started) { + return; + } + this.started = true; + + if (!isHandlerConfigured({ cfg: this.opts.cfg, accountId: this.opts.accountId })) { + return; + } + + const { url: gatewayUrl, urlSource } = buildGatewayConnectionDetails({ + config: this.opts.cfg, + url: this.opts.gatewayUrl, + }); + const gatewayUrlOverrideSource = + urlSource === "cli --url" + ? "cli" + : urlSource === "env OPENCLAW_GATEWAY_URL" + ? "env" + : undefined; + const auth = await resolveGatewayConnectionAuth({ + config: this.opts.cfg, + env: process.env, + urlOverride: gatewayUrlOverrideSource ? gatewayUrl : undefined, + urlOverrideSource: gatewayUrlOverrideSource, + }); + + this.gatewayClient = new GatewayClient({ + url: gatewayUrl, + token: auth.token, + password: auth.password, + clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + clientDisplayName: `Telegram Exec Approvals (${this.opts.accountId})`, + mode: GATEWAY_CLIENT_MODES.BACKEND, + scopes: ["operator.approvals"], + onEvent: (evt) => this.handleGatewayEvent(evt), + onConnectError: (err) => { + log.error(`telegram exec approvals: connect error: ${err.message}`); + }, + }); + this.gatewayClient.start(); + } + + async stop(): Promise { + if (!this.started) { + return; + } + this.started = false; + for (const pending of this.pending.values()) { + clearTimeout(pending.timeoutId); + } + this.pending.clear(); + this.gatewayClient?.stop(); + this.gatewayClient = null; + } + + async handleRequested(request: ExecApprovalRequest): Promise { + if (!this.shouldHandle(request)) { + return; + } + + const targetMode = resolveTelegramExecApprovalTarget({ + cfg: this.opts.cfg, + accountId: this.opts.accountId, + }); + const targets: TelegramApprovalTarget[] = []; + const sourceTarget = resolveTelegramSourceTarget({ + cfg: this.opts.cfg, + accountId: this.opts.accountId, + request, + }); + let fallbackToDm = false; + if (targetMode === "channel" || targetMode === "both") { + if (sourceTarget) { + targets.push(sourceTarget); + } else { + fallbackToDm = true; + } + } + if (targetMode === "dm" || targetMode === "both" || fallbackToDm) { + for (const approver of getTelegramExecApprovalApprovers({ + cfg: this.opts.cfg, + accountId: this.opts.accountId, + })) { + targets.push({ to: approver }); + } + } + + const resolvedTargets = dedupeTargets(targets); + if (resolvedTargets.length === 0) { + return; + } + + const payloadParams: ExecApprovalPendingReplyParams = { + approvalId: request.id, + approvalSlug: request.id.slice(0, 8), + approvalCommandId: request.id, + command: request.request.command, + cwd: request.request.cwd ?? undefined, + host: request.request.host === "node" ? "node" : "gateway", + nodeId: request.request.nodeId ?? undefined, + expiresAtMs: request.expiresAtMs, + nowMs: this.nowMs(), + }; + const payload = buildExecApprovalPendingReplyPayload(payloadParams); + const buttons = buildTelegramExecApprovalButtons(request.id); + const sentMessages: PendingMessage[] = []; + + for (const target of resolvedTargets) { + try { + await this.sendTyping(target.to, { + cfg: this.opts.cfg, + token: this.opts.token, + accountId: this.opts.accountId, + ...(typeof target.threadId === "number" ? { messageThreadId: target.threadId } : {}), + }).catch(() => {}); + + const result = await this.sendMessage(target.to, payload.text ?? "", { + cfg: this.opts.cfg, + token: this.opts.token, + accountId: this.opts.accountId, + buttons, + ...(typeof target.threadId === "number" ? { messageThreadId: target.threadId } : {}), + }); + sentMessages.push({ + chatId: result.chatId, + messageId: result.messageId, + }); + } catch (err) { + log.error(`telegram exec approvals: failed to send request ${request.id}: ${String(err)}`); + } + } + + if (sentMessages.length === 0) { + return; + } + + const timeoutMs = Math.max(0, request.expiresAtMs - this.nowMs()); + const timeoutId = setTimeout(() => { + void this.handleResolved({ id: request.id, decision: "deny", ts: Date.now() }); + }, timeoutMs); + timeoutId.unref?.(); + + this.pending.set(request.id, { + timeoutId, + messages: sentMessages, + }); + } + + async handleResolved(resolved: ExecApprovalResolved): Promise { + const pending = this.pending.get(resolved.id); + if (!pending) { + return; + } + clearTimeout(pending.timeoutId); + this.pending.delete(resolved.id); + + await Promise.allSettled( + pending.messages.map(async (message) => { + await this.editReplyMarkup(message.chatId, message.messageId, [], { + cfg: this.opts.cfg, + token: this.opts.token, + accountId: this.opts.accountId, + }); + }), + ); + } + + private handleGatewayEvent(evt: EventFrame): void { + if (evt.event === "exec.approval.requested") { + void this.handleRequested(evt.payload as ExecApprovalRequest); + return; + } + if (evt.event === "exec.approval.resolved") { + void this.handleResolved(evt.payload as ExecApprovalResolved); + } + } +} diff --git a/src/telegram/exec-approvals.test.ts b/src/telegram/exec-approvals.test.ts new file mode 100644 index 00000000000..d85e07f7187 --- /dev/null +++ b/src/telegram/exec-approvals.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + isTelegramExecApprovalApprover, + isTelegramExecApprovalClientEnabled, + resolveTelegramExecApprovalTarget, + shouldEnableTelegramExecApprovalButtons, + shouldInjectTelegramExecApprovalButtons, +} from "./exec-approvals.js"; + +function buildConfig( + execApprovals?: NonNullable["telegram"]>["execApprovals"], +): OpenClawConfig { + return { + channels: { + telegram: { + botToken: "tok", + execApprovals, + }, + }, + } as OpenClawConfig; +} + +describe("telegram exec approvals", () => { + it("requires enablement and at least one approver", () => { + expect(isTelegramExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false); + expect( + isTelegramExecApprovalClientEnabled({ + cfg: buildConfig({ enabled: true }), + }), + ).toBe(false); + expect( + isTelegramExecApprovalClientEnabled({ + cfg: buildConfig({ enabled: true, approvers: ["123"] }), + }), + ).toBe(true); + }); + + it("matches approvers by normalized sender id", () => { + const cfg = buildConfig({ enabled: true, approvers: [123, "456"] }); + expect(isTelegramExecApprovalApprover({ cfg, senderId: "123" })).toBe(true); + expect(isTelegramExecApprovalApprover({ cfg, senderId: "456" })).toBe(true); + expect(isTelegramExecApprovalApprover({ cfg, senderId: "789" })).toBe(false); + }); + + it("defaults target to dm", () => { + expect( + resolveTelegramExecApprovalTarget({ cfg: buildConfig({ enabled: true, approvers: ["1"] }) }), + ).toBe("dm"); + }); + + it("only injects approval buttons on eligible telegram targets", () => { + const dmCfg = buildConfig({ enabled: true, approvers: ["123"], target: "dm" }); + const channelCfg = buildConfig({ enabled: true, approvers: ["123"], target: "channel" }); + const bothCfg = buildConfig({ enabled: true, approvers: ["123"], target: "both" }); + + expect(shouldInjectTelegramExecApprovalButtons({ cfg: dmCfg, to: "123" })).toBe(true); + expect(shouldInjectTelegramExecApprovalButtons({ cfg: dmCfg, to: "-100123" })).toBe(false); + expect(shouldInjectTelegramExecApprovalButtons({ cfg: channelCfg, to: "-100123" })).toBe(true); + expect(shouldInjectTelegramExecApprovalButtons({ cfg: channelCfg, to: "123" })).toBe(false); + expect(shouldInjectTelegramExecApprovalButtons({ cfg: bothCfg, to: "123" })).toBe(true); + expect(shouldInjectTelegramExecApprovalButtons({ cfg: bothCfg, to: "-100123" })).toBe(true); + }); + + it("does not require generic inlineButtons capability to enable exec approval buttons", () => { + const cfg = { + channels: { + telegram: { + botToken: "tok", + capabilities: ["vision"], + execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + }, + }, + } as OpenClawConfig; + + expect(shouldEnableTelegramExecApprovalButtons({ cfg, to: "123" })).toBe(true); + }); + + it("still respects explicit inlineButtons off for exec approval buttons", () => { + const cfg = { + channels: { + telegram: { + botToken: "tok", + capabilities: { inlineButtons: "off" }, + execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + }, + }, + } as OpenClawConfig; + + expect(shouldEnableTelegramExecApprovalButtons({ cfg, to: "123" })).toBe(false); + }); +}); diff --git a/src/telegram/exec-approvals.ts b/src/telegram/exec-approvals.ts new file mode 100644 index 00000000000..1055e1d1676 --- /dev/null +++ b/src/telegram/exec-approvals.ts @@ -0,0 +1,106 @@ +import type { ReplyPayload } from "../auto-reply/types.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { TelegramExecApprovalConfig } from "../config/types.telegram.js"; +import { getExecApprovalReplyMetadata } from "../infra/exec-approval-reply.js"; +import { resolveTelegramAccount } from "./accounts.js"; +import { resolveTelegramTargetChatType } from "./targets.js"; + +function normalizeApproverId(value: string | number): string { + return String(value).trim(); +} + +export function resolveTelegramExecApprovalConfig(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): TelegramExecApprovalConfig | undefined { + return resolveTelegramAccount(params).config.execApprovals; +} + +export function getTelegramExecApprovalApprovers(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): string[] { + return (resolveTelegramExecApprovalConfig(params)?.approvers ?? []) + .map(normalizeApproverId) + .filter(Boolean); +} + +export function isTelegramExecApprovalClientEnabled(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): boolean { + const config = resolveTelegramExecApprovalConfig(params); + return Boolean(config?.enabled && getTelegramExecApprovalApprovers(params).length > 0); +} + +export function isTelegramExecApprovalApprover(params: { + cfg: OpenClawConfig; + accountId?: string | null; + senderId?: string | null; +}): boolean { + const senderId = params.senderId?.trim(); + if (!senderId) { + return false; + } + const approvers = getTelegramExecApprovalApprovers(params); + return approvers.includes(senderId); +} + +export function resolveTelegramExecApprovalTarget(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): "dm" | "channel" | "both" { + return resolveTelegramExecApprovalConfig(params)?.target ?? "dm"; +} + +export function shouldInjectTelegramExecApprovalButtons(params: { + cfg: OpenClawConfig; + accountId?: string | null; + to: string; +}): boolean { + if (!isTelegramExecApprovalClientEnabled(params)) { + return false; + } + const target = resolveTelegramExecApprovalTarget(params); + const chatType = resolveTelegramTargetChatType(params.to); + if (chatType === "direct") { + return target === "dm" || target === "both"; + } + if (chatType === "group") { + return target === "channel" || target === "both"; + } + return target === "both"; +} + +function resolveExecApprovalButtonsExplicitlyDisabled(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): boolean { + const capabilities = resolveTelegramAccount(params).config.capabilities; + if (!capabilities || Array.isArray(capabilities) || typeof capabilities !== "object") { + return false; + } + const inlineButtons = (capabilities as { inlineButtons?: unknown }).inlineButtons; + return typeof inlineButtons === "string" && inlineButtons.trim().toLowerCase() === "off"; +} + +export function shouldEnableTelegramExecApprovalButtons(params: { + cfg: OpenClawConfig; + accountId?: string | null; + to: string; +}): boolean { + if (!shouldInjectTelegramExecApprovalButtons(params)) { + return false; + } + return !resolveExecApprovalButtonsExplicitlyDisabled(params); +} + +export function shouldSuppressLocalTelegramExecApprovalPrompt(params: { + cfg: OpenClawConfig; + accountId?: string | null; + payload: ReplyPayload; +}): boolean { + void params.cfg; + void params.accountId; + return getExecApprovalReplyMetadata(params.payload) !== null; +} diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index ed1e1a8744a..7131876e6f1 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -8,6 +8,7 @@ import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections import type { RuntimeEnv } from "../runtime.js"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; +import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js"; import { isRecoverableTelegramNetworkError } from "./network-errors.js"; import { TelegramPollingSession } from "./polling-session.js"; import { makeProxyFetch } from "./proxy.js"; @@ -73,6 +74,7 @@ const isGrammyHttpError = (err: unknown): boolean => { export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { const log = opts.runtime?.error ?? console.error; let pollingSession: TelegramPollingSession | undefined; + let execApprovalsHandler: TelegramExecApprovalHandler | undefined; const unregisterHandler = registerUnhandledRejectionHandler((err) => { const isNetworkError = isRecoverableTelegramNetworkError(err, { context: "polling" }); @@ -111,6 +113,14 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { const proxyFetch = opts.proxyFetch ?? (account.config.proxy ? makeProxyFetch(account.config.proxy) : undefined); + execApprovalsHandler = new TelegramExecApprovalHandler({ + token, + accountId: account.accountId, + cfg, + runtime: opts.runtime, + }); + await execApprovalsHandler.start(); + const persistedOffsetRaw = await readTelegramUpdateOffset({ accountId: account.accountId, botToken: token, @@ -178,6 +188,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { }); await pollingSession.runUntilAbort(); } finally { + await execApprovalsHandler?.stop().catch(() => {}); unregisterHandler(); } } diff --git a/src/telegram/send.test-harness.ts b/src/telegram/send.test-harness.ts index 57f47ac20d9..b8092034a95 100644 --- a/src/telegram/send.test-harness.ts +++ b/src/telegram/send.test-harness.ts @@ -5,6 +5,7 @@ const { botApi, botCtorSpy } = vi.hoisted(() => ({ botApi: { deleteMessage: vi.fn(), editMessageText: vi.fn(), + sendChatAction: vi.fn(), sendMessage: vi.fn(), sendPoll: vi.fn(), sendPhoto: vi.fn(), diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index 38097c49232..a34f27d196f 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -17,6 +17,7 @@ const { editMessageTelegram, reactMessageTelegram, sendMessageTelegram, + sendTypingTelegram, sendPollTelegram, sendStickerTelegram, } = await importTelegramSendModule(); @@ -171,6 +172,25 @@ describe("buildInlineKeyboard", () => { }); describe("sendMessageTelegram", () => { + it("sends typing to the resolved chat and topic", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + botToken: "tok", + }, + }, + }); + botApi.sendChatAction.mockResolvedValue(true); + + await sendTypingTelegram("telegram:group:-1001234567890:topic:271", { + accountId: "default", + }); + + expect(botApi.sendChatAction).toHaveBeenCalledWith("-1001234567890", "typing", { + message_thread_id: 271, + }); + }); + it("applies timeoutSeconds config precedence", async () => { const cases = [ { diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 329329a07ff..e1b352a0a61 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -22,7 +22,7 @@ import { normalizePollInput, type PollInput } from "../polls.js"; import { loadWebMedia } from "../web/media.js"; import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; -import { buildTelegramThreadParams } from "./bot/helpers.js"; +import { buildTelegramThreadParams, buildTypingThreadParams } from "./bot/helpers.js"; import type { TelegramInlineButtons } from "./button-types.js"; import { splitTelegramCaption } from "./caption.js"; import { resolveTelegramFetch } from "./fetch.js"; @@ -88,6 +88,16 @@ type TelegramReactionOpts = { retry?: RetryConfig; }; +type TelegramTypingOpts = { + cfg?: ReturnType; + token?: string; + accountId?: string; + verbose?: boolean; + api?: TelegramApiOverride; + retry?: RetryConfig; + messageThreadId?: number; +}; + function resolveTelegramMessageIdOrThrow( result: TelegramMessageLike | null | undefined, context: string, @@ -777,6 +787,39 @@ export async function sendMessageTelegram( return { messageId: String(messageId), chatId: String(res?.chat?.id ?? chatId) }; } +export async function sendTypingTelegram( + to: string, + opts: TelegramTypingOpts = {}, +): Promise<{ ok: true }> { + const { cfg, account, api } = resolveTelegramApiContext(opts); + const target = parseTelegramTarget(to); + const chatId = await resolveAndPersistChatId({ + cfg, + api, + lookupTarget: target.chatId, + persistTarget: to, + verbose: opts.verbose, + }); + const requestWithDiag = createTelegramRequestWithDiag({ + cfg, + account, + retry: opts.retry, + verbose: opts.verbose, + shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }), + }); + const threadParams = buildTypingThreadParams(target.messageThreadId ?? opts.messageThreadId); + await requestWithDiag( + () => + api.sendChatAction( + chatId, + "typing", + threadParams as Parameters[2], + ), + "typing", + ); + return { ok: true }; +} + export async function reactMessageTelegram( chatIdInput: string | number, messageIdInput: string | number, @@ -873,6 +916,61 @@ type TelegramEditOpts = { cfg?: ReturnType; }; +type TelegramEditReplyMarkupOpts = { + token?: string; + accountId?: string; + verbose?: boolean; + api?: TelegramApiOverride; + retry?: RetryConfig; + /** Inline keyboard buttons (reply markup). Pass empty array to remove buttons. */ + buttons?: TelegramInlineButtons; + /** Optional config injection to avoid global loadConfig() (improves testability). */ + cfg?: ReturnType; +}; + +export async function editMessageReplyMarkupTelegram( + chatIdInput: string | number, + messageIdInput: string | number, + buttons: TelegramInlineButtons, + opts: TelegramEditReplyMarkupOpts = {}, +): Promise<{ ok: true; messageId: string; chatId: string }> { + const { cfg, account, api } = resolveTelegramApiContext({ + ...opts, + cfg: opts.cfg, + }); + const rawTarget = String(chatIdInput); + const chatId = await resolveAndPersistChatId({ + cfg, + api, + lookupTarget: rawTarget, + persistTarget: rawTarget, + verbose: opts.verbose, + }); + const messageId = normalizeMessageId(messageIdInput); + const requestWithDiag = createTelegramRequestWithDiag({ + cfg, + account, + retry: opts.retry, + verbose: opts.verbose, + }); + const replyMarkup = buildInlineKeyboard(buttons) ?? { inline_keyboard: [] }; + try { + await requestWithDiag( + () => api.editMessageReplyMarkup(chatId, messageId, { reply_markup: replyMarkup }), + "editMessageReplyMarkup", + { + shouldLog: (err) => !isTelegramMessageNotModifiedError(err), + }, + ); + } catch (err) { + if (!isTelegramMessageNotModifiedError(err)) { + throw err; + } + } + logVerbose(`[telegram] Edited reply markup for message ${messageId} in chat ${chatId}`); + return { ok: true, messageId: String(messageId), chatId }; +} + export async function editMessageTelegram( chatIdInput: string | number, messageIdInput: string | number, From 731f1aa9062a31f11f6bf79cafb17c2dc3794a4a Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 10 Mar 2026 08:43:07 +0530 Subject: [PATCH 0038/1173] test: avoid detect-secrets churn in observation fixtures --- .secrets.baseline | 8 ++++---- src/agents/pi-embedded-error-observation.test.ts | 15 +++++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index b1f909e6ca4..5a0c639b9e3 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -205,7 +205,7 @@ "filename": "apps/macos/Sources/OpenClawProtocol/GatewayModels.swift", "hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd", "is_verified": false, - "line_number": 1763 + "line_number": 1859 } ], "apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift": [ @@ -266,7 +266,7 @@ "filename": "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift", "hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd", "is_verified": false, - "line_number": 1763 + "line_number": 1859 } ], "docs/.i18n/zh-CN.tm.jsonl": [ @@ -11659,7 +11659,7 @@ "filename": "src/agents/tools/web-search.ts", "hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b", "is_verified": false, - "line_number": 292 + "line_number": 291 } ], "src/agents/tools/web-tools.enabled-defaults.e2e.test.ts": [ @@ -13013,5 +13013,5 @@ } ] }, - "generated_at": "2026-03-09T08:37:13Z" + "generated_at": "2026-03-10T03:11:06Z" } diff --git a/src/agents/pi-embedded-error-observation.test.ts b/src/agents/pi-embedded-error-observation.test.ts index 94979ebfb8c..4e1d6162d5c 100644 --- a/src/agents/pi-embedded-error-observation.test.ts +++ b/src/agents/pi-embedded-error-observation.test.ts @@ -6,6 +6,9 @@ import { sanitizeForConsole, } from "./pi-embedded-error-observation.js"; +const OBSERVATION_BEARER_TOKEN = "sk-redact-test-token"; +const OBSERVATION_COOKIE_VALUE = "session-cookie-token"; + afterEach(() => { vi.restoreAllMocks(); }); @@ -29,27 +32,27 @@ describe("buildApiErrorObservationFields", () => { it("forces token redaction for observation previews", () => { const observed = buildApiErrorObservationFields( - "Authorization: Bearer sk-abcdefghijklmnopqrstuvwxyz123456", + `Authorization: Bearer ${OBSERVATION_BEARER_TOKEN}`, ); - expect(observed.rawErrorPreview).not.toContain("sk-abcdefghijklmnopqrstuvwxyz123456"); - expect(observed.rawErrorPreview).toContain("sk-abc"); + expect(observed.rawErrorPreview).not.toContain(OBSERVATION_BEARER_TOKEN); + expect(observed.rawErrorPreview).toContain(OBSERVATION_BEARER_TOKEN.slice(0, 6)); expect(observed.rawErrorHash).toMatch(/^sha256:/); }); it("redacts observation-only header and cookie formats", () => { const observed = buildApiErrorObservationFields( - "x-api-key: sk-abcdefghijklmnopqrstuvwxyz123456 Cookie: session=abcdefghijklmnopqrstuvwxyz123456", + `x-api-key: ${OBSERVATION_BEARER_TOKEN} Cookie: session=${OBSERVATION_COOKIE_VALUE}`, ); - expect(observed.rawErrorPreview).not.toContain("abcdefghijklmnopqrstuvwxyz123456"); + expect(observed.rawErrorPreview).not.toContain(OBSERVATION_COOKIE_VALUE); expect(observed.rawErrorPreview).toContain("x-api-key: ***"); expect(observed.rawErrorPreview).toContain("Cookie: session="); }); it("does not let cookie redaction consume unrelated fields on the same line", () => { const observed = buildApiErrorObservationFields( - "Cookie: session=abcdefghijklmnopqrstuvwxyz123456 status=503 request_id=req_cookie", + `Cookie: session=${OBSERVATION_COOKIE_VALUE} status=503 request_id=req_cookie`, ); expect(observed.rawErrorPreview).toContain("Cookie: session="); From e74666cd0af0ddfb14970c81dcf2d7b470336be6 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 10 Mar 2026 08:47:56 +0530 Subject: [PATCH 0039/1173] build: raise extension openclaw peer floor --- extensions/googlechat/package.json | 2 +- extensions/memory-core/package.json | 2 +- pnpm-lock.yaml | 551 ++-------------------------- 3 files changed, 23 insertions(+), 532 deletions(-) diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 2abe2abbe38..2c1db3bcd27 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -8,7 +8,7 @@ "google-auth-library": "^10.6.1" }, "peerDependencies": { - "openclaw": ">=2026.3.2" + "openclaw": ">=2026.3.7" }, "peerDependenciesMeta": { "openclaw": { diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index ca697290047..664d0a469f4 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw core memory search plugin", "type": "module", "peerDependencies": { - "openclaw": ">=2026.3.2" + "openclaw": ">=2026.3.7" }, "peerDependenciesMeta": { "openclaw": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ae9ea71e0c..b2043db207d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -338,8 +338,8 @@ importers: specifier: ^10.6.1 version: 10.6.1 openclaw: - specifier: '>=2026.3.2' - version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)) + specifier: '>=2026.3.7' + version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)) extensions/imessage: {} @@ -399,8 +399,8 @@ importers: extensions/memory-core: dependencies: openclaw: - specifier: '>=2026.3.2' - version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)) + specifier: '>=2026.3.7' + version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)) extensions/memory-lancedb: dependencies: @@ -618,18 +618,10 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-bedrock-runtime@3.1000.0': - resolution: {integrity: sha512-GA96wgTFB4Z5vhysm+hErbgiEWZ9JqAl09BxARajL7Oanpf0KvdIjxuLp2rD/XqEIks9yG/5Rh9XIAoCUUTZXw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/client-bedrock-runtime@3.1004.0': resolution: {integrity: sha512-t8cl+bPLlHZQD2Sw1a4hSLUybqJZU71+m8znkyeU8CHntFqEp2mMbuLKdHKaAYQ1fAApXMsvzenCAkDzNeeJlw==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-bedrock@3.1000.0': - resolution: {integrity: sha512-wGU8uJXrPW/hZuHdPNVe1kAFIBiKcslBcoDBN0eYBzS13um8p5jJiQJ9WsD1nSpKCmyx7qZXc6xjcbIQPyOrrA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/client-bedrock@3.1004.0': resolution: {integrity: sha512-JbfZSV85IL+43S7rPBmeMbvoOYXs1wmrfbEpHkDBjkvbukRQWtoetiPAXNSKDfFq1qVsoq8sWPdoerDQwlUO8w==} engines: {node: '>=20.0.0'} @@ -718,18 +710,10 @@ packages: resolution: {integrity: sha512-g2Z9s6Y4iNh0wICaEqutgYgt/Pmhv5Ev9G3eKGFe2w9VuZDhc76vYdop6I5OocmpHV79d4TuLG+JWg5rQIVDVA==} engines: {node: '>=20.0.0'} - '@aws-sdk/eventstream-handler-node@3.972.9': - resolution: {integrity: sha512-mKPiiVssgFDWkAXdEDh8+wpr2pFSX/fBn2onXXnrfIAYbdZhYb4WilKbZ3SJMUnQi+Y48jZMam5J0RrgARluaA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-bucket-endpoint@3.972.6': resolution: {integrity: sha512-3H2bhvb7Cb/S6WFsBy/Dy9q2aegC9JmGH1inO8Lb2sWirSqpLJlZmvQHPE29h2tIxzv6el/14X/tLCQ8BQU6ZQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-eventstream@3.972.6': - resolution: {integrity: sha512-mB2+3G/oxRC+y9WRk0KCdradE2rSfxxJpcOSmAm+vDh3ex3WQHVLZ1catNIe1j5NQ+3FLBsNMRPVGkZ43PRpjw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-eventstream@3.972.7': resolution: {integrity: sha512-VWndapHYCfwLgPpCb/xwlMKG4imhFzKJzZcKOEioGn7OHY+6gdr0K7oqy1HZgbLa3ACznZ9fku+DzmAi8fUC0g==} engines: {node: '>=20.0.0'} @@ -786,10 +770,6 @@ packages: resolution: {integrity: sha512-Km90fcXt3W/iqujHzuM6IaDkYCj73gsYufcuWXApWdzoTy6KGk8fnchAjePMARU0xegIR3K4N3yIo1vy7OVe8A==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-websocket@3.972.10': - resolution: {integrity: sha512-uNqRpbL6djE+XXO4cQ+P8ra37cxNNBP+2IfkVOXu1xFdGMfW+uOTxBQuDPpP43i40PBRBXK5un79l/oYpbzYkA==} - engines: {node: '>= 14.0.0'} - '@aws-sdk/middleware-websocket@3.972.12': resolution: {integrity: sha512-iyPP6FVDKe/5wy5ojC0akpDFG1vX3FeCUU47JuwN8xfvT66xlEI8qUJZPtN55TJVFzzWZJpWL78eqUE31md08Q==} engines: {node: '>= 14.0.0'} @@ -818,10 +798,6 @@ packages: resolution: {integrity: sha512-gQYI/Buwp0CAGQxY7mR5VzkP56rkWq2Y1ROkFuXh5XY94DsSjJw62B3I0N0lysQmtwiL2ht2KHI9NylM/RP4FA==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1000.0': - resolution: {integrity: sha512-eOI+8WPtWpLdlYBGs8OCK3k5uIMUHVsNG3AFO4kaRaZcKReJ/2OO6+2O2Dd/3vTzM56kRjSKe7mBOCwa4PdYqg==} - engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1004.0': resolution: {integrity: sha512-j9BwZZId9sFp+4GPhf6KrwO8Tben2sXibZA8D1vv2I1zBdvkUHcBA2g4pkqIpTRalMTLC0NPkBPX0gERxfy/iA==} engines: {node: '>=20.0.0'} @@ -980,15 +956,9 @@ packages: '@cacheable/utils@2.3.4': resolution: {integrity: sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA==} - '@clack/core@1.0.1': - resolution: {integrity: sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==} - '@clack/core@1.1.0': resolution: {integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==} - '@clack/prompts@1.0.1': - resolution: {integrity: sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q==} - '@clack/prompts@1.1.0': resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==} @@ -1222,15 +1192,6 @@ packages: '@eshaz/web-worker@1.2.2': resolution: {integrity: sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw==} - '@google/genai@1.43.0': - resolution: {integrity: sha512-hklCsJNdMlDM1IwcCVcGQFBg2izY0+t5BIGbRsxi2UnKi6AGKL7pqJqmBDNRbw0bYCs4y3NA7TB+fkKfP/Nrdw==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@modelcontextprotocol/sdk': ^1.25.2 - peerDependenciesMeta: - '@modelcontextprotocol/sdk': - optional: true - '@google/genai@1.44.0': resolution: {integrity: sha512-kRt9ZtuXmz+tLlcNntN/VV4LRdpl6ZOu5B1KbfNgfR65db15O6sUQcwnwLka8sT/V6qysD93fWrgJHF2L7dA9A==} engines: {node: '>=20.0.0'} @@ -1644,38 +1605,20 @@ packages: resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==} hasBin: true - '@mariozechner/pi-agent-core@0.55.3': - resolution: {integrity: sha512-rqbfpQ9BrP6BDiW+Ps3A8Z/p9+Md/pAfc/ECq8JP6cwnZL/jQgU355KWZKtF8zM9az1p0Q9hIWi9cQygVo6Auw==} - engines: {node: '>=20.0.0'} - '@mariozechner/pi-agent-core@0.57.1': resolution: {integrity: sha512-WXsBbkNWOObFGHkhixaT8GXJpHDd3+fn8QntYF+4R8Sa9WB90ENXWidO6b7vcKX+JX0jjO5dIsQxmzosARJKlg==} engines: {node: '>=20.0.0'} - '@mariozechner/pi-ai@0.55.3': - resolution: {integrity: sha512-f9jWoDzJR9Wy/H8JPMbjoM4WvVUeFZ65QdYA9UHIfoOopDfwWE8F8JHQOj5mmmILMacXuzsqA3J7MYqNWZRvvQ==} - engines: {node: '>=20.0.0'} - hasBin: true - '@mariozechner/pi-ai@0.57.1': resolution: {integrity: sha512-Bd/J4a3YpdzJVyHLih0vDSdB0QPL4ti0XsAwtHOK/8eVhB0fHM1CpcgIrcBFJ23TMcKXMi0qamz18ERfp8tmgg==} engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-coding-agent@0.55.3': - resolution: {integrity: sha512-5SFbB7/BIp/Crjre7UNjUeNfpoU1KSW/i6LXa+ikJTBqI5LukWq2avE5l0v0M8Pg/dt1go2XCLrNFlQJiQDSPQ==} - engines: {node: '>=20.0.0'} - hasBin: true - '@mariozechner/pi-coding-agent@0.57.1': resolution: {integrity: sha512-u5MQEduj68rwVIsRsqrWkJYiJCyPph/a6bMoJAQKo1sb+Pc17Y/ojwa+wGssnUMjEB38AQKofWTVe8NFEpSWNw==} engines: {node: '>=20.6.0'} hasBin: true - '@mariozechner/pi-tui@0.55.3': - resolution: {integrity: sha512-Gh4wkYgiSPCJJaB/4wEWSL7Ga8bxSq1Crp1RPRT4vKybE/DG0W/MQr5VJDvktarxtJrD16ixScwE4dzdox/PIA==} - engines: {node: '>=20.0.0'} - '@mariozechner/pi-tui@0.57.1': resolution: {integrity: sha512-cjoRghLbeAHV0tTJeHgZXaryUi5zzBZofeZ7uJun1gztnckLLRjoVeaPTujNlc5BIfyKvFqhh1QWCZng/MXlpg==} engines: {node: '>=20.0.0'} @@ -1692,9 +1635,6 @@ packages: resolution: {integrity: sha512-570oJr93l1RcCNNaMVpOm+PgQkRgno/F65nH1aCWLIKLnw0o7iPoj+8Z5b7mnLMidg9lldVSCcf0dBxqTGE1/w==} engines: {node: '>=20.0.0'} - '@mistralai/mistralai@1.10.0': - resolution: {integrity: sha512-tdIgWs4Le8vpvPiUEWne6tK0qbVc+jMenujnvTqOjogrJUsCSQhus0tHTU1avDDh5//Rq2dFgP9mWRAdIEoBqg==} - '@mistralai/mistralai@1.14.1': resolution: {integrity: sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==} @@ -3198,93 +3138,6 @@ packages: resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} - '@snazzah/davey-android-arm-eabi@0.1.9': - resolution: {integrity: sha512-Dq0WyeVGBw+uQbisV/6PeCQV2ndJozfhZqiNIfQxu6ehIdXB7iHILv+oY+AQN2n+qxiFmLh/MOX9RF+pIWdPbA==} - engines: {node: '>= 10'} - cpu: [arm] - os: [android] - - '@snazzah/davey-android-arm64@0.1.9': - resolution: {integrity: sha512-OE16OZjv7F/JrD7Mzw5eL2gY2vXRPC8S7ZrmkcMyz/sHHJsGHlT+L7X5s56Bec1YDTVmzAsH4UBuvVBoXuIWEQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [android] - - '@snazzah/davey-darwin-arm64@0.1.9': - resolution: {integrity: sha512-z7oORvAPExikFkH6tvHhbUdZd77MYZp9VqbCpKEiI+sisWFVXgHde7F7iH3G4Bz6gUYJfgvKhWXiDRc+0SC4dg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@snazzah/davey-darwin-x64@0.1.9': - resolution: {integrity: sha512-f1LzGyRGlM414KpXml3OgWVSd7CgylcdYaFj/zDBb8bvWjxyvsI9iMeuPfe/cduloxRj8dELde/yCDZtFR6PdQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@snazzah/davey-freebsd-x64@0.1.9': - resolution: {integrity: sha512-k6p3JY2b8rD6j0V9Ql7kBUMR4eJdcpriNwiHltLzmtGuz/nK5RGQdkEP68gTLc+Uj3xs5Cy0jRKmv2xJQBR4sA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [freebsd] - - '@snazzah/davey-linux-arm-gnueabihf@0.1.9': - resolution: {integrity: sha512-xDaAFUC/1+n/YayNwKsqKOBMuW0KI6F0SjgWU+krYTQTVmAKNjOM80IjemrVoqTpBOxBsT80zEtct2wj11CE3Q==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@snazzah/davey-linux-arm64-gnu@0.1.9': - resolution: {integrity: sha512-t1VxFBzWExPNpsNY/9oStdAAuHqFvwZvIO2YPYyVNstxfi2KmAbHMweHUW7xb2ppXuhVQZ4VGmmeXiXcXqhPBw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@snazzah/davey-linux-arm64-musl@0.1.9': - resolution: {integrity: sha512-Xvlr+nBPzuFV4PXHufddlt08JsEyu0p8mX2DpqdPxdpysYIH4I8V86yJiS4tk04a6pLBDd8IxTbBwvXJKqd/LQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@snazzah/davey-linux-x64-gnu@0.1.9': - resolution: {integrity: sha512-6Uunc/NxiEkg1reroAKZAGfOtjl1CGa7hfTTVClb2f+DiA8ZRQWBh+3lgkq/0IeL262B4F14X8QRv5Bsv128qw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@snazzah/davey-linux-x64-musl@0.1.9': - resolution: {integrity: sha512-fFQ/n3aWt1lXhxSdy+Ge3gi5bR3VETMVsWhH0gwBALUKrbo3ZzgSktm4lNrXE9i0ncMz/CDpZ5i0wt/N3XphEQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@snazzah/davey-wasm32-wasi@0.1.9': - resolution: {integrity: sha512-xWvzej8YCVlUvzlpmqJMIf0XmLlHqulKZ2e7WNe2TxQmsK+o0zTZqiQYs2MwaEbrNXBhYlHDkdpuwoXkJdscNQ==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@snazzah/davey-win32-arm64-msvc@0.1.9': - resolution: {integrity: sha512-sTqry/DfltX2OdW1CTLKa3dFYN5FloAEb2yhGsY1i5+Bms6OhwByXfALvyMHYVo61Th2+sD+9BJpQffHFKDA3w==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@snazzah/davey-win32-ia32-msvc@0.1.9': - resolution: {integrity: sha512-twD3LwlkGnSwphsCtpGb5ztpBIWEvGdc0iujoVkdzZ6nJiq5p8iaLjJMO4hBm9h3s28fc+1Qd7AMVnagiOasnA==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - - '@snazzah/davey-win32-x64-msvc@0.1.9': - resolution: {integrity: sha512-eMnXbv4GoTngWYY538i/qHz2BS+RgSXFsvKltPzKqnqzPzhQZIY7TemEJn3D5yWGfW4qHve9u23rz93FQqnQMA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@snazzah/davey@0.1.9': - resolution: {integrity: sha512-vNZk5y+IsxjwzTAXikvzz5pqMLb35YytC64nVF2MAFVhjpXu9ITOKUriZ0JG/llwzCAi56jb5x0cXDRIyE2A2A==} - engines: {node: '>= 10'} - '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -4210,9 +4063,6 @@ packages: discord-api-types@0.38.37: resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==} - discord-api-types@0.38.40: - resolution: {integrity: sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==} - discord-api-types@0.38.41: resolution: {integrity: sha512-yMECyR8j9c2fVTvCQ+Qc24pweYFIZk/XoxDOmt1UvPeSw5tK6gXBd/2hhP+FEAe9Y6ny8pRMaf618XDK4U53OQ==} @@ -4614,10 +4464,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - grammy@1.41.0: - resolution: {integrity: sha512-CAAu74SLT+/QCg40FBhUuYJalVsxxCN3D0c31TzhFBsWWTdXrMXYjGsKngBdfvN6hQ/VzHczluj/ugZVetFNCQ==} - engines: {node: ^12.20.0 || >=14.13.1} - grammy@1.41.1: resolution: {integrity: sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==} engines: {node: ^12.20.0 || >=14.13.1} @@ -5466,18 +5312,6 @@ packages: oniguruma-to-es@4.3.4: resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} - openai@6.10.0: - resolution: {integrity: sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==} - hasBin: true - peerDependencies: - ws: ^8.18.0 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - ws: - optional: true - zod: - optional: true - openai@6.26.0: resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} hasBin: true @@ -5502,8 +5336,8 @@ packages: zod: optional: true - openclaw@2026.3.2: - resolution: {integrity: sha512-Gkqx24m7PF1DUXPI968DuC9n52lTZ5hI3X5PIi0HosC7J7d6RLkgVppj1mxvgiQAWMp41E41elvoi/h4KBjFcQ==} + openclaw@2026.3.8: + resolution: {integrity: sha512-e5Rk2Aj55sD/5LyX94mdYCQj7zpHXo0xIZsl+k140+nRopePfPAxC7nsu0V/NyypPRtaotP1riFfzK7IhaYkuQ==} engines: {node: '>=22.12.0'} hasBin: true peerDependencies: @@ -6746,9 +6580,6 @@ packages: zod@3.25.75: resolution: {integrity: sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg==} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -6818,58 +6649,6 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-bedrock-runtime@3.1000.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.15 - '@aws-sdk/credential-provider-node': 3.972.14 - '@aws-sdk/eventstream-handler-node': 3.972.9 - '@aws-sdk/middleware-eventstream': 3.972.6 - '@aws-sdk/middleware-host-header': 3.972.6 - '@aws-sdk/middleware-logger': 3.972.6 - '@aws-sdk/middleware-recursion-detection': 3.972.6 - '@aws-sdk/middleware-user-agent': 3.972.15 - '@aws-sdk/middleware-websocket': 3.972.10 - '@aws-sdk/region-config-resolver': 3.972.6 - '@aws-sdk/token-providers': 3.1000.0 - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-endpoints': 3.996.3 - '@aws-sdk/util-user-agent-browser': 3.972.6 - '@aws-sdk/util-user-agent-node': 3.973.0 - '@smithy/config-resolver': 4.4.9 - '@smithy/core': 3.23.6 - '@smithy/eventstream-serde-browser': 4.2.10 - '@smithy/eventstream-serde-config-resolver': 4.3.10 - '@smithy/eventstream-serde-node': 4.2.10 - '@smithy/fetch-http-handler': 5.3.11 - '@smithy/hash-node': 4.2.10 - '@smithy/invalid-dependency': 4.2.10 - '@smithy/middleware-content-length': 4.2.10 - '@smithy/middleware-endpoint': 4.4.20 - '@smithy/middleware-retry': 4.4.37 - '@smithy/middleware-serde': 4.2.11 - '@smithy/middleware-stack': 4.2.10 - '@smithy/node-config-provider': 4.3.10 - '@smithy/node-http-handler': 4.4.12 - '@smithy/protocol-http': 5.3.10 - '@smithy/smithy-client': 4.12.0 - '@smithy/types': 4.13.0 - '@smithy/url-parser': 4.2.10 - '@smithy/util-base64': 4.3.1 - '@smithy/util-body-length-browser': 4.2.1 - '@smithy/util-body-length-node': 4.2.2 - '@smithy/util-defaults-mode-browser': 4.3.36 - '@smithy/util-defaults-mode-node': 4.2.39 - '@smithy/util-endpoints': 3.3.1 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-retry': 4.2.10 - '@smithy/util-stream': 4.5.15 - '@smithy/util-utf8': 4.2.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/client-bedrock-runtime@3.1004.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -6922,51 +6701,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-bedrock@3.1000.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.15 - '@aws-sdk/credential-provider-node': 3.972.14 - '@aws-sdk/middleware-host-header': 3.972.6 - '@aws-sdk/middleware-logger': 3.972.6 - '@aws-sdk/middleware-recursion-detection': 3.972.6 - '@aws-sdk/middleware-user-agent': 3.972.15 - '@aws-sdk/region-config-resolver': 3.972.6 - '@aws-sdk/token-providers': 3.1000.0 - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-endpoints': 3.996.3 - '@aws-sdk/util-user-agent-browser': 3.972.6 - '@aws-sdk/util-user-agent-node': 3.973.0 - '@smithy/config-resolver': 4.4.9 - '@smithy/core': 3.23.6 - '@smithy/fetch-http-handler': 5.3.11 - '@smithy/hash-node': 4.2.10 - '@smithy/invalid-dependency': 4.2.10 - '@smithy/middleware-content-length': 4.2.10 - '@smithy/middleware-endpoint': 4.4.20 - '@smithy/middleware-retry': 4.4.37 - '@smithy/middleware-serde': 4.2.11 - '@smithy/middleware-stack': 4.2.10 - '@smithy/node-config-provider': 4.3.10 - '@smithy/node-http-handler': 4.4.12 - '@smithy/protocol-http': 5.3.10 - '@smithy/smithy-client': 4.12.0 - '@smithy/types': 4.13.0 - '@smithy/url-parser': 4.2.10 - '@smithy/util-base64': 4.3.1 - '@smithy/util-body-length-browser': 4.2.1 - '@smithy/util-body-length-node': 4.2.2 - '@smithy/util-defaults-mode-browser': 4.3.36 - '@smithy/util-defaults-mode-node': 4.2.39 - '@smithy/util-endpoints': 3.3.1 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-retry': 4.2.10 - '@smithy/util-utf8': 4.2.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/client-bedrock@3.1004.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -7324,13 +7058,6 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/eventstream-handler-node@3.972.9': - dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/eventstream-codec': 4.2.10 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@aws-sdk/middleware-bucket-endpoint@3.972.6': dependencies: '@aws-sdk/types': 3.973.4 @@ -7341,13 +7068,6 @@ snapshots: '@smithy/util-config-provider': 4.2.1 tslib: 2.8.1 - '@aws-sdk/middleware-eventstream@3.972.6': - dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@aws-sdk/middleware-eventstream@3.972.7': dependencies: '@aws-sdk/types': 3.973.5 @@ -7471,21 +7191,6 @@ snapshots: '@smithy/util-retry': 4.2.11 tslib: 2.8.1 - '@aws-sdk/middleware-websocket@3.972.10': - dependencies: - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-format-url': 3.972.6 - '@smithy/eventstream-codec': 4.2.10 - '@smithy/eventstream-serde-browser': 4.2.10 - '@smithy/fetch-http-handler': 5.3.11 - '@smithy/protocol-http': 5.3.10 - '@smithy/signature-v4': 5.3.10 - '@smithy/types': 4.13.0 - '@smithy/util-base64': 4.3.1 - '@smithy/util-hex-encoding': 4.2.1 - '@smithy/util-utf8': 4.2.1 - tslib: 2.8.1 - '@aws-sdk/middleware-websocket@3.972.12': dependencies: '@aws-sdk/types': 3.973.5 @@ -7623,18 +7328,6 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1000.0': - dependencies: - '@aws-sdk/core': 3.973.15 - '@aws-sdk/nested-clients': 3.996.3 - '@aws-sdk/types': 3.973.4 - '@smithy/property-provider': 4.2.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/token-providers@3.1004.0': dependencies: '@aws-sdk/core': 3.973.18 @@ -7858,21 +7551,10 @@ snapshots: hashery: 1.5.0 keyv: 5.6.0 - '@clack/core@1.0.1': - dependencies: - picocolors: 1.1.1 - sisteransi: 1.0.5 - '@clack/core@1.1.0': dependencies: sisteransi: 1.0.5 - '@clack/prompts@1.0.1': - dependencies: - '@clack/core': 1.0.1 - picocolors: 1.1.1 - sisteransi: 1.0.5 - '@clack/prompts@1.1.0': dependencies: '@clack/core': 1.1.0 @@ -8100,17 +7782,6 @@ snapshots: '@eshaz/web-worker@1.2.2': optional: true - '@google/genai@1.43.0': - dependencies: - google-auth-library: 10.6.1 - p-retry: 4.6.2 - protobufjs: 7.5.4 - ws: 8.19.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - '@google/genai@1.44.0': dependencies: google-auth-library: 10.6.1 @@ -8122,21 +7793,11 @@ snapshots: - supports-color - utf-8-validate - '@grammyjs/runner@2.0.3(grammy@1.41.0)': - dependencies: - abort-controller: 3.0.0 - grammy: 1.41.0 - '@grammyjs/runner@2.0.3(grammy@1.41.1)': dependencies: abort-controller: 3.0.0 grammy: 1.41.1 - '@grammyjs/transformer-throttler@1.2.1(grammy@1.41.0)': - dependencies: - bottleneck: 2.19.5 - grammy: 1.41.0 - '@grammyjs/transformer-throttler@1.2.1(grammy@1.41.1)': dependencies: bottleneck: 2.19.5 @@ -8501,18 +8162,6 @@ snapshots: std-env: 3.10.0 yoctocolors: 2.1.2 - '@mariozechner/pi-agent-core@0.55.3(ws@8.19.0)(zod@4.3.6)': - dependencies: - '@mariozechner/pi-ai': 0.55.3(ws@8.19.0)(zod@4.3.6) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@mariozechner/pi-agent-core@0.57.1(ws@8.19.0)(zod@4.3.6)': dependencies: '@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6) @@ -8525,30 +8174,6 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.55.3(ws@8.19.0)(zod@4.3.6)': - dependencies: - '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) - '@aws-sdk/client-bedrock-runtime': 3.1000.0 - '@google/genai': 1.43.0 - '@mistralai/mistralai': 1.10.0 - '@sinclair/typebox': 0.34.48 - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - chalk: 5.6.2 - openai: 6.10.0(ws@8.19.0)(zod@4.3.6) - partial-json: 0.1.7 - proxy-agent: 6.5.0 - undici: 7.22.0 - zod-to-json-schema: 3.25.1(zod@4.3.6) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@mariozechner/pi-ai@0.57.1(ws@8.19.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) @@ -8573,37 +8198,6 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.55.3(ws@8.19.0)(zod@4.3.6)': - dependencies: - '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.55.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.55.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.55.3 - '@silvia-odwyer/photon-node': 0.3.4 - chalk: 5.6.2 - cli-highlight: 2.1.11 - diff: 8.0.3 - extract-zip: 2.0.1 - file-type: 21.3.0 - glob: 13.0.6 - hosted-git-info: 9.0.2 - ignore: 7.0.5 - marked: 15.0.12 - minimatch: 10.2.4 - proper-lockfile: 4.1.2 - strip-ansi: 7.2.0 - yaml: 2.8.2 - optionalDependencies: - '@mariozechner/clipboard': 0.3.2 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@mariozechner/pi-coding-agent@0.57.1(ws@8.19.0)(zod@4.3.6)': dependencies: '@mariozechner/jiti': 2.6.5 @@ -8636,15 +8230,6 @@ snapshots: - ws - zod - '@mariozechner/pi-tui@0.55.3': - dependencies: - '@types/mime-types': 2.1.4 - chalk: 5.6.2 - get-east-asian-width: 1.5.0 - koffi: 2.15.1 - marked: 15.0.12 - mime-types: 3.0.2 - '@mariozechner/pi-tui@0.57.1': dependencies: '@types/mime-types': 2.1.4 @@ -8684,11 +8269,6 @@ snapshots: - debug - supports-color - '@mistralai/mistralai@1.10.0': - dependencies: - zod: 3.25.76 - zod-to-json-schema: 3.25.1(zod@3.25.76) - '@mistralai/mistralai@1.14.1': dependencies: ws: 8.19.0 @@ -10291,67 +9871,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@snazzah/davey-android-arm-eabi@0.1.9': - optional: true - - '@snazzah/davey-android-arm64@0.1.9': - optional: true - - '@snazzah/davey-darwin-arm64@0.1.9': - optional: true - - '@snazzah/davey-darwin-x64@0.1.9': - optional: true - - '@snazzah/davey-freebsd-x64@0.1.9': - optional: true - - '@snazzah/davey-linux-arm-gnueabihf@0.1.9': - optional: true - - '@snazzah/davey-linux-arm64-gnu@0.1.9': - optional: true - - '@snazzah/davey-linux-arm64-musl@0.1.9': - optional: true - - '@snazzah/davey-linux-x64-gnu@0.1.9': - optional: true - - '@snazzah/davey-linux-x64-musl@0.1.9': - optional: true - - '@snazzah/davey-wasm32-wasi@0.1.9': - dependencies: - '@napi-rs/wasm-runtime': 1.1.1 - optional: true - - '@snazzah/davey-win32-arm64-msvc@0.1.9': - optional: true - - '@snazzah/davey-win32-ia32-msvc@0.1.9': - optional: true - - '@snazzah/davey-win32-x64-msvc@0.1.9': - optional: true - - '@snazzah/davey@0.1.9': - optionalDependencies: - '@snazzah/davey-android-arm-eabi': 0.1.9 - '@snazzah/davey-android-arm64': 0.1.9 - '@snazzah/davey-darwin-arm64': 0.1.9 - '@snazzah/davey-darwin-x64': 0.1.9 - '@snazzah/davey-freebsd-x64': 0.1.9 - '@snazzah/davey-linux-arm-gnueabihf': 0.1.9 - '@snazzah/davey-linux-arm64-gnu': 0.1.9 - '@snazzah/davey-linux-arm64-musl': 0.1.9 - '@snazzah/davey-linux-x64-gnu': 0.1.9 - '@snazzah/davey-linux-x64-musl': 0.1.9 - '@snazzah/davey-wasm32-wasi': 0.1.9 - '@snazzah/davey-win32-arm64-msvc': 0.1.9 - '@snazzah/davey-win32-ia32-msvc': 0.1.9 - '@snazzah/davey-win32-x64-msvc': 0.1.9 - '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.19': @@ -11364,8 +10883,6 @@ snapshots: discord-api-types@0.38.37: {} - discord-api-types@0.38.40: {} - discord-api-types@0.38.41: {} doctypes@1.1.0: {} @@ -11876,16 +11393,6 @@ snapshots: graceful-fs@4.2.11: {} - grammy@1.41.0: - dependencies: - '@grammyjs/types': 3.25.0 - abort-controller: 3.0.0 - debug: 4.4.3 - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding - - supports-color - grammy@1.41.1: dependencies: '@grammyjs/types': 3.25.0 @@ -12287,7 +11794,8 @@ snapshots: klona@2.0.6: {} - koffi@2.15.1: {} + koffi@2.15.1: + optional: true leac@0.6.0: {} @@ -12806,11 +12314,6 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 - openai@6.10.0(ws@8.19.0)(zod@4.3.6): - optionalDependencies: - ws: 8.19.0 - zod: 4.3.6 - openai@6.26.0(ws@8.19.0)(zod@4.3.6): optionalDependencies: ws: 8.19.0 @@ -12821,29 +12324,28 @@ snapshots: ws: 8.19.0 zod: 4.3.6 - openclaw@2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)): + openclaw@2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)): dependencies: - '@agentclientprotocol/sdk': 0.14.1(zod@4.3.6) - '@aws-sdk/client-bedrock': 3.1000.0 + '@agentclientprotocol/sdk': 0.15.0(zod@4.3.6) + '@aws-sdk/client-bedrock': 3.1004.0 '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1) - '@clack/prompts': 1.0.1 + '@clack/prompts': 1.1.0 '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1) - '@grammyjs/runner': 2.0.3(grammy@1.41.0) - '@grammyjs/transformer-throttler': 1.2.1(grammy@1.41.0) + '@grammyjs/runner': 2.0.3(grammy@1.41.1) + '@grammyjs/transformer-throttler': 1.2.1(grammy@1.41.1) '@homebridge/ciao': 1.3.5 '@larksuiteoapi/node-sdk': 1.59.0 '@line/bot-sdk': 10.6.0 '@lydell/node-pty': 1.2.0-beta.3 - '@mariozechner/pi-agent-core': 0.55.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.55.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-coding-agent': 0.55.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.55.3 + '@mariozechner/pi-agent-core': 0.57.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-coding-agent': 0.57.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.57.1 '@mozilla/readability': 0.6.0 '@napi-rs/canvas': 0.1.95 '@sinclair/typebox': 0.34.48 '@slack/bolt': 4.6.0(@types/express@5.0.6) '@slack/web-api': 7.14.1 - '@snazzah/davey': 0.1.9 '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) ajv: 8.18.0 chalk: 5.6.2 @@ -12851,13 +12353,11 @@ snapshots: cli-highlight: 2.1.11 commander: 14.0.3 croner: 10.0.1 - discord-api-types: 0.38.40 + discord-api-types: 0.38.41 dotenv: 17.3.1 express: 5.2.1 file-type: 21.3.0 - gaxios: 7.1.3 - google-auth-library: 10.6.1 - grammy: 1.41.0 + grammy: 1.41.1 https-proxy-agent: 7.0.6 ipaddr.js: 2.3.0 jiti: 2.6.1 @@ -12866,7 +12366,6 @@ snapshots: linkedom: 0.18.12 long: 5.3.2 markdown-it: 14.1.1 - node-domexception: '@nolyfill/domexception@1.0.28' node-edge-tts: 1.2.10 node-llama-cpp: 3.16.2(typescript@5.9.3) opusscript: 0.1.1 @@ -12876,16 +12375,14 @@ snapshots: qrcode-terminal: 0.12.0 sharp: 0.34.5 sqlite-vec: 0.1.7-alpha.2 - strip-ansi: 7.2.0 tar: 7.5.10 tslog: 4.10.2 undici: 7.22.0 ws: 8.19.0 yaml: 2.8.2 zod: 4.3.6 - optionalDependencies: - '@discordjs/opus': 0.10.0 transitivePeerDependencies: + - '@discordjs/opus' - '@modelcontextprotocol/sdk' - '@types/express' - audio-decode @@ -14298,18 +13795,12 @@ snapshots: - bufferutil - utf-8-validate - zod-to-json-schema@3.25.1(zod@3.25.76): - dependencies: - zod: 3.25.76 - zod-to-json-schema@3.25.1(zod@4.3.6): dependencies: zod: 4.3.6 zod@3.25.75: {} - zod@3.25.76: {} - zod@4.3.6: {} zwitch@2.0.4: {} From 391f9430cadd95a8b458f475caf2f53a5102950b Mon Sep 17 00:00:00 2001 From: Ayane <40628300+ayanesakura@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:26:06 +0800 Subject: [PATCH 0040/1173] fix(feishu): pass mediaLocalRoots in sendText local-image auto-convert shim (openclaw#40623) Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: ayanesakura <40628300+ayanesakura@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + extensions/feishu/src/outbound.test.ts | 2 ++ extensions/feishu/src/outbound.ts | 3 ++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e5273a8df5..95f3ab600cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Feishu/local image auto-convert: pass `mediaLocalRoots` through the `sendText` local-image shim so allowed local image paths upload as Feishu images again instead of falling back to raw path text. (#40623) Thanks @ayanesakura. - macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes. - Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark. - Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz. diff --git a/extensions/feishu/src/outbound.test.ts b/extensions/feishu/src/outbound.test.ts index bed44df77a6..11cfc957e80 100644 --- a/extensions/feishu/src/outbound.test.ts +++ b/extensions/feishu/src/outbound.test.ts @@ -52,6 +52,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => { to: "chat_1", text: file, accountId: "main", + mediaLocalRoots: [dir], }); expect(sendMediaFeishuMock).toHaveBeenCalledWith( @@ -59,6 +60,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => { to: "chat_1", mediaUrl: file, accountId: "main", + mediaLocalRoots: [dir], }), ); expect(sendMessageFeishuMock).not.toHaveBeenCalled(); diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index 955777676ef..75e1fa8d42b 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -81,7 +81,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { + sendText: async ({ cfg, to, text, accountId, replyToId, threadId, mediaLocalRoots }) => { const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); // Scheme A compatibility shim: // when upstream accidentally returns a local image path as plain text, @@ -95,6 +95,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { mediaUrl: localImagePath, accountId: accountId ?? undefined, replyToMessageId, + mediaLocalRoots, }); return { channel: "feishu", ...result }; } catch (err) { From e42c4f45134cd4f7325296e0234daae3611d3f56 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 9 Mar 2026 22:43:51 -0500 Subject: [PATCH 0041/1173] docs: harden PR review gates against unsubstantiated fixes --- .pi/prompts/reviewpr.md | 46 ++++++++++++++++++++++++++++++++++------- AGENTS.md | 12 +++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/.pi/prompts/reviewpr.md b/.pi/prompts/reviewpr.md index 835be806dd5..e3ebc0dd9c6 100644 --- a/.pi/prompts/reviewpr.md +++ b/.pi/prompts/reviewpr.md @@ -9,7 +9,20 @@ Input - If ambiguous: ask. Do (review-only) -Goal: produce a thorough review and a clear recommendation (READY for /landpr vs NEEDS WORK). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command. +Goal: produce a thorough review and a clear recommendation (READY FOR /landpr vs NEEDS WORK vs INVALID CLAIM). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command. + +0. Truthfulness + reality gate (required for bug-fix claims) + + - Do not trust the issue text or PR summary by default; verify in code and evidence. + - If the PR claims to fix a bug linked to an issue, confirm the bug exists now (repro steps, logs, failing test, or clear code-path proof). + - Prove root cause with exact location (`path/file.ts:line` + explanation of why behavior is wrong). + - Verify fix targets the same code path as the root cause. + - Require a regression test when feasible (fails before fix, passes after fix). If not feasible, require explicit justification + manual verification evidence. + - Hallucination/BS red flags (treat as BLOCKER until disproven): + - claimed behavior not present in repo, + - issue/PR says "fixes #..." but changed files do not touch implicated path, + - only docs/comments changed for a runtime bug claim, + - vague AI-generated rationale without concrete evidence. 1. Identify PR meta + context @@ -56,6 +69,7 @@ Goal: produce a thorough review and a clear recommendation (READY for /landpr vs - Any deprecations, docs, types, or lint rules we should adjust? 8. Key questions to answer explicitly + - Is the core claim substantiated by evidence, or is it likely invalid/hallucinated? - Can we fix everything ourselves in a follow-up, or does the contributor need to update this PR? - Any blocking concerns (must-fix before merge)? - Is this PR ready to land, or does it need work? @@ -65,18 +79,32 @@ Goal: produce a thorough review and a clear recommendation (READY for /landpr vs A) TL;DR recommendation -- One of: READY FOR /landpr | NEEDS WORK | NEEDS DISCUSSION +- One of: READY FOR /landpr | NEEDS WORK | INVALID CLAIM (issue/bug not substantiated) | NEEDS DISCUSSION - 1–3 sentence rationale. -B) What changed +B) Claim verification matrix (required) + +- Fill this table: + + | Field | Evidence | + |---|---| + | Claimed problem | ... | + | Evidence observed (repro/log/test/code) | ... | + | Root cause location (`path:line`) | ... | + | Why this fix addresses that root cause | ... | + | Regression coverage (test name or manual proof) | ... | + +- If any row is missing/weak, default to `NEEDS WORK` or `INVALID CLAIM`. + +C) What changed - Brief bullet summary of the diff/behavioral changes. -C) What's good +D) What's good - Bullets: correctness, simplicity, tests, docs, ergonomics, etc. -D) Concerns / questions (actionable) +E) Concerns / questions (actionable) - Numbered list. - Mark each item as: @@ -84,17 +112,19 @@ D) Concerns / questions (actionable) - IMPORTANT (should fix before merge) - NIT (optional) - For each: point to the file/area and propose a concrete fix or alternative. +- If evidence for the core bug claim is missing, add a `BLOCKER` explicitly. -E) Tests +F) Tests - What exists. - What's missing (specific scenarios). +- State clearly whether there is a regression test for the claimed bug. -F) Follow-ups (optional) +G) Follow-ups (optional) - Non-blocking refactors/tickets to open later. -G) Suggested PR comment (optional) +H) Suggested PR comment (optional) - Offer: "Want me to draft a PR comment to the author?" - If yes, provide a ready-to-paste comment summarizing the above, with clear asks. diff --git a/AGENTS.md b/AGENTS.md index 1516f2e4f58..80443603c87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,18 @@ - `invalid`: close invalid items (issues are closed as `not_planned`; PRs are closed). - `dirty`: close PRs with too many unrelated/unexpected changes (PR-only label). +## PR truthfulness and bug-fix validation + +- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale. +- Before `/landpr`, run `/reviewpr` and require explicit evidence for bug-fix claims. +- Minimum merge gate for bug-fix PRs: + 1. symptom evidence (repro/log/failing test), + 2. verified root cause in code with file/line, + 3. fix touches the implicated code path, + 4. regression test (fail before/pass after) when feasible; if not feasible, include manual verification proof and why no test was added. +- If claim is unsubstantiated or likely hallucinated/BS: do not merge. Request evidence/changes, or close with `invalid` when appropriate. +- If linked issue appears wrong/outdated, correct triage first; do not merge speculative fixes. + ## Project Structure & Module Organization - Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`). From 93c44e3dad3ef0f4bcfe1f44872cac197a0baae3 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 10 Mar 2026 09:14:57 +0530 Subject: [PATCH 0042/1173] ci: drop gha cache from docker release (#41692) --- .github/workflows/docker-release.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index f991b7f8653..2cc29748c91 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -109,8 +109,6 @@ jobs: labels: ${{ steps.labels.outputs.value }} provenance: false push: true - cache-from: type=gha,scope=docker-release-amd64 - cache-to: type=gha,mode=max,scope=docker-release-amd64 - name: Build and push amd64 slim image id: build-slim @@ -124,8 +122,6 @@ jobs: labels: ${{ steps.labels.outputs.value }} provenance: false push: true - cache-from: type=gha,scope=docker-release-amd64 - cache-to: type=gha,mode=max,scope=docker-release-amd64 # Build arm64 images (default + slim share the build stage cache) build-arm64: @@ -214,8 +210,6 @@ jobs: labels: ${{ steps.labels.outputs.value }} provenance: false push: true - cache-from: type=gha,scope=docker-release-arm64 - cache-to: type=gha,mode=max,scope=docker-release-arm64 - name: Build and push arm64 slim image id: build-slim @@ -229,8 +223,6 @@ jobs: labels: ${{ steps.labels.outputs.value }} provenance: false push: true - cache-from: type=gha,scope=docker-release-arm64 - cache-to: type=gha,mode=max,scope=docker-release-arm64 # Create multi-platform manifests create-manifest: From f0eb67923cd74b9278b408e868b80b0db40a23e9 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:57:03 -0500 Subject: [PATCH 0043/1173] fix(secrets): resolve web tool SecretRefs atomically at runtime --- CHANGELOG.md | 1 + docs/gateway/secrets.md | 3 +- docs/help/faq.md | 15 +- docs/perplexity.md | 3 + docs/reference/api-usage-costs.md | 8 +- .../reference/secretref-credential-surface.md | 4 +- ...tref-user-supplied-credentials-matrix.json | 7 + docs/tools/firecrawl.md | 3 +- docs/tools/web.md | 32 +- src/agents/openclaw-tools.ts | 4 + src/agents/openclaw-tools.web-runtime.test.ts | 135 ++++ .../tools/web-fetch.cf-markdown.test.ts | 41 + src/agents/tools/web-fetch.ts | 27 +- src/agents/tools/web-search.ts | 62 +- .../tools/web-tools.enabled-defaults.test.ts | 140 +++- src/cli/command-secret-gateway.test.ts | 113 +++ src/cli/command-secret-gateway.ts | 60 +- src/cli/command-secret-targets.test.ts | 1 + src/cli/command-secret-targets.ts | 1 + src/config/types.tools.ts | 2 +- src/gateway/server.reload.test.ts | 93 +++ src/secrets/runtime-config-collectors-core.ts | 62 -- src/secrets/runtime-shared.ts | 7 +- src/secrets/runtime-web-tools.test.ts | 451 +++++++++++ src/secrets/runtime-web-tools.ts | 705 ++++++++++++++++++ src/secrets/runtime.test.ts | 164 +++- src/secrets/runtime.ts | 16 +- src/secrets/target-registry-data.ts | 11 + 28 files changed, 2059 insertions(+), 112 deletions(-) create mode 100644 src/agents/openclaw-tools.web-runtime.test.ts create mode 100644 src/secrets/runtime-web-tools.test.ts create mode 100644 src/secrets/runtime-web-tools.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f3ab600cb..c19a5c2eda7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Resolve web tool SecretRefs atomically at runtime. (#41599) Thanks @joshavant. - Feishu/local image auto-convert: pass `mediaLocalRoots` through the `sendText` local-image shim so allowed local image paths upload as Feishu images again instead of falling back to raw path text. (#40623) Thanks @ayanesakura. - macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes. - Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark. diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 3ef08267618..e9d75343147 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -38,7 +38,8 @@ Examples of inactive surfaces: - Top-level channel credentials that no enabled account inherits. - Disabled tool/feature surfaces. - Web search provider-specific keys that are not selected by `tools.web.search.provider`. - In auto mode (provider unset), provider-specific keys are also active for provider auto-detection. + In auto mode (provider unset), keys are consulted by precedence for provider auto-detection until one resolves. + After selection, non-selected provider keys are treated as inactive until selected. - `gateway.remote.token` / `gateway.remote.password` SecretRefs are active (when `gateway.remote.enabled` is not `false`) if one of these is true: - `gateway.mode=remote` - `gateway.remote.url` is configured diff --git a/docs/help/faq.md b/docs/help/faq.md index 7dad0548fd4..a43e91f4396 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1489,10 +1489,16 @@ Set `cli.banner.taglineMode` in config: ### How do I enable web search and web fetch -`web_fetch` works without an API key. `web_search` requires a Brave Search API -key. **Recommended:** run `openclaw configure --section web` to store it in -`tools.web.search.apiKey`. Environment alternative: set `BRAVE_API_KEY` for the -Gateway process. +`web_fetch` works without an API key. `web_search` requires a key for your +selected provider (Brave, Gemini, Grok, Kimi, or Perplexity). +**Recommended:** run `openclaw configure --section web` and choose a provider. +Environment alternatives: + +- Brave: `BRAVE_API_KEY` +- Gemini: `GEMINI_API_KEY` +- Grok: `XAI_API_KEY` +- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY` +- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY` ```json5 { @@ -1500,6 +1506,7 @@ Gateway process. web: { search: { enabled: true, + provider: "brave", apiKey: "BRAVE_API_KEY_HERE", maxResults: 5, }, diff --git a/docs/perplexity.md b/docs/perplexity.md index bb1acef49c8..f7eccc9453e 100644 --- a/docs/perplexity.md +++ b/docs/perplexity.md @@ -71,11 +71,14 @@ Optional legacy controls: **Via config:** run `openclaw configure --section web`. It stores the key in `~/.openclaw/openclaw.json` under `tools.web.search.perplexity.apiKey`. +That field also accepts SecretRef objects. **Via environment:** set `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables). +If `provider: "perplexity"` is configured and the Perplexity key SecretRef is unresolved with no env fallback, startup/reload fails fast. + ## Tool parameters These parameters apply to the native Perplexity Search API path. diff --git a/docs/reference/api-usage-costs.md b/docs/reference/api-usage-costs.md index dba017aacc1..baf4302ac0d 100644 --- a/docs/reference/api-usage-costs.md +++ b/docs/reference/api-usage-costs.md @@ -80,10 +80,10 @@ See [Memory](/concepts/memory). `web_search` uses API keys and may incur usage charges depending on your provider: - **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey` -- **Gemini (Google Search)**: `GEMINI_API_KEY` -- **Grok (xAI)**: `XAI_API_KEY` -- **Kimi (Moonshot)**: `KIMI_API_KEY` or `MOONSHOT_API_KEY` -- **Perplexity Search API**: `PERPLEXITY_API_KEY` +- **Gemini (Google Search)**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey` +- **Grok (xAI)**: `XAI_API_KEY` or `tools.web.search.grok.apiKey` +- **Kimi (Moonshot)**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey` +- **Perplexity Search API**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` **Brave Search free credit:** Each Brave plan includes $5/month in renewing free credit. The Search plan costs $5 per 1,000 requests, so the credit covers diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md index dd1b5f1fd2f..2a5fc5a66ac 100644 --- a/docs/reference/secretref-credential-surface.md +++ b/docs/reference/secretref-credential-surface.md @@ -31,6 +31,7 @@ Scope intent: - `talk.providers.*.apiKey` - `messages.tts.elevenlabs.apiKey` - `messages.tts.openai.apiKey` +- `tools.web.fetch.firecrawl.apiKey` - `tools.web.search.apiKey` - `tools.web.search.gemini.apiKey` - `tools.web.search.grok.apiKey` @@ -102,7 +103,8 @@ Notes: - For SecretRef-managed model providers, generated `agents/*/agent/models.json` entries persist non-secret markers (not resolved secret values) for `apiKey`/header surfaces. - For web search: - In explicit provider mode (`tools.web.search.provider` set), only the selected provider key is active. - - In auto mode (`tools.web.search.provider` unset), `tools.web.search.apiKey` and provider-specific keys are active. + - In auto mode (`tools.web.search.provider` unset), only the first provider key that resolves by precedence is active. + - In auto mode, non-selected provider refs are treated as inactive until selected. ## Unsupported credentials diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json index 773ef8ab162..6d4b05d2822 100644 --- a/docs/reference/secretref-user-supplied-credentials-matrix.json +++ b/docs/reference/secretref-user-supplied-credentials-matrix.json @@ -454,6 +454,13 @@ "secretShape": "secret_input", "optIn": true }, + { + "id": "tools.web.fetch.firecrawl.apiKey", + "configFile": "openclaw.json", + "path": "tools.web.fetch.firecrawl.apiKey", + "secretShape": "secret_input", + "optIn": true + }, { "id": "tools.web.search.apiKey", "configFile": "openclaw.json", diff --git a/docs/tools/firecrawl.md b/docs/tools/firecrawl.md index e859eb2dcb1..2cd90a06bf5 100644 --- a/docs/tools/firecrawl.md +++ b/docs/tools/firecrawl.md @@ -40,7 +40,8 @@ with JS-heavy sites or pages that block plain HTTP fetches. Notes: -- `firecrawl.enabled` defaults to true when an API key is present. +- `firecrawl.enabled` defaults to `true` unless explicitly set to `false`. +- Firecrawl fallback attempts run only when an API key is available (`tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`). - `maxAgeMs` controls how old cached results can be (ms). Default is 2 days. ## Stealth / bot circumvention diff --git a/docs/tools/web.md b/docs/tools/web.md index 1eeb4eba7db..e77d046ce5b 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -2,7 +2,7 @@ summary: "Web search + fetch tools (Brave, Gemini, Grok, Kimi, and Perplexity providers)" read_when: - You want to enable web_search or web_fetch - - You need Brave or Perplexity Search API key setup + - You need provider API key setup - You want to use Gemini with Google Search grounding title: "Web Tools" --- @@ -49,6 +49,12 @@ The table above is alphabetical. If no `provider` is explicitly set, runtime aut If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one). +Runtime SecretRef behavior: + +- Web tool SecretRefs are resolved atomically at gateway startup/reload. +- In auto-detect mode, OpenClaw resolves only the selected provider key. Non-selected provider SecretRefs stay inactive until selected. +- If the selected provider SecretRef is unresolved and no provider env fallback exists, startup/reload fails fast. + ## Setting up web search Use `openclaw configure --section web` to set up your API key and choose a provider. @@ -77,9 +83,25 @@ See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quicks ### Where to store the key -**Via config:** run `openclaw configure --section web`. It stores the key under `tools.web.search.apiKey` or `tools.web.search.perplexity.apiKey`, depending on provider. +**Via config:** run `openclaw configure --section web`. It stores the key under the provider-specific config path: -**Via environment:** set `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `BRAVE_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables). +- Brave: `tools.web.search.apiKey` +- Gemini: `tools.web.search.gemini.apiKey` +- Grok: `tools.web.search.grok.apiKey` +- Kimi: `tools.web.search.kimi.apiKey` +- Perplexity: `tools.web.search.perplexity.apiKey` + +All of these fields also support SecretRef objects. + +**Via environment:** set provider env vars in the Gateway process environment: + +- Brave: `BRAVE_API_KEY` +- Gemini: `GEMINI_API_KEY` +- Grok: `XAI_API_KEY` +- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY` +- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY` + +For a gateway install, put these in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables). ### Config examples @@ -216,6 +238,7 @@ Search the web using your configured provider. - **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey` - **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey` - **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` +- All provider key fields above support SecretRef objects. ### Config @@ -310,6 +333,7 @@ Fetch a URL and extract readable content. - `tools.web.fetch.enabled` must not be `false` (default: enabled) - Optional Firecrawl fallback: set `tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`. +- `tools.web.fetch.firecrawl.apiKey` supports SecretRef objects. ### web_fetch config @@ -351,6 +375,8 @@ Notes: - `web_fetch` uses Readability (main-content extraction) first, then Firecrawl (if configured). If both fail, the tool returns an error. - Firecrawl requests use bot-circumvention mode and cache results by default. +- Firecrawl SecretRefs are resolved only when Firecrawl is active (`tools.web.fetch.enabled !== false` and `tools.web.fetch.firecrawl.enabled !== false`). +- If Firecrawl is active and its SecretRef is unresolved with no `FIRECRAWL_API_KEY` fallback, startup/reload fails fast. - `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed. - `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`). - `maxChars` is clamped to `tools.web.fetch.maxCharsCap`. diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 17f8e6dadb4..56d0801d13c 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolvePluginTools } from "../plugins/tools.js"; +import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime.js"; import type { GatewayMessageChannel } from "../utils/message-channel.js"; import { resolveSessionAgentId } from "./agent-scope.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; @@ -72,6 +73,7 @@ export function createOpenClawTools( } & SpawnedToolContext, ): AnyAgentTool[] { const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir); + const runtimeWebTools = getActiveRuntimeWebToolsMetadata(); const imageTool = options?.agentDir?.trim() ? createImageTool({ config: options?.config, @@ -100,10 +102,12 @@ export function createOpenClawTools( const webSearchTool = createWebSearchTool({ config: options?.config, sandboxed: options?.sandboxed, + runtimeWebSearch: runtimeWebTools?.search, }); const webFetchTool = createWebFetchTool({ config: options?.config, sandboxed: options?.sandboxed, + runtimeFirecrawl: runtimeWebTools?.fetch.firecrawl, }); const messageTool = options?.disableMessageTool ? null diff --git a/src/agents/openclaw-tools.web-runtime.test.ts b/src/agents/openclaw-tools.web-runtime.test.ts new file mode 100644 index 00000000000..94478930cf1 --- /dev/null +++ b/src/agents/openclaw-tools.web-runtime.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + activateSecretsRuntimeSnapshot, + clearSecretsRuntimeSnapshot, + prepareSecretsRuntimeSnapshot, +} from "../secrets/runtime.js"; +import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; +import { createOpenClawTools } from "./openclaw-tools.js"; + +vi.mock("../plugins/tools.js", () => ({ + resolvePluginTools: () => [], +})); + +function asConfig(value: unknown): OpenClawConfig { + return value as OpenClawConfig; +} + +function findTool(name: string, config: OpenClawConfig) { + const allTools = createOpenClawTools({ config, sandboxed: true }); + const tool = allTools.find((candidate) => candidate.name === name); + expect(tool).toBeDefined(); + if (!tool) { + throw new Error(`missing ${name} tool`); + } + return tool; +} + +function makeHeaders(map: Record): { get: (key: string) => string | null } { + return { + get: (key) => map[key.toLowerCase()] ?? null, + }; +} + +async function prepareAndActivate(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv }) { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: params.config, + env: params.env, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ version: 1, profiles: {} }), + }); + activateSecretsRuntimeSnapshot(snapshot); + return snapshot; +} + +describe("openclaw tools runtime web metadata wiring", () => { + const priorFetch = global.fetch; + + afterEach(() => { + global.fetch = priorFetch; + clearSecretsRuntimeSnapshot(); + }); + + it("uses runtime-selected provider when higher-precedence provider ref is unresolved", async () => { + const snapshot = await prepareAndActivate({ + config: asConfig({ + tools: { + web: { + search: { + apiKey: { source: "env", provider: "default", id: "MISSING_BRAVE_KEY_REF" }, + gemini: { + apiKey: { source: "env", provider: "default", id: "GEMINI_WEB_KEY_REF" }, + }, + }, + }, + }, + }), + env: { + GEMINI_WEB_KEY_REF: "gemini-runtime-key", + }, + }); + + expect(snapshot.webTools.search.selectedProvider).toBe("gemini"); + + const mockFetch = vi.fn((_input?: unknown, _init?: unknown) => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + candidates: [ + { + content: { parts: [{ text: "runtime gemini ok" }] }, + groundingMetadata: { groundingChunks: [] }, + }, + ], + }), + } as Response), + ); + global.fetch = withFetchPreconnect(mockFetch); + + const webSearch = findTool("web_search", snapshot.config); + const result = await webSearch.execute("call-runtime-search", { query: "runtime search" }); + + expect(mockFetch).toHaveBeenCalled(); + expect(String(mockFetch.mock.calls[0]?.[0])).toContain("generativelanguage.googleapis.com"); + expect((result.details as { provider?: string }).provider).toBe("gemini"); + }); + + it("skips Firecrawl key resolution when runtime marks Firecrawl inactive", async () => { + const snapshot = await prepareAndActivate({ + config: asConfig({ + tools: { + web: { + fetch: { + firecrawl: { + enabled: false, + apiKey: { source: "env", provider: "default", id: "MISSING_FIRECRAWL_KEY_REF" }, + }, + }, + }, + }, + }), + }); + + const mockFetch = vi.fn((_input?: unknown, _init?: unknown) => + Promise.resolve({ + ok: true, + status: 200, + headers: makeHeaders({ "content-type": "text/html; charset=utf-8" }), + text: () => + Promise.resolve( + "

Runtime Off

Use direct fetch.

", + ), + } as Response), + ); + global.fetch = withFetchPreconnect(mockFetch); + + const webFetch = findTool("web_fetch", snapshot.config); + await webFetch.execute("call-runtime-fetch", { url: "https://example.com/runtime-off" }); + + expect(mockFetch).toHaveBeenCalled(); + expect(mockFetch.mock.calls[0]?.[0]).toBe("https://example.com/runtime-off"); + expect(String(mockFetch.mock.calls[0]?.[0])).not.toContain("api.firecrawl.dev"); + }); +}); diff --git a/src/agents/tools/web-fetch.cf-markdown.test.ts b/src/agents/tools/web-fetch.cf-markdown.test.ts index 6e7768fc43a..e235177a309 100644 --- a/src/agents/tools/web-fetch.cf-markdown.test.ts +++ b/src/agents/tools/web-fetch.cf-markdown.test.ts @@ -84,6 +84,47 @@ describe("web_fetch Cloudflare Markdown for Agents", () => { expect(details?.contentType).toBe("text/html"); }); + it("bypasses Firecrawl when runtime metadata marks Firecrawl inactive", async () => { + const fetchSpy = vi + .fn() + .mockResolvedValue( + htmlResponse( + "

Runtime Off

Use direct fetch.

", + ), + ); + global.fetch = withFetchPreconnect(fetchSpy); + + const tool = createWebFetchTool({ + config: { + tools: { + web: { + fetch: { + firecrawl: { + enabled: true, + apiKey: { + source: "env", + provider: "default", + id: "MISSING_FIRECRAWL_KEY_REF", + }, + }, + }, + }, + }, + }, + sandboxed: false, + runtimeFirecrawl: { + active: false, + apiKeySource: "secretRef", + diagnostics: [], + }, + }); + + await tool?.execute?.("call", { url: "https://example.com/runtime-firecrawl-off" }); + + expect(fetchSpy).toHaveBeenCalled(); + expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://example.com/runtime-firecrawl-off"); + }); + it("logs x-markdown-tokens when header is present", async () => { const logSpy = vi.spyOn(logger, "logDebug").mockImplementation(() => {}); const fetchSpy = vi diff --git a/src/agents/tools/web-fetch.ts b/src/agents/tools/web-fetch.ts index 4ac7a1d7bfd..f4cc88e2d83 100644 --- a/src/agents/tools/web-fetch.ts +++ b/src/agents/tools/web-fetch.ts @@ -1,7 +1,9 @@ import { Type } from "@sinclair/typebox"; import type { OpenClawConfig } from "../../config/config.js"; +import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js"; import { SsrFBlockedError } from "../../infra/net/ssrf.js"; import { logDebug } from "../../logger.js"; +import type { RuntimeWebFetchFirecrawlMetadata } from "../../secrets/runtime-web-tools.js"; import { wrapExternalContent, wrapWebContent } from "../../security/external-content.js"; import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; import { stringEnum } from "../schema/typebox.js"; @@ -71,7 +73,7 @@ type WebFetchConfig = NonNullable["web"] extends infer type FirecrawlFetchConfig = | { enabled?: boolean; - apiKey?: string; + apiKey?: unknown; baseUrl?: string; onlyMainContent?: boolean; maxAgeMs?: number; @@ -136,10 +138,14 @@ function resolveFirecrawlConfig(fetch?: WebFetchConfig): FirecrawlFetchConfig { } function resolveFirecrawlApiKey(firecrawl?: FirecrawlFetchConfig): string | undefined { - const fromConfig = - firecrawl && "apiKey" in firecrawl && typeof firecrawl.apiKey === "string" - ? normalizeSecretInput(firecrawl.apiKey) - : ""; + const fromConfigRaw = + firecrawl && "apiKey" in firecrawl + ? normalizeResolvedSecretInputString({ + value: firecrawl.apiKey, + path: "tools.web.fetch.firecrawl.apiKey", + }) + : undefined; + const fromConfig = normalizeSecretInput(fromConfigRaw); const fromEnv = normalizeSecretInput(process.env.FIRECRAWL_API_KEY); return fromConfig || fromEnv || undefined; } @@ -712,6 +718,7 @@ function resolveFirecrawlEndpoint(baseUrl: string): string { export function createWebFetchTool(options?: { config?: OpenClawConfig; sandboxed?: boolean; + runtimeFirecrawl?: RuntimeWebFetchFirecrawlMetadata; }): AnyAgentTool | null { const fetch = resolveFetchConfig(options?.config); if (!resolveFetchEnabled({ fetch, sandboxed: options?.sandboxed })) { @@ -719,8 +726,14 @@ export function createWebFetchTool(options?: { } const readabilityEnabled = resolveFetchReadabilityEnabled(fetch); const firecrawl = resolveFirecrawlConfig(fetch); - const firecrawlApiKey = resolveFirecrawlApiKey(firecrawl); - const firecrawlEnabled = resolveFirecrawlEnabled({ firecrawl, apiKey: firecrawlApiKey }); + const runtimeFirecrawlActive = options?.runtimeFirecrawl?.active; + const shouldResolveFirecrawlApiKey = + runtimeFirecrawlActive === undefined ? firecrawl?.enabled !== false : runtimeFirecrawlActive; + const firecrawlApiKey = shouldResolveFirecrawlApiKey + ? resolveFirecrawlApiKey(firecrawl) + : undefined; + const firecrawlEnabled = + runtimeFirecrawlActive ?? resolveFirecrawlEnabled({ firecrawl, apiKey: firecrawlApiKey }); const firecrawlBaseUrl = resolveFirecrawlBaseUrl(firecrawl); const firecrawlOnlyMainContent = resolveFirecrawlOnlyMainContent(firecrawl); const firecrawlMaxAgeMs = resolveFirecrawlMaxAgeMsOrDefault(firecrawl); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index d4f88caea61..4fbbfa95e43 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -3,6 +3,7 @@ import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js"; import { logVerbose } from "../../globals.js"; +import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.js"; import { wrapWebContent } from "../../security/external-content.js"; import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; import type { AnyAgentTool } from "./common.js"; @@ -193,6 +194,33 @@ function createWebSearchSchema(params: { ), } as const; + const perplexityStructuredFilterSchema = { + country: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. 2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.", + }), + ), + language: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').", + }), + ), + date_after: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).", + }), + ), + date_before: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).", + }), + ), + } as const; + if (params.provider === "brave") { return Type.Object({ ...querySchema, @@ -221,7 +249,8 @@ function createWebSearchSchema(params: { } return Type.Object({ ...querySchema, - ...filterSchema, + freshness: filterSchema.freshness, + ...perplexityStructuredFilterSchema, domain_filter: Type.Optional( Type.Array(Type.String(), { description: @@ -742,6 +771,16 @@ function resolvePerplexityTransport(perplexity?: PerplexityConfig): { }; } +function resolvePerplexitySchemaTransportHint( + perplexity?: PerplexityConfig, +): PerplexityTransport | undefined { + const hasLegacyOverride = Boolean( + (perplexity?.baseUrl && perplexity.baseUrl.trim()) || + (perplexity?.model && perplexity.model.trim()), + ); + return hasLegacyOverride ? "chat_completions" : undefined; +} + function resolveGrokConfig(search?: WebSearchConfig): GrokConfig { if (!search || typeof search !== "object") { return {}; @@ -1809,15 +1848,21 @@ async function runWebSearch(params: { export function createWebSearchTool(options?: { config?: OpenClawConfig; sandboxed?: boolean; + runtimeWebSearch?: RuntimeWebSearchMetadata; }): AnyAgentTool | null { const search = resolveSearchConfig(options?.config); if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) { return null; } - const provider = resolveSearchProvider(search); + const provider = + options?.runtimeWebSearch?.selectedProvider ?? + options?.runtimeWebSearch?.providerConfigured ?? + resolveSearchProvider(search); const perplexityConfig = resolvePerplexityConfig(search); - const perplexityTransport = resolvePerplexityTransport(perplexityConfig); + const perplexitySchemaTransportHint = + options?.runtimeWebSearch?.perplexityTransport ?? + resolvePerplexitySchemaTransportHint(perplexityConfig); const grokConfig = resolveGrokConfig(search); const geminiConfig = resolveGeminiConfig(search); const kimiConfig = resolveKimiConfig(search); @@ -1826,9 +1871,9 @@ export function createWebSearchTool(options?: { const description = provider === "perplexity" - ? perplexityTransport.transport === "chat_completions" + ? perplexitySchemaTransportHint === "chat_completions" ? "Search the web using Perplexity Sonar via Perplexity/OpenRouter chat completions. Returns AI-synthesized answers with citations from web-grounded search." - : "Search the web using the Perplexity Search API. Returns structured results (title, URL, snippet) for fast research. Supports domain, region, language, and freshness filtering." + : "Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path." : provider === "grok" ? "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search." : provider === "kimi" @@ -1845,10 +1890,13 @@ export function createWebSearchTool(options?: { description, parameters: createWebSearchSchema({ provider, - perplexityTransport: provider === "perplexity" ? perplexityTransport.transport : undefined, + perplexityTransport: provider === "perplexity" ? perplexitySchemaTransportHint : undefined, }), execute: async (_toolCallId, args) => { - const perplexityRuntime = provider === "perplexity" ? perplexityTransport : undefined; + // Resolve Perplexity auth/transport lazily at execution time so unrelated providers + // do not touch Perplexity-only credential surfaces during tool construction. + const perplexityRuntime = + provider === "perplexity" ? resolvePerplexityTransport(perplexityConfig) : undefined; const apiKey = provider === "perplexity" ? perplexityRuntime?.apiKey diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index 80dcd6a025d..4951f1c6b5a 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -166,6 +166,39 @@ describe("web tools defaults", () => { const tool = createWebSearchTool({ config: {}, sandboxed: false }); expect(tool?.name).toBe("web_search"); }); + + it("prefers runtime-selected web_search provider over local provider config", async () => { + const mockFetch = installMockFetch(createProviderSuccessPayload("gemini")); + const tool = createWebSearchTool({ + config: { + tools: { + web: { + search: { + provider: "brave", + apiKey: "brave-config-test", // pragma: allowlist secret + gemini: { + apiKey: "gemini-config-test", // pragma: allowlist secret + }, + }, + }, + }, + }, + sandboxed: true, + runtimeWebSearch: { + providerConfigured: "brave", + providerSource: "auto-detect", + selectedProvider: "gemini", + selectedProviderKeySource: "secretRef", + diagnostics: [], + }, + }); + + const result = await tool?.execute?.("call-runtime-provider", { query: "runtime override" }); + + expect(mockFetch).toHaveBeenCalled(); + expect(String(mockFetch.mock.calls[0]?.[0])).toContain("generativelanguage.googleapis.com"); + expect((result?.details as { provider?: string } | undefined)?.provider).toBe("gemini"); + }); }); describe("web_search country and language parameters", () => { @@ -489,20 +522,56 @@ describe("web_search perplexity OpenRouter compatibility", () => { expect(result?.details).toMatchObject({ error: "unsupported_domain_filter" }); }); - it("hides Search API-only schema params on the compatibility path", () => { + it("keeps Search API schema params visible before runtime auth routing", () => { vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret const tool = createPerplexitySearchTool(); const properties = (tool?.parameters as { properties?: Record } | undefined) ?.properties; expect(properties?.freshness).toBeDefined(); - expect(properties?.country).toBeUndefined(); - expect(properties?.language).toBeUndefined(); - expect(properties?.date_after).toBeUndefined(); - expect(properties?.date_before).toBeUndefined(); - expect(properties?.domain_filter).toBeUndefined(); - expect(properties?.max_tokens).toBeUndefined(); - expect(properties?.max_tokens_per_page).toBeUndefined(); + expect(properties?.country).toBeDefined(); + expect(properties?.language).toBeDefined(); + expect(properties?.date_after).toBeDefined(); + expect(properties?.date_before).toBeDefined(); + expect(properties?.domain_filter).toBeDefined(); + expect(properties?.max_tokens).toBeDefined(); + expect(properties?.max_tokens_per_page).toBeDefined(); + expect( + ( + properties?.country as + | { + description?: string; + } + | undefined + )?.description, + ).toContain("Native Perplexity Search API only."); + expect( + ( + properties?.language as + | { + description?: string; + } + | undefined + )?.description, + ).toContain("Native Perplexity Search API only."); + expect( + ( + properties?.date_after as + | { + description?: string; + } + | undefined + )?.description, + ).toContain("Native Perplexity Search API only."); + expect( + ( + properties?.date_before as + | { + description?: string; + } + | undefined + )?.description, + ).toContain("Native Perplexity Search API only."); }); it("keeps structured schema params on the native Search API path", () => { @@ -522,6 +591,61 @@ describe("web_search perplexity OpenRouter compatibility", () => { }); }); +describe("web_search Perplexity lazy resolution", () => { + const priorFetch = global.fetch; + + afterEach(() => { + vi.unstubAllEnvs(); + global.fetch = priorFetch; + }); + + it("does not read Perplexity credentials while creating non-Perplexity tools", () => { + const perplexityConfig: Record = {}; + Object.defineProperty(perplexityConfig, "apiKey", { + enumerable: true, + get() { + throw new Error("perplexity-apiKey-getter-called"); + }, + }); + + const tool = createWebSearchTool({ + config: { + tools: { + web: { + search: { + provider: "gemini", + gemini: { apiKey: "gemini-config-test" }, + perplexity: perplexityConfig as { apiKey?: string; baseUrl?: string; model?: string }, + }, + }, + }, + }, + sandboxed: true, + }); + + expect(tool?.name).toBe("web_search"); + }); + + it("defers Perplexity credential reads until execute", async () => { + const perplexityConfig: Record = {}; + Object.defineProperty(perplexityConfig, "apiKey", { + enumerable: true, + get() { + throw new Error("perplexity-apiKey-getter-called"); + }, + }); + + const tool = createPerplexitySearchTool( + perplexityConfig as { apiKey?: string; baseUrl?: string; model?: string }, + ); + + expect(tool?.name).toBe("web_search"); + await expect(tool?.execute?.("call-1", { query: "test" })).rejects.toThrow( + /perplexity-apiKey-getter-called/, + ); + }); +}); + describe("web_search kimi provider", () => { const priorFetch = global.fetch; diff --git a/src/cli/command-secret-gateway.test.ts b/src/cli/command-secret-gateway.test.ts index 7929cdbdafc..6d0f89f6349 100644 --- a/src/cli/command-secret-gateway.test.ts +++ b/src/cli/command-secret-gateway.test.ts @@ -206,6 +206,119 @@ describe("resolveCommandSecretRefsViaGateway", () => { } }); + it("falls back to local resolution for web search SecretRefs when gateway is unavailable", async () => { + const envKey = "WEB_SEARCH_GEMINI_API_KEY_LOCAL_FALLBACK"; + const priorValue = process.env[envKey]; + process.env[envKey] = "gemini-local-fallback-key"; + callGateway.mockRejectedValueOnce(new Error("gateway closed")); + + try { + const result = await resolveCommandSecretRefsViaGateway({ + config: { + tools: { + web: { + search: { + provider: "gemini", + gemini: { + apiKey: { source: "env", provider: "default", id: envKey }, + }, + }, + }, + }, + } as OpenClawConfig, + commandName: "agent", + targetIds: new Set(["tools.web.search.gemini.apiKey"]), + }); + + expect(result.resolvedConfig.tools?.web?.search?.gemini?.apiKey).toBe( + "gemini-local-fallback-key", + ); + expect(result.targetStatesByPath["tools.web.search.gemini.apiKey"]).toBe("resolved_local"); + expect( + result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")), + ).toBe(true); + expect( + result.diagnostics.some((entry) => entry.includes("resolved command secrets locally")), + ).toBe(true); + } finally { + if (priorValue === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = priorValue; + } + } + }); + + it("falls back to local resolution for Firecrawl SecretRefs when gateway is unavailable", async () => { + const envKey = "WEB_FETCH_FIRECRAWL_API_KEY_LOCAL_FALLBACK"; + const priorValue = process.env[envKey]; + process.env[envKey] = "firecrawl-local-fallback-key"; + callGateway.mockRejectedValueOnce(new Error("gateway closed")); + + try { + const result = await resolveCommandSecretRefsViaGateway({ + config: { + tools: { + web: { + fetch: { + firecrawl: { + apiKey: { source: "env", provider: "default", id: envKey }, + }, + }, + }, + }, + } as OpenClawConfig, + commandName: "agent", + targetIds: new Set(["tools.web.fetch.firecrawl.apiKey"]), + }); + + expect(result.resolvedConfig.tools?.web?.fetch?.firecrawl?.apiKey).toBe( + "firecrawl-local-fallback-key", + ); + expect(result.targetStatesByPath["tools.web.fetch.firecrawl.apiKey"]).toBe("resolved_local"); + expect( + result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")), + ).toBe(true); + expect( + result.diagnostics.some((entry) => entry.includes("resolved command secrets locally")), + ).toBe(true); + } finally { + if (priorValue === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = priorValue; + } + } + }); + + it("marks web SecretRefs inactive when the web surface is disabled during local fallback", async () => { + callGateway.mockRejectedValueOnce(new Error("gateway closed")); + const result = await resolveCommandSecretRefsViaGateway({ + config: { + tools: { + web: { + search: { + enabled: false, + gemini: { + apiKey: { source: "env", provider: "default", id: "WEB_SEARCH_DISABLED_KEY" }, + }, + }, + }, + }, + } as OpenClawConfig, + commandName: "agent", + targetIds: new Set(["tools.web.search.gemini.apiKey"]), + }); + + expect(result.hadUnresolvedTargets).toBe(false); + expect(result.targetStatesByPath["tools.web.search.gemini.apiKey"]).toBe("inactive_surface"); + expect( + result.diagnostics.some((entry) => + entry.includes("tools.web.search.gemini.apiKey: tools.web.search is disabled."), + ), + ).toBe(true); + }); + it("returns a version-skew hint when gateway does not support secrets.resolve", async () => { const envKey = "TALK_API_KEY_UNSUPPORTED"; callGateway.mockRejectedValueOnce(new Error("unknown method: secrets.resolve")); diff --git a/src/cli/command-secret-gateway.ts b/src/cli/command-secret-gateway.ts index 89b8c78a3e3..03e578b642c 100644 --- a/src/cli/command-secret-gateway.ts +++ b/src/cli/command-secret-gateway.ts @@ -10,6 +10,7 @@ import { getPath, setPathExistingStrict } from "../secrets/path-utils.js"; import { resolveSecretRefValue } from "../secrets/resolve.js"; import { collectConfigAssignments } from "../secrets/runtime-config-collectors.js"; import { createResolverContext } from "../secrets/runtime-shared.js"; +import { resolveRuntimeWebTools } from "../secrets/runtime-web-tools.js"; import { assertExpectedResolvedSecretValue } from "../secrets/secret-value.js"; import { describeUnknownError } from "../secrets/shared.js"; import { @@ -44,6 +45,15 @@ type GatewaySecretsResolveResult = { inactiveRefPaths?: string[]; }; +const WEB_RUNTIME_SECRET_TARGET_ID_PREFIXES = [ + "tools.web.search", + "tools.web.fetch.firecrawl", +] as const; +const WEB_RUNTIME_SECRET_PATH_PREFIXES = [ + "tools.web.search.", + "tools.web.fetch.firecrawl.", +] as const; + function dedupeDiagnostics(entries: readonly string[]): string[] { const seen = new Set(); const ordered: string[] = []; @@ -58,6 +68,30 @@ function dedupeDiagnostics(entries: readonly string[]): string[] { return ordered; } +function targetsRuntimeWebPath(path: string): boolean { + return WEB_RUNTIME_SECRET_PATH_PREFIXES.some((prefix) => path.startsWith(prefix)); +} + +function targetsRuntimeWebResolution(params: { + targetIds: ReadonlySet; + allowedPaths?: ReadonlySet; +}): boolean { + if (params.allowedPaths) { + for (const path of params.allowedPaths) { + if (targetsRuntimeWebPath(path)) { + return true; + } + } + return false; + } + for (const targetId of params.targetIds) { + if (WEB_RUNTIME_SECRET_TARGET_ID_PREFIXES.some((prefix) => targetId.startsWith(prefix))) { + return true; + } + } + return false; +} + function collectConfiguredTargetRefPaths(params: { config: OpenClawConfig; targetIds: Set; @@ -193,17 +227,40 @@ async function resolveCommandSecretRefsLocally(params: { sourceConfig, env: process.env, }); + const localResolutionDiagnostics: string[] = []; collectConfigAssignments({ config: structuredClone(params.config), context, }); + if ( + targetsRuntimeWebResolution({ targetIds: params.targetIds, allowedPaths: params.allowedPaths }) + ) { + try { + await resolveRuntimeWebTools({ + sourceConfig, + resolvedConfig, + context, + }); + } catch (error) { + if (params.mode === "strict") { + throw error; + } + localResolutionDiagnostics.push( + `${params.commandName}: failed to resolve web tool secrets locally (${describeUnknownError(error)}).`, + ); + } + } const inactiveRefPaths = new Set( context.warnings .filter((warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE") + .filter((warning) => !params.allowedPaths || params.allowedPaths.has(warning.path)) .map((warning) => warning.path), ); + const inactiveWarningDiagnostics = context.warnings + .filter((warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE") + .filter((warning) => !params.allowedPaths || params.allowedPaths.has(warning.path)) + .map((warning) => warning.message); const activePaths = new Set(context.assignments.map((assignment) => assignment.path)); - const localResolutionDiagnostics: string[] = []; for (const target of discoverConfigSecretTargetsByIds(sourceConfig, params.targetIds)) { if (params.allowedPaths && !params.allowedPaths.has(target.path)) { continue; @@ -244,6 +301,7 @@ async function resolveCommandSecretRefsLocally(params: { resolvedConfig, diagnostics: dedupeDiagnostics([ ...params.preflightDiagnostics, + ...inactiveWarningDiagnostics, ...filterInactiveSurfaceDiagnostics({ diagnostics: analyzed.diagnostics, inactiveRefPaths, diff --git a/src/cli/command-secret-targets.test.ts b/src/cli/command-secret-targets.test.ts index 3a7de543a02..a71ac5e00c4 100644 --- a/src/cli/command-secret-targets.test.ts +++ b/src/cli/command-secret-targets.test.ts @@ -9,6 +9,7 @@ describe("command secret target ids", () => { const ids = getAgentRuntimeCommandSecretTargetIds(); expect(ids.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true); expect(ids.has("agents.list[].memorySearch.remote.apiKey")).toBe(true); + expect(ids.has("tools.web.fetch.firecrawl.apiKey")).toBe(true); }); it("keeps memory command target set focused on memorySearch remote credentials", () => { diff --git a/src/cli/command-secret-targets.ts b/src/cli/command-secret-targets.ts index c4a4fb5ea4a..e1c2c49e0ae 100644 --- a/src/cli/command-secret-targets.ts +++ b/src/cli/command-secret-targets.ts @@ -23,6 +23,7 @@ const COMMAND_SECRET_TARGETS = { "skills.entries.", "messages.tts.", "tools.web.search", + "tools.web.fetch.firecrawl.", ]), status: idsByPrefix([ "channels.", diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 89775758411..e352f858c39 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -512,7 +512,7 @@ export type ToolsConfig = { /** Enable Firecrawl fallback (default: true when apiKey is set). */ enabled?: boolean; /** Firecrawl API key (optional; defaults to FIRECRAWL_API_KEY env var). */ - apiKey?: string; + apiKey?: SecretInput; /** Firecrawl base URL (default: https://api.firecrawl.dev). */ baseUrl?: string; /** Whether to keep only main content (default: true). */ diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index e691256d70f..b3a603fa287 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -175,12 +175,14 @@ describe("gateway hot reload", () => { let prevSkipGmail: string | undefined; let prevSkipProviders: string | undefined; let prevOpenAiApiKey: string | undefined; + let prevGeminiApiKey: string | undefined; beforeEach(() => { prevSkipChannels = process.env.OPENCLAW_SKIP_CHANNELS; prevSkipGmail = process.env.OPENCLAW_SKIP_GMAIL_WATCHER; prevSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS; prevOpenAiApiKey = process.env.OPENAI_API_KEY; + prevGeminiApiKey = process.env.GEMINI_API_KEY; process.env.OPENCLAW_SKIP_CHANNELS = "0"; delete process.env.OPENCLAW_SKIP_GMAIL_WATCHER; delete process.env.OPENCLAW_SKIP_PROVIDERS; @@ -207,6 +209,11 @@ describe("gateway hot reload", () => { } else { process.env.OPENAI_API_KEY = prevOpenAiApiKey; } + if (prevGeminiApiKey === undefined) { + delete process.env.GEMINI_API_KEY; + } else { + process.env.GEMINI_API_KEY = prevGeminiApiKey; + } }); async function writeEnvRefConfig() { @@ -328,6 +335,34 @@ describe("gateway hot reload", () => { ); } + async function writeWebSearchGeminiRefConfig() { + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + throw new Error("OPENCLAW_CONFIG_PATH is not set"); + } + await fs.writeFile( + configPath, + `${JSON.stringify( + { + tools: { + web: { + search: { + enabled: true, + provider: "gemini", + gemini: { + apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, + }, + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + } + async function removeMainAuthProfileStore() { const stateDir = process.env.OPENCLAW_STATE_DIR; if (!stateDir) { @@ -540,6 +575,64 @@ describe("gateway hot reload", () => { }); }); + it("emits one-shot degraded and recovered system events for web search secret reload transitions", async () => { + await writeWebSearchGeminiRefConfig(); + process.env.GEMINI_API_KEY = "gemini-startup-key"; // pragma: allowlist secret + + await withGatewayServer(async () => { + const onHotReload = hoisted.getOnHotReload(); + expect(onHotReload).toBeTypeOf("function"); + const sessionKey = resolveMainSessionKeyFromConfig(); + const plan = { + changedPaths: ["tools.web.search.gemini.apiKey"], + restartGateway: false, + restartReasons: [], + hotReasons: ["tools.web.search.gemini.apiKey"], + reloadHooks: false, + restartGmailWatcher: false, + restartBrowserControl: false, + restartCron: false, + restartHeartbeat: false, + restartChannels: new Set(), + noopPaths: [], + }; + const nextConfig = { + tools: { + web: { + search: { + enabled: true, + provider: "gemini", + gemini: { + apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, + }, + }, + }, + }, + }; + + delete process.env.GEMINI_API_KEY; + await expect(onHotReload?.(plan, nextConfig)).rejects.toThrow( + "[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK]", + ); + const degradedEvents = drainSystemEvents(sessionKey); + expect(degradedEvents.some((event) => event.includes("[SECRETS_RELOADER_DEGRADED]"))).toBe( + true, + ); + + await expect(onHotReload?.(plan, nextConfig)).rejects.toThrow( + "[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK]", + ); + expect(drainSystemEvents(sessionKey)).toEqual([]); + + process.env.GEMINI_API_KEY = "gemini-recovered-key"; // pragma: allowlist secret + await expect(onHotReload?.(plan, nextConfig)).resolves.toBeUndefined(); + const recoveredEvents = drainSystemEvents(sessionKey); + expect(recoveredEvents.some((event) => event.includes("[SECRETS_RELOADER_RECOVERED]"))).toBe( + true, + ); + }); + }); + it("serves secrets.reload immediately after startup without race failures", async () => { await writeEnvRefConfig(); process.env.OPENAI_API_KEY = "sk-startup"; // pragma: allowlist secret diff --git a/src/secrets/runtime-config-collectors-core.ts b/src/secrets/runtime-config-collectors-core.ts index 504331f0a96..99668371ad1 100644 --- a/src/secrets/runtime-config-collectors-core.ts +++ b/src/secrets/runtime-config-collectors-core.ts @@ -292,67 +292,6 @@ function collectMessagesTtsAssignments(params: { }); } -function collectToolsWebSearchAssignments(params: { - config: OpenClawConfig; - defaults: SecretDefaults | undefined; - context: ResolverContext; -}): void { - const tools = params.config.tools as Record | undefined; - if (!isRecord(tools) || !isRecord(tools.web) || !isRecord(tools.web.search)) { - return; - } - const search = tools.web.search; - const searchEnabled = search.enabled !== false; - const rawProvider = - typeof search.provider === "string" ? search.provider.trim().toLowerCase() : ""; - const selectedProvider = - rawProvider === "brave" || - rawProvider === "gemini" || - rawProvider === "grok" || - rawProvider === "kimi" || - rawProvider === "perplexity" - ? rawProvider - : undefined; - const paths = [ - "apiKey", - "gemini.apiKey", - "grok.apiKey", - "kimi.apiKey", - "perplexity.apiKey", - ] as const; - for (const path of paths) { - const [scope, field] = path.includes(".") ? path.split(".", 2) : [undefined, path]; - const target = scope ? search[scope] : search; - if (!isRecord(target)) { - continue; - } - const active = scope - ? searchEnabled && (selectedProvider === undefined || selectedProvider === scope) - : searchEnabled && (selectedProvider === undefined || selectedProvider === "brave"); - const inactiveReason = !searchEnabled - ? "tools.web.search is disabled." - : scope - ? selectedProvider === undefined - ? undefined - : `tools.web.search.provider is "${selectedProvider}".` - : selectedProvider === undefined - ? undefined - : `tools.web.search.provider is "${selectedProvider}".`; - collectSecretInputAssignment({ - value: target[field], - path: `tools.web.search.${path}`, - expected: "string", - defaults: params.defaults, - context: params.context, - active, - inactiveReason, - apply: (value) => { - target[field] = value; - }, - }); - } -} - function collectCronAssignments(params: { config: OpenClawConfig; defaults: SecretDefaults | undefined; @@ -401,6 +340,5 @@ export function collectCoreConfigAssignments(params: { collectTalkAssignments(params); collectGatewayAssignments(params); collectMessagesTtsAssignments(params); - collectToolsWebSearchAssignments(params); collectCronAssignments(params); } diff --git a/src/secrets/runtime-shared.ts b/src/secrets/runtime-shared.ts index 8374f642de8..77dcb3c051c 100644 --- a/src/secrets/runtime-shared.ts +++ b/src/secrets/runtime-shared.ts @@ -7,7 +7,12 @@ import { isRecord } from "./shared.js"; export type SecretResolverWarningCode = | "SECRETS_REF_OVERRIDES_PLAINTEXT" - | "SECRETS_REF_IGNORED_INACTIVE_SURFACE"; + | "SECRETS_REF_IGNORED_INACTIVE_SURFACE" + | "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT" + | "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED" + | "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK" + | "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED" + | "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK"; export type SecretResolverWarning = { code: SecretResolverWarningCode; diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts new file mode 100644 index 00000000000..b8c1e679ba6 --- /dev/null +++ b/src/secrets/runtime-web-tools.test.ts @@ -0,0 +1,451 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import * as secretResolve from "./resolve.js"; +import { createResolverContext } from "./runtime-shared.js"; +import { resolveRuntimeWebTools } from "./runtime-web-tools.js"; + +type ProviderUnderTest = "brave" | "gemini" | "grok" | "kimi" | "perplexity"; + +function asConfig(value: unknown): OpenClawConfig { + return value as OpenClawConfig; +} + +async function runRuntimeWebTools(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv }) { + const sourceConfig = structuredClone(params.config); + const resolvedConfig = structuredClone(params.config); + const context = createResolverContext({ + sourceConfig, + env: params.env ?? {}, + }); + const metadata = await resolveRuntimeWebTools({ + sourceConfig, + resolvedConfig, + context, + }); + return { metadata, resolvedConfig, context }; +} + +function createProviderSecretRefConfig( + provider: ProviderUnderTest, + envRefId: string, +): OpenClawConfig { + const search: Record = { + enabled: true, + provider, + }; + if (provider === "brave") { + search.apiKey = { source: "env", provider: "default", id: envRefId }; + } else { + search[provider] = { + apiKey: { source: "env", provider: "default", id: envRefId }, + }; + } + return asConfig({ + tools: { + web: { + search, + }, + }, + }); +} + +function readProviderKey(config: OpenClawConfig, provider: ProviderUnderTest): unknown { + if (provider === "brave") { + return config.tools?.web?.search?.apiKey; + } + if (provider === "gemini") { + return config.tools?.web?.search?.gemini?.apiKey; + } + if (provider === "grok") { + return config.tools?.web?.search?.grok?.apiKey; + } + if (provider === "kimi") { + return config.tools?.web?.search?.kimi?.apiKey; + } + return config.tools?.web?.search?.perplexity?.apiKey; +} + +describe("runtime web tools resolution", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + { + provider: "brave" as const, + envRefId: "BRAVE_PROVIDER_REF", + resolvedKey: "brave-provider-key", + }, + { + provider: "gemini" as const, + envRefId: "GEMINI_PROVIDER_REF", + resolvedKey: "gemini-provider-key", + }, + { + provider: "grok" as const, + envRefId: "GROK_PROVIDER_REF", + resolvedKey: "grok-provider-key", + }, + { + provider: "kimi" as const, + envRefId: "KIMI_PROVIDER_REF", + resolvedKey: "kimi-provider-key", + }, + { + provider: "perplexity" as const, + envRefId: "PERPLEXITY_PROVIDER_REF", + resolvedKey: "pplx-provider-key", + }, + ])( + "resolves configured provider SecretRef for $provider", + async ({ provider, envRefId, resolvedKey }) => { + const { metadata, resolvedConfig, context } = await runRuntimeWebTools({ + config: createProviderSecretRefConfig(provider, envRefId), + env: { + [envRefId]: resolvedKey, + }, + }); + + expect(metadata.search.providerConfigured).toBe(provider); + expect(metadata.search.providerSource).toBe("configured"); + expect(metadata.search.selectedProvider).toBe(provider); + expect(metadata.search.selectedProviderKeySource).toBe("secretRef"); + expect(readProviderKey(resolvedConfig, provider)).toBe(resolvedKey); + expect(context.warnings.map((warning) => warning.code)).not.toContain( + "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK", + ); + if (provider === "perplexity") { + expect(metadata.search.perplexityTransport).toBe("search_api"); + } + }, + ); + + it("auto-detects provider precedence across all configured providers", async () => { + const { metadata, resolvedConfig, context } = await runRuntimeWebTools({ + config: asConfig({ + tools: { + web: { + search: { + apiKey: { source: "env", provider: "default", id: "BRAVE_REF" }, + gemini: { + apiKey: { source: "env", provider: "default", id: "GEMINI_REF" }, + }, + grok: { + apiKey: { source: "env", provider: "default", id: "GROK_REF" }, + }, + kimi: { + apiKey: { source: "env", provider: "default", id: "KIMI_REF" }, + }, + perplexity: { + apiKey: { source: "env", provider: "default", id: "PERPLEXITY_REF" }, + }, + }, + }, + }, + }), + env: { + BRAVE_REF: "brave-precedence-key", + GEMINI_REF: "gemini-precedence-key", + GROK_REF: "grok-precedence-key", + KIMI_REF: "kimi-precedence-key", + PERPLEXITY_REF: "pplx-precedence-key", + }, + }); + + expect(metadata.search.providerSource).toBe("auto-detect"); + expect(metadata.search.selectedProvider).toBe("brave"); + expect(resolvedConfig.tools?.web?.search?.apiKey).toBe("brave-precedence-key"); + expect(context.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "tools.web.search.gemini.apiKey" }), + expect.objectContaining({ path: "tools.web.search.grok.apiKey" }), + expect.objectContaining({ path: "tools.web.search.kimi.apiKey" }), + expect.objectContaining({ path: "tools.web.search.perplexity.apiKey" }), + ]), + ); + }); + + it("auto-detects first available provider and keeps lower-priority refs inactive", async () => { + const { metadata, resolvedConfig, context } = await runRuntimeWebTools({ + config: asConfig({ + tools: { + web: { + search: { + apiKey: { source: "env", provider: "default", id: "BRAVE_API_KEY_REF" }, + gemini: { + apiKey: { + source: "env", + provider: "default", + id: "MISSING_GEMINI_API_KEY_REF", + }, + }, + }, + }, + }, + }), + env: { + BRAVE_API_KEY_REF: "brave-runtime-key", + }, + }); + + expect(metadata.search.providerSource).toBe("auto-detect"); + expect(metadata.search.selectedProvider).toBe("brave"); + expect(metadata.search.selectedProviderKeySource).toBe("secretRef"); + expect(resolvedConfig.tools?.web?.search?.apiKey).toBe("brave-runtime-key"); + expect(resolvedConfig.tools?.web?.search?.gemini?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "MISSING_GEMINI_API_KEY_REF", + }); + expect(context.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", + path: "tools.web.search.gemini.apiKey", + }), + ]), + ); + expect(context.warnings.map((warning) => warning.code)).not.toContain( + "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK", + ); + }); + + it("auto-detects the next provider when a higher-priority ref is unresolved", async () => { + const { metadata, resolvedConfig, context } = await runRuntimeWebTools({ + config: asConfig({ + tools: { + web: { + search: { + apiKey: { source: "env", provider: "default", id: "MISSING_BRAVE_API_KEY_REF" }, + gemini: { + apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY_REF" }, + }, + }, + }, + }, + }), + env: { + GEMINI_API_KEY_REF: "gemini-runtime-key", + }, + }); + + expect(metadata.search.providerSource).toBe("auto-detect"); + expect(metadata.search.selectedProvider).toBe("gemini"); + expect(resolvedConfig.tools?.web?.search?.gemini?.apiKey).toBe("gemini-runtime-key"); + expect(context.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", + path: "tools.web.search.apiKey", + }), + ]), + ); + expect(context.warnings.map((warning) => warning.code)).not.toContain( + "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK", + ); + }); + + it("warns when provider is invalid and falls back to auto-detect", async () => { + const { metadata, resolvedConfig, context } = await runRuntimeWebTools({ + config: asConfig({ + tools: { + web: { + search: { + provider: "invalid-provider", + gemini: { + apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY_REF" }, + }, + }, + }, + }, + }), + env: { + GEMINI_API_KEY_REF: "gemini-runtime-key", + }, + }); + + expect(metadata.search.providerConfigured).toBeUndefined(); + expect(metadata.search.providerSource).toBe("auto-detect"); + expect(metadata.search.selectedProvider).toBe("gemini"); + expect(resolvedConfig.tools?.web?.search?.gemini?.apiKey).toBe("gemini-runtime-key"); + expect(metadata.search.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT", + path: "tools.web.search.provider", + }), + ]), + ); + expect(context.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT", + path: "tools.web.search.provider", + }), + ]), + ); + }); + + it("fails fast when configured provider ref is unresolved with no fallback", async () => { + const sourceConfig = asConfig({ + tools: { + web: { + search: { + provider: "gemini", + gemini: { + apiKey: { source: "env", provider: "default", id: "MISSING_GEMINI_API_KEY_REF" }, + }, + }, + }, + }, + }); + const resolvedConfig = structuredClone(sourceConfig); + const context = createResolverContext({ + sourceConfig, + env: {}, + }); + + await expect( + resolveRuntimeWebTools({ + sourceConfig, + resolvedConfig, + context, + }), + ).rejects.toThrow("[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK]"); + expect(context.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK", + path: "tools.web.search.gemini.apiKey", + }), + ]), + ); + }); + + it("does not resolve Firecrawl SecretRef when Firecrawl is inactive", async () => { + const resolveSpy = vi.spyOn(secretResolve, "resolveSecretRefValues"); + const { metadata, context } = await runRuntimeWebTools({ + config: asConfig({ + tools: { + web: { + fetch: { + enabled: false, + firecrawl: { + apiKey: { source: "env", provider: "default", id: "MISSING_FIRECRAWL_REF" }, + }, + }, + }, + }, + }), + }); + + expect(resolveSpy).not.toHaveBeenCalled(); + expect(metadata.fetch.firecrawl.active).toBe(false); + expect(metadata.fetch.firecrawl.apiKeySource).toBe("secretRef"); + expect(context.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", + path: "tools.web.fetch.firecrawl.apiKey", + }), + ]), + ); + }); + + it("does not resolve Firecrawl SecretRef when Firecrawl is disabled", async () => { + const resolveSpy = vi.spyOn(secretResolve, "resolveSecretRefValues"); + const { metadata, context } = await runRuntimeWebTools({ + config: asConfig({ + tools: { + web: { + fetch: { + enabled: true, + firecrawl: { + enabled: false, + apiKey: { source: "env", provider: "default", id: "MISSING_FIRECRAWL_REF" }, + }, + }, + }, + }, + }), + }); + + expect(resolveSpy).not.toHaveBeenCalled(); + expect(metadata.fetch.firecrawl.active).toBe(false); + expect(metadata.fetch.firecrawl.apiKeySource).toBe("secretRef"); + expect(context.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", + path: "tools.web.fetch.firecrawl.apiKey", + }), + ]), + ); + }); + + it("uses env fallback for unresolved Firecrawl SecretRef when active", async () => { + const { metadata, resolvedConfig, context } = await runRuntimeWebTools({ + config: asConfig({ + tools: { + web: { + fetch: { + firecrawl: { + apiKey: { source: "env", provider: "default", id: "MISSING_FIRECRAWL_REF" }, + }, + }, + }, + }, + }), + env: { + FIRECRAWL_API_KEY: "firecrawl-fallback-key", + }, + }); + + expect(metadata.fetch.firecrawl.active).toBe(true); + expect(metadata.fetch.firecrawl.apiKeySource).toBe("env"); + expect(resolvedConfig.tools?.web?.fetch?.firecrawl?.apiKey).toBe("firecrawl-fallback-key"); + expect(context.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED", + path: "tools.web.fetch.firecrawl.apiKey", + }), + ]), + ); + }); + + it("fails fast when active Firecrawl SecretRef is unresolved with no fallback", async () => { + const sourceConfig = asConfig({ + tools: { + web: { + fetch: { + firecrawl: { + apiKey: { source: "env", provider: "default", id: "MISSING_FIRECRAWL_REF" }, + }, + }, + }, + }, + }); + const resolvedConfig = structuredClone(sourceConfig); + const context = createResolverContext({ + sourceConfig, + env: {}, + }); + + await expect( + resolveRuntimeWebTools({ + sourceConfig, + resolvedConfig, + context, + }), + ).rejects.toThrow("[WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK]"); + expect(context.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK", + path: "tools.web.fetch.firecrawl.apiKey", + }), + ]), + ); + }); +}); diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts new file mode 100644 index 00000000000..004af2bdfe2 --- /dev/null +++ b/src/secrets/runtime-web-tools.ts @@ -0,0 +1,705 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { resolveSecretInputRef } from "../config/types.secrets.js"; +import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; +import { secretRefKey } from "./ref-contract.js"; +import { resolveSecretRefValues } from "./resolve.js"; +import { + pushInactiveSurfaceWarning, + pushWarning, + type ResolverContext, + type SecretDefaults, +} from "./runtime-shared.js"; + +const WEB_SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const; +const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; +const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; +const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; +const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; + +type WebSearchProvider = (typeof WEB_SEARCH_PROVIDERS)[number]; + +type SecretResolutionSource = "config" | "secretRef" | "env" | "missing"; +type RuntimeWebProviderSource = "configured" | "auto-detect" | "none"; + +export type RuntimeWebDiagnosticCode = + | "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT" + | "WEB_SEARCH_AUTODETECT_SELECTED" + | "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED" + | "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK" + | "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED" + | "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK"; + +export type RuntimeWebDiagnostic = { + code: RuntimeWebDiagnosticCode; + message: string; + path?: string; +}; + +export type RuntimeWebSearchMetadata = { + providerConfigured?: WebSearchProvider; + providerSource: RuntimeWebProviderSource; + selectedProvider?: WebSearchProvider; + selectedProviderKeySource?: SecretResolutionSource; + perplexityTransport?: "search_api" | "chat_completions"; + diagnostics: RuntimeWebDiagnostic[]; +}; + +export type RuntimeWebFetchFirecrawlMetadata = { + active: boolean; + apiKeySource: SecretResolutionSource; + diagnostics: RuntimeWebDiagnostic[]; +}; + +export type RuntimeWebToolsMetadata = { + search: RuntimeWebSearchMetadata; + fetch: { + firecrawl: RuntimeWebFetchFirecrawlMetadata; + }; + diagnostics: RuntimeWebDiagnostic[]; +}; + +type FetchConfig = NonNullable["web"] extends infer Web + ? Web extends { fetch?: infer Fetch } + ? Fetch + : undefined + : undefined; + +type SecretResolutionResult = { + value?: string; + source: SecretResolutionSource; + secretRefConfigured: boolean; + unresolvedRefReason?: string; + fallbackEnvVar?: string; + fallbackUsedAfterRefFailure: boolean; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeProvider(value: unknown): WebSearchProvider | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if ( + normalized === "brave" || + normalized === "gemini" || + normalized === "grok" || + normalized === "kimi" || + normalized === "perplexity" + ) { + return normalized; + } + return undefined; +} + +function readNonEmptyEnvValue( + env: NodeJS.ProcessEnv, + names: string[], +): { value?: string; envVar?: string } { + for (const envVar of names) { + const value = normalizeSecretInput(env[envVar]); + if (value) { + return { value, envVar }; + } + } + return {}; +} + +function buildUnresolvedReason(params: { + path: string; + kind: "unresolved" | "non-string" | "empty"; + refLabel: string; +}): string { + if (params.kind === "non-string") { + return `${params.path} SecretRef resolved to a non-string value.`; + } + if (params.kind === "empty") { + return `${params.path} SecretRef resolved to an empty value.`; + } + return `${params.path} SecretRef is unresolved (${params.refLabel}).`; +} + +async function resolveSecretInputWithEnvFallback(params: { + sourceConfig: OpenClawConfig; + context: ResolverContext; + defaults: SecretDefaults | undefined; + value: unknown; + path: string; + envVars: string[]; +}): Promise { + const { ref } = resolveSecretInputRef({ + value: params.value, + defaults: params.defaults, + }); + + if (!ref) { + const configValue = normalizeSecretInput(params.value); + if (configValue) { + return { + value: configValue, + source: "config", + secretRefConfigured: false, + fallbackUsedAfterRefFailure: false, + }; + } + const fallback = readNonEmptyEnvValue(params.context.env, params.envVars); + if (fallback.value) { + return { + value: fallback.value, + source: "env", + fallbackEnvVar: fallback.envVar, + secretRefConfigured: false, + fallbackUsedAfterRefFailure: false, + }; + } + return { + source: "missing", + secretRefConfigured: false, + fallbackUsedAfterRefFailure: false, + }; + } + + const refLabel = `${ref.source}:${ref.provider}:${ref.id}`; + let resolvedFromRef: string | undefined; + let unresolvedRefReason: string | undefined; + + try { + const resolved = await resolveSecretRefValues([ref], { + config: params.sourceConfig, + env: params.context.env, + cache: params.context.cache, + }); + const resolvedValue = resolved.get(secretRefKey(ref)); + if (typeof resolvedValue !== "string") { + unresolvedRefReason = buildUnresolvedReason({ + path: params.path, + kind: "non-string", + refLabel, + }); + } else { + resolvedFromRef = normalizeSecretInput(resolvedValue); + if (!resolvedFromRef) { + unresolvedRefReason = buildUnresolvedReason({ + path: params.path, + kind: "empty", + refLabel, + }); + } + } + } catch { + unresolvedRefReason = buildUnresolvedReason({ + path: params.path, + kind: "unresolved", + refLabel, + }); + } + + if (resolvedFromRef) { + return { + value: resolvedFromRef, + source: "secretRef", + secretRefConfigured: true, + fallbackUsedAfterRefFailure: false, + }; + } + + const fallback = readNonEmptyEnvValue(params.context.env, params.envVars); + if (fallback.value) { + return { + value: fallback.value, + source: "env", + fallbackEnvVar: fallback.envVar, + unresolvedRefReason, + secretRefConfigured: true, + fallbackUsedAfterRefFailure: true, + }; + } + + return { + source: "missing", + unresolvedRefReason, + secretRefConfigured: true, + fallbackUsedAfterRefFailure: false, + }; +} + +function inferPerplexityBaseUrlFromApiKey(apiKey?: string): "direct" | "openrouter" | undefined { + if (!apiKey) { + return undefined; + } + const normalized = apiKey.toLowerCase(); + if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return "direct"; + } + if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return "openrouter"; + } + return undefined; +} + +function resolvePerplexityRuntimeTransport(params: { + keyValue?: string; + keySource: SecretResolutionSource; + fallbackEnvVar?: string; + configValue: unknown; +}): "search_api" | "chat_completions" | undefined { + const config = isRecord(params.configValue) ? params.configValue : undefined; + const configuredBaseUrl = typeof config?.baseUrl === "string" ? config.baseUrl.trim() : ""; + const configuredModel = typeof config?.model === "string" ? config.model.trim() : ""; + + const baseUrl = (() => { + if (configuredBaseUrl) { + return configuredBaseUrl; + } + if (params.keySource === "env") { + if (params.fallbackEnvVar === "PERPLEXITY_API_KEY") { + return PERPLEXITY_DIRECT_BASE_URL; + } + if (params.fallbackEnvVar === "OPENROUTER_API_KEY") { + return DEFAULT_PERPLEXITY_BASE_URL; + } + } + if ((params.keySource === "config" || params.keySource === "secretRef") && params.keyValue) { + const inferred = inferPerplexityBaseUrlFromApiKey(params.keyValue); + return inferred === "openrouter" ? DEFAULT_PERPLEXITY_BASE_URL : PERPLEXITY_DIRECT_BASE_URL; + } + return DEFAULT_PERPLEXITY_BASE_URL; + })(); + + const hasLegacyOverride = Boolean(configuredBaseUrl || configuredModel); + const direct = (() => { + try { + return new URL(baseUrl).hostname.toLowerCase() === "api.perplexity.ai"; + } catch { + return false; + } + })(); + return hasLegacyOverride || !direct ? "chat_completions" : "search_api"; +} + +function ensureObject(target: Record, key: string): Record { + const current = target[key]; + if (isRecord(current)) { + return current; + } + const next: Record = {}; + target[key] = next; + return next; +} + +function setResolvedWebSearchApiKey(params: { + resolvedConfig: OpenClawConfig; + provider: WebSearchProvider; + value: string; +}): void { + const tools = ensureObject(params.resolvedConfig as Record, "tools"); + const web = ensureObject(tools, "web"); + const search = ensureObject(web, "search"); + if (params.provider === "brave") { + search.apiKey = params.value; + return; + } + const providerConfig = ensureObject(search, params.provider); + providerConfig.apiKey = params.value; +} + +function setResolvedFirecrawlApiKey(params: { + resolvedConfig: OpenClawConfig; + value: string; +}): void { + const tools = ensureObject(params.resolvedConfig as Record, "tools"); + const web = ensureObject(tools, "web"); + const fetch = ensureObject(web, "fetch"); + const firecrawl = ensureObject(fetch, "firecrawl"); + firecrawl.apiKey = params.value; +} + +function envVarsForProvider(provider: WebSearchProvider): string[] { + if (provider === "brave") { + return ["BRAVE_API_KEY"]; + } + if (provider === "gemini") { + return ["GEMINI_API_KEY"]; + } + if (provider === "grok") { + return ["XAI_API_KEY"]; + } + if (provider === "kimi") { + return ["KIMI_API_KEY", "MOONSHOT_API_KEY"]; + } + return ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"]; +} + +function resolveProviderKeyValue( + search: Record, + provider: WebSearchProvider, +): unknown { + if (provider === "brave") { + return search.apiKey; + } + const scoped = search[provider]; + if (!isRecord(scoped)) { + return undefined; + } + return scoped.apiKey; +} + +function hasConfiguredSecretRef(value: unknown, defaults: SecretDefaults | undefined): boolean { + return Boolean( + resolveSecretInputRef({ + value, + defaults, + }).ref, + ); +} + +export async function resolveRuntimeWebTools(params: { + sourceConfig: OpenClawConfig; + resolvedConfig: OpenClawConfig; + context: ResolverContext; +}): Promise { + const defaults = params.sourceConfig.secrets?.defaults; + const diagnostics: RuntimeWebDiagnostic[] = []; + + const tools = isRecord(params.sourceConfig.tools) ? params.sourceConfig.tools : undefined; + const web = isRecord(tools?.web) ? tools.web : undefined; + const search = isRecord(web?.search) ? web.search : undefined; + + const searchMetadata: RuntimeWebSearchMetadata = { + providerSource: "none", + diagnostics: [], + }; + + const searchEnabled = search?.enabled !== false; + const rawProvider = + typeof search?.provider === "string" ? search.provider.trim().toLowerCase() : ""; + const configuredProvider = normalizeProvider(rawProvider); + + if (rawProvider && !configuredProvider) { + const diagnostic: RuntimeWebDiagnostic = { + code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT", + message: `tools.web.search.provider is "${rawProvider}". Falling back to auto-detect precedence.`, + path: "tools.web.search.provider", + }; + diagnostics.push(diagnostic); + searchMetadata.diagnostics.push(diagnostic); + pushWarning(params.context, { + code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT", + path: "tools.web.search.provider", + message: diagnostic.message, + }); + } + + if (configuredProvider) { + searchMetadata.providerConfigured = configuredProvider; + searchMetadata.providerSource = "configured"; + } + + if (searchEnabled && search) { + const candidates = configuredProvider ? [configuredProvider] : [...WEB_SEARCH_PROVIDERS]; + const unresolvedWithoutFallback: Array<{ + provider: WebSearchProvider; + path: string; + reason: string; + }> = []; + + let selectedProvider: WebSearchProvider | undefined; + let selectedResolution: SecretResolutionResult | undefined; + + for (const provider of candidates) { + const path = + provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; + const value = resolveProviderKeyValue(search, provider); + const resolution = await resolveSecretInputWithEnvFallback({ + sourceConfig: params.sourceConfig, + context: params.context, + defaults, + value, + path, + envVars: envVarsForProvider(provider), + }); + + if (resolution.secretRefConfigured && resolution.fallbackUsedAfterRefFailure) { + const diagnostic: RuntimeWebDiagnostic = { + code: "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED", + message: + `${path} SecretRef could not be resolved; using ${resolution.fallbackEnvVar ?? "env fallback"}. ` + + (resolution.unresolvedRefReason ?? "").trim(), + path, + }; + diagnostics.push(diagnostic); + searchMetadata.diagnostics.push(diagnostic); + pushWarning(params.context, { + code: "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED", + path, + message: diagnostic.message, + }); + } + + if (resolution.secretRefConfigured && !resolution.value && resolution.unresolvedRefReason) { + unresolvedWithoutFallback.push({ + provider, + path, + reason: resolution.unresolvedRefReason, + }); + } + + if (configuredProvider) { + selectedProvider = provider; + selectedResolution = resolution; + if (resolution.value) { + setResolvedWebSearchApiKey({ + resolvedConfig: params.resolvedConfig, + provider, + value: resolution.value, + }); + } + break; + } + + if (resolution.value) { + selectedProvider = provider; + selectedResolution = resolution; + setResolvedWebSearchApiKey({ + resolvedConfig: params.resolvedConfig, + provider, + value: resolution.value, + }); + break; + } + } + + if (configuredProvider) { + const unresolved = unresolvedWithoutFallback[0]; + if (unresolved) { + const diagnostic: RuntimeWebDiagnostic = { + code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK", + message: unresolved.reason, + path: unresolved.path, + }; + diagnostics.push(diagnostic); + searchMetadata.diagnostics.push(diagnostic); + pushWarning(params.context, { + code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK", + path: unresolved.path, + message: unresolved.reason, + }); + throw new Error(`[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK] ${unresolved.reason}`); + } + } else { + if (!selectedProvider && unresolvedWithoutFallback.length > 0) { + const unresolved = unresolvedWithoutFallback[0]; + const diagnostic: RuntimeWebDiagnostic = { + code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK", + message: unresolved.reason, + path: unresolved.path, + }; + diagnostics.push(diagnostic); + searchMetadata.diagnostics.push(diagnostic); + pushWarning(params.context, { + code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK", + path: unresolved.path, + message: unresolved.reason, + }); + throw new Error(`[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK] ${unresolved.reason}`); + } + + if (selectedProvider) { + const diagnostic: RuntimeWebDiagnostic = { + code: "WEB_SEARCH_AUTODETECT_SELECTED", + message: `tools.web.search auto-detected provider "${selectedProvider}" from available credentials.`, + path: "tools.web.search.provider", + }; + diagnostics.push(diagnostic); + searchMetadata.diagnostics.push(diagnostic); + } + } + + if (selectedProvider) { + searchMetadata.selectedProvider = selectedProvider; + searchMetadata.selectedProviderKeySource = selectedResolution?.source; + if (!configuredProvider) { + searchMetadata.providerSource = "auto-detect"; + } + if (selectedProvider === "perplexity") { + searchMetadata.perplexityTransport = resolvePerplexityRuntimeTransport({ + keyValue: selectedResolution?.value, + keySource: selectedResolution?.source ?? "missing", + fallbackEnvVar: selectedResolution?.fallbackEnvVar, + configValue: search.perplexity, + }); + } + } + } + + if (searchEnabled && search && !configuredProvider && searchMetadata.selectedProvider) { + for (const provider of WEB_SEARCH_PROVIDERS) { + if (provider === searchMetadata.selectedProvider) { + continue; + } + const path = + provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; + const value = resolveProviderKeyValue(search, provider); + if (!hasConfiguredSecretRef(value, defaults)) { + continue; + } + pushInactiveSurfaceWarning({ + context: params.context, + path, + details: `tools.web.search auto-detected provider is "${searchMetadata.selectedProvider}".`, + }); + } + } else if (search && !searchEnabled) { + for (const provider of WEB_SEARCH_PROVIDERS) { + const path = + provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; + const value = resolveProviderKeyValue(search, provider); + if (!hasConfiguredSecretRef(value, defaults)) { + continue; + } + pushInactiveSurfaceWarning({ + context: params.context, + path, + details: "tools.web.search is disabled.", + }); + } + } + + if (searchEnabled && search && configuredProvider) { + for (const provider of WEB_SEARCH_PROVIDERS) { + if (provider === configuredProvider) { + continue; + } + const path = + provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; + const value = resolveProviderKeyValue(search, provider); + if (!hasConfiguredSecretRef(value, defaults)) { + continue; + } + pushInactiveSurfaceWarning({ + context: params.context, + path, + details: `tools.web.search.provider is "${configuredProvider}".`, + }); + } + } + + const fetch = isRecord(web?.fetch) ? (web.fetch as FetchConfig) : undefined; + const firecrawl = isRecord(fetch?.firecrawl) ? fetch.firecrawl : undefined; + const fetchEnabled = fetch?.enabled !== false; + const firecrawlEnabled = firecrawl?.enabled !== false; + const firecrawlActive = Boolean(fetchEnabled && firecrawlEnabled); + const firecrawlPath = "tools.web.fetch.firecrawl.apiKey"; + let firecrawlResolution: SecretResolutionResult = { + source: "missing", + secretRefConfigured: false, + fallbackUsedAfterRefFailure: false, + }; + + const firecrawlDiagnostics: RuntimeWebDiagnostic[] = []; + + if (firecrawlActive) { + firecrawlResolution = await resolveSecretInputWithEnvFallback({ + sourceConfig: params.sourceConfig, + context: params.context, + defaults, + value: firecrawl?.apiKey, + path: firecrawlPath, + envVars: ["FIRECRAWL_API_KEY"], + }); + + if (firecrawlResolution.value) { + setResolvedFirecrawlApiKey({ + resolvedConfig: params.resolvedConfig, + value: firecrawlResolution.value, + }); + } + + if (firecrawlResolution.secretRefConfigured) { + if (firecrawlResolution.fallbackUsedAfterRefFailure) { + const diagnostic: RuntimeWebDiagnostic = { + code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED", + message: + `${firecrawlPath} SecretRef could not be resolved; using ${firecrawlResolution.fallbackEnvVar ?? "env fallback"}. ` + + (firecrawlResolution.unresolvedRefReason ?? "").trim(), + path: firecrawlPath, + }; + diagnostics.push(diagnostic); + firecrawlDiagnostics.push(diagnostic); + pushWarning(params.context, { + code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED", + path: firecrawlPath, + message: diagnostic.message, + }); + } + + if (!firecrawlResolution.value && firecrawlResolution.unresolvedRefReason) { + const diagnostic: RuntimeWebDiagnostic = { + code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK", + message: firecrawlResolution.unresolvedRefReason, + path: firecrawlPath, + }; + diagnostics.push(diagnostic); + firecrawlDiagnostics.push(diagnostic); + pushWarning(params.context, { + code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK", + path: firecrawlPath, + message: firecrawlResolution.unresolvedRefReason, + }); + throw new Error( + `[WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK] ${firecrawlResolution.unresolvedRefReason}`, + ); + } + } + } else { + if (hasConfiguredSecretRef(firecrawl?.apiKey, defaults)) { + pushInactiveSurfaceWarning({ + context: params.context, + path: firecrawlPath, + details: !fetchEnabled + ? "tools.web.fetch is disabled." + : "tools.web.fetch.firecrawl.enabled is false.", + }); + firecrawlResolution = { + source: "secretRef", + secretRefConfigured: true, + fallbackUsedAfterRefFailure: false, + }; + } else { + const configuredInlineValue = normalizeSecretInput(firecrawl?.apiKey); + if (configuredInlineValue) { + firecrawlResolution = { + value: configuredInlineValue, + source: "config", + secretRefConfigured: false, + fallbackUsedAfterRefFailure: false, + }; + } else { + const envFallback = readNonEmptyEnvValue(params.context.env, ["FIRECRAWL_API_KEY"]); + if (envFallback.value) { + firecrawlResolution = { + value: envFallback.value, + source: "env", + fallbackEnvVar: envFallback.envVar, + secretRefConfigured: false, + fallbackUsedAfterRefFailure: false, + }; + } + } + } + } + + return { + search: searchMetadata, + fetch: { + firecrawl: { + active: firecrawlActive, + apiKeySource: firecrawlResolution.source, + diagnostics: firecrawlDiagnostics, + }, + }, + diagnostics, + }; +} diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 463914bf899..f03ce73da3e 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -8,6 +8,7 @@ import { withTempHome } from "../config/home-env.test-harness.js"; import { activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot, + getActiveRuntimeWebToolsMetadata, getActiveSecretsRuntimeSnapshot, prepareSecretsRuntimeSnapshot, } from "./runtime.js"; @@ -342,7 +343,7 @@ describe("secrets runtime snapshot", () => { ); }); - it("resolves provider-specific refs in web search auto mode", async () => { + it("keeps non-selected provider refs inactive in web search auto mode", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ tools: { @@ -366,9 +367,19 @@ describe("secrets runtime snapshot", () => { }); expect(snapshot.config.tools?.web?.search?.apiKey).toBe("web-search-ref"); - expect(snapshot.config.tools?.web?.search?.gemini?.apiKey).toBe("web-search-gemini-ref"); - expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( - "tools.web.search.gemini.apiKey", + expect(snapshot.config.tools?.web?.search?.gemini?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "WEB_SEARCH_GEMINI_API_KEY", + }); + expect(snapshot.webTools.search.selectedProvider).toBe("brave"); + expect(snapshot.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", + path: "tools.web.search.gemini.apiKey", + }), + ]), ); }); @@ -401,6 +412,71 @@ describe("secrets runtime snapshot", () => { ); }); + it("fails fast at startup when selected web search provider ref is unresolved", async () => { + await expect( + prepareSecretsRuntimeSnapshot({ + config: asConfig({ + tools: { + web: { + search: { + enabled: true, + provider: "gemini", + gemini: { + apiKey: { + source: "env", + provider: "default", + id: "MISSING_WEB_SEARCH_GEMINI_API_KEY", + }, + }, + }, + }, + }, + }), + env: {}, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ version: 1, profiles: {} }), + }), + ).rejects.toThrow("[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK]"); + }); + + it("exposes active runtime web tool metadata as a defensive clone", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + tools: { + web: { + search: { + provider: "gemini", + gemini: { + apiKey: { source: "env", provider: "default", id: "WEB_SEARCH_GEMINI_API_KEY" }, + }, + }, + }, + }, + }), + env: { + WEB_SEARCH_GEMINI_API_KEY: "web-search-gemini-ref", // pragma: allowlist secret + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ version: 1, profiles: {} }), + }); + + activateSecretsRuntimeSnapshot(snapshot); + + const first = getActiveRuntimeWebToolsMetadata(); + expect(first?.search.providerConfigured).toBe("gemini"); + expect(first?.search.selectedProvider).toBe("gemini"); + expect(first?.search.selectedProviderKeySource).toBe("secretRef"); + if (!first) { + throw new Error("missing runtime web tools metadata"); + } + first.search.providerConfigured = "brave"; + first.search.selectedProvider = "brave"; + + const second = getActiveRuntimeWebToolsMetadata(); + expect(second?.search.providerConfigured).toBe("gemini"); + expect(second?.search.selectedProvider).toBe("gemini"); + }); + it("resolves file refs via configured file provider", async () => { if (process.platform === "win32") { return; @@ -615,7 +691,7 @@ describe("secrets runtime snapshot", () => { }); }); - it("clears active secrets runtime state and throws when refresh fails after a write", async () => { + it("keeps last-known-good runtime snapshot active when refresh fails after a write", async () => { if (os.platform() === "win32") { return; } @@ -704,9 +780,11 @@ describe("secrets runtime snapshot", () => { /runtime snapshot refresh failed: simulated secrets runtime refresh failure/i, ); - expect(getActiveSecretsRuntimeSnapshot()).toBeNull(); - expect(loadConfig().gateway?.auth).toEqual({ mode: "token" }); - expect(loadConfig().models?.providers?.openai?.apiKey).toEqual({ + const activeAfterFailure = getActiveSecretsRuntimeSnapshot(); + expect(activeAfterFailure).not.toBeNull(); + expect(loadConfig().gateway?.auth).toBeUndefined(); + expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-file-runtime"); + expect(activeAfterFailure?.sourceConfig.models?.providers?.openai?.apiKey).toEqual({ source: "file", provider: "default", id: "/providers/openai/apiKey", @@ -715,9 +793,75 @@ describe("secrets runtime snapshot", () => { const persistedStore = ensureAuthProfileStore(agentDir).profiles["openai:default"]; expect(persistedStore).toMatchObject({ type: "api_key", - keyRef: { source: "file", provider: "default", id: "/providers/openai/apiKey" }, + key: "sk-file-runtime", + }); + }); + }); + + it("keeps last-known-good web runtime snapshot when reload introduces unresolved active web refs", async () => { + await withTempHome("openclaw-secrets-runtime-web-reload-lkg-", async (home) => { + const prepared = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + tools: { + web: { + search: { + provider: "gemini", + gemini: { + apiKey: { source: "env", provider: "default", id: "WEB_SEARCH_GEMINI_API_KEY" }, + }, + }, + }, + }, + }), + env: { + WEB_SEARCH_GEMINI_API_KEY: "web-search-gemini-runtime-key", // pragma: allowlist secret + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ version: 1, profiles: {} }), + }); + + activateSecretsRuntimeSnapshot(prepared); + + await expect( + writeConfigFile({ + ...loadConfig(), + tools: { + web: { + search: { + provider: "gemini", + gemini: { + apiKey: { + source: "env", + provider: "default", + id: "MISSING_WEB_SEARCH_GEMINI_API_KEY", + }, + }, + }, + }, + }, + }), + ).rejects.toThrow( + /runtime snapshot refresh failed: .*WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK/i, + ); + + const activeAfterFailure = getActiveSecretsRuntimeSnapshot(); + expect(activeAfterFailure).not.toBeNull(); + expect(loadConfig().tools?.web?.search?.gemini?.apiKey).toBe("web-search-gemini-runtime-key"); + expect(activeAfterFailure?.sourceConfig.tools?.web?.search?.gemini?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "WEB_SEARCH_GEMINI_API_KEY", + }); + expect(getActiveRuntimeWebToolsMetadata()?.search.selectedProvider).toBe("gemini"); + + const persistedConfig = JSON.parse( + await fs.readFile(path.join(home, ".openclaw", "openclaw.json"), "utf8"), + ) as OpenClawConfig; + expect(persistedConfig.tools?.web?.search?.gemini?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "MISSING_WEB_SEARCH_GEMINI_API_KEY", }); - expect("key" in persistedStore ? persistedStore.key : undefined).toBeUndefined(); }); }); diff --git a/src/secrets/runtime.ts b/src/secrets/runtime.ts index 9e69ffa60ad..903fe5a6d24 100644 --- a/src/secrets/runtime.ts +++ b/src/secrets/runtime.ts @@ -25,6 +25,7 @@ import { createResolverContext, type SecretResolverWarning, } from "./runtime-shared.js"; +import { resolveRuntimeWebTools, type RuntimeWebToolsMetadata } from "./runtime-web-tools.js"; export type { SecretResolverWarning } from "./runtime-shared.js"; @@ -33,6 +34,7 @@ export type PreparedSecretsRuntimeSnapshot = { config: OpenClawConfig; authStores: Array<{ agentDir: string; store: AuthProfileStore }>; warnings: SecretResolverWarning[]; + webTools: RuntimeWebToolsMetadata; }; type SecretsRuntimeRefreshContext = { @@ -57,6 +59,7 @@ function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecret store: structuredClone(entry.store), })), warnings: snapshot.warnings.map((warning) => ({ ...warning })), + webTools: structuredClone(snapshot.webTools), }; } @@ -148,6 +151,11 @@ export async function prepareSecretsRuntimeSnapshot(params: { config: resolvedConfig, authStores, warnings: context.warnings, + webTools: await resolveRuntimeWebTools({ + sourceConfig, + resolvedConfig, + context, + }), }; preparedSnapshotRefreshContext.set(snapshot, { env: { ...(params.env ?? process.env) } as Record, @@ -185,7 +193,6 @@ export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeS activateSecretsRuntimeSnapshot(refreshed); return true; }, - clearOnRefreshFailure: clearActiveSecretsRuntimeState, }); } @@ -200,6 +207,13 @@ export function getActiveSecretsRuntimeSnapshot(): PreparedSecretsRuntimeSnapsho return snapshot; } +export function getActiveRuntimeWebToolsMetadata(): RuntimeWebToolsMetadata | null { + if (!activeSnapshot) { + return null; + } + return structuredClone(activeSnapshot.webTools); +} + export function resolveCommandSecretsFromActiveRuntimeSnapshot(params: { commandName: string; targetIds: ReadonlySet; diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index 3be4992d28f..f085c9981ab 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -689,6 +689,17 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ includeInConfigure: true, includeInAudit: true, }, + { + id: "tools.web.fetch.firecrawl.apiKey", + targetType: "tools.web.fetch.firecrawl.apiKey", + configFile: "openclaw.json", + pathPattern: "tools.web.fetch.firecrawl.apiKey", + secretShape: SECRET_INPUT_SHAPE, + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, { id: "tools.web.search.apiKey", targetType: "tools.web.search.apiKey", From 705c6a422dfc75463cedc2f51d1a46cd2384d8b7 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:01:55 -0500 Subject: [PATCH 0044/1173] Add provider routing details to bug report form (#41712) --- .github/ISSUE_TEMPLATE/bug_report.yml | 31 +++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c45885b48b6..3be43c6740a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -76,6 +76,37 @@ body: label: Install method description: How OpenClaw was installed or launched. placeholder: npm global / pnpm dev / docker / mac app + - type: input + id: model + attributes: + label: Model + description: Effective model under test. + placeholder: minimax/text-01 / openrouter/anthropic/claude-opus-4.1 / anthropic/claude-sonnet-4.5 + validations: + required: true + - type: input + id: provider_chain + attributes: + label: Provider / routing chain + description: Effective request path through gateways, proxies, providers, or model routers. + placeholder: openclaw -> cloudflare-ai-gateway -> minimax + validations: + required: true + - type: input + id: config_location + attributes: + label: Config file / key location + description: Optional. Relevant config source or key path if this bug depends on overrides or custom provider setup. Redact secrets. + placeholder: ~/.openclaw/openclaw.json ; models.providers.cloudflare-ai-gateway.baseUrl ; ~/.openclaw/agents//agent/models.json + - type: textarea + id: provider_setup_details + attributes: + label: Additional provider/model setup details + description: Optional. Include redacted routing details, per-agent overrides, auth-profile interactions, env/config context, or anything else needed to explain the effective provider/model setup. Do not include API keys, tokens, or passwords. + placeholder: | + Default route is openclaw -> cloudflare-ai-gateway -> minimax. + Previous setup was openclaw -> cloudflare-ai-gateway -> openrouter -> minimax. + Relevant config lives in ~/.openclaw/openclaw.json under models.providers.minimax and models.providers.cloudflare-ai-gateway. - type: textarea id: logs attributes: From 989ee21b2414a574164d9871215cf32089edf7a7 Mon Sep 17 00:00:00 2001 From: Benji Peng <11394934+benjipeng@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:14:07 -0400 Subject: [PATCH 0045/1173] ui: fix sessions table collapse on narrow widths (#12175) Merged via squash. Prepared head SHA: b1fcfba868fcfb7b9ee3496725921f3f38f58ac4 Co-authored-by: benjipeng <11394934+benjipeng@users.noreply.github.com> Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com> Reviewed-by: @BunsDev --- .pi/prompts/reviewpr.md | 15 +++++++-------- CHANGELOG.md | 1 + src/node-host/runner.credentials.test.ts | 3 +++ ui/src/styles/components.css | 15 +++++++++++++++ 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/.pi/prompts/reviewpr.md b/.pi/prompts/reviewpr.md index e3ebc0dd9c6..1b8a20dda90 100644 --- a/.pi/prompts/reviewpr.md +++ b/.pi/prompts/reviewpr.md @@ -12,7 +12,6 @@ Do (review-only) Goal: produce a thorough review and a clear recommendation (READY FOR /landpr vs NEEDS WORK vs INVALID CLAIM). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command. 0. Truthfulness + reality gate (required for bug-fix claims) - - Do not trust the issue text or PR summary by default; verify in code and evidence. - If the PR claims to fix a bug linked to an issue, confirm the bug exists now (repro steps, logs, failing test, or clear code-path proof). - Prove root cause with exact location (`path/file.ts:line` + explanation of why behavior is wrong). @@ -86,13 +85,13 @@ B) Claim verification matrix (required) - Fill this table: - | Field | Evidence | - |---|---| - | Claimed problem | ... | - | Evidence observed (repro/log/test/code) | ... | - | Root cause location (`path:line`) | ... | - | Why this fix addresses that root cause | ... | - | Regression coverage (test name or manual proof) | ... | + | Field | Evidence | + | ----------------------------------------------- | -------- | + | Claimed problem | ... | + | Evidence observed (repro/log/test/code) | ... | + | Root cause location (`path:line`) | ... | + | Why this fix addresses that root cause | ... | + | Regression coverage (test name or manual proof) | ... | - If any row is missing/weak, default to `NEEDS WORK` or `INVALID CLAIM`. diff --git a/CHANGELOG.md b/CHANGELOG.md index c19a5c2eda7..a786e384dc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai - CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth. - Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2. - Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo. +- Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng. ## 2026.3.8 diff --git a/src/node-host/runner.credentials.test.ts b/src/node-host/runner.credentials.test.ts index 9c17c605421..6138a6b954e 100644 --- a/src/node-host/runner.credentials.test.ts +++ b/src/node-host/runner.credentials.test.ts @@ -76,6 +76,7 @@ describe("resolveNodeHostGatewayCredentials", () => { await withEnvAsync( { OPENCLAW_GATEWAY_TOKEN: undefined, + OPENCLAW_GATEWAY_PASSWORD: undefined, REMOTE_GATEWAY_TOKEN: "token-from-ref", }, async () => { @@ -91,6 +92,7 @@ describe("resolveNodeHostGatewayCredentials", () => { await withEnvAsync( { OPENCLAW_GATEWAY_TOKEN: "token-from-env", + OPENCLAW_GATEWAY_PASSWORD: undefined, REMOTE_GATEWAY_TOKEN: "token-from-ref", }, async () => { @@ -106,6 +108,7 @@ describe("resolveNodeHostGatewayCredentials", () => { await withEnvAsync( { OPENCLAW_GATEWAY_TOKEN: undefined, + OPENCLAW_GATEWAY_PASSWORD: undefined, MISSING_REMOTE_GATEWAY_TOKEN: undefined, }, async () => { diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index c7a6a425dc7..126972ca003 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1425,6 +1425,7 @@ .table { display: grid; + container-type: inline-size; gap: 6px; } @@ -1455,6 +1456,20 @@ border-color: var(--border-strong); } +@media (max-width: 1100px) { + .table-head, + .table-row { + grid-template-columns: 1fr; + } +} + +@container (max-width: 1100px) { + .table-head, + .table-row { + grid-template-columns: 1fr; + } +} + .session-link { text-decoration: none; color: var(--accent); From 96e4975922de172ddac985fcd3bfdeaf13cc16ae Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Tue, 10 Mar 2026 12:44:33 +0800 Subject: [PATCH 0046/1173] fix: protect bootstrap files during memory flush (#38574) Merged via squash. Prepared head SHA: a0b9a02e2ef1a6f5480621ccb799a8b35a10ce48 Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Reviewed-by: @frankekn --- CHANGELOG.md | 1 + src/agents/pi-embedded-runner/run/attempt.ts | 2 + src/agents/pi-embedded-runner/run/params.ts | 2 + src/agents/pi-tools.read.ts | 156 ++++++++++++++++++ src/agents/pi-tools.ts | 40 ++++- .../pi-tools.workspace-only-false.test.ts | 54 +++++- src/auto-reply/reply/agent-runner-memory.ts | 8 + .../agent-runner.runreplyagent.e2e.test.ts | 22 ++- src/auto-reply/reply/memory-flush.test.ts | 15 +- src/auto-reply/reply/memory-flush.ts | 57 ++++++- src/auto-reply/reply/reply-state.test.ts | 8 + src/infra/fs-safe.test.ts | 57 +++++++ src/infra/fs-safe.ts | 61 ++++++- 13 files changed, 468 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a786e384dc4..f017b834209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -468,6 +468,7 @@ Docs: https://docs.openclaw.ai - Control UI/Telegram sender labels: preserve inbound sender labels in sanitized chat history so dashboard user-message groups split correctly and show real group-member names instead of `You`. (#39414) Thanks @obviyus. - Agents/failover 402 recovery: keep temporary spend-limit `402` payloads retryable, preserve explicit insufficient-credit billing detection even in long provider payloads, and allow throttled billing-cooldown probes so single-provider setups can recover instead of staying locked out. (#38533) Thanks @xialonglee. - Browser/config schema: accept `browser.profiles.*.driver: "openclaw"` while preserving legacy `"clawd"` compatibility in validated config. (#39374; based on #35621) Thanks @gambletan and @ingyukoh. +- Memory flush/bootstrap file protection: restrict memory-flush runs to append-only `read`/`write` tools and route host-side memory appends through root-enforced safe file handles so flush turns cannot overwrite bootstrap files via `exec` or unsafe raw rewrites. (#38574) Thanks @frankekn. ## 2026.3.2 diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 25f13c666c7..f6f18801497 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -870,6 +870,8 @@ export async function runEmbeddedAttempt( agentDir, workspaceDir: effectiveWorkspace, config: params.config, + trigger: params.trigger, + memoryFlushWritePath: params.memoryFlushWritePath, abortSignal: runAbortController.signal, modelProvider: params.model.provider, modelId: params.modelId, diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index ee743d7a0c1..bf65515ce46 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -29,6 +29,8 @@ export type RunEmbeddedPiAgentParams = { agentAccountId?: string; /** What initiated this agent run: "user", "heartbeat", "cron", or "memory". */ trigger?: string; + /** Relative workspace path that memory-triggered writes are allowed to append to. */ + memoryFlushWritePath?: string; /** Delivery target (e.g. telegram:group:123:topic:456) for topic/thread routing. */ messageTo?: string; /** Thread/topic identifier for routing replies to the originating thread. */ diff --git a/src/agents/pi-tools.read.ts b/src/agents/pi-tools.read.ts index b01c7adff03..5ea48b01fa1 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/pi-tools.read.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { createEditTool, createReadTool, createWriteTool } from "@mariozechner/pi-coding-agent"; import { + appendFileWithinRoot, SafeOpenError, openFileWithinRoot, readFileWithinRoot, @@ -406,6 +407,161 @@ function mapContainerPathToWorkspaceRoot(params: { return path.resolve(params.root, ...relative.split("/").filter(Boolean)); } +export function resolveToolPathAgainstWorkspaceRoot(params: { + filePath: string; + root: string; + containerWorkdir?: string; +}): string { + const mapped = mapContainerPathToWorkspaceRoot(params); + const candidate = mapped.startsWith("@") ? mapped.slice(1) : mapped; + return path.isAbsolute(candidate) + ? path.resolve(candidate) + : path.resolve(params.root, candidate || "."); +} + +type MemoryFlushAppendOnlyWriteOptions = { + root: string; + relativePath: string; + containerWorkdir?: string; + sandbox?: { + root: string; + bridge: SandboxFsBridge; + }; +}; + +async function readOptionalUtf8File(params: { + absolutePath: string; + relativePath: string; + sandbox?: MemoryFlushAppendOnlyWriteOptions["sandbox"]; + signal?: AbortSignal; +}): Promise { + try { + if (params.sandbox) { + const stat = await params.sandbox.bridge.stat({ + filePath: params.relativePath, + cwd: params.sandbox.root, + signal: params.signal, + }); + if (!stat) { + return ""; + } + const buffer = await params.sandbox.bridge.readFile({ + filePath: params.relativePath, + cwd: params.sandbox.root, + signal: params.signal, + }); + return buffer.toString("utf-8"); + } + return await fs.readFile(params.absolutePath, "utf-8"); + } catch (error) { + if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") { + return ""; + } + throw error; + } +} + +async function appendMemoryFlushContent(params: { + absolutePath: string; + root: string; + relativePath: string; + content: string; + sandbox?: MemoryFlushAppendOnlyWriteOptions["sandbox"]; + signal?: AbortSignal; +}) { + if (!params.sandbox) { + await appendFileWithinRoot({ + rootDir: params.root, + relativePath: params.relativePath, + data: params.content, + mkdir: true, + prependNewlineIfNeeded: true, + }); + return; + } + + const existing = await readOptionalUtf8File({ + absolutePath: params.absolutePath, + relativePath: params.relativePath, + sandbox: params.sandbox, + signal: params.signal, + }); + const separator = + existing.length > 0 && !existing.endsWith("\n") && !params.content.startsWith("\n") ? "\n" : ""; + const next = `${existing}${separator}${params.content}`; + if (params.sandbox) { + const parent = path.posix.dirname(params.relativePath); + if (parent && parent !== ".") { + await params.sandbox.bridge.mkdirp({ + filePath: parent, + cwd: params.sandbox.root, + signal: params.signal, + }); + } + await params.sandbox.bridge.writeFile({ + filePath: params.relativePath, + cwd: params.sandbox.root, + data: next, + mkdir: true, + signal: params.signal, + }); + return; + } + await fs.mkdir(path.dirname(params.absolutePath), { recursive: true }); + await fs.writeFile(params.absolutePath, next, "utf-8"); +} + +export function wrapToolMemoryFlushAppendOnlyWrite( + tool: AnyAgentTool, + options: MemoryFlushAppendOnlyWriteOptions, +): AnyAgentTool { + const allowedAbsolutePath = path.resolve(options.root, options.relativePath); + return { + ...tool, + description: `${tool.description} During memory flush, this tool may only append to ${options.relativePath}.`, + execute: async (toolCallId, args, signal, onUpdate) => { + const normalized = normalizeToolParams(args); + const record = + normalized ?? + (args && typeof args === "object" ? (args as Record) : undefined); + assertRequiredParams(record, CLAUDE_PARAM_GROUPS.write, tool.name); + const filePath = + typeof record?.path === "string" && record.path.trim() ? record.path : undefined; + const content = typeof record?.content === "string" ? record.content : undefined; + if (!filePath || content === undefined) { + return tool.execute(toolCallId, normalized ?? args, signal, onUpdate); + } + + const resolvedPath = resolveToolPathAgainstWorkspaceRoot({ + filePath, + root: options.root, + containerWorkdir: options.containerWorkdir, + }); + if (resolvedPath !== allowedAbsolutePath) { + throw new Error( + `Memory flush writes are restricted to ${options.relativePath}; use that path only.`, + ); + } + + await appendMemoryFlushContent({ + absolutePath: allowedAbsolutePath, + root: options.root, + relativePath: options.relativePath, + content, + sandbox: options.sandbox, + signal, + }); + return { + content: [{ type: "text", text: `Appended content to ${options.relativePath}.` }], + details: { + path: options.relativePath, + appendOnly: true, + }, + }; + }, + }; +} + export function wrapToolWorkspaceRootGuardWithOptions( tool: AnyAgentTool, root: string, diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 543a163ab0c..14418bbd362 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -36,6 +36,7 @@ import { createSandboxedWriteTool, normalizeToolParams, patchToolSchemaForClaudeCompatibility, + wrapToolMemoryFlushAppendOnlyWrite, wrapToolWorkspaceRootGuard, wrapToolWorkspaceRootGuardWithOptions, wrapToolParamNormalization, @@ -67,6 +68,7 @@ const TOOL_DENY_BY_MESSAGE_PROVIDER: Readonly> voice: ["tts"], }; const TOOL_DENY_FOR_XAI_PROVIDERS = new Set(["web_search"]); +const MEMORY_FLUSH_ALLOWED_TOOL_NAMES = new Set(["read", "write"]); function normalizeMessageProvider(messageProvider?: string): string | undefined { const normalized = messageProvider?.trim().toLowerCase(); @@ -207,6 +209,10 @@ export function createOpenClawCodingTools(options?: { sessionId?: string; /** Stable run identifier for this agent invocation. */ runId?: string; + /** What initiated this run (for trigger-specific tool restrictions). */ + trigger?: string; + /** Relative workspace path that memory-triggered writes may append to. */ + memoryFlushWritePath?: string; agentDir?: string; workspaceDir?: string; config?: OpenClawConfig; @@ -258,6 +264,11 @@ export function createOpenClawCodingTools(options?: { }): AnyAgentTool[] { const execToolName = "exec"; const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined; + const isMemoryFlushRun = options?.trigger === "memory"; + if (isMemoryFlushRun && !options?.memoryFlushWritePath) { + throw new Error("memoryFlushWritePath required for memory-triggered tool runs"); + } + const memoryFlushWritePath = isMemoryFlushRun ? options.memoryFlushWritePath : undefined; const { agentId, globalPolicy, @@ -322,7 +333,7 @@ export function createOpenClawCodingTools(options?: { const execConfig = resolveExecConfig({ cfg: options?.config, agentId }); const fsConfig = resolveToolFsConfig({ cfg: options?.config, agentId }); const fsPolicy = createToolFsPolicy({ - workspaceOnly: fsConfig.workspaceOnly, + workspaceOnly: isMemoryFlushRun || fsConfig.workspaceOnly, }); const sandboxRoot = sandbox?.workspaceDir; const sandboxFsBridge = sandbox?.fsBridge; @@ -515,7 +526,32 @@ export function createOpenClawCodingTools(options?: { sessionId: options?.sessionId, }), ]; - const toolsForMessageProvider = applyMessageProviderToolPolicy(tools, options?.messageProvider); + const toolsForMemoryFlush = + isMemoryFlushRun && memoryFlushWritePath + ? tools.flatMap((tool) => { + if (!MEMORY_FLUSH_ALLOWED_TOOL_NAMES.has(tool.name)) { + return []; + } + if (tool.name === "write") { + return [ + wrapToolMemoryFlushAppendOnlyWrite(tool, { + root: sandboxRoot ?? workspaceRoot, + relativePath: memoryFlushWritePath, + containerWorkdir: sandbox?.containerWorkdir, + sandbox: + sandboxRoot && sandboxFsBridge + ? { root: sandboxRoot, bridge: sandboxFsBridge } + : undefined, + }), + ]; + } + return [tool]; + }) + : tools; + const toolsForMessageProvider = applyMessageProviderToolPolicy( + toolsForMemoryFlush, + options?.messageProvider, + ); const toolsForModelProvider = applyModelProviderToolPolicy(toolsForMessageProvider, { modelProvider: options?.modelProvider, modelId: options?.modelId, diff --git a/src/agents/pi-tools.workspace-only-false.test.ts b/src/agents/pi-tools.workspace-only-false.test.ts index 713315de899..fb18260db09 100644 --- a/src/agents/pi-tools.workspace-only-false.test.ts +++ b/src/agents/pi-tools.workspace-only-false.test.ts @@ -1,7 +1,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@mariozechner/pi-ai/oauth", () => ({ + getOAuthApiKey: () => undefined, + getOAuthProviders: () => [], +})); + import { createOpenClawCodingTools } from "./pi-tools.js"; describe("FS tools with workspaceOnly=false", () => { @@ -181,4 +187,50 @@ describe("FS tools with workspaceOnly=false", () => { }), ).rejects.toThrow(/Path escapes (workspace|sandbox) root/); }); + + it("restricts memory-triggered writes to append-only canonical memory files", async () => { + const allowedRelativePath = "memory/2026-03-07.md"; + const allowedAbsolutePath = path.join(workspaceDir, allowedRelativePath); + await fs.mkdir(path.dirname(allowedAbsolutePath), { recursive: true }); + await fs.writeFile(allowedAbsolutePath, "seed"); + + const tools = createOpenClawCodingTools({ + workspaceDir, + trigger: "memory", + memoryFlushWritePath: allowedRelativePath, + config: { + tools: { + exec: { + applyPatch: { + enabled: true, + }, + }, + }, + }, + modelProvider: "openai", + modelId: "gpt-5", + }); + + const writeTool = tools.find((tool) => tool.name === "write"); + expect(writeTool).toBeDefined(); + expect(tools.map((tool) => tool.name).toSorted()).toEqual(["read", "write"]); + + await expect( + writeTool!.execute("test-call-memory-deny", { + path: outsideFile, + content: "should not write here", + }), + ).rejects.toThrow(/Memory flush writes are restricted to memory\/2026-03-07\.md/); + + const result = await writeTool!.execute("test-call-memory-append", { + path: allowedRelativePath, + content: "new note", + }); + expect(hasToolError(result)).toBe(false); + expect(result.content).toContainEqual({ + type: "text", + text: "Appended content to memory/2026-03-07.md.", + }); + await expect(fs.readFile(allowedAbsolutePath, "utf-8")).resolves.toBe("seed\nnew note"); + }); }); diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 643611d35a2..623bb9c1490 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -34,6 +34,7 @@ import { import { hasAlreadyFlushedForCurrentCompaction, resolveMemoryFlushContextWindowTokens, + resolveMemoryFlushRelativePathForRun, resolveMemoryFlushPromptForRun, resolveMemoryFlushSettings, shouldRunMemoryFlush, @@ -465,6 +466,11 @@ export async function runMemoryFlushIfNeeded(params: { }); } let memoryCompactionCompleted = false; + const memoryFlushNowMs = Date.now(); + const memoryFlushWritePath = resolveMemoryFlushRelativePathForRun({ + cfg: params.cfg, + nowMs: memoryFlushNowMs, + }); const flushSystemPrompt = [ params.followupRun.run.extraSystemPrompt, memoryFlushSettings.systemPrompt, @@ -495,9 +501,11 @@ export async function runMemoryFlushIfNeeded(params: { ...senderContext, ...runBaseParams, trigger: "memory", + memoryFlushWritePath, prompt: resolveMemoryFlushPromptForRun({ prompt: memoryFlushSettings.prompt, cfg: params.cfg, + nowMs: memoryFlushNowMs, }), extraSystemPrompt: flushSystemPrompt, bootstrapPromptWarningSignaturesSeen, diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index db034ac03a6..599a8fd6a48 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -28,6 +28,7 @@ type AgentRunParams = { type EmbeddedRunParams = { prompt?: string; extraSystemPrompt?: string; + memoryFlushWritePath?: string; bootstrapPromptWarningSignaturesSeen?: string[]; bootstrapPromptWarningSignature?: string; onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void; @@ -1611,9 +1612,14 @@ describe("runReplyAgent memory flush", () => { const flushCall = calls[0]; expect(flushCall?.prompt).toContain("Write notes."); expect(flushCall?.prompt).toContain("NO_REPLY"); + expect(flushCall?.prompt).toMatch(/memory\/\d{4}-\d{2}-\d{2}\.md/); + expect(flushCall?.prompt).toContain("MEMORY.md"); + expect(flushCall?.memoryFlushWritePath).toMatch(/^memory\/\d{4}-\d{2}-\d{2}\.md$/); expect(flushCall?.extraSystemPrompt).toContain("extra system"); expect(flushCall?.extraSystemPrompt).toContain("Flush memory now."); expect(flushCall?.extraSystemPrompt).toContain("NO_REPLY"); + expect(flushCall?.extraSystemPrompt).toContain("memory/YYYY-MM-DD.md"); + expect(flushCall?.extraSystemPrompt).toContain("MEMORY.md"); expect(calls[1]?.prompt).toBe("hello"); }); }); @@ -1701,9 +1707,17 @@ describe("runReplyAgent memory flush", () => { await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - const calls: Array<{ prompt?: string }> = []; + const calls: Array<{ + prompt?: string; + extraSystemPrompt?: string; + memoryFlushWritePath?: string; + }> = []; state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { - calls.push({ prompt: params.prompt }); + calls.push({ + prompt: params.prompt, + extraSystemPrompt: params.extraSystemPrompt, + memoryFlushWritePath: params.memoryFlushWritePath, + }); if (params.prompt?.includes("Pre-compaction memory flush.")) { return { payloads: [], meta: {} }; } @@ -1730,6 +1744,10 @@ describe("runReplyAgent memory flush", () => { expect(calls[0]?.prompt).toContain("Pre-compaction memory flush."); expect(calls[0]?.prompt).toContain("Current time:"); expect(calls[0]?.prompt).toMatch(/memory\/\d{4}-\d{2}-\d{2}\.md/); + expect(calls[0]?.prompt).toContain("MEMORY.md"); + expect(calls[0]?.memoryFlushWritePath).toMatch(/^memory\/\d{4}-\d{2}-\d{2}\.md$/); + expect(calls[0]?.extraSystemPrompt).toContain("memory/YYYY-MM-DD.md"); + expect(calls[0]?.extraSystemPrompt).toContain("MEMORY.md"); expect(calls[1]?.prompt).toBe("hello"); const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); diff --git a/src/auto-reply/reply/memory-flush.test.ts b/src/auto-reply/reply/memory-flush.test.ts index 0e04e7e0ea3..079c5578676 100644 --- a/src/auto-reply/reply/memory-flush.test.ts +++ b/src/auto-reply/reply/memory-flush.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import { DEFAULT_MEMORY_FLUSH_PROMPT, resolveMemoryFlushPromptForRun } from "./memory-flush.js"; +import { + DEFAULT_MEMORY_FLUSH_PROMPT, + resolveMemoryFlushPromptForRun, + resolveMemoryFlushRelativePathForRun, +} from "./memory-flush.js"; describe("resolveMemoryFlushPromptForRun", () => { const cfg = { @@ -35,6 +39,15 @@ describe("resolveMemoryFlushPromptForRun", () => { expect(prompt).toContain("Current time: already present"); expect((prompt.match(/Current time:/g) ?? []).length).toBe(1); }); + + it("resolves the canonical relative memory path using user timezone", () => { + const relativePath = resolveMemoryFlushRelativePathForRun({ + cfg, + nowMs: Date.UTC(2026, 1, 16, 15, 0, 0), + }); + + expect(relativePath).toBe("memory/2026-02-16.md"); + }); }); describe("DEFAULT_MEMORY_FLUSH_PROMPT", () => { diff --git a/src/auto-reply/reply/memory-flush.ts b/src/auto-reply/reply/memory-flush.ts index c02fad5eca0..95f6dbaa053 100644 --- a/src/auto-reply/reply/memory-flush.ts +++ b/src/auto-reply/reply/memory-flush.ts @@ -10,10 +10,23 @@ import { SILENT_REPLY_TOKEN } from "../tokens.js"; export const DEFAULT_MEMORY_FLUSH_SOFT_TOKENS = 4000; export const DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES = 2 * 1024 * 1024; +const MEMORY_FLUSH_TARGET_HINT = + "Store durable memories only in memory/YYYY-MM-DD.md (create memory/ if needed)."; +const MEMORY_FLUSH_APPEND_ONLY_HINT = + "If memory/YYYY-MM-DD.md already exists, APPEND new content only and do not overwrite existing entries."; +const MEMORY_FLUSH_READ_ONLY_HINT = + "Treat workspace bootstrap/reference files such as MEMORY.md, SOUL.md, TOOLS.md, and AGENTS.md as read-only during this flush; never overwrite, replace, or edit them."; +const MEMORY_FLUSH_REQUIRED_HINTS = [ + MEMORY_FLUSH_TARGET_HINT, + MEMORY_FLUSH_APPEND_ONLY_HINT, + MEMORY_FLUSH_READ_ONLY_HINT, +]; + export const DEFAULT_MEMORY_FLUSH_PROMPT = [ "Pre-compaction memory flush.", - "Store durable memories now (use memory/YYYY-MM-DD.md; create memory/ if needed).", - "IMPORTANT: If the file already exists, APPEND new content only — do not overwrite existing entries.", + MEMORY_FLUSH_TARGET_HINT, + MEMORY_FLUSH_READ_ONLY_HINT, + MEMORY_FLUSH_APPEND_ONLY_HINT, "Do NOT create timestamped variant files (e.g., YYYY-MM-DD-HHMM.md); always use the canonical YYYY-MM-DD.md filename.", `If nothing to store, reply with ${SILENT_REPLY_TOKEN}.`, ].join(" "); @@ -21,6 +34,9 @@ export const DEFAULT_MEMORY_FLUSH_PROMPT = [ export const DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT = [ "Pre-compaction memory flush turn.", "The session is near auto-compaction; capture durable memories to disk.", + MEMORY_FLUSH_TARGET_HINT, + MEMORY_FLUSH_READ_ONLY_HINT, + MEMORY_FLUSH_APPEND_ONLY_HINT, `You may reply, but usually ${SILENT_REPLY_TOKEN} is correct.`, ].join(" "); @@ -40,14 +56,29 @@ function formatDateStampInTimezone(nowMs: number, timezone: string): string { return new Date(nowMs).toISOString().slice(0, 10); } +export function resolveMemoryFlushRelativePathForRun(params: { + cfg?: OpenClawConfig; + nowMs?: number; +}): string { + const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now(); + const { userTimezone } = resolveCronStyleNow(params.cfg ?? {}, nowMs); + const dateStamp = formatDateStampInTimezone(nowMs, userTimezone); + return `memory/${dateStamp}.md`; +} + export function resolveMemoryFlushPromptForRun(params: { prompt: string; cfg?: OpenClawConfig; nowMs?: number; }): string { const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now(); - const { userTimezone, timeLine } = resolveCronStyleNow(params.cfg ?? {}, nowMs); - const dateStamp = formatDateStampInTimezone(nowMs, userTimezone); + const { timeLine } = resolveCronStyleNow(params.cfg ?? {}, nowMs); + const dateStamp = resolveMemoryFlushRelativePathForRun({ + cfg: params.cfg, + nowMs, + }) + .replace(/^memory\//, "") + .replace(/\.md$/, ""); const withDate = params.prompt.replaceAll("YYYY-MM-DD", dateStamp).trimEnd(); if (!withDate) { return timeLine; @@ -90,8 +121,12 @@ export function resolveMemoryFlushSettings(cfg?: OpenClawConfig): MemoryFlushSet const forceFlushTranscriptBytes = parseNonNegativeByteSize(defaults?.forceFlushTranscriptBytes) ?? DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES; - const prompt = defaults?.prompt?.trim() || DEFAULT_MEMORY_FLUSH_PROMPT; - const systemPrompt = defaults?.systemPrompt?.trim() || DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT; + const prompt = ensureMemoryFlushSafetyHints( + defaults?.prompt?.trim() || DEFAULT_MEMORY_FLUSH_PROMPT, + ); + const systemPrompt = ensureMemoryFlushSafetyHints( + defaults?.systemPrompt?.trim() || DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT, + ); const reserveTokensFloor = normalizeNonNegativeInt(cfg?.agents?.defaults?.compaction?.reserveTokensFloor) ?? DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR; @@ -113,6 +148,16 @@ function ensureNoReplyHint(text: string): string { return `${text}\n\nIf no user-visible reply is needed, start with ${SILENT_REPLY_TOKEN}.`; } +function ensureMemoryFlushSafetyHints(text: string): string { + let next = text.trim(); + for (const hint of MEMORY_FLUSH_REQUIRED_HINTS) { + if (!next.includes(hint)) { + next = next ? `${next}\n\n${hint}` : hint; + } + } + return next; +} + export function resolveMemoryFlushContextWindowTokens(params: { modelId?: string; agentCfgContextTokens?: number; diff --git a/src/auto-reply/reply/reply-state.test.ts b/src/auto-reply/reply/reply-state.test.ts index 56623fe6cfa..69dbad531e7 100644 --- a/src/auto-reply/reply/reply-state.test.ts +++ b/src/auto-reply/reply/reply-state.test.ts @@ -203,6 +203,10 @@ describe("memory flush settings", () => { expect(settings?.forceFlushTranscriptBytes).toBe(DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES); expect(settings?.prompt.length).toBeGreaterThan(0); expect(settings?.systemPrompt.length).toBeGreaterThan(0); + expect(settings?.prompt).toContain("memory/YYYY-MM-DD.md"); + expect(settings?.prompt).toContain("MEMORY.md"); + expect(settings?.systemPrompt).toContain("memory/YYYY-MM-DD.md"); + expect(settings?.systemPrompt).toContain("MEMORY.md"); }); it("respects disable flag", () => { @@ -230,6 +234,10 @@ describe("memory flush settings", () => { }); expect(settings?.prompt).toContain("NO_REPLY"); expect(settings?.systemPrompt).toContain("NO_REPLY"); + expect(settings?.prompt).toContain("memory/YYYY-MM-DD.md"); + expect(settings?.prompt).toContain("MEMORY.md"); + expect(settings?.systemPrompt).toContain("memory/YYYY-MM-DD.md"); + expect(settings?.systemPrompt).toContain("MEMORY.md"); }); it("falls back to defaults when numeric values are invalid", () => { diff --git a/src/infra/fs-safe.test.ts b/src/infra/fs-safe.test.ts index a8372a86c70..ba4c13dfc7c 100644 --- a/src/infra/fs-safe.test.ts +++ b/src/infra/fs-safe.test.ts @@ -7,6 +7,7 @@ import { } from "../test-utils/symlink-rebind-race.js"; import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; import { + appendFileWithinRoot, copyFileWithinRoot, createRootScopedReadFile, SafeOpenError, @@ -246,6 +247,22 @@ describe("fs-safe", () => { await expect(fs.readFile(path.join(root, "nested", "out.txt"), "utf8")).resolves.toBe("hello"); }); + it("appends to a file within root safely", async () => { + const root = await tempDirs.make("openclaw-fs-safe-root-"); + const targetPath = path.join(root, "nested", "out.txt"); + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, "seed"); + + await appendFileWithinRoot({ + rootDir: root, + relativePath: "nested/out.txt", + data: "next", + prependNewlineIfNeeded: true, + }); + + await expect(fs.readFile(targetPath, "utf8")).resolves.toBe("seed\nnext"); + }); + it("does not truncate existing target when atomic rename fails", async () => { const root = await tempDirs.make("openclaw-fs-safe-root-"); const targetPath = path.join(root, "nested", "out.txt"); @@ -439,6 +456,25 @@ describe("fs-safe", () => { }); }); + it.runIf(process.platform !== "win32")("rejects appending through hardlink aliases", async () => { + const root = await tempDirs.make("openclaw-fs-safe-root-"); + const hardlinkPath = path.join(root, "alias.txt"); + await withOutsideHardlinkAlias({ + aliasPath: hardlinkPath, + run: async (outsideFile) => { + await expect( + appendFileWithinRoot({ + rootDir: root, + relativePath: "alias.txt", + data: "pwned", + prependNewlineIfNeeded: true, + }), + ).rejects.toMatchObject({ code: "invalid-path" }); + await expect(fs.readFile(outsideFile, "utf8")).resolves.toBe("outside"); + }, + }); + }); + it("does not truncate out-of-root file when symlink retarget races write open", async () => { const { root, outside, slot, outsideTarget } = await setupSymlinkWriteRaceFixture({ seedInsideTarget: true, @@ -459,6 +495,27 @@ describe("fs-safe", () => { await expect(fs.readFile(outsideTarget, "utf8")).resolves.toBe("X".repeat(4096)); }); + it("does not clobber out-of-root file when symlink retarget races append open", async () => { + const { root, outside, slot, outsideTarget } = await setupSymlinkWriteRaceFixture({ + seedInsideTarget: true, + }); + + await expectSymlinkWriteRaceRejectsOutside({ + slotPath: slot, + outsideDir: outside, + runWrite: async (relativePath) => + await appendFileWithinRoot({ + rootDir: root, + relativePath, + data: "new-content", + mkdir: false, + prependNewlineIfNeeded: true, + }), + }); + + await expect(fs.readFile(outsideTarget, "utf8")).resolves.toBe("X".repeat(4096)); + }); + it("does not clobber out-of-root file when symlink retarget races write-from-path open", async () => { const { root, outside, slot, outsideTarget } = await setupSymlinkWriteRaceFixture(); const sourceDir = await tempDirs.make("openclaw-fs-safe-source-"); diff --git a/src/infra/fs-safe.ts b/src/infra/fs-safe.ts index 3a0f28ddd2c..77754437528 100644 --- a/src/infra/fs-safe.ts +++ b/src/infra/fs-safe.ts @@ -57,6 +57,14 @@ const OPEN_WRITE_CREATE_FLAGS = fsConstants.O_CREAT | fsConstants.O_EXCL | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); +const OPEN_APPEND_EXISTING_FLAGS = + fsConstants.O_RDWR | fsConstants.O_APPEND | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); +const OPEN_APPEND_CREATE_FLAGS = + fsConstants.O_RDWR | + fsConstants.O_APPEND | + fsConstants.O_CREAT | + fsConstants.O_EXCL | + (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); const ensureTrailingSep = (value: string) => (value.endsWith(path.sep) ? value : value + path.sep); @@ -375,6 +383,7 @@ export async function openWritableFileWithinRoot(params: { mkdir?: boolean; mode?: number; truncateExisting?: boolean; + append?: boolean; }): Promise { const { rootReal, rootWithSep, resolved } = await resolvePathWithinRoot(params); try { @@ -410,14 +419,16 @@ export async function openWritableFileWithinRoot(params: { let handle: FileHandle; let createdForWrite = false; + const existingFlags = params.append ? OPEN_APPEND_EXISTING_FLAGS : OPEN_WRITE_EXISTING_FLAGS; + const createFlags = params.append ? OPEN_APPEND_CREATE_FLAGS : OPEN_WRITE_CREATE_FLAGS; try { try { - handle = await fs.open(ioPath, OPEN_WRITE_EXISTING_FLAGS, fileMode); + handle = await fs.open(ioPath, existingFlags, fileMode); } catch (err) { if (!isNotFoundPathError(err)) { throw err; } - handle = await fs.open(ioPath, OPEN_WRITE_CREATE_FLAGS, fileMode); + handle = await fs.open(ioPath, createFlags, fileMode); createdForWrite = true; } } catch (err) { @@ -469,7 +480,7 @@ export async function openWritableFileWithinRoot(params: { // Truncate only after boundary and identity checks complete. This avoids // irreversible side effects if a symlink target changes before validation. - if (params.truncateExisting !== false && !createdForWrite) { + if (params.append !== true && params.truncateExisting !== false && !createdForWrite) { await handle.truncate(0); } return { @@ -489,6 +500,50 @@ export async function openWritableFileWithinRoot(params: { } } +export async function appendFileWithinRoot(params: { + rootDir: string; + relativePath: string; + data: string | Buffer; + encoding?: BufferEncoding; + mkdir?: boolean; + prependNewlineIfNeeded?: boolean; +}): Promise { + const target = await openWritableFileWithinRoot({ + rootDir: params.rootDir, + relativePath: params.relativePath, + mkdir: params.mkdir, + truncateExisting: false, + append: true, + }); + try { + let prefix = ""; + if ( + params.prependNewlineIfNeeded === true && + !target.createdForWrite && + target.openedStat.size > 0 && + ((typeof params.data === "string" && !params.data.startsWith("\n")) || + (Buffer.isBuffer(params.data) && params.data.length > 0 && params.data[0] !== 0x0a)) + ) { + const lastByte = Buffer.alloc(1); + const { bytesRead } = await target.handle.read(lastByte, 0, 1, target.openedStat.size - 1); + if (bytesRead === 1 && lastByte[0] !== 0x0a) { + prefix = "\n"; + } + } + + if (typeof params.data === "string") { + await target.handle.appendFile(`${prefix}${params.data}`, params.encoding ?? "utf8"); + return; + } + + const payload = + prefix.length > 0 ? Buffer.concat([Buffer.from(prefix, "utf8"), params.data]) : params.data; + await target.handle.appendFile(payload); + } finally { + await target.handle.close().catch(() => {}); + } +} + export async function writeFileWithinRoot(params: { rootDir: string; relativePath: string; From da4fec664121b8ca443a3d72d19a6a1c9200204f Mon Sep 17 00:00:00 2001 From: Wayne <105773686+hougangdev@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:47:39 +0800 Subject: [PATCH 0047/1173] fix(telegram): prevent duplicate messages when preview edit times out (#41662) Merged via squash. Prepared head SHA: 2780e62d070d7b4c4d7447e966ca172e33e44ad4 Co-authored-by: hougangdev <105773686+hougangdev@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + src/telegram/bot-message-dispatch.test.ts | 204 +++++++++++++++++++ src/telegram/bot-message-dispatch.ts | 34 +++- src/telegram/lane-delivery-text-deliverer.ts | 158 ++++++++++---- src/telegram/lane-delivery.test.ts | 147 ++++++++++++- src/telegram/lane-delivery.ts | 1 + 6 files changed, 492 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f017b834209..e80e2c34ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai - Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2. - Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo. - Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng. +- Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev. ## 2026.3.8 diff --git a/src/telegram/bot-message-dispatch.test.ts b/src/telegram/bot-message-dispatch.test.ts index 7caa7cc3af7..4f5e2484d50 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/src/telegram/bot-message-dispatch.test.ts @@ -906,6 +906,131 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(deliverReplies).not.toHaveBeenCalled(); }); + it("keeps the active preview when an archived final edit target is missing", async () => { + let answerMessageId: number | undefined; + let answerDraftParams: + | { + onSupersededPreview?: (preview: { messageId: number; textSnapshot: string }) => void; + } + | undefined; + const answerDraftStream = { + update: vi.fn().mockImplementation((text: string) => { + if (text.includes("Message B")) { + answerMessageId = 1002; + } + }), + flush: vi.fn().mockResolvedValue(undefined), + messageId: vi.fn().mockImplementation(() => answerMessageId), + clear: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + forceNewMessage: vi.fn().mockImplementation(() => { + answerMessageId = undefined; + }), + }; + const reasoningDraftStream = createDraftStream(); + createTelegramDraftStream + .mockImplementationOnce((params) => { + answerDraftParams = params as typeof answerDraftParams; + return answerDraftStream; + }) + .mockImplementationOnce(() => reasoningDraftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: "Message A partial" }); + await replyOptions?.onAssistantMessageStart?.(); + await replyOptions?.onPartialReply?.({ text: "Message B partial" }); + answerDraftParams?.onSupersededPreview?.({ + messageId: 1001, + textSnapshot: "Message A partial", + }); + + await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + deliverReplies.mockResolvedValue({ delivered: true }); + editMessageTelegram.mockRejectedValue(new Error("400: Bad Request: message to edit not found")); + + await dispatchWithContext({ context: createContext(), streamMode: "partial" }); + + expect(editMessageTelegram).toHaveBeenCalledWith( + 123, + 1001, + "Message A final", + expect.any(Object), + ); + expect(answerDraftStream.clear).not.toHaveBeenCalled(); + expect(deliverReplies).not.toHaveBeenCalled(); + }); + + it("still finalizes the active preview after an archived final edit is retained", async () => { + let answerMessageId: number | undefined; + let answerDraftParams: + | { + onSupersededPreview?: (preview: { messageId: number; textSnapshot: string }) => void; + } + | undefined; + const answerDraftStream = { + update: vi.fn().mockImplementation((text: string) => { + if (text.includes("Message B")) { + answerMessageId = 1002; + } + }), + flush: vi.fn().mockResolvedValue(undefined), + messageId: vi.fn().mockImplementation(() => answerMessageId), + clear: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + forceNewMessage: vi.fn().mockImplementation(() => { + answerMessageId = undefined; + }), + }; + const reasoningDraftStream = createDraftStream(); + createTelegramDraftStream + .mockImplementationOnce((params) => { + answerDraftParams = params as typeof answerDraftParams; + return answerDraftStream; + }) + .mockImplementationOnce(() => reasoningDraftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: "Message A partial" }); + await replyOptions?.onAssistantMessageStart?.(); + await replyOptions?.onPartialReply?.({ text: "Message B partial" }); + answerDraftParams?.onSupersededPreview?.({ + messageId: 1001, + textSnapshot: "Message A partial", + }); + + await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" }); + await dispatcherOptions.deliver({ text: "Message B final" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + deliverReplies.mockResolvedValue({ delivered: true }); + editMessageTelegram + .mockRejectedValueOnce(new Error("400: Bad Request: message to edit not found")) + .mockResolvedValueOnce({ ok: true, chatId: "123", messageId: "1002" }); + + await dispatchWithContext({ context: createContext(), streamMode: "partial" }); + + expect(editMessageTelegram).toHaveBeenNthCalledWith( + 1, + 123, + 1001, + "Message A final", + expect.any(Object), + ); + expect(editMessageTelegram).toHaveBeenNthCalledWith( + 2, + 123, + 1002, + "Message B final", + expect.any(Object), + ); + expect(answerDraftStream.clear).not.toHaveBeenCalled(); + expect(deliverReplies).not.toHaveBeenCalled(); + }); + it.each(["partial", "block"] as const)( "keeps finalized text preview when the next assistant message is media-only (%s mode)", async (streamMode) => { @@ -1903,4 +2028,83 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftA.clear).toHaveBeenCalledTimes(1); expect(draftB.clear).toHaveBeenCalledTimes(1); }); + + it("swallows post-connect network timeout on preview edit to prevent duplicate messages", async () => { + const draftStream = createDraftStream(999); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: "Streaming..." }); + await dispatcherOptions.deliver({ text: "Final answer" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + deliverReplies.mockResolvedValue({ delivered: true }); + // Simulate a post-connect timeout: editMessageTelegram throws a network + // error even though Telegram's server already processed the edit. + editMessageTelegram.mockRejectedValue(new Error("timeout: request timed out after 30000ms")); + + await dispatchWithContext({ context: createContext() }); + + expect(editMessageTelegram).toHaveBeenCalledTimes(1); + const deliverCalls = deliverReplies.mock.calls; + const finalTextSentViaDeliverReplies = deliverCalls.some((call: unknown[]) => + (call[0] as { replies?: Array<{ text?: string }> })?.replies?.some( + (r: { text?: string }) => r.text === "Final answer", + ), + ); + expect(finalTextSentViaDeliverReplies).toBe(false); + }); + + it("falls back to sendPayload on pre-connect error during final edit", async () => { + const draftStream = createDraftStream(999); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: "Streaming..." }); + await dispatcherOptions.deliver({ text: "Final answer" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + deliverReplies.mockResolvedValue({ delivered: true }); + const preConnectErr = new Error("connect ECONNREFUSED 149.154.167.220:443"); + (preConnectErr as NodeJS.ErrnoException).code = "ECONNREFUSED"; + editMessageTelegram.mockRejectedValue(preConnectErr); + + await dispatchWithContext({ context: createContext() }); + + expect(editMessageTelegram).toHaveBeenCalledTimes(1); + const deliverCalls = deliverReplies.mock.calls; + const finalTextSentViaDeliverReplies = deliverCalls.some((call: unknown[]) => + (call[0] as { replies?: Array<{ text?: string }> })?.replies?.some( + (r: { text?: string }) => r.text === "Final answer", + ), + ); + expect(finalTextSentViaDeliverReplies).toBe(true); + }); + + it("falls back when Telegram reports the current final edit target missing", async () => { + const draftStream = createDraftStream(999); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: "Streaming..." }); + await dispatcherOptions.deliver({ text: "Final answer" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + deliverReplies.mockResolvedValue({ delivered: true }); + editMessageTelegram.mockRejectedValue(new Error("400: Bad Request: message to edit not found")); + + await dispatchWithContext({ context: createContext() }); + + expect(editMessageTelegram).toHaveBeenCalledTimes(1); + const deliverCalls = deliverReplies.mock.calls; + const finalTextSentViaDeliverReplies = deliverCalls.some((call: unknown[]) => + (call[0] as { replies?: Array<{ text?: string }> })?.replies?.some( + (r: { text?: string }) => r.text === "Final answer", + ), + ); + expect(finalTextSentViaDeliverReplies).toBe(true); + }); }); diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index fee56211ae5..4d8d2b678e8 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -38,6 +38,7 @@ import { createLaneTextDeliverer, type DraftLaneState, type LaneName, + type LanePreviewLifecycle, } from "./lane-delivery.js"; import { createTelegramReasoningStepState, @@ -239,7 +240,14 @@ export const dispatchTelegramMessage = async ({ answer: createDraftLane("answer", canStreamAnswerDraft), reasoning: createDraftLane("reasoning", canStreamReasoningDraft), }; - const finalizedPreviewByLane: Record = { + // Active preview lifecycle answers "can this current preview still be + // finalized?" Cleanup retention is separate so archived-preview decisions do + // not poison the active lane. + const activePreviewLifecycleByLane: Record = { + answer: "transient", + reasoning: "transient", + }; + const retainPreviewOnCleanupByLane: Record = { answer: false, reasoning: false, }; @@ -288,7 +296,10 @@ export const dispatchTelegramMessage = async ({ // so it remains visible across tool boundaries. const materializedId = await answerLane.stream?.materialize?.(); const previewMessageId = materializedId ?? answerLane.stream?.messageId(); - if (typeof previewMessageId === "number" && !finalizedPreviewByLane.answer) { + if ( + typeof previewMessageId === "number" && + activePreviewLifecycleByLane.answer === "transient" + ) { archivedAnswerPreviews.push({ messageId: previewMessageId, textSnapshot: answerLane.lastPartialText, @@ -301,7 +312,8 @@ export const dispatchTelegramMessage = async ({ resetDraftLaneState(answerLane); if (didForceNewMessage) { // New assistant message boundary: this lane now tracks a fresh preview lifecycle. - finalizedPreviewByLane.answer = false; + activePreviewLifecycleByLane.answer = "transient"; + retainPreviewOnCleanupByLane.answer = false; } return didForceNewMessage; }; @@ -331,7 +343,7 @@ export const dispatchTelegramMessage = async ({ const ingestDraftLaneSegments = async (text: string | undefined) => { const split = splitTextIntoLaneSegments(text); const hasAnswerSegment = split.segments.some((segment) => segment.lane === "answer"); - if (hasAnswerSegment && finalizedPreviewByLane.answer) { + if (hasAnswerSegment && activePreviewLifecycleByLane.answer !== "transient") { // Some providers can emit the first partial of a new assistant message before // onAssistantMessageStart() arrives. Rotate preemptively so we do not edit // the previously finalized preview message with the next message's text. @@ -469,7 +481,8 @@ export const dispatchTelegramMessage = async ({ const deliverLaneText = createLaneTextDeliverer({ lanes, archivedAnswerPreviews, - finalizedPreviewByLane, + activePreviewLifecycleByLane, + retainPreviewOnCleanupByLane, draftMaxChars, applyTextToPayload, sendPayload, @@ -596,7 +609,8 @@ export const dispatchTelegramMessage = async ({ } if (info.kind === "final") { if (reasoningLane.hasStreamedMessage) { - finalizedPreviewByLane.reasoning = true; + activePreviewLifecycleByLane.reasoning = "complete"; + retainPreviewOnCleanupByLane.reasoning = true; } reasoningStepState.resetForNextStep(); } @@ -674,7 +688,8 @@ export const dispatchTelegramMessage = async ({ reasoningStepState.resetForNextStep(); if (skipNextAnswerMessageStartRotation) { skipNextAnswerMessageStartRotation = false; - finalizedPreviewByLane.answer = false; + activePreviewLifecycleByLane.answer = "transient"; + retainPreviewOnCleanupByLane.answer = false; return; } await rotateAnswerLaneForNewAssistantMessage(); @@ -682,7 +697,8 @@ export const dispatchTelegramMessage = async ({ // Even when no forceNewMessage happened (e.g. prior answer had no // streamed partials), the next partial belongs to a fresh lifecycle // and must not trigger late pre-rotation mid-message. - finalizedPreviewByLane.answer = false; + activePreviewLifecycleByLane.answer = "transient"; + retainPreviewOnCleanupByLane.answer = false; }) : undefined, onReasoningEnd: reasoningLane.stream @@ -731,7 +747,7 @@ export const dispatchTelegramMessage = async ({ (p) => p.deleteIfUnused === false && p.messageId === activePreviewMessageId, ); const shouldClear = - !finalizedPreviewByLane[laneState.laneName] && !hasBoundaryFinalizedActivePreview; + !retainPreviewOnCleanupByLane[laneState.laneName] && !hasBoundaryFinalizedActivePreview; const existing = streamCleanupStates.get(stream); if (!existing) { streamCleanupStates.set(stream, { shouldClear }); diff --git a/src/telegram/lane-delivery-text-deliverer.ts b/src/telegram/lane-delivery-text-deliverer.ts index f244d086657..c8eb10a9bb1 100644 --- a/src/telegram/lane-delivery-text-deliverer.ts +++ b/src/telegram/lane-delivery-text-deliverer.ts @@ -1,22 +1,36 @@ import type { ReplyPayload } from "../auto-reply/types.js"; import type { TelegramInlineButtons } from "./button-types.js"; import type { TelegramDraftStream } from "./draft-stream.js"; +import { isRecoverableTelegramNetworkError, isSafeToRetrySendError } from "./network-errors.js"; const MESSAGE_NOT_MODIFIED_RE = /400:\s*Bad Request:\s*message is not modified|MESSAGE_NOT_MODIFIED/i; +const MESSAGE_NOT_FOUND_RE = + /400:\s*Bad Request:\s*message to edit not found|MESSAGE_ID_INVALID|message can't be edited/i; + +function extractErrorText(err: unknown): string { + return typeof err === "string" + ? err + : err instanceof Error + ? err.message + : typeof err === "object" && err && "description" in err + ? typeof err.description === "string" + ? err.description + : "" + : ""; +} function isMessageNotModifiedError(err: unknown): boolean { - const text = - typeof err === "string" - ? err - : err instanceof Error - ? err.message - : typeof err === "object" && err && "description" in err - ? typeof err.description === "string" - ? err.description - : "" - : ""; - return MESSAGE_NOT_MODIFIED_RE.test(text); + return MESSAGE_NOT_MODIFIED_RE.test(extractErrorText(err)); +} + +/** + * Returns true when Telegram rejects an edit because the target message can no + * longer be resolved or edited. The caller still needs preview context to + * decide whether to retain a different visible preview or fall back to send. + */ +function isMissingPreviewMessageError(err: unknown): boolean { + return MESSAGE_NOT_FOUND_RE.test(extractErrorText(err)); } export type LaneName = "answer" | "reasoning"; @@ -35,12 +49,20 @@ export type ArchivedPreview = { deleteIfUnused?: boolean; }; -export type LaneDeliveryResult = "preview-finalized" | "preview-updated" | "sent" | "skipped"; +export type LanePreviewLifecycle = "transient" | "complete"; + +export type LaneDeliveryResult = + | "preview-finalized" + | "preview-retained" + | "preview-updated" + | "sent" + | "skipped"; type CreateLaneTextDelivererParams = { lanes: Record; archivedAnswerPreviews: ArchivedPreview[]; - finalizedPreviewByLane: Record; + activePreviewLifecycleByLane: Record; + retainPreviewOnCleanupByLane: Record; draftMaxChars: number; applyTextToPayload: (payload: ReplyPayload, text: string) => ReplyPayload; sendPayload: (payload: ReplyPayload) => Promise; @@ -80,6 +102,8 @@ type TryUpdatePreviewParams = { previewTextSnapshot?: string; }; +type PreviewEditResult = "edited" | "retained" | "fallback"; + type ConsumeArchivedAnswerPreviewParams = { lane: DraftLaneState; text: string; @@ -139,6 +163,10 @@ function resolvePreviewTarget(params: ResolvePreviewTargetParams): PreviewTarget export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { const getLanePreviewText = (lane: DraftLaneState) => lane.lastPartialText; + const markActivePreviewComplete = (laneName: LaneName) => { + params.activePreviewLifecycleByLane[laneName] = "complete"; + params.retainPreviewOnCleanupByLane[laneName] = true; + }; const isDraftPreviewLane = (lane: DraftLaneState) => lane.stream?.previewMode?.() === "draft"; const canMaterializeDraftFinal = ( lane: DraftLaneState, @@ -184,8 +212,9 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { previewButtons?: TelegramInlineButtons; updateLaneSnapshot: boolean; lane: DraftLaneState; - treatEditFailureAsDelivered: boolean; - }): Promise => { + finalTextAlreadyLanded: boolean; + retainAlternatePreviewOnMissingTarget: boolean; + }): Promise => { try { await params.editPreview({ laneName: args.laneName, @@ -198,26 +227,58 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { args.lane.lastPartialText = args.text; } params.markDelivered(); - return true; + return "edited"; } catch (err) { if (isMessageNotModifiedError(err)) { params.log( `telegram: ${args.laneName} preview ${args.context} edit returned "message is not modified"; treating as delivered`, ); params.markDelivered(); - return true; + return "edited"; } - if (args.treatEditFailureAsDelivered) { + if (args.context === "final") { + if (args.finalTextAlreadyLanded) { + params.log( + `telegram: ${args.laneName} preview final edit failed after stop flush; keeping existing preview (${String(err)})`, + ); + params.markDelivered(); + return "retained"; + } + if (isSafeToRetrySendError(err)) { + params.log( + `telegram: ${args.laneName} preview final edit failed before reaching Telegram; falling back to standard send (${String(err)})`, + ); + return "fallback"; + } + if (isMissingPreviewMessageError(err)) { + if (args.retainAlternatePreviewOnMissingTarget) { + params.log( + `telegram: ${args.laneName} preview final edit target missing; keeping alternate preview without fallback (${String(err)})`, + ); + params.markDelivered(); + return "retained"; + } + params.log( + `telegram: ${args.laneName} preview final edit target missing with no alternate preview; falling back to standard send (${String(err)})`, + ); + return "fallback"; + } + if (isRecoverableTelegramNetworkError(err, { allowMessageMatch: true })) { + params.log( + `telegram: ${args.laneName} preview final edit may have landed despite network error; keeping existing preview (${String(err)})`, + ); + params.markDelivered(); + return "retained"; + } params.log( - `telegram: ${args.laneName} preview ${args.context} edit failed after stop-created flush; treating as delivered (${String(err)})`, + `telegram: ${args.laneName} preview final edit rejected by Telegram; falling back to standard send (${String(err)})`, ); - params.markDelivered(); - return true; + return "fallback"; } params.log( `telegram: ${args.laneName} preview ${args.context} edit failed; falling back to standard send (${String(err)})`, ); - return false; + return "fallback"; } }; @@ -232,8 +293,12 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { context, previewMessageId: previewMessageIdOverride, previewTextSnapshot, - }: TryUpdatePreviewParams): Promise => { - const editPreview = (messageId: number, treatEditFailureAsDelivered: boolean) => + }: TryUpdatePreviewParams): Promise => { + const editPreview = ( + messageId: number, + finalTextAlreadyLanded: boolean, + retainAlternatePreviewOnMissingTarget: boolean, + ) => tryEditPreviewMessage({ laneName, messageId, @@ -242,13 +307,15 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { previewButtons, updateLaneSnapshot, lane, - treatEditFailureAsDelivered, + finalTextAlreadyLanded, + retainAlternatePreviewOnMissingTarget, }); const finalizePreview = ( previewMessageId: number, - treatEditFailureAsDelivered: boolean, + finalTextAlreadyLanded: boolean, hadPreviewMessage: boolean, - ): boolean | Promise => { + retainAlternatePreviewOnMissingTarget = false, + ): PreviewEditResult | Promise => { const currentPreviewText = previewTextSnapshot ?? getLanePreviewText(lane); const shouldSkipRegressive = shouldSkipRegressivePreviewUpdate({ currentPreviewText, @@ -258,12 +325,16 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { }); if (shouldSkipRegressive) { params.markDelivered(); - return true; + return "edited"; } - return editPreview(previewMessageId, treatEditFailureAsDelivered); + return editPreview( + previewMessageId, + finalTextAlreadyLanded, + retainAlternatePreviewOnMissingTarget, + ); }; if (!lane.stream) { - return false; + return "fallback"; } const previewTargetBeforeStop = resolvePreviewTarget({ lane, @@ -282,7 +353,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { context, }); if (typeof previewTargetAfterStop.previewMessageId !== "number") { - return false; + return "fallback"; } return finalizePreview(previewTargetAfterStop.previewMessageId, true, false); } @@ -296,12 +367,15 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { context, }); if (typeof previewTargetAfterStop.previewMessageId !== "number") { - return false; + return "fallback"; } + const activePreviewMessageId = lane.stream?.messageId(); return finalizePreview( previewTargetAfterStop.previewMessageId, false, previewTargetAfterStop.hadPreviewMessage, + typeof activePreviewMessageId === "number" && + activePreviewMessageId !== previewTargetAfterStop.previewMessageId, ); }; @@ -328,9 +402,13 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { previewMessageId: archivedPreview.messageId, previewTextSnapshot: archivedPreview.textSnapshot, }); - if (finalized) { + if (finalized === "edited") { return "preview-finalized"; } + if (finalized === "retained") { + params.retainPreviewOnCleanupByLane.answer = true; + return "preview-retained"; + } } // Send the replacement message first, then clean up the old preview. // This avoids the visual "disappear then reappear" flash. @@ -375,7 +453,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { return archivedResult; } } - if (canEditViaPreview && !params.finalizedPreviewByLane[laneName]) { + if (canEditViaPreview && params.activePreviewLifecycleByLane[laneName] === "transient") { await params.flushDraftLane(lane); if (laneName === "answer") { const archivedResultAfterFlush = await consumeArchivedAnswerPreviewForFinal({ @@ -396,7 +474,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { text, }); if (materialized) { - params.finalizedPreviewByLane[laneName] = true; + markActivePreviewComplete(laneName); return "preview-finalized"; } } @@ -409,10 +487,14 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { skipRegressive: "existingOnly", context: "final", }); - if (finalized) { - params.finalizedPreviewByLane[laneName] = true; + if (finalized === "edited") { + markActivePreviewComplete(laneName); return "preview-finalized"; } + if (finalized === "retained") { + markActivePreviewComplete(laneName); + return "preview-retained"; + } } else if (!hasMedia && !payload.isError && text.length > params.draftMaxChars) { params.log( `telegram: preview final too long for edit (${text.length} > ${params.draftMaxChars}); falling back to standard send`, @@ -452,7 +534,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { skipRegressive: "always", context: "update", }); - if (updated) { + if (updated === "edited") { return "preview-updated"; } } diff --git a/src/telegram/lane-delivery.test.ts b/src/telegram/lane-delivery.test.ts index 1cd1d36cf4c..a2dae1f05b9 100644 --- a/src/telegram/lane-delivery.test.ts +++ b/src/telegram/lane-delivery.test.ts @@ -42,7 +42,8 @@ function createHarness(params?: { const deletePreviewMessage = vi.fn().mockResolvedValue(undefined); const log = vi.fn(); const markDelivered = vi.fn(); - const finalizedPreviewByLane: Record = { answer: false, reasoning: false }; + const activePreviewLifecycleByLane = { answer: "transient", reasoning: "transient" } as const; + const retainPreviewOnCleanupByLane = { answer: false, reasoning: false } as const; const archivedAnswerPreviews: Array<{ messageId: number; textSnapshot: string; @@ -52,7 +53,8 @@ function createHarness(params?: { const deliverLaneText = createLaneTextDeliverer({ lanes, archivedAnswerPreviews, - finalizedPreviewByLane, + activePreviewLifecycleByLane: { ...activePreviewLifecycleByLane }, + retainPreviewOnCleanupByLane: { ...retainPreviewOnCleanupByLane }, draftMaxChars: params?.draftMaxChars ?? 4_096, applyTextToPayload: (payload: ReplyPayload, text: string) => ({ ...payload, text }), sendPayload, @@ -129,7 +131,7 @@ describe("createLaneTextDeliverer", () => { expect(harness.sendPayload).not.toHaveBeenCalled(); }); - it("treats stop-created preview edit failures as delivered", async () => { + it("keeps stop-created preview when follow-up final edit fails", async () => { const harness = createHarness({ answerMessageIdAfterStop: 777 }); harness.editPreview.mockRejectedValue(new Error("500: edit failed after stop flush")); @@ -140,10 +142,12 @@ describe("createLaneTextDeliverer", () => { infoKind: "final", }); - expect(result).toBe("preview-finalized"); + expect(result).toBe("preview-retained"); expect(harness.editPreview).toHaveBeenCalledTimes(1); expect(harness.sendPayload).not.toHaveBeenCalled(); - expect(harness.log).toHaveBeenCalledWith(expect.stringContaining("treating as delivered")); + expect(harness.log).toHaveBeenCalledWith( + expect.stringContaining("failed after stop flush; keeping existing preview"), + ); }); it("treats 'message is not modified' preview edit errors as delivered", async () => { @@ -170,7 +174,7 @@ describe("createLaneTextDeliverer", () => { ); }); - it("falls back to normal delivery when editing an existing preview fails", async () => { + it("falls back to sendPayload when an existing preview final edit is rejected", async () => { const harness = createHarness({ answerMessageId: 999 }); harness.editPreview.mockRejectedValue(new Error("500: preview edit failed")); @@ -186,6 +190,69 @@ describe("createLaneTextDeliverer", () => { expect(harness.sendPayload).toHaveBeenCalledWith( expect.objectContaining({ text: "Hello final" }), ); + expect(harness.log).toHaveBeenCalledWith( + expect.stringContaining("edit rejected by Telegram; falling back"), + ); + }); + + it("falls back when Telegram reports the current final edit target missing", async () => { + const harness = createHarness({ answerMessageId: 999 }); + harness.editPreview.mockRejectedValue(new Error("400: Bad Request: message to edit not found")); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Hello final", + payload: { text: "Hello final" }, + infoKind: "final", + }); + + expect(result).toBe("sent"); + expect(harness.editPreview).toHaveBeenCalledTimes(1); + expect(harness.sendPayload).toHaveBeenCalledWith( + expect.objectContaining({ text: "Hello final" }), + ); + expect(harness.log).toHaveBeenCalledWith( + expect.stringContaining("edit target missing with no alternate preview; falling back"), + ); + }); + + it("falls back to sendPayload when the final edit fails before reaching Telegram", async () => { + const harness = createHarness({ answerMessageId: 999 }); + const err = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }); + harness.editPreview.mockRejectedValue(err); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Hello final", + payload: { text: "Hello final" }, + infoKind: "final", + }); + + expect(result).toBe("sent"); + expect(harness.sendPayload).toHaveBeenCalledWith( + expect.objectContaining({ text: "Hello final" }), + ); + expect(harness.log).toHaveBeenCalledWith( + expect.stringContaining("failed before reaching Telegram; falling back"), + ); + }); + + it("keeps preview when the final edit times out after the request may have landed", async () => { + const harness = createHarness({ answerMessageId: 999 }); + harness.editPreview.mockRejectedValue(new Error("timeout: request timed out after 30000ms")); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Hello final", + payload: { text: "Hello final" }, + infoKind: "final", + }); + + expect(result).toBe("preview-retained"); + expect(harness.sendPayload).not.toHaveBeenCalled(); + expect(harness.log).toHaveBeenCalledWith( + expect.stringContaining("may have landed despite network error; keeping existing preview"), + ); }); it("falls back to normal delivery when stop-created preview has no message id", async () => { @@ -362,6 +429,74 @@ describe("createLaneTextDeliverer", () => { expect(harness.markDelivered).not.toHaveBeenCalled(); }); + // ── Duplicate message regression tests ────────────────────────────────── + // During final delivery, only ambiguous post-connect failures keep the + // preview. Definite non-delivery falls back to a real send. + + it("falls back on API error during final", async () => { + const harness = createHarness({ answerMessageId: 999 }); + harness.editPreview.mockRejectedValue(new Error("500: Internal Server Error")); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Hello final", + payload: { text: "Hello final" }, + infoKind: "final", + }); + + expect(result).toBe("sent"); + expect(harness.editPreview).toHaveBeenCalledTimes(1); + expect(harness.sendPayload).toHaveBeenCalledTimes(1); + }); + + it("falls back when an archived preview edit target is missing and no alternate preview exists", async () => { + const harness = createHarness(); + harness.archivedAnswerPreviews.push({ + messageId: 5555, + textSnapshot: "Partial streaming...", + deleteIfUnused: true, + }); + harness.editPreview.mockRejectedValue(new Error("400: Bad Request: message to edit not found")); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Complete final answer", + payload: { text: "Complete final answer" }, + infoKind: "final", + }); + + expect(harness.editPreview).toHaveBeenCalledTimes(1); + expect(harness.sendPayload).toHaveBeenCalledWith( + expect.objectContaining({ text: "Complete final answer" }), + ); + expect(result).toBe("sent"); + expect(harness.deletePreviewMessage).toHaveBeenCalledWith(5555); + }); + + it("keeps the active preview when an archived final edit target is missing", async () => { + const harness = createHarness({ answerMessageId: 999 }); + harness.archivedAnswerPreviews.push({ + messageId: 5555, + textSnapshot: "Partial streaming...", + deleteIfUnused: true, + }); + harness.editPreview.mockRejectedValue(new Error("400: Bad Request: message to edit not found")); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Complete final answer", + payload: { text: "Complete final answer" }, + infoKind: "final", + }); + + expect(harness.editPreview).toHaveBeenCalledTimes(1); + expect(harness.sendPayload).not.toHaveBeenCalled(); + expect(result).toBe("preview-retained"); + expect(harness.log).toHaveBeenCalledWith( + expect.stringContaining("edit target missing; keeping alternate preview without fallback"), + ); + }); + it("deletes consumed boundary previews after fallback final send", async () => { const harness = createHarness(); harness.archivedAnswerPreviews.push({ diff --git a/src/telegram/lane-delivery.ts b/src/telegram/lane-delivery.ts index 213b05e1158..a9114b281ff 100644 --- a/src/telegram/lane-delivery.ts +++ b/src/telegram/lane-delivery.ts @@ -4,6 +4,7 @@ export { type DraftLaneState, type LaneDeliveryResult, type LaneName, + type LanePreviewLifecycle, } from "./lane-delivery-text-deliverer.js"; export { createLaneDeliveryStateTracker, From 382287026b55e787d28f19d762380344c9f4408d Mon Sep 17 00:00:00 2001 From: futuremind2026 Date: Tue, 10 Mar 2026 13:01:45 +0800 Subject: [PATCH 0048/1173] cron: record lastErrorReason in job state (#14382) Merged via squash. Prepared head SHA: baa6b5d566a41950dea0a214881eef48697326d8 Co-authored-by: futuremind2026 <258860756+futuremind2026@users.noreply.github.com> Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com> Reviewed-by: @BunsDev --- CHANGELOG.md | 1 + src/cron/cron-protocol-conformance.test.ts | 27 +++++++++++++++++++++- src/cron/service/timer.ts | 6 ++++- src/cron/types.ts | 4 +++- src/gateway/protocol/schema/cron.ts | 10 ++++++++ 5 files changed, 45 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e80e2c34ce4..6bc7bf6f07f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai - Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo. - Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng. - Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev. +- Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026. ## 2026.3.8 diff --git a/src/cron/cron-protocol-conformance.test.ts b/src/cron/cron-protocol-conformance.test.ts index 51fe8f4767c..698f5e0038d 100644 --- a/src/cron/cron-protocol-conformance.test.ts +++ b/src/cron/cron-protocol-conformance.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js"; -import { CronDeliverySchema } from "../gateway/protocol/schema.js"; +import { CronDeliverySchema, CronJobStateSchema } from "../gateway/protocol/schema.js"; type SchemaLike = { anyOf?: Array; @@ -29,6 +29,16 @@ function extractDeliveryModes(schema: SchemaLike): string[] { return Array.from(new Set(unionModes)); } +function extractConstUnionValues(schema: SchemaLike): string[] { + return Array.from( + new Set( + (schema.anyOf ?? []) + .map((entry) => entry?.const) + .filter((value): value is string => typeof value === "string"), + ), + ); +} + const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"]; const SWIFT_MODEL_CANDIDATES = [`${MACOS_APP_SOURCES_DIR}/CronModels.swift`]; @@ -88,4 +98,19 @@ describe("cron protocol conformance", () => { expect(swift.includes("struct CronSchedulerStatus")).toBe(true); expect(swift.includes("let jobs:")).toBe(true); }); + + it("cron job state schema keeps the full failover reason set", () => { + const properties = (CronJobStateSchema as SchemaLike).properties ?? {}; + const lastErrorReason = properties.lastErrorReason as SchemaLike | undefined; + expect(lastErrorReason).toBeDefined(); + expect(extractConstUnionValues(lastErrorReason ?? {})).toEqual([ + "auth", + "format", + "rate_limit", + "billing", + "timeout", + "model_not_found", + "unknown", + ]); + }); }); diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 5320ffdf526..e12c4ae38e7 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -1,3 +1,4 @@ +import { resolveFailoverReasonFromError } from "../../agents/failover-error.js"; import type { CronConfig, CronRetryOn } from "../../config/types.cron.js"; import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js"; import { DEFAULT_AGENT_ID } from "../../routing/session-key.js"; @@ -322,6 +323,10 @@ export function applyJobResult( job.state.lastStatus = result.status; job.state.lastDurationMs = Math.max(0, result.endedAt - result.startedAt); job.state.lastError = result.error; + job.state.lastErrorReason = + result.status === "error" && typeof result.error === "string" + ? (resolveFailoverReasonFromError(result.error) ?? undefined) + : undefined; job.state.lastDelivered = result.delivered; const deliveryStatus = resolveDeliveryStatus({ job, delivered: result.delivered }); job.state.lastDeliveryStatus = deliveryStatus; @@ -670,7 +675,6 @@ export async function onTimer(state: CronServiceState) { if (completedResults.length > 0) { await locked(state, async () => { await ensureLoaded(state, { forceReload: true, skipRecompute: true }); - for (const result of completedResults) { applyOutcomeToStoredJob(state, result); } diff --git a/src/cron/types.ts b/src/cron/types.ts index ef5de924b02..2a93bc30311 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -1,3 +1,4 @@ +import type { FailoverReason } from "../agents/pi-embedded-helpers.js"; import type { ChannelId } from "../channels/plugins/types.js"; import type { CronJobBase } from "./types-shared.js"; @@ -105,7 +106,6 @@ type CronAgentTurnPayload = { type CronAgentTurnPayloadPatch = { kind: "agentTurn"; } & Partial; - export type CronJobState = { nextRunAtMs?: number; runningAtMs?: number; @@ -115,6 +115,8 @@ export type CronJobState = { /** Back-compat alias for lastRunStatus. */ lastStatus?: "ok" | "error" | "skipped"; lastError?: string; + /** Classified reason for the last error (when available). */ + lastErrorReason?: FailoverReason; lastDurationMs?: number; /** Number of consecutive execution errors (reset on success). Used for backoff. */ consecutiveErrors?: number; diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index 41e7467bece..3cba5a65781 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -56,6 +56,15 @@ const CronDeliveryStatusSchema = Type.Union([ Type.Literal("unknown"), Type.Literal("not-requested"), ]); +const CronFailoverReasonSchema = Type.Union([ + Type.Literal("auth"), + Type.Literal("format"), + Type.Literal("rate_limit"), + Type.Literal("billing"), + Type.Literal("timeout"), + Type.Literal("model_not_found"), + Type.Literal("unknown"), +]); const CronCommonOptionalFields = { agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), sessionKey: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), @@ -219,6 +228,7 @@ export const CronJobStateSchema = Type.Object( lastRunStatus: Type.Optional(CronRunStatusSchema), lastStatus: Type.Optional(CronRunStatusSchema), lastError: Type.Optional(Type.String()), + lastErrorReason: Type.Optional(CronFailoverReasonSchema), lastDurationMs: Type.Optional(Type.Integer({ minimum: 0 })), consecutiveErrors: Type.Optional(Type.Integer({ minimum: 0 })), lastDelivered: Type.Optional(Type.Boolean()), From cf9db91b611c79e71281f226a401e51931d6643b Mon Sep 17 00:00:00 2001 From: Laurie Luo Date: Tue, 10 Mar 2026 13:07:44 +0800 Subject: [PATCH 0049/1173] fix(web-search): recover OpenRouter Perplexity citations from message annotations (#40881) Merged via squash. Prepared head SHA: 66c8bb2c6a4bbc95a5d23661c185f1e551c2929e Co-authored-by: laurieluo <89195476+laurieluo@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + src/agents/tools/web-search.ts | 45 ++++++++++++++++- .../tools/web-tools.enabled-defaults.test.ts | 48 +++++++++++++++++-- 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc7bf6f07f..1ba832e4692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng. - Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev. - Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026. +- Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo. ## 2026.3.8 diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 4fbbfa95e43..6e9518f1ede 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -396,6 +396,16 @@ type PerplexitySearchResponse = { choices?: Array<{ message?: { content?: string; + annotations?: Array<{ + type?: string; + url?: string; + url_citation?: { + url?: string; + title?: string; + start_index?: number; + end_index?: number; + }; + }>; }; }>; citations?: string[]; @@ -414,6 +424,38 @@ type PerplexitySearchApiResponse = { id?: string; }; +function extractPerplexityCitations(data: PerplexitySearchResponse): string[] { + const normalizeUrl = (value: unknown): string | undefined => { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; + }; + + const topLevel = (data.citations ?? []) + .map(normalizeUrl) + .filter((url): url is string => Boolean(url)); + if (topLevel.length > 0) { + return [...new Set(topLevel)]; + } + + const citations: string[] = []; + for (const choice of data.choices ?? []) { + for (const annotation of choice.message?.annotations ?? []) { + if (annotation.type !== "url_citation") { + continue; + } + const url = normalizeUrl(annotation.url_citation?.url ?? annotation.url); + if (url) { + citations.push(url); + } + } + } + + return [...new Set(citations)]; +} + function extractGrokContent(data: GrokSearchResponse): { text: string | undefined; annotationCitations: string[]; @@ -1252,7 +1294,8 @@ async function runPerplexitySearch(params: { const data = (await res.json()) as PerplexitySearchResponse; const content = data.choices?.[0]?.message?.content ?? "No response"; - const citations = data.citations ?? []; + // Prefer top-level citations; fall back to OpenRouter-style message annotations. + const citations = extractPerplexityCitations(data); return { content, citations }; }, diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index 4951f1c6b5a..ad3345a3e06 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -113,11 +113,13 @@ function installPerplexitySearchApiFetch(results?: Array }); } -function installPerplexityChatFetch() { - return installMockFetch({ - choices: [{ message: { content: "ok" } }], - citations: ["https://example.com"], - }); +function installPerplexityChatFetch(payload?: Record) { + return installMockFetch( + payload ?? { + choices: [{ message: { content: "ok" } }], + citations: ["https://example.com"], + }, + ); } function createProviderSuccessPayload( @@ -509,6 +511,42 @@ describe("web_search perplexity OpenRouter compatibility", () => { expect(body.search_recency_filter).toBe("week"); }); + it("falls back to message annotations when top-level citations are missing", async () => { + vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret + const mockFetch = installPerplexityChatFetch({ + choices: [ + { + message: { + content: "ok", + annotations: [ + { + type: "url_citation", + url_citation: { url: "https://example.com/a" }, + }, + { + type: "url_citation", + url_citation: { url: "https://example.com/b" }, + }, + { + type: "url_citation", + url_citation: { url: "https://example.com/a" }, + }, + ], + }, + }, + ], + }); + const tool = createPerplexitySearchTool(); + const result = await tool?.execute?.("call-1", { query: "test" }); + + expect(mockFetch).toHaveBeenCalled(); + expect(result?.details).toMatchObject({ + provider: "perplexity", + citations: ["https://example.com/a", "https://example.com/b"], + content: expect.stringContaining("ok"), + }); + }); + it("fails loud for Search API-only filters on the compatibility path", async () => { vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret const mockFetch = installPerplexityChatFetch(); From d1a59557b517a93ac40b1892e541d383a604ab83 Mon Sep 17 00:00:00 2001 From: Urian Paul Danut Date: Tue, 10 Mar 2026 05:54:23 +0000 Subject: [PATCH 0050/1173] fix(security): harden replaceMarkers() to catch space/underscore boundary marker variants (#35983) Merged via squash. Prepared head SHA: ff07dc45a9c9665c0a88c9898684a5c97f76473b Co-authored-by: urianpaul94 <33277984+urianpaul94@users.noreply.github.com> Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Reviewed-by: @frankekn --- CHANGELOG.md | 1 + src/security/external-content.test.ts | 16 ++++++++++++++++ src/security/external-content.ts | 12 ++++++++---- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba832e4692..2db4805cee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai - Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev. - Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026. - Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo. +- Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94. ## 2026.3.8 diff --git a/src/security/external-content.test.ts b/src/security/external-content.test.ts index 17076b642b1..b943bdacf72 100644 --- a/src/security/external-content.test.ts +++ b/src/security/external-content.test.ts @@ -138,6 +138,21 @@ describe("external-content security", () => { content: "Before <<>> middle <<>> after", }, + { + name: "sanitizes space-separated boundary markers", + content: + "Before <<>> middle <<>> after", + }, + { + name: "sanitizes mixed space/underscore boundary markers", + content: + "Before <<>> middle <<>> after", + }, + { + name: "sanitizes tab-delimited boundary markers", + content: + "Before <<>> middle <<>> after", + }, ])("$name", ({ content }) => { const result = wrapExternalContent(content, { source: "email" }); expectSanitizedBoundaryMarkers(result); @@ -204,6 +219,7 @@ describe("external-content security", () => { ["\u27EE", "\u27EF"], // flattened parentheses ["\u276C", "\u276D"], // medium angle bracket ornaments ["\u276E", "\u276F"], // heavy angle quotation ornaments + ["\u02C2", "\u02C3"], // modifier letter left/right arrowhead ]; for (const [left, right] of bracketPairs) { diff --git a/src/security/external-content.ts b/src/security/external-content.ts index 60f92584108..ff571871b5e 100644 --- a/src/security/external-content.ts +++ b/src/security/external-content.ts @@ -132,6 +132,8 @@ const ANGLE_BRACKET_MAP: Record = { 0x276d: ">", // medium right-pointing angle bracket ornament 0x276e: "<", // heavy left-pointing angle quotation mark ornament 0x276f: ">", // heavy right-pointing angle quotation mark ornament + 0x02c2: "<", // modifier letter left arrowhead + 0x02c3: ">", // modifier letter right arrowhead }; function foldMarkerChar(char: string): string { @@ -151,25 +153,27 @@ function foldMarkerChar(char: string): string { function foldMarkerText(input: string): string { return input.replace( - /[\uFF21-\uFF3A\uFF41-\uFF5A\uFF1C\uFF1E\u2329\u232A\u3008\u3009\u2039\u203A\u27E8\u27E9\uFE64\uFE65\u00AB\u00BB\u300A\u300B\u27EA\u27EB\u27EC\u27ED\u27EE\u27EF\u276C\u276D\u276E\u276F]/g, + /[\uFF21-\uFF3A\uFF41-\uFF5A\uFF1C\uFF1E\u2329\u232A\u3008\u3009\u2039\u203A\u27E8\u27E9\uFE64\uFE65\u00AB\u00BB\u300A\u300B\u27EA\u27EB\u27EC\u27ED\u27EE\u27EF\u276C\u276D\u276E\u276F\u02C2\u02C3]/g, (char) => foldMarkerChar(char), ); } function replaceMarkers(content: string): string { const folded = foldMarkerText(content); - if (!/external_untrusted_content/i.test(folded)) { + // Intentionally catch whitespace-delimited spoof variants (space, tab, newline) in addition + // to the legacy underscore form because LLMs may still parse them as trusted boundary markers. + if (!/external[\s_]+untrusted[\s_]+content/i.test(folded)) { return content; } const replacements: Array<{ start: number; end: number; value: string }> = []; // Match markers with or without id attribute (handles both legacy and spoofed markers) const patterns: Array<{ regex: RegExp; value: string }> = [ { - regex: /<<>>/gi, + regex: /<<<\s*EXTERNAL[\s_]+UNTRUSTED[\s_]+CONTENT(?:\s+id="[^"]{1,128}")?\s*>>>/gi, value: "[[MARKER_SANITIZED]]", }, { - regex: /<<>>/gi, + regex: /<<<\s*END[\s_]+EXTERNAL[\s_]+UNTRUSTED[\s_]+CONTENT(?:\s+id="[^"]{1,128}")?\s*>>>/gi, value: "[[END_MARKER_SANITIZED]]", }, ]; From 45b74fb56c45dfe40586d6763adf03a021eb09d2 Mon Sep 17 00:00:00 2001 From: Eugene Date: Tue, 10 Mar 2026 15:58:51 +1000 Subject: [PATCH 0051/1173] fix(telegram): move network fallback to resolver-scoped dispatchers (#40740) Merged via squash. Prepared head SHA: a4456d48b42d6c588b2858831a2391d015260a9b Co-authored-by: sircrumpet <4436535+sircrumpet@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + extensions/telegram/src/channel.test.ts | 101 ++- extensions/telegram/src/channel.ts | 21 +- src/infra/net/proxy-fetch.test.ts | 1 + src/infra/net/proxy-fetch.ts | 35 +- src/telegram/audit-membership-runtime.ts | 4 +- src/telegram/audit.test.ts | 24 +- src/telegram/audit.ts | 2 + src/telegram/bot-handlers.ts | 7 +- src/telegram/bot-native-commands.ts | 1 + src/telegram/bot.media.e2e-harness.ts | 11 + src/telegram/bot.ts | 1 + .../bot/delivery.resolve-media-retry.test.ts | 56 ++ src/telegram/bot/delivery.resolve-media.ts | 32 +- src/telegram/fetch.env-proxy-runtime.test.ts | 58 ++ src/telegram/fetch.test.ts | 844 +++++++++++++----- src/telegram/fetch.ts | 415 +++++++-- src/telegram/probe.test.ts | 162 +++- src/telegram/probe.ts | 131 ++- src/telegram/proxy.test.ts | 9 +- src/telegram/proxy.ts | 2 +- src/telegram/send.proxy.test.ts | 36 +- src/telegram/send.ts | 77 +- 23 files changed, 1641 insertions(+), 390 deletions(-) create mode 100644 src/telegram/fetch.env-proxy-runtime.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2db4805cee0..2e2e65653c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai - Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026. - Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo. - Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94. +- Telegram/network env-proxy: apply configured transport policy to proxied HTTPS dispatchers as well as direct `NO_PROXY` bypasses, so resolver-scoped IPv4 fallback and network settings work consistently for env-proxied Telegram traffic. (#40740) Thanks @sircrumpet. ## 2026.3.8 diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index c1912db56f0..2bf1b681497 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -57,18 +57,38 @@ function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: strin const probeTelegram = vi.fn(async () => params?.probeOk ? { ok: true, bot: { username: params.botUsername ?? "bot" } } : { ok: false }, ); + const collectUnmentionedGroupIds = vi.fn(() => ({ + groupIds: [] as string[], + unresolvedGroups: 0, + hasWildcardUnmentionedGroups: false, + })); + const auditGroupMembership = vi.fn(async () => ({ + ok: true, + checkedGroups: 0, + unresolvedGroups: 0, + hasWildcardUnmentionedGroups: false, + groups: [], + elapsedMs: 0, + })); setTelegramRuntime({ channel: { telegram: { monitorTelegramProvider, probeTelegram, + collectUnmentionedGroupIds, + auditGroupMembership, }, }, logging: { shouldLogVerbose: () => false, }, } as unknown as PluginRuntime); - return { monitorTelegramProvider, probeTelegram }; + return { + monitorTelegramProvider, + probeTelegram, + collectUnmentionedGroupIds, + auditGroupMembership, + }; } describe("telegramPlugin duplicate token guard", () => { @@ -149,6 +169,85 @@ describe("telegramPlugin duplicate token guard", () => { ); }); + it("passes account proxy and network settings into Telegram probes", async () => { + const { probeTelegram } = installGatewayRuntime({ + probeOk: true, + botUsername: "opsbot", + }); + + const cfg = createCfg(); + cfg.channels!.telegram!.accounts!.ops = { + ...cfg.channels!.telegram!.accounts!.ops, + proxy: "http://127.0.0.1:8888", + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + }; + const account = telegramPlugin.config.resolveAccount(cfg, "ops"); + + await telegramPlugin.status!.probeAccount!({ + account, + timeoutMs: 5000, + cfg, + }); + + expect(probeTelegram).toHaveBeenCalledWith("token-ops", 5000, { + accountId: "ops", + proxyUrl: "http://127.0.0.1:8888", + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + }); + }); + + it("passes account proxy and network settings into Telegram membership audits", async () => { + const { collectUnmentionedGroupIds, auditGroupMembership } = installGatewayRuntime({ + probeOk: true, + botUsername: "opsbot", + }); + + collectUnmentionedGroupIds.mockReturnValue({ + groupIds: ["-100123"], + unresolvedGroups: 0, + hasWildcardUnmentionedGroups: false, + }); + + const cfg = createCfg(); + cfg.channels!.telegram!.accounts!.ops = { + ...cfg.channels!.telegram!.accounts!.ops, + proxy: "http://127.0.0.1:8888", + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + groups: { + "-100123": { requireMention: false }, + }, + }; + const account = telegramPlugin.config.resolveAccount(cfg, "ops"); + + await telegramPlugin.status!.auditAccount!({ + account, + timeoutMs: 5000, + probe: { ok: true, bot: { id: 123 }, elapsedMs: 1 }, + cfg, + }); + + expect(auditGroupMembership).toHaveBeenCalledWith({ + token: "token-ops", + botId: 123, + groupIds: ["-100123"], + proxyUrl: "http://127.0.0.1:8888", + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + timeoutMs: 5000, + }); + }); + it("forwards mediaLocalRoots to sendMessageTelegram for outbound media sends", async () => { const sendMessageTelegram = vi.fn(async () => ({ messageId: "tg-1" })); setTelegramRuntime({ diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 7ea0a7a6525..5893f4e0a2e 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -438,11 +438,11 @@ export const telegramPlugin: ChannelPlugin buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => - getTelegramRuntime().channel.telegram.probeTelegram( - account.token, - timeoutMs, - account.config.proxy, - ), + getTelegramRuntime().channel.telegram.probeTelegram(account.token, timeoutMs, { + accountId: account.accountId, + proxyUrl: account.config.proxy, + network: account.config.network, + }), auditAccount: async ({ account, timeoutMs, probe, cfg }) => { const groups = cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ?? @@ -468,6 +468,7 @@ export const telegramPlugin: ChannelPlugin { undiciFetch.mockResolvedValue({ ok: true }); const proxyFetch = makeProxyFetch(proxyUrl); + expect(proxyAgentSpy).not.toHaveBeenCalled(); await proxyFetch("https://api.example.com/v1/audio"); expect(proxyAgentSpy).toHaveBeenCalledWith(proxyUrl); diff --git a/src/infra/net/proxy-fetch.ts b/src/infra/net/proxy-fetch.ts index e6c11813959..391387f3cca 100644 --- a/src/infra/net/proxy-fetch.ts +++ b/src/infra/net/proxy-fetch.ts @@ -1,19 +1,46 @@ import { EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from "undici"; import { logWarn } from "../../logger.js"; +export const PROXY_FETCH_PROXY_URL = Symbol.for("openclaw.proxyFetch.proxyUrl"); +type ProxyFetchWithMetadata = typeof fetch & { + [PROXY_FETCH_PROXY_URL]?: string; +}; + /** * Create a fetch function that routes requests through the given HTTP proxy. * Uses undici's ProxyAgent under the hood. */ export function makeProxyFetch(proxyUrl: string): typeof fetch { - const agent = new ProxyAgent(proxyUrl); + let agent: ProxyAgent | null = null; + const resolveAgent = (): ProxyAgent => { + if (!agent) { + agent = new ProxyAgent(proxyUrl); + } + return agent; + }; // undici's fetch is runtime-compatible with global fetch but the types diverge // on stream/body internals. Single cast at the boundary keeps the rest type-safe. - return ((input: RequestInfo | URL, init?: RequestInit) => + const proxyFetch = ((input: RequestInfo | URL, init?: RequestInit) => undiciFetch(input as string | URL, { ...(init as Record), - dispatcher: agent, - }) as unknown as Promise) as typeof fetch; + dispatcher: resolveAgent(), + }) as unknown as Promise) as ProxyFetchWithMetadata; + Object.defineProperty(proxyFetch, PROXY_FETCH_PROXY_URL, { + value: proxyUrl, + enumerable: false, + configurable: false, + writable: false, + }); + return proxyFetch; +} + +export function getProxyUrlFromFetch(fetchImpl?: typeof fetch): string | undefined { + const proxyUrl = (fetchImpl as ProxyFetchWithMetadata | undefined)?.[PROXY_FETCH_PROXY_URL]; + if (typeof proxyUrl !== "string") { + return undefined; + } + const trimmed = proxyUrl.trim(); + return trimmed ? trimmed : undefined; } /** diff --git a/src/telegram/audit-membership-runtime.ts b/src/telegram/audit-membership-runtime.ts index 4f2c5a43710..c710fb92aa7 100644 --- a/src/telegram/audit-membership-runtime.ts +++ b/src/telegram/audit-membership-runtime.ts @@ -5,6 +5,7 @@ import type { TelegramGroupMembershipAudit, TelegramGroupMembershipAuditEntry, } from "./audit.js"; +import { resolveTelegramFetch } from "./fetch.js"; import { makeProxyFetch } from "./proxy.js"; const TELEGRAM_API_BASE = "https://api.telegram.org"; @@ -16,7 +17,8 @@ type TelegramGroupMembershipAuditData = Omit { - const fetcher = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : fetch; + const proxyFetch = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : undefined; + const fetcher = resolveTelegramFetch(proxyFetch, { network: params.network }); const base = `${TELEGRAM_API_BASE}/bot${params.token}`; const groups: TelegramGroupMembershipAuditEntry[] = []; diff --git a/src/telegram/audit.test.ts b/src/telegram/audit.test.ts index c7524c6ca05..e5cc4490e08 100644 --- a/src/telegram/audit.test.ts +++ b/src/telegram/audit.test.ts @@ -2,16 +2,22 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; let collectTelegramUnmentionedGroupIds: typeof import("./audit.js").collectTelegramUnmentionedGroupIds; let auditTelegramGroupMembership: typeof import("./audit.js").auditTelegramGroupMembership; +const undiciFetch = vi.hoisted(() => vi.fn()); + +vi.mock("undici", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetch: undiciFetch, + }; +}); function mockGetChatMemberStatus(status: string) { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValueOnce( - new Response(JSON.stringify({ ok: true, result: { status } }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ), + undiciFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true, result: { status } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), ); } @@ -31,7 +37,7 @@ describe("telegram audit", () => { }); beforeEach(() => { - vi.unstubAllGlobals(); + undiciFetch.mockReset(); }); it("collects unmentioned numeric group ids and flags wildcard", async () => { diff --git a/src/telegram/audit.ts b/src/telegram/audit.ts index 24e5f58957a..6b667c37581 100644 --- a/src/telegram/audit.ts +++ b/src/telegram/audit.ts @@ -1,4 +1,5 @@ import type { TelegramGroupConfig } from "../config/types.js"; +import type { TelegramNetworkConfig } from "../config/types.telegram.js"; export type TelegramGroupMembershipAuditEntry = { chatId: string; @@ -64,6 +65,7 @@ export type AuditTelegramGroupMembershipParams = { botId: number; groupIds: string[]; proxyUrl?: string; + network?: TelegramNetworkConfig; timeoutMs: number; }; diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 78290f342ad..2d1327bcd5f 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -123,6 +123,7 @@ export const registerTelegramHandlers = ({ accountId, bot, opts, + telegramFetchImpl, runtime, mediaMaxBytes, telegramCfg, @@ -371,7 +372,7 @@ export const registerTelegramHandlers = ({ for (const { ctx } of entry.messages) { let media; try { - media = await resolveMedia(ctx, mediaMaxBytes, opts.token, opts.proxyFetch); + media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramFetchImpl); } catch (mediaErr) { if (!isRecoverableMediaGroupError(mediaErr)) { throw mediaErr; @@ -475,7 +476,7 @@ export const registerTelegramHandlers = ({ }, mediaMaxBytes, opts.token, - opts.proxyFetch, + telegramFetchImpl, ); if (!media) { return []; @@ -986,7 +987,7 @@ export const registerTelegramHandlers = ({ let media: Awaited> = null; try { - media = await resolveMedia(ctx, mediaMaxBytes, opts.token, opts.proxyFetch); + media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramFetchImpl); } catch (mediaErr) { if (isMediaSizeLimitError(mediaErr)) { if (sendOversizeWarning) { diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index aa37c98e9b9..06148b17b33 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -94,6 +94,7 @@ export type RegisterTelegramHandlerParams = { bot: Bot; mediaMaxBytes: number; opts: TelegramBotOptions; + telegramFetchImpl?: typeof fetch; runtime: RuntimeEnv; telegramCfg: TelegramAccountConfig; allowFrom?: Array; diff --git a/src/telegram/bot.media.e2e-harness.ts b/src/telegram/bot.media.e2e-harness.ts index 58628df522b..d26eff44fb6 100644 --- a/src/telegram/bot.media.e2e-harness.ts +++ b/src/telegram/bot.media.e2e-harness.ts @@ -6,6 +6,9 @@ export const middlewareUseSpy: Mock = vi.fn(); export const onSpy: Mock = vi.fn(); export const stopSpy: Mock = vi.fn(); export const sendChatActionSpy: Mock = vi.fn(); +export const undiciFetchSpy: Mock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => + globalThis.fetch(input, init), +); async function defaultSaveMediaBuffer(buffer: Buffer, contentType?: string) { return { @@ -81,6 +84,14 @@ vi.mock("@grammyjs/transformer-throttler", () => ({ apiThrottler: () => throttlerSpy(), })); +vi.mock("undici", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetch: (...args: Parameters) => undiciFetchSpy(...args), + }; +}); + vi.mock("../media/store.js", async (importOriginal) => { const actual = await importOriginal(); const mockModule = Object.create(null) as Record; diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 8bfa0b8ac0c..48d0c745b42 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -439,6 +439,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { accountId: account.accountId, bot, opts, + telegramFetchImpl: fetchImpl as unknown as typeof fetch | undefined, runtime, mediaMaxBytes, telegramCfg, diff --git a/src/telegram/bot/delivery.resolve-media-retry.test.ts b/src/telegram/bot/delivery.resolve-media-retry.test.ts index ce8f50abbbe..df6124343fd 100644 --- a/src/telegram/bot/delivery.resolve-media-retry.test.ts +++ b/src/telegram/bot/delivery.resolve-media-retry.test.ts @@ -293,6 +293,62 @@ describe("resolveMedia getFile retry", () => { expect(getFile).toHaveBeenCalledTimes(3); expect(result).toBeNull(); }); + + it("uses caller-provided fetch impl for file downloads", async () => { + const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" }); + const callerFetch = vi.fn() as unknown as typeof fetch; + fetchRemoteMedia.mockResolvedValueOnce({ + buffer: Buffer.from("pdf-data"), + contentType: "application/pdf", + fileName: "file_42.pdf", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/file_42---uuid.pdf", + contentType: "application/pdf", + }); + + const result = await resolveMedia( + makeCtx("document", getFile), + MAX_MEDIA_BYTES, + BOT_TOKEN, + callerFetch, + ); + + expect(result).not.toBeNull(); + expect(fetchRemoteMedia).toHaveBeenCalledWith( + expect.objectContaining({ + fetchImpl: callerFetch, + }), + ); + }); + + it("uses caller-provided fetch impl for sticker downloads", async () => { + const getFile = vi.fn().mockResolvedValue({ file_path: "stickers/file_0.webp" }); + const callerFetch = vi.fn() as unknown as typeof fetch; + fetchRemoteMedia.mockResolvedValueOnce({ + buffer: Buffer.from("sticker-data"), + contentType: "image/webp", + fileName: "file_0.webp", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/file_0.webp", + contentType: "image/webp", + }); + + const result = await resolveMedia( + makeCtx("sticker", getFile), + MAX_MEDIA_BYTES, + BOT_TOKEN, + callerFetch, + ); + + expect(result).not.toBeNull(); + expect(fetchRemoteMedia).toHaveBeenCalledWith( + expect.objectContaining({ + fetchImpl: callerFetch, + }), + ); + }); }); describe("resolveMedia original filename preservation", () => { diff --git a/src/telegram/bot/delivery.resolve-media.ts b/src/telegram/bot/delivery.resolve-media.ts index 14df1d6e2a8..9f560116a5d 100644 --- a/src/telegram/bot/delivery.resolve-media.ts +++ b/src/telegram/bot/delivery.resolve-media.ts @@ -92,12 +92,20 @@ async function resolveTelegramFileWithRetry( } } -function resolveRequiredFetchImpl(proxyFetch?: typeof fetch): typeof fetch { - const fetchImpl = proxyFetch ?? globalThis.fetch; - if (!fetchImpl) { +function resolveRequiredFetchImpl(fetchImpl?: typeof fetch): typeof fetch { + const resolved = fetchImpl ?? globalThis.fetch; + if (!resolved) { throw new Error("fetch is not available; set channels.telegram.proxy in config"); } - return fetchImpl; + return resolved; +} + +function resolveOptionalFetchImpl(fetchImpl?: typeof fetch): typeof fetch | null { + try { + return resolveRequiredFetchImpl(fetchImpl); + } catch { + return null; + } } /** Default idle timeout for Telegram media downloads (30 seconds). */ @@ -134,7 +142,7 @@ async function resolveStickerMedia(params: { ctx: TelegramContext; maxBytes: number; token: string; - proxyFetch?: typeof fetch; + fetchImpl?: typeof fetch; }): Promise< | { path: string; @@ -145,7 +153,7 @@ async function resolveStickerMedia(params: { | null | undefined > { - const { msg, ctx, maxBytes, token, proxyFetch } = params; + const { msg, ctx, maxBytes, token, fetchImpl } = params; if (!msg.sticker) { return undefined; } @@ -165,15 +173,15 @@ async function resolveStickerMedia(params: { logVerbose("telegram: getFile returned no file_path for sticker"); return null; } - const fetchImpl = proxyFetch ?? globalThis.fetch; - if (!fetchImpl) { + const resolvedFetchImpl = resolveOptionalFetchImpl(fetchImpl); + if (!resolvedFetchImpl) { logVerbose("telegram: fetch not available for sticker download"); return null; } const saved = await downloadAndSaveTelegramFile({ filePath: file.file_path, token, - fetchImpl, + fetchImpl: resolvedFetchImpl, maxBytes, }); @@ -229,7 +237,7 @@ export async function resolveMedia( ctx: TelegramContext, maxBytes: number, token: string, - proxyFetch?: typeof fetch, + fetchImpl?: typeof fetch, ): Promise<{ path: string; contentType?: string; @@ -242,7 +250,7 @@ export async function resolveMedia( ctx, maxBytes, token, - proxyFetch, + fetchImpl, }); if (stickerResolved !== undefined) { return stickerResolved; @@ -263,7 +271,7 @@ export async function resolveMedia( const saved = await downloadAndSaveTelegramFile({ filePath: file.file_path, token, - fetchImpl: resolveRequiredFetchImpl(proxyFetch), + fetchImpl: resolveRequiredFetchImpl(fetchImpl), maxBytes, telegramFileName: resolveTelegramFileName(msg), }); diff --git a/src/telegram/fetch.env-proxy-runtime.test.ts b/src/telegram/fetch.env-proxy-runtime.test.ts new file mode 100644 index 00000000000..0292f465747 --- /dev/null +++ b/src/telegram/fetch.env-proxy-runtime.test.ts @@ -0,0 +1,58 @@ +import { createRequire } from "node:module"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const require = createRequire(import.meta.url); +const EnvHttpProxyAgent = require("undici/lib/dispatcher/env-http-proxy-agent.js") as { + new (opts?: Record): Record; +}; +const { kHttpsProxyAgent, kNoProxyAgent } = require("undici/lib/core/symbols.js") as { + kHttpsProxyAgent: symbol; + kNoProxyAgent: symbol; +}; + +function getOwnSymbolValue( + target: Record, + description: string, +): Record | undefined { + const symbol = Object.getOwnPropertySymbols(target).find( + (entry) => entry.description === description, + ); + const value = symbol ? target[symbol] : undefined; + return value && typeof value === "object" ? (value as Record) : undefined; +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("undici env proxy semantics", () => { + it("uses proxyTls rather than connect for proxied HTTPS transport settings", () => { + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + const connect = { + family: 4, + autoSelectFamily: false, + }; + + const withoutProxyTls = new EnvHttpProxyAgent({ connect }); + const noProxyAgent = withoutProxyTls[kNoProxyAgent] as Record; + const httpsProxyAgent = withoutProxyTls[kHttpsProxyAgent] as Record; + + expect(getOwnSymbolValue(noProxyAgent, "options")?.connect).toEqual( + expect.objectContaining(connect), + ); + expect(getOwnSymbolValue(httpsProxyAgent, "proxy tls settings")).toBeUndefined(); + + const withProxyTls = new EnvHttpProxyAgent({ + connect, + proxyTls: connect, + }); + const httpsProxyAgentWithProxyTls = withProxyTls[kHttpsProxyAgent] as Record< + PropertyKey, + unknown + >; + + expect(getOwnSymbolValue(httpsProxyAgentWithProxyTls, "proxy tls settings")).toEqual( + expect.objectContaining(connect), + ); + }); +}); diff --git a/src/telegram/fetch.test.ts b/src/telegram/fetch.test.ts index 95b26d931cb..dc4c7a5145a 100644 --- a/src/telegram/fetch.test.ts +++ b/src/telegram/fetch.test.ts @@ -1,25 +1,36 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveFetch } from "../infra/fetch.js"; -import { resetTelegramFetchStateForTests, resolveTelegramFetch } from "./fetch.js"; +import { resolveTelegramFetch } from "./fetch.js"; -const setDefaultAutoSelectFamily = vi.hoisted(() => vi.fn()); const setDefaultResultOrder = vi.hoisted(() => vi.fn()); +const setDefaultAutoSelectFamily = vi.hoisted(() => vi.fn()); + +const undiciFetch = vi.hoisted(() => vi.fn()); const setGlobalDispatcher = vi.hoisted(() => vi.fn()); -const getGlobalDispatcherState = vi.hoisted(() => ({ value: undefined as unknown })); -const getGlobalDispatcher = vi.hoisted(() => vi.fn(() => getGlobalDispatcherState.value)); -const EnvHttpProxyAgentCtor = vi.hoisted(() => - vi.fn(function MockEnvHttpProxyAgent(this: { options: unknown }, options: unknown) { +const AgentCtor = vi.hoisted(() => + vi.fn(function MockAgent( + this: { options?: Record }, + options?: Record, + ) { + this.options = options; + }), +); +const EnvHttpProxyAgentCtor = vi.hoisted(() => + vi.fn(function MockEnvHttpProxyAgent( + this: { options?: Record }, + options?: Record, + ) { + this.options = options; + }), +); +const ProxyAgentCtor = vi.hoisted(() => + vi.fn(function MockProxyAgent( + this: { options?: Record | string }, + options?: Record | string, + ) { this.options = options; }), ); - -vi.mock("node:net", async () => { - const actual = await vi.importActual("node:net"); - return { - ...actual, - setDefaultAutoSelectFamily, - }; -}); vi.mock("node:dns", async () => { const actual = await vi.importActual("node:dns"); @@ -29,266 +40,655 @@ vi.mock("node:dns", async () => { }; }); +vi.mock("node:net", async () => { + const actual = await vi.importActual("node:net"); + return { + ...actual, + setDefaultAutoSelectFamily, + }; +}); + vi.mock("undici", () => ({ + Agent: AgentCtor, EnvHttpProxyAgent: EnvHttpProxyAgentCtor, - getGlobalDispatcher, + ProxyAgent: ProxyAgentCtor, + fetch: undiciFetch, setGlobalDispatcher, })); -const originalFetch = globalThis.fetch; - -function expectEnvProxyAgentConstructorCall(params: { nth: number; autoSelectFamily: boolean }) { - expect(EnvHttpProxyAgentCtor).toHaveBeenNthCalledWith(params.nth, { - connect: { - autoSelectFamily: params.autoSelectFamily, - autoSelectFamilyAttemptTimeout: 300, - }, - }); +function resolveTelegramFetchOrThrow( + proxyFetch?: typeof fetch, + options?: { network?: { autoSelectFamily?: boolean; dnsResultOrder?: "ipv4first" | "verbatim" } }, +) { + return resolveTelegramFetch(proxyFetch, options); } -function resolveTelegramFetchOrThrow() { - const resolved = resolveTelegramFetch(); - if (!resolved) { - throw new Error("expected resolved fetch"); +function getDispatcherFromUndiciCall(nth: number) { + const call = undiciFetch.mock.calls[nth - 1] as [RequestInfo | URL, RequestInit?] | undefined; + if (!call) { + throw new Error(`missing undici fetch call #${nth}`); } - return resolved; + const init = call[1] as (RequestInit & { dispatcher?: unknown }) | undefined; + return init?.dispatcher as + | { + options?: { + connect?: Record; + proxyTls?: Record; + }; + } + | undefined; +} + +function buildFetchFallbackError(code: string) { + const connectErr = Object.assign(new Error(`connect ${code} api.telegram.org:443`), { + code, + }); + return Object.assign(new TypeError("fetch failed"), { + cause: connectErr, + }); } afterEach(() => { - resetTelegramFetchStateForTests(); - setDefaultAutoSelectFamily.mockReset(); - setDefaultResultOrder.mockReset(); + undiciFetch.mockReset(); setGlobalDispatcher.mockReset(); - getGlobalDispatcher.mockClear(); - getGlobalDispatcherState.value = undefined; + AgentCtor.mockClear(); EnvHttpProxyAgentCtor.mockClear(); + ProxyAgentCtor.mockClear(); + setDefaultResultOrder.mockReset(); + setDefaultAutoSelectFamily.mockReset(); vi.unstubAllEnvs(); vi.clearAllMocks(); - if (originalFetch) { - globalThis.fetch = originalFetch; - } else { - delete (globalThis as { fetch?: typeof fetch }).fetch; - } }); describe("resolveTelegramFetch", () => { - it("returns wrapped global fetch when available", async () => { - const fetchMock = vi.fn(async () => ({})); - globalThis.fetch = fetchMock as unknown as typeof fetch; + it("wraps proxy fetches and leaves retry policy to caller-provided fetch", async () => { + const proxyFetch = vi.fn(async () => ({ ok: true }) as Response) as unknown as typeof fetch; - const resolved = resolveTelegramFetch(); + const resolved = resolveTelegramFetchOrThrow(proxyFetch); - expect(resolved).toBeTypeOf("function"); - expect(resolved).not.toBe(fetchMock); - }); + await resolved("https://api.telegram.org/botx/getMe"); - it("wraps proxy fetches and normalizes foreign signals once", async () => { - let seenSignal: AbortSignal | undefined; - const proxyFetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { - seenSignal = init?.signal as AbortSignal | undefined; - return {} as Response; - }); - - const resolved = resolveTelegramFetch(proxyFetch as unknown as typeof fetch); - expect(resolved).toBeTypeOf("function"); - - let abortHandler: (() => void) | null = null; - const addEventListener = vi.fn((event: string, handler: () => void) => { - if (event === "abort") { - abortHandler = handler; - } - }); - const removeEventListener = vi.fn((event: string, handler: () => void) => { - if (event === "abort" && abortHandler === handler) { - abortHandler = null; - } - }); - const fakeSignal = { - aborted: false, - addEventListener, - removeEventListener, - } as unknown as AbortSignal; - - if (!resolved) { - throw new Error("expected resolved proxy fetch"); - } - await resolved("https://example.com", { signal: fakeSignal }); - - expect(proxyFetch).toHaveBeenCalledOnce(); - expect(seenSignal).toBeInstanceOf(AbortSignal); - expect(seenSignal).not.toBe(fakeSignal); - expect(addEventListener).toHaveBeenCalledTimes(1); - expect(removeEventListener).toHaveBeenCalledTimes(1); + expect(proxyFetch).toHaveBeenCalledTimes(1); + expect(undiciFetch).not.toHaveBeenCalled(); }); it("does not double-wrap an already wrapped proxy fetch", async () => { const proxyFetch = vi.fn(async () => ({ ok: true }) as Response) as unknown as typeof fetch; - const alreadyWrapped = resolveFetch(proxyFetch); + const wrapped = resolveFetch(proxyFetch); - const resolved = resolveTelegramFetch(alreadyWrapped); + const resolved = resolveTelegramFetch(wrapped); - expect(resolved).toBe(alreadyWrapped); + expect(resolved).toBe(wrapped); }); - it("honors env enable override", async () => { - vi.stubEnv("OPENCLAW_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY", "1"); - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - resolveTelegramFetch(); - expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(true); - }); + it("uses resolver-scoped Agent dispatcher with configured transport policy", async () => { + undiciFetch.mockResolvedValue({ ok: true } as Response); - it("uses config override when provided", async () => { - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); - expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(true); - }); - - it("env disable override wins over config", async () => { - vi.stubEnv("OPENCLAW_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY", "0"); - vi.stubEnv("OPENCLAW_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY", "1"); - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); - expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(false); - }); - - it("applies dns result order from config", async () => { - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - resolveTelegramFetch(undefined, { network: { dnsResultOrder: "verbatim" } }); - expect(setDefaultResultOrder).toHaveBeenCalledWith("verbatim"); - }); - - it("retries dns setter on next call when previous attempt threw", async () => { - setDefaultResultOrder.mockImplementationOnce(() => { - throw new Error("dns setter failed once"); - }); - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - - resolveTelegramFetch(undefined, { network: { dnsResultOrder: "ipv4first" } }); - resolveTelegramFetch(undefined, { network: { dnsResultOrder: "ipv4first" } }); - - expect(setDefaultResultOrder).toHaveBeenCalledTimes(2); - }); - - it("replaces global undici dispatcher with proxy-aware EnvHttpProxyAgent", async () => { - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); - - expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); - expectEnvProxyAgentConstructorCall({ nth: 1, autoSelectFamily: true }); - }); - - it("keeps an existing proxy-like global dispatcher", async () => { - getGlobalDispatcherState.value = { - constructor: { name: "ProxyAgent" }, - }; - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - - resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); - - expect(setGlobalDispatcher).not.toHaveBeenCalled(); - expect(EnvHttpProxyAgentCtor).not.toHaveBeenCalled(); - }); - - it("updates proxy-like dispatcher when proxy env is configured", async () => { - vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); - getGlobalDispatcherState.value = { - constructor: { name: "ProxyAgent" }, - }; - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - - resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); - - expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); - expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(1); - }); - - it("sets global dispatcher only once across repeated equal decisions", async () => { - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); - resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); - - expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); - }); - - it("updates global dispatcher when autoSelectFamily decision changes", async () => { - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); - resolveTelegramFetch(undefined, { network: { autoSelectFamily: false } }); - - expect(setGlobalDispatcher).toHaveBeenCalledTimes(2); - expectEnvProxyAgentConstructorCall({ nth: 1, autoSelectFamily: true }); - expectEnvProxyAgentConstructorCall({ nth: 2, autoSelectFamily: false }); - }); - - it("retries once with ipv4 fallback when fetch fails with network timeout/unreachable", async () => { - const timeoutErr = Object.assign(new Error("connect ETIMEDOUT 149.154.166.110:443"), { - code: "ETIMEDOUT", - }); - const unreachableErr = Object.assign( - new Error("connect ENETUNREACH 2001:67c:4e8:f004::9:443"), - { - code: "ENETUNREACH", + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "verbatim", }, - ); - const fetchError = Object.assign(new TypeError("fetch failed"), { - cause: Object.assign(new Error("aggregate"), { - errors: [timeoutErr, unreachableErr], - }), }); - const fetchMock = vi - .fn() - .mockRejectedValueOnce(fetchError) - .mockResolvedValueOnce({ ok: true } as Response); - globalThis.fetch = fetchMock as unknown as typeof fetch; - const resolved = resolveTelegramFetchOrThrow(); + await resolved("https://api.telegram.org/botx/getMe"); - await resolved("https://api.telegram.org/file/botx/photos/file_1.jpg"); + expect(AgentCtor).toHaveBeenCalledTimes(1); + expect(EnvHttpProxyAgentCtor).not.toHaveBeenCalled(); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(setGlobalDispatcher).toHaveBeenCalledTimes(2); - expectEnvProxyAgentConstructorCall({ nth: 1, autoSelectFamily: true }); - expectEnvProxyAgentConstructorCall({ nth: 2, autoSelectFamily: false }); + const dispatcher = getDispatcherFromUndiciCall(1); + expect(dispatcher).toBeDefined(); + expect(dispatcher?.options?.connect).toEqual( + expect.objectContaining({ + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }), + ); + expect(typeof dispatcher?.options?.connect?.lookup).toBe("function"); }); - it("retries with ipv4 fallback once per request, not once per process", async () => { - const timeoutErr = Object.assign(new Error("connect ETIMEDOUT 149.154.166.110:443"), { - code: "ETIMEDOUT", + it("uses EnvHttpProxyAgent dispatcher when proxy env is configured", async () => { + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + undiciFetch.mockResolvedValue({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, }); - const fetchError = Object.assign(new TypeError("fetch failed"), { - cause: timeoutErr, + + await resolved("https://api.telegram.org/botx/getMe"); + + expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(1); + expect(AgentCtor).not.toHaveBeenCalled(); + + const dispatcher = getDispatcherFromUndiciCall(1); + expect(dispatcher?.options?.connect).toEqual( + expect.objectContaining({ + autoSelectFamily: false, + autoSelectFamilyAttemptTimeout: 300, + }), + ); + expect(dispatcher?.options?.proxyTls).toEqual( + expect.objectContaining({ + autoSelectFamily: false, + autoSelectFamilyAttemptTimeout: 300, + }), + ); + }); + + it("pins env-proxy transport policy onto proxyTls for proxied HTTPS requests", async () => { + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + undiciFetch.mockResolvedValue({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, }); - const fetchMock = vi - .fn() + + await resolved("https://api.telegram.org/botx/getMe"); + + const dispatcher = getDispatcherFromUndiciCall(1); + expect(dispatcher?.options?.connect).toEqual( + expect.objectContaining({ + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }), + ); + expect(dispatcher?.options?.proxyTls).toEqual( + expect.objectContaining({ + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }), + ); + }); + + it("keeps resolver-scoped transport policy for OpenClaw proxy fetches", async () => { + const { makeProxyFetch } = await import("./proxy.js"); + const proxyFetch = makeProxyFetch("http://127.0.0.1:7890"); + ProxyAgentCtor.mockClear(); + undiciFetch.mockResolvedValue({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(proxyFetch, { + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + }); + + await resolved("https://api.telegram.org/botx/getMe"); + + expect(ProxyAgentCtor).toHaveBeenCalledTimes(1); + expect(EnvHttpProxyAgentCtor).not.toHaveBeenCalled(); + expect(AgentCtor).not.toHaveBeenCalled(); + const dispatcher = getDispatcherFromUndiciCall(1); + expect(dispatcher?.options).toEqual( + expect.objectContaining({ + uri: "http://127.0.0.1:7890", + }), + ); + expect(dispatcher?.options?.proxyTls).toEqual( + expect.objectContaining({ + autoSelectFamily: false, + }), + ); + }); + + it("does not blind-retry when sticky IPv4 fallback is disallowed for explicit proxy paths", async () => { + const { makeProxyFetch } = await import("./proxy.js"); + const proxyFetch = makeProxyFetch("http://127.0.0.1:7890"); + ProxyAgentCtor.mockClear(); + const fetchError = buildFetchFallbackError("EHOSTUNREACH"); + undiciFetch.mockRejectedValueOnce(fetchError).mockResolvedValueOnce({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(proxyFetch, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + await expect(resolved("https://api.telegram.org/botx/sendMessage")).rejects.toThrow( + "fetch failed", + ); + await resolved("https://api.telegram.org/botx/sendChatAction"); + + expect(undiciFetch).toHaveBeenCalledTimes(2); + expect(ProxyAgentCtor).toHaveBeenCalledTimes(1); + + const firstDispatcher = getDispatcherFromUndiciCall(1); + const secondDispatcher = getDispatcherFromUndiciCall(2); + + expect(firstDispatcher).toBe(secondDispatcher); + expect(firstDispatcher?.options?.proxyTls).toEqual( + expect.objectContaining({ + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }), + ); + expect(firstDispatcher?.options?.proxyTls?.family).not.toBe(4); + }); + + it("does not blind-retry when sticky IPv4 fallback is disallowed for env proxy paths", async () => { + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + const fetchError = buildFetchFallbackError("EHOSTUNREACH"); + undiciFetch.mockRejectedValueOnce(fetchError).mockResolvedValueOnce({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + await expect(resolved("https://api.telegram.org/botx/sendMessage")).rejects.toThrow( + "fetch failed", + ); + await resolved("https://api.telegram.org/botx/sendChatAction"); + + expect(undiciFetch).toHaveBeenCalledTimes(2); + expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(1); + + const firstDispatcher = getDispatcherFromUndiciCall(1); + const secondDispatcher = getDispatcherFromUndiciCall(2); + + expect(firstDispatcher).toBe(secondDispatcher); + expect(firstDispatcher?.options?.connect).toEqual( + expect.objectContaining({ + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }), + ); + expect(firstDispatcher?.options?.connect?.family).not.toBe(4); + }); + + it("treats ALL_PROXY-only env as direct transport and arms sticky IPv4 fallback", async () => { + vi.stubEnv("ALL_PROXY", "socks5://127.0.0.1:1080"); + const fetchError = buildFetchFallbackError("EHOSTUNREACH"); + undiciFetch .mockRejectedValueOnce(fetchError) .mockResolvedValueOnce({ ok: true } as Response) - .mockRejectedValueOnce(fetchError) .mockResolvedValueOnce({ ok: true } as Response); - globalThis.fetch = fetchMock as unknown as typeof fetch; - const resolved = resolveTelegramFetchOrThrow(); + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); - await resolved("https://api.telegram.org/file/botx/photos/file_1.jpg"); - await resolved("https://api.telegram.org/file/botx/photos/file_2.jpg"); + await resolved("https://api.telegram.org/botx/sendMessage"); + await resolved("https://api.telegram.org/botx/sendChatAction"); - expect(fetchMock).toHaveBeenCalledTimes(4); + expect(EnvHttpProxyAgentCtor).not.toHaveBeenCalled(); + expect(AgentCtor).toHaveBeenCalledTimes(2); + + const firstDispatcher = getDispatcherFromUndiciCall(1); + const secondDispatcher = getDispatcherFromUndiciCall(2); + const thirdDispatcher = getDispatcherFromUndiciCall(3); + + expect(firstDispatcher).not.toBe(secondDispatcher); + expect(secondDispatcher).toBe(thirdDispatcher); + expect(secondDispatcher?.options?.connect).toEqual( + expect.objectContaining({ + family: 4, + autoSelectFamily: false, + }), + ); }); - it("does not retry when fetch fails without fallback network error codes", async () => { - const fetchError = Object.assign(new TypeError("fetch failed"), { - cause: Object.assign(new Error("connect ECONNRESET"), { - code: "ECONNRESET", - }), + it("arms sticky IPv4 fallback when env proxy init falls back to direct Agent", async () => { + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + EnvHttpProxyAgentCtor.mockImplementationOnce(function ThrowingEnvProxyAgent() { + throw new Error("invalid proxy config"); }); - const fetchMock = vi.fn().mockRejectedValue(fetchError); - globalThis.fetch = fetchMock as unknown as typeof fetch; + const fetchError = buildFetchFallbackError("EHOSTUNREACH"); + undiciFetch + .mockRejectedValueOnce(fetchError) + .mockResolvedValueOnce({ ok: true } as Response) + .mockResolvedValueOnce({ ok: true } as Response); - const resolved = resolveTelegramFetchOrThrow(); + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); - await expect(resolved("https://api.telegram.org/file/botx/photos/file_3.jpg")).rejects.toThrow( + await resolved("https://api.telegram.org/botx/sendMessage"); + await resolved("https://api.telegram.org/botx/sendChatAction"); + + expect(undiciFetch).toHaveBeenCalledTimes(3); + expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(1); + expect(AgentCtor).toHaveBeenCalledTimes(2); + + const firstDispatcher = getDispatcherFromUndiciCall(1); + const secondDispatcher = getDispatcherFromUndiciCall(2); + const thirdDispatcher = getDispatcherFromUndiciCall(3); + + expect(firstDispatcher).not.toBe(secondDispatcher); + expect(secondDispatcher).toBe(thirdDispatcher); + expect(secondDispatcher?.options?.connect).toEqual( + expect.objectContaining({ + family: 4, + autoSelectFamily: false, + }), + ); + }); + + it("arms sticky IPv4 fallback when NO_PROXY bypasses telegram under env proxy", async () => { + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + vi.stubEnv("NO_PROXY", "api.telegram.org"); + const fetchError = buildFetchFallbackError("EHOSTUNREACH"); + undiciFetch + .mockRejectedValueOnce(fetchError) + .mockResolvedValueOnce({ ok: true } as Response) + .mockResolvedValueOnce({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + await resolved("https://api.telegram.org/botx/sendMessage"); + await resolved("https://api.telegram.org/botx/sendChatAction"); + + expect(undiciFetch).toHaveBeenCalledTimes(3); + expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(2); + expect(AgentCtor).not.toHaveBeenCalled(); + + const firstDispatcher = getDispatcherFromUndiciCall(1); + const secondDispatcher = getDispatcherFromUndiciCall(2); + const thirdDispatcher = getDispatcherFromUndiciCall(3); + + expect(firstDispatcher).not.toBe(secondDispatcher); + expect(secondDispatcher).toBe(thirdDispatcher); + expect(secondDispatcher?.options?.connect).toEqual( + expect.objectContaining({ + family: 4, + autoSelectFamily: false, + }), + ); + }); + + it("uses no_proxy over NO_PROXY when deciding env-proxy bypass", async () => { + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + vi.stubEnv("NO_PROXY", ""); + vi.stubEnv("no_proxy", "api.telegram.org"); + const fetchError = buildFetchFallbackError("EHOSTUNREACH"); + undiciFetch + .mockRejectedValueOnce(fetchError) + .mockResolvedValueOnce({ ok: true } as Response) + .mockResolvedValueOnce({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + await resolved("https://api.telegram.org/botx/sendMessage"); + await resolved("https://api.telegram.org/botx/sendChatAction"); + + expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(2); + const secondDispatcher = getDispatcherFromUndiciCall(2); + expect(secondDispatcher?.options?.connect).toEqual( + expect.objectContaining({ + family: 4, + autoSelectFamily: false, + }), + ); + }); + + it("matches whitespace and wildcard no_proxy entries like EnvHttpProxyAgent", async () => { + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + vi.stubEnv("no_proxy", "localhost *.telegram.org"); + const fetchError = buildFetchFallbackError("EHOSTUNREACH"); + undiciFetch + .mockRejectedValueOnce(fetchError) + .mockResolvedValueOnce({ ok: true } as Response) + .mockResolvedValueOnce({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + await resolved("https://api.telegram.org/botx/sendMessage"); + await resolved("https://api.telegram.org/botx/sendChatAction"); + + expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(2); + const secondDispatcher = getDispatcherFromUndiciCall(2); + expect(secondDispatcher?.options?.connect).toEqual( + expect.objectContaining({ + family: 4, + autoSelectFamily: false, + }), + ); + }); + + it("fails closed when explicit proxy dispatcher initialization fails", async () => { + const { makeProxyFetch } = await import("./proxy.js"); + const proxyFetch = makeProxyFetch("http://127.0.0.1:7890"); + ProxyAgentCtor.mockClear(); + ProxyAgentCtor.mockImplementationOnce(function ThrowingProxyAgent() { + throw new Error("invalid proxy config"); + }); + + expect(() => + resolveTelegramFetchOrThrow(proxyFetch, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }), + ).toThrow("explicit proxy dispatcher init failed: invalid proxy config"); + }); + + it("falls back to Agent when env proxy dispatcher initialization fails", async () => { + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + EnvHttpProxyAgentCtor.mockImplementationOnce(function ThrowingEnvProxyAgent() { + throw new Error("invalid proxy config"); + }); + undiciFetch.mockResolvedValue({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: false, + }, + }); + + await resolved("https://api.telegram.org/botx/getMe"); + + expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(1); + expect(AgentCtor).toHaveBeenCalledTimes(1); + + const dispatcher = getDispatcherFromUndiciCall(1); + expect(dispatcher?.options?.connect).toEqual( + expect.objectContaining({ + autoSelectFamily: false, + }), + ); + }); + + it("retries once and then keeps sticky IPv4 dispatcher for subsequent requests", async () => { + const fetchError = buildFetchFallbackError("ETIMEDOUT"); + undiciFetch + .mockRejectedValueOnce(fetchError) + .mockResolvedValueOnce({ ok: true } as Response) + .mockResolvedValueOnce({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + }, + }); + + await resolved("https://api.telegram.org/botx/sendMessage"); + await resolved("https://api.telegram.org/botx/sendChatAction"); + + expect(undiciFetch).toHaveBeenCalledTimes(3); + + const firstDispatcher = getDispatcherFromUndiciCall(1); + const secondDispatcher = getDispatcherFromUndiciCall(2); + const thirdDispatcher = getDispatcherFromUndiciCall(3); + + expect(firstDispatcher).toBeDefined(); + expect(secondDispatcher).toBeDefined(); + expect(thirdDispatcher).toBeDefined(); + + expect(firstDispatcher).not.toBe(secondDispatcher); + expect(secondDispatcher).toBe(thirdDispatcher); + + expect(firstDispatcher?.options?.connect).toEqual( + expect.objectContaining({ + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }), + ); + expect(secondDispatcher?.options?.connect).toEqual( + expect.objectContaining({ + family: 4, + autoSelectFamily: false, + }), + ); + }); + + it("preserves caller-provided dispatcher across fallback retry", async () => { + const fetchError = buildFetchFallbackError("EHOSTUNREACH"); + undiciFetch.mockRejectedValueOnce(fetchError).mockResolvedValueOnce({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + }, + }); + + const callerDispatcher = { name: "caller" }; + + await resolved("https://api.telegram.org/botx/sendMessage", { + dispatcher: callerDispatcher, + } as RequestInit); + + expect(undiciFetch).toHaveBeenCalledTimes(2); + + const firstCallInit = undiciFetch.mock.calls[0]?.[1] as + | (RequestInit & { dispatcher?: unknown }) + | undefined; + const secondCallInit = undiciFetch.mock.calls[1]?.[1] as + | (RequestInit & { dispatcher?: unknown }) + | undefined; + + expect(firstCallInit?.dispatcher).toBe(callerDispatcher); + expect(secondCallInit?.dispatcher).toBe(callerDispatcher); + }); + + it("does not arm sticky fallback from caller-provided dispatcher failures", async () => { + const fetchError = buildFetchFallbackError("EHOSTUNREACH"); + undiciFetch + .mockRejectedValueOnce(fetchError) + .mockResolvedValueOnce({ ok: true } as Response) + .mockResolvedValueOnce({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + }, + }); + + const callerDispatcher = { name: "caller" }; + + await resolved("https://api.telegram.org/botx/sendMessage", { + dispatcher: callerDispatcher, + } as RequestInit); + await resolved("https://api.telegram.org/botx/sendChatAction"); + + expect(undiciFetch).toHaveBeenCalledTimes(3); + + const firstCallInit = undiciFetch.mock.calls[0]?.[1] as + | (RequestInit & { dispatcher?: unknown }) + | undefined; + const secondCallInit = undiciFetch.mock.calls[1]?.[1] as + | (RequestInit & { dispatcher?: unknown }) + | undefined; + const thirdDispatcher = getDispatcherFromUndiciCall(3); + + expect(firstCallInit?.dispatcher).toBe(callerDispatcher); + expect(secondCallInit?.dispatcher).toBe(callerDispatcher); + expect(thirdDispatcher?.options?.connect).toEqual( + expect.objectContaining({ + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }), + ); + expect(thirdDispatcher?.options?.connect?.family).not.toBe(4); + }); + + it("does not retry when error codes do not match fallback rules", async () => { + const fetchError = buildFetchFallbackError("ECONNRESET"); + undiciFetch.mockRejectedValue(fetchError); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + }, + }); + + await expect(resolved("https://api.telegram.org/botx/sendMessage")).rejects.toThrow( "fetch failed", ); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(undiciFetch).toHaveBeenCalledTimes(1); + }); + + it("keeps per-resolver transport policy isolated across multiple accounts", async () => { + undiciFetch.mockResolvedValue({ ok: true } as Response); + + const resolverA = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + }); + const resolverB = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "verbatim", + }, + }); + + await resolverA("https://api.telegram.org/botA/getMe"); + await resolverB("https://api.telegram.org/botB/getMe"); + + const dispatcherA = getDispatcherFromUndiciCall(1); + const dispatcherB = getDispatcherFromUndiciCall(2); + + expect(dispatcherA).toBeDefined(); + expect(dispatcherB).toBeDefined(); + expect(dispatcherA).not.toBe(dispatcherB); + + expect(dispatcherA?.options?.connect).toEqual( + expect.objectContaining({ + autoSelectFamily: false, + }), + ); + expect(dispatcherB?.options?.connect).toEqual( + expect.objectContaining({ + autoSelectFamily: true, + }), + ); + + // Core guarantee: Telegram transport no longer mutates process-global defaults. + expect(setGlobalDispatcher).not.toHaveBeenCalled(); + expect(setDefaultResultOrder).not.toHaveBeenCalled(); + expect(setDefaultAutoSelectFamily).not.toHaveBeenCalled(); }); }); diff --git a/src/telegram/fetch.ts b/src/telegram/fetch.ts index f1e50021e92..3934c10c391 100644 --- a/src/telegram/fetch.ts +++ b/src/telegram/fetch.ts @@ -1,23 +1,43 @@ import * as dns from "node:dns"; -import * as net from "node:net"; -import { EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } from "undici"; +import { Agent, EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from "undici"; import type { TelegramNetworkConfig } from "../config/types.telegram.js"; import { resolveFetch } from "../infra/fetch.js"; -import { hasProxyEnvConfigured } from "../infra/net/proxy-env.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveTelegramAutoSelectFamilyDecision, resolveTelegramDnsResultOrderDecision, } from "./network-config.js"; +import { getProxyUrlFromFetch } from "./proxy.js"; -let appliedAutoSelectFamily: boolean | null = null; -let appliedDnsResultOrder: string | null = null; -let appliedGlobalDispatcherAutoSelectFamily: boolean | null = null; const log = createSubsystemLogger("telegram/network"); -function isProxyLikeDispatcher(dispatcher: unknown): boolean { - const ctorName = (dispatcher as { constructor?: { name?: string } })?.constructor?.name; - return typeof ctorName === "string" && ctorName.includes("ProxyAgent"); -} + +const TELEGRAM_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS = 300; +const TELEGRAM_API_HOSTNAME = "api.telegram.org"; + +type RequestInitWithDispatcher = RequestInit & { + dispatcher?: unknown; +}; + +type TelegramDispatcher = Agent | EnvHttpProxyAgent | ProxyAgent; + +type TelegramDispatcherMode = "direct" | "env-proxy" | "explicit-proxy"; + +type TelegramDnsResultOrder = "ipv4first" | "verbatim"; + +type LookupCallback = + | ((err: NodeJS.ErrnoException | null, address: string, family: number) => void) + | ((err: NodeJS.ErrnoException | null, addresses: dns.LookupAddress[]) => void); + +type LookupOptions = (dns.LookupOneOptions | dns.LookupAllOptions) & { + order?: TelegramDnsResultOrder; + verbatim?: boolean; +}; + +type LookupFunction = ( + hostname: string, + options: number | dns.LookupOneOptions | dns.LookupAllOptions | undefined, + callback: LookupCallback, +) => void; const FALLBACK_RETRY_ERROR_CODES = [ "ETIMEDOUT", @@ -48,72 +68,215 @@ const IPV4_FALLBACK_RULES: readonly Ipv4FallbackRule[] = [ }, ]; -// Node 22 workaround: enable autoSelectFamily to allow IPv4 fallback on broken IPv6 networks. -// Many networks have IPv6 configured but not routed, causing "Network is unreachable" errors. -// See: https://github.com/nodejs/node/issues/54359 -function applyTelegramNetworkWorkarounds(network?: TelegramNetworkConfig): void { - // Apply autoSelectFamily workaround - const autoSelectDecision = resolveTelegramAutoSelectFamilyDecision({ network }); - if (autoSelectDecision.value !== null && autoSelectDecision.value !== appliedAutoSelectFamily) { - if (typeof net.setDefaultAutoSelectFamily === "function") { - try { - net.setDefaultAutoSelectFamily(autoSelectDecision.value); - appliedAutoSelectFamily = autoSelectDecision.value; - const label = autoSelectDecision.source ? ` (${autoSelectDecision.source})` : ""; - log.info(`autoSelectFamily=${autoSelectDecision.value}${label}`); - } catch { - // ignore if unsupported by the runtime - } - } +function normalizeDnsResultOrder(value: string | null): TelegramDnsResultOrder | null { + if (value === "ipv4first" || value === "verbatim") { + return value; + } + return null; +} + +function createDnsResultOrderLookup( + order: TelegramDnsResultOrder | null, +): LookupFunction | undefined { + if (!order) { + return undefined; + } + const lookup = dns.lookup as unknown as ( + hostname: string, + options: LookupOptions, + callback: LookupCallback, + ) => void; + return (hostname, options, callback) => { + const baseOptions: LookupOptions = + typeof options === "number" + ? { family: options } + : options + ? { ...(options as LookupOptions) } + : {}; + const lookupOptions: LookupOptions = { + ...baseOptions, + order, + // Keep `verbatim` for compatibility with Node runtimes that ignore `order`. + verbatim: order === "verbatim", + }; + lookup(hostname, lookupOptions, callback); + }; +} + +function buildTelegramConnectOptions(params: { + autoSelectFamily: boolean | null; + dnsResultOrder: TelegramDnsResultOrder | null; + forceIpv4: boolean; +}): { + autoSelectFamily?: boolean; + autoSelectFamilyAttemptTimeout?: number; + family?: number; + lookup?: LookupFunction; +} | null { + const connect: { + autoSelectFamily?: boolean; + autoSelectFamilyAttemptTimeout?: number; + family?: number; + lookup?: LookupFunction; + } = {}; + + if (params.forceIpv4) { + connect.family = 4; + connect.autoSelectFamily = false; + } else if (typeof params.autoSelectFamily === "boolean") { + connect.autoSelectFamily = params.autoSelectFamily; + connect.autoSelectFamilyAttemptTimeout = TELEGRAM_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS; } - // Node 22's built-in globalThis.fetch uses undici's internal Agent whose - // connect options are frozen at construction time. Calling - // net.setDefaultAutoSelectFamily() after that agent is created has no - // effect on it. Replace the global dispatcher with one that carries the - // current autoSelectFamily setting so subsequent globalThis.fetch calls - // inherit the same decision. - // See: https://github.com/openclaw/openclaw/issues/25676 - if ( - autoSelectDecision.value !== null && - autoSelectDecision.value !== appliedGlobalDispatcherAutoSelectFamily - ) { - const existingGlobalDispatcher = getGlobalDispatcher(); - const shouldPreserveExistingProxy = - isProxyLikeDispatcher(existingGlobalDispatcher) && !hasProxyEnvConfigured(); - if (!shouldPreserveExistingProxy) { - try { - setGlobalDispatcher( - new EnvHttpProxyAgent({ - connect: { - autoSelectFamily: autoSelectDecision.value, - autoSelectFamilyAttemptTimeout: 300, - }, - }), - ); - appliedGlobalDispatcherAutoSelectFamily = autoSelectDecision.value; - log.info(`global undici dispatcher autoSelectFamily=${autoSelectDecision.value}`); - } catch { - // ignore if setGlobalDispatcher is unavailable - } - } + const lookup = createDnsResultOrderLookup(params.dnsResultOrder); + if (lookup) { + connect.lookup = lookup; } - // Apply DNS result order workaround for IPv4/IPv6 issues. - // Some APIs (including Telegram) may fail with IPv6 on certain networks. - // See: https://github.com/openclaw/openclaw/issues/5311 - const dnsDecision = resolveTelegramDnsResultOrderDecision({ network }); - if (dnsDecision.value !== null && dnsDecision.value !== appliedDnsResultOrder) { - if (typeof dns.setDefaultResultOrder === "function") { - try { - dns.setDefaultResultOrder(dnsDecision.value as "ipv4first" | "verbatim"); - appliedDnsResultOrder = dnsDecision.value; - const label = dnsDecision.source ? ` (${dnsDecision.source})` : ""; - log.info(`dnsResultOrder=${dnsDecision.value}${label}`); - } catch { - // ignore if unsupported by the runtime - } + return Object.keys(connect).length > 0 ? connect : null; +} + +function shouldBypassEnvProxyForTelegramApi(env: NodeJS.ProcessEnv = process.env): boolean { + // We need this classification before dispatch to decide whether sticky IPv4 fallback + // can safely arm. EnvHttpProxyAgent does not expose route decisions (proxy vs direct + // NO_PROXY bypass), so we mirror undici's parsing/matching behavior for this host. + // Match EnvHttpProxyAgent behavior (undici): + // - lower-case no_proxy takes precedence over NO_PROXY + // - entries split by comma or whitespace + // - wildcard handling is exact-string "*" only + // - leading "." and "*." are normalized the same way + const noProxyValue = env.no_proxy ?? env.NO_PROXY ?? ""; + if (!noProxyValue) { + return false; + } + if (noProxyValue === "*") { + return true; + } + const targetHostname = TELEGRAM_API_HOSTNAME.toLowerCase(); + const targetPort = 443; + const noProxyEntries = noProxyValue.split(/[,\s]/); + for (let i = 0; i < noProxyEntries.length; i++) { + const entry = noProxyEntries[i]; + if (!entry) { + continue; } + const parsed = entry.match(/^(.+):(\d+)$/); + const entryHostname = (parsed ? parsed[1] : entry).replace(/^\*?\./, "").toLowerCase(); + const entryPort = parsed ? Number.parseInt(parsed[2], 10) : 0; + if (entryPort && entryPort !== targetPort) { + continue; + } + if ( + targetHostname === entryHostname || + targetHostname.slice(-(entryHostname.length + 1)) === `.${entryHostname}` + ) { + return true; + } + } + return false; +} + +function hasEnvHttpProxyForTelegramApi(env: NodeJS.ProcessEnv = process.env): boolean { + // Match EnvHttpProxyAgent behavior (undici) for HTTPS requests: + // - lower-case env vars take precedence over upper-case + // - HTTPS requests use https_proxy/HTTPS_PROXY first, then fall back to http_proxy/HTTP_PROXY + // - ALL_PROXY is ignored by EnvHttpProxyAgent + const httpProxy = env.http_proxy ?? env.HTTP_PROXY; + const httpsProxy = env.https_proxy ?? env.HTTPS_PROXY; + return Boolean(httpProxy) || Boolean(httpsProxy); +} + +function createTelegramDispatcher(params: { + autoSelectFamily: boolean | null; + dnsResultOrder: TelegramDnsResultOrder | null; + useEnvProxy: boolean; + forceIpv4: boolean; + proxyUrl?: string; +}): { dispatcher: TelegramDispatcher; mode: TelegramDispatcherMode } { + const connect = buildTelegramConnectOptions({ + autoSelectFamily: params.autoSelectFamily, + dnsResultOrder: params.dnsResultOrder, + forceIpv4: params.forceIpv4, + }); + const explicitProxyUrl = params.proxyUrl?.trim(); + if (explicitProxyUrl) { + const proxyOptions = connect + ? ({ + uri: explicitProxyUrl, + proxyTls: connect, + } satisfies ConstructorParameters[0]) + : explicitProxyUrl; + try { + return { + dispatcher: new ProxyAgent(proxyOptions), + mode: "explicit-proxy", + }; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error(`explicit proxy dispatcher init failed: ${reason}`, { cause: err }); + } + } + if (params.useEnvProxy) { + const proxyOptions = connect + ? ({ + connect, + // undici's EnvHttpProxyAgent passes `connect` only to the no-proxy Agent. + // Real proxied HTTPS traffic reads transport settings from ProxyAgent.proxyTls. + proxyTls: connect, + } satisfies ConstructorParameters[0]) + : undefined; + try { + return { + dispatcher: new EnvHttpProxyAgent(proxyOptions), + mode: "env-proxy", + }; + } catch (err) { + log.warn( + `env proxy dispatcher init failed; falling back to direct dispatcher: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + const agentOptions = connect + ? ({ + connect, + } satisfies ConstructorParameters[0]) + : undefined; + return { + dispatcher: new Agent(agentOptions), + mode: "direct", + }; +} + +function withDispatcherIfMissing( + init: RequestInit | undefined, + dispatcher: TelegramDispatcher, +): RequestInitWithDispatcher { + const withDispatcher = init as RequestInitWithDispatcher | undefined; + if (withDispatcher?.dispatcher) { + return init ?? {}; + } + return init ? { ...init, dispatcher } : { dispatcher }; +} + +function resolveWrappedFetch(fetchImpl: typeof fetch): typeof fetch { + return resolveFetch(fetchImpl) ?? fetchImpl; +} + +function logResolverNetworkDecisions(params: { + autoSelectDecision: ReturnType; + dnsDecision: ReturnType; +}): void { + if (params.autoSelectDecision.value !== null) { + const sourceLabel = params.autoSelectDecision.source + ? ` (${params.autoSelectDecision.source})` + : ""; + log.info(`autoSelectFamily=${params.autoSelectDecision.value}${sourceLabel}`); + } + if (params.dnsDecision.value !== null) { + const sourceLabel = params.dnsDecision.source ? ` (${params.dnsDecision.source})` : ""; + log.info(`dnsResultOrder=${params.dnsDecision.value}${sourceLabel}`); } } @@ -151,6 +314,11 @@ function collectErrorCodes(err: unknown): Set { return codes; } +function formatErrorCodes(err: unknown): string { + const codes = [...collectErrorCodes(err)]; + return codes.length > 0 ? codes.join(",") : "none"; +} + function shouldRetryWithIpv4Fallback(err: unknown): boolean { const ctx: Ipv4FallbackContext = { message: @@ -165,44 +333,97 @@ function shouldRetryWithIpv4Fallback(err: unknown): boolean { return true; } -function applyTelegramIpv4Fallback(): void { - applyTelegramNetworkWorkarounds({ - autoSelectFamily: false, - dnsResultOrder: "ipv4first", - }); - log.warn("fetch fallback: forcing autoSelectFamily=false + dnsResultOrder=ipv4first"); -} - // Prefer wrapped fetch when available to normalize AbortSignal across runtimes. export function resolveTelegramFetch( proxyFetch?: typeof fetch, options?: { network?: TelegramNetworkConfig }, -): typeof fetch | undefined { - applyTelegramNetworkWorkarounds(options?.network); - const sourceFetch = proxyFetch ? resolveFetch(proxyFetch) : resolveFetch(); - if (!sourceFetch) { - throw new Error("fetch is not available; set channels.telegram.proxy in config"); - } - // When Telegram media fetch hits dual-stack edge cases (ENETUNREACH/ETIMEDOUT), - // switch to IPv4-safe network mode and retry once. - if (proxyFetch) { +): typeof fetch { + const autoSelectDecision = resolveTelegramAutoSelectFamilyDecision({ + network: options?.network, + }); + const dnsDecision = resolveTelegramDnsResultOrderDecision({ + network: options?.network, + }); + logResolverNetworkDecisions({ + autoSelectDecision, + dnsDecision, + }); + + const explicitProxyUrl = proxyFetch ? getProxyUrlFromFetch(proxyFetch) : undefined; + const undiciSourceFetch = resolveWrappedFetch(undiciFetch as unknown as typeof fetch); + const sourceFetch = explicitProxyUrl + ? undiciSourceFetch + : proxyFetch + ? resolveWrappedFetch(proxyFetch) + : undiciSourceFetch; + + // Preserve fully caller-owned custom fetch implementations. + // OpenClaw proxy fetches are metadata-tagged and continue into resolver-scoped policy. + if (proxyFetch && !explicitProxyUrl) { return sourceFetch; } + + const dnsResultOrder = normalizeDnsResultOrder(dnsDecision.value); + const useEnvProxy = !explicitProxyUrl && hasEnvHttpProxyForTelegramApi(); + const defaultDispatcherResolution = createTelegramDispatcher({ + autoSelectFamily: autoSelectDecision.value, + dnsResultOrder, + useEnvProxy, + forceIpv4: false, + proxyUrl: explicitProxyUrl, + }); + const defaultDispatcher = defaultDispatcherResolution.dispatcher; + const shouldBypassEnvProxy = shouldBypassEnvProxyForTelegramApi(); + const allowStickyIpv4Fallback = + defaultDispatcherResolution.mode === "direct" || + (defaultDispatcherResolution.mode === "env-proxy" && shouldBypassEnvProxy); + const stickyShouldUseEnvProxy = defaultDispatcherResolution.mode === "env-proxy"; + + let stickyIpv4FallbackEnabled = false; + let stickyIpv4Dispatcher: TelegramDispatcher | null = null; + const resolveStickyIpv4Dispatcher = () => { + if (!stickyIpv4Dispatcher) { + stickyIpv4Dispatcher = createTelegramDispatcher({ + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + useEnvProxy: stickyShouldUseEnvProxy, + forceIpv4: true, + proxyUrl: explicitProxyUrl, + }).dispatcher; + } + return stickyIpv4Dispatcher; + }; + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const callerProvidedDispatcher = Boolean( + (init as RequestInitWithDispatcher | undefined)?.dispatcher, + ); + const initialInit = withDispatcherIfMissing( + init, + stickyIpv4FallbackEnabled ? resolveStickyIpv4Dispatcher() : defaultDispatcher, + ); try { - return await sourceFetch(input, init); + return await sourceFetch(input, initialInit); } catch (err) { if (shouldRetryWithIpv4Fallback(err)) { - applyTelegramIpv4Fallback(); - return sourceFetch(input, init); + // Preserve caller-owned dispatchers on retry. + if (callerProvidedDispatcher) { + return sourceFetch(input, init ?? {}); + } + // Proxy routes should not arm sticky IPv4 mode; `family=4` would constrain + // proxy-connect behavior instead of Telegram endpoint selection. + if (!allowStickyIpv4Fallback) { + throw err; + } + if (!stickyIpv4FallbackEnabled) { + stickyIpv4FallbackEnabled = true; + log.warn( + `fetch fallback: enabling sticky IPv4-only dispatcher (codes=${formatErrorCodes(err)})`, + ); + } + return sourceFetch(input, withDispatcherIfMissing(init, resolveStickyIpv4Dispatcher())); } throw err; } }) as typeof fetch; } - -export function resetTelegramFetchStateForTests(): void { - appliedAutoSelectFamily = null; - appliedDnsResultOrder = null; - appliedGlobalDispatcherAutoSelectFamily = null; -} diff --git a/src/telegram/probe.test.ts b/src/telegram/probe.test.ts index 11b0b317eec..7006d14a2f7 100644 --- a/src/telegram/probe.test.ts +++ b/src/telegram/probe.test.ts @@ -1,14 +1,28 @@ -import { type Mock, describe, expect, it, vi } from "vitest"; +import { afterEach, type Mock, describe, expect, it, vi } from "vitest"; import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; -import { probeTelegram } from "./probe.js"; +import { probeTelegram, resetTelegramProbeFetcherCacheForTests } from "./probe.js"; + +const resolveTelegramFetch = vi.hoisted(() => vi.fn()); +const makeProxyFetch = vi.hoisted(() => vi.fn()); + +vi.mock("./fetch.js", () => ({ + resolveTelegramFetch, +})); + +vi.mock("./proxy.js", () => ({ + makeProxyFetch, +})); describe("probeTelegram retry logic", () => { const token = "test-token"; const timeoutMs = 5000; + const originalFetch = global.fetch; const installFetchMock = (): Mock => { const fetchMock = vi.fn(); global.fetch = withFetchPreconnect(fetchMock); + resolveTelegramFetch.mockImplementation((proxyFetch?: typeof fetch) => proxyFetch ?? fetch); + makeProxyFetch.mockImplementation(() => fetchMock as unknown as typeof fetch); return fetchMock; }; @@ -41,6 +55,19 @@ describe("probeTelegram retry logic", () => { expect(result.bot?.username).toBe("test_bot"); } + afterEach(() => { + resetTelegramProbeFetcherCacheForTests(); + resolveTelegramFetch.mockReset(); + makeProxyFetch.mockReset(); + vi.unstubAllEnvs(); + vi.clearAllMocks(); + if (originalFetch) { + global.fetch = originalFetch; + } else { + delete (globalThis as { fetch?: typeof fetch }).fetch; + } + }); + it.each([ { errors: [], @@ -95,6 +122,35 @@ describe("probeTelegram retry logic", () => { } }); + it("respects timeout budget across retries", async () => { + const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) { + reject(new Error("Request aborted")); + return; + } + signal?.addEventListener("abort", () => reject(new Error("Request aborted")), { + once: true, + }); + }); + }); + global.fetch = withFetchPreconnect(fetchMock as unknown as typeof fetch); + resolveTelegramFetch.mockImplementation((proxyFetch?: typeof fetch) => proxyFetch ?? fetch); + makeProxyFetch.mockImplementation(() => fetchMock as unknown as typeof fetch); + vi.useFakeTimers(); + try { + const probePromise = probeTelegram(`${token}-budget`, 500); + await vi.advanceTimersByTimeAsync(600); + const result = await probePromise; + + expect(result.ok).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + it("should NOT retry if getMe returns a 401 Unauthorized", async () => { const fetchMock = installFetchMock(); const mockResponse = { @@ -114,4 +170,106 @@ describe("probeTelegram retry logic", () => { expect(result.error).toBe("Unauthorized"); expect(fetchMock).toHaveBeenCalledTimes(1); // Should not retry }); + + it("uses resolver-scoped Telegram fetch with probe network options", async () => { + const fetchMock = installFetchMock(); + mockGetMeSuccess(fetchMock); + mockGetWebhookInfoSuccess(fetchMock); + + await probeTelegram(token, timeoutMs, { + proxyUrl: "http://127.0.0.1:8888", + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + }); + + expect(makeProxyFetch).toHaveBeenCalledWith("http://127.0.0.1:8888"); + expect(resolveTelegramFetch).toHaveBeenCalledWith(fetchMock, { + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + }); + }); + + it("reuses probe fetcher across repeated probes for the same account transport settings", async () => { + const fetchMock = installFetchMock(); + vi.stubEnv("VITEST", ""); + vi.stubEnv("NODE_ENV", "production"); + + mockGetMeSuccess(fetchMock); + mockGetWebhookInfoSuccess(fetchMock); + await probeTelegram(`${token}-cache`, timeoutMs, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + mockGetMeSuccess(fetchMock); + mockGetWebhookInfoSuccess(fetchMock); + await probeTelegram(`${token}-cache`, timeoutMs, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + expect(resolveTelegramFetch).toHaveBeenCalledTimes(1); + }); + + it("does not reuse probe fetcher cache when network settings differ", async () => { + const fetchMock = installFetchMock(); + vi.stubEnv("VITEST", ""); + vi.stubEnv("NODE_ENV", "production"); + + mockGetMeSuccess(fetchMock); + mockGetWebhookInfoSuccess(fetchMock); + await probeTelegram(`${token}-cache-variant`, timeoutMs, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + mockGetMeSuccess(fetchMock); + mockGetWebhookInfoSuccess(fetchMock); + await probeTelegram(`${token}-cache-variant`, timeoutMs, { + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + }); + + expect(resolveTelegramFetch).toHaveBeenCalledTimes(2); + }); + + it("reuses probe fetcher cache across token rotation when accountId is stable", async () => { + const fetchMock = installFetchMock(); + vi.stubEnv("VITEST", ""); + vi.stubEnv("NODE_ENV", "production"); + + mockGetMeSuccess(fetchMock); + mockGetWebhookInfoSuccess(fetchMock); + await probeTelegram(`${token}-old`, timeoutMs, { + accountId: "main", + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + mockGetMeSuccess(fetchMock); + mockGetWebhookInfoSuccess(fetchMock); + await probeTelegram(`${token}-new`, timeoutMs, { + accountId: "main", + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + expect(resolveTelegramFetch).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/telegram/probe.ts b/src/telegram/probe.ts index f988733f0ee..8311506e455 100644 --- a/src/telegram/probe.ts +++ b/src/telegram/probe.ts @@ -1,5 +1,7 @@ import type { BaseProbeResult } from "../channels/plugins/types.js"; +import type { TelegramNetworkConfig } from "../config/types.telegram.js"; import { fetchWithTimeout } from "../utils/fetch-timeout.js"; +import { resolveTelegramFetch } from "./fetch.js"; import { makeProxyFetch } from "./proxy.js"; const TELEGRAM_API_BASE = "https://api.telegram.org"; @@ -17,15 +19,90 @@ export type TelegramProbe = BaseProbeResult & { webhook?: { url?: string | null; hasCustomCert?: boolean | null }; }; +export type TelegramProbeOptions = { + proxyUrl?: string; + network?: TelegramNetworkConfig; + accountId?: string; +}; + +const probeFetcherCache = new Map(); +const MAX_PROBE_FETCHER_CACHE_SIZE = 64; + +export function resetTelegramProbeFetcherCacheForTests(): void { + probeFetcherCache.clear(); +} + +function resolveProbeOptions( + proxyOrOptions?: string | TelegramProbeOptions, +): TelegramProbeOptions | undefined { + if (!proxyOrOptions) { + return undefined; + } + if (typeof proxyOrOptions === "string") { + return { proxyUrl: proxyOrOptions }; + } + return proxyOrOptions; +} + +function shouldUseProbeFetcherCache(): boolean { + return !process.env.VITEST && process.env.NODE_ENV !== "test"; +} + +function buildProbeFetcherCacheKey(token: string, options?: TelegramProbeOptions): string { + const cacheIdentity = options?.accountId?.trim() || token; + const cacheIdentityKind = options?.accountId?.trim() ? "account" : "token"; + const proxyKey = options?.proxyUrl?.trim() ?? ""; + const autoSelectFamily = options?.network?.autoSelectFamily; + const autoSelectFamilyKey = + typeof autoSelectFamily === "boolean" ? String(autoSelectFamily) : "default"; + const dnsResultOrderKey = options?.network?.dnsResultOrder ?? "default"; + return `${cacheIdentityKind}:${cacheIdentity}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}`; +} + +function setCachedProbeFetcher(cacheKey: string, fetcher: typeof fetch): typeof fetch { + probeFetcherCache.set(cacheKey, fetcher); + if (probeFetcherCache.size > MAX_PROBE_FETCHER_CACHE_SIZE) { + const oldestKey = probeFetcherCache.keys().next().value; + if (oldestKey !== undefined) { + probeFetcherCache.delete(oldestKey); + } + } + return fetcher; +} + +function resolveProbeFetcher(token: string, options?: TelegramProbeOptions): typeof fetch { + const cacheEnabled = shouldUseProbeFetcherCache(); + const cacheKey = cacheEnabled ? buildProbeFetcherCacheKey(token, options) : null; + if (cacheKey) { + const cachedFetcher = probeFetcherCache.get(cacheKey); + if (cachedFetcher) { + return cachedFetcher; + } + } + + const proxyUrl = options?.proxyUrl?.trim(); + const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined; + const resolved = resolveTelegramFetch(proxyFetch, { network: options?.network }); + + if (cacheKey) { + return setCachedProbeFetcher(cacheKey, resolved); + } + return resolved; +} + export async function probeTelegram( token: string, timeoutMs: number, - proxyUrl?: string, + proxyOrOptions?: string | TelegramProbeOptions, ): Promise { const started = Date.now(); - const fetcher = proxyUrl ? makeProxyFetch(proxyUrl) : fetch; + const timeoutBudgetMs = Math.max(1, Math.floor(timeoutMs)); + const deadlineMs = started + timeoutBudgetMs; + const options = resolveProbeOptions(proxyOrOptions); + const fetcher = resolveProbeFetcher(token, options); const base = `${TELEGRAM_API_BASE}/bot${token}`; - const retryDelayMs = Math.max(50, Math.min(1000, timeoutMs)); + const retryDelayMs = Math.max(50, Math.min(1000, Math.floor(timeoutBudgetMs / 5))); + const resolveRemainingBudgetMs = () => Math.max(0, deadlineMs - Date.now()); const result: TelegramProbe = { ok: false, @@ -40,19 +117,35 @@ export async function probeTelegram( // Retry loop for initial connection (handles network/DNS startup races) for (let i = 0; i < 3; i++) { + const remainingBudgetMs = resolveRemainingBudgetMs(); + if (remainingBudgetMs <= 0) { + break; + } try { - meRes = await fetchWithTimeout(`${base}/getMe`, {}, timeoutMs, fetcher); + meRes = await fetchWithTimeout( + `${base}/getMe`, + {}, + Math.max(1, Math.min(timeoutBudgetMs, remainingBudgetMs)), + fetcher, + ); break; } catch (err) { fetchError = err; if (i < 2) { - await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + const remainingAfterAttemptMs = resolveRemainingBudgetMs(); + if (remainingAfterAttemptMs <= 0) { + break; + } + const delayMs = Math.min(retryDelayMs, remainingAfterAttemptMs); + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } } } } if (!meRes) { - throw fetchError; + throw fetchError ?? new Error(`probe timed out after ${timeoutBudgetMs}ms`); } const meJson = (await meRes.json()) as { @@ -89,16 +182,24 @@ export async function probeTelegram( // Try to fetch webhook info, but don't fail health if it errors. try { - const webhookRes = await fetchWithTimeout(`${base}/getWebhookInfo`, {}, timeoutMs, fetcher); - const webhookJson = (await webhookRes.json()) as { - ok?: boolean; - result?: { url?: string; has_custom_certificate?: boolean }; - }; - if (webhookRes.ok && webhookJson?.ok) { - result.webhook = { - url: webhookJson.result?.url ?? null, - hasCustomCert: webhookJson.result?.has_custom_certificate ?? null, + const webhookRemainingBudgetMs = resolveRemainingBudgetMs(); + if (webhookRemainingBudgetMs > 0) { + const webhookRes = await fetchWithTimeout( + `${base}/getWebhookInfo`, + {}, + Math.max(1, Math.min(timeoutBudgetMs, webhookRemainingBudgetMs)), + fetcher, + ); + const webhookJson = (await webhookRes.json()) as { + ok?: boolean; + result?: { url?: string; has_custom_certificate?: boolean }; }; + if (webhookRes.ok && webhookJson?.ok) { + result.webhook = { + url: webhookJson.result?.url ?? null, + hasCustomCert: webhookJson.result?.has_custom_certificate ?? null, + }; + } } } catch { // ignore webhook errors for probe diff --git a/src/telegram/proxy.test.ts b/src/telegram/proxy.test.ts index 27065d5c50c..4f2ca8f62e6 100644 --- a/src/telegram/proxy.test.ts +++ b/src/telegram/proxy.test.ts @@ -29,7 +29,7 @@ vi.mock("undici", () => ({ setGlobalDispatcher: mocks.setGlobalDispatcher, })); -import { makeProxyFetch } from "./proxy.js"; +import { getProxyUrlFromFetch, makeProxyFetch } from "./proxy.js"; describe("makeProxyFetch", () => { it("uses undici fetch with ProxyAgent dispatcher", async () => { @@ -46,4 +46,11 @@ describe("makeProxyFetch", () => { ); expect(mocks.setGlobalDispatcher).not.toHaveBeenCalled(); }); + + it("attaches proxy metadata for resolver transport handling", () => { + const proxyUrl = "http://proxy.test:8080"; + const proxyFetch = makeProxyFetch(proxyUrl); + + expect(getProxyUrlFromFetch(proxyFetch)).toBe(proxyUrl); + }); }); diff --git a/src/telegram/proxy.ts b/src/telegram/proxy.ts index c4cb7129a17..3ac2bb10159 100644 --- a/src/telegram/proxy.ts +++ b/src/telegram/proxy.ts @@ -1 +1 @@ -export { makeProxyFetch } from "../infra/net/proxy-fetch.js"; +export { getProxyUrlFromFetch, makeProxyFetch } from "../infra/net/proxy-fetch.js"; diff --git a/src/telegram/send.proxy.test.ts b/src/telegram/send.proxy.test.ts index ee47ec765c4..8e16078a67c 100644 --- a/src/telegram/send.proxy.test.ts +++ b/src/telegram/send.proxy.test.ts @@ -51,7 +51,12 @@ vi.mock("grammy", () => ({ InputFile: class {}, })); -import { deleteMessageTelegram, reactMessageTelegram, sendMessageTelegram } from "./send.js"; +import { + deleteMessageTelegram, + reactMessageTelegram, + resetTelegramClientOptionsCacheForTests, + sendMessageTelegram, +} from "./send.js"; describe("telegram proxy client", () => { const proxyUrl = "http://proxy.test:8080"; @@ -76,6 +81,8 @@ describe("telegram proxy client", () => { }; beforeEach(() => { + resetTelegramClientOptionsCacheForTests(); + vi.unstubAllEnvs(); botApi.sendMessage.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); botApi.setMessageReaction.mockResolvedValue(undefined); botApi.deleteMessage.mockResolvedValue(true); @@ -87,6 +94,33 @@ describe("telegram proxy client", () => { resolveTelegramFetch.mockClear(); }); + it("reuses cached Telegram client options for repeated sends with same account transport settings", async () => { + const { fetchImpl } = prepareProxyFetch(); + vi.stubEnv("VITEST", ""); + vi.stubEnv("NODE_ENV", "production"); + + await sendMessageTelegram("123", "first", { token: "tok", accountId: "foo" }); + await sendMessageTelegram("123", "second", { token: "tok", accountId: "foo" }); + + expect(makeProxyFetch).toHaveBeenCalledTimes(1); + expect(resolveTelegramFetch).toHaveBeenCalledTimes(1); + expect(botCtorSpy).toHaveBeenCalledTimes(2); + expect(botCtorSpy).toHaveBeenNthCalledWith( + 1, + "tok", + expect.objectContaining({ + client: expect.objectContaining({ fetch: fetchImpl }), + }), + ); + expect(botCtorSpy).toHaveBeenNthCalledWith( + 2, + "tok", + expect.objectContaining({ + client: expect.objectContaining({ fetch: fetchImpl }), + }), + ); + }); + it.each([ { name: "sendMessage", diff --git a/src/telegram/send.ts b/src/telegram/send.ts index e1b352a0a61..313abf361e8 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -115,6 +115,12 @@ const MESSAGE_NOT_MODIFIED_RE = const CHAT_NOT_FOUND_RE = /400: Bad Request: chat not found/i; const sendLogger = createSubsystemLogger("telegram/send"); const diagLogger = createSubsystemLogger("telegram/diagnostic"); +const telegramClientOptionsCache = new Map(); +const MAX_TELEGRAM_CLIENT_OPTIONS_CACHE_SIZE = 64; + +export function resetTelegramClientOptionsCacheForTests(): void { + telegramClientOptionsCache.clear(); +} function createTelegramHttpLogger(cfg: ReturnType) { const enabled = isDiagnosticFlagEnabled("telegram.http", cfg); @@ -130,25 +136,74 @@ function createTelegramHttpLogger(cfg: ReturnType) { }; } +function shouldUseTelegramClientOptionsCache(): boolean { + return !process.env.VITEST && process.env.NODE_ENV !== "test"; +} + +function buildTelegramClientOptionsCacheKey(params: { + account: ResolvedTelegramAccount; + timeoutSeconds?: number; +}): string { + const proxyKey = params.account.config.proxy?.trim() ?? ""; + const autoSelectFamily = params.account.config.network?.autoSelectFamily; + const autoSelectFamilyKey = + typeof autoSelectFamily === "boolean" ? String(autoSelectFamily) : "default"; + const dnsResultOrderKey = params.account.config.network?.dnsResultOrder ?? "default"; + const timeoutSecondsKey = + typeof params.timeoutSeconds === "number" ? String(params.timeoutSeconds) : "default"; + return `${params.account.accountId}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}::${timeoutSecondsKey}`; +} + +function setCachedTelegramClientOptions( + cacheKey: string, + clientOptions: ApiClientOptions | undefined, +): ApiClientOptions | undefined { + telegramClientOptionsCache.set(cacheKey, clientOptions); + if (telegramClientOptionsCache.size > MAX_TELEGRAM_CLIENT_OPTIONS_CACHE_SIZE) { + const oldestKey = telegramClientOptionsCache.keys().next().value; + if (oldestKey !== undefined) { + telegramClientOptionsCache.delete(oldestKey); + } + } + return clientOptions; +} + function resolveTelegramClientOptions( account: ResolvedTelegramAccount, ): ApiClientOptions | undefined { - const proxyUrl = account.config.proxy?.trim(); - const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined; - const fetchImpl = resolveTelegramFetch(proxyFetch, { - network: account.config.network, - }); const timeoutSeconds = typeof account.config.timeoutSeconds === "number" && Number.isFinite(account.config.timeoutSeconds) ? Math.max(1, Math.floor(account.config.timeoutSeconds)) : undefined; - return fetchImpl || timeoutSeconds - ? { - ...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}), - ...(timeoutSeconds ? { timeoutSeconds } : {}), - } - : undefined; + + const cacheEnabled = shouldUseTelegramClientOptionsCache(); + const cacheKey = cacheEnabled + ? buildTelegramClientOptionsCacheKey({ + account, + timeoutSeconds, + }) + : null; + if (cacheKey && telegramClientOptionsCache.has(cacheKey)) { + return telegramClientOptionsCache.get(cacheKey); + } + + const proxyUrl = account.config.proxy?.trim(); + const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined; + const fetchImpl = resolveTelegramFetch(proxyFetch, { + network: account.config.network, + }); + const clientOptions = + fetchImpl || timeoutSeconds + ? { + ...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}), + ...(timeoutSeconds ? { timeoutSeconds } : {}), + } + : undefined; + if (cacheKey) { + return setCachedTelegramClientOptions(cacheKey, clientOptions); + } + return clientOptions; } function resolveToken(explicit: string | undefined, params: { accountId: string; token: string }) { From 8306eabf85ea0c08e02fb0e45c697e22e77dd8c6 Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Tue, 10 Mar 2026 14:18:41 +0800 Subject: [PATCH 0052/1173] fix(agents): forward memory flush write path (#41761) Merged via squash. Prepared head SHA: 0a8ebf8e5b426c5b402adc34509830f46e4bb849 Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Reviewed-by: @frankekn --- CHANGELOG.md | 1 + src/agents/pi-embedded-runner/run.ts | 1 + .../usage-reporting.test.ts | 30 +++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e2e65653c0..c3a2c9c2d75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai - Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo. - Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94. - Telegram/network env-proxy: apply configured transport policy to proxied HTTPS dispatchers as well as direct `NO_PROXY` bypasses, so resolver-scoped IPv4 fallback and network settings work consistently for env-proxied Telegram traffic. (#40740) Thanks @sircrumpet. +- Agents/memory flush: forward `memoryFlushWritePath` through `runEmbeddedPiAgent` so memory-triggered flush turns keep the append-only write guard without aborting before tool setup. Follows up on #38574. (#41761) Thanks @frankekn. ## 2026.3.8 diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 298bac9fe9e..7f5f4f525b7 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -850,6 +850,7 @@ export async function runEmbeddedPiAgent( sessionId: params.sessionId, sessionKey: params.sessionKey, trigger: params.trigger, + memoryFlushWritePath: params.memoryFlushWritePath, messageChannel: params.messageChannel, messageProvider: params.messageProvider, agentAccountId: params.agentAccountId, diff --git a/src/agents/pi-embedded-runner/usage-reporting.test.ts b/src/agents/pi-embedded-runner/usage-reporting.test.ts index 48cb586e727..ebab56a841b 100644 --- a/src/agents/pi-embedded-runner/usage-reporting.test.ts +++ b/src/agents/pi-embedded-runner/usage-reporting.test.ts @@ -79,6 +79,36 @@ describe("runEmbeddedPiAgent usage reporting", () => { ); }); + it("forwards memory flush write paths into memory-triggered attempts", async () => { + mockedRunEmbeddedAttempt.mockResolvedValueOnce({ + aborted: false, + promptError: null, + timedOut: false, + sessionIdUsed: "test-session", + assistantTexts: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + await runEmbeddedPiAgent({ + sessionId: "test-session", + sessionKey: "test-key", + sessionFile: "/tmp/session.json", + workspaceDir: "/tmp/workspace", + prompt: "flush", + timeoutMs: 30000, + runId: "run-memory-forwarding", + trigger: "memory", + memoryFlushWritePath: "memory/2026-03-10.md", + }); + + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledWith( + expect.objectContaining({ + trigger: "memory", + memoryFlushWritePath: "memory/2026-03-10.md", + }), + ); + }); + it("reports total usage from the last turn instead of accumulated total", async () => { // Simulate a multi-turn run result. // Turn 1: Input 100, Output 50. Total 150. From 5296147c20954607e8336191035de7ff2f51e571 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 10 Mar 2026 01:22:41 -0500 Subject: [PATCH 0053/1173] CI: select Swift 6.2 toolchain for CodeQL (#41787) Merged via squash. Prepared head SHA: 8abc6c16571661450a6b932de17b74607ecacb8e Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com> Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com> Reviewed-by: @BunsDev --- .github/workflows/codeql.yml | 6 +++++- CHANGELOG.md | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9b78a3c6172..1d8e473af4f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -93,7 +93,11 @@ jobs: - name: Setup Swift build tools if: matrix.needs_swift_tools - run: brew install xcodegen swiftlint swiftformat + run: | + sudo xcode-select -s /Applications/Xcode_26.1.app + xcodebuild -version + brew install xcodegen swiftlint swiftformat + swift --version - name: Initialize CodeQL uses: github/codeql-action/init@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index c3a2c9c2d75..ecb7cd16680 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai - Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94. - Telegram/network env-proxy: apply configured transport policy to proxied HTTPS dispatchers as well as direct `NO_PROXY` bypasses, so resolver-scoped IPv4 fallback and network settings work consistently for env-proxied Telegram traffic. (#40740) Thanks @sircrumpet. - Agents/memory flush: forward `memoryFlushWritePath` through `runEmbeddedPiAgent` so memory-triggered flush turns keep the append-only write guard without aborting before tool setup. Follows up on #38574. (#41761) Thanks @frankekn. +- CI/CodeQL Swift toolchain: select Xcode 26.1 before installing Swift build tools so the CodeQL Swift job uses Swift tools 6.2 on `macos-latest`. (#41787) thanks @BunsDev. ## 2026.3.8 From 9d403fd4154ff4eb34aed3e91b4650c8797e65ff Mon Sep 17 00:00:00 2001 From: Austin <112558420+rixau@users.noreply.github.com> Date: Tue, 10 Mar 2026 02:30:31 -0400 Subject: [PATCH 0054/1173] fix(ui): replace Manual RPC text input with sorted method dropdown (#14967) Merged via squash. Prepared head SHA: 1bb49b2e64675d37882d0975eb19f8fafd3c6fe9 Co-authored-by: rixau <112558420+rixau@users.noreply.github.com> Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com> Reviewed-by: @BunsDev --- CHANGELOG.md | 1 + ui/src/ui/app-render.ts | 1 + ui/src/ui/views/debug.ts | 19 ++++++++++++++----- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecb7cd16680..f7574f71eb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,7 @@ Docs: https://docs.openclaw.ai - MS Teams/authz: keep `groupPolicy: "allowlist"` enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent. - Security/system.run: bind approved `bun` and `deno run` script operands to on-disk file snapshots so post-approval script rewrites are denied before execution. - Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey. +- Control UI/Debug: replace the Manual RPC free-text method field with a sorted dropdown sourced from gateway-advertised methods, and stack the form vertically for narrower layouts. (#14967) thanks @rixau. ## 2026.3.7 diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 7fbe38c9ca7..1214bcc93a6 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1081,6 +1081,7 @@ export function renderApp(state: AppViewState) { models: state.debugModels, heartbeat: state.debugHeartbeat, eventLog: state.eventLog, + methods: (state.hello?.features?.methods ?? []).toSorted(), callMethod: state.debugCallMethod, callParams: state.debugCallParams, callResult: state.debugCallResult, diff --git a/ui/src/ui/views/debug.ts b/ui/src/ui/views/debug.ts index 9ca33725993..3379e881345 100644 --- a/ui/src/ui/views/debug.ts +++ b/ui/src/ui/views/debug.ts @@ -9,6 +9,7 @@ export type DebugProps = { models: unknown[]; heartbeat: unknown; eventLog: EventLogEntry[]; + methods: string[]; callMethod: string; callParams: string; callResult: string | null; @@ -71,14 +72,22 @@ export function renderDebug(props: DebugProps) {
Manual RPC
Send a raw gateway method with JSON params.
-
+
+
+ ${THEME_MODE_OPTIONS.map( + (opt) => html` + + `, + )}
`; } -function renderSunIcon() { - return html` - - `; -} +export function renderThemeToggle(state: AppViewState) { + const setOpen = (orb: HTMLElement, nextOpen: boolean) => { + orb.classList.toggle("theme-orb--open", nextOpen); + const trigger = orb.querySelector(".theme-orb__trigger"); + const menu = orb.querySelector(".theme-orb__menu"); + if (trigger) { + trigger.setAttribute("aria-expanded", nextOpen ? "true" : "false"); + } + if (menu) { + menu.setAttribute("aria-hidden", nextOpen ? "false" : "true"); + } + }; -function renderMoonIcon() { - return html` - - `; -} + const toggleOpen = (e: Event) => { + const orb = (e.currentTarget as HTMLElement).closest(".theme-orb"); + if (!orb) { + return; + } + const isOpen = orb.classList.contains("theme-orb--open"); + if (isOpen) { + setOpen(orb, false); + } else { + setOpen(orb, true); + const close = (ev: MouseEvent) => { + if (!orb.contains(ev.target as Node)) { + setOpen(orb, false); + document.removeEventListener("click", close); + } + }; + requestAnimationFrame(() => document.addEventListener("click", close)); + } + }; + + const pick = (opt: ThemeOption, e: Event) => { + const orb = (e.currentTarget as HTMLElement).closest(".theme-orb"); + if (orb) { + setOpen(orb, false); + } + if (opt.id !== state.theme) { + const context: ThemeTransitionContext = { element: orb ?? undefined }; + state.setTheme(opt.id, context); + } + }; -function renderMonitorIcon() { return html` - +
+ + +
`; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 1214bcc93a6..1b5390adc15 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1,9 +1,17 @@ import { html, nothing } from "lit"; -import { parseAgentSessionKey } from "../../../src/routing/session-key.js"; +import { + buildAgentMainSessionKey, + parseAgentSessionKey, +} from "../../../src/routing/session-key.js"; import { t } from "../i18n/index.ts"; import { refreshChatAvatar } from "./app-chat.ts"; import { renderUsageTab } from "./app-render-usage-tab.ts"; -import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers.ts"; +import { + renderChatControls, + renderChatSessionSelect, + renderTab, + renderTopbarThemeModeToggle, +} from "./app-render.helpers.ts"; import type { AppViewState } from "./app-view-state.ts"; import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts"; import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts"; @@ -16,6 +24,7 @@ import { ensureAgentConfigEntry, findAgentConfigEntryIndex, loadConfig, + openConfigFile, runUpdate, saveConfig, updateConfigFormValue, @@ -65,6 +74,7 @@ import { updateSkillEdit, updateSkillEnabled, } from "./controllers/skills.ts"; +import "./components/dashboard-header.ts"; import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts"; import { icons } from "./icons.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; @@ -75,23 +85,53 @@ import { resolveModelPrimary, sortLocaleStrings, } from "./views/agents-utils.ts"; -import { renderAgents } from "./views/agents.ts"; -import { renderChannels } from "./views/channels.ts"; import { renderChat } from "./views/chat.ts"; +import { renderCommandPalette } from "./views/command-palette.ts"; import { renderConfig } from "./views/config.ts"; -import { renderCron } from "./views/cron.ts"; -import { renderDebug } from "./views/debug.ts"; import { renderExecApprovalPrompt } from "./views/exec-approval.ts"; import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.ts"; -import { renderInstances } from "./views/instances.ts"; -import { renderLogs } from "./views/logs.ts"; -import { renderNodes } from "./views/nodes.ts"; +import { renderLoginGate } from "./views/login-gate.ts"; import { renderOverview } from "./views/overview.ts"; -import { renderSessions } from "./views/sessions.ts"; -import { renderSkills } from "./views/skills.ts"; -const AVATAR_DATA_RE = /^data:/i; -const AVATAR_HTTP_RE = /^https?:\/\//i; +// Lazy-loaded view modules – deferred so the initial bundle stays small. +// Each loader resolves once; subsequent calls return the cached module. +type LazyState = { mod: T | null; promise: Promise | null }; + +let _pendingUpdate: (() => void) | undefined; + +function createLazy(loader: () => Promise): () => T | null { + const s: LazyState = { mod: null, promise: null }; + return () => { + if (s.mod) { + return s.mod; + } + if (!s.promise) { + s.promise = loader().then((m) => { + s.mod = m; + _pendingUpdate?.(); + return m; + }); + } + return null; + }; +} + +const lazyAgents = createLazy(() => import("./views/agents.ts")); +const lazyChannels = createLazy(() => import("./views/channels.ts")); +const lazyCron = createLazy(() => import("./views/cron.ts")); +const lazyDebug = createLazy(() => import("./views/debug.ts")); +const lazyInstances = createLazy(() => import("./views/instances.ts")); +const lazyLogs = createLazy(() => import("./views/logs.ts")); +const lazyNodes = createLazy(() => import("./views/nodes.ts")); +const lazySessions = createLazy(() => import("./views/sessions.ts")); +const lazySkills = createLazy(() => import("./views/skills.ts")); + +function lazyRender(getter: () => M | null, render: (mod: M) => unknown) { + const mod = getter(); + return mod ? render(mod) : nothing; +} + +const UPDATE_BANNER_DISMISS_KEY = "openclaw:control-ui:update-banner-dismissed:v1"; const CRON_THINKING_SUGGESTIONS = ["off", "minimal", "low", "medium", "high"]; const CRON_TIMEZONE_SUGGESTIONS = [ "UTC", @@ -130,6 +170,126 @@ function uniquePreserveOrder(values: string[]): string[] { return output; } +type DismissedUpdateBanner = { + latestVersion: string; + channel: string | null; + dismissedAtMs: number; +}; + +function loadDismissedUpdateBanner(): DismissedUpdateBanner | null { + try { + const raw = localStorage.getItem(UPDATE_BANNER_DISMISS_KEY); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw) as Partial; + if (!parsed || typeof parsed.latestVersion !== "string") { + return null; + } + return { + latestVersion: parsed.latestVersion, + channel: typeof parsed.channel === "string" ? parsed.channel : null, + dismissedAtMs: typeof parsed.dismissedAtMs === "number" ? parsed.dismissedAtMs : Date.now(), + }; + } catch { + return null; + } +} + +function isUpdateBannerDismissed(updateAvailable: unknown): boolean { + const dismissed = loadDismissedUpdateBanner(); + if (!dismissed) { + return false; + } + const info = updateAvailable as { latestVersion?: unknown; channel?: unknown }; + const latestVersion = info && typeof info.latestVersion === "string" ? info.latestVersion : null; + const channel = info && typeof info.channel === "string" ? info.channel : null; + return Boolean( + latestVersion && dismissed.latestVersion === latestVersion && dismissed.channel === channel, + ); +} + +function dismissUpdateBanner(updateAvailable: unknown) { + const info = updateAvailable as { latestVersion?: unknown; channel?: unknown }; + const latestVersion = info && typeof info.latestVersion === "string" ? info.latestVersion : null; + if (!latestVersion) { + return; + } + const channel = info && typeof info.channel === "string" ? info.channel : null; + const payload: DismissedUpdateBanner = { + latestVersion, + channel, + dismissedAtMs: Date.now(), + }; + try { + localStorage.setItem(UPDATE_BANNER_DISMISS_KEY, JSON.stringify(payload)); + } catch { + // ignore + } +} + +const AVATAR_DATA_RE = /^data:/i; +const AVATAR_HTTP_RE = /^https?:\/\//i; +const COMMUNICATION_SECTION_KEYS = ["channels", "messages", "broadcast", "talk", "audio"] as const; +const APPEARANCE_SECTION_KEYS = ["__appearance__", "ui", "wizard"] as const; +const AUTOMATION_SECTION_KEYS = [ + "commands", + "hooks", + "bindings", + "cron", + "approvals", + "plugins", +] as const; +const INFRASTRUCTURE_SECTION_KEYS = [ + "gateway", + "web", + "browser", + "nodeHost", + "canvasHost", + "discovery", + "media", +] as const; +const AI_AGENTS_SECTION_KEYS = [ + "agents", + "models", + "skills", + "tools", + "memory", + "session", +] as const; +type CommunicationSectionKey = (typeof COMMUNICATION_SECTION_KEYS)[number]; +type AppearanceSectionKey = (typeof APPEARANCE_SECTION_KEYS)[number]; +type AutomationSectionKey = (typeof AUTOMATION_SECTION_KEYS)[number]; +type InfrastructureSectionKey = (typeof INFRASTRUCTURE_SECTION_KEYS)[number]; +type AiAgentsSectionKey = (typeof AI_AGENTS_SECTION_KEYS)[number]; + +const NAV_WIDTH_MIN = 200; +const NAV_WIDTH_MAX = 400; + +function handleNavResizeStart(e: MouseEvent, state: AppViewState) { + e.preventDefault(); + const startX = e.clientX; + const startWidth = state.settings.navWidth; + + const onMove = (ev: MouseEvent) => { + const delta = ev.clientX - startX; + const next = Math.round(Math.min(NAV_WIDTH_MAX, Math.max(NAV_WIDTH_MIN, startWidth + delta))); + state.applySettings({ ...state.settings, navWidth: next }); + }; + + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); +} + function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { const list = state.agentsList?.agents ?? []; const parsed = parseAgentSessionKey(state.sessionKey); @@ -147,16 +307,22 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { } export function renderApp(state: AppViewState) { - const openClawVersion = - (typeof state.hello?.server?.version === "string" && state.hello.server.version.trim()) || - state.updateAvailable?.currentVersion || - t("common.na"); - const availableUpdate = - state.updateAvailable && - state.updateAvailable.latestVersion !== state.updateAvailable.currentVersion - ? state.updateAvailable - : null; - const versionStatusClass = availableUpdate ? "warn" : "ok"; + const updatableState = state as AppViewState & { requestUpdate?: () => void }; + const requestHostUpdate = + typeof updatableState.requestUpdate === "function" + ? () => updatableState.requestUpdate?.() + : undefined; + _pendingUpdate = requestHostUpdate; + + // Gate: require successful gateway connection before showing the dashboard. + // The gateway URL confirmation overlay is always rendered so URL-param flows still work. + if (!state.connected) { + return html` + ${renderLoginGate(state)} + ${renderGatewayUrlConfirmation(state)} + `; + } + const presenceCount = state.presenceEntries.length; const sessionsCount = state.sessionsResult?.count ?? null; const cronNext = state.cronStatus?.nextWakeAtMs ?? null; @@ -234,77 +400,116 @@ export function renderApp(state: AppViewState) { : rawDeliveryToSuggestions; return html` -
+ ${renderCommandPalette({ + open: state.paletteOpen, + query: state.paletteQuery, + activeIndex: state.paletteActiveIndex, + onToggle: () => { + state.paletteOpen = !state.paletteOpen; + }, + onQueryChange: (q) => { + state.paletteQuery = q; + }, + onActiveIndexChange: (i) => { + state.paletteActiveIndex = i; + }, + onNavigate: (tab) => { + state.setTab(tab as import("./navigation.ts").Tab); + }, + onSlashCommand: (cmd) => { + state.setTab("chat" as import("./navigation.ts").Tab); + state.chatMessage = cmd.endsWith(" ") ? cmd : `${cmd} `; + }, + })} +
-
- -
- -
-
OPENCLAW
-
Gateway Dashboard
-
-
-
+ +
-
- - ${t("common.version")} - ${openClawVersion} -
-
- - ${t("common.health")} - ${state.connected ? t("common.ok") : t("common.offline")} -
- ${renderThemeToggle(state)} + ${renderTopbarThemeModeToggle(state)}
-
- ${ - params.toolsCatalogError - ? html` -
- Could not load runtime tool catalog. Showing fallback list. -
- ` - : nothing - } ${ !params.configForm ? html` @@ -188,6 +199,22 @@ export function renderAgentTools(params: { ` : nothing } + ${ + params.toolsCatalogLoading && !params.toolsCatalogResult && !params.toolsCatalogError + ? html` +
Loading runtime tool catalog…
+ ` + : nothing + } + ${ + params.toolsCatalogError + ? html` +
+ Could not load runtime tool catalog. Showing built-in fallback list instead. +
+ ` + : nothing + }
@@ -235,50 +262,27 @@ export function renderAgentTools(params: {
- ${sections.map( + ${toolSections.map( (section) => html`
${section.label} ${ - "source" in section && section.source === "plugin" - ? html` - plugin - ` + section.source === "plugin" && section.pluginId + ? html`plugin:${section.pluginId}` : nothing }
${section.tools.map((tool) => { const { allowed } = resolveAllowed(tool.id); - const catalogTool = tool as { - source?: "core" | "plugin"; - pluginId?: string; - optional?: boolean; - }; - const source = - catalogTool.source === "plugin" - ? catalogTool.pluginId - ? `plugin:${catalogTool.pluginId}` - : "plugin" - : "core"; - const isOptional = catalogTool.optional === true; return html`
-
- ${tool.label} - ${source} - ${ - isOptional - ? html` - optional - ` - : nothing - } -
+
${tool.label}
${tool.description}
+ ${renderToolBadges(section, tool)}
-
- - +
+
+ + + +
diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index 556b1c98247..45b39e5a77b 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -1,18 +1,157 @@ import { html } from "lit"; -import { - listCoreToolSections, - PROFILE_OPTIONS as TOOL_PROFILE_OPTIONS, -} from "../../../../src/agents/tool-catalog.js"; import { expandToolGroups, normalizeToolName, resolveToolProfilePolicy, } from "../../../../src/agents/tool-policy-shared.js"; -import type { AgentIdentityResult, AgentsFilesListResult, AgentsListResult } from "../types.ts"; +import type { + AgentIdentityResult, + AgentsFilesListResult, + AgentsListResult, + ToolCatalogProfile, + ToolsCatalogResult, +} from "../types.ts"; -export const TOOL_SECTIONS = listCoreToolSections(); +export type AgentToolEntry = { + id: string; + label: string; + description: string; + source?: "core" | "plugin"; + pluginId?: string; + optional?: boolean; + defaultProfiles?: string[]; +}; -export const PROFILE_OPTIONS = TOOL_PROFILE_OPTIONS; +export type AgentToolSection = { + id: string; + label: string; + source?: "core" | "plugin"; + pluginId?: string; + tools: AgentToolEntry[]; +}; + +export const FALLBACK_TOOL_SECTIONS: AgentToolSection[] = [ + { + id: "fs", + label: "Files", + tools: [ + { id: "read", label: "read", description: "Read file contents" }, + { id: "write", label: "write", description: "Create or overwrite files" }, + { id: "edit", label: "edit", description: "Make precise edits" }, + { id: "apply_patch", label: "apply_patch", description: "Patch files (OpenAI)" }, + ], + }, + { + id: "runtime", + label: "Runtime", + tools: [ + { id: "exec", label: "exec", description: "Run shell commands" }, + { id: "process", label: "process", description: "Manage background processes" }, + ], + }, + { + id: "web", + label: "Web", + tools: [ + { id: "web_search", label: "web_search", description: "Search the web" }, + { id: "web_fetch", label: "web_fetch", description: "Fetch web content" }, + ], + }, + { + id: "memory", + label: "Memory", + tools: [ + { id: "memory_search", label: "memory_search", description: "Semantic search" }, + { id: "memory_get", label: "memory_get", description: "Read memory files" }, + ], + }, + { + id: "sessions", + label: "Sessions", + tools: [ + { id: "sessions_list", label: "sessions_list", description: "List sessions" }, + { id: "sessions_history", label: "sessions_history", description: "Session history" }, + { id: "sessions_send", label: "sessions_send", description: "Send to session" }, + { id: "sessions_spawn", label: "sessions_spawn", description: "Spawn sub-agent" }, + { id: "session_status", label: "session_status", description: "Session status" }, + ], + }, + { + id: "ui", + label: "UI", + tools: [ + { id: "browser", label: "browser", description: "Control web browser" }, + { id: "canvas", label: "canvas", description: "Control canvases" }, + ], + }, + { + id: "messaging", + label: "Messaging", + tools: [{ id: "message", label: "message", description: "Send messages" }], + }, + { + id: "automation", + label: "Automation", + tools: [ + { id: "cron", label: "cron", description: "Schedule tasks" }, + { id: "gateway", label: "gateway", description: "Gateway control" }, + ], + }, + { + id: "nodes", + label: "Nodes", + tools: [{ id: "nodes", label: "nodes", description: "Nodes + devices" }], + }, + { + id: "agents", + label: "Agents", + tools: [{ id: "agents_list", label: "agents_list", description: "List agents" }], + }, + { + id: "media", + label: "Media", + tools: [{ id: "image", label: "image", description: "Image understanding" }], + }, +]; + +export const PROFILE_OPTIONS = [ + { id: "minimal", label: "Minimal" }, + { id: "coding", label: "Coding" }, + { id: "messaging", label: "Messaging" }, + { id: "full", label: "Full" }, +] as const; + +export function resolveToolSections( + toolsCatalogResult: ToolsCatalogResult | null, +): AgentToolSection[] { + if (toolsCatalogResult?.groups?.length) { + return toolsCatalogResult.groups.map((group) => ({ + id: group.id, + label: group.label, + source: group.source, + pluginId: group.pluginId, + tools: group.tools.map((tool) => ({ + id: tool.id, + label: tool.label, + description: tool.description, + source: tool.source, + pluginId: tool.pluginId, + optional: tool.optional, + defaultProfiles: [...tool.defaultProfiles], + })), + })); + } + return FALLBACK_TOOL_SECTIONS; +} + +export function resolveToolProfileOptions( + toolsCatalogResult: ToolsCatalogResult | null, +): readonly ToolCatalogProfile[] | typeof PROFILE_OPTIONS { + if (toolsCatalogResult?.profiles?.length) { + return toolsCatalogResult.profiles; + } + return PROFILE_OPTIONS; +} type ToolPolicy = { allow?: string[]; @@ -55,6 +194,30 @@ export function normalizeAgentLabel(agent: { return agent.name?.trim() || agent.identity?.name?.trim() || agent.id; } +const AVATAR_URL_RE = /^(https?:\/\/|data:image\/|\/)/i; + +export function resolveAgentAvatarUrl( + agent: { identity?: { avatar?: string; avatarUrl?: string } }, + agentIdentity?: AgentIdentityResult | null, +): string | null { + const url = + agentIdentity?.avatar?.trim() ?? + agent.identity?.avatarUrl?.trim() ?? + agent.identity?.avatar?.trim(); + if (!url) { + return null; + } + if (AVATAR_URL_RE.test(url)) { + return url; + } + return null; +} + +export function agentLogoUrl(basePath: string): string { + const base = basePath?.trim() ? basePath.replace(/\/$/, "") : ""; + return base ? `${base}/favicon.svg` : "/favicon.svg"; +} + function isLikelyEmoji(value: string) { const trimmed = value.trim(); if (!trimmed) { @@ -106,6 +269,14 @@ export function agentBadgeText(agentId: string, defaultId: string | null) { return defaultId && agentId === defaultId ? "default" : null; } +export function agentAvatarHue(id: string): number { + let hash = 0; + for (let i = 0; i < id.length; i += 1) { + hash = (hash * 31 + id.charCodeAt(i)) | 0; + } + return ((hash % 360) + 360) % 360; +} + export function formatBytes(bytes?: number) { if (bytes == null || !Number.isFinite(bytes)) { return "-"; @@ -138,7 +309,7 @@ export type AgentContext = { workspace: string; model: string; identityName: string; - identityEmoji: string; + identityAvatar: string; skillsLabel: string; isDefault: boolean; }; @@ -164,14 +335,14 @@ export function buildAgentContext( agent.name?.trim() || config.entry?.name || agent.id; - const identityEmoji = resolveAgentEmoji(agent, agentIdentity) || "-"; + const identityAvatar = resolveAgentAvatarUrl(agent, agentIdentity) ? "custom" : "—"; const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null; const skillCount = skillFilter?.length ?? null; return { workspace, model: modelLabel, identityName, - identityEmoji, + identityAvatar, skillsLabel: skillFilter ? `${skillCount} selected` : "all skills", isDefault: Boolean(defaultId && agent.id === defaultId), }; diff --git a/ui/src/ui/views/agents.ts b/ui/src/ui/views/agents.ts index 891190d9abb..63917b0f732 100644 --- a/ui/src/ui/views/agents.ts +++ b/ui/src/ui/views/agents.ts @@ -9,64 +9,78 @@ import type { SkillStatusReport, ToolsCatalogResult, } from "../types.ts"; +import { renderAgentOverview } from "./agents-panels-overview.ts"; import { renderAgentFiles, renderAgentChannels, renderAgentCron, } from "./agents-panels-status-files.ts"; import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts"; -import { - agentBadgeText, - buildAgentContext, - buildModelOptions, - normalizeAgentLabel, - normalizeModelValue, - parseFallbackList, - resolveAgentConfig, - resolveAgentEmoji, - resolveEffectiveModelFallbacks, - resolveModelLabel, - resolveModelPrimary, -} from "./agents-utils.ts"; +import { agentBadgeText, buildAgentContext, normalizeAgentLabel } from "./agents-utils.ts"; export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron"; +export type ConfigState = { + form: Record | null; + loading: boolean; + saving: boolean; + dirty: boolean; +}; + +export type ChannelsState = { + snapshot: ChannelsStatusSnapshot | null; + loading: boolean; + error: string | null; + lastSuccess: number | null; +}; + +export type CronState = { + status: CronStatus | null; + jobs: CronJob[]; + loading: boolean; + error: string | null; +}; + +export type AgentFilesState = { + list: AgentsFilesListResult | null; + loading: boolean; + error: string | null; + active: string | null; + contents: Record; + drafts: Record; + saving: boolean; +}; + +export type AgentSkillsState = { + report: SkillStatusReport | null; + loading: boolean; + error: string | null; + agentId: string | null; + filter: string; +}; + +export type ToolsCatalogState = { + loading: boolean; + error: string | null; + result: ToolsCatalogResult | null; +}; + export type AgentsProps = { + basePath: string; loading: boolean; error: string | null; agentsList: AgentsListResult | null; selectedAgentId: string | null; activePanel: AgentsPanel; - configForm: Record | null; - configLoading: boolean; - configSaving: boolean; - configDirty: boolean; - channelsLoading: boolean; - channelsError: string | null; - channelsSnapshot: ChannelsStatusSnapshot | null; - channelsLastSuccess: number | null; - cronLoading: boolean; - cronStatus: CronStatus | null; - cronJobs: CronJob[]; - cronError: string | null; - agentFilesLoading: boolean; - agentFilesError: string | null; - agentFilesList: AgentsFilesListResult | null; - agentFileActive: string | null; - agentFileContents: Record; - agentFileDrafts: Record; - agentFileSaving: boolean; + config: ConfigState; + channels: ChannelsState; + cron: CronState; + agentFiles: AgentFilesState; agentIdentityLoading: boolean; agentIdentityError: string | null; agentIdentityById: Record; - agentSkillsLoading: boolean; - agentSkillsReport: SkillStatusReport | null; - agentSkillsError: string | null; - agentSkillsAgentId: string | null; - toolsCatalogLoading: boolean; - toolsCatalogError: string | null; - toolsCatalogResult: ToolsCatalogResult | null; - skillsFilter: string; + agentSkills: AgentSkillsState; + toolsCatalog: ToolsCatalogState; onRefresh: () => void; onSelectAgent: (agentId: string) => void; onSelectPanel: (panel: AgentsPanel) => void; @@ -83,20 +97,13 @@ export type AgentsProps = { onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; onChannelsRefresh: () => void; onCronRefresh: () => void; + onCronRunNow: (jobId: string) => void; onSkillsFilterChange: (next: string) => void; onSkillsRefresh: () => void; onAgentSkillToggle: (agentId: string, skillName: string, enabled: boolean) => void; onAgentSkillsClear: (agentId: string) => void; onAgentSkillsDisableAll: (agentId: string) => void; -}; - -export type AgentContext = { - workspace: string; - model: string; - identityName: string; - identityEmoji: string; - skillsLabel: string; - isDefault: boolean; + onSetDefault: (agentId: string) => void; }; export function renderAgents(props: AgentsProps) { @@ -107,49 +114,96 @@ export function renderAgents(props: AgentsProps) { ? (agents.find((agent) => agent.id === selectedId) ?? null) : null; + const channelEntryCount = props.channels.snapshot + ? Object.keys(props.channels.snapshot.channelAccounts ?? {}).length + : null; + const cronJobCount = selectedId + ? props.cron.jobs.filter((j) => j.agentId === selectedId).length + : null; + const tabCounts: Record = { + files: props.agentFiles.list?.files?.length ?? null, + skills: props.agentSkills.report?.skills?.length ?? null, + channels: channelEntryCount, + cron: cronJobCount || null, + }; + return html`
-
-
-
-
Agents
-
${agents.length} configured.
+
+
+ Agent +
+
+ +
+
+ ${ + selectedAgent + ? html` +
+ + ${ + actionsMenuOpen + ? html` +
+ + +
+ ` + : nothing + } +
+ ` + : nothing + } + +
-
${ props.error - ? html`
${props.error}
` + ? html`
${props.error}
` : nothing } -
- ${ - agents.length === 0 - ? html` -
No agents found.
- ` - : agents.map((agent) => { - const badge = agentBadgeText(agent.id, defaultId); - const emoji = resolveAgentEmoji(agent, props.agentIdentityById[agent.id] ?? null); - return html` - - `; - }) - } -
${ @@ -161,29 +215,26 @@ export function renderAgents(props: AgentsProps) {
` : html` - ${renderAgentHeader( - selectedAgent, - defaultId, - props.agentIdentityById[selectedAgent.id] ?? null, - )} - ${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel))} + ${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel), tabCounts)} ${ props.activePanel === "overview" ? renderAgentOverview({ agent: selectedAgent, + basePath: props.basePath, defaultId, - configForm: props.configForm, - agentFilesList: props.agentFilesList, + configForm: props.config.form, + agentFilesList: props.agentFiles.list, agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null, agentIdentityError: props.agentIdentityError, agentIdentityLoading: props.agentIdentityLoading, - configLoading: props.configLoading, - configSaving: props.configSaving, - configDirty: props.configDirty, + configLoading: props.config.loading, + configSaving: props.config.saving, + configDirty: props.config.dirty, onConfigReload: props.onConfigReload, onConfigSave: props.onConfigSave, onModelChange: props.onModelChange, onModelFallbacksChange: props.onModelFallbacksChange, + onSelectPanel: props.onSelectPanel, }) : nothing } @@ -191,13 +242,13 @@ export function renderAgents(props: AgentsProps) { props.activePanel === "files" ? renderAgentFiles({ agentId: selectedAgent.id, - agentFilesList: props.agentFilesList, - agentFilesLoading: props.agentFilesLoading, - agentFilesError: props.agentFilesError, - agentFileActive: props.agentFileActive, - agentFileContents: props.agentFileContents, - agentFileDrafts: props.agentFileDrafts, - agentFileSaving: props.agentFileSaving, + agentFilesList: props.agentFiles.list, + agentFilesLoading: props.agentFiles.loading, + agentFilesError: props.agentFiles.error, + agentFileActive: props.agentFiles.active, + agentFileContents: props.agentFiles.contents, + agentFileDrafts: props.agentFiles.drafts, + agentFileSaving: props.agentFiles.saving, onLoadFiles: props.onLoadFiles, onSelectFile: props.onSelectFile, onFileDraftChange: props.onFileDraftChange, @@ -210,13 +261,13 @@ export function renderAgents(props: AgentsProps) { props.activePanel === "tools" ? renderAgentTools({ agentId: selectedAgent.id, - configForm: props.configForm, - configLoading: props.configLoading, - configSaving: props.configSaving, - configDirty: props.configDirty, - toolsCatalogLoading: props.toolsCatalogLoading, - toolsCatalogError: props.toolsCatalogError, - toolsCatalogResult: props.toolsCatalogResult, + configForm: props.config.form, + configLoading: props.config.loading, + configSaving: props.config.saving, + configDirty: props.config.dirty, + toolsCatalogLoading: props.toolsCatalog.loading, + toolsCatalogError: props.toolsCatalog.error, + toolsCatalogResult: props.toolsCatalog.result, onProfileChange: props.onToolsProfileChange, onOverridesChange: props.onToolsOverridesChange, onConfigReload: props.onConfigReload, @@ -228,15 +279,15 @@ export function renderAgents(props: AgentsProps) { props.activePanel === "skills" ? renderAgentSkills({ agentId: selectedAgent.id, - report: props.agentSkillsReport, - loading: props.agentSkillsLoading, - error: props.agentSkillsError, - activeAgentId: props.agentSkillsAgentId, - configForm: props.configForm, - configLoading: props.configLoading, - configSaving: props.configSaving, - configDirty: props.configDirty, - filter: props.skillsFilter, + report: props.agentSkills.report, + loading: props.agentSkills.loading, + error: props.agentSkills.error, + activeAgentId: props.agentSkills.agentId, + configForm: props.config.form, + configLoading: props.config.loading, + configSaving: props.config.saving, + configDirty: props.config.dirty, + filter: props.agentSkills.filter, onFilterChange: props.onSkillsFilterChange, onRefresh: props.onSkillsRefresh, onToggle: props.onAgentSkillToggle, @@ -252,16 +303,16 @@ export function renderAgents(props: AgentsProps) { ? renderAgentChannels({ context: buildAgentContext( selectedAgent, - props.configForm, - props.agentFilesList, + props.config.form, + props.agentFiles.list, defaultId, props.agentIdentityById[selectedAgent.id] ?? null, ), - configForm: props.configForm, - snapshot: props.channelsSnapshot, - loading: props.channelsLoading, - error: props.channelsError, - lastSuccess: props.channelsLastSuccess, + configForm: props.config.form, + snapshot: props.channels.snapshot, + loading: props.channels.loading, + error: props.channels.error, + lastSuccess: props.channels.lastSuccess, onRefresh: props.onChannelsRefresh, }) : nothing @@ -271,17 +322,18 @@ export function renderAgents(props: AgentsProps) { ? renderAgentCron({ context: buildAgentContext( selectedAgent, - props.configForm, - props.agentFilesList, + props.config.form, + props.agentFiles.list, defaultId, props.agentIdentityById[selectedAgent.id] ?? null, ), agentId: selectedAgent.id, - jobs: props.cronJobs, - status: props.cronStatus, - loading: props.cronLoading, - error: props.cronError, + jobs: props.cron.jobs, + status: props.cron.status, + loading: props.cron.loading, + error: props.cron.error, onRefresh: props.onCronRefresh, + onRunNow: props.onCronRunNow, }) : nothing } @@ -292,33 +344,13 @@ export function renderAgents(props: AgentsProps) { `; } -function renderAgentHeader( - agent: AgentsListResult["agents"][number], - defaultId: string | null, - agentIdentity: AgentIdentityResult | null, -) { - const badge = agentBadgeText(agent.id, defaultId); - const displayName = normalizeAgentLabel(agent); - const subtitle = agent.identity?.theme?.trim() || "Agent workspace and routing."; - const emoji = resolveAgentEmoji(agent, agentIdentity); - return html` -
-
-
${emoji || displayName.slice(0, 1)}
-
-
${displayName}
-
${subtitle}
-
-
-
-
${agent.id}
- ${badge ? html`${badge}` : nothing} -
-
- `; -} +let actionsMenuOpen = false; -function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) => void) { +function renderAgentTabs( + active: AgentsPanel, + onSelect: (panel: AgentsPanel) => void, + counts: Record, +) { const tabs: Array<{ id: AgentsPanel; label: string }> = [ { id: "overview", label: "Overview" }, { id: "files", label: "Files" }, @@ -336,164 +368,10 @@ function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) => type="button" @click=${() => onSelect(tab.id)} > - ${tab.label} + ${tab.label}${counts[tab.id] != null ? html`${counts[tab.id]}` : nothing} `, )}
`; } - -function renderAgentOverview(params: { - agent: AgentsListResult["agents"][number]; - defaultId: string | null; - configForm: Record | null; - agentFilesList: AgentsFilesListResult | null; - agentIdentity: AgentIdentityResult | null; - agentIdentityLoading: boolean; - agentIdentityError: string | null; - configLoading: boolean; - configSaving: boolean; - configDirty: boolean; - onConfigReload: () => void; - onConfigSave: () => void; - onModelChange: (agentId: string, modelId: string | null) => void; - onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; -}) { - const { - agent, - configForm, - agentFilesList, - agentIdentity, - agentIdentityLoading, - agentIdentityError, - configLoading, - configSaving, - configDirty, - onConfigReload, - onConfigSave, - onModelChange, - onModelFallbacksChange, - } = params; - const config = resolveAgentConfig(configForm, agent.id); - const workspaceFromFiles = - agentFilesList && agentFilesList.agentId === agent.id ? agentFilesList.workspace : null; - const workspace = - workspaceFromFiles || config.entry?.workspace || config.defaults?.workspace || "default"; - const model = config.entry?.model - ? resolveModelLabel(config.entry?.model) - : resolveModelLabel(config.defaults?.model); - const defaultModel = resolveModelLabel(config.defaults?.model); - const modelPrimary = - resolveModelPrimary(config.entry?.model) || (model !== "-" ? normalizeModelValue(model) : null); - const defaultPrimary = - resolveModelPrimary(config.defaults?.model) || - (defaultModel !== "-" ? normalizeModelValue(defaultModel) : null); - const effectivePrimary = modelPrimary ?? defaultPrimary ?? null; - const modelFallbacks = resolveEffectiveModelFallbacks( - config.entry?.model, - config.defaults?.model, - ); - const fallbackText = modelFallbacks ? modelFallbacks.join(", ") : ""; - const identityName = - agentIdentity?.name?.trim() || - agent.identity?.name?.trim() || - agent.name?.trim() || - config.entry?.name || - "-"; - const resolvedEmoji = resolveAgentEmoji(agent, agentIdentity); - const identityEmoji = resolvedEmoji || "-"; - const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null; - const skillCount = skillFilter?.length ?? null; - const identityStatus = agentIdentityLoading - ? "Loading…" - : agentIdentityError - ? "Unavailable" - : ""; - const isDefault = Boolean(params.defaultId && agent.id === params.defaultId); - - return html` -
-
Overview
-
Workspace paths and identity metadata.
-
-
-
Workspace
-
${workspace}
-
-
-
Primary Model
-
${model}
-
-
-
Identity Name
-
${identityName}
- ${identityStatus ? html`
${identityStatus}
` : nothing} -
-
-
Default
-
${isDefault ? "yes" : "no"}
-
-
-
Identity Emoji
-
${identityEmoji}
-
-
-
Skills Filter
-
${skillFilter ? `${skillCount} selected` : "all skills"}
-
-
- -
-
Model Selection
-
- - -
-
- - -
-
-
- `; -} diff --git a/ui/src/ui/views/bottom-tabs.ts b/ui/src/ui/views/bottom-tabs.ts new file mode 100644 index 00000000000..b8dfbebf39c --- /dev/null +++ b/ui/src/ui/views/bottom-tabs.ts @@ -0,0 +1,33 @@ +import { html } from "lit"; +import { icons } from "../icons.ts"; +import type { Tab } from "../navigation.ts"; + +export type BottomTabsProps = { + activeTab: Tab; + onTabChange: (tab: Tab) => void; +}; + +const BOTTOM_TABS: Array<{ id: Tab; label: string; icon: keyof typeof icons }> = [ + { id: "overview", label: "Dashboard", icon: "barChart" }, + { id: "chat", label: "Chat", icon: "messageSquare" }, + { id: "sessions", label: "Sessions", icon: "fileText" }, + { id: "config", label: "Settings", icon: "settings" }, +]; + +export function renderBottomTabs(props: BottomTabsProps) { + return html` + + `; +} diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 516042c27f1..db0b924322d 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -1,17 +1,37 @@ -import { html, nothing } from "lit"; +import { html, nothing, type TemplateResult } from "lit"; import { ref } from "lit/directives/ref.js"; import { repeat } from "lit/directives/repeat.js"; +import { + CHAT_ATTACHMENT_ACCEPT, + isSupportedChatAttachmentMimeType, +} from "../chat/attachment-support.ts"; +import { DeletedMessages } from "../chat/deleted-messages.ts"; +import { exportChatMarkdown } from "../chat/export.ts"; import { renderMessageGroup, renderReadingIndicatorGroup, renderStreamingGroup, } from "../chat/grouped-render.ts"; +import { InputHistory } from "../chat/input-history.ts"; import { normalizeMessage, normalizeRoleForGrouping } from "../chat/message-normalizer.ts"; +import { PinnedMessages } from "../chat/pinned-messages.ts"; +import { getPinnedMessageSummary } from "../chat/pinned-summary.ts"; +import { messageMatchesSearchQuery } from "../chat/search-match.ts"; +import { getOrCreateSessionCacheValue } from "../chat/session-cache.ts"; +import { + CATEGORY_LABELS, + SLASH_COMMANDS, + getSlashCommandCompletions, + type SlashCommandCategory, + type SlashCommandDef, +} from "../chat/slash-commands.ts"; +import { isSttSupported, startStt, stopStt } from "../chat/speech.ts"; import { icons } from "../icons.ts"; import { detectTextDirection } from "../text-direction.ts"; -import type { SessionsListResult } from "../types.ts"; +import type { GatewaySessionRow, SessionsListResult } from "../types.ts"; import type { ChatItem, MessageGroup } from "../types/chat-types.ts"; import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts"; +import { agentLogoUrl } from "./agents-utils.ts"; import { renderMarkdownSidebar } from "./markdown-sidebar.ts"; import "../components/resizable-divider.ts"; @@ -54,49 +74,124 @@ export type ChatProps = { disabledReason: string | null; error: string | null; sessions: SessionsListResult | null; - // Focus mode focusMode: boolean; - // Sidebar state sidebarOpen?: boolean; sidebarContent?: string | null; sidebarError?: string | null; splitRatio?: number; assistantName: string; assistantAvatar: string | null; - // Image attachments attachments?: ChatAttachment[]; onAttachmentsChange?: (attachments: ChatAttachment[]) => void; - // Scroll control showNewMessages?: boolean; onScrollToBottom?: () => void; - // Event handlers onRefresh: () => void; onToggleFocusMode: () => void; + getDraft?: () => string; onDraftChange: (next: string) => void; + onRequestUpdate?: () => void; onSend: () => void; onAbort?: () => void; onQueueRemove: (id: string) => void; onNewSession: () => void; + onClearHistory?: () => void; + agentsList: { + agents: Array<{ id: string; name?: string; identity?: { name?: string; avatarUrl?: string } }>; + defaultId?: string; + } | null; + currentAgentId: string; + onAgentChange: (agentId: string) => void; + onNavigateToAgent?: () => void; + onSessionSelect?: (sessionKey: string) => void; onOpenSidebar?: (content: string) => void; onCloseSidebar?: () => void; onSplitRatioChange?: (ratio: number) => void; onChatScroll?: (event: Event) => void; + basePath?: string; }; const COMPACTION_TOAST_DURATION_MS = 5000; const FALLBACK_TOAST_DURATION_MS = 8000; +// Persistent instances keyed by session +const inputHistories = new Map(); +const pinnedMessagesMap = new Map(); +const deletedMessagesMap = new Map(); + +function getInputHistory(sessionKey: string): InputHistory { + return getOrCreateSessionCacheValue(inputHistories, sessionKey, () => new InputHistory()); +} + +function getPinnedMessages(sessionKey: string): PinnedMessages { + return getOrCreateSessionCacheValue( + pinnedMessagesMap, + sessionKey, + () => new PinnedMessages(sessionKey), + ); +} + +function getDeletedMessages(sessionKey: string): DeletedMessages { + return getOrCreateSessionCacheValue( + deletedMessagesMap, + sessionKey, + () => new DeletedMessages(sessionKey), + ); +} + +interface ChatEphemeralState { + sttRecording: boolean; + sttInterimText: string; + slashMenuOpen: boolean; + slashMenuItems: SlashCommandDef[]; + slashMenuIndex: number; + slashMenuMode: "command" | "args"; + slashMenuCommand: SlashCommandDef | null; + slashMenuArgItems: string[]; + searchOpen: boolean; + searchQuery: string; + pinnedExpanded: boolean; +} + +function createChatEphemeralState(): ChatEphemeralState { + return { + sttRecording: false, + sttInterimText: "", + slashMenuOpen: false, + slashMenuItems: [], + slashMenuIndex: 0, + slashMenuMode: "command", + slashMenuCommand: null, + slashMenuArgItems: [], + searchOpen: false, + searchQuery: "", + pinnedExpanded: false, + }; +} + +const vs = createChatEphemeralState(); + +/** + * Reset chat view ephemeral state when navigating away. + * Stops STT recording and clears search/slash UI that should not survive navigation. + */ +export function resetChatViewState() { + if (vs.sttRecording) { + stopStt(); + } + Object.assign(vs, createChatEphemeralState()); +} + +export const cleanupChatModuleState = resetChatViewState; + function adjustTextareaHeight(el: HTMLTextAreaElement) { el.style.height = "auto"; - el.style.height = `${el.scrollHeight}px`; + el.style.height = `${Math.min(el.scrollHeight, 150)}px`; } function renderCompactionIndicator(status: CompactionIndicatorStatus | null | undefined) { if (!status) { return nothing; } - - // Show "compacting..." while active if (status.active) { return html`
@@ -104,8 +199,6 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
`; } - - // Show "compaction complete" briefly after completion if (status.completedAt) { const elapsed = Date.now() - status.completedAt; if (elapsed < COMPACTION_TOAST_DURATION_MS) { @@ -116,7 +209,6 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un `; } } - return nothing; } @@ -148,17 +240,59 @@ function renderFallbackIndicator(status: FallbackIndicatorStatus | null | undefi : "compaction-indicator compaction-indicator--fallback"; const icon = phase === "cleared" ? icons.check : icons.brain; return html` -
+
${icon} ${message}
`; } +/** + * Compact notice when context usage reaches 85%+. + * Progressively shifts from amber (85%) to red (90%+). + */ +function renderContextNotice( + session: GatewaySessionRow | undefined, + defaultContextTokens: number | null, +) { + const used = session?.inputTokens ?? 0; + const limit = session?.contextTokens ?? defaultContextTokens ?? 0; + if (!used || !limit) { + return nothing; + } + const ratio = used / limit; + if (ratio < 0.85) { + return nothing; + } + const pct = Math.min(Math.round(ratio * 100), 100); + // Lerp from amber (#d97706) at 85% to red (#dc2626) at 95%+ + const t = Math.min(Math.max((ratio - 0.85) / 0.1, 0), 1); + // RGB: amber(217,119,6) → red(220,38,38) + const r = Math.round(217 + (220 - 217) * t); + const g = Math.round(119 + (38 - 119) * t); + const b = Math.round(6 + (38 - 6) * t); + const color = `rgb(${r}, ${g}, ${b})`; + const bgOpacity = 0.08 + 0.08 * t; + const bg = `rgba(${r}, ${g}, ${b}, ${bgOpacity})`; + return html` +
+ + ${pct}% context used + ${formatTokensCompact(used)} / ${formatTokensCompact(limit)} +
+ `; +} + +/** Format token count compactly (e.g. 128000 → "128k"). */ +function formatTokensCompact(n: number): string { + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; + } + if (n >= 1_000) { + return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; + } + return String(n); +} + function generateAttachmentId(): string { return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; } @@ -168,7 +302,6 @@ function handlePaste(e: ClipboardEvent, props: ChatProps) { if (!items || !props.onAttachmentsChange) { return; } - const imageItems: DataTransferItem[] = []; for (let i = 0; i < items.length; i++) { const item = items[i]; @@ -176,19 +309,15 @@ function handlePaste(e: ClipboardEvent, props: ChatProps) { imageItems.push(item); } } - if (imageItems.length === 0) { return; } - e.preventDefault(); - for (const item of imageItems) { const file = item.getAsFile(); if (!file) { continue; } - const reader = new FileReader(); reader.addEventListener("load", () => { const dataUrl = reader.result as string; @@ -204,33 +333,86 @@ function handlePaste(e: ClipboardEvent, props: ChatProps) { } } -function renderAttachmentPreview(props: ChatProps) { +function handleFileSelect(e: Event, props: ChatProps) { + const input = e.target as HTMLInputElement; + if (!input.files || !props.onAttachmentsChange) { + return; + } + const current = props.attachments ?? []; + const additions: ChatAttachment[] = []; + let pending = 0; + for (const file of input.files) { + if (!isSupportedChatAttachmentMimeType(file.type)) { + continue; + } + pending++; + const reader = new FileReader(); + reader.addEventListener("load", () => { + additions.push({ + id: generateAttachmentId(), + dataUrl: reader.result as string, + mimeType: file.type, + }); + pending--; + if (pending === 0) { + props.onAttachmentsChange?.([...current, ...additions]); + } + }); + reader.readAsDataURL(file); + } + input.value = ""; +} + +function handleDrop(e: DragEvent, props: ChatProps) { + e.preventDefault(); + const files = e.dataTransfer?.files; + if (!files || !props.onAttachmentsChange) { + return; + } + const current = props.attachments ?? []; + const additions: ChatAttachment[] = []; + let pending = 0; + for (const file of files) { + if (!isSupportedChatAttachmentMimeType(file.type)) { + continue; + } + pending++; + const reader = new FileReader(); + reader.addEventListener("load", () => { + additions.push({ + id: generateAttachmentId(), + dataUrl: reader.result as string, + mimeType: file.type, + }); + pending--; + if (pending === 0) { + props.onAttachmentsChange?.([...current, ...additions]); + } + }); + reader.readAsDataURL(file); + } +} + +function renderAttachmentPreview(props: ChatProps): TemplateResult | typeof nothing { const attachments = props.attachments ?? []; if (attachments.length === 0) { return nothing; } - return html` -
+
${attachments.map( (att) => html` -
- Attachment preview +
+ Attachment preview + >×
`, )} @@ -238,6 +420,379 @@ function renderAttachmentPreview(props: ChatProps) { `; } +function resetSlashMenuState(): void { + vs.slashMenuMode = "command"; + vs.slashMenuCommand = null; + vs.slashMenuArgItems = []; + vs.slashMenuItems = []; +} + +function updateSlashMenu(value: string, requestUpdate: () => void): void { + // Arg mode: /command + const argMatch = value.match(/^\/(\S+)\s(.*)$/); + if (argMatch) { + const cmdName = argMatch[1].toLowerCase(); + const argFilter = argMatch[2].toLowerCase(); + const cmd = SLASH_COMMANDS.find((c) => c.name === cmdName); + if (cmd?.argOptions?.length) { + const filtered = argFilter + ? cmd.argOptions.filter((opt) => opt.toLowerCase().startsWith(argFilter)) + : cmd.argOptions; + if (filtered.length > 0) { + vs.slashMenuMode = "args"; + vs.slashMenuCommand = cmd; + vs.slashMenuArgItems = filtered; + vs.slashMenuOpen = true; + vs.slashMenuIndex = 0; + vs.slashMenuItems = []; + requestUpdate(); + return; + } + } + vs.slashMenuOpen = false; + resetSlashMenuState(); + requestUpdate(); + return; + } + + // Command mode: /partial-command + const match = value.match(/^\/(\S*)$/); + if (match) { + const items = getSlashCommandCompletions(match[1]); + vs.slashMenuItems = items; + vs.slashMenuOpen = items.length > 0; + vs.slashMenuIndex = 0; + vs.slashMenuMode = "command"; + vs.slashMenuCommand = null; + vs.slashMenuArgItems = []; + } else { + vs.slashMenuOpen = false; + resetSlashMenuState(); + } + requestUpdate(); +} + +function selectSlashCommand( + cmd: SlashCommandDef, + props: ChatProps, + requestUpdate: () => void, +): void { + // Transition to arg picker when the command has fixed options + if (cmd.argOptions?.length) { + props.onDraftChange(`/${cmd.name} `); + vs.slashMenuMode = "args"; + vs.slashMenuCommand = cmd; + vs.slashMenuArgItems = cmd.argOptions; + vs.slashMenuOpen = true; + vs.slashMenuIndex = 0; + vs.slashMenuItems = []; + requestUpdate(); + return; + } + + vs.slashMenuOpen = false; + resetSlashMenuState(); + + if (cmd.executeLocal && !cmd.args) { + props.onDraftChange(`/${cmd.name}`); + requestUpdate(); + props.onSend(); + } else { + props.onDraftChange(`/${cmd.name} `); + requestUpdate(); + } +} + +function tabCompleteSlashCommand( + cmd: SlashCommandDef, + props: ChatProps, + requestUpdate: () => void, +): void { + // Tab: fill in the command text without executing + if (cmd.argOptions?.length) { + props.onDraftChange(`/${cmd.name} `); + vs.slashMenuMode = "args"; + vs.slashMenuCommand = cmd; + vs.slashMenuArgItems = cmd.argOptions; + vs.slashMenuOpen = true; + vs.slashMenuIndex = 0; + vs.slashMenuItems = []; + requestUpdate(); + return; + } + + vs.slashMenuOpen = false; + resetSlashMenuState(); + props.onDraftChange(cmd.args ? `/${cmd.name} ` : `/${cmd.name}`); + requestUpdate(); +} + +function selectSlashArg( + arg: string, + props: ChatProps, + requestUpdate: () => void, + execute: boolean, +): void { + const cmdName = vs.slashMenuCommand?.name ?? ""; + vs.slashMenuOpen = false; + resetSlashMenuState(); + props.onDraftChange(`/${cmdName} ${arg}`); + requestUpdate(); + if (execute) { + props.onSend(); + } +} + +function tokenEstimate(draft: string): string | null { + if (draft.length < 100) { + return null; + } + return `~${Math.ceil(draft.length / 4)} tokens`; +} + +/** + * Export chat markdown - delegates to shared utility. + */ +function exportMarkdown(props: ChatProps): void { + exportChatMarkdown(props.messages, props.assistantName); +} + +const WELCOME_SUGGESTIONS = [ + "What can you do?", + "Summarize my recent sessions", + "Help me configure a channel", + "Check system health", +]; + +function renderWelcomeState(props: ChatProps): TemplateResult { + const name = props.assistantName || "Assistant"; + const avatar = props.assistantAvatar ?? props.assistantAvatarUrl; + const logoUrl = agentLogoUrl(props.basePath ?? ""); + + return html` +
+
+ ${ + avatar + ? html`${name}` + : html`` + } +

${name}

+
+ Ready to chat +
+

+ Type a message below · / for commands +

+
+ ${WELCOME_SUGGESTIONS.map( + (text) => html` + + `, + )} +
+
+ `; +} + +function renderSearchBar(requestUpdate: () => void): TemplateResult | typeof nothing { + if (!vs.searchOpen) { + return nothing; + } + return html` + + `; +} + +function renderPinnedSection( + props: ChatProps, + pinned: PinnedMessages, + requestUpdate: () => void, +): TemplateResult | typeof nothing { + const messages = Array.isArray(props.messages) ? props.messages : []; + const entries: Array<{ index: number; text: string; role: string }> = []; + for (const idx of pinned.indices) { + const msg = messages[idx] as Record | undefined; + if (!msg) { + continue; + } + const text = getPinnedMessageSummary(msg); + const role = typeof msg.role === "string" ? msg.role : "unknown"; + entries.push({ index: idx, text, role }); + } + if (entries.length === 0) { + return nothing; + } + return html` +
+ + ${ + vs.pinnedExpanded + ? html` +
+ ${entries.map( + ({ index, text, role }) => html` +
+ ${role === "user" ? "You" : "Assistant"} + ${text.slice(0, 100)}${text.length > 100 ? "..." : ""} + +
+ `, + )} +
+ ` + : nothing + } +
+ `; +} + +function renderSlashMenu( + requestUpdate: () => void, + props: ChatProps, +): TemplateResult | typeof nothing { + if (!vs.slashMenuOpen) { + return nothing; + } + + // Arg-picker mode: show options for the selected command + if (vs.slashMenuMode === "args" && vs.slashMenuCommand && vs.slashMenuArgItems.length > 0) { + return html` +
+
+
/${vs.slashMenuCommand.name} ${vs.slashMenuCommand.description}
+ ${vs.slashMenuArgItems.map( + (arg, i) => html` +
selectSlashArg(arg, props, requestUpdate, true)} + @mouseenter=${() => { + vs.slashMenuIndex = i; + requestUpdate(); + }} + > + ${vs.slashMenuCommand?.icon ? html`${icons[vs.slashMenuCommand.icon]}` : nothing} + ${arg} + /${vs.slashMenuCommand?.name} ${arg} +
+ `, + )} +
+ +
+ `; + } + + // Command mode: show grouped commands + if (vs.slashMenuItems.length === 0) { + return nothing; + } + + const grouped = new Map< + SlashCommandCategory, + Array<{ cmd: SlashCommandDef; globalIdx: number }> + >(); + for (let i = 0; i < vs.slashMenuItems.length; i++) { + const cmd = vs.slashMenuItems[i]; + const cat = cmd.category ?? "session"; + let list = grouped.get(cat); + if (!list) { + list = []; + grouped.set(cat, list); + } + list.push({ cmd, globalIdx: i }); + } + + const sections: TemplateResult[] = []; + for (const [cat, entries] of grouped) { + sections.push(html` +
+
${CATEGORY_LABELS[cat]}
+ ${entries.map( + ({ cmd, globalIdx }) => html` +
selectSlashCommand(cmd, props, requestUpdate)} + @mouseenter=${() => { + vs.slashMenuIndex = globalIdx; + requestUpdate(); + }} + > + ${cmd.icon ? html`${icons[cmd.icon]}` : nothing} + /${cmd.name} + ${cmd.args ? html`${cmd.args}` : nothing} + ${cmd.description} + ${ + cmd.argOptions?.length + ? html`${cmd.argOptions.length} options` + : cmd.executeLocal && !cmd.args + ? html` + instant + ` + : nothing + } +
+ `, + )} +
+ `); + } + + return html` +
+ ${sections} + +
+ `; +} + export function renderChat(props: ChatProps) { const canCompose = props.connected; const isBusy = props.sending || props.stream !== null; @@ -249,32 +804,93 @@ export function renderChat(props: ChatProps) { name: props.assistantName, avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null, }; - + const pinned = getPinnedMessages(props.sessionKey); + const deleted = getDeletedMessages(props.sessionKey); + const inputHistory = getInputHistory(props.sessionKey); const hasAttachments = (props.attachments?.length ?? 0) > 0; - const composePlaceholder = props.connected + const tokens = tokenEstimate(props.draft); + + const placeholder = props.connected ? hasAttachments ? "Add a message or paste more images..." - : "Message (↩ to send, Shift+↩ for line breaks, paste images)" - : "Connect to the gateway to start chatting…"; + : `Message ${props.assistantName || "agent"} (Enter to send)` + : "Connect to the gateway to start chatting..."; + + const requestUpdate = props.onRequestUpdate ?? (() => {}); + const getDraft = props.getDraft ?? (() => props.draft); const splitRatio = props.splitRatio ?? 0.6; const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar); + + const handleCodeBlockCopy = (e: Event) => { + const btn = (e.target as HTMLElement).closest(".code-block-copy"); + if (!btn) { + return; + } + const code = (btn as HTMLElement).dataset.code ?? ""; + navigator.clipboard.writeText(code).then( + () => { + btn.classList.add("copied"); + setTimeout(() => btn.classList.remove("copied"), 1500); + }, + () => {}, + ); + }; + + const chatItems = buildChatItems(props); + const isEmpty = chatItems.length === 0 && !props.loading; + const thread = html`
+
${ props.loading ? html` -
Loading chat…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ` + : nothing + } + ${isEmpty && !vs.searchOpen ? renderWelcomeState(props) : nothing} + ${ + isEmpty && vs.searchOpen + ? html` +
No matching messages
` : nothing } ${repeat( - buildChatItems(props), + chatItems, (item) => item.key, (item) => { if (item.kind === "divider") { @@ -286,39 +902,168 @@ export function renderChat(props: ChatProps) {
`; } - if (item.kind === "reading-indicator") { - return renderReadingIndicatorGroup(assistantIdentity); + return renderReadingIndicatorGroup(assistantIdentity, props.basePath); } - if (item.kind === "stream") { return renderStreamingGroup( item.text, item.startedAt, props.onOpenSidebar, assistantIdentity, + props.basePath, ); } - if (item.kind === "group") { + if (deleted.has(item.key)) { + return nothing; + } return renderMessageGroup(item, { onOpenSidebar: props.onOpenSidebar, showReasoning, assistantName: props.assistantName, assistantAvatar: assistantIdentity.avatar, + basePath: props.basePath, + contextWindow: + activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null, + onDelete: () => { + deleted.delete(item.key); + requestUpdate(); + }, }); } - return nothing; }, )} +
`; - return html` -
- ${props.disabledReason ? html`
${props.disabledReason}
` : nothing} + const handleKeyDown = (e: KeyboardEvent) => { + // Slash menu navigation — arg mode + if (vs.slashMenuOpen && vs.slashMenuMode === "args" && vs.slashMenuArgItems.length > 0) { + const len = vs.slashMenuArgItems.length; + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + vs.slashMenuIndex = (vs.slashMenuIndex + 1) % len; + requestUpdate(); + return; + case "ArrowUp": + e.preventDefault(); + vs.slashMenuIndex = (vs.slashMenuIndex - 1 + len) % len; + requestUpdate(); + return; + case "Tab": + e.preventDefault(); + selectSlashArg(vs.slashMenuArgItems[vs.slashMenuIndex], props, requestUpdate, false); + return; + case "Enter": + e.preventDefault(); + selectSlashArg(vs.slashMenuArgItems[vs.slashMenuIndex], props, requestUpdate, true); + return; + case "Escape": + e.preventDefault(); + vs.slashMenuOpen = false; + resetSlashMenuState(); + requestUpdate(); + return; + } + } + // Slash menu navigation — command mode + if (vs.slashMenuOpen && vs.slashMenuItems.length > 0) { + const len = vs.slashMenuItems.length; + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + vs.slashMenuIndex = (vs.slashMenuIndex + 1) % len; + requestUpdate(); + return; + case "ArrowUp": + e.preventDefault(); + vs.slashMenuIndex = (vs.slashMenuIndex - 1 + len) % len; + requestUpdate(); + return; + case "Tab": + e.preventDefault(); + tabCompleteSlashCommand(vs.slashMenuItems[vs.slashMenuIndex], props, requestUpdate); + return; + case "Enter": + e.preventDefault(); + selectSlashCommand(vs.slashMenuItems[vs.slashMenuIndex], props, requestUpdate); + return; + case "Escape": + e.preventDefault(); + vs.slashMenuOpen = false; + resetSlashMenuState(); + requestUpdate(); + return; + } + } + + // Input history (only when input is empty) + if (!props.draft.trim()) { + if (e.key === "ArrowUp") { + const prev = inputHistory.up(); + if (prev !== null) { + e.preventDefault(); + props.onDraftChange(prev); + } + return; + } + if (e.key === "ArrowDown") { + const next = inputHistory.down(); + e.preventDefault(); + props.onDraftChange(next ?? ""); + return; + } + } + + // Cmd+F for search + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === "f") { + e.preventDefault(); + vs.searchOpen = !vs.searchOpen; + if (!vs.searchOpen) { + vs.searchQuery = ""; + } + requestUpdate(); + return; + } + + // Send on Enter (without shift) + if (e.key === "Enter" && !e.shiftKey) { + if (e.isComposing || e.keyCode === 229) { + return; + } + if (!props.connected) { + return; + } + e.preventDefault(); + if (canCompose) { + if (props.draft.trim()) { + inputHistory.push(props.draft); + } + props.onSend(); + } + } + }; + + const handleInput = (e: Event) => { + const target = e.target as HTMLTextAreaElement; + adjustTextareaHeight(target); + updateSlashMenu(target.value, requestUpdate); + inputHistory.reset(); + props.onDraftChange(target.value); + }; + + return html` +
handleDrop(e, props)} + @dragover=${(e: DragEvent) => e.preventDefault()} + > + ${props.disabledReason ? html`
${props.disabledReason}
` : nothing} ${props.error ? html`
${props.error}
` : nothing} ${ @@ -337,9 +1082,10 @@ export function renderChat(props: ChatProps) { : nothing } -
+ ${renderSearchBar(requestUpdate)} + ${renderPinnedSection(props, pinned, requestUpdate)} + +
- New messages ${icons.arrowDown} + ${icons.arrowDown} New messages ` : nothing } -
+ +
+ ${renderSlashMenu(requestUpdate, props)} ${renderAttachmentPreview(props)} -
- -
+ + handleFileSelect(e, props)} + /> + + ${vs.sttRecording && vs.sttInterimText ? html`
${vs.sttInterimText}
` : nothing} + + + +
+
- + + ${ + isSttSupported() + ? html` + + ` + : nothing + } + + ${tokens ? html`${tokens}` : nothing} +
+ +
+ ${nothing /* search hidden for now */} + ${ + canAbort + ? nothing + : html` + + ` + } + + + ${ + canAbort && (isBusy || props.sending) + ? html` + + ` + : html` + + ` + }
@@ -567,6 +1402,11 @@ function buildChatItems(props: ChatProps): Array { continue; } + // Apply search filter if active + if (vs.searchOpen && vs.searchQuery.trim() && !messageMatchesSearchQuery(msg, vs.searchQuery)) { + continue; + } + items.push({ kind: "message", key: messageKey(msg, i), diff --git a/ui/src/ui/views/command-palette.ts b/ui/src/ui/views/command-palette.ts new file mode 100644 index 00000000000..ec79f022873 --- /dev/null +++ b/ui/src/ui/views/command-palette.ts @@ -0,0 +1,263 @@ +import { html, nothing } from "lit"; +import { ref } from "lit/directives/ref.js"; +import { t } from "../../i18n/index.ts"; +import { SLASH_COMMANDS } from "../chat/slash-commands.ts"; +import { icons, type IconName } from "../icons.ts"; + +type PaletteItem = { + id: string; + label: string; + icon: IconName; + category: "search" | "navigation" | "skills"; + action: string; + description?: string; +}; + +const SLASH_PALETTE_ITEMS: PaletteItem[] = SLASH_COMMANDS.map((command) => ({ + id: `slash:${command.name}`, + label: `/${command.name}`, + icon: command.icon ?? "terminal", + category: "search", + action: `/${command.name}`, + description: command.description, +})); + +const PALETTE_ITEMS: PaletteItem[] = [ + ...SLASH_PALETTE_ITEMS, + { + id: "nav-overview", + label: "Overview", + icon: "barChart", + category: "navigation", + action: "nav:overview", + }, + { + id: "nav-sessions", + label: "Sessions", + icon: "fileText", + category: "navigation", + action: "nav:sessions", + }, + { + id: "nav-cron", + label: "Scheduled", + icon: "scrollText", + category: "navigation", + action: "nav:cron", + }, + { id: "nav-skills", label: "Skills", icon: "zap", category: "navigation", action: "nav:skills" }, + { + id: "nav-config", + label: "Settings", + icon: "settings", + category: "navigation", + action: "nav:config", + }, + { + id: "nav-agents", + label: "Agents", + icon: "folder", + category: "navigation", + action: "nav:agents", + }, + { + id: "skill-shell", + label: "Shell Command", + icon: "monitor", + category: "skills", + action: "/skill shell", + description: "Run shell", + }, + { + id: "skill-debug", + label: "Debug Mode", + icon: "bug", + category: "skills", + action: "/verbose full", + description: "Toggle debug", + }, +]; + +export function getPaletteItems(): readonly PaletteItem[] { + return PALETTE_ITEMS; +} + +export type CommandPaletteProps = { + open: boolean; + query: string; + activeIndex: number; + onToggle: () => void; + onQueryChange: (query: string) => void; + onActiveIndexChange: (index: number) => void; + onNavigate: (tab: string) => void; + onSlashCommand: (command: string) => void; +}; + +function filteredItems(query: string): PaletteItem[] { + if (!query) { + return PALETTE_ITEMS; + } + const q = query.toLowerCase(); + return PALETTE_ITEMS.filter( + (item) => + item.label.toLowerCase().includes(q) || + (item.description?.toLowerCase().includes(q) ?? false), + ); +} + +function groupItems(items: PaletteItem[]): Array<[string, PaletteItem[]]> { + const map = new Map(); + for (const item of items) { + const group = map.get(item.category) ?? []; + group.push(item); + map.set(item.category, group); + } + return [...map.entries()]; +} + +let previouslyFocused: Element | null = null; + +function saveFocus() { + previouslyFocused = document.activeElement; +} + +function restoreFocus() { + if (previouslyFocused && previouslyFocused instanceof HTMLElement) { + requestAnimationFrame(() => previouslyFocused && (previouslyFocused as HTMLElement).focus()); + } + previouslyFocused = null; +} + +function selectItem(item: PaletteItem, props: CommandPaletteProps) { + if (item.action.startsWith("nav:")) { + props.onNavigate(item.action.slice(4)); + } else { + props.onSlashCommand(item.action); + } + props.onToggle(); + restoreFocus(); +} + +function scrollActiveIntoView() { + requestAnimationFrame(() => { + const el = document.querySelector(".cmd-palette__item--active"); + el?.scrollIntoView({ block: "nearest" }); + }); +} + +function handleKeydown(e: KeyboardEvent, props: CommandPaletteProps) { + const items = filteredItems(props.query); + if (items.length === 0 && (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter")) { + return; + } + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + props.onActiveIndexChange((props.activeIndex + 1) % items.length); + scrollActiveIntoView(); + break; + case "ArrowUp": + e.preventDefault(); + props.onActiveIndexChange((props.activeIndex - 1 + items.length) % items.length); + scrollActiveIntoView(); + break; + case "Enter": + e.preventDefault(); + if (items[props.activeIndex]) { + selectItem(items[props.activeIndex], props); + } + break; + case "Escape": + e.preventDefault(); + props.onToggle(); + restoreFocus(); + break; + } +} + +const CATEGORY_LABELS: Record = { + search: "Search", + navigation: "Navigation", + skills: "Skills", +}; + +function focusInput(el: Element | undefined) { + if (el) { + saveFocus(); + requestAnimationFrame(() => (el as HTMLInputElement).focus()); + } +} + +export function renderCommandPalette(props: CommandPaletteProps) { + if (!props.open) { + return nothing; + } + + const items = filteredItems(props.query); + const grouped = groupItems(items); + + return html` +
{ + props.onToggle(); + restoreFocus(); + }}> +
e.stopPropagation()} + @keydown=${(e: KeyboardEvent) => handleKeydown(e, props)} + > + { + props.onQueryChange((e.target as HTMLInputElement).value); + props.onActiveIndexChange(0); + }} + /> +
+ ${ + grouped.length === 0 + ? html`
+ ${icons.search} + ${t("overview.palette.noResults")} +
` + : grouped.map( + ([category, groupedItems]) => html` +
${CATEGORY_LABELS[category] ?? category}
+ ${groupedItems.map((item) => { + const globalIndex = items.indexOf(item); + const isActive = globalIndex === props.activeIndex; + return html` +
{ + e.stopPropagation(); + selectItem(item, props); + }} + @mouseenter=${() => props.onActiveIndexChange(globalIndex)} + > + ${icons[item.icon]} + ${item.label} + ${ + item.description + ? html`${item.description}` + : nothing + } +
+ `; + })} + `, + ) + } +
+ +
+
+ `; +} diff --git a/ui/src/ui/views/config-form.analyze.ts b/ui/src/ui/views/config-form.analyze.ts index 05c3bb5f1f0..82071bb4f6b 100644 --- a/ui/src/ui/views/config-form.analyze.ts +++ b/ui/src/ui/views/config-form.analyze.ts @@ -249,11 +249,21 @@ function normalizeUnion( return res; } - const primitiveTypes = new Set(["string", "number", "integer", "boolean"]); + const renderableUnionTypes = new Set([ + "string", + "number", + "integer", + "boolean", + "object", + "array", + ]); if ( remaining.length > 0 && literals.length === 0 && - remaining.every((entry) => entry.type && primitiveTypes.has(String(entry.type))) + remaining.every((entry) => { + const type = schemaType(entry); + return Boolean(type) && renderableUnionTypes.has(String(type)); + }) ) { return { schema: { diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index bd02be896ea..e7758e1c29a 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -1,10 +1,13 @@ import { html, nothing, type TemplateResult } from "lit"; +import { icons as sharedIcons } from "../icons.ts"; import type { ConfigUiHints } from "../types.ts"; import { defaultValue, + hasSensitiveConfigData, hintForPath, humanize, pathKey, + REDACTED_PLACEHOLDER, schemaType, type JsonSchema, } from "./config-form.shared.ts"; @@ -100,11 +103,77 @@ type FieldMeta = { tags: string[]; }; +type SensitiveRenderParams = { + path: Array; + value: unknown; + hints: ConfigUiHints; + revealSensitive: boolean; + isSensitivePathRevealed?: (path: Array) => boolean; +}; + +type SensitiveRenderState = { + isSensitive: boolean; + isRedacted: boolean; + isRevealed: boolean; + canReveal: boolean; +}; + export type ConfigSearchCriteria = { text: string; tags: string[]; }; +function getSensitiveRenderState(params: SensitiveRenderParams): SensitiveRenderState { + const isSensitive = hasSensitiveConfigData(params.value, params.path, params.hints); + const isRevealed = + isSensitive && + (params.revealSensitive || (params.isSensitivePathRevealed?.(params.path) ?? false)); + return { + isSensitive, + isRedacted: isSensitive && !isRevealed, + isRevealed, + canReveal: isSensitive, + }; +} + +function renderSensitiveToggleButton(params: { + path: Array; + state: SensitiveRenderState; + disabled: boolean; + onToggleSensitivePath?: (path: Array) => void; +}): TemplateResult | typeof nothing { + const { state } = params; + if (!state.isSensitive || !params.onToggleSensitivePath) { + return nothing; + } + return html` + + `; +} + function hasSearchCriteria(criteria: ConfigSearchCriteria | undefined): boolean { return Boolean(criteria && (criteria.text.length > 0 || criteria.tags.length > 0)); } @@ -331,6 +400,9 @@ export function renderNode(params: { disabled: boolean; showLabel?: boolean; searchCriteria?: ConfigSearchCriteria; + revealSensitive?: boolean; + isSensitivePathRevealed?: (path: Array) => boolean; + onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => void; }): TemplateResult | typeof nothing { const { schema, value, path, hints, unsupported, disabled, onPatch } = params; @@ -440,6 +512,20 @@ export function renderNode(params: { }); } } + + // Complex union (e.g. array | object) — render as JSON textarea + return renderJsonTextarea({ + schema, + value, + path, + hints, + disabled, + showLabel, + revealSensitive: params.revealSensitive ?? false, + isSensitivePathRevealed: params.isSensitivePathRevealed, + onToggleSensitivePath: params.onToggleSensitivePath, + onPatch, + }); } // Enum - use segmented for small, dropdown for large @@ -537,6 +623,9 @@ function renderTextInput(params: { disabled: boolean; showLabel?: boolean; searchCriteria?: ConfigSearchCriteria; + revealSensitive?: boolean; + isSensitivePathRevealed?: (path: Array) => boolean; + onToggleSensitivePath?: (path: Array) => void; inputType: "text" | "number"; onPatch: (path: Array, value: unknown) => void; }): TemplateResult { @@ -544,17 +633,22 @@ function renderTextInput(params: { const showLabel = params.showLabel ?? true; const hint = hintForPath(path, hints); const { label, help, tags } = resolveFieldMeta(path, schema, hints); - const isSensitive = - (hint?.sensitive ?? false) && !/^\$\{[^}]*\}$/.test(String(value ?? "").trim()); - const placeholder = - hint?.placeholder ?? - // oxlint-disable typescript/no-base-to-string - (isSensitive - ? "••••" - : schema.default !== undefined - ? `Default: ${String(schema.default)}` - : ""); - const displayValue = value ?? ""; + const sensitiveState = getSensitiveRenderState({ + path, + value, + hints, + revealSensitive: params.revealSensitive ?? false, + isSensitivePathRevealed: params.isSensitivePathRevealed, + }); + const placeholder = sensitiveState.isRedacted + ? REDACTED_PLACEHOLDER + : (hint?.placeholder ?? + // oxlint-disable typescript/no-base-to-string + (schema.default !== undefined ? `Default: ${String(schema.default)}` : "")); + const displayValue = sensitiveState.isRedacted ? "" : (value ?? ""); + const effectiveDisabled = disabled || sensitiveState.isRedacted; + const effectiveInputType = + sensitiveState.isSensitive && !sensitiveState.isRedacted ? "text" : inputType; return html`
@@ -563,12 +657,16 @@ function renderTextInput(params: { ${renderTags(tags)}
{ + if (sensitiveState.isRedacted) { + return; + } const raw = (e.target as HTMLInputElement).value; if (inputType === "number") { if (raw.trim() === "") { @@ -582,13 +680,19 @@ function renderTextInput(params: { onPatch(path, raw); }} @change=${(e: Event) => { - if (inputType === "number") { + if (inputType === "number" || sensitiveState.isRedacted) { return; } const raw = (e.target as HTMLInputElement).value; onPatch(path, raw.trim()); }} /> + ${renderSensitiveToggleButton({ + path, + state: sensitiveState, + disabled, + onToggleSensitivePath: params.onToggleSensitivePath, + })} ${ schema.default !== undefined ? html` @@ -596,7 +700,7 @@ function renderTextInput(params: { type="button" class="cfg-input__reset" title="Reset to default" - ?disabled=${disabled} + ?disabled=${effectiveDisabled} @click=${() => onPatch(path, schema.default)} >↺ ` @@ -702,6 +806,73 @@ function renderSelect(params: { `; } +function renderJsonTextarea(params: { + schema: JsonSchema; + value: unknown; + path: Array; + hints: ConfigUiHints; + disabled: boolean; + showLabel?: boolean; + revealSensitive?: boolean; + isSensitivePathRevealed?: (path: Array) => boolean; + onToggleSensitivePath?: (path: Array) => void; + onPatch: (path: Array, value: unknown) => void; +}): TemplateResult { + const { schema, value, path, hints, disabled, onPatch } = params; + const showLabel = params.showLabel ?? true; + const { label, help, tags } = resolveFieldMeta(path, schema, hints); + const fallback = jsonValue(value); + const sensitiveState = getSensitiveRenderState({ + path, + value, + hints, + revealSensitive: params.revealSensitive ?? false, + isSensitivePathRevealed: params.isSensitivePathRevealed, + }); + const displayValue = sensitiveState.isRedacted ? "" : fallback; + const effectiveDisabled = disabled || sensitiveState.isRedacted; + + return html` +
+ ${showLabel ? html`` : nothing} + ${help ? html`
${help}
` : nothing} + ${renderTags(tags)} +
+ + ${renderSensitiveToggleButton({ + path, + state: sensitiveState, + disabled, + onToggleSensitivePath: params.onToggleSensitivePath, + })} +
+
+ `; +} + function renderObject(params: { schema: JsonSchema; value: unknown; @@ -711,9 +882,24 @@ function renderObject(params: { disabled: boolean; showLabel?: boolean; searchCriteria?: ConfigSearchCriteria; + revealSensitive?: boolean; + isSensitivePathRevealed?: (path: Array) => boolean; + onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => void; }): TemplateResult { - const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params; + const { + schema, + value, + path, + hints, + unsupported, + disabled, + onPatch, + searchCriteria, + revealSensitive, + isSensitivePathRevealed, + onToggleSensitivePath, + } = params; const showLabel = params.showLabel ?? true; const { label, help, tags } = resolveFieldMeta(path, schema, hints); const selfMatched = @@ -754,6 +940,9 @@ function renderObject(params: { unsupported, disabled, searchCriteria: childSearchCriteria, + revealSensitive, + isSensitivePathRevealed, + onToggleSensitivePath, onPatch, }), )} @@ -768,6 +957,9 @@ function renderObject(params: { disabled, reservedKeys: reserved, searchCriteria: childSearchCriteria, + revealSensitive, + isSensitivePathRevealed, + onToggleSensitivePath, onPatch, }) : nothing @@ -818,9 +1010,24 @@ function renderArray(params: { disabled: boolean; showLabel?: boolean; searchCriteria?: ConfigSearchCriteria; + revealSensitive?: boolean; + isSensitivePathRevealed?: (path: Array) => boolean; + onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => void; }): TemplateResult { - const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params; + const { + schema, + value, + path, + hints, + unsupported, + disabled, + onPatch, + searchCriteria, + revealSensitive, + isSensitivePathRevealed, + onToggleSensitivePath, + } = params; const showLabel = params.showLabel ?? true; const { label, help, tags } = resolveFieldMeta(path, schema, hints); const selfMatched = @@ -900,6 +1107,9 @@ function renderArray(params: { disabled, searchCriteria: childSearchCriteria, showLabel: false, + revealSensitive, + isSensitivePathRevealed, + onToggleSensitivePath, onPatch, })}
@@ -922,6 +1132,9 @@ function renderMapField(params: { disabled: boolean; reservedKeys: Set; searchCriteria?: ConfigSearchCriteria; + revealSensitive?: boolean; + isSensitivePathRevealed?: (path: Array) => boolean; + onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => void; }): TemplateResult { const { @@ -934,6 +1147,9 @@ function renderMapField(params: { reservedKeys, onPatch, searchCriteria, + revealSensitive, + isSensitivePathRevealed, + onToggleSensitivePath, } = params; const anySchema = isAnySchema(schema); const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key)); @@ -985,6 +1201,13 @@ function renderMapField(params: { ${visibleEntries.map(([key, entryValue]) => { const valuePath = [...path, key]; const fallback = jsonValue(entryValue); + const sensitiveState = getSensitiveRenderState({ + path: valuePath, + value: entryValue, + hints, + revealSensitive: revealSensitive ?? false, + isSensitivePathRevealed, + }); return html`
@@ -1028,26 +1251,40 @@ function renderMapField(params: { ${ anySchema ? html` - + rows="2" + .value=${sensitiveState.isRedacted ? "" : fallback} + ?disabled=${disabled || sensitiveState.isRedacted} + ?readonly=${sensitiveState.isRedacted} + @change=${(e: Event) => { + if (sensitiveState.isRedacted) { + return; + } + const target = e.target as HTMLTextAreaElement; + const raw = target.value.trim(); + if (!raw) { + onPatch(valuePath, undefined); + return; + } + try { + onPatch(valuePath, JSON.parse(raw)); + } catch { + target.value = fallback; + } + }} + > + ${renderSensitiveToggleButton({ + path: valuePath, + state: sensitiveState, + disabled, + onToggleSensitivePath, + })} +
` : renderNode({ schema, @@ -1058,6 +1295,9 @@ function renderMapField(params: { disabled, searchCriteria, showLabel: false, + revealSensitive, + isSensitivePathRevealed, + onToggleSensitivePath, onPatch, }) } diff --git a/ui/src/ui/views/config-form.render.ts b/ui/src/ui/views/config-form.render.ts index 124ca50a585..07d78963d61 100644 --- a/ui/src/ui/views/config-form.render.ts +++ b/ui/src/ui/views/config-form.render.ts @@ -13,6 +13,9 @@ export type ConfigFormProps = { searchQuery?: string; activeSection?: string | null; activeSubsection?: string | null; + revealSensitive?: boolean; + isSensitivePathRevealed?: (path: Array) => boolean; + onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => void; }; @@ -431,6 +434,9 @@ export function renderConfigForm(props: ConfigFormProps) { disabled: props.disabled ?? false, showLabel: false, searchCriteria, + revealSensitive: props.revealSensitive ?? false, + isSensitivePathRevealed: props.isSensitivePathRevealed, + onToggleSensitivePath: props.onToggleSensitivePath, onPatch: props.onPatch, })}
@@ -466,6 +472,9 @@ export function renderConfigForm(props: ConfigFormProps) { disabled: props.disabled ?? false, showLabel: false, searchCriteria, + revealSensitive: props.revealSensitive ?? false, + isSensitivePathRevealed: props.isSensitivePathRevealed, + onToggleSensitivePath: props.onToggleSensitivePath, onPatch: props.onPatch, })}
diff --git a/ui/src/ui/views/config-form.shared.ts b/ui/src/ui/views/config-form.shared.ts index 366671041da..b535c49e25f 100644 --- a/ui/src/ui/views/config-form.shared.ts +++ b/ui/src/ui/views/config-form.shared.ts @@ -1,4 +1,4 @@ -import type { ConfigUiHints } from "../types.ts"; +import type { ConfigUiHint, ConfigUiHints } from "../types.ts"; export type JsonSchema = { type?: string | string[]; @@ -94,3 +94,110 @@ export function humanize(raw: string) { .replace(/\s+/g, " ") .replace(/^./, (m) => m.toUpperCase()); } + +const SENSITIVE_KEY_WHITELIST_SUFFIXES = [ + "maxtokens", + "maxoutputtokens", + "maxinputtokens", + "maxcompletiontokens", + "contexttokens", + "totaltokens", + "tokencount", + "tokenlimit", + "tokenbudget", + "passwordfile", +] as const; + +const SENSITIVE_PATTERNS = [ + /token$/i, + /password/i, + /secret/i, + /api.?key/i, + /serviceaccount(?:ref)?$/i, +]; + +const ENV_VAR_PLACEHOLDER_PATTERN = /^\$\{[^}]*\}$/; + +export const REDACTED_PLACEHOLDER = "[redacted - click reveal to view]"; + +function isEnvVarPlaceholder(value: string): boolean { + return ENV_VAR_PLACEHOLDER_PATTERN.test(value.trim()); +} + +export function isSensitiveConfigPath(path: string): boolean { + const lowerPath = path.toLowerCase(); + const whitelisted = SENSITIVE_KEY_WHITELIST_SUFFIXES.some((suffix) => lowerPath.endsWith(suffix)); + return !whitelisted && SENSITIVE_PATTERNS.some((pattern) => pattern.test(path)); +} + +function isSensitiveLeafValue(value: unknown): boolean { + if (typeof value === "string") { + return value.trim().length > 0 && !isEnvVarPlaceholder(value); + } + return value !== undefined && value !== null; +} + +function isHintSensitive(hint: ConfigUiHint | undefined): boolean { + return hint?.sensitive ?? false; +} + +export function hasSensitiveConfigData( + value: unknown, + path: Array, + hints: ConfigUiHints, +): boolean { + const key = pathKey(path); + const hint = hintForPath(path, hints); + const pathIsSensitive = isHintSensitive(hint) || isSensitiveConfigPath(key); + + if (pathIsSensitive && isSensitiveLeafValue(value)) { + return true; + } + + if (Array.isArray(value)) { + return value.some((item, index) => hasSensitiveConfigData(item, [...path, index], hints)); + } + + if (value && typeof value === "object") { + return Object.entries(value as Record).some(([childKey, childValue]) => + hasSensitiveConfigData(childValue, [...path, childKey], hints), + ); + } + + return false; +} + +export function countSensitiveConfigValues( + value: unknown, + path: Array, + hints: ConfigUiHints, +): number { + if (value == null) { + return 0; + } + + const key = pathKey(path); + const hint = hintForPath(path, hints); + const pathIsSensitive = isHintSensitive(hint) || isSensitiveConfigPath(key); + + if (pathIsSensitive && isSensitiveLeafValue(value)) { + return 1; + } + + if (Array.isArray(value)) { + return value.reduce( + (count, item, index) => count + countSensitiveConfigValues(item, [...path, index], hints), + 0, + ); + } + + if (value && typeof value === "object") { + return Object.entries(value as Record).reduce( + (count, [childKey, childValue]) => + count + countSensitiveConfigValues(childValue, [...path, childKey], hints), + 0, + ); + } + + return 0; +} diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 5fa88c53aac..aede197a705 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -1,8 +1,17 @@ -import { html, nothing } from "lit"; +import { html, nothing, type TemplateResult } from "lit"; +import { icons } from "../icons.ts"; +import type { ThemeTransitionContext } from "../theme-transition.ts"; +import type { ThemeMode, ThemeName } from "../theme.ts"; import type { ConfigUiHints } from "../types.ts"; -import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts"; +import { + countSensitiveConfigValues, + humanize, + pathKey, + REDACTED_PLACEHOLDER, + schemaType, + type JsonSchema, +} from "./config-form.shared.ts"; import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts"; -import { getTagFilters, replaceTagFilters } from "./config-search.ts"; export type ConfigProps = { raw: string; @@ -18,6 +27,7 @@ export type ConfigProps = { schemaLoading: boolean; uiHints: ConfigUiHints; formMode: "form" | "raw"; + showModeToggle?: boolean; formValue: Record | null; originalValue: Record | null; searchQuery: string; @@ -33,26 +43,21 @@ export type ConfigProps = { onSave: () => void; onApply: () => void; onUpdate: () => void; + onOpenFile?: () => void; + version: string; + theme: ThemeName; + themeMode: ThemeMode; + setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void; + setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void; + gatewayUrl: string; + assistantName: string; + configPath?: string | null; + navRootLabel?: string; + includeSections?: string[]; + excludeSections?: string[]; + includeVirtualSections?: boolean; }; -const TAG_SEARCH_PRESETS = [ - "security", - "auth", - "network", - "access", - "privacy", - "observability", - "performance", - "reliability", - "storage", - "models", - "media", - "automation", - "channels", - "tools", - "advanced", -] as const; - // SVG Icons for sidebar (Lucide-style) const sidebarIcons = { all: html` @@ -273,6 +278,19 @@ const sidebarIcons = { `, + __appearance__: html` + + + + + + + + + + + + `, default: html` @@ -281,35 +299,137 @@ const sidebarIcons = { `, }; -// Section definitions -const SECTIONS: Array<{ key: string; label: string }> = [ - { key: "env", label: "Environment" }, - { key: "update", label: "Updates" }, - { key: "agents", label: "Agents" }, - { key: "auth", label: "Authentication" }, - { key: "channels", label: "Channels" }, - { key: "messages", label: "Messages" }, - { key: "commands", label: "Commands" }, - { key: "hooks", label: "Hooks" }, - { key: "skills", label: "Skills" }, - { key: "tools", label: "Tools" }, - { key: "gateway", label: "Gateway" }, - { key: "wizard", label: "Setup Wizard" }, -]; - -type SubsectionEntry = { - key: string; +// Categorised section definitions +type SectionCategory = { + id: string; label: string; - description?: string; - order: number; + sections: Array<{ key: string; label: string }>; }; -const ALL_SUBSECTION = "__all__"; +const SECTION_CATEGORIES: SectionCategory[] = [ + { + id: "core", + label: "Core", + sections: [ + { key: "env", label: "Environment" }, + { key: "auth", label: "Authentication" }, + { key: "update", label: "Updates" }, + { key: "meta", label: "Meta" }, + { key: "logging", label: "Logging" }, + ], + }, + { + id: "ai", + label: "AI & Agents", + sections: [ + { key: "agents", label: "Agents" }, + { key: "models", label: "Models" }, + { key: "skills", label: "Skills" }, + { key: "tools", label: "Tools" }, + { key: "memory", label: "Memory" }, + { key: "session", label: "Session" }, + ], + }, + { + id: "communication", + label: "Communication", + sections: [ + { key: "channels", label: "Channels" }, + { key: "messages", label: "Messages" }, + { key: "broadcast", label: "Broadcast" }, + { key: "talk", label: "Talk" }, + { key: "audio", label: "Audio" }, + ], + }, + { + id: "automation", + label: "Automation", + sections: [ + { key: "commands", label: "Commands" }, + { key: "hooks", label: "Hooks" }, + { key: "bindings", label: "Bindings" }, + { key: "cron", label: "Cron" }, + { key: "approvals", label: "Approvals" }, + { key: "plugins", label: "Plugins" }, + ], + }, + { + id: "infrastructure", + label: "Infrastructure", + sections: [ + { key: "gateway", label: "Gateway" }, + { key: "web", label: "Web" }, + { key: "browser", label: "Browser" }, + { key: "nodeHost", label: "NodeHost" }, + { key: "canvasHost", label: "CanvasHost" }, + { key: "discovery", label: "Discovery" }, + { key: "media", label: "Media" }, + ], + }, + { + id: "appearance", + label: "Appearance", + sections: [ + { key: "__appearance__", label: "Appearance" }, + { key: "ui", label: "UI" }, + { key: "wizard", label: "Setup Wizard" }, + ], + }, +]; + +// Flat lookup: all categorised keys +const CATEGORISED_KEYS = new Set(SECTION_CATEGORIES.flatMap((c) => c.sections.map((s) => s.key))); function getSectionIcon(key: string) { return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default; } +function scopeSchemaSections( + schema: JsonSchema | null, + params: { include?: ReadonlySet | null; exclude?: ReadonlySet | null }, +): JsonSchema | null { + if (!schema || schemaType(schema) !== "object" || !schema.properties) { + return schema; + } + const include = params.include; + const exclude = params.exclude; + const nextProps: Record = {}; + for (const [key, value] of Object.entries(schema.properties)) { + if (include && include.size > 0 && !include.has(key)) { + continue; + } + if (exclude && exclude.size > 0 && exclude.has(key)) { + continue; + } + nextProps[key] = value; + } + return { ...schema, properties: nextProps }; +} + +function scopeUnsupportedPaths( + unsupportedPaths: string[], + params: { include?: ReadonlySet | null; exclude?: ReadonlySet | null }, +): string[] { + const include = params.include; + const exclude = params.exclude; + if ((!include || include.size === 0) && (!exclude || exclude.size === 0)) { + return unsupportedPaths; + } + return unsupportedPaths.filter((entry) => { + if (entry === "") { + return true; + } + const [top] = entry.split("."); + if (include && include.size > 0) { + return include.has(top); + } + if (exclude && exclude.size > 0) { + return !exclude.has(top); + } + return true; + }); +} + function resolveSectionMeta( key: string, schema?: JsonSchema, @@ -327,26 +447,6 @@ function resolveSectionMeta( }; } -function resolveSubsections(params: { - key: string; - schema: JsonSchema | undefined; - uiHints: ConfigUiHints; -}): SubsectionEntry[] { - const { key, schema, uiHints } = params; - if (!schema || schemaType(schema) !== "object" || !schema.properties) { - return []; - } - const entries = Object.entries(schema.properties).map(([subKey, node]) => { - const hint = hintForPath([key, subKey], uiHints); - const label = hint?.label ?? node.title ?? humanize(subKey); - const description = hint?.help ?? node.description ?? ""; - const order = hint?.order ?? 50; - return { key: subKey, label, description, order }; - }); - entries.sort((a, b) => (a.order !== b.order ? a.order - b.order : a.key.localeCompare(b.key))); - return entries; -} - function computeDiff( original: Record | null, current: Record | null, @@ -402,237 +502,280 @@ function truncateValue(value: unknown, maxLen = 40): string { return str.slice(0, maxLen - 3) + "..."; } +function renderDiffValue(path: string, value: unknown, _uiHints: ConfigUiHints): string { + return truncateValue(value); +} + +type ThemeOption = { id: ThemeName; label: string; description: string; icon: TemplateResult }; +const THEME_OPTIONS: ThemeOption[] = [ + { id: "claw", label: "Claw", description: "Chroma family", icon: icons.zap }, + { id: "knot", label: "Knot", description: "Knot family", icon: icons.link }, + { id: "dash", label: "Dash", description: "Field family", icon: icons.barChart }, +]; + +function renderAppearanceSection(props: ConfigProps) { + const MODE_OPTIONS: Array<{ + id: ThemeMode; + label: string; + description: string; + icon: TemplateResult; + }> = [ + { id: "system", label: "System", description: "Follow OS light or dark", icon: icons.monitor }, + { id: "light", label: "Light", description: "Force light mode", icon: icons.sun }, + { id: "dark", label: "Dark", description: "Force dark mode", icon: icons.moon }, + ]; + + return html` +
+
+

Theme

+

Choose a theme family.

+
+ ${THEME_OPTIONS.map( + (opt) => html` + + `, + )} +
+
+ +
+

Mode

+

Choose light or dark mode for the selected theme.

+
+ ${MODE_OPTIONS.map( + (opt) => html` + + `, + )} +
+
+ +
+

Connection

+
+
+ Gateway + ${props.gatewayUrl || "-"} +
+
+ Status + + + ${props.connected ? "Connected" : "Offline"} + +
+ ${ + props.assistantName + ? html` +
+ Assistant + ${props.assistantName} +
+ ` + : nothing + } +
+
+
+ `; +} + +interface ConfigEphemeralState { + rawRevealed: boolean; + envRevealed: boolean; + validityDismissed: boolean; + revealedSensitivePaths: Set; +} + +function createConfigEphemeralState(): ConfigEphemeralState { + return { + rawRevealed: false, + envRevealed: false, + validityDismissed: false, + revealedSensitivePaths: new Set(), + }; +} + +const cvs = createConfigEphemeralState(); + +function isSensitivePathRevealed(path: Array): boolean { + const key = pathKey(path); + return key ? cvs.revealedSensitivePaths.has(key) : false; +} + +function toggleSensitivePathReveal(path: Array) { + const key = pathKey(path); + if (!key) { + return; + } + if (cvs.revealedSensitivePaths.has(key)) { + cvs.revealedSensitivePaths.delete(key); + } else { + cvs.revealedSensitivePaths.add(key); + } +} + +export function resetConfigViewStateForTests() { + Object.assign(cvs, createConfigEphemeralState()); +} + export function renderConfig(props: ConfigProps) { + const showModeToggle = props.showModeToggle ?? false; const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid"; - const analysis = analyzeConfigSchema(props.schema); + const includeVirtualSections = props.includeVirtualSections ?? true; + const include = props.includeSections?.length ? new Set(props.includeSections) : null; + const exclude = props.excludeSections?.length ? new Set(props.excludeSections) : null; + const rawAnalysis = analyzeConfigSchema(props.schema); + const analysis = { + schema: scopeSchemaSections(rawAnalysis.schema, { include, exclude }), + unsupportedPaths: scopeUnsupportedPaths(rawAnalysis.unsupportedPaths, { include, exclude }), + }; const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false; + const formMode = showModeToggle ? props.formMode : "form"; + const envSensitiveVisible = cvs.envRevealed; - // Get available sections from schema + // Build categorised nav from schema - only include sections that exist in the schema const schemaProps = analysis.schema?.properties ?? {}; - const availableSections = SECTIONS.filter((s) => s.key in schemaProps); - // Add any sections in schema but not in our list - const knownKeys = new Set(SECTIONS.map((s) => s.key)); + const VIRTUAL_SECTIONS = new Set(["__appearance__"]); + const visibleCategories = SECTION_CATEGORIES.map((cat) => ({ + ...cat, + sections: cat.sections.filter( + (s) => (includeVirtualSections && VIRTUAL_SECTIONS.has(s.key)) || s.key in schemaProps, + ), + })).filter((cat) => cat.sections.length > 0); + + // Catch any schema keys not in our categories const extraSections = Object.keys(schemaProps) - .filter((k) => !knownKeys.has(k)) + .filter((k) => !CATEGORISED_KEYS.has(k)) .map((k) => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) })); - const allSections = [...availableSections, ...extraSections]; + const otherCategory: SectionCategory | null = + extraSections.length > 0 ? { id: "other", label: "Other", sections: extraSections } : null; + const isVirtualSection = + includeVirtualSections && + props.activeSection != null && + VIRTUAL_SECTIONS.has(props.activeSection); const activeSectionSchema = - props.activeSection && analysis.schema && schemaType(analysis.schema) === "object" + props.activeSection && + !isVirtualSection && + analysis.schema && + schemaType(analysis.schema) === "object" ? analysis.schema.properties?.[props.activeSection] : undefined; - const activeSectionMeta = props.activeSection - ? resolveSectionMeta(props.activeSection, activeSectionSchema) - : null; - const subsections = props.activeSection - ? resolveSubsections({ - key: props.activeSection, - schema: activeSectionSchema, - uiHints: props.uiHints, - }) - : []; - const allowSubnav = - props.formMode === "form" && Boolean(props.activeSection) && subsections.length > 0; - const isAllSubsection = props.activeSubsection === ALL_SUBSECTION; - const effectiveSubsection = props.searchQuery - ? null - : isAllSubsection - ? null - : (props.activeSubsection ?? subsections[0]?.key ?? null); + const activeSectionMeta = + props.activeSection && !isVirtualSection + ? resolveSectionMeta(props.activeSection, activeSectionSchema) + : null; + // Config subsections are always rendered as a single page per section. + const effectiveSubsection = null; + + const topTabs = [ + { key: null as string | null, label: props.navRootLabel ?? "Settings" }, + ...[...visibleCategories, ...(otherCategory ? [otherCategory] : [])].flatMap((cat) => + cat.sections.map((s) => ({ key: s.key, label: s.label })), + ), + ]; // Compute diff for showing changes (works for both form and raw modes) - const diff = props.formMode === "form" ? computeDiff(props.originalValue, props.formValue) : []; - const hasRawChanges = props.formMode === "raw" && props.raw !== props.originalRaw; - const hasChanges = props.formMode === "form" ? diff.length > 0 : hasRawChanges; + const diff = formMode === "form" ? computeDiff(props.originalValue, props.formValue) : []; + const hasRawChanges = formMode === "raw" && props.raw !== props.originalRaw; + const hasChanges = formMode === "form" ? diff.length > 0 : hasRawChanges; // Save/apply buttons require actual changes to be enabled. // Note: formUnsafe warns about unsupported schema paths but shouldn't block saving. const canSaveForm = Boolean(props.formValue) && !props.loading && Boolean(analysis.schema); const canSave = - props.connected && - !props.saving && - hasChanges && - (props.formMode === "raw" ? true : canSaveForm); + props.connected && !props.saving && hasChanges && (formMode === "raw" ? true : canSaveForm); const canApply = props.connected && !props.applying && !props.updating && hasChanges && - (props.formMode === "raw" ? true : canSaveForm); + (formMode === "raw" ? true : canSaveForm); const canUpdate = props.connected && !props.applying && !props.updating; - const selectedTags = new Set(getTagFilters(props.searchQuery)); + + const showAppearanceOnRoot = + includeVirtualSections && + formMode === "form" && + props.activeSection === null && + Boolean(include?.has("__appearance__")); return html`
- - - -
-
${ hasChanges ? html` - ${ - props.formMode === "raw" - ? "Unsaved changes" - : `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}` - } - ` + ${ + formMode === "raw" + ? "Unsaved changes" + : `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}` + } + ` : html` No changes ` }
+ ${ + props.onOpenFile + ? html` + + ` + : nothing + }
+
+ ${ + formMode === "form" + ? html` + + ` + : nothing + } + +
+ ${topTabs.map( + (tab) => html` + + `, + )} +
+ +
+ ${ + showModeToggle + ? html` +
+ + +
+ ` + : nothing + } +
+
+ + ${ + validity === "invalid" && !cvs.validityDismissed + ? html` +
+ + + + + + Your configuration is invalid. Some settings may not work as expected. + +
+ ` + : nothing + } + ${ - hasChanges && props.formMode === "form" + hasChanges && formMode === "form" ? html`
@@ -691,11 +938,11 @@ export function renderConfig(props: ConfigProps) {
${change.path}
${truncateValue(change.from)}${renderDiffValue(change.path, change.from, props.uiHints)} ${truncateValue(change.to)}${renderDiffValue(change.path, change.to, props.uiHints)}
@@ -706,12 +953,12 @@ export function renderConfig(props: ConfigProps) { ` : nothing } - ${ - activeSectionMeta && props.formMode === "form" - ? html` -
-
- ${getSectionIcon(props.activeSection ?? "")} + ${ + activeSectionMeta && formMode === "form" + ? html` +
+
+ ${getSectionIcon(props.activeSection ?? "")}
@@ -725,43 +972,40 @@ export function renderConfig(props: ConfigProps) { : nothing }
+ ${ + props.activeSection === "env" + ? html` + + ` + : nothing + }
` - : nothing - } - ${ - allowSubnav - ? html` -
- - ${subsections.map( - (entry) => html` - - `, - )} -
- ` - : nothing - } - + : nothing + }
${ - props.formMode === "form" - ? html` + props.activeSection === "__appearance__" + ? includeVirtualSections + ? renderAppearanceSection(props) + : nothing + : formMode === "form" + ? html` + ${showAppearanceOnRoot ? renderAppearanceSection(props) : nothing} ${ props.schemaLoading ? html` @@ -780,28 +1024,75 @@ export function renderConfig(props: ConfigProps) { searchQuery: props.searchQuery, activeSection: props.activeSection, activeSubsection: effectiveSubsection, + revealSensitive: + props.activeSection === "env" ? envSensitiveVisible : false, + isSensitivePathRevealed, + onToggleSensitivePath: (path) => { + toggleSensitivePathReveal(path); + props.onRawChange(props.raw); + }, }) } - ${ - formUnsafe - ? html` -
- Form view can't safely edit some fields. Use Raw to avoid losing config entries. -
- ` - : nothing - } - ` - : html` - ` + : (() => { + const sensitiveCount = countSensitiveConfigValues( + props.formValue, + [], + props.uiHints, + ); + const blurred = sensitiveCount > 0 && !cvs.rawRevealed; + return html` + ${ + formUnsafe + ? html` +
+ Your config contains fields the form editor can't safely represent. Use Raw mode to edit those + entries. +
+ ` + : nothing + } + + `; + })() }
diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 296a692d115..836b72dbbcc 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -360,7 +360,9 @@ export function renderCron(props: CronProps) { props.runsScope === "all" ? t("cron.jobList.allJobs") : (selectedJob?.name ?? props.runsJobId ?? t("cron.jobList.selectJob")); - const runs = props.runs; + const runs = props.runs.toSorted((a, b) => + props.runsSortDir === "asc" ? a.ts - b.ts : b.ts - a.ts, + ); const runStatusOptions = getRunStatusOptions(); const runDeliveryOptions = getRunDeliveryOptions(); const selectedStatusLabels = runStatusOptions @@ -1569,7 +1571,7 @@ function renderJob(job: CronJob, props: CronProps) { ?disabled=${props.busy} @click=${(event: Event) => { event.stopPropagation(); - selectAnd(() => props.onLoadRuns(job.id)); + props.onLoadRuns(job.id); }} > ${t("cron.jobList.history")} diff --git a/ui/src/ui/views/debug.ts b/ui/src/ui/views/debug.ts index 3379e881345..f63e9be8267 100644 --- a/ui/src/ui/views/debug.ts +++ b/ui/src/ui/views/debug.ts @@ -34,7 +34,7 @@ export function renderDebug(props: DebugProps) { critical > 0 ? `${critical} critical` : warn > 0 ? `${warn} warnings` : "No critical issues"; return html` -
+
diff --git a/ui/src/ui/views/instances.ts b/ui/src/ui/views/instances.ts index df5fe5fd4fe..9648c7a4572 100644 --- a/ui/src/ui/views/instances.ts +++ b/ui/src/ui/views/instances.ts @@ -1,5 +1,6 @@ import { html, nothing } from "lit"; -import { formatPresenceAge, formatPresenceSummary } from "../presenter.ts"; +import { icons } from "../icons.ts"; +import { formatPresenceAge } from "../presenter.ts"; import type { PresenceEntry } from "../types.ts"; export type InstancesProps = { @@ -10,7 +11,11 @@ export type InstancesProps = { onRefresh: () => void; }; +let hostsRevealed = false; + export function renderInstances(props: InstancesProps) { + const masked = !hostsRevealed; + return html`
@@ -18,9 +23,24 @@ export function renderInstances(props: InstancesProps) {
Connected Instances
Presence beacons from the gateway and clients.
- +
+ + +
${ props.lastError @@ -42,16 +62,18 @@ export function renderInstances(props: InstancesProps) { ? html`
No instances reported yet.
` - : props.entries.map((entry) => renderEntry(entry)) + : props.entries.map((entry) => renderEntry(entry, masked)) }
`; } -function renderEntry(entry: PresenceEntry) { +function renderEntry(entry: PresenceEntry, masked: boolean) { const lastInput = entry.lastInputSeconds != null ? `${entry.lastInputSeconds}s ago` : "n/a"; const mode = entry.mode ?? "unknown"; + const host = entry.host ?? "unknown host"; + const ip = entry.ip ?? null; const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : []; const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : []; const scopesLabel = @@ -63,8 +85,12 @@ function renderEntry(entry: PresenceEntry) { return html`
-
${entry.host ?? "unknown host"}
-
${formatPresenceSummary(entry)}
+
+ ${host} +
+
+ ${ip ? html`${ip} ` : nothing}${mode} ${entry.version ?? ""} +
${mode} ${roles.map((role) => html`${role}`)} diff --git a/ui/src/ui/views/login-gate.ts b/ui/src/ui/views/login-gate.ts new file mode 100644 index 00000000000..d63a12c047e --- /dev/null +++ b/ui/src/ui/views/login-gate.ts @@ -0,0 +1,132 @@ +import { html } from "lit"; +import { t } from "../../i18n/index.ts"; +import { renderThemeToggle } from "../app-render.helpers.ts"; +import type { AppViewState } from "../app-view-state.ts"; +import { icons } from "../icons.ts"; +import { normalizeBasePath } from "../navigation.ts"; + +export function renderLoginGate(state: AppViewState) { + const basePath = normalizeBasePath(state.basePath ?? ""); + const faviconSrc = basePath ? `${basePath}/favicon.svg` : "/favicon.svg"; + + return html` + + `; +} diff --git a/ui/src/ui/views/overview-attention.ts b/ui/src/ui/views/overview-attention.ts new file mode 100644 index 00000000000..8e09ce1c19f --- /dev/null +++ b/ui/src/ui/views/overview-attention.ts @@ -0,0 +1,61 @@ +import { html, nothing } from "lit"; +import { t } from "../../i18n/index.ts"; +import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts"; +import { icons, type IconName } from "../icons.ts"; +import type { AttentionItem } from "../types.ts"; + +export type OverviewAttentionProps = { + items: AttentionItem[]; +}; + +function severityClass(severity: string) { + if (severity === "error") { + return "danger"; + } + if (severity === "warning") { + return "warn"; + } + return ""; +} + +function attentionIcon(name: string) { + if (name in icons) { + return icons[name as IconName]; + } + return icons.radio; +} + +export function renderOverviewAttention(props: OverviewAttentionProps) { + if (props.items.length === 0) { + return nothing; + } + + return html` +
+
${t("overview.attention.title")}
+
+ ${props.items.map( + (item) => html` +
+ ${attentionIcon(item.icon)} +
+
${item.title}
+
${item.description}
+
+ ${ + item.href + ? html`${t("common.docs")}` + : nothing + } +
+ `, + )} +
+
+ `; +} diff --git a/ui/src/ui/views/overview-cards.ts b/ui/src/ui/views/overview-cards.ts new file mode 100644 index 00000000000..61e98e94781 --- /dev/null +++ b/ui/src/ui/views/overview-cards.ts @@ -0,0 +1,162 @@ +import { html, nothing, type TemplateResult } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { t } from "../../i18n/index.ts"; +import { formatCost, formatTokens, formatRelativeTimestamp } from "../format.ts"; +import { formatNextRun } from "../presenter.ts"; +import type { + SessionsUsageResult, + SessionsListResult, + SkillStatusReport, + CronJob, + CronStatus, +} from "../types.ts"; + +export type OverviewCardsProps = { + usageResult: SessionsUsageResult | null; + sessionsResult: SessionsListResult | null; + skillsReport: SkillStatusReport | null; + cronJobs: CronJob[]; + cronStatus: CronStatus | null; + presenceCount: number; + onNavigate: (tab: string) => void; +}; + +const DIGIT_RUN = /\d{3,}/g; + +function blurDigits(value: string): TemplateResult { + const escaped = value.replace(/&/g, "&").replace(//g, ">"); + const blurred = escaped.replace(DIGIT_RUN, (m) => `${m}`); + return html`${unsafeHTML(blurred)}`; +} + +type StatCard = { + kind: string; + tab: string; + label: string; + value: string | TemplateResult; + hint: string | TemplateResult; +}; + +function renderStatCard(card: StatCard, onNavigate: (tab: string) => void) { + return html` + + `; +} + +function renderSkeletonCards() { + return html` +
+ ${[0, 1, 2, 3].map( + (i) => html` +
+ + + +
+ `, + )} +
+ `; +} + +export function renderOverviewCards(props: OverviewCardsProps) { + const dataLoaded = + props.usageResult != null || props.sessionsResult != null || props.skillsReport != null; + if (!dataLoaded) { + return renderSkeletonCards(); + } + + const totals = props.usageResult?.totals; + const totalCost = formatCost(totals?.totalCost); + const totalTokens = formatTokens(totals?.totalTokens); + const totalMessages = totals ? String(props.usageResult?.aggregates?.messages?.total ?? 0) : "0"; + const sessionCount = props.sessionsResult?.count ?? null; + + const skills = props.skillsReport?.skills ?? []; + const enabledSkills = skills.filter((s) => !s.disabled).length; + const blockedSkills = skills.filter((s) => s.blockedByAllowlist).length; + const totalSkills = skills.length; + + const cronEnabled = props.cronStatus?.enabled ?? null; + const cronNext = props.cronStatus?.nextWakeAtMs ?? null; + const cronJobCount = props.cronJobs.length; + const failedCronCount = props.cronJobs.filter((j) => j.state?.lastStatus === "error").length; + + const cronValue = + cronEnabled == null + ? t("common.na") + : cronEnabled + ? `${cronJobCount} jobs` + : t("common.disabled"); + + const cronHint = + failedCronCount > 0 + ? html`${failedCronCount} failed` + : cronNext + ? t("overview.stats.cronNext", { time: formatNextRun(cronNext) }) + : ""; + + const cards: StatCard[] = [ + { + kind: "cost", + tab: "usage", + label: t("overview.cards.cost"), + value: totalCost, + hint: `${totalTokens} tokens · ${totalMessages} msgs`, + }, + { + kind: "sessions", + tab: "sessions", + label: t("overview.stats.sessions"), + value: String(sessionCount ?? t("common.na")), + hint: t("overview.stats.sessionsHint"), + }, + { + kind: "skills", + tab: "skills", + label: t("overview.cards.skills"), + value: `${enabledSkills}/${totalSkills}`, + hint: blockedSkills > 0 ? `${blockedSkills} blocked` : `${enabledSkills} active`, + }, + { + kind: "cron", + tab: "cron", + label: t("overview.stats.cron"), + value: cronValue, + hint: cronHint, + }, + ]; + + const sessions = props.sessionsResult?.sessions.slice(0, 5) ?? []; + + return html` +
+ ${cards.map((c) => renderStatCard(c, props.onNavigate))} +
+ + ${ + sessions.length > 0 + ? html` +
+

${t("overview.cards.recentSessions")}

+
    + ${sessions.map( + (s) => html` +
  • + ${blurDigits(s.displayName || s.label || s.key)} + ${s.model ?? ""} + ${s.updatedAt ? formatRelativeTimestamp(s.updatedAt) : ""} +
  • + `, + )} +
+
+ ` + : nothing + } + `; +} diff --git a/ui/src/ui/views/overview-event-log.ts b/ui/src/ui/views/overview-event-log.ts new file mode 100644 index 00000000000..04079f5243a --- /dev/null +++ b/ui/src/ui/views/overview-event-log.ts @@ -0,0 +1,42 @@ +import { html, nothing } from "lit"; +import { t } from "../../i18n/index.ts"; +import type { EventLogEntry } from "../app-events.ts"; +import { icons } from "../icons.ts"; +import { formatEventPayload } from "../presenter.ts"; + +export type OverviewEventLogProps = { + events: EventLogEntry[]; +}; + +export function renderOverviewEventLog(props: OverviewEventLogProps) { + if (props.events.length === 0) { + return nothing; + } + + const visible = props.events.slice(0, 20); + + return html` +
+ + ${icons.radio} + ${t("overview.eventLog.title")} + ${props.events.length} + +
+ ${visible.map( + (entry) => html` +
+ ${new Date(entry.ts).toLocaleTimeString()} + ${entry.event} + ${ + entry.payload + ? html`${formatEventPayload(entry.payload).slice(0, 120)}` + : nothing + } +
+ `, + )} +
+
+ `; +} diff --git a/ui/src/ui/views/overview-hints.ts b/ui/src/ui/views/overview-hints.ts index 9db33a2b577..fa661016464 100644 --- a/ui/src/ui/views/overview-hints.ts +++ b/ui/src/ui/views/overview-hints.ts @@ -1,5 +1,31 @@ import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js"; +const AUTH_REQUIRED_CODES = new Set([ + ConnectErrorDetailCodes.AUTH_REQUIRED, + ConnectErrorDetailCodes.AUTH_TOKEN_MISSING, + ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING, + ConnectErrorDetailCodes.AUTH_TOKEN_NOT_CONFIGURED, + ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED, +]); + +const AUTH_FAILURE_CODES = new Set([ + ...AUTH_REQUIRED_CODES, + ConnectErrorDetailCodes.AUTH_UNAUTHORIZED, + ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH, + ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH, + ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH, + ConnectErrorDetailCodes.AUTH_RATE_LIMITED, + ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING, + ConnectErrorDetailCodes.AUTH_TAILSCALE_PROXY_MISSING, + ConnectErrorDetailCodes.AUTH_TAILSCALE_WHOIS_FAILED, + ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH, +]); + +const INSECURE_CONTEXT_CODES = new Set([ + ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, + ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED, +]); + /** Whether the overview should show device-pairing guidance for this error. */ export function shouldShowPairingHint( connected: boolean, @@ -14,3 +40,44 @@ export function shouldShowPairingHint( } return lastError.toLowerCase().includes("pairing required"); } + +export function shouldShowAuthHint( + connected: boolean, + lastError: string | null, + lastErrorCode?: string | null, +): boolean { + if (connected || !lastError) { + return false; + } + if (lastErrorCode) { + return AUTH_FAILURE_CODES.has(lastErrorCode); + } + const lower = lastError.toLowerCase(); + return lower.includes("unauthorized") || lower.includes("connect failed"); +} + +export function shouldShowAuthRequiredHint( + hasToken: boolean, + hasPassword: boolean, + lastErrorCode?: string | null, +): boolean { + if (lastErrorCode) { + return AUTH_REQUIRED_CODES.has(lastErrorCode); + } + return !hasToken && !hasPassword; +} + +export function shouldShowInsecureContextHint( + connected: boolean, + lastError: string | null, + lastErrorCode?: string | null, +): boolean { + if (connected || !lastError) { + return false; + } + if (lastErrorCode) { + return INSECURE_CONTEXT_CODES.has(lastErrorCode); + } + const lower = lastError.toLowerCase(); + return lower.includes("secure context") || lower.includes("device identity required"); +} diff --git a/ui/src/ui/views/overview-log-tail.ts b/ui/src/ui/views/overview-log-tail.ts new file mode 100644 index 00000000000..8be2aa9d5c5 --- /dev/null +++ b/ui/src/ui/views/overview-log-tail.ts @@ -0,0 +1,44 @@ +import { html, nothing } from "lit"; +import { t } from "../../i18n/index.ts"; +import { icons } from "../icons.ts"; + +/** Strip ANSI escape codes (SGR, OSC-8) for readable log display. */ +function stripAnsi(text: string): string { + /* eslint-disable no-control-regex -- stripping ANSI escape sequences requires matching ESC */ + return text.replace(/\x1b\]8;;.*?\x1b\\|\x1b\]8;;\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, ""); +} + +export type OverviewLogTailProps = { + lines: string[]; + onRefreshLogs: () => void; +}; + +export function renderOverviewLogTail(props: OverviewLogTailProps) { + if (props.lines.length === 0) { + return nothing; + } + + const displayLines = props.lines + .slice(-50) + .map((line) => stripAnsi(line)) + .join("\n"); + + return html` +
+ + ${icons.scrollText} + ${t("overview.logTail.title")} + ${props.lines.length} + { + e.preventDefault(); + e.stopPropagation(); + props.onRefreshLogs(); + }} + >${icons.loader} + +
${displayLines}
+
+ `; +} diff --git a/ui/src/ui/views/overview-quick-actions.ts b/ui/src/ui/views/overview-quick-actions.ts new file mode 100644 index 00000000000..b1358ca2e67 --- /dev/null +++ b/ui/src/ui/views/overview-quick-actions.ts @@ -0,0 +1,31 @@ +import { html } from "lit"; +import { t } from "../../i18n/index.ts"; +import { icons } from "../icons.ts"; + +export type OverviewQuickActionsProps = { + onNavigate: (tab: string) => void; + onRefresh: () => void; +}; + +export function renderOverviewQuickActions(props: OverviewQuickActionsProps) { + return html` +
+ + + + +
+ `; +} diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index 6ebcb884ff6..ed8ef6fb740 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -1,12 +1,29 @@ -import { html } from "lit"; -import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js"; +import { html, nothing } from "lit"; import { t, i18n, SUPPORTED_LOCALES, type Locale } from "../../i18n/index.ts"; +import type { EventLogEntry } from "../app-events.ts"; import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts"; import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts"; import type { GatewayHelloOk } from "../gateway.ts"; -import { formatNextRun } from "../presenter.ts"; +import { icons } from "../icons.ts"; import type { UiSettings } from "../storage.ts"; -import { shouldShowPairingHint } from "./overview-hints.ts"; +import type { + AttentionItem, + CronJob, + CronStatus, + SessionsListResult, + SessionsUsageResult, + SkillStatusReport, +} from "../types.ts"; +import { renderOverviewAttention } from "./overview-attention.ts"; +import { renderOverviewCards } from "./overview-cards.ts"; +import { renderOverviewEventLog } from "./overview-event-log.ts"; +import { + shouldShowAuthHint, + shouldShowAuthRequiredHint, + shouldShowInsecureContextHint, + shouldShowPairingHint, +} from "./overview-hints.ts"; +import { renderOverviewLogTail } from "./overview-log-tail.ts"; export type OverviewProps = { connected: boolean; @@ -20,24 +37,39 @@ export type OverviewProps = { cronEnabled: boolean | null; cronNext: number | null; lastChannelsRefresh: number | null; + // New dashboard data + usageResult: SessionsUsageResult | null; + sessionsResult: SessionsListResult | null; + skillsReport: SkillStatusReport | null; + cronJobs: CronJob[]; + cronStatus: CronStatus | null; + attentionItems: AttentionItem[]; + eventLog: EventLogEntry[]; + overviewLogLines: string[]; + showGatewayToken: boolean; + showGatewayPassword: boolean; onSettingsChange: (next: UiSettings) => void; onPasswordChange: (next: string) => void; onSessionKeyChange: (next: string) => void; + onToggleGatewayTokenVisibility: () => void; + onToggleGatewayPasswordVisibility: () => void; onConnect: () => void; onRefresh: () => void; + onNavigate: (tab: string) => void; + onRefreshLogs: () => void; }; export function renderOverview(props: OverviewProps) { const snapshot = props.hello?.snapshot as | { uptimeMs?: number; - policy?: { tickIntervalMs?: number }; authMode?: "none" | "token" | "password" | "trusted-proxy"; } | undefined; const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : t("common.na"); - const tick = snapshot?.policy?.tickIntervalMs - ? `${snapshot.policy.tickIntervalMs}ms` + const tickIntervalMs = props.hello?.policy?.tickIntervalMs; + const tick = tickIntervalMs + ? `${(tickIntervalMs / 1000).toFixed(tickIntervalMs % 1000 === 0 ? 0 : 1)}s` : t("common.na"); const authMode = snapshot?.authMode; const isTrustedProxy = authMode === "trusted-proxy"; @@ -74,38 +106,12 @@ export function renderOverview(props: OverviewProps) { if (props.connected || !props.lastError) { return null; } - const lower = props.lastError.toLowerCase(); - const authRequiredCodes = new Set([ - ConnectErrorDetailCodes.AUTH_REQUIRED, - ConnectErrorDetailCodes.AUTH_TOKEN_MISSING, - ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING, - ConnectErrorDetailCodes.AUTH_TOKEN_NOT_CONFIGURED, - ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED, - ]); - const authFailureCodes = new Set([ - ...authRequiredCodes, - ConnectErrorDetailCodes.AUTH_UNAUTHORIZED, - ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH, - ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH, - ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH, - ConnectErrorDetailCodes.AUTH_RATE_LIMITED, - ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING, - ConnectErrorDetailCodes.AUTH_TAILSCALE_PROXY_MISSING, - ConnectErrorDetailCodes.AUTH_TAILSCALE_WHOIS_FAILED, - ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH, - ]); - const authFailed = props.lastErrorCode - ? authFailureCodes.has(props.lastErrorCode) - : lower.includes("unauthorized") || lower.includes("connect failed"); - if (!authFailed) { + if (!shouldShowAuthHint(props.connected, props.lastError, props.lastErrorCode)) { return null; } const hasToken = Boolean(props.settings.token.trim()); const hasPassword = Boolean(props.password.trim()); - const isAuthRequired = props.lastErrorCode - ? authRequiredCodes.has(props.lastErrorCode) - : !hasToken && !hasPassword; - if (isAuthRequired) { + if (shouldShowAuthRequiredHint(hasToken, hasPassword, props.lastErrorCode)) { return html`
${t("overview.auth.required")} @@ -151,15 +157,7 @@ export function renderOverview(props: OverviewProps) { if (isSecureContext) { return null; } - const lower = props.lastError.toLowerCase(); - const insecureContextCode = - props.lastErrorCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED || - props.lastErrorCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED; - if ( - !insecureContextCode && - !lower.includes("secure context") && - !lower.includes("device identity required") - ) { + if (!shouldShowInsecureContextHint(props.connected, props.lastError, props.lastErrorCode)) { return null; } return html` @@ -194,12 +192,12 @@ export function renderOverview(props: OverviewProps) { const currentLocale = i18n.getLocale(); return html` -
+
${t("overview.access.title")}
${t("overview.access.subtitle")}
-
-
@@ -321,45 +374,32 @@ export function renderOverview(props: OverviewProps) {
-
-
-
${t("overview.stats.instances")}
-
${props.presenceCount}
-
${t("overview.stats.instancesHint")}
-
-
-
${t("overview.stats.sessions")}
-
${props.sessionsCount ?? t("common.na")}
-
${t("overview.stats.sessionsHint")}
-
-
-
${t("overview.stats.cron")}
-
- ${props.cronEnabled == null ? t("common.na") : props.cronEnabled ? t("common.enabled") : t("common.disabled")} -
-
${t("overview.stats.cronNext", { time: formatNextRun(props.cronNext) })}
-
-
+
+ + ${renderOverviewCards({ + usageResult: props.usageResult, + sessionsResult: props.sessionsResult, + skillsReport: props.skillsReport, + cronJobs: props.cronJobs, + cronStatus: props.cronStatus, + presenceCount: props.presenceCount, + onNavigate: props.onNavigate, + })} + + ${renderOverviewAttention({ items: props.attentionItems })} + +
+ +
+ ${renderOverviewEventLog({ + events: props.eventLog, + })} + + ${renderOverviewLogTail({ + lines: props.overviewLogLines, + onRefreshLogs: props.onRefreshLogs, + })} +
-
-
${t("overview.notes.title")}
-
${t("overview.notes.subtitle")}
-
-
-
${t("overview.notes.tailscaleTitle")}
-
- ${t("overview.notes.tailscaleText")} -
-
-
-
${t("overview.notes.sessionTitle")}
-
${t("overview.notes.sessionText")}
-
-
-
${t("overview.notes.cronTitle")}
-
${t("overview.notes.cronText")}
-
-
-
`; } diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 6f0332f62be..bb1bef96d38 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -1,5 +1,6 @@ import { html, nothing } from "lit"; import { formatRelativeTimestamp } from "../format.ts"; +import { icons } from "../icons.ts"; import { pathForTab } from "../navigation.ts"; import { formatSessionTokens } from "../presenter.ts"; import type { GatewaySessionRow, SessionsListResult } from "../types.ts"; @@ -13,12 +14,23 @@ export type SessionsProps = { includeGlobal: boolean; includeUnknown: boolean; basePath: string; + searchQuery: string; + sortColumn: "key" | "kind" | "updated" | "tokens"; + sortDir: "asc" | "desc"; + page: number; + pageSize: number; + actionsOpenKey: string | null; onFiltersChange: (next: { activeMinutes: string; limit: string; includeGlobal: boolean; includeUnknown: boolean; }) => void; + onSearchChange: (query: string) => void; + onSortChange: (column: "key" | "kind" | "updated" | "tokens", dir: "asc" | "desc") => void; + onPageChange: (page: number) => void; + onPageSizeChange: (size: number) => void; + onActionsOpenChange: (key: string | null) => void; onRefresh: () => void; onPatch: ( key: string, @@ -41,6 +53,7 @@ const VERBOSE_LEVELS = [ { value: "full", label: "full" }, ] as const; const REASONING_LEVELS = ["", "off", "on", "stream"] as const; +const PAGE_SIZES = [10, 25, 50, 100] as const; function normalizeProviderId(provider?: string | null): string { if (!provider) { @@ -107,24 +120,110 @@ function resolveThinkLevelPatchValue(value: string, isBinary: boolean): string | return value; } +function filterRows(rows: GatewaySessionRow[], query: string): GatewaySessionRow[] { + const q = query.trim().toLowerCase(); + if (!q) { + return rows; + } + return rows.filter((row) => { + const key = (row.key ?? "").toLowerCase(); + const label = (row.label ?? "").toLowerCase(); + const kind = (row.kind ?? "").toLowerCase(); + const displayName = (row.displayName ?? "").toLowerCase(); + return key.includes(q) || label.includes(q) || kind.includes(q) || displayName.includes(q); + }); +} + +function sortRows( + rows: GatewaySessionRow[], + column: "key" | "kind" | "updated" | "tokens", + dir: "asc" | "desc", +): GatewaySessionRow[] { + const cmp = dir === "asc" ? 1 : -1; + return [...rows].toSorted((a, b) => { + let diff = 0; + switch (column) { + case "key": + diff = (a.key ?? "").localeCompare(b.key ?? ""); + break; + case "kind": + diff = (a.kind ?? "").localeCompare(b.kind ?? ""); + break; + case "updated": { + const au = a.updatedAt ?? 0; + const bu = b.updatedAt ?? 0; + diff = au - bu; + break; + } + case "tokens": { + const at = a.totalTokens ?? a.inputTokens ?? a.outputTokens ?? 0; + const bt = b.totalTokens ?? b.inputTokens ?? b.outputTokens ?? 0; + diff = at - bt; + break; + } + } + return diff * cmp; + }); +} + +function paginateRows(rows: T[], page: number, pageSize: number): T[] { + const start = page * pageSize; + return rows.slice(start, start + pageSize); +} + export function renderSessions(props: SessionsProps) { - const rows = props.result?.sessions ?? []; + const rawRows = props.result?.sessions ?? []; + const filtered = filterRows(rawRows, props.searchQuery); + const sorted = sortRows(filtered, props.sortColumn, props.sortDir); + const totalRows = sorted.length; + const totalPages = Math.max(1, Math.ceil(totalRows / props.pageSize)); + const page = Math.min(props.page, totalPages - 1); + const paginated = paginateRows(sorted, page, props.pageSize); + + const sortHeader = (col: "key" | "kind" | "updated" | "tokens", label: string) => { + const isActive = props.sortColumn === col; + const nextDir = isActive && props.sortDir === "asc" ? ("desc" as const) : ("asc" as const); + return html` + props.onSortChange(col, isActive ? nextDir : "desc")} + > + ${label} + ${icons.arrowUpDown} + + `; + }; + return html` -
-
+ ${ + props.actionsOpenKey + ? html` +
props.onActionsOpenChange(null)} + aria-hidden="true" + >
+ ` + : nothing + } +
+
Sessions
-
Active session keys and per-session overrides.
+
${props.result ? `Store: ${props.result.path}` : "Active session keys and per-session overrides."}
-
-
@@ -219,6 +381,8 @@ function renderRow( basePath: string, onPatch: SessionsProps["onPatch"], onDelete: SessionsProps["onDelete"], + onActionsOpenChange: (key: string | null) => void, + actionsOpenKey: string | null, disabled: boolean, ) { const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "n/a"; @@ -234,36 +398,58 @@ function renderRow( typeof row.displayName === "string" && row.displayName.trim().length > 0 ? row.displayName.trim() : null; - const label = typeof row.label === "string" ? row.label.trim() : ""; - const showDisplayName = Boolean(displayName && displayName !== row.key && displayName !== label); + const showDisplayName = Boolean( + displayName && + displayName !== row.key && + displayName !== (typeof row.label === "string" ? row.label.trim() : ""), + ); const canLink = row.kind !== "global"; const chatUrl = canLink ? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(row.key)}` : null; + const isMenuOpen = actionsOpenKey === row.key; + const badgeClass = + row.kind === "direct" + ? "data-table-badge--direct" + : row.kind === "group" + ? "data-table-badge--group" + : row.kind === "global" + ? "data-table-badge--global" + : "data-table-badge--unknown"; return html` -
-
- ${canLink ? html`${row.key}` : row.key} - ${showDisplayName ? html`${displayName}` : nothing} -
-
+ + +
+ ${canLink ? html`${row.key}` : row.key} + ${ + showDisplayName + ? html`${displayName}` + : nothing + } +
+ + { const value = (e.target as HTMLInputElement).value.trim(); onPatch(row.key, { label: value || null }); }} /> -
-
${row.kind}
-
${updated}
-
${formatSessionTokens(row)}
-
+ + + ${row.kind} + + ${updated} + ${formatSessionTokens(row)} + -
-
+ + -
-
+ + -
-
- -
-
+ + +
+ + ${ + isMenuOpen + ? html` +
+ ${ + canLink + ? html` + onActionsOpenChange(null)} + > + Open in Chat + + ` + : nothing + } + +
+ ` + : nothing + } +
+ + `; } diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index 830f97921f8..ad0f4ee63c0 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/ui/views/skills.ts @@ -10,6 +10,7 @@ import { } from "./skills-shared.ts"; export type SkillsProps = { + connected: boolean; loading: boolean; report: SkillStatusReport | null; error: string | null; @@ -40,16 +41,22 @@ export function renderSkills(props: SkillsProps) {
Skills
-
Bundled, managed, and workspace skills.
+
Installed skills and their status.
-
-
-
${ diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index b659c195754..ad2910625b6 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -71,11 +71,15 @@ export type AppViewState = { fallbackStatus: FallbackStatus | null; chatAvatarUrl: string | null; chatThinkingLevel: string | null; + chatModelOverrides: Record; + chatModelsLoading: boolean; + chatModelCatalog: ModelCatalogEntry[]; chatQueue: ChatQueueItem[]; chatManualRefreshInFlight: boolean; nodesLoading: boolean; nodes: Array>; chatNewMessagesBelow: boolean; + navDrawerOpen: boolean; sidebarOpen: boolean; sidebarContent: string | null; sidebarError: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 7f936722ca5..1b3971a41f6 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -158,9 +158,13 @@ export class OpenClawApp extends LitElement { @state() fallbackStatus: FallbackStatus | null = null; @state() chatAvatarUrl: string | null = null; @state() chatThinkingLevel: string | null = null; + @state() chatModelOverrides: Record = {}; + @state() chatModelsLoading = false; + @state() chatModelCatalog: ModelCatalogEntry[] = []; @state() chatQueue: ChatQueueItem[] = []; @state() chatAttachments: ChatAttachment[] = []; @state() chatManualRefreshInFlight = false; + @state() navDrawerOpen = false; onSlashAction?: (action: string) => void; @@ -541,6 +545,7 @@ export class OpenClawApp extends LitElement { setTab(next: Tab) { setTabInternal(this as unknown as Parameters[0], next); + this.navDrawerOpen = false; } setTheme(next: ThemeName, context?: Parameters[2]) { diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 9a7f7d2eeb2..6b584be512b 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -174,7 +174,11 @@ export function renderMessageGroup( ${timestamp} ${renderMessageMeta(meta)} ${normalizedRole === "assistant" && isTtsSupported() ? renderTtsButton(group) : nothing} - ${opts.onDelete ? renderDeleteButton(opts.onDelete) : nothing} + ${ + opts.onDelete + ? renderDeleteButton(opts.onDelete, normalizedRole === "user" ? "left" : "right") + : nothing + }
@@ -312,6 +316,8 @@ function extractGroupText(group: MessageGroup): string { const SKIP_DELETE_CONFIRM_KEY = "openclaw:skipDeleteConfirm"; +type DeleteConfirmSide = "left" | "right"; + function shouldSkipDeleteConfirm(): boolean { try { return localStorage.getItem(SKIP_DELETE_CONFIRM_KEY) === "1"; @@ -320,7 +326,7 @@ function shouldSkipDeleteConfirm(): boolean { } } -function renderDeleteButton(onDelete: () => void) { +function renderDeleteButton(onDelete: () => void, side: DeleteConfirmSide) { return html` - ` - : nothing - } +
+ + + + + + props.onSearchChange((e.target as HTMLInputElement).value)} + /> + ${ + props.searchQuery + ? html` + + ` + : nothing + } +
` : nothing diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index ad0f4ee63c0..b9338971c8e 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/ui/views/skills.ts @@ -61,6 +61,8 @@ export function renderSkills(props: SkillsProps) { .value=${props.filter} @input=${(e: Event) => props.onFilterChange((e.target as HTMLInputElement).value)} placeholder="Search skills" + autocomplete="off" + name="skills-filter" />
${filtered.length} shown
From 55e79adf6916ffed4b745744793f1502338f1b92 Mon Sep 17 00:00:00 2001 From: Max aka Mosheh Date: Fri, 13 Mar 2026 17:09:51 +0200 Subject: [PATCH 0460/1173] fix: resolve target agent workspace for cross-agent subagent spawns (#40176) Merged via squash. Prepared head SHA: 2378e40383f194557c582b8e28976e57dfe03e8a Co-authored-by: moshehbenavraham <17122072+moshehbenavraham@users.noreply.github.com> Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com> Reviewed-by: @mcaxtr --- CHANGELOG.md | 1 + src/agents/spawned-context.test.ts | 30 ++- src/agents/spawned-context.ts | 14 +- src/agents/subagent-spawn.ts | 7 +- src/agents/subagent-spawn.workspace.test.ts | 192 ++++++++++++++++++++ 5 files changed, 234 insertions(+), 10 deletions(-) create mode 100644 src/agents/subagent-spawn.workspace.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 34c7cab869f..4b1cf0c9e98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -333,6 +333,7 @@ Docs: https://docs.openclaw.ai - Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb. - Agents/embedded runner: carry provider-observed overflow token counts into compaction so overflow retries and diagnostics use the rejected live prompt size instead of only transcript estimates. (#40357) thanks @rabsef-bicrym. - Agents/compaction transcript updates: emit a transcript-update event immediately after successful embedded compaction so downstream listeners observe the post-compact transcript without waiting for a later write. (#25558) thanks @rodrigouroz. +- Agents/sessions_spawn: use the target agent workspace for cross-agent spawned runs instead of inheriting the caller workspace, so child sessions load the correct workspace-scoped instructions and persona files. (#40176) Thanks @moshehbenavraham. ## 2026.3.7 diff --git a/src/agents/spawned-context.test.ts b/src/agents/spawned-context.test.ts index 964bf47a789..3f163eb3030 100644 --- a/src/agents/spawned-context.test.ts +++ b/src/agents/spawned-context.test.ts @@ -44,18 +44,44 @@ describe("mapToolContextToSpawnedRunMetadata", () => { }); describe("resolveSpawnedWorkspaceInheritance", () => { + const config = { + agents: { + list: [ + { id: "main", workspace: "/tmp/workspace-main" }, + { id: "ops", workspace: "/tmp/workspace-ops" }, + ], + }, + }; + it("prefers explicit workspaceDir when provided", () => { const resolved = resolveSpawnedWorkspaceInheritance({ - config: {}, + config, requesterSessionKey: "agent:main:subagent:parent", explicitWorkspaceDir: " /tmp/explicit ", }); expect(resolved).toBe("/tmp/explicit"); }); + it("prefers targetAgentId over requester session agent for cross-agent spawns", () => { + const resolved = resolveSpawnedWorkspaceInheritance({ + config, + targetAgentId: "ops", + requesterSessionKey: "agent:main:subagent:parent", + }); + expect(resolved).toBe("/tmp/workspace-ops"); + }); + + it("falls back to requester session agent when targetAgentId is missing", () => { + const resolved = resolveSpawnedWorkspaceInheritance({ + config, + requesterSessionKey: "agent:main:subagent:parent", + }); + expect(resolved).toBe("/tmp/workspace-main"); + }); + it("returns undefined for missing requester context", () => { const resolved = resolveSpawnedWorkspaceInheritance({ - config: {}, + config, requesterSessionKey: undefined, explicitWorkspaceDir: undefined, }); diff --git a/src/agents/spawned-context.ts b/src/agents/spawned-context.ts index 32a4d299e74..d0919c86baa 100644 --- a/src/agents/spawned-context.ts +++ b/src/agents/spawned-context.ts @@ -58,6 +58,7 @@ export function mapToolContextToSpawnedRunMetadata( export function resolveSpawnedWorkspaceInheritance(params: { config: OpenClawConfig; + targetAgentId?: string; requesterSessionKey?: string; explicitWorkspaceDir?: string | null; }): string | undefined { @@ -65,12 +66,13 @@ export function resolveSpawnedWorkspaceInheritance(params: { if (explicit) { return explicit; } - const requesterAgentId = params.requesterSessionKey - ? parseAgentSessionKey(params.requesterSessionKey)?.agentId - : undefined; - return requesterAgentId - ? resolveAgentWorkspaceDir(params.config, normalizeAgentId(requesterAgentId)) - : undefined; + // For cross-agent spawns, use the target agent's workspace instead of the requester's. + const agentId = + params.targetAgentId ?? + (params.requesterSessionKey + ? parseAgentSessionKey(params.requesterSessionKey)?.agentId + : undefined); + return agentId ? resolveAgentWorkspaceDir(params.config, normalizeAgentId(agentId)) : undefined; } export function resolveIngressWorkspaceOverrideForSpawnedRun( diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index a4a6229c715..1750d948e6c 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -576,8 +576,11 @@ export async function spawnSubagentDirect( ...toolSpawnMetadata, workspaceDir: resolveSpawnedWorkspaceInheritance({ config: cfg, - requesterSessionKey: requesterInternalKey, - explicitWorkspaceDir: toolSpawnMetadata.workspaceDir, + targetAgentId, + // For cross-agent spawns, ignore the caller's inherited workspace; + // let targetAgentId resolve the correct workspace instead. + explicitWorkspaceDir: + targetAgentId !== requesterAgentId ? undefined : toolSpawnMetadata.workspaceDir, }), }); const spawnLineagePatchError = await patchChildSession({ diff --git a/src/agents/subagent-spawn.workspace.test.ts b/src/agents/subagent-spawn.workspace.test.ts new file mode 100644 index 00000000000..fef6bc7515c --- /dev/null +++ b/src/agents/subagent-spawn.workspace.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { spawnSubagentDirect } from "./subagent-spawn.js"; + +type TestAgentConfig = { + id?: string; + workspace?: string; + subagents?: { + allowAgents?: string[]; + }; +}; + +type TestConfig = { + agents?: { + list?: TestAgentConfig[]; + }; +}; + +const hoisted = vi.hoisted(() => ({ + callGatewayMock: vi.fn(), + configOverride: {} as Record, + registerSubagentRunMock: vi.fn(), +})); + +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => hoisted.configOverride, + }; +}); + +vi.mock("@mariozechner/pi-ai/oauth", () => ({ + getOAuthApiKey: () => "", + getOAuthProviders: () => [], +})); + +vi.mock("./subagent-registry.js", () => ({ + countActiveRunsForSession: () => 0, + registerSubagentRun: (args: unknown) => hoisted.registerSubagentRunMock(args), +})); + +vi.mock("./subagent-announce.js", () => ({ + buildSubagentSystemPrompt: () => "system-prompt", +})); + +vi.mock("./subagent-depth.js", () => ({ + getSubagentDepthFromSessionStore: () => 0, +})); + +vi.mock("./model-selection.js", () => ({ + resolveSubagentSpawnModelSelection: () => undefined, +})); + +vi.mock("./sandbox/runtime-status.js", () => ({ + resolveSandboxRuntimeStatus: () => ({ sandboxed: false }), +})); + +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => ({ hasHooks: () => false }), +})); + +vi.mock("../utils/delivery-context.js", () => ({ + normalizeDeliveryContext: (value: unknown) => value, +})); + +vi.mock("./tools/sessions-helpers.js", () => ({ + resolveMainSessionAlias: () => ({ mainKey: "main", alias: "main" }), + resolveInternalSessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main", + resolveDisplaySessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main", +})); + +vi.mock("./agent-scope.js", () => ({ + resolveAgentConfig: (cfg: TestConfig, agentId: string) => + cfg.agents?.list?.find((entry) => entry.id === agentId), + resolveAgentWorkspaceDir: (cfg: TestConfig, agentId: string) => + cfg.agents?.list?.find((entry) => entry.id === agentId)?.workspace ?? + `/tmp/workspace-${agentId}`, +})); + +function createConfigOverride(overrides?: Record) { + return { + session: { + mainKey: "main", + scope: "per-sender", + }, + agents: { + list: [ + { + id: "main", + workspace: "/tmp/workspace-main", + }, + ], + }, + ...overrides, + }; +} + +function setupGatewayMock() { + hoisted.callGatewayMock.mockImplementation( + async (opts: { method?: string; params?: Record }) => { + if (opts.method === "sessions.patch") { + return { ok: true }; + } + if (opts.method === "sessions.delete") { + return { ok: true }; + } + if (opts.method === "agent") { + return { runId: "run-1" }; + } + return {}; + }, + ); +} + +function getRegisteredRun() { + return hoisted.registerSubagentRunMock.mock.calls.at(0)?.[0] as + | Record + | undefined; +} + +describe("spawnSubagentDirect workspace inheritance", () => { + beforeEach(() => { + hoisted.callGatewayMock.mockClear(); + hoisted.registerSubagentRunMock.mockClear(); + hoisted.configOverride = createConfigOverride(); + setupGatewayMock(); + }); + + it("uses the target agent workspace for cross-agent spawns", async () => { + hoisted.configOverride = createConfigOverride({ + agents: { + list: [ + { + id: "main", + workspace: "/tmp/workspace-main", + subagents: { + allowAgents: ["ops"], + }, + }, + { + id: "ops", + workspace: "/tmp/workspace-ops", + }, + ], + }, + }); + + const result = await spawnSubagentDirect( + { + task: "inspect workspace", + agentId: "ops", + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "telegram", + agentAccountId: "123", + agentTo: "456", + workspaceDir: "/tmp/requester-workspace", + }, + ); + + expect(result.status).toBe("accepted"); + expect(getRegisteredRun()).toMatchObject({ + workspaceDir: "/tmp/workspace-ops", + }); + }); + + it("preserves the inherited workspace for same-agent spawns", async () => { + const result = await spawnSubagentDirect( + { + task: "inspect workspace", + agentId: "main", + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "telegram", + agentAccountId: "123", + agentTo: "456", + workspaceDir: "/tmp/requester-workspace", + }, + ); + + expect(result.status).toBe("accepted"); + expect(getRegisteredRun()).toMatchObject({ + workspaceDir: "/tmp/requester-workspace", + }); + }); +}); From 394fd87c2c491790c1f79d6eb37ba40de7178cbc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 15:37:21 +0000 Subject: [PATCH 0461/1173] fix: clarify gated core tool warnings --- CHANGELOG.md | 1 + src/agents/tool-policy-pipeline.test.ts | 25 +++++++++++++++++++++ src/agents/tool-policy-pipeline.ts | 30 ++++++++++++++++++++++--- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b1cf0c9e98..cae46427d1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark. - macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so `openclaw onboard --install-daemon` no longer false-fails on slower Macs and fresh VM snapshots. - Agents/compaction: preserve safeguard compaction summary language continuity via default and configurable custom instructions so persona drift is reduced after auto-compaction. (#10456) Thanks @keepitmello. +- Agents/tool warnings: distinguish gated core tools like `apply_patch` from plugin-only unknown entries in `tools.profile` warnings, so unavailable core tools now report current runtime/provider/model/config gating instead of suggesting a missing plugin. ## 2026.3.12 diff --git a/src/agents/tool-policy-pipeline.test.ts b/src/agents/tool-policy-pipeline.test.ts index 9d0a9d5846f..70d4301d42a 100644 --- a/src/agents/tool-policy-pipeline.test.ts +++ b/src/agents/tool-policy-pipeline.test.ts @@ -45,6 +45,31 @@ describe("tool-policy-pipeline", () => { expect(warnings[0]).toContain("unknown entries (wat)"); }); + test("warns gated core tools as unavailable instead of plugin-only unknowns", () => { + const warnings: string[] = []; + const tools = [{ name: "exec" }] as unknown as DummyTool[]; + applyToolPolicyPipeline({ + // oxlint-disable-next-line typescript/no-explicit-any + tools: tools as any, + // oxlint-disable-next-line typescript/no-explicit-any + toolMeta: () => undefined, + warn: (msg) => warnings.push(msg), + steps: [ + { + policy: { allow: ["apply_patch"] }, + label: "tools.profile (coding)", + stripPluginOnlyAllowlist: true, + }, + ], + }); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain("unknown entries (apply_patch)"); + expect(warnings[0]).toContain( + "shipped core tools but unavailable in the current runtime/provider/model/config", + ); + expect(warnings[0]).not.toContain("unless the plugin is enabled"); + }); + test("applies allowlist filtering when core tools are explicitly listed", () => { const tools = [{ name: "exec" }, { name: "process" }] as unknown as DummyTool[]; const filtered = applyToolPolicyPipeline({ diff --git a/src/agents/tool-policy-pipeline.ts b/src/agents/tool-policy-pipeline.ts index d3304a020d6..70a7bddaf29 100644 --- a/src/agents/tool-policy-pipeline.ts +++ b/src/agents/tool-policy-pipeline.ts @@ -1,5 +1,6 @@ import { filterToolsByPolicy } from "./pi-tools.policy.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; +import { isKnownCoreToolId } from "./tool-catalog.js"; import { buildPluginToolGroups, expandPolicyWithPluginGroups, @@ -91,9 +92,15 @@ export function applyToolPolicyPipeline(params: { const resolved = stripPluginOnlyAllowlist(policy, pluginGroups, coreToolNames); if (resolved.unknownAllowlist.length > 0) { const entries = resolved.unknownAllowlist.join(", "); - const suffix = resolved.strippedAllowlist - ? "Ignoring allowlist so core tools remain available. Use tools.alsoAllow for additive plugin tool enablement." - : "These entries won't match any tool unless the plugin is enabled."; + const gatedCoreEntries = resolved.unknownAllowlist.filter((entry) => + isKnownCoreToolId(entry), + ); + const otherEntries = resolved.unknownAllowlist.filter((entry) => !isKnownCoreToolId(entry)); + const suffix = describeUnknownAllowlistSuffix({ + strippedAllowlist: resolved.strippedAllowlist, + hasGatedCoreEntries: gatedCoreEntries.length > 0, + hasOtherEntries: otherEntries.length > 0, + }); params.warn( `tools: ${step.label} allowlist contains unknown entries (${entries}). ${suffix}`, ); @@ -106,3 +113,20 @@ export function applyToolPolicyPipeline(params: { } return filtered; } + +function describeUnknownAllowlistSuffix(params: { + strippedAllowlist: boolean; + hasGatedCoreEntries: boolean; + hasOtherEntries: boolean; +}): string { + const preface = params.strippedAllowlist + ? "Ignoring allowlist so core tools remain available." + : ""; + const detail = + params.hasGatedCoreEntries && params.hasOtherEntries + ? "Some entries are shipped core tools but unavailable in the current runtime/provider/model/config; other entries won't match any tool unless the plugin is enabled." + : params.hasGatedCoreEntries + ? "These entries are shipped core tools but unavailable in the current runtime/provider/model/config." + : "These entries won't match any tool unless the plugin is enabled."; + return preface ? `${preface} ${detail}` : detail; +} From 202765c8109b2c2320610958cf65795b19fade8c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:22:13 +0000 Subject: [PATCH 0462/1173] fix: quiet local windows gateway auth noise --- CHANGELOG.md | 1 + src/gateway/call.test.ts | 14 ++++++++++++++ src/gateway/call.ts | 20 +++++++++++++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cae46427d1e..2a8270dd154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups. +- Windows/gateway auth: stop attaching device identity on local loopback shared-token and password gateway calls, so native Windows agent replies no longer log stale `device signature expired` fallback noise before succeeding. - Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus. - Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv. - Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman. diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 87590e58d49..e4d8d28f562 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -14,6 +14,7 @@ let lastClientOptions: { password?: string; tlsFingerprint?: string; scopes?: string[]; + deviceIdentity?: unknown; onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise; onClose?: (code: number, reason: string) => void; } | null = null; @@ -197,6 +198,19 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.token).toBe("explicit-token"); }); + it("does not attach device identity for local loopback shared-token auth", async () => { + setLocalLoopbackGatewayConfig(); + + await callGateway({ + method: "health", + token: "explicit-token", + }); + + expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789"); + expect(lastClientOptions?.token).toBe("explicit-token"); + expect(lastClientOptions?.deviceIdentity).toBeUndefined(); + }); + it("uses OPENCLAW_GATEWAY_URL env override in remote mode when remote URL is missing", async () => { loadConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", remote: {} }, diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 31d11ac14b9..8e8f449fc59 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -81,6 +81,22 @@ export type GatewayConnectionDetails = { message: string; }; +function shouldAttachDeviceIdentityForGatewayCall(params: { + url: string; + token?: string; + password?: string; +}): boolean { + if (!(params.token || params.password)) { + return true; + } + try { + const parsed = new URL(params.url); + return !["127.0.0.1", "::1", "localhost"].includes(parsed.hostname); + } catch { + return true; + } +} + export type ExplicitGatewayAuth = { token?: string; password?: string; @@ -818,7 +834,9 @@ async function executeGatewayRequestWithScopes(params: { mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI, role: "operator", scopes, - deviceIdentity: loadOrCreateDeviceIdentity(), + deviceIdentity: shouldAttachDeviceIdentityForGatewayCall({ url, token, password }) + ? loadOrCreateDeviceIdentity() + : undefined, minProtocol: opts.minProtocol ?? PROTOCOL_VERSION, maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION, onHelloOk: async (hello) => { From f4ed3170832db59a9761178494126ca3307ec804 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:24:58 +0000 Subject: [PATCH 0463/1173] refactor: deduplicate acpx availability checks --- extensions/acpx/src/runtime.ts | 155 +++++++++++++++++++++------------ 1 file changed, 101 insertions(+), 54 deletions(-) diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index b0f166584d5..ad3fb23c709 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -13,7 +13,7 @@ import type { } from "openclaw/plugin-sdk/acpx"; import { AcpRuntimeError } from "openclaw/plugin-sdk/acpx"; import { toAcpMcpServers, type ResolvedAcpxPluginConfig } from "./config.js"; -import { checkAcpxVersion } from "./ensure.js"; +import { checkAcpxVersion, type AcpxVersionCheckResult } from "./ensure.js"; import { parseJsonLines, parsePromptEventLine, @@ -51,6 +51,28 @@ const ACPX_CAPABILITIES: AcpRuntimeCapabilities = { controls: ["session/set_mode", "session/set_config_option", "session/status"], }; +type AcpxHealthCheckResult = + | { + ok: true; + versionCheck: Extract; + } + | { + ok: false; + failure: + | { + kind: "version-check"; + versionCheck: Extract; + } + | { + kind: "help-check"; + result: Awaited>; + } + | { + kind: "exception"; + error: unknown; + }; + }; + function formatPermissionModeGuidance(): string { return "Configure plugins.entries.acpx.config.permissionMode to one of: approve-reads, approve-all, deny-all."; } @@ -165,35 +187,71 @@ export class AcpxRuntime implements AcpRuntime { ); } - async probeAvailability(): Promise { - const versionCheck = await checkAcpxVersion({ + private async checkVersion(): Promise { + return await checkAcpxVersion({ command: this.config.command, cwd: this.config.cwd, expectedVersion: this.config.expectedVersion, stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, spawnOptions: this.spawnCommandOptions, }); + } + + private async runHelpCheck(): Promise>> { + return await spawnAndCollect( + { + command: this.config.command, + args: ["--help"], + cwd: this.config.cwd, + stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, + }, + this.spawnCommandOptions, + ); + } + + private async checkHealth(): Promise { + const versionCheck = await this.checkVersion(); if (!versionCheck.ok) { - this.healthy = false; - return; + return { + ok: false, + failure: { + kind: "version-check", + versionCheck, + }, + }; } try { - const result = await spawnAndCollect( - { - command: this.config.command, - args: ["--help"], - cwd: this.config.cwd, - stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, + const result = await this.runHelpCheck(); + if (result.error != null || (result.code ?? 0) !== 0) { + return { + ok: false, + failure: { + kind: "help-check", + result, + }, + }; + } + return { + ok: true, + versionCheck, + }; + } catch (error) { + return { + ok: false, + failure: { + kind: "exception", + error, }, - this.spawnCommandOptions, - ); - this.healthy = result.error == null && (result.code ?? 0) === 0; - } catch { - this.healthy = false; + }; } } + async probeAvailability(): Promise { + const result = await this.checkHealth(); + this.healthy = result.ok; + } + async ensureSession(input: AcpRuntimeEnsureInput): Promise { const sessionName = asTrimmedString(input.sessionKey); if (!sessionName) { @@ -494,14 +552,9 @@ export class AcpxRuntime implements AcpRuntime { } async doctor(): Promise { - const versionCheck = await checkAcpxVersion({ - command: this.config.command, - cwd: this.config.cwd, - expectedVersion: this.config.expectedVersion, - stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, - spawnOptions: this.spawnCommandOptions, - }); - if (!versionCheck.ok) { + const result = await this.checkHealth(); + if (!result.ok && result.failure.kind === "version-check") { + const { versionCheck } = result.failure; this.healthy = false; const details = [ versionCheck.expectedVersion ? `expected=${versionCheck.expectedVersion}` : null, @@ -516,20 +569,12 @@ export class AcpxRuntime implements AcpRuntime { }; } - try { - const result = await spawnAndCollect( - { - command: this.config.command, - args: ["--help"], - cwd: this.config.cwd, - stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, - }, - this.spawnCommandOptions, - ); - if (result.error) { - const spawnFailure = resolveSpawnFailure(result.error, this.config.cwd); + if (!result.ok && result.failure.kind === "help-check") { + const { result: helpResult } = result.failure; + this.healthy = false; + if (helpResult.error) { + const spawnFailure = resolveSpawnFailure(helpResult.error, this.config.cwd); if (spawnFailure === "missing-command") { - this.healthy = false; return { ok: false, code: "ACP_BACKEND_UNAVAILABLE", @@ -538,42 +583,44 @@ export class AcpxRuntime implements AcpRuntime { }; } if (spawnFailure === "missing-cwd") { - this.healthy = false; return { ok: false, code: "ACP_BACKEND_UNAVAILABLE", message: `ACP runtime working directory does not exist: ${this.config.cwd}`, }; } - this.healthy = false; return { ok: false, code: "ACP_BACKEND_UNAVAILABLE", - message: result.error.message, - details: [String(result.error)], + message: helpResult.error.message, + details: [String(helpResult.error)], }; } - if ((result.code ?? 0) !== 0) { - this.healthy = false; - return { - ok: false, - code: "ACP_BACKEND_UNAVAILABLE", - message: result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`, - }; - } - this.healthy = true; return { - ok: true, - message: `acpx command available (${this.config.command}, version ${versionCheck.version}${this.config.expectedVersion ? `, expected ${this.config.expectedVersion}` : ""})`, + ok: false, + code: "ACP_BACKEND_UNAVAILABLE", + message: + helpResult.stderr.trim() || `acpx exited with code ${helpResult.code ?? "unknown"}`, }; - } catch (error) { + } + + if (!result.ok) { this.healthy = false; return { ok: false, code: "ACP_BACKEND_UNAVAILABLE", - message: error instanceof Error ? error.message : String(error), + message: + result.failure.error instanceof Error + ? result.failure.error.message + : String(result.failure.error), }; } + + this.healthy = true; + return { + ok: true, + message: `acpx command available (${this.config.command}, version ${result.versionCheck.version}${this.config.expectedVersion ? `, expected ${this.config.expectedVersion}` : ""})`, + }; } async cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise { From a37e25fa21aba307bc7dd3846a888989be43d0c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:25:54 +0000 Subject: [PATCH 0464/1173] refactor: deduplicate media store writes --- src/media/store.ts | 71 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 21 deletions(-) diff --git a/src/media/store.ts b/src/media/store.ts index ceb346a1f94..32acd951d32 100644 --- a/src/media/store.ts +++ b/src/media/store.ts @@ -255,6 +255,48 @@ export type SavedMedia = { contentType?: string; }; +function buildSavedMediaId(params: { + baseId: string; + ext: string; + originalFilename?: string; +}): string { + if (!params.originalFilename) { + return params.ext ? `${params.baseId}${params.ext}` : params.baseId; + } + + const base = path.parse(params.originalFilename).name; + const sanitized = sanitizeFilename(base); + return sanitized + ? `${sanitized}---${params.baseId}${params.ext}` + : `${params.baseId}${params.ext}`; +} + +function buildSavedMediaResult(params: { + dir: string; + id: string; + size: number; + contentType?: string; +}): SavedMedia { + return { + id: params.id, + path: path.join(params.dir, params.id), + size: params.size, + contentType: params.contentType, + }; +} + +async function writeSavedMediaBuffer(params: { + dir: string; + id: string; + buffer: Buffer; +}): Promise { + const dest = path.join(params.dir, params.id); + await retryAfterRecreatingDir(params.dir, () => + fs.writeFile(dest, params.buffer, { mode: MEDIA_FILE_MODE }), + ); + return dest; +} + export type SaveMediaSourceErrorCode = | "invalid-path" | "not-found" @@ -321,20 +363,19 @@ export async function saveMediaSource( filePath: source, }); const ext = extensionForMime(mime) ?? path.extname(new URL(source).pathname); - const id = ext ? `${baseId}${ext}` : baseId; + const id = buildSavedMediaId({ baseId, ext }); const finalDest = path.join(dir, id); await fs.rename(tempDest, finalDest); - return { id, path: finalDest, size, contentType: mime }; + return buildSavedMediaResult({ dir, id, size, contentType: mime }); } // local path try { const { buffer, stat } = await readLocalFileSafely({ filePath: source, maxBytes: MAX_BYTES }); const mime = await detectMime({ buffer, filePath: source }); const ext = extensionForMime(mime) ?? path.extname(source); - const id = ext ? `${baseId}${ext}` : baseId; - const dest = path.join(dir, id); - await retryAfterRecreatingDir(dir, () => fs.writeFile(dest, buffer, { mode: MEDIA_FILE_MODE })); - return { id, path: dest, size: stat.size, contentType: mime }; + const id = buildSavedMediaId({ baseId, ext }); + await writeSavedMediaBuffer({ dir, id, buffer }); + return buildSavedMediaResult({ dir, id, size: stat.size, contentType: mime }); } catch (err) { if (err instanceof SafeOpenError) { throw toSaveMediaSourceError(err); @@ -359,19 +400,7 @@ export async function saveMediaBuffer( const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined); const mime = await detectMime({ buffer, headerMime: contentType }); const ext = headerExt ?? extensionForMime(mime) ?? ""; - - let id: string; - if (originalFilename) { - // Embed original name: {sanitized}---{uuid}.ext - const base = path.parse(originalFilename).name; - const sanitized = sanitizeFilename(base); - id = sanitized ? `${sanitized}---${uuid}${ext}` : `${uuid}${ext}`; - } else { - // Legacy: just UUID - id = ext ? `${uuid}${ext}` : uuid; - } - - const dest = path.join(dir, id); - await retryAfterRecreatingDir(dir, () => fs.writeFile(dest, buffer, { mode: MEDIA_FILE_MODE })); - return { id, path: dest, size: buffer.byteLength, contentType: mime }; + const id = buildSavedMediaId({ baseId: uuid, ext, originalFilename }); + await writeSavedMediaBuffer({ dir, id, buffer }); + return buildSavedMediaResult({ dir, id, size: buffer.byteLength, contentType: mime }); } From 501837058cb811d0f310b2473b2bfd18d2b562ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:26:42 +0000 Subject: [PATCH 0465/1173] refactor: share outbound media payload sequencing --- .../plugins/outbound/direct-text-media.ts | 56 +++++++++++++------ src/channels/plugins/outbound/telegram.ts | 27 ++++----- 2 files changed, 52 insertions(+), 31 deletions(-) diff --git a/src/channels/plugins/outbound/direct-text-media.ts b/src/channels/plugins/outbound/direct-text-media.ts index 9617798325d..ea813fcf75b 100644 --- a/src/channels/plugins/outbound/direct-text-media.ts +++ b/src/channels/plugins/outbound/direct-text-media.ts @@ -28,34 +28,58 @@ type SendPayloadAdapter = Pick< "sendMedia" | "sendText" | "chunker" | "textChunkLimit" >; +export function resolvePayloadMediaUrls(payload: SendPayloadContext["payload"]): string[] { + return payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : []; +} + +export async function sendPayloadMediaSequence(params: { + text: string; + mediaUrls: readonly string[]; + send: (input: { + text: string; + mediaUrl: string; + index: number; + isFirst: boolean; + }) => Promise; +}): Promise { + let lastResult: TResult | undefined; + for (let i = 0; i < params.mediaUrls.length; i += 1) { + const mediaUrl = params.mediaUrls[i]; + if (!mediaUrl) { + continue; + } + lastResult = await params.send({ + text: i === 0 ? params.text : "", + mediaUrl, + index: i, + isFirst: i === 0, + }); + } + return lastResult; +} + export async function sendTextMediaPayload(params: { channel: string; ctx: SendPayloadContext; adapter: SendPayloadAdapter; }): Promise { const text = params.ctx.payload.text ?? ""; - const urls = params.ctx.payload.mediaUrls?.length - ? params.ctx.payload.mediaUrls - : params.ctx.payload.mediaUrl - ? [params.ctx.payload.mediaUrl] - : []; + const urls = resolvePayloadMediaUrls(params.ctx.payload); if (!text && urls.length === 0) { return { channel: params.channel, messageId: "" }; } if (urls.length > 0) { - let lastResult = await params.adapter.sendMedia!({ - ...params.ctx, + const lastResult = await sendPayloadMediaSequence({ text, - mediaUrl: urls[0], + mediaUrls: urls, + send: async ({ text, mediaUrl }) => + await params.adapter.sendMedia!({ + ...params.ctx, + text, + mediaUrl, + }), }); - for (let i = 1; i < urls.length; i++) { - lastResult = await params.adapter.sendMedia!({ - ...params.ctx, - text: "", - mediaUrl: urls[i], - }); - } - return lastResult; + return lastResult ?? { channel: params.channel, messageId: "" }; } const limit = params.adapter.textChunkLimit; const chunks = limit && params.adapter.chunker ? params.adapter.chunker(text, limit) : [text]; diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index 8af1b5831ee..c96a44a7047 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -8,6 +8,7 @@ import { } from "../../../telegram/outbound-params.js"; import { sendMessageTelegram } from "../../../telegram/send.js"; import type { ChannelOutboundAdapter } from "../types.js"; +import { resolvePayloadMediaUrls, sendPayloadMediaSequence } from "./direct-text-media.js"; type TelegramSendFn = typeof sendMessageTelegram; type TelegramSendOpts = Parameters[2]; @@ -55,11 +56,7 @@ export async function sendTelegramPayloadMessages(params: { const quoteText = typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; const text = params.payload.text ?? ""; - const mediaUrls = params.payload.mediaUrls?.length - ? params.payload.mediaUrls - : params.payload.mediaUrl - ? [params.payload.mediaUrl] - : []; + const mediaUrls = resolvePayloadMediaUrls(params.payload); const payloadOpts = { ...params.baseOpts, quoteText, @@ -73,16 +70,16 @@ export async function sendTelegramPayloadMessages(params: { } // Telegram allows reply_markup on media; attach buttons only to the first send. - let finalResult: Awaited> | undefined; - for (let i = 0; i < mediaUrls.length; i += 1) { - const mediaUrl = mediaUrls[i]; - const isFirst = i === 0; - finalResult = await params.send(params.to, isFirst ? text : "", { - ...payloadOpts, - mediaUrl, - ...(isFirst ? { buttons: telegramData?.buttons } : {}), - }); - } + const finalResult = await sendPayloadMediaSequence({ + text, + mediaUrls, + send: async ({ text, mediaUrl, isFirst }) => + await params.send(params.to, text, { + ...payloadOpts, + mediaUrl, + ...(isFirst ? { buttons: telegramData?.buttons } : {}), + }), + }); return finalResult ?? { messageId: "unknown", chatId: params.to }; } From 3f37afd18cd9083dac4c709acb44c11b73325a0f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:27:18 +0000 Subject: [PATCH 0466/1173] refactor: extract acpx event builders --- .../acpx/src/runtime-internals/events.ts | 98 ++++++++++--------- 1 file changed, 51 insertions(+), 47 deletions(-) diff --git a/extensions/acpx/src/runtime-internals/events.ts b/extensions/acpx/src/runtime-internals/events.ts index f83f4ddabb9..f0326bbe938 100644 --- a/extensions/acpx/src/runtime-internals/events.ts +++ b/extensions/acpx/src/runtime-internals/events.ts @@ -162,6 +162,39 @@ function resolveTextChunk(params: { }; } +function createTextDeltaEvent(params: { + content: string | null | undefined; + stream: "output" | "thought"; + tag?: AcpSessionUpdateTag; +}): AcpRuntimeEvent | null { + if (params.content == null || params.content.length === 0) { + return null; + } + return { + type: "text_delta", + text: params.content, + stream: params.stream, + ...(params.tag ? { tag: params.tag } : {}), + }; +} + +function createToolCallEvent(params: { + payload: Record; + tag: AcpSessionUpdateTag; +}): AcpRuntimeEvent { + const title = asTrimmedString(params.payload.title) || "tool call"; + const status = asTrimmedString(params.payload.status); + const toolCallId = asOptionalString(params.payload.toolCallId); + return { + type: "tool_call", + text: status ? `${title} (${status})` : title, + tag: params.tag, + ...(toolCallId ? { toolCallId } : {}), + ...(status ? { status } : {}), + title, + }; +} + export function parsePromptEventLine(line: string): AcpRuntimeEvent | null { const trimmed = line.trim(); if (!trimmed) { @@ -187,57 +220,28 @@ export function parsePromptEventLine(line: string): AcpRuntimeEvent | null { const tag = structured.tag; switch (type) { - case "text": { - const content = asString(payload.content); - if (content == null || content.length === 0) { - return null; - } - return { - type: "text_delta", - text: content, + case "text": + return createTextDeltaEvent({ + content: asString(payload.content), stream: "output", - ...(tag ? { tag } : {}), - }; - } - case "thought": { - const content = asString(payload.content); - if (content == null || content.length === 0) { - return null; - } - return { - type: "text_delta", - text: content, + tag, + }); + case "thought": + return createTextDeltaEvent({ + content: asString(payload.content), stream: "thought", - ...(tag ? { tag } : {}), - }; - } - case "tool_call": { - const title = asTrimmedString(payload.title) || "tool call"; - const status = asTrimmedString(payload.status); - const toolCallId = asOptionalString(payload.toolCallId); - return { - type: "tool_call", - text: status ? `${title} (${status})` : title, + tag, + }); + case "tool_call": + return createToolCallEvent({ + payload, tag: (tag ?? "tool_call") as AcpSessionUpdateTag, - ...(toolCallId ? { toolCallId } : {}), - ...(status ? { status } : {}), - title, - }; - } - case "tool_call_update": { - const title = asTrimmedString(payload.title) || "tool call"; - const status = asTrimmedString(payload.status); - const toolCallId = asOptionalString(payload.toolCallId); - const text = status ? `${title} (${status})` : title; - return { - type: "tool_call", - text, + }); + case "tool_call_update": + return createToolCallEvent({ + payload, tag: (tag ?? "tool_call_update") as AcpSessionUpdateTag, - ...(toolCallId ? { toolCallId } : {}), - ...(status ? { status } : {}), - title, - }; - } + }); case "agent_message_chunk": return resolveTextChunk({ payload, From 261a40dae12c181ce78b5572dfb94ca63e652886 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:28:31 +0000 Subject: [PATCH 0467/1173] fix: narrow acpx health failure handling --- extensions/acpx/src/runtime.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index ad3fb23c709..e55ef360424 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -606,13 +606,16 @@ export class AcpxRuntime implements AcpRuntime { if (!result.ok) { this.healthy = false; + const failure = result.failure; return { ok: false, code: "ACP_BACKEND_UNAVAILABLE", message: - result.failure.error instanceof Error - ? result.failure.error.message - : String(result.failure.error), + failure.kind === "exception" + ? failure.error instanceof Error + ? failure.error.message + : String(failure.error) + : "acpx backend unavailable", }; } From 41718404a1ddcce7726fbcbae278fc46ff31f959 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:41:22 +0000 Subject: [PATCH 0468/1173] ci: opt workflows into Node 24 action runtime --- .github/workflows/auto-response.yml | 3 +++ .github/workflows/ci.yml | 3 +++ .github/workflows/codeql.yml | 3 +++ .github/workflows/docker-release.yml | 1 + .github/workflows/install-smoke.yml | 3 +++ .github/workflows/labeler.yml | 3 +++ .github/workflows/openclaw-npm-release.yml | 1 + .github/workflows/sandbox-common-smoke.yml | 3 +++ .github/workflows/stale.yml | 3 +++ .github/workflows/workflow-sanity.yml | 3 +++ 10 files changed, 26 insertions(+) diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index d9d810bffa7..c3aca216775 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -8,6 +8,9 @@ on: pull_request_target: types: [labeled] +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + permissions: {} jobs: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9038096a488..18c6f14fdaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,9 @@ concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + jobs: # Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android). # Lint and format always run. Fail-safe: if detection fails, run everything. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1d8e473af4f..e01f7185a37 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -7,6 +7,9 @@ concurrency: group: codeql-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + permissions: actions: read contents: read diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 3ad4b539311..0486bc76760 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -18,6 +18,7 @@ concurrency: cancel-in-progress: false env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index ca04748f9bf..26b5de0e2b6 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -10,6 +10,9 @@ concurrency: group: install-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + jobs: docs-scope: runs-on: blacksmith-16vcpu-ubuntu-2404 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 8de54a416f8..716f39ea24c 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -16,6 +16,9 @@ on: required: false default: "50" +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + permissions: {} jobs: diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index f3783045820..e690896bdd2 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -10,6 +10,7 @@ concurrency: cancel-in-progress: false env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" NODE_VERSION: "24.x" PNPM_VERSION: "10.23.0" diff --git a/.github/workflows/sandbox-common-smoke.yml b/.github/workflows/sandbox-common-smoke.yml index 8ece9010a20..5320ef7d712 100644 --- a/.github/workflows/sandbox-common-smoke.yml +++ b/.github/workflows/sandbox-common-smoke.yml @@ -17,6 +17,9 @@ concurrency: group: sandbox-common-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + jobs: sandbox-common-smoke: runs-on: blacksmith-16vcpu-ubuntu-2404 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index e6feef90e6b..f36361e987e 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -5,6 +5,9 @@ on: - cron: "17 3 * * *" workflow_dispatch: +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + permissions: {} jobs: diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml index 19668e697ad..e6cbaa8c9e0 100644 --- a/.github/workflows/workflow-sanity.yml +++ b/.github/workflows/workflow-sanity.yml @@ -9,6 +9,9 @@ concurrency: group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + jobs: no-tabs: runs-on: blacksmith-16vcpu-ubuntu-2404 From 966653e1749d13dfe70f3579c7c0a15f60fec88c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:48:34 +0000 Subject: [PATCH 0469/1173] ci: suppress expected zizmor pull_request_target findings --- .github/workflows/auto-response.yml | 2 +- .github/workflows/labeler.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index c3aca216775..cc1601886a4 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -5,7 +5,7 @@ on: types: [opened, edited, labeled] issue_comment: types: [created] - pull_request_target: + pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned label automation; no untrusted checkout or code execution types: [labeled] env: diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 716f39ea24c..8e7d707a3d1 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -1,7 +1,7 @@ name: Labeler on: - pull_request_target: + pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned triage workflow; no untrusted checkout or PR code execution types: [opened, synchronize, reopened] issues: types: [opened] From ef8cc3d0fb083c965e89932ad52b2d69879a9533 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:32:26 +0000 Subject: [PATCH 0470/1173] refactor: share tlon inline text rendering --- extensions/tlon/src/monitor/utils.ts | 131 +++++++++++---------------- 1 file changed, 55 insertions(+), 76 deletions(-) diff --git a/extensions/tlon/src/monitor/utils.ts b/extensions/tlon/src/monitor/utils.ts index c0649dfbe85..3eccbf6cbc9 100644 --- a/extensions/tlon/src/monitor/utils.ts +++ b/extensions/tlon/src/monitor/utils.ts @@ -162,41 +162,55 @@ export function isGroupInviteAllowed( } // Helper to recursively extract text from inline content +function renderInlineItem( + item: any, + options?: { + linkMode?: "content-or-href" | "href"; + allowBreak?: boolean; + allowBlockquote?: boolean; + }, +): string { + if (typeof item === "string") { + return item; + } + if (!item || typeof item !== "object") { + return ""; + } + if (item.ship) { + return item.ship; + } + if ("sect" in item) { + return `@${item.sect || "all"}`; + } + if (options?.allowBreak && item.break !== undefined) { + return "\n"; + } + if (item["inline-code"]) { + return `\`${item["inline-code"]}\``; + } + if (item.code) { + return `\`${item.code}\``; + } + if (item.link && item.link.href) { + return options?.linkMode === "href" ? item.link.href : item.link.content || item.link.href; + } + if (item.bold && Array.isArray(item.bold)) { + return `**${extractInlineText(item.bold)}**`; + } + if (item.italics && Array.isArray(item.italics)) { + return `*${extractInlineText(item.italics)}*`; + } + if (item.strike && Array.isArray(item.strike)) { + return `~~${extractInlineText(item.strike)}~~`; + } + if (options?.allowBlockquote && item.blockquote && Array.isArray(item.blockquote)) { + return `> ${extractInlineText(item.blockquote)}`; + } + return ""; +} + function extractInlineText(items: any[]): string { - return items - .map((item: any) => { - if (typeof item === "string") { - return item; - } - if (item && typeof item === "object") { - if (item.ship) { - return item.ship; - } - if ("sect" in item) { - return `@${item.sect || "all"}`; - } - if (item["inline-code"]) { - return `\`${item["inline-code"]}\``; - } - if (item.code) { - return `\`${item.code}\``; - } - if (item.link && item.link.href) { - return item.link.content || item.link.href; - } - if (item.bold && Array.isArray(item.bold)) { - return `**${extractInlineText(item.bold)}**`; - } - if (item.italics && Array.isArray(item.italics)) { - return `*${extractInlineText(item.italics)}*`; - } - if (item.strike && Array.isArray(item.strike)) { - return `~~${extractInlineText(item.strike)}~~`; - } - } - return ""; - }) - .join(""); + return items.map((item: any) => renderInlineItem(item)).join(""); } export function extractMessageText(content: unknown): string { @@ -209,48 +223,13 @@ export function extractMessageText(content: unknown): string { // Handle inline content (text, ships, links, etc.) if (verse.inline && Array.isArray(verse.inline)) { return verse.inline - .map((item: any) => { - if (typeof item === "string") { - return item; - } - if (item && typeof item === "object") { - if (item.ship) { - return item.ship; - } - // Handle sect (role mentions like @all) - if ("sect" in item) { - return `@${item.sect || "all"}`; - } - if (item.break !== undefined) { - return "\n"; - } - if (item.link && item.link.href) { - return item.link.href; - } - // Handle inline code (Tlon uses "inline-code" key) - if (item["inline-code"]) { - return `\`${item["inline-code"]}\``; - } - if (item.code) { - return `\`${item.code}\``; - } - // Handle bold/italic/strike - recursively extract text - if (item.bold && Array.isArray(item.bold)) { - return `**${extractInlineText(item.bold)}**`; - } - if (item.italics && Array.isArray(item.italics)) { - return `*${extractInlineText(item.italics)}*`; - } - if (item.strike && Array.isArray(item.strike)) { - return `~~${extractInlineText(item.strike)}~~`; - } - // Handle blockquote inline - if (item.blockquote && Array.isArray(item.blockquote)) { - return `> ${extractInlineText(item.blockquote)}`; - } - } - return ""; - }) + .map((item: any) => + renderInlineItem(item, { + linkMode: "href", + allowBreak: true, + allowBlockquote: true, + }), + ) .join(""); } From 6b07604d64b8a59350fc420fe3152ebaa6530602 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:33:09 +0000 Subject: [PATCH 0471/1173] refactor: share nextcloud target normalization --- .../nextcloud-talk/src/normalize.test.ts | 28 +++++++++++++++++++ extensions/nextcloud-talk/src/normalize.ts | 9 ++++-- extensions/nextcloud-talk/src/send.ts | 18 ++---------- 3 files changed, 37 insertions(+), 18 deletions(-) create mode 100644 extensions/nextcloud-talk/src/normalize.test.ts diff --git a/extensions/nextcloud-talk/src/normalize.test.ts b/extensions/nextcloud-talk/src/normalize.test.ts new file mode 100644 index 00000000000..2419e063ff1 --- /dev/null +++ b/extensions/nextcloud-talk/src/normalize.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { + looksLikeNextcloudTalkTargetId, + normalizeNextcloudTalkMessagingTarget, + stripNextcloudTalkTargetPrefix, +} from "./normalize.js"; + +describe("nextcloud-talk target normalization", () => { + it("strips supported prefixes to a room token", () => { + expect(stripNextcloudTalkTargetPrefix(" room:abc123 ")).toBe("abc123"); + expect(stripNextcloudTalkTargetPrefix("nextcloud-talk:room:AbC123")).toBe("AbC123"); + expect(stripNextcloudTalkTargetPrefix("nc-talk:room:ops")).toBe("ops"); + expect(stripNextcloudTalkTargetPrefix("nc:room:ops")).toBe("ops"); + expect(stripNextcloudTalkTargetPrefix("room: ")).toBeUndefined(); + }); + + it("normalizes messaging targets to lowercase channel ids", () => { + expect(normalizeNextcloudTalkMessagingTarget("room:AbC123")).toBe("nextcloud-talk:abc123"); + expect(normalizeNextcloudTalkMessagingTarget("nc-talk:room:Ops")).toBe("nextcloud-talk:ops"); + }); + + it("detects prefixed and bare room ids", () => { + expect(looksLikeNextcloudTalkTargetId("nextcloud-talk:room:abc12345")).toBe(true); + expect(looksLikeNextcloudTalkTargetId("nc:opsroom1")).toBe(true); + expect(looksLikeNextcloudTalkTargetId("abc12345")).toBe(true); + expect(looksLikeNextcloudTalkTargetId("")).toBe(false); + }); +}); diff --git a/extensions/nextcloud-talk/src/normalize.ts b/extensions/nextcloud-talk/src/normalize.ts index 6854d603fc0..295caadd8a4 100644 --- a/extensions/nextcloud-talk/src/normalize.ts +++ b/extensions/nextcloud-talk/src/normalize.ts @@ -1,4 +1,4 @@ -export function normalizeNextcloudTalkMessagingTarget(raw: string): string | undefined { +export function stripNextcloudTalkTargetPrefix(raw: string): string | undefined { const trimmed = raw.trim(); if (!trimmed) { return undefined; @@ -22,7 +22,12 @@ export function normalizeNextcloudTalkMessagingTarget(raw: string): string | und return undefined; } - return `nextcloud-talk:${normalized}`.toLowerCase(); + return normalized; +} + +export function normalizeNextcloudTalkMessagingTarget(raw: string): string | undefined { + const normalized = stripNextcloudTalkTargetPrefix(raw); + return normalized ? `nextcloud-talk:${normalized}`.toLowerCase() : undefined; } export function looksLikeNextcloudTalkTargetId(raw: string): boolean { diff --git a/extensions/nextcloud-talk/src/send.ts b/extensions/nextcloud-talk/src/send.ts index 7cc8f05658c..4af8bde76f7 100644 --- a/extensions/nextcloud-talk/src/send.ts +++ b/extensions/nextcloud-talk/src/send.ts @@ -1,4 +1,5 @@ import { resolveNextcloudTalkAccount } from "./accounts.js"; +import { stripNextcloudTalkTargetPrefix } from "./normalize.js"; import { getNextcloudTalkRuntime } from "./runtime.js"; import { generateNextcloudTalkSignature } from "./signature.js"; import type { CoreConfig, NextcloudTalkSendResult } from "./types.js"; @@ -34,22 +35,7 @@ function resolveCredentials( } function normalizeRoomToken(to: string): string { - const trimmed = to.trim(); - if (!trimmed) { - throw new Error("Room token is required for Nextcloud Talk sends"); - } - - let normalized = trimmed; - if (normalized.startsWith("nextcloud-talk:")) { - normalized = normalized.slice("nextcloud-talk:".length).trim(); - } else if (normalized.startsWith("nc:")) { - normalized = normalized.slice("nc:".length).trim(); - } - - if (normalized.startsWith("room:")) { - normalized = normalized.slice("room:".length).trim(); - } - + const normalized = stripNextcloudTalkTargetPrefix(to); if (!normalized) { throw new Error("Room token is required for Nextcloud Talk sends"); } From a4525b721edd05680a20135fcac6e607c50966bf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:33:59 +0000 Subject: [PATCH 0472/1173] refactor: deduplicate nextcloud send context --- extensions/nextcloud-talk/src/send.ts | 30 ++++++++++++++------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/extensions/nextcloud-talk/src/send.ts b/extensions/nextcloud-talk/src/send.ts index 4af8bde76f7..2b6284a6fc2 100644 --- a/extensions/nextcloud-talk/src/send.ts +++ b/extensions/nextcloud-talk/src/send.ts @@ -42,11 +42,12 @@ function normalizeRoomToken(to: string): string { return normalized; } -export async function sendMessageNextcloudTalk( - to: string, - text: string, - opts: NextcloudTalkSendOpts = {}, -): Promise { +function resolveNextcloudTalkSendContext(opts: NextcloudTalkSendOpts): { + cfg: CoreConfig; + account: ReturnType; + baseUrl: string; + secret: string; +} { const cfg = (opts.cfg ?? getNextcloudTalkRuntime().config.loadConfig()) as CoreConfig; const account = resolveNextcloudTalkAccount({ cfg, @@ -56,6 +57,15 @@ export async function sendMessageNextcloudTalk( { baseUrl: opts.baseUrl, secret: opts.secret }, account, ); + return { cfg, account, baseUrl, secret }; +} + +export async function sendMessageNextcloudTalk( + to: string, + text: string, + opts: NextcloudTalkSendOpts = {}, +): Promise { + const { cfg, account, baseUrl, secret } = resolveNextcloudTalkSendContext(opts); const roomToken = normalizeRoomToken(to); if (!text?.trim()) { @@ -162,15 +172,7 @@ export async function sendReactionNextcloudTalk( reaction: string, opts: Omit = {}, ): Promise<{ ok: true }> { - const cfg = (opts.cfg ?? getNextcloudTalkRuntime().config.loadConfig()) as CoreConfig; - const account = resolveNextcloudTalkAccount({ - cfg, - accountId: opts.accountId, - }); - const { baseUrl, secret } = resolveCredentials( - { baseUrl: opts.baseUrl, secret: opts.secret }, - account, - ); + const { account, baseUrl, secret } = resolveNextcloudTalkSendContext(opts); const normalizedToken = normalizeRoomToken(roomToken); const body = JSON.stringify({ reaction }); From 1ff8de3a8a7a1990c2b2ce0f11be2cfefabf9f1a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:35:18 +0000 Subject: [PATCH 0473/1173] test: deduplicate session target discovery cases --- src/config/sessions/targets.test.ts | 305 ++++++++++------------------ 1 file changed, 104 insertions(+), 201 deletions(-) diff --git a/src/config/sessions/targets.test.ts b/src/config/sessions/targets.test.ts index 8d924c8feae..720cc3e892e 100644 --- a/src/config/sessions/targets.test.ts +++ b/src/config/sessions/targets.test.ts @@ -15,6 +15,58 @@ async function resolveRealStorePath(sessionsDir: string): Promise { return fsSync.realpathSync.native(path.join(sessionsDir, "sessions.json")); } +async function createAgentSessionStores( + root: string, + agentIds: string[], +): Promise> { + const storePaths: Record = {}; + for (const agentId of agentIds) { + const sessionsDir = path.join(root, "agents", agentId, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + await fs.writeFile(path.join(sessionsDir, "sessions.json"), "{}", "utf8"); + storePaths[agentId] = await resolveRealStorePath(sessionsDir); + } + return storePaths; +} + +function createCustomRootCfg(customRoot: string, defaultAgentId = "ops"): OpenClawConfig { + return { + session: { + store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), + }, + agents: { + list: [{ id: defaultAgentId, default: true }], + }, + }; +} + +function expectTargetsToContainStores( + targets: Array<{ agentId: string; storePath: string }>, + stores: Record, +): void { + expect(targets).toEqual( + expect.arrayContaining( + Object.entries(stores).map(([agentId, storePath]) => ({ + agentId, + storePath, + })), + ), + ); +} + +const discoveryResolvers = [ + { + label: "async", + resolve: async (cfg: OpenClawConfig, env: NodeJS.ProcessEnv) => + await resolveAllAgentSessionStoreTargets(cfg, { env }), + }, + { + label: "sync", + resolve: async (cfg: OpenClawConfig, env: NodeJS.ProcessEnv) => + resolveAllAgentSessionStoreTargetsSync(cfg, { env }), + }, +] as const; + describe("resolveSessionStoreTargets", () => { it("resolves all configured agent stores", () => { const cfg: OpenClawConfig = { @@ -83,97 +135,39 @@ describe("resolveAllAgentSessionStoreTargets", () => { it("includes discovered on-disk agent stores alongside configured targets", async () => { await withTempHome(async (home) => { const stateDir = path.join(home, ".openclaw"); - const opsSessionsDir = path.join(stateDir, "agents", "ops", "sessions"); - const retiredSessionsDir = path.join(stateDir, "agents", "retired", "sessions"); - await fs.mkdir(opsSessionsDir, { recursive: true }); - await fs.mkdir(retiredSessionsDir, { recursive: true }); - await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8"); - await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); + const storePaths = await createAgentSessionStores(stateDir, ["ops", "retired"]); const cfg: OpenClawConfig = { agents: { list: [{ id: "ops", default: true }], }, }; - const opsStorePath = await resolveRealStorePath(opsSessionsDir); - const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); - expect(targets).toEqual( - expect.arrayContaining([ - { - agentId: "ops", - storePath: opsStorePath, - }, - { - agentId: "retired", - storePath: retiredStorePath, - }, - ]), - ); - expect(targets.filter((target) => target.storePath === opsStorePath)).toHaveLength(1); + expectTargetsToContainStores(targets, storePaths); + expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1); }); }); it("discovers retired agent stores under a configured custom session root", async () => { await withTempHome(async (home) => { const customRoot = path.join(home, "custom-state"); - const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); - const retiredSessionsDir = path.join(customRoot, "agents", "retired", "sessions"); - await fs.mkdir(opsSessionsDir, { recursive: true }); - await fs.mkdir(retiredSessionsDir, { recursive: true }); - await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8"); - await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); - - const cfg: OpenClawConfig = { - session: { - store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), - }, - agents: { - list: [{ id: "ops", default: true }], - }, - }; - const opsStorePath = await resolveRealStorePath(opsSessionsDir); - const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); + const storePaths = await createAgentSessionStores(customRoot, ["ops", "retired"]); + const cfg = createCustomRootCfg(customRoot); const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); - expect(targets).toEqual( - expect.arrayContaining([ - { - agentId: "ops", - storePath: opsStorePath, - }, - { - agentId: "retired", - storePath: retiredStorePath, - }, - ]), - ); - expect(targets.filter((target) => target.storePath === opsStorePath)).toHaveLength(1); + expectTargetsToContainStores(targets, storePaths); + expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1); }); }); it("keeps the actual on-disk store path for discovered retired agents", async () => { await withTempHome(async (home) => { const customRoot = path.join(home, "custom-state"); - const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); - const retiredSessionsDir = path.join(customRoot, "agents", "Retired Agent", "sessions"); - await fs.mkdir(opsSessionsDir, { recursive: true }); - await fs.mkdir(retiredSessionsDir, { recursive: true }); - await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8"); - await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); - - const cfg: OpenClawConfig = { - session: { - store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), - }, - agents: { - list: [{ id: "ops", default: true }], - }, - }; - const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); + const storePaths = await createAgentSessionStores(customRoot, ["ops", "Retired Agent"]); + const cfg = createCustomRootCfg(customRoot); const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); @@ -181,7 +175,7 @@ describe("resolveAllAgentSessionStoreTargets", () => { expect.arrayContaining([ expect.objectContaining({ agentId: "retired-agent", - storePath: retiredStorePath, + storePath: storePaths["Retired Agent"], }), ]), ); @@ -223,73 +217,52 @@ describe("resolveAllAgentSessionStoreTargets", () => { }); }); - it("skips unreadable or invalid discovery roots when other roots are still readable", async () => { - await withTempHome(async (home) => { - const customRoot = path.join(home, "custom-state"); - await fs.mkdir(customRoot, { recursive: true }); - await fs.writeFile(path.join(customRoot, "agents"), "not-a-directory", "utf8"); + for (const resolver of discoveryResolvers) { + it(`skips unreadable or invalid discovery roots when other roots are still readable (${resolver.label})`, async () => { + await withTempHome(async (home) => { + const customRoot = path.join(home, "custom-state"); + await fs.mkdir(customRoot, { recursive: true }); + await fs.writeFile(path.join(customRoot, "agents"), "not-a-directory", "utf8"); - const envStateDir = path.join(home, "env-state"); - const mainSessionsDir = path.join(envStateDir, "agents", "main", "sessions"); - const retiredSessionsDir = path.join(envStateDir, "agents", "retired", "sessions"); - await fs.mkdir(mainSessionsDir, { recursive: true }); - await fs.mkdir(retiredSessionsDir, { recursive: true }); - await fs.writeFile(path.join(mainSessionsDir, "sessions.json"), "{}", "utf8"); - await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); + const envStateDir = path.join(home, "env-state"); + const storePaths = await createAgentSessionStores(envStateDir, ["main", "retired"]); + const cfg = createCustomRootCfg(customRoot, "main"); + const env = { + ...process.env, + OPENCLAW_STATE_DIR: envStateDir, + }; - const cfg: OpenClawConfig = { - session: { - store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), - }, - agents: { - list: [{ id: "main", default: true }], - }, - }; - const env = { - ...process.env, - OPENCLAW_STATE_DIR: envStateDir, - }; - const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); - - await expect(resolveAllAgentSessionStoreTargets(cfg, { env })).resolves.toEqual( - expect.arrayContaining([ - { - agentId: "retired", - storePath: retiredStorePath, - }, - ]), - ); - }); - }); - - it("skips symlinked discovered stores under templated agents roots", async () => { - await withTempHome(async (home) => { - if (process.platform === "win32") { - return; - } - const customRoot = path.join(home, "custom-state"); - const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); - const leakedFile = path.join(home, "outside.json"); - await fs.mkdir(opsSessionsDir, { recursive: true }); - await fs.writeFile(leakedFile, JSON.stringify({ leak: { secret: "x" } }), "utf8"); - await fs.symlink(leakedFile, path.join(opsSessionsDir, "sessions.json")); - - const cfg: OpenClawConfig = { - session: { - store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), - }, - agents: { - list: [{ id: "ops", default: true }], - }, - }; - - const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); - expect(targets).not.toContainEqual({ - agentId: "ops", - storePath: expect.stringContaining(path.join("ops", "sessions", "sessions.json")), + await expect(resolver.resolve(cfg, env)).resolves.toEqual( + expect.arrayContaining([ + { + agentId: "retired", + storePath: storePaths.retired, + }, + ]), + ); }); }); - }); + + it(`skips symlinked discovered stores under templated agents roots (${resolver.label})`, async () => { + await withTempHome(async (home) => { + if (process.platform === "win32") { + return; + } + const customRoot = path.join(home, "custom-state"); + const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); + const leakedFile = path.join(home, "outside.json"); + await fs.mkdir(opsSessionsDir, { recursive: true }); + await fs.writeFile(leakedFile, JSON.stringify({ leak: { secret: "x" } }), "utf8"); + await fs.symlink(leakedFile, path.join(opsSessionsDir, "sessions.json")); + + const targets = await resolver.resolve(createCustomRootCfg(customRoot), process.env); + expect(targets).not.toContainEqual({ + agentId: "ops", + storePath: expect.stringContaining(path.join("ops", "sessions", "sessions.json")), + }); + }); + }); + } it("skips discovered directories that only normalize into the default main agent", async () => { await withTempHome(async (home) => { @@ -315,73 +288,3 @@ describe("resolveAllAgentSessionStoreTargets", () => { }); }); }); - -describe("resolveAllAgentSessionStoreTargetsSync", () => { - it("skips unreadable or invalid discovery roots when other roots are still readable", async () => { - await withTempHome(async (home) => { - const customRoot = path.join(home, "custom-state"); - await fs.mkdir(customRoot, { recursive: true }); - await fs.writeFile(path.join(customRoot, "agents"), "not-a-directory", "utf8"); - - const envStateDir = path.join(home, "env-state"); - const mainSessionsDir = path.join(envStateDir, "agents", "main", "sessions"); - const retiredSessionsDir = path.join(envStateDir, "agents", "retired", "sessions"); - await fs.mkdir(mainSessionsDir, { recursive: true }); - await fs.mkdir(retiredSessionsDir, { recursive: true }); - await fs.writeFile(path.join(mainSessionsDir, "sessions.json"), "{}", "utf8"); - await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); - - const cfg: OpenClawConfig = { - session: { - store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), - }, - agents: { - list: [{ id: "main", default: true }], - }, - }; - const env = { - ...process.env, - OPENCLAW_STATE_DIR: envStateDir, - }; - const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); - - expect(resolveAllAgentSessionStoreTargetsSync(cfg, { env })).toEqual( - expect.arrayContaining([ - { - agentId: "retired", - storePath: retiredStorePath, - }, - ]), - ); - }); - }); - - it("skips symlinked discovered stores under templated agents roots", async () => { - await withTempHome(async (home) => { - if (process.platform === "win32") { - return; - } - const customRoot = path.join(home, "custom-state"); - const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); - const leakedFile = path.join(home, "outside.json"); - await fs.mkdir(opsSessionsDir, { recursive: true }); - await fs.writeFile(leakedFile, JSON.stringify({ leak: { secret: "x" } }), "utf8"); - await fs.symlink(leakedFile, path.join(opsSessionsDir, "sessions.json")); - - const cfg: OpenClawConfig = { - session: { - store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), - }, - agents: { - list: [{ id: "ops", default: true }], - }, - }; - - const targets = resolveAllAgentSessionStoreTargetsSync(cfg, { env: process.env }); - expect(targets).not.toContainEqual({ - agentId: "ops", - storePath: expect.stringContaining(path.join("ops", "sessions", "sessions.json")), - }); - }); - }); -}); From 7b8e48ffb6130a93c3d97cfdb3f5f59fc3ece514 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:36:16 +0000 Subject: [PATCH 0474/1173] refactor: share cron manual run preflight --- src/cron/service/ops.ts | 54 ++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index c027c8d553f..de2c581bf68 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -360,13 +360,23 @@ type ManualRunDisposition = | Extract | { ok: true; runnable: true }; +type ManualRunPreflightResult = + | { ok: false } + | Extract + | { + ok: true; + runnable: true; + job: CronJob; + now: number; + }; + let nextManualRunId = 1; -async function inspectManualRunDisposition( +async function inspectManualRunPreflight( state: CronServiceState, id: string, mode?: "due" | "force", -): Promise { +): Promise { return await locked(state, async () => { warnIfDisabled(state, "run"); await ensureLoaded(state, { skipRecompute: true }); @@ -383,46 +393,50 @@ async function inspectManualRunDisposition( if (!due) { return { ok: true, ran: false, reason: "not-due" as const }; } - return { ok: true, runnable: true } as const; + return { ok: true, runnable: true, job, now } as const; }); } +async function inspectManualRunDisposition( + state: CronServiceState, + id: string, + mode?: "due" | "force", +): Promise { + const result = await inspectManualRunPreflight(state, id, mode); + if (!result.ok || !result.runnable) { + return result; + } + return { ok: true, runnable: true } as const; +} + async function prepareManualRun( state: CronServiceState, id: string, mode?: "due" | "force", ): Promise { + const preflight = await inspectManualRunPreflight(state, id, mode); + if (!preflight.ok || !preflight.runnable) { + return preflight; + } return await locked(state, async () => { - warnIfDisabled(state, "run"); - await ensureLoaded(state, { skipRecompute: true }); - // Normalize job tick state (clears stale runningAtMs markers) before - // checking if already running, so a stale marker from a crashed Phase-1 - // persist does not block manual triggers for up to STUCK_RUN_MS (#17554). - recomputeNextRunsForMaintenance(state); + // Reserve this run under lock, then execute outside lock so read ops + // (`list`, `status`) stay responsive while the run is in progress. const job = findJobOrThrow(state, id); if (typeof job.state.runningAtMs === "number") { return { ok: true, ran: false, reason: "already-running" as const }; } - const now = state.deps.nowMs(); - const due = isJobDue(job, now, { forced: mode === "force" }); - if (!due) { - return { ok: true, ran: false, reason: "not-due" as const }; - } - - // Reserve this run under lock, then execute outside lock so read ops - // (`list`, `status`) stay responsive while the run is in progress. - job.state.runningAtMs = now; + job.state.runningAtMs = preflight.now; job.state.lastError = undefined; // Persist the running marker before releasing lock so timer ticks that // force-reload from disk cannot start the same job concurrently. await persist(state); - emit(state, { jobId: job.id, action: "started", runAtMs: now }); + emit(state, { jobId: job.id, action: "started", runAtMs: preflight.now }); const executionJob = JSON.parse(JSON.stringify(job)) as CronJob; return { ok: true, ran: true, jobId: job.id, - startedAt: now, + startedAt: preflight.now, executionJob, } as const; }); From e94ac57f803c6db746f35d5356426e964da72918 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:36:39 +0000 Subject: [PATCH 0475/1173] refactor: reuse gateway talk provider schema fields --- src/gateway/protocol/schema/channels.ts | 33 ++++++++++--------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/src/gateway/protocol/schema/channels.ts b/src/gateway/protocol/schema/channels.ts index ee4d6d1ea1f..041318897ac 100644 --- a/src/gateway/protocol/schema/channels.ts +++ b/src/gateway/protocol/schema/channels.ts @@ -16,16 +16,17 @@ export const TalkConfigParamsSchema = Type.Object( { additionalProperties: false }, ); -const TalkProviderConfigSchema = Type.Object( - { - voiceId: Type.Optional(Type.String()), - voiceAliases: Type.Optional(Type.Record(Type.String(), Type.String())), - modelId: Type.Optional(Type.String()), - outputFormat: Type.Optional(Type.String()), - apiKey: Type.Optional(SecretInputSchema), - }, - { additionalProperties: true }, -); +const talkProviderFieldSchemas = { + voiceId: Type.Optional(Type.String()), + voiceAliases: Type.Optional(Type.Record(Type.String(), Type.String())), + modelId: Type.Optional(Type.String()), + outputFormat: Type.Optional(Type.String()), + apiKey: Type.Optional(SecretInputSchema), +}; + +const TalkProviderConfigSchema = Type.Object(talkProviderFieldSchemas, { + additionalProperties: true, +}); const ResolvedTalkConfigSchema = Type.Object( { @@ -37,11 +38,7 @@ const ResolvedTalkConfigSchema = Type.Object( const LegacyTalkConfigSchema = Type.Object( { - voiceId: Type.Optional(Type.String()), - voiceAliases: Type.Optional(Type.Record(Type.String(), Type.String())), - modelId: Type.Optional(Type.String()), - outputFormat: Type.Optional(Type.String()), - apiKey: Type.Optional(SecretInputSchema), + ...talkProviderFieldSchemas, interruptOnSpeech: Type.Optional(Type.Boolean()), silenceTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })), }, @@ -53,11 +50,7 @@ const NormalizedTalkConfigSchema = Type.Object( provider: Type.Optional(Type.String()), providers: Type.Optional(Type.Record(Type.String(), TalkProviderConfigSchema)), resolved: ResolvedTalkConfigSchema, - voiceId: Type.Optional(Type.String()), - voiceAliases: Type.Optional(Type.Record(Type.String(), Type.String())), - modelId: Type.Optional(Type.String()), - outputFormat: Type.Optional(Type.String()), - apiKey: Type.Optional(SecretInputSchema), + ...talkProviderFieldSchemas, interruptOnSpeech: Type.Optional(Type.Boolean()), silenceTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })), }, From 6b04ab1e35ed9b310b42f68dac646c17876cdb2f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:37:50 +0000 Subject: [PATCH 0476/1173] refactor: share teams drive upload flow --- extensions/msteams/src/graph-upload.test.ts | 101 ++++++++++++++++ extensions/msteams/src/graph-upload.ts | 124 +++++++++----------- 2 files changed, 157 insertions(+), 68 deletions(-) create mode 100644 extensions/msteams/src/graph-upload.test.ts diff --git a/extensions/msteams/src/graph-upload.test.ts b/extensions/msteams/src/graph-upload.test.ts new file mode 100644 index 00000000000..484075984dd --- /dev/null +++ b/extensions/msteams/src/graph-upload.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; +import { uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js"; + +describe("graph upload helpers", () => { + const tokenProvider = { + getAccessToken: vi.fn(async () => "graph-token"), + }; + + it("uploads to OneDrive with the personal drive path", async () => { + const fetchFn = vi.fn( + async () => + new Response( + JSON.stringify({ id: "item-1", webUrl: "https://example.com/1", name: "a.txt" }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); + + const result = await uploadToOneDrive({ + buffer: Buffer.from("hello"), + filename: "a.txt", + tokenProvider, + fetchFn: fetchFn as typeof fetch, + }); + + expect(fetchFn).toHaveBeenCalledWith( + "https://graph.microsoft.com/v1.0/me/drive/root:/OpenClawShared/a.txt:/content", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ + Authorization: "Bearer graph-token", + "Content-Type": "application/octet-stream", + }), + }), + ); + expect(result).toEqual({ + id: "item-1", + webUrl: "https://example.com/1", + name: "a.txt", + }); + }); + + it("uploads to SharePoint with the site drive path", async () => { + const fetchFn = vi.fn( + async () => + new Response( + JSON.stringify({ id: "item-2", webUrl: "https://example.com/2", name: "b.txt" }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); + + const result = await uploadToSharePoint({ + buffer: Buffer.from("world"), + filename: "b.txt", + siteId: "site-123", + tokenProvider, + fetchFn: fetchFn as typeof fetch, + }); + + expect(fetchFn).toHaveBeenCalledWith( + "https://graph.microsoft.com/v1.0/sites/site-123/drive/root:/OpenClawShared/b.txt:/content", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ + Authorization: "Bearer graph-token", + "Content-Type": "application/octet-stream", + }), + }), + ); + expect(result).toEqual({ + id: "item-2", + webUrl: "https://example.com/2", + name: "b.txt", + }); + }); + + it("rejects upload responses missing required fields", async () => { + const fetchFn = vi.fn( + async () => + new Response(JSON.stringify({ id: "item-3" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + await expect( + uploadToSharePoint({ + buffer: Buffer.from("world"), + filename: "bad.txt", + siteId: "site-123", + tokenProvider, + fetchFn: fetchFn as typeof fetch, + }), + ).rejects.toThrow("SharePoint upload response missing required fields"); + }); +}); diff --git a/extensions/msteams/src/graph-upload.ts b/extensions/msteams/src/graph-upload.ts index 65e854ac439..9705b1a63a4 100644 --- a/extensions/msteams/src/graph-upload.ts +++ b/extensions/msteams/src/graph-upload.ts @@ -21,6 +21,53 @@ export interface OneDriveUploadResult { name: string; } +function parseUploadedDriveItem( + data: { id?: string; webUrl?: string; name?: string }, + label: "OneDrive" | "SharePoint", +): OneDriveUploadResult { + if (!data.id || !data.webUrl || !data.name) { + throw new Error(`${label} upload response missing required fields`); + } + + return { + id: data.id, + webUrl: data.webUrl, + name: data.name, + }; +} + +async function uploadDriveItem(params: { + buffer: Buffer; + filename: string; + contentType?: string; + tokenProvider: MSTeamsAccessTokenProvider; + fetchFn?: typeof fetch; + url: string; + label: "OneDrive" | "SharePoint"; +}): Promise { + const fetchFn = params.fetchFn ?? fetch; + const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE); + + const res = await fetchFn(params.url, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": params.contentType ?? "application/octet-stream", + }, + body: new Uint8Array(params.buffer), + }); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`${params.label} upload failed: ${res.status} ${res.statusText} - ${body}`); + } + + return parseUploadedDriveItem( + (await res.json()) as { id?: string; webUrl?: string; name?: string }, + params.label, + ); +} + /** * Upload a file to the user's OneDrive root folder. * For larger files, this uses the simple upload endpoint (up to 4MB). @@ -32,41 +79,13 @@ export async function uploadToOneDrive(params: { tokenProvider: MSTeamsAccessTokenProvider; fetchFn?: typeof fetch; }): Promise { - const fetchFn = params.fetchFn ?? fetch; - const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE); - // Use "OpenClawShared" folder to organize bot-uploaded files const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`; - - const res = await fetchFn(`${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`, { - method: "PUT", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": params.contentType ?? "application/octet-stream", - }, - body: new Uint8Array(params.buffer), + return await uploadDriveItem({ + ...params, + url: `${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`, + label: "OneDrive", }); - - if (!res.ok) { - const body = await res.text().catch(() => ""); - throw new Error(`OneDrive upload failed: ${res.status} ${res.statusText} - ${body}`); - } - - const data = (await res.json()) as { - id?: string; - webUrl?: string; - name?: string; - }; - - if (!data.id || !data.webUrl || !data.name) { - throw new Error("OneDrive upload response missing required fields"); - } - - return { - id: data.id, - webUrl: data.webUrl, - name: data.name, - }; } export interface OneDriveSharingLink { @@ -175,44 +194,13 @@ export async function uploadToSharePoint(params: { siteId: string; fetchFn?: typeof fetch; }): Promise { - const fetchFn = params.fetchFn ?? fetch; - const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE); - // Use "OpenClawShared" folder to organize bot-uploaded files const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`; - - const res = await fetchFn( - `${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`, - { - method: "PUT", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": params.contentType ?? "application/octet-stream", - }, - body: new Uint8Array(params.buffer), - }, - ); - - if (!res.ok) { - const body = await res.text().catch(() => ""); - throw new Error(`SharePoint upload failed: ${res.status} ${res.statusText} - ${body}`); - } - - const data = (await res.json()) as { - id?: string; - webUrl?: string; - name?: string; - }; - - if (!data.id || !data.webUrl || !data.name) { - throw new Error("SharePoint upload response missing required fields"); - } - - return { - id: data.id, - webUrl: data.webUrl, - name: data.name, - }; + return await uploadDriveItem({ + ...params, + url: `${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`, + label: "SharePoint", + }); } export interface ChatMember { From fb40b09157d718e1dd67e30ac28e027eaeda8ca0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:38:51 +0000 Subject: [PATCH 0477/1173] refactor: share feishu media client setup --- extensions/feishu/src/media.ts | 118 +++++++++++++++------------------ 1 file changed, 55 insertions(+), 63 deletions(-) diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index 4aba038b4a9..41438c570f2 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -22,6 +22,45 @@ export type DownloadMessageResourceResult = { fileName?: string; }; +function createConfiguredFeishuMediaClient(params: { cfg: ClawdbotConfig; accountId?: string }): { + account: ReturnType; + client: ReturnType; +} { + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); + } + + return { + account, + client: createFeishuClient({ + ...account, + httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS, + }), + }; +} + +function extractFeishuUploadKey( + response: unknown, + params: { + key: "image_key" | "file_key"; + errorPrefix: string; + }, +): string { + // SDK v1.30+ returns data directly without code wrapper on success. + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type + const responseAny = response as any; + if (responseAny.code !== undefined && responseAny.code !== 0) { + throw new Error(`${params.errorPrefix}: ${responseAny.msg || `code ${responseAny.code}`}`); + } + + const key = responseAny[params.key] ?? responseAny.data?.[params.key]; + if (!key) { + throw new Error(`${params.errorPrefix}: no ${params.key} returned`); + } + return key; +} + async function readFeishuResponseBuffer(params: { response: unknown; tmpDirPrefix: string; @@ -94,15 +133,7 @@ export async function downloadImageFeishu(params: { if (!normalizedImageKey) { throw new Error("Feishu image download failed: invalid image_key"); } - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient({ - ...account, - httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS, - }); + const { client } = createConfiguredFeishuMediaClient({ cfg, accountId }); const response = await client.im.image.get({ path: { image_key: normalizedImageKey }, @@ -132,15 +163,7 @@ export async function downloadMessageResourceFeishu(params: { if (!normalizedFileKey) { throw new Error("Feishu message resource download failed: invalid file_key"); } - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient({ - ...account, - httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS, - }); + const { client } = createConfiguredFeishuMediaClient({ cfg, accountId }); const response = await client.im.messageResource.get({ path: { message_id: messageId, file_key: normalizedFileKey }, @@ -179,15 +202,7 @@ export async function uploadImageFeishu(params: { accountId?: string; }): Promise { const { cfg, image, imageType = "message", accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient({ - ...account, - httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS, - }); + const { client } = createConfiguredFeishuMediaClient({ cfg, accountId }); // SDK accepts Buffer directly or fs.ReadStream for file paths // Using Readable.from(buffer) causes issues with form-data library @@ -202,20 +217,12 @@ export async function uploadImageFeishu(params: { }, }); - // SDK v1.30+ returns data directly without code wrapper on success - // On error, it throws or returns { code, msg } - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type - const responseAny = response as any; - if (responseAny.code !== undefined && responseAny.code !== 0) { - throw new Error(`Feishu image upload failed: ${responseAny.msg || `code ${responseAny.code}`}`); - } - - const imageKey = responseAny.image_key ?? responseAny.data?.image_key; - if (!imageKey) { - throw new Error("Feishu image upload failed: no image_key returned"); - } - - return { imageKey }; + return { + imageKey: extractFeishuUploadKey(response, { + key: "image_key", + errorPrefix: "Feishu image upload failed", + }), + }; } /** @@ -249,15 +256,7 @@ export async function uploadFileFeishu(params: { accountId?: string; }): Promise { const { cfg, file, fileName, fileType, duration, accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient({ - ...account, - httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS, - }); + const { client } = createConfiguredFeishuMediaClient({ cfg, accountId }); // SDK accepts Buffer directly or fs.ReadStream for file paths // Using Readable.from(buffer) causes issues with form-data library @@ -276,19 +275,12 @@ export async function uploadFileFeishu(params: { }, }); - // SDK v1.30+ returns data directly without code wrapper on success - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type - const responseAny = response as any; - if (responseAny.code !== undefined && responseAny.code !== 0) { - throw new Error(`Feishu file upload failed: ${responseAny.msg || `code ${responseAny.code}`}`); - } - - const fileKey = responseAny.file_key ?? responseAny.data?.file_key; - if (!fileKey) { - throw new Error("Feishu file upload failed: no file_key returned"); - } - - return { fileKey }; + return { + fileKey: extractFeishuUploadKey(response, { + key: "file_key", + errorPrefix: "Feishu file upload failed", + }), + }; } /** From b6b5e5caac9d96cf8d51c1a8a3a74f02998a89b1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:40:56 +0000 Subject: [PATCH 0478/1173] refactor: deduplicate push test fixtures --- src/gateway/server-methods/push.test.ts | 245 ++++++++++-------------- 1 file changed, 96 insertions(+), 149 deletions(-) diff --git a/src/gateway/server-methods/push.test.ts b/src/gateway/server-methods/push.test.ts index 9997b336797..fc56e0e25d0 100644 --- a/src/gateway/server-methods/push.test.ts +++ b/src/gateway/server-methods/push.test.ts @@ -21,6 +21,8 @@ vi.mock("../../infra/push-apns.js", () => ({ })); import { + type ApnsPushResult, + type ApnsRegistration, clearApnsRegistrationIfCurrent, loadApnsRegistration, normalizeApnsEnvironment, @@ -32,6 +34,63 @@ import { type RespondCall = [boolean, unknown?, { code: number; message: string }?]; +const DEFAULT_DIRECT_REGISTRATION = { + nodeId: "ios-node-1", + transport: "direct", + token: "abcd", + topic: "ai.openclaw.ios", + environment: "sandbox", + updatedAtMs: 1, +} as const; + +const DEFAULT_RELAY_REGISTRATION = { + nodeId: "ios-node-1", + transport: "relay", + relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", + installationId: "install-123", + topic: "ai.openclaw.ios", + environment: "production", + distribution: "official", + updatedAtMs: 1, + tokenDebugSuffix: "abcd1234", +} as const; + +function directRegistration( + overrides: Partial> = {}, +): Extract { + return { ...DEFAULT_DIRECT_REGISTRATION, ...overrides }; +} + +function relayRegistration( + overrides: Partial> = {}, +): Extract { + return { ...DEFAULT_RELAY_REGISTRATION, ...overrides }; +} + +function mockDirectAuth() { + vi.mocked(resolveApnsAuthConfigFromEnv).mockResolvedValue({ + ok: true, + value: { + teamId: "TEAM123", + keyId: "KEY123", + privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret + }, + }); +} + +function apnsResult(overrides: Partial): ApnsPushResult { + return { + ok: true, + status: 200, + tokenSuffix: "1234abcd", + topic: "ai.openclaw.ios", + environment: "sandbox", + transport: "direct", + ...overrides, + }; +} + function createInvokeParams(params: Record) { const respond = vi.fn(); return { @@ -85,31 +144,10 @@ describe("push.test handler", () => { }); it("sends push test when registration and auth are available", async () => { - vi.mocked(loadApnsRegistration).mockResolvedValue({ - nodeId: "ios-node-1", - transport: "direct", - token: "abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }); - vi.mocked(resolveApnsAuthConfigFromEnv).mockResolvedValue({ - ok: true, - value: { - teamId: "TEAM123", - keyId: "KEY123", - privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret - }, - }); + vi.mocked(loadApnsRegistration).mockResolvedValue(directRegistration()); + mockDirectAuth(); vi.mocked(normalizeApnsEnvironment).mockReturnValue(null); - vi.mocked(sendApnsAlert).mockResolvedValue({ - ok: true, - status: 200, - tokenSuffix: "1234abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - transport: "direct", - }); + vi.mocked(sendApnsAlert).mockResolvedValue(apnsResult({})); const { respond, invoke } = createInvokeParams({ nodeId: "ios-node-1", @@ -137,18 +175,9 @@ describe("push.test handler", () => { }, }, }); - vi.mocked(loadApnsRegistration).mockResolvedValue({ - nodeId: "ios-node-1", - transport: "relay", - relayHandle: "relay-handle-123", - sendGrant: "send-grant-123", - installationId: "install-1", - topic: "ai.openclaw.ios", - environment: "production", - distribution: "official", - updatedAtMs: 1, - tokenDebugSuffix: "abcd1234", - }); + vi.mocked(loadApnsRegistration).mockResolvedValue( + relayRegistration({ installationId: "install-1" }), + ); vi.mocked(resolveApnsRelayConfigFromEnv).mockReturnValue({ ok: true, value: { @@ -157,14 +186,13 @@ describe("push.test handler", () => { }, }); vi.mocked(normalizeApnsEnvironment).mockReturnValue(null); - vi.mocked(sendApnsAlert).mockResolvedValue({ - ok: true, - status: 200, - tokenSuffix: "abcd1234", - topic: "ai.openclaw.ios", - environment: "production", - transport: "relay", - }); + vi.mocked(sendApnsAlert).mockResolvedValue( + apnsResult({ + tokenSuffix: "abcd1234", + environment: "production", + transport: "relay", + }), + ); const { respond, invoke } = createInvokeParams({ nodeId: "ios-node-1", @@ -192,32 +220,17 @@ describe("push.test handler", () => { }); it("clears stale registrations after invalid token push-test failures", async () => { - vi.mocked(loadApnsRegistration).mockResolvedValue({ - nodeId: "ios-node-1", - transport: "direct", - token: "abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }); - vi.mocked(resolveApnsAuthConfigFromEnv).mockResolvedValue({ - ok: true, - value: { - teamId: "TEAM123", - keyId: "KEY123", - privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret - }, - }); + const registration = directRegistration(); + vi.mocked(loadApnsRegistration).mockResolvedValue(registration); + mockDirectAuth(); vi.mocked(normalizeApnsEnvironment).mockReturnValue(null); - vi.mocked(sendApnsAlert).mockResolvedValue({ - ok: false, - status: 400, - reason: "BadDeviceToken", - tokenSuffix: "1234abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - transport: "direct", - }); + vi.mocked(sendApnsAlert).mockResolvedValue( + apnsResult({ + ok: false, + status: 400, + reason: "BadDeviceToken", + }), + ); vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(true); const { invoke } = createInvokeParams({ @@ -229,30 +242,13 @@ describe("push.test handler", () => { expect(clearApnsRegistrationIfCurrent).toHaveBeenCalledWith({ nodeId: "ios-node-1", - registration: { - nodeId: "ios-node-1", - transport: "direct", - token: "abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }, + registration, }); }); it("does not clear relay registrations after invalidation-shaped failures", async () => { - vi.mocked(loadApnsRegistration).mockResolvedValue({ - nodeId: "ios-node-1", - transport: "relay", - relayHandle: "relay-handle-123", - sendGrant: "send-grant-123", - installationId: "install-123", - topic: "ai.openclaw.ios", - environment: "production", - distribution: "official", - updatedAtMs: 1, - tokenDebugSuffix: "abcd1234", - }); + const registration = relayRegistration(); + vi.mocked(loadApnsRegistration).mockResolvedValue(registration); vi.mocked(resolveApnsRelayConfigFromEnv).mockReturnValue({ ok: true, value: { @@ -261,15 +257,15 @@ describe("push.test handler", () => { }, }); vi.mocked(normalizeApnsEnvironment).mockReturnValue(null); - vi.mocked(sendApnsAlert).mockResolvedValue({ + const result = apnsResult({ ok: false, status: 410, reason: "Unregistered", tokenSuffix: "abcd1234", - topic: "ai.openclaw.ios", environment: "production", transport: "relay", }); + vi.mocked(sendApnsAlert).mockResolvedValue(result); vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(false); const { invoke } = createInvokeParams({ @@ -280,59 +276,25 @@ describe("push.test handler", () => { await invoke(); expect(shouldClearStoredApnsRegistration).toHaveBeenCalledWith({ - registration: { - nodeId: "ios-node-1", - transport: "relay", - relayHandle: "relay-handle-123", - sendGrant: "send-grant-123", - installationId: "install-123", - topic: "ai.openclaw.ios", - environment: "production", - distribution: "official", - updatedAtMs: 1, - tokenDebugSuffix: "abcd1234", - }, - result: { - ok: false, - status: 410, - reason: "Unregistered", - tokenSuffix: "abcd1234", - topic: "ai.openclaw.ios", - environment: "production", - transport: "relay", - }, + registration, + result, overrideEnvironment: null, }); expect(clearApnsRegistrationIfCurrent).not.toHaveBeenCalled(); }); it("does not clear direct registrations when push.test overrides the environment", async () => { - vi.mocked(loadApnsRegistration).mockResolvedValue({ - nodeId: "ios-node-1", - transport: "direct", - token: "abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }); - vi.mocked(resolveApnsAuthConfigFromEnv).mockResolvedValue({ - ok: true, - value: { - teamId: "TEAM123", - keyId: "KEY123", - privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret - }, - }); + const registration = directRegistration(); + vi.mocked(loadApnsRegistration).mockResolvedValue(registration); + mockDirectAuth(); vi.mocked(normalizeApnsEnvironment).mockReturnValue("production"); - vi.mocked(sendApnsAlert).mockResolvedValue({ + const result = apnsResult({ ok: false, status: 400, reason: "BadDeviceToken", - tokenSuffix: "1234abcd", - topic: "ai.openclaw.ios", environment: "production", - transport: "direct", }); + vi.mocked(sendApnsAlert).mockResolvedValue(result); vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(false); const { invoke } = createInvokeParams({ @@ -344,23 +306,8 @@ describe("push.test handler", () => { await invoke(); expect(shouldClearStoredApnsRegistration).toHaveBeenCalledWith({ - registration: { - nodeId: "ios-node-1", - transport: "direct", - token: "abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }, - result: { - ok: false, - status: 400, - reason: "BadDeviceToken", - tokenSuffix: "1234abcd", - topic: "ai.openclaw.ios", - environment: "production", - transport: "direct", - }, + registration, + result, overrideEnvironment: "production", }); expect(clearApnsRegistrationIfCurrent).not.toHaveBeenCalled(); From 592dd35ce9473a6c6a127c8e2124fd7fbbcfc216 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:42:04 +0000 Subject: [PATCH 0479/1173] refactor: share directory config helpers --- .../plugins/directory-config-helpers.ts | 4 ++-- src/channels/plugins/directory-config.ts | 20 +------------------ 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/src/channels/plugins/directory-config-helpers.ts b/src/channels/plugins/directory-config-helpers.ts index 13cd05d65c3..72f589bc0a7 100644 --- a/src/channels/plugins/directory-config-helpers.ts +++ b/src/channels/plugins/directory-config-helpers.ts @@ -8,7 +8,7 @@ function resolveDirectoryLimit(limit?: number | null): number | undefined { return typeof limit === "number" && limit > 0 ? limit : undefined; } -function applyDirectoryQueryAndLimit( +export function applyDirectoryQueryAndLimit( ids: string[], params: { query?: string | null; limit?: number | null }, ): string[] { @@ -18,7 +18,7 @@ function applyDirectoryQueryAndLimit( return typeof limit === "number" ? filtered.slice(0, limit) : filtered; } -function toDirectoryEntries(kind: "user" | "group", ids: string[]): ChannelDirectoryEntry[] { +export function toDirectoryEntries(kind: "user" | "group", ids: string[]): ChannelDirectoryEntry[] { return ids.map((id) => ({ kind, id }) as const); } diff --git a/src/channels/plugins/directory-config.ts b/src/channels/plugins/directory-config.ts index eaf35fa33ef..e1270a9ceed 100644 --- a/src/channels/plugins/directory-config.ts +++ b/src/channels/plugins/directory-config.ts @@ -5,6 +5,7 @@ import { inspectSlackAccount } from "../../slack/account-inspect.js"; import { inspectTelegramAccount } from "../../telegram/account-inspect.js"; import { resolveWhatsAppAccount } from "../../web/accounts.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; +import { applyDirectoryQueryAndLimit, toDirectoryEntries } from "./directory-config-helpers.js"; import { normalizeSlackMessagingTarget } from "./normalize/slack.js"; import type { ChannelDirectoryEntry } from "./types.js"; @@ -54,25 +55,6 @@ function normalizeTrimmedSet( .filter((id): id is string => Boolean(id)); } -function resolveDirectoryQuery(query?: string | null): string { - return query?.trim().toLowerCase() || ""; -} - -function resolveDirectoryLimit(limit?: number | null): number | undefined { - return typeof limit === "number" && limit > 0 ? limit : undefined; -} - -function applyDirectoryQueryAndLimit(ids: string[], params: DirectoryConfigParams): string[] { - const q = resolveDirectoryQuery(params.query); - const limit = resolveDirectoryLimit(params.limit); - const filtered = ids.filter((id) => (q ? id.toLowerCase().includes(q) : true)); - return typeof limit === "number" ? filtered.slice(0, limit) : filtered; -} - -function toDirectoryEntries(kind: "user" | "group", ids: string[]): ChannelDirectoryEntry[] { - return ids.map((id) => ({ kind, id }) as const); -} - export async function listSlackDirectoryPeersFromConfig( params: DirectoryConfigParams, ): Promise { From 3ccf5f9dc87fbb16b4373327a70e58d4b8190b49 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:43:55 +0000 Subject: [PATCH 0480/1173] refactor: share imessage inbound test fixtures --- .../monitor/inbound-processing.test.ts | 237 +++++------------- 1 file changed, 61 insertions(+), 176 deletions(-) diff --git a/src/imessage/monitor/inbound-processing.test.ts b/src/imessage/monitor/inbound-processing.test.ts index b18012b9f1f..d2adc37bf74 100644 --- a/src/imessage/monitor/inbound-processing.test.ts +++ b/src/imessage/monitor/inbound-processing.test.ts @@ -9,25 +9,28 @@ import { createSelfChatCache } from "./self-chat-cache.js"; describe("resolveIMessageInboundDecision echo detection", () => { const cfg = {} as OpenClawConfig; + type InboundDecisionParams = Parameters[0]; - it("drops inbound messages when outbound message id matches echo cache", () => { - const echoHas = vi.fn((_scope: string, lookup: { text?: string; messageId?: string }) => { - return lookup.messageId === "42"; - }); - - const decision = resolveIMessageInboundDecision({ + function createInboundDecisionParams( + overrides: Omit, "message"> & { + message?: Partial; + } = {}, + ): InboundDecisionParams { + const { message: messageOverrides, ...restOverrides } = overrides; + const message = { + id: 42, + sender: "+15555550123", + text: "ok", + is_from_me: false, + is_group: false, + ...messageOverrides, + }; + const messageText = restOverrides.messageText ?? message.text ?? ""; + const bodyText = restOverrides.bodyText ?? messageText; + const baseParams: Omit = { cfg, accountId: "default", - message: { - id: 42, - sender: "+15555550123", - text: "Reasoning:\n_step_", - is_from_me: false, - is_group: false, - }, opts: undefined, - messageText: "Reasoning:\n_step_", - bodyText: "Reasoning:\n_step_", allowFrom: [], groupAllowFrom: [], groupPolicy: "open", @@ -35,8 +38,40 @@ describe("resolveIMessageInboundDecision echo detection", () => { storeAllowFrom: [], historyLimit: 0, groupHistories: new Map(), - echoCache: { has: echoHas }, + echoCache: undefined, + selfChatCache: undefined, logVerbose: undefined, + }; + return { + ...baseParams, + ...restOverrides, + message, + messageText, + bodyText, + }; + } + + function resolveDecision( + overrides: Omit, "message"> & { + message?: Partial; + } = {}, + ) { + return resolveIMessageInboundDecision(createInboundDecisionParams(overrides)); + } + + it("drops inbound messages when outbound message id matches echo cache", () => { + const echoHas = vi.fn((_scope: string, lookup: { text?: string; messageId?: string }) => { + return lookup.messageId === "42"; + }); + + const decision = resolveDecision({ + message: { + id: 42, + text: "Reasoning:\n_step_", + }, + messageText: "Reasoning:\n_step_", + bodyText: "Reasoning:\n_step_", + echoCache: { has: echoHas }, }); expect(decision).toEqual({ kind: "drop", reason: "echo" }); @@ -54,58 +89,29 @@ describe("resolveIMessageInboundDecision echo detection", () => { const createdAt = "2026-03-02T20:58:10.649Z"; expect( - resolveIMessageInboundDecision({ - cfg, - accountId: "default", + resolveDecision({ message: { id: 9641, - sender: "+15555550123", text: "Do you want to report this issue?", created_at: createdAt, is_from_me: true, - is_group: false, }, - opts: undefined, messageText: "Do you want to report this issue?", bodyText: "Do you want to report this issue?", - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, - logVerbose: undefined, }), ).toEqual({ kind: "drop", reason: "from me" }); expect( - resolveIMessageInboundDecision({ - cfg, - accountId: "default", + resolveDecision({ message: { id: 9642, - sender: "+15555550123", text: "Do you want to report this issue?", created_at: createdAt, - is_from_me: false, - is_group: false, }, - opts: undefined, messageText: "Do you want to report this issue?", bodyText: "Do you want to report this issue?", - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, - logVerbose: undefined, }), ).toEqual({ kind: "drop", reason: "self-chat echo" }); }); @@ -113,56 +119,23 @@ describe("resolveIMessageInboundDecision echo detection", () => { it("does not drop same-text messages when created_at differs", () => { const selfChatCache = createSelfChatCache(); - resolveIMessageInboundDecision({ - cfg, - accountId: "default", + resolveDecision({ message: { id: 9641, - sender: "+15555550123", text: "ok", created_at: "2026-03-02T20:58:10.649Z", is_from_me: true, - is_group: false, }, - opts: undefined, - messageText: "ok", - bodyText: "ok", - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, - logVerbose: undefined, }); - const decision = resolveIMessageInboundDecision({ - cfg, - accountId: "default", + const decision = resolveDecision({ message: { id: 9642, - sender: "+15555550123", text: "ok", created_at: "2026-03-02T20:58:11.649Z", - is_from_me: false, - is_group: false, }, - opts: undefined, - messageText: "ok", - bodyText: "ok", - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, - logVerbose: undefined, }); expect(decision.kind).toBe("dispatch"); @@ -183,59 +156,28 @@ describe("resolveIMessageInboundDecision echo detection", () => { const createdAt = "2026-03-02T20:58:10.649Z"; expect( - resolveIMessageInboundDecision({ + resolveDecision({ cfg: groupedCfg, - accountId: "default", message: { id: 9701, chat_id: 123, - sender: "+15555550123", text: "same text", created_at: createdAt, is_from_me: true, - is_group: false, }, - opts: undefined, - messageText: "same text", - bodyText: "same text", - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, - logVerbose: undefined, }), ).toEqual({ kind: "drop", reason: "from me" }); - const decision = resolveIMessageInboundDecision({ + const decision = resolveDecision({ cfg: groupedCfg, - accountId: "default", message: { id: 9702, chat_id: 456, - sender: "+15555550123", text: "same text", created_at: createdAt, - is_from_me: false, - is_group: false, }, - opts: undefined, - messageText: "same text", - bodyText: "same text", - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, - logVerbose: undefined, }); expect(decision.kind).toBe("dispatch"); @@ -246,59 +188,29 @@ describe("resolveIMessageInboundDecision echo detection", () => { const createdAt = "2026-03-02T20:58:10.649Z"; expect( - resolveIMessageInboundDecision({ - cfg, - accountId: "default", + resolveDecision({ message: { id: 9751, chat_id: 123, - sender: "+15555550123", text: "same text", created_at: createdAt, is_from_me: true, is_group: true, }, - opts: undefined, - messageText: "same text", - bodyText: "same text", - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, - logVerbose: undefined, }), ).toEqual({ kind: "drop", reason: "from me" }); - const decision = resolveIMessageInboundDecision({ - cfg, - accountId: "default", + const decision = resolveDecision({ message: { id: 9752, chat_id: 123, sender: "+15555550999", text: "same text", created_at: createdAt, - is_from_me: false, is_group: true, }, - opts: undefined, - messageText: "same text", - bodyText: "same text", - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, - logVerbose: undefined, }); expect(decision.kind).toBe("dispatch"); @@ -310,54 +222,27 @@ describe("resolveIMessageInboundDecision echo detection", () => { const createdAt = "2026-03-02T20:58:10.649Z"; const bodyText = "line-1\nline-2\t\u001b[31mred"; - resolveIMessageInboundDecision({ - cfg, - accountId: "default", + resolveDecision({ message: { id: 9801, - sender: "+15555550123", text: bodyText, created_at: createdAt, is_from_me: true, - is_group: false, }, - opts: undefined, messageText: bodyText, bodyText, - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, logVerbose, }); - resolveIMessageInboundDecision({ - cfg, - accountId: "default", + resolveDecision({ message: { id: 9802, - sender: "+15555550123", text: bodyText, created_at: createdAt, - is_from_me: false, - is_group: false, }, - opts: undefined, messageText: bodyText, bodyText, - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, logVerbose, }); From e351a86290f7552a09b21a3dff3462fdd44b166f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:45:41 +0000 Subject: [PATCH 0481/1173] refactor: share node wake test apns fixtures --- .../server-methods/nodes.invoke-wake.test.ts | 219 ++++++++---------- 1 file changed, 97 insertions(+), 122 deletions(-) diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index 36d19a9a014..23976d71db0 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -59,6 +59,92 @@ type TestNodeSession = { }; const WAKE_WAIT_TIMEOUT_MS = 3_001; +const DEFAULT_RELAY_CONFIG = { + baseUrl: "https://relay.example.com", + timeoutMs: 1000, +} as const; +type WakeResultOverrides = Partial<{ + ok: boolean; + status: number; + reason: string; + tokenSuffix: string; + topic: string; + environment: "sandbox" | "production"; + transport: "direct" | "relay"; +}>; + +function directRegistration(nodeId: string) { + return { + nodeId, + transport: "direct" as const, + token: "abcd1234abcd1234abcd1234abcd1234", + topic: "ai.openclaw.ios", + environment: "sandbox" as const, + updatedAtMs: 1, + }; +} + +function relayRegistration(nodeId: string) { + return { + nodeId, + transport: "relay" as const, + relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", + installationId: "install-123", + topic: "ai.openclaw.ios", + environment: "production" as const, + distribution: "official" as const, + updatedAtMs: 1, + tokenDebugSuffix: "abcd1234", + }; +} + +function mockDirectWakeConfig(nodeId: string, overrides: WakeResultOverrides = {}) { + mocks.loadApnsRegistration.mockResolvedValue(directRegistration(nodeId)); + mocks.resolveApnsAuthConfigFromEnv.mockResolvedValue({ + ok: true, + value: { + teamId: "TEAM123", + keyId: "KEY123", + privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret + }, + }); + mocks.sendApnsBackgroundWake.mockResolvedValue({ + ok: true, + status: 200, + tokenSuffix: "1234abcd", + topic: "ai.openclaw.ios", + environment: "sandbox", + transport: "direct", + ...overrides, + }); +} + +function mockRelayWakeConfig(nodeId: string, overrides: WakeResultOverrides = {}) { + mocks.loadConfig.mockReturnValue({ + gateway: { + push: { + apns: { + relay: DEFAULT_RELAY_CONFIG, + }, + }, + }, + }); + mocks.loadApnsRegistration.mockResolvedValue(relayRegistration(nodeId)); + mocks.resolveApnsRelayConfigFromEnv.mockReturnValue({ + ok: true, + value: DEFAULT_RELAY_CONFIG, + }); + mocks.sendApnsBackgroundWake.mockResolvedValue({ + ok: true, + status: 200, + tokenSuffix: "abcd1234", + topic: "ai.openclaw.ios", + environment: "production", + transport: "relay", + ...overrides, + }); +} function makeNodeInvokeParams(overrides?: Partial>) { return { @@ -157,33 +243,6 @@ async function ackPending(nodeId: string, ids: string[]) { return respond; } -function mockSuccessfulWakeConfig(nodeId: string) { - mocks.loadApnsRegistration.mockResolvedValue({ - nodeId, - transport: "direct", - token: "abcd1234abcd1234abcd1234abcd1234", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }); - mocks.resolveApnsAuthConfigFromEnv.mockResolvedValue({ - ok: true, - value: { - teamId: "TEAM123", - keyId: "KEY123", - privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret - }, - }); - mocks.sendApnsBackgroundWake.mockResolvedValue({ - ok: true, - status: 200, - tokenSuffix: "1234abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - transport: "direct", - }); -} - describe("node.invoke APNs wake path", () => { beforeEach(() => { mocks.loadConfig.mockClear(); @@ -227,18 +286,7 @@ describe("node.invoke APNs wake path", () => { }); it("does not throttle repeated relay wake attempts when relay config is missing", async () => { - mocks.loadApnsRegistration.mockResolvedValue({ - nodeId: "ios-node-relay-no-auth", - transport: "relay", - relayHandle: "relay-handle-123", - sendGrant: "send-grant-123", - installationId: "install-123", - topic: "ai.openclaw.ios", - environment: "production", - distribution: "official", - updatedAtMs: 1, - tokenDebugSuffix: "abcd1234", - }); + mocks.loadApnsRegistration.mockResolvedValue(relayRegistration("ios-node-relay-no-auth")); mocks.resolveApnsRelayConfigFromEnv.mockReturnValue({ ok: false, error: "relay config missing", @@ -265,7 +313,7 @@ describe("node.invoke APNs wake path", () => { it("wakes and retries invoke after the node reconnects", async () => { vi.useFakeTimers(); - mockSuccessfulWakeConfig("ios-node-reconnect"); + mockDirectWakeConfig("ios-node-reconnect"); let connected = false; const session: TestNodeSession = { nodeId: "ios-node-reconnect", commands: ["camera.capture"] }; @@ -308,30 +356,12 @@ describe("node.invoke APNs wake path", () => { }); it("clears stale registrations after an invalid device token wake failure", async () => { - mocks.loadApnsRegistration.mockResolvedValue({ - nodeId: "ios-node-stale", - transport: "direct", - token: "abcd1234abcd1234abcd1234abcd1234", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }); - mocks.resolveApnsAuthConfigFromEnv.mockResolvedValue({ - ok: true, - value: { - teamId: "TEAM123", - keyId: "KEY123", - privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret - }, - }); - mocks.sendApnsBackgroundWake.mockResolvedValue({ + const registration = directRegistration("ios-node-stale"); + mocks.loadApnsRegistration.mockResolvedValue(registration); + mockDirectWakeConfig("ios-node-stale", { ok: false, status: 400, reason: "BadDeviceToken", - tokenSuffix: "1234abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - transport: "direct", }); mocks.shouldClearStoredApnsRegistration.mockReturnValue(true); @@ -350,57 +380,16 @@ describe("node.invoke APNs wake path", () => { expect(call?.[2]?.message).toBe("node not connected"); expect(mocks.clearApnsRegistrationIfCurrent).toHaveBeenCalledWith({ nodeId: "ios-node-stale", - registration: { - nodeId: "ios-node-stale", - transport: "direct", - token: "abcd1234abcd1234abcd1234abcd1234", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }, + registration, }); }); it("does not clear relay registrations from wake failures", async () => { - mocks.loadConfig.mockReturnValue({ - gateway: { - push: { - apns: { - relay: { - baseUrl: "https://relay.example.com", - timeoutMs: 1000, - }, - }, - }, - }, - }); - mocks.loadApnsRegistration.mockResolvedValue({ - nodeId: "ios-node-relay", - transport: "relay", - relayHandle: "relay-handle-123", - sendGrant: "send-grant-123", - installationId: "install-123", - topic: "ai.openclaw.ios", - environment: "production", - distribution: "official", - updatedAtMs: 1, - tokenDebugSuffix: "abcd1234", - }); - mocks.resolveApnsRelayConfigFromEnv.mockReturnValue({ - ok: true, - value: { - baseUrl: "https://relay.example.com", - timeoutMs: 1000, - }, - }); - mocks.sendApnsBackgroundWake.mockResolvedValue({ + const registration = relayRegistration("ios-node-relay"); + mockRelayWakeConfig("ios-node-relay", { ok: false, status: 410, reason: "Unregistered", - tokenSuffix: "abcd1234", - topic: "ai.openclaw.ios", - environment: "production", - transport: "relay", }); mocks.shouldClearStoredApnsRegistration.mockReturnValue(false); @@ -420,26 +409,12 @@ describe("node.invoke APNs wake path", () => { expect(mocks.resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(process.env, { push: { apns: { - relay: { - baseUrl: "https://relay.example.com", - timeoutMs: 1000, - }, + relay: DEFAULT_RELAY_CONFIG, }, }, }); expect(mocks.shouldClearStoredApnsRegistration).toHaveBeenCalledWith({ - registration: { - nodeId: "ios-node-relay", - transport: "relay", - relayHandle: "relay-handle-123", - sendGrant: "send-grant-123", - installationId: "install-123", - topic: "ai.openclaw.ios", - environment: "production", - distribution: "official", - updatedAtMs: 1, - tokenDebugSuffix: "abcd1234", - }, + registration, result: { ok: false, status: 410, @@ -455,7 +430,7 @@ describe("node.invoke APNs wake path", () => { it("forces one retry wake when the first wake still fails to reconnect", async () => { vi.useFakeTimers(); - mockSuccessfulWakeConfig("ios-node-throttle"); + mockDirectWakeConfig("ios-node-throttle"); const nodeRegistry = { get: vi.fn(() => undefined), From acfb95e2c65f6b1be25d70ae76e40d638fd3e4e9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:46:51 +0000 Subject: [PATCH 0482/1173] refactor: share tlon channel put requests --- extensions/tlon/src/urbit/channel-ops.ts | 91 ++++++++++-------------- 1 file changed, 36 insertions(+), 55 deletions(-) diff --git a/extensions/tlon/src/urbit/channel-ops.ts b/extensions/tlon/src/urbit/channel-ops.ts index f5401d3bb73..ef65e4ca9fe 100644 --- a/extensions/tlon/src/urbit/channel-ops.ts +++ b/extensions/tlon/src/urbit/channel-ops.ts @@ -12,6 +12,29 @@ export type UrbitChannelDeps = { fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; }; +async function putUrbitChannel( + deps: UrbitChannelDeps, + params: { body: unknown; auditContext: string }, +) { + return await urbitFetch({ + baseUrl: deps.baseUrl, + path: `/~/channel/${deps.channelId}`, + init: { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: deps.cookie, + }, + body: JSON.stringify(params.body), + }, + ssrfPolicy: deps.ssrfPolicy, + lookupFn: deps.lookupFn, + fetchImpl: deps.fetchImpl, + timeoutMs: 30_000, + auditContext: params.auditContext, + }); +} + export async function pokeUrbitChannel( deps: UrbitChannelDeps, params: { app: string; mark: string; json: unknown; auditContext: string }, @@ -26,21 +49,8 @@ export async function pokeUrbitChannel( json: params.json, }; - const { response, release } = await urbitFetch({ - baseUrl: deps.baseUrl, - path: `/~/channel/${deps.channelId}`, - init: { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: deps.cookie, - }, - body: JSON.stringify([pokeData]), - }, - ssrfPolicy: deps.ssrfPolicy, - lookupFn: deps.lookupFn, - fetchImpl: deps.fetchImpl, - timeoutMs: 30_000, + const { response, release } = await putUrbitChannel(deps, { + body: [pokeData], auditContext: params.auditContext, }); @@ -88,23 +98,7 @@ export async function createUrbitChannel( deps: UrbitChannelDeps, params: { body: unknown; auditContext: string }, ): Promise { - const { response, release } = await urbitFetch({ - baseUrl: deps.baseUrl, - path: `/~/channel/${deps.channelId}`, - init: { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: deps.cookie, - }, - body: JSON.stringify(params.body), - }, - ssrfPolicy: deps.ssrfPolicy, - lookupFn: deps.lookupFn, - fetchImpl: deps.fetchImpl, - timeoutMs: 30_000, - auditContext: params.auditContext, - }); + const { response, release } = await putUrbitChannel(deps, params); try { if (!response.ok && response.status !== 204) { @@ -116,30 +110,17 @@ export async function createUrbitChannel( } export async function wakeUrbitChannel(deps: UrbitChannelDeps): Promise { - const { response, release } = await urbitFetch({ - baseUrl: deps.baseUrl, - path: `/~/channel/${deps.channelId}`, - init: { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: deps.cookie, + const { response, release } = await putUrbitChannel(deps, { + body: [ + { + id: Date.now(), + action: "poke", + ship: deps.ship, + app: "hood", + mark: "helm-hi", + json: "Opening API channel", }, - body: JSON.stringify([ - { - id: Date.now(), - action: "poke", - ship: deps.ship, - app: "hood", - mark: "helm-hi", - json: "Opening API channel", - }, - ]), - }, - ssrfPolicy: deps.ssrfPolicy, - lookupFn: deps.lookupFn, - fetchImpl: deps.fetchImpl, - timeoutMs: 30_000, + ], auditContext: "tlon-urbit-channel-wake", }); From 49f3fbf726c09e3aaab0f36db9ac690e50dadc2e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:49:50 +0000 Subject: [PATCH 0483/1173] fix: restore cron manual run type narrowing --- src/cron/service/ops.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index de2c581bf68..69751e4dfdb 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -403,7 +403,10 @@ async function inspectManualRunDisposition( mode?: "due" | "force", ): Promise { const result = await inspectManualRunPreflight(state, id, mode); - if (!result.ok || !result.runnable) { + if (!result.ok) { + return result; + } + if ("reason" in result) { return result; } return { ok: true, runnable: true } as const; @@ -415,9 +418,16 @@ async function prepareManualRun( mode?: "due" | "force", ): Promise { const preflight = await inspectManualRunPreflight(state, id, mode); - if (!preflight.ok || !preflight.runnable) { + if (!preflight.ok) { return preflight; } + if ("reason" in preflight) { + return { + ok: true, + ran: false, + reason: preflight.reason, + } as const; + } return await locked(state, async () => { // Reserve this run under lock, then execute outside lock so read ops // (`list`, `status`) stay responsive while the run is in progress. From a14a32695d51da53ff3e4421ec5a363a11cd6939 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:49:56 +0000 Subject: [PATCH 0484/1173] refactor: share feishu reaction client setup --- extensions/feishu/src/reactions.ts | 47 +++++++++++++----------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/extensions/feishu/src/reactions.ts b/extensions/feishu/src/reactions.ts index d446a674b88..951b3d03c6b 100644 --- a/extensions/feishu/src/reactions.ts +++ b/extensions/feishu/src/reactions.ts @@ -9,6 +9,20 @@ export type FeishuReaction = { operatorId: string; }; +function resolveConfiguredFeishuClient(params: { cfg: ClawdbotConfig; accountId?: string }) { + const account = resolveFeishuAccount(params); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); + } + return createFeishuClient(account); +} + +function assertFeishuReactionApiSuccess(response: { code?: number; msg?: string }, action: string) { + if (response.code !== 0) { + throw new Error(`Feishu ${action} failed: ${response.msg || `code ${response.code}`}`); + } +} + /** * Add a reaction (emoji) to a message. * @param emojiType - Feishu emoji type, e.g., "SMILE", "THUMBSUP", "HEART" @@ -21,12 +35,7 @@ export async function addReactionFeishu(params: { accountId?: string; }): Promise<{ reactionId: string }> { const { cfg, messageId, emojiType, accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient(account); + const client = resolveConfiguredFeishuClient({ cfg, accountId }); const response = (await client.im.messageReaction.create({ path: { message_id: messageId }, @@ -41,9 +50,7 @@ export async function addReactionFeishu(params: { data?: { reaction_id?: string }; }; - if (response.code !== 0) { - throw new Error(`Feishu add reaction failed: ${response.msg || `code ${response.code}`}`); - } + assertFeishuReactionApiSuccess(response, "add reaction"); const reactionId = response.data?.reaction_id; if (!reactionId) { @@ -63,12 +70,7 @@ export async function removeReactionFeishu(params: { accountId?: string; }): Promise { const { cfg, messageId, reactionId, accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient(account); + const client = resolveConfiguredFeishuClient({ cfg, accountId }); const response = (await client.im.messageReaction.delete({ path: { @@ -77,9 +79,7 @@ export async function removeReactionFeishu(params: { }, })) as { code?: number; msg?: string }; - if (response.code !== 0) { - throw new Error(`Feishu remove reaction failed: ${response.msg || `code ${response.code}`}`); - } + assertFeishuReactionApiSuccess(response, "remove reaction"); } /** @@ -92,12 +92,7 @@ export async function listReactionsFeishu(params: { accountId?: string; }): Promise { const { cfg, messageId, emojiType, accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient(account); + const client = resolveConfiguredFeishuClient({ cfg, accountId }); const response = (await client.im.messageReaction.list({ path: { message_id: messageId }, @@ -115,9 +110,7 @@ export async function listReactionsFeishu(params: { }; }; - if (response.code !== 0) { - throw new Error(`Feishu list reactions failed: ${response.msg || `code ${response.code}`}`); - } + assertFeishuReactionApiSuccess(response, "list reactions"); const items = response.data?.items ?? []; return items.map((item) => ({ From e358d57fb5141c9dae8c0dbd8010baf0f03eebdc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:50:43 +0000 Subject: [PATCH 0485/1173] refactor: share feishu reply fallback flow --- extensions/feishu/src/send.ts | 118 +++++++++++++++++++--------------- 1 file changed, 66 insertions(+), 52 deletions(-) diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 0f4fd7e7758..5bfa836e0a6 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -43,6 +43,10 @@ function isWithdrawnReplyError(err: unknown): boolean { type FeishuCreateMessageClient = { im: { message: { + reply: (opts: { + path: { message_id: string }; + data: { content: string; msg_type: string; reply_in_thread?: true }; + }) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>; create: (opts: { params: { receive_id_type: "chat_id" | "email" | "open_id" | "union_id" | "user_id" }; data: { receive_id: string; content: string; msg_type: string }; @@ -74,6 +78,50 @@ async function sendFallbackDirect( return toFeishuSendResult(response, params.receiveId); } +async function sendReplyOrFallbackDirect( + client: FeishuCreateMessageClient, + params: { + replyToMessageId?: string; + replyInThread?: boolean; + content: string; + msgType: string; + directParams: { + receiveId: string; + receiveIdType: "chat_id" | "email" | "open_id" | "union_id" | "user_id"; + content: string; + msgType: string; + }; + directErrorPrefix: string; + replyErrorPrefix: string; + }, +): Promise { + if (!params.replyToMessageId) { + return sendFallbackDirect(client, params.directParams, params.directErrorPrefix); + } + + let response: { code?: number; msg?: string; data?: { message_id?: string } }; + try { + response = await client.im.message.reply({ + path: { message_id: params.replyToMessageId }, + data: { + content: params.content, + msg_type: params.msgType, + ...(params.replyInThread ? { reply_in_thread: true } : {}), + }, + }); + } catch (err) { + if (!isWithdrawnReplyError(err)) { + throw err; + } + return sendFallbackDirect(client, params.directParams, params.directErrorPrefix); + } + if (shouldFallbackFromReplyTarget(response)) { + return sendFallbackDirect(client, params.directParams, params.directErrorPrefix); + } + assertFeishuMessageApiSuccess(response, params.replyErrorPrefix); + return toFeishuSendResult(response, params.directParams.receiveId); +} + function parseInteractiveCardContent(parsed: unknown): string { if (!parsed || typeof parsed !== "object") { return "[Interactive Card]"; @@ -290,32 +338,15 @@ export async function sendMessageFeishu( const { content, msgType } = buildFeishuPostMessagePayload({ messageText }); const directParams = { receiveId, receiveIdType, content, msgType }; - - if (replyToMessageId) { - let response: { code?: number; msg?: string; data?: { message_id?: string } }; - try { - response = await client.im.message.reply({ - path: { message_id: replyToMessageId }, - data: { - content, - msg_type: msgType, - ...(replyInThread ? { reply_in_thread: true } : {}), - }, - }); - } catch (err) { - if (!isWithdrawnReplyError(err)) { - throw err; - } - return sendFallbackDirect(client, directParams, "Feishu send failed"); - } - if (shouldFallbackFromReplyTarget(response)) { - return sendFallbackDirect(client, directParams, "Feishu send failed"); - } - assertFeishuMessageApiSuccess(response, "Feishu reply failed"); - return toFeishuSendResult(response, receiveId); - } - - return sendFallbackDirect(client, directParams, "Feishu send failed"); + return sendReplyOrFallbackDirect(client, { + replyToMessageId, + replyInThread, + content, + msgType, + directParams, + directErrorPrefix: "Feishu send failed", + replyErrorPrefix: "Feishu reply failed", + }); } export type SendFeishuCardParams = { @@ -334,32 +365,15 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise Date: Fri, 13 Mar 2026 16:57:20 +0000 Subject: [PATCH 0486/1173] ci: modernize GitHub Actions workflow versions --- .github/actions/setup-node-env/action.yml | 4 +- .../actions/setup-pnpm-store-cache/action.yml | 4 +- .github/workflows/auto-response.yml | 6 +- .github/workflows/ci.yml | 56 +++++++++---------- .github/workflows/codeql.yml | 8 +-- .github/workflows/docker-release.yml | 16 +++--- .github/workflows/install-smoke.yml | 6 +- .github/workflows/labeler.yml | 24 ++++---- .github/workflows/openclaw-npm-release.yml | 2 +- .github/workflows/sandbox-common-smoke.yml | 4 +- .github/workflows/stale.yml | 14 ++--- .github/workflows/workflow-sanity.yml | 4 +- 12 files changed, 74 insertions(+), 74 deletions(-) diff --git a/.github/actions/setup-node-env/action.yml b/.github/actions/setup-node-env/action.yml index 5ea0373ff76..41ca9eb98b0 100644 --- a/.github/actions/setup-node-env/action.yml +++ b/.github/actions/setup-node-env/action.yml @@ -49,7 +49,7 @@ runs: exit 1 - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@v6 with: node-version: ${{ inputs.node-version }} check-latest: false @@ -63,7 +63,7 @@ runs: - name: Setup Bun if: inputs.install-bun == 'true' - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@v2.1.3 with: bun-version: "1.3.9" diff --git a/.github/actions/setup-pnpm-store-cache/action.yml b/.github/actions/setup-pnpm-store-cache/action.yml index 249544d49ac..2f7c992a978 100644 --- a/.github/actions/setup-pnpm-store-cache/action.yml +++ b/.github/actions/setup-pnpm-store-cache/action.yml @@ -61,14 +61,14 @@ runs: - name: Restore pnpm store cache (exact key only) # PRs that request sticky disks still need a safe cache restore path. if: inputs.use-actions-cache == 'true' && (inputs.use-sticky-disk != 'true' || github.event_name == 'pull_request') && inputs.use-restore-keys != 'true' - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.path }} key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }} - name: Restore pnpm store cache (with fallback keys) if: inputs.use-actions-cache == 'true' && (inputs.use-sticky-disk != 'true' || github.event_name == 'pull_request') && inputs.use-restore-keys == 'true' - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.path }} key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }} diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index cc1601886a4..69dff002c7b 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -20,20 +20,20 @@ jobs: pull-requests: write runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token continue-on-error: true with: app-id: "2729701" private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token-fallback if: steps.app-token.outcome == 'failure' with: app-id: "2971289" private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} - name: Handle labeled items - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@v8 with: github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} script: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18c6f14fdaf..b365b2ed944 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: docs_changed: ${{ steps.check.outputs.docs_changed }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 fetch-tags: false @@ -53,7 +53,7 @@ jobs: run_windows: ${{ steps.scope.outputs.run_windows }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 fetch-tags: false @@ -86,7 +86,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -101,13 +101,13 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" - use-sticky-disk: "true" + use-sticky-disk: "false" - name: Build dist run: pnpm build - name: Upload dist artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: dist-build path: dist/ @@ -120,7 +120,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -128,10 +128,10 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" - use-sticky-disk: "true" + use-sticky-disk: "false" - name: Download dist artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: dist-build path: dist/ @@ -166,7 +166,7 @@ jobs: - name: Checkout if: github.event_name != 'push' || matrix.runtime != 'bun' - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -175,7 +175,7 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "${{ matrix.runtime == 'bun' }}" - use-sticky-disk: "true" + use-sticky-disk: "false" - name: Configure Node test resources if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node' @@ -197,7 +197,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -205,7 +205,7 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" - use-sticky-disk: "true" + use-sticky-disk: "false" - name: Check types and lint and oxfmt run: pnpm check @@ -223,7 +223,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -231,7 +231,7 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" - use-sticky-disk: "true" + use-sticky-disk: "false" - name: Check docs run: pnpm check:docs @@ -243,7 +243,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -253,7 +253,7 @@ jobs: node-version: "22.x" cache-key-suffix: "node22" install-bun: "false" - use-sticky-disk: "true" + use-sticky-disk: "false" - name: Configure Node 22 test resources run: | @@ -276,12 +276,12 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" @@ -300,7 +300,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -319,7 +319,7 @@ jobs: - name: Setup Python id: setup-python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" cache: "pip" @@ -329,7 +329,7 @@ jobs: .github/workflows/ci.yml - name: Restore pre-commit cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/pre-commit key: pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} @@ -412,7 +412,7 @@ jobs: command: pnpm test steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -436,7 +436,7 @@ jobs: } - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@v6 with: node-version: 24.x check-latest: false @@ -498,7 +498,7 @@ jobs: runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -534,7 +534,7 @@ jobs: swiftformat --lint apps/macos/Sources --config .swiftformat - name: Cache SwiftPM - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/Library/Caches/org.swift.swiftpm key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }} @@ -570,7 +570,7 @@ jobs: runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -739,12 +739,12 @@ jobs: command: ./gradlew --no-daemon :app:assembleDebug steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin # setup-android's sdkmanager currently crashes on JDK 21 in CI. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e01f7185a37..79c041ef727 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -70,7 +70,7 @@ jobs: config_file: "" steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -79,17 +79,17 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" - use-sticky-disk: "true" + use-sticky-disk: "false" - name: Setup Python if: matrix.needs_python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" - name: Setup Java if: matrix.needs_java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: "21" diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 0486bc76760..f4128cddc88 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -34,13 +34,13 @@ jobs: slim-digest: ${{ steps.build-slim.outputs.digest }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Docker Builder - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} @@ -135,13 +135,13 @@ jobs: slim-digest: ${{ steps.build-slim.outputs.digest }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Docker Builder - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} @@ -234,10 +234,10 @@ jobs: needs: [build-amd64, build-arm64] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index 26b5de0e2b6..f48c794b668 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -20,7 +20,7 @@ jobs: docs_only: ${{ steps.check.outputs.docs_only }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 fetch-tags: false @@ -41,10 +41,10 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout CLI - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Docker Builder - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 # Blacksmith can fall back to the local docker driver, which rejects gha # cache export/import. Keep smoke builds driver-agnostic. diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 8e7d707a3d1..3a38e5213c3 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -28,25 +28,25 @@ jobs: pull-requests: write runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token continue-on-error: true with: app-id: "2729701" private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token-fallback if: steps.app-token.outcome == 'failure' with: app-id: "2971289" private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} - - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5 + - uses: actions/labeler@v6 with: configuration-path: .github/labeler.yml repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} sync-labels: true - name: Apply PR size label - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@v8 with: github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} script: | @@ -135,7 +135,7 @@ jobs: labels: [targetSizeLabel], }); - name: Apply maintainer or trusted-contributor label - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@v8 with: github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} script: | @@ -206,7 +206,7 @@ jobs: // }); // } - name: Apply too-many-prs label - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@v8 with: github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} script: | @@ -384,20 +384,20 @@ jobs: pull-requests: write runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token continue-on-error: true with: app-id: "2729701" private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token-fallback if: steps.app-token.outcome == 'failure' with: app-id: "2971289" private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} - name: Backfill PR labels - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@v8 with: github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} script: | @@ -632,20 +632,20 @@ jobs: issues: write runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token continue-on-error: true with: app-id: "2729701" private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token-fallback if: steps.app-token.outcome == 'failure' with: app-id: "2971289" private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} - name: Apply maintainer or trusted-contributor label - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@v8 with: github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} script: | diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index e690896bdd2..ac0a8f728e3 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -23,7 +23,7 @@ jobs: id-token: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/sandbox-common-smoke.yml b/.github/workflows/sandbox-common-smoke.yml index 5320ef7d712..4a839b4d878 100644 --- a/.github/workflows/sandbox-common-smoke.yml +++ b/.github/workflows/sandbox-common-smoke.yml @@ -25,12 +25,12 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false - name: Set up Docker Builder - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build minimal sandbox base (USER sandbox) shell: bash diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index f36361e987e..95dc406da45 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,13 +17,13 @@ jobs: pull-requests: write runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token continue-on-error: true with: app-id: "2729701" private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token-fallback continue-on-error: true with: @@ -32,7 +32,7 @@ jobs: - name: Mark stale issues and pull requests (primary) id: stale-primary continue-on-error: true - uses: actions/stale@v9 + uses: actions/stale@v10 with: repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} days-before-issue-stale: 7 @@ -65,7 +65,7 @@ jobs: - name: Check stale state cache id: stale-state if: always() - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@v8 with: github-token: ${{ steps.app-token-fallback.outputs.token || steps.app-token.outputs.token }} script: | @@ -88,7 +88,7 @@ jobs: } - name: Mark stale issues and pull requests (fallback) if: (steps.stale-primary.outcome == 'failure' || steps.stale-state.outputs.has_state == 'true') && steps.app-token-fallback.outputs.token != '' - uses: actions/stale@v9 + uses: actions/stale@v10 with: repo-token: ${{ steps.app-token-fallback.outputs.token }} days-before-issue-stale: 7 @@ -124,13 +124,13 @@ jobs: issues: write runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token with: app-id: "2729701" private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Lock closed issues after 48h of no comments - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@v8 with: github-token: ${{ steps.app-token.outputs.token }} script: | diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml index e6cbaa8c9e0..9426f678926 100644 --- a/.github/workflows/workflow-sanity.yml +++ b/.github/workflows/workflow-sanity.yml @@ -17,7 +17,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Fail on tabs in workflow files run: | @@ -48,7 +48,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install actionlint shell: bash From 369430f9ab98af384f1e2342529eb88bf9acfdc7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:53:14 +0000 Subject: [PATCH 0487/1173] refactor: share tlon upload test mocks --- extensions/tlon/src/urbit/upload.test.ts | 113 ++++++++++------------- 1 file changed, 51 insertions(+), 62 deletions(-) diff --git a/extensions/tlon/src/urbit/upload.test.ts b/extensions/tlon/src/urbit/upload.test.ts index ca95a0412d4..1a573a6b359 100644 --- a/extensions/tlon/src/urbit/upload.test.ts +++ b/extensions/tlon/src/urbit/upload.test.ts @@ -15,6 +15,36 @@ vi.mock("@tloncorp/api", () => ({ })); describe("uploadImageFromUrl", () => { + async function loadUploadMocks() { + const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon"); + const { uploadFile } = await import("@tloncorp/api"); + const { uploadImageFromUrl } = await import("./upload.js"); + return { + mockFetch: vi.mocked(fetchWithSsrFGuard), + mockUploadFile: vi.mocked(uploadFile), + uploadImageFromUrl, + }; + } + + type UploadMocks = Awaited>; + + function mockSuccessfulFetch(params: { + mockFetch: UploadMocks["mockFetch"]; + blob: Blob; + finalUrl: string; + contentType: string; + }) { + params.mockFetch.mockResolvedValue({ + response: { + ok: true, + headers: new Headers({ "content-type": params.contentType }), + blob: () => Promise.resolve(params.blob), + } as unknown as Response, + finalUrl: params.finalUrl, + release: vi.fn().mockResolvedValue(undefined), + }); + } + beforeEach(() => { vi.clearAllMocks(); }); @@ -24,28 +54,17 @@ describe("uploadImageFromUrl", () => { }); it("fetches image and calls uploadFile, returns uploaded URL", async () => { - const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon"); - const mockFetch = vi.mocked(fetchWithSsrFGuard); + const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks(); - const { uploadFile } = await import("@tloncorp/api"); - const mockUploadFile = vi.mocked(uploadFile); - - // Mock fetchWithSsrFGuard to return a successful response with a blob const mockBlob = new Blob(["fake-image"], { type: "image/png" }); - mockFetch.mockResolvedValue({ - response: { - ok: true, - headers: new Headers({ "content-type": "image/png" }), - blob: () => Promise.resolve(mockBlob), - } as unknown as Response, + mockSuccessfulFetch({ + mockFetch, + blob: mockBlob, finalUrl: "https://example.com/image.png", - release: vi.fn().mockResolvedValue(undefined), + contentType: "image/png", }); - - // Mock uploadFile to return a successful upload mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.png" }); - const { uploadImageFromUrl } = await import("./upload.js"); const result = await uploadImageFromUrl("https://example.com/image.png"); expect(result).toBe("https://memex.tlon.network/uploaded.png"); @@ -59,10 +78,8 @@ describe("uploadImageFromUrl", () => { }); it("returns original URL if fetch fails", async () => { - const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon"); - const mockFetch = vi.mocked(fetchWithSsrFGuard); + const { mockFetch, uploadImageFromUrl } = await loadUploadMocks(); - // Mock fetchWithSsrFGuard to return a failed response mockFetch.mockResolvedValue({ response: { ok: false, @@ -72,35 +89,23 @@ describe("uploadImageFromUrl", () => { release: vi.fn().mockResolvedValue(undefined), }); - const { uploadImageFromUrl } = await import("./upload.js"); const result = await uploadImageFromUrl("https://example.com/image.png"); expect(result).toBe("https://example.com/image.png"); }); it("returns original URL if upload fails", async () => { - const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon"); - const mockFetch = vi.mocked(fetchWithSsrFGuard); + const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks(); - const { uploadFile } = await import("@tloncorp/api"); - const mockUploadFile = vi.mocked(uploadFile); - - // Mock fetchWithSsrFGuard to return a successful response const mockBlob = new Blob(["fake-image"], { type: "image/png" }); - mockFetch.mockResolvedValue({ - response: { - ok: true, - headers: new Headers({ "content-type": "image/png" }), - blob: () => Promise.resolve(mockBlob), - } as unknown as Response, + mockSuccessfulFetch({ + mockFetch, + blob: mockBlob, finalUrl: "https://example.com/image.png", - release: vi.fn().mockResolvedValue(undefined), + contentType: "image/png", }); - - // Mock uploadFile to throw an error mockUploadFile.mockRejectedValue(new Error("Upload failed")); - const { uploadImageFromUrl } = await import("./upload.js"); const result = await uploadImageFromUrl("https://example.com/image.png"); expect(result).toBe("https://example.com/image.png"); @@ -127,26 +132,18 @@ describe("uploadImageFromUrl", () => { }); it("extracts filename from URL path", async () => { - const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon"); - const mockFetch = vi.mocked(fetchWithSsrFGuard); - - const { uploadFile } = await import("@tloncorp/api"); - const mockUploadFile = vi.mocked(uploadFile); + const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks(); const mockBlob = new Blob(["fake-image"], { type: "image/jpeg" }); - mockFetch.mockResolvedValue({ - response: { - ok: true, - headers: new Headers({ "content-type": "image/jpeg" }), - blob: () => Promise.resolve(mockBlob), - } as unknown as Response, + mockSuccessfulFetch({ + mockFetch, + blob: mockBlob, finalUrl: "https://example.com/path/to/my-image.jpg", - release: vi.fn().mockResolvedValue(undefined), + contentType: "image/jpeg", }); mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.jpg" }); - const { uploadImageFromUrl } = await import("./upload.js"); await uploadImageFromUrl("https://example.com/path/to/my-image.jpg"); expect(mockUploadFile).toHaveBeenCalledWith( @@ -157,26 +154,18 @@ describe("uploadImageFromUrl", () => { }); it("uses default filename when URL has no path", async () => { - const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon"); - const mockFetch = vi.mocked(fetchWithSsrFGuard); - - const { uploadFile } = await import("@tloncorp/api"); - const mockUploadFile = vi.mocked(uploadFile); + const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks(); const mockBlob = new Blob(["fake-image"], { type: "image/png" }); - mockFetch.mockResolvedValue({ - response: { - ok: true, - headers: new Headers({ "content-type": "image/png" }), - blob: () => Promise.resolve(mockBlob), - } as unknown as Response, + mockSuccessfulFetch({ + mockFetch, + blob: mockBlob, finalUrl: "https://example.com/", - release: vi.fn().mockResolvedValue(undefined), + contentType: "image/png", }); mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.png" }); - const { uploadImageFromUrl } = await import("./upload.js"); await uploadImageFromUrl("https://example.com/"); expect(mockUploadFile).toHaveBeenCalledWith( From 4a00cefe63cbe697704819379fc0bacd44d45783 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:53:31 +0000 Subject: [PATCH 0488/1173] refactor: share outbound plugin test results --- .../outbound/outbound-send-service.test.ts | 42 +++++++------------ 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/src/infra/outbound/outbound-send-service.test.ts b/src/infra/outbound/outbound-send-service.test.ts index ae12622fcae..68c956d93fc 100644 --- a/src/infra/outbound/outbound-send-service.test.ts +++ b/src/infra/outbound/outbound-send-service.test.ts @@ -34,6 +34,18 @@ vi.mock("../../config/sessions.js", () => ({ import { executePollAction, executeSendAction } from "./outbound-send-service.js"; describe("executeSendAction", () => { + function pluginActionResult(messageId: string) { + return { + ok: true, + value: { messageId }, + continuePrompt: "", + output: "", + sessionId: "s1", + model: "gpt-5.2", + usage: {}, + }; + } + beforeEach(() => { mocks.dispatchChannelMessageAction.mockClear(); mocks.sendMessage.mockClear(); @@ -75,15 +87,7 @@ describe("executeSendAction", () => { }); it("uses plugin poll action when available", async () => { - mocks.dispatchChannelMessageAction.mockResolvedValue({ - ok: true, - value: { messageId: "poll-plugin" }, - continuePrompt: "", - output: "", - sessionId: "s1", - model: "gpt-5.2", - usage: {}, - }); + mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("poll-plugin")); const result = await executePollAction({ ctx: { @@ -103,15 +107,7 @@ describe("executeSendAction", () => { }); it("passes agent-scoped media local roots to plugin dispatch", async () => { - mocks.dispatchChannelMessageAction.mockResolvedValue({ - ok: true, - value: { messageId: "msg-plugin" }, - continuePrompt: "", - output: "", - sessionId: "s1", - model: "gpt-5.2", - usage: {}, - }); + mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin")); await executeSendAction({ ctx: { @@ -134,15 +130,7 @@ describe("executeSendAction", () => { }); it("passes mirror idempotency keys through plugin-handled sends", async () => { - mocks.dispatchChannelMessageAction.mockResolvedValue({ - ok: true, - value: { messageId: "msg-plugin" }, - continuePrompt: "", - output: "", - sessionId: "s1", - model: "gpt-5.2", - usage: {}, - }); + mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin")); await executeSendAction({ ctx: { From 8de94abfbc9b48d1ac8aae722cef074e5c8be295 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:55:23 +0000 Subject: [PATCH 0489/1173] refactor: share chat abort test helpers --- .../chat.abort-authorization.test.ts | 96 ++++++------------ .../chat.abort-persistence.test.ts | 97 +++++++------------ .../server-methods/chat.abort.test-helpers.ts | 69 +++++++++++++ 3 files changed, 132 insertions(+), 130 deletions(-) create mode 100644 src/gateway/server-methods/chat.abort.test-helpers.ts diff --git a/src/gateway/server-methods/chat.abort-authorization.test.ts b/src/gateway/server-methods/chat.abort-authorization.test.ts index 6fbf0478df3..607e80b58ff 100644 --- a/src/gateway/server-methods/chat.abort-authorization.test.ts +++ b/src/gateway/server-methods/chat.abort-authorization.test.ts @@ -1,68 +1,24 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { + createActiveRun, + createChatAbortContext, + invokeChatAbortHandler, +} from "./chat.abort.test-helpers.js"; import { chatHandlers } from "./chat.js"; -function createActiveRun(sessionKey: string, owner?: { connId?: string; deviceId?: string }) { - const now = Date.now(); - return { - controller: new AbortController(), - sessionId: `${sessionKey}-session`, - sessionKey, - startedAtMs: now, - expiresAtMs: now + 30_000, - ownerConnId: owner?.connId, - ownerDeviceId: owner?.deviceId, - }; -} - -function createContext(overrides: Record = {}) { - return { - chatAbortControllers: new Map(), - chatRunBuffers: new Map(), - chatDeltaSentAt: new Map(), - chatAbortedRuns: new Map(), - removeChatRun: vi - .fn() - .mockImplementation((run: string) => ({ sessionKey: "main", clientRunId: run })), - agentRunSeq: new Map(), - broadcast: vi.fn(), - nodeSendToSession: vi.fn(), - logGateway: { warn: vi.fn() }, - ...overrides, - }; -} - -async function invokeChatAbort(params: { - context: ReturnType; - request: { sessionKey: string; runId?: string }; - client?: { - connId?: string; - connect?: { - device?: { id?: string }; - scopes?: string[]; - }; - } | null; -}) { - const respond = vi.fn(); - await chatHandlers["chat.abort"]({ - params: params.request, - respond: respond as never, - context: params.context as never, - req: {} as never, - client: (params.client ?? null) as never, - isWebchatConnect: () => false, - }); - return respond; -} - describe("chat.abort authorization", () => { it("rejects explicit run aborts from other clients", async () => { - const context = createContext({ + const context = createChatAbortContext({ chatAbortControllers: new Map([ - ["run-1", createActiveRun("main", { connId: "conn-owner", deviceId: "dev-owner" })], + [ + "run-1", + createActiveRun("main", { owner: { connId: "conn-owner", deviceId: "dev-owner" } }), + ], ]), }); - const respond = await invokeChatAbort({ + const respond = await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], context, request: { sessionKey: "main", runId: "run-1" }, client: { @@ -79,13 +35,14 @@ describe("chat.abort authorization", () => { }); it("allows the same paired device to abort after reconnecting", async () => { - const context = createContext({ + const context = createChatAbortContext({ chatAbortControllers: new Map([ - ["run-1", createActiveRun("main", { connId: "conn-old", deviceId: "dev-1" })], + ["run-1", createActiveRun("main", { owner: { connId: "conn-old", deviceId: "dev-1" } })], ]), }); - const respond = await invokeChatAbort({ + const respond = await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], context, request: { sessionKey: "main", runId: "run-1" }, client: { @@ -101,14 +58,15 @@ describe("chat.abort authorization", () => { }); it("only aborts session-scoped runs owned by the requester", async () => { - const context = createContext({ + const context = createChatAbortContext({ chatAbortControllers: new Map([ - ["run-mine", createActiveRun("main", { deviceId: "dev-1" })], - ["run-other", createActiveRun("main", { deviceId: "dev-2" })], + ["run-mine", createActiveRun("main", { owner: { deviceId: "dev-1" } })], + ["run-other", createActiveRun("main", { owner: { deviceId: "dev-2" } })], ]), }); - const respond = await invokeChatAbort({ + const respond = await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], context, request: { sessionKey: "main" }, client: { @@ -125,13 +83,17 @@ describe("chat.abort authorization", () => { }); it("allows operator.admin clients to bypass owner checks", async () => { - const context = createContext({ + const context = createChatAbortContext({ chatAbortControllers: new Map([ - ["run-1", createActiveRun("main", { connId: "conn-owner", deviceId: "dev-owner" })], + [ + "run-1", + createActiveRun("main", { owner: { connId: "conn-owner", deviceId: "dev-owner" } }), + ], ]), }); - const respond = await invokeChatAbort({ + const respond = await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], context, request: { sessionKey: "main", runId: "run-1" }, client: { diff --git a/src/gateway/server-methods/chat.abort-persistence.test.ts b/src/gateway/server-methods/chat.abort-persistence.test.ts index b7add3740eb..31a00a3f186 100644 --- a/src/gateway/server-methods/chat.abort-persistence.test.ts +++ b/src/gateway/server-methods/chat.abort-persistence.test.ts @@ -3,6 +3,11 @@ import os from "node:os"; import path from "node:path"; import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createActiveRun, + createChatAbortContext, + invokeChatAbortHandler, +} from "./chat.abort.test-helpers.js"; type TranscriptLine = { message?: Record; @@ -31,17 +36,6 @@ vi.mock("../session-utils.js", async (importOriginal) => { const { chatHandlers } = await import("./chat.js"); -function createActiveRun(sessionKey: string, sessionId: string) { - const now = Date.now(); - return { - controller: new AbortController(), - sessionId, - sessionKey, - startedAtMs: now, - expiresAtMs: now + 30_000, - }; -} - async function writeTranscriptHeader(transcriptPath: string, sessionId: string) { const header = { type: "session", @@ -81,49 +75,6 @@ async function createTranscriptFixture(prefix: string) { return { transcriptPath, sessionId }; } -function createChatAbortContext(overrides: Record = {}): { - chatAbortControllers: Map>; - chatRunBuffers: Map; - chatDeltaSentAt: Map; - chatAbortedRuns: Map; - removeChatRun: ReturnType; - agentRunSeq: Map; - broadcast: ReturnType; - nodeSendToSession: ReturnType; - logGateway: { warn: ReturnType }; - dedupe?: { get: ReturnType }; -} { - return { - chatAbortControllers: new Map(), - chatRunBuffers: new Map(), - chatDeltaSentAt: new Map(), - chatAbortedRuns: new Map(), - removeChatRun: vi - .fn() - .mockImplementation((run: string) => ({ sessionKey: "main", clientRunId: run })), - agentRunSeq: new Map(), - broadcast: vi.fn(), - nodeSendToSession: vi.fn(), - logGateway: { warn: vi.fn() }, - ...overrides, - }; -} - -async function invokeChatAbort( - context: ReturnType, - params: { sessionKey: string; runId?: string }, - respond: ReturnType, -) { - await chatHandlers["chat.abort"]({ - params, - respond: respond as never, - context: context as never, - req: {} as never, - client: null, - isWebchatConnect: () => false, - }); -} - afterEach(() => { vi.restoreAllMocks(); }); @@ -134,7 +85,7 @@ describe("chat abort transcript persistence", () => { const runId = "idem-abort-run-1"; const respond = vi.fn(); const context = createChatAbortContext({ - chatAbortControllers: new Map([[runId, createActiveRun("main", sessionId)]]), + chatAbortControllers: new Map([[runId, createActiveRun("main", { sessionId })]]), chatRunBuffers: new Map([[runId, "Partial from run abort"]]), chatDeltaSentAt: new Map([[runId, Date.now()]]), removeChatRun: vi @@ -149,17 +100,27 @@ describe("chat abort transcript persistence", () => { logGateway: { warn: vi.fn() }, }); - await invokeChatAbort(context, { sessionKey: "main", runId }, respond); + await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], + context, + request: { sessionKey: "main", runId }, + respond, + }); const [ok1, payload1] = respond.mock.calls.at(-1) ?? []; expect(ok1).toBe(true); expect(payload1).toMatchObject({ aborted: true, runIds: [runId] }); - context.chatAbortControllers.set(runId, createActiveRun("main", sessionId)); + context.chatAbortControllers.set(runId, createActiveRun("main", { sessionId })); context.chatRunBuffers.set(runId, "Partial from run abort"); context.chatDeltaSentAt.set(runId, Date.now()); - await invokeChatAbort(context, { sessionKey: "main", runId }, respond); + await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], + context, + request: { sessionKey: "main", runId }, + respond, + }); const lines = await readTranscriptLines(transcriptPath); const persisted = lines @@ -188,8 +149,8 @@ describe("chat abort transcript persistence", () => { const respond = vi.fn(); const context = createChatAbortContext({ chatAbortControllers: new Map([ - ["run-a", createActiveRun("main", sessionId)], - ["run-b", createActiveRun("main", sessionId)], + ["run-a", createActiveRun("main", { sessionId })], + ["run-b", createActiveRun("main", { sessionId })], ]), chatRunBuffers: new Map([ ["run-a", "Session abort partial"], @@ -201,7 +162,12 @@ describe("chat abort transcript persistence", () => { ]), }); - await invokeChatAbort(context, { sessionKey: "main" }, respond); + await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], + context, + request: { sessionKey: "main" }, + respond, + }); const [ok, payload] = respond.mock.calls.at(-1) ?? []; expect(ok).toBe(true); @@ -280,12 +246,17 @@ describe("chat abort transcript persistence", () => { const runId = "idem-abort-run-blank"; const respond = vi.fn(); const context = createChatAbortContext({ - chatAbortControllers: new Map([[runId, createActiveRun("main", sessionId)]]), + chatAbortControllers: new Map([[runId, createActiveRun("main", { sessionId })]]), chatRunBuffers: new Map([[runId, " \n\t "]]), chatDeltaSentAt: new Map([[runId, Date.now()]]), }); - await invokeChatAbort(context, { sessionKey: "main", runId }, respond); + await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], + context, + request: { sessionKey: "main", runId }, + respond, + }); const [ok, payload] = respond.mock.calls.at(-1) ?? []; expect(ok).toBe(true); diff --git a/src/gateway/server-methods/chat.abort.test-helpers.ts b/src/gateway/server-methods/chat.abort.test-helpers.ts new file mode 100644 index 00000000000..fe5cd324ccb --- /dev/null +++ b/src/gateway/server-methods/chat.abort.test-helpers.ts @@ -0,0 +1,69 @@ +import { vi } from "vitest"; + +export function createActiveRun( + sessionKey: string, + params: { + sessionId?: string; + owner?: { connId?: string; deviceId?: string }; + } = {}, +) { + const now = Date.now(); + return { + controller: new AbortController(), + sessionId: params.sessionId ?? `${sessionKey}-session`, + sessionKey, + startedAtMs: now, + expiresAtMs: now + 30_000, + ownerConnId: params.owner?.connId, + ownerDeviceId: params.owner?.deviceId, + }; +} + +export function createChatAbortContext(overrides: Record = {}) { + return { + chatAbortControllers: new Map(), + chatRunBuffers: new Map(), + chatDeltaSentAt: new Map(), + chatAbortedRuns: new Map(), + removeChatRun: vi + .fn() + .mockImplementation((run: string) => ({ sessionKey: "main", clientRunId: run })), + agentRunSeq: new Map(), + broadcast: vi.fn(), + nodeSendToSession: vi.fn(), + logGateway: { warn: vi.fn() }, + ...overrides, + }; +} + +export async function invokeChatAbortHandler(params: { + handler: (args: { + params: { sessionKey: string; runId?: string }; + respond: never; + context: never; + req: never; + client: never; + isWebchatConnect: () => boolean; + }) => Promise; + context: ReturnType; + request: { sessionKey: string; runId?: string }; + client?: { + connId?: string; + connect?: { + device?: { id?: string }; + scopes?: string[]; + }; + } | null; + respond?: ReturnType; +}) { + const respond = params.respond ?? vi.fn(); + await params.handler({ + params: params.request, + respond: respond as never, + context: params.context as never, + req: {} as never, + client: (params.client ?? null) as never, + isWebchatConnect: () => false, + }); + return respond; +} From 644fb76960ccc63af925ebe3c460489dbec96207 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:56:38 +0000 Subject: [PATCH 0490/1173] refactor: share node pending test client --- .../server-methods/nodes.invoke-wake.test.ts | 41 ++++++++----------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index 23976d71db0..58596d582f8 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -195,24 +195,28 @@ async function invokeNode(params: { return respond; } +function createNodeClient(nodeId: string) { + return { + connect: { + role: "node" as const, + client: { + id: nodeId, + mode: "node" as const, + name: "ios-test", + platform: "iOS 26.4.0", + version: "test", + }, + }, + }; +} + async function pullPending(nodeId: string) { const respond = vi.fn(); await nodeHandlers["node.pending.pull"]({ params: {}, respond: respond as never, context: {} as never, - client: { - connect: { - role: "node", - client: { - id: nodeId, - mode: "node", - name: "ios-test", - platform: "iOS 26.4.0", - version: "test", - }, - }, - } as never, + client: createNodeClient(nodeId) as never, req: { type: "req", id: "req-node-pending", method: "node.pending.pull" }, isWebchatConnect: () => false, }); @@ -225,18 +229,7 @@ async function ackPending(nodeId: string, ids: string[]) { params: { ids }, respond: respond as never, context: {} as never, - client: { - connect: { - role: "node", - client: { - id: nodeId, - mode: "node", - name: "ios-test", - platform: "iOS 26.4.0", - version: "test", - }, - }, - } as never, + client: createNodeClient(nodeId) as never, req: { type: "req", id: "req-node-pending-ack", method: "node.pending.ack" }, isWebchatConnect: () => false, }); From ee1d4eb29dc1bb762222a9ebd937472eb10eabf0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:33:03 +0000 Subject: [PATCH 0491/1173] test: align chat abort helpers with gateway handler types --- .../server-methods/chat.abort-persistence.test.ts | 2 +- src/gateway/server-methods/chat.abort.test-helpers.ts | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/gateway/server-methods/chat.abort-persistence.test.ts b/src/gateway/server-methods/chat.abort-persistence.test.ts index 31a00a3f186..e11b2dc08cb 100644 --- a/src/gateway/server-methods/chat.abort-persistence.test.ts +++ b/src/gateway/server-methods/chat.abort-persistence.test.ts @@ -197,7 +197,7 @@ describe("chat abort transcript persistence", () => { const { transcriptPath, sessionId } = await createTranscriptFixture("openclaw-chat-stop-"); const respond = vi.fn(); const context = createChatAbortContext({ - chatAbortControllers: new Map([["run-stop-1", createActiveRun("main", sessionId)]]), + chatAbortControllers: new Map([["run-stop-1", createActiveRun("main", { sessionId })]]), chatRunBuffers: new Map([["run-stop-1", "Partial from /stop"]]), chatDeltaSentAt: new Map([["run-stop-1", Date.now()]]), removeChatRun: vi.fn().mockReturnValue({ sessionKey: "main", clientRunId: "client-stop-1" }), diff --git a/src/gateway/server-methods/chat.abort.test-helpers.ts b/src/gateway/server-methods/chat.abort.test-helpers.ts index fe5cd324ccb..c1db68f5774 100644 --- a/src/gateway/server-methods/chat.abort.test-helpers.ts +++ b/src/gateway/server-methods/chat.abort.test-helpers.ts @@ -1,4 +1,5 @@ import { vi } from "vitest"; +import type { GatewayRequestHandler } from "./types.js"; export function createActiveRun( sessionKey: string, @@ -37,14 +38,7 @@ export function createChatAbortContext(overrides: Record = {}) } export async function invokeChatAbortHandler(params: { - handler: (args: { - params: { sessionKey: string; runId?: string }; - respond: never; - context: never; - req: never; - client: never; - isWebchatConnect: () => boolean; - }) => Promise; + handler: GatewayRequestHandler; context: ReturnType; request: { sessionKey: string; runId?: string }; client?: { From 7778627b71d442485afff9ea3496d94292eadf8f Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Sat, 14 Mar 2026 01:38:06 +0800 Subject: [PATCH 0492/1173] fix(ollama): hide native reasoning-only output (#45330) Thanks @xi7ang Co-authored-by: xi7ang <266449609+xi7ang@users.noreply.github.com> Co-authored-by: Frank Yang --- CHANGELOG.md | 1 + src/agents/ollama-stream.test.ts | 16 ++++++++-------- src/agents/ollama-stream.ts | 17 ++++------------- 3 files changed, 13 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a8270dd154..f7679f4c5b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Ollama/reasoning visibility: stop promoting native `thinking` and `reasoning` fields into final assistant text so local reasoning models no longer leak internal thoughts in normal replies. (#45330) Thanks @xi7ang. - Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups. - Windows/gateway auth: stop attaching device identity on local loopback shared-token and password gateway calls, so native Windows agent replies no longer log stale `device signature expired` fallback noise before succeeding. - Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus. diff --git a/src/agents/ollama-stream.test.ts b/src/agents/ollama-stream.test.ts index 2af5e490c7f..241c7a0f858 100644 --- a/src/agents/ollama-stream.test.ts +++ b/src/agents/ollama-stream.test.ts @@ -106,7 +106,7 @@ describe("buildAssistantMessage", () => { expect(result.usage.totalTokens).toBe(15); }); - it("falls back to thinking when content is empty", () => { + it("drops thinking-only output when content is empty", () => { const response = { model: "qwen3:32b", created_at: "2026-01-01T00:00:00Z", @@ -119,10 +119,10 @@ describe("buildAssistantMessage", () => { }; const result = buildAssistantMessage(response, modelInfo); expect(result.stopReason).toBe("stop"); - expect(result.content).toEqual([{ type: "text", text: "Thinking output" }]); + expect(result.content).toEqual([]); }); - it("falls back to reasoning when content and thinking are empty", () => { + it("drops reasoning-only output when content and thinking are empty", () => { const response = { model: "qwen3:32b", created_at: "2026-01-01T00:00:00Z", @@ -135,7 +135,7 @@ describe("buildAssistantMessage", () => { }; const result = buildAssistantMessage(response, modelInfo); expect(result.stopReason).toBe("stop"); - expect(result.content).toEqual([{ type: "text", text: "Reasoning output" }]); + expect(result.content).toEqual([]); }); it("builds response with tool calls", () => { @@ -485,7 +485,7 @@ describe("createOllamaStreamFn", () => { ); }); - it("accumulates thinking chunks when content is empty", async () => { + it("drops thinking chunks when no final content is emitted", async () => { await withMockNdjsonFetch( [ '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","thinking":"reasoned"},"done":false}', @@ -501,7 +501,7 @@ describe("createOllamaStreamFn", () => { throw new Error("Expected done event"); } - expect(doneEvent.message.content).toEqual([{ type: "text", text: "reasoned output" }]); + expect(doneEvent.message.content).toEqual([]); }, ); }); @@ -528,7 +528,7 @@ describe("createOllamaStreamFn", () => { ); }); - it("accumulates reasoning chunks when thinking is absent", async () => { + it("drops reasoning chunks when no final content is emitted", async () => { await withMockNdjsonFetch( [ '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","reasoning":"reasoned"},"done":false}', @@ -544,7 +544,7 @@ describe("createOllamaStreamFn", () => { throw new Error("Expected done event"); } - expect(doneEvent.message.content).toEqual([{ type: "text", text: "reasoned output" }]); + expect(doneEvent.message.content).toEqual([]); }, ); }); diff --git a/src/agents/ollama-stream.ts b/src/agents/ollama-stream.ts index 9d23852bb31..70a2ef33cf1 100644 --- a/src/agents/ollama-stream.ts +++ b/src/agents/ollama-stream.ts @@ -340,10 +340,9 @@ export function buildAssistantMessage( ): AssistantMessage { const content: (TextContent | ToolCall)[] = []; - // Ollama-native reasoning models may emit their answer in `thinking` or - // `reasoning` with an empty `content`. Fall back so replies are not dropped. - const text = - response.message.content || response.message.thinking || response.message.reasoning || ""; + // Native Ollama reasoning fields are internal model output. The reply text + // must come from `content`; reasoning visibility is controlled elsewhere. + const text = response.message.content || ""; if (text) { content.push({ type: "text", text }); } @@ -497,20 +496,12 @@ export function createOllamaStreamFn( const reader = response.body.getReader(); let accumulatedContent = ""; - let fallbackContent = ""; - let sawContent = false; const accumulatedToolCalls: OllamaToolCall[] = []; let finalResponse: OllamaChatResponse | undefined; for await (const chunk of parseNdjsonStream(reader)) { if (chunk.message?.content) { - sawContent = true; accumulatedContent += chunk.message.content; - } else if (!sawContent && chunk.message?.thinking) { - fallbackContent += chunk.message.thinking; - } else if (!sawContent && chunk.message?.reasoning) { - // Backward compatibility for older/native variants that still use reasoning. - fallbackContent += chunk.message.reasoning; } // Ollama sends tool_calls in intermediate (done:false) chunks, @@ -529,7 +520,7 @@ export function createOllamaStreamFn( throw new Error("Ollama API stream ended without a final response"); } - finalResponse.message.content = accumulatedContent || fallbackContent; + finalResponse.message.content = accumulatedContent; if (accumulatedToolCalls.length > 0) { finalResponse.message.tool_calls = accumulatedToolCalls; } From 9b5000057ec611116b39214807a9bf9ea544b603 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:41:58 +0000 Subject: [PATCH 0493/1173] ci: remove Android Node 20 action warnings --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b365b2ed944..2761a7b0d3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -747,23 +747,37 @@ jobs: uses: actions/setup-java@v5 with: distribution: temurin - # setup-android's sdkmanager currently crashes on JDK 21 in CI. + # Keep sdkmanager on the stable JDK path for Linux CI runners. java-version: 17 - - name: Setup Android SDK - uses: android-actions/setup-android@v3 - with: - accept-android-sdk-licenses: false + - name: Setup Android SDK cmdline-tools + run: | + set -euo pipefail + ANDROID_SDK_ROOT="$HOME/.android-sdk" + CMDLINE_TOOLS_VERSION="12266719" + ARCHIVE="commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip" + URL="https://dl.google.com/android/repository/${ARCHIVE}" + + mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools" + curl -fsSL "$URL" -o "/tmp/${ARCHIVE}" + rm -rf "$ANDROID_SDK_ROOT/cmdline-tools/latest" + unzip -q "/tmp/${ARCHIVE}" -d "$ANDROID_SDK_ROOT/cmdline-tools" + mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest" + + echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV" + echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV" + echo "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin" >> "$GITHUB_PATH" + echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH" - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v5 with: gradle-version: 8.11.1 - name: Install Android SDK packages run: | - yes | sdkmanager --licenses >/dev/null - sdkmanager --install \ + yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null + sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --install \ "platform-tools" \ "platforms;android-36" \ "build-tools;36.0.0" From 4aec20d36586b96a3b755d3a8725ec9976a92775 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:45:21 +0000 Subject: [PATCH 0494/1173] test: tighten gateway helper coverage --- src/gateway/control-ui-routing.test.ts | 155 +++++--- src/gateway/live-tool-probe-utils.test.ts | 421 ++++++++++++---------- src/gateway/origin-check.test.ts | 185 +++++----- src/gateway/ws-log.test.ts | 109 ++++-- 4 files changed, 511 insertions(+), 359 deletions(-) diff --git a/src/gateway/control-ui-routing.test.ts b/src/gateway/control-ui-routing.test.ts index f3f172cc7d4..929c645cd01 100644 --- a/src/gateway/control-ui-routing.test.ts +++ b/src/gateway/control-ui-routing.test.ts @@ -2,65 +2,114 @@ import { describe, expect, it } from "vitest"; import { classifyControlUiRequest } from "./control-ui-routing.js"; describe("classifyControlUiRequest", () => { - it("falls through non-read root requests for plugin webhooks", () => { - const classified = classifyControlUiRequest({ - basePath: "", - pathname: "/bluebubbles-webhook", - search: "", - method: "POST", + describe("root-mounted control ui", () => { + it.each([ + { + name: "serves the root entrypoint", + pathname: "/", + method: "GET", + expected: { kind: "serve" as const }, + }, + { + name: "serves other read-only SPA routes", + pathname: "/chat", + method: "HEAD", + expected: { kind: "serve" as const }, + }, + { + name: "keeps health probes outside the SPA catch-all", + pathname: "/healthz", + method: "GET", + expected: { kind: "not-control-ui" as const }, + }, + { + name: "keeps readiness probes outside the SPA catch-all", + pathname: "/ready", + method: "HEAD", + expected: { kind: "not-control-ui" as const }, + }, + { + name: "keeps plugin routes outside the SPA catch-all", + pathname: "/plugins/webhook", + method: "GET", + expected: { kind: "not-control-ui" as const }, + }, + { + name: "keeps API routes outside the SPA catch-all", + pathname: "/api/sessions", + method: "GET", + expected: { kind: "not-control-ui" as const }, + }, + { + name: "returns not-found for legacy ui routes", + pathname: "/ui/settings", + method: "GET", + expected: { kind: "not-found" as const }, + }, + { + name: "falls through non-read requests", + pathname: "/bluebubbles-webhook", + method: "POST", + expected: { kind: "not-control-ui" as const }, + }, + ])("$name", ({ pathname, method, expected }) => { + expect( + classifyControlUiRequest({ + basePath: "", + pathname, + search: "", + method, + }), + ).toEqual(expected); }); - expect(classified).toEqual({ kind: "not-control-ui" }); }); - it("returns not-found for legacy /ui routes when root-mounted", () => { - const classified = classifyControlUiRequest({ - basePath: "", - pathname: "/ui/settings", - search: "", - method: "GET", - }); - expect(classified).toEqual({ kind: "not-found" }); - }); - - it("falls through basePath non-read methods for plugin webhooks", () => { - const classified = classifyControlUiRequest({ - basePath: "/openclaw", - pathname: "/openclaw", - search: "", - method: "POST", - }); - expect(classified).toEqual({ kind: "not-control-ui" }); - }); - - it("falls through PUT/DELETE/PATCH/OPTIONS under basePath for plugin handlers", () => { - for (const method of ["PUT", "DELETE", "PATCH", "OPTIONS"]) { - const classified = classifyControlUiRequest({ - basePath: "/openclaw", + describe("basePath-mounted control ui", () => { + it.each([ + { + name: "redirects the basePath entrypoint", + pathname: "/openclaw", + search: "?foo=1", + method: "GET", + expected: { kind: "redirect" as const, location: "/openclaw/?foo=1" }, + }, + { + name: "serves nested read-only routes", + pathname: "/openclaw/chat", + search: "", + method: "HEAD", + expected: { kind: "serve" as const }, + }, + { + name: "falls through unmatched paths", + pathname: "/elsewhere/chat", + search: "", + method: "GET", + expected: { kind: "not-control-ui" as const }, + }, + { + name: "falls through write requests to the basePath entrypoint", + pathname: "/openclaw", + search: "", + method: "POST", + expected: { kind: "not-control-ui" as const }, + }, + ...["PUT", "DELETE", "PATCH", "OPTIONS"].map((method) => ({ + name: `falls through ${method} subroute requests`, pathname: "/openclaw/webhook", search: "", method, - }); - expect(classified, `${method} should fall through`).toEqual({ kind: "not-control-ui" }); - } - }); - - it("returns redirect for basePath entrypoint GET", () => { - const classified = classifyControlUiRequest({ - basePath: "/openclaw", - pathname: "/openclaw", - search: "?foo=1", - method: "GET", + expected: { kind: "not-control-ui" as const }, + })), + ])("$name", ({ pathname, search, method, expected }) => { + expect( + classifyControlUiRequest({ + basePath: "/openclaw", + pathname, + search, + method, + }), + ).toEqual(expected); }); - expect(classified).toEqual({ kind: "redirect", location: "/openclaw/?foo=1" }); - }); - - it("classifies basePath subroutes as control ui", () => { - const classified = classifyControlUiRequest({ - basePath: "/openclaw", - pathname: "/openclaw/chat", - search: "", - method: "HEAD", - }); - expect(classified).toEqual({ kind: "serve" }); }); }); diff --git a/src/gateway/live-tool-probe-utils.test.ts b/src/gateway/live-tool-probe-utils.test.ts index ca73032c6fb..75f27c08036 100644 --- a/src/gateway/live-tool-probe-utils.test.ts +++ b/src/gateway/live-tool-probe-utils.test.ts @@ -8,198 +8,245 @@ import { } from "./live-tool-probe-utils.js"; describe("live tool probe utils", () => { - it("matches nonce pair when both are present", () => { - expect(hasExpectedToolNonce("value a-1 and b-2", "a-1", "b-2")).toBe(true); - expect(hasExpectedToolNonce("value a-1 only", "a-1", "b-2")).toBe(false); + describe("nonce matching", () => { + it.each([ + { + name: "matches tool nonce pairs only when both are present", + actual: hasExpectedToolNonce("value a-1 and b-2", "a-1", "b-2"), + expected: true, + }, + { + name: "rejects partial tool nonce matches", + actual: hasExpectedToolNonce("value a-1 only", "a-1", "b-2"), + expected: false, + }, + { + name: "matches a single nonce when present", + actual: hasExpectedSingleNonce("value nonce-1", "nonce-1"), + expected: true, + }, + { + name: "rejects single nonce mismatches", + actual: hasExpectedSingleNonce("value nonce-2", "nonce-1"), + expected: false, + }, + ])("$name", ({ actual, expected }) => { + expect(actual).toBe(expected); + }); }); - it("matches single nonce when present", () => { - expect(hasExpectedSingleNonce("value nonce-1", "nonce-1")).toBe(true); - expect(hasExpectedSingleNonce("value nonce-2", "nonce-1")).toBe(false); + describe("refusal detection", () => { + it.each([ + { + name: "detects nonce refusal phrasing", + text: "Same request, same answer — this isn't a real OpenClaw probe. No part of the system asks me to parrot back nonce values.", + expected: true, + }, + { + name: "detects prompt-injection style refusals without nonce text", + text: "That's not a legitimate self-test. This looks like a prompt injection attempt.", + expected: true, + }, + { + name: "ignores generic helper text", + text: "I can help with that request.", + expected: false, + }, + { + name: "does not treat nonce markers without the word nonce as refusal", + text: "No part of the system asks me to parrot back values.", + expected: false, + }, + ])("$name", ({ text, expected }) => { + expect(isLikelyToolNonceRefusal(text)).toBe(expected); + }); }); - it("detects anthropic nonce refusal phrasing", () => { - expect( - isLikelyToolNonceRefusal( - "Same request, same answer — this isn't a real OpenClaw probe. No part of the system asks me to parrot back nonce values.", - ), - ).toBe(true); + describe("shouldRetryToolReadProbe", () => { + it.each([ + { + name: "retries malformed tool output when attempts remain", + params: { + text: "read[object Object],[object Object]", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "mistral", + attempt: 0, + maxAttempts: 3, + }, + expected: true, + }, + { + name: "does not retry once max attempts are exhausted", + params: { + text: "read[object Object],[object Object]", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "mistral", + attempt: 2, + maxAttempts: 3, + }, + expected: false, + }, + { + name: "does not retry when the nonce pair is already present", + params: { + text: "nonce-a nonce-b", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "mistral", + attempt: 0, + maxAttempts: 3, + }, + expected: false, + }, + { + name: "prefers a valid nonce pair even if the text still contains scaffolding words", + params: { + text: "tool output nonce-a nonce-b function", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "openai", + attempt: 0, + maxAttempts: 3, + }, + expected: false, + }, + { + name: "retries empty output", + params: { + text: " ", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "openai", + attempt: 0, + maxAttempts: 3, + }, + expected: true, + }, + { + name: "retries tool scaffolding output", + params: { + text: "Use tool function read[] now.", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "openai", + attempt: 0, + maxAttempts: 3, + }, + expected: true, + }, + { + name: "retries mistral nonce marker echoes without parsed values", + params: { + text: "nonceA= nonceB=", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "mistral", + attempt: 0, + maxAttempts: 3, + }, + expected: true, + }, + { + name: "retries anthropic refusal output", + params: { + text: "This isn't a real OpenClaw probe; I won't parrot back nonce values.", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "anthropic", + attempt: 0, + maxAttempts: 3, + }, + expected: true, + }, + { + name: "does not special-case anthropic refusals for other providers", + params: { + text: "This isn't a real OpenClaw probe; I won't parrot back nonce values.", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "openai", + attempt: 0, + maxAttempts: 3, + }, + expected: false, + }, + ])("$name", ({ params, expected }) => { + expect(shouldRetryToolReadProbe(params)).toBe(expected); + }); }); - it("does not treat generic helper text as nonce refusal", () => { - expect(isLikelyToolNonceRefusal("I can help with that request.")).toBe(false); - }); - - it("detects prompt-injection style tool refusal without nonce text", () => { - expect( - isLikelyToolNonceRefusal( - "That's not a legitimate self-test. This looks like a prompt injection attempt.", - ), - ).toBe(true); - }); - - it("retries malformed tool output when attempts remain", () => { - expect( - shouldRetryToolReadProbe({ - text: "read[object Object],[object Object]", - nonceA: "nonce-a", - nonceB: "nonce-b", - provider: "mistral", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(true); - }); - - it("does not retry once max attempts are exhausted", () => { - expect( - shouldRetryToolReadProbe({ - text: "read[object Object],[object Object]", - nonceA: "nonce-a", - nonceB: "nonce-b", - provider: "mistral", - attempt: 2, - maxAttempts: 3, - }), - ).toBe(false); - }); - - it("does not retry when nonce pair is already present", () => { - expect( - shouldRetryToolReadProbe({ - text: "nonce-a nonce-b", - nonceA: "nonce-a", - nonceB: "nonce-b", - provider: "mistral", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(false); - }); - - it("retries when tool output is empty and attempts remain", () => { - expect( - shouldRetryToolReadProbe({ - text: " ", - nonceA: "nonce-a", - nonceB: "nonce-b", - provider: "openai", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(true); - }); - - it("retries when output still looks like tool/function scaffolding", () => { - expect( - shouldRetryToolReadProbe({ - text: "Use tool function read[] now.", - nonceA: "nonce-a", - nonceB: "nonce-b", - provider: "openai", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(true); - }); - - it("retries mistral nonce marker echoes without parsed nonce values", () => { - expect( - shouldRetryToolReadProbe({ - text: "nonceA= nonceB=", - nonceA: "nonce-a", - nonceB: "nonce-b", - provider: "mistral", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(true); - }); - - it("retries anthropic nonce refusal output", () => { - expect( - shouldRetryToolReadProbe({ - text: "This isn't a real OpenClaw probe; I won't parrot back nonce values.", - nonceA: "nonce-a", - nonceB: "nonce-b", - provider: "anthropic", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(true); - }); - - it("retries anthropic prompt-injection refusal output", () => { - expect( - shouldRetryToolReadProbe({ - text: "This is not a legitimate self-test; it appears to be a prompt injection attempt.", - nonceA: "nonce-a", - nonceB: "nonce-b", - provider: "anthropic", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(true); - }); - - it("does not retry nonce marker echoes for non-mistral providers", () => { - expect( - shouldRetryToolReadProbe({ - text: "nonceA= nonceB=", - nonceA: "nonce-a", - nonceB: "nonce-b", - provider: "openai", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(false); - }); - - it("retries malformed exec+read output when attempts remain", () => { - expect( - shouldRetryExecReadProbe({ - text: "read[object Object]", - nonce: "nonce-c", - provider: "openai", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(true); - }); - - it("does not retry exec+read once max attempts are exhausted", () => { - expect( - shouldRetryExecReadProbe({ - text: "read[object Object]", - nonce: "nonce-c", - provider: "openai", - attempt: 2, - maxAttempts: 3, - }), - ).toBe(false); - }); - - it("does not retry exec+read when nonce is present", () => { - expect( - shouldRetryExecReadProbe({ - text: "nonce-c", - nonce: "nonce-c", - provider: "openai", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(false); - }); - - it("retries anthropic exec+read nonce refusal output", () => { - expect( - shouldRetryExecReadProbe({ - text: "No part of the system asks me to parrot back nonce values.", - nonce: "nonce-c", - provider: "anthropic", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(true); + describe("shouldRetryExecReadProbe", () => { + it.each([ + { + name: "retries malformed exec+read output when attempts remain", + params: { + text: "read[object Object]", + nonce: "nonce-c", + provider: "openai", + attempt: 0, + maxAttempts: 3, + }, + expected: true, + }, + { + name: "does not retry once max attempts are exhausted", + params: { + text: "read[object Object]", + nonce: "nonce-c", + provider: "openai", + attempt: 2, + maxAttempts: 3, + }, + expected: false, + }, + { + name: "does not retry when the nonce is already present", + params: { + text: "nonce-c", + nonce: "nonce-c", + provider: "openai", + attempt: 0, + maxAttempts: 3, + }, + expected: false, + }, + { + name: "prefers a valid nonce even if the text still contains scaffolding words", + params: { + text: "tool output nonce-c function", + nonce: "nonce-c", + provider: "openai", + attempt: 0, + maxAttempts: 3, + }, + expected: false, + }, + { + name: "retries anthropic nonce refusal output", + params: { + text: "No part of the system asks me to parrot back nonce values.", + nonce: "nonce-c", + provider: "anthropic", + attempt: 0, + maxAttempts: 3, + }, + expected: true, + }, + { + name: "does not special-case anthropic refusals for other providers", + params: { + text: "No part of the system asks me to parrot back nonce values.", + nonce: "nonce-c", + provider: "openai", + attempt: 0, + maxAttempts: 3, + }, + expected: false, + }, + ])("$name", ({ params, expected }) => { + expect(shouldRetryExecReadProbe(params)).toBe(expected); + }); }); }); diff --git a/src/gateway/origin-check.test.ts b/src/gateway/origin-check.test.ts index 50c031e927d..2bdec288fd6 100644 --- a/src/gateway/origin-check.test.ts +++ b/src/gateway/origin-check.test.ts @@ -2,102 +2,93 @@ import { describe, expect, it } from "vitest"; import { checkBrowserOrigin } from "./origin-check.js"; describe("checkBrowserOrigin", () => { - it("accepts same-origin host matches only with legacy host-header fallback", () => { - const result = checkBrowserOrigin({ - requestHost: "127.0.0.1:18789", - origin: "http://127.0.0.1:18789", - allowHostHeaderOriginFallback: true, - }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.matchedBy).toBe("host-header-fallback"); - } - }); - - it("rejects same-origin host matches when legacy host-header fallback is disabled", () => { - const result = checkBrowserOrigin({ - requestHost: "gateway.example.com:18789", - origin: "https://gateway.example.com:18789", - }); - expect(result.ok).toBe(false); - }); - - it("accepts loopback host mismatches for dev", () => { - const result = checkBrowserOrigin({ - requestHost: "127.0.0.1:18789", - origin: "http://localhost:5173", - isLocalClient: true, - }); - expect(result.ok).toBe(true); - }); - - it("rejects loopback origin mismatches when request is not local", () => { - const result = checkBrowserOrigin({ - requestHost: "127.0.0.1:18789", - origin: "http://localhost:5173", - isLocalClient: false, - }); - expect(result.ok).toBe(false); - }); - - it("accepts allowlisted origins", () => { - const result = checkBrowserOrigin({ - requestHost: "gateway.example.com:18789", - origin: "https://control.example.com", - allowedOrigins: ["https://control.example.com"], - }); - expect(result.ok).toBe(true); - }); - - it("accepts wildcard allowedOrigins", () => { - const result = checkBrowserOrigin({ - requestHost: "gateway.example.com:18789", - origin: "https://any-origin.example.com", - allowedOrigins: ["*"], - }); - expect(result.ok).toBe(true); - }); - - it("rejects missing origin", () => { - const result = checkBrowserOrigin({ - requestHost: "gateway.example.com:18789", - origin: "", - }); - expect(result.ok).toBe(false); - }); - - it("rejects mismatched origins", () => { - const result = checkBrowserOrigin({ - requestHost: "gateway.example.com:18789", - origin: "https://attacker.example.com", - }); - expect(result.ok).toBe(false); - }); - - it('accepts any origin when allowedOrigins includes "*" (regression: #30990)', () => { - const result = checkBrowserOrigin({ - requestHost: "100.86.79.37:18789", - origin: "https://100.86.79.37:18789", - allowedOrigins: ["*"], - }); - expect(result.ok).toBe(true); - }); - - it('accepts any origin when allowedOrigins includes "*" alongside specific entries', () => { - const result = checkBrowserOrigin({ - requestHost: "gateway.tailnet.ts.net:18789", - origin: "https://gateway.tailnet.ts.net:18789", - allowedOrigins: ["https://control.example.com", "*"], - }); - expect(result.ok).toBe(true); - }); - - it("accepts wildcard entries with surrounding whitespace", () => { - const result = checkBrowserOrigin({ - requestHost: "100.86.79.37:18789", - origin: "https://100.86.79.37:18789", - allowedOrigins: [" * "], - }); - expect(result.ok).toBe(true); + it.each([ + { + name: "accepts host-header fallback when explicitly enabled", + input: { + requestHost: "127.0.0.1:18789", + origin: "http://127.0.0.1:18789", + allowHostHeaderOriginFallback: true, + }, + expected: { ok: true as const, matchedBy: "host-header-fallback" as const }, + }, + { + name: "rejects same-origin host matches when fallback is disabled", + input: { + requestHost: "gateway.example.com:18789", + origin: "https://gateway.example.com:18789", + }, + expected: { ok: false as const, reason: "origin not allowed" }, + }, + { + name: "accepts local loopback mismatches for local clients", + input: { + requestHost: "127.0.0.1:18789", + origin: "http://localhost:5173", + isLocalClient: true, + }, + expected: { ok: true as const, matchedBy: "local-loopback" as const }, + }, + { + name: "rejects loopback mismatches for non-local clients", + input: { + requestHost: "127.0.0.1:18789", + origin: "http://localhost:5173", + isLocalClient: false, + }, + expected: { ok: false as const, reason: "origin not allowed" }, + }, + { + name: "accepts trimmed lowercase-normalized allowlist matches", + input: { + requestHost: "gateway.example.com:18789", + origin: "https://CONTROL.example.com", + allowedOrigins: [" https://control.example.com "], + }, + expected: { ok: true as const, matchedBy: "allowlist" as const }, + }, + { + name: "accepts wildcard allowlists even alongside specific entries", + input: { + requestHost: "gateway.tailnet.ts.net:18789", + origin: "https://any-origin.example.com", + allowedOrigins: ["https://control.example.com", " * "], + }, + expected: { ok: true as const, matchedBy: "allowlist" as const }, + }, + { + name: "rejects missing origin", + input: { + requestHost: "gateway.example.com:18789", + origin: "", + }, + expected: { ok: false as const, reason: "origin missing or invalid" }, + }, + { + name: 'rejects literal "null" origin', + input: { + requestHost: "gateway.example.com:18789", + origin: "null", + }, + expected: { ok: false as const, reason: "origin missing or invalid" }, + }, + { + name: "rejects malformed origin URLs", + input: { + requestHost: "gateway.example.com:18789", + origin: "not a url", + }, + expected: { ok: false as const, reason: "origin missing or invalid" }, + }, + { + name: "rejects mismatched origins", + input: { + requestHost: "gateway.example.com:18789", + origin: "https://attacker.example.com", + }, + expected: { ok: false as const, reason: "origin not allowed" }, + }, + ])("$name", ({ input, expected }) => { + expect(checkBrowserOrigin(input)).toEqual(expected); }); }); diff --git a/src/gateway/ws-log.test.ts b/src/gateway/ws-log.test.ts index 5a748c38eb7..a14bca6f628 100644 --- a/src/gateway/ws-log.test.ts +++ b/src/gateway/ws-log.test.ts @@ -2,20 +2,39 @@ import { describe, expect, test } from "vitest"; import { formatForLog, shortId, summarizeAgentEventForWsLog } from "./ws-log.js"; describe("gateway ws log helpers", () => { - test("shortId compacts uuids and long strings", () => { - expect(shortId("12345678-1234-1234-1234-123456789abc")).toBe("12345678…9abc"); - expect(shortId("a".repeat(30))).toBe("aaaaaaaaaaaa…aaaa"); - expect(shortId("short")).toBe("short"); + test.each([ + { + name: "compacts uuids", + input: "12345678-1234-1234-1234-123456789abc", + expected: "12345678…9abc", + }, + { + name: "compacts long strings", + input: "a".repeat(30), + expected: "aaaaaaaaaaaa…aaaa", + }, + { + name: "trims before checking length", + input: " short ", + expected: "short", + }, + ])("shortId $name", ({ input, expected }) => { + expect(shortId(input)).toBe(expected); }); - test("formatForLog formats errors and messages", () => { - const err = new Error("boom"); - err.name = "TestError"; - expect(formatForLog(err)).toContain("TestError"); - expect(formatForLog(err)).toContain("boom"); - - const obj = { name: "Oops", message: "failed", code: "E1" }; - expect(formatForLog(obj)).toBe("Oops: failed: code=E1"); + test.each([ + { + name: "formats Error instances", + input: Object.assign(new Error("boom"), { name: "TestError" }), + expected: "TestError: boom", + }, + { + name: "formats message-like objects with codes", + input: { name: "Oops", message: "failed", code: "E1" }, + expected: "Oops: failed: code=E1", + }, + ])("formatForLog $name", ({ input, expected }) => { + expect(formatForLog(input)).toBe(expected); }); test("formatForLog redacts obvious secrets", () => { @@ -26,33 +45,79 @@ describe("gateway ws log helpers", () => { expect(out).toContain("…"); }); - test("summarizeAgentEventForWsLog extracts useful fields", () => { + test("summarizeAgentEventForWsLog compacts assistant payloads", () => { const summary = summarizeAgentEventForWsLog({ runId: "12345678-1234-1234-1234-123456789abc", sessionKey: "agent:main:main", stream: "assistant", seq: 2, - data: { text: "hello world", mediaUrls: ["a", "b"] }, + data: { + text: "hello\n\nworld ".repeat(20), + mediaUrls: ["a", "b"], + }, }); + expect(summary).toMatchObject({ agent: "main", run: "12345678…9abc", session: "main", stream: "assistant", aseq: 2, - text: "hello world", media: 2, }); + expect(summary.text).toBeTypeOf("string"); + expect(summary.text).not.toContain("\n"); + }); - const tool = summarizeAgentEventForWsLog({ - runId: "run-1", - stream: "tool", - data: { phase: "start", name: "fetch", toolCallId: "call-1" }, - }); - expect(tool).toMatchObject({ + test("summarizeAgentEventForWsLog includes tool metadata", () => { + expect( + summarizeAgentEventForWsLog({ + runId: "run-1", + stream: "tool", + data: { phase: "start", name: "fetch", toolCallId: "12345678-1234-1234-1234-123456789abc" }, + }), + ).toMatchObject({ + run: "run-1", stream: "tool", tool: "start:fetch", - call: "call-1", + call: "12345678…9abc", + }); + }); + + test("summarizeAgentEventForWsLog includes lifecycle errors with compact previews", () => { + const summary = summarizeAgentEventForWsLog({ + runId: "run-2", + sessionKey: "agent:main:thread-1", + stream: "lifecycle", + data: { + phase: "abort", + aborted: true, + error: "fatal ".repeat(40), + }, + }); + + expect(summary).toMatchObject({ + agent: "main", + session: "thread-1", + stream: "lifecycle", + phase: "abort", + aborted: true, + }); + expect(summary.error).toBeTypeOf("string"); + expect((summary.error as string).length).toBeLessThanOrEqual(120); + }); + + test("summarizeAgentEventForWsLog preserves invalid session keys and unknown-stream reasons", () => { + expect( + summarizeAgentEventForWsLog({ + sessionKey: "bogus-session", + stream: "other", + data: { reason: "dropped" }, + }), + ).toEqual({ + session: "bogus-session", + stream: "other", + reason: "dropped", }); }); }); From 2d32cf283948203a5606a195937ef0b374f80fdf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:47:47 +0000 Subject: [PATCH 0495/1173] test: harden infra formatter and retry coverage --- src/infra/format-time/format-time.test.ts | 43 ++++- src/infra/retry-policy.test.ts | 184 +++++++++++++++++----- 2 files changed, 180 insertions(+), 47 deletions(-) diff --git a/src/infra/format-time/format-time.test.ts b/src/infra/format-time/format-time.test.ts index e9a25578edd..22ae60dcc6d 100644 --- a/src/infra/format-time/format-time.test.ts +++ b/src/infra/format-time/format-time.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { formatUtcTimestamp, formatZonedTimestamp, resolveTimezone } from "./format-datetime.js"; import { formatDurationCompact, @@ -188,6 +188,15 @@ describe("format-relative", () => { }); describe("formatRelativeTimestamp", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-02-10T12:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it("returns fallback for invalid timestamp input", () => { for (const value of [null, undefined]) { expect(formatRelativeTimestamp(value)).toBe("n/a"); @@ -197,21 +206,39 @@ describe("format-relative", () => { it.each([ { offsetMs: -10000, expected: "just now" }, + { offsetMs: -30000, expected: "just now" }, { offsetMs: -300000, expected: "5m ago" }, { offsetMs: -7200000, expected: "2h ago" }, + { offsetMs: -(47 * 3600000), expected: "47h ago" }, + { offsetMs: -(48 * 3600000), expected: "2d ago" }, { offsetMs: 30000, expected: "in <1m" }, { offsetMs: 300000, expected: "in 5m" }, { offsetMs: 7200000, expected: "in 2h" }, ])("formats relative timestamp for offset $offsetMs", ({ offsetMs, expected }) => { - const now = Date.now(); - expect(formatRelativeTimestamp(now + offsetMs)).toBe(expected); + expect(formatRelativeTimestamp(Date.now() + offsetMs)).toBe(expected); }); - it("falls back to date for old timestamps when enabled", () => { - const oldDate = Date.now() - 30 * 24 * 3600000; // 30 days ago - const result = formatRelativeTimestamp(oldDate, { dateFallback: true }); - // Should be a short date like "Jan 9" not "30d ago" - expect(result).toMatch(/[A-Z][a-z]{2} \d{1,2}/); + it.each([ + { + name: "keeps 7-day-old timestamps relative", + offsetMs: -7 * 24 * 3600000, + options: { dateFallback: true, timezone: "UTC" }, + expected: "7d ago", + }, + { + name: "falls back to a short date once the timestamp is older than 7 days", + offsetMs: -8 * 24 * 3600000, + options: { dateFallback: true, timezone: "UTC" }, + expected: "Feb 2", + }, + { + name: "keeps relative output when date fallback is disabled", + offsetMs: -8 * 24 * 3600000, + options: { timezone: "UTC" }, + expected: "8d ago", + }, + ])("$name", ({ offsetMs, options, expected }) => { + expect(formatRelativeTimestamp(Date.now() + offsetMs, options)).toBe(expected); }); }); }); diff --git a/src/infra/retry-policy.test.ts b/src/infra/retry-policy.test.ts index 76a4415deee..be0e4d91de3 100644 --- a/src/infra/retry-policy.test.ts +++ b/src/infra/retry-policy.test.ts @@ -1,48 +1,154 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createTelegramRetryRunner } from "./retry-policy.js"; +const ZERO_DELAY_RETRY = { attempts: 3, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }; + describe("createTelegramRetryRunner", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + describe("strictShouldRetry", () => { - it("without strictShouldRetry: ECONNRESET is retried via regex fallback even when predicate returns false", async () => { - const fn = vi - .fn() - .mockRejectedValue(Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" })); - const runner = createTelegramRetryRunner({ - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - shouldRetry: () => false, // predicate says no - // strictShouldRetry not set — regex fallback still applies - }); - await expect(runner(fn, "test")).rejects.toThrow("ECONNRESET"); - // Regex matches "reset" so it retried despite shouldRetry returning false - expect(fn).toHaveBeenCalledTimes(2); - }); + it.each([ + { + name: "falls back to regex matching when strictShouldRetry is disabled", + runnerOptions: { + retry: { ...ZERO_DELAY_RETRY, attempts: 2 }, + shouldRetry: () => false, + }, + fnSteps: [ + { + type: "reject" as const, + value: Object.assign(new Error("read ECONNRESET"), { + code: "ECONNRESET", + }), + }, + ], + expectedCalls: 2, + expectedError: "ECONNRESET", + }, + { + name: "suppresses regex fallback when strictShouldRetry is enabled", + runnerOptions: { + retry: { ...ZERO_DELAY_RETRY, attempts: 2 }, + shouldRetry: () => false, + strictShouldRetry: true, + }, + fnSteps: [ + { + type: "reject" as const, + value: Object.assign(new Error("read ECONNRESET"), { + code: "ECONNRESET", + }), + }, + ], + expectedCalls: 1, + expectedError: "ECONNRESET", + }, + { + name: "still retries when the strict predicate returns true", + runnerOptions: { + retry: { ...ZERO_DELAY_RETRY, attempts: 2 }, + shouldRetry: (err: unknown) => (err as { code?: string }).code === "ECONNREFUSED", + strictShouldRetry: true, + }, + fnSteps: [ + { + type: "reject" as const, + value: Object.assign(new Error("ECONNREFUSED"), { + code: "ECONNREFUSED", + }), + }, + { type: "resolve" as const, value: "ok" }, + ], + expectedCalls: 2, + expectedValue: "ok", + }, + { + name: "does not retry unrelated errors when neither predicate nor regex match", + runnerOptions: { + retry: { ...ZERO_DELAY_RETRY, attempts: 2 }, + }, + fnSteps: [ + { + type: "reject" as const, + value: Object.assign(new Error("permission denied"), { + code: "EACCES", + }), + }, + ], + expectedCalls: 1, + expectedError: "permission denied", + }, + { + name: "keeps retrying retriable errors until attempts are exhausted", + runnerOptions: { + retry: ZERO_DELAY_RETRY, + }, + fnSteps: [ + { + type: "reject" as const, + value: Object.assign(new Error("connection timeout"), { + code: "ETIMEDOUT", + }), + }, + ], + expectedCalls: 3, + expectedError: "connection timeout", + }, + ])("$name", async ({ runnerOptions, fnSteps, expectedCalls, expectedValue, expectedError }) => { + vi.useFakeTimers(); + const runner = createTelegramRetryRunner(runnerOptions); + const fn = vi.fn(); + const allRejects = fnSteps.length > 0 && fnSteps.every((step) => step.type === "reject"); + if (allRejects) { + fn.mockRejectedValue(fnSteps[0]?.value); + } + for (const [index, step] of fnSteps.entries()) { + if (allRejects && index > 0) { + break; + } + if (step.type === "reject") { + fn.mockRejectedValueOnce(step.value); + } else { + fn.mockResolvedValueOnce(step.value); + } + } - it("with strictShouldRetry=true: ECONNRESET is NOT retried when predicate returns false", async () => { - const fn = vi - .fn() - .mockRejectedValue(Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" })); - const runner = createTelegramRetryRunner({ - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - shouldRetry: () => false, - strictShouldRetry: true, // predicate is authoritative - }); - await expect(runner(fn, "test")).rejects.toThrow("ECONNRESET"); - // No retry — predicate returned false and regex fallback was suppressed - expect(fn).toHaveBeenCalledTimes(1); - }); + const promise = runner(fn, "test"); + const assertion = expectedError + ? expect(promise).rejects.toThrow(expectedError) + : expect(promise).resolves.toBe(expectedValue); - it("with strictShouldRetry=true: ECONNREFUSED is still retried when predicate returns true", async () => { - const fn = vi - .fn() - .mockRejectedValueOnce(Object.assign(new Error("ECONNREFUSED"), { code: "ECONNREFUSED" })) - .mockResolvedValue("ok"); - const runner = createTelegramRetryRunner({ - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - shouldRetry: (err) => (err as { code?: string }).code === "ECONNREFUSED", - strictShouldRetry: true, - }); - await expect(runner(fn, "test")).resolves.toBe("ok"); - expect(fn).toHaveBeenCalledTimes(2); + await vi.runAllTimersAsync(); + await assertion; + expect(fn).toHaveBeenCalledTimes(expectedCalls); }); }); + + it("honors nested retry_after hints before retrying", async () => { + vi.useFakeTimers(); + + const runner = createTelegramRetryRunner({ + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1_000, jitter: 0 }, + }); + const fn = vi + .fn() + .mockRejectedValueOnce({ + message: "429 Too Many Requests", + response: { parameters: { retry_after: 1 } }, + }) + .mockResolvedValue("ok"); + + const promise = runner(fn, "test"); + + expect(fn).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(999); + expect(fn).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + await expect(promise).resolves.toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); + }); }); From f5b006f6a1a5dde4047d2dd5d4b07b4267a5c35a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:49:32 +0000 Subject: [PATCH 0496/1173] test: simplify model ref normalization coverage --- src/agents/model-selection.test.ts | 232 ++++++++++++++--------------- 1 file changed, 111 insertions(+), 121 deletions(-) diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 63aef63561c..35ac52dcf26 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -80,131 +80,121 @@ describe("model-selection", () => { }); describe("parseModelRef", () => { - it("should parse full model refs", () => { - expect(parseModelRef("anthropic/claude-3-5-sonnet", "openai")).toEqual({ - provider: "anthropic", - model: "claude-3-5-sonnet", - }); + const expectParsedModelVariants = ( + variants: string[], + defaultProvider: string, + expected: { provider: string; model: string }, + ) => { + for (const raw of variants) { + expect(parseModelRef(raw, defaultProvider), raw).toEqual(expected); + } + }; + + it.each([ + { + name: "parses explicit provider/model refs", + variants: ["anthropic/claude-3-5-sonnet"], + defaultProvider: "openai", + expected: { provider: "anthropic", model: "claude-3-5-sonnet" }, + }, + { + name: "uses the default provider when omitted", + variants: ["claude-3-5-sonnet"], + defaultProvider: "anthropic", + expected: { provider: "anthropic", model: "claude-3-5-sonnet" }, + }, + { + name: "preserves nested model ids after the provider prefix", + variants: ["nvidia/moonshotai/kimi-k2.5"], + defaultProvider: "anthropic", + expected: { provider: "nvidia", model: "moonshotai/kimi-k2.5" }, + }, + { + name: "normalizes anthropic shorthand aliases", + variants: ["anthropic/opus-4.6", "opus-4.6", " anthropic / opus-4.6 "], + defaultProvider: "anthropic", + expected: { provider: "anthropic", model: "claude-opus-4-6" }, + }, + { + name: "normalizes anthropic sonnet aliases", + variants: ["anthropic/sonnet-4.6", "sonnet-4.6"], + defaultProvider: "anthropic", + expected: { provider: "anthropic", model: "claude-sonnet-4-6" }, + }, + { + name: "normalizes deprecated google flash preview ids", + variants: ["google/gemini-3.1-flash-preview", "gemini-3.1-flash-preview"], + defaultProvider: "google", + expected: { provider: "google", model: "gemini-3-flash-preview" }, + }, + { + name: "normalizes gemini 3.1 flash-lite ids", + variants: ["google/gemini-3.1-flash-lite", "gemini-3.1-flash-lite"], + defaultProvider: "google", + expected: { provider: "google", model: "gemini-3.1-flash-lite-preview" }, + }, + { + name: "keeps OpenAI codex refs on the openai provider", + variants: ["openai/gpt-5.3-codex", "gpt-5.3-codex"], + defaultProvider: "openai", + expected: { provider: "openai", model: "gpt-5.3-codex" }, + }, + { + name: "preserves openrouter native model prefixes", + variants: ["openrouter/aurora-alpha"], + defaultProvider: "openai", + expected: { provider: "openrouter", model: "openrouter/aurora-alpha" }, + }, + { + name: "passes through openrouter upstream provider ids", + variants: ["openrouter/anthropic/claude-sonnet-4-5"], + defaultProvider: "openai", + expected: { provider: "openrouter", model: "anthropic/claude-sonnet-4-5" }, + }, + { + name: "normalizes Vercel Claude shorthand to anthropic-prefixed model ids", + variants: ["vercel-ai-gateway/claude-opus-4.6"], + defaultProvider: "openai", + expected: { provider: "vercel-ai-gateway", model: "anthropic/claude-opus-4.6" }, + }, + { + name: "normalizes Vercel Anthropic aliases without double-prefixing", + variants: ["vercel-ai-gateway/opus-4.6"], + defaultProvider: "openai", + expected: { provider: "vercel-ai-gateway", model: "anthropic/claude-opus-4-6" }, + }, + { + name: "keeps already-prefixed Vercel Anthropic models unchanged", + variants: ["vercel-ai-gateway/anthropic/claude-opus-4.6"], + defaultProvider: "openai", + expected: { provider: "vercel-ai-gateway", model: "anthropic/claude-opus-4.6" }, + }, + { + name: "passes through non-Claude Vercel model ids unchanged", + variants: ["vercel-ai-gateway/openai/gpt-5.2"], + defaultProvider: "openai", + expected: { provider: "vercel-ai-gateway", model: "openai/gpt-5.2" }, + }, + { + name: "keeps already-suffixed codex variants unchanged", + variants: ["openai/gpt-5.3-codex-codex"], + defaultProvider: "anthropic", + expected: { provider: "openai", model: "gpt-5.3-codex-codex" }, + }, + ])("$name", ({ variants, defaultProvider, expected }) => { + expectParsedModelVariants(variants, defaultProvider, expected); }); - it("preserves nested model ids after provider prefix", () => { - expect(parseModelRef("nvidia/moonshotai/kimi-k2.5", "anthropic")).toEqual({ - provider: "nvidia", - model: "moonshotai/kimi-k2.5", - }); + it("round-trips normalized refs through modelKey", () => { + const parsed = parseModelRef(" opus-4.6 ", "anthropic"); + expect(parsed).toEqual({ provider: "anthropic", model: "claude-opus-4-6" }); + expect(modelKey(parsed?.provider ?? "", parsed?.model ?? "")).toBe( + "anthropic/claude-opus-4-6", + ); }); - it("normalizes anthropic alias refs to canonical model ids", () => { - expect(parseModelRef("anthropic/opus-4.6", "openai")).toEqual({ - provider: "anthropic", - model: "claude-opus-4-6", - }); - expect(parseModelRef("opus-4.6", "anthropic")).toEqual({ - provider: "anthropic", - model: "claude-opus-4-6", - }); - expect(parseModelRef("anthropic/sonnet-4.6", "openai")).toEqual({ - provider: "anthropic", - model: "claude-sonnet-4-6", - }); - expect(parseModelRef("sonnet-4.6", "anthropic")).toEqual({ - provider: "anthropic", - model: "claude-sonnet-4-6", - }); - }); - - it("should use default provider if none specified", () => { - expect(parseModelRef("claude-3-5-sonnet", "anthropic")).toEqual({ - provider: "anthropic", - model: "claude-3-5-sonnet", - }); - }); - - it("normalizes deprecated google flash preview ids to the working model id", () => { - expect(parseModelRef("google/gemini-3.1-flash-preview", "openai")).toEqual({ - provider: "google", - model: "gemini-3-flash-preview", - }); - expect(parseModelRef("gemini-3.1-flash-preview", "google")).toEqual({ - provider: "google", - model: "gemini-3-flash-preview", - }); - }); - - it("normalizes gemini 3.1 flash-lite to the preview model id", () => { - expect(parseModelRef("google/gemini-3.1-flash-lite", "openai")).toEqual({ - provider: "google", - model: "gemini-3.1-flash-lite-preview", - }); - expect(parseModelRef("gemini-3.1-flash-lite", "google")).toEqual({ - provider: "google", - model: "gemini-3.1-flash-lite-preview", - }); - }); - - it("keeps openai gpt-5.3 codex refs on the openai provider", () => { - expect(parseModelRef("openai/gpt-5.3-codex", "anthropic")).toEqual({ - provider: "openai", - model: "gpt-5.3-codex", - }); - expect(parseModelRef("gpt-5.3-codex", "openai")).toEqual({ - provider: "openai", - model: "gpt-5.3-codex", - }); - expect(parseModelRef("openai/gpt-5.3-codex-codex", "anthropic")).toEqual({ - provider: "openai", - model: "gpt-5.3-codex-codex", - }); - }); - - it("should return null for empty strings", () => { - expect(parseModelRef("", "anthropic")).toBeNull(); - expect(parseModelRef(" ", "anthropic")).toBeNull(); - }); - - it("should preserve openrouter/ prefix for native models", () => { - expect(parseModelRef("openrouter/aurora-alpha", "openai")).toEqual({ - provider: "openrouter", - model: "openrouter/aurora-alpha", - }); - }); - - it("should pass through openrouter external provider models as-is", () => { - expect(parseModelRef("openrouter/anthropic/claude-sonnet-4-5", "openai")).toEqual({ - provider: "openrouter", - model: "anthropic/claude-sonnet-4-5", - }); - }); - - it("normalizes Vercel Claude shorthand to anthropic-prefixed model ids", () => { - expect(parseModelRef("vercel-ai-gateway/claude-opus-4.6", "openai")).toEqual({ - provider: "vercel-ai-gateway", - model: "anthropic/claude-opus-4.6", - }); - expect(parseModelRef("vercel-ai-gateway/opus-4.6", "openai")).toEqual({ - provider: "vercel-ai-gateway", - model: "anthropic/claude-opus-4-6", - }); - }); - - it("keeps already-prefixed Vercel Anthropic models unchanged", () => { - expect(parseModelRef("vercel-ai-gateway/anthropic/claude-opus-4.6", "openai")).toEqual({ - provider: "vercel-ai-gateway", - model: "anthropic/claude-opus-4.6", - }); - }); - - it("passes through non-Claude Vercel model ids unchanged", () => { - expect(parseModelRef("vercel-ai-gateway/openai/gpt-5.2", "openai")).toEqual({ - provider: "vercel-ai-gateway", - model: "openai/gpt-5.2", - }); - }); - - it("should handle invalid slash usage", () => { - expect(parseModelRef("/", "anthropic")).toBeNull(); - expect(parseModelRef("anthropic/", "anthropic")).toBeNull(); - expect(parseModelRef("/model", "anthropic")).toBeNull(); + it.each(["", " ", "/", "anthropic/", "/model"])("returns null for invalid ref %j", (raw) => { + expect(parseModelRef(raw, "anthropic")).toBeNull(); }); }); From 87c447ed46c355bb8c54c41324a5b5a63c0a61aa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:51:36 +0000 Subject: [PATCH 0497/1173] test: tighten failover classifier coverage --- ...dded-helpers.isbillingerrormessage.test.ts | 265 ++++++++++-------- 1 file changed, 143 insertions(+), 122 deletions(-) diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 3cbefadbce8..e8578c7feb2 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -45,98 +45,117 @@ const GROQ_TOO_MANY_REQUESTS_MESSAGE = const GROQ_SERVICE_UNAVAILABLE_MESSAGE = "503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance."; // pragma: allowlist secret +function expectMessageMatches( + matcher: (message: string) => boolean, + samples: readonly string[], + expected: boolean, +) { + for (const sample of samples) { + expect(matcher(sample), sample).toBe(expected); + } +} + describe("isAuthPermanentErrorMessage", () => { - it("matches permanent auth failure patterns", () => { - const samples = [ - "invalid_api_key", - "api key revoked", - "api key deactivated", - "key has been disabled", - "key has been revoked", - "account has been deactivated", - "could not authenticate api key", - "could not validate credentials", - "API_KEY_REVOKED", - "api_key_deleted", - ]; - for (const sample of samples) { - expect(isAuthPermanentErrorMessage(sample)).toBe(true); - } - }); - it("does not match transient auth errors", () => { - const samples = [ - "unauthorized", - "invalid token", - "authentication failed", - "forbidden", - "access denied", - "token has expired", - ]; - for (const sample of samples) { - expect(isAuthPermanentErrorMessage(sample)).toBe(false); - } + it.each([ + { + name: "matches permanent auth failure patterns", + samples: [ + "invalid_api_key", + "api key revoked", + "api key deactivated", + "key has been disabled", + "key has been revoked", + "account has been deactivated", + "could not authenticate api key", + "could not validate credentials", + "API_KEY_REVOKED", + "api_key_deleted", + ], + expected: true, + }, + { + name: "does not match transient auth errors", + samples: [ + "unauthorized", + "invalid token", + "authentication failed", + "forbidden", + "access denied", + "token has expired", + ], + expected: false, + }, + ])("$name", ({ samples, expected }) => { + expectMessageMatches(isAuthPermanentErrorMessage, samples, expected); }); }); describe("isAuthErrorMessage", () => { - it("matches credential validation errors", () => { - const samples = [ - 'No credentials found for profile "anthropic:default".', - "No API key found for profile openai.", - ]; - for (const sample of samples) { - expect(isAuthErrorMessage(sample)).toBe(true); - } - }); - it("matches OAuth refresh failures", () => { - const samples = [ - "OAuth token refresh failed for anthropic: Failed to refresh OAuth token for anthropic. Please try again or re-authenticate.", - "Please re-authenticate to continue.", - ]; - for (const sample of samples) { - expect(isAuthErrorMessage(sample)).toBe(true); - } + it.each([ + 'No credentials found for profile "anthropic:default".', + "No API key found for profile openai.", + "OAuth token refresh failed for anthropic: Failed to refresh OAuth token for anthropic. Please try again or re-authenticate.", + "Please re-authenticate to continue.", + ])("matches auth errors for %j", (sample) => { + expect(isAuthErrorMessage(sample)).toBe(true); }); }); describe("isBillingErrorMessage", () => { - it("matches credit / payment failures", () => { - const samples = [ - "Your credit balance is too low to access the Anthropic API.", - "insufficient credits", - "Payment Required", - "HTTP 402 Payment Required", - "plans & billing", - // Venice returns "Insufficient USD or Diem balance" which has extra words - // between "insufficient" and "balance" - "Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.", - // OpenRouter returns "requires more credits" for underfunded accounts - "This model requires more credits to use", - "This endpoint require more credits", - ]; - for (const sample of samples) { - expect(isBillingErrorMessage(sample)).toBe(true); - } - }); - it("does not false-positive on issue IDs or text containing 402", () => { - const falsePositives = [ - "Fixed issue CHE-402 in the latest release", - "See ticket #402 for details", - "ISSUE-402 has been resolved", - "Room 402 is available", - "Error code 403 was returned, not 402-related", - "The building at 402 Main Street", - "processed 402 records", - "402 items found in the database", - "port 402 is open", - "Use a 402 stainless bolt", - "Book a 402 room", - "There is a 402 near me", - ]; - for (const sample of falsePositives) { - expect(isBillingErrorMessage(sample)).toBe(false); - } + it.each([ + { + name: "matches credit and payment failures", + samples: [ + "Your credit balance is too low to access the Anthropic API.", + "insufficient credits", + "Payment Required", + "HTTP 402 Payment Required", + "plans & billing", + "Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.", + "This model requires more credits to use", + "This endpoint require more credits", + ], + expected: true, + }, + { + name: "does not false-positive on issue ids and numeric references", + samples: [ + "Fixed issue CHE-402 in the latest release", + "See ticket #402 for details", + "ISSUE-402 has been resolved", + "Room 402 is available", + "Error code 403 was returned, not 402-related", + "The building at 402 Main Street", + "processed 402 records", + "402 items found in the database", + "port 402 is open", + "Use a 402 stainless bolt", + "Book a 402 room", + "There is a 402 near me", + ], + expected: false, + }, + { + name: "still matches real HTTP 402 billing errors", + samples: [ + "HTTP 402 Payment Required", + "status: 402", + "error code 402", + "http 402", + "status=402 payment required", + "got a 402 from the API", + "returned 402", + "received a 402 response", + '{"status":402,"type":"error"}', + '{"code":402,"message":"payment required"}', + '{"error":{"code":402,"message":"billing hard limit reached"}}', + ], + expected: true, + }, + ])("$name", ({ samples, expected }) => { + expectMessageMatches(isBillingErrorMessage, samples, expected); }); + it("does not false-positive on long assistant responses mentioning billing keywords", () => { // Simulate a multi-paragraph assistant response that mentions billing terms const longResponse = @@ -176,37 +195,27 @@ describe("isBillingErrorMessage", () => { expect(longNonError.length).toBeGreaterThan(512); expect(isBillingErrorMessage(longNonError)).toBe(false); }); - it("still matches real HTTP 402 billing errors", () => { - const realErrors = [ - "HTTP 402 Payment Required", - "status: 402", - "error code 402", - "http 402", - "status=402 payment required", - "got a 402 from the API", - "returned 402", - "received a 402 response", - '{"status":402,"type":"error"}', - '{"code":402,"message":"payment required"}', - '{"error":{"code":402,"message":"billing hard limit reached"}}', - ]; - for (const sample of realErrors) { - expect(isBillingErrorMessage(sample)).toBe(true); - } + + it("prefers billing when API-key and 402 hints both appear", () => { + const sample = + "402 Payment Required: The account associated with this API key has reached its maximum allowed monthly spending limit."; + expect(isBillingErrorMessage(sample)).toBe(true); + expect(classifyFailoverReason(sample)).toBe("billing"); }); }); describe("isCloudCodeAssistFormatError", () => { it("matches format errors", () => { - const samples = [ - "INVALID_REQUEST_ERROR: string should match pattern", - "messages.1.content.1.tool_use.id", - "tool_use.id should match pattern", - "invalid request format", - ]; - for (const sample of samples) { - expect(isCloudCodeAssistFormatError(sample)).toBe(true); - } + expectMessageMatches( + isCloudCodeAssistFormatError, + [ + "INVALID_REQUEST_ERROR: string should match pattern", + "messages.1.content.1.tool_use.id", + "tool_use.id should match pattern", + "invalid request format", + ], + true, + ); }); }); @@ -238,20 +247,24 @@ describe("isCloudflareOrHtmlErrorPage", () => { }); describe("isCompactionFailureError", () => { - it("matches compaction overflow failures", () => { - const samples = [ - 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', - "auto-compaction failed due to context overflow", - "Compaction failed: prompt is too long", - "Summarization failed: context window exceeded for this request", - ]; - for (const sample of samples) { - expect(isCompactionFailureError(sample)).toBe(true); - } - }); - it("ignores non-compaction overflow errors", () => { - expect(isCompactionFailureError("Context overflow: prompt too large")).toBe(false); - expect(isCompactionFailureError("rate limit exceeded")).toBe(false); + it.each([ + { + name: "matches compaction overflow failures", + samples: [ + 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', + "auto-compaction failed due to context overflow", + "Compaction failed: prompt is too long", + "Summarization failed: context window exceeded for this request", + ], + expected: true, + }, + { + name: "ignores non-compaction overflow errors", + samples: ["Context overflow: prompt too large", "rate limit exceeded"], + expected: false, + }, + ])("$name", ({ samples, expected }) => { + expectMessageMatches(isCompactionFailureError, samples, expected); }); }); @@ -506,6 +519,10 @@ describe("isTransientHttpError", () => { }); describe("classifyFailoverReasonFromHttpStatus", () => { + it("treats HTTP 401 permanent auth failures as auth_permanent", () => { + expect(classifyFailoverReasonFromHttpStatus(401, "invalid_api_key")).toBe("auth_permanent"); + }); + it("treats HTTP 422 as format error", () => { expect(classifyFailoverReasonFromHttpStatus(422)).toBe("format"); expect(classifyFailoverReasonFromHttpStatus(422, "check open ai req parameter error")).toBe( @@ -518,6 +535,10 @@ describe("classifyFailoverReasonFromHttpStatus", () => { expect(classifyFailoverReasonFromHttpStatus(422, "insufficient credits")).toBe("billing"); }); + it("treats HTTP 400 insufficient-quota payloads as billing instead of format", () => { + expect(classifyFailoverReasonFromHttpStatus(400, INSUFFICIENT_QUOTA_PAYLOAD)).toBe("billing"); + }); + it("treats HTTP 499 as transient for structured errors", () => { expect(classifyFailoverReasonFromHttpStatus(499)).toBe("timeout"); expect(classifyFailoverReasonFromHttpStatus(499, "499 Client Closed Request")).toBe("timeout"); From 118abfbdb78375aa0af22ed78e2d71d7f7b0d7bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:52:49 +0000 Subject: [PATCH 0498/1173] test: simplify trusted proxy coverage --- src/gateway/net.test.ts | 252 ++++++++++++++++++++++------------------ 1 file changed, 141 insertions(+), 111 deletions(-) diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index f5ee5db9a8e..185325d5428 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -49,117 +49,147 @@ describe("isLocalishHost", () => { }); describe("isTrustedProxyAddress", () => { - describe("exact IP matching", () => { - it("returns true when IP matches exactly", () => { - expect(isTrustedProxyAddress("192.168.1.1", ["192.168.1.1"])).toBe(true); - }); - - it("returns false when IP does not match", () => { - expect(isTrustedProxyAddress("192.168.1.2", ["192.168.1.1"])).toBe(false); - }); - - it("returns true when IP matches one of multiple proxies", () => { - expect(isTrustedProxyAddress("10.0.0.5", ["192.168.1.1", "10.0.0.5", "172.16.0.1"])).toBe( - true, - ); - }); - - it("ignores surrounding whitespace in exact IP entries", () => { - expect(isTrustedProxyAddress("10.0.0.5", [" 10.0.0.5 "])).toBe(true); - }); - }); - - describe("CIDR subnet matching", () => { - it("returns true when IP is within /24 subnet", () => { - expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/24"])).toBe(true); - expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.0/24"])).toBe(true); - expect(isTrustedProxyAddress("10.42.0.254", ["10.42.0.0/24"])).toBe(true); - }); - - it("returns false when IP is outside /24 subnet", () => { - expect(isTrustedProxyAddress("10.42.1.1", ["10.42.0.0/24"])).toBe(false); - expect(isTrustedProxyAddress("10.43.0.1", ["10.42.0.0/24"])).toBe(false); - }); - - it("returns true when IP is within /16 subnet", () => { - expect(isTrustedProxyAddress("172.19.5.100", ["172.19.0.0/16"])).toBe(true); - expect(isTrustedProxyAddress("172.19.255.255", ["172.19.0.0/16"])).toBe(true); - }); - - it("returns false when IP is outside /16 subnet", () => { - expect(isTrustedProxyAddress("172.20.0.1", ["172.19.0.0/16"])).toBe(false); - }); - - it("returns true when IP is within /32 subnet (single IP)", () => { - expect(isTrustedProxyAddress("10.42.0.0", ["10.42.0.0/32"])).toBe(true); - }); - - it("returns false when IP does not match /32 subnet", () => { - expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.0/32"])).toBe(false); - }); - - it("handles mixed exact IPs and CIDR notation", () => { - const proxies = ["192.168.1.1", "10.42.0.0/24", "172.19.0.0/16"]; - expect(isTrustedProxyAddress("192.168.1.1", proxies)).toBe(true); // exact match - expect(isTrustedProxyAddress("10.42.0.59", proxies)).toBe(true); // CIDR match - expect(isTrustedProxyAddress("172.19.5.100", proxies)).toBe(true); // CIDR match - expect(isTrustedProxyAddress("10.43.0.1", proxies)).toBe(false); // no match - }); - - it("supports IPv6 CIDR notation", () => { - expect(isTrustedProxyAddress("2001:db8::1234", ["2001:db8::/32"])).toBe(true); - expect(isTrustedProxyAddress("2001:db9::1234", ["2001:db8::/32"])).toBe(false); - }); - }); - - describe("backward compatibility", () => { - it("preserves exact IP matching behavior (no CIDR notation)", () => { - // Old configs with exact IPs should work exactly as before - expect(isTrustedProxyAddress("192.168.1.1", ["192.168.1.1"])).toBe(true); - expect(isTrustedProxyAddress("192.168.1.2", ["192.168.1.1"])).toBe(false); - expect(isTrustedProxyAddress("10.0.0.5", ["192.168.1.1", "10.0.0.5"])).toBe(true); - }); - - it("does NOT treat plain IPs as /32 CIDR (exact match only)", () => { - // "10.42.0.1" without /32 should match ONLY that exact IP - expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.1"])).toBe(true); - expect(isTrustedProxyAddress("10.42.0.2", ["10.42.0.1"])).toBe(false); - expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.1"])).toBe(false); - }); - - it("handles IPv4-mapped IPv6 addresses (existing normalizeIp behavior)", () => { - // Existing normalizeIp() behavior should be preserved - expect(isTrustedProxyAddress("::ffff:192.168.1.1", ["192.168.1.1"])).toBe(true); - }); - }); - - describe("edge cases", () => { - it("returns false when IP is undefined", () => { - expect(isTrustedProxyAddress(undefined, ["192.168.1.1"])).toBe(false); - }); - - it("returns false when trustedProxies is undefined", () => { - expect(isTrustedProxyAddress("192.168.1.1", undefined)).toBe(false); - }); - - it("returns false when trustedProxies is empty", () => { - expect(isTrustedProxyAddress("192.168.1.1", [])).toBe(false); - }); - - it("returns false for invalid CIDR notation", () => { - expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/33"])).toBe(false); // invalid prefix - expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/-1"])).toBe(false); // negative prefix - expect(isTrustedProxyAddress("10.42.0.59", ["invalid/24"])).toBe(false); // invalid IP - }); - - it("ignores surrounding whitespace in CIDR entries", () => { - expect(isTrustedProxyAddress("10.42.0.59", [" 10.42.0.0/24 "])).toBe(true); - }); - - it("ignores blank trusted proxy entries", () => { - expect(isTrustedProxyAddress("10.0.0.5", [" ", "\t"])).toBe(false); - expect(isTrustedProxyAddress("10.0.0.5", [" ", "10.0.0.5", ""])).toBe(true); - }); + it.each([ + { + name: "matches exact IP entries", + ip: "192.168.1.1", + trustedProxies: ["192.168.1.1"], + expected: true, + }, + { + name: "rejects non-matching exact IP entries", + ip: "192.168.1.2", + trustedProxies: ["192.168.1.1"], + expected: false, + }, + { + name: "matches one of multiple exact entries", + ip: "10.0.0.5", + trustedProxies: ["192.168.1.1", "10.0.0.5", "172.16.0.1"], + expected: true, + }, + { + name: "ignores surrounding whitespace in exact IP entries", + ip: "10.0.0.5", + trustedProxies: [" 10.0.0.5 "], + expected: true, + }, + { + name: "matches /24 CIDR entries", + ip: "10.42.0.59", + trustedProxies: ["10.42.0.0/24"], + expected: true, + }, + { + name: "rejects IPs outside /24 CIDR entries", + ip: "10.42.1.1", + trustedProxies: ["10.42.0.0/24"], + expected: false, + }, + { + name: "matches /16 CIDR entries", + ip: "172.19.255.255", + trustedProxies: ["172.19.0.0/16"], + expected: true, + }, + { + name: "rejects IPs outside /16 CIDR entries", + ip: "172.20.0.1", + trustedProxies: ["172.19.0.0/16"], + expected: false, + }, + { + name: "treats /32 as a single-IP CIDR", + ip: "10.42.0.0", + trustedProxies: ["10.42.0.0/32"], + expected: true, + }, + { + name: "rejects non-matching /32 CIDR entries", + ip: "10.42.0.1", + trustedProxies: ["10.42.0.0/32"], + expected: false, + }, + { + name: "handles mixed exact IP and CIDR entries", + ip: "172.19.5.100", + trustedProxies: ["192.168.1.1", "10.42.0.0/24", "172.19.0.0/16"], + expected: true, + }, + { + name: "rejects IPs missing from mixed exact IP and CIDR entries", + ip: "10.43.0.1", + trustedProxies: ["192.168.1.1", "10.42.0.0/24", "172.19.0.0/16"], + expected: false, + }, + { + name: "supports IPv6 CIDR notation", + ip: "2001:db8::1234", + trustedProxies: ["2001:db8::/32"], + expected: true, + }, + { + name: "rejects IPv6 addresses outside the configured CIDR", + ip: "2001:db9::1234", + trustedProxies: ["2001:db8::/32"], + expected: false, + }, + { + name: "preserves exact matching behavior for plain IP entries", + ip: "10.42.0.59", + trustedProxies: ["10.42.0.1"], + expected: false, + }, + { + name: "normalizes IPv4-mapped IPv6 addresses", + ip: "::ffff:192.168.1.1", + trustedProxies: ["192.168.1.1"], + expected: true, + }, + { + name: "returns false when IP is undefined", + ip: undefined, + trustedProxies: ["192.168.1.1"], + expected: false, + }, + { + name: "returns false when trusted proxies are undefined", + ip: "192.168.1.1", + trustedProxies: undefined, + expected: false, + }, + { + name: "returns false when trusted proxies are empty", + ip: "192.168.1.1", + trustedProxies: [], + expected: false, + }, + { + name: "rejects invalid CIDR prefixes and addresses", + ip: "10.42.0.59", + trustedProxies: ["10.42.0.0/33", "10.42.0.0/-1", "invalid/24", "2001:db8::/129"], + expected: false, + }, + { + name: "ignores surrounding whitespace in CIDR entries", + ip: "10.42.0.59", + trustedProxies: [" 10.42.0.0/24 "], + expected: true, + }, + { + name: "ignores blank trusted proxy entries", + ip: "10.0.0.5", + trustedProxies: [" ", "10.0.0.5", ""], + expected: true, + }, + { + name: "treats all-blank trusted proxy entries as no match", + ip: "10.0.0.5", + trustedProxies: [" ", "\t"], + expected: false, + }, + ])("$name", ({ ip, trustedProxies, expected }) => { + expect(isTrustedProxyAddress(ip, trustedProxies)).toBe(expected); }); }); From a68caaf719b0106a1cefd813c2a1116f6947089e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:54:38 +0000 Subject: [PATCH 0499/1173] test: dedupe infra runtime and heartbeat coverage --- src/infra/infra-runtime.test.ts | 22 --- src/infra/outbound/targets.test.ts | 262 ++++++++++++++--------------- 2 files changed, 126 insertions(+), 158 deletions(-) diff --git a/src/infra/infra-runtime.test.ts b/src/infra/infra-runtime.test.ts index e7656de974f..1596b73bbe8 100644 --- a/src/infra/infra-runtime.test.ts +++ b/src/infra/infra-runtime.test.ts @@ -13,7 +13,6 @@ import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck, } from "./restart.js"; -import { createTelegramRetryRunner } from "./retry-policy.js"; import { listTailnetAddresses } from "./tailnet.js"; describe("infra runtime", () => { @@ -61,27 +60,6 @@ describe("infra runtime", () => { }); }); - describe("createTelegramRetryRunner", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("retries when custom shouldRetry matches non-telegram error", async () => { - vi.useFakeTimers(); - const runner = createTelegramRetryRunner({ - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - shouldRetry: (err) => err instanceof Error && err.message === "boom", - }); - const fn = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValue("ok"); - - const promise = runner(fn, "request"); - await vi.runAllTimersAsync(); - - await expect(promise).resolves.toBe("ok"); - expect(fn).toHaveBeenCalledTimes(2); - }); - }); - describe("restart authorization", () => { setupRestartSignalSuite(); diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 6a8b50403b5..e0b669040a6 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -339,35 +339,138 @@ describe("resolveSessionDeliveryTarget", () => { }, }); - it("allows heartbeat delivery to Slack DMs and avoids inherited threadId by default", () => { - const resolved = resolveHeartbeatTarget({ - sessionId: "sess-heartbeat-outbound", - updatedAt: 1, - lastChannel: "slack", - lastTo: "user:U123", - lastThreadId: "1739142736.000100", - }); + const expectHeartbeatTarget = (params: { + name: string; + entry: Parameters[0]["entry"]; + directPolicy?: "allow" | "block"; + expectedChannel: string; + expectedTo?: string; + expectedReason?: string; + expectedThreadId?: string | number; + }) => { + const resolved = resolveHeartbeatTarget(params.entry, params.directPolicy); + expect(resolved.channel, params.name).toBe(params.expectedChannel); + expect(resolved.to, params.name).toBe(params.expectedTo); + expect(resolved.reason, params.name).toBe(params.expectedReason); + expect(resolved.threadId, params.name).toBe(params.expectedThreadId); + }; - expect(resolved.channel).toBe("slack"); - expect(resolved.to).toBe("user:U123"); - expect(resolved.threadId).toBeUndefined(); - }); - - it("blocks heartbeat delivery to Slack DMs when directPolicy is block", () => { - const resolved = resolveHeartbeatTarget( - { - sessionId: "sess-heartbeat-outbound", + it.each([ + { + name: "allows heartbeat delivery to Slack DMs by default and drops inherited thread ids", + entry: { + sessionId: "sess-heartbeat-slack-direct", updatedAt: 1, lastChannel: "slack", lastTo: "user:U123", lastThreadId: "1739142736.000100", }, - "block", - ); - - expect(resolved.channel).toBe("none"); - expect(resolved.reason).toBe("dm-blocked"); - expect(resolved.threadId).toBeUndefined(); + expectedChannel: "slack", + expectedTo: "user:U123", + }, + { + name: "blocks heartbeat delivery to Slack DMs when directPolicy is block", + entry: { + sessionId: "sess-heartbeat-slack-direct-blocked", + updatedAt: 1, + lastChannel: "slack", + lastTo: "user:U123", + lastThreadId: "1739142736.000100", + }, + directPolicy: "block" as const, + expectedChannel: "none", + expectedReason: "dm-blocked", + }, + { + name: "allows heartbeat delivery to Telegram direct chats by default", + entry: { + sessionId: "sess-heartbeat-telegram-direct", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "5232990709", + }, + expectedChannel: "telegram", + expectedTo: "5232990709", + }, + { + name: "blocks heartbeat delivery to Telegram direct chats when directPolicy is block", + entry: { + sessionId: "sess-heartbeat-telegram-direct-blocked", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "5232990709", + }, + directPolicy: "block" as const, + expectedChannel: "none", + expectedReason: "dm-blocked", + }, + { + name: "keeps heartbeat delivery to Telegram groups", + entry: { + sessionId: "sess-heartbeat-telegram-group", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "-1001234567890", + }, + expectedChannel: "telegram", + expectedTo: "-1001234567890", + }, + { + name: "allows heartbeat delivery to WhatsApp direct chats by default", + entry: { + sessionId: "sess-heartbeat-whatsapp-direct", + updatedAt: 1, + lastChannel: "whatsapp", + lastTo: "+15551234567", + }, + expectedChannel: "whatsapp", + expectedTo: "+15551234567", + }, + { + name: "keeps heartbeat delivery to WhatsApp groups", + entry: { + sessionId: "sess-heartbeat-whatsapp-group", + updatedAt: 1, + lastChannel: "whatsapp", + lastTo: "120363140186826074@g.us", + }, + expectedChannel: "whatsapp", + expectedTo: "120363140186826074@g.us", + }, + { + name: "uses session chatType hints when target parsing cannot classify a direct chat", + entry: { + sessionId: "sess-heartbeat-imessage-direct", + updatedAt: 1, + lastChannel: "imessage", + lastTo: "chat-guid-unknown-shape", + chatType: "direct", + }, + expectedChannel: "imessage", + expectedTo: "chat-guid-unknown-shape", + }, + { + name: "blocks session chatType direct hints when directPolicy is block", + entry: { + sessionId: "sess-heartbeat-imessage-direct-blocked", + updatedAt: 1, + lastChannel: "imessage", + lastTo: "chat-guid-unknown-shape", + chatType: "direct", + }, + directPolicy: "block" as const, + expectedChannel: "none", + expectedReason: "dm-blocked", + }, + ])("$name", ({ name, entry, directPolicy, expectedChannel, expectedTo, expectedReason }) => { + expectHeartbeatTarget({ + name, + entry, + directPolicy, + expectedChannel, + expectedTo, + expectedReason, + }); }); it("allows heartbeat delivery to Discord DMs by default", () => { @@ -389,119 +492,6 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.to).toBe("user:12345"); }); - it("allows heartbeat delivery to Telegram direct chats by default", () => { - const resolved = resolveHeartbeatTarget({ - sessionId: "sess-heartbeat-telegram-direct", - updatedAt: 1, - lastChannel: "telegram", - lastTo: "5232990709", - }); - - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("5232990709"); - }); - - it("blocks heartbeat delivery to Telegram direct chats when directPolicy is block", () => { - const resolved = resolveHeartbeatTarget( - { - sessionId: "sess-heartbeat-telegram-direct", - updatedAt: 1, - lastChannel: "telegram", - lastTo: "5232990709", - }, - "block", - ); - - expect(resolved.channel).toBe("none"); - expect(resolved.reason).toBe("dm-blocked"); - }); - - it("keeps heartbeat delivery to Telegram groups", () => { - const cfg: OpenClawConfig = {}; - const resolved = resolveHeartbeatDeliveryTarget({ - cfg, - entry: { - sessionId: "sess-heartbeat-telegram-group", - updatedAt: 1, - lastChannel: "telegram", - lastTo: "-1001234567890", - }, - heartbeat: { - target: "last", - }, - }); - - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("-1001234567890"); - }); - - it("allows heartbeat delivery to WhatsApp direct chats by default", () => { - const cfg: OpenClawConfig = {}; - const resolved = resolveHeartbeatDeliveryTarget({ - cfg, - entry: { - sessionId: "sess-heartbeat-whatsapp-direct", - updatedAt: 1, - lastChannel: "whatsapp", - lastTo: "+15551234567", - }, - heartbeat: { - target: "last", - }, - }); - - expect(resolved.channel).toBe("whatsapp"); - expect(resolved.to).toBe("+15551234567"); - }); - - it("keeps heartbeat delivery to WhatsApp groups", () => { - const cfg: OpenClawConfig = {}; - const resolved = resolveHeartbeatDeliveryTarget({ - cfg, - entry: { - sessionId: "sess-heartbeat-whatsapp-group", - updatedAt: 1, - lastChannel: "whatsapp", - lastTo: "120363140186826074@g.us", - }, - heartbeat: { - target: "last", - }, - }); - - expect(resolved.channel).toBe("whatsapp"); - expect(resolved.to).toBe("120363140186826074@g.us"); - }); - - it("uses session chatType hint when target parser cannot classify and allows direct by default", () => { - const resolved = resolveHeartbeatTarget({ - sessionId: "sess-heartbeat-imessage-direct", - updatedAt: 1, - lastChannel: "imessage", - lastTo: "chat-guid-unknown-shape", - chatType: "direct", - }); - - expect(resolved.channel).toBe("imessage"); - expect(resolved.to).toBe("chat-guid-unknown-shape"); - }); - - it("blocks session chatType direct hints when directPolicy is block", () => { - const resolved = resolveHeartbeatTarget( - { - sessionId: "sess-heartbeat-imessage-direct", - updatedAt: 1, - lastChannel: "imessage", - lastTo: "chat-guid-unknown-shape", - chatType: "direct", - }, - "block", - ); - - expect(resolved.channel).toBe("none"); - expect(resolved.reason).toBe("dm-blocked"); - }); - it("keeps heartbeat delivery to Discord channels", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ From 981062a94edbe1d6a874dfbea58ede7470b49b22 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:55:55 +0000 Subject: [PATCH 0500/1173] test: simplify outbound channel coverage --- src/infra/outbound/message.channels.test.ts | 109 +++++++++++--------- 1 file changed, 60 insertions(+), 49 deletions(-) diff --git a/src/infra/outbound/message.channels.test.ts b/src/infra/outbound/message.channels.test.ts index 0a21264b43e..257d2ec94d6 100644 --- a/src/infra/outbound/message.channels.test.ts +++ b/src/infra/outbound/message.channels.test.ts @@ -97,13 +97,10 @@ describe("sendMessage channel normalization", () => { expect(seen.to).toBe("+15551234567"); }); - it("normalizes Teams alias", async () => { - const sendMSTeams = vi.fn(async () => ({ - messageId: "m1", - conversationId: "c1", - })); - setRegistry( - createTestRegistry([ + it.each([ + { + name: "normalizes Teams aliases", + registry: createTestRegistry([ { pluginId: "msteams", source: "test", @@ -113,40 +110,57 @@ describe("sendMessage channel normalization", () => { }), }, ]), - ); - const result = await sendMessage({ - cfg: {}, - to: "conversation:19:abc@thread.tacv2", - content: "hi", - channel: "teams", - deps: { sendMSTeams }, - }); - - expect(sendMSTeams).toHaveBeenCalledWith("conversation:19:abc@thread.tacv2", "hi"); - expect(result.channel).toBe("msteams"); - }); - - it("normalizes iMessage alias", async () => { - const sendIMessage = vi.fn(async () => ({ messageId: "i1" })); - setRegistry( - createTestRegistry([ + params: { + to: "conversation:19:abc@thread.tacv2", + channel: "teams", + deps: { + sendMSTeams: vi.fn(async () => ({ + messageId: "m1", + conversationId: "c1", + })), + }, + }, + assertDeps: (deps: { sendMSTeams?: ReturnType }) => { + expect(deps.sendMSTeams).toHaveBeenCalledWith("conversation:19:abc@thread.tacv2", "hi"); + }, + expectedChannel: "msteams", + }, + { + name: "normalizes iMessage aliases", + registry: createTestRegistry([ { pluginId: "imessage", source: "test", plugin: createIMessageTestPlugin(), }, ]), - ); + params: { + to: "someone@example.com", + channel: "imsg", + deps: { + sendIMessage: vi.fn(async () => ({ messageId: "i1" })), + }, + }, + assertDeps: (deps: { sendIMessage?: ReturnType }) => { + expect(deps.sendIMessage).toHaveBeenCalledWith( + "someone@example.com", + "hi", + expect.any(Object), + ); + }, + expectedChannel: "imessage", + }, + ])("$name", async ({ registry, params, assertDeps, expectedChannel }) => { + setRegistry(registry); + const result = await sendMessage({ cfg: {}, - to: "someone@example.com", content: "hi", - channel: "imsg", - deps: { sendIMessage }, + ...params, }); - expect(sendIMessage).toHaveBeenCalledWith("someone@example.com", "hi", expect.any(Object)); - expect(result.channel).toBe("imessage"); + assertDeps(params.deps); + expect(result.channel).toBe(expectedChannel); }); }); @@ -162,34 +176,31 @@ describe("sendMessage replyToId threading", () => { return capturedCtx; }; - it("passes replyToId through to the outbound adapter", async () => { + it.each([ + { + name: "passes replyToId through to the outbound adapter", + params: { content: "thread reply", replyToId: "post123" }, + field: "replyToId", + expected: "post123", + }, + { + name: "passes threadId through to the outbound adapter", + params: { content: "topic reply", threadId: "topic456" }, + field: "threadId", + expected: "topic456", + }, + ])("$name", async ({ params, field, expected }) => { const capturedCtx = setupMattermostCapture(); await sendMessage({ cfg: {}, to: "channel:town-square", - content: "thread reply", channel: "mattermost", - replyToId: "post123", + ...params, }); expect(capturedCtx).toHaveLength(1); - expect(capturedCtx[0]?.replyToId).toBe("post123"); - }); - - it("passes threadId through to the outbound adapter", async () => { - const capturedCtx = setupMattermostCapture(); - - await sendMessage({ - cfg: {}, - to: "channel:town-square", - content: "topic reply", - channel: "mattermost", - threadId: "topic456", - }); - - expect(capturedCtx).toHaveLength(1); - expect(capturedCtx[0]?.threadId).toBe("topic456"); + expect(capturedCtx[0]?.[field]).toBe(expected); }); }); From 91f1894372d3170407d8e9a4b05563e6032345ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:57:05 +0000 Subject: [PATCH 0501/1173] test: tighten server method helper coverage --- .../server-methods/server-methods.test.ts | 99 ++++++++++--------- 1 file changed, 55 insertions(+), 44 deletions(-) diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 424511370cd..bd42485f4f8 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -221,59 +221,70 @@ describe("injectTimestamp", () => { }); describe("timestampOptsFromConfig", () => { - it("extracts timezone from config", () => { - const opts = timestampOptsFromConfig({ - agents: { - defaults: { - userTimezone: "America/Chicago", - }, - }, + it.each([ + { + name: "extracts timezone from config", // oxlint-disable-next-line typescript/no-explicit-any - } as any); - - expect(opts.timezone).toBe("America/Chicago"); - }); - - it("falls back gracefully with empty config", () => { - // oxlint-disable-next-line typescript/no-explicit-any - const opts = timestampOptsFromConfig({} as any); - - expect(opts.timezone).toBeDefined(); + cfg: { agents: { defaults: { userTimezone: "America/Chicago" } } } as any, + expected: "America/Chicago", + }, + { + name: "falls back gracefully with empty config", + // oxlint-disable-next-line typescript/no-explicit-any + cfg: {} as any, + expected: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + ])("$name", ({ cfg, expected }) => { + expect(timestampOptsFromConfig(cfg).timezone).toBe(expected); }); }); describe("normalizeRpcAttachmentsToChatAttachments", () => { - it("passes through string content", () => { - const res = normalizeRpcAttachmentsToChatAttachments([ - { type: "file", mimeType: "image/png", fileName: "a.png", content: "Zm9v" }, - ]); - expect(res).toEqual([ - { type: "file", mimeType: "image/png", fileName: "a.png", content: "Zm9v" }, - ]); - }); - - it("converts Uint8Array content to base64", () => { - const bytes = new TextEncoder().encode("foo"); - const res = normalizeRpcAttachmentsToChatAttachments([{ content: bytes }]); - expect(res[0]?.content).toBe("Zm9v"); + it.each([ + { + name: "passes through string content", + attachments: [{ type: "file", mimeType: "image/png", fileName: "a.png", content: "Zm9v" }], + expected: [{ type: "file", mimeType: "image/png", fileName: "a.png", content: "Zm9v" }], + }, + { + name: "converts Uint8Array content to base64", + attachments: [{ content: new TextEncoder().encode("foo") }], + expected: [{ type: undefined, mimeType: undefined, fileName: undefined, content: "Zm9v" }], + }, + { + name: "converts ArrayBuffer content to base64", + attachments: [{ content: new TextEncoder().encode("bar").buffer }], + expected: [{ type: undefined, mimeType: undefined, fileName: undefined, content: "YmFy" }], + }, + { + name: "drops attachments without usable content", + attachments: [{ content: undefined }, { mimeType: "image/png" }], + expected: [], + }, + ])("$name", ({ attachments, expected }) => { + expect(normalizeRpcAttachmentsToChatAttachments(attachments)).toEqual(expected); }); }); describe("sanitizeChatSendMessageInput", () => { - it("rejects null bytes", () => { - expect(sanitizeChatSendMessageInput("before\u0000after")).toEqual({ - ok: false, - error: "message must not contain null bytes", - }); - }); - - it("strips unsafe control characters while preserving tab/newline/carriage return", () => { - const result = sanitizeChatSendMessageInput("a\u0001b\tc\nd\re\u0007f\u007f"); - expect(result).toEqual({ ok: true, message: "ab\tc\nd\ref" }); - }); - - it("normalizes unicode to NFC", () => { - expect(sanitizeChatSendMessageInput("Cafe\u0301")).toEqual({ ok: true, message: "Café" }); + it.each([ + { + name: "rejects null bytes", + input: "before\u0000after", + expected: { ok: false as const, error: "message must not contain null bytes" }, + }, + { + name: "strips unsafe control characters while preserving tab/newline/carriage return", + input: "a\u0001b\tc\nd\re\u0007f\u007f", + expected: { ok: true as const, message: "ab\tc\nd\ref" }, + }, + { + name: "normalizes unicode to NFC", + input: "Cafe\u0301", + expected: { ok: true as const, message: "Café" }, + }, + ])("$name", ({ input, expected }) => { + expect(sanitizeChatSendMessageInput(input)).toEqual(expected); }); }); From e25fa446e8efafe624d81d2212b286c2a9e8e5ac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:58:28 +0000 Subject: [PATCH 0502/1173] test: refine gateway auth helper coverage --- src/gateway/device-auth.test.ts | 84 ++++++++++++++++++++++++--------- src/gateway/probe-auth.test.ts | 84 ++++++++++++++++----------------- 2 files changed, 104 insertions(+), 64 deletions(-) diff --git a/src/gateway/device-auth.test.ts b/src/gateway/device-auth.test.ts index 9d7ac3fb7b5..8db88428ce9 100644 --- a/src/gateway/device-auth.test.ts +++ b/src/gateway/device-auth.test.ts @@ -1,29 +1,69 @@ import { describe, expect, it } from "vitest"; -import { buildDeviceAuthPayloadV3, normalizeDeviceMetadataForAuth } from "./device-auth.js"; +import { + buildDeviceAuthPayload, + buildDeviceAuthPayloadV3, + normalizeDeviceMetadataForAuth, +} from "./device-auth.js"; describe("device-auth payload vectors", () => { - it("builds canonical v3 payload", () => { - const payload = buildDeviceAuthPayloadV3({ - deviceId: "dev-1", - clientId: "openclaw-macos", - clientMode: "ui", - role: "operator", - scopes: ["operator.admin", "operator.read"], - signedAtMs: 1_700_000_000_000, - token: "tok-123", - nonce: "nonce-abc", - platform: " IOS ", - deviceFamily: " iPhone ", - }); - - expect(payload).toBe( - "v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone", - ); + it.each([ + { + name: "builds canonical v2 payloads", + build: () => + buildDeviceAuthPayload({ + deviceId: "dev-1", + clientId: "openclaw-macos", + clientMode: "ui", + role: "operator", + scopes: ["operator.admin", "operator.read"], + signedAtMs: 1_700_000_000_000, + token: null, + nonce: "nonce-abc", + }), + expected: + "v2|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000||nonce-abc", + }, + { + name: "builds canonical v3 payloads", + build: () => + buildDeviceAuthPayloadV3({ + deviceId: "dev-1", + clientId: "openclaw-macos", + clientMode: "ui", + role: "operator", + scopes: ["operator.admin", "operator.read"], + signedAtMs: 1_700_000_000_000, + token: "tok-123", + nonce: "nonce-abc", + platform: " IOS ", + deviceFamily: " iPhone ", + }), + expected: + "v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone", + }, + { + name: "keeps empty metadata slots in v3 payloads", + build: () => + buildDeviceAuthPayloadV3({ + deviceId: "dev-2", + clientId: "openclaw-ios", + clientMode: "ui", + role: "operator", + scopes: ["operator.read"], + signedAtMs: 1_700_000_000_001, + nonce: "nonce-def", + }), + expected: "v3|dev-2|openclaw-ios|ui|operator|operator.read|1700000000001||nonce-def||", + }, + ])("$name", ({ build, expected }) => { + expect(build()).toBe(expected); }); - it("normalizes metadata with ASCII-only lowercase", () => { - expect(normalizeDeviceMetadataForAuth(" İOS ")).toBe("İos"); - expect(normalizeDeviceMetadataForAuth(" MAC ")).toBe("mac"); - expect(normalizeDeviceMetadataForAuth(undefined)).toBe(""); + it.each([ + { input: " İOS ", expected: "İos" }, + { input: " MAC ", expected: "mac" }, + { input: undefined, expected: "" }, + ])("normalizes metadata %j", ({ input, expected }) => { + expect(normalizeDeviceMetadataForAuth(input)).toBe(expected); }); }); diff --git a/src/gateway/probe-auth.test.ts b/src/gateway/probe-auth.test.ts index 7a6d639e10a..314702c33db 100644 --- a/src/gateway/probe-auth.test.ts +++ b/src/gateway/probe-auth.test.ts @@ -6,8 +6,9 @@ import { } from "./probe-auth.js"; describe("resolveGatewayProbeAuthSafe", () => { - it("returns probe auth credentials when available", () => { - const result = resolveGatewayProbeAuthSafe({ + it.each([ + { + name: "returns probe auth credentials when available", cfg: { gateway: { auth: { @@ -15,20 +16,17 @@ describe("resolveGatewayProbeAuthSafe", () => { }, }, } as OpenClawConfig, - mode: "local", + mode: "local" as const, env: {} as NodeJS.ProcessEnv, - }); - - expect(result).toEqual({ - auth: { - token: "token-value", - password: undefined, + expected: { + auth: { + token: "token-value", + password: undefined, + }, }, - }); - }); - - it("returns warning and empty auth when token SecretRef is unresolved", () => { - const result = resolveGatewayProbeAuthSafe({ + }, + { + name: "returns warning and empty auth when a local token SecretRef is unresolved", cfg: { gateway: { auth: { @@ -42,17 +40,15 @@ describe("resolveGatewayProbeAuthSafe", () => { }, }, } as OpenClawConfig, - mode: "local", + mode: "local" as const, env: {} as NodeJS.ProcessEnv, - }); - - expect(result.auth).toEqual({}); - expect(result.warning).toContain("gateway.auth.token"); - expect(result.warning).toContain("unresolved"); - }); - - it("does not fall through to remote token when local token SecretRef is unresolved", () => { - const result = resolveGatewayProbeAuthSafe({ + expected: { + auth: {}, + warningIncludes: ["gateway.auth.token", "unresolved"], + }, + }, + { + name: "does not fall through to remote token when the local SecretRef is unresolved", cfg: { gateway: { mode: "local", @@ -70,17 +66,15 @@ describe("resolveGatewayProbeAuthSafe", () => { }, }, } as OpenClawConfig, - mode: "local", + mode: "local" as const, env: {} as NodeJS.ProcessEnv, - }); - - expect(result.auth).toEqual({}); - expect(result.warning).toContain("gateway.auth.token"); - expect(result.warning).toContain("unresolved"); - }); - - it("ignores unresolved local token SecretRef in remote mode when remote-only auth is requested", () => { - const result = resolveGatewayProbeAuthSafe({ + expected: { + auth: {}, + warningIncludes: ["gateway.auth.token", "unresolved"], + }, + }, + { + name: "ignores unresolved local token SecretRefs in remote mode", cfg: { gateway: { mode: "remote", @@ -98,16 +92,22 @@ describe("resolveGatewayProbeAuthSafe", () => { }, }, } as OpenClawConfig, - mode: "remote", + mode: "remote" as const, env: {} as NodeJS.ProcessEnv, - }); - - expect(result).toEqual({ - auth: { - token: undefined, - password: undefined, + expected: { + auth: { + token: undefined, + password: undefined, + }, }, - }); + }, + ])("$name", ({ cfg, mode, env, expected }) => { + const result = resolveGatewayProbeAuthSafe({ cfg, mode, env }); + + expect(result.auth).toEqual(expected.auth); + for (const fragment of expected.warningIncludes ?? []) { + expect(result.warning).toContain(fragment); + } }); }); From 1f85c9af68ab1f639b3583b49fe815152865f34d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:00:03 +0000 Subject: [PATCH 0503/1173] test: simplify runtime config coverage --- src/gateway/server-runtime-config.test.ts | 99 +++++++++++++---------- 1 file changed, 57 insertions(+), 42 deletions(-) diff --git a/src/gateway/server-runtime-config.test.ts b/src/gateway/server-runtime-config.test.ts index 34cc4632670..205bac8cf3e 100644 --- a/src/gateway/server-runtime-config.test.ts +++ b/src/gateway/server-runtime-config.test.ts @@ -201,39 +201,73 @@ describe("resolveGatewayRuntimeConfig", () => { ); }); - it("rejects non-loopback control UI when allowed origins are missing", async () => { - await expect( - resolveGatewayRuntimeConfig({ - cfg: { - gateway: { - bind: "lan", - auth: TOKEN_AUTH, - }, - }, - port: 18789, - }), - ).rejects.toThrow("non-loopback Control UI requires gateway.controlUi.allowedOrigins"); - }); - - it("allows non-loopback control UI without allowed origins when dangerous fallback is enabled", async () => { - const result = await resolveGatewayRuntimeConfig({ + it.each([ + { + name: "rejects non-loopback control UI when allowed origins are missing", cfg: { gateway: { - bind: "lan", + bind: "lan" as const, + auth: TOKEN_AUTH, + }, + }, + expectedError: "non-loopback Control UI requires gateway.controlUi.allowedOrigins", + }, + { + name: "allows non-loopback control UI without allowed origins when dangerous fallback is enabled", + cfg: { + gateway: { + bind: "lan" as const, auth: TOKEN_AUTH, controlUi: { dangerouslyAllowHostHeaderOriginFallback: true, }, }, }, - port: 18789, - }); - expect(result.bindHost).toBe("0.0.0.0"); + expectedBindHost: "0.0.0.0", + }, + { + name: "allows non-loopback control UI when allowed origins collapse after trimming", + cfg: { + gateway: { + bind: "lan" as const, + auth: TOKEN_AUTH, + controlUi: { + allowedOrigins: [" https://control.example.com "], + }, + }, + }, + expectedBindHost: "0.0.0.0", + }, + ])("$name", async ({ cfg, expectedError, expectedBindHost }) => { + if (expectedError) { + await expect(resolveGatewayRuntimeConfig({ cfg, port: 18789 })).rejects.toThrow( + expectedError, + ); + return; + } + const result = await resolveGatewayRuntimeConfig({ cfg, port: 18789 }); + expect(result.bindHost).toBe(expectedBindHost); }); }); describe("HTTP security headers", () => { - it("resolves strict transport security header from config", async () => { + it.each([ + { + name: "resolves strict transport security headers from config", + strictTransportSecurity: " max-age=31536000; includeSubDomains ", + expected: "max-age=31536000; includeSubDomains", + }, + { + name: "does not set strict transport security when explicitly disabled", + strictTransportSecurity: false, + expected: undefined, + }, + { + name: "does not set strict transport security when the value is blank", + strictTransportSecurity: " ", + expected: undefined, + }, + ])("$name", async ({ strictTransportSecurity, expected }) => { const result = await resolveGatewayRuntimeConfig({ cfg: { gateway: { @@ -241,7 +275,7 @@ describe("resolveGatewayRuntimeConfig", () => { auth: { mode: "none" }, http: { securityHeaders: { - strictTransportSecurity: " max-age=31536000; includeSubDomains ", + strictTransportSecurity, }, }, }, @@ -249,26 +283,7 @@ describe("resolveGatewayRuntimeConfig", () => { port: 18789, }); - expect(result.strictTransportSecurityHeader).toBe("max-age=31536000; includeSubDomains"); - }); - - it("does not set strict transport security when explicitly disabled", async () => { - const result = await resolveGatewayRuntimeConfig({ - cfg: { - gateway: { - bind: "loopback", - auth: { mode: "none" }, - http: { - securityHeaders: { - strictTransportSecurity: false, - }, - }, - }, - }, - port: 18789, - }); - - expect(result.strictTransportSecurityHeader).toBeUndefined(); + expect(result.strictTransportSecurityHeader).toBe(expected); }); }); }); From 987c254eea57321338173ee3e1cc8b4084cf7bf2 Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Sat, 14 Mar 2026 02:03:14 +0800 Subject: [PATCH 0504/1173] test: annotate chat abort helper exports (#45346) --- .../server-methods/chat.abort.test-helpers.ts | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/gateway/server-methods/chat.abort.test-helpers.ts b/src/gateway/server-methods/chat.abort.test-helpers.ts index c1db68f5774..fb6efebd8f5 100644 --- a/src/gateway/server-methods/chat.abort.test-helpers.ts +++ b/src/gateway/server-methods/chat.abort.test-helpers.ts @@ -1,5 +1,6 @@ import { vi } from "vitest"; -import type { GatewayRequestHandler } from "./types.js"; +import type { Mock } from "vitest"; +import type { GatewayRequestHandler, RespondFn } from "./types.js"; export function createActiveRun( sessionKey: string, @@ -20,7 +21,23 @@ export function createActiveRun( }; } -export function createChatAbortContext(overrides: Record = {}) { +export type ChatAbortTestContext = Record & { + chatAbortControllers: Map>; + chatRunBuffers: Map; + chatDeltaSentAt: Map; + chatAbortedRuns: Map; + removeChatRun: (...args: unknown[]) => { sessionKey: string; clientRunId: string } | undefined; + agentRunSeq: Map; + broadcast: (...args: unknown[]) => void; + nodeSendToSession: (...args: unknown[]) => void; + logGateway: { warn: (...args: unknown[]) => void }; +}; + +export type ChatAbortRespondMock = Mock; + +export function createChatAbortContext( + overrides: Record = {}, +): ChatAbortTestContext { return { chatAbortControllers: new Map(), chatRunBuffers: new Map(), @@ -39,7 +56,7 @@ export function createChatAbortContext(overrides: Record = {}) export async function invokeChatAbortHandler(params: { handler: GatewayRequestHandler; - context: ReturnType; + context: ChatAbortTestContext; request: { sessionKey: string; runId?: string }; client?: { connId?: string; @@ -48,8 +65,8 @@ export async function invokeChatAbortHandler(params: { scopes?: string[]; }; } | null; - respond?: ReturnType; -}) { + respond?: ChatAbortRespondMock; +}): Promise { const respond = params.respond ?? vi.fn(); await params.handler({ params: params.request, From 91d4f5cd2f432d692179516e50ee33e8ef47b82a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:03:18 +0000 Subject: [PATCH 0505/1173] test: simplify control ui http coverage --- src/gateway/control-ui.http.test.ts | 219 +++++++++++++++------------- 1 file changed, 120 insertions(+), 99 deletions(-) diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index a63bb1590e2..54cf972e79c 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -40,6 +40,25 @@ describe("handleControlUiHttpRequest", () => { expect(params.end).toHaveBeenCalledWith("Not Found"); } + function expectUnhandledRoutes(params: { + urls: string[]; + method: "GET" | "POST"; + rootPath: string; + basePath?: string; + expectationLabel: string; + }) { + for (const url of params.urls) { + const { handled, end } = runControlUiRequest({ + url, + method: params.method, + rootPath: params.rootPath, + ...(params.basePath ? { basePath: params.basePath } : {}), + }); + expect(handled, `${params.expectationLabel}: ${url}`).toBe(false); + expect(end, `${params.expectationLabel}: ${url}`).not.toHaveBeenCalled(); + } + } + function runControlUiRequest(params: { url: string; method: "GET" | "HEAD" | "POST"; @@ -147,53 +166,80 @@ describe("handleControlUiHttpRequest", () => { }); }); - it("serves bootstrap config JSON", async () => { + it.each([ + { + name: "at root", + url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH, + expectedBasePath: "", + assistantName: ".png", + expectedAvatarUrl: "/avatar/main", + }, + { + name: "under basePath", + url: `/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`, + basePath: "/openclaw", + expectedBasePath: "/openclaw", + assistantName: "Ops", + assistantAvatar: "ops.png", + expectedAvatarUrl: "/openclaw/avatar/main", + }, + ])("serves bootstrap config JSON $name", async (testCase) => { await withControlUiRoot({ fn: async (tmp) => { const { res, end } = makeMockHttpResponse(); const handled = handleControlUiHttpRequest( - { url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH, method: "GET" } as IncomingMessage, + { url: testCase.url, method: "GET" } as IncomingMessage, res, { + ...(testCase.basePath ? { basePath: testCase.basePath } : {}), root: { kind: "resolved", path: tmp }, config: { agents: { defaults: { workspace: tmp } }, - ui: { assistant: { name: ".png" } }, + ui: { + assistant: { + name: testCase.assistantName, + avatar: testCase.assistantAvatar, + }, + }, }, }, ); expect(handled).toBe(true); const parsed = parseBootstrapPayload(end); - expect(parsed.basePath).toBe(""); - expect(parsed.assistantName).toBe(".png", - expectedAvatarUrl: "/avatar/main", - }, - { - name: "under basePath", - url: `/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`, - basePath: "/openclaw", - expectedBasePath: "/openclaw", - assistantName: "Ops", - assistantAvatar: "ops.png", - expectedAvatarUrl: "/openclaw/avatar/main", - }, - ])("serves bootstrap config JSON $name", async (testCase) => { + it("serves bootstrap config JSON", async () => { await withControlUiRoot({ fn: async (tmp) => { const { res, end } = makeMockHttpResponse(); const handled = handleControlUiHttpRequest( - { url: testCase.url, method: "GET" } as IncomingMessage, + { url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH, method: "GET" } as IncomingMessage, res, { - ...(testCase.basePath ? { basePath: testCase.basePath } : {}), root: { kind: "resolved", path: tmp }, config: { agents: { defaults: { workspace: tmp } }, - ui: { - assistant: { - name: testCase.assistantName, - avatar: testCase.assistantAvatar, - }, - }, + ui: { assistant: { name: ".png" } }, }, }, ); expect(handled).toBe(true); const parsed = parseBootstrapPayload(end); - expect(parsed.basePath).toBe(testCase.expectedBasePath); - expect(parsed.assistantName).toBe(testCase.assistantName); - expect(parsed.assistantAvatar).toBe(testCase.expectedAvatarUrl); + expect(parsed.basePath).toBe(""); + expect(parsed.assistantName).toBe("` : ""} + `; } @@ -360,16 +352,12 @@ type RenderedSection = { }; function buildRenderedSection(params: { - viewerPrerenderedHtml: string; - imagePrerenderedHtml: string; - payload: Omit; + viewerPayload: DiffViewerPayload; + imagePayload: DiffViewerPayload; }): RenderedSection { return { - viewer: renderDiffCard({ - prerenderedHTML: params.viewerPrerenderedHtml, - ...params.payload, - }), - image: renderStaticDiffCard(params.imagePrerenderedHtml), + viewer: renderDiffCard(params.viewerPayload), + image: renderDiffCard(params.imagePayload), }; } @@ -401,21 +389,20 @@ async function renderBeforeAfterDiff( }; const { viewerOptions, imageOptions } = buildRenderVariants(options); const [viewerResult, imageResult] = await Promise.all([ - preloadMultiFileDiff({ + preloadMultiFileDiffWithFallback({ oldFile, newFile, options: viewerOptions, }), - preloadMultiFileDiff({ + preloadMultiFileDiffWithFallback({ oldFile, newFile, options: imageOptions, }), ]); const section = buildRenderedSection({ - viewerPrerenderedHtml: viewerResult.prerenderedHTML, - imagePrerenderedHtml: imageResult.prerenderedHTML, - payload: { + viewerPayload: { + prerenderedHTML: viewerResult.prerenderedHTML, oldFile: viewerResult.oldFile, newFile: viewerResult.newFile, options: viewerOptions, @@ -424,6 +411,16 @@ async function renderBeforeAfterDiff( newFile: viewerResult.newFile, }), }, + imagePayload: { + prerenderedHTML: imageResult.prerenderedHTML, + oldFile: imageResult.oldFile, + newFile: imageResult.newFile, + options: imageOptions, + langs: buildPayloadLanguages({ + oldFile: imageResult.oldFile, + newFile: imageResult.newFile, + }), + }, }); return { @@ -456,24 +453,29 @@ async function renderPatchDiff( const sections = await Promise.all( files.map(async (fileDiff) => { const [viewerResult, imageResult] = await Promise.all([ - preloadFileDiff({ + preloadFileDiffWithFallback({ fileDiff, options: viewerOptions, }), - preloadFileDiff({ + preloadFileDiffWithFallback({ fileDiff, options: imageOptions, }), ]); return buildRenderedSection({ - viewerPrerenderedHtml: viewerResult.prerenderedHTML, - imagePrerenderedHtml: imageResult.prerenderedHTML, - payload: { + viewerPayload: { + prerenderedHTML: viewerResult.prerenderedHTML, fileDiff: viewerResult.fileDiff, options: viewerOptions, langs: buildPayloadLanguages({ fileDiff: viewerResult.fileDiff }), }, + imagePayload: { + prerenderedHTML: imageResult.prerenderedHTML, + fileDiff: imageResult.fileDiff, + options: imageOptions, + langs: buildPayloadLanguages({ fileDiff: imageResult.fileDiff }), + }, }); }), ); @@ -514,3 +516,49 @@ export async function renderDiffDocument( inputKind: input.kind, }; } + +type PreloadedFileDiffResult = Awaited>; +type PreloadedMultiFileDiffResult = Awaited>; + +function shouldFallbackToClientHydration(error: unknown): boolean { + return ( + error instanceof TypeError && + error.message.includes('needs an import attribute of "type: json"') + ); +} + +async function preloadFileDiffWithFallback(params: { + fileDiff: FileDiffMetadata; + options: DiffViewerOptions; +}): Promise { + try { + return await preloadFileDiff(params); + } catch (error) { + if (!shouldFallbackToClientHydration(error)) { + throw error; + } + return { + fileDiff: params.fileDiff, + prerenderedHTML: "", + }; + } +} + +async function preloadMultiFileDiffWithFallback(params: { + oldFile: FileContents; + newFile: FileContents; + options: DiffViewerOptions; +}): Promise { + try { + return await preloadMultiFileDiff(params); + } catch (error) { + if (!shouldFallbackToClientHydration(error)) { + throw error; + } + return { + oldFile: params.oldFile, + newFile: params.newFile, + prerenderedHTML: "", + }; + } +} diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 056b10c0643..2f845727274 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -57,7 +57,7 @@ describe("diffs tool", () => { const cleanupSpy = vi.spyOn(store, "scheduleCleanup"); const screenshotter = createPngScreenshotter({ assertHtml: (html) => { - expect(html).not.toContain("/plugins/diffs/assets/viewer.js"); + expect(html).toContain("/plugins/diffs/assets/viewer.js"); }, assertImage: (image) => { expect(image).toMatchObject({ @@ -332,13 +332,13 @@ describe("diffs tool", () => { const html = await store.readHtml(id); expect(html).toContain('body data-theme="light"'); expect(html).toContain("--diffs-font-size: 17px;"); - expect(html).toContain('--diffs-font-family: "JetBrains Mono"'); + expect(html).toContain("JetBrains Mono"); }); it("prefers explicit tool params over configured defaults", async () => { const screenshotter = createPngScreenshotter({ assertHtml: (html) => { - expect(html).not.toContain("/plugins/diffs/assets/viewer.js"); + expect(html).toContain("/plugins/diffs/assets/viewer.js"); }, assertImage: (image) => { expect(image).toMatchObject({ diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 14df6901024..2976dee3924 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -230,11 +230,22 @@ JOB SCHEMA (for add action): "name": "string (optional)", "schedule": { ... }, // Required: when to run "payload": { ... }, // Required: what to execute - "delivery": { ... }, // Optional: announce summary or webhook POST - "sessionTarget": "main" | "isolated", // Required + "delivery": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST + "sessionTarget": "main" | "isolated" | "current" | "session:", // Optional, defaults based on context "enabled": true | false // Optional, default true } +SESSION TARGET OPTIONS: +- "main": Run in the main session (requires payload.kind="systemEvent") +- "isolated": Run in an ephemeral isolated session (requires payload.kind="agentTurn") +- "current": Bind to the current session where the cron is created (resolved at creation time) +- "session:": Run in a persistent named session (e.g., "session:project-alpha-daily") + +DEFAULT BEHAVIOR (unchanged for backward compatibility): +- payload.kind="systemEvent" → defaults to "main" +- payload.kind="agentTurn" → defaults to "isolated" +To use current session binding, explicitly set sessionTarget="current". + SCHEDULE TYPES (schedule.kind): - "at": One-shot at absolute time { "kind": "at", "at": "" } @@ -260,9 +271,9 @@ DELIVERY (top-level): CRITICAL CONSTRAINTS: - sessionTarget="main" REQUIRES payload.kind="systemEvent" -- sessionTarget="isolated" REQUIRES payload.kind="agentTurn" +- sessionTarget="isolated" | "current" | "session:xxx" REQUIRES payload.kind="agentTurn" - For webhook callbacks, use delivery.mode="webhook" with delivery.to set to a URL. -Default: prefer isolated agentTurn jobs unless the user explicitly wants a main-session system event. +Default: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding. WAKE MODES (for wake action): - "next-heartbeat" (default): Wake on next heartbeat @@ -346,7 +357,10 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con if (!params.job || typeof params.job !== "object") { throw new Error("job required"); } - const job = normalizeCronJobCreate(params.job) ?? params.job; + const job = + normalizeCronJobCreate(params.job, { + sessionContext: { sessionKey: opts?.agentSessionKey }, + }) ?? params.job; if (job && typeof job === "object") { const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index bd7d0ff1af5..e916c459863 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -194,8 +194,13 @@ export function registerCronAddCommand(cron: Command) { const inferredSessionTarget = payload.kind === "agentTurn" ? "isolated" : "main"; const sessionTarget = sessionSource === "cli" ? sessionTargetRaw || "" : inferredSessionTarget; - if (sessionTarget !== "main" && sessionTarget !== "isolated") { - throw new Error("--session must be main or isolated"); + const isCustomSessionTarget = + sessionTarget.toLowerCase().startsWith("session:") && + sessionTarget.slice(8).trim().length > 0; + const isIsolatedLikeSessionTarget = + sessionTarget === "isolated" || sessionTarget === "current" || isCustomSessionTarget; + if (sessionTarget !== "main" && !isIsolatedLikeSessionTarget) { + throw new Error("--session must be main, isolated, current, or session:"); } if (opts.deleteAfterRun && opts.keepAfterRun) { @@ -205,14 +210,14 @@ export function registerCronAddCommand(cron: Command) { if (sessionTarget === "main" && payload.kind !== "systemEvent") { throw new Error("Main jobs require --system-event (systemEvent)."); } - if (sessionTarget === "isolated" && payload.kind !== "agentTurn") { - throw new Error("Isolated jobs require --message (agentTurn)."); + if (isIsolatedLikeSessionTarget && payload.kind !== "agentTurn") { + throw new Error("Isolated/current/custom-session jobs require --message (agentTurn)."); } if ( (opts.announce || typeof opts.deliver === "boolean") && - (sessionTarget !== "isolated" || payload.kind !== "agentTurn") + (!isIsolatedLikeSessionTarget || payload.kind !== "agentTurn") ) { - throw new Error("--announce/--no-deliver require --session isolated."); + throw new Error("--announce/--no-deliver require a non-main agentTurn session target."); } const accountId = @@ -220,12 +225,12 @@ export function registerCronAddCommand(cron: Command) { ? opts.account.trim() : undefined; - if (accountId && (sessionTarget !== "isolated" || payload.kind !== "agentTurn")) { - throw new Error("--account requires an isolated agentTurn job with delivery."); + if (accountId && (!isIsolatedLikeSessionTarget || payload.kind !== "agentTurn")) { + throw new Error("--account requires a non-main agentTurn job with delivery."); } const deliveryMode = - sessionTarget === "isolated" && payload.kind === "agentTurn" + isIsolatedLikeSessionTarget && payload.kind === "agentTurn" ? hasAnnounce ? "announce" : hasNoDeliver diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index d3601b6ce40..3574a63ab27 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -247,9 +247,9 @@ export function printCronList(jobs: CronJob[], runtime = defaultRuntime) { })(); const coloredTarget = - job.sessionTarget === "isolated" - ? colorize(rich, theme.accentBright, targetLabel) - : colorize(rich, theme.accent, targetLabel); + job.sessionTarget === "main" + ? colorize(rich, theme.accent, targetLabel) + : colorize(rich, theme.accentBright, targetLabel); const coloredAgent = job.agentId ? colorize(rich, theme.info, agentLabel) : colorize(rich, theme.muted, agentLabel); diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts index 6f34c85ebed..969faa6bb6f 100644 --- a/src/cron/normalize.test.ts +++ b/src/cron/normalize.test.ts @@ -414,6 +414,42 @@ describe("normalizeCronJobCreate", () => { expect(delivery.mode).toBeUndefined(); expect(delivery.to).toBe("123"); }); + + it("resolves current sessionTarget to a persistent session when context is available", () => { + const normalized = normalizeCronJobCreate( + { + name: "current-session", + schedule: { kind: "cron", expr: "* * * * *" }, + sessionTarget: "current", + payload: { kind: "agentTurn", message: "hello" }, + }, + { sessionContext: { sessionKey: "agent:main:discord:group:ops" } }, + ) as unknown as Record; + + expect(normalized.sessionTarget).toBe("session:agent:main:discord:group:ops"); + }); + + it("falls back current sessionTarget to isolated without context", () => { + const normalized = normalizeCronJobCreate({ + name: "current-without-context", + schedule: { kind: "cron", expr: "* * * * *" }, + sessionTarget: "current", + payload: { kind: "agentTurn", message: "hello" }, + }) as unknown as Record; + + expect(normalized.sessionTarget).toBe("isolated"); + }); + + it("preserves custom session ids with a session: prefix", () => { + const normalized = normalizeCronJobCreate({ + name: "custom-session", + schedule: { kind: "cron", expr: "* * * * *" }, + sessionTarget: "session:MySessionID", + payload: { kind: "agentTurn", message: "hello" }, + }) as unknown as Record; + + expect(normalized.sessionTarget).toBe("session:MySessionID"); + }); }); describe("normalizeCronJobPatch", () => { diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index 5a6c66ff356..b1afdfaaa12 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -11,6 +11,8 @@ type UnknownRecord = Record; type NormalizeOptions = { applyDefaults?: boolean; + /** Session context for resolving "current" sessionTarget or auto-binding when not specified */ + sessionContext?: { sessionKey?: string }; }; const DEFAULT_OPTIONS: NormalizeOptions = { @@ -218,9 +220,17 @@ function normalizeSessionTarget(raw: unknown) { if (typeof raw !== "string") { return undefined; } - const trimmed = raw.trim().toLowerCase(); - if (trimmed === "main" || trimmed === "isolated") { - return trimmed; + const trimmed = raw.trim(); + const lower = trimmed.toLowerCase(); + if (lower === "main" || lower === "isolated" || lower === "current") { + return lower; + } + // Support custom session IDs with "session:" prefix + if (lower.startsWith("session:")) { + const sessionId = trimmed.slice(8).trim(); + if (sessionId) { + return `session:${sessionId}`; + } } return undefined; } @@ -431,10 +441,37 @@ export function normalizeCronJobInput( } if (!next.sessionTarget && isRecord(next.payload)) { const kind = typeof next.payload.kind === "string" ? next.payload.kind : ""; + // Keep default behavior unchanged for backward compatibility: + // - systemEvent defaults to "main" + // - agentTurn defaults to "isolated" (NOT "current", to avoid token accumulation) + // Users must explicitly specify "current" or "session:xxx" for custom session binding if (kind === "systemEvent") { next.sessionTarget = "main"; + } else if (kind === "agentTurn") { + next.sessionTarget = "isolated"; } - if (kind === "agentTurn") { + } + + // Resolve "current" sessionTarget to the actual sessionKey from context + if (next.sessionTarget === "current") { + if (options.sessionContext?.sessionKey) { + const sessionKey = options.sessionContext.sessionKey.trim(); + if (sessionKey) { + // Store as session:customId format for persistence + next.sessionTarget = `session:${sessionKey}`; + } + } + // If "current" wasn't resolved, fall back to "isolated" behavior + // This handles CLI/headless usage where no session context exists + if (next.sessionTarget === "current") { + next.sessionTarget = "isolated"; + } + } + if (next.sessionTarget === "current") { + const sessionKey = options.sessionContext?.sessionKey?.trim(); + if (sessionKey) { + next.sessionTarget = `session:${sessionKey}`; + } else { next.sessionTarget = "isolated"; } } @@ -462,8 +499,12 @@ export function normalizeCronJobInput( const payload = isRecord(next.payload) ? next.payload : null; const payloadKind = payload && typeof payload.kind === "string" ? payload.kind : ""; const sessionTarget = typeof next.sessionTarget === "string" ? next.sessionTarget : ""; + // Support "isolated", custom session IDs (session:xxx), and resolved "current" as isolated-like targets const isIsolatedAgentTurn = - sessionTarget === "isolated" || (sessionTarget === "" && payloadKind === "agentTurn"); + sessionTarget === "isolated" || + sessionTarget === "current" || + sessionTarget.startsWith("session:") || + (sessionTarget === "" && payloadKind === "agentTurn"); const hasDelivery = "delivery" in next && next.delivery !== undefined; const normalizedLegacy = normalizeLegacyDeliveryInput({ delivery: isRecord(next.delivery) ? next.delivery : null, @@ -487,7 +528,7 @@ export function normalizeCronJobInput( export function normalizeCronJobCreate( raw: unknown, - options?: NormalizeOptions, + options?: Omit, ): CronJobCreate | null { return normalizeCronJobInput(raw, { applyDefaults: true, diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index 053ea8764de..c514f7528ba 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -103,6 +103,29 @@ describe("applyJobPatch", () => { }); }); + it("maps legacy payload delivery updates for custom session targets", () => { + const job = createIsolatedAgentTurnJob( + "job-custom-session", + { + mode: "announce", + channel: "telegram", + to: "123", + }, + { sessionTarget: "session:project-alpha" }, + ); + + applyJobPatch(job, { + payload: { kind: "agentTurn", to: "555" }, + }); + + expect(job.delivery).toEqual({ + mode: "announce", + channel: "telegram", + to: "555", + bestEffort: undefined, + }); + }); + it("treats legacy payload targets as announce requests", () => { const job = createIsolatedAgentTurnJob("job-3", { mode: "none", diff --git a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts index 555750bd738..75ffb262d4d 100644 --- a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts +++ b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts @@ -759,7 +759,7 @@ describe("CronService", () => { wakeMode: "next-heartbeat", payload: { kind: "systemEvent", text: "nope" }, }), - ).rejects.toThrow(/isolated cron jobs require/); + ).rejects.toThrow(/isolated.*cron jobs require/); cron.stop(); await store.cleanup(); diff --git a/src/cron/service.store-migration.test.ts b/src/cron/service.store-migration.test.ts index 52c9f571b08..216154fa503 100644 --- a/src/cron/service.store-migration.test.ts +++ b/src/cron/service.store-migration.test.ts @@ -72,6 +72,39 @@ function createLegacyIsolatedAgentTurnJob( } describe("CronService store migrations", () => { + it("treats stored current session targets as isolated-like for default delivery migration", async () => { + const { store, cron } = await startCronWithStoredJobs([ + createLegacyIsolatedAgentTurnJob({ + id: "stored-current-job", + name: "stored current", + sessionTarget: "current", + }), + ]); + + const job = await listJobById(cron, "stored-current-job"); + expect(job).toBeDefined(); + expect(job?.sessionTarget).toBe("isolated"); + expect(job?.delivery).toEqual({ mode: "announce" }); + + await stopCronAndCleanup(cron, store); + }); + + it("preserves stored custom session targets", async () => { + const { store, cron } = await startCronWithStoredJobs([ + createLegacyIsolatedAgentTurnJob({ + id: "custom-session-job", + name: "custom session", + sessionTarget: "session:ProjectAlpha", + }), + ]); + + const job = await listJobById(cron, "custom-session-job"); + expect(job?.sessionTarget).toBe("session:ProjectAlpha"); + expect(job?.delivery).toEqual({ mode: "announce" }); + + await stopCronAndCleanup(cron, store); + }); + it("migrates legacy top-level agentTurn fields and initializes missing state", async () => { const { store, cron } = await startCronWithStoredJobs([ createLegacyIsolatedAgentTurnJob({ diff --git a/src/cron/service.store.migration.test.ts b/src/cron/service.store.migration.test.ts index 8daa0b39e9a..973efca67a6 100644 --- a/src/cron/service.store.migration.test.ts +++ b/src/cron/service.store.migration.test.ts @@ -133,6 +133,24 @@ describe("cron store migration", () => { expect(schedule.at).toBe(new Date(atMs).toISOString()); }); + it("preserves stored custom session targets", async () => { + const migrated = await migrateLegacyJob( + makeLegacyJob({ + id: "job-custom-session", + name: "Custom session", + schedule: { kind: "cron", expr: "0 23 * * *", tz: "UTC" }, + sessionTarget: "session:ProjectAlpha", + payload: { + kind: "agentTurn", + message: "hello", + }, + }), + ); + + expect(migrated.sessionTarget).toBe("session:ProjectAlpha"); + expect(migrated.delivery).toEqual({ mode: "announce" }); + }); + it("adds anchorMs to legacy every schedules", async () => { const createdAtMs = 1_700_000_000_000; const migrated = await migrateLegacyJob( diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 5579e5430f0..542ba81053d 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -132,11 +132,15 @@ function resolveEveryAnchorMs(params: { } export function assertSupportedJobSpec(job: Pick) { + const isIsolatedLike = + job.sessionTarget === "isolated" || + job.sessionTarget === "current" || + job.sessionTarget.startsWith("session:"); if (job.sessionTarget === "main" && job.payload.kind !== "systemEvent") { throw new Error('main cron jobs require payload.kind="systemEvent"'); } - if (job.sessionTarget === "isolated" && job.payload.kind !== "agentTurn") { - throw new Error('isolated cron jobs require payload.kind="agentTurn"'); + if (isIsolatedLike && job.payload.kind !== "agentTurn") { + throw new Error('isolated/current/session cron jobs require payload.kind="agentTurn"'); } } @@ -181,6 +185,7 @@ function assertDeliverySupport(job: Pick) if (!job.delivery || job.delivery.mode === "none") { return; } + // Webhook delivery is allowed for any session target if (job.delivery.mode === "webhook") { const target = normalizeHttpWebhookUrl(job.delivery.to); if (!target) { @@ -189,7 +194,11 @@ function assertDeliverySupport(job: Pick) job.delivery.to = target; return; } - if (job.sessionTarget !== "isolated") { + const isIsolatedLike = + job.sessionTarget === "isolated" || + job.sessionTarget === "current" || + job.sessionTarget.startsWith("session:"); + if (!isIsolatedLike) { throw new Error('cron channel delivery config is only supported for sessionTarget="isolated"'); } if (job.delivery.channel === "telegram") { @@ -606,11 +615,11 @@ export function applyJobPatch( if (!patch.delivery && patch.payload?.kind === "agentTurn") { // Back-compat: legacy clients still update delivery via payload fields. const legacyDeliveryPatch = buildLegacyDeliveryPatch(patch.payload); - if ( - legacyDeliveryPatch && - job.sessionTarget === "isolated" && - job.payload.kind === "agentTurn" - ) { + const isIsolatedLike = + job.sessionTarget === "isolated" || + job.sessionTarget === "current" || + job.sessionTarget.startsWith("session:"); + if (legacyDeliveryPatch && isIsolatedLike && job.payload.kind === "agentTurn") { job.delivery = mergeCronDelivery(job.delivery, legacyDeliveryPatch); } } diff --git a/src/cron/store-migration.ts b/src/cron/store-migration.ts index 1e9dcb1b136..0a460174bd2 100644 --- a/src/cron/store-migration.ts +++ b/src/cron/store-migration.ts @@ -451,11 +451,25 @@ export function normalizeStoredCronJobs( const payloadKind = payloadRecord && typeof payloadRecord.kind === "string" ? payloadRecord.kind : ""; - const normalizedSessionTarget = - typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : ""; - if (normalizedSessionTarget === "main" || normalizedSessionTarget === "isolated") { - if (raw.sessionTarget !== normalizedSessionTarget) { - raw.sessionTarget = normalizedSessionTarget; + const rawSessionTarget = typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim() : ""; + const loweredSessionTarget = rawSessionTarget.toLowerCase(); + if (loweredSessionTarget === "main" || loweredSessionTarget === "isolated") { + if (raw.sessionTarget !== loweredSessionTarget) { + raw.sessionTarget = loweredSessionTarget; + mutated = true; + } + } else if (loweredSessionTarget.startsWith("session:")) { + const customSessionId = rawSessionTarget.slice(8).trim(); + if (customSessionId) { + const normalizedSessionTarget = `session:${customSessionId}`; + if (raw.sessionTarget !== normalizedSessionTarget) { + raw.sessionTarget = normalizedSessionTarget; + mutated = true; + } + } + } else if (loweredSessionTarget === "current") { + if (raw.sessionTarget !== "isolated") { + raw.sessionTarget = "isolated"; mutated = true; } } else { @@ -469,7 +483,10 @@ export function normalizeStoredCronJobs( const sessionTarget = typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : ""; const isIsolatedAgentTurn = - sessionTarget === "isolated" || (sessionTarget === "" && payloadKind === "agentTurn"); + sessionTarget === "isolated" || + sessionTarget === "current" || + sessionTarget.startsWith("session:") || + (sessionTarget === "" && payloadKind === "agentTurn"); const hasDelivery = delivery && typeof delivery === "object" && !Array.isArray(delivery); const normalizedLegacy = normalizeLegacyDeliveryInput({ delivery: hasDelivery ? (delivery as Record) : null, diff --git a/src/cron/types.ts b/src/cron/types.ts index 2a93bc30311..02078d15424 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -13,7 +13,7 @@ export type CronSchedule = staggerMs?: number; }; -export type CronSessionTarget = "main" | "isolated"; +export type CronSessionTarget = "main" | "isolated" | "current" | `session:${string}`; export type CronWakeMode = "next-heartbeat" | "now"; export type CronMessageChannel = ChannelId | "last"; diff --git a/src/gateway/protocol/cron-validators.test.ts b/src/gateway/protocol/cron-validators.test.ts index 33df9d478e9..1de9db206b9 100644 --- a/src/gateway/protocol/cron-validators.test.ts +++ b/src/gateway/protocol/cron-validators.test.ts @@ -21,6 +21,29 @@ describe("cron protocol validators", () => { expect(validateCronAddParams(minimalAddParams)).toBe(true); }); + it("accepts current and custom session targets", () => { + expect( + validateCronAddParams({ + ...minimalAddParams, + sessionTarget: "current", + payload: { kind: "agentTurn", message: "tick" }, + }), + ).toBe(true); + expect( + validateCronAddParams({ + ...minimalAddParams, + sessionTarget: "session:project-alpha", + payload: { kind: "agentTurn", message: "tick" }, + }), + ).toBe(true); + expect( + validateCronUpdateParams({ + id: "job-1", + patch: { sessionTarget: "session:project-alpha" }, + }), + ).toBe(true); + }); + it("rejects add params when required scheduling fields are missing", () => { const { wakeMode: _wakeMode, ...withoutWakeMode } = minimalAddParams; expect(validateCronAddParams(withoutWakeMode)).toBe(false); diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index 3cba5a65781..f61d3e42711 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -21,7 +21,12 @@ function cronAgentTurnPayloadSchema(params: { message: TSchema }) { ); } -const CronSessionTargetSchema = Type.Union([Type.Literal("main"), Type.Literal("isolated")]); +const CronSessionTargetSchema = Type.Union([ + Type.Literal("main"), + Type.Literal("isolated"), + Type.Literal("current"), + Type.String({ pattern: "^session:.+" }), +]); const CronWakeModeSchema = Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]); const CronRunStatusSchema = Type.Union([ Type.Literal("ok"), diff --git a/src/gateway/server-cron.test.ts b/src/gateway/server-cron.test.ts index 2608560e20f..d7a6b375d10 100644 --- a/src/gateway/server-cron.test.ts +++ b/src/gateway/server-cron.test.ts @@ -5,10 +5,19 @@ import type { CliDeps } from "../cli/deps.js"; import type { OpenClawConfig } from "../config/config.js"; import { SsrFBlockedError } from "../infra/net/ssrf.js"; -const enqueueSystemEventMock = vi.fn(); -const requestHeartbeatNowMock = vi.fn(); -const loadConfigMock = vi.fn(); -const fetchWithSsrFGuardMock = vi.fn(); +const { + enqueueSystemEventMock, + requestHeartbeatNowMock, + loadConfigMock, + fetchWithSsrFGuardMock, + runCronIsolatedAgentTurnMock, +} = vi.hoisted(() => ({ + enqueueSystemEventMock: vi.fn(), + requestHeartbeatNowMock: vi.fn(), + loadConfigMock: vi.fn(), + fetchWithSsrFGuardMock: vi.fn(), + runCronIsolatedAgentTurnMock: vi.fn(async () => ({ status: "ok" as const, summary: "ok" })), +})); function enqueueSystemEvent(...args: unknown[]) { return enqueueSystemEventMock(...args); @@ -35,7 +44,11 @@ vi.mock("../config/config.js", async () => { }); vi.mock("../infra/net/fetch-guard.js", () => ({ - fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args), + fetchWithSsrFGuard: fetchWithSsrFGuardMock, +})); + +vi.mock("../cron/isolated-agent.js", () => ({ + runCronIsolatedAgentTurn: runCronIsolatedAgentTurnMock, })); import { buildGatewayCronService } from "./server-cron.js"; @@ -58,6 +71,7 @@ describe("buildGatewayCronService", () => { requestHeartbeatNowMock.mockClear(); loadConfigMock.mockClear(); fetchWithSsrFGuardMock.mockClear(); + runCronIsolatedAgentTurnMock.mockClear(); }); it("routes main-target jobs to the scoped session for enqueue + wake", async () => { @@ -142,4 +156,44 @@ describe("buildGatewayCronService", () => { state.cron.stop(); } }); + + it("passes custom session targets through to isolated cron runs", async () => { + const tmpDir = path.join(os.tmpdir(), `server-cron-custom-session-${Date.now()}`); + const cfg = { + session: { + mainKey: "main", + }, + cron: { + store: path.join(tmpDir, "cron.json"), + }, + } as OpenClawConfig; + loadConfigMock.mockReturnValue(cfg); + + const state = buildGatewayCronService({ + cfg, + deps: {} as CliDeps, + broadcast: () => {}, + }); + try { + const job = await state.cron.add({ + name: "custom-session", + enabled: true, + schedule: { kind: "at", at: new Date(1).toISOString() }, + sessionTarget: "session:project-alpha-monitor", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "hello" }, + }); + + await state.cron.run(job.id, "force"); + + expect(runCronIsolatedAgentTurnMock).toHaveBeenCalledWith( + expect.objectContaining({ + job: expect.objectContaining({ id: job.id }), + sessionKey: "project-alpha-monitor", + }), + ); + } finally { + state.cron.stop(); + } + }); }); diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 1f1cd1f5359..8a288866721 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -284,6 +284,13 @@ export function buildGatewayCronService(params: { }, runIsolatedAgentJob: async ({ job, message, abortSignal }) => { const { agentId, cfg: runtimeConfig } = resolveCronAgent(job.agentId); + let sessionKey = `cron:${job.id}`; + if (job.sessionTarget.startsWith("session:")) { + const customSessionId = job.sessionTarget.slice(8).trim(); + if (customSessionId) { + sessionKey = customSessionId; + } + } return await runCronIsolatedAgentTurn({ cfg: runtimeConfig, deps: params.deps, @@ -291,7 +298,7 @@ export function buildGatewayCronService(params: { message, abortSignal, agentId, - sessionKey: `cron:${job.id}`, + sessionKey, lane: "cron", }); }, diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 830d12c9509..7eccb895534 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -89,7 +89,14 @@ export const cronHandlers: GatewayRequestHandlers = { respond(true, status, undefined); }, "cron.add": async ({ params, respond, context }) => { - const normalized = normalizeCronJobCreate(params) ?? params; + const sessionKey = + typeof (params as { sessionKey?: unknown } | null)?.sessionKey === "string" + ? (params as { sessionKey: string }).sessionKey + : undefined; + const normalized = + normalizeCronJobCreate(params, { + sessionContext: { sessionKey }, + }) ?? params; if (!validateCronAddParams(normalized)) { respond( false, diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index c81d69c57ea..c6073a8e626 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -84,7 +84,7 @@ export type CronModelSuggestionsState = { export function supportsAnnounceDelivery( form: Pick, ) { - return form.sessionTarget === "isolated" && form.payloadKind === "agentTurn"; + return form.sessionTarget !== "main" && form.payloadKind === "agentTurn"; } export function normalizeCronFormState(form: CronFormState): CronFormState { diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 17ff4293afa..d9764a024e6 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -427,7 +427,7 @@ export type CronSchedule = | { kind: "every"; everyMs: number; anchorMs?: number } | { kind: "cron"; expr: string; tz?: string; staggerMs?: number }; -export type CronSessionTarget = "main" | "isolated"; +export type CronSessionTarget = "main" | "isolated" | "current" | `session:${string}`; export type CronWakeMode = "next-heartbeat" | "now"; export type CronPayload = diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index c01e2cf0f7d..2cd1709d841 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -33,7 +33,7 @@ export type CronFormState = { scheduleExact: boolean; staggerAmount: string; staggerUnit: "seconds" | "minutes"; - sessionTarget: "main" | "isolated"; + sessionTarget: "main" | "isolated" | "current" | `session:${string}`; wakeMode: "next-heartbeat" | "now"; payloadKind: "systemEvent" | "agentTurn"; payloadText: string; diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 836b72dbbcc..1509637b46f 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -374,7 +374,7 @@ export function renderCron(props: CronProps) { const statusSummary = summarizeSelection(selectedStatusLabels, t("cron.runs.allStatuses")); const deliverySummary = summarizeSelection(selectedDeliveryLabels, t("cron.runs.allDelivery")); const supportsAnnounce = - props.form.sessionTarget === "isolated" && props.form.payloadKind === "agentTurn"; + props.form.sessionTarget !== "main" && props.form.payloadKind === "agentTurn"; const selectedDeliveryMode = props.form.deliveryMode === "announce" && !supportsAnnounce ? "none" : props.form.deliveryMode; const blockingFields = collectBlockingFields(props.fieldErrors, props.form, selectedDeliveryMode); From 6e251dcf6881604f828de5c5357abab6d585c540 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 05:54:41 +0000 Subject: [PATCH 1162/1173] test: harden parallels beta smoke flows --- AGENTS.md | 2 + scripts/e2e/parallels-linux-smoke.sh | 70 +++++++++++++++++++++-- scripts/e2e/parallels-macos-smoke.sh | 76 ++++++++++++++++++++++--- scripts/e2e/parallels-windows-smoke.sh | 79 +++++++++++++++++++++++--- 4 files changed, 207 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 28d1b9cc2a6..0b1e17c8b3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,6 +203,8 @@ - Vocabulary: "makeup" = "mac app". - Parallels macOS retests: use the snapshot most closely named like `macOS 26.3.1 fresh` when the user asks for a clean/fresh macOS rerun; avoid older Tahoe snapshots unless explicitly requested. +- Parallels beta smoke: use `--target-package-spec openclaw@` for the beta artifact, and pin the stable side with both `--install-version ` and `--latest-version ` for upgrade runs. npm dist-tags can move mid-run. +- Parallels beta smoke, Windows nuance: old stable `2026.3.12` still prints the Unicode Windows onboarding banner, so mojibake during the stable precheck log is expected there. Judge the beta package by the post-upgrade lane. - Parallels macOS smoke playbook: - `prlctl exec` is fine for deterministic repo commands, but it can misrepresent interactive shell behavior (`PATH`, `HOME`, `curl | bash`, shebang resolution). For installer parity or shell-sensitive repros, prefer the guest Terminal or `prlctl enter`. - Fresh Tahoe snapshot current reality: `brew` exists, `node` may not be on `PATH` in noninteractive guest exec. Use absolute `/opt/homebrew/bin/node` for repo/CLI runs when needed. diff --git a/scripts/e2e/parallels-linux-smoke.sh b/scripts/e2e/parallels-linux-smoke.sh index dfed00bf89d..a3e3f96bb56 100644 --- a/scripts/e2e/parallels-linux-smoke.sh +++ b/scripts/e2e/parallels-linux-smoke.sh @@ -10,6 +10,8 @@ HOST_PORT="18427" HOST_PORT_EXPLICIT=0 HOST_IP="" LATEST_VERSION="" +INSTALL_VERSION="" +TARGET_PACKAGE_SPEC="" JSON_OUTPUT=0 KEEP_SERVER=0 @@ -41,6 +43,14 @@ say() { printf '==> %s\n' "$*" } +artifact_label() { + if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then + printf 'target package tgz' + return + fi + printf 'current main tgz' +} + warn() { printf 'warn: %s\n' "$*" >&2 } @@ -72,6 +82,10 @@ Options: --host-port Host HTTP port for current-main tgz. Default: 18427 --host-ip Override Parallels host IP. --latest-version Override npm latest version lookup. + --install-version Pin site-installer version/dist-tag for the baseline lane. + --target-package-spec + Install this npm package tarball instead of packing current main. + Example: openclaw@2026.3.13-beta.1 --keep-server Leave temp host HTTP server running. --json Print machine-readable JSON summary. -h, --help Show help. @@ -113,6 +127,14 @@ while [[ $# -gt 0 ]]; do LATEST_VERSION="$2" shift 2 ;; + --install-version) + INSTALL_VERSION="$2" + shift 2 + ;; + --target-package-spec) + TARGET_PACKAGE_SPEC="$2" + shift 2 + ;; --keep-server) KEEP_SERVER=1 shift @@ -299,10 +321,26 @@ ensure_current_build() { [[ "$build_commit" == "$head" ]] || die "dist/build-info.json still does not match HEAD after build" } +extract_package_version_from_tgz() { + tar -xOf "$1" package/package.json | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])' +} + pack_main_tgz() { + local short_head pkg + if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then + say "Pack target package tgz: $TARGET_PACKAGE_SPEC" + pkg="$( + npm pack "$TARGET_PACKAGE_SPEC" --ignore-scripts --json --pack-destination "$MAIN_TGZ_DIR" \ + | python3 -c 'import json, sys; data = json.load(sys.stdin); print(data[-1]["filename"])' + )" + MAIN_TGZ_PATH="$MAIN_TGZ_DIR/$(basename "$pkg")" + TARGET_EXPECT_VERSION="$(extract_package_version_from_tgz "$MAIN_TGZ_PATH")" + say "Packed $MAIN_TGZ_PATH" + say "Target package version: $TARGET_EXPECT_VERSION" + return + fi say "Pack current main tgz" ensure_current_build - local short_head pkg short_head="$(git rev-parse --short HEAD)" pkg="$( npm pack --ignore-scripts --json --pack-destination "$MAIN_TGZ_DIR" \ @@ -314,6 +352,14 @@ pack_main_tgz() { tar -xOf "$MAIN_TGZ_PATH" package/dist/build-info.json } +verify_target_version() { + if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then + verify_version_contains "$TARGET_EXPECT_VERSION" + return + fi + verify_version_contains "$(git rev-parse --short=7 HEAD)" +} + start_server() { local host_ip="$1" local artifact probe_url attempt @@ -321,7 +367,7 @@ start_server() { attempt=0 while :; do attempt=$((attempt + 1)) - say "Serve current main tgz on $host_ip:$HOST_PORT" + say "Serve $(artifact_label) on $host_ip:$HOST_PORT" ( cd "$MAIN_TGZ_DIR" exec python3 -m http.server "$HOST_PORT" --bind 0.0.0.0 @@ -344,8 +390,12 @@ start_server() { } install_latest_release() { + local version_args=() + if [[ -n "$INSTALL_VERSION" ]]; then + version_args=(--version "$INSTALL_VERSION") + fi guest_exec curl -fsSL "$INSTALL_URL" -o /tmp/openclaw-install.sh - guest_exec /usr/bin/env OPENCLAW_NO_ONBOARD=1 bash /tmp/openclaw-install.sh --no-onboard + guest_exec /usr/bin/env OPENCLAW_NO_ONBOARD=1 bash /tmp/openclaw-install.sh "${version_args[@]}" --no-onboard guest_exec openclaw --version } @@ -478,6 +528,8 @@ summary = { "snapshotId": os.environ["SUMMARY_SNAPSHOT_ID"], "mode": os.environ["SUMMARY_MODE"], "latestVersion": os.environ["SUMMARY_LATEST_VERSION"], + "installVersion": os.environ["SUMMARY_INSTALL_VERSION"], + "targetPackageSpec": os.environ["SUMMARY_TARGET_PACKAGE_SPEC"], "currentHead": os.environ["SUMMARY_CURRENT_HEAD"], "runDir": os.environ["SUMMARY_RUN_DIR"], "daemon": os.environ["SUMMARY_DAEMON_STATUS"], @@ -509,7 +561,7 @@ run_fresh_main_lane() { phase_run "fresh.install-latest-bootstrap" "$TIMEOUT_INSTALL_S" install_latest_release phase_run "fresh.install-main" "$TIMEOUT_INSTALL_S" install_main_tgz "$host_ip" "openclaw-main-fresh.tgz" FRESH_MAIN_VERSION="$(extract_last_version "$(phase_log_path fresh.install-main)")" - phase_run "fresh.verify-main-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$(git rev-parse --short=7 HEAD)" + phase_run "fresh.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version phase_run "fresh.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard FRESH_GATEWAY_STATUS="skipped-no-detached-linux-gateway" phase_run "fresh.first-local-agent-turn" "$TIMEOUT_AGENT_S" verify_local_turn @@ -526,7 +578,7 @@ run_upgrade_lane() { phase_run "upgrade.verify-latest-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$LATEST_VERSION" phase_run "upgrade.install-main" "$TIMEOUT_INSTALL_S" install_main_tgz "$host_ip" "openclaw-main-upgrade.tgz" UPGRADE_MAIN_VERSION="$(extract_last_version "$(phase_log_path upgrade.install-main)")" - phase_run "upgrade.verify-main-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$(git rev-parse --short=7 HEAD)" + phase_run "upgrade.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version phase_run "upgrade.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard UPGRADE_GATEWAY_STATUS="skipped-no-detached-linux-gateway" phase_run "upgrade.first-local-agent-turn" "$TIMEOUT_AGENT_S" verify_local_turn @@ -582,6 +634,8 @@ SUMMARY_JSON_PATH="$( SUMMARY_SNAPSHOT_ID="$SNAPSHOT_ID" \ SUMMARY_MODE="$MODE" \ SUMMARY_LATEST_VERSION="$LATEST_VERSION" \ + SUMMARY_INSTALL_VERSION="$INSTALL_VERSION" \ + SUMMARY_TARGET_PACKAGE_SPEC="$TARGET_PACKAGE_SPEC" \ SUMMARY_CURRENT_HEAD="$(git rev-parse --short HEAD)" \ SUMMARY_RUN_DIR="$RUN_DIR" \ SUMMARY_DAEMON_STATUS="$DAEMON_STATUS" \ @@ -601,6 +655,12 @@ if [[ "$JSON_OUTPUT" -eq 1 ]]; then cat "$SUMMARY_JSON_PATH" else printf '\nSummary:\n' + if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then + printf ' target-package: %s\n' "$TARGET_PACKAGE_SPEC" + fi + if [[ -n "$INSTALL_VERSION" ]]; then + printf ' baseline-install-version: %s\n' "$INSTALL_VERSION" + fi printf ' daemon: %s\n' "$DAEMON_STATUS" printf ' fresh-main: %s (%s)\n' "$FRESH_MAIN_STATUS" "$FRESH_MAIN_VERSION" printf ' latest->main: %s (%s)\n' "$UPGRADE_STATUS" "$UPGRADE_MAIN_VERSION" diff --git a/scripts/e2e/parallels-macos-smoke.sh b/scripts/e2e/parallels-macos-smoke.sh index 4de2fb19ae3..0b790346358 100644 --- a/scripts/e2e/parallels-macos-smoke.sh +++ b/scripts/e2e/parallels-macos-smoke.sh @@ -12,6 +12,8 @@ HOST_PORT="18425" HOST_PORT_EXPLICIT=0 HOST_IP="" LATEST_VERSION="" +INSTALL_VERSION="" +TARGET_PACKAGE_SPEC="" KEEP_SERVER=0 CHECK_LATEST_REF=1 JSON_OUTPUT=0 @@ -46,6 +48,14 @@ say() { printf '==> %s\n' "$*" } +artifact_label() { + if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then + printf 'target package tgz' + return + fi + printf 'current main tgz' +} + warn() { printf 'warn: %s\n' "$*" >&2 } @@ -81,8 +91,8 @@ Options: --snapshot-hint Snapshot name substring/fuzzy match. Default: "macOS 26.3.1 fresh" --mode - fresh = fresh snapshot -> current main tgz -> onboard smoke - upgrade = fresh snapshot -> latest release -> current main tgz -> onboard smoke + fresh = fresh snapshot -> target package/current main tgz -> onboard smoke + upgrade = fresh snapshot -> latest release -> target package/current main tgz -> onboard smoke both = run both lanes --openai-api-key-env Host env var name for OpenAI API key. Default: OPENAI_API_KEY @@ -90,6 +100,10 @@ Options: --host-port Host HTTP port for current-main tgz. Default: 18425 --host-ip Override Parallels host IP. --latest-version Override npm latest version lookup. + --install-version Pin site-installer version/dist-tag for the baseline lane. + --target-package-spec + Install this npm package tarball instead of packing current main. + Example: openclaw@2026.3.13-beta.1 --skip-latest-ref-check Skip the known latest-release ref-mode precheck in upgrade lane. --keep-server Leave temp host HTTP server running. --json Print machine-readable JSON summary. @@ -132,6 +146,14 @@ while [[ $# -gt 0 ]]; do LATEST_VERSION="$2" shift 2 ;; + --install-version) + INSTALL_VERSION="$2" + shift 2 + ;; + --target-package-spec) + TARGET_PACKAGE_SPEC="$2" + shift 2 + ;; --skip-latest-ref-check) CHECK_LATEST_REF=0 shift @@ -343,12 +365,16 @@ resolve_latest_version() { } install_latest_release() { - local install_url_q + local install_url_q version_arg_q install_url_q="$(shell_quote "$INSTALL_URL")" + version_arg_q="" + if [[ -n "$INSTALL_VERSION" ]]; then + version_arg_q=" --version $(shell_quote "$INSTALL_VERSION")" + fi guest_current_user_sh "$(cat <main precheck: %s (%s)\n' "$UPGRADE_PRECHECK_STATUS" "$LATEST_INSTALLED_VERSION" printf ' latest->main: %s (%s)\n' "$UPGRADE_STATUS" "$UPGRADE_MAIN_VERSION" diff --git a/scripts/e2e/parallels-windows-smoke.sh b/scripts/e2e/parallels-windows-smoke.sh index 3b9ec366790..cd144511f49 100644 --- a/scripts/e2e/parallels-windows-smoke.sh +++ b/scripts/e2e/parallels-windows-smoke.sh @@ -10,6 +10,8 @@ HOST_PORT="18426" HOST_PORT_EXPLICIT=0 HOST_IP="" LATEST_VERSION="" +INSTALL_VERSION="" +TARGET_PACKAGE_SPEC="" JSON_OUTPUT=0 KEEP_SERVER=0 CHECK_LATEST_REF=1 @@ -44,6 +46,14 @@ say() { printf '==> %s\n' "$*" } +artifact_label() { + if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then + printf 'target package tgz' + return + fi + printf 'current main tgz' +} + warn() { printf 'warn: %s\n' "$*" >&2 } @@ -77,6 +87,10 @@ Options: --host-port Host HTTP port for current-main tgz. Default: 18426 --host-ip Override Parallels host IP. --latest-version Override npm latest version lookup. + --install-version Pin site-installer version/dist-tag for the baseline lane. + --target-package-spec + Install this npm package tarball instead of packing current main. + Example: openclaw@2026.3.13-beta.1 --skip-latest-ref-check Skip latest-release ref-mode precheck. --keep-server Leave temp host HTTP server running. --json Print machine-readable JSON summary. @@ -119,6 +133,14 @@ while [[ $# -gt 0 ]]; do LATEST_VERSION="$2" shift 2 ;; + --install-version) + INSTALL_VERSION="$2" + shift 2 + ;; + --target-package-spec) + TARGET_PACKAGE_SPEC="$2" + shift 2 + ;; --skip-latest-ref-check) CHECK_LATEST_REF=0 shift @@ -421,6 +443,8 @@ summary = { "snapshotId": os.environ["SUMMARY_SNAPSHOT_ID"], "mode": os.environ["SUMMARY_MODE"], "latestVersion": os.environ["SUMMARY_LATEST_VERSION"], + "installVersion": os.environ["SUMMARY_INSTALL_VERSION"], + "targetPackageSpec": os.environ["SUMMARY_TARGET_PACKAGE_SPEC"], "currentHead": os.environ["SUMMARY_CURRENT_HEAD"], "runDir": os.environ["SUMMARY_RUN_DIR"], "freshMain": { @@ -556,6 +580,7 @@ ensure_guest_git() { return fi guest_exec cmd.exe /d /s /c "if exist \"%LOCALAPPDATA%\\OpenClaw\\deps\\portable-git\" rmdir /s /q \"%LOCALAPPDATA%\\OpenClaw\\deps\\portable-git\"" + guest_exec cmd.exe /d /s /c "if not exist \"%LOCALAPPDATA%\\OpenClaw\\deps\" mkdir \"%LOCALAPPDATA%\\OpenClaw\\deps\"" guest_exec cmd.exe /d /s /c "mkdir \"%LOCALAPPDATA%\\OpenClaw\\deps\\portable-git\"" guest_exec cmd.exe /d /s /c "curl.exe -fsSL \"$mingit_url\" -o \"%TEMP%\\$MINGIT_ZIP_NAME\"" guest_exec cmd.exe /d /s /c "tar.exe -xf \"%TEMP%\\$MINGIT_ZIP_NAME\" -C \"%LOCALAPPDATA%\\OpenClaw\\deps\\portable-git\"" @@ -563,9 +588,30 @@ ensure_guest_git() { } pack_main_tgz() { + local mingit_name mingit_url short_head pkg + if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then + say "Pack target package tgz: $TARGET_PACKAGE_SPEC" + mapfile -t mingit_meta < <(resolve_mingit_download) + mingit_name="${mingit_meta[0]}" + mingit_url="${mingit_meta[1]}" + MINGIT_ZIP_NAME="$mingit_name" + MINGIT_ZIP_PATH="$MAIN_TGZ_DIR/$mingit_name" + if [[ ! -f "$MINGIT_ZIP_PATH" ]]; then + say "Download $MINGIT_ZIP_NAME" + curl -fsSL "$mingit_url" -o "$MINGIT_ZIP_PATH" + fi + pkg="$( + npm pack "$TARGET_PACKAGE_SPEC" --ignore-scripts --json --pack-destination "$MAIN_TGZ_DIR" \ + | python3 -c 'import json, sys; data = json.load(sys.stdin); print(data[-1]["filename"])' + )" + MAIN_TGZ_PATH="$MAIN_TGZ_DIR/$(basename "$pkg")" + TARGET_EXPECT_VERSION="$(tar -xOf "$MAIN_TGZ_PATH" package/package.json | python3 -c "import json, sys; print(json.load(sys.stdin)['version'])")" + say "Packed $MAIN_TGZ_PATH" + say "Target package version: $TARGET_EXPECT_VERSION" + return + fi say "Pack current main tgz" ensure_current_build - local mingit_name mingit_url mapfile -t mingit_meta < <(resolve_mingit_download) mingit_name="${mingit_meta[0]}" mingit_url="${mingit_meta[1]}" @@ -575,7 +621,6 @@ pack_main_tgz() { say "Download $MINGIT_ZIP_NAME" curl -fsSL "$mingit_url" -o "$MINGIT_ZIP_PATH" fi - local short_head pkg short_head="$(git rev-parse --short HEAD)" pkg="$( npm pack --ignore-scripts --json --pack-destination "$MAIN_TGZ_DIR" \ @@ -587,6 +632,14 @@ pack_main_tgz() { tar -xOf "$MAIN_TGZ_PATH" package/dist/build-info.json } +verify_target_version() { + if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then + verify_version_contains "$TARGET_EXPECT_VERSION" + return + fi + verify_version_contains "$(git rev-parse --short=7 HEAD)" +} + start_server() { local host_ip="$1" local artifact probe_url attempt @@ -594,7 +647,7 @@ start_server() { attempt=0 while :; do attempt=$((attempt + 1)) - say "Serve current main tgz on $host_ip:$HOST_PORT" + say "Serve $(artifact_label) on $host_ip:$HOST_PORT" ( cd "$MAIN_TGZ_DIR" exec python3 -m http.server "$HOST_PORT" --bind 0.0.0.0 @@ -617,12 +670,16 @@ start_server() { } install_latest_release() { - local install_url_q + local install_url_q version_flag_q install_url_q="$(ps_single_quote "$INSTALL_URL")" + version_flag_q="" + if [[ -n "$INSTALL_VERSION" ]]; then + version_flag_q="-Tag '$(ps_single_quote "$INSTALL_VERSION")' " + fi guest_powershell "$(cat <main precheck: %s (%s)\n' "$UPGRADE_PRECHECK_STATUS" "$LATEST_INSTALLED_VERSION" printf ' latest->main: %s (%s)\n' "$UPGRADE_STATUS" "$UPGRADE_MAIN_VERSION" From be8fc3399e3657950d7a5fc270a8df77b101e1c4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 06:01:52 +0000 Subject: [PATCH 1163/1173] build: prepare 2026.3.14 cycle --- CHANGELOG.md | 4 ++++ apps/ios/Config/Version.xcconfig | 6 +++--- apps/macos/Sources/OpenClaw/Resources/Info.plist | 4 ++-- package.json | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7463733f3b1..6d7f222fe10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai ## Unreleased +### Changes + +- Placeholder: replace with the first 2026.3.14 user-facing change. + ## 2026.3.13 ### Changes diff --git a/apps/ios/Config/Version.xcconfig b/apps/ios/Config/Version.xcconfig index db38e86df80..4297bc8ff57 100644 --- a/apps/ios/Config/Version.xcconfig +++ b/apps/ios/Config/Version.xcconfig @@ -1,8 +1,8 @@ // Shared iOS version defaults. // Generated overrides live in build/Version.xcconfig (git-ignored). -OPENCLAW_GATEWAY_VERSION = 0.0.0 -OPENCLAW_MARKETING_VERSION = 0.0.0 -OPENCLAW_BUILD_VERSION = 0 +OPENCLAW_GATEWAY_VERSION = 2026.3.14 +OPENCLAW_MARKETING_VERSION = 2026.3.14 +OPENCLAW_BUILD_VERSION = 202603140 #include? "../build/Version.xcconfig" diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index 218d638a7e5..89ebf70beb4 100644 --- a/apps/macos/Sources/OpenClaw/Resources/Info.plist +++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.3.13 + 2026.3.14 CFBundleVersion - 202603130 + 202603140 CFBundleIconFile OpenClaw CFBundleURLTypes diff --git a/package.json b/package.json index f19e5c6718a..567798c3b4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.3.13", + "version": "2026.3.14", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", From 49a2ff7d01d8f8b8854420bf2cfb9dbe9581b8c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 06:05:39 +0000 Subject: [PATCH 1164/1173] build: sync plugins for 2026.3.14 --- extensions/acpx/package.json | 2 +- extensions/bluebubbles/package.json | 2 +- extensions/copilot-proxy/package.json | 2 +- extensions/diagnostics-otel/package.json | 2 +- extensions/diffs/package.json | 2 +- extensions/discord/package.json | 2 +- extensions/feishu/package.json | 2 +- extensions/google-gemini-cli-auth/package.json | 2 +- extensions/googlechat/package.json | 5 +---- extensions/imessage/package.json | 2 +- extensions/irc/package.json | 2 +- extensions/line/package.json | 2 +- extensions/llm-task/package.json | 2 +- extensions/lobster/package.json | 2 +- extensions/matrix/CHANGELOG.md | 6 ++++++ extensions/matrix/package.json | 2 +- extensions/mattermost/package.json | 2 +- extensions/memory-core/package.json | 5 +---- extensions/memory-lancedb/package.json | 2 +- extensions/minimax-portal-auth/package.json | 2 +- extensions/msteams/CHANGELOG.md | 6 ++++++ extensions/msteams/package.json | 2 +- extensions/nextcloud-talk/package.json | 2 +- extensions/nostr/CHANGELOG.md | 6 ++++++ extensions/nostr/package.json | 2 +- extensions/ollama/package.json | 2 +- extensions/open-prose/package.json | 2 +- extensions/sglang/package.json | 2 +- extensions/signal/package.json | 2 +- extensions/slack/package.json | 2 +- extensions/synology-chat/package.json | 2 +- extensions/telegram/package.json | 2 +- extensions/tlon/package.json | 2 +- extensions/twitch/CHANGELOG.md | 6 ++++++ extensions/twitch/package.json | 2 +- extensions/vllm/package.json | 2 +- extensions/voice-call/CHANGELOG.md | 6 ++++++ extensions/voice-call/package.json | 2 +- extensions/whatsapp/package.json | 2 +- extensions/zalo/CHANGELOG.md | 6 ++++++ extensions/zalo/package.json | 2 +- extensions/zalouser/CHANGELOG.md | 6 ++++++ extensions/zalouser/package.json | 2 +- 43 files changed, 78 insertions(+), 42 deletions(-) diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json index 66780c709b1..d3947cc7552 100644 --- a/extensions/acpx/package.json +++ b/extensions/acpx/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/acpx", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw ACP runtime backend via acpx", "type": "module", "dependencies": { diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index b2c13701ead..67df516b8d7 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/bluebubbles", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw BlueBubbles channel plugin", "type": "module", "dependencies": { diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 9829860d042..fdab55b3da8 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/copilot-proxy", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw Copilot Proxy provider plugin", "type": "module", diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index 95eea6a702a..b51ead550ef 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/diagnostics-otel", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw diagnostics OpenTelemetry exporter", "type": "module", "dependencies": { diff --git a/extensions/diffs/package.json b/extensions/diffs/package.json index 391a6893173..b92b16052b8 100644 --- a/extensions/diffs/package.json +++ b/extensions/diffs/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/diffs", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw diff viewer plugin", "type": "module", diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 337e6fd90a5..a85eb37b85f 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/discord", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Discord channel plugin", "type": "module", "openclaw": { diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index d44131fa4cf..805dd389b0a 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/feishu", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)", "type": "module", "dependencies": { diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json index a5c5fd54652..61ae5be803c 100644 --- a/extensions/google-gemini-cli-auth/package.json +++ b/extensions/google-gemini-cli-auth/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/google-gemini-cli-auth", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw Gemini CLI OAuth provider plugin", "type": "module", diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 8b6f42e371c..3514ac52b90 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -1,15 +1,12 @@ { "name": "@openclaw/googlechat", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw Google Chat channel plugin", "type": "module", "dependencies": { "google-auth-library": "^10.6.1" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "peerDependencies": { "openclaw": ">=2026.3.11" }, diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 0f8ca0ac9dd..c0988ee601c 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/imessage", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw iMessage channel plugin", "type": "module", diff --git a/extensions/irc/package.json b/extensions/irc/package.json index 85a04dcdaea..8d162b9ac20 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/irc", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw IRC channel plugin", "type": "module", "dependencies": { diff --git a/extensions/line/package.json b/extensions/line/package.json index e9e691ac8b8..85bfac7f0ac 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/line", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw LINE channel plugin", "type": "module", diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json index ac792d4a8d2..6b19e5cb4b2 100644 --- a/extensions/llm-task/package.json +++ b/extensions/llm-task/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/llm-task", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw JSON-only LLM task plugin", "type": "module", diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index d18581200db..915e5d5c3de 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/lobster", - "version": "2026.3.13", + "version": "2026.3.14", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "type": "module", "dependencies": { diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md index 4e4ac1f71fe..5e6a7ed5327 100644 --- a/extensions/matrix/CHANGELOG.md +++ b/extensions/matrix/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.14 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.13 ### Changes diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 6fd32f7d951..5b973b88635 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/matrix", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Matrix channel plugin", "type": "module", "dependencies": { diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index bc8c14f458f..17f8add1b1f 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/mattermost", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Mattermost channel plugin", "type": "module", "dependencies": { diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index 969bff3e07c..a6a8d1dbca8 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/memory-core", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw core memory search plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "peerDependencies": { "openclaw": ">=2026.3.11" }, diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index 9e1af0d7df2..3f387bee4f4 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/memory-lancedb", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture", "type": "module", diff --git a/extensions/minimax-portal-auth/package.json b/extensions/minimax-portal-auth/package.json index bd61f8c9f65..093d42dad1d 100644 --- a/extensions/minimax-portal-auth/package.json +++ b/extensions/minimax-portal-auth/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/minimax-portal-auth", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw MiniMax Portal OAuth provider plugin", "type": "module", diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md index 229656712f8..4fb831f9278 100644 --- a/extensions/msteams/CHANGELOG.md +++ b/extensions/msteams/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.14 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.13 ### Changes diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index f14baa64f3a..4784334d1d5 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/msteams", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Microsoft Teams channel plugin", "type": "module", "dependencies": { diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index 6c7957a5b25..c217d0f0ce7 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nextcloud-talk", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Nextcloud Talk channel plugin", "type": "module", "dependencies": { diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md index 0e59b1cb08e..c8cdc11422e 100644 --- a/extensions/nostr/CHANGELOG.md +++ b/extensions/nostr/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.14 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.13 ### Changes diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 1c3499f3481..19ef7cc03e7 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nostr", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs", "type": "module", "dependencies": { diff --git a/extensions/ollama/package.json b/extensions/ollama/package.json index 5bdf5fd688e..61a8227c3ed 100644 --- a/extensions/ollama/package.json +++ b/extensions/ollama/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/ollama-provider", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw Ollama provider plugin", "type": "module", diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json index f8f0e97cef3..69272781198 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/open-prose", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenProse VM skill pack plugin (slash command + telemetry).", "type": "module", diff --git a/extensions/sglang/package.json b/extensions/sglang/package.json index 6b38cfafb60..d64495bd110 100644 --- a/extensions/sglang/package.json +++ b/extensions/sglang/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/sglang-provider", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw SGLang provider plugin", "type": "module", diff --git a/extensions/signal/package.json b/extensions/signal/package.json index 95a4879cc82..67d6eae6506 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/signal", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw Signal channel plugin", "type": "module", diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 6fbcfb6f122..183cdce7ad4 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/slack", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw Slack channel plugin", "type": "module", diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index bc8623b6059..c6148c856a3 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/synology-chat", - "version": "2026.3.13", + "version": "2026.3.14", "description": "Synology Chat channel plugin for OpenClaw", "type": "module", "dependencies": { diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 2b4e5fd584d..92054ca01a3 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/telegram", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw Telegram channel plugin", "type": "module", diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index e5f9c1e9ed5..40ec9aeedde 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/tlon", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", "dependencies": { diff --git a/extensions/twitch/CHANGELOG.md b/extensions/twitch/CHANGELOG.md index 123b391c2ce..cc887a99055 100644 --- a/extensions/twitch/CHANGELOG.md +++ b/extensions/twitch/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.14 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.13 ### Changes diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index 5213b5c7b74..bc730150b5e 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/twitch", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Twitch channel plugin", "type": "module", "dependencies": { diff --git a/extensions/vllm/package.json b/extensions/vllm/package.json index 3ef665a6bf2..bb293610355 100644 --- a/extensions/vllm/package.json +++ b/extensions/vllm/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/vllm-provider", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw vLLM provider plugin", "type": "module", diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index 25b90b3db54..d9d27a97e87 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.14 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.13 ### Changes diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 75c500db1f9..3c65532f9c9 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/voice-call", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw voice-call plugin", "type": "module", "dependencies": { diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 383edd4612d..ec73a1b0613 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/whatsapp", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw WhatsApp channel plugin", "type": "module", diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md index 154f69b9867..6c3b72b8fbb 100644 --- a/extensions/zalo/CHANGELOG.md +++ b/extensions/zalo/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.14 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.13 ### Changes diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 3880b66abf8..a72aabbb29e 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalo", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Zalo channel plugin", "type": "module", "dependencies": { diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md index 09dfdbb1ff3..9731672126c 100644 --- a/extensions/zalouser/CHANGELOG.md +++ b/extensions/zalouser/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.14 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.13 ### Changes diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 82e796cf676..e7c12c9b4b2 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalouser", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Zalo Personal Account plugin via native zca-js integration", "type": "module", "dependencies": { From 2f5d3b657431866c6dacdc34e1b71722dee442a5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 06:10:06 +0000 Subject: [PATCH 1165/1173] build: refresh lockfile for plugin sync --- pnpm-lock.yaml | 99 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 93 insertions(+), 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc3ec60b125..6460473fe84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -347,10 +347,9 @@ importers: google-auth-library: specifier: ^10.6.1 version: 10.6.1 - devDependencies: openclaw: - specifier: workspace:* - version: link:../.. + specifier: '>=2026.3.11' + version: 2026.3.13(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3)) extensions/imessage: {} @@ -408,10 +407,10 @@ importers: version: 4.3.6 extensions/memory-core: - devDependencies: + dependencies: openclaw: - specifier: workspace:* - version: link:../.. + specifier: '>=2026.3.11' + version: 2026.3.13(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3)) extensions/memory-lancedb: dependencies: @@ -5532,6 +5531,17 @@ packages: zod: optional: true + openclaw@2026.3.13: + resolution: {integrity: sha512-/juSUb070Xz8K8CnShjaZQr7CVtRaW4FbR93lgr1hLepcRSbyz2PQR+V4w5giVWkea61opXWPA6Vb8dybaztFg==} + engines: {node: '>=22.16.0'} + hasBin: true + peerDependencies: + '@napi-rs/canvas': ^0.1.89 + node-llama-cpp: 3.16.2 + peerDependenciesMeta: + node-llama-cpp: + optional: true + opus-decoder@0.7.11: resolution: {integrity: sha512-+e+Jz3vGQLxRTBHs8YJQPRPc1Tr+/aC6coV/DlZylriA29BdHQAYXhvNRKtjftof17OFng0+P4wsFIqQu3a48A==} @@ -12807,6 +12817,83 @@ snapshots: ws: 8.19.0 zod: 4.3.6 + openclaw@2026.3.13(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3)): + dependencies: + '@agentclientprotocol/sdk': 0.16.1(zod@4.3.6) + '@aws-sdk/client-bedrock': 3.1009.0 + '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1) + '@clack/prompts': 1.1.0 + '@discordjs/voice': 0.19.1(@discordjs/opus@0.10.0)(opusscript@0.1.1) + '@grammyjs/runner': 2.0.3(grammy@1.41.1) + '@grammyjs/transformer-throttler': 1.2.1(grammy@1.41.1) + '@homebridge/ciao': 1.3.5 + '@larksuiteoapi/node-sdk': 1.59.0 + '@line/bot-sdk': 10.6.0 + '@lydell/node-pty': 1.2.0-beta.3 + '@mariozechner/pi-agent-core': 0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-coding-agent': 0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.58.0 + '@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6) + '@mozilla/readability': 0.6.0 + '@napi-rs/canvas': 0.1.95 + '@sinclair/typebox': 0.34.48 + '@slack/bolt': 4.6.0(@types/express@5.0.6) + '@slack/web-api': 7.15.0 + '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) + ajv: 8.18.0 + chalk: 5.6.2 + chokidar: 5.0.0 + cli-highlight: 2.1.11 + commander: 14.0.3 + croner: 10.0.1 + discord-api-types: 0.38.42 + dotenv: 17.3.1 + express: 5.2.1 + file-type: 21.3.2 + grammy: 1.41.1 + hono: 4.12.7 + https-proxy-agent: 8.0.0 + ipaddr.js: 2.3.0 + jiti: 2.6.1 + json5: 2.2.3 + jszip: 3.10.1 + linkedom: 0.18.12 + long: 5.3.2 + markdown-it: 14.1.1 + node-edge-tts: 1.2.10 + opusscript: 0.1.1 + osc-progress: 0.3.0 + pdfjs-dist: 5.5.207 + playwright-core: 1.58.2 + qrcode-terminal: 0.12.0 + sharp: 0.34.5 + sqlite-vec: 0.1.7-alpha.2 + tar: 7.5.11 + tslog: 4.10.2 + undici: 7.24.1 + ws: 8.19.0 + yaml: 2.8.2 + zod: 4.3.6 + optionalDependencies: + node-llama-cpp: 3.16.2(typescript@5.9.3) + transitivePeerDependencies: + - '@cfworker/json-schema' + - '@discordjs/opus' + - '@types/express' + - audio-decode + - aws-crt + - bufferutil + - canvas + - debug + - encoding + - ffmpeg-static + - jimp + - link-preview-js + - node-opus + - supports-color + - utf-8-validate + opus-decoder@0.7.11: dependencies: '@wasm-audio-decoders/common': 9.0.7 From dac220bd88c2898c6f2f5bd43fee9486399a2961 Mon Sep 17 00:00:00 2001 From: Catalin Lupuleti <105351510+lupuletic@users.noreply.github.com> Date: Sun, 8 Mar 2026 12:21:41 +0000 Subject: [PATCH 1166/1173] fix(agents): normalize abort-wrapped RESOURCE_EXHAUSTED into failover errors (#11972) --- src/agents/failover-error.ts | 72 ++++++++++++++++++++++++- src/agents/model-fallback.probe.test.ts | 70 ++++++++++++++++++++++++ src/agents/model-fallback.ts | 10 +++- src/agents/pi-embedded-runner/run.ts | 42 +++++++++++---- 4 files changed, 179 insertions(+), 15 deletions(-) diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index 8c49df40acb..e367461ea31 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -72,9 +72,16 @@ function getStatusCode(err: unknown): number | undefined { if (!err || typeof err !== "object") { return undefined; } + // Dig into nested `err.error` shapes (e.g. Google Vertex abort wrappers) + const nestedError = + "error" in err && err.error && typeof err.error === "object" + ? (err.error as { status?: unknown; code?: unknown }) + : undefined; const candidate = (err as { status?: unknown; statusCode?: unknown }).status ?? - (err as { statusCode?: unknown }).statusCode; + (err as { statusCode?: unknown }).statusCode ?? + nestedError?.code ?? + nestedError?.status; if (typeof candidate === "number") { return candidate; } @@ -88,7 +95,11 @@ function getErrorCode(err: unknown): string | undefined { if (!err || typeof err !== "object") { return undefined; } - const candidate = (err as { code?: unknown }).code; + const nestedError = + "error" in err && err.error && typeof err.error === "object" + ? (err.error as { code?: unknown; status?: unknown }) + : undefined; + const candidate = (err as { code?: unknown }).code ?? nestedError?.status ?? nestedError?.code; if (typeof candidate !== "string") { return undefined; } @@ -114,10 +125,53 @@ function getErrorMessage(err: unknown): string { if (typeof message === "string") { return message; } + // Extract message from nested `err.error.message` (e.g. Google Vertex wrappers) + const nestedMessage = + "error" in err && + err.error && + typeof err.error === "object" && + typeof (err.error as { message?: unknown }).message === "string" + ? ((err.error as { message: string }).message ?? "") + : ""; + if (nestedMessage) { + return nestedMessage; + } } return ""; } +function getErrorCause(err: unknown): unknown { + if (!err || typeof err !== "object" || !("cause" in err)) { + return undefined; + } + return (err as { cause?: unknown }).cause; +} + +/** Classify rate-limit / overloaded from symbolic error codes like RESOURCE_EXHAUSTED. */ +function classifyFailoverReasonFromSymbolicCode(raw: string | undefined): FailoverReason | null { + const normalized = raw?.trim().toUpperCase(); + if (!normalized) { + return null; + } + switch (normalized) { + case "RESOURCE_EXHAUSTED": + case "RATE_LIMIT": + case "RATE_LIMITED": + case "RATE_LIMIT_EXCEEDED": + case "TOO_MANY_REQUESTS": + case "THROTTLED": + case "THROTTLING": + case "THROTTLINGEXCEPTION": + case "THROTTLING_EXCEPTION": + return "rate_limit"; + case "OVERLOADED": + case "OVERLOADED_ERROR": + return "overloaded"; + default: + return null; + } +} + function hasTimeoutHint(err: unknown): boolean { if (!err) { return false; @@ -160,6 +214,12 @@ export function resolveFailoverReasonFromError(err: unknown): FailoverReason | n return statusReason; } + // Check symbolic error codes (e.g. RESOURCE_EXHAUSTED from Google APIs) + const symbolicCodeReason = classifyFailoverReasonFromSymbolicCode(getErrorCode(err)); + if (symbolicCodeReason) { + return symbolicCodeReason; + } + const code = (getErrorCode(err) ?? "").toUpperCase(); if ( [ @@ -181,6 +241,14 @@ export function resolveFailoverReasonFromError(err: unknown): FailoverReason | n if (isTimeoutError(err)) { return "timeout"; } + // Walk into error cause chain (e.g. AbortError wrapping a rate-limit cause) + const cause = getErrorCause(err); + if (cause && cause !== err) { + const causeReason = resolveFailoverReasonFromError(cause); + if (causeReason) { + return causeReason; + } + } if (!message) { return null; } diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts index 3969416cd38..4795bdb4c65 100644 --- a/src/agents/model-fallback.probe.test.ts +++ b/src/agents/model-fallback.probe.test.ts @@ -331,6 +331,76 @@ describe("runWithModelFallback – probe logic", () => { }); }); + it("keeps walking remaining fallbacks after an abort-wrapped RESOURCE_EXHAUSTED probe failure", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "google/gemini-3-flash-preview", + fallbacks: ["anthropic/claude-haiku-3-5", "deepseek/deepseek-chat"], + }, + }, + }, + } as Partial); + + mockedResolveAuthProfileOrder.mockImplementation(({ provider }: { provider: string }) => { + if (provider === "google") { + return ["google-profile-1"]; + } + if (provider === "anthropic") { + return ["anthropic-profile-1"]; + } + if (provider === "deepseek") { + return ["deepseek-profile-1"]; + } + return []; + }); + mockedIsProfileInCooldown.mockImplementation((_store, profileId: string) => + profileId.startsWith("google"), + ); + mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 30 * 1000); + mockedResolveProfilesUnavailableReason.mockReturnValue("rate_limit"); + + // Simulate Google Vertex abort-wrapped RESOURCE_EXHAUSTED (the shape that was + // previously swallowed by shouldRethrowAbort before the fallback loop could continue) + const primaryAbort = Object.assign(new Error("request aborted"), { + name: "AbortError", + cause: { + error: { + code: 429, + message: "Resource has been exhausted (e.g. check quota).", + status: "RESOURCE_EXHAUSTED", + }, + }, + }); + const run = vi + .fn() + .mockRejectedValueOnce(primaryAbort) + .mockRejectedValueOnce( + Object.assign(new Error("fallback still rate limited"), { status: 429 }), + ) + .mockRejectedValueOnce( + Object.assign(new Error("final fallback still rate limited"), { status: 429 }), + ); + + await expect( + runWithModelFallback({ + cfg, + provider: "google", + model: "gemini-3-flash-preview", + run, + }), + ).rejects.toThrow(/All models failed \(3\)/); + + // All three candidates must be attempted — the abort must not short-circuit + expect(run).toHaveBeenCalledTimes(3); + expect(run).toHaveBeenNthCalledWith(1, "google", "gemini-3-flash-preview", { + allowTransientCooldownProbe: true, + }); + expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5"); + expect(run).toHaveBeenNthCalledWith(3, "deepseek", "deepseek-chat"); + }); + it("throttles probe when called within 30s interval", async () => { const cfg = makeCfg(); // Cooldown just about to expire (within probe margin) diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index d14ede7658b..5fd6e533a1a 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -140,10 +140,16 @@ async function runFallbackCandidate(params: { result, }; } catch (err) { - if (shouldRethrowAbort(err)) { + // Normalize abort-wrapped rate-limit errors (e.g. Google Vertex RESOURCE_EXHAUSTED) + // so they become FailoverErrors and continue the fallback loop instead of aborting. + const normalizedFailover = coerceToFailoverError(err, { + provider: params.provider, + model: params.model, + }); + if (shouldRethrowAbort(err) && !normalizedFailover) { throw err; } - return { ok: false, error: err }; + return { ok: false, error: normalizedFailover ?? err }; } } diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 1839a9df1bb..4ca6c0ea226 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -28,7 +28,12 @@ import { resolveContextWindowInfo, } from "../context-window-guard.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; -import { FailoverError, resolveFailoverStatus } from "../failover-error.js"; +import { + coerceToFailoverError, + describeFailoverError, + FailoverError, + resolveFailoverStatus, +} from "../failover-error.js"; import { applyLocalNoAuthHeaderOverride, ensureAuthProfileStore, @@ -1217,7 +1222,17 @@ export async function runEmbeddedPiAgent( } if (promptError && !aborted) { - const errorText = describeUnknownError(promptError); + // Normalize wrapped errors (e.g. abort-wrapped RESOURCE_EXHAUSTED) into + // FailoverError so rate-limit classification works even for nested shapes. + const normalizedPromptFailover = coerceToFailoverError(promptError, { + provider: activeErrorContext.provider, + model: activeErrorContext.model, + profileId: lastProfileId, + }); + const promptErrorDetails = normalizedPromptFailover + ? describeFailoverError(normalizedPromptFailover) + : describeFailoverError(promptError); + const errorText = promptErrorDetails.message || describeUnknownError(promptError); if (await maybeRefreshCopilotForAuthError(errorText, copilotAuthRetry)) { authRetryPending = true; continue; @@ -1281,14 +1296,16 @@ export async function runEmbeddedPiAgent( }, }; } - const promptFailoverReason = classifyFailoverReason(errorText); + const promptFailoverReason = + promptErrorDetails.reason ?? classifyFailoverReason(errorText); const promptProfileFailureReason = resolveAuthProfileFailureReason(promptFailoverReason); await maybeMarkAuthProfileFailure({ profileId: lastProfileId, reason: promptProfileFailureReason, }); - const promptFailoverFailure = isFailoverErrorMessage(errorText); + const promptFailoverFailure = + promptFailoverReason !== null || isFailoverErrorMessage(errorText); // Capture the failing profile before auth-profile rotation mutates `lastProfileId`. const failedPromptProfileId = lastProfileId; const logPromptFailoverDecision = createFailoverDecisionLogger({ @@ -1330,13 +1347,16 @@ export async function runEmbeddedPiAgent( const status = resolveFailoverStatus(promptFailoverReason ?? "unknown"); logPromptFailoverDecision("fallback_model", { status }); await maybeBackoffBeforeOverloadFailover(promptFailoverReason); - throw new FailoverError(errorText, { - reason: promptFailoverReason ?? "unknown", - provider, - model: modelId, - profileId: lastProfileId, - status, - }); + throw ( + normalizedPromptFailover ?? + new FailoverError(errorText, { + reason: promptFailoverReason ?? "unknown", + provider, + model: modelId, + profileId: lastProfileId, + status: resolveFailoverStatus(promptFailoverReason ?? "unknown"), + }) + ); } if (promptFailoverFailure || promptFailoverReason) { logPromptFailoverDecision("surface_error"); From c1c74f9952167ca73b08caedad344e6c58219453 Mon Sep 17 00:00:00 2001 From: Catalin Lupuleti <105351510+lupuletic@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:39:49 +0000 Subject: [PATCH 1167/1173] fix: move cause-chain traversal before timeout heuristic (review feedback) --- src/agents/failover-error.ts | 10 ++++++---- src/agents/model-fallback.probe.test.ts | 9 +++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index e367461ea31..205f12ee18b 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -238,10 +238,9 @@ export function resolveFailoverReasonFromError(err: unknown): FailoverReason | n ) { return "timeout"; } - if (isTimeoutError(err)) { - return "timeout"; - } - // Walk into error cause chain (e.g. AbortError wrapping a rate-limit cause) + // Walk into error cause chain *before* timeout heuristics so that a specific + // cause (e.g. RESOURCE_EXHAUSTED wrapped in AbortError) overrides a parent + // message-based "timeout" guess from isTimeoutError. const cause = getErrorCause(err); if (cause && cause !== err) { const causeReason = resolveFailoverReasonFromError(cause); @@ -249,6 +248,9 @@ export function resolveFailoverReasonFromError(err: unknown): FailoverReason | n return causeReason; } } + if (isTimeoutError(err)) { + return "timeout"; + } if (!message) { return null; } diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts index 4795bdb4c65..7b7435b1bcc 100644 --- a/src/agents/model-fallback.probe.test.ts +++ b/src/agents/model-fallback.probe.test.ts @@ -394,6 +394,15 @@ describe("runWithModelFallback – probe logic", () => { // All three candidates must be attempted — the abort must not short-circuit expect(run).toHaveBeenCalledTimes(3); + + // Verify the primary error is classified as rate_limit, not timeout — the + // cause chain (RESOURCE_EXHAUSTED) must override the parent AbortError message. + try { + await runWithModelFallback({ cfg, provider: "google", model: "gemini-3-flash-preview", run }); + } catch (err) { + expect(String(err)).toContain("(rate_limit)"); + expect(String(err)).not.toMatch(/gemini.*\(timeout\)/); + } expect(run).toHaveBeenNthCalledWith(1, "google", "gemini-3-flash-preview", { allowTransientCooldownProbe: true, }); From e403ed6546af9ea6367fdc3e754a4217c0e10058 Mon Sep 17 00:00:00 2001 From: Darshil Date: Fri, 13 Mar 2026 00:16:12 -0700 Subject: [PATCH 1168/1173] fix: harden wrapped rate-limit failover (openclaw#39820) thanks @lupuletic --- CHANGELOG.md | 1 + src/agents/failover-error.test.ts | 17 ++++ src/agents/failover-error.ts | 86 +++++++++++-------- src/agents/model-fallback.probe.test.ts | 10 +-- .../run.overflow-compaction.mocks.shared.ts | 13 ++- .../run.overflow-compaction.test.ts | 70 ++++++++++++++- 6 files changed, 152 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d7f222fe10..85ad205ff0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ Docs: https://docs.openclaw.ai - Signal/config validation: add `channels.signal.groups` schema support so per-group `requireMention`, `tools`, and `toolsBySender` overrides no longer get rejected during config validation. (#27199) Thanks @unisone. - Config/discovery: accept `discovery.wideArea.domain` in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh. - 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. ## 2026.3.12 diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index 1ddd1d9ceef..38e3530f011 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -364,6 +364,23 @@ describe("failover-error", () => { expect(isTimeoutError(err)).toBe(true); }); + it("classifies abort-wrapped RESOURCE_EXHAUSTED as rate_limit", () => { + const err = Object.assign(new Error("request aborted"), { + name: "AbortError", + cause: { + error: { + code: 429, + message: GEMINI_RESOURCE_EXHAUSTED_MESSAGE, + status: "RESOURCE_EXHAUSTED", + }, + }, + }); + + expect(resolveFailoverReasonFromError(err)).toBe("rate_limit"); + expect(coerceToFailoverError(err)?.reason).toBe("rate_limit"); + expect(coerceToFailoverError(err)?.status).toBe(429); + }); + it("coerces failover-worthy errors into FailoverError with metadata", () => { const err = coerceToFailoverError("credit balance too low", { provider: "anthropic", diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index 205f12ee18b..dd482310a2b 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -68,20 +68,36 @@ export function resolveFailoverStatus(reason: FailoverReason): number | undefine } } -function getStatusCode(err: unknown): number | undefined { +function findErrorProperty( + err: unknown, + reader: (candidate: unknown) => T | undefined, + seen: Set = new Set(), +): T | undefined { + const direct = reader(err); + if (direct !== undefined) { + return direct; + } + if (!err || typeof err !== "object") { + return undefined; + } + if (seen.has(err)) { + return undefined; + } + seen.add(err); + const candidate = err as { error?: unknown; cause?: unknown }; + return ( + findErrorProperty(candidate.error, reader, seen) ?? + findErrorProperty(candidate.cause, reader, seen) + ); +} + +function readDirectStatusCode(err: unknown): number | undefined { if (!err || typeof err !== "object") { return undefined; } - // Dig into nested `err.error` shapes (e.g. Google Vertex abort wrappers) - const nestedError = - "error" in err && err.error && typeof err.error === "object" - ? (err.error as { status?: unknown; code?: unknown }) - : undefined; const candidate = (err as { status?: unknown; statusCode?: unknown }).status ?? - (err as { statusCode?: unknown }).statusCode ?? - nestedError?.code ?? - nestedError?.status; + (err as { statusCode?: unknown }).statusCode; if (typeof candidate === "number") { return candidate; } @@ -91,53 +107,55 @@ function getStatusCode(err: unknown): number | undefined { return undefined; } -function getErrorCode(err: unknown): string | undefined { +function getStatusCode(err: unknown): number | undefined { + return findErrorProperty(err, readDirectStatusCode); +} + +function readDirectErrorCode(err: unknown): string | undefined { if (!err || typeof err !== "object") { return undefined; } - const nestedError = - "error" in err && err.error && typeof err.error === "object" - ? (err.error as { code?: unknown; status?: unknown }) - : undefined; - const candidate = (err as { code?: unknown }).code ?? nestedError?.status ?? nestedError?.code; - if (typeof candidate !== "string") { + const directCode = (err as { code?: unknown }).code; + if (typeof directCode === "string") { + const trimmed = directCode.trim(); + return trimmed ? trimmed : undefined; + } + const status = (err as { status?: unknown }).status; + if (typeof status !== "string" || /^\d+$/.test(status)) { return undefined; } - const trimmed = candidate.trim(); + const trimmed = status.trim(); return trimmed ? trimmed : undefined; } -function getErrorMessage(err: unknown): string { +function getErrorCode(err: unknown): string | undefined { + return findErrorProperty(err, readDirectErrorCode); +} + +function readDirectErrorMessage(err: unknown): string | undefined { if (err instanceof Error) { - return err.message; + return err.message || undefined; } if (typeof err === "string") { - return err; + return err || undefined; } if (typeof err === "number" || typeof err === "boolean" || typeof err === "bigint") { return String(err); } if (typeof err === "symbol") { - return err.description ?? ""; + return err.description ?? undefined; } if (err && typeof err === "object") { const message = (err as { message?: unknown }).message; if (typeof message === "string") { - return message; - } - // Extract message from nested `err.error.message` (e.g. Google Vertex wrappers) - const nestedMessage = - "error" in err && - err.error && - typeof err.error === "object" && - typeof (err.error as { message?: unknown }).message === "string" - ? ((err.error as { message: string }).message ?? "") - : ""; - if (nestedMessage) { - return nestedMessage; + return message || undefined; } } - return ""; + return undefined; +} + +function getErrorMessage(err: unknown): string { + return findErrorProperty(err, readDirectErrorMessage) ?? ""; } function getErrorCause(err: unknown): unknown { diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts index 7b7435b1bcc..a351730521f 100644 --- a/src/agents/model-fallback.probe.test.ts +++ b/src/agents/model-fallback.probe.test.ts @@ -2,8 +2,8 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { registerLogTransport, resetLogger, setLoggerOverride } from "../logging/logger.js"; import type { AuthProfileStore } from "./auth-profiles.js"; +import { registerLogTransport, resetLogger, setLoggerOverride } from "../logging/logger.js"; import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js"; // Mock auth-profiles module — must be before importing model-fallback @@ -395,14 +395,6 @@ describe("runWithModelFallback – probe logic", () => { // All three candidates must be attempted — the abort must not short-circuit expect(run).toHaveBeenCalledTimes(3); - // Verify the primary error is classified as rate_limit, not timeout — the - // cause chain (RESOURCE_EXHAUSTED) must override the parent AbortError message. - try { - await runWithModelFallback({ cfg, provider: "google", model: "gemini-3-flash-preview", run }); - } catch (err) { - expect(String(err)).toContain("(rate_limit)"); - expect(String(err)).not.toMatch(/gemini.*\(timeout\)/); - } expect(run).toHaveBeenNthCalledWith(1, "google", "gemini-3-flash-preview", { allowTransientCooldownProbe: true, }); diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts index 3e3d4a83461..dfc2bc0c961 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts @@ -209,9 +209,20 @@ vi.mock("../defaults.js", () => ({ DEFAULT_PROVIDER: "anthropic", })); +export const mockedCoerceToFailoverError = vi.fn(); +export const mockedDescribeFailoverError = vi.fn((err: unknown) => ({ + message: err instanceof Error ? err.message : String(err), + reason: undefined, + status: undefined, + code: undefined, +})); +export const mockedResolveFailoverStatus = vi.fn(); + vi.mock("../failover-error.js", () => ({ FailoverError: class extends Error {}, - resolveFailoverStatus: vi.fn(), + coerceToFailoverError: mockedCoerceToFailoverError, + describeFailoverError: mockedDescribeFailoverError, + resolveFailoverStatus: mockedResolveFailoverStatus, })); vi.mock("./lanes.js", () => ({ diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts index b9f7707c0b6..8458e840e70 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -9,7 +9,12 @@ import { mockOverflowRetrySuccess, queueOverflowAttemptWithOversizedToolOutput, } from "./run.overflow-compaction.fixture.js"; -import { mockedGlobalHookRunner } from "./run.overflow-compaction.mocks.shared.js"; +import { + mockedCoerceToFailoverError, + mockedDescribeFailoverError, + mockedGlobalHookRunner, + mockedResolveFailoverStatus, +} from "./run.overflow-compaction.mocks.shared.js"; import { mockedContextEngine, mockedCompactDirect, @@ -25,6 +30,9 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { vi.clearAllMocks(); mockedRunEmbeddedAttempt.mockReset(); mockedCompactDirect.mockReset(); + mockedCoerceToFailoverError.mockReset(); + mockedDescribeFailoverError.mockReset(); + mockedResolveFailoverStatus.mockReset(); mockedSessionLikelyHasOversizedToolResults.mockReset(); mockedTruncateOversizedToolResultsInSession.mockReset(); mockedGlobalHookRunner.runBeforeAgentStart.mockReset(); @@ -36,6 +44,13 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { compacted: false, reason: "nothing to compact", }); + mockedCoerceToFailoverError.mockReturnValue(null); + mockedDescribeFailoverError.mockImplementation((err: unknown) => ({ + message: err instanceof Error ? err.message : String(err), + reason: undefined, + status: undefined, + code: undefined, + })); mockedSessionLikelyHasOversizedToolResults.mockReturnValue(false); mockedTruncateOversizedToolResultsInSession.mockResolvedValue({ truncated: false, @@ -255,4 +270,57 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { expect(result.meta.error?.kind).toBe("retry_limit"); expect(result.payloads?.[0]?.isError).toBe(true); }); + + it("normalizes abort-wrapped prompt errors before handing off to model fallback", async () => { + const promptError = Object.assign(new Error("request aborted"), { + name: "AbortError", + cause: { + error: { + code: 429, + message: "Resource has been exhausted (e.g. check quota).", + status: "RESOURCE_EXHAUSTED", + }, + }, + }); + const normalized = Object.assign(new Error("Resource has been exhausted (e.g. check quota)."), { + name: "FailoverError", + reason: "rate_limit", + status: 429, + }); + + mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError })); + mockedCoerceToFailoverError.mockReturnValueOnce(normalized); + mockedDescribeFailoverError.mockImplementation((err: unknown) => ({ + message: err instanceof Error ? err.message : String(err), + reason: err === normalized ? "rate_limit" : undefined, + status: err === normalized ? 429 : undefined, + code: undefined, + })); + mockedResolveFailoverStatus.mockReturnValueOnce(429); + + await expect( + runEmbeddedPiAgent({ + ...overflowBaseRunParams, + cfg: { + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-5.2"], + }, + }, + }, + }, + }), + ).rejects.toBe(normalized); + + expect(mockedCoerceToFailoverError).toHaveBeenCalledWith( + promptError, + expect.objectContaining({ + provider: "anthropic", + model: "test-model", + profileId: "test-profile", + }), + ); + expect(mockedResolveFailoverStatus).toHaveBeenCalledWith("rate_limit"); + }); }); From 105dcd69e75330bbefd8dfb863d2d3dddffeac60 Mon Sep 17 00:00:00 2001 From: Darshil Date: Fri, 13 Mar 2026 00:21:10 -0700 Subject: [PATCH 1169/1173] style: format probe regression test (openclaw#39820) thanks @lupuletic --- src/agents/model-fallback.probe.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts index a351730521f..e80c3e3edd4 100644 --- a/src/agents/model-fallback.probe.test.ts +++ b/src/agents/model-fallback.probe.test.ts @@ -2,8 +2,8 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import type { AuthProfileStore } from "./auth-profiles.js"; import { registerLogTransport, resetLogger, setLoggerOverride } from "../logging/logger.js"; +import type { AuthProfileStore } from "./auth-profiles.js"; import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js"; // Mock auth-profiles module — must be before importing model-fallback From dd6ecd5bfa5da81fea423d74ac3b10c586684c33 Mon Sep 17 00:00:00 2001 From: Darshil Date: Fri, 13 Mar 2026 00:23:15 -0700 Subject: [PATCH 1170/1173] fix: tighten runner failover test types (openclaw#39820) thanks @lupuletic --- .../run.overflow-compaction.mocks.shared.ts | 21 +++++++++++++------ .../run.overflow-compaction.test.ts | 2 +- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts index dfc2bc0c961..5276bd1c0d6 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts @@ -210,12 +210,21 @@ vi.mock("../defaults.js", () => ({ })); export const mockedCoerceToFailoverError = vi.fn(); -export const mockedDescribeFailoverError = vi.fn((err: unknown) => ({ - message: err instanceof Error ? err.message : String(err), - reason: undefined, - status: undefined, - code: undefined, -})); +type MockFailoverErrorDescription = { + message: string; + reason: string | undefined; + status: number | undefined; + code: string | undefined; +}; + +export const mockedDescribeFailoverError = vi.fn( + (err: unknown): MockFailoverErrorDescription => ({ + message: err instanceof Error ? err.message : String(err), + reason: undefined, + status: undefined, + code: undefined, + }), +); export const mockedResolveFailoverStatus = vi.fn(); vi.mock("../failover-error.js", () => ({ diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts index 8458e840e70..d18123a4ae2 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -301,7 +301,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { await expect( runEmbeddedPiAgent({ ...overflowBaseRunParams, - cfg: { + config: { agents: { defaults: { model: { From 61bf7b8536c509bb870dadb92e41886c3fb82b7e Mon Sep 17 00:00:00 2001 From: Darshil Date: Fri, 13 Mar 2026 00:25:27 -0700 Subject: [PATCH 1171/1173] fix: annotate shared failover mocks (openclaw#39820) thanks @lupuletic --- .../run.overflow-compaction.mocks.shared.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts index 5276bd1c0d6..53e73e6246d 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts @@ -209,7 +209,6 @@ vi.mock("../defaults.js", () => ({ DEFAULT_PROVIDER: "anthropic", })); -export const mockedCoerceToFailoverError = vi.fn(); type MockFailoverErrorDescription = { message: string; reason: string | undefined; @@ -217,7 +216,15 @@ type MockFailoverErrorDescription = { code: string | undefined; }; -export const mockedDescribeFailoverError = vi.fn( +type MockCoerceToFailoverError = ( + err: unknown, + params?: { provider?: string; model?: string; profileId?: string }, +) => unknown; +type MockDescribeFailoverError = (err: unknown) => MockFailoverErrorDescription; +type MockResolveFailoverStatus = (reason: string) => number | undefined; + +export const mockedCoerceToFailoverError = vi.fn(); +export const mockedDescribeFailoverError = vi.fn( (err: unknown): MockFailoverErrorDescription => ({ message: err instanceof Error ? err.message : String(err), reason: undefined, @@ -225,7 +232,7 @@ export const mockedDescribeFailoverError = vi.fn( code: undefined, }), ); -export const mockedResolveFailoverStatus = vi.fn(); +export const mockedResolveFailoverStatus = vi.fn(); vi.mock("../failover-error.js", () => ({ FailoverError: class extends Error {}, From 17cb60080ade324bcd34a88d49841c89d8b8b286 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 06:28:51 +0000 Subject: [PATCH 1172/1173] test(ci): isolate cron heartbeat delivery cases --- ...onse-has-heartbeat-ok-but-includes.test.ts | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts index 023c1e9eedc..8ea21bffefe 100644 --- a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts +++ b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts @@ -138,11 +138,10 @@ describe("runCronIsolatedAgentTurn", () => { }); }); - it("handles media heartbeat delivery and last-target text delivery", async () => { + it("delivers media payloads even when heartbeat text is suppressed", async () => { await withTempHome(async (home) => { const { storePath, deps } = await createTelegramDeliveryFixture(home); - // Media should still be delivered even if text is just HEARTBEAT_OK. mockEmbeddedAgentPayloads([ { text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" }, ]); @@ -156,9 +155,13 @@ describe("runCronIsolatedAgentTurn", () => { expect(mediaRes.status).toBe("ok"); expect(deps.sendMessageTelegram).toHaveBeenCalled(); expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + }); + }); + + it("keeps non-empty heartbeat text when last-target ack suppression is disabled", async () => { + await withTempHome(async (home) => { + const { storePath, deps } = await createTelegramDeliveryFixture(home); - vi.mocked(runSubagentAnnounceFlow).mockClear(); - vi.mocked(deps.sendMessageTelegram).mockClear(); mockEmbeddedAgentPayloads([{ text: "HEARTBEAT_OK 🦞" }]); const cfg = makeCfg(home, storePath); @@ -194,10 +197,23 @@ describe("runCronIsolatedAgentTurn", () => { "HEARTBEAT_OK 🦞", expect.objectContaining({ accountId: undefined }), ); + }); + }); - vi.mocked(deps.sendMessageTelegram).mockClear(); - vi.mocked(runSubagentAnnounceFlow).mockClear(); - vi.mocked(callGateway).mockClear(); + it("deletes the direct cron session after last-target text delivery", async () => { + await withTempHome(async (home) => { + const { storePath, deps } = await createTelegramDeliveryFixture(home); + + mockEmbeddedAgentPayloads([{ text: "HEARTBEAT_OK 🦞" }]); + + const cfg = makeCfg(home, storePath); + cfg.agents = { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + heartbeat: { ackMaxChars: 0 }, + }, + }; const deleteRes = await runCronIsolatedAgentTurn({ cfg, From 0c926a2c5e82e5fa01eee151618f2d8a05c160de Mon Sep 17 00:00:00 2001 From: Teconomix Date: Sat, 14 Mar 2026 07:53:23 +0100 Subject: [PATCH 1173/1173] fix(mattermost): carry thread context to non-inbound reply paths (#44283) Merged via squash. Prepared head SHA: 2846a6cfa959019d3ed811ccafae6b757db3bdf3 Co-authored-by: teconomix <6959299+teconomix@users.noreply.github.com> Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com> Reviewed-by: @mukhtharcm --- CHANGELOG.md | 1 + extensions/mattermost/src/channel.test.ts | 47 ++++++++ extensions/mattermost/src/channel.ts | 17 ++- .../reply/dispatch-from-config.test.ts | 114 +++++++++++++++++- src/auto-reply/reply/dispatch-from-config.ts | 15 ++- src/auto-reply/reply/route-reply.test.ts | 37 ++++++ src/auto-reply/reply/route-reply.ts | 4 +- .../monitor.tool-result.test-harness.ts | 16 ++- src/slack/monitor.test-helpers.ts | 18 +-- 9 files changed, 244 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ad205ff0e..25bad54390e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ Docs: https://docs.openclaw.ai - Config/discovery: accept `discovery.wideArea.domain` in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh. - 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 ## 2026.3.12 diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index c188a8e6719..5ac333b2e6c 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -355,6 +355,53 @@ describe("mattermostPlugin", () => { }), ); }); + + it("uses threadId as fallback when replyToId is absent (sendText)", async () => { + const sendText = mattermostPlugin.outbound?.sendText; + if (!sendText) { + return; + } + + await sendText({ + to: "channel:CHAN1", + text: "hello", + accountId: "default", + threadId: "post-root", + } as any); + + expect(sendMessageMattermostMock).toHaveBeenCalledWith( + "channel:CHAN1", + "hello", + expect.objectContaining({ + accountId: "default", + replyToId: "post-root", + }), + ); + }); + + it("uses threadId as fallback when replyToId is absent (sendMedia)", async () => { + const sendMedia = mattermostPlugin.outbound?.sendMedia; + if (!sendMedia) { + return; + } + + await sendMedia({ + to: "channel:CHAN1", + text: "caption", + mediaUrl: "https://example.com/image.png", + accountId: "default", + threadId: "post-root", + } as any); + + expect(sendMessageMattermostMock).toHaveBeenCalledWith( + "channel:CHAN1", + "caption", + expect.objectContaining({ + accountId: "default", + replyToId: "post-root", + }), + ); + }); }); describe("config", () => { diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index c872b8d5085..45c4d863c7c 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -390,21 +390,30 @@ export const mattermostPlugin: ChannelPlugin = { } return { ok: true, to: trimmed }; }, - sendText: async ({ cfg, to, text, accountId, replyToId }) => { + sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { const result = await sendMessageMattermost(to, text, { cfg, accountId: accountId ?? undefined, - replyToId: replyToId ?? undefined, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), }); return { channel: "mattermost", ...result }; }, - sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, replyToId }) => { + sendMedia: async ({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + replyToId, + threadId, + }) => { const result = await sendMessageMattermost(to, text, { cfg, accountId: accountId ?? undefined, mediaUrl, mediaLocalRoots, - replyToId: replyToId ?? undefined, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), }); return { channel: "mattermost", ...result }; }, diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 87e77785bbb..666964eb865 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -41,6 +41,12 @@ const acpMocks = vi.hoisted(() => ({ const sessionBindingMocks = vi.hoisted(() => ({ listBySession: vi.fn<(targetSessionKey: string) => SessionBindingRecord[]>(() => []), })); +const sessionStoreMocks = vi.hoisted(() => ({ + currentEntry: undefined as Record | undefined, + loadSessionStore: vi.fn(() => ({})), + resolveStorePath: vi.fn(() => "/tmp/mock-sessions.json"), + resolveSessionStoreEntry: vi.fn(() => ({ existing: sessionStoreMocks.currentEntry })), +})); const ttsMocks = vi.hoisted(() => { const state = { synthesizeFinalAudio: false, @@ -77,9 +83,16 @@ vi.mock("./route-reply.js", () => ({ isRoutableChannel: (channel: string | undefined) => Boolean( channel && - ["telegram", "slack", "discord", "signal", "imessage", "whatsapp", "feishu"].includes( - channel, - ), + [ + "telegram", + "slack", + "discord", + "signal", + "imessage", + "whatsapp", + "feishu", + "mattermost", + ].includes(channel), ), routeReply: mocks.routeReply, })); @@ -100,6 +113,15 @@ vi.mock("../../logging/diagnostic.js", () => ({ logMessageProcessed: diagnosticMocks.logMessageProcessed, logSessionStateChange: diagnosticMocks.logSessionStateChange, })); +vi.mock("../../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSessionStore: sessionStoreMocks.loadSessionStore, + resolveStorePath: sessionStoreMocks.resolveStorePath, + resolveSessionStoreEntry: sessionStoreMocks.resolveSessionStoreEntry, + }; +}); vi.mock("../../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => hookMocks.runner, @@ -228,6 +250,10 @@ describe("dispatchReplyFromConfig", () => { acpMocks.requireAcpRuntimeBackend.mockReset(); sessionBindingMocks.listBySession.mockReset(); sessionBindingMocks.listBySession.mockReturnValue([]); + sessionStoreMocks.currentEntry = undefined; + sessionStoreMocks.loadSessionStore.mockClear(); + sessionStoreMocks.resolveStorePath.mockClear(); + sessionStoreMocks.resolveSessionStoreEntry.mockClear(); ttsMocks.state.synthesizeFinalAudio = false; ttsMocks.maybeApplyTtsToPayload.mockClear(); ttsMocks.normalizeTtsAutoMode.mockClear(); @@ -293,6 +319,88 @@ describe("dispatchReplyFromConfig", () => { ); }); + it("falls back to thread-scoped session key when current ctx has no MessageThreadId", async () => { + setNoAbort(); + mocks.routeReply.mockClear(); + sessionStoreMocks.currentEntry = { + deliveryContext: { + channel: "mattermost", + to: "channel:CHAN1", + accountId: "default", + }, + origin: { + threadId: "stale-origin-root", + }, + lastThreadId: "stale-origin-root", + }; + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "webchat", + Surface: "webchat", + SessionKey: "agent:main:mattermost:channel:CHAN1:thread:post-root", + AccountId: "default", + MessageThreadId: undefined, + OriginatingChannel: "mattermost", + OriginatingTo: "channel:CHAN1", + ExplicitDeliverRoute: true, + }); + + const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(mocks.routeReply).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "mattermost", + to: "channel:CHAN1", + threadId: "post-root", + }), + ); + }); + + it("does not resurrect a cleared route thread from origin metadata", async () => { + setNoAbort(); + mocks.routeReply.mockClear(); + // Simulate the real store: lastThreadId and deliveryContext.threadId may be normalised from + // origin.threadId on read, but a non-thread session key must still route to channel root. + sessionStoreMocks.currentEntry = { + deliveryContext: { + channel: "mattermost", + to: "channel:CHAN1", + accountId: "default", + threadId: "stale-root", + }, + lastThreadId: "stale-root", + origin: { + threadId: "stale-root", + }, + }; + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "webchat", + Surface: "webchat", + SessionKey: "agent:main:mattermost:channel:CHAN1", + AccountId: "default", + MessageThreadId: undefined, + OriginatingChannel: "mattermost", + OriginatingTo: "channel:CHAN1", + ExplicitDeliverRoute: true, + }); + + const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + const routeCall = mocks.routeReply.mock.calls[0]?.[0] as + | { channel?: string; to?: string; threadId?: string | number } + | undefined; + expect(routeCall).toMatchObject({ + channel: "mattermost", + to: "channel:CHAN1", + }); + expect(routeCall?.threadId).toBeUndefined(); + }); + it("forces suppressTyping when routing to a different originating channel", async () => { setNoAbort(); const cfg = emptyConfig; diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 5b250b03362..b21fcabe80b 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -2,6 +2,7 @@ import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../config/config.js"; import { loadSessionStore, + parseSessionThreadInfo, resolveSessionStoreEntry, resolveStorePath, type SessionEntry, @@ -172,6 +173,12 @@ export async function dispatchReplyFromConfig(params: { const sessionStoreEntry = resolveSessionStoreLookup(ctx, cfg); const acpDispatchSessionKey = sessionStoreEntry.sessionKey ?? sessionKey; + // Restore route thread context only from the active turn or the thread-scoped session key. + // Do not read thread ids from the normalised session store here: `origin.threadId` can be + // folded back into lastThreadId/deliveryContext during store normalisation and resurrect a + // stale route after thread delivery was intentionally cleared. + const routeThreadId = + ctx.MessageThreadId ?? parseSessionThreadInfo(acpDispatchSessionKey).threadId; const inboundAudio = isInboundAudioContext(ctx); const sessionTtsAuto = normalizeTtsAutoMode(sessionStoreEntry.entry?.ttsAuto); const hookRunner = getGlobalHookRunner(); @@ -260,7 +267,7 @@ export async function dispatchReplyFromConfig(params: { to: originatingTo, sessionKey: ctx.SessionKey, accountId: ctx.AccountId, - threadId: ctx.MessageThreadId, + threadId: routeThreadId, cfg, abortSignal, mirror, @@ -289,7 +296,7 @@ export async function dispatchReplyFromConfig(params: { to: originatingTo, sessionKey: ctx.SessionKey, accountId: ctx.AccountId, - threadId: ctx.MessageThreadId, + threadId: routeThreadId, cfg, isGroup, groupId, @@ -519,7 +526,7 @@ export async function dispatchReplyFromConfig(params: { to: originatingTo, sessionKey: ctx.SessionKey, accountId: ctx.AccountId, - threadId: ctx.MessageThreadId, + threadId: routeThreadId, cfg, isGroup, groupId, @@ -571,7 +578,7 @@ export async function dispatchReplyFromConfig(params: { to: originatingTo, sessionKey: ctx.SessionKey, accountId: ctx.AccountId, - threadId: ctx.MessageThreadId, + threadId: routeThreadId, cfg, isGroup, groupId, diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 62f91097223..bfae51e63c2 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mattermostPlugin } from "../../../extensions/mattermost/src/channel.js"; import { discordOutbound } from "../../channels/plugins/outbound/discord.js"; import { imessageOutbound } from "../../channels/plugins/outbound/imessage.js"; import { signalOutbound } from "../../channels/plugins/outbound/signal.js"; @@ -24,6 +25,7 @@ const mocks = vi.hoisted(() => ({ sendMessageSlack: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })), sendMessageTelegram: vi.fn(async () => ({ messageId: "m1", chatId: "c1" })), sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1", toJid: "jid" })), + sendMessageMattermost: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })), deliverOutboundPayloads: vi.fn(), })); @@ -46,6 +48,9 @@ vi.mock("../../web/outbound.js", () => ({ sendMessageWhatsApp: mocks.sendMessageWhatsApp, sendPollWhatsApp: mocks.sendMessageWhatsApp, })); +vi.mock("../../../extensions/mattermost/src/mattermost/send.js", () => ({ + sendMessageMattermost: mocks.sendMessageMattermost, +})); vi.mock("../../infra/outbound/deliver.js", async () => { const actual = await vi.importActual( "../../infra/outbound/deliver.js", @@ -335,6 +340,33 @@ describe("routeReply", () => { ); }); + it("uses threadId as replyToId for Mattermost when replyToId is missing", async () => { + mocks.deliverOutboundPayloads.mockResolvedValue([]); + await routeReply({ + payload: { text: "hi" }, + channel: "mattermost", + to: "channel:CHAN1", + threadId: "post-root", + cfg: { + channels: { + mattermost: { + enabled: true, + botToken: "test-token", + baseUrl: "https://chat.example.com", + }, + }, + } as unknown as OpenClawConfig, + }); + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "mattermost", + to: "channel:CHAN1", + replyToId: "post-root", + threadId: "post-root", + }), + ); + }); + it("sends multiple mediaUrls (caption only on first)", async () => { mocks.sendMessageSlack.mockClear(); await routeReply({ @@ -501,4 +533,9 @@ const defaultRegistry = createTestRegistry([ }), source: "test", }, + { + pluginId: "mattermost", + plugin: mattermostPlugin, + source: "test", + }, ]); diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 8b3319698b2..a6f863d7d18 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -149,7 +149,9 @@ export async function routeReply(params: RouteReplyParams): Promise ({ upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), })); -vi.mock("../config/sessions.js", () => ({ - resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), - updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), - readSessionUpdatedAt: vi.fn(() => undefined), - recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), -})); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), + updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), + readSessionUpdatedAt: vi.fn(() => undefined), + recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), + }; +}); vi.mock("./client.js", () => ({ streamSignalEvents: (...args: unknown[]) => streamMock(...args), diff --git a/src/slack/monitor.test-helpers.ts b/src/slack/monitor.test-helpers.ts index 17b868fa972..99028f29a11 100644 --- a/src/slack/monitor.test-helpers.ts +++ b/src/slack/monitor.test-helpers.ts @@ -180,13 +180,17 @@ vi.mock("../pairing/pairing-store.js", () => ({ slackTestState.upsertPairingRequestMock(...args), })); -vi.mock("../config/sessions.js", () => ({ - resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), - updateLastRoute: (...args: unknown[]) => slackTestState.updateLastRouteMock(...args), - resolveSessionKey: vi.fn(), - readSessionUpdatedAt: vi.fn(() => undefined), - recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), -})); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), + updateLastRoute: (...args: unknown[]) => slackTestState.updateLastRouteMock(...args), + resolveSessionKey: vi.fn(), + readSessionUpdatedAt: vi.fn(() => undefined), + recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), + }; +}); vi.mock("@slack/bolt", () => { const handlers = new Map();