From 1969452c3fc2e6bf1637a59a32edd8e6ee2a9e42 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:19:20 +0100 Subject: [PATCH 01/25] fix: hide raw agent failures in group chats --- CHANGELOG.md | 1 + docs/concepts/messages.md | 5 + .../reply/agent-runner-execution.test.ts | 139 ++++++++++++++++-- .../reply/agent-runner-execution.ts | 94 ++++++++++-- 4 files changed, 217 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b4dda8396c..c84f73ac97c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex. - UI/Windows: quote resolved pnpm `.cmd` launcher paths before spawning UI install/build/test commands so Node installs under `C:\Program Files` no longer fail as `C:\Program`. Fixes #45275. Thanks @Kobevictor, @stoppieboy, and @iubns. - Codex/agent: translate `--thinking minimal` to `low` for modern Codex models (gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.2) at request build time so the first turn is accepted instead of paying a wasted call + retry-with-low fallback. Older Codex models still receive `minimal` directly. Fixes #71946. Thanks @hclsys. - Plugins/uninstall: remove tracked plugin files from their recorded managed extensions root even when the current state directory points somewhere else, so `openclaw plugins uninstall --force` does not leave the plugin discoverable. Thanks @shakkernerd. diff --git a/docs/concepts/messages.md b/docs/concepts/messages.md index 7d6ee3876af..8aac5a0cf4b 100644 --- a/docs/concepts/messages.md +++ b/docs/concepts/messages.md @@ -176,6 +176,11 @@ OpenClaw resolves that behavior by conversation type: - Groups/channels allow silence by default. - Internal orchestration allows silence by default. +OpenClaw also uses silent replies for internal runner failures that happen +before any assistant reply in non-direct chats, so groups/channels do not see +gateway error boilerplate. Direct chats show compact failure copy by default; +raw runner details are shown only when `/verbose` is `on` or `full`. + Defaults live under `agents.defaults.silentReply` and `agents.defaults.silentReplyRewrite`; `surfaces..silentReply` and `surfaces..silentReplyRewrite` can override them per surface. diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index aa104d716ac..63926840ce1 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -3,6 +3,7 @@ import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-erro import type { SessionEntry } from "../../config/sessions.js"; import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js"; import type { TemplateContext } from "../templating.js"; +import { SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; import { MAX_LIVE_SWITCH_RETRIES } from "./agent-runner-execution.js"; import type { FollowupRun } from "./queue.js"; @@ -18,6 +19,9 @@ const state = vi.hoisted(() => ({ createBlockReplyDeliveryHandlerMock: vi.fn(), })); +const GENERIC_RUN_FAILURE_TEXT = + "⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session."; + vi.mock("../../agents/pi-embedded.js", () => ({ runEmbeddedPiAgent: (params: unknown) => state.runEmbeddedPiAgentMock(params), })); @@ -260,14 +264,17 @@ function createMockReplyOperation(): { function createMinimalRunAgentTurnParams(overrides?: { followupRun?: FollowupRun; opts?: GetReplyOptions; + sessionCtx?: TemplateContext; }) { return { commandBody: "fix it", followupRun: overrides?.followupRun ?? createFollowupRun(), - sessionCtx: { - Provider: "whatsapp", - MessageSid: "msg", - } as unknown as TemplateContext, + sessionCtx: + overrides?.sessionCtx ?? + ({ + Provider: "whatsapp", + MessageSid: "msg", + } as unknown as TemplateContext), opts: overrides?.opts ?? ({} satisfies GetReplyOptions), typingSignals: createMockTypingSignaler(), blockReplyPipeline: null, @@ -1706,9 +1713,9 @@ describe("runAgentTurnWithFallback", () => { expect(result.kind).toBe("final"); if (result.kind === "final") { - expect(result.payload.text).toContain("Agent failed before reply"); - expect(result.payload.text).toContain("All models failed"); - expect(result.payload.text).toContain("402 (billing)"); + expect(result.payload.text).toBe(GENERIC_RUN_FAILURE_TEXT); + expect(result.payload.text).not.toContain("All models failed"); + expect(result.payload.text).not.toContain("402 (billing)"); expect(result.payload.text).not.toContain("Rate-limited"); } }); @@ -1923,7 +1930,7 @@ describe("runAgentTurnWithFallback", () => { expect(failMock).not.toHaveBeenCalled(); }); - it("forwards sanitized generic errors on external chat channels", async () => { + it("uses compact generic copy for raw external chat errors when verbose is off", async () => { state.runEmbeddedPiAgentMock.mockRejectedValueOnce( new Error("INVALID_ARGUMENT: some other failure"), ); @@ -1953,6 +1960,42 @@ describe("runAgentTurnWithFallback", () => { resolvedVerboseLevel: "off", }); + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.text).toBe(GENERIC_RUN_FAILURE_TEXT); + } + }); + + it("forwards sanitized generic errors on external chat channels when verbose is on", async () => { + state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + new Error("INVALID_ARGUMENT: some other failure"), + ); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback({ + commandBody: "hello", + followupRun: createFollowupRun(), + sessionCtx: { + Provider: "whatsapp", + MessageSid: "msg", + } as unknown as TemplateContext, + opts: {}, + typingSignals: createMockTypingSignaler(), + blockReplyPipeline: null, + blockStreamingEnabled: false, + resolvedBlockStreamingBreak: "message_end", + applyReplyToMode: (payload) => payload, + shouldEmitToolResult: () => true, + shouldEmitToolOutput: () => false, + pendingToolTasks: new Set(), + resetSessionAfterCompactionFailure: async () => false, + resetSessionAfterRoleOrderingConflict: async () => false, + isHeartbeat: false, + sessionKey: "main", + getActiveSessionEntry: () => undefined, + resolvedVerboseLevel: "on", + }); + expect(result.kind).toBe("final"); if (result.kind === "final") { expect(result.payload.text).toBe( @@ -1961,7 +2004,83 @@ describe("runAgentTurnWithFallback", () => { } }); - it("formats raw Codex API payloads before forwarding external errors", async () => { + it.each(["group", "channel"] as const)( + "keeps raw runner failure boilerplate out of Discord %s chats", + async (chatType) => { + state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + new Error("openai-codex/gpt-5.5 ended with an incomplete terminal response"), + ); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback( + createMinimalRunAgentTurnParams({ + sessionCtx: { + Provider: "discord", + Surface: "discord", + ChatType: chatType, + GroupSubject: "agent group", + GroupChannel: "#general", + MessageSid: "msg", + } as unknown as TemplateContext, + }), + ); + + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.text).toBe(SILENT_REPLY_TOKEN); + } + }, + ); + + it("uses compact generic copy for raw runner failures in normal Discord direct chats", async () => { + state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + new Error("openai-codex/gpt-5.5 ended with an incomplete terminal response"), + ); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback( + createMinimalRunAgentTurnParams({ + sessionCtx: { + Provider: "discord", + Surface: "discord", + ChatType: "direct", + MessageSid: "msg", + } as unknown as TemplateContext, + }), + ); + + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.text).toBe(GENERIC_RUN_FAILURE_TEXT); + } + }); + + it("keeps raw runner failure guidance visible in verbose Discord direct chats", async () => { + state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + new Error("openai-codex/gpt-5.5 ended with an incomplete terminal response"), + ); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback({ + ...createMinimalRunAgentTurnParams({ + sessionCtx: { + Provider: "discord", + Surface: "discord", + ChatType: "direct", + MessageSid: "msg", + } as unknown as TemplateContext, + }), + resolvedVerboseLevel: "on", + }); + + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.text).toContain("Agent failed before reply"); + expect(result.payload.text).toContain("incomplete terminal response"); + } + }); + + it("formats raw Codex API payloads before forwarding verbose external errors", async () => { state.runEmbeddedPiAgentMock.mockRejectedValueOnce( new Error( 'Codex error: {"type":"error","error":{"type":"server_error","message":"Something exploded"},"sequence_number":2}', @@ -1990,7 +2109,7 @@ describe("runAgentTurnWithFallback", () => { isHeartbeat: false, sessionKey: "main", getActiveSessionEntry: () => undefined, - resolvedVerboseLevel: "off", + resolvedVerboseLevel: "on", }); expect(result.kind).toBe("final"); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 35c3e1ec3e9..e0ac690a51e 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -356,6 +356,37 @@ function collapseRepeatedFailureDetail(message: string): string { const SAFE_MISSING_API_KEY_PROVIDERS = new Set(["anthropic", "google", "openai", "openai-codex"]); const EXTERNAL_RUN_FAILURE_DETAIL_MAX_CHARS = 900; +const AGENT_FAILED_BEFORE_REPLY_TEXT = "Agent failed before reply:"; +const GENERIC_EXTERNAL_RUN_FAILURE_TEXT = + "⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session."; + +type ExternalRunFailureReply = { + text: string; + isGenericRunnerFailure: boolean; +}; + +function isNonDirectConversationContext(ctx: TemplateContext): boolean { + const chatType = normalizeLowercaseStringOrEmpty(ctx.ChatType); + return chatType === "group" || chatType === "channel"; +} + +function isVerboseFailureDetailEnabled(level: VerboseLevel | undefined): boolean { + return level === "on" || level === "full"; +} + +function resolveExternalRunFailureTextForConversation(params: { + text: string; + sessionCtx: TemplateContext; + isGenericRunnerFailure: boolean; +}): string { + if (!isNonDirectConversationContext(params.sessionCtx)) { + return params.text; + } + if (!params.isGenericRunnerFailure && !params.text.includes(AGENT_FAILED_BEFORE_REPLY_TEXT)) { + return params.text; + } + return SILENT_REPLY_TOKEN; +} function buildMissingApiKeyFailureText(message: string): string | null { const normalizedMessage = collapseRepeatedFailureDetail(message); @@ -379,7 +410,7 @@ function formatForwardedExternalRunFailureText(message: string): string { .replace(/^⚠️\s*/u, "") .replace(/\s+/gu, " "); if (!sanitized) { - return "⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session."; + return GENERIC_EXTERNAL_RUN_FAILURE_TEXT; } const detail = sanitized.length > EXTERNAL_RUN_FAILURE_DETAIL_MAX_CHARS @@ -389,24 +420,41 @@ function formatForwardedExternalRunFailureText(message: string): string { return `⚠️ Agent failed before reply: ${detail}${suffix} Please try again, or use /new to start a fresh session.`; } -function buildExternalRunFailureText(message: string): string { +function buildExternalRunFailureReply( + message: string, + options?: { includeDetails?: boolean }, +): ExternalRunFailureReply { const normalizedMessage = collapseRepeatedFailureDetail(message); if (isToolResultTurnMismatchError(normalizedMessage)) { - return "⚠️ Session history got out of sync. Please try again, or use /new to start a fresh session."; + return { + text: "⚠️ Session history got out of sync. Please try again, or use /new to start a fresh session.", + isGenericRunnerFailure: false, + }; } const missingApiKeyFailure = buildMissingApiKeyFailureText(normalizedMessage); if (missingApiKeyFailure) { - return missingApiKeyFailure; + return { text: missingApiKeyFailure, isGenericRunnerFailure: false }; } const oauthRefreshFailure = classifyOAuthRefreshFailure(normalizedMessage); if (oauthRefreshFailure) { const loginCommand = buildOAuthRefreshFailureLoginCommand(oauthRefreshFailure.provider); if (oauthRefreshFailure.reason) { - return `⚠️ Model login expired on the gateway${oauthRefreshFailure.provider ? ` for ${oauthRefreshFailure.provider}` : ""}. Re-auth with \`${loginCommand}\`, then try again.`; + return { + text: `⚠️ Model login expired on the gateway${oauthRefreshFailure.provider ? ` for ${oauthRefreshFailure.provider}` : ""}. Re-auth with \`${loginCommand}\`, then try again.`, + isGenericRunnerFailure: false, + }; } - return `⚠️ Model login failed on the gateway${oauthRefreshFailure.provider ? ` for ${oauthRefreshFailure.provider}` : ""}. Please try again. If this keeps happening, re-auth with \`${loginCommand}\`.`; + return { + text: `⚠️ Model login failed on the gateway${oauthRefreshFailure.provider ? ` for ${oauthRefreshFailure.provider}` : ""}. Please try again. If this keeps happening, re-auth with \`${loginCommand}\`.`, + isGenericRunnerFailure: false, + }; } - return formatForwardedExternalRunFailureText(normalizedMessage); + return { + text: options?.includeDetails + ? formatForwardedExternalRunFailureText(normalizedMessage) + : GENERIC_EXTERNAL_RUN_FAILURE_TEXT, + isGenericRunnerFailure: true, + }; } function shouldApplyOpenAIGptChatGuard(params: { provider?: string; model?: string }): boolean { @@ -1460,13 +1508,19 @@ export async function runAgentTurnWithFallback(params: { ? "⚠️ Agent failed before reply: model switch could not be completed. " + "The requested model may be temporarily unavailable.\n" + "Logs: openclaw logs --follow" - : "⚠️ Agent failed before reply: model switch could not be completed. " + - "The requested model may be temporarily unavailable. Please try again shortly."; + : isVerboseFailureDetailEnabled(params.resolvedVerboseLevel) + ? "⚠️ Agent failed before reply: model switch could not be completed. " + + "The requested model may be temporarily unavailable. Please try again shortly." + : "⚠️ Model switch could not be completed. The requested model may be temporarily unavailable. Please try again shortly."; params.replyOperation?.fail("run_failed", err); return { kind: "final", payload: { - text: switchErrorText, + text: resolveExternalRunFailureTextForConversation({ + text: switchErrorText, + sessionCtx: params.sessionCtx, + isGenericRunnerFailure: !shouldSurfaceToControlUi, + }), }, }; } @@ -1637,6 +1691,17 @@ export async function runAgentTurnWithFallback(params: { ? sanitizeUserFacingText(message, { errorContext: true }) : message; const trimmedMessage = safeMessage.replace(/\.\s*$/, ""); + const externalRunFailureReply = + !isBilling && + !(isRateLimit && !isOverloadedErrorMessage(message)) && + !rateLimitOrOverloadedCopy && + !isContextOverflow && + !isRoleOrderingError && + !shouldSurfaceToControlUi + ? buildExternalRunFailureReply(message, { + includeDetails: isVerboseFailureDetailEnabled(params.resolvedVerboseLevel), + }) + : undefined; const fallbackText = isBilling ? BILLING_ERROR_USER_MESSAGE : isRateLimit && !isOverloadedErrorMessage(message) @@ -1649,13 +1714,18 @@ export async function runAgentTurnWithFallback(params: { ? "⚠️ Message ordering conflict - please try again. If this persists, use /new to start a fresh session." : shouldSurfaceToControlUi ? `⚠️ Agent failed before reply: ${trimmedMessage}.\nLogs: openclaw logs --follow` - : buildExternalRunFailureText(message); + : (externalRunFailureReply?.text ?? GENERIC_EXTERNAL_RUN_FAILURE_TEXT); + const userVisibleFallbackText = resolveExternalRunFailureTextForConversation({ + text: fallbackText, + sessionCtx: params.sessionCtx, + isGenericRunnerFailure: externalRunFailureReply?.isGenericRunnerFailure ?? false, + }); params.replyOperation?.fail("run_failed", err); return { kind: "final", payload: { - text: fallbackText, + text: userVisibleFallbackText, }, }; } From 4ad8b613c9b60466224d9b3737fff7d63ada652a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:24:10 +0100 Subject: [PATCH 02/25] test: update npm telegram workflow expectations --- test/scripts/npm-telegram-live.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test/scripts/npm-telegram-live.test.ts b/test/scripts/npm-telegram-live.test.ts index b53b1ca66dc..7bd40f2cfe2 100644 --- a/test/scripts/npm-telegram-live.test.ts +++ b/test/scripts/npm-telegram-live.test.ts @@ -46,26 +46,28 @@ describe("npm Telegram live Docker E2E", () => { expect(workflow).toContain("approve_release_manager:"); expect(workflow).toContain("environment: npm-release"); - expect(workflow).toContain("needs: [approve_release_manager, prepare_docker_e2e_image]"); + expect(workflow).toContain("needs: approve_release_manager"); expect(workflow).not.toContain('new Set(["admin", "write"])'); expect(workflow).not.toContain("data.role_name"); expect(workflow).not.toContain("github.rest.teams.listMembersInOrg"); expect(workflow).not.toContain("getMembershipForUserInOrg"); }); - it("prepares and reuses a cached Docker E2E image before approval", () => { + it("builds and reuses a local Docker E2E image after approval", () => { const workflow = readFileSync(WORKFLOW_PATH, "utf8"); - expect(workflow).toContain("prepare_docker_e2e_image:"); + expect(workflow).not.toContain("prepare_docker_e2e_image:"); + expect(workflow).toContain("run_npm_telegram_beta_e2e:"); + expect(workflow).toContain("needs: approve_release_manager"); expect(workflow).toContain("useblacksmith/setup-docker-builder"); expect(workflow).toContain("useblacksmith/build-push-action"); + expect(workflow).toContain("tags: openclaw-docker-e2e:local"); + expect(workflow).toContain("load: true"); + expect(workflow).toContain("push: false"); expect(workflow).not.toContain("cache-from: type=gha"); expect(workflow).not.toContain("cache-to: type=gha"); - expect(workflow).toContain("needs: [approve_release_manager, prepare_docker_e2e_image]"); expect(workflow).toContain('OPENCLAW_SKIP_DOCKER_BUILD: "1"'); - expect(workflow).toContain( - "OPENCLAW_DOCKER_E2E_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.image }}", - ); + expect(workflow).toContain("OPENCLAW_DOCKER_E2E_IMAGE: openclaw-docker-e2e:local"); }); it("lets npm-specific credential aliases override shared QA env", () => { From 3b5463591be93c676a074134c5e384f8024a6945 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:28:14 +0100 Subject: [PATCH 03/25] chore: bump version to 2026.4.26 --- CHANGELOG.md | 2 +- apps/android/app/build.gradle.kts | 4 ++-- apps/ios/CHANGELOG.md | 4 ++++ apps/ios/Config/Version.xcconfig | 4 ++-- apps/ios/version.json | 2 +- apps/macos/Sources/OpenClaw/Resources/Info.plist | 4 ++-- package.json | 2 +- src/config/schema.base.generated.ts | 2 +- 8 files changed, 14 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c84f73ac97c..dab59b911d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Docs: https://docs.openclaw.ai - Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd. -## 2026.4.25 +## 2026.4.26 ### Changes diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index e5ea19b070a..e50ed09eaa2 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -65,8 +65,8 @@ android { applicationId = "ai.openclaw.app" minSdk = 31 targetSdk = 36 - versionCode = 2026042500 - versionName = "2026.4.25" + versionCode = 2026042600 + versionName = "2026.4.26" ndk { // Support all major ABIs — native libs are tiny (~47 KB per ABI) abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") diff --git a/apps/ios/CHANGELOG.md b/apps/ios/CHANGELOG.md index d795c34b1f1..ac0cf5fa5c3 100644 --- a/apps/ios/CHANGELOG.md +++ b/apps/ios/CHANGELOG.md @@ -1,5 +1,9 @@ # OpenClaw iOS Changelog +## 2026.4.26 - 2026-04-26 + +Maintenance update for the current OpenClaw development release. + ## 2026.4.25 - 2026-04-25 Maintenance update for the current OpenClaw development release. diff --git a/apps/ios/Config/Version.xcconfig b/apps/ios/Config/Version.xcconfig index 99bb3a2e829..7e345445e21 100644 --- a/apps/ios/Config/Version.xcconfig +++ b/apps/ios/Config/Version.xcconfig @@ -2,8 +2,8 @@ // Source of truth: apps/ios/version.json // Generated by scripts/ios-sync-versioning.ts. -OPENCLAW_IOS_VERSION = 2026.4.25 -OPENCLAW_MARKETING_VERSION = 2026.4.25 +OPENCLAW_IOS_VERSION = 2026.4.26 +OPENCLAW_MARKETING_VERSION = 2026.4.26 OPENCLAW_BUILD_VERSION = 1 #include? "../build/Version.xcconfig" diff --git a/apps/ios/version.json b/apps/ios/version.json index 02399aa3158..7539c2b908a 100644 --- a/apps/ios/version.json +++ b/apps/ios/version.json @@ -1,3 +1,3 @@ { - "version": "2026.4.25" + "version": "2026.4.26" } diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index cbd027f0862..9c73f2e03b3 100644 --- a/apps/macos/Sources/OpenClaw/Resources/Info.plist +++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.4.25 + 2026.4.26 CFBundleVersion - 2026042500 + 2026042600 CFBundleIconFile OpenClaw CFBundleURLTypes diff --git a/package.json b/package.json index 843784ef09d..ceff5ef7824 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.4.25", + "version": "2026.4.26", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index e172456064f..fba40897f65 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -28617,6 +28617,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { tags: ["advanced", "url-secret"], }, }, - version: "2026.4.25", + version: "2026.4.26", generatedAt: "2026-03-22T21:17:33.302Z", }; From eb6b35671a50154339e97880d3c0fb263891c454 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 01:34:53 -0700 Subject: [PATCH 04/25] docs(changelog): flatten 27 multi-line bullets into single lines per AGENTS.md rule --- CHANGELOG.md | 114 ++++++++++++--------------------------------------- 1 file changed, 27 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dab59b911d6..8118fb11c69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,10 +114,7 @@ Docs: https://docs.openclaw.ai - Agents/runtime: submit heartbeat, cron, and exec wakeups as transient runtime context instead of visible user prompts, keeping synthetic system work out of chat transcripts. Fixes #66496 and #66814. Thanks @jeades and @mandomaker. - Telegram: include native quote excerpts automatically for threaded replies and reply tags when the original Telegram text is available, without adding another config knob. Fixes #6975. Thanks @rex05ai. - Node/Linux: make `openclaw node install` enable and restart the `openclaw-node` systemd unit instead of the gateway unit on node-only VMs. Fixes #68287. Thanks @dlebee-agent. -- Browser/CDP: retry transient raw-CDP WebSocket handshake failures before any - browser command is sent, and reconnect stale persistent Playwright CDP - sessions for safe tab-list reads without replaying mutating browser actions. - Fixes #67728. +- Browser/CDP: retry transient raw-CDP WebSocket handshake failures before any browser command is sent, and reconnect stale persistent Playwright CDP sessions for safe tab-list reads without replaying mutating browser actions. Fixes #67728. - Gateway/Linux: retry `systemctl --user enable` after a second daemon reload when the freshly written gateway unit is not visible yet on migrated systemd installs. Fixes #65184. Thanks @liushuaiiu. - Telegram: preserve exact selected quote text when sending native quote replies, and retry with legacy replies if Telegram rejects quote parameters. (#71952) Thanks @rubencu. - Plugins/CLI: preserve manifest name, description, format, and source metadata in cold `openclaw plugins list` output without importing plugin runtime. Thanks @shakkernerd. @@ -125,27 +122,13 @@ Docs: https://docs.openclaw.ai - Plugins/chat: keep `/plugins list`, `/plugins enable`, and `/plugins disable` on the persisted plugin index path so chat plugin management does not load diagnostic/runtime plugin registries before execution. Thanks @shakkernerd. - Plugins/doctor: read workspace plugin status and legacy web-search ownership through installed-index manifest metadata instead of broad manifest registry scans. Thanks @shakkernerd. - CLI/agents: read channel provider status from read-only plugin index metadata for text `agents list` output instead of the loaded channel registry. Thanks @shakkernerd. -- Logging: redact configured secret patterns at console and file-log sink exits - so credentials that reach the logger are masked before terminal display or - JSONL persistence. Fixes #67953. Thanks @Ziy1-Tan. -- Gateway/services: refuse process and service mutations from an older OpenClaw - binary when the config was last written by a newer version, preventing - split-brain installs from stopping or rewriting newer gateway services. Fixes - #57079. +- Logging: redact configured secret patterns at console and file-log sink exits so credentials that reach the logger are masked before terminal display or JSONL persistence. Fixes #67953. Thanks @Ziy1-Tan. +- Gateway/services: refuse process and service mutations from an older OpenClaw binary when the config was last written by a newer version, preventing split-brain installs from stopping or rewriting newer gateway services. Fixes #57079. - Gateway: reserve `/healthz` and `/readyz` ahead of plugin, canvas, and Control UI HTTP stages so liveness/readiness probes still answer when a later route handler stalls. Fixes #69674. Thanks @Xike-Creek. -- Logging: load `logging.file` and redaction settings directly from the active - OpenClaw config path in bundled runtimes, so packaged gateways stop falling - back to `/tmp/openclaw`. Fixes #59370, #67168, and #61295. Thanks @KeaneYan, - @Pan9hu, and @zsjlovelike. -- Logging: rotate file logs at `logging.maxFileBytes`, keep bounded numbered - archives, and make long-lived rolling loggers follow the current-day file - instead of suppressing diagnostics or writing stale dated files. Fixes #58583 - and #62381. Thanks @jpeghead and @zhaoleink. +- Logging: load `logging.file` and redaction settings directly from the active OpenClaw config path in bundled runtimes, so packaged gateways stop falling back to `/tmp/openclaw`. Fixes #59370, #67168, and #61295. Thanks @KeaneYan, @Pan9hu, and @zsjlovelike. +- Logging: rotate file logs at `logging.maxFileBytes`, keep bounded numbered archives, and make long-lived rolling loggers follow the current-day file instead of suppressing diagnostics or writing stale dated files. Fixes #58583 and #62381. Thanks @jpeghead and @zhaoleink. - Agents/groups: treat clean empty assistant stops as silent `NO_REPLY` only for always-on groups where silent replies are allowed, while keeping direct and mention-gated sessions on the incomplete-turn retry path. Thanks @MagnaAI. -- macOS/Node: keep native remote app nodes from advertising `browser.proxy`, - start browser-capable CLI node services through the restored - `openclaw node start` command, and show an actionable browser-control error - when the local control service is missing. Fixes #66637. +- macOS/Node: keep native remote app nodes from advertising `browser.proxy`, start browser-capable CLI node services through the restored `openclaw node start` command, and show an actionable browser-control error when the local control service is missing. Fixes #66637. - Gateway/update: fail package updates when the restarted managed gateway reports the wrong version, including fallback restarts and JSON mode, avoiding false-success mixed-version restarts after macOS LaunchAgent updates. Fixes #71835. Thanks @abhinas90 and @jsompis. - Gateway/update: warn before package updates and bundled plugin runtime-dependency repairs when the target volume appears low on disk space, without blocking installs on best-effort filesystem checks. Fixes #71835. Thanks @abhinas90 and @jsompis. - Plugins/runtime deps: surface activated plugin load failures in health and fail package-update restart verification or doctor repair when bundled runtime deps still cannot load, avoiding false-success repairs. (#71883) Thanks @Solvely-Colin. @@ -159,52 +142,25 @@ Docs: https://docs.openclaw.ai - Plugins: scope setup and web-provider metadata manifest reads to explicit plugin ids when callers already know the owning plugin set. Thanks @vincentkoc. - Plugins/onboarding: defer onboarding install-record index writes until the guarded config commit so setup failures cannot leave the plugin index ahead of `openclaw.json`. Thanks @shakkernerd. - Plugins/registry: resolve web provider ownership from the installed plugin index instead of broad manifest scans on secret, tool, and pricing paths. Thanks @shakkernerd. -- Config/providers: accept `video` and `audio` in configured model `input` values and - preserve them in provider catalog entries. Fixes #20721. Thanks @alvinttang. +- Config/providers: accept `video` and `audio` in configured model `input` values and preserve them in provider catalog entries. Fixes #20721. Thanks @alvinttang. - Models/auth: honor the parent `--agent` flag for auth write commands (`add`, `login`, `setup-token`, `paste-token`, and the GitHub Copilot shortcut) so OAuth/API-key/token results are written to the requested agent store instead of the default agent. Fixes #71864. (#71933) Thanks @balric-seo. -- TTS: strip model-emitted TTS directives from streamed block text before channel - delivery, including directives split across adjacent blocks, while preserving - the accumulated raw reply for final-mode synthesis. Fixes #38937. -- TTS: keep explicit `provider=...` directive keys scoped to that provider and - warn on unsupported keys instead of letting another speech provider consume - overlapping keys. Fixes #60131. -- TTS/Feishu: normalize final-mode streamed TTS-only audio before delivery so - generated voice-note files use the same safe media path and native voice - routing as normal final replies. Fixes #71920. -- Feishu: transcribe inbound voice-note audio with the shared media audio path - before agent dispatch and keep raw Feishu `file_key` payloads out of message - text. Fixes #67120 and #61876. +- TTS: strip model-emitted TTS directives from streamed block text before channel delivery, including directives split across adjacent blocks, while preserving the accumulated raw reply for final-mode synthesis. Fixes #38937. +- TTS: keep explicit `provider=...` directive keys scoped to that provider and warn on unsupported keys instead of letting another speech provider consume overlapping keys. Fixes #60131. +- TTS/Feishu: normalize final-mode streamed TTS-only audio before delivery so generated voice-note files use the same safe media path and native voice routing as normal final replies. Fixes #71920. +- Feishu: transcribe inbound voice-note audio with the shared media audio path before agent dispatch and keep raw Feishu `file_key` payloads out of message text. Fixes #67120 and #61876. - Tasks: terminalize async Gateway agent task records from the Gateway run result while preserving aborted, failed, and cancelled outcomes instead of leaving completed runs stuck as active or lost. (#71905) Thanks @likewen-tech. -- WhatsApp: let authorized group voice-note transcripts satisfy mention gating - before reply dispatch, while keeping unmentioned transcripts in pending group - history. Fixes #44908. -- Media understanding: carry channel voice-note preflight state into attachment - selection so WhatsApp, Feishu, Telegram, and Discord do not transcribe the - same inbound audio twice. Fixes #70580. -- TTS/BlueBubbles: deliver compatible auto-TTS audio as iMessage voice memo - bubbles instead of plain MP3/CAF file attachments. Fixes #16848. -- TTS: resolve voice-note and voice-memo routing from channel plugin - capabilities instead of speech-core-owned channel id lists. -- ACP: send subagent and async-task completion wakes to external ACP harnesses as - plain prompts instead of OpenClaw internal runtime-context envelopes, while - keeping those envelopes out of ACP transcripts. +- WhatsApp: let authorized group voice-note transcripts satisfy mention gating before reply dispatch, while keeping unmentioned transcripts in pending group history. Fixes #44908. +- Media understanding: carry channel voice-note preflight state into attachment selection so WhatsApp, Feishu, Telegram, and Discord do not transcribe the same inbound audio twice. Fixes #70580. +- TTS/BlueBubbles: deliver compatible auto-TTS audio as iMessage voice memo bubbles instead of plain MP3/CAF file attachments. Fixes #16848. +- TTS: resolve voice-note and voice-memo routing from channel plugin capabilities instead of speech-core-owned channel id lists. +- ACP: send subagent and async-task completion wakes to external ACP harnesses as plain prompts instead of OpenClaw internal runtime-context envelopes, while keeping those envelopes out of ACP transcripts. - TTS/status: show configured TTS model, voice, and sanitized custom endpoint in `/status`, preserve OpenAI-compatible TTS instructions on custom endpoints, and retry empty Microsoft/Edge TTS output once. Addresses #46602, #47232, and #43936. Thanks @leekuangtao, @Huntterxx, and @rex993. - Agents/Gateway: steer agent-driven config edits and restarts through the owner-only `gateway` tool, document `config.schema.lookup` as the field-doc source, and warn against using `gateway stop && gateway start` as a restart substitute on macOS. Fixes #71929. Thanks @ygc3817922006-sketch. - Media understanding/audio: inject a deterministic transcript placeholder for too-small voice notes so agents do not hallucinate transcription or provider failures. Fixes #48944. Thanks @eulicesl. -- Providers/vLLM: send Nemotron 3 chat-template kwargs when thinking is off - and honor configured `params.chat_template_kwargs` for OpenAI-compatible - completions, so vLLM/Nemotron replies stay visible instead of becoming - thinking-only. Fixes #71891. Thanks @jmystaki-create and @dennis-lynch. -- Channels/replies: strip copied inbound metadata blocks from user-facing - assistant replies and model replay history, so Discord/vLLM sessions do not - leak `Conversation info` / `UNTRUSTED ... message body` envelopes after a - model echoes them. Fixes #71847. Thanks @jmystaki-create. -- Subagents/memory: keep inter-session completion wakes out of memory and - dreaming session exports, and strip internal runtime-context blocks from - realtime Control UI chat events. -- Agents/Claude: treat zero-token empty `stop` turns as failed provider output, - retry once, repair replay, and allow configured model fallback instead of - preserving them as successful silent replies. Fixes #71880. Thanks @MagnaAI. +- Providers/vLLM: send Nemotron 3 chat-template kwargs when thinking is off and honor configured `params.chat_template_kwargs` for OpenAI-compatible completions, so vLLM/Nemotron replies stay visible instead of becoming thinking-only. Fixes #71891. Thanks @jmystaki-create and @dennis-lynch. +- Channels/replies: strip copied inbound metadata blocks from user-facing assistant replies and model replay history, so Discord/vLLM sessions do not leak `Conversation info` / `UNTRUSTED ... message body` envelopes after a model echoes them. Fixes #71847. Thanks @jmystaki-create. +- Subagents/memory: keep inter-session completion wakes out of memory and dreaming session exports, and strip internal runtime-context blocks from realtime Control UI chat events. +- Agents/Claude: treat zero-token empty `stop` turns as failed provider output, retry once, repair replay, and allow configured model fallback instead of preserving them as successful silent replies. Fixes #71880. Thanks @MagnaAI. - Tasks: normalize task lifecycle timestamps at create, update, and restore time, and report retained lost tasks as audit warnings until their cleanup window expires. (#71871) Thanks @likewen-tech. - Diagnostics/OTEL: treat normal early model stream cleanup as a completed model call instead of exporting a misleading `StreamAbandoned` error span. Thanks @vincentkoc. - Gateway/pairing: stop corrupt or unreadable device/node pairing stores from being treated as empty state, preserving `paired.json` for repair instead of overwriting approved pairings. Fixes #71873. Thanks @iret77. @@ -216,13 +172,8 @@ Docs: https://docs.openclaw.ai - Control UI: hide the chat loading skeleton during background history reloads when existing messages or active stream content are already visible, avoiding reload flashes on high-latency local gateways. Fixes #71844. Thanks @WolvenRA. - Control UI: keep locally optimistic chat messages visible when a history reload temporarily returns empty, avoiding lost first-turn messages on high-latency gateways. Fixes #71878. Thanks @WolvenRA. - Control UI: keep chat history limits based on visible messages after filtering heartbeat and control-only transcript rows, so recent hidden entries no longer make older visible replies disappear. Thanks @WolvenRA. -- Agents/images: scrub old `[media attached: ...]`, `[Image: source: ...]`, - and `media://inbound/...` markers from pruned model replay context so stale - media refs are not rehydrated as fresh prompt images. Fixes #71868. Thanks - @jmeadlock. -- Docker/Bonjour: disable Bonjour/mDNS advertising by default for bundled - Compose gateways on bridge networking, while keeping host/macvlan opt-in with - `OPENCLAW_DISABLE_BONJOUR=0`. Fixes #71879. Thanks @gbballpack. +- Agents/images: scrub old `[media attached: ...]`, `[Image: source: ...]`, and `media://inbound/...` markers from pruned model replay context so stale media refs are not rehydrated as fresh prompt images. Fixes #71868. Thanks @jmeadlock. +- Docker/Bonjour: disable Bonjour/mDNS advertising by default for bundled Compose gateways on bridge networking, while keeping host/macvlan opt-in with `OPENCLAW_DISABLE_BONJOUR=0`. Fixes #71879. Thanks @gbballpack. - CLI/status: label the OpenClaw Serve/Funnel setting as `Tailscale exposure` and show daemon state separately when available, so `gateway.tailscale.mode: "off"` no longer reads like the Tailscale daemon is stopped. Fixes #71790. Thanks @pesvobodak. - Plugins/Bonjour: stop ciao mDNS watchdog failures from looping forever when the advertiser stays stuck in `probing` or `announcing`; Bonjour now disables itself for the current Gateway process after repeated failed restarts while the Gateway keeps running. Fixes #69011. Thanks @siddharthaagarwalofficial-ux, @FiredMosquito831, and @spikefcz. - Gateway/Fly.io: seed Control UI allowed origins from the actual runtime bind and port so CLI-driven non-loopback starts do not crash before config exists. Fixes #71823. @@ -241,9 +192,7 @@ Docs: https://docs.openclaw.ai - Agents/Codex: keep ACP prompt/skill routing hidden unless an ACP runtime backend is available, and warn in doctor when enabled Codex plugin configs still route `openai-codex/*` models through PI. Thanks @vincentkoc. - Media delivery: avoid sending generated image attachments twice when the assistant reply already includes explicit `MEDIA:` lines for the same turn, and reject unsafe remote `MEDIA:` URLs before delivery. Thanks @pashpashpash. - Codex harness: ignore retryable app-server error notifications after Codex recovers, and preserve the real nested error message for terminal app-server failures instead of replacing it with a generic failure. Thanks @pashpashpash. -- Agents/Codex: prepare native Codex sub-agent session metadata without a - nested Gateway session patch and add a focused Docker smoke for the app-server - sub-agent path. Thanks @vincentkoc. +- Agents/Codex: prepare native Codex sub-agent session metadata without a nested Gateway session patch and add a focused Docker smoke for the app-server sub-agent path. Thanks @vincentkoc. - Agents/subagents: keep queued subagent announces session-only when the requester has no external channel target, avoiding ambiguous multi-channel delivery failures. Fixes #59201. Thanks @larrylhollan. - Image understanding: preserve configured provider-prefixed vision model metadata when callers request the model without the provider prefix, so custom image models keep their `input: ["text", "image"]` capability. Fixes #33185. Thanks @Kobe9312 and @vincentkoc. - Plugins/install: restore the previous plugin index records if a concurrent config write conflict interrupts install, update, or uninstall metadata commits. Thanks @shakkernerd. @@ -819,16 +768,9 @@ Docs: https://docs.openclaw.ai ### Fixes -- Dependencies: refresh workspace package pins and lockfile entries for AWS SDK, - Anthropic SDK, ACP SDK, Matrix crypto, TypeBox, Vite, tsdown, Slack Bolt, - CopilotKit AIMock, and related bundled plugin packages. Thanks @steipete. -- Gateway/env: import each missing expected login-shell env var independently, - so an existing gateway token no longer prevents `env.shellEnv` from loading - plugin credentials such as `TWILIO_*` from `.profile`. Thanks @steipete. -- macOS/Gateway pairing: silently accept same-host native app - `metadata-upgrade` reconnects, so macOS patch-version changes update paired - metadata instead of spamming security audit warnings and `pairing required` - disconnects. Thanks @steipete. +- Dependencies: refresh workspace package pins and lockfile entries for AWS SDK, Anthropic SDK, ACP SDK, Matrix crypto, TypeBox, Vite, tsdown, Slack Bolt, CopilotKit AIMock, and related bundled plugin packages. Thanks @steipete. +- Gateway/env: import each missing expected login-shell env var independently, so an existing gateway token no longer prevents `env.shellEnv` from loading plugin credentials such as `TWILIO_*` from `.profile`. Thanks @steipete. +- macOS/Gateway pairing: silently accept same-host native app `metadata-upgrade` reconnects, so macOS patch-version changes update paired metadata instead of spamming security audit warnings and `pairing required` disconnects. Thanks @steipete. - CLI/Gateway: wait for one-shot gateway RPC clients to finish WebSocket teardown before the CLI process exits, reducing hangs where commands like `openclaw status` or `openclaw version` could finish their work but stay alive until an external timeout killed them (#70691). Thanks @Takhoffman. - Thinking defaults/status: raise the implicit default thinking level for reasoning-capable models from legacy `off`/`low` fallback behavior to a safe provider-supported `medium` equivalent when no explicit config default is set, preserve configured-model reasoning metadata when runtime catalog loading is empty, and make `/status` report the same resolved default as runtime (#70601). Thanks @Takhoffman. - Gateway/model pricing: extend OpenRouter and LiteLLM catalog fetch timeouts to 60 seconds, reducing noisy timeout warnings during slow upstream responses. Thanks @steipete. @@ -867,9 +809,7 @@ Docs: https://docs.openclaw.ai - Config/includes: write through single-file top-level includes for isolated OpenClaw-owned mutations, so `plugins install` and `plugins update` update an included `plugins.json5` file instead of flattening modular `$include` configs. Fixes #41050 and #66048. - Config/reload: plan gateway reloads from source-authored config instead of runtime-materialized snapshots, so plugin update writes no longer trigger false restarts from derived provider/plugin config paths. Fixes #68732. - Plugins/update: skip npm plugin reinstall/config rewrites when the installed version and recorded artifact identity already match the registry target, let bare npm package names resolve back to tracked install records, and point already-installed `plugins install` attempts at `plugins update` / `--force` instead of a hook-pack fallback. Fixes #46955, #67957, and #68073. -- Agents/MCP: keep `mcp.servers` and bundle MCP tools available in Pi embedded runs. - `coding` and `messaging` sessions while preserving `minimal` profile and - `tools.deny: ["bundle-mcp"]` opt-out behavior. Fixes #68875 and #68818. +- Agents/MCP: keep `mcp.servers` and bundle MCP tools available in Pi embedded runs. `coding` and `messaging` sessions while preserving `minimal` profile and `tools.deny: ["bundle-mcp"]` opt-out behavior. Fixes #68875 and #68818. - Plugins/startup: tolerate transient bundled-channel catalog/metadata drift while auto-enabling configured plugins, so CLI and gateway startup no longer crash when a channel id is known but its display metadata is unavailable. - CLI/Claude: report CLI-backed reply runs as streaming while Claude/Codex CLI turns are still in flight, so WebChat keeps visible response state until the backend finishes. Fixes #70125. - Slack/streaming: fall back to normal Slack replies for Slack Connect streams rejected before the SDK flushes its local buffer, so short replies no longer disappear or report success before Slack acknowledges delivery. Fixes #70295. (#70370) Thanks @mvanhorn. From abd5ec98ab01f107d23a95983c0828fce9bc3efe Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 01:38:21 -0700 Subject: [PATCH 05/25] fix(runtime): harden dependency install surfaces (#71997) * fix(runtime): harden dependency surfaces * fix(runtime): harden dependency install surfaces * fix(runtime): address dependency surface review * fix(runtime): address dependency surface review * fix(channels): avoid read-only plugin loader cycle * fix(channels): allow optional read-only loader workspace * test(commands): refresh current main checks * test(commands): keep provider metadata mock unique * test(commands): keep doctor security read-only mock unique --- CHANGELOG.md | 5 + extensions/signal/src/client.test.ts | 253 ++++++++++++-- extensions/signal/src/client.ts | 328 ++++++++++++++---- package.json | 1 + scripts/watch-node.d.mts | 12 + scripts/watch-node.mjs | 54 ++- src/agents/skills-install.test.ts | 67 +++- src/agents/skills-install.ts | 38 ++ .../plugins/bundled.shape-guard.test.ts | 47 +++ src/channels/plugins/bundled.ts | 6 +- src/channels/plugins/read-only.ts | 19 +- src/commands/doctor-security.test.ts | 4 - src/dockerfile.test.ts | 3 + src/infra/watch-node.test.ts | 63 ++++ 14 files changed, 784 insertions(+), 116 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8118fb11c69..f65c9999c9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,11 @@ Docs: https://docs.openclaw.ai - Installer: warn when multiple npm global roots contain OpenClaw installs, showing active Node/npm/openclaw plus each install path and version so stale version-manager installs are visible. Fixes #40839. Thanks @zhixianio. - Cron/tasks: recover completed cron task ledger records from durable run logs and job state before marking them `lost`, reducing false `backing session missing` audit errors for isolated cron runs and keeping offline CLI audit from treating its empty local cron active-job set as authoritative. Fixes #71963. - Docker: copy patched dependency files into runtime images so downstream `pnpm install` layers keep working. Fixes #69224. Thanks @gucasbrg. +- Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc. +- Plugins/channels: treat malformed bundled channel plugin loaders that return `undefined` as unavailable instead of crashing config and help paths. Fixes #69044. Thanks @frankhli843 and @vincentkoc. +- Scripts/watch: show corrupted dependency package-config recovery guidance when `gateway:watch` fails during watcher startup, without double-logging unrelated import failures. (#58780) Thanks @roytong9 and @vincentkoc. +- Signal: read signal-cli RPC, health checks, and SSE events through Node's HTTP client so Node 24/25 fetch regressions do not break Signal sends or inbound events. Fixes #51716 and #53040. Thanks @Barukimang, @minupla, and @vincentkoc. +- Skills/Docker: run npm-backed skill dependency installs with an OpenClaw-managed user prefix so non-root Docker images do not write to `/usr/local`. Fixes #59601. Thanks @chanjarster and @vincentkoc. - Agents/runtime: submit heartbeat, cron, and exec wakeups as transient runtime context instead of visible user prompts, keeping synthetic system work out of chat transcripts. Fixes #66496 and #66814. Thanks @jeades and @mandomaker. - Telegram: include native quote excerpts automatically for threaded replies and reply tags when the original Telegram text is available, without adding another config knob. Fixes #6975. Thanks @rex05ai. - Node/Linux: make `openclaw node install` enable and restart the `openclaw-node` systemd unit instead of the gateway unit on node-only VMs. Fixes #68287. Thanks @dlebee-agent. diff --git a/extensions/signal/src/client.test.ts b/extensions/signal/src/client.test.ts index b24ba47bb60..be81e71c9c3 100644 --- a/extensions/signal/src/client.test.ts +++ b/extensions/signal/src/client.test.ts @@ -1,11 +1,7 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; - -const fetchWithTimeoutMock = vi.fn(); -const resolveFetchMock = vi.fn(); - -vi.mock("openclaw/plugin-sdk/fetch-runtime", () => ({ - resolveFetch: (...args: unknown[]) => resolveFetchMock(...args), -})); +import { Buffer } from "node:buffer"; +import { once } from "node:events"; +import http, { type IncomingMessage, type ServerResponse } from "node:http"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; vi.mock("openclaw/plugin-sdk/core", async () => { const actual = await vi.importActual( @@ -17,47 +13,91 @@ vi.mock("openclaw/plugin-sdk/core", async () => { }; }); -vi.mock("openclaw/plugin-sdk/text-runtime", () => ({ - fetchWithTimeout: (...args: unknown[]) => fetchWithTimeoutMock(...args), -})); - +let signalCheck: typeof import("./client.js").signalCheck; let signalRpcRequest: typeof import("./client.js").signalRpcRequest; +let streamSignalEvents: typeof import("./client.js").streamSignalEvents; -function rpcResponse(body: unknown, status = 200): Response { - if (typeof body === "string") { - return new Response(body, { status }); +const servers: http.Server[] = []; + +async function readRequestBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req as AsyncIterable) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); } - return new Response(JSON.stringify(body), { status }); + return Buffer.concat(chunks).toString("utf8"); } +async function withSignalServer( + handler: (req: IncomingMessage, res: ServerResponse) => void | Promise, +): Promise { + const server = http.createServer((req, res) => { + void Promise.resolve(handler(req, res)).catch((error: unknown) => { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end(error instanceof Error ? error.message : String(error)); + }); + }); + servers.push(server); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("missing test server address"); + } + return `http://127.0.0.1:${address.port}`; +} + +beforeAll(async () => { + ({ signalCheck, signalRpcRequest, streamSignalEvents } = await import("./client.js")); +}); + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }), + ), + ); +}); + describe("signalRpcRequest", () => { - beforeAll(async () => { - ({ signalRpcRequest } = await import("./client.js")); - }); - - beforeEach(() => { - vi.clearAllMocks(); - resolveFetchMock.mockReturnValue(vi.fn()); - }); - it("returns parsed RPC result", async () => { - fetchWithTimeoutMock.mockResolvedValueOnce( - rpcResponse({ jsonrpc: "2.0", result: { version: "0.13.22" }, id: "test-id" }), - ); + const baseUrl = await withSignalServer(async (req, res) => { + expect(req.method).toBe("POST"); + expect(req.url).toBe("/api/v1/rpc"); + expect(req.headers["content-type"]).toBe("application/json"); + expect(JSON.parse(await readRequestBody(req))).toEqual({ + jsonrpc: "2.0", + method: "version", + id: "test-id", + }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", result: { version: "0.13.22" }, id: "test-id" })); + }); const result = await signalRpcRequest<{ version: string }>("version", undefined, { - baseUrl: "http://127.0.0.1:8080", + baseUrl, }); expect(result).toEqual({ version: "0.13.22" }); }); it("throws a wrapped error when RPC response JSON is malformed", async () => { - fetchWithTimeoutMock.mockResolvedValueOnce(rpcResponse("not-json", 502)); + const baseUrl = await withSignalServer((_req, res) => { + res.writeHead(502, { "Content-Type": "text/plain" }); + res.end("not-json"); + }); await expect( signalRpcRequest("version", undefined, { - baseUrl: "http://127.0.0.1:8080", + baseUrl, }), ).rejects.toMatchObject({ message: "Signal RPC returned malformed JSON (status 502)", @@ -66,12 +106,159 @@ describe("signalRpcRequest", () => { }); it("throws when RPC response envelope has neither result nor error", async () => { - fetchWithTimeoutMock.mockResolvedValueOnce(rpcResponse({ jsonrpc: "2.0", id: "test-id" })); + const baseUrl = await withSignalServer((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: "test-id" })); + }); await expect( signalRpcRequest("version", undefined, { - baseUrl: "http://127.0.0.1:8080", + baseUrl, }), ).rejects.toThrow("Signal RPC returned invalid response envelope (status 200)"); }); + + it("rejects credentialed base URLs", async () => { + await expect( + signalRpcRequest("version", undefined, { + baseUrl: "http://user:pass@127.0.0.1:8080", + }), + ).rejects.toThrow("Signal base URL must not include credentials"); + }); + + it("rejects oversized RPC responses", async () => { + const baseUrl = await withSignalServer((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end("x".repeat(1_048_577)); + }); + + await expect( + signalRpcRequest("version", undefined, { + baseUrl, + }), + ).rejects.toThrow("Signal HTTP response exceeded size limit"); + }); + + it("uses an absolute deadline for slow-drip RPC responses", async () => { + const baseUrl = await withSignalServer((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + const interval = setInterval(() => { + res.write(" "); + }, 5); + res.on("close", () => clearInterval(interval)); + }); + + await expect( + signalRpcRequest("version", undefined, { + baseUrl, + timeoutMs: 25, + }), + ).rejects.toThrow("Signal HTTP exceeded deadline after 25ms"); + }); +}); + +describe("signalCheck", () => { + it("returns ok for a healthy signal-cli check", async () => { + const baseUrl = await withSignalServer((req, res) => { + expect(req.method).toBe("GET"); + expect(req.url).toBe("/api/v1/check"); + res.writeHead(204); + res.end(); + }); + + await expect(signalCheck(baseUrl)).resolves.toEqual({ ok: true, status: 204, error: null }); + }); + + it("returns an HTTP status failure for unhealthy checks", async () => { + const baseUrl = await withSignalServer((_req, res) => { + res.writeHead(503); + res.end("down"); + }); + + await expect(signalCheck(baseUrl)).resolves.toEqual({ + ok: false, + status: 503, + error: "HTTP 503", + }); + }); +}); + +describe("streamSignalEvents", () => { + it("streams events through node http instead of fetch", async () => { + const events: Array = []; + const baseUrl = await withSignalServer((req, res) => { + expect(req.url).toBe("/api/v1/events?account=%2B15555550123"); + expect(req.headers.accept).toBe("text/event-stream"); + res.writeHead(200, { "Content-Type": "text/event-stream" }); + res.end('id: 42\nevent: message\ndata: {"group":true}\n\n'); + }); + + await streamSignalEvents({ + baseUrl, + account: "+15555550123", + onEvent: (event) => events.push(event), + }); + + expect(events).toEqual([{ id: "42", event: "message", data: '{"group":true}' }]); + }); + + it("reports HTTP status failures from the event stream", async () => { + const baseUrl = await withSignalServer((_req, res) => { + res.writeHead(503, "Unavailable"); + res.end("down"); + }); + + await expect( + streamSignalEvents({ + baseUrl, + onEvent: () => {}, + }), + ).rejects.toThrow("Signal SSE failed (503 Unavailable)"); + }); + + it("rejects event streams that do not send headers before the deadline", async () => { + const baseUrl = await withSignalServer(() => { + // Leave the request open without response headers. + }); + + await expect( + streamSignalEvents({ + baseUrl, + timeoutMs: 25, + onEvent: () => {}, + }), + ).rejects.toThrow("Signal SSE connection timed out after 25ms"); + }); + + it("rejects oversized SSE line buffers by byte size", async () => { + const baseUrl = await withSignalServer((_req, res) => { + res.writeHead(200, { "Content-Type": "text/event-stream" }); + res.end(`data: ${"🙂".repeat(262_145)}`); + }); + + await expect( + streamSignalEvents({ + baseUrl, + onEvent: () => {}, + }), + ).rejects.toThrow("Signal SSE buffer exceeded size limit"); + }); + + it("rejects oversized SSE events split across smaller data lines", async () => { + const baseUrl = await withSignalServer((_req, res) => { + res.writeHead(200, { "Content-Type": "text/event-stream" }); + const line = `data: ${"x".repeat(4096)}\n`; + for (let index = 0; index < 260; index += 1) { + res.write(line); + } + res.end(); + }); + + await expect( + streamSignalEvents({ + baseUrl, + onEvent: () => {}, + }), + ).rejects.toThrow("Signal SSE event data exceeded size limit"); + }); }); diff --git a/extensions/signal/src/client.ts b/extensions/signal/src/client.ts index 2e9d33ef7d8..093c9eead87 100644 --- a/extensions/signal/src/client.ts +++ b/extensions/signal/src/client.ts @@ -1,7 +1,8 @@ +import { Buffer } from "node:buffer"; +import http, { type ClientRequest, type IncomingMessage } from "node:http"; +import https from "node:https"; import { generateSecureUuid } from "openclaw/plugin-sdk/core"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime"; -import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime"; export type SignalRpcOptions = { baseUrl: string; @@ -28,6 +29,21 @@ export type SignalSseEvent = { }; const DEFAULT_TIMEOUT_MS = 10_000; +const MAX_SIGNAL_HTTP_RESPONSE_BYTES = 1_048_576; +const MAX_SIGNAL_SSE_BUFFER_BYTES = 1_048_576; +const MAX_SIGNAL_SSE_EVENT_DATA_BYTES = 1_048_576; + +type SignalHttpResponse = { + status: number; + statusText: string; + text: string; +}; + +function createSignalSseAbortError(): Error { + const error = new Error("Signal SSE aborted"); + error.name = "AbortError"; + return error; +} function normalizeBaseUrl(url: string): string { const trimmed = url.trim(); @@ -40,12 +56,16 @@ function normalizeBaseUrl(url: string): string { return `http://${trimmed}`.replace(/\/+$/, ""); } -function getRequiredFetch(): typeof fetch { - const fetchImpl = resolveFetch(); - if (!fetchImpl) { - throw new Error("fetch is not available"); +function parseSignalBaseUrl(url: string): URL { + const parsed = new URL(normalizeBaseUrl(url)); + if (parsed.username || parsed.password) { + throw new Error("Signal base URL must not include credentials"); } - return fetchImpl; + return parsed; +} + +function resolveSignalEndpointUrl(baseUrl: string, pathname: string): URL { + return new URL(pathname, parseSignalBaseUrl(baseUrl)); } function parseSignalRpcResponse(text: string, status: number): SignalRpcResponse { @@ -68,12 +88,97 @@ function parseSignalRpcResponse(text: string, status: number): SignalRpcRespo return rpc; } +function assertSignalHttpProtocol(url: URL, label: string): void { + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`Signal ${label} unsupported protocol: ${url.protocol}`); + } +} + +function requestSignalHttpText( + url: URL, + options: { + method: "GET" | "POST"; + headers?: Record; + body?: string; + timeoutMs: number; + }, +): Promise { + assertSignalHttpProtocol(url, "HTTP"); + const client = url.protocol === "https:" ? https : http; + return new Promise((resolve, reject) => { + let settled = false; + let request: ClientRequest | undefined; + const deadline = setTimeout(() => { + request?.destroy(new Error(`Signal HTTP exceeded deadline after ${options.timeoutMs}ms`)); + }, options.timeoutMs); + deadline.unref?.(); + const cleanup = () => { + clearTimeout(deadline); + request?.setTimeout(0); + }; + const rejectOnce = (error: unknown) => { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(error); + }; + const resolveOnce = (response: SignalHttpResponse) => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(response); + }; + request = client.request( + url, + { + method: options.method, + headers: options.headers, + }, + (res) => { + const chunks: Buffer[] = []; + let totalBytes = 0; + res.on("data", (chunk: Buffer | string) => { + const next = typeof chunk === "string" ? Buffer.from(chunk) : chunk; + totalBytes += next.byteLength; + if (totalBytes > MAX_SIGNAL_HTTP_RESPONSE_BYTES) { + const error = new Error("Signal HTTP response exceeded size limit"); + request?.destroy(error); + res.destroy(error); + rejectOnce(error); + return; + } + chunks.push(next); + }); + res.on("error", rejectOnce); + res.on("end", () => { + resolveOnce({ + status: res.statusCode ?? 0, + statusText: res.statusMessage || "error", + text: Buffer.concat(chunks).toString("utf8"), + }); + }); + }, + ); + request.setTimeout(options.timeoutMs, () => { + request?.destroy(new Error(`Signal HTTP timed out after ${options.timeoutMs}ms`)); + }); + request.on("error", rejectOnce); + if (options.body !== undefined) { + request.write(options.body); + } + request.end(); + }); +} + export async function signalRpcRequest( method: string, params: Record | undefined, opts: SignalRpcOptions, ): Promise { - const baseUrl = normalizeBaseUrl(opts.baseUrl); const id = generateSecureUuid(); const body = JSON.stringify({ jsonrpc: "2.0", @@ -81,24 +186,22 @@ export async function signalRpcRequest( params, id, }); - const res = await fetchWithTimeout( - `${baseUrl}/api/v1/rpc`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body, + const res = await requestSignalHttpText(resolveSignalEndpointUrl(opts.baseUrl, "/api/v1/rpc"), { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": String(Buffer.byteLength(body)), }, - opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, - getRequiredFetch(), - ); + body, + timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }); if (res.status === 201) { return undefined as T; } - const text = await res.text(); - if (!text) { + if (!res.text) { throw new Error(`Signal RPC empty response (status ${res.status})`); } - const parsed = parseSignalRpcResponse(text, res.status); + const parsed = parseSignalRpcResponse(res.text, res.status); if (parsed.error) { const code = parsed.error.code ?? "unknown"; const msg = parsed.error.message ?? "Signal RPC error"; @@ -111,15 +214,12 @@ export async function signalCheck( baseUrl: string, timeoutMs = DEFAULT_TIMEOUT_MS, ): Promise<{ ok: boolean; status?: number | null; error?: string | null }> { - const normalized = normalizeBaseUrl(baseUrl); try { - const res = await fetchWithTimeout( - `${normalized}/api/v1/check`, - { method: "GET" }, + const res = await requestSignalHttpText(resolveSignalEndpointUrl(baseUrl, "/api/v1/check"), { + method: "GET", timeoutMs, - getRequiredFetch(), - ); - if (!res.ok) { + }); + if (res.status < 200 || res.status >= 300) { return { ok: false, status: res.status, error: `HTTP ${res.status}` }; } return { ok: true, status: res.status, error: null }; @@ -132,35 +232,99 @@ export async function signalCheck( } } +function openSignalEventStream( + url: URL, + abortSignal?: AbortSignal, + timeoutMs = DEFAULT_TIMEOUT_MS, +): Promise<{ response: IncomingMessage; cleanup: () => void }> { + assertSignalHttpProtocol(url, "SSE"); + if (abortSignal?.aborted) { + throw createSignalSseAbortError(); + } + + const client = url.protocol === "https:" ? https : http; + return new Promise((resolve, reject) => { + let settled = false; + let response: IncomingMessage | undefined; + let onAbort: () => void = () => {}; + let request: ClientRequest; + const headerDeadline = setTimeout(() => { + const error = new Error(`Signal SSE connection timed out after ${timeoutMs}ms`); + response?.destroy(error); + request.destroy(error); + rejectOnce(error); + }, timeoutMs); + headerDeadline.unref?.(); + const cleanup = () => { + clearTimeout(headerDeadline); + abortSignal?.removeEventListener("abort", onAbort); + }; + const rejectOnce = (error: unknown) => { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(error); + }; + request = client.request( + url, + { + method: "GET", + headers: { Accept: "text/event-stream" }, + }, + (res) => { + const status = res.statusCode ?? 0; + if (status < 200 || status >= 300) { + res.resume(); + rejectOnce(new Error(`Signal SSE failed (${status} ${res.statusMessage || "error"})`)); + return; + } + if (settled) { + res.destroy(); + return; + } + clearTimeout(headerDeadline); + settled = true; + response = res; + resolve({ response: res, cleanup }); + }, + ); + onAbort = () => { + const error = createSignalSseAbortError(); + response?.destroy(error); + request.destroy(error); + rejectOnce(error); + }; + + abortSignal?.addEventListener("abort", onAbort, { once: true }); + request.on("error", rejectOnce); + request.end(); + }); +} + export async function streamSignalEvents(params: { baseUrl: string; account?: string; abortSignal?: AbortSignal; + timeoutMs?: number; onEvent: (event: SignalSseEvent) => void; }): Promise { - const baseUrl = normalizeBaseUrl(params.baseUrl); - const url = new URL(`${baseUrl}/api/v1/events`); + const url = resolveSignalEndpointUrl(params.baseUrl, "/api/v1/events"); if (params.account) { url.searchParams.set("account", params.account); } - const fetchImpl = resolveFetch(); - if (!fetchImpl) { - throw new Error("fetch is not available"); - } - const res = await fetchImpl(url, { - method: "GET", - headers: { Accept: "text/event-stream" }, - signal: params.abortSignal, - }); - if (!res.ok || !res.body) { - throw new Error(`Signal SSE failed (${res.status} ${res.statusText || "error"})`); - } - - const reader = res.body.getReader(); + const { response, cleanup } = await openSignalEventStream( + url, + params.abortSignal, + params.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ); const decoder = new TextDecoder(); let buffer = ""; + let bufferedBytes = 0; let currentEvent: SignalSseEvent = {}; + let currentEventDataBytes = 0; const flushEvent = () => { if (!currentEvent.data && !currentEvent.event && !currentEvent.id) { @@ -172,14 +336,36 @@ export async function streamSignalEvents(params: { id: currentEvent.id, }); currentEvent = {}; + currentEventDataBytes = 0; }; - while (true) { - const { value, done } = await reader.read(); - if (done) { - break; + const processLine = (line: string) => { + if (line === "") { + flushEvent(); + return; } - buffer += decoder.decode(value, { stream: true }); + if (line.startsWith(":")) { + return; + } + const [rawField, ...rest] = line.split(":"); + const field = rawField.trim(); + const rawValue = rest.join(":"); + const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue; + if (field === "event") { + currentEvent.event = value; + } else if (field === "data") { + const segment = currentEvent.data ? `\n${value}` : value; + currentEventDataBytes += Buffer.byteLength(segment, "utf8"); + if (currentEventDataBytes > MAX_SIGNAL_SSE_EVENT_DATA_BYTES) { + throw new Error("Signal SSE event data exceeded size limit"); + } + currentEvent.data = currentEvent.data ? `${currentEvent.data}${segment}` : segment; + } else if (field === "id") { + currentEvent.id = value; + } + }; + + const drainCompleteLines = () => { let lineEnd = buffer.indexOf("\n"); while (lineEnd !== -1) { let line = buffer.slice(0, lineEnd); @@ -187,29 +373,33 @@ export async function streamSignalEvents(params: { if (line.endsWith("\r")) { line = line.slice(0, -1); } - - if (line === "") { - flushEvent(); - lineEnd = buffer.indexOf("\n"); - continue; - } - if (line.startsWith(":")) { - lineEnd = buffer.indexOf("\n"); - continue; - } - const [rawField, ...rest] = line.split(":"); - const field = rawField.trim(); - const rawValue = rest.join(":"); - const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue; - if (field === "event") { - currentEvent.event = value; - } else if (field === "data") { - currentEvent.data = currentEvent.data ? `${currentEvent.data}\n${value}` : value; - } else if (field === "id") { - currentEvent.id = value; - } + processLine(line); lineEnd = buffer.indexOf("\n"); } + bufferedBytes = Buffer.byteLength(buffer, "utf8"); + }; + + try { + for await (const chunk of response as AsyncIterable) { + const value = typeof chunk === "string" ? Buffer.from(chunk) : chunk; + bufferedBytes += value.byteLength; + if (bufferedBytes > MAX_SIGNAL_SSE_BUFFER_BYTES) { + throw new Error("Signal SSE buffer exceeded size limit"); + } + buffer += decoder.decode(value, { stream: true }); + drainCompleteLines(); + } + const tail = decoder.decode(); + if (tail) { + buffer += tail; + bufferedBytes = Buffer.byteLength(buffer, "utf8"); + } + if (bufferedBytes > MAX_SIGNAL_SSE_BUFFER_BYTES) { + throw new Error("Signal SSE buffer exceeded size limit"); + } + drainCompleteLines(); + } finally { + cleanup(); } flushEvent(); diff --git a/package.json b/package.json index ceff5ef7824..1a83d946cfc 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "!dist/qa-runtime-*.js", "docs/", "!docs/.generated/**", + "patches/", "skills/", "scripts/npm-runner.mjs", "scripts/preinstall-package-manager-warning.mjs", diff --git a/scripts/watch-node.d.mts b/scripts/watch-node.d.mts index 362670826a6..88b13600663 100644 --- a/scripts/watch-node.d.mts +++ b/scripts/watch-node.d.mts @@ -17,6 +17,18 @@ export function runWatchMain(params?: { on: (event: "add" | "change" | "unlink" | "error", cb: (arg?: unknown) => void) => void; close?: () => Promise | void; }; + loadChokidar?: () => Promise<{ + watch: ( + paths: string[], + options: { + ignoreInitial: boolean; + ignored: (watchPath: string) => boolean; + }, + ) => { + on: (event: "add" | "change" | "unlink" | "error", cb: (arg?: unknown) => void) => void; + close?: () => Promise | void; + }; + }>; watchPaths?: string[]; process?: NodeJS.Process; cwd?: string; diff --git a/scripts/watch-node.mjs b/scripts/watch-node.mjs index eecda3bf0b0..edefe2e7930 100644 --- a/scripts/watch-node.mjs +++ b/scripts/watch-node.mjs @@ -5,7 +5,6 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; -import chokidar from "chokidar"; import { isRestartRelevantRunNodePath, runNodeWatchedPaths } from "./run-node.mjs"; const WATCH_NODE_RUNNER = "scripts/run-node.mjs"; @@ -120,6 +119,39 @@ const logWatcher = (message, deps) => { deps.process.stderr?.write?.(`[openclaw] ${message}\n`); }; +const isInvalidPackageConfigError = (err) => err?.code === "ERR_INVALID_PACKAGE_CONFIG"; + +const extractInvalidPackageConfigPath = (err) => { + const message = String(err?.message ?? ""); + const match = message.match(/Invalid package config (.+?) while importing /); + return match?.[1] ?? null; +}; + +const printFriendlyWatchStartupError = (err) => { + const packageConfigPath = extractInvalidPackageConfigPath(err); + + console.error(""); + console.error( + "[openclaw] gateway:watch could not start because a dependency package config looks corrupted.", + ); + if (packageConfigPath) { + console.error(`[openclaw] Invalid package config: ${packageConfigPath}`); + } + console.error("[openclaw] This usually means a file in node_modules is empty or truncated."); + console.error("[openclaw] Recommended recovery:"); + console.error("[openclaw] rm -rf node_modules"); + console.error("[openclaw] pnpm store prune"); + console.error("[openclaw] pnpm install"); + console.error(""); + console.error("[openclaw] Original error:"); + console.error(err); +}; + +const loadChokidar = async () => { + const mod = await import("chokidar"); + return mod.default ?? mod; +}; + const waitForWatcherRelease = async (lockPath, pid, deps) => { const deadline = deps.now() + WATCH_LOCK_WAIT_MS; while (deps.now() < deadline) { @@ -212,6 +244,19 @@ const releaseWatchLock = (lockHandle) => { * }} [params] */ export async function runWatchMain(params = {}) { + let createWatcher = params.createWatcher; + if (!createWatcher) { + try { + const chokidarModule = await (params.loadChokidar ?? loadChokidar)(); + createWatcher = (watchPaths, options) => chokidarModule.watch(watchPaths, options); + } catch (err) { + if (isInvalidPackageConfigError(err)) { + printFriendlyWatchStartupError(err); + } + throw err; + } + } + const deps = { spawn: params.spawn ?? spawn, process: params.process ?? process, @@ -222,8 +267,7 @@ export async function runWatchMain(params = {}) { sleep: params.sleep ?? sleep, signalProcess: params.signalProcess ?? ((pid, signal) => process.kill(pid, signal)), lockDisabled: params.lockDisabled === true, - createWatcher: - params.createWatcher ?? ((watchPaths, options) => chokidar.watch(watchPaths, options)), + createWatcher, watchPaths: params.watchPaths ?? runNodeWatchedPaths, }; @@ -363,7 +407,9 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { void runWatchMain() .then((code) => process.exit(code)) .catch((err) => { - console.error(err); + if (!isInvalidPackageConfigError(err)) { + console.error(err); + } process.exit(1); }); } diff --git a/src/agents/skills-install.test.ts b/src/agents/skills-install.test.ts index 9db7145a74c..dda90668539 100644 --- a/src/agents/skills-install.test.ts +++ b/src/agents/skills-install.test.ts @@ -122,11 +122,18 @@ async function withWorkspaceCase( describe("installSkill code safety scanning", () => { beforeEach(() => { resetGlobalHookRunner(); - skillsInstallTesting.setDepsForTest({ - loadWorkspaceSkillEntries: loadTestWorkspaceSkillEntries, - }); runCommandWithTimeoutMock.mockClear(); scanDirectoryWithSummaryMock.mockClear(); + skillsInstallTesting.setDepsForTest({ + loadWorkspaceSkillEntries: loadTestWorkspaceSkillEntries, + resolveNodeInstallStateDir: () => { + const stateDir = process.env.OPENCLAW_STATE_DIR; + if (!stateDir) { + throw new Error("OPENCLAW_STATE_DIR missing in skills install test"); + } + return stateDir; + }, + }); runCommandWithTimeoutMock.mockResolvedValue({ code: 0, stdout: "ok", @@ -187,6 +194,60 @@ describe("installSkill code safety scanning", () => { }); }); + it("runs npm node installs with an OpenClaw-managed user prefix", async () => { + await withWorkspaceCase(async ({ workspaceDir, stateDir }) => { + await writeInstallableSkill(workspaceDir, "node-prefix-skill"); + + const result = await installSkill({ + workspaceDir, + skillName: "node-prefix-skill", + installId: "deps", + }); + + expect(result.ok).toBe(true); + const npmPrefix = path.join(stateDir, "tools", "node", "npm"); + const call = runCommandWithTimeoutMock.mock.calls.at(-1); + expect(call?.[0]).toEqual(["npm", "install", "-g", "--ignore-scripts", "example-package"]); + const options = call?.[1] as { env?: NodeJS.ProcessEnv }; + expect(options.env).toMatchObject({ + NPM_CONFIG_PREFIX: npmPrefix, + npm_config_prefix: npmPrefix, + }); + expect(options.env).not.toHaveProperty("PATH"); + const stat = await fs.stat(npmPrefix); + expect(stat.isDirectory()).toBe(true); + }); + }); + + it("keeps the default npm prefix out of env-overridden state paths", () => { + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR", "OPENCLAW_CONFIG_PATH"]); + try { + process.env.OPENCLAW_STATE_DIR = "/tmp/untrusted-state"; + process.env.OPENCLAW_CONFIG_PATH = "/tmp/untrusted-config/openclaw.json"; + + expect( + skillsInstallTesting.resolveDefaultNodeInstallStateDir({ + getuid: () => 501, + homedir: () => "/Users/tester", + platform: "darwin", + }), + ).toBe("/Users/tester/.openclaw"); + } finally { + envSnapshot.restore(); + } + }); + + it("uses a fixed system state root for root npm installs", () => { + expect( + skillsInstallTesting.resolveDefaultNodeInstallStateDir({ + cwd: "/workspace/openclaw", + getuid: () => 0, + homedir: () => "/root", + platform: "linux", + }), + ).toBe("/var/lib/openclaw"); + }); + it("blocks install when skill scan fails", async () => { await withWorkspaceCase(async ({ workspaceDir }) => { await writeInstallableSkill(workspaceDir, "scanfail-skill"); diff --git a/src/agents/skills-install.ts b/src/agents/skills-install.ts index a55f865a7c6..59dadb3cc0a 100644 --- a/src/agents/skills-install.ts +++ b/src/agents/skills-install.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveBrewExecutable as defaultResolveBrewExecutable } from "../infra/brew.js"; @@ -35,6 +36,7 @@ export type { SkillInstallResult } from "./skills-install.types.js"; type SkillsInstallDeps = { hasBinary: (bin: string) => boolean; loadWorkspaceSkillEntries: typeof defaultLoadWorkspaceSkillEntries; + resolveNodeInstallStateDir: () => string; resolveBrewExecutable: () => string | undefined; resolveSkillsInstallPreferences: typeof defaultResolveSkillsInstallPreferences; }; @@ -42,6 +44,7 @@ type SkillsInstallDeps = { const defaultSkillsInstallDeps: SkillsInstallDeps = { hasBinary: defaultHasBinary, loadWorkspaceSkillEntries: defaultLoadWorkspaceSkillEntries, + resolveNodeInstallStateDir: resolveDefaultNodeInstallStateDir, resolveBrewExecutable: defaultResolveBrewExecutable, resolveSkillsInstallPreferences: defaultResolveSkillsInstallPreferences, }; @@ -107,6 +110,37 @@ function buildNodeInstallCommand(packageName: string, prefs: SkillsInstallPrefer } } +function resolveDefaultNodeInstallStateDir({ + cwd = process.cwd(), + getuid = process.getuid?.bind(process), + homedir = os.homedir, + platform = process.platform, +}: { + cwd?: string; + getuid?: () => number; + homedir?: () => string; + platform?: NodeJS.Platform; +} = {}): string { + if (platform !== "win32" && getuid?.() === 0) { + return path.join(path.parse(cwd).root, "var", "lib", "openclaw"); + } + return path.join(homedir(), ".openclaw"); +} + +async function buildNodeInstallEnv(prefs: SkillsInstallPreferences): Promise { + if (prefs.nodeManager !== "npm") { + return {}; + } + + const stateDir = getSkillsInstallDeps().resolveNodeInstallStateDir(); + const prefix = path.join(stateDir, "tools", "node", "npm"); + await fs.promises.mkdir(prefix, { recursive: true, mode: 0o700 }); + return { + NPM_CONFIG_PREFIX: prefix, + npm_config_prefix: prefix, + }; +} + // Strict allowlist patterns to prevent option injection and malicious package names. const SAFE_BREW_FORMULA = /^[a-z0-9][a-z0-9+._@-]*(\/[a-z0-9][a-z0-9+._@-]*){0,2}$/; const SAFE_NODE_PACKAGE = /^(@[a-z0-9._-]+\/)?[a-z0-9._-]+(@[a-z0-9^~>=<.*|-]+)?$/; @@ -524,6 +558,9 @@ export async function installSkill(params: SkillInstallRequest): Promise): void { skillsInstallDeps = { ...defaultSkillsInstallDeps, diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index 8823666bd72..fcba5bf999d 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -731,6 +731,53 @@ describe("bundled channel entry shape guards", () => { } }); + it("caches undefined bundled plugin loads as unavailable", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-null-load-")); + const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + const pluginDir = path.join(root, "dist", "extensions", "alpha"); + const testGlobal = globalThis as typeof globalThis & { + __bundledPluginUndefinedLoads?: number; + }; + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "index.js"), + [ + "export default {", + " kind: 'bundled-channel-entry',", + " id: 'alpha',", + " name: 'Alpha',", + " description: 'Alpha',", + " register() {},", + " loadChannelPlugin() {", + " globalThis.__bundledPluginUndefinedLoads = (globalThis.__bundledPluginUndefinedLoads ?? 0) + 1;", + " return undefined;", + " },", + "};", + "", + ].join("\n"), + "utf8", + ); + + mockAlphaDistExtensionRuntime(); + + try { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(root, "dist", "extensions"); + + const bundled = await importFreshModule( + import.meta.url, + "./bundled.js?scope=bundled-undefined-load", + ); + + expect(bundled.getBundledChannelPlugin("alpha")).toBeUndefined(); + expect(bundled.getBundledChannelPlugin("alpha")).toBeUndefined(); + expect(testGlobal.__bundledPluginUndefinedLoads).toBe(1); + } finally { + restoreBundledPluginsDir(previousBundledPluginsDir); + fs.rmSync(root, { recursive: true, force: true }); + delete testGlobal.__bundledPluginUndefinedLoads; + } + }); + it("keeps channel entrypoints on the dedicated entry-contract SDK surface", () => { const offenders = collectBundledChannelEntrypointOffenders( bundledPluginRoots, diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 933a5d0d9f6..80b6eb51b4e 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -545,7 +545,11 @@ function getBundledChannelPluginForRoot( cacheContext.pluginLoadInProgressIds.add(id); try { const metadata = resolveBundledChannelMetadata(id, rootScope); - const plugin = entry.loadChannelPlugin(); + const plugin = entry.loadChannelPlugin() as ChannelPlugin | undefined; + if (!plugin) { + cacheContext.lazyPluginsById.set(id, null); + return undefined; + } const normalizedPlugin = { ...plugin, meta: normalizeChannelMeta({ diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 98149b8bc81..f841a7b6163 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -11,7 +11,6 @@ import { getCachedPluginJitiLoader, type PluginJitiLoaderCache, } from "../../plugins/jiti-loader-cache.js"; -import type { loadOpenClawPlugins as loadOpenClawPluginsType } from "../../plugins/loader.js"; import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; import { loadPluginManifestRegistryForPluginRegistry } from "../../plugins/plugin-registry.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; @@ -33,7 +32,23 @@ const LOADER_MODULE_CANDIDATES = [ const jitiLoaders: PluginJitiLoaderCache = new Map(); type PluginLoaderModule = { - loadOpenClawPlugins: typeof loadOpenClawPluginsType; + loadOpenClawPlugins: (params: { + config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + env?: NodeJS.ProcessEnv; + workspaceDir?: string; + cache?: boolean; + activate?: boolean; + includeSetupOnlyChannelPlugins?: boolean; + forceSetupOnlyChannelPlugins?: boolean; + requireSetupEntryForSetupOnlyChannelPlugins?: boolean; + onlyPluginIds?: readonly string[]; + }) => { + channelSetups: Iterable<{ + pluginId: string; + plugin: ChannelPlugin; + }>; + }; }; let pluginLoaderModule: PluginLoaderModule | undefined; diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index 92af2432c2c..24fd49db866 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -12,10 +12,6 @@ vi.mock("../terminal/note.js", () => ({ note, })); -vi.mock("../channels/plugins/index.js", () => ({ - listChannelPlugins: () => pluginRegistry.list, -})); - vi.mock("../channels/plugins/read-only.js", () => ({ listReadOnlyChannelPluginsForConfig: listReadOnlyChannelPluginsForConfigMock, })); diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index 4f97b724a61..b6f0f0e5956 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -67,6 +67,9 @@ describe("Dockerfile", () => { expect(dockerfile).toContain( "COPY --from=runtime-assets --chown=node:node /app/node_modules ./node_modules", ); + expect(dockerfile).toContain( + "COPY --from=runtime-assets --chown=node:node /app/patches ./patches", + ); }); it("keeps package manager patch files in runtime images", async () => { diff --git a/src/infra/watch-node.test.ts b/src/infra/watch-node.test.ts index 83adf8a4c9c..47427154afb 100644 --- a/src/infra/watch-node.test.ts +++ b/src/infra/watch-node.test.ts @@ -345,6 +345,69 @@ describe("watch-node script", () => { expect(watcher.close).toHaveBeenCalledTimes(1); }); + it("prints recovery guidance when chokidar fails with invalid package config", async () => { + const error = Object.assign( + new Error( + 'Invalid package config /tmp/openclaw/.pnpm/chokidar/package.json while importing "chokidar" from /tmp/openclaw/scripts/watch-node.mjs.', + ), + { code: "ERR_INVALID_PACKAGE_CONFIG" }, + ); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect( + runWatch({ + args: ["gateway", "--force"], + cwd: "/tmp/openclaw", + loadChokidar: vi.fn(async () => { + throw error; + }), + process: createFakeProcess(), + }), + ).rejects.toBe(error); + + expect(errorSpy.mock.calls).toEqual([ + [""], + [ + "[openclaw] gateway:watch could not start because a dependency package config looks corrupted.", + ], + ["[openclaw] Invalid package config: /tmp/openclaw/.pnpm/chokidar/package.json"], + ["[openclaw] This usually means a file in node_modules is empty or truncated."], + ["[openclaw] Recommended recovery:"], + ["[openclaw] rm -rf node_modules"], + ["[openclaw] pnpm store prune"], + ["[openclaw] pnpm install"], + [""], + ["[openclaw] Original error:"], + [error], + ]); + } finally { + errorSpy.mockRestore(); + } + }); + + it("does not log non-package-config chokidar import errors before rethrowing", async () => { + const error = Object.assign(new Error("Cannot find package 'chokidar'"), { + code: "ERR_MODULE_NOT_FOUND", + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect( + runWatch({ + loadChokidar: vi.fn(async () => { + throw error; + }), + process: createFakeProcess(), + }), + ).rejects.toBe(error); + + expect(errorSpy).not.toHaveBeenCalled(); + } finally { + errorSpy.mockRestore(); + } + }); + it("replaces an existing watcher lock holder before starting", async () => { const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness(); await withTempDir({ prefix: "openclaw-watch-node-lock-" }, async (cwd) => { From f164b8b357b972a404795cf70d7fa6fe31fc3225 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 01:39:25 -0700 Subject: [PATCH 06/25] docs(webchat): note that reasoning-flagged payloads are excluded from WebChat assistant content, transcript text, and audio blocks (4823288b3b) --- docs/web/webchat.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/web/webchat.md b/docs/web/webchat.md index a423fc61191..7cbeba6a8ab 100644 --- a/docs/web/webchat.md +++ b/docs/web/webchat.md @@ -33,6 +33,7 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket. leaked ASCII/full-width model control tokens are stripped from visible text, and assistant entries whose whole visible text is only the exact silent token `NO_REPLY` / `no_reply` are omitted. +- Reasoning-flagged reply payloads (`isReasoning: true`) are excluded from WebChat assistant content, transcript replay text, and audio content blocks, so thinking-only payloads do not surface as visible assistant messages or playable audio. - `chat.inject` appends an assistant note directly to the transcript and broadcasts it to the UI (no agent run). - Aborted runs can keep partial assistant output visible in the UI. - Gateway persists aborted partial assistant text into transcript history when buffered output exists, and marks those entries with abort metadata. From 218636a0eaf81f88a8358f644040e2477da0a03b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:39:50 +0100 Subject: [PATCH 07/25] docs(changelog): split 2026.4.25 and 2026.4.26 notes --- CHANGELOG.md | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f65c9999c9e..8e04dced077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,28 @@ Docs: https://docs.openclaw.ai ## Unreleased +## 2026.4.26 + ### Fixes - Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd. +- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex. +- Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc. +- Plugins/channels: treat malformed bundled channel plugin loaders that return `undefined` as unavailable instead of crashing config and help paths. Fixes #69044. Thanks @frankhli843 and @vincentkoc. +- Scripts/watch: show corrupted dependency package-config recovery guidance when `gateway:watch` fails during watcher startup, without double-logging unrelated import failures. (#58780) Thanks @roytong9 and @vincentkoc. +- Signal: read signal-cli RPC, health checks, and SSE events through Node's HTTP client so Node 24/25 fetch regressions do not break Signal sends or inbound events. Fixes #51716 and #53040. Thanks @Barukimang, @minupla, and @vincentkoc. +- Skills/Docker: run npm-backed skill dependency installs with an OpenClaw-managed user prefix so non-root Docker images do not write to `/usr/local`. Fixes #59601. Thanks @chanjarster and @vincentkoc. -## 2026.4.26 +## 2026.4.25 + +### Highlights + +- Voice replies get a full TTS upgrade: `/tts latest`, chat-scoped auto-TTS controls, personas, per-agent/per-account overrides, and new Azure Speech, Xiaomi, Local CLI, Inworld, Volcengine, and ElevenLabs v3 provider coverage. Thanks @leonchui, @zoujiejun, @solar2ain, @cshape, @xuruiray, @itsuzef, and @barronlroth. +- Plugin startup and install paths move to the cold persisted registry, cutting broad manifest scans while making plugin update, repair, provider discovery, and install metadata more deterministic. Thanks @vincentkoc and @shakkernerd. +- OpenTelemetry coverage expands across model calls, token usage, tool loops, harness runs, exec processes, outbound delivery, context assembly, and memory pressure with bounded low-cardinality attributes. Thanks @vincentkoc, @jlapenna, @Lidang-Jiang, and @oc-factus. +- Browser automation gets safer tab URLs, iframe-aware role snapshots, CDP readiness tuning, headless one-shot launch, and deeper browser doctor probes for slow hosts. Thanks @beat843796 and @BenediktSchackenberg. +- Control UI and setup flows add PWA/Web Push support, Crestodian first-run repair, TUI setup, context mode selection, and a shorter startup greeting. Thanks @eduardocruz, @SebTardif, and @kevinlin-openai. +- Install/update hardening covers Windows, macOS, Linux, Docker, bundled plugin runtime deps, Node service restarts, LaunchAgent token rotation, and mixed-version gateway verification. Thanks @Kobevictor, @igormf, @abhinas90, @jsompis, @Solvely-Colin, and @gucasbrg. ### Changes @@ -83,7 +100,6 @@ Docs: https://docs.openclaw.ai ### Fixes -- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex. - UI/Windows: quote resolved pnpm `.cmd` launcher paths before spawning UI install/build/test commands so Node installs under `C:\Program Files` no longer fail as `C:\Program`. Fixes #45275. Thanks @Kobevictor, @stoppieboy, and @iubns. - Codex/agent: translate `--thinking minimal` to `low` for modern Codex models (gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.2) at request build time so the first turn is accepted instead of paying a wasted call + retry-with-low fallback. Older Codex models still receive `minimal` directly. Fixes #71946. Thanks @hclsys. - Plugins/uninstall: remove tracked plugin files from their recorded managed extensions root even when the current state directory points somewhere else, so `openclaw plugins uninstall --force` does not leave the plugin discoverable. Thanks @shakkernerd. @@ -111,11 +127,6 @@ Docs: https://docs.openclaw.ai - Installer: warn when multiple npm global roots contain OpenClaw installs, showing active Node/npm/openclaw plus each install path and version so stale version-manager installs are visible. Fixes #40839. Thanks @zhixianio. - Cron/tasks: recover completed cron task ledger records from durable run logs and job state before marking them `lost`, reducing false `backing session missing` audit errors for isolated cron runs and keeping offline CLI audit from treating its empty local cron active-job set as authoritative. Fixes #71963. - Docker: copy patched dependency files into runtime images so downstream `pnpm install` layers keep working. Fixes #69224. Thanks @gucasbrg. -- Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc. -- Plugins/channels: treat malformed bundled channel plugin loaders that return `undefined` as unavailable instead of crashing config and help paths. Fixes #69044. Thanks @frankhli843 and @vincentkoc. -- Scripts/watch: show corrupted dependency package-config recovery guidance when `gateway:watch` fails during watcher startup, without double-logging unrelated import failures. (#58780) Thanks @roytong9 and @vincentkoc. -- Signal: read signal-cli RPC, health checks, and SSE events through Node's HTTP client so Node 24/25 fetch regressions do not break Signal sends or inbound events. Fixes #51716 and #53040. Thanks @Barukimang, @minupla, and @vincentkoc. -- Skills/Docker: run npm-backed skill dependency installs with an OpenClaw-managed user prefix so non-root Docker images do not write to `/usr/local`. Fixes #59601. Thanks @chanjarster and @vincentkoc. - Agents/runtime: submit heartbeat, cron, and exec wakeups as transient runtime context instead of visible user prompts, keeping synthetic system work out of chat transcripts. Fixes #66496 and #66814. Thanks @jeades and @mandomaker. - Telegram: include native quote excerpts automatically for threaded replies and reply tags when the original Telegram text is available, without adding another config knob. Fixes #6975. Thanks @rex05ai. - Node/Linux: make `openclaw node install` enable and restart the `openclaw-node` systemd unit instead of the gateway unit on node-only VMs. Fixes #68287. Thanks @dlebee-agent. From 2652c9eacfd54ac69ec2f2ba653b86849995cd06 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 01:41:57 -0700 Subject: [PATCH 08/25] fix(configure): defer web search setup runtime Keep web-search configure and channel command defaults on cold plugin metadata, harden persisted registry reads, and require active config for manifest command defaults.\n\nThanks @vincentkoc --- CHANGELOG.md | 1 + extensions/discord/src/monitor/provider.ts | 7 +- .../plugins/read-only-command-defaults.ts | 44 ++++- src/commands/configure.wizard.test.ts | 25 +++ src/commands/configure.wizard.ts | 45 +++--- .../search-setup-cold-imports.test.ts | 15 ++ src/config/commands.test.ts | 34 +++- src/config/commands.ts | 13 +- src/gateway/server-methods/commands.ts | 5 +- src/plugins/command-registry-state.ts | 34 ++++ src/plugins/commands.test.ts | 40 +++++ .../installed-plugin-index-store.test.ts | 37 +++++ src/plugins/installed-plugin-index-store.ts | 151 ++++++++++-------- 13 files changed, 353 insertions(+), 98 deletions(-) create mode 100644 src/commands/search-setup-cold-imports.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e04dced077..bb22198f6a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Providers/plugins: keep onboarding and auth-choice setup lists on cold manifest/install metadata and add Provider Index install metadata for not-yet-installed provider plugins. Thanks @vincentkoc. - Providers/plugins: keep provider setup guidance and configure auth imports on cold manifest metadata, with a regression guard against static provider-runtime imports on setup/configure list paths. Thanks @vincentkoc. - CLI/capabilities: keep capability command registration from importing the models auth runtime until `model auth login` actually runs. Thanks @vincentkoc. +- CLI/configure: keep web-search configure prompts on cold plugin registry metadata until the user chooses managed search setup. Thanks @vincentkoc. - Plugins/chat commands: refresh the persisted plugin registry after `/plugins enable` and `/plugins disable`, matching the CLI mutation path. Thanks @vincentkoc. - Plugins/compat: mark `OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY` as a deprecated break-glass switch and point operators at registry repair instead. Thanks @vincentkoc. - Plugins/registry: ignore stale persisted registry reads when plugin policy no longer matches current config, and stamp generated registry files with a do-not-edit warning. Thanks @vincentkoc. diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 0a86576a445..547eecde115 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -188,6 +188,7 @@ function formatThreadBindingDurationForConfigLabel(durationMs: number): string { async function appendPluginCommandSpecs(params: { commandSpecs: NativeCommandSpec[]; runtime: RuntimeEnv; + cfg: OpenClawConfig; }): Promise { const merged = [...params.commandSpecs]; const existingNames = new Set( @@ -195,7 +196,7 @@ async function appendPluginCommandSpecs(params: { ); const getPluginCommandSpecs = getPluginCommandSpecsForTesting ?? (await loadPluginRuntime()).getPluginCommandSpecs; - for (const pluginCommand of getPluginCommandSpecs("discord")) { + for (const pluginCommand of getPluginCommandSpecs("discord", { config: params.cfg })) { const normalizedName = normalizeLowercaseStringOrEmpty(pluginCommand.name); if (!normalizedName) { continue; @@ -747,7 +748,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }) : []; if (nativeEnabled) { - commandSpecs = await appendPluginCommandSpecs({ commandSpecs, runtime }); + commandSpecs = await appendPluginCommandSpecs({ commandSpecs, runtime, cfg }); } const initialCommandCount = commandSpecs.length; if (nativeEnabled && nativeSkillsEnabled && commandSpecs.length > maxDiscordCommands) { @@ -756,7 +757,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { cfg, { skillCommands: [], provider: "discord" }, ); - commandSpecs = await appendPluginCommandSpecs({ commandSpecs, runtime }); + commandSpecs = await appendPluginCommandSpecs({ commandSpecs, runtime, cfg }); runtime.log?.( warn( `discord: ${initialCommandCount} commands exceeds limit; removing per-skill commands and keeping /skill.`, diff --git a/src/channels/plugins/read-only-command-defaults.ts b/src/channels/plugins/read-only-command-defaults.ts index abacae4de21..17ef8265ebf 100644 --- a/src/channels/plugins/read-only-command-defaults.ts +++ b/src/channels/plugins/read-only-command-defaults.ts @@ -1,6 +1,10 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { isBlockedObjectKey } from "../../infra/prototype-keys.js"; import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; -import { loadPluginManifestRegistryForPluginRegistry } from "../../plugins/plugin-registry.js"; +import { + isPluginEnabled, + loadPluginManifestRegistryForPluginRegistry, +} from "../../plugins/plugin-registry.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import type { ChannelPlugin } from "./types.plugin.js"; @@ -36,12 +40,17 @@ export function normalizeChannelCommandDefaults( : undefined; const nativeSkillsAutoEnabled = typeof value.nativeSkillsAutoEnabled === "boolean" ? value.nativeSkillsAutoEnabled : undefined; - return nativeCommandsAutoEnabled !== undefined || nativeSkillsAutoEnabled !== undefined - ? { - ...(nativeCommandsAutoEnabled !== undefined ? { nativeCommandsAutoEnabled } : {}), - ...(nativeSkillsAutoEnabled !== undefined ? { nativeSkillsAutoEnabled } : {}), - } - : undefined; + if (nativeCommandsAutoEnabled === undefined && nativeSkillsAutoEnabled === undefined) { + return undefined; + } + const defaults: ChannelCommandDefaults = {}; + if (nativeCommandsAutoEnabled !== undefined) { + defaults.nativeCommandsAutoEnabled = nativeCommandsAutoEnabled; + } + if (nativeSkillsAutoEnabled !== undefined) { + defaults.nativeSkillsAutoEnabled = nativeSkillsAutoEnabled; + } + return defaults; } export function resolveReadOnlyChannelCommandDefaults( @@ -50,13 +59,15 @@ export function resolveReadOnlyChannelCommandDefaults( env?: NodeJS.ProcessEnv; stateDir?: string; workspaceDir?: string; - } = {}, + config: OpenClawConfig; + }, ): ChannelCommandDefaults | undefined { const normalizedChannelId = normalizeOptionalString(channelId) ?? ""; if (!normalizedChannelId || !isSafeManifestChannelId(normalizedChannelId)) { return undefined; } const registry = loadPluginManifestRegistryForPluginRegistry({ + config: options.config, stateDir: options.stateDir, workspaceDir: options.workspaceDir, env: options.env ?? process.env, @@ -66,6 +77,23 @@ export function resolveReadOnlyChannelCommandDefaults( if (!record.channels.includes(normalizedChannelId)) { continue; } + if ( + record.id !== normalizedChannelId && + record.channelCatalogMeta?.id !== normalizedChannelId + ) { + continue; + } + if ( + !isPluginEnabled({ + pluginId: record.id, + config: options.config, + stateDir: options.stateDir, + workspaceDir: options.workspaceDir, + env: options.env ?? process.env, + }) + ) { + continue; + } const channelConfigValue = record.channelConfigs ? readOwnRecordValue(record.channelConfigs as Record, normalizedChannelId) : undefined; diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index a932e654ae1..96cc3d4bba9 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => { clackText: vi.fn(), clackConfirm: vi.fn(), resolveSearchProviderOptions: vi.fn(), + resolvePluginContributionOwners: vi.fn(), setupSearch: vi.fn(), readConfigFileSnapshot: vi.fn(), writeConfigFile, @@ -113,6 +114,10 @@ vi.mock("./onboard-search.js", () => ({ setupSearch: mocks.setupSearch, })); +vi.mock("../plugins/plugin-registry.js", () => ({ + resolvePluginContributionOwners: mocks.resolvePluginContributionOwners, +})); + vi.mock("../agents/codex-native-web-search.js", () => ({ isCodexNativeWebSearchRelevant: mocks.isCodexNativeWebSearchRelevant, })); @@ -210,6 +215,7 @@ describe("runConfigureWizard", () => { beforeEach(() => { vi.clearAllMocks(); mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true }); + mocks.resolvePluginContributionOwners.mockReturnValue(["firecrawl"]); mocks.resolveSearchProviderOptions.mockReturnValue([ { id: "firecrawl", @@ -360,6 +366,25 @@ describe("runConfigureWizard", () => { ); }); + it("does not load managed search provider options when web search is disabled", async () => { + setupBaseWizardState(); + queueWizardPrompts({ + select: ["local"], + confirm: [false, true], + }); + + await runWebConfigureWizard(); + + expect(mocks.resolvePluginContributionOwners).toHaveBeenCalledWith( + expect.objectContaining({ + contribution: "contracts", + matches: "webSearchProviders", + }), + ); + expect(mocks.resolveSearchProviderOptions).not.toHaveBeenCalled(); + expect(mocks.setupSearch).not.toHaveBeenCalled(); + }); + it("defers channel status checks until a channel is selected", async () => { setupBaseWizardState(); queueWizardPrompts({ diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 236244be557..073aa06eb83 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -9,6 +9,7 @@ import { logConfigUpdated } from "../config/logging.js"; import { ConfigMutationConflictError } from "../config/mutate.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; +import { resolvePluginContributionOwners } from "../plugins/plugin-registry.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; @@ -199,9 +200,13 @@ async function promptWebToolsConfig( type WebSearchConfig = NonNullable["web"]>["search"]; const existingSearch = nextConfig.tools?.web?.search; const existingFetch = nextConfig.tools?.web?.fetch; - const { resolveSearchProviderOptions, setupSearch } = await import("./onboard-search.js"); const { isCodexNativeWebSearchRelevant } = await import("../agents/codex-native-web-search.js"); - const searchProviderOptions = resolveSearchProviderOptions(nextConfig); + const hasManagedSearchProviders = + resolvePluginContributionOwners({ + config: nextConfig, + contribution: "contracts", + matches: "webSearchProviders", + }).length > 0; note( [ @@ -215,7 +220,7 @@ async function promptWebToolsConfig( const enableSearch = guardCancel( await confirm({ message: "Enable web_search?", - initialValue: existingSearch?.enabled ?? searchProviderOptions.length > 0, + initialValue: existingSearch?.enabled ?? hasManagedSearchProviders, }), runtime, ); @@ -297,8 +302,10 @@ async function promptWebToolsConfig( } } - if (searchProviderOptions.length === 0) { - if (configureManagedProvider) { + if (configureManagedProvider) { + const { resolveSearchProviderOptions, setupSearch } = await import("./onboard-search.js"); + const searchProviderOptions = resolveSearchProviderOptions(nextConfig); + if (searchProviderOptions.length === 0) { note( [ "No web search providers are currently available under this plugin policy.", @@ -307,23 +314,23 @@ async function promptWebToolsConfig( ].join("\n"), "Web search", ); - } - if (nextSearch.openaiCodex?.enabled !== true) { + if (nextSearch.openaiCodex?.enabled !== true) { + nextSearch = { + ...existingSearch, + enabled: false, + }; + } + } else { + workingConfig = await setupSearch(workingConfig, runtime, prompter); nextSearch = { - ...existingSearch, - enabled: false, + ...workingConfig.tools?.web?.search, + enabled: workingConfig.tools?.web?.search?.provider ? true : existingSearch?.enabled, + openaiCodex: { + ...existingSearch?.openaiCodex, + ...(nextSearch.openaiCodex as Record | undefined), + }, }; } - } else if (configureManagedProvider) { - workingConfig = await setupSearch(workingConfig, runtime, prompter); - nextSearch = { - ...workingConfig.tools?.web?.search, - enabled: workingConfig.tools?.web?.search?.provider ? true : existingSearch?.enabled, - openaiCodex: { - ...existingSearch?.openaiCodex, - ...(nextSearch.openaiCodex as Record | undefined), - }, - }; } } diff --git a/src/commands/search-setup-cold-imports.test.ts b/src/commands/search-setup-cold-imports.test.ts new file mode 100644 index 00000000000..70ec99ada60 --- /dev/null +++ b/src/commands/search-setup-cold-imports.test.ts @@ -0,0 +1,15 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repoRoot = fileURLToPath(new URL("../..", import.meta.url)); + +describe("search setup cold imports", () => { + it("keeps configure wizard command registration off search provider runtime", () => { + const source = fs.readFileSync(path.join(repoRoot, "src/commands/configure.wizard.ts"), "utf8"); + + expect(source).not.toMatch(/\bfrom\s+["'][^"']*onboard-search\.js["']/); + expect(source).not.toMatch(/\bfrom\s+["'][^"']*web-search-providers\.runtime\.js["']/); + }); +}); diff --git a/src/config/commands.test.ts b/src/config/commands.test.ts index f6650cacd2d..accf03b6aec 100644 --- a/src/config/commands.test.ts +++ b/src/config/commands.test.ts @@ -101,7 +101,7 @@ describe("resolveNativeSkillsEnabled", () => { ).toBe(false); }); - it("uses package channel metadata for bundled auto defaults before runtime loads", () => { + it("uses only enabled package channel metadata for bundled auto defaults before runtime loads", () => { setActivePluginRegistry(createTestRegistry([])); const env = { ...process.env, @@ -117,6 +117,22 @@ describe("resolveNativeSkillsEnabled", () => { globalSetting: "auto", env, }), + ).toBe(false); + expect( + resolveNativeSkillsEnabled({ + providerId: "discord", + globalSetting: "auto", + env, + config: { + plugins: { + entries: { + discord: { + enabled: true, + }, + }, + }, + }, + }), ).toBe(true); expect( resolveNativeCommandsEnabled({ @@ -125,6 +141,22 @@ describe("resolveNativeSkillsEnabled", () => { env, }), ).toBe(false); + expect( + resolveNativeCommandsEnabled({ + providerId: "discord", + globalSetting: "auto", + env, + config: { + plugins: { + entries: { + discord: { + enabled: false, + }, + }, + }, + }, + }), + ).toBe(false); }); it("honors explicit provider settings", () => { diff --git a/src/config/commands.ts b/src/config/commands.ts index 14a7b089f4b..68818077386 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -3,6 +3,7 @@ import { resolveReadOnlyChannelCommandDefaults } from "../channels/plugins/read- import type { ChannelId } from "../channels/plugins/types.public.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import type { NativeCommandsSetting } from "./types.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; export { isCommandFlagEnabled, isRestartEnabled, type CommandFlagKey } from "./commands.flags.js"; function resolveAutoDefault( @@ -12,6 +13,7 @@ function resolveAutoDefault( env?: NodeJS.ProcessEnv; stateDir?: string; workspaceDir?: string; + config?: OpenClawConfig; autoDefault?: boolean; }, ): boolean { @@ -23,7 +25,13 @@ function resolveAutoDefault( return options.autoDefault; } const commandDefaults = - getLoadedChannelPlugin(id)?.commands ?? resolveReadOnlyChannelCommandDefaults(id, options); + getLoadedChannelPlugin(id)?.commands ?? + (options?.config + ? resolveReadOnlyChannelCommandDefaults(id, { + ...options, + config: options.config, + }) + : undefined); if (kind === "native") { return commandDefaults?.nativeCommandsAutoEnabled === true; } @@ -37,6 +45,7 @@ export function resolveNativeSkillsEnabled(params: { env?: NodeJS.ProcessEnv; stateDir?: string; workspaceDir?: string; + config?: OpenClawConfig; autoDefault?: boolean; }): boolean { return resolveNativeCommandSetting({ ...params, kind: "nativeSkills" }); @@ -49,6 +58,7 @@ export function resolveNativeCommandsEnabled(params: { env?: NodeJS.ProcessEnv; stateDir?: string; workspaceDir?: string; + config?: OpenClawConfig; autoDefault?: boolean; }): boolean { return resolveNativeCommandSetting({ ...params, kind: "native" }); @@ -62,6 +72,7 @@ function resolveNativeCommandSetting(params: { env?: NodeJS.ProcessEnv; stateDir?: string; workspaceDir?: string; + config?: OpenClawConfig; autoDefault?: boolean; }): boolean { const { providerId, providerSetting, globalSetting, kind = "native", ...options } = params; diff --git a/src/gateway/server-methods/commands.ts b/src/gateway/server-methods/commands.ts index 946ce068b24..5d63cefd146 100644 --- a/src/gateway/server-methods/commands.ts +++ b/src/gateway/server-methods/commands.ts @@ -172,9 +172,10 @@ function mapCommand( function buildPluginCommandEntries(params: { provider?: string; nameSurface: CommandNameSurface; + cfg: OpenClawConfig; }): CommandEntry[] { const pluginTextSpecs = listPluginCommands(); - const pluginNativeSpecs = getPluginCommandSpecs(params.provider); + const pluginNativeSpecs = getPluginCommandSpecs(params.provider, { config: params.cfg }); const entries: CommandEntry[] = []; for (const [index, textSpec] of pluginTextSpecs.entries()) { @@ -233,7 +234,7 @@ export function buildCommandsListResult(params: { ); } - commands.push(...buildPluginCommandEntries({ provider, nameSurface })); + commands.push(...buildPluginCommandEntries({ provider, nameSurface, cfg: params.cfg })); return { commands: commands.slice(0, COMMAND_LIST_MAX_ITEMS) }; } diff --git a/src/plugins/command-registry-state.ts b/src/plugins/command-registry-state.ts index fb1f8cc0db4..33fbdd06f69 100644 --- a/src/plugins/command-registry-state.ts +++ b/src/plugins/command-registry-state.ts @@ -1,3 +1,6 @@ +import { getLoadedChannelPlugin } from "../channels/plugins/index.js"; +import { resolveReadOnlyChannelCommandDefaults } from "../channels/plugins/read-only-command-defaults.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import type { OpenClawPluginCommandDefinition } from "./types.js"; @@ -81,6 +84,37 @@ function resolvePluginNativeName( return command.name; } +export function getPluginCommandSpecs( + provider?: string, + options: { + env?: NodeJS.ProcessEnv; + stateDir?: string; + workspaceDir?: string; + config?: OpenClawConfig; + } = {}, +): Array<{ + name: string; + description: string; + acceptsArgs: boolean; +}> { + const providerName = normalizeOptionalLowercaseString(provider); + const commandDefaults = + providerName && options.config + ? resolveReadOnlyChannelCommandDefaults(providerName, { + ...options, + config: options.config, + }) + : undefined; + if ( + providerName && + (getLoadedChannelPlugin(providerName)?.commands ?? commandDefaults) + ?.nativeCommandsAutoEnabled !== true + ) { + return []; + } + return listProviderPluginCommandSpecs(provider); +} + /** Resolve plugin command specs for a provider's native naming surface without support gating. */ export function listProviderPluginCommandSpecs(provider?: string): Array<{ name: string; diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 0fd2d36ad1a..0bda679304e 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { @@ -331,6 +332,45 @@ describe("registerPluginCommand", () => { ]); }); + it("requires config before using read-only manifest command defaults", () => { + setActivePluginRegistry(createTestRegistry([])); + registerVoiceCommandForTest({ + nativeNames: { + discord: "discordvoice", + }, + description: "Demo command", + }); + const env = { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: path.resolve("extensions"), + OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY: "1", + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", + }; + + expect(getPluginCommandSpecs("discord", { env })).toEqual([]); + expect( + getPluginCommandSpecs("discord", { + env, + config: { + plugins: { + entries: { + discord: { + enabled: true, + }, + }, + }, + }, + }), + ).toEqual([ + { + name: "discordvoice", + description: "Demo command", + acceptsArgs: false, + }, + ]); + }); + it("accepts native progress metadata on plugin commands", () => { const result = registerVoiceCommandForTest({ nativeProgressMessages: { telegram: "Running voice command..." }, diff --git a/src/plugins/installed-plugin-index-store.test.ts b/src/plugins/installed-plugin-index-store.test.ts index e4bfb175792..61af416c615 100644 --- a/src/plugins/installed-plugin-index-store.test.ts +++ b/src/plugins/installed-plugin-index-store.test.ts @@ -102,6 +102,43 @@ describe("installed plugin index persistence", () => { await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject(index); }); + it("does not preserve prototype poison keys from persisted index JSON", async () => { + const stateDir = makeTempDir(); + const filePath = resolveInstalledPluginIndexStorePath({ stateDir }); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const index = createIndex({ + installRecords: { + demo: { + source: "npm", + spec: "demo@1.0.0", + }, + }, + }); + Object.defineProperty(index, "__proto__", { + enumerable: true, + value: { polluted: true }, + }); + Object.defineProperty(index.installRecords, "__proto__", { + enumerable: true, + value: { polluted: true }, + }); + fs.writeFileSync(filePath, JSON.stringify(index), "utf8"); + + const persisted = await readPersistedInstalledPluginIndex({ stateDir }); + + expect(persisted).toMatchObject({ + plugins: [expect.objectContaining({ pluginId: "demo" })], + installRecords: { + demo: expect.objectContaining({ source: "npm" }), + }, + }); + expect(Object.prototype.hasOwnProperty.call(persisted as object, "__proto__")).toBe(false); + expect(Object.prototype.hasOwnProperty.call(persisted?.installRecords ?? {}, "__proto__")).toBe( + false, + ); + expect(({} as Record).polluted).toBeUndefined(); + }); + it("returns null for missing or invalid persisted indexes", async () => { const stateDir = makeTempDir(); await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toBeNull(); diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index a5d23b3467f..0827ca174c9 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { saveJsonFile } from "../infra/json-file.js"; import { readJsonFile, readJsonFileSync, writeJsonAtomic } from "../infra/json-files.js"; +import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import { safeParseWithSchema } from "../utils/zod-parse.js"; import { resolveInstalledPluginIndexStorePath, @@ -15,6 +16,7 @@ import { loadInstalledPluginIndex, refreshInstalledPluginIndex, type InstalledPluginIndex, + type InstalledPluginInstallRecordInfo, type InstalledPluginIndexRefreshReason, type LoadInstalledPluginIndexParams, type RefreshInstalledPluginIndexParams, @@ -36,71 +38,79 @@ export type InstalledPluginIndexStoreInspection = { const StringArraySchema = z.array(z.string()); -const InstalledPluginIndexStartupSchema = z - .object({ - sidecar: z.boolean(), - memory: z.boolean(), - deferConfiguredChannelFullLoadUntilAfterListen: z.boolean(), - agentHarnesses: StringArraySchema, - }) - .passthrough(); +const InstalledPluginIndexStartupSchema = z.object({ + sidecar: z.boolean(), + memory: z.boolean(), + deferConfiguredChannelFullLoadUntilAfterListen: z.boolean(), + agentHarnesses: StringArraySchema, +}); -const InstalledPluginIndexRecordSchema = z - .object({ - pluginId: z.string(), - packageName: z.string().optional(), - packageVersion: z.string().optional(), - installRecord: z.record(z.string(), z.unknown()).optional(), - installRecordHash: z.string().optional(), - packageInstall: z.unknown().optional(), - packageChannel: z.unknown().optional(), - manifestPath: z.string(), - manifestHash: z.string(), - format: z.string().optional(), - bundleFormat: z.string().optional(), - source: z.string().optional(), - setupSource: z.string().optional(), - packageJson: z - .object({ - path: z.string(), - hash: z.string(), - }) - .optional(), - rootDir: z.string(), - origin: z.string(), - enabled: z.boolean(), - enabledByDefault: z.boolean().optional(), - startup: InstalledPluginIndexStartupSchema, - compat: z.array(z.string()), - }) - .passthrough(); +const InstalledPluginIndexRecordSchema = z.object({ + pluginId: z.string(), + packageName: z.string().optional(), + packageVersion: z.string().optional(), + installRecord: z.record(z.string(), z.unknown()).optional(), + installRecordHash: z.string().optional(), + packageInstall: z.unknown().optional(), + packageChannel: z.unknown().optional(), + manifestPath: z.string(), + manifestHash: z.string(), + format: z.string().optional(), + bundleFormat: z.string().optional(), + source: z.string().optional(), + setupSource: z.string().optional(), + packageJson: z + .object({ + path: z.string(), + hash: z.string(), + }) + .optional(), + rootDir: z.string(), + origin: z.string(), + enabled: z.boolean(), + enabledByDefault: z.boolean().optional(), + startup: InstalledPluginIndexStartupSchema, + compat: z.array(z.string()), +}); const InstalledPluginInstallRecordSchema = z.record(z.string(), z.unknown()); -const PluginDiagnosticSchema = z - .object({ - level: z.union([z.literal("warn"), z.literal("error")]), - message: z.string(), - pluginId: z.string().optional(), - source: z.string().optional(), - }) - .passthrough(); +const PluginDiagnosticSchema = z.object({ + level: z.union([z.literal("warn"), z.literal("error")]), + message: z.string(), + pluginId: z.string().optional(), + source: z.string().optional(), +}); -const InstalledPluginIndexSchema = z - .object({ - version: z.literal(INSTALLED_PLUGIN_INDEX_VERSION), - warning: z.string().optional(), - hostContractVersion: z.string(), - compatRegistryVersion: z.string(), - migrationVersion: z.literal(INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION), - policyHash: z.string(), - generatedAtMs: z.number(), - refreshReason: z.string().optional(), - installRecords: z.record(z.string(), InstalledPluginInstallRecordSchema).optional(), - plugins: z.array(InstalledPluginIndexRecordSchema), - diagnostics: z.array(PluginDiagnosticSchema), - }) - .passthrough(); +const InstalledPluginIndexSchema = z.object({ + version: z.literal(INSTALLED_PLUGIN_INDEX_VERSION), + warning: z.string().optional(), + hostContractVersion: z.string(), + compatRegistryVersion: z.string(), + migrationVersion: z.literal(INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION), + policyHash: z.string(), + generatedAtMs: z.number(), + refreshReason: z.string().optional(), + installRecords: z.record(z.string(), InstalledPluginInstallRecordSchema).optional(), + plugins: z.array(InstalledPluginIndexRecordSchema), + diagnostics: z.array(PluginDiagnosticSchema), +}); + +function copySafeInstallRecords( + records: Readonly> | undefined, +): Record | undefined { + if (!records) { + return undefined; + } + const safeRecords: Record = {}; + for (const [pluginId, record] of Object.entries(records)) { + if (isBlockedObjectKey(pluginId)) { + continue; + } + safeRecords[pluginId] = record; + } + return safeRecords; +} function parseInstalledPluginIndex(value: unknown): InstalledPluginIndex | null { const parsed = safeParseWithSchema(InstalledPluginIndexSchema, value) as @@ -111,11 +121,24 @@ function parseInstalledPluginIndex(value: unknown): InstalledPluginIndex | null if (!parsed) { return null; } - return { - ...parsed, - installRecords: - parsed.installRecords ?? + const installRecords = + copySafeInstallRecords(parsed.installRecords) ?? + copySafeInstallRecords( extractPluginInstallRecordsFromInstalledPluginIndex(parsed as InstalledPluginIndex), + ) ?? + {}; + return { + version: parsed.version, + ...(parsed.warning ? { warning: parsed.warning } : {}), + hostContractVersion: parsed.hostContractVersion, + compatRegistryVersion: parsed.compatRegistryVersion, + migrationVersion: parsed.migrationVersion, + policyHash: parsed.policyHash, + generatedAtMs: parsed.generatedAtMs, + ...(parsed.refreshReason ? { refreshReason: parsed.refreshReason } : {}), + installRecords, + plugins: parsed.plugins, + diagnostics: parsed.diagnostics, }; } From 5ab5b7534837586397d3fc0ebb9481163f97aff4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:42:12 +0100 Subject: [PATCH 09/25] fix: update Docker plugin registry smokes --- scripts/e2e/config-reload-source-docker.sh | 17 ++-------- scripts/e2e/plugin-update-unchanged-docker.sh | 34 +++++++++++-------- 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/scripts/e2e/config-reload-source-docker.sh b/scripts/e2e/config-reload-source-docker.sh index fefdf543481..9421c72edd3 100755 --- a/scripts/e2e/config-reload-source-docker.sh +++ b/scripts/e2e/config-reload-source-docker.sh @@ -44,6 +44,7 @@ cat > \"\$HOME/.openclaw/openclaw.json\" <<'JSON' \"id\": \"GATEWAY_AUTH_TOKEN_REF\" } }, + \"channelHealthCheckMinutes\": 1, \"controlUi\": { \"enabled\": false }, @@ -51,17 +52,6 @@ cat > \"\$HOME/.openclaw/openclaw.json\" <<'JSON' \"mode\": \"hybrid\", \"debounceMs\": 0 } - }, - \"plugins\": { - \"installs\": { - \"lossless-claw\": { - \"source\": \"npm\", - \"spec\": \"@martian-engineering/lossless-claw\", - \"installPath\": \"/tmp/lossless-claw\", - \"installedAt\": \"2026-04-22T00:00:00.000Z\", - \"resolvedAt\": \"2026-04-22T00:00:00.000Z\" - } - } } } JSON @@ -110,7 +100,7 @@ entry=dist/index.mjs node \"\$entry\" gateway status --url ws://127.0.0.1:$PORT --token '$TOKEN' --require-rpc --timeout 30000 >/tmp/config-reload-status-before.log " -echo "Mutating plugin install timestamp metadata..." +echo "Mutating hot-reload gateway metadata..." docker exec "$CONTAINER_NAME" bash -lc "node --input-type=module - <<'NODE' import fs from 'node:fs'; import os from 'node:os'; @@ -118,8 +108,7 @@ import path from 'node:path'; const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json'); const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); -config.plugins.installs['lossless-claw'].installedAt = '2026-04-22T00:01:00.000Z'; -config.plugins.installs['lossless-claw'].resolvedAt = '2026-04-22T00:01:00.000Z'; +config.gateway.channelHealthCheckMinutes = 2; fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); NODE" diff --git a/scripts/e2e/plugin-update-unchanged-docker.sh b/scripts/e2e/plugin-update-unchanged-docker.sh index dd586fee1c5..ab9f3308c1c 100755 --- a/scripts/e2e/plugin-update-unchanged-docker.sh +++ b/scripts/e2e/plugin-update-unchanged-docker.sh @@ -36,20 +36,26 @@ mkdir -p \"\$HOME/.openclaw/plugins\" cat > \"\$HOME/.openclaw/plugins/installs.json\" <<'JSON' { \"version\": 1, - \"warning\": \"DO NOT EDIT. This file is generated by OpenClaw plugin install/update/uninstall commands. Use `openclaw plugins install/update/uninstall` instead.\", - \"updatedAtMs\": 1777118400000, - \"records\": { - \"lossless-claw\": { - \"source\": \"npm\", - \"spec\": \"@example/lossless-claw@0.9.0\", - \"installPath\": \"~/.openclaw/extensions/lossless-claw\", - \"resolvedName\": \"@example/lossless-claw\", - \"resolvedVersion\": \"0.9.0\", - \"resolvedSpec\": \"@example/lossless-claw@0.9.0\", - \"integrity\": \"sha512-same\", - \"shasum\": \"same\" - } - } + \"warning\": \"DO NOT EDIT. This file is generated by OpenClaw plugin registry commands.\", + \"hostContractVersion\": \"docker-e2e\", + \"compatRegistryVersion\": \"docker-e2e\", + \"migrationVersion\": 1, + \"policyHash\": \"docker-e2e\", + \"generatedAtMs\": 1777118400000, + \"installRecords\": { + \"lossless-claw\": { + \"source\": \"npm\", + \"spec\": \"@example/lossless-claw@0.9.0\", + \"installPath\": \"~/.openclaw/extensions/lossless-claw\", + \"resolvedName\": \"@example/lossless-claw\", + \"resolvedVersion\": \"0.9.0\", + \"resolvedSpec\": \"@example/lossless-claw@0.9.0\", + \"integrity\": \"sha512-same\", + \"shasum\": \"same\" + } + }, + \"plugins\": [], + \"diagnostics\": [] } JSON From e29d3516bf05591e6007bfb8ccdaea6d76194d41 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:41:44 +0100 Subject: [PATCH 10/25] fix(gateway): skip Tailscale Control UI pairing --- CHANGELOG.md | 1 + src/gateway/server.auth.modes.suite.ts | 24 +++++++++++ src/gateway/server.auth.shared.ts | 3 +- .../ws-connection/connect-policy.test.ts | 41 +++++++++++++++++++ .../server/ws-connection/connect-policy.ts | 4 ++ .../server/ws-connection/message-handler.ts | 1 + 6 files changed, 73 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb22198f6a1..a494056a546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Tailscale: let Tailscale-authenticated Control UI operator sessions with browser device identity skip the device-pairing round trip while still rejecting device-less and node-role connections. Refs #71986. Thanks @jokedul. - Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd. - Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex. - Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc. diff --git a/src/gateway/server.auth.modes.suite.ts b/src/gateway/server.auth.modes.suite.ts index 3a589bdbd89..f0efddc455e 100644 --- a/src/gateway/server.auth.modes.suite.ts +++ b/src/gateway/server.auth.modes.suite.ts @@ -145,9 +145,18 @@ export function registerAuthModesSuite(): void { describe("tailscale auth", () => { let server: Awaited>; let port: number; + const tailscaleOrigin = "https://gateway.tailnet.ts.net"; beforeAll(async () => { testState.gatewayAuth = { mode: "token", token: "secret", allowTailscale: true }; + testState.gatewayControlUi = { allowedOrigins: [tailscaleOrigin] }; + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + gateway: { + auth: testState.gatewayAuth, + controlUi: testState.gatewayControlUi, + }, + }); port = await getFreePort(); server = await startGatewayServer(port); }); @@ -158,6 +167,7 @@ export function registerAuthModesSuite(): void { beforeEach(() => { testState.gatewayAuth = { mode: "token", token: "secret", allowTailscale: true }; + testState.gatewayControlUi = { allowedOrigins: [tailscaleOrigin] }; testTailscaleWhois.value = { login: "peter", name: "Peter" }; }); @@ -173,6 +183,20 @@ export function registerAuthModesSuite(): void { ws.close(); }); + test("skips pairing for tailscale-authenticated control ui with device identity", async () => { + const ws = await openTailscaleWs(port, { origin: tailscaleOrigin }); + const res = await connectReq(ws, { + skipDefaultAuth: true, + client: { + ...CONTROL_UI_CLIENT, + }, + }); + expect(res.ok, JSON.stringify(res)).toBe(true); + const status = await rpcReq(ws, "status"); + expect(status.ok).toBe(true); + ws.close(); + }); + test("connects with shared token but clears scopes when tailscale auth skips device", async () => { const ws = await openTailscaleWs(port); const res = await connectReq(ws, { token: "secret", device: null }); diff --git a/src/gateway/server.auth.shared.ts b/src/gateway/server.auth.shared.ts index e57523c11f4..aadfa1053ce 100644 --- a/src/gateway/server.auth.shared.ts +++ b/src/gateway/server.auth.shared.ts @@ -74,7 +74,7 @@ const readConnectChallengeNonce = async (ws: WebSocket) => { return String(nonce); }; -const openTailscaleWs = async (port: number) => { +const openTailscaleWs = async (port: number, headers?: Record) => { const ws = new WebSocket(`ws://127.0.0.1:${port}`, { headers: { "x-forwarded-for": "100.64.0.1", @@ -82,6 +82,7 @@ const openTailscaleWs = async (port: number) => { "x-forwarded-host": "gateway.tailnet.ts.net", "tailscale-user-login": "peter", "tailscale-user-name": "Peter", + ...headers, }, }); trackConnectChallengeNonce(ws); diff --git a/src/gateway/server/ws-connection/connect-policy.test.ts b/src/gateway/server/ws-connection/connect-policy.test.ts index ad50272622e..49f1fdbce7d 100644 --- a/src/gateway/server/ws-connection/connect-policy.test.ts +++ b/src/gateway/server/ws-connection/connect-policy.test.ts @@ -251,6 +251,47 @@ describe("ws connect policy", () => { expect(shouldSkipControlUiPairing(controlUi, "operator", false)).toBe(false); }); + test("tailscale auth skips pairing only for operator control-ui with device identity", () => { + const device = { + id: "dev-1", + publicKey: "pk", + signature: "sig", + signedAt: Date.now(), + nonce: "nonce-1", + }; + const controlUiWithDevice = resolveControlUiAuthPolicy({ + isControlUi: true, + controlUiConfig: undefined, + deviceRaw: device, + }); + const controlUiWithoutDevice = resolveControlUiAuthPolicy({ + isControlUi: true, + controlUiConfig: undefined, + deviceRaw: null, + }); + const nonControlUiWithDevice = resolveControlUiAuthPolicy({ + isControlUi: false, + controlUiConfig: undefined, + deviceRaw: device, + }); + + expect( + shouldSkipControlUiPairing(controlUiWithDevice, "operator", false, "token", "tailscale"), + ).toBe(true); + expect( + shouldSkipControlUiPairing(controlUiWithoutDevice, "operator", false, "token", "tailscale"), + ).toBe(false); + expect( + shouldSkipControlUiPairing(controlUiWithDevice, "node", false, "token", "tailscale"), + ).toBe(false); + expect( + shouldSkipControlUiPairing(nonControlUiWithDevice, "operator", false, "token", "tailscale"), + ).toBe(false); + expect( + shouldSkipControlUiPairing(controlUiWithDevice, "operator", false, "token", "token"), + ).toBe(false); + }); + test("trusted-proxy control-ui bypass only applies to operator + trusted-proxy auth", () => { const cases: Array<{ role: "operator" | "node"; diff --git a/src/gateway/server/ws-connection/connect-policy.ts b/src/gateway/server/ws-connection/connect-policy.ts index c3628623b9c..27284609174 100644 --- a/src/gateway/server/ws-connection/connect-policy.ts +++ b/src/gateway/server/ws-connection/connect-policy.ts @@ -39,10 +39,14 @@ export function shouldSkipControlUiPairing( role: GatewayRole, trustedProxyAuthOk = false, authMode?: string, + authMethod?: string, ): boolean { if (trustedProxyAuthOk) { return true; } + if (policy.isControlUi && role === "operator" && authMethod === "tailscale" && policy.device) { + return true; + } // When auth is completely disabled (mode=none), there is no shared secret // or token to gate pairing. Requiring pairing in this configuration adds // friction without security value since any client can already connect diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 58aac425ae8..f3bf99bc8ed 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -844,6 +844,7 @@ export function attachGatewayWsMessageHandler(params: { role, trustedProxyAuthOk, resolvedAuth.mode, + authMethod, ); if (device && devicePublicKey) { const formatAuditList = (items: string[] | undefined): string => { From aad7b678b027b87ed359a58948f2c7c5bf757c30 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:45:03 +0100 Subject: [PATCH 11/25] fix: pass config to plugin command specs --- src/plugins/command-specs.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/plugins/command-specs.ts b/src/plugins/command-specs.ts index 3b8aaf25b8a..e1742289302 100644 --- a/src/plugins/command-specs.ts +++ b/src/plugins/command-specs.ts @@ -1,20 +1,34 @@ import { getLoadedChannelPlugin } from "../channels/plugins/index.js"; -import { resolveReadOnlyChannelCommandDefaults } from "../channels/plugins/read-only.js"; +import { resolveReadOnlyChannelCommandDefaults } from "../channels/plugins/read-only-command-defaults.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { listProviderPluginCommandSpecs } from "./command-registry-state.js"; -export function getPluginCommandSpecs(provider?: string): Array<{ +export function getPluginCommandSpecs( + provider?: string, + options: { + env?: NodeJS.ProcessEnv; + stateDir?: string; + workspaceDir?: string; + config?: OpenClawConfig; + } = {}, +): Array<{ name: string; description: string; acceptsArgs: boolean; }> { const providerName = normalizeOptionalLowercaseString(provider); + const commandDefaults = + providerName && options.config + ? resolveReadOnlyChannelCommandDefaults(providerName, { + ...options, + config: options.config, + }) + : undefined; if ( providerName && - ( - getLoadedChannelPlugin(providerName)?.commands ?? - resolveReadOnlyChannelCommandDefaults(providerName) - )?.nativeCommandsAutoEnabled !== true + (getLoadedChannelPlugin(providerName)?.commands ?? commandDefaults) + ?.nativeCommandsAutoEnabled !== true ) { return []; } From 6f50253a4d08b567da0c244f33a2d526d0bbea0c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:46:34 +0100 Subject: [PATCH 12/25] fix: clarify install switching --- docs/help/faq-first-run.md | 28 +++++++++++++++------------- docs/install/index.md | 4 ++++ docs/install/updating.md | 31 ++++++++++++++++++++++++++++++- scripts/install.ps1 | 19 ++++++++++++++++--- scripts/install.sh | 2 +- src/cli/update-cli.ts | 2 +- 6 files changed, 67 insertions(+), 19 deletions(-) diff --git a/docs/help/faq-first-run.md b/docs/help/faq-first-run.md index 87c0c83ba99..ff9a9e947fe 100644 --- a/docs/help/faq-first-run.md +++ b/docs/help/faq-first-run.md @@ -766,30 +766,32 @@ and troubleshooting see the main [FAQ](/help/faq). - Yes. Install the other flavor, then run Doctor so the gateway service points at the new entrypoint. - This **does not delete your data** - it only changes the OpenClaw code install. Your state - (`~/.openclaw`) and workspace (`~/.openclaw/workspace`) stay untouched. + Yes. Use `openclaw update --channel ...` when OpenClaw is already installed. + This **does not delete your data** - it only changes the OpenClaw code install. + Your state (`~/.openclaw`) and workspace (`~/.openclaw/workspace`) stay untouched. From npm to git: ```bash - git clone https://github.com/openclaw/openclaw.git - cd openclaw - pnpm install - pnpm build - openclaw doctor - openclaw gateway restart + openclaw update --channel dev ``` From git to npm: ```bash - npm install -g openclaw@latest - openclaw doctor - openclaw gateway restart + openclaw update --channel stable ``` - Doctor detects a gateway service entrypoint mismatch and offers to rewrite the service config to match the current install (use `--repair` in automation). + Add `--dry-run` to preview the planned mode switch first. The updater runs + Doctor follow-ups, refreshes plugin sources for the target channel, and + restarts the gateway unless you pass `--no-restart`. + + The installer can force either mode too: + + ```bash + curl -fsSL https://openclaw.ai/install.sh | bash -s -- --install-method git + curl -fsSL https://openclaw.ai/install.sh | bash -s -- --install-method npm + ``` Backup tips: see [Backup strategy](#where-things-live-on-disk). diff --git a/docs/install/index.md b/docs/install/index.md index b943c7db533..0c641cd1163 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -61,6 +61,10 @@ curl -fsSL https://openclaw.ai/install-cli.sh | bash It supports npm installs by default, plus git-checkout installs under the same prefix flow. Full reference: [Installer internals](/install/installer#install-clish). +Already installed? Switch between package and git installs with +`openclaw update --channel dev` and `openclaw update --channel stable`. See +[Updating](/install/updating#switch-between-npm-and-git-installs). + ### npm, pnpm, or bun If you already manage Node yourself: diff --git a/docs/install/updating.md b/docs/install/updating.md index 375d08ade8f..b46539f3c44 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -20,6 +20,7 @@ To switch channels or target a specific version: ```bash openclaw update --channel beta +openclaw update --channel dev openclaw update --tag main openclaw update --dry-run # preview without applying ``` @@ -30,13 +31,41 @@ if you want the raw npm beta dist-tag for a one-off package update. See [Development channels](/install/development-channels) for channel semantics. +## Switch between npm and git installs + +Use channels when you want to change the install type. The updater keeps your +state, config, credentials, and workspace in `~/.openclaw`; it only changes +which OpenClaw code install the CLI and gateway use. + +```bash +# npm package install -> editable git checkout +openclaw update --channel dev + +# git checkout -> npm package install +openclaw update --channel stable +``` + +Run with `--dry-run` first to preview the exact install-mode switch: + +```bash +openclaw update --channel dev --dry-run +openclaw update --channel stable --dry-run +``` + +The `dev` channel ensures a git checkout, builds it, and installs the global CLI +from that checkout. The `stable` and `beta` channels use package installs. If the +gateway is already installed, `openclaw update` refreshes the service metadata +and restarts it unless you pass `--no-restart`. + ## Alternative: re-run the installer ```bash curl -fsSL https://openclaw.ai/install.sh | bash ``` -Add `--no-onboard` to skip onboarding. For source installs, pass `--install-method git --no-onboard`. +Add `--no-onboard` to skip onboarding. To force a specific install type through +the installer, pass `--install-method git --no-onboard` or +`--install-method npm --no-onboard`. ## Alternative: manual npm, pnpm, or bun diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 4b08fbdb6fe..df86aaac385 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -3,6 +3,7 @@ # Or: & ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -NoOnboard param( + [ValidateSet("npm", "git")] [string]$InstallMethod = "npm", [string]$Tag = "latest", [string]$GitDir = "$env:USERPROFILE\openclaw", @@ -336,11 +337,13 @@ function Install-OpenClawGit { if (!(Test-Path $wrapperDir)) { New-Item -ItemType Directory -Path $wrapperDir -Force | Out-Null } - + + $entryPath = Join-Path $RepoDir "dist\entry.js" @" @echo off -node "%~dp0..\openclaw\dist\entry.js" %* +node "$entryPath" %* "@ | Out-File -FilePath "$wrapperDir\openclaw.cmd" -Encoding ASCII -Force + Add-ToPath -Path $wrapperDir Write-Host "OpenClaw installed" -Level success return $true @@ -432,7 +435,12 @@ function Main { if ($DryRun) { Write-Host "[DRY RUN] Would install OpenClaw from git to $GitDir" -Level info } else { - Install-OpenClawGit -RepoDir $GitDir -Update:(-not $NoGitUpdate) + try { + npm uninstall -g openclaw 2>$null | Out-Null + } catch { } + if (!(Install-OpenClawGit -RepoDir $GitDir -Update:(-not $NoGitUpdate))) { + return (Fail-Install) + } } } else { # npm method @@ -443,6 +451,11 @@ function Main { if ($DryRun) { Write-Host "[DRY RUN] Would install OpenClaw via npm ($((Resolve-PackageInstallSpec -Target $Tag)))" -Level info } else { + $gitWrapper = "$env:USERPROFILE\.local\bin\openclaw.cmd" + if (Test-Path $gitWrapper) { + Remove-Item -Force $gitWrapper + Write-Host "Removed git wrapper (switching to npm)" -Level info + } if (!(Install-OpenClawNpm -Target $Tag)) { return (Fail-Install) } diff --git a/scripts/install.sh b/scripts/install.sh index 9e56b40517e..6e1084d958a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2667,7 +2667,7 @@ main() { ui_section "Source install details" ui_kv "Checkout" "$final_git_dir" ui_kv "Wrapper" "$HOME/.local/bin/openclaw" - ui_kv "Update command" "openclaw update --restart" + ui_kv "Update command" "openclaw update" ui_kv "Switch to npm" "curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --install-method npm" elif [[ "$is_upgrade" == "true" ]]; then ui_info "Upgrade complete" diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index f0c669de3b8..2413de7fdb6 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -75,7 +75,7 @@ ${theme.heading("Switch channels:")} ${theme.heading("Non-interactive:")} - Use --yes to accept downgrade prompts - - Combine with --channel/--tag/--restart/--json/--timeout as needed + - Combine with --channel/--tag/--no-restart/--json/--timeout as needed - Use --dry-run to preview actions without writing config/installing/restarting ${theme.heading("Examples:")} From d9c5040fc5bc35b3b48be0086697915dfb079de0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:46:54 +0100 Subject: [PATCH 13/25] docs(tailscale): clarify Control UI pairing --- docs/gateway/tailscale.md | 5 +++++ docs/web/control-ui.md | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/gateway/tailscale.md b/docs/gateway/tailscale.md index 6b11d71f753..4d89f33dbcd 100644 --- a/docs/gateway/tailscale.md +++ b/docs/gateway/tailscale.md @@ -37,6 +37,11 @@ daemon (`tailscale whois`) and matching it to the header before accepting it. OpenClaw only treats a request as Serve when it arrives from loopback with Tailscale’s `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` headers. +For Control UI operator sessions that include browser device identity, this +verified Serve path also skips the device-pairing round trip. It does not bypass +browser device identity: device-less clients are still rejected, and node-role +or non-Control UI WebSocket connections still follow the normal pairing and +auth checks. HTTP API endpoints (for example `/v1/*`, `/tools/invoke`, and `/api/channels/*`) do **not** use Tailscale identity-header auth. They still follow the gateway's normal HTTP auth mode: shared-secret auth by default, or an intentionally diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 9a243170539..aa2e6609c67 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -33,7 +33,7 @@ The dashboard settings panel keeps a token for the current browser tab session a ## Device pairing (first connection) -When you connect to the Control UI from a new browser or device, the Gateway requires a **one-time pairing approval** — even if you're on the same Tailnet with `gateway.auth.allowTailscale: true`. This is a security measure to prevent unauthorized access. +When you connect to the Control UI from a new browser or device, the Gateway usually requires a **one-time pairing approval**. This is a security measure to prevent unauthorized access. **What you'll see:** "disconnected (1008): pairing required" @@ -58,7 +58,8 @@ Once approved, the device is remembered and won't require re-approval unless you - Direct local loopback browser connections (`127.0.0.1` / `localhost`) are auto-approved. -- Tailnet and LAN browser connects still require explicit approval, even when they originate from the same machine. +- Tailscale Serve can skip the pairing round trip for Control UI operator sessions when `gateway.auth.allowTailscale: true`, Tailscale identity verifies, and the browser presents its device identity. +- Direct Tailnet binds, LAN browser connects, and browser profiles without device identity still require explicit approval. - Each browser profile generates a unique device ID, so switching browsers or clearing browser data will require re-pairing. @@ -237,7 +238,7 @@ Absolute external `http(s)` embed URLs stay blocked by default. If you intention - `https:///` (or your configured `gateway.controlUi.basePath`) - By default, Control UI/WebSocket Serve requests can authenticate via Tailscale identity headers (`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. OpenClaw verifies the identity by resolving the `x-forwarded-for` address with `tailscale whois` and matching it to the header, and only accepts these when the request hits loopback with Tailscale's `x-forwarded-*` headers. Set `gateway.auth.allowTailscale: false` if you want to require explicit shared-secret credentials even for Serve traffic. Then use `gateway.auth.mode: "token"` or `"password"`. + By default, Control UI/WebSocket Serve requests can authenticate via Tailscale identity headers (`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. OpenClaw verifies the identity by resolving the `x-forwarded-for` address with `tailscale whois` and matching it to the header, and only accepts these when the request hits loopback with Tailscale's `x-forwarded-*` headers. For Control UI operator sessions with browser device identity, this verified Serve path also skips the device-pairing round trip; device-less browsers and node-role connections still follow the normal device checks. Set `gateway.auth.allowTailscale: false` if you want to require explicit shared-secret credentials even for Serve traffic. Then use `gateway.auth.mode: "token"` or `"password"`. For that async Serve identity path, failed auth attempts for the same client IP and auth scope are serialized before rate-limit writes. Concurrent bad retries from the same browser can therefore show `retry later` on the second request instead of two plain mismatches racing in parallel. From 832bdbc777a2650834effe46abb7e3a0b7a8a008 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:49:30 +0100 Subject: [PATCH 14/25] fix(update): repair package config after update --- CHANGELOG.md | 1 + src/cli/update-cli.test.ts | 4 ++-- src/cli/update-cli/update-command.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a494056a546..188dc0bef90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Gateway/Tailscale: let Tailscale-authenticated Control UI operator sessions with browser device identity skip the device-pairing round trip while still rejecting device-less and node-role connections. Refs #71986. Thanks @jokedul. - Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd. +- CLI/update: run package post-update doctor with `--fix` so package updates repair config migrations before restart. Thanks @shakkernerd. - Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex. - Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc. - Plugins/channels: treat malformed bundled channel plugin loaders that return `undefined` as unavailable instead of crashing config and help paths. Fixes #69044. Thanks @frankhli843 and @vincentkoc. diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 97887259237..fe63dde9f83 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1202,7 +1202,7 @@ describe("update-cli", () => { await updateCommand({ yes: true }); expect(runCommandWithTimeout).toHaveBeenCalledWith( - [expect.stringMatching(/node/), entryPath, "doctor", "--non-interactive"], + [expect.stringMatching(/node/), entryPath, "doctor", "--non-interactive", "--fix"], expect.objectContaining({ env: expect.objectContaining({ OPENCLAW_UPDATE_IN_PROGRESS: "1", @@ -1271,7 +1271,7 @@ describe("update-cli", () => { expect.any(Object), ); expect(runCommandWithTimeout).toHaveBeenCalledWith( - [expect.stringMatching(/node/), entryPath, "doctor", "--non-interactive"], + [expect.stringMatching(/node/), entryPath, "doctor", "--non-interactive", "--fix"], expect.any(Object), ); expect(updateNpmInstalledPlugins).toHaveBeenCalled(); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 19537de54ce..50af145670e 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -439,7 +439,7 @@ async function runPackageInstallUpdate(params: { if (entryPath) { const doctorStep = await runUpdateStep({ name: `${CLI_NAME} doctor`, - argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive"], + argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive", "--fix"], env: { ...process.env, OPENCLAW_UPDATE_IN_PROGRESS: "1", From 42487d0dacc5abe537f371cee5f0e7d64421f703 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:49:44 +0100 Subject: [PATCH 15/25] fix(update): retry npm updates without optional deps --- CHANGELOG.md | 1 + src/cli/update-cli.test.ts | 70 ++++++++++++++++++++++++++++ src/cli/update-cli/update-command.ts | 21 ++++++++- 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 188dc0bef90..bbd2cbd4a52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - Gateway/Tailscale: let Tailscale-authenticated Control UI operator sessions with browser device identity skip the device-pairing round trip while still rejecting device-less and node-role connections. Refs #71986. Thanks @jokedul. - Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd. - CLI/update: run package post-update doctor with `--fix` so package updates repair config migrations before restart. Thanks @shakkernerd. +- CLI/update: retry failed npm global updates with `--omit=optional` and ignore the superseded first failure when the fallback succeeds. Thanks @shakkernerd. - Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex. - Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc. - Plugins/channels: treat malformed bundled channel plugin loaders that return `undefined` as unavailable instead of crashing config and help paths. Fixes #69044. Thanks @frankhli843 and @vincentkoc. diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index fe63dde9f83..78fe06baa73 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1283,6 +1283,76 @@ describe("update-cli", () => { ).not.toContain("already-current"); }); + it("retries package updates without optional deps when npm global update fails", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-optional-")); + const nodeModules = path.join(tempDir, "node_modules"); + const pkgRoot = path.join(nodeModules, "openclaw"); + mockPackageInstallStatus(pkgRoot); + await fs.mkdir(pkgRoot, { recursive: true }); + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "1.0.0" }), + "utf-8", + ); + + vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { + return { + stdout: `${nodeModules}\n`, + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + } + if ( + Array.isArray(argv) && + argv[0] === "npm" && + argv.includes("-g") && + !argv.includes("--omit=optional") + ) { + return { + stdout: "", + stderr: "node-gyp failed", + code: 1, + signal: null, + killed: false, + termination: "exit", + }; + } + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + }); + + await updateCommand({ yes: true, restart: false }); + + expect(runCommandWithTimeout).toHaveBeenCalledWith( + ["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"], + expect.any(Object), + ); + expect(runCommandWithTimeout).toHaveBeenCalledWith( + [ + "npm", + "i", + "-g", + "openclaw@latest", + "--omit=optional", + "--no-fund", + "--no-audit", + "--loglevel=error", + ], + expect.any(Object), + ); + expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); + }); + it("uses the owning npm binary for package updates when PATH npm points elsewhere", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); const brewPrefix = createCaseDir("brew-prefix"); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 50af145670e..a896708e14f 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -37,6 +37,7 @@ import { canResolveRegistryVersionForPackageTarget, createGlobalInstallEnv, cleanupGlobalRenameDirs, + globalInstallFallbackArgs, globalInstallArgs, resolveExpectedInstalledVersionFromSpec, resolveGlobalInstallTarget, @@ -407,6 +408,21 @@ async function runPackageInstallUpdate(params: { }); const steps = [updateStep]; + let finalInstallStep = updateStep; + if (updateStep.exitCode !== 0) { + const fallbackArgv = globalInstallFallbackArgs(installTarget, installSpec); + if (fallbackArgv) { + const fallbackStep = await runUpdateStep({ + name: "global update (omit optional)", + argv: fallbackArgv, + env: installEnv, + timeoutMs: params.timeoutMs, + progress: params.progress, + }); + steps.push(fallbackStep); + finalInstallStep = fallbackStep; + } + } let afterVersion = beforeVersion; const verifiedPackageRoot = @@ -451,7 +467,10 @@ async function runPackageInstallUpdate(params: { } } - const failedStep = steps.find((step) => step.exitCode !== 0); + const failedStep = + finalInstallStep.exitCode !== 0 + ? finalInstallStep + : (steps.find((step) => step !== updateStep && step.exitCode !== 0) ?? null); return { status: failedStep ? "error" : "ok", mode: manager, From c6b7444d168e01aadc0d9ee3990d7322669a7992 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:49:53 +0100 Subject: [PATCH 16/25] fix(plugins): reset context engine slot on uninstall --- CHANGELOG.md | 1 + src/cli/plugins-cli-test-helpers.ts | 1 + src/cli/plugins-cli.ts | 8 ++++++ src/cli/plugins-cli.uninstall.test.ts | 6 +++++ src/plugins/uninstall.test.ts | 16 ++++++++++++ src/plugins/uninstall.ts | 11 ++++++++- src/plugins/update.test.ts | 35 +++++++++++++++++++++++++++ src/plugins/update.ts | 14 +++++------ 8 files changed, 84 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbd2cbd4a52..70450d05fe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd. - CLI/update: run package post-update doctor with `--fix` so package updates repair config migrations before restart. Thanks @shakkernerd. - CLI/update: retry failed npm global updates with `--omit=optional` and ignore the superseded first failure when the fallback succeeds. Thanks @shakkernerd. +- Plugins/uninstall: migrate and reset `plugins.slots.contextEngine` alongside memory slots when plugin ids change or selected plugins are removed. Thanks @shakkernerd. - Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex. - Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc. - Plugins/channels: treat malformed bundled channel plugin loaders that return `undefined` as unavailable instead of crashing config and help paths. Fixes #69044. Thanks @frankhli843 and @vincentkoc. diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index a02fd04110b..975ccd839fd 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -594,6 +594,7 @@ export function resetPluginsCliTestState() { allowlist: false, loadPath: false, memorySlot: false, + contextEngineSlot: false, directory: false, }, }); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index c390e4f9c4e..dbec8169d90 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -634,6 +634,11 @@ export function registerPluginsCli(program: Command) { if (cfg.plugins?.slots?.memory === pluginId) { preview.push(`memory slot (will reset to "${defaultSlotIdForKey("memory")}")`); } + if (cfg.plugins?.slots?.contextEngine === pluginId) { + preview.push( + `context engine slot (will reset to "${defaultSlotIdForKey("contextEngine")}")`, + ); + } const channelIds = plugin?.status === "loaded" ? plugin.channelIds : undefined; const channels = cfg.channels as Record | undefined; if (hasInstall && channels) { @@ -723,6 +728,9 @@ export function registerPluginsCli(program: Command) { if (result.actions.memorySlot) { removed.push("memory slot"); } + if (result.actions.contextEngineSlot) { + removed.push("context engine slot"); + } if (result.actions.channelConfig) { removed.push("channel config"); } diff --git a/src/cli/plugins-cli.uninstall.test.ts b/src/cli/plugins-cli.uninstall.test.ts index d98f10ad34e..91452a5d114 100644 --- a/src/cli/plugins-cli.uninstall.test.ts +++ b/src/cli/plugins-cli.uninstall.test.ts @@ -40,6 +40,9 @@ describe("plugins cli uninstall", () => { installPath: ALPHA_INSTALL_PATH, }, }, + slots: { + contextEngine: "alpha", + }, }, } as OpenClawConfig); buildPluginDiagnosticsReport.mockReturnValue({ @@ -53,6 +56,7 @@ describe("plugins cli uninstall", () => { expect(writeConfigFile).not.toHaveBeenCalled(); expect(refreshPluginRegistry).not.toHaveBeenCalled(); expect(runtimeLogs.some((line) => line.includes("Dry run, no changes made."))).toBe(true); + expect(runtimeLogs.some((line) => line.includes("context engine slot"))).toBe(true); }); it("uninstalls with --force and --keep-files without prompting", async () => { @@ -93,6 +97,7 @@ describe("plugins cli uninstall", () => { allowlist: false, loadPath: false, memorySlot: false, + contextEngineSlot: false, directory: false, }, }); @@ -162,6 +167,7 @@ describe("plugins cli uninstall", () => { allowlist: false, loadPath: false, memorySlot: false, + contextEngineSlot: false, directory: false, }, }); diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts index dae137d81c6..5ad61a0f4e6 100644 --- a/src/plugins/uninstall.test.ts +++ b/src/plugins/uninstall.test.ts @@ -365,6 +365,22 @@ describe("removePluginFromConfig", () => { expect(actions.memorySlot).toBe(expectedChanged); }); + it("clears context engine slot when uninstalling active context engine plugin", () => { + const config = createPluginConfig({ + entries: { + "context-plugin": { enabled: true }, + }, + slots: { + contextEngine: "context-plugin", + }, + }); + + const { config: result, actions } = removePluginFromConfig(config, "context-plugin"); + + expect(result.plugins?.slots?.contextEngine).toBe("legacy"); + expect(actions.contextEngineSlot).toBe(true); + }); + it("removes plugins object when uninstall leaves only empty slots", () => { const config = createSinglePluginWithEmptySlotsConfig(); diff --git a/src/plugins/uninstall.ts b/src/plugins/uninstall.ts index 6badad6e019..b1f054941b6 100644 --- a/src/plugins/uninstall.ts +++ b/src/plugins/uninstall.ts @@ -13,6 +13,7 @@ export type UninstallActions = { allowlist: boolean; loadPath: boolean; memorySlot: boolean; + contextEngineSlot: boolean; channelConfig: boolean; directory: boolean; }; @@ -155,6 +156,7 @@ export function removePluginFromConfig( allowlist: false, loadPath: false, memorySlot: false, + contextEngineSlot: false, channelConfig: false, }; @@ -204,7 +206,7 @@ export function removePluginFromConfig( } } - // Reset memory slot if this plugin was selected + // Reset slots if this plugin was selected. let slots = pluginsConfig.slots; if (slots?.memory === pluginId) { slots = { @@ -213,6 +215,13 @@ export function removePluginFromConfig( }; actions.memorySlot = true; } + if (slots?.contextEngine === pluginId) { + slots = { + ...slots, + contextEngine: defaultSlotIdForKey("contextEngine"), + }; + actions.contextEngineSlot = true; + } if (slots && Object.keys(slots).length === 0) { slots = undefined; } diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 01be9f10307..69c9995b448 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -876,6 +876,41 @@ describe("updateNpmInstalledPlugins", () => { expect(result.config.plugins?.installs?.["voice-call"]).toBeUndefined(); }); + it("migrates context engine slot when a plugin id changes during update", async () => { + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: true, + pluginId: "@openclaw/context-engine", + targetDir: "/tmp/openclaw-context-engine", + version: "0.0.2", + extensions: ["index.ts"], + }); + + const result = await updateNpmInstalledPlugins({ + config: { + plugins: { + slots: { contextEngine: "context-engine" }, + installs: { + "context-engine": { + source: "npm", + spec: "@openclaw/context-engine", + installPath: "/tmp/context-engine", + }, + }, + }, + } as OpenClawConfig, + pluginIds: ["context-engine"], + }); + + expect(result.config.plugins?.slots?.contextEngine).toBe("@openclaw/context-engine"); + expect(result.config.plugins?.installs?.["@openclaw/context-engine"]).toMatchObject({ + source: "npm", + spec: "@openclaw/context-engine", + installPath: "/tmp/openclaw-context-engine", + version: "0.0.2", + }); + expect(result.config.plugins?.installs?.["context-engine"]).toBeUndefined(); + }); + it("checks marketplace installs during dry-run updates", async () => { installPluginFromMarketplaceMock.mockResolvedValue({ ok: true, diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 32e74c051ef..226844485a7 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -417,13 +417,13 @@ function migratePluginConfigId(cfg: OpenClawConfig, fromId: string, toId: string delete nextEntries[fromId]; } - const nextSlots = - slots?.memory === fromId - ? { - ...slots, - memory: toId, - } - : slots; + const nextSlots = slots + ? { + ...slots, + ...(slots.memory === fromId ? { memory: toId } : {}), + ...(slots.contextEngine === fromId ? { contextEngine: toId } : {}), + } + : undefined; return { ...cfg, From 91666fe194d9010c4c5d0b0d9756f3fcebf48c6d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 01:53:48 -0700 Subject: [PATCH 17/25] docs(cli-plugins): rewrite with CardGroup, AccordionGroup for install/update behavior, ParamField for list flags, Tabs for marketplace sources --- docs/cli/plugins.md | 317 +++++++++++++++++--------------------------- 1 file changed, 125 insertions(+), 192 deletions(-) diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 5a91d77cc13..91b64876c9c 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -4,18 +4,25 @@ read_when: - You want to install or manage Gateway plugins or compatible bundles - You want to debug plugin load failures title: "Plugins" +sidebarTitle: "Plugins" --- -# `openclaw plugins` - Manage Gateway plugins, hook packs, and compatible bundles. -Related: - -- Plugin system: [Plugins](/tools/plugin) -- Bundle compatibility: [Plugin bundles](/plugins/bundles) -- Plugin manifest + schema: [Plugin manifest](/plugins/manifest) -- Security hardening: [Security](/gateway/security) + + + End-user guide for installing, enabling, and troubleshooting plugins. + + + Bundle compatibility model. + + + Manifest fields and config schema. + + + Security hardening for plugin installs. + + ## Commands @@ -41,17 +48,13 @@ openclaw plugins marketplace list openclaw plugins marketplace list --json ``` -Bundled plugins ship with OpenClaw. Some are enabled by default (for example -bundled model providers, bundled speech providers, and the bundled browser -plugin); others require `plugins enable`. + +Bundled plugins ship with OpenClaw. Some are enabled by default (for example bundled model providers, bundled speech providers, and the bundled browser plugin); others require `plugins enable`. -Native OpenClaw plugins must ship `openclaw.plugin.json` with an inline JSON -Schema (`configSchema`, even if empty). Compatible bundles use their own bundle -manifests instead. +Native OpenClaw plugins must ship `openclaw.plugin.json` with an inline JSON Schema (`configSchema`, even if empty). Compatible bundles use their own bundle manifests instead. -`plugins list` shows `Format: openclaw` or `Format: bundle`. Verbose list/info -output also shows the bundle subtype (`codex`, `claude`, or `cursor`) plus detected bundle -capabilities. +`plugins list` shows `Format: openclaw` or `Format: bundle`. Verbose list/info output also shows the bundle subtype (`codex`, `claude`, or `cursor`) plus detected bundle capabilities. + ### Install @@ -67,66 +70,49 @@ openclaw plugins install --marketplace # marketplace (explicit) openclaw plugins install --marketplace https://github.com// ``` -Bare package names are checked against ClawHub first, then npm. Security note: -treat plugin installs like running code. Prefer pinned versions. + +Bare package names are checked against ClawHub first, then npm. Treat plugin installs like running code. Prefer pinned versions. + -If your `plugins` section is backed by a single-file `$include`, `plugins install/update/enable/disable/uninstall` write through to that included file and leave `openclaw.json` untouched. Root includes, include arrays, and includes with sibling overrides fail closed instead of flattening. See [Config includes](/gateway/configuration) for the supported shapes. + + + If your `plugins` section is backed by a single-file `$include`, `plugins install/update/enable/disable/uninstall` write through to that included file and leave `openclaw.json` untouched. Root includes, include arrays, and includes with sibling overrides fail closed instead of flattening. See [Config includes](/gateway/configuration) for the supported shapes. -If config is invalid, `plugins install` normally fails closed and tells you to -run `openclaw doctor --fix` first. The only documented exception is a narrow -bundled-plugin recovery path for plugins that explicitly opt into -`openclaw.install.allowInvalidConfigRecovery`. + If config is invalid, `plugins install` normally fails closed and tells you to run `openclaw doctor --fix` first. The only documented exception is a narrow bundled-plugin recovery path for plugins that explicitly opt into `openclaw.install.allowInvalidConfigRecovery`. -`--force` reuses the existing install target and overwrites an already-installed -plugin or hook pack in place. Use it when you are intentionally reinstalling -the same id from a new local path, archive, ClawHub package, or npm artifact. -For routine upgrades of an already tracked npm plugin, prefer -`openclaw plugins update `. + + + `--force` reuses the existing install target and overwrites an already-installed plugin or hook pack in place. Use it when you are intentionally reinstalling the same id from a new local path, archive, ClawHub package, or npm artifact. For routine upgrades of an already tracked npm plugin, prefer `openclaw plugins update `. -If you run `plugins install` for a plugin id that is already installed, OpenClaw -stops and points you at `plugins update ` for a normal upgrade, -or at `plugins install --force` when you genuinely want to overwrite -the current install from a different source. + If you run `plugins install` for a plugin id that is already installed, OpenClaw stops and points you at `plugins update ` for a normal upgrade, or at `plugins install --force` when you genuinely want to overwrite the current install from a different source. -`--pin` applies to npm installs only. It is not supported with `--marketplace`, -because marketplace installs persist marketplace source metadata instead of an -npm spec. + + + `--pin` applies to npm installs only. It is not supported with `--marketplace`, because marketplace installs persist marketplace source metadata instead of an npm spec. + + + `--dangerously-force-unsafe-install` is a break-glass option for false positives in the built-in dangerous-code scanner. It allows the install to continue even when the built-in scanner reports `critical` findings, but it does **not** bypass plugin `before_install` hook policy blocks and does **not** bypass scan failures. -`--dangerously-force-unsafe-install` is a break-glass option for false positives -in the built-in dangerous-code scanner. It allows the install to continue even -when the built-in scanner reports `critical` findings, but it does **not** -bypass plugin `before_install` hook policy blocks and does **not** bypass scan -failures. + This CLI flag applies to plugin install/update flows. Gateway-backed skill dependency installs use the matching `dangerouslyForceUnsafeInstall` request override, while `openclaw skills install` remains a separate ClawHub skill download/install flow. -This CLI flag applies to plugin install/update flows. Gateway-backed skill -dependency installs use the matching `dangerouslyForceUnsafeInstall` request -override, while `openclaw skills install` remains a separate ClawHub skill -download/install flow. + + + `plugins install` is also the install surface for hook packs that expose `openclaw.hooks` in `package.json`. Use `openclaw hooks` for filtered hook visibility and per-hook enablement, not package installation. -`plugins install` is also the install surface for hook packs that expose -`openclaw.hooks` in `package.json`. Use `openclaw hooks` for filtered hook -visibility and per-hook enablement, not package installation. + Npm specs are **registry-only** (package name + optional **exact version** or **dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency installs run project-local with `--ignore-scripts` for safety, even when your shell has global npm install settings. -Npm specs are **registry-only** (package name + optional **exact version** or -**dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency -installs run project-local with `--ignore-scripts` for safety, even when your -shell has global npm install settings. + Bare specs and `@latest` stay on the stable track. If npm resolves either of those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a prerelease tag such as `@beta`/`@rc` or an exact prerelease version such as `@1.2.3-beta.4`. -Bare specs and `@latest` stay on the stable track. If npm resolves either of -those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a -prerelease tag such as `@beta`/`@rc` or an exact prerelease version such as -`@1.2.3-beta.4`. + If a bare install spec matches a bundled plugin id (for example `diffs`), OpenClaw installs the bundled plugin directly. To install an npm package with the same name, use an explicit scoped spec (for example `@scope/diffs`). -If a bare install spec matches a bundled plugin id (for example `diffs`), OpenClaw -installs the bundled plugin directly. To install an npm package with the same -name, use an explicit scoped spec (for example `@scope/diffs`). + + + Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`. Native OpenClaw plugin archives must contain a valid `openclaw.plugin.json` at the extracted plugin root; archives that only contain `package.json` are rejected before OpenClaw writes install records. -Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`. -Native OpenClaw plugin archives must contain a valid `openclaw.plugin.json` at -the extracted plugin root; archives that only contain `package.json` are -rejected before OpenClaw writes install records. + Claude marketplace installs are also supported. -Claude marketplace installs are also supported. + + ClawHub installs use an explicit `clawhub:` locator: @@ -135,20 +121,17 @@ openclaw plugins install clawhub:openclaw-codex-app-server openclaw plugins install clawhub:openclaw-codex-app-server@1.2.3 ``` -OpenClaw now also prefers ClawHub for bare npm-safe plugin specs. It only falls -back to npm if ClawHub does not have that package or version: +OpenClaw now also prefers ClawHub for bare npm-safe plugin specs. It only falls back to npm if ClawHub does not have that package or version: ```bash openclaw plugins install openclaw-codex-app-server ``` -OpenClaw downloads the package archive from ClawHub, checks the advertised -plugin API / minimum gateway compatibility, then installs it through the normal -archive path. Recorded installs keep their ClawHub source metadata for later -updates. +OpenClaw downloads the package archive from ClawHub, checks the advertised plugin API / minimum gateway compatibility, then installs it through the normal archive path. Recorded installs keep their ClawHub source metadata for later updates. -Use `plugin@marketplace` shorthand when the marketplace name exists in Claude's -local registry cache at `~/.claude/plugins/known_marketplaces.json`: +#### Marketplace shorthand + +Use `plugin@marketplace` shorthand when the marketplace name exists in Claude's local registry cache at `~/.claude/plugins/known_marketplaces.json`: ```bash openclaw plugins marketplace list @@ -164,33 +147,29 @@ openclaw plugins install --marketplace https://github.com// openclaw plugins install --marketplace ./my-marketplace ``` -Marketplace sources can be: - -- a Claude known-marketplace name from `~/.claude/plugins/known_marketplaces.json` -- a local marketplace root or `marketplace.json` path -- a GitHub repo shorthand such as `owner/repo` -- a GitHub repo URL such as `https://github.com/owner/repo` -- a git URL - -For remote marketplaces loaded from GitHub or git, plugin entries must stay -inside the cloned marketplace repo. OpenClaw accepts relative path sources from -that repo and rejects HTTP(S), absolute-path, git, GitHub, and other non-path -plugin sources from remote manifests. + + + - a Claude known-marketplace name from `~/.claude/plugins/known_marketplaces.json` + - a local marketplace root or `marketplace.json` path + - a GitHub repo shorthand such as `owner/repo` + - a GitHub repo URL such as `https://github.com/owner/repo` + - a git URL + + + For remote marketplaces loaded from GitHub or git, plugin entries must stay inside the cloned marketplace repo. OpenClaw accepts relative path sources from that repo and rejects HTTP(S), absolute-path, git, GitHub, and other non-path plugin sources from remote manifests. + + For local paths and archives, OpenClaw auto-detects: - native OpenClaw plugins (`openclaw.plugin.json`) - Codex-compatible bundles (`.codex-plugin/plugin.json`) -- Claude-compatible bundles (`.claude-plugin/plugin.json` or the default Claude - component layout) +- Claude-compatible bundles (`.claude-plugin/plugin.json` or the default Claude component layout) - Cursor-compatible bundles (`.cursor-plugin/plugin.json`) -Compatible bundles install into the normal plugin root and participate in -the same list/info/enable/disable flow. Today, bundle skills, Claude -command-skills, Claude `settings.json` defaults, Claude `.lsp.json` / -manifest-declared `lspServers` defaults, Cursor command-skills, and compatible -Codex hook directories are supported; other detected bundle capabilities are -shown in diagnostics/info but are not yet wired into runtime execution. + +Compatible bundles install into the normal plugin root and participate in the same list/info/enable/disable flow. Today, bundle skills, Claude command-skills, Claude `settings.json` defaults, Claude `.lsp.json` / manifest-declared `lspServers` defaults, Cursor command-skills, and compatible Codex hook directories are supported; other detected bundle capabilities are shown in diagnostics/info but are not yet wired into runtime execution. + ### List @@ -201,30 +180,25 @@ openclaw plugins list --verbose openclaw plugins list --json ``` -Use `--enabled` to show only enabled plugins. Use `--verbose` to switch from the -table view to per-plugin detail lines with source/origin/version/activation -metadata. Use `--json` for machine-readable inventory plus registry -diagnostics. + + Show only enabled plugins. + + + Switch from the table view to per-plugin detail lines with source/origin/version/activation metadata. + + + Machine-readable inventory plus registry diagnostics. + -`plugins list` reads the persisted local plugin registry first, with a -manifest-only derived fallback when the registry is missing or invalid. It is -useful for checking whether a plugin is installed, enabled, and visible to cold -startup planning, but it is not a live runtime probe of an already-running -Gateway process. After changing plugin code, enablement, hook policy, or -`plugins.load.paths`, restart the Gateway that serves the channel before -expecting new `register(api)` code or hooks to run. For remote/container -deployments, verify you are restarting the actual `openclaw gateway run` child, -not only a wrapper process. + +`plugins list` reads the persisted local plugin registry first, with a manifest-only derived fallback when the registry is missing or invalid. It is useful for checking whether a plugin is installed, enabled, and visible to cold startup planning, but it is not a live runtime probe of an already-running Gateway process. After changing plugin code, enablement, hook policy, or `plugins.load.paths`, restart the Gateway that serves the channel before expecting new `register(api)` code or hooks to run. For remote/container deployments, verify you are restarting the actual `openclaw gateway run` child, not only a wrapper process. + For runtime hook debugging: -- `openclaw plugins inspect --json` shows registered hooks and diagnostics - from a module-loaded inspection pass. -- `openclaw gateway status --deep --require-rpc` confirms the reachable Gateway, - service/process hints, config path, and RPC health. -- Non-bundled conversation hooks (`llm_input`, `llm_output`, - `before_agent_finalize`, `agent_end`) require - `plugins.entries..hooks.allowConversationAccess=true`. +- `openclaw plugins inspect --json` shows registered hooks and diagnostics from a module-loaded inspection pass. +- `openclaw gateway status --deep --require-rpc` confirms the reachable Gateway, service/process hints, config path, and RPC health. +- Non-bundled conversation hooks (`llm_input`, `llm_output`, `before_agent_finalize`, `agent_end`) require `plugins.entries..hooks.allowConversationAccess=true`. Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`): @@ -232,24 +206,17 @@ Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`): openclaw plugins install -l ./my-plugin ``` -`--force` is not supported with `--link` because linked installs reuse the -source path instead of copying over a managed install target. + +`--force` is not supported with `--link` because linked installs reuse the source path instead of copying over a managed install target. -Use `--pin` on npm installs to save the resolved exact spec (`name@version`) in -the managed plugin index while keeping the default behavior unpinned. +Use `--pin` on npm installs to save the resolved exact spec (`name@version`) in the managed plugin index while keeping the default behavior unpinned. + -### Plugin Index +### Plugin index -Plugin install metadata is machine-managed state, not user config. Installs -and updates write it to `plugins/installs.json` under the active OpenClaw state -directory. Its top-level `installRecords` map is the durable source of install -metadata, including records for broken or missing plugin manifests. The -`plugins` array is the manifest-derived cold registry cache. The file includes a -do-not-edit warning and is used by `openclaw plugins update`, uninstall, -diagnostics, and the cold plugin registry. -When OpenClaw sees shipped legacy `plugins.installs` records in config, it moves -them into the plugin index and removes the config key; if either write fails, -the config records are kept so the install metadata is not lost. +Plugin install metadata is machine-managed state, not user config. Installs and updates write it to `plugins/installs.json` under the active OpenClaw state directory. Its top-level `installRecords` map is the durable source of install metadata, including records for broken or missing plugin manifests. The `plugins` array is the manifest-derived cold registry cache. The file includes a do-not-edit warning and is used by `openclaw plugins update`, uninstall, diagnostics, and the cold plugin registry. + +When OpenClaw sees shipped legacy `plugins.installs` records in config, it moves them into the plugin index and removes the config key; if either write fails, the config records are kept so the install metadata is not lost. ### Uninstall @@ -259,13 +226,11 @@ openclaw plugins uninstall --dry-run openclaw plugins uninstall --keep-files ``` -`uninstall` removes plugin records from `plugins.entries`, the persisted plugin -index, the plugin allowlist, and linked `plugins.load.paths` entries when -applicable. Unless `--keep-files` is set, uninstall also removes the tracked -managed install directory when it is inside OpenClaw's plugin extensions root. -For active memory plugins, the memory slot resets to `memory-core`. +`uninstall` removes plugin records from `plugins.entries`, the persisted plugin index, the plugin allowlist, and linked `plugins.load.paths` entries when applicable. Unless `--keep-files` is set, uninstall also removes the tracked managed install directory when it is inside OpenClaw's plugin extensions root. For active memory plugins, the memory slot resets to `memory-core`. + `--keep-config` is supported as a deprecated alias for `--keep-files`. + ### Update @@ -277,38 +242,27 @@ openclaw plugins update @openclaw/voice-call@beta openclaw plugins update openclaw-codex-app-server --dangerously-force-unsafe-install ``` -Updates apply to tracked plugin installs in the managed plugin index and -tracked hook-pack installs in `hooks.internal.installs`. +Updates apply to tracked plugin installs in the managed plugin index and tracked hook-pack installs in `hooks.internal.installs`. -When you pass a plugin id, OpenClaw reuses the recorded install spec for that -plugin. That means previously stored dist-tags such as `@beta` and exact pinned -versions continue to be used on later `update ` runs. + + + When you pass a plugin id, OpenClaw reuses the recorded install spec for that plugin. That means previously stored dist-tags such as `@beta` and exact pinned versions continue to be used on later `update ` runs. -For npm installs, you can also pass an explicit npm package spec with a dist-tag -or exact version. OpenClaw resolves that package name back to the tracked plugin -record, updates that installed plugin, and records the new npm spec for future -id-based updates. + For npm installs, you can also pass an explicit npm package spec with a dist-tag or exact version. OpenClaw resolves that package name back to the tracked plugin record, updates that installed plugin, and records the new npm spec for future id-based updates. -Passing the npm package name without a version or tag also resolves back to the -tracked plugin record. Use this when a plugin was pinned to an exact version and -you want to move it back to the registry's default release line. + Passing the npm package name without a version or tag also resolves back to the tracked plugin record. Use this when a plugin was pinned to an exact version and you want to move it back to the registry's default release line. -Before a live npm update, OpenClaw checks the installed package version against -the npm registry metadata. If the installed version and recorded artifact -identity already match the resolved target, the update is skipped without -downloading, reinstalling, or rewriting `openclaw.json`. + + + Before a live npm update, OpenClaw checks the installed package version against the npm registry metadata. If the installed version and recorded artifact identity already match the resolved target, the update is skipped without downloading, reinstalling, or rewriting `openclaw.json`. -When a stored integrity hash exists and the fetched artifact hash changes, -OpenClaw treats that as npm artifact drift. The interactive -`openclaw plugins update` command prints the expected and actual hashes and asks -for confirmation before proceeding. Non-interactive update helpers fail closed -unless the caller supplies an explicit continuation policy. + When a stored integrity hash exists and the fetched artifact hash changes, OpenClaw treats that as npm artifact drift. The interactive `openclaw plugins update` command prints the expected and actual hashes and asks for confirmation before proceeding. Non-interactive update helpers fail closed unless the caller supplies an explicit continuation policy. -`--dangerously-force-unsafe-install` is also available on `plugins update` as a -break-glass override for built-in dangerous-code scan false positives during -plugin updates. It still does not bypass plugin `before_install` policy blocks -or scan-failure blocking, and it only applies to plugin updates, not hook-pack -updates. + + + `--dangerously-force-unsafe-install` is also available on `plugins update` as a break-glass override for built-in dangerous-code scan false positives during plugin updates. It still does not bypass plugin `before_install` policy blocks or scan-failure blocking, and it only applies to plugin updates, not hook-pack updates. + + ### Inspect @@ -317,10 +271,7 @@ openclaw plugins inspect openclaw plugins inspect --json ``` -Deep introspection for a single plugin. Shows identity, load status, source, -registered capabilities, hooks, tools, commands, services, gateway methods, -HTTP routes, policy flags, diagnostics, install metadata, bundle capabilities, -and any detected MCP or LSP server support. +Deep introspection for a single plugin. Shows identity, load status, source, registered capabilities, hooks, tools, commands, services, gateway methods, HTTP routes, policy flags, diagnostics, install metadata, bundle capabilities, and any detected MCP or LSP server support. Each plugin is classified by what it actually registers at runtime: @@ -331,13 +282,9 @@ Each plugin is classified by what it actually registers at runtime: See [Plugin shapes](/plugins/architecture#plugin-shapes) for more on the capability model. -The `--json` flag outputs a machine-readable report suitable for scripting and -auditing. - -`inspect --all` renders a fleet-wide table with shape, capability kinds, -compatibility notices, bundle capabilities, and hook summary columns. - -`info` is an alias for `inspect`. + +The `--json` flag outputs a machine-readable report suitable for scripting and auditing. `inspect --all` renders a fleet-wide table with shape, capability kinds, compatibility notices, bundle capabilities, and hook summary columns. `info` is an alias for `inspect`. + ### Doctor @@ -345,13 +292,9 @@ compatibility notices, bundle capabilities, and hook summary columns. openclaw plugins doctor ``` -`doctor` reports plugin load errors, manifest/discovery diagnostics, and -compatibility notices. When everything is clean it prints `No plugin issues -detected.` +`doctor` reports plugin load errors, manifest/discovery diagnostics, and compatibility notices. When everything is clean it prints `No plugin issues detected.` -For module-shape failures such as missing `register`/`activate` exports, rerun -with `OPENCLAW_PLUGIN_LOAD_DEBUG=1` to include a compact export-shape summary in -the diagnostic output. +For module-shape failures such as missing `register`/`activate` exports, rerun with `OPENCLAW_PLUGIN_LOAD_DEBUG=1` to include a compact export-shape summary in the diagnostic output. ### Registry @@ -361,20 +304,13 @@ openclaw plugins registry --refresh openclaw plugins registry --json ``` -The local plugin registry is OpenClaw's persisted cold read model for installed -plugin identity, enablement, source metadata, and contribution ownership. -Normal startup, provider owner lookup, channel setup classification, and plugin -inventory can read it without importing plugin runtime modules. +The local plugin registry is OpenClaw's persisted cold read model for installed plugin identity, enablement, source metadata, and contribution ownership. Normal startup, provider owner lookup, channel setup classification, and plugin inventory can read it without importing plugin runtime modules. -Use `plugins registry` to inspect whether the persisted registry is present, -current, or stale. Use `--refresh` to rebuild it from the persisted plugin -index, config policy, and manifest/package metadata. This is a repair path, not -a runtime activation path. +Use `plugins registry` to inspect whether the persisted registry is present, current, or stale. Use `--refresh` to rebuild it from the persisted plugin index, config policy, and manifest/package metadata. This is a repair path, not a runtime activation path. -`OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY=1` is a deprecated break-glass -compatibility switch for registry read failures. Prefer `plugins registry ---refresh` or `openclaw doctor --fix`; the env fallback is only for emergency -startup recovery while the migration rolls out. + +`OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY=1` is a deprecated break-glass compatibility switch for registry read failures. Prefer `plugins registry --refresh` or `openclaw doctor --fix`; the env fallback is only for emergency startup recovery while the migration rolls out. + ### Marketplace @@ -383,13 +319,10 @@ openclaw plugins marketplace list openclaw plugins marketplace list --json ``` -Marketplace list accepts a local marketplace path, a `marketplace.json` path, a -GitHub shorthand like `owner/repo`, a GitHub repo URL, or a git URL. `--json` -prints the resolved source label plus the parsed marketplace manifest and -plugin entries. +Marketplace list accepts a local marketplace path, a `marketplace.json` path, a GitHub shorthand like `owner/repo`, a GitHub repo URL, or a git URL. `--json` prints the resolved source label plus the parsed marketplace manifest and plugin entries. ## Related -- [CLI reference](/cli) - [Building plugins](/plugins/building-plugins) +- [CLI reference](/cli) - [Community plugins](/plugins/community) From ed537edacf8070732f0732f1bc23cbc5c4ae3fff Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 01:55:13 -0700 Subject: [PATCH 18/25] docs(twitch): rewrite with Steps for setup, Tabs for install/auth/access patterns, ParamField for account config, AccordionGroup for troubleshooting --- docs/channels/twitch.md | 355 ++++++++++++++++++++++------------------ 1 file changed, 195 insertions(+), 160 deletions(-) diff --git a/docs/channels/twitch.md b/docs/channels/twitch.md index c8aabd3536e..b148363b4b4 100644 --- a/docs/channels/twitch.md +++ b/docs/channels/twitch.md @@ -3,50 +3,69 @@ summary: "Twitch chat bot configuration and setup" read_when: - Setting up Twitch chat integration for OpenClaw title: "Twitch" +sidebarTitle: "Twitch" --- Twitch chat support via IRC connection. OpenClaw connects as a Twitch user (bot account) to receive and send messages in channels. ## Bundled plugin -Twitch ships as a bundled plugin in current OpenClaw releases, so normal -packaged builds do not need a separate install. + +Twitch ships as a bundled plugin in current OpenClaw releases, so normal packaged builds do not need a separate install. + -If you are on an older build or a custom install that excludes Twitch, install -it manually: +If you are on an older build or a custom install that excludes Twitch, install it manually: -Install via CLI (npm registry): - -```bash -openclaw plugins install @openclaw/twitch -``` - -Local checkout (when running from a git repo): - -```bash -openclaw plugins install ./path/to/local/twitch-plugin -``` + + + ```bash + openclaw plugins install @openclaw/twitch + ``` + + + ```bash + openclaw plugins install ./path/to/local/twitch-plugin + ``` + + Details: [Plugins](/tools/plugin) ## Quick setup (beginner) -1. Ensure the Twitch plugin is available. - - Current packaged OpenClaw releases already bundle it. - - Older/custom installs can add it manually with the commands above. -2. Create a dedicated Twitch account for the bot (or use an existing account). -3. Generate credentials: [Twitch Token Generator](https://twitchtokengenerator.com/) - - Select **Bot Token** - - Verify scopes `chat:read` and `chat:write` are selected - - Copy the **Client ID** and **Access Token** -4. Find your Twitch user ID: [https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/](https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/) -5. Configure the token: - - Env: `OPENCLAW_TWITCH_ACCESS_TOKEN=...` (default account only) - - Or config: `channels.twitch.accessToken` - - If both are set, config takes precedence (env fallback is default-account only). -6. Start the gateway. + + + Current packaged OpenClaw releases already bundle it. Older/custom installs can add it manually with the commands above. + + + Create a dedicated Twitch account for the bot (or use an existing account). + + + Use [Twitch Token Generator](https://twitchtokengenerator.com/): -**⚠️ Important:** Add access control (`allowFrom` or `allowedRoles`) to prevent unauthorized users from triggering the bot. `requireMention` defaults to `true`. + - Select **Bot Token** + - Verify scopes `chat:read` and `chat:write` are selected + - Copy the **Client ID** and **Access Token** + + + + Use [https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/](https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/) to convert a username to a Twitch user ID. + + + - Env: `OPENCLAW_TWITCH_ACCESS_TOKEN=...` (default account only) + - Or config: `channels.twitch.accessToken` + + If both are set, config takes precedence (env fallback is default-account only). + + + + Start the gateway with the configured channel. + + + + +Add access control (`allowFrom` or `allowedRoles`) to prevent unauthorized users from triggering the bot. `requireMention` defaults to `true`. + Minimal config: @@ -82,31 +101,34 @@ Use [Twitch Token Generator](https://twitchtokengenerator.com/): - Verify scopes `chat:read` and `chat:write` are selected - Copy the **Client ID** and **Access Token** + No manual app registration needed. Tokens expire after several hours. + ### Configure the bot -**Env var (default account only):** - -```bash -OPENCLAW_TWITCH_ACCESS_TOKEN=oauth:abc123... -``` - -**Or config:** - -```json5 -{ - channels: { - twitch: { - enabled: true, - username: "openclaw", - accessToken: "oauth:abc123...", - clientId: "xyz789...", - channel: "vevisk", - }, - }, -} -``` + + + ```bash + OPENCLAW_TWITCH_ACCESS_TOKEN=oauth:abc123... + ``` + + + ```json5 + { + channels: { + twitch: { + enabled: true, + username: "openclaw", + accessToken: "oauth:abc123...", + clientId: "xyz789...", + channel: "vevisk", + }, + }, + } + ``` + + If both env and config are set, config takes precedence. @@ -126,9 +148,11 @@ Prefer `allowFrom` for a hard allowlist. Use `allowedRoles` instead if you want **Available roles:** `"moderator"`, `"owner"`, `"vip"`, `"subscriber"`, `"all"`. + **Why user IDs?** Usernames can change, allowing impersonation. User IDs are permanent. Find your Twitch user ID: [https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/](https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/) (Convert your Twitch username to ID) + ## Token refresh (optional) @@ -151,7 +175,7 @@ The bot automatically refreshes tokens before expiration and logs refresh events ## Multi-account support -Use `channels.twitch.accounts` with per-account tokens. See [`gateway/configuration`](/gateway/configuration) for the shared pattern. +Use `channels.twitch.accounts` with per-account tokens. See [Configuration](/gateway/configuration) for the shared pattern. Example (one bot account in two channels): @@ -178,78 +202,65 @@ Example (one bot account in two channels): } ``` -**Note:** Each account needs its own token (one token per channel). + +Each account needs its own token (one token per channel). + ## Access control -### Role-based restrictions - -```json5 -{ - channels: { - twitch: { - accounts: { - default: { - allowedRoles: ["moderator", "vip"], + + + ```json5 + { + channels: { + twitch: { + accounts: { + default: { + allowFrom: ["123456789", "987654321"], + }, + }, }, }, - }, - }, -} -``` - -### Allowlist by User ID (most secure) - -```json5 -{ - channels: { - twitch: { - accounts: { - default: { - allowFrom: ["123456789", "987654321"], + } + ``` + + + ```json5 + { + channels: { + twitch: { + accounts: { + default: { + allowedRoles: ["moderator", "vip"], + }, + }, }, }, - }, - }, -} -``` + } + ``` -### Role-based access (alternative) + `allowFrom` is a hard allowlist. When set, only those user IDs are allowed. If you want role-based access, leave `allowFrom` unset and configure `allowedRoles` instead. -`allowFrom` is a hard allowlist. When set, only those user IDs are allowed. -If you want role-based access, leave `allowFrom` unset and configure `allowedRoles` instead: + + + By default, `requireMention` is `true`. To disable and respond to all messages: -```json5 -{ - channels: { - twitch: { - accounts: { - default: { - allowedRoles: ["moderator"], + ```json5 + { + channels: { + twitch: { + accounts: { + default: { + requireMention: false, + }, + }, }, }, - }, - }, -} -``` + } + ``` -### Disable @mention requirement - -By default, `requireMention` is `true`. To disable and respond to all messages: - -```json5 -{ - channels: { - twitch: { - accounts: { - default: { - requireMention: false, - }, - }, - }, - }, -} -``` + + ## Troubleshooting @@ -260,53 +271,77 @@ openclaw doctor openclaw channels status --probe ``` -### Bot does not respond to messages + + + - **Check access control:** Ensure your user ID is in `allowFrom`, or temporarily remove `allowFrom` and set `allowedRoles: ["all"]` to test. + - **Check the bot is in the channel:** The bot must join the channel specified in `channel`. + + + "Failed to connect" or authentication errors: -**Check access control:** Ensure your user ID is in `allowFrom`, or temporarily remove -`allowFrom` and set `allowedRoles: ["all"]` to test. + - Verify `accessToken` is the OAuth access token value (typically starts with `oauth:` prefix) + - Check token has `chat:read` and `chat:write` scopes + - If using token refresh, verify `clientSecret` and `refreshToken` are set -**Check the bot is in the channel:** The bot must join the channel specified in `channel`. + + + Check logs for refresh events: -### Token issues + ``` + Using env token source for mybot + Access token refreshed for user 123456 (expires in 14400s) + ``` -**"Failed to connect" or authentication errors:** + If you see "token refresh disabled (no refresh token)": -- Verify `accessToken` is the OAuth access token value (typically starts with `oauth:` prefix) -- Check token has `chat:read` and `chat:write` scopes -- If using token refresh, verify `clientSecret` and `refreshToken` are set + - Ensure `clientSecret` is provided + - Ensure `refreshToken` is provided -### Token refresh not working - -**Check logs for refresh events:** - -``` -Using env token source for mybot -Access token refreshed for user 123456 (expires in 14400s) -``` - -If you see "token refresh disabled (no refresh token)": - -- Ensure `clientSecret` is provided -- Ensure `refreshToken` is provided + + ## Config -**Account config:** +### Account config -- `username` - Bot username -- `accessToken` - OAuth access token with `chat:read` and `chat:write` -- `clientId` - Twitch Client ID (from Token Generator or your app) -- `channel` - Channel to join (required) -- `enabled` - Enable this account (default: `true`) -- `clientSecret` - Optional: For automatic token refresh -- `refreshToken` - Optional: For automatic token refresh -- `expiresIn` - Token expiry in seconds -- `obtainmentTimestamp` - Token obtained timestamp -- `allowFrom` - User ID allowlist -- `allowedRoles` - Role-based access control (`"moderator" | "owner" | "vip" | "subscriber" | "all"`) -- `requireMention` - Require @mention (default: `true`) + + Bot username. + + + OAuth access token with `chat:read` and `chat:write`. + + + Twitch Client ID (from Token Generator or your app). + + + Channel to join. + + + Enable this account. + + + Optional: for automatic token refresh. + + + Optional: for automatic token refresh. + + + Token expiry in seconds. + + + Token obtained timestamp. + + + User ID allowlist. + + + Role-based access control. + + + Require @mention. + -**Provider options:** +### Provider options - `channels.twitch.enabled` - Enable/disable channel startup - `channels.twitch.username` - Bot username (simplified single-account config) @@ -368,25 +403,25 @@ Example: } ``` -## Safety & ops +## Safety and ops -- **Treat tokens like passwords** - Never commit tokens to git -- **Use automatic token refresh** for long-running bots -- **Use user ID allowlists** instead of usernames for access control -- **Monitor logs** for token refresh events and connection status -- **Scope tokens minimally** - Only request `chat:read` and `chat:write` -- **If stuck**: Restart the gateway after confirming no other process owns the session +- **Treat tokens like passwords** — Never commit tokens to git. +- **Use automatic token refresh** for long-running bots. +- **Use user ID allowlists** instead of usernames for access control. +- **Monitor logs** for token refresh events and connection status. +- **Scope tokens minimally** — Only request `chat:read` and `chat:write`. +- **If stuck**: Restart the gateway after confirming no other process owns the session. ## Limits -- **500 characters** per message (auto-chunked at word boundaries) -- Markdown is stripped before chunking -- No rate limiting (uses Twitch's built-in rate limits) +- **500 characters** per message (auto-chunked at word boundaries). +- Markdown is stripped before chunking. +- No rate limiting (uses Twitch's built-in rate limits). ## Related -- [Channels Overview](/channels) — all supported channels -- [Pairing](/channels/pairing) — DM authentication and pairing flow -- [Groups](/channels/groups) — group chat behavior and mention gating - [Channel Routing](/channels/channel-routing) — session routing for messages +- [Channels Overview](/channels) — all supported channels +- [Groups](/channels/groups) — group chat behavior and mention gating +- [Pairing](/channels/pairing) — DM authentication and pairing flow - [Security](/gateway/security) — access model and hardening From 8741a86f9303e2e5875093c5a49dfc7a6e41115f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 01:56:29 -0700 Subject: [PATCH 19/25] docs(broadcast-groups): rewrite with AccordionGroup for use cases and best practices, Tabs for strategy and contexts, Steps for message flow --- docs/channels/broadcast-groups.md | 519 ++++++++++++++++-------------- 1 file changed, 273 insertions(+), 246 deletions(-) diff --git a/docs/channels/broadcast-groups.md b/docs/channels/broadcast-groups.md index de662b9ec97..b241dd95dd8 100644 --- a/docs/channels/broadcast-groups.md +++ b/docs/channels/broadcast-groups.md @@ -5,10 +5,12 @@ read_when: - Debugging multi-agent replies in WhatsApp status: experimental title: "Broadcast groups" +sidebarTitle: "Broadcast groups" --- -**Status:** Experimental -**Version:** Added in 2026.1.9 + +**Status:** Experimental. Added in 2026.1.9. + ## Overview @@ -18,55 +20,55 @@ Current scope: **WhatsApp only** (web channel). Broadcast groups are evaluated after channel allowlists and group activation rules. In WhatsApp groups, this means broadcasts happen when OpenClaw would normally reply (for example: on mention, depending on your group settings). -## Use Cases +## Use cases -### 1. Specialized Agent Teams + + + Deploy multiple agents with atomic, focused responsibilities: -Deploy multiple agents with atomic, focused responsibilities: + ``` + Group: "Development Team" + Agents: + - CodeReviewer (reviews code snippets) + - DocumentationBot (generates docs) + - SecurityAuditor (checks for vulnerabilities) + - TestGenerator (suggests test cases) + ``` -``` -Group: "Development Team" -Agents: - - CodeReviewer (reviews code snippets) - - DocumentationBot (generates docs) - - SecurityAuditor (checks for vulnerabilities) - - TestGenerator (suggests test cases) -``` + Each agent processes the same message and provides its specialized perspective. -Each agent processes the same message and provides its specialized perspective. - -### 2. Multi-Language Support - -``` -Group: "International Support" -Agents: - - Agent_EN (responds in English) - - Agent_DE (responds in German) - - Agent_ES (responds in Spanish) -``` - -### 3. Quality Assurance Workflows - -``` -Group: "Customer Support" -Agents: - - SupportAgent (provides answer) - - QAAgent (reviews quality, only responds if issues found) -``` - -### 4. Task Automation - -``` -Group: "Project Management" -Agents: - - TaskTracker (updates task database) - - TimeLogger (logs time spent) - - ReportGenerator (creates summaries) -``` + + + ``` + Group: "International Support" + Agents: + - Agent_EN (responds in English) + - Agent_DE (responds in German) + - Agent_ES (responds in Spanish) + ``` + + + ``` + Group: "Customer Support" + Agents: + - SupportAgent (provides answer) + - QAAgent (reviews quality, only responds if issues found) + ``` + + + ``` + Group: "Project Management" + Agents: + - TaskTracker (updates task database) + - TimeLogger (logs time spent) + - ReportGenerator (creates summaries) + ``` + + ## Configuration -### Basic Setup +### Basic setup Add a top-level `broadcast` section (next to `bindings`). Keys are WhatsApp peer ids: @@ -83,37 +85,40 @@ Add a top-level `broadcast` section (next to `bindings`). Keys are WhatsApp peer **Result:** When OpenClaw would reply in this chat, it will run all three agents. -### Processing Strategy +### Processing strategy Control how agents process messages: -#### Parallel (Default) + + + All agents process simultaneously: -All agents process simultaneously: + ```json + { + "broadcast": { + "strategy": "parallel", + "120363403215116621@g.us": ["alfred", "baerbel"] + } + } + ``` -```json -{ - "broadcast": { - "strategy": "parallel", - "120363403215116621@g.us": ["alfred", "baerbel"] - } -} -``` + + + Agents process in order (one waits for previous to finish): -#### Sequential + ```json + { + "broadcast": { + "strategy": "sequential", + "120363403215116621@g.us": ["alfred", "baerbel"] + } + } + ``` -Agents process in order (one waits for previous to finish): + + -```json -{ - "broadcast": { - "strategy": "sequential", - "120363403215116621@g.us": ["alfred", "baerbel"] - } -} -``` - -### Complete Example +### Complete example ```json { @@ -148,22 +153,32 @@ Agents process in order (one waits for previous to finish): } ``` -## How It Works +## How it works -### Message Flow +### Message flow -1. **Incoming message** arrives in a WhatsApp group -2. **Broadcast check**: System checks if peer ID is in `broadcast` -3. **If in broadcast list**: - - All listed agents process the message - - Each agent has its own session key and isolated context - - Agents process in parallel (default) or sequentially -4. **If not in broadcast list**: - - Normal routing applies (first matching binding) + + + A WhatsApp group or DM message arrives. + + + System checks if peer ID is in `broadcast`. + + + - All listed agents process the message. + - Each agent has its own session key and isolated context. + - Agents process in parallel (default) or sequentially. + + + Normal routing applies (first matching binding). + + -Note: broadcast groups do not bypass channel allowlists or group activation rules (mentions/commands/etc). They only change _which agents run_ when a message is eligible for processing. + +Broadcast groups do not bypass channel allowlists or group activation rules (mentions/commands/etc). They only change _which agents run_ when a message is eligible for processing. + -### Session Isolation +### Session isolation Each agent in a broadcast group maintains completely separate: @@ -181,92 +196,95 @@ This allows each agent to have: - Different models (e.g., opus vs. sonnet) - Different skills installed -### Example: Isolated Sessions +### Example: isolated sessions In group `120363403215116621@g.us` with agents `["alfred", "baerbel"]`: -**Alfred's context:** + + + ``` + Session: agent:alfred:whatsapp:group:120363403215116621@g.us + History: [user message, alfred's previous responses] + Workspace: /Users/user/openclaw-alfred/ + Tools: read, write, exec + ``` + + + ``` + Session: agent:baerbel:whatsapp:group:120363403215116621@g.us + History: [user message, baerbel's previous responses] + Workspace: /Users/user/openclaw-baerbel/ + Tools: read only + ``` + + -``` -Session: agent:alfred:whatsapp:group:120363403215116621@g.us -History: [user message, alfred's previous responses] -Workspace: /Users/user/openclaw-alfred/ -Tools: read, write, exec -``` +## Best practices -**Bärbel's context:** + + + Design each agent with a single, clear responsibility: -``` -Session: agent:baerbel:whatsapp:group:120363403215116621@g.us -History: [user message, baerbel's previous responses] -Workspace: /Users/user/openclaw-baerbel/ -Tools: read only -``` - -## Best Practices - -### 1. Keep Agents Focused - -Design each agent with a single, clear responsibility: - -```json -{ - "broadcast": { - "DEV_GROUP": ["formatter", "linter", "tester"] - } -} -``` - -✅ **Good:** Each agent has one job -❌ **Bad:** One generic "dev-helper" agent - -### 2. Use Descriptive Names - -Make it clear what each agent does: - -```json -{ - "agents": { - "security-scanner": { "name": "Security Scanner" }, - "code-formatter": { "name": "Code Formatter" }, - "test-generator": { "name": "Test Generator" } - } -} -``` - -### 3. Configure Different Tool Access - -Give agents only the tools they need: - -```json -{ - "agents": { - "reviewer": { - "tools": { "allow": ["read", "exec"] } // Read-only - }, - "fixer": { - "tools": { "allow": ["read", "write", "edit", "exec"] } // Read-write + ```json + { + "broadcast": { + "DEV_GROUP": ["formatter", "linter", "tester"] + } } - } -} -``` + ``` -### 4. Monitor Performance + ✅ **Good:** Each agent has one job. ❌ **Bad:** One generic "dev-helper" agent. -With many agents, consider: + + + Make it clear what each agent does: -- Using `"strategy": "parallel"` (default) for speed -- Limiting broadcast groups to 5-10 agents -- Using faster models for simpler agents + ```json + { + "agents": { + "security-scanner": { "name": "Security Scanner" }, + "code-formatter": { "name": "Code Formatter" }, + "test-generator": { "name": "Test Generator" } + } + } + ``` -### 5. Handle Failures Gracefully + + + Give agents only the tools they need: -Agents fail independently. One agent's error doesn't block others: + ```json + { + "agents": { + "reviewer": { + "tools": { "allow": ["read", "exec"] } // Read-only + }, + "fixer": { + "tools": { "allow": ["read", "write", "edit", "exec"] } // Read-write + } + } + } + ``` -``` -Message → [Agent A ✓, Agent B ✗ error, Agent C ✓] -Result: Agent A and C respond, Agent B logs error -``` + + + With many agents, consider: + + - Using `"strategy": "parallel"` (default) for speed + - Limiting broadcast groups to 5-10 agents + - Using faster models for simpler agents + + + + Agents fail independently. One agent's error doesn't block others: + + ``` + Message → [Agent A ✓, Agent B ✗ error, Agent C ✓] + Result: Agent A and C respond, Agent B logs error + ``` + + + ## Compatibility @@ -297,108 +315,116 @@ Broadcast groups work alongside existing routing: } ``` -- `GROUP_A`: Only alfred responds (normal routing) -- `GROUP_B`: agent1 AND agent2 respond (broadcast) +- `GROUP_A`: Only alfred responds (normal routing). +- `GROUP_B`: agent1 AND agent2 respond (broadcast). + **Precedence:** `broadcast` takes priority over `bindings`. + ## Troubleshooting -### Agents Not Responding + + + **Check:** -**Check:** + 1. Agent IDs exist in `agents.list`. + 2. Peer ID format is correct (e.g., `120363403215116621@g.us`). + 3. Agents are not in deny lists. -1. Agent IDs exist in `agents.list` -2. Peer ID format is correct (e.g., `120363403215116621@g.us`) -3. Agents are not in deny lists + **Debug:** -**Debug:** + ```bash + tail -f ~/.openclaw/logs/gateway.log | grep broadcast + ``` -```bash -tail -f ~/.openclaw/logs/gateway.log | grep broadcast -``` + + + **Cause:** Peer ID might be in `bindings` but not `broadcast`. -### Only One Agent Responding + **Fix:** Add to broadcast config or remove from bindings. -**Cause:** Peer ID might be in `bindings` but not `broadcast`. + + + If slow with many agents: -**Fix:** Add to broadcast config or remove from bindings. + - Reduce number of agents per group. + - Use lighter models (sonnet instead of opus). + - Check sandbox startup time. -### Performance Issues - -**If slow with many agents:** - -- Reduce number of agents per group -- Use lighter models (sonnet instead of opus) -- Check sandbox startup time + + ## Examples -### Example 1: Code Review Team - -```json -{ - "broadcast": { - "strategy": "parallel", - "120363403215116621@g.us": [ - "code-formatter", - "security-scanner", - "test-coverage", - "docs-checker" - ] - }, - "agents": { - "list": [ - { - "id": "code-formatter", - "workspace": "~/agents/formatter", - "tools": { "allow": ["read", "write"] } + + + ```json + { + "broadcast": { + "strategy": "parallel", + "120363403215116621@g.us": [ + "code-formatter", + "security-scanner", + "test-coverage", + "docs-checker" + ] }, - { - "id": "security-scanner", - "workspace": "~/agents/security", - "tools": { "allow": ["read", "exec"] } + "agents": { + "list": [ + { + "id": "code-formatter", + "workspace": "~/agents/formatter", + "tools": { "allow": ["read", "write"] } + }, + { + "id": "security-scanner", + "workspace": "~/agents/security", + "tools": { "allow": ["read", "exec"] } + }, + { + "id": "test-coverage", + "workspace": "~/agents/testing", + "tools": { "allow": ["read", "exec"] } + }, + { "id": "docs-checker", "workspace": "~/agents/docs", "tools": { "allow": ["read"] } } + ] + } + } + ``` + + **User sends:** Code snippet. + + **Responses:** + + - code-formatter: "Fixed indentation and added type hints" + - security-scanner: "⚠️ SQL injection vulnerability in line 12" + - test-coverage: "Coverage is 45%, missing tests for error cases" + - docs-checker: "Missing docstring for function `process_data`" + + + + ```json + { + "broadcast": { + "strategy": "sequential", + "+15555550123": ["detect-language", "translator-en", "translator-de"] }, - { - "id": "test-coverage", - "workspace": "~/agents/testing", - "tools": { "allow": ["read", "exec"] } - }, - { "id": "docs-checker", "workspace": "~/agents/docs", "tools": { "allow": ["read"] } } - ] - } -} -``` + "agents": { + "list": [ + { "id": "detect-language", "workspace": "~/agents/lang-detect" }, + { "id": "translator-en", "workspace": "~/agents/translate-en" }, + { "id": "translator-de", "workspace": "~/agents/translate-de" } + ] + } + } + ``` + + -**User sends:** Code snippet -**Responses:** +## API reference -- code-formatter: "Fixed indentation and added type hints" -- security-scanner: "⚠️ SQL injection vulnerability in line 12" -- test-coverage: "Coverage is 45%, missing tests for error cases" -- docs-checker: "Missing docstring for function `process_data`" - -### Example 2: Multi-Language Support - -```json -{ - "broadcast": { - "strategy": "sequential", - "+15555550123": ["detect-language", "translator-en", "translator-de"] - }, - "agents": { - "list": [ - { "id": "detect-language", "workspace": "~/agents/lang-detect" }, - { "id": "translator-en", "workspace": "~/agents/translate-en" }, - { "id": "translator-de", "workspace": "~/agents/translate-de" } - ] - } -} -``` - -## API Reference - -### Config Schema +### Config schema ```typescript interface OpenClawConfig { @@ -411,20 +437,21 @@ interface OpenClawConfig { ### Fields -- `strategy` (optional): How to process agents - - `"parallel"` (default): All agents process simultaneously - - `"sequential"`: Agents process in array order -- `[peerId]`: WhatsApp group JID, E.164 number, or other peer ID - - Value: Array of agent IDs that should process messages + + How to process agents. `parallel` runs all agents simultaneously; `sequential` runs them in array order. + + + WhatsApp group JID, E.164 number, or other peer ID. Value is the array of agent IDs that should process messages. + ## Limitations -1. **Max agents:** No hard limit, but 10+ agents may be slow -2. **Shared context:** Agents don't see each other's responses (by design) -3. **Message ordering:** Parallel responses may arrive in any order -4. **Rate limits:** All agents count toward WhatsApp rate limits +1. **Max agents:** No hard limit, but 10+ agents may be slow. +2. **Shared context:** Agents don't see each other's responses (by design). +3. **Message ordering:** Parallel responses may arrive in any order. +4. **Rate limits:** All agents count toward WhatsApp rate limits. -## Future Enhancements +## Future enhancements Planned features: @@ -435,8 +462,8 @@ Planned features: ## Related -- [Groups](/channels/groups) - [Channel routing](/channels/channel-routing) -- [Pairing](/channels/pairing) +- [Groups](/channels/groups) - [Multi-agent sandbox tools](/tools/multi-agent-sandbox-tools) +- [Pairing](/channels/pairing) - [Session management](/concepts/session) From a1b656705992e31df99147c786217fcc517c68b3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 00:29:06 -0700 Subject: [PATCH 20/25] fix(agents): fallback subagent completion delivery --- src/agents/subagent-announce-delivery.test.ts | 128 ++++++++++++++++++ src/agents/subagent-announce-delivery.ts | 68 +++++++--- src/agents/subagent-announce.ts | 3 + .../subagent-registry-lifecycle.test.ts | 76 +++++++++++ src/agents/subagent-registry-lifecycle.ts | 39 ++++++ src/agents/subagent-registry.types.ts | 1 + src/tasks/detached-task-runtime-contract.ts | 1 + src/tasks/task-executor.ts | 1 + src/tasks/task-registry.ts | 12 +- 9 files changed, 309 insertions(+), 20 deletions(-) diff --git a/src/agents/subagent-announce-delivery.test.ts b/src/agents/subagent-announce-delivery.test.ts index f36513a6048..f5519e639d6 100644 --- a/src/agents/subagent-announce-delivery.test.ts +++ b/src/agents/subagent-announce-delivery.test.ts @@ -115,6 +115,48 @@ async function deliverDiscordDirectMessageCompletion(params: { }); } +async function deliverTelegramDirectMessageCompletion(params: { + callGateway: typeof runtimeCallGateway; + sendMessage?: typeof runtimeSendMessage; + internalEvents?: AgentInternalEvent[]; + isActive?: boolean; + queueEmbeddedPiMessage?: (sessionId: string, message: string) => boolean; +}) { + const origin = { + channel: "telegram", + to: "123456789", + accountId: "bot-1", + }; + __testing.setDepsForTest({ + callGateway: params.callGateway, + getRequesterSessionActivity: () => ({ + sessionId: "requester-session-telegram", + isActive: params.isActive === true, + }), + loadConfig: () => ({}) as never, + ...(params.queueEmbeddedPiMessage + ? { queueEmbeddedPiMessage: params.queueEmbeddedPiMessage } + : {}), + ...(params.sendMessage ? { sendMessage: params.sendMessage } : {}), + }); + + return deliverSubagentAnnouncement({ + requesterSessionKey: "agent:main:telegram:123456789", + targetRequesterSessionKey: "agent:main:telegram:123456789", + triggerMessage: "child done", + steerMessage: "child done", + requesterOrigin: origin, + requesterSessionOrigin: origin, + completionDirectOrigin: origin, + directOrigin: origin, + requesterIsSubagent: false, + expectsCompletionMessage: true, + bestEffortDeliver: true, + directIdempotencyKey: "announce-telegram-dm-fallback", + internalEvents: params.internalEvents, + }); +} + async function deliverSlackChannelAnnouncement(params: { callGateway: typeof runtimeCallGateway; isActive: boolean; @@ -510,6 +552,92 @@ describe("deliverSubagentAnnouncement completion delivery", () => { ); }); + it("uses direct fallback for Telegram DMs when announce-agent delivery fails", async () => { + const callGateway = vi.fn(async () => { + throw new Error("UNAVAILABLE: requester wake failed"); + }) as unknown as typeof runtimeCallGateway; + const sendMessage = createSendMessageMock(); + const result = await deliverTelegramDirectMessageCompletion({ + callGateway, + sendMessage, + internalEvents: [ + { + type: "task_completion", + source: "subagent", + childSessionKey: "agent:worker:subagent:child", + childSessionId: "child-session-id", + announceType: "subagent task", + taskLabel: "telegram completion smoke", + status: "ok", + statusLabel: "completed successfully", + result: "child completion output", + replyInstruction: "Summarize the result.", + }, + ], + }); + + expect(result).toEqual( + expect.objectContaining({ + delivered: true, + path: "direct-fallback", + }), + ); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + accountId: "bot-1", + to: "123456789", + threadId: undefined, + content: "child completion output", + requesterSessionKey: "agent:main:telegram:123456789", + bestEffort: true, + idempotencyKey: "announce-telegram-dm-fallback", + }), + ); + }); + + it("uses direct fallback when an active Telegram requester cannot be woken", async () => { + const callGateway = createGatewayMock(); + const sendMessage = createSendMessageMock(); + const queueEmbeddedPiMessage = vi.fn(() => false); + const result = await deliverTelegramDirectMessageCompletion({ + callGateway, + sendMessage, + isActive: true, + queueEmbeddedPiMessage, + internalEvents: [ + { + type: "task_completion", + source: "subagent", + childSessionKey: "agent:worker:subagent:child", + childSessionId: "child-session-id", + announceType: "subagent task", + taskLabel: "telegram wake smoke", + status: "ok", + statusLabel: "completed successfully", + result: "child completion output", + replyInstruction: "Summarize the result.", + }, + ], + }); + + expect(result).toEqual( + expect.objectContaining({ + delivered: true, + path: "direct-fallback", + }), + ); + expect(queueEmbeddedPiMessage).toHaveBeenCalledWith("requester-session-telegram", "child done"); + expect(callGateway).not.toHaveBeenCalled(); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + to: "123456789", + content: "child completion output", + }), + ); + }); + it("uses a direct thread fallback when announce-agent returns no visible output", async () => { const callGateway = createGatewayMock({ result: { diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index 8202777faf6..5f8ad5d73ec 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -681,6 +681,10 @@ async function sendSubagentAnnounceDirectly(params: { isGatewayMessageChannel(normalizedSessionOnlyOriginChannel) ? normalizedSessionOnlyOriginChannel : undefined; + const completionFallbackText = + params.expectsCompletionMessage && deliveryTarget.deliver + ? extractThreadCompletionFallbackText(params.internalEvents) + : ""; const requesterActivity = resolveRequesterSessionActivity(canonicalRequesterSessionKey); if (params.expectsCompletionMessage && requesterActivity.sessionId) { const woke = requesterActivity.sessionId @@ -696,6 +700,32 @@ async function sendSubagentAnnounceDirectly(params: { }; } if (requesterActivity.isActive) { + try { + const didFallback = await sendCompletionFallback({ + cfg, + channel: deliveryTarget.channel, + to: deliveryTarget.to, + accountId: deliveryTarget.accountId, + threadId: deliveryTarget.threadId, + content: completionFallbackText, + requesterSessionKey: canonicalRequesterSessionKey, + bestEffortDeliver: params.bestEffortDeliver, + idempotencyKey: params.directIdempotencyKey, + signal: params.signal, + }); + if (didFallback) { + return { + delivered: true, + path: resolveCompletionFallbackPath(deliveryTarget.threadId), + }; + } + } catch (err) { + return { + delivered: false, + path: "direct", + error: `active requester session could not be woken; fallback send failed: ${summarizeDeliveryError(err)}`, + }; + } return { delivered: false, path: "direct", @@ -709,10 +739,6 @@ async function sendSubagentAnnounceDirectly(params: { path: "none", }; } - const completionFallbackText = - params.expectsCompletionMessage && deliveryTarget.deliver - ? extractThreadCompletionFallbackText(params.internalEvents) - : ""; let directAnnounceResponse: unknown; try { directAnnounceResponse = await runAnnounceDeliveryWithRetry({ @@ -758,22 +784,30 @@ async function sendSubagentAnnounceDirectly(params: { }), }); } catch (err) { - const didFallback = await sendCompletionFallback({ - cfg, - channel: deliveryTarget.channel, - to: deliveryTarget.to, - accountId: deliveryTarget.accountId, - threadId: deliveryTarget.threadId, - content: deliveryTarget.threadId ? completionFallbackText : "", - requesterSessionKey: canonicalRequesterSessionKey, - bestEffortDeliver: params.bestEffortDeliver, - idempotencyKey: params.directIdempotencyKey, - signal: params.signal, - }); + let didFallback = false; + try { + didFallback = await sendCompletionFallback({ + cfg, + channel: deliveryTarget.channel, + to: deliveryTarget.to, + accountId: deliveryTarget.accountId, + threadId: deliveryTarget.threadId, + content: completionFallbackText, + requesterSessionKey: canonicalRequesterSessionKey, + bestEffortDeliver: params.bestEffortDeliver, + idempotencyKey: params.directIdempotencyKey, + signal: params.signal, + }); + } catch (fallbackErr) { + throw new Error( + `${summarizeDeliveryError(err)}; fallback send failed: ${summarizeDeliveryError(fallbackErr)}`, + { cause: fallbackErr }, + ); + } if (didFallback) { return { delivered: true, - path: "direct-thread-fallback", + path: resolveCompletionFallbackPath(deliveryTarget.threadId), }; } throw err; diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index a6302402da6..4b0c5b82cbd 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -23,6 +23,7 @@ import { resolveSubagentAnnounceTimeoutMs, resolveSubagentCompletionOrigin, } from "./subagent-announce-delivery.js"; +import type { SubagentAnnounceDeliveryResult } from "./subagent-announce-dispatch.js"; import { resolveAnnounceOrigin } from "./subagent-announce-origin.js"; import { applySubagentWaitOutcome, @@ -244,6 +245,7 @@ export async function runSubagentAnnounceFlow(params: { wakeOnDescendantSettle?: boolean; signal?: AbortSignal; bestEffortDeliver?: boolean; + onDeliveryResult?: (delivery: SubagentAnnounceDeliveryResult) => void; }): Promise { let didAnnounce = false; const expectsCompletionMessage = params.expectsCompletionMessage === true; @@ -562,6 +564,7 @@ export async function runSubagentAnnounceFlow(params: { directIdempotencyKey, signal: params.signal, }); + params.onDeliveryResult?.(delivery); didAnnounce = delivery.delivered; if (!delivery.delivered && delivery.path === "direct" && delivery.error) { defaultRuntime.error?.( diff --git a/src/agents/subagent-registry-lifecycle.test.ts b/src/agents/subagent-registry-lifecycle.test.ts index 07951c68dd0..35ee9df2338 100644 --- a/src/agents/subagent-registry-lifecycle.test.ts +++ b/src/agents/subagent-registry-lifecycle.test.ts @@ -569,6 +569,82 @@ describe("subagent registry lifecycle hardening", () => { expect(persist).toHaveBeenCalled(); }); + it("persists the concrete announce delivery error when cleanup gives up", async () => { + const persist = vi.fn(); + const entry = createRunEntry({ + endedAt: 4_000, + expectsCompletionMessage: true, + retainAttachmentsOnKeep: true, + }); + const runSubagentAnnounceFlow = vi.fn( + async (announceParams: { + onDeliveryResult?: (delivery: { + delivered: false; + path: "direct"; + error: string; + phases: Array<{ + phase: "direct-primary" | "queue-fallback"; + delivered: boolean; + path: "direct" | "none"; + error?: string; + }>; + }) => void; + }) => { + announceParams.onDeliveryResult?.({ + delivered: false, + path: "direct", + error: "UNAVAILABLE: requester wake failed", + phases: [ + { + phase: "direct-primary", + delivered: false, + path: "direct", + error: "UNAVAILABLE: requester wake failed", + }, + { + phase: "queue-fallback", + delivered: false, + path: "none", + }, + ], + }); + return false; + }, + ); + + const controller = createLifecycleController({ + entry, + persist, + runSubagentAnnounceFlow, + }); + + await expect( + controller.completeSubagentRun({ + runId: entry.runId, + endedAt: 4_000, + outcome: { status: "ok" }, + reason: SUBAGENT_ENDED_REASON_COMPLETE, + triggerCleanup: true, + }), + ).resolves.toBeUndefined(); + + expect(taskExecutorMocks.setDetachedTaskDeliveryStatusByRunId).toHaveBeenCalledWith( + expect.objectContaining({ + runId: entry.runId, + runtime: "subagent", + sessionKey: entry.childSessionKey, + deliveryStatus: "failed", + error: + "UNAVAILABLE: requester wake failed; direct-primary: UNAVAILABLE: requester wake failed", + }), + ); + expect(entry.lastAnnounceDeliveryError).toBe( + "UNAVAILABLE: requester wake failed; direct-primary: UNAVAILABLE: requester wake failed", + ); + expect(entry.cleanupCompletedAt).toBeTypeOf("number"); + expect(persist).toHaveBeenCalled(); + }); + it("skips browser cleanup when steer restart suppresses cleanup flow", async () => { const entry = createRunEntry({ expectsCompletionMessage: false, diff --git a/src/agents/subagent-registry-lifecycle.ts b/src/agents/subagent-registry-lifecycle.ts index 83777211798..ab6959b0757 100644 --- a/src/agents/subagent-registry-lifecycle.ts +++ b/src/agents/subagent-registry-lifecycle.ts @@ -10,6 +10,7 @@ import { } from "../tasks/detached-task-runtime.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js"; import { retireSessionMcpRuntimeForSessionKey } from "./pi-bundle-mcp-tools.js"; +import type { SubagentAnnounceDeliveryResult } from "./subagent-announce-dispatch.js"; import { type SubagentRunOutcome, withSubagentOutcomeTiming } from "./subagent-announce-output.js"; import { SUBAGENT_ENDED_REASON_COMPLETE, @@ -126,10 +127,25 @@ export function createSubagentRegistryLifecycleController(params: { return name ? { name, message } : { message }; }; + const formatAnnounceDeliveryError = (delivery: SubagentAnnounceDeliveryResult): string => { + const errors = [ + delivery.error, + ...(delivery.phases ?? []).map((phase) => + phase.error ? `${phase.phase}: ${phase.error}` : undefined, + ), + ] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + return errors.length > 0 + ? [...new Set(errors)].join("; ") + : `delivery path ${delivery.path} did not complete`; + }; + const safeSetSubagentTaskDeliveryStatus = (args: { runId: string; childSessionKey: string; deliveryStatus: "delivered" | "failed"; + deliveryError?: string; }) => { try { setDetachedTaskDeliveryStatusByRunId({ @@ -137,6 +153,7 @@ export function createSubagentRegistryLifecycleController(params: { runtime: "subagent", sessionKey: args.childSessionKey, deliveryStatus: args.deliveryStatus, + error: args.deliveryStatus === "failed" ? args.deliveryError : undefined, }); } catch (err) { params.warn("failed to update subagent background task delivery state", { @@ -301,6 +318,7 @@ export function createSubagentRegistryLifecycleController(params: { runId: giveUpParams.runId, childSessionKey: giveUpParams.entry.childSessionKey, deliveryStatus: "failed", + deliveryError: giveUpParams.entry.lastAnnounceDeliveryError, }); giveUpParams.entry.wakeOnDescendantSettle = undefined; giveUpParams.entry.fallbackFrozenResultText = undefined; @@ -464,6 +482,7 @@ export function createSubagentRegistryLifecycleController(params: { childSessionKey: entry.childSessionKey, deliveryStatus: "delivered", }); + entry.lastAnnounceDeliveryError = undefined; entry.wakeOnDescendantSettle = undefined; entry.fallbackFrozenResultText = undefined; entry.fallbackFrozenResultCapturedAt = undefined; @@ -518,6 +537,7 @@ export function createSubagentRegistryLifecycleController(params: { runId, childSessionKey: entry.childSessionKey, deliveryStatus: "failed", + deliveryError: entry.lastAnnounceDeliveryError, }); entry.wakeOnDescendantSettle = undefined; entry.fallbackFrozenResultText = undefined; @@ -571,7 +591,11 @@ export function createSubagentRegistryLifecycleController(params: { return false; } const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin); + let latestDeliveryError = entry.lastAnnounceDeliveryError; const finalizeAnnounceCleanup = (didAnnounce: boolean) => { + if (!didAnnounce && latestDeliveryError) { + entry.lastAnnounceDeliveryError = latestDeliveryError; + } void finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce).catch((err) => { defaultRuntime.log(`[warn] subagent cleanup finalize failed (${runId}): ${String(err)}`); const current = params.runs.get(runId); @@ -603,6 +627,21 @@ export function createSubagentRegistryLifecycleController(params: { spawnMode: entry.spawnMode, expectsCompletionMessage: entry.expectsCompletionMessage, wakeOnDescendantSettle: entry.wakeOnDescendantSettle === true, + onDeliveryResult: (delivery) => { + if (delivery.delivered) { + if (entry.lastAnnounceDeliveryError !== undefined) { + entry.lastAnnounceDeliveryError = undefined; + params.persist(); + } + latestDeliveryError = undefined; + return; + } + latestDeliveryError = formatAnnounceDeliveryError(delivery); + if (entry.lastAnnounceDeliveryError !== latestDeliveryError) { + entry.lastAnnounceDeliveryError = latestDeliveryError; + params.persist(); + } + }, }) .then((didAnnounce) => { finalizeAnnounceCleanup(didAnnounce); diff --git a/src/agents/subagent-registry.types.ts b/src/agents/subagent-registry.types.ts index bec68b9389a..3274b91fbbb 100644 --- a/src/agents/subagent-registry.types.ts +++ b/src/agents/subagent-registry.types.ts @@ -30,6 +30,7 @@ export type SubagentRunRecord = { expectsCompletionMessage?: boolean; announceRetryCount?: number; lastAnnounceRetryAt?: number; + lastAnnounceDeliveryError?: string; endedReason?: SubagentLifecycleEndedReason; wakeOnDescendantSettle?: boolean; frozenResultText?: string | null; diff --git a/src/tasks/detached-task-runtime-contract.ts b/src/tasks/detached-task-runtime-contract.ts index 82455277e3e..07112a234ce 100644 --- a/src/tasks/detached-task-runtime-contract.ts +++ b/src/tasks/detached-task-runtime-contract.ts @@ -96,6 +96,7 @@ export type DetachedTaskDeliveryStatusParams = { runtime?: TaskRuntime; sessionKey?: string; deliveryStatus: TaskDeliveryStatus; + error?: string; }; export type DetachedTaskCancelParams = { diff --git a/src/tasks/task-executor.ts b/src/tasks/task-executor.ts index ce09f698eda..f8058189745 100644 --- a/src/tasks/task-executor.ts +++ b/src/tasks/task-executor.ts @@ -211,6 +211,7 @@ export function setDetachedTaskDeliveryStatusByRunId(params: { runtime?: TaskRuntime; sessionKey?: string; deliveryStatus: TaskDeliveryStatus; + error?: string; }) { return setTaskRunDeliveryStatusByRunId(params); } diff --git a/src/tasks/task-registry.ts b/src/tasks/task-registry.ts index f2a08464bcb..a571bf9827c 100644 --- a/src/tasks/task-registry.ts +++ b/src/tasks/task-registry.ts @@ -1672,15 +1672,20 @@ function updateTaskDeliveryByRunId(params: { runtime?: TaskRuntime; sessionKey?: string; deliveryStatus: TaskDeliveryStatus; + error?: string; }) { ensureTaskRegistryReady(); + const patch: Partial = { + deliveryStatus: params.deliveryStatus, + }; + if (params.error !== undefined) { + patch.error = params.error; + } return updateTasksByRunId({ runId: params.runId, runtime: params.runtime, sessionKey: params.sessionKey, - patch: { - deliveryStatus: params.deliveryStatus, - }, + patch, }); } @@ -1772,6 +1777,7 @@ export function setTaskRunDeliveryStatusByRunId(params: { runtime?: TaskRuntime; sessionKey?: string; deliveryStatus: TaskDeliveryStatus; + error?: string; }) { return updateTaskDeliveryByRunId(params); } From a911eb748bd094fd540e864c014f4e7c9cd14494 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 01:54:10 -0700 Subject: [PATCH 21/25] test(qa): cover subagent completion fallback --- .../src/providers/mock-openai/server.test.ts | 70 +++++++++++++ .../src/providers/mock-openai/server.ts | 29 ++++++ .../subagent-completion-direct-fallback.md | 99 +++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 qa/scenarios/agents/subagent-completion-direct-fallback.md diff --git a/extensions/qa-lab/src/providers/mock-openai/server.test.ts b/extensions/qa-lab/src/providers/mock-openai/server.test.ts index e6f1d529c8c..50c5172ef6f 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.test.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.test.ts @@ -68,6 +68,7 @@ function makeUserInput(text: string) { } const SESSIONS_SPAWN_TOOL = { type: "function", name: "sessions_spawn" } as const; +const SESSIONS_YIELD_TOOL = { type: "function", name: "sessions_yield" } as const; const THREAD_SUBAGENT_CHILD_ERROR_TOKEN = "QA_SUBAGENT_CHILD_ERROR"; const THREAD_SUBAGENT_TOOL_ERROR = "thread=true requested but thread delivery is unavailable in this test harness."; @@ -707,6 +708,75 @@ describe("qa mock openai server", () => { }); }); + it("drives yielded-parent subagent fallback QA through sessions_spawn and sessions_yield", async () => { + const server = await startMockServer(); + const prompt = + "Subagent direct fallback QA check: spawn one worker and yield until QA-SUBAGENT-DIRECT-FALLBACK-OK is delivered."; + + await expectResponsesText(server, { + stream: true, + tools: [SESSIONS_SPAWN_TOOL, SESSIONS_YIELD_TOOL], + input: [makeUserInput(prompt)], + }); + + await expect( + (await fetch(`${server.baseUrl}/debug/last-request`)).json(), + ).resolves.toMatchObject({ + plannedToolName: "sessions_spawn", + plannedToolArgs: { + label: "qa-direct-fallback-worker", + thread: false, + mode: "run", + }, + }); + + const body = await expectResponsesText(server, { + stream: true, + tools: [SESSIONS_SPAWN_TOOL, SESSIONS_YIELD_TOOL], + input: [ + makeUserInput(prompt), + { + type: "function_call_output", + call_id: "call_mock_sessions_spawn_1", + output: JSON.stringify({ + status: "accepted", + childSessionKey: "agent:qa:subagent:child", + runId: "run-child-1", + }), + }, + ], + }); + + expect(body).toContain('"name":"sessions_yield"'); + expect(body).toContain("QA-SUBAGENT-DIRECT-FALLBACK-OK"); + await expect( + (await fetch(`${server.baseUrl}/debug/last-request`)).json(), + ).resolves.toMatchObject({ + plannedToolName: "sessions_yield", + }); + }); + + it("returns no visible announce output for the direct fallback QA marker", async () => { + const server = await startMockServer(); + + const body = await expectResponsesJson<{ + output?: Array<{ content?: Array<{ text?: string }> }>; + }>(server, { + stream: false, + input: [ + makeUserInput( + [ + "[Internal task completion event]", + "Task: qa-direct-fallback-worker", + "Result: QA-SUBAGENT-DIRECT-FALLBACK-OK", + ].join("\n"), + ), + ], + }); + + expect(body.output?.[0]?.content?.[0]?.text).toBe(""); + }); + it("surfaces sessions_spawn tool errors instead of echoing child-task tokens", async () => { const server = await startMockServer(); diff --git a/extensions/qa-lab/src/providers/mock-openai/server.ts b/extensions/qa-lab/src/providers/mock-openai/server.ts index 233f99dba2f..6ac729c839a 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.ts @@ -147,6 +147,9 @@ const QA_EMPTY_RESPONSE_RECOVERY_PROMPT_RE = /empty response continuation qa che const QA_EMPTY_RESPONSE_EXHAUSTION_PROMPT_RE = /empty response exhaustion qa check/i; const QA_QUIET_STREAMING_PROMPT_RE = /quiet streaming qa check/i; const QA_BLOCK_STREAMING_PROMPT_RE = /block streaming qa check/i; +const QA_SUBAGENT_DIRECT_FALLBACK_PROMPT_RE = /subagent direct fallback qa check/i; +const QA_SUBAGENT_DIRECT_FALLBACK_WORKER_RE = /subagent direct fallback worker/i; +const QA_SUBAGENT_DIRECT_FALLBACK_MARKER = "QA-SUBAGENT-DIRECT-FALLBACK-OK"; const QA_REASONING_ONLY_RETRY_NEEDLE = "recorded reasoning but did not produce a user-visible answer"; const QA_EMPTY_RESPONSE_RETRY_NEEDLE = @@ -784,6 +787,9 @@ function buildAssistantText( if (/fanout worker beta/i.test(prompt)) { return "BETA-OK"; } + if (QA_SUBAGENT_DIRECT_FALLBACK_WORKER_RE.test(prompt)) { + return QA_SUBAGENT_DIRECT_FALLBACK_MARKER; + } if (/report the visible code/i.test(prompt) && /FORKED-CONTEXT-ALPHA/i.test(allInputText)) { return "FORKED-CONTEXT-ALPHA"; } @@ -1153,6 +1159,29 @@ async function buildResponsesPayload( const hasReasoningOnlyRetryInstruction = allInputText.includes(QA_REASONING_ONLY_RETRY_NEEDLE); const hasEmptyResponseRetryInstruction = allInputText.includes(QA_EMPTY_RESPONSE_RETRY_NEEDLE); const canCallSessionsSpawn = hasDeclaredTool(body, "sessions_spawn"); + const canCallSessionsYield = hasDeclaredTool(body, "sessions_yield"); + if ( + allInputText.includes(QA_SUBAGENT_DIRECT_FALLBACK_MARKER) && + /Internal task completion event/i.test(allInputText) + ) { + return buildAssistantEvents(""); + } + if (QA_SUBAGENT_DIRECT_FALLBACK_PROMPT_RE.test(allInputText)) { + if (!toolOutput && canCallSessionsSpawn) { + return buildToolCallEventsWithArgs("sessions_spawn", { + task: `Subagent direct fallback worker: finish with exactly ${QA_SUBAGENT_DIRECT_FALLBACK_MARKER}.`, + label: "qa-direct-fallback-worker", + thread: false, + mode: "run", + runTimeoutSeconds: 30, + }); + } + if (toolOutput && canCallSessionsYield && !/\byielded\b/i.test(toolOutput)) { + return buildToolCallEventsWithArgs("sessions_yield", { + message: `Waiting for ${QA_SUBAGENT_DIRECT_FALLBACK_MARKER}.`, + }); + } + } if (/remember this fact/i.test(prompt)) { return buildAssistantEvents(buildAssistantText(input, body, scenarioState)); } diff --git a/qa/scenarios/agents/subagent-completion-direct-fallback.md b/qa/scenarios/agents/subagent-completion-direct-fallback.md new file mode 100644 index 00000000000..4b62d365a18 --- /dev/null +++ b/qa/scenarios/agents/subagent-completion-direct-fallback.md @@ -0,0 +1,99 @@ +# Subagent completion direct fallback + +```yaml qa-scenario +id: subagent-completion-direct-fallback +title: Subagent completion direct fallback +surface: subagents +coverage: + primary: + - agents.subagents + secondary: + - runtime.delivery + - channels.qa-channel +objective: Verify a yielded parent still receives a successful subagent result through direct fallback delivery when the dormant announce turn produces no visible reply. +successCriteria: + - Parent launches a native subagent. + - Parent yields instead of waiting in-turn. + - Subagent completion result is delivered to the original QA DM without a thread id. + - Durable task delivery is marked delivered, not failed. +docsRefs: + - docs/tools/subagents.md + - docs/help/testing.md + - docs/channels/qa-channel.md +codeRefs: + - src/agents/subagent-announce-delivery.ts + - src/agents/subagent-registry-lifecycle.ts + - src/agents/tools/sessions-yield-tool.ts + - extensions/qa-lab/src/providers/mock-openai/server.ts +execution: + kind: flow + summary: Reproduce yielded-parent subagent completion delivery and require frozen-result fallback to the QA DM. + config: + prompt: "Subagent direct fallback QA check: spawn one native subagent worker. The worker must finish with exactly QA-SUBAGENT-DIRECT-FALLBACK-OK. After spawning it, call sessions_yield and wait for the completion event. Do not use ACP." + expectedMarker: QA-SUBAGENT-DIRECT-FALLBACK-OK + expectedLabel: qa-direct-fallback-worker +``` + +```yaml qa-flow +steps: + - name: yielded parent receives child completion through direct fallback + actions: + - call: waitForGatewayHealthy + args: + - ref: env + - 120000 + - call: waitForQaChannelReady + args: + - ref: env + - 120000 + - call: reset + - set: sessionKey + value: + expr: "`agent:qa:subagent-direct-fallback:${randomUUID().slice(0, 8)}`" + - call: runAgentPrompt + args: + - ref: env + - sessionKey: + ref: sessionKey + message: + expr: config.prompt + timeoutMs: + expr: liveTurnTimeoutMs(env, 90000) + - call: waitForCondition + saveAs: outbound + args: + - lambda: + expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound' && String(message.text ?? '').includes(config.expectedMarker)).at(-1)" + - expr: liveTurnTimeoutMs(env, 60000) + - expr: "env.providerMode === 'mock-openai' ? 100 : 250" + - assert: + expr: "String(outbound.text ?? '').trim().includes(config.expectedMarker)" + message: + expr: "`fallback completion marker missing from outbound QA DM: ${recentOutboundSummary(state)}`" + - if: + expr: "Boolean(env.mock)" + then: + - set: fallbackDebugRequests + value: + expr: "[...(await fetchJson(`${env.mock.baseUrl}/debug/requests`))]" + - assert: + expr: "fallbackDebugRequests.some((request) => !request.toolOutput && /subagent direct fallback qa check/i.test(String(request.allInputText ?? '')) && request.plannedToolName === 'sessions_spawn' && request.plannedToolArgs?.label === config.expectedLabel)" + message: + expr: "`expected sessions_spawn for yielded fallback scenario, saw ${JSON.stringify(fallbackDebugRequests.map((request) => ({ plannedToolName: request.plannedToolName ?? null, plannedToolArgs: request.plannedToolArgs ?? null })))}`" + - assert: + expr: "fallbackDebugRequests.some((request) => /subagent direct fallback qa check/i.test(String(request.allInputText ?? '')) && request.plannedToolName === 'sessions_yield')" + message: + expr: "`expected sessions_yield for yielded fallback scenario, saw ${JSON.stringify(fallbackDebugRequests.map((request) => request.plannedToolName ?? null))}`" + - call: waitForCondition + saveAs: deliveredTask + args: + - lambda: + expr: "(async () => { const payload = await runQaCli(env, ['tasks', 'list', '--json', '--runtime', 'subagent'], { timeoutMs: liveTurnTimeoutMs(env, 60000), json: true }); return (payload.tasks ?? []).find((task) => task.label === config.expectedLabel && task.deliveryStatus === 'delivered' && task.status === 'succeeded') ?? null; })()" + - expr: liveTurnTimeoutMs(env, 30000) + - 250 + - assert: + expr: "deliveredTask.deliveryStatus === 'delivered'" + message: + expr: "`expected delivered task status for ${config.expectedLabel}, got ${JSON.stringify(deliveredTask)}`" + detailsExpr: "outbound.text" +``` From da000ce511fa5d5530bd84b88f7bb21c9523cf16 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 01:57:24 -0700 Subject: [PATCH 22/25] docs(changelog): note subagent completion fallback --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70450d05fe2..9592ce41c52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/subagents: deliver completed yielded-subagent results back to no-thread requester routes via direct fallback when the dormant parent announce turn produces no visible reply, and add QA-lab coverage for the regression. Thanks @vincentkoc. - UI/Windows: quote resolved pnpm `.cmd` launcher paths before spawning UI install/build/test commands so Node installs under `C:\Program Files` no longer fail as `C:\Program`. Fixes #45275. Thanks @Kobevictor, @stoppieboy, and @iubns. - Codex/agent: translate `--thinking minimal` to `low` for modern Codex models (gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.2) at request build time so the first turn is accepted instead of paying a wasted call + retry-with-low fallback. Older Codex models still receive `minimal` directly. Fixes #71946. Thanks @hclsys. - Plugins/uninstall: remove tracked plugin files from their recorded managed extensions root even when the current state directory points somewhere else, so `openclaw plugins uninstall --force` does not leave the plugin discoverable. Thanks @shakkernerd. From e40029596973518c463928d27db00959ed660dd0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 01:59:16 -0700 Subject: [PATCH 23/25] docs(cli-gateway): rewrite with CardGroup, ParamField for run/probe/install flags, AccordionGroup for status semantics and probe interpretation --- docs/cli/gateway.md | 481 +++++++++++++++++++++++++++----------------- 1 file changed, 294 insertions(+), 187 deletions(-) diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 4c2cf07954a..0abae2ff35d 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -5,19 +5,22 @@ read_when: - Debugging Gateway auth, bind modes, and connectivity - Discovering gateways via Bonjour (local + wide-area DNS-SD) title: "Gateway" +sidebarTitle: "Gateway" --- -# Gateway CLI +The Gateway is OpenClaw's WebSocket server (channels, nodes, sessions, hooks). Subcommands in this page live under `openclaw gateway …`. -The Gateway is OpenClaw’s WebSocket server (channels, nodes, sessions, hooks). - -Subcommands in this page live under `openclaw gateway …`. - -Related docs: - -- [/gateway/bonjour](/gateway/bonjour) -- [/gateway/discovery](/gateway/discovery) -- [/gateway/configuration](/gateway/configuration) + + + Local mDNS + wide-area DNS-SD setup. + + + How OpenClaw advertises and finds gateways. + + + Top-level gateway config keys. + + ## Run the Gateway @@ -33,37 +36,79 @@ Foreground alias: openclaw gateway run ``` -Notes: - -- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.openclaw/openclaw.json`. Use `--allow-unconfigured` for ad-hoc/dev runs. -- `openclaw onboard --mode local` and `openclaw setup` are expected to write `gateway.mode=local`. If the file exists but `gateway.mode` is missing, treat that as a broken or clobbered config and repair it instead of assuming local mode implicitly. -- If the file exists and `gateway.mode` is missing, the Gateway treats that as suspicious config damage and refuses to “guess local” for you. -- Binding beyond loopback without auth is blocked (safety guardrail). -- `SIGUSR1` triggers an in-process restart when authorized (`commands.restart` is enabled by default; set `commands.restart: false` to block manual restart, while gateway tool/config apply/update remain allowed). -- `SIGINT`/`SIGTERM` handlers stop the gateway process, but they don’t restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit. + + + - By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.openclaw/openclaw.json`. Use `--allow-unconfigured` for ad-hoc/dev runs. + - `openclaw onboard --mode local` and `openclaw setup` are expected to write `gateway.mode=local`. If the file exists but `gateway.mode` is missing, treat that as a broken or clobbered config and repair it instead of assuming local mode implicitly. + - If the file exists and `gateway.mode` is missing, the Gateway treats that as suspicious config damage and refuses to "guess local" for you. + - Binding beyond loopback without auth is blocked (safety guardrail). + - `SIGUSR1` triggers an in-process restart when authorized (`commands.restart` is enabled by default; set `commands.restart: false` to block manual restart, while gateway tool/config apply/update remain allowed). + - `SIGINT`/`SIGTERM` handlers stop the gateway process, but they don't restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit. + + ### Options -- `--port `: WebSocket port (default comes from config/env; usually `18789`). -- `--bind `: listener bind mode. -- `--auth `: auth mode override. -- `--token `: token override (also sets `OPENCLAW_GATEWAY_TOKEN` for the process). -- `--password `: password override. Warning: inline passwords can be exposed in local process listings. -- `--password-file `: read the gateway password from a file. -- `--tailscale `: expose the Gateway via Tailscale. -- `--tailscale-reset-on-exit`: reset Tailscale serve/funnel config on shutdown. -- `--allow-unconfigured`: allow gateway start without `gateway.mode=local` in config. This bypasses the startup guard for ad-hoc/dev bootstrap only; it does not write or repair the config file. -- `--dev`: create a dev config + workspace if missing (skips BOOTSTRAP.md). -- `--reset`: reset dev config + credentials + sessions + workspace (requires `--dev`). -- `--force`: kill any existing listener on the selected port before starting. -- `--verbose`: verbose logs. -- `--cli-backend-logs`: only show CLI backend logs in the console (and enable stdout/stderr). -- `--ws-log `: websocket log style (default `auto`). -- `--compact`: alias for `--ws-log compact`. -- `--raw-stream`: log raw model stream events to jsonl. -- `--raw-stream-path `: raw stream jsonl path. + + WebSocket port (default comes from config/env; usually `18789`). + + + Listener bind mode. + + + Auth mode override. + + + Token override (also sets `OPENCLAW_GATEWAY_TOKEN` for the process). + + + Password override. + + + Read the gateway password from a file. + + + Expose the Gateway via Tailscale. + + + Reset Tailscale serve/funnel config on shutdown. + + + Allow gateway start without `gateway.mode=local` in config. Bypasses the startup guard for ad-hoc/dev bootstrap only; does not write or repair the config file. + + + Create a dev config + workspace if missing (skips BOOTSTRAP.md). + + + Reset dev config + credentials + sessions + workspace (requires `--dev`). + + + Kill any existing listener on the selected port before starting. + + + Verbose logs. + + + Only show CLI backend logs in the console (and enable stdout/stderr). + + + Websocket log style. + + + Alias for `--ws-log compact`. + + + Log raw model stream events to jsonl. + + + Raw stream jsonl path. + -Startup profiling: + +Inline `--password` can be exposed in local process listings. Prefer `--password-file`, env, or a SecretRef-backed `gateway.auth.password`. + + +### Startup profiling - Set `OPENCLAW_GATEWAY_STARTUP_TRACE=1` to log phase timings during Gateway startup. - Run `pnpm test:startup:gateway -- --runs 5 --warmup 1` to benchmark Gateway startup. The benchmark records first process output, `/healthz`, `/readyz`, and startup trace timings. @@ -72,22 +117,24 @@ Startup profiling: All query commands use WebSocket RPC. -Output modes: + + + - Default: human-readable (colored in TTY). + - `--json`: machine-readable JSON (no styling/spinner). + - `--no-color` (or `NO_COLOR=1`): disable ANSI while keeping human layout. + + + - `--url `: Gateway WebSocket URL. + - `--token `: Gateway token. + - `--password `: Gateway password. + - `--timeout `: timeout/budget (varies per command). + - `--expect-final`: wait for a "final" response (agent calls). + + -- Default: human-readable (colored in TTY). -- `--json`: machine-readable JSON (no styling/spinner). -- `--no-color` (or `NO_COLOR=1`): disable ANSI while keeping human layout. - -Shared options (where supported): - -- `--url `: Gateway WebSocket URL. -- `--token `: Gateway token. -- `--password `: Gateway password. -- `--timeout `: timeout/budget (varies per command). -- `--expect-final`: wait for a “final” response (agent calls). - -Note: when you set `--url`, the CLI does not fall back to config or environment credentials. -Pass `--token` or `--password` explicitly. Missing explicit credentials is an error. + +When you set `--url`, the CLI does not fall back to config or environment credentials. Pass `--token` or `--password` explicitly. Missing explicit credentials is an error. + ### `gateway health` @@ -107,9 +154,9 @@ openclaw gateway usage-cost --days 7 openclaw gateway usage-cost --json ``` -Options: - -- `--days `: number of days to include (default `30`). + + Number of days to include. + ### `gateway stability` @@ -123,24 +170,35 @@ openclaw gateway stability --bundle latest --export openclaw gateway stability --json ``` -Options: + + Maximum number of recent events to include (max `1000`). + + + Filter by diagnostic event type, such as `payload.large` or `diagnostic.memory.pressure`. + + + Include only events after a diagnostic sequence number. + + + Read a persisted stability bundle instead of calling the running Gateway. Use `--bundle latest` (or just `--bundle`) for the newest bundle under the state directory, or pass a bundle JSON path directly. + + + Write a shareable support diagnostics zip instead of printing stability details. + + + Output path for `--export`. + -- `--limit `: maximum number of recent events to include (default `25`, max `1000`). -- `--type `: filter by diagnostic event type, such as `payload.large` or `diagnostic.memory.pressure`. -- `--since-seq `: include only events after a diagnostic sequence number. -- `--bundle [path]`: read a persisted stability bundle instead of calling the running Gateway. Use `--bundle latest` (or just `--bundle`) for the newest bundle under the state directory, or pass a bundle JSON path directly. -- `--export`: write a shareable support diagnostics zip instead of printing stability details. -- `--output `: output path for `--export`. - -Notes: - -- Records keep operational metadata: event names, counts, byte sizes, memory readings, queue/session state, channel/plugin names, and redacted session summaries. They do not keep chat text, webhook bodies, tool outputs, raw request or response bodies, tokens, cookies, secret values, hostnames, or raw session ids. Set `diagnostics.enabled: false` to disable the recorder entirely. -- On fatal Gateway exits, shutdown timeouts, and restart startup failures, OpenClaw writes the same diagnostic snapshot to `~/.openclaw/logs/stability/openclaw-stability-*.json` when the recorder has events. Inspect the newest bundle with `openclaw gateway stability --bundle latest`; `--limit`, `--type`, and `--since-seq` also apply to bundle output. + + + - Records keep operational metadata: event names, counts, byte sizes, memory readings, queue/session state, channel/plugin names, and redacted session summaries. They do not keep chat text, webhook bodies, tool outputs, raw request or response bodies, tokens, cookies, secret values, hostnames, or raw session ids. Set `diagnostics.enabled: false` to disable the recorder entirely. + - On fatal Gateway exits, shutdown timeouts, and restart startup failures, OpenClaw writes the same diagnostic snapshot to `~/.openclaw/logs/stability/openclaw-stability-*.json` when the recorder has events. Inspect the newest bundle with `openclaw gateway stability --bundle latest`; `--limit`, `--type`, and `--since-seq` also apply to bundle output. + + ### `gateway diagnostics export` -Write a local diagnostics zip that is designed to attach to bug reports. -For the privacy model and bundle contents, see [Diagnostics Export](/gateway/diagnostics). +Write a local diagnostics zip that is designed to attach to bug reports. For the privacy model and bundle contents, see [Diagnostics Export](/gateway/diagnostics). ```bash openclaw gateway diagnostics export @@ -148,17 +206,33 @@ openclaw gateway diagnostics export --output openclaw-diagnostics.zip openclaw gateway diagnostics export --json ``` -Options: - -- `--output `: output zip path. Defaults to a support export under the state directory. -- `--log-lines `: maximum sanitized log lines to include (default `5000`). -- `--log-bytes `: maximum log bytes to inspect (default `1000000`). -- `--url `: Gateway WebSocket URL for the health snapshot. -- `--token `: Gateway token for the health snapshot. -- `--password `: Gateway password for the health snapshot. -- `--timeout `: status/health snapshot timeout (default `3000`). -- `--no-stability-bundle`: skip persisted stability bundle lookup. -- `--json`: print the written path, size, and manifest as JSON. + + Output zip path. Defaults to a support export under the state directory. + + + Maximum sanitized log lines to include. + + + Maximum log bytes to inspect. + + + Gateway WebSocket URL for the health snapshot. + + + Gateway token for the health snapshot. + + + Gateway password for the health snapshot. + + + Status/health snapshot timeout. + + + Skip persisted stability bundle lookup. + + + Print the written path, size, and manifest as JSON. + The export contains a manifest, a Markdown summary, config shape, sanitized config details, sanitized log summaries, sanitized Gateway status/health snapshots, and the newest stability bundle when one exists. @@ -174,93 +248,113 @@ openclaw gateway status --json openclaw gateway status --require-rpc ``` -Options: + + Add an explicit probe target. Configured remote + localhost are still probed. + + + Token auth for the probe. + + + Password auth for the probe. + + + Probe timeout. + + + Skip the connectivity probe (service-only view). + + + Scan system-level services too. + + + Upgrade the default connectivity probe to a read probe and exit non-zero when that read probe fails. Cannot be combined with `--no-probe`. + -- `--url `: add an explicit probe target. Configured remote + localhost are still probed. -- `--token `: token auth for the probe. -- `--password `: password auth for the probe. -- `--timeout `: probe timeout (default `10000`). -- `--no-probe`: skip the connectivity probe (service-only view). -- `--deep`: scan system-level services too. -- `--require-rpc`: upgrade the default connectivity probe to a read probe and exit non-zero when that read probe fails. Cannot be combined with `--no-probe`. - -Notes: - -- `gateway status` stays available for diagnostics even when the local CLI config is missing or invalid. -- Default `gateway status` proves service state, WebSocket connect, and the auth capability visible at handshake time. It does not prove read/write/admin operations. -- Diagnostic probes are non-mutating for first-time device auth: they reuse an - existing cached device token when one exists, but they do not create a new CLI - device identity or read-only device pairing record just to check status. -- `gateway status` resolves configured auth SecretRefs for probe auth when possible. -- If a required auth SecretRef is unresolved in this command path, `gateway status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first. -- If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives. -- Use `--require-rpc` in scripts and automation when a listening service is not enough and you need read-scope RPC calls to be healthy too. -- `--deep` adds a best-effort scan for extra launchd/systemd/schtasks installs. When multiple gateway-like services are detected, human output prints cleanup hints and warns that most setups should run one gateway per machine. -- Human output includes the resolved file log path plus the CLI-vs-service config paths/validity snapshot to help diagnose profile or state-dir drift. -- On Linux systemd installs, service auth drift checks read both `Environment=` and `EnvironmentFile=` values from the unit (including `%h`, quoted paths, multiple files, and optional `-` files). -- Drift checks resolve `gateway.auth.token` SecretRefs using merged runtime env (service command env first, then process env fallback). -- If token auth is not effectively active (explicit `gateway.auth.mode` of `password`/`none`/`trusted-proxy`, or mode unset where password can win and no token candidate can win), token-drift checks skip config token resolution. + + + - `gateway status` stays available for diagnostics even when the local CLI config is missing or invalid. + - Default `gateway status` proves service state, WebSocket connect, and the auth capability visible at handshake time. It does not prove read/write/admin operations. + - Diagnostic probes are non-mutating for first-time device auth: they reuse an existing cached device token when one exists, but they do not create a new CLI device identity or read-only device pairing record just to check status. + - `gateway status` resolves configured auth SecretRefs for probe auth when possible. + - If a required auth SecretRef is unresolved in this command path, `gateway status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first. + - If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives. + - Use `--require-rpc` in scripts and automation when a listening service is not enough and you need read-scope RPC calls to be healthy too. + - `--deep` adds a best-effort scan for extra launchd/systemd/schtasks installs. When multiple gateway-like services are detected, human output prints cleanup hints and warns that most setups should run one gateway per machine. + - Human output includes the resolved file log path plus the CLI-vs-service config paths/validity snapshot to help diagnose profile or state-dir drift. + + + - On Linux systemd installs, service auth drift checks read both `Environment=` and `EnvironmentFile=` values from the unit (including `%h`, quoted paths, multiple files, and optional `-` files). + - Drift checks resolve `gateway.auth.token` SecretRefs using merged runtime env (service command env first, then process env fallback). + - If token auth is not effectively active (explicit `gateway.auth.mode` of `password`/`none`/`trusted-proxy`, or mode unset where password can win and no token candidate can win), token-drift checks skip config token resolution. + + ### `gateway probe` -`gateway probe` is the “debug everything” command. It always probes: +`gateway probe` is the "debug everything" command. It always probes: - your configured remote gateway (if set), and - localhost (loopback) **even if remote is configured**. -If you pass `--url`, that explicit target is added ahead of both. Human output labels the -targets as: +If you pass `--url`, that explicit target is added ahead of both. Human output labels the targets as: - `URL (explicit)` - `Remote (configured)` or `Remote (configured, inactive)` - `Local loopback` + If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use isolated profiles/ports (e.g., a rescue bot), but most installs still run a single gateway. + ```bash openclaw gateway probe openclaw gateway probe --json ``` -Interpretation: + + + - `Reachable: yes` means at least one target accepted a WebSocket connect. + - `Capability: read-only|write-capable|admin-capable|pairing-pending|connect-only` reports what the probe could prove about auth. It is separate from reachability. + - `Read probe: ok` means read-scope detail RPC calls (`health`/`status`/`system-presence`/`config.get`) also succeeded. + - `Read probe: limited - missing scope: operator.read` means connect succeeded but read-scope RPC is limited. This is reported as **degraded** reachability, not full failure. + - Like `gateway status`, probe reuses existing cached device auth but does not create first-time device identity or pairing state. + - Exit code is non-zero only when no probed target is reachable. + + + Top level: -- `Reachable: yes` means at least one target accepted a WebSocket connect. -- `Capability: read-only|write-capable|admin-capable|pairing-pending|connect-only` reports what the probe could prove about auth. It is separate from reachability. -- `Read probe: ok` means read-scope detail RPC calls (`health`/`status`/`system-presence`/`config.get`) also succeeded. -- `Read probe: limited - missing scope: operator.read` means connect succeeded but read-scope RPC is limited. This is reported as **degraded** reachability, not full failure. -- Like `gateway status`, probe reuses existing cached device auth but does not - create first-time device identity or pairing state. -- Exit code is non-zero only when no probed target is reachable. + - `ok`: at least one target is reachable. + - `degraded`: at least one target had scope-limited detail RPC. + - `capability`: best capability seen across reachable targets (`read_only`, `write_capable`, `admin_capable`, `pairing_pending`, `connected_no_operator_scope`, or `unknown`). + - `primaryTargetId`: best target to treat as the active winner in this order: explicit URL, SSH tunnel, configured remote, then local loopback. + - `warnings[]`: best-effort warning records with `code`, `message`, and optional `targetIds`. + - `network`: local loopback/tailnet URL hints derived from current config and host networking. + - `discovery.timeoutMs` and `discovery.count`: the actual discovery budget/result count used for this probe pass. -JSON notes (`--json`): + Per target (`targets[].connect`): -- Top level: - - `ok`: at least one target is reachable. - - `degraded`: at least one target had scope-limited detail RPC. - - `capability`: best capability seen across reachable targets (`read_only`, `write_capable`, `admin_capable`, `pairing_pending`, `connected_no_operator_scope`, or `unknown`). - - `primaryTargetId`: best target to treat as the active winner in this order: explicit URL, SSH tunnel, configured remote, then local loopback. - - `warnings[]`: best-effort warning records with `code`, `message`, and optional `targetIds`. - - `network`: local loopback/tailnet URL hints derived from current config and host networking. - - `discovery.timeoutMs` and `discovery.count`: the actual discovery budget/result count used for this probe pass. -- Per target (`targets[].connect`): - - `ok`: reachability after connect + degraded classification. - - `rpcOk`: full detail RPC success. - - `scopeLimited`: detail RPC failed due to missing operator scope. -- Per target (`targets[].auth`): - - `role`: auth role reported in `hello-ok` when available. - - `scopes`: granted scopes reported in `hello-ok` when available. - - `capability`: the surfaced auth capability classification for that target. + - `ok`: reachability after connect + degraded classification. + - `rpcOk`: full detail RPC success. + - `scopeLimited`: detail RPC failed due to missing operator scope. -Common warning codes: + Per target (`targets[].auth`): -- `ssh_tunnel_failed`: SSH tunnel setup failed; the command fell back to direct probes. -- `multiple_gateways`: more than one target was reachable; this is unusual unless you intentionally run isolated profiles, such as a rescue bot. -- `auth_secretref_unresolved`: a configured auth SecretRef could not be resolved for a failed target. -- `probe_scope_limited`: WebSocket connect succeeded, but the read probe was limited by missing `operator.read`. + - `role`: auth role reported in `hello-ok` when available. + - `scopes`: granted scopes reported in `hello-ok` when available. + - `capability`: the surfaced auth capability classification for that target. + + + + - `ssh_tunnel_failed`: SSH tunnel setup failed; the command fell back to direct probes. + - `multiple_gateways`: more than one target was reachable; this is unusual unless you intentionally run isolated profiles, such as a rescue bot. + - `auth_secretref_unresolved`: a configured auth SecretRef could not be resolved for a failed target. + - `probe_scope_limited`: WebSocket connect succeeded, but the read probe was limited by missing `operator.read`. + + #### Remote over SSH (Mac app parity) -The macOS app “Remote over SSH” mode uses a local port-forward so the remote gateway (which may be bound to loopback only) becomes reachable at `ws://127.0.0.1:`. +The macOS app "Remote over SSH" mode uses a local port-forward so the remote gateway (which may be bound to loopback only) becomes reachable at `ws://127.0.0.1:`. CLI equivalent: @@ -268,13 +362,15 @@ CLI equivalent: openclaw gateway probe --ssh user@gateway-host ``` -Options: - -- `--ssh `: `user@host` or `user@host:port` (port defaults to `22`). -- `--ssh-identity `: identity file. -- `--ssh-auto`: pick the first discovered gateway host as SSH target from the resolved - discovery endpoint (`local.` plus the configured wide-area domain, if any). TXT-only - hints are ignored. + + `user@host` or `user@host:port` (port defaults to `22`). + + + Identity file. + + + Pick the first discovered gateway host as SSH target from the resolved discovery endpoint (`local.` plus the configured wide-area domain, if any). TXT-only hints are ignored. + Config (optional, used as defaults): @@ -290,20 +386,31 @@ openclaw gateway call status openclaw gateway call logs.tail --params '{"sinceMs": 60000}' ``` -Options: + + JSON object string for params. + + + Gateway WebSocket URL. + + + Gateway token. + + + Gateway password. + + + Timeout budget. + + + Mainly for agent-style RPCs that stream intermediate events before a final payload. + + + Machine-readable JSON output. + -- `--params `: JSON object string for params (default `{}`) -- `--url ` -- `--token ` -- `--password ` -- `--timeout ` -- `--expect-final` -- `--json` - -Notes: - -- `--params` must be valid JSON. -- `--expect-final` is mainly for agent-style RPCs that stream intermediate events before a final payload. + +`--params` must be valid JSON. + ## Manage the Gateway service @@ -315,29 +422,30 @@ openclaw gateway restart openclaw gateway uninstall ``` -Command options: - -- `gateway status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json` -- `gateway install`: `--port`, `--runtime `, `--token`, `--force`, `--json` -- `gateway uninstall|start|stop|restart`: `--json` - -Notes: - -- `gateway install` supports `--port`, `--runtime`, `--token`, `--force`, `--json`. -- Use `gateway restart` to restart a managed service. Do not chain `gateway stop` and `gateway start` as a restart substitute; on macOS, `gateway stop` intentionally disables the LaunchAgent before stopping it. -- When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `gateway install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata. -- If token auth requires a token and the configured token SecretRef is unresolved, install fails closed instead of persisting fallback plaintext. -- For password auth on `gateway run`, prefer `OPENCLAW_GATEWAY_PASSWORD`, `--password-file`, or a SecretRef-backed `gateway.auth.password` over inline `--password`. -- In inferred auth mode, shell-only `OPENCLAW_GATEWAY_PASSWORD` does not relax install token requirements; use durable config (`gateway.auth.password` or config `env`) when installing a managed service. -- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, install is blocked until mode is set explicitly. -- Lifecycle commands accept `--json` for scripting. + + + - `gateway status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json` + - `gateway install`: `--port`, `--runtime `, `--token`, `--force`, `--json` + - `gateway uninstall|start|stop|restart`: `--json` + + + - `gateway install` supports `--port`, `--runtime`, `--token`, `--force`, `--json`. + - Use `gateway restart` to restart a managed service. Do not chain `gateway stop` and `gateway start` as a restart substitute; on macOS, `gateway stop` intentionally disables the LaunchAgent before stopping it. + - When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `gateway install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata. + - If token auth requires a token and the configured token SecretRef is unresolved, install fails closed instead of persisting fallback plaintext. + - For password auth on `gateway run`, prefer `OPENCLAW_GATEWAY_PASSWORD`, `--password-file`, or a SecretRef-backed `gateway.auth.password` over inline `--password`. + - In inferred auth mode, shell-only `OPENCLAW_GATEWAY_PASSWORD` does not relax install token requirements; use durable config (`gateway.auth.password` or config `env`) when installing a managed service. + - If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, install is blocked until mode is set explicitly. + - Lifecycle commands accept `--json` for scripting. + + ## Discover gateways (Bonjour) `gateway discover` scans for Gateway beacons (`_openclaw-gw._tcp`). - Multicast DNS-SD: `local.` -- Unicast DNS-SD (Wide-Area Bonjour): choose a domain (example: `openclaw.internal.`) and set up split DNS + a DNS server; see [/gateway/bonjour](/gateway/bonjour) +- Unicast DNS-SD (Wide-Area Bonjour): choose a domain (example: `openclaw.internal.`) and set up split DNS + a DNS server; see [Bonjour](/gateway/bonjour). Only gateways with Bonjour discovery enabled (default) advertise the beacon. @@ -357,10 +465,12 @@ Wide-Area discovery records include (TXT): openclaw gateway discover ``` -Options: - -- `--timeout `: per-command timeout (browse/resolve); default `2000`. -- `--json`: machine-readable output (also disables styling/spinner). + + Per-command timeout (browse/resolve). + + + Machine-readable output (also disables styling/spinner). + Examples: @@ -369,14 +479,11 @@ openclaw gateway discover --timeout 4000 openclaw gateway discover --json | jq '.beacons[].wsUrl' ``` -Notes: - + - The CLI scans `local.` plus the configured wide-area domain when one is enabled. -- `wsUrl` in JSON output is derived from the resolved service endpoint, not from TXT-only - hints such as `lanHost` or `tailnetDns`. -- On `local.` mDNS, `sshPort` and `cliPath` are only broadcast when - `discovery.mdns.mode` is `full`. Wide-area DNS-SD still writes `cliPath`; `sshPort` - stays optional there too. +- `wsUrl` in JSON output is derived from the resolved service endpoint, not from TXT-only hints such as `lanHost` or `tailnetDns`. +- On `local.` mDNS, `sshPort` and `cliPath` are only broadcast when `discovery.mdns.mode` is `full`. Wide-area DNS-SD still writes `cliPath`; `sshPort` stays optional there too. + ## Related From e6c91232621a78534acb0d89ec9ce2f54f106714 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:58:34 +0100 Subject: [PATCH 24/25] docs(release): codify beta train backport scan (cherry picked from commit b7733c48c06c2c7323e2b5a7a5db99c8a97ef85d) --- .../openclaw-release-maintainer/SKILL.md | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/.agents/skills/openclaw-release-maintainer/SKILL.md b/.agents/skills/openclaw-release-maintainer/SKILL.md index 1fe4de534ae..a70f6d9b81c 100644 --- a/.agents/skills/openclaw-release-maintainer/SKILL.md +++ b/.agents/skills/openclaw-release-maintainer/SKILL.md @@ -28,12 +28,17 @@ Use this skill for release and publish-time workflow. Keep ordinary development - Do not delete or rewrite beta tags after they leave the machine. If a published or pushed beta needs a fix, commit the fix on the release branch and increment to the next `-beta.N`. -- For a beta release train, run the full pre-npm test roster before publishing - each beta. After a beta is published, run the smaller published-install roster - focused on install/update/Docker/Parallels. If anything fails, fix it on the - release branch, commit/push/pull, increment beta number, and repeat. Operators - may authorize up to 4 autonomous beta attempts; after 4 failed beta attempts, - stop and report. +- For a beta release train, run the fast local preflight first, publish the + beta to npm `beta`, then run the expensive published-package roster focused + on install/update/Docker/Parallels/NPM Telegram. If anything fails, fix it on + the release branch, commit/push/pull, increment beta number, and repeat. Run + the full expensive roster at least once before stable/latest promotion; for + later beta attempts, rerun only lanes whose evidence changed unless the fix + touches broad release, install/update, plugin, Docker, Parallels, or live QA + behavior. After each beta is published, scan current `main` once for critical + fixes that landed after the release branch cut and backport only important + low-risk fixes. Operators may authorize up to 4 autonomous beta attempts; + after 4 failed beta attempts, stop and report. - Use `/changelog` before version/tag preparation so the top changelog section is deduped and ordered by user impact. - Do not create beta-specific `CHANGELOG.md` headings. Beta releases use the @@ -491,8 +496,10 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts 6. Create `release/YYYY.M.D` from that post-changelog `main` commit. 7. Make every repo version location match the beta tag before creating it. 8. Commit release preparation changes on the release branch and push the branch. -9. Run the local build, Docker, and Parallels parts of the full pre-npm beta - test roster from the release branch before any npm preflight or publish. +9. Run the fast local beta preflight from the release branch before any npm + preflight or publish. Keep expensive Docker, Parallels, and published-package + install/update lanes for after the beta is live unless the operator asks to + run them before beta publication. 10. For beta releases, skip mac app build/sign/notarize unless beta scope or a release blocker specifically requires it. For stable releases, include the mac app, signing, notarization, and appcast path. @@ -529,10 +536,14 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts 21. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`. 22. Run postpublish verification: `node --import tsx scripts/openclaw-npm-postpublish-verify.ts `. -23. Run the post-published beta verification roster. If any lane fails after - the beta tag/package is pushed or published, fix, commit/push/pull, - increment to the next beta tag, and restart at the full pre-npm beta test - roster for the new beta. The roster includes the manual Actions > +23. Run the post-published beta verification roster. First scan current `main` + for critical fixes that landed after the release branch cut; backport only + important low-risk fixes before starting expensive lanes, or increment to + the next beta if the fix must change the already-published package. If any + lane fails after the beta tag/package is pushed or published, fix, + commit/push/pull, increment to the next beta tag, and rerun the affected + beta evidence. Ensure the full expensive roster has passed at least once + before stable/latest promotion. The roster includes the manual Actions > `NPM Telegram Beta E2E` workflow against the exact published beta package. If a pre-npm lane fails before any tag/package leaves the machine, fix and rerun the same intended beta attempt. Repeat up to the operator's From 382c5547863efdc5998e822671ea061d17a5c1d3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:59:08 +0100 Subject: [PATCH 25/25] docs(release): keep 2026.4.26 changelog marker empty --- .../openclaw-release-maintainer/SKILL.md | 7 +++++- CHANGELOG.md | 25 ++++++++----------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/.agents/skills/openclaw-release-maintainer/SKILL.md b/.agents/skills/openclaw-release-maintainer/SKILL.md index a70f6d9b81c..c9532b707c4 100644 --- a/.agents/skills/openclaw-release-maintainer/SKILL.md +++ b/.agents/skills/openclaw-release-maintainer/SKILL.md @@ -80,6 +80,10 @@ Use this skill for release and publish-time workflow. Keep ordinary development parallel, publish npm from the successful npm preflight, then start published npm install/update, Docker, and Parallels verification while mac artifacts continue. +- After a beta is published, run the expensive Docker, Parallels, and QA-Lab + release rosters in parallel instead of serializing them. Use selective reruns + after failures or fixes, but keep proof that Docker, Parallels, and QA-Lab + each passed at least once before stable/latest promotion. - Mac packaging may be built from a slight release-branch variation of the tagged commit when the delta is mac packaging, signing, workflow, or validation-only release machinery. If mac packaging needs release-branch-only @@ -542,7 +546,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts the next beta if the fix must change the already-published package. If any lane fails after the beta tag/package is pushed or published, fix, commit/push/pull, increment to the next beta tag, and rerun the affected - beta evidence. Ensure the full expensive roster has passed at least once + beta evidence. Start Docker, Parallels, and QA-Lab in parallel once the + beta is live. Ensure the full expensive roster has passed at least once before stable/latest promotion. The roster includes the manual Actions > `NPM Telegram Beta E2E` workflow against the exact published beta package. If a pre-npm lane fails before any tag/package leaves the machine, fix and diff --git a/CHANGELOG.md b/CHANGELOG.md index 9592ce41c52..b4b60ce8673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,20 +6,6 @@ Docs: https://docs.openclaw.ai ## 2026.4.26 -### Fixes - -- Gateway/Tailscale: let Tailscale-authenticated Control UI operator sessions with browser device identity skip the device-pairing round trip while still rejecting device-less and node-role connections. Refs #71986. Thanks @jokedul. -- Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd. -- CLI/update: run package post-update doctor with `--fix` so package updates repair config migrations before restart. Thanks @shakkernerd. -- CLI/update: retry failed npm global updates with `--omit=optional` and ignore the superseded first failure when the fallback succeeds. Thanks @shakkernerd. -- Plugins/uninstall: migrate and reset `plugins.slots.contextEngine` alongside memory slots when plugin ids change or selected plugins are removed. Thanks @shakkernerd. -- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex. -- Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc. -- Plugins/channels: treat malformed bundled channel plugin loaders that return `undefined` as unavailable instead of crashing config and help paths. Fixes #69044. Thanks @frankhli843 and @vincentkoc. -- Scripts/watch: show corrupted dependency package-config recovery guidance when `gateway:watch` fails during watcher startup, without double-logging unrelated import failures. (#58780) Thanks @roytong9 and @vincentkoc. -- Signal: read signal-cli RPC, health checks, and SSE events through Node's HTTP client so Node 24/25 fetch regressions do not break Signal sends or inbound events. Fixes #51716 and #53040. Thanks @Barukimang, @minupla, and @vincentkoc. -- Skills/Docker: run npm-backed skill dependency installs with an OpenClaw-managed user prefix so non-root Docker images do not write to `/usr/local`. Fixes #59601. Thanks @chanjarster and @vincentkoc. - ## 2026.4.25 ### Highlights @@ -106,6 +92,12 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/subagents: deliver completed yielded-subagent results back to no-thread requester routes via direct fallback when the dormant parent announce turn produces no visible reply, and add QA-lab coverage for the regression. Thanks @vincentkoc. +- Gateway/Tailscale: let Tailscale-authenticated Control UI operator sessions with browser device identity skip the device-pairing round trip while still rejecting device-less and node-role connections. Refs #71986. Thanks @jokedul. +- Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd. +- CLI/update: run package post-update doctor with `--fix` so package updates repair config migrations before restart. Thanks @shakkernerd. +- CLI/update: retry failed npm global updates with `--omit=optional` and ignore the superseded first failure when the fallback succeeds. Thanks @shakkernerd. +- Plugins/uninstall: migrate and reset `plugins.slots.contextEngine` alongside memory slots when plugin ids change or selected plugins are removed. Thanks @shakkernerd. +- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex. - UI/Windows: quote resolved pnpm `.cmd` launcher paths before spawning UI install/build/test commands so Node installs under `C:\Program Files` no longer fail as `C:\Program`. Fixes #45275. Thanks @Kobevictor, @stoppieboy, and @iubns. - Codex/agent: translate `--thinking minimal` to `low` for modern Codex models (gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.2) at request build time so the first turn is accepted instead of paying a wasted call + retry-with-low fallback. Older Codex models still receive `minimal` directly. Fixes #71946. Thanks @hclsys. - Plugins/uninstall: remove tracked plugin files from their recorded managed extensions root even when the current state directory points somewhere else, so `openclaw plugins uninstall --force` does not leave the plugin discoverable. Thanks @shakkernerd. @@ -133,6 +125,11 @@ Docs: https://docs.openclaw.ai - Installer: warn when multiple npm global roots contain OpenClaw installs, showing active Node/npm/openclaw plus each install path and version so stale version-manager installs are visible. Fixes #40839. Thanks @zhixianio. - Cron/tasks: recover completed cron task ledger records from durable run logs and job state before marking them `lost`, reducing false `backing session missing` audit errors for isolated cron runs and keeping offline CLI audit from treating its empty local cron active-job set as authoritative. Fixes #71963. - Docker: copy patched dependency files into runtime images so downstream `pnpm install` layers keep working. Fixes #69224. Thanks @gucasbrg. +- Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc. +- Plugins/channels: treat malformed bundled channel plugin loaders that return `undefined` as unavailable instead of crashing config and help paths. Fixes #69044. Thanks @frankhli843 and @vincentkoc. +- Scripts/watch: show corrupted dependency package-config recovery guidance when `gateway:watch` fails during watcher startup, without double-logging unrelated import failures. (#58780) Thanks @roytong9 and @vincentkoc. +- Signal: read signal-cli RPC, health checks, and SSE events through Node's HTTP client so Node 24/25 fetch regressions do not break Signal sends or inbound events. Fixes #51716 and #53040. Thanks @Barukimang, @minupla, and @vincentkoc. +- Skills/Docker: run npm-backed skill dependency installs with an OpenClaw-managed user prefix so non-root Docker images do not write to `/usr/local`. Fixes #59601. Thanks @chanjarster and @vincentkoc. - Agents/runtime: submit heartbeat, cron, and exec wakeups as transient runtime context instead of visible user prompts, keeping synthetic system work out of chat transcripts. Fixes #66496 and #66814. Thanks @jeades and @mandomaker. - Telegram: include native quote excerpts automatically for threaded replies and reply tags when the original Telegram text is available, without adding another config knob. Fixes #6975. Thanks @rex05ai. - Node/Linux: make `openclaw node install` enable and restart the `openclaw-node` systemd unit instead of the gateway unit on node-only VMs. Fixes #68287. Thanks @dlebee-agent.