From 41718404a1ddcce7726fbcbae278fc46ff31f959 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:41:22 +0000 Subject: [PATCH 001/267] 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 002/267] 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 003/267] 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 004/267] 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 005/267] 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 006/267] 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 007/267] 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 008/267] 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 009/267] 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 010/267] 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 011/267] 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 012/267] 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 013/267] 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 014/267] 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 015/267] 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 016/267] 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 017/267] 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 018/267] 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 019/267] 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 020/267] 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 021/267] 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 022/267] 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 023/267] 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 024/267] 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 025/267] 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 026/267] 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 027/267] 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 028/267] 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 029/267] 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 030/267] 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 031/267] 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 032/267] 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 033/267] 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 034/267] 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 035/267] 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 036/267] 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 037/267] 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 038/267] 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("