From 0068f55dd81b32e2232f3d3d419f56d9d2722c59 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 01:56:11 +0000 Subject: [PATCH 001/113] fix(memory): fail closed for Windows qmd wrappers --- CHANGELOG.md | 1 + src/memory/qmd-manager.test.ts | 80 ++++++++++++++++++++-------------- src/memory/qmd-manager.ts | 55 +++++------------------ src/memory/qmd-process.test.ts | 75 +++++++++++++++++++++++++++++++ src/memory/qmd-process.ts | 18 +------- 5 files changed, 136 insertions(+), 93 deletions(-) create mode 100644 src/memory/qmd-process.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b5c2577f63b..0515c111f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -216,6 +216,7 @@ Docs: https://docs.openclaw.ai - Agents/embedded runner: recover canonical allowlisted tool names from malformed `toolCallId` and malformed non-blank tool-name variants before dispatch, while failing closed on ambiguous matches. (#34485) thanks @yuweuii. - Agents/failover: classify ZenMux quota-refresh `402` responses as `rate_limit` so model fallback retries continue instead of stopping on a temporary subscription window. (#43917) thanks @bwjoke. - Agents/failover: classify HTTP 422 malformed-request responses as `format` and recognize OpenRouter "requires more credits" billing errors so provider fallback triggers instead of surfacing raw errors. (#43823) thanks @jnMetaCode. +- Memory/QMD Windows: fail closed when `qmd.cmd` or `mcporter.cmd` wrappers cannot be resolved to a direct entrypoint, so memory search no longer falls back to shell execution on Windows. ## 2026.3.8 diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 48c8a4ec5d5..5a5b577dcba 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -1078,7 +1078,23 @@ describe("QmdMemoryManager", () => { it("resolves bare qmd command to a Windows-compatible spawn invocation", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const previousPath = process.env.PATH; try { + const nodeModulesDir = path.join(tmpRoot, "node_modules"); + const shimDir = path.join(nodeModulesDir, ".bin"); + const packageDir = path.join(nodeModulesDir, "qmd"); + const scriptPath = path.join(packageDir, "dist", "cli.js"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.mkdir(shimDir, { recursive: true }); + await fs.writeFile(path.join(shimDir, "qmd.cmd"), "@echo off\r\n", "utf8"); + await fs.writeFile( + path.join(packageDir, "package.json"), + JSON.stringify({ name: "qmd", version: "0.0.0", bin: { qmd: "dist/cli.js" } }), + "utf8", + ); + await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); + process.env.PATH = `${shimDir};${previousPath ?? ""}`; + const { manager } = await createManager({ mode: "status" }); await manager.sync({ reason: "manual" }); @@ -1093,19 +1109,14 @@ describe("QmdMemoryManager", () => { for (const call of qmdCalls) { const command = String(call[0]); const options = call[2] as { shell?: boolean } | undefined; - if (/(^|[\\/])qmd(?:\.cmd)?$/i.test(command)) { - // Wrapper unresolved: keep `.cmd` and use shell for PATHEXT lookup. - expect(command.toLowerCase().endsWith("qmd.cmd")).toBe(true); - expect(options?.shell).toBe(true); - } else { - // Wrapper resolved to node/exe entrypoint: shell fallback should not be used. - expect(options?.shell).not.toBe(true); - } + expect(command).not.toMatch(/(^|[\\/])qmd\.cmd$/i); + expect(options?.shell).not.toBe(true); } await manager.close(); } finally { platformSpy.mockRestore(); + process.env.PATH = previousPath; } }); @@ -1576,9 +1587,25 @@ describe("QmdMemoryManager", () => { await manager.close(); }); - it("uses mcporter.cmd on Windows when mcporter bridge is enabled", async () => { + it("resolves mcporter to a direct Windows entrypoint without enabling shell mode", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const previousPath = process.env.PATH; try { + const nodeModulesDir = path.join(tmpRoot, "node_modules"); + const shimDir = path.join(nodeModulesDir, ".bin"); + const packageDir = path.join(nodeModulesDir, "mcporter"); + const scriptPath = path.join(packageDir, "dist", "cli.js"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.mkdir(shimDir, { recursive: true }); + await fs.writeFile(path.join(shimDir, "mcporter.cmd"), "@echo off\r\n", "utf8"); + await fs.writeFile( + path.join(packageDir, "package.json"), + JSON.stringify({ name: "mcporter", version: "0.0.0", bin: { mcporter: "dist/cli.js" } }), + "utf8", + ); + await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); + process.env.PATH = `${shimDir};${previousPath ?? ""}`; + cfg = { ...cfg, memory: { @@ -1612,21 +1639,17 @@ describe("QmdMemoryManager", () => { const callCommand = mcporterCall?.[0]; expect(typeof callCommand).toBe("string"); const options = mcporterCall?.[2] as { shell?: boolean } | undefined; - if (isMcporterCommand(callCommand)) { - expect(callCommand).toBe("mcporter.cmd"); - expect(options?.shell).toBe(true); - } else { - // If wrapper entrypoint resolution succeeded, spawn may invoke node/exe directly. - expect(options?.shell).not.toBe(true); - } + expect(callCommand).not.toBe("mcporter.cmd"); + expect(options?.shell).not.toBe(true); await manager.close(); } finally { platformSpy.mockRestore(); + process.env.PATH = previousPath; } }); - it("retries mcporter search with bare command on Windows EINVAL cmd-shim failures", async () => { + it("fails closed on Windows EINVAL cmd-shim failures instead of retrying through the shell", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); const previousPath = process.env.PATH; try { @@ -1647,7 +1670,6 @@ describe("QmdMemoryManager", () => { }, } as OpenClawConfig; - let sawRetry = false; let firstCallCommand: string | null = null; spawnMock.mockImplementation((cmd: string, args: string[]) => { if (args[0] === "call" && firstCallCommand === null) { @@ -1661,12 +1683,6 @@ describe("QmdMemoryManager", () => { }); return child; } - if (args[0] === "call" && cmd === "mcporter") { - sawRetry = true; - const child = createMockChild({ autoClose: false }); - emitAndClose(child, "stdout", JSON.stringify({ results: [] })); - return child; - } const child = createMockChild({ autoClose: false }); emitAndClose(child, "stdout", "[]"); return child; @@ -1675,16 +1691,16 @@ describe("QmdMemoryManager", () => { const { manager } = await createManager(); await expect( manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }), - ).resolves.toEqual([]); + ).rejects.toThrow(/without shell execution|EINVAL/); const attemptedCmdShim = (firstCallCommand ?? "").toLowerCase().endsWith(".cmd"); if (attemptedCmdShim) { - expect(sawRetry).toBe(true); - expect(logWarnMock).toHaveBeenCalledWith( - expect.stringContaining("retrying with bare mcporter"), - ); - } else { - // When wrapper resolution upgrades to a direct node/exe entrypoint, cmd-shim retry is unnecessary. - expect(sawRetry).toBe(false); + expect( + spawnMock.mock.calls.some( + (call: unknown[]) => + call[0] === "mcporter" && + (call[2] as { shell?: boolean } | undefined)?.shell === true, + ), + ).toBe(false); } await manager.close(); } finally { diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 986d526e013..46a80156677 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -8,11 +8,7 @@ import { resolveStateDir } from "../config/paths.js"; import { writeFileWithinRoot } from "../infra/fs-safe.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { isFileMissingError, statRegularFile } from "./fs-utils.js"; -import { - isWindowsCommandShimEinval, - resolveCliSpawnInvocation, - runCliCommand, -} from "./qmd-process.js"; +import { resolveCliSpawnInvocation, runCliCommand } from "./qmd-process.js"; import { deriveQmdScopeChannel, deriveQmdScopeChatType, isQmdScopeAllowed } from "./qmd-scope.js"; import { listSessionFilesForAgent, @@ -1248,50 +1244,21 @@ export class QmdMemoryManager implements MemorySearchManager { args: string[], opts?: { timeoutMs?: number }, ): Promise<{ stdout: string; stderr: string }> { - const runWithInvocation = async (spawnInvocation: { - command: string; - argv: string[]; - shell?: boolean; - windowsHide?: boolean; - }): Promise<{ stdout: string; stderr: string }> => - await runCliCommand({ - commandSummary: `${spawnInvocation.command} ${spawnInvocation.argv.join(" ")}`, - spawnInvocation, - // Keep mcporter and direct qmd commands on the same agent-scoped XDG state. - env: this.env, - cwd: this.workspaceDir, - timeoutMs: opts?.timeoutMs, - maxOutputChars: this.maxQmdOutputChars, - }); - - const primaryInvocation = resolveCliSpawnInvocation({ + const spawnInvocation = resolveCliSpawnInvocation({ command: "mcporter", args, env: this.env, packageName: "mcporter", }); - try { - return await runWithInvocation(primaryInvocation); - } catch (err) { - if ( - !isWindowsCommandShimEinval({ - err, - command: primaryInvocation.command, - commandBase: "mcporter", - }) - ) { - throw err; - } - // Some Windows npm cmd shims can still throw EINVAL on spawn; retry through - // shell command resolution so PATH/PATHEXT can select a runnable entrypoint. - log.warn("mcporter.cmd spawn returned EINVAL on Windows; retrying with bare mcporter"); - return await runWithInvocation({ - command: "mcporter", - argv: args, - shell: true, - windowsHide: true, - }); - } + return await runCliCommand({ + commandSummary: `${spawnInvocation.command} ${spawnInvocation.argv.join(" ")}`, + spawnInvocation, + // Keep mcporter and direct qmd commands on the same agent-scoped XDG state. + env: this.env, + cwd: this.workspaceDir, + timeoutMs: opts?.timeoutMs, + maxOutputChars: this.maxQmdOutputChars, + }); } private async runQmdSearchViaMcporter(params: { diff --git a/src/memory/qmd-process.test.ts b/src/memory/qmd-process.test.ts new file mode 100644 index 00000000000..84237c43cab --- /dev/null +++ b/src/memory/qmd-process.test.ts @@ -0,0 +1,75 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveCliSpawnInvocation } from "./qmd-process.js"; + +describe("resolveCliSpawnInvocation", () => { + let tempDir = ""; + let platformSpy: { mockRestore(): void } | null = null; + const originalPath = process.env.PATH; + const originalPathExt = process.env.PATHEXT; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-win-spawn-")); + platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + }); + + afterEach(async () => { + platformSpy?.mockRestore(); + process.env.PATH = originalPath; + process.env.PATHEXT = originalPathExt; + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + tempDir = ""; + } + }); + + it("unwraps npm cmd shims to a direct node entrypoint", async () => { + const binDir = path.join(tempDir, "node_modules", ".bin"); + const packageDir = path.join(tempDir, "node_modules", "qmd"); + const scriptPath = path.join(packageDir, "dist", "cli.js"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.mkdir(binDir, { recursive: true }); + await fs.writeFile(path.join(binDir, "qmd.cmd"), "@echo off\r\n", "utf8"); + await fs.writeFile( + path.join(packageDir, "package.json"), + JSON.stringify({ name: "qmd", version: "0.0.0", bin: { qmd: "dist/cli.js" } }), + "utf8", + ); + await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); + + process.env.PATH = `${binDir};${originalPath ?? ""}`; + process.env.PATHEXT = ".CMD;.EXE"; + + const invocation = resolveCliSpawnInvocation({ + command: "qmd", + args: ["query", "hello"], + env: process.env, + packageName: "qmd", + }); + + expect(invocation.command).toBe(process.execPath); + expect(invocation.argv).toEqual([scriptPath, "query", "hello"]); + expect(invocation.shell).not.toBe(true); + expect(invocation.windowsHide).toBe(true); + }); + + it("fails closed when a Windows cmd shim cannot be resolved without shell execution", async () => { + const binDir = path.join(tempDir, "bad-bin"); + await fs.mkdir(binDir, { recursive: true }); + await fs.writeFile(path.join(binDir, "qmd.cmd"), "@echo off\r\nREM no entrypoint\r\n", "utf8"); + + process.env.PATH = `${binDir};${originalPath ?? ""}`; + process.env.PATHEXT = ".CMD;.EXE"; + + expect(() => + resolveCliSpawnInvocation({ + command: "qmd", + args: ["query", "hello"], + env: process.env, + packageName: "qmd", + }), + ).toThrow(/without shell execution/); + }); +}); diff --git a/src/memory/qmd-process.ts b/src/memory/qmd-process.ts index 7c0b1a6c3ba..bb0eea803ba 100644 --- a/src/memory/qmd-process.ts +++ b/src/memory/qmd-process.ts @@ -43,27 +43,11 @@ export function resolveCliSpawnInvocation(params: { env: params.env, execPath: process.execPath, packageName: params.packageName, - allowShellFallback: true, + allowShellFallback: false, }); return materializeWindowsSpawnProgram(program, params.args); } -export function isWindowsCommandShimEinval(params: { - err: unknown; - command: string; - commandBase: string; -}): boolean { - if (process.platform !== "win32") { - return false; - } - const errno = params.err as NodeJS.ErrnoException | undefined; - if (errno?.code !== "EINVAL") { - return false; - } - const escapedBase = params.commandBase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return new RegExp(`(^|[\\\\/])${escapedBase}\\.cmd$`, "i").test(params.command); -} - export async function runCliCommand(params: { commandSummary: string; spawnInvocation: CliSpawnInvocation; From f906bf58db2622bf90b38529e26b17e5ad924bd6 Mon Sep 17 00:00:00 2001 From: Bruce MacDonald Date: Wed, 11 Mar 2026 14:00:22 -0700 Subject: [PATCH 002/113] docs(ollama): update onboarding flow Co-Authored-By: Jeffrey Morgan (cherry picked from commit e8ca2ff4e522f2d971801a537b3c4fdfecde0711) --- docs/cli/index.md | 6 ++-- docs/providers/index.md | 2 +- docs/providers/ollama.md | 52 ++++++++++++++++++++++++++--- docs/start/wizard-cli-automation.md | 11 ++++++ docs/start/wizard-cli-reference.md | 7 +++- 5 files changed, 69 insertions(+), 9 deletions(-) diff --git a/docs/cli/index.md b/docs/cli/index.md index cbcd5bff0b5..2796e7927d2 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -337,7 +337,7 @@ Options: - `--non-interactive` - `--mode ` - `--flow ` (manual is an alias for advanced) -- `--auth-choice ` +- `--auth-choice ` - `--token-provider ` (non-interactive; used with `--auth-choice token`) - `--token ` (non-interactive; used with `--auth-choice token`) - `--token-profile-id ` (non-interactive; default: `:manual`) @@ -355,8 +355,8 @@ Options: - `--minimax-api-key ` - `--opencode-zen-api-key ` - `--opencode-go-api-key ` -- `--custom-base-url ` (non-interactive; used with `--auth-choice custom-api-key`) -- `--custom-model-id ` (non-interactive; used with `--auth-choice custom-api-key`) +- `--custom-base-url ` (non-interactive; used with `--auth-choice custom-api-key` or `--auth-choice ollama`) +- `--custom-model-id ` (non-interactive; used with `--auth-choice custom-api-key` or `--auth-choice ollama`) - `--custom-api-key ` (non-interactive; optional; used with `--auth-choice custom-api-key`; falls back to `CUSTOM_API_KEY` when omitted) - `--custom-provider-id ` (non-interactive; optional custom provider id) - `--custom-compatibility ` (non-interactive; optional; default `openai`) diff --git a/docs/providers/index.md b/docs/providers/index.md index 50e45c6559b..f68cd0e0b53 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -37,7 +37,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi - [Mistral](/providers/mistral) - [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot) - [NVIDIA](/providers/nvidia) -- [Ollama (local models)](/providers/ollama) +- [Ollama (cloud + local models)](/providers/ollama) - [OpenAI (API + Codex)](/providers/openai) - [OpenCode (Zen + Go)](/providers/opencode) - [OpenRouter](/providers/openrouter) diff --git a/docs/providers/ollama.md b/docs/providers/ollama.md index abc41361ed0..c4604a8e350 100644 --- a/docs/providers/ollama.md +++ b/docs/providers/ollama.md @@ -1,7 +1,7 @@ --- -summary: "Run OpenClaw with Ollama (local LLM runtime)" +summary: "Run OpenClaw with Ollama (cloud and local models)" read_when: - - You want to run OpenClaw with local models via Ollama + - You want to run OpenClaw with cloud or local models via Ollama - You need Ollama setup and configuration guidance title: "Ollama" --- @@ -16,6 +16,42 @@ Ollama is a local LLM runtime that makes it easy to run open-source models on yo ## Quick start +### Onboarding wizard (recommended) + +The fastest way to set up Ollama is through the onboarding wizard: + +```bash +openclaw onboard +``` + +Select **Ollama** from the provider list. The wizard will: + +1. Ask for the Ollama base URL where your instance can be reached (default `http://127.0.0.1:11434`). +2. Let you choose **Cloud + Local** (cloud models and local models) or **Local** (local models only). +3. Open a browser sign-in flow if you choose **Cloud + Local** and are not signed in to ollama.com. +4. Discover available models and suggest defaults. +5. Auto-pull the selected model if it is not available locally. + +Non-interactive mode is also supported: + +```bash +openclaw onboard --non-interactive \ + --auth-choice ollama \ + --accept-risk +``` + +Optionally specify a custom base URL or model: + +```bash +openclaw onboard --non-interactive \ + --auth-choice ollama \ + --custom-base-url "http://ollama-host:11434" \ + --custom-model-id "qwen3.5:27b" \ + --accept-risk +``` + +### Manual setup + 1. Install Ollama: [https://ollama.com/download](https://ollama.com/download) 2. Pull a local model if you want local inference: @@ -28,7 +64,7 @@ ollama pull gpt-oss:20b ollama pull llama3.3 ``` -3. If you want Ollama Cloud models too, sign in: +3. If you want cloud models too, sign in: ```bash ollama signin @@ -41,7 +77,7 @@ openclaw onboard ``` - `Local`: local models only -- `Cloud + Local`: local models plus Ollama Cloud models +- `Cloud + Local`: local models plus cloud models - Cloud models such as `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, and `glm-5:cloud` do **not** require a local `ollama pull` OpenClaw currently suggests: @@ -191,6 +227,14 @@ Once configured, all your Ollama models are available: } ``` +## Cloud models + +Cloud models let you run cloud-hosted models (for example `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, `glm-5:cloud`) alongside your local models. + +To use cloud models, select **Cloud + Local** mode during onboarding. The wizard checks whether you are signed in and opens a browser sign-in flow when needed. If authentication cannot be verified, the wizard falls back to local model defaults. + +You can also sign in directly at [ollama.com/signin](https://ollama.com/signin). + ## Advanced ### Reasoning models diff --git a/docs/start/wizard-cli-automation.md b/docs/start/wizard-cli-automation.md index 8547f60ac19..cd00787c5c7 100644 --- a/docs/start/wizard-cli-automation.md +++ b/docs/start/wizard-cli-automation.md @@ -134,6 +134,17 @@ openclaw onboard --non-interactive \ ``` Swap to `--auth-choice opencode-go --opencode-go-api-key "$OPENCODE_API_KEY"` for the Go catalog. + + ```bash + openclaw onboard --non-interactive \ + --mode local \ + --auth-choice ollama \ + --custom-model-id "qwen3.5:27b" \ + --accept-risk \ + --gateway-port 18789 \ + --gateway-bind loopback + ``` + ```bash openclaw onboard --non-interactive \ diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index 20f99accd8d..5d3e6be6e72 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -16,7 +16,7 @@ For the short guide, see [Onboarding Wizard (CLI)](/start/wizard). Local mode (default) walks you through: -- Model and auth setup (OpenAI Code subscription OAuth, Anthropic API key or setup token, plus MiniMax, GLM, Moonshot, and AI Gateway options) +- Model and auth setup (OpenAI Code subscription OAuth, Anthropic API key or setup token, plus MiniMax, GLM, Ollama, Moonshot, and AI Gateway options) - Workspace location and bootstrap files - Gateway settings (port, bind, auth, tailscale) - Channels and providers (Telegram, WhatsApp, Discord, Google Chat, Mattermost plugin, Signal) @@ -178,6 +178,11 @@ What you set: Prompts for `SYNTHETIC_API_KEY`. More detail: [Synthetic](/providers/synthetic). + + Prompts for base URL (default `http://127.0.0.1:11434`), then offers Cloud + Local or Local mode. + Discovers available models and suggests defaults. + More detail: [Ollama](/providers/ollama). + Moonshot (Kimi K2) and Kimi Coding configs are auto-written. More detail: [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot). From d6d01f853f9db9a08c1581be962d039494127e0e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 01:56:02 +0000 Subject: [PATCH 003/113] fix: align Ollama onboarding docs before landing (#43473) (thanks @BruceMacD) (cherry picked from commit 19fa274343a102ca85c7679ec28c5a3503a99f55) --- CHANGELOG.md | 1 + docs/cli/onboard.md | 12 ++++++++++++ docs/reference/wizard.md | 14 ++++++++++++++ docs/start/wizard.md | 6 ++++-- 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0515c111f7e..e70bf45d41d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai - Security/agent tools: mark `nodes` as explicitly owner-only and document/test that `canvas` remains a shared trusted-operator surface unless a real boundary bypass exists. - Security/exec approvals: fail closed for Ruby approval flows that use `-r`, `--require`, or `-I` so approval-backed commands no longer bind only the main script while extra local code-loading flags remain outside the reviewed file snapshot. - Security/device pairing: cap issued and verified device-token scopes to each paired device's approved scope baseline so stale or overbroad tokens cannot exceed approved access. (`GHSA-2pwv-x786-56f8`)(#43686) Thanks @tdjackey and @vincentkoc. +- Docs/onboarding: align the legacy wizard reference and `openclaw onboard` command docs with the Ollama onboarding flow so all onboarding reference paths now document `--auth-choice ollama`, Cloud + Local mode, and non-interactive usage. (#43473) Thanks @BruceMacD. - Models/secrets: enforce source-managed SecretRef markers in generated `models.json` so runtime-resolved provider secrets are not persisted when runtime projection is skipped. (#43759) Thanks @joshavant. - Security/WebSocket preauth: shorten unauthenticated handshake retention and reject oversized pre-auth frames before application-layer parsing to reduce pre-pairing exposure on unsupported public deployments. (`GHSA-jv4g-m82p-2j93`)(#44089) (`GHSA-xwx2-ppv2-wx98`)(#44089) Thanks @ez-lbz and @vincentkoc. - Security/proxy attachments: restore the shared media-store size cap for persisted browser proxy files so oversized payloads are rejected instead of overriding the intended 5 MB limit. (`GHSA-6rph-mmhp-h7h9`)(#43684) Thanks @tdjackey and @vincentkoc. diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index 36629a3bb8d..ae62d10361f 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -43,6 +43,18 @@ openclaw onboard --non-interactive \ `--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`. +Non-interactive Ollama: + +```bash +openclaw onboard --non-interactive \ + --auth-choice ollama \ + --custom-base-url "http://ollama-host:11434" \ + --custom-model-id "qwen3.5:27b" \ + --accept-risk +``` + +`--custom-base-url` defaults to `http://127.0.0.1:11434`. `--custom-model-id` is optional; if omitted, onboarding uses Ollama's suggested defaults. Cloud model IDs such as `kimi-k2.5:cloud` also work here. + Store provider keys as refs instead of plaintext: ```bash diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md index d58ab96c83a..60e88fe4226 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -39,6 +39,8 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard). - **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then stores it in auth profiles. - **xAI (Grok) API key**: prompts for `XAI_API_KEY` and configures xAI as a model provider. - **OpenCode**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth) and lets you pick the Zen or Go catalog. + - **Ollama**: prompts for the Ollama base URL, offers **Cloud + Local** or **Local** mode, discovers available models, and auto-pulls the selected local model when needed. + - More detail: [Ollama](/providers/ollama) - **API key**: stores the key for you. - **Vercel AI Gateway (multi-model proxy)**: prompts for `AI_GATEWAY_API_KEY`. - More detail: [Vercel AI Gateway](/providers/vercel-ai-gateway) @@ -239,6 +241,18 @@ openclaw onboard --non-interactive \ ``` Swap to `--auth-choice opencode-go --opencode-go-api-key "$OPENCODE_API_KEY"` for the Go catalog. + + ```bash + openclaw onboard --non-interactive \ + --mode local \ + --auth-choice ollama \ + --custom-model-id "qwen3.5:27b" \ + --accept-risk \ + --gateway-port 18789 \ + --gateway-bind loopback + ``` + Add `--custom-base-url "http://ollama-host:11434"` to target a remote Ollama instance. + ### Add agent (non-interactive) diff --git a/docs/start/wizard.md b/docs/start/wizard.md index ef1fc52b31a..05c09ed53fd 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -111,8 +111,10 @@ Notes: ## Full reference -For detailed step-by-step breakdowns, non-interactive scripting, Signal setup, -RPC API, and a full list of config fields the wizard writes, see the +For detailed step-by-step breakdowns and config outputs, see +[CLI Onboarding Reference](/start/wizard-cli-reference). +For non-interactive examples, see [CLI Automation](/start/wizard-cli-automation). +For the deeper technical reference, including RPC details, see [Wizard Reference](/reference/wizard). ## Related docs From 4fb3b88e57d69fc0ccd8391ad2f4a6bf0d994778 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 02:11:50 +0000 Subject: [PATCH 004/113] docs: reorder latest release changelog --- CHANGELOG.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e70bf45d41d..11ba9dcbb7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,26 +8,26 @@ Docs: https://docs.openclaw.ai ### Changes -- Agents/subagents: add `sessions_yield` so orchestrators can end the current turn immediately, skip queued tool work, and carry a hidden follow-up payload into the next session turn. (#36537) thanks @jriff -- Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi - Control UI/dashboard-v2: refresh the gateway dashboard with modular overview, chat, config, agent, and session views, plus a command palette, mobile bottom tabs, and richer chat tools like slash commands, search, export, and pinned messages. (#41503) Thanks @BunsDev. -- Models/plugins: move Ollama, vLLM, and SGLang onto the provider-plugin architecture, with provider-owned onboarding, discovery, model-picker setup, and post-selection hooks so core provider wiring is more modular. - OpenAI/GPT-5.4 fast mode: add configurable session-level fast toggles across `/fast`, TUI, Control UI, and ACP, with per-model config defaults and OpenAI/Codex request shaping. - Anthropic/Claude fast mode: map the shared `/fast` toggle and `params.fastMode` to direct Anthropic API-key `service_tier` requests, with live verification for both Anthropic and OpenAI fast-mode tiers. +- Models/plugins: move Ollama, vLLM, and SGLang onto the provider-plugin architecture, with provider-owned onboarding, discovery, model-picker setup, and post-selection hooks so core provider wiring is more modular. +- Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi +- Agents/subagents: add `sessions_yield` so orchestrators can end the current turn immediately, skip queued tool work, and carry a hidden follow-up payload into the next session turn. (#36537) thanks @jriff ### Fixes -- Models/OpenAI Codex Spark: keep `gpt-5.3-codex-spark` working on the `openai-codex/*` path via resolver fallbacks and clearer Codex-only handling, while continuing to suppress the stale direct `openai/*` Spark row that OpenAI rejects live. -- Ollama/Kimi Cloud: apply the Moonshot Kimi payload compatibility wrapper to Ollama-hosted Kimi models like `kimi-k2.5:cloud`, so tool routing no longer breaks when thinking is enabled. (#41519) Thanks @vincentkoc. -- Models/Kimi Coding: send the built-in `User-Agent: claude-code/0.1.0` header by default for `kimi-coding` while still allowing explicit provider headers to override it, so Kimi Code subscription auth can work without a local header-injection proxy. (#30099) Thanks @Amineelfarssi and @vincentkoc. - Security/device pairing: switch `/pair` and `openclaw qr` setup codes to short-lived bootstrap tokens so the next release no longer embeds shared gateway credentials in chat or QR pairing payloads. Thanks @lintsinghua. - Security/plugins: disable implicit workspace plugin auto-load so cloned repositories cannot execute workspace plugin code without an explicit trust decision. (`GHSA-99qw-6mr3-36qr`)(#44174) Thanks @lintsinghua and @vincentkoc. -- Moonshot CN API: respect explicit `baseUrl` (api.moonshot.cn) in implicit provider resolution so platform.moonshot.cn API keys authenticate correctly instead of returning HTTP 401. (#33637) Thanks @chengzhichao-xydt. -- Kimi Coding/provider config: respect explicit `models.providers["kimi-coding"].baseUrl` when resolving the implicit provider so custom Kimi Coding endpoints no longer get overwritten by the built-in default. (#36353) Thanks @2233admin. - Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz. - TUI/chat log: reuse the active assistant message component for the same streaming run so `openclaw tui` no longer renders duplicate assistant replies. (#35364) Thanks @lisitan. - Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb. - Cron/proactive delivery: keep isolated direct cron sends out of the write-ahead resend queue so transient-send retries do not replay duplicate proactive messages after restart. (#40646) Thanks @openperf and @vincentkoc. +- Models/Kimi Coding: send the built-in `User-Agent: claude-code/0.1.0` header by default for `kimi-coding` while still allowing explicit provider headers to override it, so Kimi Code subscription auth can work without a local header-injection proxy. (#30099) Thanks @Amineelfarssi and @vincentkoc. +- Models/OpenAI Codex Spark: keep `gpt-5.3-codex-spark` working on the `openai-codex/*` path via resolver fallbacks and clearer Codex-only handling, while continuing to suppress the stale direct `openai/*` Spark row that OpenAI rejects live. +- Ollama/Kimi Cloud: apply the Moonshot Kimi payload compatibility wrapper to Ollama-hosted Kimi models like `kimi-k2.5:cloud`, so tool routing no longer breaks when thinking is enabled. (#41519) Thanks @vincentkoc. +- Moonshot CN API: respect explicit `baseUrl` (api.moonshot.cn) in implicit provider resolution so platform.moonshot.cn API keys authenticate correctly instead of returning HTTP 401. (#33637) Thanks @chengzhichao-xydt. +- Kimi Coding/provider config: respect explicit `models.providers["kimi-coding"].baseUrl` when resolving the implicit provider so custom Kimi Coding endpoints no longer get overwritten by the built-in default. (#36353) Thanks @2233admin. - Gateway/main-session routing: keep TUI and other `mode:UI` main-session sends on the internal surface when `deliver` is enabled, so replies no longer inherit the session's persisted Telegram/WhatsApp route. (#43918) Thanks @obviyus. - BlueBubbles/self-chat echo dedupe: drop reflected duplicate webhook copies only when a matching `fromMe` event was just seen for the same chat, body, and timestamp, preventing self-chat loops without broad webhook suppression. Related to #32166. (#38442) Thanks @vincentkoc. - iMessage/self-chat echo dedupe: drop reflected duplicate copies only when a matching `is_from_me` event was just seen for the same chat, text, and `created_at`, preventing self-chat loops without broad text-only suppression. Related to #32166. (#38440) Thanks @vincentkoc. From 296a106f4991a139220c9514fa7c35364b16915b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 02:16:42 +0000 Subject: [PATCH 005/113] test: stabilize hooks loader log assertion on Windows --- src/hooks/loader.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/hooks/loader.test.ts b/src/hooks/loader.test.ts index c1d71106d54..b9b4fbfc121 100644 --- a/src/hooks/loader.test.ts +++ b/src/hooks/loader.test.ts @@ -5,6 +5,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import type { OpenClawConfig } from "../config/config.js"; import { setLoggerOverride } from "../logging/logger.js"; import { loggingState } from "../logging/state.js"; +import { stripAnsi } from "../terminal/ansi.js"; import { captureEnv } from "../test-utils/env.js"; import { clearInternalHooks, @@ -361,9 +362,11 @@ describe("loader", () => { await expectNoCommandHookRegistration(cfg); - const messages = (error as ReturnType).mock.calls - .map((call) => String(call[0] ?? "")) - .join("\n"); + const messages = stripAnsi( + (error as ReturnType).mock.calls + .map((call) => String(call[0] ?? "")) + .join("\n"), + ); expect(messages).toContain("forged-log"); expect(messages).not.toContain("\u001b[31m"); expect(messages).not.toContain("\nforged-log"); From c8439f65870539e4b0c14a885723d8fdc1f9787e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 02:16:46 +0000 Subject: [PATCH 006/113] fix: import oauth types from the oauth entrypoint --- src/agents/auth-profiles/oauth.ts | 8 ++++++-- src/commands/openai-codex-oauth.ts | 3 +-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 072b3a77246..edc1ddfb24e 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -1,5 +1,9 @@ -import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai"; -import { getOAuthApiKey, getOAuthProviders } from "@mariozechner/pi-ai/oauth"; +import { + getOAuthApiKey, + getOAuthProviders, + type OAuthCredentials, + type OAuthProvider, +} from "@mariozechner/pi-ai/oauth"; import { loadConfig, type OpenClawConfig } from "../../config/config.js"; import { coerceSecretRef } from "../../config/types.secrets.js"; import { withFileLock } from "../../infra/file-lock.js"; diff --git a/src/commands/openai-codex-oauth.ts b/src/commands/openai-codex-oauth.ts index 1f6a8f9cde8..a868217750b 100644 --- a/src/commands/openai-codex-oauth.ts +++ b/src/commands/openai-codex-oauth.ts @@ -1,5 +1,4 @@ -import type { OAuthCredentials } from "@mariozechner/pi-ai"; -import { loginOpenAICodex } from "@mariozechner/pi-ai/oauth"; +import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai/oauth"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { createVpsAwareOAuthHandlers } from "./oauth-flow.js"; From 23c7fc745f0c8713d9a552df8371dc811f923d7f Mon Sep 17 00:00:00 2001 From: Dinakar Sarbada Date: Thu, 12 Mar 2026 19:34:55 -0700 Subject: [PATCH 007/113] refactor(agents): replace console.warn with SubsystemLogger in compaction-safeguard.ts (#9974) Merged via squash. Prepared head SHA: 35dcc5ba354ad7f058d796846bda9d1f8a416e04 Co-authored-by: dinakars777 <250428393+dinakars777@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + src/agents/pi-extensions/compaction-safeguard.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11ba9dcbb7b..8f752a13cef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ Docs: https://docs.openclaw.ai - Gateway/hooks: bucket hook auth failures by forwarded client IP behind trusted proxies and warn when `hooks.allowedAgentIds` leaves hook routing unrestricted. - Agents/compaction: skip the post-compaction `cache-ttl` marker write when a compaction completed in the same attempt, preventing the next turn from immediately triggering a second tiny compaction. (#28548) thanks @MoerAI. - Native chat/macOS: add `/new`, `/reset`, and `/clear` reset triggers, keep shared main-session aliases aligned, and ignore stale model-selection completions so native chat state stays in sync across reset and fast model changes. (#10898) Thanks @Nachx639. +- Agents/compaction safeguard: route missing-model and missing-API-key cancellation warnings through the shared subsystem logger so they land in structured and file logs. (#9974) Thanks @dinakars777. ## 2026.3.11 diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index 7eb2cc29352..6012aed604d 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -726,7 +726,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { // Use a WeakSet to track which session managers have already logged the warning. if (!ctx.model && !runtime?.model && !missedModelWarningSessions.has(ctx.sessionManager)) { missedModelWarningSessions.add(ctx.sessionManager); - console.warn( + log.warn( "[compaction-safeguard] Both ctx.model and runtime.model are undefined. " + "Compaction summarization will not run. This indicates extensionRunner.initialize() " + "was not called and model was not passed through runtime registry.", @@ -737,7 +737,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { const apiKey = await ctx.modelRegistry.getApiKey(model); if (!apiKey) { - console.warn( + log.warn( "Compaction safeguard: no API key available; cancelling compaction to preserve history.", ); return { cancel: true }; From b858d6c3a91ee94905131a2d41b7ee17608ebd00 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 02:40:25 +0000 Subject: [PATCH 008/113] fix: clarify windows onboarding gateway health --- CHANGELOG.md | 1 + docs/cli/onboard.md | 7 +++ docs/platforms/windows.md | 27 ++++++++++++ .../onboard-non-interactive.gateway.test.ts | 43 ++++++++++++++++++- src/commands/onboard-non-interactive/local.ts | 24 ++++++++++- 5 files changed, 100 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f752a13cef..1bccd809750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,7 @@ Docs: https://docs.openclaw.ai - Windows/install: stop auto-installing `node-llama-cpp` during normal npm CLI installs so `openclaw@latest` no longer fails on Windows while building optional local-embedding dependencies. - Windows/update: mirror the native installer environment during global npm updates, including portable Git fallback and Windows-safe npm shell settings, so `openclaw update` works again on native Windows installs. - Gateway/status: expose `runtimeVersion` in gateway status output so install/update smoke tests can verify the running version before and after updates. +- Windows/onboarding: explain when non-interactive local onboarding is waiting for an already-running gateway, and surface native Scheduled Task admin requirements more clearly instead of failing with an opaque gateway timeout. - Agents/text sanitization: strip leaked model control tokens (`<|...|>` and full-width `<|...|>` variants) from user-facing assistant text, preventing GLM-5 and DeepSeek internal delimiters from reaching end users. (#42173) Thanks @imwyvern. - iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky. - Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark. diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index ae62d10361f..6eed344eec1 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -95,6 +95,13 @@ openclaw onboard --non-interactive \ --accept-risk ``` +Non-interactive local gateway health: + +- Unless you pass `--skip-health`, onboarding waits for a reachable local gateway before it exits successfully. +- `--install-daemon` starts the managed gateway install path first. Without it, you must already have a local gateway running, for example `openclaw gateway run`. +- If you only want config/workspace/bootstrap writes in automation, use `--skip-health`. +- On native Windows, `--install-daemon` currently uses Scheduled Tasks and may require running PowerShell as Administrator. + Interactive onboarding behavior with reference mode: - Choose **Use secret reference** when prompted. diff --git a/docs/platforms/windows.md b/docs/platforms/windows.md index 3ab668ea01e..e6c46368f08 100644 --- a/docs/platforms/windows.md +++ b/docs/platforms/windows.md @@ -22,6 +22,33 @@ Native Windows companion apps are planned. - [Install & updates](/install/updating) - Official WSL2 guide (Microsoft): [https://learn.microsoft.com/windows/wsl/install](https://learn.microsoft.com/windows/wsl/install) +## Native Windows status + +Native Windows CLI flows are improving, but WSL2 is still the recommended path. + +What works well on native Windows today: + +- website installer via `install.ps1` +- local CLI use such as `openclaw --version`, `openclaw doctor`, and `openclaw plugins list --json` +- embedded local-agent/provider smoke such as: + +```powershell +openclaw agent --local --agent main --thinking low -m "Reply with exactly WINDOWS-HATCH-OK." +``` + +Current caveats: + +- `openclaw onboard --non-interactive` still expects a reachable local gateway unless you pass `--skip-health` +- `openclaw onboard --non-interactive --install-daemon` and `openclaw gateway install` currently use Windows Scheduled Tasks +- on some native Windows setups, Scheduled Task install may require running PowerShell as Administrator + +If you want the native CLI only, without gateway service install, use one of these: + +```powershell +openclaw onboard --non-interactive --skip-health +openclaw gateway run +``` + ## Gateway - [Gateway runbook](/gateway) diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index c5d29a12177..e7ab668ea30 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { makeTempWorkspace } from "../test-helpers/workspace.js"; import { captureEnv } from "../test-utils/env.js"; import { createThrowingRuntime, readJsonFile } from "./onboard-non-interactive.test-helpers.js"; @@ -13,6 +13,12 @@ const gatewayClientCalls: Array<{ onClose?: (code: number, reason: string) => void; }> = []; const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {}); +let waitForGatewayReachableMock: + | ((params: { url: string; token?: string; password?: string }) => Promise<{ + ok: boolean; + detail?: string; + }>) + | undefined; vi.mock("../gateway/client.js", () => ({ GatewayClient: class { @@ -46,6 +52,10 @@ vi.mock("./onboard-helpers.js", async (importOriginal) => { return { ...actual, ensureWorkspaceAndSessions: ensureWorkspaceAndSessionsMock, + waitForGatewayReachable: (...args: Parameters) => + waitForGatewayReachableMock + ? waitForGatewayReachableMock(args[0]) + : actual.waitForGatewayReachable(...args), }; }); @@ -116,6 +126,10 @@ describe("onboard (non-interactive): gateway and remote auth", () => { envSnapshot.restore(); }); + afterEach(() => { + waitForGatewayReachableMock = undefined; + }); + it("writes gateway token auth into config", async () => { await withStateDir("state-noninteractive-", async (stateDir) => { const token = "tok_test_123"; @@ -302,6 +316,33 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }); }, 60_000); + it("explains local health failure when no daemon was requested", async () => { + await withStateDir("state-local-health-hint-", async (stateDir) => { + waitForGatewayReachableMock = vi.fn(async () => ({ + ok: false, + detail: "socket closed: 1006 abnormal closure", + })); + + await expect( + runNonInteractiveOnboarding( + { + nonInteractive: true, + mode: "local", + workspace: path.join(stateDir, "openclaw"), + authChoice: "skip", + skipSkills: true, + skipHealth: false, + installDaemon: false, + gatewayBind: "loopback", + }, + runtime, + ), + ).rejects.toThrow( + /only waits for an already-running gateway unless you pass --install-daemon[\s\S]*--skip-health/, + ); + }); + }, 60_000); + it("auto-generates token auth when binding LAN and persists the token", async () => { if (process.platform === "win32") { // Windows runner occasionally drops the temp config write in this flow; skip to keep CI green. diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index 4e0482ae2c8..d6292c015e8 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -104,11 +104,33 @@ export async function runNonInteractiveOnboardingLocal(params: { customBindHost: nextConfig.gateway?.customBindHost, basePath: undefined, }); - await waitForGatewayReachable({ + const probe = await waitForGatewayReachable({ url: links.wsUrl, token: gatewayResult.gatewayToken, deadlineMs: 15_000, }); + if (!probe.ok) { + const message = [ + `Gateway did not become reachable at ${links.wsUrl}.`, + probe.detail ? `Last probe: ${probe.detail}` : undefined, + !opts.installDaemon + ? [ + "Non-interactive local onboarding only waits for an already-running gateway unless you pass --install-daemon.", + `Fix: start \`${formatCliCommand("openclaw gateway run")}\`, re-run with \`--install-daemon\`, or use \`--skip-health\`.`, + process.platform === "win32" + ? "Native Windows managed gateway install currently uses Scheduled Tasks and may require running PowerShell as Administrator." + : undefined, + ] + .filter(Boolean) + .join("\n") + : undefined, + ] + .filter(Boolean) + .join("\n"); + runtime.error(message); + runtime.exit(1); + return; + } await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); } From 7dc447f79f83908c06d9220723881e2047d1278c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 02:51:32 +0000 Subject: [PATCH 009/113] fix(gateway): strip unbound scopes for shared-auth connects --- src/gateway/server.auth.default-token.suite.ts | 5 +++-- src/gateway/server/ws-connection/message-handler.ts | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/gateway/server.auth.default-token.suite.ts b/src/gateway/server.auth.default-token.suite.ts index 532ec88b46a..4d090b78cb3 100644 --- a/src/gateway/server.auth.default-token.suite.ts +++ b/src/gateway/server.auth.default-token.suite.ts @@ -157,10 +157,11 @@ export function registerDefaultAuthTokenSuite(): void { expectStatusError?: string; }> = [ { - name: "operator + valid shared token => connected with preserved scopes", + name: "operator + valid shared token => connected with cleared scopes", opts: { role: "operator", token, device: null }, expectConnectOk: true, - expectStatusOk: true, + expectStatusOk: false, + expectStatusError: "missing scope", }, { name: "node + valid shared token => rejected without device", diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index d327cd683dc..e226ebfc911 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -526,7 +526,10 @@ export function attachGatewayWsMessageHandler(params: { hasSharedAuth, isLocalClient, }); - if (!device && decision.kind !== "allow") { + // Shared token/password auth can bypass pairing for trusted operators, but + // device-less backend clients must not self-declare scopes. Control UI + // keeps its explicitly allowed device-less scopes on the allow path. + if (!device && (!isControlUi || decision.kind !== "allow")) { clearUnboundScopes(); } if (decision.kind === "allow") { From 6b14e6b55b210ae38cefc0e8e72a62c47e8e2908 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 02:51:35 +0000 Subject: [PATCH 010/113] test(commands): align slash-command config persistence coverage --- src/auto-reply/reply/commands.test-harness.ts | 2 +- src/auto-reply/reply/commands.test.ts | 284 ++++++++++-------- 2 files changed, 161 insertions(+), 125 deletions(-) diff --git a/src/auto-reply/reply/commands.test-harness.ts b/src/auto-reply/reply/commands.test-harness.ts index 84ef0c0f84d..806e36895c8 100644 --- a/src/auto-reply/reply/commands.test-harness.ts +++ b/src/auto-reply/reply/commands.test-harness.ts @@ -26,7 +26,7 @@ export function buildCommandTestParams( ctx, cfg, isGroup: false, - triggerBodyNormalized: commandBody.trim().toLowerCase(), + triggerBodyNormalized: commandBody.trim(), commandAuthorized: true, }); diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 8f48029fd18..f6d2d88f5ba 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -133,6 +133,31 @@ afterAll(async () => { await fs.rm(testWorkspaceDir, { recursive: true, force: true }); }); +async function withTempConfigPath( + initialConfig: Record, + run: (configPath: string) => Promise, +): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-commands-config-")); + const configPath = path.join(dir, "openclaw.json"); + const previous = process.env.OPENCLAW_CONFIG_PATH; + process.env.OPENCLAW_CONFIG_PATH = configPath; + await fs.writeFile(configPath, JSON.stringify(initialConfig, null, 2), "utf-8"); + try { + return await run(configPath); + } finally { + if (previous === undefined) { + delete process.env.OPENCLAW_CONFIG_PATH; + } else { + process.env.OPENCLAW_CONFIG_PATH = previous; + } + await fs.rm(dir, { recursive: true, force: true }); + } +} + +async function readJsonFile(filePath: string): Promise { + return JSON.parse(await fs.readFile(filePath, "utf-8")) as T; +} + function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Partial) { return buildCommandTestParams(commandBody, cfg, ctxOverrides, { workspaceDir: testWorkspaceDir }); } @@ -702,13 +727,13 @@ describe("handleCommands /config owner gating", () => { } as OpenClawConfig; readConfigFileSnapshotMock.mockResolvedValueOnce({ valid: true, - parsed: { messages: { ackreaction: ":)" } }, + parsed: { messages: { ackReaction: ":)" } }, }); const params = buildParams("/config show messages.ackReaction", cfg); params.command.senderIsOwner = true; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config messages.ackreaction"); + expect(result.reply?.text).toContain("Config messages.ackReaction"); }); }); @@ -795,7 +820,7 @@ describe("handleCommands /config configWrites gating", () => { } as OpenClawConfig; readConfigFileSnapshotMock.mockResolvedValueOnce({ valid: true, - parsed: { messages: { ackreaction: ":)" } }, + parsed: { messages: { ackReaction: ":)" } }, }); const params = buildParams("/config show messages.ackReaction", cfg, { Provider: INTERNAL_MESSAGE_CHANNEL, @@ -806,76 +831,82 @@ describe("handleCommands /config configWrites gating", () => { params.command.senderIsOwner = false; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config messages.ackreaction"); + expect(result.reply?.text).toContain("Config messages.ackReaction"); }); it("keeps /config set working for gateway operator.admin clients", async () => { - const cfg = { - commands: { config: true, text: true }, - } as OpenClawConfig; - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { messages: { ackReaction: ":)" } }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - const params = buildParams('/config set messages.ackReaction=":D"', cfg, { - Provider: INTERNAL_MESSAGE_CHANNEL, - Surface: INTERNAL_MESSAGE_CHANNEL, - GatewayClientScopes: ["operator.write", "operator.admin"], - }); - params.command.channel = INTERNAL_MESSAGE_CHANNEL; - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(writeConfigFileMock).toHaveBeenCalledOnce(); - expect(result.reply?.text).toContain("Config updated"); - }); - - it("keeps /config set working for gateway operator.admin on protected account paths", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { - channels: { - telegram: { - accounts: { - work: { enabled: true, configWrites: false }, - }, - }, - }, - }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - const params = buildParams( - "/config set channels.telegram.accounts.work.enabled=false", - { + await withTempConfigPath({ messages: { ackReaction: ":)" } }, async (configPath) => { + const cfg = { commands: { config: true, text: true }, - channels: { - telegram: { - accounts: { - work: { enabled: true, configWrites: false }, - }, - }, - }, - } as OpenClawConfig, - { + } as OpenClawConfig; + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { messages: { ackReaction: ":)" } }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + const params = buildParams('/config set messages.ackReaction=":D"', cfg, { Provider: INTERNAL_MESSAGE_CHANNEL, Surface: INTERNAL_MESSAGE_CHANNEL, GatewayClientScopes: ["operator.write", "operator.admin"], + }); + params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = true; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config updated"); + const written = await readJsonFile(configPath); + expect(written.messages?.ackReaction).toBe(":D"); + }); + }); + + it("keeps /config set working for gateway operator.admin on protected account paths", async () => { + const initialConfig = { + channels: { + telegram: { + accounts: { + work: { enabled: true, configWrites: false }, + }, + }, }, - ); - params.command.channel = INTERNAL_MESSAGE_CHANNEL; - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config updated"); - const written = writeConfigFileMock.mock.calls.at(-1)?.[0] as OpenClawConfig; - expect(written.channels?.telegram?.accounts?.work?.enabled).toBe(false); + }; + await withTempConfigPath(initialConfig, async (configPath) => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: structuredClone(initialConfig), + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + const params = buildParams( + "/config set channels.telegram.accounts.work.enabled=false", + { + commands: { config: true, text: true }, + channels: { + telegram: { + accounts: { + work: { enabled: true, configWrites: false }, + }, + }, + }, + } as OpenClawConfig, + { + Provider: INTERNAL_MESSAGE_CHANNEL, + Surface: INTERNAL_MESSAGE_CHANNEL, + GatewayClientScopes: ["operator.write", "operator.admin"], + }, + ); + params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = true; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config updated"); + const written = await readJsonFile(configPath); + expect(written.channels?.telegram?.accounts?.work?.enabled).toBe(false); + }); }); }); @@ -940,7 +971,7 @@ function buildPolicyParams( ctx, cfg, isGroup: false, - triggerBodyNormalized: commandBody.trim().toLowerCase(), + triggerBodyNormalized: commandBody.trim(), commandAuthorized: true, }); @@ -986,40 +1017,44 @@ describe("handleCommands /allowlist", () => { }); it("adds entries to config and pairing store", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { + await withTempConfigPath( + { channels: { telegram: { allowFrom: ["123"] } }, }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ - changed: true, - allowFrom: ["123", "789"], - }); + async (configPath) => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { telegram: { allowFrom: ["123"] } }, + }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ + changed: true, + allowFrom: ["123", "789"], + }); - const cfg = { - commands: { text: true, config: true }, - channels: { telegram: { allowFrom: ["123"] } }, - } as OpenClawConfig; - const params = buildPolicyParams("/allowlist add dm 789", cfg); - const result = await handleCommands(params); + const cfg = { + commands: { text: true, config: true }, + channels: { telegram: { allowFrom: ["123"] } }, + } as OpenClawConfig; + const params = buildPolicyParams("/allowlist add dm 789", cfg); + const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(writeConfigFileMock).toHaveBeenCalledWith( - expect.objectContaining({ - channels: { telegram: { allowFrom: ["123", "789"] } }, - }), + expect(result.shouldContinue).toBe(false); + const written = await readJsonFile(configPath); + expect(written.channels?.telegram?.allowFrom).toEqual(["123", "789"]); + expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ + channel: "telegram", + entry: "789", + accountId: "default", + }); + expect(result.reply?.text).toContain("DM allowlist added"); + }, ); - expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ - channel: "telegram", - entry: "789", - accountId: "default", - }); - expect(result.reply?.text).toContain("DM allowlist added"); }); it("writes store entries to the selected account scope", async () => { @@ -1151,22 +1186,7 @@ describe("handleCommands /allowlist", () => { })); for (const testCase of cases) { - const previousWriteCount = writeConfigFileMock.mock.calls.length; - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { - channels: { - [testCase.provider]: { - allowFrom: testCase.initialAllowFrom, - dm: { allowFrom: testCase.initialAllowFrom }, - configWrites: true, - }, - }, - }, - }); - - const cfg = { - commands: { text: true, config: true }, + const initialConfig = { channels: { [testCase.provider]: { allowFrom: testCase.initialAllowFrom, @@ -1174,21 +1194,37 @@ describe("handleCommands /allowlist", () => { configWrites: true, }, }, - } as OpenClawConfig; + }; + await withTempConfigPath(initialConfig, async (configPath) => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: structuredClone(initialConfig), + }); - const params = buildPolicyParams(`/allowlist remove dm ${testCase.removeId}`, cfg, { - Provider: testCase.provider, - Surface: testCase.provider, + const cfg = { + commands: { text: true, config: true }, + channels: { + [testCase.provider]: { + allowFrom: testCase.initialAllowFrom, + dm: { allowFrom: testCase.initialAllowFrom }, + configWrites: true, + }, + }, + } as OpenClawConfig; + + const params = buildPolicyParams(`/allowlist remove dm ${testCase.removeId}`, cfg, { + Provider: testCase.provider, + Surface: testCase.provider, + }); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + const written = await readJsonFile(configPath); + const channelConfig = written.channels?.[testCase.provider]; + expect(channelConfig?.allowFrom).toEqual(testCase.expectedAllowFrom); + expect(channelConfig?.dm?.allowFrom).toBeUndefined(); + expect(result.reply?.text).toContain(`channels.${testCase.provider}.allowFrom`); }); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount + 1); - const written = writeConfigFileMock.mock.calls.at(-1)?.[0] as OpenClawConfig; - const channelConfig = written.channels?.[testCase.provider]; - expect(channelConfig?.allowFrom).toEqual(testCase.expectedAllowFrom); - expect(channelConfig?.dm?.allowFrom).toBeUndefined(); - expect(result.reply?.text).toContain(`channels.${testCase.provider}.allowFrom`); } }); }); From 08da1b47badccb8aa7fbd1e7bb5a8af5ff848622 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 02:59:32 +0000 Subject: [PATCH 011/113] fix: use build-stage image for docker live tests --- scripts/test-live-gateway-models-docker.sh | 7 ++++--- scripts/test-live-models-docker.sh | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/scripts/test-live-gateway-models-docker.sh b/scripts/test-live-gateway-models-docker.sh index 92ddb905ed5..3998110efa6 100755 --- a/scripts/test-live-gateway-models-docker.sh +++ b/scripts/test-live-gateway-models-docker.sh @@ -3,6 +3,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" IMAGE_NAME="${OPENCLAW_IMAGE:-${CLAWDBOT_IMAGE:-openclaw:local}}" +LIVE_IMAGE_NAME="${OPENCLAW_LIVE_IMAGE:-${CLAWDBOT_LIVE_IMAGE:-${IMAGE_NAME}-live}}" CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-${CLAWDBOT_CONFIG_DIR:-$HOME/.openclaw}}" WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-${CLAWDBOT_WORKSPACE_DIR:-$HOME/.openclaw/workspace}}" PROFILE_FILE="${OPENCLAW_PROFILE_FILE:-${CLAWDBOT_PROFILE_FILE:-$HOME/.profile}}" @@ -33,8 +34,8 @@ cd "$tmp_dir" pnpm test:live EOF -echo "==> Build image: $IMAGE_NAME" -docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR" +echo "==> Build live-test image: $LIVE_IMAGE_NAME (target=build)" +docker build --target build -t "$LIVE_IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR" echo "==> Run gateway live model tests (profile keys)" docker run --rm -t \ @@ -51,5 +52,5 @@ docker run --rm -t \ -v "$CONFIG_DIR":/home/node/.openclaw \ -v "$WORKSPACE_DIR":/home/node/.openclaw/workspace \ "${PROFILE_MOUNT[@]}" \ - "$IMAGE_NAME" \ + "$LIVE_IMAGE_NAME" \ -lc "$LIVE_TEST_CMD" diff --git a/scripts/test-live-models-docker.sh b/scripts/test-live-models-docker.sh index 5e3e1d0a311..cca4202710d 100755 --- a/scripts/test-live-models-docker.sh +++ b/scripts/test-live-models-docker.sh @@ -3,6 +3,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" IMAGE_NAME="${OPENCLAW_IMAGE:-${CLAWDBOT_IMAGE:-openclaw:local}}" +LIVE_IMAGE_NAME="${OPENCLAW_LIVE_IMAGE:-${CLAWDBOT_LIVE_IMAGE:-${IMAGE_NAME}-live}}" CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-${CLAWDBOT_CONFIG_DIR:-$HOME/.openclaw}}" WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-${CLAWDBOT_WORKSPACE_DIR:-$HOME/.openclaw/workspace}}" PROFILE_FILE="${OPENCLAW_PROFILE_FILE:-${CLAWDBOT_PROFILE_FILE:-$HOME/.profile}}" @@ -33,8 +34,8 @@ cd "$tmp_dir" pnpm test:live EOF -echo "==> Build image: $IMAGE_NAME" -docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR" +echo "==> Build live-test image: $LIVE_IMAGE_NAME (target=build)" +docker build --target build -t "$LIVE_IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR" echo "==> Run live model tests (profile keys)" docker run --rm -t \ @@ -52,5 +53,5 @@ docker run --rm -t \ -v "$CONFIG_DIR":/home/node/.openclaw \ -v "$WORKSPACE_DIR":/home/node/.openclaw/workspace \ "${PROFILE_MOUNT[@]}" \ - "$IMAGE_NAME" \ + "$LIVE_IMAGE_NAME" \ -lc "$LIVE_TEST_CMD" From 21fa50f564104303637e6df137200152e3cab253 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 03:01:03 +0000 Subject: [PATCH 012/113] test: harden plugin env-scoped fixtures --- src/config/config.plugin-validation.test.ts | 3 +++ src/config/plugin-auto-enable.test.ts | 2 ++ src/plugins/discovery.test.ts | 2 +- src/plugins/loader.test.ts | 4 ++++ src/plugins/manifest-registry.test.ts | 2 ++ 5 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 464a5f37ced..5eec7f2ed4d 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -41,7 +41,10 @@ describe("config plugin validation", () => { const suiteEnv = () => ({ ...process.env, + HOME: suiteHome, + OPENCLAW_HOME: undefined, OPENCLAW_STATE_DIR: path.join(suiteHome, ".openclaw"), + CLAWDBOT_STATE_DIR: undefined, OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: "10000", }) satisfies NodeJS.ProcessEnv; diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index da358084db3..fd2ccfa4b89 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -214,6 +214,7 @@ describe("applyPluginAutoEnable", () => { }, env: { ...process.env, + OPENCLAW_HOME: undefined, OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined, OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", @@ -439,6 +440,7 @@ describe("applyPluginAutoEnable", () => { config: makeApnChannelConfig(), env: { ...process.env, + OPENCLAW_HOME: undefined, OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined, OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index c771b17a957..39bc1775aed 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -16,9 +16,9 @@ function makeTempDir() { function buildDiscoveryEnv(stateDir: string): NodeJS.ProcessEnv { return { - ...process.env, OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined, + OPENCLAW_HOME: undefined, OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", }; } diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 2241fbd1f15..884c819890f 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -540,6 +540,7 @@ describe("loadOpenClawPlugins", () => { env: { ...process.env, HOME: homeA, + OPENCLAW_HOME: undefined, OPENCLAW_STATE_DIR: stateDir, OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, }, @@ -549,6 +550,7 @@ describe("loadOpenClawPlugins", () => { env: { ...process.env, HOME: homeB, + OPENCLAW_HOME: undefined, OPENCLAW_STATE_DIR: stateDir, OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, }, @@ -679,6 +681,7 @@ describe("loadOpenClawPlugins", () => { env: { ...process.env, HOME: homeDir, + OPENCLAW_HOME: undefined, OPENCLAW_BUNDLED_PLUGINS_DIR: override, }, config: { @@ -1814,6 +1817,7 @@ describe("loadOpenClawPlugins", () => { cwd: process.cwd(), env: { ...process.env, + OPENCLAW_HOME: undefined, OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", }, encoding: "utf-8", diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 7d5421b1a35..e84158b3ca6 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -346,6 +346,7 @@ describe("loadPluginManifestRegistry", () => { env: { ...process.env, HOME: homeA, + OPENCLAW_HOME: undefined, OPENCLAW_STATE_DIR: path.join(homeA, ".state"), }, }); @@ -355,6 +356,7 @@ describe("loadPluginManifestRegistry", () => { env: { ...process.env, HOME: homeB, + OPENCLAW_HOME: undefined, OPENCLAW_STATE_DIR: path.join(homeB, ".state"), }, }); From 3e2c776aafc94e9ee8978cdf5063a5b2a2a9ef05 Mon Sep 17 00:00:00 2001 From: shuicici Date: Thu, 12 Mar 2026 20:06:29 +0800 Subject: [PATCH 013/113] fix(cron): avoid false legacy payload kind migrations --- src/cron/store-migration.test.ts | 21 +++++++++++++++++++++ src/cron/store-migration.ts | 21 ++++++++++++++------- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/cron/store-migration.test.ts b/src/cron/store-migration.test.ts index 79f3314c019..1cf43318815 100644 --- a/src/cron/store-migration.test.ts +++ b/src/cron/store-migration.test.ts @@ -75,4 +75,25 @@ describe("normalizeStoredCronJobs", () => { channel: "slack", }); }); + + it("does not report legacyPayloadKind for already-normalized payload kinds", () => { + const jobs = [ + { + id: "normalized-agent-turn", + name: "normalized", + enabled: true, + wakeMode: "now", + schedule: { kind: "every", everyMs: 60_000, anchorMs: 1 }, + payload: { kind: "agentTurn", message: "ping" }, + sessionTarget: "isolated", + delivery: { mode: "announce" }, + state: {}, + }, + ] as Array>; + + const result = normalizeStoredCronJobs(jobs); + + expect(result.mutated).toBe(false); + expect(result.issues.legacyPayloadKind).toBeUndefined(); + }); }); diff --git a/src/cron/store-migration.ts b/src/cron/store-migration.ts index 11789422e61..260b89cfe6a 100644 --- a/src/cron/store-migration.ts +++ b/src/cron/store-migration.ts @@ -28,14 +28,21 @@ function incrementIssue(issues: CronStoreIssues, key: CronStoreIssueKey) { } function normalizePayloadKind(payload: Record) { - const raw = typeof payload.kind === "string" ? payload.kind.trim().toLowerCase() : ""; - if (raw === "agentturn") { - payload.kind = "agentTurn"; - return true; + const original = typeof payload.kind === "string" ? payload.kind.trim() : ""; + const lowered = original.toLowerCase(); + if (lowered === "agentturn") { + if (original !== "agentTurn") { + payload.kind = "agentTurn"; + return true; + } + return false; } - if (raw === "systemevent") { - payload.kind = "systemEvent"; - return true; + if (lowered === "systemevent") { + if (original !== "systemEvent") { + payload.kind = "systemEvent"; + return true; + } + return false; } return false; } From 42613b9baab2628b1093979b7b8c48d853fe1352 Mon Sep 17 00:00:00 2001 From: shuicici Date: Fri, 13 Mar 2026 02:02:31 +0800 Subject: [PATCH 014/113] fix(cron): compare raw value not trimmed in normalizePayloadKind --- src/cron/store-migration.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/cron/store-migration.ts b/src/cron/store-migration.ts index 260b89cfe6a..1e9dcb1b136 100644 --- a/src/cron/store-migration.ts +++ b/src/cron/store-migration.ts @@ -28,17 +28,16 @@ function incrementIssue(issues: CronStoreIssues, key: CronStoreIssueKey) { } function normalizePayloadKind(payload: Record) { - const original = typeof payload.kind === "string" ? payload.kind.trim() : ""; - const lowered = original.toLowerCase(); - if (lowered === "agentturn") { - if (original !== "agentTurn") { + const raw = typeof payload.kind === "string" ? payload.kind.trim().toLowerCase() : ""; + if (raw === "agentturn") { + if (payload.kind !== "agentTurn") { payload.kind = "agentTurn"; return true; } return false; } - if (lowered === "systemevent") { - if (original !== "systemEvent") { + if (raw === "systemevent") { + if (payload.kind !== "systemEvent") { payload.kind = "systemEvent"; return true; } From ff2368af570b4d490e2370114e7321f3a813e193 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 13 Mar 2026 08:38:04 +0530 Subject: [PATCH 015/113] fix: stop false cron payload-kind warnings in doctor (#44012) (thanks @shuicici) --- CHANGELOG.md | 1 + src/cron/store-migration.test.ts | 34 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bccd809750..68d84d3cd9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ Docs: https://docs.openclaw.ai - Agents/compaction: skip the post-compaction `cache-ttl` marker write when a compaction completed in the same attempt, preventing the next turn from immediately triggering a second tiny compaction. (#28548) thanks @MoerAI. - Native chat/macOS: add `/new`, `/reset`, and `/clear` reset triggers, keep shared main-session aliases aligned, and ignore stale model-selection completions so native chat state stays in sync across reset and fast model changes. (#10898) Thanks @Nachx639. - Agents/compaction safeguard: route missing-model and missing-API-key cancellation warnings through the shared subsystem logger so they land in structured and file logs. (#9974) Thanks @dinakars777. +- Cron/doctor: stop flagging canonical `agentTurn` and `systemEvent` payload kinds as legacy cron storage, while still normalizing whitespace-padded and non-canonical variants. (#44012) Thanks @shuicici. ## 2026.3.11 diff --git a/src/cron/store-migration.test.ts b/src/cron/store-migration.test.ts index 1cf43318815..9d82c55c472 100644 --- a/src/cron/store-migration.test.ts +++ b/src/cron/store-migration.test.ts @@ -96,4 +96,38 @@ describe("normalizeStoredCronJobs", () => { expect(result.mutated).toBe(false); expect(result.issues.legacyPayloadKind).toBeUndefined(); }); + + it("normalizes whitespace-padded and non-canonical payload kinds", () => { + const jobs = [ + { + id: "spaced-agent-turn", + name: "normalized", + enabled: true, + wakeMode: "now", + schedule: { kind: "every", everyMs: 60_000, anchorMs: 1 }, + payload: { kind: " agentTurn ", message: "ping" }, + sessionTarget: "isolated", + delivery: { mode: "announce" }, + state: {}, + }, + { + id: "upper-system-event", + name: "normalized", + enabled: true, + wakeMode: "now", + schedule: { kind: "every", everyMs: 60_000, anchorMs: 1 }, + payload: { kind: "SYSTEMEVENT", text: "pong" }, + sessionTarget: "main", + delivery: { mode: "announce" }, + state: {}, + }, + ] as Array>; + + const result = normalizeStoredCronJobs(jobs); + + expect(result.mutated).toBe(true); + expect(result.issues.legacyPayloadKind).toBe(2); + expect(jobs[0]?.payload).toMatchObject({ kind: "agentTurn", message: "ping" }); + expect(jobs[1]?.payload).toMatchObject({ kind: "systemEvent", text: "pong" }); + }); }); From fb9984a774dce2dde7b23a35956cba230995a9be Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 03:09:07 +0000 Subject: [PATCH 016/113] fix(memory): stop forcing Windows qmd cmd shims --- src/memory/qmd-process.test.ts | 16 ++++++++++++++++ src/memory/qmd-process.ts | 22 +--------------------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/src/memory/qmd-process.test.ts b/src/memory/qmd-process.test.ts index 84237c43cab..8f969fb92b6 100644 --- a/src/memory/qmd-process.test.ts +++ b/src/memory/qmd-process.test.ts @@ -72,4 +72,20 @@ describe("resolveCliSpawnInvocation", () => { }), ).toThrow(/without shell execution/); }); + + it("keeps bare commands bare when no Windows wrapper exists on PATH", () => { + process.env.PATH = originalPath ?? ""; + process.env.PATHEXT = ".CMD;.EXE"; + + const invocation = resolveCliSpawnInvocation({ + command: "qmd", + args: ["query", "hello"], + env: process.env, + packageName: "qmd", + }); + + expect(invocation.command).toBe("qmd"); + expect(invocation.argv).toEqual(["query", "hello"]); + expect(invocation.shell).not.toBe(true); + }); }); diff --git a/src/memory/qmd-process.ts b/src/memory/qmd-process.ts index bb0eea803ba..5a70cd3c361 100644 --- a/src/memory/qmd-process.ts +++ b/src/memory/qmd-process.ts @@ -1,5 +1,4 @@ import { spawn } from "node:child_process"; -import path from "node:path"; import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram, @@ -12,25 +11,6 @@ export type CliSpawnInvocation = { windowsHide?: boolean; }; -function resolveWindowsCommandShim(command: string): string { - if (process.platform !== "win32") { - return command; - } - const trimmed = command.trim(); - if (!trimmed) { - return command; - } - const ext = path.extname(trimmed).toLowerCase(); - if (ext === ".cmd" || ext === ".exe" || ext === ".bat") { - return command; - } - const base = path.basename(trimmed).toLowerCase(); - if (base === "qmd" || base === "mcporter") { - return `${trimmed}.cmd`; - } - return command; -} - export function resolveCliSpawnInvocation(params: { command: string; args: string[]; @@ -38,7 +18,7 @@ export function resolveCliSpawnInvocation(params: { packageName: string; }): CliSpawnInvocation { const program = resolveWindowsSpawnProgram({ - command: resolveWindowsCommandShim(params.command), + command: params.command, platform: process.platform, env: params.env, execPath: process.execPath, From ec3c20d96d39939bb326be0fb382194e48f2de62 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 03:13:16 +0000 Subject: [PATCH 017/113] test: harden plugin fixture permissions on macos --- src/config/config.plugin-validation.test.ts | 19 +++++- src/config/plugin-auto-enable.test.ts | 31 +++++++-- src/plugins/discovery.test.ts | 75 +++++++++++++-------- src/plugins/loader.test.ts | 52 +++++++++----- src/plugins/manifest-registry.test.ts | 31 +++++++-- 5 files changed, 150 insertions(+), 58 deletions(-) diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 5eec7f2ed4d..d7e6ae46aca 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -5,13 +5,25 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; import { validateConfigObjectWithPlugins } from "./config.js"; +async function chmodSafeDir(dir: string) { + if (process.platform === "win32") { + return; + } + await fs.chmod(dir, 0o755); +} + +async function mkdirSafe(dir: string) { + await fs.mkdir(dir, { recursive: true }); + await chmodSafeDir(dir); +} + async function writePluginFixture(params: { dir: string; id: string; schema: Record; channels?: string[]; }) { - await fs.mkdir(params.dir, { recursive: true }); + await mkdirSafe(params.dir); await fs.writeFile( path.join(params.dir, "index.js"), `export default { id: "${params.id}", register() {} };`, @@ -32,6 +44,7 @@ async function writePluginFixture(params: { } describe("config plugin validation", () => { + const previousUmask = process.umask(0o022); let fixtureRoot = ""; let suiteHome = ""; let badPluginDir = ""; @@ -53,8 +66,9 @@ describe("config plugin validation", () => { beforeAll(async () => { fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-plugin-validation-")); + await chmodSafeDir(fixtureRoot); suiteHome = path.join(fixtureRoot, "home"); - await fs.mkdir(suiteHome, { recursive: true }); + await mkdirSafe(suiteHome); badPluginDir = path.join(suiteHome, "bad-plugin"); enumPluginDir = path.join(suiteHome, "enum-plugin"); bluebubblesPluginDir = path.join(suiteHome, "bluebubbles-plugin"); @@ -122,6 +136,7 @@ describe("config plugin validation", () => { afterAll(async () => { await fs.rm(fixtureRoot, { recursive: true, force: true }); clearPluginManifestRegistryCache(); + process.umask(previousUmask); }); it("reports missing plugin refs across load paths, entries, and allowlist surfaces", async () => { diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index fd2ccfa4b89..c44a600a23f 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, describe, expect, it } from "vitest"; import { clearPluginDiscoveryCache } from "../plugins/discovery.js"; import { clearPluginManifestRegistryCache, @@ -11,15 +11,34 @@ import { validateConfigObject } from "./config.js"; import { applyPluginAutoEnable } from "./plugin-auto-enable.js"; const tempDirs: string[] = []; +const previousUmask = process.umask(0o022); + +function chmodSafeDir(dir: string) { + if (process.platform === "win32") { + return; + } + fs.chmodSync(dir, 0o755); +} + +function mkdtempSafe(prefix: string) { + const dir = fs.mkdtempSync(prefix); + chmodSafeDir(dir); + return dir; +} + +function mkdirSafe(dir: string) { + fs.mkdirSync(dir, { recursive: true }); + chmodSafeDir(dir); +} function makeTempDir() { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-auto-enable-")); + const dir = mkdtempSafe(path.join(os.tmpdir(), "openclaw-plugin-auto-enable-")); tempDirs.push(dir); return dir; } function writePluginManifestFixture(params: { rootDir: string; id: string; channels: string[] }) { - fs.mkdirSync(params.rootDir, { recursive: true }); + mkdirSafe(params.rootDir); fs.writeFileSync( path.join(params.rootDir, "openclaw.plugin.json"), JSON.stringify( @@ -107,6 +126,10 @@ afterEach(() => { } }); +afterAll(() => { + process.umask(previousUmask); +}); + describe("applyPluginAutoEnable", () => { it("auto-enables built-in channels and appends to existing allowlist", () => { const result = applyWithSlackConfig({ plugins: { allow: ["telegram"] } }); @@ -228,7 +251,7 @@ describe("applyPluginAutoEnable", () => { it("uses env-scoped catalog metadata for preferOver auto-enable decisions", () => { const stateDir = makeTempDir(); const catalogPath = path.join(stateDir, "plugins", "catalog.json"); - fs.mkdirSync(path.dirname(catalogPath), { recursive: true }); + mkdirSafe(path.dirname(catalogPath)); fs.writeFileSync( catalogPath, JSON.stringify({ diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 39bc1775aed..65873cbc2f6 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -2,14 +2,27 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, describe, expect, it } from "vitest"; import { clearPluginDiscoveryCache, discoverOpenClawPlugins } from "./discovery.js"; const tempDirs: string[] = []; +const previousUmask = process.umask(0o022); + +function chmodSafeDir(dir: string) { + if (process.platform === "win32") { + return; + } + fs.chmodSync(dir, 0o755); +} + +function mkdirSafe(dir: string) { + fs.mkdirSync(dir, { recursive: true }); + chmodSafeDir(dir); +} function makeTempDir() { const dir = path.join(os.tmpdir(), `openclaw-plugins-${randomUUID()}`); - fs.mkdirSync(dir, { recursive: true }); + mkdirSafe(dir); tempDirs.push(dir); return dir; } @@ -62,17 +75,21 @@ afterEach(() => { } }); +afterAll(() => { + process.umask(previousUmask); +}); + describe("discoverOpenClawPlugins", () => { it("discovers global and workspace extensions", async () => { const stateDir = makeTempDir(); const workspaceDir = path.join(stateDir, "workspace"); const globalExt = path.join(stateDir, "extensions"); - fs.mkdirSync(globalExt, { recursive: true }); + mkdirSafe(globalExt); fs.writeFileSync(path.join(globalExt, "alpha.ts"), "export default function () {}", "utf-8"); const workspaceExt = path.join(workspaceDir, ".openclaw", "extensions"); - fs.mkdirSync(workspaceExt, { recursive: true }); + mkdirSafe(workspaceExt); fs.writeFileSync(path.join(workspaceExt, "beta.ts"), "export default function () {}", "utf-8"); const { candidates } = await discoverWithStateDir(stateDir, { workspaceDir }); @@ -87,7 +104,7 @@ describe("discoverOpenClawPlugins", () => { const homeDir = makeTempDir(); const workspaceRoot = path.join(homeDir, "workspace"); const workspaceExt = path.join(workspaceRoot, ".openclaw", "extensions"); - fs.mkdirSync(workspaceExt, { recursive: true }); + mkdirSafe(workspaceExt); fs.writeFileSync(path.join(workspaceExt, "tilde-workspace.ts"), "export default {}", "utf-8"); const result = discoverOpenClawPlugins({ @@ -106,22 +123,22 @@ describe("discoverOpenClawPlugins", () => { it("ignores backup and disabled plugin directories in scanned roots", async () => { const stateDir = makeTempDir(); const globalExt = path.join(stateDir, "extensions"); - fs.mkdirSync(globalExt, { recursive: true }); + mkdirSafe(globalExt); const backupDir = path.join(globalExt, "feishu.backup-20260222"); - fs.mkdirSync(backupDir, { recursive: true }); + mkdirSafe(backupDir); fs.writeFileSync(path.join(backupDir, "index.ts"), "export default function () {}", "utf-8"); const disabledDir = path.join(globalExt, "telegram.disabled.20260222"); - fs.mkdirSync(disabledDir, { recursive: true }); + mkdirSafe(disabledDir); fs.writeFileSync(path.join(disabledDir, "index.ts"), "export default function () {}", "utf-8"); const bakDir = path.join(globalExt, "discord.bak"); - fs.mkdirSync(bakDir, { recursive: true }); + mkdirSafe(bakDir); fs.writeFileSync(path.join(bakDir, "index.ts"), "export default function () {}", "utf-8"); const liveDir = path.join(globalExt, "live"); - fs.mkdirSync(liveDir, { recursive: true }); + mkdirSafe(liveDir); fs.writeFileSync(path.join(liveDir, "index.ts"), "export default function () {}", "utf-8"); const { candidates } = await discoverWithStateDir(stateDir, {}); @@ -136,7 +153,7 @@ describe("discoverOpenClawPlugins", () => { it("loads package extension packs", async () => { const stateDir = makeTempDir(); const globalExt = path.join(stateDir, "extensions", "pack"); - fs.mkdirSync(path.join(globalExt, "src"), { recursive: true }); + mkdirSafe(path.join(globalExt, "src")); writePluginPackageManifest({ packageDir: globalExt, @@ -164,7 +181,7 @@ describe("discoverOpenClawPlugins", () => { it("derives unscoped ids for scoped packages", async () => { const stateDir = makeTempDir(); const globalExt = path.join(stateDir, "extensions", "voice-call-pack"); - fs.mkdirSync(path.join(globalExt, "src"), { recursive: true }); + mkdirSafe(path.join(globalExt, "src")); writePluginPackageManifest({ packageDir: globalExt, @@ -186,7 +203,7 @@ describe("discoverOpenClawPlugins", () => { it("treats configured directory paths as plugin packages", async () => { const stateDir = makeTempDir(); const packDir = path.join(stateDir, "packs", "demo-plugin-dir"); - fs.mkdirSync(packDir, { recursive: true }); + mkdirSafe(packDir); writePluginPackageManifest({ packageDir: packDir, @@ -204,7 +221,7 @@ describe("discoverOpenClawPlugins", () => { const stateDir = makeTempDir(); const globalExt = path.join(stateDir, "extensions", "escape-pack"); const outside = path.join(stateDir, "outside.js"); - fs.mkdirSync(globalExt, { recursive: true }); + mkdirSafe(globalExt); writePluginPackageManifest({ packageDir: globalExt, @@ -224,8 +241,8 @@ describe("discoverOpenClawPlugins", () => { const globalExt = path.join(stateDir, "extensions", "pack"); const outsideDir = path.join(stateDir, "outside"); const linkedDir = path.join(globalExt, "linked"); - fs.mkdirSync(globalExt, { recursive: true }); - fs.mkdirSync(outsideDir, { recursive: true }); + mkdirSafe(globalExt); + mkdirSafe(outsideDir); fs.writeFileSync(path.join(outsideDir, "escape.ts"), "export default {}", "utf-8"); try { fs.symlinkSync(outsideDir, linkedDir, process.platform === "win32" ? "junction" : "dir"); @@ -254,8 +271,8 @@ describe("discoverOpenClawPlugins", () => { const outsideDir = path.join(stateDir, "outside"); const outsideFile = path.join(outsideDir, "escape.ts"); const linkedFile = path.join(globalExt, "escape.ts"); - fs.mkdirSync(globalExt, { recursive: true }); - fs.mkdirSync(outsideDir, { recursive: true }); + mkdirSafe(globalExt); + mkdirSafe(outsideDir); fs.writeFileSync(outsideFile, "export default {}", "utf-8"); try { fs.linkSync(outsideFile, linkedFile); @@ -287,8 +304,8 @@ describe("discoverOpenClawPlugins", () => { const outsideDir = path.join(stateDir, "outside"); const outsideManifest = path.join(outsideDir, "package.json"); const linkedManifest = path.join(globalExt, "package.json"); - fs.mkdirSync(globalExt, { recursive: true }); - fs.mkdirSync(outsideDir, { recursive: true }); + mkdirSafe(globalExt); + mkdirSafe(outsideDir); fs.writeFileSync(path.join(globalExt, "entry.ts"), "export default {}", "utf-8"); fs.writeFileSync( outsideManifest, @@ -315,7 +332,7 @@ describe("discoverOpenClawPlugins", () => { it.runIf(process.platform !== "win32")("blocks world-writable plugin paths", async () => { const stateDir = makeTempDir(); const globalExt = path.join(stateDir, "extensions"); - fs.mkdirSync(globalExt, { recursive: true }); + mkdirSafe(globalExt); const pluginPath = path.join(globalExt, "world-open.ts"); fs.writeFileSync(pluginPath, "export default function () {}", "utf-8"); fs.chmodSync(pluginPath, 0o777); @@ -334,7 +351,7 @@ describe("discoverOpenClawPlugins", () => { const stateDir = makeTempDir(); const bundledDir = path.join(stateDir, "bundled"); const packDir = path.join(bundledDir, "demo-pack"); - fs.mkdirSync(packDir, { recursive: true }); + mkdirSafe(packDir); fs.writeFileSync(path.join(packDir, "index.ts"), "export default function () {}", "utf-8"); fs.chmodSync(packDir, 0o777); @@ -362,7 +379,7 @@ describe("discoverOpenClawPlugins", () => { async () => { const stateDir = makeTempDir(); const globalExt = path.join(stateDir, "extensions"); - fs.mkdirSync(globalExt, { recursive: true }); + mkdirSafe(globalExt); fs.writeFileSync( path.join(globalExt, "owner-mismatch.ts"), "export default function () {}", @@ -382,7 +399,7 @@ describe("discoverOpenClawPlugins", () => { it("reuses discovery results from cache until cleared", async () => { const stateDir = makeTempDir(); const globalExt = path.join(stateDir, "extensions"); - fs.mkdirSync(globalExt, { recursive: true }); + mkdirSafe(globalExt); const pluginPath = path.join(globalExt, "cached.ts"); fs.writeFileSync(pluginPath, "export default function () {}", "utf-8"); @@ -420,8 +437,8 @@ describe("discoverOpenClawPlugins", () => { const stateDirB = makeTempDir(); const globalExtA = path.join(stateDirA, "extensions"); const globalExtB = path.join(stateDirB, "extensions"); - fs.mkdirSync(globalExtA, { recursive: true }); - fs.mkdirSync(globalExtB, { recursive: true }); + mkdirSafe(globalExtA); + mkdirSafe(globalExtB); fs.writeFileSync(path.join(globalExtA, "alpha.ts"), "export default function () {}", "utf-8"); fs.writeFileSync(path.join(globalExtB, "beta.ts"), "export default function () {}", "utf-8"); @@ -450,8 +467,8 @@ describe("discoverOpenClawPlugins", () => { const homeB = makeTempDir(); const pluginA = path.join(homeA, "plugins", "demo.ts"); const pluginB = path.join(homeB, "plugins", "demo.ts"); - fs.mkdirSync(path.dirname(pluginA), { recursive: true }); - fs.mkdirSync(path.dirname(pluginB), { recursive: true }); + mkdirSafe(path.dirname(pluginA)); + mkdirSafe(path.dirname(pluginB)); fs.writeFileSync(pluginA, "export default {}", "utf-8"); fs.writeFileSync(pluginB, "export default {}", "utf-8"); @@ -482,7 +499,7 @@ describe("discoverOpenClawPlugins", () => { const stateDir = makeTempDir(); const pluginA = path.join(stateDir, "plugins", "alpha.ts"); const pluginB = path.join(stateDir, "plugins", "beta.ts"); - fs.mkdirSync(path.dirname(pluginA), { recursive: true }); + mkdirSafe(path.dirname(pluginA)); fs.writeFileSync(pluginA, "export default {}", "utf-8"); fs.writeFileSync(pluginB, "export default {}", "utf-8"); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 884c819890f..95b790b69fd 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -34,10 +34,29 @@ const { loadOpenClawPlugins, resetGlobalHookRunner, } = await importFreshPluginTestModules(); +const previousUmask = process.umask(0o022); type TempPlugin = { dir: string; file: string; id: string }; -const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-")); +function chmodSafeDir(dir: string) { + if (process.platform === "win32") { + return; + } + fs.chmodSync(dir, 0o755); +} + +function mkdtempSafe(prefix: string) { + const dir = fs.mkdtempSync(prefix); + chmodSafeDir(dir); + return dir; +} + +function mkdirSafe(dir: string) { + fs.mkdirSync(dir, { recursive: true }); + chmodSafeDir(dir); +} + +const fixtureRoot = mkdtempSafe(path.join(os.tmpdir(), "openclaw-plugin-")); let tempDirIndex = 0; const prevBundledDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} }; @@ -69,7 +88,7 @@ const BUNDLED_TELEGRAM_PLUGIN_BODY = `module.exports = { function makeTempDir() { const dir = path.join(fixtureRoot, `case-${tempDirIndex++}`); - fs.mkdirSync(dir, { recursive: true }); + mkdirSafe(dir); return dir; } @@ -81,7 +100,7 @@ function writePlugin(params: { }): TempPlugin { const dir = params.dir ?? makeTempDir(); const filename = params.filename ?? `${params.id}.cjs`; - fs.mkdirSync(dir, { recursive: true }); + mkdirSafe(dir); const file = path.join(dir, filename); fs.writeFileSync(file, params.body, "utf-8"); fs.writeFileSync( @@ -126,7 +145,7 @@ function loadBundledMemoryPluginRegistry(options?: { if (options?.packageMeta) { pluginDir = path.join(bundledDir, "memory-core"); pluginFilename = options.pluginFilename ?? "index.js"; - fs.mkdirSync(pluginDir, { recursive: true }); + mkdirSafe(pluginDir); fs.writeFileSync( path.join(pluginDir, "package.json"), JSON.stringify( @@ -259,8 +278,8 @@ function createPluginSdkAliasFixture(params?: { const root = makeTempDir(); const srcFile = path.join(root, "src", "plugin-sdk", params?.srcFile ?? "index.ts"); const distFile = path.join(root, "dist", "plugin-sdk", params?.distFile ?? "index.js"); - fs.mkdirSync(path.dirname(srcFile), { recursive: true }); - fs.mkdirSync(path.dirname(distFile), { recursive: true }); + mkdirSafe(path.dirname(srcFile)); + mkdirSafe(path.dirname(distFile)); fs.writeFileSync(srcFile, params?.srcBody ?? "export {};\n", "utf-8"); fs.writeFileSync(distFile, params?.distBody ?? "export {};\n", "utf-8"); return { root, srcFile, distFile }; @@ -281,6 +300,7 @@ afterAll(() => { } catch { // ignore cleanup failures } finally { + process.umask(previousUmask); cachedBundledTelegramDir = ""; cachedBundledMemoryDir = ""; } @@ -571,7 +591,7 @@ describe("loadOpenClawPlugins", () => { const ignoredHome = makeTempDir(); const stateDir = makeTempDir(); const pluginDir = path.join(openclawHome, "plugins", "tracked-install-cache"); - fs.mkdirSync(pluginDir, { recursive: true }); + mkdirSafe(pluginDir); const plugin = writePlugin({ id: "tracked-install-cache", dir: pluginDir, @@ -1271,8 +1291,8 @@ describe("loadOpenClawPlugins", () => { const bundledDir = makeTempDir(); const memoryADir = path.join(bundledDir, "memory-a"); const memoryBDir = path.join(bundledDir, "memory-b"); - fs.mkdirSync(memoryADir, { recursive: true }); - fs.mkdirSync(memoryBDir, { recursive: true }); + mkdirSafe(memoryADir); + mkdirSafe(memoryBDir); writePlugin({ id: "memory-a", dir: memoryADir, @@ -1402,7 +1422,7 @@ describe("loadOpenClawPlugins", () => { const stateDir = makeTempDir(); withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => { const globalDir = path.join(stateDir, "extensions", "feishu"); - fs.mkdirSync(globalDir, { recursive: true }); + mkdirSafe(globalDir); writePlugin({ id: "feishu", body: `module.exports = { id: "feishu", register() {} };`, @@ -1456,7 +1476,7 @@ describe("loadOpenClawPlugins", () => { useNoBundledPlugins(); const workspaceDir = makeTempDir(); const workspaceExtDir = path.join(workspaceDir, ".openclaw", "extensions", "workspace-helper"); - fs.mkdirSync(workspaceExtDir, { recursive: true }); + mkdirSafe(workspaceExtDir); writePlugin({ id: "workspace-helper", body: `module.exports = { id: "workspace-helper", register() {} };`, @@ -1484,7 +1504,7 @@ describe("loadOpenClawPlugins", () => { useNoBundledPlugins(); const workspaceDir = makeTempDir(); const workspaceExtDir = path.join(workspaceDir, ".openclaw", "extensions", "workspace-helper"); - fs.mkdirSync(workspaceExtDir, { recursive: true }); + mkdirSafe(workspaceExtDir); writePlugin({ id: "workspace-helper", body: `module.exports = { id: "workspace-helper", register() {} };`, @@ -1513,7 +1533,7 @@ describe("loadOpenClawPlugins", () => { const stateDir = makeTempDir(); withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => { const globalDir = path.join(stateDir, "extensions", "rogue"); - fs.mkdirSync(globalDir, { recursive: true }); + mkdirSafe(globalDir); writePlugin({ id: "rogue", body: `module.exports = { id: "rogue", register() {} };`, @@ -1549,7 +1569,7 @@ describe("loadOpenClawPlugins", () => { const ignoredHome = makeTempDir(); const stateDir = makeTempDir(); const pluginDir = path.join(openclawHome, "plugins", "tracked-load-path"); - fs.mkdirSync(pluginDir, { recursive: true }); + mkdirSafe(pluginDir); const plugin = writePlugin({ id: "tracked-load-path", dir: pluginDir, @@ -1591,7 +1611,7 @@ describe("loadOpenClawPlugins", () => { const ignoredHome = makeTempDir(); const stateDir = makeTempDir(); const pluginDir = path.join(openclawHome, "plugins", "tracked-install-path"); - fs.mkdirSync(pluginDir, { recursive: true }); + mkdirSafe(pluginDir); const plugin = writePlugin({ id: "tracked-install-path", dir: pluginDir, @@ -1702,7 +1722,7 @@ describe("loadOpenClawPlugins", () => { } const bundledDir = makeTempDir(); const pluginDir = path.join(bundledDir, "hardlinked-bundled"); - fs.mkdirSync(pluginDir, { recursive: true }); + mkdirSafe(pluginDir); const outsideDir = makeTempDir(); const outsideEntry = path.join(outsideDir, "outside.cjs"); diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index e84158b3ca6..bbf65d14e41 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, describe, expect, it } from "vitest"; import type { PluginCandidate } from "./discovery.js"; import { clearPluginManifestRegistryCache, @@ -10,10 +10,23 @@ import { } from "./manifest-registry.js"; const tempDirs: string[] = []; +const previousUmask = process.umask(0o022); + +function chmodSafeDir(dir: string) { + if (process.platform === "win32") { + return; + } + fs.chmodSync(dir, 0o755); +} + +function mkdirSafe(dir: string) { + fs.mkdirSync(dir, { recursive: true }); + chmodSafeDir(dir); +} function makeTempDir() { const dir = path.join(os.tmpdir(), `openclaw-manifest-registry-${randomUUID()}`); - fs.mkdirSync(dir, { recursive: true }); + mkdirSafe(dir); tempDirs.push(dir); return dir; } @@ -133,6 +146,10 @@ afterEach(() => { } }); +afterAll(() => { + process.umask(previousUmask); +}); + describe("loadPluginManifestRegistry", () => { it("emits duplicate warning for truly distinct plugins with same id", () => { const dirA = makeTempDir(); @@ -214,7 +231,7 @@ describe("loadPluginManifestRegistry", () => { it("prefers higher-precedence origins for the same physical directory (config > workspace > global > bundled)", () => { const dir = makeTempDir(); - fs.mkdirSync(path.join(dir, "sub"), { recursive: true }); + mkdirSafe(path.join(dir, "sub")); const manifest = { id: "precedence-plugin", configSchema: { type: "object" } }; writeManifest(dir, manifest); @@ -274,8 +291,8 @@ describe("loadPluginManifestRegistry", () => { const bundledB = makeTempDir(); const matrixA = path.join(bundledA, "matrix"); const matrixB = path.join(bundledB, "matrix"); - fs.mkdirSync(matrixA, { recursive: true }); - fs.mkdirSync(matrixB, { recursive: true }); + mkdirSafe(matrixA); + mkdirSafe(matrixB); writeManifest(matrixA, { id: "matrix", name: "Matrix A", @@ -317,8 +334,8 @@ describe("loadPluginManifestRegistry", () => { const homeB = makeTempDir(); const demoA = path.join(homeA, "plugins", "demo"); const demoB = path.join(homeB, "plugins", "demo"); - fs.mkdirSync(demoA, { recursive: true }); - fs.mkdirSync(demoB, { recursive: true }); + mkdirSafe(demoA); + mkdirSafe(demoB); writeManifest(demoA, { id: "demo", name: "Demo A", From 496ca3a6373a3c1203b7a0b82ed8c93acfbb22e0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 03:10:12 +0000 Subject: [PATCH 018/113] fix(feishu): fail closed on webhook signature checks --- extensions/feishu/src/monitor.transport.ts | 110 ++++++- .../feishu/src/monitor.webhook-e2e.test.ts | 306 ++++++++++++++++++ src/plugin-sdk/feishu.ts | 2 +- 3 files changed, 411 insertions(+), 7 deletions(-) create mode 100644 extensions/feishu/src/monitor.webhook-e2e.test.ts diff --git a/extensions/feishu/src/monitor.transport.ts b/extensions/feishu/src/monitor.transport.ts index 49a9130bb61..d619f3cddb3 100644 --- a/extensions/feishu/src/monitor.transport.ts +++ b/extensions/feishu/src/monitor.transport.ts @@ -1,7 +1,9 @@ import * as http from "http"; +import crypto from "node:crypto"; import * as Lark from "@larksuiteoapi/node-sdk"; import { applyBasicWebhookRequestGuards, + readJsonBodyWithLimit, type RuntimeEnv, installRequestBodyLimitGuard, } from "openclaw/plugin-sdk/feishu"; @@ -26,6 +28,50 @@ export type MonitorTransportParams = { eventDispatcher: Lark.EventDispatcher; }; +function isFeishuWebhookPayload(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function buildFeishuWebhookEnvelope( + req: http.IncomingMessage, + payload: Record, +): Record { + return Object.assign(Object.create({ headers: req.headers }), payload) as Record; +} + +function isFeishuWebhookSignatureValid(params: { + headers: http.IncomingHttpHeaders; + payload: Record; + encryptKey?: string; +}): boolean { + const encryptKey = params.encryptKey?.trim(); + if (!encryptKey) { + return true; + } + + const timestampHeader = params.headers["x-lark-request-timestamp"]; + const nonceHeader = params.headers["x-lark-request-nonce"]; + const signatureHeader = params.headers["x-lark-signature"]; + const timestamp = Array.isArray(timestampHeader) ? timestampHeader[0] : timestampHeader; + const nonce = Array.isArray(nonceHeader) ? nonceHeader[0] : nonceHeader; + const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader; + if (!timestamp || !nonce || !signature) { + return false; + } + + const computedSignature = crypto + .createHash("sha256") + .update(timestamp + nonce + encryptKey + JSON.stringify(params.payload)) + .digest("hex"); + return computedSignature === signature; +} + +function respondText(res: http.ServerResponse, statusCode: number, body: string): void { + res.statusCode = statusCode; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end(body); +} + export async function monitorWebSocket({ account, accountId, @@ -88,7 +134,6 @@ export async function monitorWebhook({ log(`feishu[${accountId}]: starting Webhook server on ${host}:${port}, path ${path}...`); const server = http.createServer(); - const webhookHandler = Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true }); server.on("request", (req, res) => { res.on("finish", () => { @@ -118,15 +163,68 @@ export async function monitorWebhook({ return; } - void Promise.resolve(webhookHandler(req, res)) - .catch((err) => { + void (async () => { + try { + const bodyResult = await readJsonBodyWithLimit(req, { + maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES, + timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS, + }); + if (guard.isTripped() || res.writableEnded) { + return; + } + if (!bodyResult.ok) { + if (bodyResult.code === "INVALID_JSON") { + respondText(res, 400, "Invalid JSON"); + } + return; + } + if (!isFeishuWebhookPayload(bodyResult.value)) { + respondText(res, 400, "Invalid JSON"); + return; + } + + // Lark's default adapter drops invalid signatures as an empty 200. Reject here instead. + if ( + !isFeishuWebhookSignatureValid({ + headers: req.headers, + payload: bodyResult.value, + encryptKey: account.encryptKey, + }) + ) { + respondText(res, 401, "Invalid signature"); + return; + } + + const { isChallenge, challenge } = Lark.generateChallenge(bodyResult.value, { + encryptKey: account.encryptKey ?? "", + }); + if (isChallenge) { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify(challenge)); + return; + } + + const value = await eventDispatcher.invoke( + buildFeishuWebhookEnvelope(req, bodyResult.value), + { needCheck: false }, + ); + if (!res.headersSent) { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify(value)); + } + } catch (err) { if (!guard.isTripped()) { error(`feishu[${accountId}]: webhook handler error: ${String(err)}`); + if (!res.headersSent) { + respondText(res, 500, "Internal Server Error"); + } } - }) - .finally(() => { + } finally { guard.dispose(); - }); + } + })(); }); httpServers.set(accountId, server); diff --git a/extensions/feishu/src/monitor.webhook-e2e.test.ts b/extensions/feishu/src/monitor.webhook-e2e.test.ts new file mode 100644 index 00000000000..2e73f973408 --- /dev/null +++ b/extensions/feishu/src/monitor.webhook-e2e.test.ts @@ -0,0 +1,306 @@ +import crypto from "node:crypto"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createFeishuRuntimeMockModule } from "./monitor.test-mocks.js"; + +const probeFeishuMock = vi.hoisted(() => vi.fn()); + +vi.mock("./probe.js", () => ({ + probeFeishu: probeFeishuMock, +})); + +vi.mock("./client.js", async () => { + const actual = await vi.importActual("./client.js"); + return { + ...actual, + createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })), + }; +}); + +vi.mock("./runtime.js", () => createFeishuRuntimeMockModule()); + +import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js"; + +async function getFreePort(): Promise { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const address = server.address() as AddressInfo | null; + if (!address) { + throw new Error("missing server address"); + } + await new Promise((resolve) => server.close(() => resolve())); + return address.port; +} + +async function waitUntilServerReady(url: string): Promise { + for (let i = 0; i < 50; i += 1) { + try { + const response = await fetch(url, { method: "GET" }); + if (response.status >= 200 && response.status < 500) { + return; + } + } catch { + // retry + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`server did not start: ${url}`); +} + +function buildConfig(params: { + accountId: string; + path: string; + port: number; + verificationToken?: string; + encryptKey?: string; +}): ClawdbotConfig { + return { + channels: { + feishu: { + enabled: true, + accounts: { + [params.accountId]: { + enabled: true, + appId: "cli_test", + appSecret: "secret_test", // pragma: allowlist secret + connectionMode: "webhook", + webhookHost: "127.0.0.1", + webhookPort: params.port, + webhookPath: params.path, + encryptKey: params.encryptKey, + verificationToken: params.verificationToken, + }, + }, + }, + }, + } as ClawdbotConfig; +} + +function signFeishuPayload(params: { + encryptKey: string; + payload: Record; + timestamp?: string; + nonce?: string; +}): Record { + const timestamp = params.timestamp ?? "1711111111"; + const nonce = params.nonce ?? "nonce-test"; + const signature = crypto + .createHash("sha256") + .update(timestamp + nonce + params.encryptKey + JSON.stringify(params.payload)) + .digest("hex"); + return { + "content-type": "application/json", + "x-lark-request-timestamp": timestamp, + "x-lark-request-nonce": nonce, + "x-lark-signature": signature, + }; +} + +function encryptFeishuPayload(encryptKey: string, payload: Record): string { + const iv = crypto.randomBytes(16); + const key = crypto.createHash("sha256").update(encryptKey).digest(); + const cipher = crypto.createCipheriv("aes-256-cbc", key, iv); + const plaintext = Buffer.from(JSON.stringify(payload), "utf8"); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + return Buffer.concat([iv, encrypted]).toString("base64"); +} + +async function withRunningWebhookMonitor( + params: { + accountId: string; + path: string; + verificationToken: string; + encryptKey: string; + }, + run: (url: string) => Promise, +) { + const port = await getFreePort(); + const cfg = buildConfig({ + accountId: params.accountId, + path: params.path, + port, + encryptKey: params.encryptKey, + verificationToken: params.verificationToken, + }); + + const abortController = new AbortController(); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const monitorPromise = monitorFeishuProvider({ + config: cfg, + runtime, + abortSignal: abortController.signal, + }); + + const url = `http://127.0.0.1:${port}${params.path}`; + await waitUntilServerReady(url); + + try { + await run(url); + } finally { + abortController.abort(); + await monitorPromise; + } +} + +afterEach(() => { + stopFeishuMonitor(); +}); + +describe("Feishu webhook signed-request e2e", () => { + it("rejects invalid signatures with 401 instead of empty 200", async () => { + probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); + + await withRunningWebhookMonitor( + { + accountId: "invalid-signature", + path: "/hook-e2e-invalid-signature", + verificationToken: "verify_token", + encryptKey: "encrypt_key", + }, + async (url) => { + const payload = { type: "url_verification", challenge: "challenge-token" }; + const response = await fetch(url, { + method: "POST", + headers: { + ...signFeishuPayload({ encryptKey: "wrong_key", payload }), + }, + body: JSON.stringify(payload), + }); + + expect(response.status).toBe(401); + expect(await response.text()).toBe("Invalid signature"); + }, + ); + }); + + it("rejects missing signature headers with 401", async () => { + probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); + + await withRunningWebhookMonitor( + { + accountId: "missing-signature", + path: "/hook-e2e-missing-signature", + verificationToken: "verify_token", + encryptKey: "encrypt_key", + }, + async (url) => { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "url_verification", challenge: "challenge-token" }), + }); + + expect(response.status).toBe(401); + expect(await response.text()).toBe("Invalid signature"); + }, + ); + }); + + it("returns 400 for invalid json before invoking the sdk", async () => { + probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); + + await withRunningWebhookMonitor( + { + accountId: "invalid-json", + path: "/hook-e2e-invalid-json", + verificationToken: "verify_token", + encryptKey: "encrypt_key", + }, + async (url) => { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{not-json", + }); + + expect(response.status).toBe(400); + expect(await response.text()).toBe("Invalid JSON"); + }, + ); + }); + + it("accepts signed plaintext url_verification challenges end-to-end", async () => { + probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); + + await withRunningWebhookMonitor( + { + accountId: "signed-challenge", + path: "/hook-e2e-signed-challenge", + verificationToken: "verify_token", + encryptKey: "encrypt_key", + }, + async (url) => { + const payload = { type: "url_verification", challenge: "challenge-token" }; + const response = await fetch(url, { + method: "POST", + headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }), + body: JSON.stringify(payload), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ challenge: "challenge-token" }); + }, + ); + }); + + it("accepts signed non-challenge events and reaches the dispatcher", async () => { + probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); + + await withRunningWebhookMonitor( + { + accountId: "signed-dispatch", + path: "/hook-e2e-signed-dispatch", + verificationToken: "verify_token", + encryptKey: "encrypt_key", + }, + async (url) => { + const payload = { + schema: "2.0", + header: { event_type: "unknown.event" }, + event: {}, + }; + const response = await fetch(url, { + method: "POST", + headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }), + body: JSON.stringify(payload), + }); + + expect(response.status).toBe(200); + expect(await response.text()).toContain("no unknown.event event handle"); + }, + ); + }); + + it("accepts signed encrypted url_verification challenges end-to-end", async () => { + probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); + + await withRunningWebhookMonitor( + { + accountId: "encrypted-challenge", + path: "/hook-e2e-encrypted-challenge", + verificationToken: "verify_token", + encryptKey: "encrypt_key", + }, + async (url) => { + const payload = { + encrypt: encryptFeishuPayload("encrypt_key", { + type: "url_verification", + challenge: "encrypted-challenge-token", + }), + }; + const response = await fetch(url, { + method: "POST", + headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }), + body: JSON.stringify(payload), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + challenge: "encrypted-challenge-token", + }); + }, + ); + }); +}); diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 88703e6adc4..4b8b0b9abe9 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -51,7 +51,7 @@ export { } from "../config/types.secrets.js"; export { buildSecretInputSchema } from "./secret-input-schema.js"; export { createDedupeCache } from "../infra/dedupe.js"; -export { installRequestBodyLimitGuard } from "../infra/http-body.js"; +export { installRequestBodyLimitGuard, readJsonBodyWithLimit } from "../infra/http-body.js"; export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; From 0979264ed5fb403968c8725f8dc047fade7d9578 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 03:13:45 +0000 Subject: [PATCH 019/113] test(qmd): make windows cli fixtures explicit --- src/memory/qmd-manager.test.ts | 56 ++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 5a5b577dcba..1896e80388a 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -92,6 +92,33 @@ import { QmdMemoryManager } from "./qmd-manager.js"; import { requireNodeSqlite } from "./sqlite.js"; const spawnMock = mockedSpawn as unknown as Mock; +const originalPath = process.env.PATH; +const originalPathExt = process.env.PATHEXT; +const originalWindowsPath = (process.env as NodeJS.ProcessEnv & { Path?: string }).Path; + +async function installFakeWindowsCliPackage(params: { + rootDir: string; + packageName: "qmd" | "mcporter"; +}): Promise { + const nodeModulesDir = path.join(params.rootDir, "node_modules"); + const shimDir = path.join(nodeModulesDir, ".bin"); + const packageDir = path.join(nodeModulesDir, params.packageName); + const scriptPath = path.join(packageDir, "dist", "cli.js"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.mkdir(shimDir, { recursive: true }); + await fs.writeFile(path.join(shimDir, `${params.packageName}.cmd`), "@echo off\r\n", "utf8"); + await fs.writeFile( + path.join(packageDir, "package.json"), + JSON.stringify({ + name: params.packageName, + version: "0.0.0", + bin: { [params.packageName]: "dist/cli.js" }, + }), + "utf8", + ); + await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); + return shimDir; +} describe("QmdMemoryManager", () => { let fixtureRoot: string; @@ -140,6 +167,20 @@ describe("QmdMemoryManager", () => { // created lazily by manager code when needed. await fs.mkdir(workspaceDir); process.env.OPENCLAW_STATE_DIR = stateDir; + if (process.platform === "win32") { + const qmdShimDir = await installFakeWindowsCliPackage({ + rootDir: path.join(tmpRoot, "fake-qmd-cli"), + packageName: "qmd", + }); + const mcporterShimDir = await installFakeWindowsCliPackage({ + rootDir: path.join(tmpRoot, "fake-mcporter-cli"), + packageName: "mcporter", + }); + const nextPath = [qmdShimDir, mcporterShimDir, originalPath].filter(Boolean).join(";"); + process.env.PATH = nextPath; + process.env.PATHEXT = ".CMD;.EXE"; + (process.env as NodeJS.ProcessEnv & { Path?: string }).Path = nextPath; + } cfg = { agents: { list: [{ id: agentId, default: true, workspace: workspaceDir }], @@ -158,6 +199,21 @@ describe("QmdMemoryManager", () => { afterEach(() => { vi.useRealTimers(); delete process.env.OPENCLAW_STATE_DIR; + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + if (originalPathExt === undefined) { + delete process.env.PATHEXT; + } else { + process.env.PATHEXT = originalPathExt; + } + if (originalWindowsPath === undefined) { + delete (process.env as NodeJS.ProcessEnv & { Path?: string }).Path; + } else { + (process.env as NodeJS.ProcessEnv & { Path?: string }).Path = originalWindowsPath; + } delete (globalThis as Record).__openclawMcporterDaemonStart; delete (globalThis as Record).__openclawMcporterColdStartWarned; }); From a60a4b4b5eeb8f12be6d2f1dd501fdfd63449758 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 03:17:51 +0000 Subject: [PATCH 020/113] test(gateway): avoid hoisted reply mock tdz --- src/gateway/test-helpers.mocks.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index d8dfdcbbe84..43811da1492 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -584,7 +584,8 @@ vi.mock("../commands/agent.js", () => ({ agentCommandFromIngress: agentCommand, })); vi.mock("../auto-reply/reply.js", () => ({ - getReplyFromConfig, + getReplyFromConfig: (...args: Parameters) => + hoisted.getReplyFromConfig(...args), })); vi.mock("../cli/deps.js", async () => { const actual = await vi.importActual("../cli/deps.js"); From 433e65711f78492e961e5da678de91175b4f151b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 03:18:17 +0000 Subject: [PATCH 021/113] fix: fall back to a startup entry for windows gateway install --- CHANGELOG.md | 1 + docs/cli/onboard.md | 2 +- docs/platforms/windows.md | 14 +- src/daemon/schtasks.startup-fallback.test.ts | 185 ++++++++++++++++ src/daemon/schtasks.ts | 214 +++++++++++++++++-- 5 files changed, 400 insertions(+), 16 deletions(-) create mode 100644 src/daemon/schtasks.startup-fallback.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 68d84d3cd9d..2992b5a29df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,6 +120,7 @@ Docs: https://docs.openclaw.ai - Windows/update: mirror the native installer environment during global npm updates, including portable Git fallback and Windows-safe npm shell settings, so `openclaw update` works again on native Windows installs. - Gateway/status: expose `runtimeVersion` in gateway status output so install/update smoke tests can verify the running version before and after updates. - Windows/onboarding: explain when non-interactive local onboarding is waiting for an already-running gateway, and surface native Scheduled Task admin requirements more clearly instead of failing with an opaque gateway timeout. +- Windows/gateway install: fall back from denied Scheduled Task creation to a per-user Startup-folder login item, so native `openclaw gateway install` and `--install-daemon` keep working without an elevated PowerShell shell. - Agents/text sanitization: strip leaked model control tokens (`<|...|>` and full-width `<|...|>` variants) from user-facing assistant text, preventing GLM-5 and DeepSeek internal delimiters from reaching end users. (#42173) Thanks @imwyvern. - iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky. - Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark. diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index 6eed344eec1..4b30e0d52b3 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -100,7 +100,7 @@ Non-interactive local gateway health: - Unless you pass `--skip-health`, onboarding waits for a reachable local gateway before it exits successfully. - `--install-daemon` starts the managed gateway install path first. Without it, you must already have a local gateway running, for example `openclaw gateway run`. - If you only want config/workspace/bootstrap writes in automation, use `--skip-health`. -- On native Windows, `--install-daemon` currently uses Scheduled Tasks and may require running PowerShell as Administrator. +- On native Windows, `--install-daemon` tries Scheduled Tasks first and falls back to a per-user Startup-folder login item if task creation is denied. Interactive onboarding behavior with reference mode: diff --git a/docs/platforms/windows.md b/docs/platforms/windows.md index e6c46368f08..e755a241375 100644 --- a/docs/platforms/windows.md +++ b/docs/platforms/windows.md @@ -39,8 +39,9 @@ openclaw agent --local --agent main --thinking low -m "Reply with exactly WINDOW Current caveats: - `openclaw onboard --non-interactive` still expects a reachable local gateway unless you pass `--skip-health` -- `openclaw onboard --non-interactive --install-daemon` and `openclaw gateway install` currently use Windows Scheduled Tasks -- on some native Windows setups, Scheduled Task install may require running PowerShell as Administrator +- `openclaw onboard --non-interactive --install-daemon` and `openclaw gateway install` try Windows Scheduled Tasks first +- if Scheduled Task creation is denied, OpenClaw falls back to a per-user Startup-folder login item and starts the gateway immediately +- Scheduled Tasks are still preferred when available because they provide better supervisor status If you want the native CLI only, without gateway service install, use one of these: @@ -49,6 +50,15 @@ openclaw onboard --non-interactive --skip-health openclaw gateway run ``` +If you do want managed startup on native Windows: + +```powershell +openclaw gateway install +openclaw gateway status --json +``` + +If Scheduled Task creation is blocked, the fallback service mode still auto-starts after login through the current user's Startup folder. + ## Gateway - [Gateway runbook](/gateway) diff --git a/src/daemon/schtasks.startup-fallback.test.ts b/src/daemon/schtasks.startup-fallback.test.ts new file mode 100644 index 00000000000..0bf27dc1028 --- /dev/null +++ b/src/daemon/schtasks.startup-fallback.test.ts @@ -0,0 +1,185 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const schtasksResponses = vi.hoisted( + () => [] as Array<{ code: number; stdout: string; stderr: string }>, +); +const schtasksCalls = vi.hoisted(() => [] as string[][]); +const inspectPortUsage = vi.hoisted(() => vi.fn()); +const killProcessTree = vi.hoisted(() => vi.fn()); +const runCommandWithTimeout = vi.hoisted(() => vi.fn()); + +vi.mock("./schtasks-exec.js", () => ({ + execSchtasks: async (argv: string[]) => { + schtasksCalls.push(argv); + return schtasksResponses.shift() ?? { code: 0, stdout: "", stderr: "" }; + }, +})); + +vi.mock("../infra/ports.js", () => ({ + inspectPortUsage: (...args: unknown[]) => inspectPortUsage(...args), +})); + +vi.mock("../process/kill-tree.js", () => ({ + killProcessTree: (...args: unknown[]) => killProcessTree(...args), +})); + +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeout(...args), +})); + +const { + installScheduledTask, + isScheduledTaskInstalled, + readScheduledTaskRuntime, + restartScheduledTask, + resolveTaskScriptPath, +} = await import("./schtasks.js"); + +function resolveStartupEntryPath(env: Record) { + return path.join( + env.APPDATA, + "Microsoft", + "Windows", + "Start Menu", + "Programs", + "Startup", + "OpenClaw Gateway.cmd", + ); +} + +async function withWindowsEnv( + run: (params: { tmpDir: string; env: Record }) => Promise, +) { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-win-startup-")); + const env = { + USERPROFILE: tmpDir, + APPDATA: path.join(tmpDir, "AppData", "Roaming"), + OPENCLAW_PROFILE: "default", + OPENCLAW_GATEWAY_PORT: "18789", + }; + try { + await run({ tmpDir, env }); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + +beforeEach(() => { + schtasksResponses.length = 0; + schtasksCalls.length = 0; + inspectPortUsage.mockReset(); + killProcessTree.mockReset(); + runCommandWithTimeout.mockReset(); + runCommandWithTimeout.mockResolvedValue({ + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Windows startup fallback", () => { + it("falls back to a Startup-folder launcher when schtasks create is denied", async () => { + await withWindowsEnv(async ({ env }) => { + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 5, stdout: "", stderr: "ERROR: Access is denied." }, + ); + + const stdout = new PassThrough(); + let printed = ""; + stdout.on("data", (chunk) => { + printed += String(chunk); + }); + + const result = await installScheduledTask({ + env, + stdout, + programArguments: ["node", "gateway.js", "--port", "18789"], + environment: { OPENCLAW_GATEWAY_PORT: "18789" }, + }); + + const startupEntryPath = resolveStartupEntryPath(env); + const startupScript = await fs.readFile(startupEntryPath, "utf8"); + expect(result.scriptPath).toBe(resolveTaskScriptPath(env)); + expect(startupScript).toContain('start "" /min cmd.exe /d /c'); + expect(startupScript).toContain("gateway.cmd"); + expect(runCommandWithTimeout).toHaveBeenCalledWith( + ["cmd.exe", "/d", "/s", "/c", startupEntryPath], + expect.objectContaining({ timeoutMs: 3000, windowsVerbatimArguments: true }), + ); + expect(printed).toContain("Installed Windows login item"); + }); + }); + + it("treats an installed Startup-folder launcher as loaded", async () => { + await withWindowsEnv(async ({ env }) => { + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 1, stdout: "", stderr: "not found" }, + ); + await fs.mkdir(path.dirname(resolveStartupEntryPath(env)), { recursive: true }); + await fs.writeFile(resolveStartupEntryPath(env), "@echo off\r\n", "utf8"); + + await expect(isScheduledTaskInstalled({ env })).resolves.toBe(true); + }); + }); + + it("reports runtime from the gateway listener when using the Startup fallback", async () => { + await withWindowsEnv(async ({ env }) => { + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 1, stdout: "", stderr: "not found" }, + ); + await fs.mkdir(path.dirname(resolveStartupEntryPath(env)), { recursive: true }); + await fs.writeFile(resolveStartupEntryPath(env), "@echo off\r\n", "utf8"); + inspectPortUsage.mockResolvedValue({ + port: 18789, + status: "busy", + listeners: [{ pid: 4242, command: "node.exe" }], + hints: [], + }); + + await expect(readScheduledTaskRuntime(env)).resolves.toMatchObject({ + status: "running", + pid: 4242, + }); + }); + }); + + it("restarts the Startup fallback by killing the current pid and relaunching the entry", async () => { + await withWindowsEnv(async ({ env }) => { + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 1, stdout: "", stderr: "not found" }, + { code: 0, stdout: "", stderr: "" }, + { code: 1, stdout: "", stderr: "not found" }, + ); + await fs.mkdir(path.dirname(resolveStartupEntryPath(env)), { recursive: true }); + await fs.writeFile(resolveStartupEntryPath(env), "@echo off\r\n", "utf8"); + inspectPortUsage.mockResolvedValue({ + port: 18789, + status: "busy", + listeners: [{ pid: 5151, command: "node.exe" }], + hints: [], + }); + + const stdout = new PassThrough(); + await expect(restartScheduledTask({ env, stdout })).resolves.toEqual({ + outcome: "completed", + }); + expect(killProcessTree).toHaveBeenCalledWith(5151, { graceMs: 300 }); + expect(runCommandWithTimeout).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index ddca704f6a4..8e5b60786c5 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -1,5 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { inspectPortUsage } from "../infra/ports.js"; +import { runCommandWithTimeout } from "../process/exec.js"; +import { killProcessTree } from "../process/kill-tree.js"; import { parseCmdScriptCommandLine, quoteCmdScriptArg } from "./cmd-argv.js"; import { assertNoCmdLineBreak, parseCmdSetAssignment, renderCmdSetAssignment } from "./cmd-set.js"; import { resolveGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js"; @@ -37,6 +40,36 @@ export function resolveTaskScriptPath(env: GatewayServiceEnv): string { return path.join(stateDir, scriptName); } +function resolveWindowsStartupDir(env: GatewayServiceEnv): string { + const appData = env.APPDATA?.trim(); + if (appData) { + return path.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup"); + } + const home = env.USERPROFILE?.trim() || env.HOME?.trim(); + if (!home) { + throw new Error("Windows startup folder unavailable: APPDATA/USERPROFILE not set"); + } + return path.join( + home, + "AppData", + "Roaming", + "Microsoft", + "Windows", + "Start Menu", + "Programs", + "Startup", + ); +} + +function sanitizeWindowsFilename(value: string): string { + return value.replace(/[<>:"/\\|?*]/g, "_").replace(/\p{Cc}/gu, "_"); +} + +function resolveStartupEntryPath(env: GatewayServiceEnv): string { + const taskName = resolveTaskName(env); + return path.join(resolveWindowsStartupDir(env), `${sanitizeWindowsFilename(taskName)}.cmd`); +} + // `/TR` is parsed by schtasks itself, while the generated `gateway.cmd` line is parsed by cmd.exe. // Keep their quoting strategies separate so each parser gets the encoding it expects. function quoteSchtasksArg(value: string): string { @@ -103,6 +136,7 @@ export async function readScheduledTaskCommand( programArguments: parseCmdScriptCommandLine(commandLine), ...(workingDirectory ? { workingDirectory } : {}), ...(Object.keys(environment).length > 0 ? { environment } : {}), + sourcePath: scriptPath, }; } catch { return null; @@ -211,6 +245,17 @@ function buildTaskScript({ return `${lines.join("\r\n")}\r\n`; } +function buildStartupLauncherScript(params: { description?: string; scriptPath: string }): string { + const lines = ["@echo off"]; + const trimmedDescription = params.description?.trim(); + if (trimmedDescription) { + assertNoCmdLineBreak(trimmedDescription, "Startup launcher description"); + lines.push(`rem ${trimmedDescription}`); + } + lines.push(`start "" /min cmd.exe /d /c ${quoteCmdScriptArg(params.scriptPath)}`); + return `${lines.join("\r\n")}\r\n`; +} + async function assertSchtasksAvailable() { const res = await execSchtasks(["/Query"]); if (res.code === 0) { @@ -220,6 +265,92 @@ async function assertSchtasksAvailable() { throw new Error(`schtasks unavailable: ${detail || "unknown error"}`.trim()); } +async function isStartupEntryInstalled(env: GatewayServiceEnv): Promise { + try { + await fs.access(resolveStartupEntryPath(env)); + return true; + } catch { + return false; + } +} + +async function isRegisteredScheduledTask(env: GatewayServiceEnv): Promise { + const taskName = resolveTaskName(env); + const res = await execSchtasks(["/Query", "/TN", taskName]).catch(() => ({ + code: 1, + stdout: "", + stderr: "", + })); + return res.code === 0; +} + +async function launchStartupEntry(env: GatewayServiceEnv): Promise { + const startupEntryPath = resolveStartupEntryPath(env); + await runCommandWithTimeout(["cmd.exe", "/d", "/s", "/c", startupEntryPath], { + timeoutMs: 3000, + windowsVerbatimArguments: true, + }); +} + +function resolveConfiguredGatewayPort(env: GatewayServiceEnv): number | null { + const raw = env.OPENCLAW_GATEWAY_PORT?.trim(); + if (!raw) { + return null; + } + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +async function resolveFallbackRuntime(env: GatewayServiceEnv): Promise { + const port = resolveConfiguredGatewayPort(env); + if (!port) { + return { + status: "unknown", + detail: "Startup-folder login item installed; gateway port unknown.", + }; + } + const diagnostics = await inspectPortUsage(port).catch(() => null); + if (!diagnostics) { + return { + status: "unknown", + detail: `Startup-folder login item installed; could not inspect port ${port}.`, + }; + } + const listener = diagnostics.listeners.find((item) => typeof item.pid === "number"); + return { + status: diagnostics.status === "busy" ? "running" : "stopped", + ...(listener?.pid ? { pid: listener.pid } : {}), + detail: + diagnostics.status === "busy" + ? `Startup-folder login item installed; listener detected on port ${port}.` + : `Startup-folder login item installed; no listener detected on port ${port}.`, + }; +} + +async function stopStartupEntry( + env: GatewayServiceEnv, + stdout: NodeJS.WritableStream, +): Promise { + const runtime = await resolveFallbackRuntime(env); + if (typeof runtime.pid === "number" && runtime.pid > 0) { + killProcessTree(runtime.pid, { graceMs: 300 }); + } + stdout.write(`${formatLine("Stopped Windows login item", resolveTaskName(env))}\n`); +} + +async function restartStartupEntry( + env: GatewayServiceEnv, + stdout: NodeJS.WritableStream, +): Promise { + const runtime = await resolveFallbackRuntime(env); + if (typeof runtime.pid === "number" && runtime.pid > 0) { + killProcessTree(runtime.pid, { graceMs: 300 }); + } + await launchStartupEntry(env); + stdout.write(`${formatLine("Restarted Windows login item", resolveTaskName(env))}\n`); + return { outcome: "completed" }; +} + export async function installScheduledTask({ env, stdout, @@ -263,10 +394,23 @@ export async function installScheduledTask({ } if (create.code !== 0) { const detail = create.stderr || create.stdout; - const hint = /access is denied/i.test(detail) - ? " Run PowerShell as Administrator or rerun without installing the daemon." - : ""; - throw new Error(`schtasks create failed: ${detail}${hint}`.trim()); + if (/access is denied/i.test(detail)) { + const startupEntryPath = resolveStartupEntryPath(env); + await fs.mkdir(path.dirname(startupEntryPath), { recursive: true }); + const launcher = buildStartupLauncherScript({ description: taskDescription, scriptPath }); + await fs.writeFile(startupEntryPath, launcher, "utf8"); + await launchStartupEntry(env); + writeFormattedLines( + stdout, + [ + { label: "Installed Windows login item", value: startupEntryPath }, + { label: "Task script", value: scriptPath }, + ], + { leadingBlankLine: true }, + ); + return { scriptPath }; + } + throw new Error(`schtasks create failed: ${detail}`.trim()); } await execSchtasks(["/Run", "/TN", taskName]); @@ -288,7 +432,16 @@ export async function uninstallScheduledTask({ }: GatewayServiceManageArgs): Promise { await assertSchtasksAvailable(); const taskName = resolveTaskName(env); - await execSchtasks(["/Delete", "/F", "/TN", taskName]); + const taskInstalled = await isRegisteredScheduledTask(env).catch(() => false); + if (taskInstalled) { + await execSchtasks(["/Delete", "/F", "/TN", taskName]); + } + + const startupEntryPath = resolveStartupEntryPath(env); + try { + await fs.unlink(startupEntryPath); + stdout.write(`${formatLine("Removed Windows login item", startupEntryPath)}\n`); + } catch {} const scriptPath = resolveTaskScriptPath(env); try { @@ -305,8 +458,23 @@ function isTaskNotRunning(res: { stdout: string; stderr: string; code: number }) } export async function stopScheduledTask({ stdout, env }: GatewayServiceControlArgs): Promise { - await assertSchtasksAvailable(); - const taskName = resolveTaskName(env ?? (process.env as GatewayServiceEnv)); + const effectiveEnv = env ?? (process.env as GatewayServiceEnv); + try { + await assertSchtasksAvailable(); + } catch (err) { + if (await isStartupEntryInstalled(effectiveEnv)) { + await stopStartupEntry(effectiveEnv, stdout); + return; + } + throw err; + } + if (!(await isRegisteredScheduledTask(effectiveEnv))) { + if (await isStartupEntryInstalled(effectiveEnv)) { + await stopStartupEntry(effectiveEnv, stdout); + return; + } + } + const taskName = resolveTaskName(effectiveEnv); const res = await execSchtasks(["/End", "/TN", taskName]); if (res.code !== 0 && !isTaskNotRunning(res)) { throw new Error(`schtasks end failed: ${res.stderr || res.stdout}`.trim()); @@ -318,8 +486,21 @@ export async function restartScheduledTask({ stdout, env, }: GatewayServiceControlArgs): Promise { - await assertSchtasksAvailable(); - const taskName = resolveTaskName(env ?? (process.env as GatewayServiceEnv)); + const effectiveEnv = env ?? (process.env as GatewayServiceEnv); + try { + await assertSchtasksAvailable(); + } catch (err) { + if (await isStartupEntryInstalled(effectiveEnv)) { + return await restartStartupEntry(effectiveEnv, stdout); + } + throw err; + } + if (!(await isRegisteredScheduledTask(effectiveEnv))) { + if (await isStartupEntryInstalled(effectiveEnv)) { + return await restartStartupEntry(effectiveEnv, stdout); + } + } + const taskName = resolveTaskName(effectiveEnv); await execSchtasks(["/End", "/TN", taskName]); const res = await execSchtasks(["/Run", "/TN", taskName]); if (res.code !== 0) { @@ -330,10 +511,11 @@ export async function restartScheduledTask({ } export async function isScheduledTaskInstalled(args: GatewayServiceEnvArgs): Promise { - await assertSchtasksAvailable(); - const taskName = resolveTaskName(args.env ?? (process.env as GatewayServiceEnv)); - const res = await execSchtasks(["/Query", "/TN", taskName]); - return res.code === 0; + const effectiveEnv = args.env ?? (process.env as GatewayServiceEnv); + if (await isRegisteredScheduledTask(effectiveEnv)) { + return true; + } + return await isStartupEntryInstalled(effectiveEnv); } export async function readScheduledTaskRuntime( @@ -342,6 +524,9 @@ export async function readScheduledTaskRuntime( try { await assertSchtasksAvailable(); } catch (err) { + if (await isStartupEntryInstalled(env)) { + return await resolveFallbackRuntime(env); + } return { status: "unknown", detail: String(err), @@ -350,6 +535,9 @@ export async function readScheduledTaskRuntime( const taskName = resolveTaskName(env); const res = await execSchtasks(["/Query", "/TN", taskName, "/V", "/FO", "LIST"]); if (res.code !== 0) { + if (await isStartupEntryInstalled(env)) { + return await resolveFallbackRuntime(env); + } const detail = (res.stderr || res.stdout).trim(); const missing = detail.toLowerCase().includes("cannot find the file"); return { From 42efd98ff88b5ad3741851223b0fa03ec1a8df92 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 23:18:59 -0400 Subject: [PATCH 022/113] Slack: support Block Kit payloads in agent replies (#44592) * Slack: route reply blocks through outbound adapter * Slack: cover Block Kit outbound payloads * Changelog: add Slack Block Kit agent reply entry --- CHANGELOG.md | 1 + .../outbound/slack.sendpayload.test.ts | 64 ++++++++++++++++++- src/channels/plugins/outbound/slack.ts | 32 +++++++++- 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2992b5a29df..34b250f2f75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Models/plugins: move Ollama, vLLM, and SGLang onto the provider-plugin architecture, with provider-owned onboarding, discovery, model-picker setup, and post-selection hooks so core provider wiring is more modular. - Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi - Agents/subagents: add `sessions_yield` so orchestrators can end the current turn immediately, skip queued tool work, and carry a hidden follow-up payload into the next session turn. (#36537) thanks @jriff +- Slack/agent replies: support `channelData.slack.blocks` in the shared reply delivery path so agents can send Block Kit messages through standard Slack outbound delivery. (#44592) Thanks @vincentkoc. ### Fixes diff --git a/src/channels/plugins/outbound/slack.sendpayload.test.ts b/src/channels/plugins/outbound/slack.sendpayload.test.ts index 374c9881a73..8c6b0806254 100644 --- a/src/channels/plugins/outbound/slack.sendpayload.test.ts +++ b/src/channels/plugins/outbound/slack.sendpayload.test.ts @@ -1,4 +1,4 @@ -import { describe, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { ReplyPayload } from "../../../auto-reply/types.js"; import { installSendPayloadContractSuite, @@ -38,4 +38,66 @@ describe("slackOutbound sendPayload", () => { chunking: { mode: "passthrough", longTextLength: 5000 }, createHarness, }); + + it("forwards Slack blocks from channelData", async () => { + const { run, sendMock, to } = createHarness({ + payload: { + text: "Fallback summary", + channelData: { + slack: { + blocks: [{ type: "divider" }], + }, + }, + }, + }); + + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledWith( + to, + "Fallback summary", + expect.objectContaining({ + blocks: [{ type: "divider" }], + }), + ); + expect(result).toMatchObject({ channel: "slack", messageId: "sl-1" }); + }); + + it("accepts blocks encoded as JSON strings in Slack channelData", async () => { + const { run, sendMock, to } = createHarness({ + payload: { + channelData: { + slack: { + blocks: '[{"type":"section","text":{"type":"mrkdwn","text":"hello"}}]', + }, + }, + }, + }); + + await run(); + + expect(sendMock).toHaveBeenCalledWith( + to, + "", + expect.objectContaining({ + blocks: [{ type: "section", text: { type: "mrkdwn", text: "hello" } }], + }), + ); + }); + + it("rejects invalid Slack blocks from channelData", async () => { + const { run, sendMock } = createHarness({ + payload: { + channelData: { + slack: { + blocks: {}, + }, + }, + }, + }); + + await expect(run()).rejects.toThrow(/blocks must be an array/i); + expect(sendMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/channels/plugins/outbound/slack.ts b/src/channels/plugins/outbound/slack.ts index 1c14cc3743d..ae29f988afd 100644 --- a/src/channels/plugins/outbound/slack.ts +++ b/src/channels/plugins/outbound/slack.ts @@ -1,5 +1,6 @@ import type { OutboundIdentity } from "../../../infra/outbound/identity.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; +import { parseSlackBlocksInput } from "../../../slack/blocks-input.js"; import { sendMessageSlack, type SlackSendIdentity } from "../../../slack/send.js"; import type { ChannelOutboundAdapter } from "../types.js"; import { sendTextMediaPayload } from "./direct-text-media.js"; @@ -53,6 +54,7 @@ async function sendSlackOutboundMessage(params: { text: string; mediaUrl?: string; mediaLocalRoots?: readonly string[]; + blocks?: Parameters[2]["blocks"]; accountId?: string | null; deps?: { sendSlack?: typeof sendMessageSlack } | null; replyToId?: string | null; @@ -87,17 +89,43 @@ async function sendSlackOutboundMessage(params: { ...(params.mediaUrl ? { mediaUrl: params.mediaUrl, mediaLocalRoots: params.mediaLocalRoots } : {}), + ...(params.blocks ? { blocks: params.blocks } : {}), ...(slackIdentity ? { identity: slackIdentity } : {}), }); return { channel: "slack" as const, ...result }; } +function resolveSlackBlocks(channelData: Record | undefined) { + const slackData = channelData?.slack; + if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) { + return undefined; + } + return parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks); +} + export const slackOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: null, textChunkLimit: 4000, - sendPayload: async (ctx) => - await sendTextMediaPayload({ channel: "slack", ctx, adapter: slackOutbound }), + sendPayload: async (ctx) => { + const blocks = resolveSlackBlocks(ctx.payload.channelData); + if (!blocks) { + return await sendTextMediaPayload({ channel: "slack", ctx, adapter: slackOutbound }); + } + return await sendSlackOutboundMessage({ + cfg: ctx.cfg, + to: ctx.to, + text: ctx.payload.text ?? "", + mediaUrl: ctx.payload.mediaUrl, + mediaLocalRoots: ctx.mediaLocalRoots, + blocks, + accountId: ctx.accountId, + deps: ctx.deps, + replyToId: ctx.replyToId, + threadId: ctx.threadId, + identity: ctx.identity, + }); + }, sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity }) => { return await sendSlackOutboundMessage({ cfg, From 2201d533fde7d72ee762d7ed3d72917ccf40440e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 03:21:50 +0000 Subject: [PATCH 023/113] fix: enable fast mode for isolated cron runs --- src/cron/isolated-agent/run.fast-mode.test.ts | 182 ++++++++++++++++++ src/cron/isolated-agent/run.ts | 7 + 2 files changed, 189 insertions(+) create mode 100644 src/cron/isolated-agent/run.fast-mode.test.ts diff --git a/src/cron/isolated-agent/run.fast-mode.test.ts b/src/cron/isolated-agent/run.fast-mode.test.ts new file mode 100644 index 00000000000..471471e9ecd --- /dev/null +++ b/src/cron/isolated-agent/run.fast-mode.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { + makeIsolatedAgentTurnJob, + makeIsolatedAgentTurnParams, + setupRunCronIsolatedAgentTurnSuite, +} from "./run.suite-helpers.js"; +import { + loadRunCronIsolatedAgentTurn, + makeCronSession, + resolveCronSessionMock, + runEmbeddedPiAgentMock, + runWithModelFallbackMock, +} from "./run.test-harness.js"; + +const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); + +describe("runCronIsolatedAgentTurn — fast mode", () => { + setupRunCronIsolatedAgentTurnSuite(); + + it("passes config-driven fast mode into embedded cron runs", async () => { + const cronSession = makeCronSession(); + resolveCronSessionMock.mockReturnValue(cronSession); + + runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { + await run(provider, model); + return { + result: { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 10, output: 20 } } }, + }, + provider, + model, + attempts: [], + }; + }); + + const result = await runCronIsolatedAgentTurn( + makeIsolatedAgentTurnParams({ + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-4": { + params: { + fastMode: true, + }, + }, + }, + }, + }, + }, + job: makeIsolatedAgentTurnJob({ + payload: { + kind: "agentTurn", + message: "test fast mode", + model: "openai/gpt-4", + }, + }), + }), + ); + + expect(result.status).toBe("ok"); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + expect(runEmbeddedPiAgentMock.mock.calls[0][0]).toMatchObject({ + provider: "openai", + model: "gpt-4", + fastMode: true, + }); + }); + + it("honors session fastMode=false over config fastMode=true", async () => { + const cronSession = makeCronSession({ + sessionEntry: { + ...makeCronSession().sessionEntry, + fastMode: false, + }, + }); + resolveCronSessionMock.mockReturnValue(cronSession); + + runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { + await run(provider, model); + return { + result: { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 10, output: 20 } } }, + }, + provider, + model, + attempts: [], + }; + }); + + const result = await runCronIsolatedAgentTurn( + makeIsolatedAgentTurnParams({ + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-4": { + params: { + fastMode: true, + }, + }, + }, + }, + }, + }, + job: makeIsolatedAgentTurnJob({ + payload: { + kind: "agentTurn", + message: "test fast mode override", + model: "openai/gpt-4", + }, + }), + }), + ); + + expect(result.status).toBe("ok"); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + expect(runEmbeddedPiAgentMock.mock.calls[0][0]).toMatchObject({ + provider: "openai", + model: "gpt-4", + fastMode: false, + }); + }); + + it("honors session fastMode=true over config fastMode=false", async () => { + const cronSession = makeCronSession({ + sessionEntry: { + ...makeCronSession().sessionEntry, + fastMode: true, + }, + }); + resolveCronSessionMock.mockReturnValue(cronSession); + + runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { + await run(provider, model); + return { + result: { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 10, output: 20 } } }, + }, + provider, + model, + attempts: [], + }; + }); + + const result = await runCronIsolatedAgentTurn( + makeIsolatedAgentTurnParams({ + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-4": { + params: { + fastMode: false, + }, + }, + }, + }, + }, + }, + job: makeIsolatedAgentTurnJob({ + payload: { + kind: "agentTurn", + message: "test fast mode session override", + model: "openai/gpt-4", + }, + }), + }), + ); + + expect(result.status).toBe("ok"); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + expect(runEmbeddedPiAgentMock.mock.calls[0][0]).toMatchObject({ + provider: "openai", + model: "gpt-4", + fastMode: true, + }); + }); +}); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 4c7a5c87fe2..8a074338da7 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -12,6 +12,7 @@ import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js"; import { lookupContextTokens } from "../../agents/context.js"; import { resolveCronStyleNow } from "../../agents/current-time.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; +import { resolveFastModeState } from "../../agents/fast-mode.js"; import { resolveNestedAgentLane } from "../../agents/lanes.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; @@ -617,6 +618,12 @@ export async function runCronIsolatedAgentTurn(params: { authProfileId, authProfileIdSource, thinkLevel, + fastMode: resolveFastModeState({ + cfg: cfgWithAgentDefaults, + provider: providerOverride, + model: modelOverride, + sessionEntry: cronSession.sessionEntry, + }).enabled, verboseLevel: resolvedVerboseLevel, timeoutMs, bootstrapContextMode: agentPayload?.lightContext ? "lightweight" : undefined, From 17c954c46e116dc271db70c418b3c963b4b9bfd9 Mon Sep 17 00:00:00 2001 From: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:23:57 -0700 Subject: [PATCH 024/113] fix(acp): preserve final assistant message snapshot before end_turn (#44597) Process messageData via handleDeltaEvent for both delta and final states before resolving the turn, so ACP clients no longer drop the last visible assistant text when the gateway sends the final message body on the terminal chat event. Closes #15377 Based on #17615 Co-authored-by: PJ Eby <3527052+pjeby@users.noreply.github.com> --- CHANGELOG.md | 1 + src/acp/translator.session-rate-limit.test.ts | 141 ++++++++++++++++++ src/acp/translator.ts | 10 +- 3 files changed, 150 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34b250f2f75..a2175fcf9fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -662,6 +662,7 @@ Docs: https://docs.openclaw.ai - Memory flush/bootstrap file protection: restrict memory-flush runs to append-only `read`/`write` tools and route host-side memory appends through root-enforced safe file handles so flush turns cannot overwrite bootstrap files via `exec` or unsafe raw rewrites. (#38574) Thanks @frankekn. - Mattermost/DM media uploads: resolve bare 26-character Mattermost IDs user-first for direct messages so media sends no longer fail with `403 Forbidden` when targets are configured as unprefixed user IDs. (#29925) Thanks @teconomix. - Voice-call/OpenAI TTS config parity: add missing `speed`, `instructions`, and `baseUrl` fields to the OpenAI TTS config schema and gate `instructions` to supported models so voice-call overrides validate and route cleanly through core TTS. (#39226) Thanks @ademczuk. +- ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving `end_turn`, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby. ## 2026.3.2 diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index 554dc87e2b8..3e3f254d0ee 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -1020,3 +1020,144 @@ describe("acp prompt size hardening", () => { }); }); }); + +describe("acp final chat snapshots", () => { + async function createSnapshotHarness() { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "chat.send") { + return new Promise(() => {}); + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + await agent.loadSession(createLoadSessionRequest("snapshot-session")); + sessionUpdate.mockClear(); + const promptPromise = agent.prompt(createPromptRequest("snapshot-session", "hello")); + const runId = sessionStore.getSession("snapshot-session")?.activeRunId; + if (!runId) { + throw new Error("Expected ACP prompt run to be active"); + } + return { agent, sessionUpdate, promptPromise, runId, sessionStore }; + } + + it("emits final snapshot text before resolving end_turn", async () => { + const { agent, sessionUpdate, promptPromise, runId, sessionStore } = + await createSnapshotHarness(); + + await agent.handleGatewayEvent({ + event: "chat", + payload: { + sessionKey: "snapshot-session", + runId, + state: "final", + stopReason: "end_turn", + message: { + content: [{ type: "text", text: "FINAL TEXT SHOULD BE EMITTED" }], + }, + }, + } as unknown as EventFrame); + + await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "snapshot-session", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "FINAL TEXT SHOULD BE EMITTED" }, + }, + }); + expect(sessionStore.getSession("snapshot-session")?.activeRunId).toBeNull(); + sessionStore.clearAllSessionsForTest(); + }); + + it("does not duplicate text when final repeats the last delta snapshot", async () => { + const { agent, sessionUpdate, promptPromise, runId, sessionStore } = + await createSnapshotHarness(); + + await agent.handleGatewayEvent({ + event: "chat", + payload: { + sessionKey: "snapshot-session", + runId, + state: "delta", + message: { + content: [{ type: "text", text: "Hello world" }], + }, + }, + } as unknown as EventFrame); + + await agent.handleGatewayEvent({ + event: "chat", + payload: { + sessionKey: "snapshot-session", + runId, + state: "final", + stopReason: "end_turn", + message: { + content: [{ type: "text", text: "Hello world" }], + }, + }, + } as unknown as EventFrame); + + await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" }); + const chunks = sessionUpdate.mock.calls.filter( + (call: unknown[]) => + (call[0] as Record)?.update && + (call[0] as Record>).update?.sessionUpdate === + "agent_message_chunk", + ); + expect(chunks).toHaveLength(1); + sessionStore.clearAllSessionsForTest(); + }); + + it("emits only the missing tail when the final snapshot extends prior deltas", async () => { + const { agent, sessionUpdate, promptPromise, runId, sessionStore } = + await createSnapshotHarness(); + + await agent.handleGatewayEvent({ + event: "chat", + payload: { + sessionKey: "snapshot-session", + runId, + state: "delta", + message: { + content: [{ type: "text", text: "Hello" }], + }, + }, + } as unknown as EventFrame); + + await agent.handleGatewayEvent({ + event: "chat", + payload: { + sessionKey: "snapshot-session", + runId, + state: "final", + stopReason: "max_tokens", + message: { + content: [{ type: "text", text: "Hello world" }], + }, + }, + } as unknown as EventFrame); + + await expect(promptPromise).resolves.toEqual({ stopReason: "max_tokens" }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "snapshot-session", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Hello" }, + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "snapshot-session", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: " world" }, + }, + }); + sessionStore.clearAllSessionsForTest(); + }); +}); diff --git a/src/acp/translator.ts b/src/acp/translator.ts index b5a6802d07b..8ab1f821fc8 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -800,9 +800,15 @@ export class AcpGatewayAgent implements Agent { return; } - if (state === "delta" && messageData) { + const shouldHandleMessageSnapshot = messageData && (state === "delta" || state === "final"); + if (shouldHandleMessageSnapshot) { + // Gateway chat events can carry the latest full assistant snapshot on both + // incremental updates and the terminal final event. Process the snapshot + // first so ACP clients never drop the last visible assistant text. await this.handleDeltaEvent(pending.sessionId, messageData); - return; + if (state === "delta") { + return; + } } if (state === "final") { From 0f290fe6d61cf986d01b159d4cd97865a97490e4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 03:29:00 +0000 Subject: [PATCH 025/113] fix: narrow Slack outbound blocks opt type --- src/channels/plugins/outbound/slack.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/channels/plugins/outbound/slack.ts b/src/channels/plugins/outbound/slack.ts index ae29f988afd..96ff7b1b0cb 100644 --- a/src/channels/plugins/outbound/slack.ts +++ b/src/channels/plugins/outbound/slack.ts @@ -54,7 +54,7 @@ async function sendSlackOutboundMessage(params: { text: string; mediaUrl?: string; mediaLocalRoots?: readonly string[]; - blocks?: Parameters[2]["blocks"]; + blocks?: NonNullable[2]>["blocks"]; accountId?: string | null; deps?: { sendSlack?: typeof sendMessageSlack } | null; replyToId?: string | null; From 255414032f23215cc3b7a88a09e737803ab0d35b Mon Sep 17 00:00:00 2001 From: scoootscooob Date: Thu, 12 Mar 2026 20:31:03 -0700 Subject: [PATCH 026/113] changelog: move ACP final-snapshot entry to active 2026.3.12 section --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2175fcf9fa..8e51b3c5d51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,7 @@ Docs: https://docs.openclaw.ai - Native chat/macOS: add `/new`, `/reset`, and `/clear` reset triggers, keep shared main-session aliases aligned, and ignore stale model-selection completions so native chat state stays in sync across reset and fast model changes. (#10898) Thanks @Nachx639. - Agents/compaction safeguard: route missing-model and missing-API-key cancellation warnings through the shared subsystem logger so they land in structured and file logs. (#9974) Thanks @dinakars777. - Cron/doctor: stop flagging canonical `agentTurn` and `systemEvent` payload kinds as legacy cron storage, while still normalizing whitespace-padded and non-canonical variants. (#44012) Thanks @shuicici. +- ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving `end_turn`, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby. ## 2026.3.11 @@ -662,7 +663,6 @@ Docs: https://docs.openclaw.ai - Memory flush/bootstrap file protection: restrict memory-flush runs to append-only `read`/`write` tools and route host-side memory appends through root-enforced safe file handles so flush turns cannot overwrite bootstrap files via `exec` or unsafe raw rewrites. (#38574) Thanks @frankekn. - Mattermost/DM media uploads: resolve bare 26-character Mattermost IDs user-first for direct messages so media sends no longer fail with `403 Forbidden` when targets are configured as unprefixed user IDs. (#29925) Thanks @teconomix. - Voice-call/OpenAI TTS config parity: add missing `speed`, `instructions`, and `baseUrl` fields to the OpenAI TTS config schema and gate `instructions` to supported models so voice-call overrides validate and route cleanly through core TTS. (#39226) Thanks @ademczuk. -- ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving `end_turn`, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby. ## 2026.3.2 From f803215474b8adb2c46c8c23ef14b702b5dfcda3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 03:34:41 +0000 Subject: [PATCH 027/113] fix(ci): restore full gate --- src/daemon/schtasks.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/daemon/schtasks.test.ts b/src/daemon/schtasks.test.ts index 4b45445f727..633df0fee7e 100644 --- a/src/daemon/schtasks.test.ts +++ b/src/daemon/schtasks.test.ts @@ -179,6 +179,7 @@ describe("readScheduledTaskCommand", () => { const result = await readScheduledTaskCommand(env); expect(result).toEqual({ programArguments: ["C:/Program Files/Node/node.exe", "gateway.js"], + sourcePath: resolveTaskScriptPath(env), }); }, ); @@ -222,6 +223,7 @@ describe("readScheduledTaskCommand", () => { NODE_ENV: "production", OPENCLAW_PORT: "18789", }, + sourcePath: resolveTaskScriptPath(env), }); }, ); @@ -245,6 +247,7 @@ describe("readScheduledTaskCommand", () => { "--port", "18789", ], + sourcePath: resolveTaskScriptPath(env), }); }, ); @@ -268,6 +271,7 @@ describe("readScheduledTaskCommand", () => { "--port", "18789", ], + sourcePath: resolveTaskScriptPath(env), }); }, ); @@ -283,6 +287,7 @@ describe("readScheduledTaskCommand", () => { const result = await readScheduledTaskCommand(env); expect(result).toEqual({ programArguments: ["node", "gateway.js", "--from-state-dir"], + sourcePath: resolveTaskScriptPath(env), }); }, ); From fd071323890cad8e2a4022892b416ec77b06422a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 23:35:46 -0400 Subject: [PATCH 028/113] UI: fix control chat logo fallback --- ui/src/ui/app-render.ts | 3 ++- ui/src/ui/views/agents-utils.test.ts | 12 ++++++++++++ ui/src/ui/views/agents-utils.ts | 2 +- ui/src/ui/views/login-gate.ts | 3 ++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 1b5390adc15..74644f07708 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -78,6 +78,7 @@ import "./components/dashboard-header.ts"; import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts"; import { icons } from "./icons.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; +import { agentLogoUrl } from "./views/agents-utils.ts"; import { resolveAgentConfig, resolveConfiguredCronModelSuggestions, @@ -450,7 +451,7 @@ export function renderApp(state: AppViewState) { ? nothing : html` ` diff --git a/ui/src/ui/views/agents-utils.test.ts b/ui/src/ui/views/agents-utils.test.ts index eea9bec03c8..8935520c2a4 100644 --- a/ui/src/ui/views/agents-utils.test.ts +++ b/ui/src/ui/views/agents-utils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + agentLogoUrl, resolveConfiguredCronModelSuggestions, resolveEffectiveModelFallbacks, sortLocaleStrings, @@ -98,3 +99,14 @@ describe("sortLocaleStrings", () => { expect(sortLocaleStrings(new Set(["beta", "alpha"]))).toEqual(["alpha", "beta"]); }); }); + +describe("agentLogoUrl", () => { + it("keeps base-mounted control UI logo paths absolute to the mount", () => { + expect(agentLogoUrl("/ui")).toBe("/ui/favicon.svg"); + expect(agentLogoUrl("/apps/openclaw/")).toBe("/apps/openclaw/favicon.svg"); + }); + + it("uses a route-relative fallback before basePath bootstrap finishes", () => { + expect(agentLogoUrl("")).toBe("favicon.svg"); + }); +}); diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index 45b39e5a77b..1eb28892bb5 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -215,7 +215,7 @@ export function resolveAgentAvatarUrl( export function agentLogoUrl(basePath: string): string { const base = basePath?.trim() ? basePath.replace(/\/$/, "") : ""; - return base ? `${base}/favicon.svg` : "/favicon.svg"; + return base ? `${base}/favicon.svg` : "favicon.svg"; } function isLikelyEmoji(value: string) { diff --git a/ui/src/ui/views/login-gate.ts b/ui/src/ui/views/login-gate.ts index d63a12c047e..77613822cdf 100644 --- a/ui/src/ui/views/login-gate.ts +++ b/ui/src/ui/views/login-gate.ts @@ -4,10 +4,11 @@ import { renderThemeToggle } from "../app-render.helpers.ts"; import type { AppViewState } from "../app-view-state.ts"; import { icons } from "../icons.ts"; import { normalizeBasePath } from "../navigation.ts"; +import { agentLogoUrl } from "./agents-utils.ts"; export function renderLoginGate(state: AppViewState) { const basePath = normalizeBasePath(state.basePath ?? ""); - const faviconSrc = basePath ? `${basePath}/favicon.svg` : "/favicon.svg"; + const faviconSrc = agentLogoUrl(basePath); return html` `; } @@ -316,11 +318,139 @@ async function refreshSessionOptions(state: AppViewState) { await loadSessions(state as unknown as Parameters[0], { activeMinutes: 0, limit: 0, - includeGlobal: false, - includeUnknown: false, + includeGlobal: true, + includeUnknown: true, }); } +function resolveActiveSessionRow(state: AppViewState) { + return state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey); +} + +function resolveModelOverrideValue(state: AppViewState): string { + // Prefer the local cache — it reflects in-flight patches before sessionsResult refreshes. + const cached = state.chatModelOverrides[state.sessionKey]; + if (typeof cached === "string") { + return cached.trim(); + } + // cached === null means explicitly cleared to default. + if (cached === null) { + return ""; + } + // No local override recorded yet — fall back to server data. + const activeRow = resolveActiveSessionRow(state); + if (activeRow) { + return typeof activeRow.model === "string" ? activeRow.model.trim() : ""; + } + return ""; +} + +function resolveDefaultModelValue(state: AppViewState): string { + const model = state.sessionsResult?.defaults?.model; + return typeof model === "string" ? model.trim() : ""; +} + +function buildChatModelOptions( + catalog: ModelCatalogEntry[], + currentOverride: string, + defaultModel: string, +): Array<{ value: string; label: string }> { + const seen = new Set(); + const options: Array<{ value: string; label: string }> = []; + const addOption = (value: string, label?: string) => { + const trimmed = value.trim(); + if (!trimmed) { + return; + } + const key = trimmed.toLowerCase(); + if (seen.has(key)) { + return; + } + seen.add(key); + options.push({ value: trimmed, label: label ?? trimmed }); + }; + + for (const entry of catalog) { + const provider = entry.provider?.trim(); + addOption(entry.id, provider ? `${entry.id} · ${provider}` : entry.id); + } + + if (currentOverride) { + addOption(currentOverride); + } + if (defaultModel) { + addOption(defaultModel); + } + return options; +} + +function renderChatModelSelect(state: AppViewState) { + const currentOverride = resolveModelOverrideValue(state); + const defaultModel = resolveDefaultModelValue(state); + const options = buildChatModelOptions( + state.chatModelCatalog ?? [], + currentOverride, + defaultModel, + ); + const defaultLabel = defaultModel ? `Default (${defaultModel})` : "Default model"; + const busy = + state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null; + const disabled = + !state.connected || busy || (state.chatModelsLoading && options.length === 0) || !state.client; + return html` + + `; +} + +async function switchChatModel(state: AppViewState, nextModel: string) { + if (!state.client || !state.connected) { + return; + } + const currentOverride = resolveModelOverrideValue(state); + if (currentOverride === nextModel) { + return; + } + const targetSessionKey = state.sessionKey; + const prevOverride = state.chatModelOverrides[targetSessionKey]; + state.lastError = null; + // Write the override cache immediately so the picker stays in sync during the RPC round-trip. + state.chatModelOverrides = { + ...state.chatModelOverrides, + [targetSessionKey]: nextModel || null, + }; + try { + await state.client.request("sessions.patch", { + key: targetSessionKey, + model: nextModel || null, + }); + await refreshSessionOptions(state); + } catch (err) { + // Roll back so the picker reflects the actual server model. + state.chatModelOverrides = { ...state.chatModelOverrides, [targetSessionKey]: prevOverride }; + state.lastError = `Failed to set model: ${String(err)}`; + } +} + /* ── Channel display labels ────────────────────────────── */ const CHANNEL_LABELS: Record = { bluebubbles: "iMessage", @@ -504,6 +634,9 @@ export function resolveSessionOptionGroups( }; for (const row of rows) { + if (row.key !== sessionKey && (row.kind === "global" || row.kind === "unknown")) { + continue; + } if (hideCron && row.key !== sessionKey && isCronSessionKey(row.key)) { continue; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 74644f07708..b1ddf9e323c 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -264,33 +264,6 @@ type AutomationSectionKey = (typeof AUTOMATION_SECTION_KEYS)[number]; type InfrastructureSectionKey = (typeof INFRASTRUCTURE_SECTION_KEYS)[number]; type AiAgentsSectionKey = (typeof AI_AGENTS_SECTION_KEYS)[number]; -const NAV_WIDTH_MIN = 200; -const NAV_WIDTH_MAX = 400; - -function handleNavResizeStart(e: MouseEvent, state: AppViewState) { - e.preventDefault(); - const startX = e.clientX; - const startWidth = state.settings.navWidth; - - const onMove = (ev: MouseEvent) => { - const delta = ev.clientX - startX; - const next = Math.round(Math.min(NAV_WIDTH_MAX, Math.max(NAV_WIDTH_MIN, startWidth + delta))); - state.applySettings({ ...state.settings, navWidth: next }); - }; - - const onUp = () => { - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - }; - - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); -} - function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { const list = state.agentsList?.agents ?? []; const parsed = parseAgentSessionKey(state.sessionKey); @@ -330,6 +303,8 @@ export function renderApp(state: AppViewState) { const chatDisabledReason = state.connected ? null : t("chat.disconnected"); const isChat = state.tab === "chat"; const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding); + const navDrawerOpen = Boolean(state.navDrawerOpen && !chatFocus && !state.onboarding); + const navCollapsed = Boolean(state.settings.navCollapsed && !navDrawerOpen); const showThinking = state.onboarding ? false : state.settings.chatShowThinking; const assistantAvatarUrl = resolveAssistantAvatarUrl(state); const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null; @@ -423,144 +398,164 @@ export function renderApp(state: AppViewState) { }, })}
+
- - -
- ${renderTopbarThemeModeToggle(state)} +
+ +
+ +
+
+ +
+ ${renderTopbarThemeModeToggle(state)} +
+
-
+
+
- - - ${ - !state.settings.navCollapsed && !chatFocus - ? html` - - ` - : nothing - } +
${ diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index b659c195754..ad2910625b6 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -71,11 +71,15 @@ export type AppViewState = { fallbackStatus: FallbackStatus | null; chatAvatarUrl: string | null; chatThinkingLevel: string | null; + chatModelOverrides: Record; + chatModelsLoading: boolean; + chatModelCatalog: ModelCatalogEntry[]; chatQueue: ChatQueueItem[]; chatManualRefreshInFlight: boolean; nodesLoading: boolean; nodes: Array>; chatNewMessagesBelow: boolean; + navDrawerOpen: boolean; sidebarOpen: boolean; sidebarContent: string | null; sidebarError: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 7f936722ca5..1b3971a41f6 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -158,9 +158,13 @@ export class OpenClawApp extends LitElement { @state() fallbackStatus: FallbackStatus | null = null; @state() chatAvatarUrl: string | null = null; @state() chatThinkingLevel: string | null = null; + @state() chatModelOverrides: Record = {}; + @state() chatModelsLoading = false; + @state() chatModelCatalog: ModelCatalogEntry[] = []; @state() chatQueue: ChatQueueItem[] = []; @state() chatAttachments: ChatAttachment[] = []; @state() chatManualRefreshInFlight = false; + @state() navDrawerOpen = false; onSlashAction?: (action: string) => void; @@ -541,6 +545,7 @@ export class OpenClawApp extends LitElement { setTab(next: Tab) { setTabInternal(this as unknown as Parameters[0], next); + this.navDrawerOpen = false; } setTheme(next: ThemeName, context?: Parameters[2]) { diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 9a7f7d2eeb2..6b584be512b 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -174,7 +174,11 @@ export function renderMessageGroup( ${timestamp} ${renderMessageMeta(meta)} ${normalizedRole === "assistant" && isTtsSupported() ? renderTtsButton(group) : nothing} - ${opts.onDelete ? renderDeleteButton(opts.onDelete) : nothing} + ${ + opts.onDelete + ? renderDeleteButton(opts.onDelete, normalizedRole === "user" ? "left" : "right") + : nothing + } @@ -312,6 +316,8 @@ function extractGroupText(group: MessageGroup): string { const SKIP_DELETE_CONFIRM_KEY = "openclaw:skipDeleteConfirm"; +type DeleteConfirmSide = "left" | "right"; + function shouldSkipDeleteConfirm(): boolean { try { return localStorage.getItem(SKIP_DELETE_CONFIRM_KEY) === "1"; @@ -320,7 +326,7 @@ function shouldSkipDeleteConfirm(): boolean { } } -function renderDeleteButton(onDelete: () => void) { +function renderDeleteButton(onDelete: () => void, side: DeleteConfirmSide) { return html` - ` - : nothing - } +
+ + + + + + props.onSearchChange((e.target as HTMLInputElement).value)} + /> + ${ + props.searchQuery + ? html` + + ` + : nothing + } +
` : nothing diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index ad0f4ee63c0..b9338971c8e 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/ui/views/skills.ts @@ -61,6 +61,8 @@ export function renderSkills(props: SkillsProps) { .value=${props.filter} @input=${(e: Event) => props.onFilterChange((e.target as HTMLInputElement).value)} placeholder="Search skills" + autocomplete="off" + name="skills-filter" />
${filtered.length} shown
From 55e79adf6916ffed4b745744793f1502338f1b92 Mon Sep 17 00:00:00 2001 From: Max aka Mosheh Date: Fri, 13 Mar 2026 17:09:51 +0200 Subject: [PATCH 087/113] fix: resolve target agent workspace for cross-agent subagent spawns (#40176) Merged via squash. Prepared head SHA: 2378e40383f194557c582b8e28976e57dfe03e8a Co-authored-by: moshehbenavraham <17122072+moshehbenavraham@users.noreply.github.com> Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com> Reviewed-by: @mcaxtr --- CHANGELOG.md | 1 + src/agents/spawned-context.test.ts | 30 ++- src/agents/spawned-context.ts | 14 +- src/agents/subagent-spawn.ts | 7 +- src/agents/subagent-spawn.workspace.test.ts | 192 ++++++++++++++++++++ 5 files changed, 234 insertions(+), 10 deletions(-) create mode 100644 src/agents/subagent-spawn.workspace.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 34c7cab869f..4b1cf0c9e98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -333,6 +333,7 @@ Docs: https://docs.openclaw.ai - Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb. - Agents/embedded runner: carry provider-observed overflow token counts into compaction so overflow retries and diagnostics use the rejected live prompt size instead of only transcript estimates. (#40357) thanks @rabsef-bicrym. - Agents/compaction transcript updates: emit a transcript-update event immediately after successful embedded compaction so downstream listeners observe the post-compact transcript without waiting for a later write. (#25558) thanks @rodrigouroz. +- Agents/sessions_spawn: use the target agent workspace for cross-agent spawned runs instead of inheriting the caller workspace, so child sessions load the correct workspace-scoped instructions and persona files. (#40176) Thanks @moshehbenavraham. ## 2026.3.7 diff --git a/src/agents/spawned-context.test.ts b/src/agents/spawned-context.test.ts index 964bf47a789..3f163eb3030 100644 --- a/src/agents/spawned-context.test.ts +++ b/src/agents/spawned-context.test.ts @@ -44,18 +44,44 @@ describe("mapToolContextToSpawnedRunMetadata", () => { }); describe("resolveSpawnedWorkspaceInheritance", () => { + const config = { + agents: { + list: [ + { id: "main", workspace: "/tmp/workspace-main" }, + { id: "ops", workspace: "/tmp/workspace-ops" }, + ], + }, + }; + it("prefers explicit workspaceDir when provided", () => { const resolved = resolveSpawnedWorkspaceInheritance({ - config: {}, + config, requesterSessionKey: "agent:main:subagent:parent", explicitWorkspaceDir: " /tmp/explicit ", }); expect(resolved).toBe("/tmp/explicit"); }); + it("prefers targetAgentId over requester session agent for cross-agent spawns", () => { + const resolved = resolveSpawnedWorkspaceInheritance({ + config, + targetAgentId: "ops", + requesterSessionKey: "agent:main:subagent:parent", + }); + expect(resolved).toBe("/tmp/workspace-ops"); + }); + + it("falls back to requester session agent when targetAgentId is missing", () => { + const resolved = resolveSpawnedWorkspaceInheritance({ + config, + requesterSessionKey: "agent:main:subagent:parent", + }); + expect(resolved).toBe("/tmp/workspace-main"); + }); + it("returns undefined for missing requester context", () => { const resolved = resolveSpawnedWorkspaceInheritance({ - config: {}, + config, requesterSessionKey: undefined, explicitWorkspaceDir: undefined, }); diff --git a/src/agents/spawned-context.ts b/src/agents/spawned-context.ts index 32a4d299e74..d0919c86baa 100644 --- a/src/agents/spawned-context.ts +++ b/src/agents/spawned-context.ts @@ -58,6 +58,7 @@ export function mapToolContextToSpawnedRunMetadata( export function resolveSpawnedWorkspaceInheritance(params: { config: OpenClawConfig; + targetAgentId?: string; requesterSessionKey?: string; explicitWorkspaceDir?: string | null; }): string | undefined { @@ -65,12 +66,13 @@ export function resolveSpawnedWorkspaceInheritance(params: { if (explicit) { return explicit; } - const requesterAgentId = params.requesterSessionKey - ? parseAgentSessionKey(params.requesterSessionKey)?.agentId - : undefined; - return requesterAgentId - ? resolveAgentWorkspaceDir(params.config, normalizeAgentId(requesterAgentId)) - : undefined; + // For cross-agent spawns, use the target agent's workspace instead of the requester's. + const agentId = + params.targetAgentId ?? + (params.requesterSessionKey + ? parseAgentSessionKey(params.requesterSessionKey)?.agentId + : undefined); + return agentId ? resolveAgentWorkspaceDir(params.config, normalizeAgentId(agentId)) : undefined; } export function resolveIngressWorkspaceOverrideForSpawnedRun( diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index a4a6229c715..1750d948e6c 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -576,8 +576,11 @@ export async function spawnSubagentDirect( ...toolSpawnMetadata, workspaceDir: resolveSpawnedWorkspaceInheritance({ config: cfg, - requesterSessionKey: requesterInternalKey, - explicitWorkspaceDir: toolSpawnMetadata.workspaceDir, + targetAgentId, + // For cross-agent spawns, ignore the caller's inherited workspace; + // let targetAgentId resolve the correct workspace instead. + explicitWorkspaceDir: + targetAgentId !== requesterAgentId ? undefined : toolSpawnMetadata.workspaceDir, }), }); const spawnLineagePatchError = await patchChildSession({ diff --git a/src/agents/subagent-spawn.workspace.test.ts b/src/agents/subagent-spawn.workspace.test.ts new file mode 100644 index 00000000000..fef6bc7515c --- /dev/null +++ b/src/agents/subagent-spawn.workspace.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { spawnSubagentDirect } from "./subagent-spawn.js"; + +type TestAgentConfig = { + id?: string; + workspace?: string; + subagents?: { + allowAgents?: string[]; + }; +}; + +type TestConfig = { + agents?: { + list?: TestAgentConfig[]; + }; +}; + +const hoisted = vi.hoisted(() => ({ + callGatewayMock: vi.fn(), + configOverride: {} as Record, + registerSubagentRunMock: vi.fn(), +})); + +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => hoisted.configOverride, + }; +}); + +vi.mock("@mariozechner/pi-ai/oauth", () => ({ + getOAuthApiKey: () => "", + getOAuthProviders: () => [], +})); + +vi.mock("./subagent-registry.js", () => ({ + countActiveRunsForSession: () => 0, + registerSubagentRun: (args: unknown) => hoisted.registerSubagentRunMock(args), +})); + +vi.mock("./subagent-announce.js", () => ({ + buildSubagentSystemPrompt: () => "system-prompt", +})); + +vi.mock("./subagent-depth.js", () => ({ + getSubagentDepthFromSessionStore: () => 0, +})); + +vi.mock("./model-selection.js", () => ({ + resolveSubagentSpawnModelSelection: () => undefined, +})); + +vi.mock("./sandbox/runtime-status.js", () => ({ + resolveSandboxRuntimeStatus: () => ({ sandboxed: false }), +})); + +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => ({ hasHooks: () => false }), +})); + +vi.mock("../utils/delivery-context.js", () => ({ + normalizeDeliveryContext: (value: unknown) => value, +})); + +vi.mock("./tools/sessions-helpers.js", () => ({ + resolveMainSessionAlias: () => ({ mainKey: "main", alias: "main" }), + resolveInternalSessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main", + resolveDisplaySessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main", +})); + +vi.mock("./agent-scope.js", () => ({ + resolveAgentConfig: (cfg: TestConfig, agentId: string) => + cfg.agents?.list?.find((entry) => entry.id === agentId), + resolveAgentWorkspaceDir: (cfg: TestConfig, agentId: string) => + cfg.agents?.list?.find((entry) => entry.id === agentId)?.workspace ?? + `/tmp/workspace-${agentId}`, +})); + +function createConfigOverride(overrides?: Record) { + return { + session: { + mainKey: "main", + scope: "per-sender", + }, + agents: { + list: [ + { + id: "main", + workspace: "/tmp/workspace-main", + }, + ], + }, + ...overrides, + }; +} + +function setupGatewayMock() { + hoisted.callGatewayMock.mockImplementation( + async (opts: { method?: string; params?: Record }) => { + if (opts.method === "sessions.patch") { + return { ok: true }; + } + if (opts.method === "sessions.delete") { + return { ok: true }; + } + if (opts.method === "agent") { + return { runId: "run-1" }; + } + return {}; + }, + ); +} + +function getRegisteredRun() { + return hoisted.registerSubagentRunMock.mock.calls.at(0)?.[0] as + | Record + | undefined; +} + +describe("spawnSubagentDirect workspace inheritance", () => { + beforeEach(() => { + hoisted.callGatewayMock.mockClear(); + hoisted.registerSubagentRunMock.mockClear(); + hoisted.configOverride = createConfigOverride(); + setupGatewayMock(); + }); + + it("uses the target agent workspace for cross-agent spawns", async () => { + hoisted.configOverride = createConfigOverride({ + agents: { + list: [ + { + id: "main", + workspace: "/tmp/workspace-main", + subagents: { + allowAgents: ["ops"], + }, + }, + { + id: "ops", + workspace: "/tmp/workspace-ops", + }, + ], + }, + }); + + const result = await spawnSubagentDirect( + { + task: "inspect workspace", + agentId: "ops", + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "telegram", + agentAccountId: "123", + agentTo: "456", + workspaceDir: "/tmp/requester-workspace", + }, + ); + + expect(result.status).toBe("accepted"); + expect(getRegisteredRun()).toMatchObject({ + workspaceDir: "/tmp/workspace-ops", + }); + }); + + it("preserves the inherited workspace for same-agent spawns", async () => { + const result = await spawnSubagentDirect( + { + task: "inspect workspace", + agentId: "main", + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "telegram", + agentAccountId: "123", + agentTo: "456", + workspaceDir: "/tmp/requester-workspace", + }, + ); + + expect(result.status).toBe("accepted"); + expect(getRegisteredRun()).toMatchObject({ + workspaceDir: "/tmp/requester-workspace", + }); + }); +}); From 394fd87c2c491790c1f79d6eb37ba40de7178cbc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 15:37:21 +0000 Subject: [PATCH 088/113] fix: clarify gated core tool warnings --- CHANGELOG.md | 1 + src/agents/tool-policy-pipeline.test.ts | 25 +++++++++++++++++++++ src/agents/tool-policy-pipeline.ts | 30 ++++++++++++++++++++++--- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b1cf0c9e98..cae46427d1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark. - macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so `openclaw onboard --install-daemon` no longer false-fails on slower Macs and fresh VM snapshots. - Agents/compaction: preserve safeguard compaction summary language continuity via default and configurable custom instructions so persona drift is reduced after auto-compaction. (#10456) Thanks @keepitmello. +- Agents/tool warnings: distinguish gated core tools like `apply_patch` from plugin-only unknown entries in `tools.profile` warnings, so unavailable core tools now report current runtime/provider/model/config gating instead of suggesting a missing plugin. ## 2026.3.12 diff --git a/src/agents/tool-policy-pipeline.test.ts b/src/agents/tool-policy-pipeline.test.ts index 9d0a9d5846f..70d4301d42a 100644 --- a/src/agents/tool-policy-pipeline.test.ts +++ b/src/agents/tool-policy-pipeline.test.ts @@ -45,6 +45,31 @@ describe("tool-policy-pipeline", () => { expect(warnings[0]).toContain("unknown entries (wat)"); }); + test("warns gated core tools as unavailable instead of plugin-only unknowns", () => { + const warnings: string[] = []; + const tools = [{ name: "exec" }] as unknown as DummyTool[]; + applyToolPolicyPipeline({ + // oxlint-disable-next-line typescript/no-explicit-any + tools: tools as any, + // oxlint-disable-next-line typescript/no-explicit-any + toolMeta: () => undefined, + warn: (msg) => warnings.push(msg), + steps: [ + { + policy: { allow: ["apply_patch"] }, + label: "tools.profile (coding)", + stripPluginOnlyAllowlist: true, + }, + ], + }); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain("unknown entries (apply_patch)"); + expect(warnings[0]).toContain( + "shipped core tools but unavailable in the current runtime/provider/model/config", + ); + expect(warnings[0]).not.toContain("unless the plugin is enabled"); + }); + test("applies allowlist filtering when core tools are explicitly listed", () => { const tools = [{ name: "exec" }, { name: "process" }] as unknown as DummyTool[]; const filtered = applyToolPolicyPipeline({ diff --git a/src/agents/tool-policy-pipeline.ts b/src/agents/tool-policy-pipeline.ts index d3304a020d6..70a7bddaf29 100644 --- a/src/agents/tool-policy-pipeline.ts +++ b/src/agents/tool-policy-pipeline.ts @@ -1,5 +1,6 @@ import { filterToolsByPolicy } from "./pi-tools.policy.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; +import { isKnownCoreToolId } from "./tool-catalog.js"; import { buildPluginToolGroups, expandPolicyWithPluginGroups, @@ -91,9 +92,15 @@ export function applyToolPolicyPipeline(params: { const resolved = stripPluginOnlyAllowlist(policy, pluginGroups, coreToolNames); if (resolved.unknownAllowlist.length > 0) { const entries = resolved.unknownAllowlist.join(", "); - const suffix = resolved.strippedAllowlist - ? "Ignoring allowlist so core tools remain available. Use tools.alsoAllow for additive plugin tool enablement." - : "These entries won't match any tool unless the plugin is enabled."; + const gatedCoreEntries = resolved.unknownAllowlist.filter((entry) => + isKnownCoreToolId(entry), + ); + const otherEntries = resolved.unknownAllowlist.filter((entry) => !isKnownCoreToolId(entry)); + const suffix = describeUnknownAllowlistSuffix({ + strippedAllowlist: resolved.strippedAllowlist, + hasGatedCoreEntries: gatedCoreEntries.length > 0, + hasOtherEntries: otherEntries.length > 0, + }); params.warn( `tools: ${step.label} allowlist contains unknown entries (${entries}). ${suffix}`, ); @@ -106,3 +113,20 @@ export function applyToolPolicyPipeline(params: { } return filtered; } + +function describeUnknownAllowlistSuffix(params: { + strippedAllowlist: boolean; + hasGatedCoreEntries: boolean; + hasOtherEntries: boolean; +}): string { + const preface = params.strippedAllowlist + ? "Ignoring allowlist so core tools remain available." + : ""; + const detail = + params.hasGatedCoreEntries && params.hasOtherEntries + ? "Some entries are shipped core tools but unavailable in the current runtime/provider/model/config; other entries won't match any tool unless the plugin is enabled." + : params.hasGatedCoreEntries + ? "These entries are shipped core tools but unavailable in the current runtime/provider/model/config." + : "These entries won't match any tool unless the plugin is enabled."; + return preface ? `${preface} ${detail}` : detail; +} From 202765c8109b2c2320610958cf65795b19fade8c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:22:13 +0000 Subject: [PATCH 089/113] fix: quiet local windows gateway auth noise --- CHANGELOG.md | 1 + src/gateway/call.test.ts | 14 ++++++++++++++ src/gateway/call.ts | 20 +++++++++++++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cae46427d1e..2a8270dd154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups. +- Windows/gateway auth: stop attaching device identity on local loopback shared-token and password gateway calls, so native Windows agent replies no longer log stale `device signature expired` fallback noise before succeeding. - Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus. - Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv. - Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman. diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 87590e58d49..e4d8d28f562 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -14,6 +14,7 @@ let lastClientOptions: { password?: string; tlsFingerprint?: string; scopes?: string[]; + deviceIdentity?: unknown; onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise; onClose?: (code: number, reason: string) => void; } | null = null; @@ -197,6 +198,19 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.token).toBe("explicit-token"); }); + it("does not attach device identity for local loopback shared-token auth", async () => { + setLocalLoopbackGatewayConfig(); + + await callGateway({ + method: "health", + token: "explicit-token", + }); + + expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789"); + expect(lastClientOptions?.token).toBe("explicit-token"); + expect(lastClientOptions?.deviceIdentity).toBeUndefined(); + }); + it("uses OPENCLAW_GATEWAY_URL env override in remote mode when remote URL is missing", async () => { loadConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", remote: {} }, diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 31d11ac14b9..8e8f449fc59 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -81,6 +81,22 @@ export type GatewayConnectionDetails = { message: string; }; +function shouldAttachDeviceIdentityForGatewayCall(params: { + url: string; + token?: string; + password?: string; +}): boolean { + if (!(params.token || params.password)) { + return true; + } + try { + const parsed = new URL(params.url); + return !["127.0.0.1", "::1", "localhost"].includes(parsed.hostname); + } catch { + return true; + } +} + export type ExplicitGatewayAuth = { token?: string; password?: string; @@ -818,7 +834,9 @@ async function executeGatewayRequestWithScopes(params: { mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI, role: "operator", scopes, - deviceIdentity: loadOrCreateDeviceIdentity(), + deviceIdentity: shouldAttachDeviceIdentityForGatewayCall({ url, token, password }) + ? loadOrCreateDeviceIdentity() + : undefined, minProtocol: opts.minProtocol ?? PROTOCOL_VERSION, maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION, onHelloOk: async (hello) => { From f4ed3170832db59a9761178494126ca3307ec804 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:24:58 +0000 Subject: [PATCH 090/113] refactor: deduplicate acpx availability checks --- extensions/acpx/src/runtime.ts | 155 +++++++++++++++++++++------------ 1 file changed, 101 insertions(+), 54 deletions(-) diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index b0f166584d5..ad3fb23c709 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -13,7 +13,7 @@ import type { } from "openclaw/plugin-sdk/acpx"; import { AcpRuntimeError } from "openclaw/plugin-sdk/acpx"; import { toAcpMcpServers, type ResolvedAcpxPluginConfig } from "./config.js"; -import { checkAcpxVersion } from "./ensure.js"; +import { checkAcpxVersion, type AcpxVersionCheckResult } from "./ensure.js"; import { parseJsonLines, parsePromptEventLine, @@ -51,6 +51,28 @@ const ACPX_CAPABILITIES: AcpRuntimeCapabilities = { controls: ["session/set_mode", "session/set_config_option", "session/status"], }; +type AcpxHealthCheckResult = + | { + ok: true; + versionCheck: Extract; + } + | { + ok: false; + failure: + | { + kind: "version-check"; + versionCheck: Extract; + } + | { + kind: "help-check"; + result: Awaited>; + } + | { + kind: "exception"; + error: unknown; + }; + }; + function formatPermissionModeGuidance(): string { return "Configure plugins.entries.acpx.config.permissionMode to one of: approve-reads, approve-all, deny-all."; } @@ -165,35 +187,71 @@ export class AcpxRuntime implements AcpRuntime { ); } - async probeAvailability(): Promise { - const versionCheck = await checkAcpxVersion({ + private async checkVersion(): Promise { + return await checkAcpxVersion({ command: this.config.command, cwd: this.config.cwd, expectedVersion: this.config.expectedVersion, stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, spawnOptions: this.spawnCommandOptions, }); + } + + private async runHelpCheck(): Promise>> { + return await spawnAndCollect( + { + command: this.config.command, + args: ["--help"], + cwd: this.config.cwd, + stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, + }, + this.spawnCommandOptions, + ); + } + + private async checkHealth(): Promise { + const versionCheck = await this.checkVersion(); if (!versionCheck.ok) { - this.healthy = false; - return; + return { + ok: false, + failure: { + kind: "version-check", + versionCheck, + }, + }; } try { - const result = await spawnAndCollect( - { - command: this.config.command, - args: ["--help"], - cwd: this.config.cwd, - stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, + const result = await this.runHelpCheck(); + if (result.error != null || (result.code ?? 0) !== 0) { + return { + ok: false, + failure: { + kind: "help-check", + result, + }, + }; + } + return { + ok: true, + versionCheck, + }; + } catch (error) { + return { + ok: false, + failure: { + kind: "exception", + error, }, - this.spawnCommandOptions, - ); - this.healthy = result.error == null && (result.code ?? 0) === 0; - } catch { - this.healthy = false; + }; } } + async probeAvailability(): Promise { + const result = await this.checkHealth(); + this.healthy = result.ok; + } + async ensureSession(input: AcpRuntimeEnsureInput): Promise { const sessionName = asTrimmedString(input.sessionKey); if (!sessionName) { @@ -494,14 +552,9 @@ export class AcpxRuntime implements AcpRuntime { } async doctor(): Promise { - const versionCheck = await checkAcpxVersion({ - command: this.config.command, - cwd: this.config.cwd, - expectedVersion: this.config.expectedVersion, - stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, - spawnOptions: this.spawnCommandOptions, - }); - if (!versionCheck.ok) { + const result = await this.checkHealth(); + if (!result.ok && result.failure.kind === "version-check") { + const { versionCheck } = result.failure; this.healthy = false; const details = [ versionCheck.expectedVersion ? `expected=${versionCheck.expectedVersion}` : null, @@ -516,20 +569,12 @@ export class AcpxRuntime implements AcpRuntime { }; } - try { - const result = await spawnAndCollect( - { - command: this.config.command, - args: ["--help"], - cwd: this.config.cwd, - stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, - }, - this.spawnCommandOptions, - ); - if (result.error) { - const spawnFailure = resolveSpawnFailure(result.error, this.config.cwd); + if (!result.ok && result.failure.kind === "help-check") { + const { result: helpResult } = result.failure; + this.healthy = false; + if (helpResult.error) { + const spawnFailure = resolveSpawnFailure(helpResult.error, this.config.cwd); if (spawnFailure === "missing-command") { - this.healthy = false; return { ok: false, code: "ACP_BACKEND_UNAVAILABLE", @@ -538,42 +583,44 @@ export class AcpxRuntime implements AcpRuntime { }; } if (spawnFailure === "missing-cwd") { - this.healthy = false; return { ok: false, code: "ACP_BACKEND_UNAVAILABLE", message: `ACP runtime working directory does not exist: ${this.config.cwd}`, }; } - this.healthy = false; return { ok: false, code: "ACP_BACKEND_UNAVAILABLE", - message: result.error.message, - details: [String(result.error)], + message: helpResult.error.message, + details: [String(helpResult.error)], }; } - if ((result.code ?? 0) !== 0) { - this.healthy = false; - return { - ok: false, - code: "ACP_BACKEND_UNAVAILABLE", - message: result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`, - }; - } - this.healthy = true; return { - ok: true, - message: `acpx command available (${this.config.command}, version ${versionCheck.version}${this.config.expectedVersion ? `, expected ${this.config.expectedVersion}` : ""})`, + ok: false, + code: "ACP_BACKEND_UNAVAILABLE", + message: + helpResult.stderr.trim() || `acpx exited with code ${helpResult.code ?? "unknown"}`, }; - } catch (error) { + } + + if (!result.ok) { this.healthy = false; return { ok: false, code: "ACP_BACKEND_UNAVAILABLE", - message: error instanceof Error ? error.message : String(error), + message: + result.failure.error instanceof Error + ? result.failure.error.message + : String(result.failure.error), }; } + + this.healthy = true; + return { + ok: true, + message: `acpx command available (${this.config.command}, version ${result.versionCheck.version}${this.config.expectedVersion ? `, expected ${this.config.expectedVersion}` : ""})`, + }; } async cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise { From a37e25fa21aba307bc7dd3846a888989be43d0c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:25:54 +0000 Subject: [PATCH 091/113] refactor: deduplicate media store writes --- src/media/store.ts | 71 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 21 deletions(-) diff --git a/src/media/store.ts b/src/media/store.ts index ceb346a1f94..32acd951d32 100644 --- a/src/media/store.ts +++ b/src/media/store.ts @@ -255,6 +255,48 @@ export type SavedMedia = { contentType?: string; }; +function buildSavedMediaId(params: { + baseId: string; + ext: string; + originalFilename?: string; +}): string { + if (!params.originalFilename) { + return params.ext ? `${params.baseId}${params.ext}` : params.baseId; + } + + const base = path.parse(params.originalFilename).name; + const sanitized = sanitizeFilename(base); + return sanitized + ? `${sanitized}---${params.baseId}${params.ext}` + : `${params.baseId}${params.ext}`; +} + +function buildSavedMediaResult(params: { + dir: string; + id: string; + size: number; + contentType?: string; +}): SavedMedia { + return { + id: params.id, + path: path.join(params.dir, params.id), + size: params.size, + contentType: params.contentType, + }; +} + +async function writeSavedMediaBuffer(params: { + dir: string; + id: string; + buffer: Buffer; +}): Promise { + const dest = path.join(params.dir, params.id); + await retryAfterRecreatingDir(params.dir, () => + fs.writeFile(dest, params.buffer, { mode: MEDIA_FILE_MODE }), + ); + return dest; +} + export type SaveMediaSourceErrorCode = | "invalid-path" | "not-found" @@ -321,20 +363,19 @@ export async function saveMediaSource( filePath: source, }); const ext = extensionForMime(mime) ?? path.extname(new URL(source).pathname); - const id = ext ? `${baseId}${ext}` : baseId; + const id = buildSavedMediaId({ baseId, ext }); const finalDest = path.join(dir, id); await fs.rename(tempDest, finalDest); - return { id, path: finalDest, size, contentType: mime }; + return buildSavedMediaResult({ dir, id, size, contentType: mime }); } // local path try { const { buffer, stat } = await readLocalFileSafely({ filePath: source, maxBytes: MAX_BYTES }); const mime = await detectMime({ buffer, filePath: source }); const ext = extensionForMime(mime) ?? path.extname(source); - const id = ext ? `${baseId}${ext}` : baseId; - const dest = path.join(dir, id); - await retryAfterRecreatingDir(dir, () => fs.writeFile(dest, buffer, { mode: MEDIA_FILE_MODE })); - return { id, path: dest, size: stat.size, contentType: mime }; + const id = buildSavedMediaId({ baseId, ext }); + await writeSavedMediaBuffer({ dir, id, buffer }); + return buildSavedMediaResult({ dir, id, size: stat.size, contentType: mime }); } catch (err) { if (err instanceof SafeOpenError) { throw toSaveMediaSourceError(err); @@ -359,19 +400,7 @@ export async function saveMediaBuffer( const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined); const mime = await detectMime({ buffer, headerMime: contentType }); const ext = headerExt ?? extensionForMime(mime) ?? ""; - - let id: string; - if (originalFilename) { - // Embed original name: {sanitized}---{uuid}.ext - const base = path.parse(originalFilename).name; - const sanitized = sanitizeFilename(base); - id = sanitized ? `${sanitized}---${uuid}${ext}` : `${uuid}${ext}`; - } else { - // Legacy: just UUID - id = ext ? `${uuid}${ext}` : uuid; - } - - const dest = path.join(dir, id); - await retryAfterRecreatingDir(dir, () => fs.writeFile(dest, buffer, { mode: MEDIA_FILE_MODE })); - return { id, path: dest, size: buffer.byteLength, contentType: mime }; + const id = buildSavedMediaId({ baseId: uuid, ext, originalFilename }); + await writeSavedMediaBuffer({ dir, id, buffer }); + return buildSavedMediaResult({ dir, id, size: buffer.byteLength, contentType: mime }); } From 501837058cb811d0f310b2473b2bfd18d2b562ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:26:42 +0000 Subject: [PATCH 092/113] refactor: share outbound media payload sequencing --- .../plugins/outbound/direct-text-media.ts | 56 +++++++++++++------ src/channels/plugins/outbound/telegram.ts | 27 ++++----- 2 files changed, 52 insertions(+), 31 deletions(-) diff --git a/src/channels/plugins/outbound/direct-text-media.ts b/src/channels/plugins/outbound/direct-text-media.ts index 9617798325d..ea813fcf75b 100644 --- a/src/channels/plugins/outbound/direct-text-media.ts +++ b/src/channels/plugins/outbound/direct-text-media.ts @@ -28,34 +28,58 @@ type SendPayloadAdapter = Pick< "sendMedia" | "sendText" | "chunker" | "textChunkLimit" >; +export function resolvePayloadMediaUrls(payload: SendPayloadContext["payload"]): string[] { + return payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : []; +} + +export async function sendPayloadMediaSequence(params: { + text: string; + mediaUrls: readonly string[]; + send: (input: { + text: string; + mediaUrl: string; + index: number; + isFirst: boolean; + }) => Promise; +}): Promise { + let lastResult: TResult | undefined; + for (let i = 0; i < params.mediaUrls.length; i += 1) { + const mediaUrl = params.mediaUrls[i]; + if (!mediaUrl) { + continue; + } + lastResult = await params.send({ + text: i === 0 ? params.text : "", + mediaUrl, + index: i, + isFirst: i === 0, + }); + } + return lastResult; +} + export async function sendTextMediaPayload(params: { channel: string; ctx: SendPayloadContext; adapter: SendPayloadAdapter; }): Promise { const text = params.ctx.payload.text ?? ""; - const urls = params.ctx.payload.mediaUrls?.length - ? params.ctx.payload.mediaUrls - : params.ctx.payload.mediaUrl - ? [params.ctx.payload.mediaUrl] - : []; + const urls = resolvePayloadMediaUrls(params.ctx.payload); if (!text && urls.length === 0) { return { channel: params.channel, messageId: "" }; } if (urls.length > 0) { - let lastResult = await params.adapter.sendMedia!({ - ...params.ctx, + const lastResult = await sendPayloadMediaSequence({ text, - mediaUrl: urls[0], + mediaUrls: urls, + send: async ({ text, mediaUrl }) => + await params.adapter.sendMedia!({ + ...params.ctx, + text, + mediaUrl, + }), }); - for (let i = 1; i < urls.length; i++) { - lastResult = await params.adapter.sendMedia!({ - ...params.ctx, - text: "", - mediaUrl: urls[i], - }); - } - return lastResult; + return lastResult ?? { channel: params.channel, messageId: "" }; } const limit = params.adapter.textChunkLimit; const chunks = limit && params.adapter.chunker ? params.adapter.chunker(text, limit) : [text]; diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index 8af1b5831ee..c96a44a7047 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -8,6 +8,7 @@ import { } from "../../../telegram/outbound-params.js"; import { sendMessageTelegram } from "../../../telegram/send.js"; import type { ChannelOutboundAdapter } from "../types.js"; +import { resolvePayloadMediaUrls, sendPayloadMediaSequence } from "./direct-text-media.js"; type TelegramSendFn = typeof sendMessageTelegram; type TelegramSendOpts = Parameters[2]; @@ -55,11 +56,7 @@ export async function sendTelegramPayloadMessages(params: { const quoteText = typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; const text = params.payload.text ?? ""; - const mediaUrls = params.payload.mediaUrls?.length - ? params.payload.mediaUrls - : params.payload.mediaUrl - ? [params.payload.mediaUrl] - : []; + const mediaUrls = resolvePayloadMediaUrls(params.payload); const payloadOpts = { ...params.baseOpts, quoteText, @@ -73,16 +70,16 @@ export async function sendTelegramPayloadMessages(params: { } // Telegram allows reply_markup on media; attach buttons only to the first send. - let finalResult: Awaited> | undefined; - for (let i = 0; i < mediaUrls.length; i += 1) { - const mediaUrl = mediaUrls[i]; - const isFirst = i === 0; - finalResult = await params.send(params.to, isFirst ? text : "", { - ...payloadOpts, - mediaUrl, - ...(isFirst ? { buttons: telegramData?.buttons } : {}), - }); - } + const finalResult = await sendPayloadMediaSequence({ + text, + mediaUrls, + send: async ({ text, mediaUrl, isFirst }) => + await params.send(params.to, text, { + ...payloadOpts, + mediaUrl, + ...(isFirst ? { buttons: telegramData?.buttons } : {}), + }), + }); return finalResult ?? { messageId: "unknown", chatId: params.to }; } From 3f37afd18cd9083dac4c709acb44c11b73325a0f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:27:18 +0000 Subject: [PATCH 093/113] refactor: extract acpx event builders --- .../acpx/src/runtime-internals/events.ts | 98 ++++++++++--------- 1 file changed, 51 insertions(+), 47 deletions(-) diff --git a/extensions/acpx/src/runtime-internals/events.ts b/extensions/acpx/src/runtime-internals/events.ts index f83f4ddabb9..f0326bbe938 100644 --- a/extensions/acpx/src/runtime-internals/events.ts +++ b/extensions/acpx/src/runtime-internals/events.ts @@ -162,6 +162,39 @@ function resolveTextChunk(params: { }; } +function createTextDeltaEvent(params: { + content: string | null | undefined; + stream: "output" | "thought"; + tag?: AcpSessionUpdateTag; +}): AcpRuntimeEvent | null { + if (params.content == null || params.content.length === 0) { + return null; + } + return { + type: "text_delta", + text: params.content, + stream: params.stream, + ...(params.tag ? { tag: params.tag } : {}), + }; +} + +function createToolCallEvent(params: { + payload: Record; + tag: AcpSessionUpdateTag; +}): AcpRuntimeEvent { + const title = asTrimmedString(params.payload.title) || "tool call"; + const status = asTrimmedString(params.payload.status); + const toolCallId = asOptionalString(params.payload.toolCallId); + return { + type: "tool_call", + text: status ? `${title} (${status})` : title, + tag: params.tag, + ...(toolCallId ? { toolCallId } : {}), + ...(status ? { status } : {}), + title, + }; +} + export function parsePromptEventLine(line: string): AcpRuntimeEvent | null { const trimmed = line.trim(); if (!trimmed) { @@ -187,57 +220,28 @@ export function parsePromptEventLine(line: string): AcpRuntimeEvent | null { const tag = structured.tag; switch (type) { - case "text": { - const content = asString(payload.content); - if (content == null || content.length === 0) { - return null; - } - return { - type: "text_delta", - text: content, + case "text": + return createTextDeltaEvent({ + content: asString(payload.content), stream: "output", - ...(tag ? { tag } : {}), - }; - } - case "thought": { - const content = asString(payload.content); - if (content == null || content.length === 0) { - return null; - } - return { - type: "text_delta", - text: content, + tag, + }); + case "thought": + return createTextDeltaEvent({ + content: asString(payload.content), stream: "thought", - ...(tag ? { tag } : {}), - }; - } - case "tool_call": { - const title = asTrimmedString(payload.title) || "tool call"; - const status = asTrimmedString(payload.status); - const toolCallId = asOptionalString(payload.toolCallId); - return { - type: "tool_call", - text: status ? `${title} (${status})` : title, + tag, + }); + case "tool_call": + return createToolCallEvent({ + payload, tag: (tag ?? "tool_call") as AcpSessionUpdateTag, - ...(toolCallId ? { toolCallId } : {}), - ...(status ? { status } : {}), - title, - }; - } - case "tool_call_update": { - const title = asTrimmedString(payload.title) || "tool call"; - const status = asTrimmedString(payload.status); - const toolCallId = asOptionalString(payload.toolCallId); - const text = status ? `${title} (${status})` : title; - return { - type: "tool_call", - text, + }); + case "tool_call_update": + return createToolCallEvent({ + payload, tag: (tag ?? "tool_call_update") as AcpSessionUpdateTag, - ...(toolCallId ? { toolCallId } : {}), - ...(status ? { status } : {}), - title, - }; - } + }); case "agent_message_chunk": return resolveTextChunk({ payload, From 261a40dae12c181ce78b5572dfb94ca63e652886 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:28:31 +0000 Subject: [PATCH 094/113] fix: narrow acpx health failure handling --- extensions/acpx/src/runtime.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index ad3fb23c709..e55ef360424 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -606,13 +606,16 @@ export class AcpxRuntime implements AcpRuntime { if (!result.ok) { this.healthy = false; + const failure = result.failure; return { ok: false, code: "ACP_BACKEND_UNAVAILABLE", message: - result.failure.error instanceof Error - ? result.failure.error.message - : String(result.failure.error), + failure.kind === "exception" + ? failure.error instanceof Error + ? failure.error.message + : String(failure.error) + : "acpx backend unavailable", }; } From 41718404a1ddcce7726fbcbae278fc46ff31f959 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:41:22 +0000 Subject: [PATCH 095/113] 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 096/113] 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 097/113] 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 098/113] 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 099/113] 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 100/113] 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 101/113] 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 102/113] 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 103/113] 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 104/113] 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 105/113] 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 106/113] 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 107/113] 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 108/113] 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 109/113] 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 110/113] 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 111/113] 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 112/113] 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 113/113] 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