From 295bcde7b82cf31e4dc1d14c110a288b4c60575a Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 08:32:44 +0100 Subject: [PATCH 01/25] test: update channel metadata mocks --- src/commands/agents.commands.list.test.ts | 43 +++++++++++++++++++++-- src/commands/doctor-security.test.ts | 18 ++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/commands/agents.commands.list.test.ts b/src/commands/agents.commands.list.test.ts index 5ca1b330c06..49e82047432 100644 --- a/src/commands/agents.commands.list.test.ts +++ b/src/commands/agents.commands.list.test.ts @@ -2,9 +2,29 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { OutputRuntimeEnv } from "../runtime.js"; -const { buildProviderStatusIndexMock, requireValidConfigMock } = vi.hoisted(() => ({ +const { + buildProviderStatusIndexMock, + buildProviderSummaryMetadataIndexMock, + listProvidersForAgentMock, + providerSummaryMetadataMock, + requireValidConfigMock, + summarizeBindingsMock, +} = vi.hoisted(() => ({ buildProviderStatusIndexMock: vi.fn(), + buildProviderSummaryMetadataIndexMock: vi.fn(), + listProvidersForAgentMock: vi.fn(), + providerSummaryMetadataMock: new Map([ + [ + "telegram", + { + label: "Telegram", + defaultAccountId: "default", + visibleInConfiguredLists: true, + }, + ], + ]), requireValidConfigMock: vi.fn(), + summarizeBindingsMock: vi.fn(), })); vi.mock("./agents.command-shared.js", () => ({ @@ -13,8 +33,9 @@ vi.mock("./agents.command-shared.js", () => ({ vi.mock("./agents.providers.js", () => ({ buildProviderStatusIndex: buildProviderStatusIndexMock, - listProvidersForAgent: () => ["Telegram default: configured"], - summarizeBindings: () => ["Telegram default"], + buildProviderSummaryMetadataIndex: buildProviderSummaryMetadataIndexMock, + listProvidersForAgent: listProvidersForAgentMock, + summarizeBindings: summarizeBindingsMock, })); const { agentsListCommand } = await import("./agents.commands.list.js"); @@ -47,6 +68,9 @@ describe("agentsListCommand", () => { vi.clearAllMocks(); requireValidConfigMock.mockResolvedValue(createConfig()); buildProviderStatusIndexMock.mockResolvedValue(new Map()); + buildProviderSummaryMetadataIndexMock.mockReturnValue(providerSummaryMetadataMock); + listProvidersForAgentMock.mockReturnValue(["Telegram default: configured"]); + summarizeBindingsMock.mockReturnValue(["Telegram default"]); }); it("keeps plain JSON output on the config-only path", async () => { @@ -67,6 +91,19 @@ describe("agentsListCommand", () => { await agentsListCommand({ json: true, bindings: true }, runtime); expect(buildProviderStatusIndexMock).toHaveBeenCalledOnce(); + expect(buildProviderSummaryMetadataIndexMock).toHaveBeenCalledOnce(); + expect(summarizeBindingsMock).toHaveBeenCalledWith( + expect.objectContaining({ agents: expect.any(Object) }), + [expect.objectContaining({ agentId: "main" })], + providerSummaryMetadataMock, + ); + expect(listProvidersForAgentMock).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: expect.objectContaining({ agents: expect.any(Object) }), + bindings: [expect.objectContaining({ agentId: "main" })], + providerMetadata: providerSummaryMetadataMock, + }), + ); expect(runtime.json[0]).toEqual([ expect.objectContaining({ id: "main", diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index f1754d3125f..92af2432c2c 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../config/config.js"; const note = vi.hoisted(() => vi.fn()); const pluginRegistry = vi.hoisted(() => ({ list: [] as unknown[] })); +const listReadOnlyChannelPluginsForConfigMock = vi.hoisted(() => vi.fn()); vi.mock("../terminal/note.js", () => ({ note, @@ -15,6 +16,10 @@ vi.mock("../channels/plugins/index.js", () => ({ listChannelPlugins: () => pluginRegistry.list, })); +vi.mock("../channels/plugins/read-only.js", () => ({ + listReadOnlyChannelPluginsForConfig: listReadOnlyChannelPluginsForConfigMock, +})); + vi.mock("../channels/read-only-account-inspect.js", () => ({ inspectReadOnlyChannelAccount: vi.fn(async () => null), })); @@ -28,6 +33,8 @@ describe("noteSecurityWarnings gateway exposure", () => { beforeEach(() => { note.mockClear(); + listReadOnlyChannelPluginsForConfigMock.mockReset(); + listReadOnlyChannelPluginsForConfigMock.mockImplementation(() => pluginRegistry.list); pluginRegistry.list = []; prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; prevPassword = process.env.OPENCLAW_GATEWAY_PASSWORD; @@ -197,6 +204,10 @@ describe("noteSecurityWarnings gateway exposure", () => { ]; const cfg = { session: { dmScope: "main" } } as OpenClawConfig; await noteSecurityWarnings(cfg); + expect(listReadOnlyChannelPluginsForConfigMock).toHaveBeenCalledWith(cfg, { + includePersistedAuthState: true, + includeSetupRuntimeFallback: false, + }); const message = lastMessage(); expect(message).toContain('config set session.dmScope "per-channel-peer"'); }); @@ -454,6 +465,13 @@ describe("noteSecurityWarnings gateway exposure", () => { ]; await noteSecurityWarnings({} as OpenClawConfig); + expect(listReadOnlyChannelPluginsForConfigMock).toHaveBeenCalledWith( + {}, + { + includePersistedAuthState: true, + includeSetupRuntimeFallback: false, + }, + ); const message = lastMessage(); expect(message).toContain("[secrets]"); expect(message).toContain("failed to resolve account"); From 06d409dc2738034f6005ede426726e830ea48679 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 00:55:42 -0700 Subject: [PATCH 02/25] docs(mattermost): rewrite with Steps for setup and HMAC, Tabs for chatmodes, AccordionGroup for slash commands and troubleshooting --- docs/channels/mattermost.md | 366 ++++++++++++++++++++---------------- 1 file changed, 199 insertions(+), 167 deletions(-) diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md index c2c35814f11..72cbc0469cc 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -4,62 +4,68 @@ read_when: - Setting up Mattermost - Debugging Mattermost routing title: "Mattermost" +sidebarTitle: "Mattermost" --- -Status: bundled plugin (bot token + WebSocket events). Channels, groups, and DMs are supported. -Mattermost is a self-hostable team messaging platform; see the official site at -[mattermost.com](https://mattermost.com) for product details and downloads. +Status: bundled plugin (bot token + WebSocket events). Channels, groups, and DMs are supported. Mattermost is a self-hostable team messaging platform; see the official site at [mattermost.com](https://mattermost.com) for product details and downloads. ## Bundled plugin -Mattermost ships as a bundled plugin in current OpenClaw releases, so normal -packaged builds do not need a separate install. + +Mattermost 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 Mattermost, -install it manually: +If you are on an older build or a custom install that excludes Mattermost, install it manually: -Install via CLI (npm registry): - -```bash -openclaw plugins install @openclaw/mattermost -``` - -Local checkout (when running from a git repo): - -```bash -openclaw plugins install ./path/to/local/mattermost-plugin -``` + + + ```bash + openclaw plugins install @openclaw/mattermost + ``` + + + ```bash + openclaw plugins install ./path/to/local/mattermost-plugin + ``` + + Details: [Plugins](/tools/plugin) ## Quick setup -1. Ensure the Mattermost plugin is available. - - Current packaged OpenClaw releases already bundle it. - - Older/custom installs can add it manually with the commands above. -2. Create a Mattermost bot account and copy the **bot token**. -3. Copy the Mattermost **base URL** (e.g., `https://chat.example.com`). -4. Configure OpenClaw and start the gateway. + + + Current packaged OpenClaw releases already bundle it. Older/custom installs can add it manually with the commands above. + + + Create a Mattermost bot account and copy the **bot token**. + + + Copy the Mattermost **base URL** (e.g., `https://chat.example.com`). + + + Minimal config: -Minimal config: + ```json5 + { + channels: { + mattermost: { + enabled: true, + botToken: "mm-token", + baseUrl: "https://chat.example.com", + dmPolicy: "pairing", + }, + }, + } + ``` -```json5 -{ - channels: { - mattermost: { - enabled: true, - botToken: "mm-token", - baseUrl: "https://chat.example.com", - dmPolicy: "pairing", - }, - }, -} -``` + + ## Native slash commands -Native slash commands are opt-in. When enabled, OpenClaw registers `oc_*` slash commands via -the Mattermost API and receives callback POSTs on the gateway HTTP server. +Native slash commands are opt-in. When enabled, OpenClaw registers `oc_*` slash commands via the Mattermost API and receives callback POSTs on the gateway HTTP server. ```json5 { @@ -77,27 +83,33 @@ the Mattermost API and receives callback POSTs on the gateway HTTP server. } ``` -Notes: + + + - `native: "auto"` defaults to disabled for Mattermost. Set `native: true` to enable. + - If `callbackUrl` is omitted, OpenClaw derives one from gateway host/port + `callbackPath`. + - For multi-account setups, `commands` can be set at the top level or under `channels.mattermost.accounts..commands` (account values override top-level fields). + - Command callbacks are validated with the per-command tokens returned by Mattermost when OpenClaw registers `oc_*` commands. + - Slash callbacks fail closed when registration failed, startup was partial, or the callback token does not match one of the registered commands. + + + The callback endpoint must be reachable from the Mattermost server. + + - Do not set `callbackUrl` to `localhost` unless Mattermost runs on the same host/network namespace as OpenClaw. + - Do not set `callbackUrl` to your Mattermost base URL unless that URL reverse-proxies `/api/channels/mattermost/command` to OpenClaw. + - A quick check is `curl https:///api/channels/mattermost/command`; a GET should return `405 Method Not Allowed` from OpenClaw, not `404`. + + + + If your callback targets private/tailnet/internal addresses, set Mattermost `ServiceSettings.AllowedUntrustedInternalConnections` to include the callback host/domain. + + Use host/domain entries, not full URLs. -- `native: "auto"` defaults to disabled for Mattermost. Set `native: true` to enable. -- If `callbackUrl` is omitted, OpenClaw derives one from gateway host/port + `callbackPath`. -- For multi-account setups, `commands` can be set at the top level or under - `channels.mattermost.accounts..commands` (account values override top-level fields). -- Command callbacks are validated with the per-command tokens returned by - Mattermost when OpenClaw registers `oc_*` commands. -- Slash callbacks fail closed when registration failed, startup was partial, or - the callback token does not match one of the registered commands. -- Reachability requirement: the callback endpoint must be reachable from the Mattermost server. - - Do not set `callbackUrl` to `localhost` unless Mattermost runs on the same host/network namespace as OpenClaw. - - Do not set `callbackUrl` to your Mattermost base URL unless that URL reverse-proxies `/api/channels/mattermost/command` to OpenClaw. - - A quick check is `curl https:///api/channels/mattermost/command`; a GET should return `405 Method Not Allowed` from OpenClaw, not `404`. -- Mattermost egress allowlist requirement: - - If your callback targets private/tailnet/internal addresses, set Mattermost - `ServiceSettings.AllowedUntrustedInternalConnections` to include the callback host/domain. - - Use host/domain entries, not full URLs. - Good: `gateway.tailnet-name.ts.net` - Bad: `https://gateway.tailnet-name.ts.net` + + + ## Environment variables (default account) Set these on the gateway host if you prefer env vars: @@ -105,17 +117,27 @@ Set these on the gateway host if you prefer env vars: - `MATTERMOST_BOT_TOKEN=...` - `MATTERMOST_URL=https://chat.example.com` + Env vars apply only to the **default** account (`default`). Other accounts must use config values. `MATTERMOST_URL` cannot be set from a workspace `.env`; see [Workspace `.env` files](/gateway/security). + ## Chat modes Mattermost responds to DMs automatically. Channel behavior is controlled by `chatmode`: -- `oncall` (default): respond only when @mentioned in channels. -- `onmessage`: respond to every channel message. -- `onchar`: respond when a message starts with a trigger prefix. + + + Respond only when @mentioned in channels. + + + Respond to every channel message. + + + Respond when a message starts with a trigger prefix. + + Config example: @@ -137,12 +159,10 @@ Notes: ## Threading and sessions -Use `channels.mattermost.replyToMode` to control whether channel and group replies stay in the -main channel or start a thread under the triggering post. +Use `channels.mattermost.replyToMode` to control whether channel and group replies stay in the main channel or start a thread under the triggering post. - `off` (default): only reply in a thread when the inbound post is already in one. -- `first`: for top-level channel/group posts, start a thread under that post and route the - conversation to a thread-scoped session. +- `first`: for top-level channel/group posts, start a thread under that post and route the conversation to a thread-scoped session. - `all`: same behavior as `first` for Mattermost today. - Direct messages ignore this setting and stay non-threaded. @@ -161,8 +181,7 @@ Config example: Notes: - Thread-scoped sessions use the triggering post id as the thread root. -- `first` and `all` are currently equivalent because once Mattermost has a thread root, - follow-up chunks and media continue in that same thread. +- `first` and `all` are currently equivalent because once Mattermost has a thread root, follow-up chunks and media continue in that same thread. ## Access control (DMs) @@ -176,8 +195,7 @@ Notes: - Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated). - Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs recommended). -- Per-channel mention overrides live under `channels.mattermost.groups..requireMention` - or `channels.mattermost.groups["*"].requireMention` for a default. +- Per-channel mention overrides live under `channels.mattermost.groups..requireMention` or `channels.mattermost.groups["*"].requireMention` for a default. - `@username` matching is mutable and only enabled when `channels.mattermost.dangerouslyAllowNameMatching: true`. - Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated). - Runtime note: if `channels.mattermost` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). @@ -206,6 +224,7 @@ Use these target formats with `openclaw message send` or cron/webhooks: - `user:` for a DM - `@username` for a DM (resolved via the Mattermost API) + Bare opaque IDs (like `64ifufp...`) are **ambiguous** in Mattermost (user ID vs channel ID). OpenClaw resolves them **user-first**: @@ -214,14 +233,13 @@ OpenClaw resolves them **user-first**: - Otherwise the ID is treated as a **channel ID**. If you need deterministic behavior, always use the explicit prefixes (`user:` / `channel:`). + ## DM channel retry -When OpenClaw sends to a Mattermost DM target and needs to resolve the direct channel first, it -retries transient direct-channel creation failures by default. +When OpenClaw sends to a Mattermost DM target and needs to resolve the direct channel first, it retries transient direct-channel creation failures by default. -Use `channels.mattermost.dmChannelRetry` to tune that behavior globally for the Mattermost plugin, -or `channels.mattermost.accounts..dmChannelRetry` for one account. +Use `channels.mattermost.dmChannelRetry` to tune that behavior globally for the Mattermost plugin, or `channels.mattermost.accounts..dmChannelRetry` for one account. ```json5 { @@ -260,15 +278,19 @@ Enable via `channels.mattermost.streaming`: } ``` -Notes: - -- `partial` is the usual choice: one preview post that is edited as the reply grows, then finalized with the complete answer. -- `block` uses append-style draft chunks inside the preview post. -- `progress` shows a status preview while generating and only posts the final answer at completion. -- `off` disables preview streaming. -- If the stream cannot be finalized in place (for example the post was deleted mid-stream), OpenClaw falls back to sending a fresh final post so the reply is never lost. -- Reasoning-only payloads are suppressed from channel posts, including text that arrives as a `> Reasoning:` blockquote. Set `/reasoning on` to see thinking in other surfaces; the Mattermost final post keeps the answer only. -- See [Streaming](/concepts/streaming#preview-streaming-modes) for the channel-mapping matrix. + + + - `partial` is the usual choice: one preview post that is edited as the reply grows, then finalized with the complete answer. + - `block` uses append-style draft chunks inside the preview post. + - `progress` shows a status preview while generating and only posts the final answer at completion. + - `off` disables preview streaming. + + + - If the stream cannot be finalized in place (for example the post was deleted mid-stream), OpenClaw falls back to sending a fresh final post so the reply is never lost. + - Reasoning-only payloads are suppressed from channel posts, including text that arrives as a `> Reasoning:` blockquote. Set `/reasoning on` to see thinking in other surfaces; the Mattermost final post keeps the answer only. + - See [Streaming](/concepts/streaming#preview-streaming-modes) for the channel-mapping matrix. + + ## Reactions (message tool) @@ -292,8 +314,7 @@ Config: ## Interactive buttons (message tool) -Send messages with clickable buttons. When a user clicks a button, the agent receives the -selection and can respond. +Send messages with clickable buttons. When a user clicks a button, the agent receives the selection and can respond. Enable buttons by adding `inlineButtons` to the channel capabilities: @@ -315,44 +336,46 @@ message action=send channel=mattermost target=channel: buttons=[[{"te Button fields: -- `text` (required): display label. -- `callback_data` (required): value sent back on click (used as the action ID). -- `style` (optional): `"default"`, `"primary"`, or `"danger"`. + + Display label. + + + Value sent back on click (used as the action ID). + + + Button style. + When a user clicks a button: -1. All buttons are replaced with a confirmation line (e.g., "✓ **Yes** selected by @user"). -2. The agent receives the selection as an inbound message and responds. + + + All buttons are replaced with a confirmation line (e.g., "✓ **Yes** selected by @user"). + + + The agent receives the selection as an inbound message and responds. + + -Notes: - -- Button callbacks use HMAC-SHA256 verification (automatic, no config needed). -- Mattermost strips callback data from its API responses (security feature), so all buttons - are removed on click — partial removal is not possible. -- Action IDs containing hyphens or underscores are sanitized automatically - (Mattermost routing limitation). - -Config: - -- `channels.mattermost.capabilities`: array of capability strings. Add `"inlineButtons"` to - enable the buttons tool description in the agent system prompt. -- `channels.mattermost.interactions.callbackBaseUrl`: optional external base URL for button - callbacks (for example `https://gateway.example.com`). Use this when Mattermost cannot - reach the gateway at its bind host directly. -- In multi-account setups, you can also set the same field under - `channels.mattermost.accounts..interactions.callbackBaseUrl`. -- If `interactions.callbackBaseUrl` is omitted, OpenClaw derives the callback URL from - `gateway.customBindHost` + `gateway.port`, then falls back to `http://localhost:`. -- Reachability rule: the button callback URL must be reachable from the Mattermost server. - `localhost` only works when Mattermost and OpenClaw run on the same host/network namespace. -- If your callback target is private/tailnet/internal, add its host/domain to Mattermost - `ServiceSettings.AllowedUntrustedInternalConnections`. + + + - Button callbacks use HMAC-SHA256 verification (automatic, no config needed). + - Mattermost strips callback data from its API responses (security feature), so all buttons are removed on click — partial removal is not possible. + - Action IDs containing hyphens or underscores are sanitized automatically (Mattermost routing limitation). + + + - `channels.mattermost.capabilities`: array of capability strings. Add `"inlineButtons"` to enable the buttons tool description in the agent system prompt. + - `channels.mattermost.interactions.callbackBaseUrl`: optional external base URL for button callbacks (for example `https://gateway.example.com`). Use this when Mattermost cannot reach the gateway at its bind host directly. + - In multi-account setups, you can also set the same field under `channels.mattermost.accounts..interactions.callbackBaseUrl`. + - If `interactions.callbackBaseUrl` is omitted, OpenClaw derives the callback URL from `gateway.customBindHost` + `gateway.port`, then falls back to `http://localhost:`. + - Reachability rule: the button callback URL must be reachable from the Mattermost server. `localhost` only works when Mattermost and OpenClaw run on the same host/network namespace. + - If your callback target is private/tailnet/internal, add its host/domain to Mattermost `ServiceSettings.AllowedUntrustedInternalConnections`. + + ### Direct API integration (external scripts) -External scripts and webhooks can post buttons directly via the Mattermost REST API -instead of going through the agent's `message` tool. Use `buildButtonAttachments()` from -the plugin when possible; if posting raw JSON, follow these rules: +External scripts and webhooks can post buttons directly via the Mattermost REST API instead of going through the agent's `message` tool. Use `buildButtonAttachments()` from the plugin when possible; if posting raw JSON, follow these rules: **Payload structure:** @@ -386,29 +409,38 @@ the plugin when possible; if posting raw JSON, follow these rules: } ``` -**Critical rules:** + +**Critical rules** 1. Attachments go in `props.attachments`, not top-level `attachments` (silently ignored). 2. Every action needs `type: "button"` — without it, clicks are swallowed silently. 3. Every action needs an `id` field — Mattermost ignores actions without IDs. -4. Action `id` must be **alphanumeric only** (`[a-zA-Z0-9]`). Hyphens and underscores break - Mattermost's server-side action routing (returns 404). Strip them before use. -5. `context.action_id` must match the button's `id` so the confirmation message shows the - button name (e.g., "Approve") instead of a raw ID. +4. Action `id` must be **alphanumeric only** (`[a-zA-Z0-9]`). Hyphens and underscores break Mattermost's server-side action routing (returns 404). Strip them before use. +5. `context.action_id` must match the button's `id` so the confirmation message shows the button name (e.g., "Approve") instead of a raw ID. 6. `context.action_id` is required — the interaction handler returns 400 without it. + -**HMAC token generation:** +**HMAC token generation** -The gateway verifies button clicks with HMAC-SHA256. External scripts must generate tokens -that match the gateway's verification logic: +The gateway verifies button clicks with HMAC-SHA256. External scripts must generate tokens that match the gateway's verification logic: -1. Derive the secret from the bot token: - `HMAC-SHA256(key="openclaw-mattermost-interactions", data=botToken)` -2. Build the context object with all fields **except** `_token`. -3. Serialize with **sorted keys** and **no spaces** (the gateway uses `JSON.stringify` - with sorted keys, which produces compact output). -4. Sign: `HMAC-SHA256(key=secret, data=serializedContext)` -5. Add the resulting hex digest as `_token` in the context. + + + `HMAC-SHA256(key="openclaw-mattermost-interactions", data=botToken)` + + + Build the context object with all fields **except** `_token`. + + + Serialize with **sorted keys** and **no spaces** (the gateway uses `JSON.stringify` with sorted keys, which produces compact output). + + + `HMAC-SHA256(key=secret, data=serializedContext)` + + + Add the resulting hex digest as `_token` in the context. + + Python example: @@ -427,22 +459,18 @@ token = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest() context = {**ctx, "_token": token} ``` -Common HMAC pitfalls: - -- Python's `json.dumps` adds spaces by default (`{"key": "val"}`). Use - `separators=(",", ":")` to match JavaScript's compact output (`{"key":"val"}`). -- Always sign **all** context fields (minus `_token`). The gateway strips `_token` then - signs everything remaining. Signing a subset causes silent verification failure. -- Use `sort_keys=True` — the gateway sorts keys before signing, and Mattermost may - reorder context fields when storing the payload. -- Derive the secret from the bot token (deterministic), not random bytes. The secret - must be the same across the process that creates buttons and the gateway that verifies. + + + - Python's `json.dumps` adds spaces by default (`{"key": "val"}`). Use `separators=(",", ":")` to match JavaScript's compact output (`{"key":"val"}`). + - Always sign **all** context fields (minus `_token`). The gateway strips `_token` then signs everything remaining. Signing a subset causes silent verification failure. + - Use `sort_keys=True` — the gateway sorts keys before signing, and Mattermost may reorder context fields when storing the payload. + - Derive the secret from the bot token (deterministic), not random bytes. The secret must be the same across the process that creates buttons and the gateway that verifies. + + ## Directory adapter -The Mattermost plugin includes a directory adapter that resolves channel and user names -via the Mattermost API. This enables `#channel-name` and `@username` targets in -`openclaw message send` and cron/webhook deliveries. +The Mattermost plugin includes a directory adapter that resolves channel and user names via the Mattermost API. This enables `#channel-name` and `@username` targets in `openclaw message send` and cron/webhook deliveries. No configuration is needed — the adapter uses the bot token from the account config. @@ -465,34 +493,38 @@ Mattermost supports multiple accounts under `channels.mattermost.accounts`: ## Troubleshooting -- No replies in channels: ensure the bot is in the channel and mention it (oncall), use a trigger prefix (onchar), or set `chatmode: "onmessage"`. -- Auth errors: check the bot token, base URL, and whether the account is enabled. -- Multi-account issues: env vars only apply to the `default` account. -- Native slash commands return `Unauthorized: invalid command token.`: OpenClaw - did not accept the callback token. Typical causes: - - slash command registration failed or only partially completed at startup - - the callback is hitting the wrong gateway/account - - Mattermost still has old commands pointing at a previous callback target - - the gateway restarted without reactivating slash commands -- If native slash commands stop working, check logs for - `mattermost: failed to register slash commands` or - `mattermost: native slash commands enabled but no commands could be registered`. -- If `callbackUrl` is omitted and logs warn that the callback resolved to - `http://127.0.0.1:18789/...`, that URL is probably only reachable when - Mattermost runs on the same host/network namespace as OpenClaw. Set an - explicit externally reachable `commands.callbackUrl` instead. -- Buttons appear as white boxes: the agent may be sending malformed button data. Check that each button has both `text` and `callback_data` fields. -- Buttons render but clicks do nothing: verify `AllowedUntrustedInternalConnections` in Mattermost server config includes `127.0.0.1 localhost`, and that `EnablePostActionIntegration` is `true` in ServiceSettings. -- Buttons return 404 on click: the button `id` likely contains hyphens or underscores. Mattermost's action router breaks on non-alphanumeric IDs. Use `[a-zA-Z0-9]` only. -- Gateway logs `invalid _token`: HMAC mismatch. Check that you sign all context fields (not a subset), use sorted keys, and use compact JSON (no spaces). See the HMAC section above. -- Gateway logs `missing _token in context`: the `_token` field is not in the button's context. Ensure it is included when building the integration payload. -- Confirmation shows raw ID instead of button name: `context.action_id` does not match the button's `id`. Set both to the same sanitized value. -- Agent doesn't know about buttons: add `capabilities: ["inlineButtons"]` to the Mattermost channel config. + + + Ensure the bot is in the channel and mention it (oncall), use a trigger prefix (onchar), or set `chatmode: "onmessage"`. + + + - Check the bot token, base URL, and whether the account is enabled. + - Multi-account issues: env vars only apply to the `default` account. + + + - `Unauthorized: invalid command token.`: OpenClaw did not accept the callback token. Typical causes: + - slash command registration failed or only partially completed at startup + - the callback is hitting the wrong gateway/account + - Mattermost still has old commands pointing at a previous callback target + - the gateway restarted without reactivating slash commands + - If native slash commands stop working, check logs for `mattermost: failed to register slash commands` or `mattermost: native slash commands enabled but no commands could be registered`. + - If `callbackUrl` is omitted and logs warn that the callback resolved to `http://127.0.0.1:18789/...`, that URL is probably only reachable when Mattermost runs on the same host/network namespace as OpenClaw. Set an explicit externally reachable `commands.callbackUrl` instead. + + + - Buttons appear as white boxes: the agent may be sending malformed button data. Check that each button has both `text` and `callback_data` fields. + - Buttons render but clicks do nothing: verify `AllowedUntrustedInternalConnections` in Mattermost server config includes `127.0.0.1 localhost`, and that `EnablePostActionIntegration` is `true` in ServiceSettings. + - Buttons return 404 on click: the button `id` likely contains hyphens or underscores. Mattermost's action router breaks on non-alphanumeric IDs. Use `[a-zA-Z0-9]` only. + - Gateway logs `invalid _token`: HMAC mismatch. Check that you sign all context fields (not a subset), use sorted keys, and use compact JSON (no spaces). See the HMAC section above. + - Gateway logs `missing _token in context`: the `_token` field is not in the button's context. Ensure it is included when building the integration payload. + - Confirmation shows raw ID instead of button name: `context.action_id` does not match the button's `id`. Set both to the same sanitized value. + - Agent doesn't know about buttons: add `capabilities: ["inlineButtons"]` to the Mattermost channel config. + + ## 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 7cbe271d081ae4e4c937993f3d9f869dbdd7434b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 08:57:38 +0100 Subject: [PATCH 03/25] fix: keep channel command defaults read-only --- .../plugins/read-only-command-defaults.ts | 86 +++++++++++++++++++ src/channels/plugins/read-only.ts | 84 ++---------------- src/config/commands.ts | 2 +- src/plugins/command-registry-state.ts | 2 +- 4 files changed, 95 insertions(+), 79 deletions(-) create mode 100644 src/channels/plugins/read-only-command-defaults.ts diff --git a/src/channels/plugins/read-only-command-defaults.ts b/src/channels/plugins/read-only-command-defaults.ts new file mode 100644 index 00000000000..abacae4de21 --- /dev/null +++ b/src/channels/plugins/read-only-command-defaults.ts @@ -0,0 +1,86 @@ +import { isBlockedObjectKey } from "../../infra/prototype-keys.js"; +import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; +import { loadPluginManifestRegistryForPluginRegistry } from "../../plugins/plugin-registry.js"; +import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import type { ChannelPlugin } from "./types.plugin.js"; + +const SAFE_MANIFEST_CHANNEL_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/i; + +export type ChannelCommandDefaults = Pick< + NonNullable, + "nativeCommandsAutoEnabled" | "nativeSkillsAutoEnabled" +>; + +type ManifestChannelConfigRecord = NonNullable[string]; + +export function isSafeManifestChannelId(channelId: string): boolean { + return SAFE_MANIFEST_CHANNEL_ID_PATTERN.test(channelId) && !isBlockedObjectKey(channelId); +} + +export function readOwnRecordValue(record: Record, key: string): unknown { + if (isBlockedObjectKey(key) || !Object.prototype.hasOwnProperty.call(record, key)) { + return undefined; + } + return record[key]; +} + +export function normalizeChannelCommandDefaults( + value: ChannelCommandDefaults | undefined, +): ChannelCommandDefaults | undefined { + if (!value) { + return undefined; + } + const nativeCommandsAutoEnabled = + typeof value.nativeCommandsAutoEnabled === "boolean" + ? value.nativeCommandsAutoEnabled + : undefined; + const nativeSkillsAutoEnabled = + typeof value.nativeSkillsAutoEnabled === "boolean" ? value.nativeSkillsAutoEnabled : undefined; + return nativeCommandsAutoEnabled !== undefined || nativeSkillsAutoEnabled !== undefined + ? { + ...(nativeCommandsAutoEnabled !== undefined ? { nativeCommandsAutoEnabled } : {}), + ...(nativeSkillsAutoEnabled !== undefined ? { nativeSkillsAutoEnabled } : {}), + } + : undefined; +} + +export function resolveReadOnlyChannelCommandDefaults( + channelId: string, + options: { + env?: NodeJS.ProcessEnv; + stateDir?: string; + workspaceDir?: string; + } = {}, +): ChannelCommandDefaults | undefined { + const normalizedChannelId = normalizeOptionalString(channelId) ?? ""; + if (!normalizedChannelId || !isSafeManifestChannelId(normalizedChannelId)) { + return undefined; + } + const registry = loadPluginManifestRegistryForPluginRegistry({ + stateDir: options.stateDir, + workspaceDir: options.workspaceDir, + env: options.env ?? process.env, + includeDisabled: true, + }); + for (const record of registry.plugins) { + if (!record.channels.includes(normalizedChannelId)) { + continue; + } + const channelConfigValue = record.channelConfigs + ? readOwnRecordValue(record.channelConfigs as Record, normalizedChannelId) + : undefined; + const channelConfig = + channelConfigValue && + typeof channelConfigValue === "object" && + !Array.isArray(channelConfigValue) + ? (channelConfigValue as ManifestChannelConfigRecord) + : undefined; + const commands = normalizeChannelCommandDefaults( + channelConfig?.commands ?? record.channelCatalogMeta?.commands, + ); + if (commands) { + return commands; + } + } + return undefined; +} diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 35c8375d636..98149b8bc81 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -15,13 +15,17 @@ import type { loadOpenClawPlugins as loadOpenClawPluginsType } from "../../plugi 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"; -import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { sanitizeForLog } from "../../terminal/ansi.js"; import { getBundledChannelSetupPlugin } from "./bundled.js"; +import { + isSafeManifestChannelId, + normalizeChannelCommandDefaults, + readOwnRecordValue, + resolveReadOnlyChannelCommandDefaults, +} from "./read-only-command-defaults.js"; import { listChannelPlugins } from "./registry.js"; import type { ChannelPlugin } from "./types.plugin.js"; -const SAFE_MANIFEST_CHANNEL_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/i; const LOADER_MODULE_CANDIDATES = [ new URL("../../plugins/loader.js", import.meta.url), new URL("../../plugins/loader.ts", import.meta.url), @@ -73,10 +77,6 @@ type ReadOnlyChannelPluginResolution = { missingConfiguredChannelIds: string[]; }; type ManifestChannelConfigRecord = NonNullable[string]; -type ChannelCommandDefaults = Pick< - NonNullable, - "nativeCommandsAutoEnabled" | "nativeSkillsAutoEnabled" ->; function addChannelPlugins( byId: Map, @@ -115,41 +115,10 @@ function rebindChannelScopedString( return value; } -function isSafeManifestChannelId(channelId: string): boolean { - return SAFE_MANIFEST_CHANNEL_ID_PATTERN.test(channelId) && !isBlockedObjectKey(channelId); -} - -function readOwnRecordValue(record: Record, key: string): unknown { - if (isBlockedObjectKey(key) || !Object.prototype.hasOwnProperty.call(record, key)) { - return undefined; - } - return record[key]; -} - function normalizeManifestText(value: string | undefined, fallback: string): string { return sanitizeForLog(value?.trim() || fallback).trim(); } -function normalizeChannelCommandDefaults( - value: ChannelCommandDefaults | undefined, -): ChannelCommandDefaults | undefined { - if (!value) { - return undefined; - } - const nativeCommandsAutoEnabled = - typeof value.nativeCommandsAutoEnabled === "boolean" - ? value.nativeCommandsAutoEnabled - : undefined; - const nativeSkillsAutoEnabled = - typeof value.nativeSkillsAutoEnabled === "boolean" ? value.nativeSkillsAutoEnabled : undefined; - return nativeCommandsAutoEnabled !== undefined || nativeSkillsAutoEnabled !== undefined - ? { - ...(nativeCommandsAutoEnabled !== undefined ? { nativeCommandsAutoEnabled } : {}), - ...(nativeSkillsAutoEnabled !== undefined ? { nativeSkillsAutoEnabled } : {}), - } - : undefined; -} - function rebindChannelConfig( cfg: OpenClawConfig, sourceChannelId: string, @@ -347,46 +316,7 @@ function canUseManifestChannelPlugin(record: PluginManifestRecord, channelId: st return record.channelCatalogMeta?.id === channelId; } -export function resolveReadOnlyChannelCommandDefaults( - channelId: string, - options: { - env?: NodeJS.ProcessEnv; - stateDir?: string; - workspaceDir?: string; - } = {}, -): ChannelCommandDefaults | undefined { - const normalizedChannelId = normalizeOptionalString(channelId) ?? ""; - if (!normalizedChannelId || !isSafeManifestChannelId(normalizedChannelId)) { - return undefined; - } - const registry = loadPluginManifestRegistryForPluginRegistry({ - stateDir: options.stateDir, - workspaceDir: options.workspaceDir, - env: options.env ?? process.env, - includeDisabled: true, - }); - for (const record of registry.plugins) { - if (!record.channels.includes(normalizedChannelId)) { - continue; - } - const channelConfigValue = record.channelConfigs - ? readOwnRecordValue(record.channelConfigs as Record, normalizedChannelId) - : undefined; - const channelConfig = - channelConfigValue && - typeof channelConfigValue === "object" && - !Array.isArray(channelConfigValue) - ? (channelConfigValue as ManifestChannelConfigRecord) - : undefined; - const commands = normalizeChannelCommandDefaults( - channelConfig?.commands ?? record.channelCatalogMeta?.commands, - ); - if (commands) { - return commands; - } - } - return undefined; -} +export { resolveReadOnlyChannelCommandDefaults }; function rebindChannelPluginConfig( config: ChannelPlugin["config"], diff --git a/src/config/commands.ts b/src/config/commands.ts index e91ab650be6..14a7b089f4b 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -1,5 +1,5 @@ import { getLoadedChannelPlugin, normalizeChannelId } 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 { ChannelId } from "../channels/plugins/types.public.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import type { NativeCommandsSetting } from "./types.js"; diff --git a/src/plugins/command-registry-state.ts b/src/plugins/command-registry-state.ts index 5cf85408b5b..0a783974d27 100644 --- a/src/plugins/command-registry-state.ts +++ b/src/plugins/command-registry-state.ts @@ -1,5 +1,5 @@ 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 { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import type { OpenClawPluginCommandDefinition } from "./types.js"; From 606a7dbc757ff7833764ab4e0512f3db51435435 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 08:57:40 +0100 Subject: [PATCH 04/25] test: stabilize telegram command pagination retry --- extensions/telegram/src/bot.create-telegram-bot.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 7545e9223c3..cd7fe5a8ddf 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -3369,9 +3369,9 @@ describe("createTelegramBot", () => { it("retries command pagination callbacks after a bubbled preflight failure", async () => { const listSkillCommandsMock = listSkillCommandsForAgents as unknown as ReturnType; - listSkillCommandsMock.mockClear(); createTelegramBot({ token: "tok" }); + listSkillCommandsMock.mockClear(); const callbackHandler = getOnHandler("callback_query"); const middlewares = middlewareUseSpy.mock.calls .map((call) => call[0]) From 626313a3973cf34263b4c77e2a907ee892bc7842 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 08:57:42 +0100 Subject: [PATCH 05/25] fix: satisfy diagnostic trace lint --- .../pi-embedded-runner/run/attempt.model-diagnostic-events.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/pi-embedded-runner/run/attempt.model-diagnostic-events.ts b/src/agents/pi-embedded-runner/run/attempt.model-diagnostic-events.ts index 54fba3bc916..a85aa64489d 100644 --- a/src/agents/pi-embedded-runner/run/attempt.model-diagnostic-events.ts +++ b/src/agents/pi-embedded-runner/run/attempt.model-diagnostic-events.ts @@ -218,7 +218,7 @@ function withDiagnosticTraceparentHeader( } headers[TRACEPARENT_HEADER_NAME] = traceparent; return { - ...(options ?? {}), + ...options, headers, }; } From 6360e1146ff1b367842ebe9650cefabe9e8aa55c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 00:58:16 -0700 Subject: [PATCH 06/25] docs(media-understanding): rewrite with Steps for behavior and auto-detect, Tabs for config examples and entries, ParamField for attachments --- docs/nodes/media-understanding.md | 568 ++++++++++++++++-------------- 1 file changed, 294 insertions(+), 274 deletions(-) diff --git a/docs/nodes/media-understanding.md b/docs/nodes/media-understanding.md index d55eb354b3a..af4a0fd0488 100644 --- a/docs/nodes/media-understanding.md +++ b/docs/nodes/media-understanding.md @@ -4,51 +4,65 @@ read_when: - Designing or refactoring media understanding - Tuning inbound audio/video/image preprocessing title: "Media understanding" +sidebarTitle: "Media understanding" --- -# Media Understanding - Inbound (2026-01-17) +OpenClaw can **summarize inbound media** (image/audio/video) before the reply pipeline runs. It auto-detects when local tools or provider keys are available, and can be disabled or customized. If understanding is off, models still receive the original files/URLs as usual. -OpenClaw can **summarize inbound media** (image/audio/video) before the reply pipeline runs. It auto‑detects when local tools or provider keys are available, and can be disabled or customized. If understanding is off, models still receive the original files/URLs as usual. - -Vendor-specific media behavior is registered by vendor plugins, while OpenClaw -core owns the shared `tools.media` config, fallback order, and reply-pipeline -integration. +Vendor-specific media behavior is registered by vendor plugins, while OpenClaw core owns the shared `tools.media` config, fallback order, and reply-pipeline integration. ## Goals -- Optional: pre‑digest inbound media into short text for faster routing + better command parsing. +- Optional: pre-digest inbound media into short text for faster routing + better command parsing. - Preserve original media delivery to the model (always). - Support **provider APIs** and **CLI fallbacks**. - Allow multiple models with ordered fallback (error/size/timeout). ## High-level behavior -1. Collect inbound attachments (`MediaPaths`, `MediaUrls`, `MediaTypes`). -2. For each enabled capability (image/audio/video), select attachments per policy (default: **first**). -3. Choose the first eligible model entry (size + capability + auth). -4. If a model fails or the media is too large, **fall back to the next entry**. -5. On success: - - `Body` becomes `[Image]`, `[Audio]`, or `[Video]` block. - - Audio sets `{{Transcript}}`; command parsing uses caption text when present, - otherwise the transcript. - - Captions are preserved as `User text:` inside the block. + + + Collect inbound attachments (`MediaPaths`, `MediaUrls`, `MediaTypes`). + + + For each enabled capability (image/audio/video), select attachments per policy (default: **first**). + + + Choose the first eligible model entry (size + capability + auth). + + + If a model fails or the media is too large, **fall back to the next entry**. + + + On success: + + - `Body` becomes `[Image]`, `[Audio]`, or `[Video]` block. + - Audio sets `{{Transcript}}`; command parsing uses caption text when present, otherwise the transcript. + - Captions are preserved as `User text:` inside the block. + + + If understanding fails or is disabled, **the reply flow continues** with the original body + attachments. ## Config overview -`tools.media` supports **shared models** plus per‑capability overrides: +`tools.media` supports **shared models** plus per-capability overrides: -- `tools.media.models`: shared model list (use `capabilities` to gate). -- `tools.media.image` / `tools.media.audio` / `tools.media.video`: - - defaults (`prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`) - - provider overrides (`baseUrl`, `headers`, `providerOptions`) - - Deepgram audio options via `tools.media.audio.providerOptions.deepgram` - - audio transcript echo controls (`echoTranscript`, default `false`; `echoFormat`) - - optional **per‑capability `models` list** (preferred before shared models) - - `attachments` policy (`mode`, `maxAttachments`, `prefer`) - - `scope` (optional gating by channel/chatType/session key) -- `tools.media.concurrency`: max concurrent capability runs (default **2**). + + + - `tools.media.models`: shared model list (use `capabilities` to gate). + - `tools.media.image` / `tools.media.audio` / `tools.media.video`: + - defaults (`prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`) + - provider overrides (`baseUrl`, `headers`, `providerOptions`) + - Deepgram audio options via `tools.media.audio.providerOptions.deepgram` + - audio transcript echo controls (`echoTranscript`, default `false`; `echoFormat`) + - optional **per-capability `models` list** (preferred before shared models) + - `attachments` policy (`mode`, `maxAttachments`, `prefer`) + - `scope` (optional gating by channel/chatType/session key) + - `tools.media.concurrency`: max concurrent capability runs (default **2**). + + ```json5 { @@ -77,99 +91,110 @@ If understanding fails or is disabled, **the reply flow continues** with the ori Each `models[]` entry can be **provider** or **CLI**: -```json5 -{ - type: "provider", // default if omitted - provider: "openai", - model: "gpt-5.5", - prompt: "Describe the image in <= 500 chars.", - maxChars: 500, - maxBytes: 10485760, - timeoutSeconds: 60, - capabilities: ["image"], // optional, used for multi‑modal entries - profile: "vision-profile", - preferredProfile: "vision-fallback", -} -``` + + + ```json5 + { + type: "provider", // default if omitted + provider: "openai", + model: "gpt-5.5", + prompt: "Describe the image in <= 500 chars.", + maxChars: 500, + maxBytes: 10485760, + timeoutSeconds: 60, + capabilities: ["image"], // optional, used for multi-modal entries + profile: "vision-profile", + preferredProfile: "vision-fallback", + } + ``` + + + ```json5 + { + type: "cli", + command: "gemini", + args: [ + "-m", + "gemini-3-flash", + "--allowed-tools", + "read_file", + "Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters.", + ], + maxChars: 500, + maxBytes: 52428800, + timeoutSeconds: 120, + capabilities: ["video", "image"], + } + ``` -```json5 -{ - type: "cli", - command: "gemini", - args: [ - "-m", - "gemini-3-flash", - "--allowed-tools", - "read_file", - "Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters.", - ], - maxChars: 500, - maxBytes: 52428800, - timeoutSeconds: 120, - capabilities: ["video", "image"], -} -``` + CLI templates can also use: -CLI templates can also use: + - `{{MediaDir}}` (directory containing the media file) + - `{{OutputDir}}` (scratch dir created for this run) + - `{{OutputBase}}` (scratch file base path, no extension) -- `{{MediaDir}}` (directory containing the media file) -- `{{OutputDir}}` (scratch dir created for this run) -- `{{OutputBase}}` (scratch file base path, no extension) + + ## Defaults and limits Recommended defaults: -- `maxChars`: **500** for image/video (short, command‑friendly) +- `maxChars`: **500** for image/video (short, command-friendly) - `maxChars`: **unset** for audio (full transcript unless you set a limit) - `maxBytes`: - image: **10MB** - audio: **20MB** - video: **50MB** -Rules: - -- If media exceeds `maxBytes`, that model is skipped and the **next model is tried**. -- Audio files smaller than **1024 bytes** are treated as empty/corrupt and skipped before provider/CLI transcription; inbound reply context receives a deterministic placeholder transcript so the agent knows the note was too small. -- If the model returns more than `maxChars`, output is trimmed. -- `prompt` defaults to simple “Describe the {media}.” plus the `maxChars` guidance (image/video only). -- If the active primary image model already supports vision natively, OpenClaw - skips the `[Image]` summary block and passes the original image into the - model instead. -- If a Gateway/WebChat primary model is text-only, image attachments are - preserved as offloaded `media://inbound/*` refs so the image/PDF tools or - configured image model can still inspect them instead of losing the attachment. -- Explicit `openclaw infer image describe --model ` requests - are different: they run that image-capable provider/model directly, including - Ollama refs such as `ollama/qwen2.5vl:7b`. -- If `.enabled: true` but no models are configured, OpenClaw tries the - **active reply model** when its provider supports the capability. + + + - If media exceeds `maxBytes`, that model is skipped and the **next model is tried**. + - Audio files smaller than **1024 bytes** are treated as empty/corrupt and skipped before provider/CLI transcription; inbound reply context receives a deterministic placeholder transcript so the agent knows the note was too small. + - If the model returns more than `maxChars`, output is trimmed. + - `prompt` defaults to simple "Describe the {media}." plus the `maxChars` guidance (image/video only). + - If the active primary image model already supports vision natively, OpenClaw skips the `[Image]` summary block and passes the original image into the model instead. + - If a Gateway/WebChat primary model is text-only, image attachments are preserved as offloaded `media://inbound/*` refs so the image/PDF tools or configured image model can still inspect them instead of losing the attachment. + - Explicit `openclaw infer image describe --model ` requests are different: they run that image-capable provider/model directly, including Ollama refs such as `ollama/qwen2.5vl:7b`. + - If `.enabled: true` but no models are configured, OpenClaw tries the **active reply model** when its provider supports the capability. + + ### Auto-detect media understanding (default) -If `tools.media..enabled` is **not** set to `false` and you haven’t -configured models, OpenClaw auto-detects in this order and **stops at the first -working option**: +If `tools.media..enabled` is **not** set to `false` and you haven't configured models, OpenClaw auto-detects in this order and **stops at the first working option**: -1. **Active reply model** when its provider supports the capability. -2. **`agents.defaults.imageModel`** primary/fallback refs (image only). -3. **Local CLIs** (audio only; if installed) - - `sherpa-onnx-offline` (requires `SHERPA_ONNX_MODEL_DIR` with encoder/decoder/joiner/tokens) - - `whisper-cli` (`whisper-cpp`; uses `WHISPER_CPP_MODEL` or the bundled tiny model) - - `whisper` (Python CLI; downloads models automatically) -4. **Gemini CLI** (`gemini`) using `read_many_files` -5. **Provider auth** - - Configured `models.providers.*` entries that support the capability are - tried before the bundled fallback order. - - Image-only config providers with an image-capable model auto-register for - media understanding even when they are not a bundled vendor plugin. - - Ollama image understanding is available when selected explicitly, for - example through `agents.defaults.imageModel` or - `openclaw infer image describe --model ollama/`. - - Bundled fallback order: - - Audio: OpenAI → Groq → xAI → Deepgram → Google → SenseAudio → ElevenLabs → Mistral - - Image: OpenAI → Anthropic → Google → MiniMax → MiniMax Portal → Z.AI - - Video: Google → Qwen → Moonshot + + + Active reply model when its provider supports the capability. + + + `agents.defaults.imageModel` primary/fallback refs (image only). + + + Local CLIs (if installed): + + - `sherpa-onnx-offline` (requires `SHERPA_ONNX_MODEL_DIR` with encoder/decoder/joiner/tokens) + - `whisper-cli` (`whisper-cpp`; uses `WHISPER_CPP_MODEL` or the bundled tiny model) + - `whisper` (Python CLI; downloads models automatically) + + + + `gemini` using `read_many_files`. + + + - Configured `models.providers.*` entries that support the capability are tried before the bundled fallback order. + - Image-only config providers with an image-capable model auto-register for media understanding even when they are not a bundled vendor plugin. + - Ollama image understanding is available when selected explicitly, for example through `agents.defaults.imageModel` or `openclaw infer image describe --model ollama/`. + + Bundled fallback order: + + - Audio: OpenAI → Groq → xAI → Deepgram → Google → SenseAudio → ElevenLabs → Mistral + - Image: OpenAI → Anthropic → Google → MiniMax → MiniMax Portal → Z.AI + - Video: Google → Qwen → Moonshot + + + To disable auto-detection, set: @@ -185,26 +210,24 @@ To disable auto-detection, set: } ``` -Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI is on `PATH` (we expand `~`), or set an explicit CLI model with a full command path. + +Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI is on `PATH` (we expand `~`), or set an explicit CLI model with a full command path. + ### Proxy environment support (provider models) -When provider-based **audio** and **video** media understanding is enabled, OpenClaw -honors standard outbound proxy environment variables for provider HTTP calls: +When provider-based **audio** and **video** media understanding is enabled, OpenClaw honors standard outbound proxy environment variables for provider HTTP calls: - `HTTPS_PROXY` - `HTTP_PROXY` - `https_proxy` - `http_proxy` -If no proxy env vars are set, media understanding uses direct egress. -If the proxy value is malformed, OpenClaw logs a warning and falls back to direct -fetch. +If no proxy env vars are set, media understanding uses direct egress. If the proxy value is malformed, OpenClaw logs a warning and falls back to direct fetch. ## Capabilities (optional) -If you set `capabilities`, the entry only runs for those media types. For shared -lists, OpenClaw can infer defaults: +If you set `capabilities`, the entry only runs for those media types. For shared lists, OpenClaw can infer defaults: - `openai`, `anthropic`, `minimax`: **image** - `minimax-portal`: **image** @@ -217,11 +240,9 @@ lists, OpenClaw can infer defaults: - `groq`: **audio** - `xai`: **audio** - `deepgram`: **audio** -- Any `models.providers..models[]` catalog with an image-capable model: - **image** +- Any `models.providers..models[]` catalog with an image-capable model: **image** -For CLI entries, **set `capabilities` explicitly** to avoid surprising matches. -If you omit `capabilities`, the entry is eligible for the list it appears in. +For CLI entries, **set `capabilities` explicitly** to avoid surprising matches. If you omit `capabilities`, the entry is eligible for the list it appears in. ## Provider support matrix (OpenClaw integrations) @@ -231,12 +252,12 @@ If you omit `capabilities`, the entry is eligible for the list it appears in. | Audio | OpenAI, Groq, xAI, Deepgram, Google, SenseAudio, ElevenLabs, Mistral | Provider transcription (Whisper/Groq/xAI/Deepgram/Gemini/SenseAudio/Scribe/Voxtral). | | Video | Google, Qwen, Moonshot | Provider video understanding via vendor plugins; Qwen video understanding uses the Standard DashScope endpoints. | -MiniMax note: + +**MiniMax note** -- `minimax` and `minimax-portal` image understanding comes from the plugin-owned - `MiniMax-VL-01` media provider. -- The bundled MiniMax text catalog still starts text-only; explicit - `models.providers.minimax` entries materialize image-capable M2.7 chat refs. +- `minimax` and `minimax-portal` image understanding comes from the plugin-owned `MiniMax-VL-01` media provider. +- The bundled MiniMax text catalog still starts text-only; explicit `models.providers.minimax` entries materialize image-capable M2.7 chat refs. + ## Model selection guidance @@ -248,177 +269,176 @@ MiniMax note: ## Attachment policy -Per‑capability `attachments` controls which attachments are processed: +Per-capability `attachments` controls which attachments are processed: -- `mode`: `first` (default) or `all` -- `maxAttachments`: cap the number processed (default **1**) -- `prefer`: `first`, `last`, `path`, `url` + + Whether to process the first selected attachment or all of them. + + + Cap the number processed. + + + Selection preference among candidate attachments. + When `mode: "all"`, outputs are labeled `[Image 1/2]`, `[Audio 2/2]`, etc. -File-attachment extraction behavior: - -- Extracted file text is wrapped as **untrusted external content** before it is - appended to the media prompt. -- The injected block uses explicit boundary markers like - `<<>>` / - `<<>>` and includes a - `Source: External` metadata line. -- This attachment-extraction path intentionally omits the long - `SECURITY NOTICE:` banner to avoid bloating the media prompt; the boundary - markers and metadata still remain. -- If a file has no extractable text, OpenClaw injects `[No extractable text]`. -- If a PDF falls back to rendered page images in this path, the media prompt keeps - the placeholder `[PDF content rendered to images; images not forwarded to model]` - because this attachment-extraction step forwards text blocks, not the rendered PDF images. + + + - Extracted file text is wrapped as **untrusted external content** before it is appended to the media prompt. + - The injected block uses explicit boundary markers like `<<>>` / `<<>>` and includes a `Source: External` metadata line. + - This attachment-extraction path intentionally omits the long `SECURITY NOTICE:` banner to avoid bloating the media prompt; the boundary markers and metadata still remain. + - If a file has no extractable text, OpenClaw injects `[No extractable text]`. + - If a PDF falls back to rendered page images in this path, the media prompt keeps the placeholder `[PDF content rendered to images; images not forwarded to model]` because this attachment-extraction step forwards text blocks, not the rendered PDF images. + + ## Config examples -### 1) Shared models list + overrides - -```json5 -{ - tools: { - media: { - models: [ - { provider: "openai", model: "gpt-5.5", capabilities: ["image"] }, - { - provider: "google", - model: "gemini-3-flash-preview", - capabilities: ["image", "audio", "video"], - }, - { - type: "cli", - command: "gemini", - args: [ - "-m", - "gemini-3-flash", - "--allowed-tools", - "read_file", - "Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters.", + + + ```json5 + { + tools: { + media: { + models: [ + { provider: "openai", model: "gpt-5.5", capabilities: ["image"] }, + { + provider: "google", + model: "gemini-3-flash-preview", + capabilities: ["image", "audio", "video"], + }, + { + type: "cli", + command: "gemini", + args: [ + "-m", + "gemini-3-flash", + "--allowed-tools", + "read_file", + "Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters.", + ], + capabilities: ["image", "video"], + }, ], - capabilities: ["image", "video"], + audio: { + attachments: { mode: "all", maxAttachments: 2 }, + }, + video: { + maxChars: 500, + }, }, - ], - audio: { - attachments: { mode: "all", maxAttachments: 2 }, }, - video: { - maxChars: 500, - }, - }, - }, -} -``` - -### 2) Audio + Video only (image off) - -```json5 -{ - tools: { - media: { - audio: { - enabled: true, - models: [ - { provider: "openai", model: "gpt-4o-mini-transcribe" }, - { - type: "cli", - command: "whisper", - args: ["--model", "base", "{{MediaPath}}"], - }, - ], - }, - video: { - enabled: true, - maxChars: 500, - models: [ - { provider: "google", model: "gemini-3-flash-preview" }, - { - type: "cli", - command: "gemini", - args: [ - "-m", - "gemini-3-flash", - "--allowed-tools", - "read_file", - "Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters.", + } + ``` + + + ```json5 + { + tools: { + media: { + audio: { + enabled: true, + models: [ + { provider: "openai", model: "gpt-4o-mini-transcribe" }, + { + type: "cli", + command: "whisper", + args: ["--model", "base", "{{MediaPath}}"], + }, ], }, - ], - }, - }, - }, -} -``` - -### 3) Optional image understanding - -```json5 -{ - tools: { - media: { - image: { - enabled: true, - maxBytes: 10485760, - maxChars: 500, - models: [ - { provider: "openai", model: "gpt-5.5" }, - { provider: "anthropic", model: "claude-opus-4-6" }, - { - type: "cli", - command: "gemini", - args: [ - "-m", - "gemini-3-flash", - "--allowed-tools", - "read_file", - "Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters.", + video: { + enabled: true, + maxChars: 500, + models: [ + { provider: "google", model: "gemini-3-flash-preview" }, + { + type: "cli", + command: "gemini", + args: [ + "-m", + "gemini-3-flash", + "--allowed-tools", + "read_file", + "Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters.", + ], + }, ], }, - ], + }, }, - }, - }, -} -``` - -### 4) Multi-modal single entry (explicit capabilities) - -```json5 -{ - tools: { - media: { - image: { - models: [ - { - provider: "google", - model: "gemini-3.1-pro-preview", - capabilities: ["image", "video", "audio"], + } + ``` + + + ```json5 + { + tools: { + media: { + image: { + enabled: true, + maxBytes: 10485760, + maxChars: 500, + models: [ + { provider: "openai", model: "gpt-5.5" }, + { provider: "anthropic", model: "claude-opus-4-6" }, + { + type: "cli", + command: "gemini", + args: [ + "-m", + "gemini-3-flash", + "--allowed-tools", + "read_file", + "Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters.", + ], + }, + ], }, - ], + }, }, - audio: { - models: [ - { - provider: "google", - model: "gemini-3.1-pro-preview", - capabilities: ["image", "video", "audio"], + } + ``` + + + ```json5 + { + tools: { + media: { + image: { + models: [ + { + provider: "google", + model: "gemini-3.1-pro-preview", + capabilities: ["image", "video", "audio"], + }, + ], }, - ], - }, - video: { - models: [ - { - provider: "google", - model: "gemini-3.1-pro-preview", - capabilities: ["image", "video", "audio"], + audio: { + models: [ + { + provider: "google", + model: "gemini-3.1-pro-preview", + capabilities: ["image", "video", "audio"], + }, + ], }, - ], + video: { + models: [ + { + provider: "google", + model: "gemini-3.1-pro-preview", + capabilities: ["image", "video", "audio"], + }, + ], + }, + }, }, - }, - }, -} -``` + } + ``` + + ## Status output @@ -428,15 +448,15 @@ When media understanding runs, `/status` includes a short summary line: 📎 Media: image ok (openai/gpt-5.4) · audio skipped (maxBytes) ``` -This shows per‑capability outcomes and the chosen provider/model when applicable. +This shows per-capability outcomes and the chosen provider/model when applicable. ## Notes -- Understanding is **best‑effort**. Errors do not block replies. +- Understanding is **best-effort**. Errors do not block replies. - Attachments are still passed to models even when understanding is disabled. - Use `scope` to limit where understanding runs (e.g. only DMs). -## Related docs +## Related - [Configuration](/gateway/configuration) -- [Image & Media Support](/nodes/images) +- [Image & media support](/nodes/images) From 878e1a2201acd22e41bf2e6227d56a66311ddad6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 08:58:52 +0100 Subject: [PATCH 07/25] fix(plugins): preload cli backend runtime owners --- CHANGELOG.md | 5 +- docs/plugins/manifest.md | 20 ++++-- src/config/plugin-auto-enable.core.test.ts | 41 +++++++++-- src/config/plugin-auto-enable.shared.ts | 4 +- src/config/plugin-auto-enable.test-helpers.ts | 3 +- src/plugins/channel-plugin-ids.test.ts | 70 +++++++++++++++++++ src/plugins/installed-plugin-index.ts | 5 +- 7 files changed, 126 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 862d9556f9c..c848866dafb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,10 +82,7 @@ Docs: https://docs.openclaw.ai - 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. -- Agents/runtime: add `agentRuntime.id` as the canonical config key, migrate - legacy runtime-policy configs with `openclaw doctor --fix`, and route - canonical Anthropic models through `claude-cli` without passing CLI backend - aliases to embedded harness selection. Fixes #71957. Thanks @WolvenRA. +- Agents/runtime: add `agentRuntime.id` as the canonical config key, migrate legacy runtime-policy configs with `openclaw doctor --fix`, route canonical Anthropic models through `claude-cli` without passing CLI backend aliases to embedded harness selection, and load CLI backend owner plugins before channel startup. Fixes #71957. Thanks @WolvenRA. - CLI/update: guard Windows scheduled-task stops by state and timeout so auto-update restart cannot hang indefinitely on `schtasks /End` before stale-listener cleanup. Fixes #69970. Thanks @yangswld and @sherlock-huang. - Windows install/Lobster: execute `pnpm.exe` directly when `npm_execpath` points at the native pnpm binary, add an installed-package fallback for the Lobster embedded runtime, and include the Lobster runner regression test in Windows CI. Fixes #69456. Thanks @igormf. - Gateway/install: refresh loaded gateway service installs when the current service embeds stale gateway auth instead of returning already-installed, avoiding LaunchAgent token-mismatch loops after token rotation. Fixes #70752. Thanks @hyspacex. diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index dfb9c1add81..481c3896fe8 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -231,6 +231,9 @@ Prefer the narrowest metadata that already describes ownership. Use `providers`, `channels`, `commandAliases`, setup descriptors, or `contracts` when those fields express the relationship. Use `activation` for extra planner hints that cannot be represented by those ownership fields. +Use top-level `cliBackends` for CLI runtime aliases such as `claude-cli`, +`codex-cli`, or `google-gemini-cli`; `activation.onAgentHarnesses` is only for +embedded agent harness ids that do not already have an ownership field. This block is metadata only. It does not register runtime behavior, and it does not replace `register(...)`, `setupEntry`, or other runtime/plugin entrypoints. @@ -250,18 +253,21 @@ change correctness while legacy manifest ownership fallbacks still exist. } ``` -| Field | Required | Type | What it means | -| ---------------- | -------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `onProviders` | No | `string[]` | Provider ids that should include this plugin in activation/load plans. | -| `onCommands` | No | `string[]` | Command ids that should include this plugin in activation/load plans. | -| `onChannels` | No | `string[]` | Channel ids that should include this plugin in activation/load plans. | -| `onRoutes` | No | `string[]` | Route kinds that should include this plugin in activation/load plans. | -| `onCapabilities` | No | `Array<"provider" \| "channel" \| "tool" \| "hook">` | Broad capability hints used by control-plane activation planning. Prefer narrower fields when possible. | +| Field | Required | Type | What it means | +| ------------------ | -------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `onProviders` | No | `string[]` | Provider ids that should include this plugin in activation/load plans. | +| `onAgentHarnesses` | No | `string[]` | Embedded agent harness runtime ids that should include this plugin in activation/load plans. Use top-level `cliBackends` for CLI backend aliases. | +| `onCommands` | No | `string[]` | Command ids that should include this plugin in activation/load plans. | +| `onChannels` | No | `string[]` | Channel ids that should include this plugin in activation/load plans. | +| `onRoutes` | No | `string[]` | Route kinds that should include this plugin in activation/load plans. | +| `onCapabilities` | No | `Array<"provider" \| "channel" \| "tool" \| "hook">` | Broad capability hints used by control-plane activation planning. Prefer narrower fields when possible. | Current live consumers: - command-triggered CLI planning falls back to legacy `commandAliases[].cliCommand` or `commandAliases[].name` +- agent-runtime startup planning uses `activation.onAgentHarnesses` for + embedded harnesses and top-level `cliBackends[]` for CLI runtime aliases - channel-triggered setup/channel planning falls back to legacy `channels[]` ownership when explicit channel activation metadata is missing - provider-triggered setup/runtime planning falls back to legacy diff --git a/src/config/plugin-auto-enable.core.test.ts b/src/config/plugin-auto-enable.core.test.ts index 0e84eb0a6e5..e0a9e031c1c 100644 --- a/src/config/plugin-auto-enable.core.test.ts +++ b/src/config/plugin-auto-enable.core.test.ts @@ -312,7 +312,7 @@ describe("applyPluginAutoEnable core", () => { expect(result.config.plugins?.entries?.codex?.enabled).toBe(true); expect(result.changes).toEqual([ "openai/gpt-5.5 model configured, enabled automatically.", - "codex agent harness runtime configured, enabled automatically.", + "codex agent runtime configured, enabled automatically.", ]); }); @@ -341,9 +341,38 @@ describe("applyPluginAutoEnable core", () => { }); expect(result.config.plugins?.entries?.codex?.enabled).toBe(true); - expect(result.changes).toContain( - "codex agent harness runtime configured, enabled automatically.", - ); + expect(result.changes).toContain("codex agent runtime configured, enabled automatically."); + }); + + it("auto-enables a CLI backend owner when an agent runtime is configured", () => { + const result = applyPluginAutoEnable({ + config: { + agents: { + defaults: { + agentRuntime: { + id: "claude-cli", + fallback: "none", + }, + }, + }, + plugins: { + allow: ["telegram"], + }, + }, + env, + manifestRegistry: makeRegistry([ + { + id: "anthropic", + channels: [], + providers: ["anthropic"], + cliBackends: ["claude-cli"], + }, + ]), + }); + + expect(result.config.plugins?.entries?.anthropic?.enabled).toBe(true); + expect(result.config.plugins?.allow).toEqual(["telegram", "anthropic"]); + expect(result.changes).toContain("claude-cli agent runtime configured, enabled automatically."); }); it("auto-enables an opt-in plugin when an agent harness runtime is forced by env", () => { @@ -362,9 +391,7 @@ describe("applyPluginAutoEnable core", () => { }); expect(result.config.plugins?.entries?.codex?.enabled).toBe(true); - expect(result.changes).toContain( - "codex agent harness runtime configured, enabled automatically.", - ); + expect(result.changes).toContain("codex agent runtime configured, enabled automatically."); }); it("skips auto-enable work for configs without channel or plugin-owned surfaces", () => { diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index bc1a0cf338f..4558515f079 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -113,7 +113,7 @@ function resolveAgentHarnessOwnerPluginIds( } return registry.plugins .filter((plugin) => - (plugin.activation?.onAgentHarnesses ?? []).some( + [...(plugin.activation?.onAgentHarnesses ?? []), ...(plugin.cliBackends ?? [])].some( (entry) => normalizeOptionalLowercaseString(entry) === normalizedRuntime, ), ) @@ -476,7 +476,7 @@ export function resolvePluginAutoEnableCandidateReason( case "provider-model-configured": return `${candidate.modelRef} model configured`; case "agent-harness-runtime-configured": - return `${candidate.runtime} agent harness runtime configured`; + return `${candidate.runtime} agent runtime configured`; case "web-fetch-provider-selected": return `${candidate.providerId} web fetch provider selected`; case "plugin-web-search-configured": diff --git a/src/config/plugin-auto-enable.test-helpers.ts b/src/config/plugin-auto-enable.test-helpers.ts index d0e5684596f..5f2ba70781e 100644 --- a/src/config/plugin-auto-enable.test-helpers.ts +++ b/src/config/plugin-auto-enable.test-helpers.ts @@ -64,6 +64,7 @@ export function makeRegistry( modelSupport?: { modelPrefixes?: string[]; modelPatterns?: string[] }; contracts?: { webSearchProviders?: string[]; webFetchProviders?: string[]; tools?: string[] }; providers?: string[]; + cliBackends?: string[]; configSchema?: Record; channelConfigs?: Record; preferOver?: string[] }>; }>, @@ -79,7 +80,7 @@ export function makeRegistry( configSchema: plugin.configSchema, channelConfigs: plugin.channelConfigs, providers: plugin.providers ?? [], - cliBackends: [], + cliBackends: plugin.cliBackends ?? [], skills: [], hooks: [], origin: "config" as const, diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index 2c884662b54..8993fad34be 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -96,6 +96,30 @@ function createManifestRegistryFixture() { providers: ["demo-provider"], cliBackends: ["demo-cli"], }, + { + id: "anthropic", + channels: [], + origin: "bundled", + enabledByDefault: true, + providers: ["anthropic"], + cliBackends: ["claude-cli"], + }, + { + id: "openai", + channels: [], + origin: "bundled", + enabledByDefault: true, + providers: ["openai", "openai-codex"], + cliBackends: ["codex-cli"], + }, + { + id: "google", + channels: [], + origin: "bundled", + enabledByDefault: true, + providers: ["google", "google-gemini-cli"], + cliBackends: ["google-gemini-cli"], + }, { id: "codex", channels: [], @@ -672,6 +696,52 @@ describe("resolveGatewayStartupPluginIds", () => { }); }); + it("includes required CLI backend owner plugins when the default runtime is forced", () => { + expectStartupPluginIdsCase({ + config: createStartupConfig({ + agentRuntimeId: "demo-cli", + enabledPluginIds: ["demo-provider-plugin"], + }), + expected: ["demo-channel", "browser", "demo-provider-plugin"], + }); + }); + + it.each([ + ["claude-cli", "anthropic"], + ["codex-cli", "openai"], + ["google-gemini-cli", "google"], + ] as const)("includes the bundled %s CLI backend owner at startup", (runtime, pluginId) => { + expectStartupPluginIdsCase({ + config: createStartupConfig({ + agentRuntimeId: runtime, + }), + expected: ["demo-channel", "browser", pluginId], + }); + }); + + it("does not include required CLI backend owner plugins when they are explicitly disabled", () => { + expectStartupPluginIdsCase({ + config: { + agents: { + defaults: { + agentRuntime: { + id: "demo-cli", + fallback: "none", + }, + }, + }, + plugins: { + entries: { + "demo-provider-plugin": { + enabled: false, + }, + }, + }, + } as OpenClawConfig, + expected: ["demo-channel", "browser"], + }); + }); + it("does not include required agent harness owner plugins when they are explicitly disabled", () => { expectStartupPluginIdsCase({ config: { diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index ca2e63af6f1..a040375b35e 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -205,7 +205,10 @@ function buildStartupInfo(record: PluginManifestRecord): InstalledPluginStartupI memory: hasKind(record.kind, "memory"), deferConfiguredChannelFullLoadUntilAfterListen: record.startupDeferConfiguredChannelFullLoadUntilAfterListen === true, - agentHarnesses: sortUnique(record.activation?.onAgentHarnesses ?? []), + agentHarnesses: sortUnique([ + ...(record.activation?.onAgentHarnesses ?? []), + ...(record.cliBackends ?? []), + ]), }; } From 164aaa48dbc33e1012c017b2ecd9d6dc81e75d2b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:00:28 +0100 Subject: [PATCH 08/25] style: format gateway imports --- src/gateway/server-methods/voicewake-routing.ts | 2 +- src/gateway/server/ws-connection/message-handler.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/gateway/server-methods/voicewake-routing.ts b/src/gateway/server-methods/voicewake-routing.ts index a5e1e860394..a23a2ca7a6c 100644 --- a/src/gateway/server-methods/voicewake-routing.ts +++ b/src/gateway/server-methods/voicewake-routing.ts @@ -1,4 +1,3 @@ -import type { GatewayRequestHandlers } from "./types.js"; import { loadVoiceWakeRoutingConfig, normalizeVoiceWakeRoutingConfig, @@ -6,6 +5,7 @@ import { validateVoiceWakeRoutingConfigInput, } from "../../infra/voicewake-routing.js"; import { ErrorCodes, errorShape } from "../protocol/index.js"; +import type { GatewayRequestHandlers } from "./types.js"; export const voicewakeRoutingHandlers: GatewayRequestHandlers = { "voicewake.routing.get": async ({ respond }) => { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index a7436fd7ad1..58aac425ae8 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -1,9 +1,6 @@ import type { IncomingMessage } from "node:http"; -import type { WebSocket } from "ws"; import os from "node:os"; -import type { createSubsystemLogger } from "../../../logging/subsystem.js"; -import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js"; -import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js"; +import type { WebSocket } from "ws"; import { loadConfig } from "../../../config/config.js"; import { getBoundDeviceBootstrapProfile, @@ -40,6 +37,7 @@ import { loadVoiceWakeRoutingConfig } from "../../../infra/voicewake-routing.js" import { loadVoiceWakeConfig } from "../../../infra/voicewake.js"; import { rawDataToString } from "../../../infra/ws.js"; import { logRejectedLargePayload } from "../../../logging/diagnostic-payload.js"; +import type { createSubsystemLogger } from "../../../logging/subsystem.js"; import { resolveBootstrapProfileScopesForRole, type DeviceBootstrapProfile, @@ -53,6 +51,7 @@ import { } from "../../../utils/message-channel.js"; import { resolveRuntimeServiceVersion } from "../../../version.js"; import type { AuthRateLimiter } from "../../auth-rate-limit.js"; +import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js"; import { hasForwardedRequestHeaders, isLocalDirectRequest } from "../../auth.js"; import { buildCanvasScopedHostUrl, @@ -101,6 +100,7 @@ import { TICK_INTERVAL_MS, } from "../../server-constants.js"; import { handleGatewayRequest } from "../../server-methods.js"; +import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js"; import { formatError } from "../../server-utils.js"; import { formatForLog, logWs } from "../../ws-log.js"; import { truncateCloseReason } from "../close-reason.js"; From 4823288b3b8ae35a2deb44635f46be2ddc94407c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:00:47 +0100 Subject: [PATCH 09/25] fix(gateway): hide webchat reasoning payloads --- .../server-methods/chat-webchat-media.test.ts | 32 +++++++++++++++++++ .../server-methods/chat-webchat-media.ts | 6 ++++ .../chat.directive-tags.test.ts | 30 +++++++++++++++++ src/gateway/server-methods/chat.ts | 11 ++++++- 4 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/gateway/server-methods/chat-webchat-media.test.ts b/src/gateway/server-methods/chat-webchat-media.test.ts index 67de5784ede..ae41dd9bed2 100644 --- a/src/gateway/server-methods/chat-webchat-media.test.ts +++ b/src/gateway/server-methods/chat-webchat-media.test.ts @@ -43,6 +43,26 @@ describe("buildWebchatAudioContentBlocksFromReplyPayloads", () => { ); }); + it("suppresses reasoning payload audio", async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-webchat-audio-")); + const audioPath = path.join(tmpDir, "clip.mp3"); + fs.writeFileSync(audioPath, Buffer.from([0xff, 0xfb, 0x90, 0x00])); + + const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads( + [ + { + text: "Reasoning:\n_step_", + mediaUrl: audioPath, + trustedLocalMedia: true, + isReasoning: true, + }, + ], + { localRoots: [tmpDir] }, + ); + + expect(blocks).toHaveLength(0); + }); + it("skips remote URLs", async () => { const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads([ { mediaUrl: "https://example.com/a.mp3", trustedLocalMedia: true }, @@ -212,6 +232,18 @@ describe("buildWebchatAssistantMessageFromReplyPayloads", () => { }); }); + it("suppresses reasoning payload media transcripts", async () => { + const message = await buildWebchatAssistantMessageFromReplyPayloads([ + { + text: "Reasoning:\n_step_", + mediaUrl: "data:image/png;base64,cG5n", + isReasoning: true, + }, + ]); + + expect(message).toBeNull(); + }); + it("suppresses control tokens and falls back to synthetic image text", async () => { const message = await buildWebchatAssistantMessageFromReplyPayloads([ { diff --git a/src/gateway/server-methods/chat-webchat-media.ts b/src/gateway/server-methods/chat-webchat-media.ts index 06a88fa8939..e1b76902c36 100644 --- a/src/gateway/server-methods/chat-webchat-media.ts +++ b/src/gateway/server-methods/chat-webchat-media.ts @@ -162,6 +162,9 @@ export async function buildWebchatAudioContentBlocksFromReplyPayloads( const seen = new Set(); const blocks: Array> = []; for (const payload of payloads) { + if (payload.isReasoning === true) { + continue; + } const parts = resolveSendableOutboundReplyParts(payload); for (const raw of parts.mediaUrls) { const url = raw.trim(); @@ -194,6 +197,9 @@ export async function buildWebchatAssistantMessageFromReplyPayloads( let hasImage = false; for (const payload of payloads) { + if (payload.isReasoning === true) { + continue; + } const visibleText = payload.text?.trim(); const text = visibleText && !isSuppressedControlReplyText(visibleText) ? visibleText : undefined; diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 7fd3f213c23..ed78955c645 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -26,6 +26,7 @@ const mockState = vi.hoisted(() => ({ sensitiveMedia?: boolean; replyToId?: string; replyToCurrent?: boolean; + isReasoning?: boolean; } | null, dispatchedReplies: [] as Array<{ kind: "tool" | "block" | "final"; @@ -36,6 +37,7 @@ const mockState = vi.hoisted(() => ({ trustedLocalMedia?: boolean; replyToId?: string; replyToCurrent?: boolean; + isReasoning?: boolean; }; }>, dispatchError: null as Error | null, @@ -114,6 +116,7 @@ vi.mock("../../auto-reply/dispatch.js", () => ({ sensitiveMedia?: boolean; replyToId?: string; replyToCurrent?: boolean; + isReasoning?: boolean; }) => boolean; sendBlockReply: (payload: { text?: string; @@ -122,6 +125,7 @@ vi.mock("../../auto-reply/dispatch.js", () => ({ trustedLocalMedia?: boolean; replyToId?: string; replyToCurrent?: boolean; + isReasoning?: boolean; }) => boolean; sendToolResult: (payload: { text?: string; @@ -130,6 +134,7 @@ vi.mock("../../auto-reply/dispatch.js", () => ({ trustedLocalMedia?: boolean; replyToId?: string; replyToCurrent?: boolean; + isReasoning?: boolean; }) => boolean; markComplete: () => void; waitForIdle: () => Promise; @@ -599,6 +604,31 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(JSON.stringify(payload?.message)).not.toContain("MEDIA:data:image/png;base64,cG5n"); }); + it("suppresses reasoning payloads from webchat transcript replies", async () => { + createTranscriptFixture("openclaw-chat-send-reasoning-hidden-"); + mockState.dispatchedReplies = [ + { + kind: "final", + payload: { text: "Reasoning:\n_step_", isReasoning: true }, + }, + { + kind: "final", + payload: { text: "final answer" }, + }, + ]; + const respond = vi.fn(); + const context = createChatContext(); + + const payload = await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-reasoning-hidden", + }); + + expect(JSON.stringify(payload?.message)).toContain("final answer"); + expect(JSON.stringify(payload?.message)).not.toContain("Reasoning"); + }); + it("chat.inject keeps message defined when directive tag is the only content", async () => { createTranscriptFixture("openclaw-chat-inject-directive-only-"); const respond = vi.fn(); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 1af9302ddd5..4f1d0769b92 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -131,6 +131,9 @@ type ChatAbortRequester = { /** True when a reply payload carries at least one media reference (mediaUrl or mediaUrls). */ function isMediaBearingPayload(payload: ReplyPayload): boolean { + if (payload.isReasoning === true) { + return false; + } if (payload.mediaUrl?.trim()) { return true; } @@ -227,6 +230,9 @@ type SideResultPayload = { function buildTranscriptReplyText(payloads: ReplyPayload[]): string { const chunks = payloads .map((payload) => { + if (payload.isReasoning === true) { + return ""; + } const parts = resolveSendableOutboundReplyParts(payload); const lines: string[] = []; const replyToId = sanitizeReplyDirectiveId(payload.replyToId); @@ -301,7 +307,10 @@ async function buildAssistantDisplayContentFromReplyPayloads(params: { onManagedImagePrepareError?: (message: string) => void; }): Promise { const rawTextPayloadCount = params.payloads.filter( - (payload) => typeof payload.text === "string" && payload.text.trim().length > 0, + (payload) => + payload.isReasoning !== true && + typeof payload.text === "string" && + payload.text.trim().length > 0, ).length; const normalized = normalizeReplyPayloadsForDelivery(params.payloads); if (normalized.length === 0) { From a434133aacb18468ebcaf14cd560426f9c637f3d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:00:58 +0100 Subject: [PATCH 10/25] fix: fail update on plugin sync errors --- CHANGELOG.md | 1 + docs/cli/update.md | 13 +++-- src/cli/update-cli.test.ts | 87 +++++++++++++++++++++++++++- src/cli/update-cli/update-command.ts | 44 +++++++++++--- src/plugins/update.test.ts | 55 ++++++++++++++++++ src/plugins/update.ts | 30 +++++++--- 6 files changed, 207 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c848866dafb..3b5cc9a6bab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,7 @@ Docs: https://docs.openclaw.ai - Windows install/Lobster: execute `pnpm.exe` directly when `npm_execpath` points at the native pnpm binary, add an installed-package fallback for the Lobster embedded runtime, and include the Lobster runner regression test in Windows CI. Fixes #69456. Thanks @igormf. - Gateway/install: refresh loaded gateway service installs when the current service embeds stale gateway auth instead of returning already-installed, avoiding LaunchAgent token-mismatch loops after token rotation. Fixes #70752. Thanks @hyspacex. - Update: ignore bundled plugin `.openclaw-install-stage` directories during global install verification and packaged dist pruning so leftover runtime-dep staging files do not turn successful updates into `unexpected packaged dist file` failures. Fixes #71752. Thanks @waynegault. +- CLI/update: fail package updates when post-update plugin sync fails and refresh legacy npm plugin install records before trusting unchanged artifacts, preventing successful updates from restarting with stale or failed plugin state. Thanks @vincentkoc and @shakkernerd. - Node runtime: keep node-host retry timers alive across Gateway restarts and exit on terminal credential pauses so supervised nodes do not become silent zombies. Fixes #69800. Thanks @meroli28. - Gateway/plugins: stop persisted WhatsApp auth state from activating bundled channel runtime-dependency repair during startup when `channels.whatsapp` is absent, avoiding npm/git stalls on packaged Linux installs. Fixes #71994. Thanks @xiao398008. - Gateway/device tokens: enforce caller-scope containment inside token rotation and revocation so pairing-only sessions cannot mutate higher-scope operator tokens. Fixes #71990. Thanks @coygeek. diff --git a/docs/cli/update.md b/docs/cli/update.md index 1c998ea1d19..e05e4315642 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -83,10 +83,11 @@ install method aligned: The Gateway core auto-updater (when enabled via config) reuses this same update path. For package-manager installs, `openclaw update` resolves the target package -version before invoking the package manager. If the installed version exactly -matches the target and no update-channel change needs to be persisted, the -command exits as skipped before package install, plugin sync, completion refresh, -or gateway restart work. +version before invoking the package manager. Even when the installed version +already matches the target, the command refreshes the global package install, +then runs plugin sync, completion refresh, and restart work. This keeps packaged +sidecars and channel-owned plugin records aligned with the installed OpenClaw +build. ## Git checkout flow @@ -114,6 +115,10 @@ differs from the stored install record, `openclaw update` aborts that plugin artifact update instead of installing it. Reinstall or update the plugin explicitly only after verifying that you trust the new artifact. +Post-update plugin sync failures fail the update result and stop restart +follow-up work. Fix the plugin install/update error, then rerun +`openclaw update`. + If pnpm bootstrap still fails, the updater now stops early with a package-manager-specific error instead of trying `npm run build` inside the checkout. ## `--update` shorthand diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index fe91cb3211f..97887259237 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -667,7 +667,7 @@ describe("update-cli", () => { expect(logs.join("\n")).toContain("Plugin update aborted"); }); - it("includes plugin integrity drift details in update json output", async () => { + it("fails json update output when post-core plugin updates fail", async () => { updateNpmInstalledPlugins.mockImplementationOnce( async (params: { config: OpenClawConfig; @@ -713,6 +713,9 @@ describe("update-cli", () => { const jsonOutput = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0] as | UpdateRunResult | undefined; + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + expect(jsonOutput?.status).toBe("error"); + expect(jsonOutput?.reason).toBe("post-update-plugins"); expect(jsonOutput?.postUpdate?.plugins?.integrityDrifts).toEqual([ { pluginId: "demo", @@ -728,6 +731,88 @@ describe("update-cli", () => { expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]?.status).toBe("error"); }); + it("fails before restart when post-core plugin updates fail", async () => { + updateNpmInstalledPlugins.mockResolvedValueOnce({ + changed: false, + config: baseConfig, + outcomes: [ + { + pluginId: "demo", + status: "error", + message: "Failed to update demo: registry timeout", + }, + ], + }); + serviceLoaded.mockResolvedValue(true); + + await updateCommand({ yes: true }); + + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + expect(runDaemonInstall).not.toHaveBeenCalled(); + expect(runDaemonRestart).not.toHaveBeenCalled(); + expect(runRestartScript).not.toHaveBeenCalled(); + expect( + vi + .mocked(defaultRuntime.error) + .mock.calls.map((call) => String(call[0])) + .join("\n"), + ).toContain("Update failed during plugin post-update sync."); + }); + + it("preserves fresh-process plugin failure details in parent json output", async () => { + setupUpdatedRootRefresh(); + spawn.mockImplementationOnce((_node, _argv, options) => { + const child = new EventEmitter() as EventEmitter & { + once: EventEmitter["once"]; + }; + const env = (options as { env?: NodeJS.ProcessEnv }).env; + queueMicrotask(async () => { + const resultPath = env?.OPENCLAW_UPDATE_POST_CORE_RESULT_PATH; + if (resultPath) { + await fs.writeFile( + resultPath, + JSON.stringify({ + status: "error", + changed: false, + sync: { + changed: false, + switchedToBundled: [], + switchedToNpm: [], + warnings: [], + errors: [], + }, + npm: { + changed: false, + outcomes: [ + { + pluginId: "demo", + status: "error", + message: "Failed to update demo: registry timeout", + }, + ], + }, + integrityDrifts: [], + }), + "utf-8", + ); + } + child.emit("exit", 1, null); + }); + return child; + }); + vi.mocked(defaultRuntime.writeJson).mockClear(); + + await updateCommand({ yes: true, json: true, restart: false }); + + const jsonOutput = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0] as + | UpdateRunResult + | undefined; + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + expect(jsonOutput?.status).toBe("error"); + expect(jsonOutput?.reason).toBe("post-update-plugins"); + expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]?.message).toContain("registry timeout"); + }); + it.each([ { name: "preview mode", diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index e9a583fb81c..19537de54ce 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -1006,13 +1006,16 @@ async function continuePostCoreUpdateInFreshProcess(params: { }); }); - if (exitCode !== 0) { - defaultRuntime.exit(exitCode); - throw new Error(`post-update process exited with code ${exitCode}`); - } const pluginUpdate = resultPath ? await readPostCorePluginUpdateResultFile(resultPath) : undefined; + if (exitCode !== 0) { + if (pluginUpdate) { + return { resumed: true, pluginUpdate }; + } + defaultRuntime.exit(exitCode); + throw new Error(`post-update process exited with code ${exitCode}`); + } return { resumed: true, ...(pluginUpdate ? { pluginUpdate } : {}) }; } finally { if (resultDir) { @@ -1075,6 +1078,10 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { defaultRuntime.writeJson(result); } } + if (pluginUpdate.status === "error") { + defaultRuntime.exit(1); + return; + } return; } @@ -1434,6 +1441,28 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { }); } + const resultWithPostUpdate: UpdateRunResult = postCorePluginUpdate + ? { + ...result, + status: postCorePluginUpdate.status === "error" ? "error" : result.status, + ...(postCorePluginUpdate.status === "error" ? { reason: "post-update-plugins" } : {}), + postUpdate: { + ...result.postUpdate, + plugins: postCorePluginUpdate, + }, + } + : result; + + if (postCorePluginUpdate?.status === "error") { + if (opts.json) { + defaultRuntime.writeJson(resultWithPostUpdate); + } else { + defaultRuntime.error(theme.error("Update failed during plugin post-update sync.")); + } + defaultRuntime.exit(1); + return; + } + let restartScriptPath: string | null = null; let refreshGatewayServiceEnv = false; const gatewayPort = resolveGatewayPort( @@ -1469,7 +1498,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { const restartOk = await maybeRestartService({ shouldRestart, - result, + result: resultWithPostUpdate, opts, refreshServiceEnv: refreshGatewayServiceEnv, gatewayPort, @@ -1485,9 +1514,6 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { if (!opts.json) { defaultRuntime.log(theme.muted(pickUpdateQuip())); } else { - defaultRuntime.writeJson({ - ...result, - ...(postCorePluginUpdate ? { postUpdate: { plugins: postCorePluginUpdate } } : {}), - }); + defaultRuntime.writeJson(resultWithPostUpdate); } } diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index adc2af7be2c..01be9f10307 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -71,8 +71,10 @@ function createNpmInstallConfig(params: { spec: string; installPath: string; integrity?: string; + shasum?: string; resolvedName?: string; resolvedSpec?: string; + resolvedVersion?: string; }) { return { plugins: { @@ -82,8 +84,10 @@ function createNpmInstallConfig(params: { spec: params.spec, installPath: params.installPath, ...(params.integrity ? { integrity: params.integrity } : {}), + ...(params.shasum ? { shasum: params.shasum } : {}), ...(params.resolvedName ? { resolvedName: params.resolvedName } : {}), ...(params.resolvedSpec ? { resolvedSpec: params.resolvedSpec } : {}), + ...(params.resolvedVersion ? { resolvedVersion: params.resolvedVersion } : {}), }, }, }, @@ -412,6 +416,55 @@ describe("updateNpmInstalledPlugins", () => { ]); }); + it("refreshes legacy npm install records before skipping unchanged artifacts", async () => { + const installPath = createInstalledPackageDir({ + name: "@martian-engineering/lossless-claw", + version: "0.9.0", + }); + mockNpmViewMetadata({ + name: "@martian-engineering/lossless-claw", + version: "0.9.0", + integrity: "sha512-same", + shasum: "same", + }); + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "lossless-claw", + targetDir: installPath, + version: "0.9.0", + npmResolution: { + name: "@martian-engineering/lossless-claw", + version: "0.9.0", + resolvedSpec: "@martian-engineering/lossless-claw@0.9.0", + }, + }), + ); + + const result = await updateNpmInstalledPlugins({ + config: createNpmInstallConfig({ + pluginId: "lossless-claw", + spec: "@martian-engineering/lossless-claw", + installPath, + }), + pluginIds: ["lossless-claw"], + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(1); + expect(result.changed).toBe(true); + expect(result.outcomes[0]).toMatchObject({ + pluginId: "lossless-claw", + status: "unchanged", + currentVersion: "0.9.0", + nextVersion: "0.9.0", + }); + expect(result.config.plugins?.installs?.["lossless-claw"]).toMatchObject({ + source: "npm", + resolvedName: "@martian-engineering/lossless-claw", + resolvedVersion: "0.9.0", + resolvedSpec: "@martian-engineering/lossless-claw@0.9.0", + }); + }); + it("expands home-relative install paths before checking installed npm versions", async () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-home-")); tempDirs.push(home); @@ -436,8 +489,10 @@ describe("updateNpmInstalledPlugins", () => { spec: "@martian-engineering/lossless-claw", installPath: "~/.openclaw/extensions/lossless-claw", resolvedName: "@martian-engineering/lossless-claw", + resolvedVersion: "0.9.0", resolvedSpec: "@martian-engineering/lossless-claw@0.9.0", integrity: "sha512-same", + shasum: "same", }), pluginIds: ["lossless-claw"], }); diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 48da2a3bea1..32e74c051ef 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -115,10 +115,6 @@ type InstallIntegrityDrift = { }; }; -function stringFieldMatches(recorded: string | undefined, resolved: string | undefined): boolean { - return !recorded || (resolved !== undefined && recorded === resolved); -} - function shouldSkipUnchangedNpmInstall(params: { currentVersion?: string; record: { @@ -136,12 +132,28 @@ function shouldSkipUnchangedNpmInstall(params: { if (params.currentVersion !== params.metadata.version) { return false; } + if ( + !params.record.resolvedName || + !params.record.resolvedSpec || + !params.record.resolvedVersion + ) { + return false; + } + if (!params.metadata.name || !params.metadata.resolvedSpec) { + return false; + } + if (params.metadata.integrity && !params.record.integrity) { + return false; + } + if (params.metadata.shasum && !params.record.shasum) { + return false; + } return ( - stringFieldMatches(params.record.integrity, params.metadata.integrity) && - stringFieldMatches(params.record.shasum, params.metadata.shasum) && - stringFieldMatches(params.record.resolvedName, params.metadata.name) && - stringFieldMatches(params.record.resolvedSpec, params.metadata.resolvedSpec) && - stringFieldMatches(params.record.resolvedVersion, params.metadata.version) + (!params.metadata.integrity || params.record.integrity === params.metadata.integrity) && + (!params.metadata.shasum || params.record.shasum === params.metadata.shasum) && + params.record.resolvedName === params.metadata.name && + params.record.resolvedSpec === params.metadata.resolvedSpec && + params.record.resolvedVersion === params.metadata.version ); } From 0a82c819bbe3e24db80e551b2b373c385257fe0e Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 09:00:07 +0100 Subject: [PATCH 11/25] fix: keep status channel metadata cold --- CHANGELOG.md | 1 + src/commands/channels/add.ts | 3 +- src/commands/channels/capabilities.ts | 1 + src/commands/channels/list.ts | 1 + src/commands/channels/remove.ts | 8 ++--- src/commands/channels/runtime-label.ts | 11 +++++++ src/commands/channels/shared.ts | 20 ++++--------- src/commands/channels/status-config-format.ts | 16 ++++++++-- src/commands/channels/status.ts | 9 +++++- src/commands/status-runtime-shared.test.ts | 3 ++ src/commands/status-runtime-shared.ts | 1 + src/commands/status.test.ts | 2 ++ .../audit-plugin-readonly-scope.test.ts | 29 +++++++++++++++++++ src/security/audit.ts | 7 +++++ 14 files changed, 86 insertions(+), 26 deletions(-) create mode 100644 src/commands/channels/runtime-label.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b5cc9a6bab..3983166843b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ Docs: https://docs.openclaw.ai - Gateway/plugins: stop persisted WhatsApp auth state from activating bundled channel runtime-dependency repair during startup when `channels.whatsapp` is absent, avoiding npm/git stalls on packaged Linux installs. Fixes #71994. Thanks @xiao398008. - Gateway/device tokens: enforce caller-scope containment inside token rotation and revocation so pairing-only sessions cannot mutate higher-scope operator tokens. Fixes #71990. Thanks @coygeek. - Plugins/channels: keep security checks, thread-binding placement, provider summaries, health formatting, and message action labels on read-only or already-loaded channel metadata instead of importing full channel runtime. Thanks @shakkernerd. +- Plugins/status: keep config-only channel labels and status security summaries from importing plugin runtime modules just to render metadata. Thanks @shakkernerd. - Sessions/channels: stop group-session metadata from loading bundled channel runtime just to classify `#channel` subjects, using only already-loaded channel capabilities on that path. Thanks @shakkernerd. - Plugins/channels: keep native command and native skill `auto` defaults on static channel metadata so config, audit, and command-list checks do not load channel runtime just to read those defaults. Thanks @shakkernerd. - CLI/channels: keep channel remove selection and all-channel capabilities summaries on read-only plugin metadata, loading channel runtime only for the selected mutation path. Thanks @shakkernerd. diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index ccf49a45b1d..baad702c9f3 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -15,7 +15,8 @@ import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { applyAgentBindings, describeBinding } from "../agents.bindings.js"; import type { ChannelChoice } from "../onboard-types.js"; import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js"; -import { channelLabel, requireValidConfigFileSnapshot, shouldUseWizard } from "./shared.js"; +import { channelLabel } from "./runtime-label.js"; +import { requireValidConfigFileSnapshot, shouldUseWizard } from "./shared.js"; type ChannelSetupPluginInstallModule = typeof import("../channel-setup/plugin-install.js"); type OnboardChannelsModule = typeof import("../onboard-channels.js"); diff --git a/src/commands/channels/capabilities.ts b/src/commands/channels/capabilities.ts index 95664de72d4..dbe9d50b7e0 100644 --- a/src/commands/channels/capabilities.ts +++ b/src/commands/channels/capabilities.ts @@ -317,6 +317,7 @@ export async function channelsCapabilitiesCommand( channel: report.channel, accountId: report.accountId, name: report.accountName, + channelLabel: report.plugin.meta.label ?? report.channel, channelStyle: theme.accent, accountStyle: theme.heading, }); diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index 58c7c7163c1..fecb3754dd2 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -61,6 +61,7 @@ function formatAccountLine(params: { channel: channel.id, accountId: snapshot.accountId, name: snapshot.name, + channelLabel: channel.meta.label ?? channel.id, channelStyle: theme.accent, accountStyle: theme.heading, }); diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index 1ffee9dff26..9cdfc343f0f 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -9,12 +9,8 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-ke import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; -import { - type ChatChannel, - channelLabel, - requireValidConfigFileSnapshot, - shouldUseWizard, -} from "./shared.js"; +import { channelLabel } from "./runtime-label.js"; +import { type ChatChannel, requireValidConfigFileSnapshot, shouldUseWizard } from "./shared.js"; export type ChannelsRemoveOptions = { channel?: string; diff --git a/src/commands/channels/runtime-label.ts b/src/commands/channels/runtime-label.ts new file mode 100644 index 00000000000..54e6698dbe8 --- /dev/null +++ b/src/commands/channels/runtime-label.ts @@ -0,0 +1,11 @@ +import { getBundledChannelSetupPlugin } from "../../channels/plugins/bundled.js"; +import { getChannelPlugin, getLoadedChannelPlugin } from "../../channels/plugins/index.js"; +import type { ChatChannel } from "./shared.js"; + +export const channelLabel = (channel: ChatChannel) => { + const plugin = + getLoadedChannelPlugin(channel) ?? + getBundledChannelSetupPlugin(channel) ?? + getChannelPlugin(channel); + return plugin?.meta.label ?? channel; +}; diff --git a/src/commands/channels/shared.ts b/src/commands/channels/shared.ts index 178e6f591a2..e7427c4eb2f 100644 --- a/src/commands/channels/shared.ts +++ b/src/commands/channels/shared.ts @@ -1,10 +1,5 @@ import { hasConfiguredUnavailableCredentialStatus } from "../../channels/account-snapshot-fields.js"; -import { getBundledChannelSetupPlugin } from "../../channels/plugins/bundled.js"; -import { - type ChannelId, - getChannelPlugin, - getLoadedChannelPlugin, -} from "../../channels/plugins/index.js"; +import type { ChannelId } from "../../channels/plugins/types.public.js"; import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolution.js"; import type { CommandSecretResolutionMode } from "../../cli/command-secret-gateway.js"; import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; @@ -50,22 +45,15 @@ export function formatAccountLabel(params: { accountId: string; name?: string }) return base; } -export const channelLabel = (channel: ChatChannel) => { - const plugin = - getLoadedChannelPlugin(channel) ?? - getBundledChannelSetupPlugin(channel) ?? - getChannelPlugin(channel); - return plugin?.meta.label ?? channel; -}; - export function formatChannelAccountLabel(params: { channel: ChatChannel; accountId: string; name?: string; + channelLabel?: string; channelStyle?: (value: string) => string; accountStyle?: (value: string) => string; }): string { - const channelText = channelLabel(params.channel); + const channelText = params.channelLabel ?? params.channel; const accountText = formatAccountLabel({ accountId: params.accountId, name: params.name, @@ -130,6 +118,7 @@ export function buildChannelAccountLine( provider: ChatChannel, account: Record, bits: string[], + opts?: { channelLabel?: string }, ): string { const accountId = typeof account.accountId === "string" ? account.accountId : DEFAULT_ACCOUNT_ID; const name = typeof account.name === "string" ? account.name : undefined; @@ -137,6 +126,7 @@ export function buildChannelAccountLine( channel: provider, accountId, name, + channelLabel: opts?.channelLabel, }); return `- ${labelText}: ${bits.join(", ")}`; } diff --git a/src/commands/channels/status-config-format.ts b/src/commands/channels/status-config-format.ts index de93afbaf84..50a7bf33252 100644 --- a/src/commands/channels/status-config-format.ts +++ b/src/commands/channels/status-config-format.ts @@ -20,6 +20,11 @@ import { type ChatChannel, } from "./shared.js"; +type ChannelStatusPluginLabel = { + id: ChatChannel; + meta: { label?: string }; +}; + export async function formatConfigChannelsStatusLines( cfg: OpenClawConfig, meta: { path?: string; mode?: "local" | "remote" }, @@ -37,14 +42,19 @@ export async function formatConfigChannelsStatusLines( lines.push(""); } - const accountLines = (provider: ChatChannel, accounts: Array>) => + const accountLines = ( + plugin: ChannelStatusPluginLabel, + accounts: Array>, + ) => accounts.map((account) => { const bits: string[] = []; appendEnabledConfiguredLinkedBits(bits, account); appendModeBit(bits, account); appendTokenSourceBits(bits, account); appendBaseUrlBit(bits, account); - return buildChannelAccountLine(provider, account, bits); + return buildChannelAccountLine(plugin.id, account, bits, { + channelLabel: plugin.meta.label ?? plugin.id, + }); }); const sourceConfig = opts?.sourceConfig ?? cfg; @@ -79,7 +89,7 @@ export async function formatConfigChannelsStatusLines( ); } if (snapshots.length > 0) { - lines.push(...accountLines(plugin.id, snapshots)); + lines.push(...accountLines(plugin, snapshots)); } } diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index d4d1ef2427f..879b1b60f24 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -44,6 +44,10 @@ function formatChannelsStatusError(err: unknown): string { export function formatGatewayChannelsStatusLines(payload: Record): string[] { const lines: string[] = []; lines.push(theme.success("Gateway reachable.")); + const channelLabels = + payload.channelLabels && typeof payload.channelLabels === "object" + ? (payload.channelLabels as Record) + : {}; const accountLines = (provider: ChatChannel, accounts: Array>) => accounts.map((account) => { const bits: string[] = []; @@ -118,7 +122,10 @@ export function formatGatewayChannelsStatusLines(payload: Record | undefined; diff --git a/src/commands/status-runtime-shared.test.ts b/src/commands/status-runtime-shared.test.ts index a60d8eccfcd..49b07fc7319 100644 --- a/src/commands/status-runtime-shared.test.ts +++ b/src/commands/status-runtime-shared.test.ts @@ -67,6 +67,7 @@ describe("status-runtime-shared", () => { deep: false, includeFilesystem: true, includeChannelSecurity: true, + loadPluginSecurityCollectors: false, plugins: expect.any(Array), }); expect(mocks.resolveReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith( @@ -96,6 +97,7 @@ describe("status-runtime-shared", () => { deep: false, includeFilesystem: true, includeChannelSecurity: true, + loadPluginSecurityCollectors: false, }); }); @@ -283,6 +285,7 @@ describe("status-runtime-shared", () => { deep: false, includeFilesystem: true, includeChannelSecurity: true, + loadPluginSecurityCollectors: false, plugins: expect.any(Array), }); }); diff --git a/src/commands/status-runtime-shared.ts b/src/commands/status-runtime-shared.ts index c78f6346f38..c5c222442c1 100644 --- a/src/commands/status-runtime-shared.ts +++ b/src/commands/status-runtime-shared.ts @@ -38,6 +38,7 @@ export async function resolveStatusSecurityAudit(params: { deep: false, includeFilesystem: true, includeChannelSecurity: true, + loadPluginSecurityCollectors: false, ...(readOnlyPlugins.missingConfiguredChannelIds.length === 0 ? { plugins: readOnlyPlugins.plugins } : {}), diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 9764f8d290c..61d4a426ecb 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -740,6 +740,7 @@ vi.mock("./status-runtime-shared.ts", () => ({ deep: false, includeFilesystem: true, includeChannelSecurity: true, + loadPluginSecurityCollectors: false, }), ), resolveStatusUsageSummary: vi.fn(async () => undefined), @@ -759,6 +760,7 @@ vi.mock("./status-runtime-shared.ts", () => ({ deep: false, includeFilesystem: true, includeChannelSecurity: true, + loadPluginSecurityCollectors: false, })) )({ config: params.config, diff --git a/src/security/audit-plugin-readonly-scope.test.ts b/src/security/audit-plugin-readonly-scope.test.ts index aef07f3a721..73fb85dee94 100644 --- a/src/security/audit-plugin-readonly-scope.test.ts +++ b/src/security/audit-plugin-readonly-scope.test.ts @@ -1,6 +1,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const applyPluginAutoEnableMock = vi.hoisted(() => vi.fn()); +const getActivePluginRegistryMock = vi.hoisted(() => vi.fn()); const loadPluginMetadataRegistrySnapshotMock = vi.hoisted(() => vi.fn()); const resolveConfiguredChannelPluginIdsMock = vi.hoisted(() => vi.fn()); @@ -13,6 +14,10 @@ vi.mock("../plugins/channel-plugin-ids.js", () => ({ resolveConfiguredChannelPluginIdsMock(...args), })); +vi.mock("../plugins/runtime.js", () => ({ + getActivePluginRegistry: (...args: unknown[]) => getActivePluginRegistryMock(...args), +})); + vi.mock("../plugins/runtime/metadata-registry-loader.js", () => ({ loadPluginMetadataRegistrySnapshot: (...args: unknown[]) => loadPluginMetadataRegistrySnapshotMock(...args), @@ -36,6 +41,7 @@ function createAuditContext(params: { stateDir: "/tmp/openclaw-test-state", configPath: "/tmp/openclaw-test-config.json", plugins: params.plugins, + loadPluginSecurityCollectors: true, configSnapshot: null, codeSafetySummaryCache: new Map>(), }; @@ -48,8 +54,10 @@ describe("security audit read-only plugin scope", () => { beforeEach(() => { applyPluginAutoEnableMock.mockReset(); + getActivePluginRegistryMock.mockReset(); loadPluginMetadataRegistrySnapshotMock.mockReset(); resolveConfiguredChannelPluginIdsMock.mockReset(); + getActivePluginRegistryMock.mockReturnValue(null); applyPluginAutoEnableMock.mockImplementation((params: { config: unknown }) => ({ config: params.config, changes: [], @@ -127,4 +135,25 @@ describe("security audit read-only plugin scope", () => { }), ); }); + + it("skips plugin runtime and collector discovery when collector loading is disabled", async () => { + const sourceConfig = { + plugins: { + allow: ["audit-plugin"], + }, + }; + + const findings = await collectPluginSecurityAuditFindings({ + ...createAuditContext({ + sourceConfig, + plugins: [], + }), + loadPluginSecurityCollectors: false, + }); + + expect(findings).toEqual([]); + expect(getActivePluginRegistryMock).not.toHaveBeenCalled(); + expect(applyPluginAutoEnableMock).not.toHaveBeenCalled(); + expect(loadPluginMetadataRegistrySnapshotMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/security/audit.ts b/src/security/audit.ts index 7b05818b84b..311a323dc62 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -58,6 +58,8 @@ export type SecurityAuditOptions = { deepTimeoutMs?: number; /** Dependency injection for tests. */ plugins?: ChannelPlugin[]; + /** Whether to import plugin modules to discover plugin security audit collectors. */ + loadPluginSecurityCollectors?: boolean; /** Dependency injection for tests (Windows ACL checks). */ execIcacls?: ExecFn; /** Dependency injection for tests (Docker label checks). */ @@ -89,6 +91,7 @@ export type AuditExecutionContext = { execDockerRawFn?: ExecDockerRawFn; probeGatewayFn?: ProbeGatewayFn; plugins?: ChannelPlugin[]; + loadPluginSecurityCollectors: boolean; configSnapshot: ConfigFileSnapshot | null; codeSafetySummaryCache: Map>; deepProbeAuth?: { token?: string; password?: string }; @@ -338,6 +341,9 @@ export function collectGatewayConfigFindings( export async function collectPluginSecurityAuditFindings( context: AuditExecutionContext, ): Promise { + if (!context.loadPluginSecurityCollectors) { + return []; + } const { getActivePluginRegistry } = await loadPluginRuntimeModule(); let collectors = getActivePluginRegistry()?.securityAuditCollectors ?? []; if (collectors.length === 0) { @@ -940,6 +946,7 @@ async function createAuditExecutionContext( execDockerRawFn: opts.execDockerRawFn, probeGatewayFn: opts.probeGatewayFn, plugins: opts.plugins, + loadPluginSecurityCollectors: opts.loadPluginSecurityCollectors !== false, workspaceDir, configSnapshot, codeSafetySummaryCache: opts.codeSafetySummaryCache ?? new Map>(), From 5037298d82de884d414cf02e40a8dd608a338868 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 09:00:58 +0100 Subject: [PATCH 12/25] test: update channel status label fixtures --- ....adds-non-default-telegram-account.test.ts | 20 ++++++++++++++++++- ...time-errors-channels-status-output.test.ts | 6 ++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/commands/channels.adds-non-default-telegram-account.test.ts b/src/commands/channels.adds-non-default-telegram-account.test.ts index c0fb08a27ac..00c3ec57c0f 100644 --- a/src/commands/channels.adds-non-default-telegram-account.test.ts +++ b/src/commands/channels.adds-non-default-telegram-account.test.ts @@ -31,7 +31,15 @@ type ChannelSectionConfig = { }; function formatChannelStatusJoined(channelAccounts: Record) { - return formatGatewayChannelsStatusLines({ channelAccounts }).join("\n"); + return formatGatewayChannelsStatusLines({ + channelLabels: { + discord: "Discord", + signal: "Signal", + telegram: "Telegram", + whatsapp: "WhatsApp", + }, + channelAccounts, + }).join("\n"); } function listConfiguredAccountIds(channelConfig: ChannelSectionConfig | undefined): string[] { @@ -637,6 +645,10 @@ describe("channels command", () => { it("formats gateway channel status lines in registry order", () => { const lines = formatGatewayChannelsStatusLines({ + channelLabels: { + telegram: "Telegram", + whatsapp: "WhatsApp", + }, channelAccounts: { telegram: [{ accountId: "default", configured: true }], whatsapp: [{ accountId: "default", linked: true }], @@ -756,6 +768,9 @@ describe("channels command", () => { it("surfaces WhatsApp auth/runtime hints when unlinked or disconnected", () => { const unlinked = formatGatewayChannelsStatusLines({ + channelLabels: { + whatsapp: "WhatsApp", + }, channelAccounts: { whatsapp: [{ accountId: "default", enabled: true, linked: false }], }, @@ -764,6 +779,9 @@ describe("channels command", () => { expect(unlinked.join("\n")).toMatch(/Not linked/i); const disconnected = formatGatewayChannelsStatusLines({ + channelLabels: { + whatsapp: "WhatsApp", + }, channelAccounts: { whatsapp: [ { diff --git a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts index 36bb1acadb7..315ba812ee7 100644 --- a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts +++ b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts @@ -33,6 +33,9 @@ describe("channels command", () => { it("surfaces Signal runtime errors in channels status output", () => { const lines = formatGatewayChannelsStatusLines({ + channelLabels: { + signal: "Signal", + }, channelAccounts: { signal: [ { @@ -61,6 +64,9 @@ describe("channels command", () => { ]), ); const lines = formatGatewayChannelsStatusLines({ + channelLabels: { + imessage: "iMessage", + }, channelAccounts: { imessage: [ { From f2744978a0d2c484d77bb67db5f6a594b33f09b6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 01:02:41 -0700 Subject: [PATCH 13/25] docs(slash-commands): rewrite with ParamField for config keys, AccordionGroup for command groups and surface notes --- docs/tools/slash-commands.md | 385 ++++++++++++++++++++--------------- 1 file changed, 219 insertions(+), 166 deletions(-) diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 8a1c350c0a8..2632de49f46 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -4,29 +4,35 @@ read_when: - Using or configuring chat commands - Debugging command routing or permissions title: "Slash commands" +sidebarTitle: "Slash commands" --- -Commands are handled by the Gateway. Most commands must be sent as a **standalone** message that starts with `/`. -The host-only bash chat command uses `! ` (with `/bash ` as an alias). +Commands are handled by the Gateway. Most commands must be sent as a **standalone** message that starts with `/`. The host-only bash chat command uses `! ` (with `/bash ` as an alias). -When a conversation or thread is bound to an ACP session, normal follow-up text -routes to that ACP harness. Gateway management commands still stay local: -`/acp ...` always reaches the OpenClaw ACP command handler, and `/status` plus -`/unfocus` stay local whenever command handling is enabled for the surface. +When a conversation or thread is bound to an ACP session, normal follow-up text routes to that ACP harness. Gateway management commands still stay local: `/acp ...` always reaches the OpenClaw ACP command handler, and `/status` plus `/unfocus` stay local whenever command handling is enabled for the surface. There are two related systems: -- **Commands**: standalone `/...` messages. -- **Directives**: `/think`, `/fast`, `/verbose`, `/trace`, `/reasoning`, `/elevated`, `/exec`, `/model`, `/queue`. - - Directives are stripped from the message before the model sees it. - - In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings. - - In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement. - - Directives are only applied for **authorized senders**. If `commands.allowFrom` is set, it is the only - allowlist used; otherwise authorization comes from channel allowlists/pairing plus `commands.useAccessGroups`. - Unauthorized senders see directives treated as plain text. + + + Standalone `/...` messages. + + + `/think`, `/fast`, `/verbose`, `/trace`, `/reasoning`, `/elevated`, `/exec`, `/model`, `/queue`. -There are also a few **inline shortcuts** (allowlisted/authorized senders only): `/help`, `/commands`, `/status`, `/whoami` (`/id`). -They run immediately, are stripped before the model sees the message, and the remaining text continues through the normal flow. + - Directives are stripped from the message before the model sees it. + - In normal chat messages (not directive-only), they are treated as "inline hints" and do **not** persist session settings. + - In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement. + - Directives are only applied for **authorized senders**. If `commands.allowFrom` is set, it is the only allowlist used; otherwise authorization comes from channel allowlists/pairing plus `commands.useAccessGroups`. Unauthorized senders see directives treated as plain text. + + + + Allowlisted/authorized senders only: `/help`, `/commands`, `/status`, `/whoami` (`/id`). + + They run immediately, are stripped before the model sees the message, and the remaining text continues through the normal flow. + + + ## Config @@ -55,30 +61,54 @@ They run immediately, are stripped before the model sees the message, and the re } ``` -- `commands.text` (default `true`) enables parsing `/...` in chat messages. - - On surfaces without native commands (WhatsApp/WebChat/Signal/iMessage/Google Chat/Microsoft Teams), text commands still work even if you set this to `false`. -- `commands.native` (default `"auto"`) registers native commands. - - Auto: on for Discord/Telegram; off for Slack (until you add slash commands); ignored for providers without native support. - - Set `channels.discord.commands.native`, `channels.telegram.commands.native`, or `channels.slack.commands.native` to override per provider (bool or `"auto"`). - - `false` clears previously registered commands on Discord/Telegram at startup. Slack commands are managed in the Slack app and are not removed automatically. -- `commands.nativeSkills` (default `"auto"`) registers **skill** commands natively when supported. - - Auto: on for Discord/Telegram; off for Slack (Slack requires creating a slash command per skill). - - Set `channels.discord.commands.nativeSkills`, `channels.telegram.commands.nativeSkills`, or `channels.slack.commands.nativeSkills` to override per provider (bool or `"auto"`). -- `commands.bash` (default `false`) enables `! ` to run host shell commands (`/bash ` is an alias; requires `tools.elevated` allowlists). -- `commands.bashForegroundMs` (default `2000`) controls how long bash waits before switching to background mode (`0` backgrounds immediately). -- `commands.config` (default `false`) enables `/config` (reads/writes `openclaw.json`). -- `commands.mcp` (default `false`) enables `/mcp` (reads/writes OpenClaw-managed MCP config under `mcp.servers`). -- `commands.plugins` (default `false`) enables `/plugins` (plugin discovery/status plus install + enable/disable controls). -- `commands.debug` (default `false`) enables `/debug` (runtime-only overrides). -- `commands.restart` (default `true`) enables `/restart` plus gateway restart tool actions. -- `commands.ownerAllowFrom` (optional) sets the explicit owner allowlist for owner-only command/tool surfaces. This is separate from `commands.allowFrom`. -- Per-channel `channels..commands.enforceOwnerForCommands` (optional, default `false`) makes owner-only commands require **owner identity** to run on that surface. When `true`, the sender must either match a resolved owner candidate (for example an entry in `commands.ownerAllowFrom` or provider-native owner metadata) or hold internal `operator.admin` scope on an internal message channel. A wildcard entry in channel `allowFrom`, or an empty/unresolved owner-candidate list, is **not** sufficient — owner-only commands fail closed on that channel. Leave this off if you want owner-only commands gated only by `ownerAllowFrom` and the standard command allowlists. -- `commands.ownerDisplay` controls how owner ids appear in the system prompt: `raw` or `hash`. -- `commands.ownerDisplaySecret` optionally sets the HMAC secret used when `commands.ownerDisplay="hash"`. -- `commands.allowFrom` (optional) sets a per-provider allowlist for command authorization. When configured, it is the - only authorization source for commands and directives (channel allowlists/pairing and `commands.useAccessGroups` - are ignored). Use `"*"` for a global default; provider-specific keys override it. -- `commands.useAccessGroups` (default `true`) enforces allowlists/policies for commands when `commands.allowFrom` is not set. + + Enables parsing `/...` in chat messages. On surfaces without native commands (WhatsApp/WebChat/Signal/iMessage/Google Chat/Microsoft Teams), text commands still work even if you set this to `false`. + + + Registers native commands. Auto: on for Discord/Telegram; off for Slack (until you add slash commands); ignored for providers without native support. Set `channels.discord.commands.native`, `channels.telegram.commands.native`, or `channels.slack.commands.native` to override per provider (bool or `"auto"`). `false` clears previously registered commands on Discord/Telegram at startup. Slack commands are managed in the Slack app and are not removed automatically. + + + Registers **skill** commands natively when supported. Auto: on for Discord/Telegram; off for Slack (Slack requires creating a slash command per skill). Set `channels.discord.commands.nativeSkills`, `channels.telegram.commands.nativeSkills`, or `channels.slack.commands.nativeSkills` to override per provider (bool or `"auto"`). + + + Enables `! ` to run host shell commands (`/bash ` is an alias; requires `tools.elevated` allowlists). + + + Controls how long bash waits before switching to background mode (`0` backgrounds immediately). + + + Enables `/config` (reads/writes `openclaw.json`). + + + Enables `/mcp` (reads/writes OpenClaw-managed MCP config under `mcp.servers`). + + + Enables `/plugins` (plugin discovery/status plus install + enable/disable controls). + + + Enables `/debug` (runtime-only overrides). + + + Enables `/restart` plus gateway restart tool actions. + + + Sets the explicit owner allowlist for owner-only command/tool surfaces. Separate from `commands.allowFrom`. + + + Per-channel: makes owner-only commands require **owner identity** to run on that surface. When `true`, the sender must either match a resolved owner candidate (for example an entry in `commands.ownerAllowFrom` or provider-native owner metadata) or hold internal `operator.admin` scope on an internal message channel. A wildcard entry in channel `allowFrom`, or an empty/unresolved owner-candidate list, is **not** sufficient — owner-only commands fail closed on that channel. Leave this off if you want owner-only commands gated only by `ownerAllowFrom` and the standard command allowlists. + + + Controls how owner ids appear in the system prompt. + + + Optionally sets the HMAC secret used when `commands.ownerDisplay="hash"`. + + + Per-provider allowlist for command authorization. When configured, it is the only authorization source for commands and directives (channel allowlists/pairing and `commands.useAccessGroups` are ignored). Use `"*"` for a global default; provider-specific keys override it. + + + Enforces allowlists/policies for commands when `commands.allowFrom` is not set. + ## Command list @@ -91,56 +121,70 @@ Current source-of-truth: ### Core built-in commands -Built-in commands available today: - -- `/new [model]` starts a new session; `/reset` is the reset alias. -- `/reset soft [message]` keeps the current transcript, drops reused CLI backend session ids, and reruns startup/system-prompt loading in-place. -- `/compact [instructions]` compacts the session context. See [/concepts/compaction](/concepts/compaction). -- `/stop` aborts the current run. -- `/session idle ` and `/session max-age ` manage thread-binding expiry. -- `/think ` sets the thinking level. Options come from the active model's provider profile; common levels are `off`, `minimal`, `low`, `medium`, and `high`, with custom levels such as `xhigh`, `adaptive`, `max`, or binary `on` only where supported. Aliases: `/thinking`, `/t`. -- `/verbose on|off|full` toggles verbose output. Alias: `/v`. -- `/trace on|off` toggles plugin trace output for the current session. -- `/fast [status|on|off]` shows or sets fast mode. -- `/reasoning [on|off|stream]` toggles reasoning visibility. Alias: `/reason`. -- `/elevated [on|off|ask|full]` toggles elevated mode. Alias: `/elev`. -- `/exec host= security= ask= node=` shows or sets exec defaults. -- `/model [name|#|status]` shows or sets the model. -- `/models [provider] [page] [limit=|size=|all]` lists providers or models for a provider. -- `/queue ` manages queue behavior (`steer`, `interrupt`, `followup`, `collect`, `steer-backlog`) plus options like `debounce:2s cap:25 drop:summarize`. -- `/help` shows the short help summary. -- `/commands` shows the generated command catalog. -- `/tools [compact|verbose]` shows what the current agent can use right now. -- `/status` shows execution/runtime status, including `Execution`/`Runtime` labels and provider usage/quota when available. -- `/crestodian ` runs the Crestodian setup and repair helper from an owner DM. -- `/tasks` lists active/recent background tasks for the current session. -- `/context [list|detail|json]` explains how context is assembled. -- `/export-session [path]` exports the current session to HTML. Alias: `/export`. -- `/export-trajectory [path]` exports a JSONL [trajectory bundle](/tools/trajectory) for the current session. Alias: `/trajectory`. -- `/whoami` shows your sender id. Alias: `/id`. -- `/skill [input]` runs a skill by name. -- `/allowlist [list|add|remove] ...` manages allowlist entries. Text-only. -- `/approve ` resolves exec approval prompts. -- `/btw ` asks a side question without changing future session context. See [/tools/btw](/tools/btw). -- `/subagents list|kill|log|info|send|steer|spawn` manages sub-agent runs for the current session. -- `/acp spawn|cancel|steer|close|sessions|status|set-mode|set|cwd|permissions|timeout|model|reset-options|doctor|install|help` manages ACP sessions and runtime options. -- `/focus ` binds the current Discord thread or Telegram topic/conversation to a session target. -- `/unfocus` removes the current binding. -- `/agents` lists thread-bound agents for the current session. -- `/kill ` aborts one or all running sub-agents. -- `/steer ` sends steering to a running sub-agent. Alias: `/tell`. -- `/config show|get|set|unset` reads or writes `openclaw.json`. Owner-only. Requires `commands.config: true`. -- `/mcp show|get|set|unset` reads or writes OpenClaw-managed MCP server config under `mcp.servers`. Owner-only. Requires `commands.mcp: true`. -- `/plugins list|inspect|show|get|install|enable|disable` inspects or mutates plugin state. `/plugin` is an alias. Owner-only for writes. Requires `commands.plugins: true`. -- `/debug show|set|unset|reset` manages runtime-only config overrides. Owner-only. Requires `commands.debug: true`. -- `/usage off|tokens|full|cost` controls the per-response usage footer or prints a local cost summary. -- `/tts on|off|status|chat|latest|provider|limit|summary|audio|help` controls TTS. See [/tools/tts](/tools/tts). -- `/restart` restarts OpenClaw when enabled. Default: enabled; set `commands.restart: false` to disable it. -- `/activation mention|always` sets group activation mode. -- `/send on|off|inherit` sets send policy. Owner-only. -- `/bash ` runs a host shell command. Text-only. Alias: `! `. Requires `commands.bash: true` plus `tools.elevated` allowlists. -- `!poll [sessionId]` checks a background bash job. -- `!stop [sessionId]` stops a background bash job. + + + - `/new [model]` starts a new session; `/reset` is the reset alias. + - `/reset soft [message]` keeps the current transcript, drops reused CLI backend session ids, and reruns startup/system-prompt loading in-place. + - `/compact [instructions]` compacts the session context. See [Compaction](/concepts/compaction). + - `/stop` aborts the current run. + - `/session idle ` and `/session max-age ` manage thread-binding expiry. + - `/export-session [path]` exports the current session to HTML. Alias: `/export`. + - `/export-trajectory [path]` exports a JSONL [trajectory bundle](/tools/trajectory) for the current session. Alias: `/trajectory`. + + + - `/think ` sets the thinking level. Options come from the active model's provider profile; common levels are `off`, `minimal`, `low`, `medium`, and `high`, with custom levels such as `xhigh`, `adaptive`, `max`, or binary `on` only where supported. Aliases: `/thinking`, `/t`. + - `/verbose on|off|full` toggles verbose output. Alias: `/v`. + - `/trace on|off` toggles plugin trace output for the current session. + - `/fast [status|on|off]` shows or sets fast mode. + - `/reasoning [on|off|stream]` toggles reasoning visibility. Alias: `/reason`. + - `/elevated [on|off|ask|full]` toggles elevated mode. Alias: `/elev`. + - `/exec host= security= ask= node=` shows or sets exec defaults. + - `/model [name|#|status]` shows or sets the model. + - `/models [provider] [page] [limit=|size=|all]` lists providers or models for a provider. + - `/queue ` manages queue behavior (`steer`, `interrupt`, `followup`, `collect`, `steer-backlog`) plus options like `debounce:2s cap:25 drop:summarize`. + + + - `/help` shows the short help summary. + - `/commands` shows the generated command catalog. + - `/tools [compact|verbose]` shows what the current agent can use right now. + - `/status` shows execution/runtime status, including `Execution`/`Runtime` labels and provider usage/quota when available. + - `/crestodian ` runs the Crestodian setup and repair helper from an owner DM. + - `/tasks` lists active/recent background tasks for the current session. + - `/context [list|detail|json]` explains how context is assembled. + - `/whoami` shows your sender id. Alias: `/id`. + - `/usage off|tokens|full|cost` controls the per-response usage footer or prints a local cost summary. + + + - `/skill [input]` runs a skill by name. + - `/allowlist [list|add|remove] ...` manages allowlist entries. Text-only. + - `/approve ` resolves exec approval prompts. + - `/btw ` asks a side question without changing future session context. See [BTW](/tools/btw). + + + - `/subagents list|kill|log|info|send|steer|spawn` manages sub-agent runs for the current session. + - `/acp spawn|cancel|steer|close|sessions|status|set-mode|set|cwd|permissions|timeout|model|reset-options|doctor|install|help` manages ACP sessions and runtime options. + - `/focus ` binds the current Discord thread or Telegram topic/conversation to a session target. + - `/unfocus` removes the current binding. + - `/agents` lists thread-bound agents for the current session. + - `/kill ` aborts one or all running sub-agents. + - `/steer ` sends steering to a running sub-agent. Alias: `/tell`. + + + - `/config show|get|set|unset` reads or writes `openclaw.json`. Owner-only. Requires `commands.config: true`. + - `/mcp show|get|set|unset` reads or writes OpenClaw-managed MCP server config under `mcp.servers`. Owner-only. Requires `commands.mcp: true`. + - `/plugins list|inspect|show|get|install|enable|disable` inspects or mutates plugin state. `/plugin` is an alias. Owner-only for writes. Requires `commands.plugins: true`. + - `/debug show|set|unset|reset` manages runtime-only config overrides. Owner-only. Requires `commands.debug: true`. + - `/restart` restarts OpenClaw when enabled. Default: enabled; set `commands.restart: false` to disable it. + - `/send on|off|inherit` sets send policy. Owner-only. + + + - `/tts on|off|status|chat|latest|provider|limit|summary|audio|help` controls TTS. See [TTS](/tools/tts). + - `/activation mention|always` sets group activation mode. + - `/bash ` runs a host shell command. Text-only. Alias: `! `. Requires `commands.bash: true` plus `tools.elevated` allowlists. + - `!poll [sessionId]` checks a background bash job. + - `!stop [sessionId]` stops a background bash job. + + ### Generated dock commands @@ -160,7 +204,7 @@ Bundled plugins can add more slash commands. Current bundled commands in this re - `/phone status|arm [duration]|disarm` temporarily arms high-risk phone node commands. - `/voice status|list [limit]|set ` manages Talk voice config. On Discord, the native command name is `/talkvoice`. - `/card ...` sends LINE rich card presets. See [LINE](/channels/line). -- `/codex status|models|threads|resume|compact|review|account|mcp|skills` inspects and controls the bundled Codex app-server harness. See [Codex Harness](/plugins/codex-harness). +- `/codex status|models|threads|resume|compact|review|account|mcp|skills` inspects and controls the bundled Codex app-server harness. See [Codex harness](/plugins/codex-harness). - QQBot-only commands: - `/bot-ping` - `/bot-version` @@ -176,65 +220,71 @@ User-invocable skills are also exposed as slash commands: - skills may also appear as direct commands like `/prose` when the skill/plugin registers them. - native skill-command registration is controlled by `commands.nativeSkills` and `channels..commands.nativeSkills`. -Notes: - -- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`). -- `/new ` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body. -- For full provider usage breakdown, use `openclaw status --usage`. -- `/allowlist add|remove` requires `commands.config=true` and honors channel `configWrites`. -- In multi-account channels, config-targeted `/allowlist --account ` and `/config set channels..accounts....` also honor the target account's `configWrites`. -- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from OpenClaw session logs. -- `/restart` is enabled by default; set `commands.restart: false` to disable it. -- `/plugins install ` accepts the same plugin specs as `openclaw plugins install`: local path/archive, npm package, or `clawhub:`. -- `/plugins enable|disable` updates plugin config and may prompt for a restart. -- Discord-only native command: `/vc join|leave|status` controls voice channels (not available as text). `join` requires a guild and selected voice/stage channel. Requires `channels.discord.voice` and native commands. -- Discord thread-binding commands (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`) require effective thread bindings to be enabled (`session.threadBindings.enabled` and/or `channels.discord.threadBindings.enabled`). -- ACP command reference and runtime behavior: [ACP Agents](/tools/acp-agents). -- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. -- `/trace` is narrower than `/verbose`: it only reveals plugin-owned trace/debug lines and keeps normal verbose tool chatter off. -- `/fast on|off` persists a session override. Use the Sessions UI `inherit` option to clear it and fall back to config defaults. -- `/fast` is provider-specific: OpenAI/OpenAI Codex map it to `service_tier=priority` on native Responses endpoints, while direct public Anthropic requests, including OAuth-authenticated traffic sent to `api.anthropic.com`, map it to `service_tier=auto` or `standard_only`. See [OpenAI](/providers/openai) and [Anthropic](/providers/anthropic). -- Tool failure summaries are still shown when relevant, but detailed failure text is only included when `/verbose` is `on` or `full`. -- `/reasoning`, `/verbose`, and `/trace` are risky in group settings: they may reveal internal reasoning, tool output, or plugin diagnostics you did not intend to expose. Prefer leaving them off, especially in group chats. -- `/model` persists the new session model immediately. -- If the agent is idle, the next run uses it right away. -- If a run is already active, OpenClaw marks a live switch as pending and only restarts into the new model at a clean retry point. -- If tool activity or reply output has already started, the pending switch can stay queued until a later retry opportunity or the next user turn. -- In the local TUI, `/crestodian [request]` returns from the normal agent TUI to - Crestodian. This is separate from message-channel rescue mode and does not - grant remote config authority. -- **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model). -- **Group mention gating:** command-only messages from allowlisted senders bypass mention requirements. -- **Inline shortcuts (allowlisted senders only):** certain commands also work when embedded in a normal message and are stripped before the model sees the remaining text. - - Example: `hey /status` triggers a status reply, and the remaining text continues through the normal flow. -- Currently: `/help`, `/commands`, `/status`, `/whoami` (`/id`). -- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text. -- **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`). - - `/skill [input]` runs a skill by name (useful when native command limits prevent per-skill commands). - - By default, skill commands are forwarded to the model as a normal request. - - Skills may optionally declare `command-dispatch: tool` to route the command directly to a tool (deterministic, no model). - - Example: `/prose` (OpenProse plugin) — see [OpenProse](/prose). -- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg. Dynamic choices are resolved against the target session model, so model-specific options such as `/think` levels follow that session's `/model` override. + + + - Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`). + - `/new ` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body. + - For full provider usage breakdown, use `openclaw status --usage`. + - `/allowlist add|remove` requires `commands.config=true` and honors channel `configWrites`. + - In multi-account channels, config-targeted `/allowlist --account ` and `/config set channels..accounts....` also honor the target account's `configWrites`. + - `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from OpenClaw session logs. + - `/restart` is enabled by default; set `commands.restart: false` to disable it. + - `/plugins install ` accepts the same plugin specs as `openclaw plugins install`: local path/archive, npm package, or `clawhub:`. + - `/plugins enable|disable` updates plugin config and may prompt for a restart. + + + - Discord-only native command: `/vc join|leave|status` controls voice channels (not available as text). `join` requires a guild and selected voice/stage channel. Requires `channels.discord.voice` and native commands. + - Discord thread-binding commands (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`) require effective thread bindings to be enabled (`session.threadBindings.enabled` and/or `channels.discord.threadBindings.enabled`). + - ACP command reference and runtime behavior: [ACP agents](/tools/acp-agents). + + + - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. + - `/trace` is narrower than `/verbose`: it only reveals plugin-owned trace/debug lines and keeps normal verbose tool chatter off. + - `/fast on|off` persists a session override. Use the Sessions UI `inherit` option to clear it and fall back to config defaults. + - `/fast` is provider-specific: OpenAI/OpenAI Codex map it to `service_tier=priority` on native Responses endpoints, while direct public Anthropic requests, including OAuth-authenticated traffic sent to `api.anthropic.com`, map it to `service_tier=auto` or `standard_only`. See [OpenAI](/providers/openai) and [Anthropic](/providers/anthropic). + - Tool failure summaries are still shown when relevant, but detailed failure text is only included when `/verbose` is `on` or `full`. + - `/reasoning`, `/verbose`, and `/trace` are risky in group settings: they may reveal internal reasoning, tool output, or plugin diagnostics you did not intend to expose. Prefer leaving them off, especially in group chats. + + + - `/model` persists the new session model immediately. + - If the agent is idle, the next run uses it right away. + - If a run is already active, OpenClaw marks a live switch as pending and only restarts into the new model at a clean retry point. + - If tool activity or reply output has already started, the pending switch can stay queued until a later retry opportunity or the next user turn. + - In the local TUI, `/crestodian [request]` returns from the normal agent TUI to Crestodian. This is separate from message-channel rescue mode and does not grant remote config authority. + + + - **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model). + - **Group mention gating:** command-only messages from allowlisted senders bypass mention requirements. + - **Inline shortcuts (allowlisted senders only):** certain commands also work when embedded in a normal message and are stripped before the model sees the remaining text. + - Example: `hey /status` triggers a status reply, and the remaining text continues through the normal flow. + - Currently: `/help`, `/commands`, `/status`, `/whoami` (`/id`). + - Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text. + + + - **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`). + - `/skill [input]` runs a skill by name (useful when native command limits prevent per-skill commands). + - By default, skill commands are forwarded to the model as a normal request. + - Skills may optionally declare `command-dispatch: tool` to route the command directly to a tool (deterministic, no model). + - Example: `/prose` (OpenProse plugin) — see [OpenProse](/prose). + - **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg. Dynamic choices are resolved against the target session model, so model-specific options such as `/think` levels follow that session's `/model` override. + + ## `/tools` -`/tools` answers a runtime question, not a config question: **what this agent can use right now in -this conversation**. +`/tools` answers a runtime question, not a config question: **what this agent can use right now in this conversation**. - Default `/tools` is compact and optimized for quick scanning. - `/tools verbose` adds short descriptions. - Native-command surfaces that support arguments expose the same mode switch as `compact|verbose`. -- Results are session-scoped, so changing agent, channel, thread, sender authorization, or model can - change the output. -- `/tools` includes tools that are actually reachable at runtime, including core tools, connected - plugin tools, and channel-owned tools. +- Results are session-scoped, so changing agent, channel, thread, sender authorization, or model can change the output. +- `/tools` includes tools that are actually reachable at runtime, including core tools, connected plugin tools, and channel-owned tools. -For profile and override editing, use the Control UI Tools panel or config/catalog surfaces instead -of treating `/tools` as a static catalog. +For profile and override editing, use the Control UI Tools panel or config/catalog surfaces instead of treating `/tools` as a static catalog. ## Usage surfaces (what shows where) -- **Provider usage/quota** (example: “Claude 80% left”) shows up in `/status` for the current model provider when usage tracking is enabled. OpenClaw normalizes provider windows to `% left`; for MiniMax, remaining-only percent fields are inverted before display, and `model_remains` responses prefer the chat-model entry plus a model-tagged plan label. +- **Provider usage/quota** (example: "Claude 80% left") shows up in `/status` for the current model provider when usage tracking is enabled. OpenClaw normalizes provider windows to `% left`; for MiniMax, remaining-only percent fields are inverted before display, and `model_remains` responses prefer the chat-model entry plus a model-tagged plan label. - **Token/cache lines** in `/status` can fall back to the latest transcript usage entry when the live session snapshot is sparse. Existing nonzero live values still win, and transcript fallback can also recover the active runtime model label plus a larger prompt-oriented total when stored totals are missing or smaller. - **Execution vs runtime:** `/status` reports `Execution` for the effective sandbox path and `Runtime` for who is actually running the session: `OpenClaw Pi Default`, `OpenAI Codex`, a CLI backend, or an ACP backend. - **Per-response tokens/cost** is controlled by `/usage off|tokens|full` (appended to normal replies). @@ -276,10 +326,9 @@ Examples: /debug reset ``` -Notes: - -- Overrides apply immediately to new config reads, but do **not** write to `openclaw.json`. -- Use `/debug reset` to clear all overrides and return to the on-disk config. + +Overrides apply immediately to new config reads, but do **not** write to `openclaw.json`. Use `/debug reset` to clear all overrides and return to the on-disk config. + ## Plugin trace output @@ -316,10 +365,9 @@ Examples: /config unset messages.responsePrefix ``` -Notes: - -- Config is validated before write; invalid changes are rejected. -- `/config` updates persist across restarts. + +Config is validated before write; invalid changes are rejected. `/config` updates persist across restarts. + ## MCP updates @@ -334,10 +382,9 @@ Examples: /mcp unset context7 ``` -Notes: - -- `/mcp` stores config in OpenClaw config, not Pi-owned project settings. -- Runtime adapters decide which transports are actually executable. + +`/mcp` stores config in OpenClaw config, not Pi-owned project settings. Runtime adapters decide which transports are actually executable. + ## Plugin updates @@ -353,22 +400,30 @@ Examples: /plugins disable context7 ``` -Notes: - + - `/plugins list` and `/plugins show` use real plugin discovery against the current workspace plus on-disk config. - `/plugins enable|disable` updates plugin config only; it does not install or uninstall plugins. - After enable/disable changes, restart the gateway to apply them. + ## Surface notes -- **Text commands** run in the normal chat session (DMs share `main`, groups have their own session). -- **Native commands** use isolated sessions: - - Discord: `agent::discord:slash:` - - Slack: `agent::slack:slash:` (prefix configurable via `channels.slack.slashCommand.sessionPrefix`) - - Telegram: `telegram:slash:` (targets the chat session via `CommandTargetSessionKey`) -- **`/stop`** targets the active chat session so it can abort the current run. -- **Slack:** `channels.slack.slashCommand` is still supported for a single `/openclaw`-style command. If you enable `commands.native`, you must create one Slack slash command per built-in command (same names as `/help`). Command argument menus for Slack are delivered as ephemeral Block Kit buttons. - - Slack native exception: register `/agentstatus` (not `/status`) because Slack reserves `/status`. Text `/status` still works in Slack messages. + + + - **Text commands** run in the normal chat session (DMs share `main`, groups have their own session). + - **Native commands** use isolated sessions: + - Discord: `agent::discord:slash:` + - Slack: `agent::slack:slash:` (prefix configurable via `channels.slack.slashCommand.sessionPrefix`) + - Telegram: `telegram:slash:` (targets the chat session via `CommandTargetSessionKey`) + - **`/stop`** targets the active chat session so it can abort the current run. + + + `channels.slack.slashCommand` is still supported for a single `/openclaw`-style command. If you enable `commands.native`, you must create one Slack slash command per built-in command (same names as `/help`). Command argument menus for Slack are delivered as ephemeral Block Kit buttons. + + Slack native exception: register `/agentstatus` (not `/status`) because Slack reserves `/status`. Text `/status` still works in Slack messages. + + + ## BTW side questions @@ -382,8 +437,7 @@ Unlike normal chat: - it is not written to transcript history, - it is delivered as a live side result instead of a normal assistant message. -That makes `/btw` useful when you want a temporary clarification while the main -task keeps going. +That makes `/btw` useful when you want a temporary clarification while the main task keeps going. Example: @@ -391,11 +445,10 @@ Example: /btw what are we doing right now? ``` -See [BTW Side Questions](/tools/btw) for the full behavior and client UX -details. +See [BTW Side Questions](/tools/btw) for the full behavior and client UX details. ## Related +- [Creating skills](/tools/creating-skills) - [Skills](/tools/skills) - [Skills config](/tools/skills-config) -- [Creating skills](/tools/creating-skills) From 64a7a34c83343e3add5638eb34f48693abaf05dc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 01:04:51 -0700 Subject: [PATCH 14/25] docs(trusted-proxy-auth): rewrite with Steps for handshake, Tabs for TLS, AccordionGroup for proxy examples and troubleshooting --- docs/gateway/trusted-proxy-auth.md | 494 ++++++++++++++++------------- 1 file changed, 266 insertions(+), 228 deletions(-) diff --git a/docs/gateway/trusted-proxy-auth.md b/docs/gateway/trusted-proxy-auth.md index fb9f00b9df6..4776d8d7678 100644 --- a/docs/gateway/trusted-proxy-auth.md +++ b/docs/gateway/trusted-proxy-auth.md @@ -1,6 +1,7 @@ --- summary: "Delegate gateway authentication to a trusted reverse proxy (Pomerium, Caddy, nginx + OAuth)" title: "Trusted proxy auth" +sidebarTitle: "Trusted proxy auth" read_when: - Running OpenClaw behind an identity-aware proxy - Setting up Pomerium, Caddy, or nginx with OAuth in front of OpenClaw @@ -8,37 +9,49 @@ read_when: - Deciding where to set HSTS and other HTTP hardening headers --- -> ⚠️ **Security-sensitive feature.** This mode delegates authentication entirely to your reverse proxy. Misconfiguration can expose your Gateway to unauthorized access. Read this page carefully before enabling. + +**Security-sensitive feature.** This mode delegates authentication entirely to your reverse proxy. Misconfiguration can expose your Gateway to unauthorized access. Read this page carefully before enabling. + -## When to Use +## When to use Use `trusted-proxy` auth mode when: -- You run OpenClaw behind an **identity-aware proxy** (Pomerium, Caddy + OAuth, nginx + oauth2-proxy, Traefik + forward auth) -- Your proxy handles all authentication and passes user identity via headers -- You're in a Kubernetes or container environment where the proxy is the only path to the Gateway -- You're hitting WebSocket `1008 unauthorized` errors because browsers can't pass tokens in WS payloads +- You run OpenClaw behind an **identity-aware proxy** (Pomerium, Caddy + OAuth, nginx + oauth2-proxy, Traefik + forward auth). +- Your proxy handles all authentication and passes user identity via headers. +- You're in a Kubernetes or container environment where the proxy is the only path to the Gateway. +- You're hitting WebSocket `1008 unauthorized` errors because browsers can't pass tokens in WS payloads. -## When NOT to Use +## When NOT to use -- If your proxy doesn't authenticate users (just a TLS terminator or load balancer) -- If there's any path to the Gateway that bypasses the proxy (firewall holes, internal network access) -- If you're unsure whether your proxy correctly strips/overwrites forwarded headers -- If you only need personal single-user access (consider Tailscale Serve + loopback for simpler setup) +- If your proxy doesn't authenticate users (just a TLS terminator or load balancer). +- If there's any path to the Gateway that bypasses the proxy (firewall holes, internal network access). +- If you're unsure whether your proxy correctly strips/overwrites forwarded headers. +- If you only need personal single-user access (consider Tailscale Serve + loopback for simpler setup). -## How It Works +## How it works -1. Your reverse proxy authenticates users (OAuth, OIDC, SAML, etc.) -2. Proxy adds a header with the authenticated user identity (e.g., `x-forwarded-user: nick@example.com`) -3. OpenClaw checks that the request came from a **trusted proxy IP** (configured in `gateway.trustedProxies`) -4. OpenClaw extracts the user identity from the configured header -5. If everything checks out, the request is authorized + + + Your reverse proxy authenticates users (OAuth, OIDC, SAML, etc.). + + + Proxy adds a header with the authenticated user identity (e.g., `x-forwarded-user: nick@example.com`). + + + OpenClaw checks that the request came from a **trusted proxy IP** (configured in `gateway.trustedProxies`). + + + OpenClaw extracts the user identity from the configured header. + + + If everything checks out, the request is authorized. + + -## Control UI Pairing Behavior +## Control UI pairing behavior -When `gateway.auth.mode = "trusted-proxy"` is active and the request passes -trusted-proxy checks, Control UI WebSocket sessions can connect without device -pairing identity. +When `gateway.auth.mode = "trusted-proxy"` is active and the request passes trusted-proxy checks, Control UI WebSocket sessions can connect without device pairing identity. Implications: @@ -74,61 +87,73 @@ Implications: } ``` -Important runtime rule: + +**Important runtime rules** - Trusted-proxy auth rejects loopback-source requests (`127.0.0.1`, `::1`, loopback CIDRs). - Same-host loopback reverse proxies do **not** satisfy trusted-proxy auth. - For same-host loopback proxy setups, use token/password auth instead, or route through a non-loopback trusted proxy address that OpenClaw can verify. - Non-loopback Control UI deployments still need explicit `gateway.controlUi.allowedOrigins`. - **Forwarded-header evidence overrides loopback locality.** If a request arrives on loopback but carries `X-Forwarded-For` / `X-Forwarded-Host` / `X-Forwarded-Proto` headers pointing at a non-local origin, that evidence disqualifies the loopback locality claim. The request is treated as remote for pairing, trusted-proxy auth, and Control UI device-identity gating. This prevents a same-host loopback proxy from laundering forwarded-header identity into trusted-proxy auth. + -### Configuration Reference +### Configuration reference -| Field | Required | Description | -| ------------------------------------------- | -------- | --------------------------------------------------------------------------- | -| `gateway.trustedProxies` | Yes | Array of proxy IP addresses to trust. Requests from other IPs are rejected. | -| `gateway.auth.mode` | Yes | Must be `"trusted-proxy"` | -| `gateway.auth.trustedProxy.userHeader` | Yes | Header name containing the authenticated user identity | -| `gateway.auth.trustedProxy.requiredHeaders` | No | Additional headers that must be present for the request to be trusted | -| `gateway.auth.trustedProxy.allowUsers` | No | Allowlist of user identities. Empty means allow all authenticated users. | + + Array of proxy IP addresses to trust. Requests from other IPs are rejected. + + + Must be `"trusted-proxy"`. + + + Header name containing the authenticated user identity. + + + Additional headers that must be present for the request to be trusted. + + + Allowlist of user identities. Empty means allow all authenticated users. + ## TLS termination and HSTS Use one TLS termination point and apply HSTS there. -### Recommended pattern: proxy TLS termination + + + When your reverse proxy handles HTTPS for `https://control.example.com`, set `Strict-Transport-Security` at the proxy for that domain. -When your reverse proxy handles HTTPS for `https://control.example.com`, set -`Strict-Transport-Security` at the proxy for that domain. + - Good fit for internet-facing deployments. + - Keeps certificate + HTTP hardening policy in one place. + - OpenClaw can stay on loopback HTTP behind the proxy. -- Good fit for internet-facing deployments. -- Keeps certificate + HTTP hardening policy in one place. -- OpenClaw can stay on loopback HTTP behind the proxy. + Example header value: -Example header value: + ```text + Strict-Transport-Security: max-age=31536000; includeSubDomains + ``` -```text -Strict-Transport-Security: max-age=31536000; includeSubDomains -``` + + + If OpenClaw itself serves HTTPS directly (no TLS-terminating proxy), set: -### Gateway TLS termination - -If OpenClaw itself serves HTTPS directly (no TLS-terminating proxy), set: - -```json5 -{ - gateway: { - tls: { enabled: true }, - http: { - securityHeaders: { - strictTransportSecurity: "max-age=31536000; includeSubDomains", + ```json5 + { + gateway: { + tls: { enabled: true }, + http: { + securityHeaders: { + strictTransportSecurity: "max-age=31536000; includeSubDomains", + }, + }, }, - }, - }, -} -``` + } + ``` -`strictTransportSecurity` accepts a string header value, or `false` to disable explicitly. + `strictTransportSecurity` accepts a string header value, or `false` to disable explicitly. + + + ### Rollout guidance @@ -138,124 +163,126 @@ If OpenClaw itself serves HTTPS directly (no TLS-terminating proxy), set: - Use preload only if you intentionally meet preload requirements for your full domain set. - Loopback-only local development does not benefit from HSTS. -## Proxy Setup Examples +## Proxy setup examples -### Pomerium + + + Pomerium passes identity in `x-pomerium-claim-email` (or other claim headers) and a JWT in `x-pomerium-jwt-assertion`. -Pomerium passes identity in `x-pomerium-claim-email` (or other claim headers) and a JWT in `x-pomerium-jwt-assertion`. - -```json5 -{ - gateway: { - bind: "lan", - trustedProxies: ["10.0.0.1"], // Pomerium's IP - auth: { - mode: "trusted-proxy", - trustedProxy: { - userHeader: "x-pomerium-claim-email", - requiredHeaders: ["x-pomerium-jwt-assertion"], + ```json5 + { + gateway: { + bind: "lan", + trustedProxies: ["10.0.0.1"], // Pomerium's IP + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-pomerium-claim-email", + requiredHeaders: ["x-pomerium-jwt-assertion"], + }, + }, }, - }, - }, -} -``` - -Pomerium config snippet: - -```yaml -routes: - - from: https://openclaw.example.com - to: http://openclaw-gateway:18789 - policy: - - allow: - or: - - email: - is: nick@example.com - pass_identity_headers: true -``` - -### Caddy with OAuth - -Caddy with the `caddy-security` plugin can authenticate users and pass identity headers. - -```json5 -{ - gateway: { - bind: "lan", - trustedProxies: ["10.0.0.1"], // Caddy/sidecar proxy IP - auth: { - mode: "trusted-proxy", - trustedProxy: { - userHeader: "x-forwarded-user", - }, - }, - }, -} -``` - -Caddyfile snippet: - -``` -openclaw.example.com { - authenticate with oauth2_provider - authorize with policy1 - - reverse_proxy openclaw:18789 { - header_up X-Forwarded-User {http.auth.user.email} } -} -``` + ``` -### nginx + oauth2-proxy + Pomerium config snippet: -oauth2-proxy authenticates users and passes identity in `x-auth-request-email`. + ```yaml + routes: + - from: https://openclaw.example.com + to: http://openclaw-gateway:18789 + policy: + - allow: + or: + - email: + is: nick@example.com + pass_identity_headers: true + ``` -```json5 -{ - gateway: { - bind: "lan", - trustedProxies: ["10.0.0.1"], // nginx/oauth2-proxy IP - auth: { - mode: "trusted-proxy", - trustedProxy: { - userHeader: "x-auth-request-email", + + + Caddy with the `caddy-security` plugin can authenticate users and pass identity headers. + + ```json5 + { + gateway: { + bind: "lan", + trustedProxies: ["10.0.0.1"], // Caddy/sidecar proxy IP + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, }, - }, - }, -} -``` + } + ``` -nginx config snippet: + Caddyfile snippet: -```nginx -location / { - auth_request /oauth2/auth; - auth_request_set $user $upstream_http_x_auth_request_email; + ``` + openclaw.example.com { + authenticate with oauth2_provider + authorize with policy1 - proxy_pass http://openclaw:18789; - proxy_set_header X-Auth-Request-Email $user; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; -} -``` + reverse_proxy openclaw:18789 { + header_up X-Forwarded-User {http.auth.user.email} + } + } + ``` -### Traefik with Forward Auth + + + oauth2-proxy authenticates users and passes identity in `x-auth-request-email`. -```json5 -{ - gateway: { - bind: "lan", - trustedProxies: ["172.17.0.1"], // Traefik container IP - auth: { - mode: "trusted-proxy", - trustedProxy: { - userHeader: "x-forwarded-user", + ```json5 + { + gateway: { + bind: "lan", + trustedProxies: ["10.0.0.1"], // nginx/oauth2-proxy IP + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-auth-request-email", + }, + }, }, - }, - }, -} -``` + } + ``` + + nginx config snippet: + + ```nginx + location / { + auth_request /oauth2/auth; + auth_request_set $user $upstream_http_x_auth_request_email; + + proxy_pass http://openclaw:18789; + proxy_set_header X-Auth-Request-Email $user; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + ``` + + + + ```json5 + { + gateway: { + bind: "lan", + trustedProxies: ["172.17.0.1"], // Traefik container IP + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + }, + } + ``` + + ## Mixed token configuration @@ -270,8 +297,7 @@ Loopback trusted-proxy auth also fails closed: same-host callers must supply the ## Operator scopes header -Trusted-proxy auth is an **identity-bearing** HTTP mode, so callers may -optionally declare operator scopes with `x-openclaw-scopes`. +Trusted-proxy auth is an **identity-bearing** HTTP mode, so callers may optionally declare operator scopes with `x-openclaw-scopes`. Examples: @@ -287,26 +313,22 @@ Behavior: - Gateway-auth **plugin HTTP routes** are narrower by default: when `x-openclaw-scopes` is absent, their runtime scope falls back to `operator.write`. - Browser-origin HTTP requests still have to pass `gateway.controlUi.allowedOrigins` (or deliberate Host-header fallback mode) even after trusted-proxy auth succeeds. -Practical rule: +Practical rule: send `x-openclaw-scopes` explicitly when you want a trusted-proxy request to be narrower than the defaults, or when a gateway-auth plugin route needs something stronger than write scope. -- Send `x-openclaw-scopes` explicitly when you want a trusted-proxy request to - be narrower than the defaults, or when a gateway-auth plugin route needs - something stronger than write scope. - -## Security Checklist +## Security checklist Before enabling trusted-proxy auth, verify: -- [ ] **Proxy is the only path**: The Gateway port is firewalled from everything except your proxy -- [ ] **trustedProxies is minimal**: Only your actual proxy IPs, not entire subnets -- [ ] **No loopback proxy source**: trusted-proxy auth fails closed for loopback-source requests -- [ ] **Proxy strips headers**: Your proxy overwrites (not appends) `x-forwarded-*` headers from clients -- [ ] **TLS termination**: Your proxy handles TLS; users connect via HTTPS -- [ ] **allowedOrigins is explicit**: Non-loopback Control UI uses explicit `gateway.controlUi.allowedOrigins` -- [ ] **allowUsers is set** (recommended): Restrict to known users rather than allowing anyone authenticated -- [ ] **No mixed token config**: Do not set both `gateway.auth.token` and `gateway.auth.mode: "trusted-proxy"` +- [ ] **Proxy is the only path**: The Gateway port is firewalled from everything except your proxy. +- [ ] **trustedProxies is minimal**: Only your actual proxy IPs, not entire subnets. +- [ ] **No loopback proxy source**: trusted-proxy auth fails closed for loopback-source requests. +- [ ] **Proxy strips headers**: Your proxy overwrites (not appends) `x-forwarded-*` headers from clients. +- [ ] **TLS termination**: Your proxy handles TLS; users connect via HTTPS. +- [ ] **allowedOrigins is explicit**: Non-loopback Control UI uses explicit `gateway.controlUi.allowedOrigins`. +- [ ] **allowUsers is set** (recommended): Restrict to known users rather than allowing anyone authenticated. +- [ ] **No mixed token config**: Do not set both `gateway.auth.token` and `gateway.auth.mode: "trusted-proxy"`. -## Security Audit +## Security audit `openclaw security audit` will flag trusted-proxy auth with a **critical** severity finding. This is intentional — it's a reminder that you're delegating security to your proxy setup. @@ -320,79 +342,95 @@ The audit checks for: ## Troubleshooting -### "trusted_proxy_untrusted_source" + + + The request didn't come from an IP in `gateway.trustedProxies`. Check: -The request didn't come from an IP in `gateway.trustedProxies`. Check: + - Is the proxy IP correct? (Docker container IPs can change.) + - Is there a load balancer in front of your proxy? + - Use `docker inspect` or `kubectl get pods -o wide` to find actual IPs. -- Is the proxy IP correct? (Docker container IPs can change) -- Is there a load balancer in front of your proxy? -- Use `docker inspect` or `kubectl get pods -o wide` to find actual IPs + + + OpenClaw rejected a loopback-source trusted-proxy request. -### "trusted_proxy_loopback_source" + Check: -OpenClaw rejected a loopback-source trusted-proxy request. + - Is the proxy connecting from `127.0.0.1` / `::1`? + - Are you trying to use trusted-proxy auth with a same-host loopback reverse proxy? -Check: + Fix: -- Is the proxy connecting from `127.0.0.1` / `::1`? -- Are you trying to use trusted-proxy auth with a same-host loopback reverse proxy? + - Use token/password auth for same-host loopback proxy setups, or + - Route through a non-loopback trusted proxy address and keep that IP in `gateway.trustedProxies`. -Fix: + + + The user header was empty or missing. Check: -- Use token/password auth for same-host loopback proxy setups, or -- Route through a non-loopback trusted proxy address and keep that IP in `gateway.trustedProxies`. + - Is your proxy configured to pass identity headers? + - Is the header name correct? (case-insensitive, but spelling matters) + - Is the user actually authenticated at the proxy? -### "trusted_proxy_user_missing" + + + A required header wasn't present. Check: -The user header was empty or missing. Check: + - Your proxy configuration for those specific headers. + - Whether headers are being stripped somewhere in the chain. -- Is your proxy configured to pass identity headers? -- Is the header name correct? (case-insensitive, but spelling matters) -- Is the user actually authenticated at the proxy? + + + The user is authenticated but not in `allowUsers`. Either add them or remove the allowlist. + + + Trusted-proxy auth succeeded, but the browser `Origin` header did not pass Control UI origin checks. -### "trusted*proxy_missing_header*\*" + Check: -A required header wasn't present. Check: + - `gateway.controlUi.allowedOrigins` includes the exact browser origin. + - You are not relying on wildcard origins unless you intentionally want allow-all behavior. + - If you intentionally use Host-header fallback mode, `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` is set deliberately. -- Your proxy configuration for those specific headers -- Whether headers are being stripped somewhere in the chain + + + Make sure your proxy: -### "trusted_proxy_user_not_allowed" + - Supports WebSocket upgrades (`Upgrade: websocket`, `Connection: upgrade`). + - Passes the identity headers on WebSocket upgrade requests (not just HTTP). + - Doesn't have a separate auth path for WebSocket connections. -The user is authenticated but not in `allowUsers`. Either add them or remove the allowlist. + + -### "trusted_proxy_origin_not_allowed" - -Trusted-proxy auth succeeded, but the browser `Origin` header did not pass Control UI origin checks. - -Check: - -- `gateway.controlUi.allowedOrigins` includes the exact browser origin -- You are not relying on wildcard origins unless you intentionally want allow-all behavior -- If you intentionally use Host-header fallback mode, `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` is set deliberately - -### WebSocket Still Failing - -Make sure your proxy: - -- Supports WebSocket upgrades (`Upgrade: websocket`, `Connection: upgrade`) -- Passes the identity headers on WebSocket upgrade requests (not just HTTP) -- Doesn't have a separate auth path for WebSocket connections - -## Migration from Token Auth +## Migration from token auth If you're moving from token auth to trusted-proxy: -1. Configure your proxy to authenticate users and pass headers -2. Test the proxy setup independently (curl with headers) -3. Update OpenClaw config with trusted-proxy auth -4. Restart the Gateway -5. Test WebSocket connections from the Control UI -6. Run `openclaw security audit` and review findings + + + Configure your proxy to authenticate users and pass headers. + + + Test the proxy setup independently (curl with headers). + + + Update OpenClaw config with trusted-proxy auth. + + + Restart the Gateway. + + + Test WebSocket connections from the Control UI. + + + Run `openclaw security audit` and review findings. + + ## Related -- [Security](/gateway/security) — full security guide - [Configuration](/gateway/configuration) — config reference -- [Remote Access](/gateway/remote) — other remote access patterns +- [Remote access](/gateway/remote) — other remote access patterns +- [Security](/gateway/security) — full security guide - [Tailscale](/gateway/tailscale) — simpler alternative for tailnet-only access From d24c6095ce10251b160504a1d0470ce98bae0c35 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 01:07:59 -0700 Subject: [PATCH 15/25] docs(sdk-setup): rewrite with Tabs for package metadata and install paths, ParamField for openclaw fields, AccordionGroup for setup-entry rules and helpers --- docs/plugins/sdk-setup.md | 434 +++++++++++++++++--------------------- 1 file changed, 193 insertions(+), 241 deletions(-) diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md index a60a5f8f05c..239528b5c77 100644 --- a/docs/plugins/sdk-setup.md +++ b/docs/plugins/sdk-setup.md @@ -1,86 +1,92 @@ --- summary: "Setup wizards, setup-entry.ts, config schemas, and package.json metadata" title: "Plugin setup and config" -sidebarTitle: "Setup and Config" +sidebarTitle: "Setup and config" read_when: - You are adding a setup wizard to a plugin - You need to understand setup-entry.ts vs index.ts - You are defining plugin config schemas or package.json openclaw metadata --- -Reference for plugin packaging (`package.json` metadata), manifests -(`openclaw.plugin.json`), setup entries, and config schemas. +Reference for plugin packaging (`package.json` metadata), manifests (`openclaw.plugin.json`), setup entries, and config schemas. - **Looking for a walkthrough?** The how-to guides cover packaging in context: - [Channel Plugins](/plugins/sdk-channel-plugins#step-1-package-and-manifest) and - [Provider Plugins](/plugins/sdk-provider-plugins#step-1-package-and-manifest). +**Looking for a walkthrough?** The how-to guides cover packaging in context: [Channel plugins](/plugins/sdk-channel-plugins#step-1-package-and-manifest) and [Provider plugins](/plugins/sdk-provider-plugins#step-1-package-and-manifest). ## Package metadata -Your `package.json` needs an `openclaw` field that tells the plugin system what -your plugin provides: +Your `package.json` needs an `openclaw` field that tells the plugin system what your plugin provides: -**Channel plugin:** - -```json -{ - "name": "@myorg/openclaw-my-channel", - "version": "1.0.0", - "type": "module", - "openclaw": { - "extensions": ["./index.ts"], - "setupEntry": "./setup-entry.ts", - "channel": { - "id": "my-channel", - "label": "My Channel", - "blurb": "Short description of the channel." + + + ```json + { + "name": "@myorg/openclaw-my-channel", + "version": "1.0.0", + "type": "module", + "openclaw": { + "extensions": ["./index.ts"], + "setupEntry": "./setup-entry.ts", + "channel": { + "id": "my-channel", + "label": "My Channel", + "blurb": "Short description of the channel." + } + } } - } -} -``` - -**Provider plugin / ClawHub publish baseline:** - -```json openclaw-clawhub-package.json -{ - "name": "@myorg/openclaw-my-plugin", - "version": "1.0.0", - "type": "module", - "openclaw": { - "extensions": ["./index.ts"], - "compat": { - "pluginApi": ">=2026.3.24-beta.2", - "minGatewayVersion": "2026.3.24-beta.2" - }, - "build": { - "openclawVersion": "2026.3.24-beta.2", - "pluginSdkVersion": "2026.3.24-beta.2" + ``` + + + ```json openclaw-clawhub-package.json + { + "name": "@myorg/openclaw-my-plugin", + "version": "1.0.0", + "type": "module", + "openclaw": { + "extensions": ["./index.ts"], + "compat": { + "pluginApi": ">=2026.3.24-beta.2", + "minGatewayVersion": "2026.3.24-beta.2" + }, + "build": { + "openclawVersion": "2026.3.24-beta.2", + "pluginSdkVersion": "2026.3.24-beta.2" + } + } } - } -} -``` + ``` + + -If you publish the plugin externally on ClawHub, those `compat` and `build` -fields are required. The canonical publish snippets live in -`docs/snippets/plugin-publish/`. + +If you publish the plugin externally on ClawHub, those `compat` and `build` fields are required. The canonical publish snippets live in `docs/snippets/plugin-publish/`. + ### `openclaw` fields -| Field | Type | Description | -| ------------ | ---------- | --------------------------------------------------------------------------------------------------------------------------- | -| `extensions` | `string[]` | Entry point files (relative to package root) | -| `setupEntry` | `string` | Lightweight setup-only entry (optional) | -| `channel` | `object` | Channel catalog metadata for setup, picker, quickstart, and status surfaces | -| `providers` | `string[]` | Provider ids registered by this plugin | -| `install` | `object` | Install hints: `npmSpec`, `localPath`, `defaultChoice`, `minHostVersion`, `expectedIntegrity`, `allowInvalidConfigRecovery` | -| `startup` | `object` | Startup behavior flags | + + Entry point files (relative to package root). + + + Lightweight setup-only entry (optional). + + + Channel catalog metadata for setup, picker, quickstart, and status surfaces. + + + Provider ids registered by this plugin. + + + Install hints: `npmSpec`, `localPath`, `defaultChoice`, `minHostVersion`, `expectedIntegrity`, `allowInvalidConfigRecovery`. + + + Startup behavior flags. + ### `openclaw.channel` -`openclaw.channel` is cheap package metadata for channel discovery and setup -surfaces before runtime loads. +`openclaw.channel` is cheap package metadata for channel discovery and setup surfaces before runtime loads. | Field | Type | What it means | | -------------------------------------- | ---------- | ----------------------------------------------------------------------------- | @@ -140,8 +146,9 @@ Example: - `setup`: include the channel in interactive setup/configure pickers - `docs`: mark the channel as public-facing in docs/navigation surfaces -`showConfigured` and `showInSetup` remain supported as legacy aliases. Prefer -`exposure`. + +`showConfigured` and `showInSetup` remain supported as legacy aliases. Prefer `exposure`. + ### `openclaw.install` @@ -156,39 +163,33 @@ Example: | `expectedIntegrity` | `string` | Expected npm dist integrity string, usually `sha512-...`, for pinned installs. | | `allowInvalidConfigRecovery` | `boolean` | Lets bundled-plugin reinstall flows recover from specific stale-config failures. | -Interactive onboarding also uses `openclaw.install` for install-on-demand -surfaces. If your plugin exposes provider auth choices or channel setup/catalog -metadata before runtime loads, onboarding can show that choice, prompt for npm -vs local install, install or enable the plugin, then continue the selected -flow. Npm onboarding choices require trusted catalog metadata with a registry -`npmSpec`; exact versions and `expectedIntegrity` are optional pins. If -`expectedIntegrity` is present, install/update flows enforce it. Keep the "what -to show" metadata in `openclaw.plugin.json` and the "how to install it" -metadata in `package.json`. + + + Interactive onboarding also uses `openclaw.install` for install-on-demand surfaces. If your plugin exposes provider auth choices or channel setup/catalog metadata before runtime loads, onboarding can show that choice, prompt for npm vs local install, install or enable the plugin, then continue the selected flow. Npm onboarding choices require trusted catalog metadata with a registry `npmSpec`; exact versions and `expectedIntegrity` are optional pins. If `expectedIntegrity` is present, install/update flows enforce it. Keep the "what to show" metadata in `openclaw.plugin.json` and the "how to install it" metadata in `package.json`. + + + If `minHostVersion` is set, install and manifest-registry loading both enforce it. Older hosts skip the plugin; invalid version strings are rejected. + + + For pinned npm installs, keep the exact version in `npmSpec` and add the expected artifact integrity: -If `minHostVersion` is set, install and manifest-registry loading both enforce -it. Older hosts skip the plugin; invalid version strings are rejected. - -For pinned npm installs, keep the exact version in `npmSpec` and add the -expected artifact integrity: - -```json -{ - "openclaw": { - "install": { - "npmSpec": "@wecom/wecom-openclaw-plugin@1.2.3", - "expectedIntegrity": "sha512-REPLACE_WITH_NPM_DIST_INTEGRITY", - "defaultChoice": "npm" + ```json + { + "openclaw": { + "install": { + "npmSpec": "@wecom/wecom-openclaw-plugin@1.2.3", + "expectedIntegrity": "sha512-REPLACE_WITH_NPM_DIST_INTEGRITY", + "defaultChoice": "npm" + } + } } - } -} -``` + ``` -`allowInvalidConfigRecovery` is not a general bypass for broken configs. It is -for narrow bundled-plugin recovery only, so reinstall/setup can repair known -upgrade leftovers like a missing bundled plugin path or stale `channels.` -entry for that same plugin. If config is broken for unrelated reasons, install -still fails closed and tells the operator to run `openclaw doctor --fix`. + + + `allowInvalidConfigRecovery` is not a general bypass for broken configs. It is for narrow bundled-plugin recovery only, so reinstall/setup can repair known upgrade leftovers like a missing bundled plugin path or stale `channels.` entry for that same plugin. If config is broken for unrelated reasons, install still fails closed and tells the operator to run `openclaw doctor --fix`. + + ### Deferred full load @@ -206,26 +207,17 @@ Channel plugins can opt into deferred loading with: } ``` -When enabled, OpenClaw loads only `setupEntry` during the pre-listen startup -phase, even for already-configured channels. The full entry loads after the -gateway starts listening. +When enabled, OpenClaw loads only `setupEntry` during the pre-listen startup phase, even for already-configured channels. The full entry loads after the gateway starts listening. - Only enable deferred loading when your `setupEntry` registers everything the - gateway needs before it starts listening (channel registration, HTTP routes, - gateway methods). If the full entry owns required startup capabilities, keep - the default behavior. +Only enable deferred loading when your `setupEntry` registers everything the gateway needs before it starts listening (channel registration, HTTP routes, gateway methods). If the full entry owns required startup capabilities, keep the default behavior. -If your setup/full entry registers gateway RPC methods, keep them on a -plugin-specific prefix. Reserved core admin namespaces (`config.*`, -`exec.approvals.*`, `wizard.*`, `update.*`) stay core-owned and always resolve -to `operator.admin`. +If your setup/full entry registers gateway RPC methods, keep them on a plugin-specific prefix. Reserved core admin namespaces (`config.*`, `exec.approvals.*`, `wizard.*`, `update.*`) stay core-owned and always resolve to `operator.admin`. ## Plugin manifest -Every native plugin must ship an `openclaw.plugin.json` in the package root. -OpenClaw uses this to validate config without executing plugin code. +Every native plugin must ship an `openclaw.plugin.json` in the package root. OpenClaw uses this to validate config without executing plugin code. ```json { @@ -272,7 +264,7 @@ Even plugins with no config must ship a schema. An empty schema is valid: } ``` -See [Plugin Manifest](/plugins/manifest) for the full schema reference. +See [Plugin manifest](/plugins/manifest) for the full schema reference. ## ClawHub publishing @@ -283,14 +275,13 @@ clawhub package publish your-org/your-plugin --dry-run clawhub package publish your-org/your-plugin ``` -The legacy skill-only publish alias is for skills. Plugin packages should -always use `clawhub package publish`. + +The legacy skill-only publish alias is for skills. Plugin packages should always use `clawhub package publish`. + ## Setup entry -The `setup-entry.ts` file is a lightweight alternative to `index.ts` that -OpenClaw loads when it only needs setup surfaces (onboarding, config repair, -disabled channel inspection). +The `setup-entry.ts` file is a lightweight alternative to `index.ts` that OpenClaw loads when it only needs setup surfaces (onboarding, config repair, disabled channel inspection). ```typescript // setup-entry.ts @@ -300,41 +291,35 @@ import { myChannelPlugin } from "./src/channel.js"; export default defineSetupPluginEntry(myChannelPlugin); ``` -This avoids loading heavy runtime code (crypto libraries, CLI registrations, -background services) during setup flows. +This avoids loading heavy runtime code (crypto libraries, CLI registrations, background services) during setup flows. -Bundled workspace channels that keep setup-safe exports in sidecar modules can -use `defineBundledChannelSetupEntry(...)` from -`openclaw/plugin-sdk/channel-entry-contract` instead of -`defineSetupPluginEntry(...)`. That bundled contract also supports an optional -`runtime` export so setup-time runtime wiring can stay lightweight and explicit. +Bundled workspace channels that keep setup-safe exports in sidecar modules can use `defineBundledChannelSetupEntry(...)` from `openclaw/plugin-sdk/channel-entry-contract` instead of `defineSetupPluginEntry(...)`. That bundled contract also supports an optional `runtime` export so setup-time runtime wiring can stay lightweight and explicit. -**When OpenClaw uses `setupEntry` instead of the full entry:** + + + - The channel is disabled but needs setup/onboarding surfaces. + - The channel is enabled but unconfigured. + - Deferred loading is enabled (`deferConfiguredChannelFullLoadUntilAfterListen`). + + + - The channel plugin object (via `defineSetupPluginEntry`). + - Any HTTP routes required before gateway listen. + - Any gateway methods needed during startup. -- The channel is disabled but needs setup/onboarding surfaces -- The channel is enabled but unconfigured -- Deferred loading is enabled (`deferConfiguredChannelFullLoadUntilAfterListen`) + Those startup gateway methods should still avoid reserved core admin namespaces such as `config.*` or `update.*`. -**What `setupEntry` must register:** - -- The channel plugin object (via `defineSetupPluginEntry`) -- Any HTTP routes required before gateway listen -- Any gateway methods needed during startup - -Those startup gateway methods should still avoid reserved core admin -namespaces such as `config.*` or `update.*`. - -**What `setupEntry` should NOT include:** - -- CLI registrations -- Background services -- Heavy runtime imports (crypto, SDKs) -- Gateway methods only needed after startup + + + - CLI registrations. + - Background services. + - Heavy runtime imports (crypto, SDKs). + - Gateway methods only needed after startup. + + ### Narrow setup helper imports -For hot setup-only paths, prefer the narrow setup helper seams over the broader -`plugin-sdk/setup` umbrella when you only need part of the setup surface: +For hot setup-only paths, prefer the narrow setup helper seams over the broader `plugin-sdk/setup` umbrella when you only need part of the setup surface: | Import path | Use it for | Key exports | | ---------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -342,41 +327,27 @@ For hot setup-only paths, prefer the narrow setup helper seams over the broader | `plugin-sdk/setup-adapter-runtime` | environment-aware account setup adapters | `createEnvPatchedAccountSetupAdapter` | | `plugin-sdk/setup-tools` | setup/install CLI/archive/docs helpers | `formatCliCommand`, `detectBinary`, `extractArchive`, `resolveBrewExecutable`, `formatDocsLink`, `CONFIG_DIR` | -Use the broader `plugin-sdk/setup` seam when you want the full shared setup -toolbox, including config-patch helpers such as -`moveSingleAccountChannelSectionToDefaultAccount(...)`. +Use the broader `plugin-sdk/setup` seam when you want the full shared setup toolbox, including config-patch helpers such as `moveSingleAccountChannelSectionToDefaultAccount(...)`. -The setup patch adapters stay hot-path safe on import. Their bundled -single-account promotion contract-surface lookup is lazy, so importing -`plugin-sdk/setup-runtime` does not eagerly load bundled contract-surface -discovery before the adapter is actually used. +The setup patch adapters stay hot-path safe on import. Their bundled single-account promotion contract-surface lookup is lazy, so importing `plugin-sdk/setup-runtime` does not eagerly load bundled contract-surface discovery before the adapter is actually used. ### Channel-owned single-account promotion -When a channel upgrades from a single-account top-level config to -`channels..accounts.*`, the default shared behavior is to move promoted -account-scoped values into `accounts.default`. +When a channel upgrades from a single-account top-level config to `channels..accounts.*`, the default shared behavior is to move promoted account-scoped values into `accounts.default`. -Bundled channels can narrow or override that promotion through their setup -contract surface: +Bundled channels can narrow or override that promotion through their setup contract surface: -- `singleAccountKeysToMove`: extra top-level keys that should move into the - promoted account -- `namedAccountPromotionKeys`: when named accounts already exist, only these - keys move into the promoted account; shared policy/delivery keys stay at the - channel root -- `resolveSingleAccountPromotionTarget(...)`: choose which existing account - receives promoted values +- `singleAccountKeysToMove`: extra top-level keys that should move into the promoted account +- `namedAccountPromotionKeys`: when named accounts already exist, only these keys move into the promoted account; shared policy/delivery keys stay at the channel root +- `resolveSingleAccountPromotionTarget(...)`: choose which existing account receives promoted values -Matrix is the current bundled example. If exactly one named Matrix account -already exists, or if `defaultAccount` points at an existing non-canonical key -such as `Ops`, promotion preserves that account instead of creating a new -`accounts.default` entry. + +Matrix is the current bundled example. If exactly one named Matrix account already exists, or if `defaultAccount` points at an existing non-canonical key such as `Ops`, promotion preserves that account instead of creating a new `accounts.default` entry. + ## Config schema -Plugin config is validated against the JSON Schema in your manifest. Users -configure plugins via: +Plugin config is validated against the JSON Schema in your manifest. Users configure plugins via: ```json5 { @@ -409,8 +380,7 @@ For channel-specific config, use the channel config section instead: ### Building channel config schemas -Use `buildChannelConfigSchema` to convert a Zod schema into the -`ChannelConfigSchema` wrapper used by plugin-owned config artifacts: +Use `buildChannelConfigSchema` to convert a Zod schema into the `ChannelConfigSchema` wrapper used by plugin-owned config artifacts: ```typescript import { z } from "zod"; @@ -426,15 +396,11 @@ const accountSchema = z.object({ const configSchema = buildChannelConfigSchema(accountSchema); ``` -For third-party plugins, the cold-path contract is still the plugin manifest: -mirror the generated JSON Schema into `openclaw.plugin.json#channelConfigs` so -config schema, setup, and UI surfaces can inspect `channels.` without -loading runtime code. +For third-party plugins, the cold-path contract is still the plugin manifest: mirror the generated JSON Schema into `openclaw.plugin.json#channelConfigs` so config schema, setup, and UI surfaces can inspect `channels.` without loading runtime code. ## Setup wizards -Channel plugins can provide interactive setup wizards for `openclaw onboard`. -The wizard is a `ChannelSetupWizard` object on the `ChannelPlugin`: +Channel plugins can provide interactive setup wizards for `openclaw onboard`. The wizard is a `ChannelSetupWizard` object on the `ChannelPlugin`: ```typescript import type { ChannelSetupWizard } from "openclaw/plugin-sdk/channel-setup"; @@ -467,84 +433,75 @@ const setupWizard: ChannelSetupWizard = { }; ``` -The `ChannelSetupWizard` type supports `credentials`, `textInputs`, -`dmPolicy`, `allowFrom`, `groupAccess`, `prepare`, `finalize`, and more. -See bundled plugin packages (for example the Discord plugin `src/channel.setup.ts`) for -full examples. +The `ChannelSetupWizard` type supports `credentials`, `textInputs`, `dmPolicy`, `allowFrom`, `groupAccess`, `prepare`, `finalize`, and more. See bundled plugin packages (for example the Discord plugin `src/channel.setup.ts`) for full examples. -For DM allowlist prompts that only need the standard -`note -> prompt -> parse -> merge -> patch` flow, prefer the shared setup -helpers from `openclaw/plugin-sdk/setup`: `createPromptParsedAllowFromForAccount(...)`, -`createTopLevelChannelParsedAllowFromPrompt(...)`, and -`createNestedChannelParsedAllowFromPrompt(...)`. + + + For DM allowlist prompts that only need the standard `note -> prompt -> parse -> merge -> patch` flow, prefer the shared setup helpers from `openclaw/plugin-sdk/setup`: `createPromptParsedAllowFromForAccount(...)`, `createTopLevelChannelParsedAllowFromPrompt(...)`, and `createNestedChannelParsedAllowFromPrompt(...)`. + + + For channel setup status blocks that only vary by labels, scores, and optional extra lines, prefer `createStandardChannelSetupStatus(...)` from `openclaw/plugin-sdk/setup` instead of hand-rolling the same `status` object in each plugin. + + + For optional setup surfaces that should only appear in certain contexts, use `createOptionalChannelSetupSurface` from `openclaw/plugin-sdk/channel-setup`: -For channel setup status blocks that only vary by labels, scores, and optional -extra lines, prefer `createStandardChannelSetupStatus(...)` from -`openclaw/plugin-sdk/setup` instead of hand-rolling the same `status` object in -each plugin. + ```typescript + import { createOptionalChannelSetupSurface } from "openclaw/plugin-sdk/channel-setup"; -For optional setup surfaces that should only appear in certain contexts, use -`createOptionalChannelSetupSurface` from `openclaw/plugin-sdk/channel-setup`: + const setupSurface = createOptionalChannelSetupSurface({ + channel: "my-channel", + label: "My Channel", + npmSpec: "@myorg/openclaw-my-channel", + docsPath: "/channels/my-channel", + }); + // Returns { setupAdapter, setupWizard } + ``` -```typescript -import { createOptionalChannelSetupSurface } from "openclaw/plugin-sdk/channel-setup"; + `plugin-sdk/channel-setup` also exposes the lower-level `createOptionalChannelSetupAdapter(...)` and `createOptionalChannelSetupWizard(...)` builders when you only need one half of that optional-install surface. -const setupSurface = createOptionalChannelSetupSurface({ - channel: "my-channel", - label: "My Channel", - npmSpec: "@myorg/openclaw-my-channel", - docsPath: "/channels/my-channel", -}); -// Returns { setupAdapter, setupWizard } -``` + The generated optional adapter/wizard fail closed on real config writes. They reuse one install-required message across `validateInput`, `applyAccountConfig`, and `finalize`, and append a docs link when `docsPath` is set. -`plugin-sdk/channel-setup` also exposes the lower-level -`createOptionalChannelSetupAdapter(...)` and -`createOptionalChannelSetupWizard(...)` builders when you only need one half of -that optional-install surface. + + + For binary-backed setup UIs, prefer the shared delegated helpers instead of copying the same binary/status glue into every channel: -The generated optional adapter/wizard fail closed on real config writes. They -reuse one install-required message across `validateInput`, -`applyAccountConfig`, and `finalize`, and append a docs link when `docsPath` is -set. + - `createDetectedBinaryStatus(...)` for status blocks that vary only by labels, hints, scores, and binary detection + - `createCliPathTextInput(...)` for path-backed text inputs + - `createDelegatedSetupWizardStatusResolvers(...)`, `createDelegatedPrepare(...)`, `createDelegatedFinalize(...)`, and `createDelegatedResolveConfigured(...)` when `setupEntry` needs to forward to a heavier full wizard lazily + - `createDelegatedTextInputShouldPrompt(...)` when `setupEntry` only needs to delegate a `textInputs[*].shouldPrompt` decision -For binary-backed setup UIs, prefer the shared delegated helpers instead of -copying the same binary/status glue into every channel: - -- `createDetectedBinaryStatus(...)` for status blocks that vary only by labels, - hints, scores, and binary detection -- `createCliPathTextInput(...)` for path-backed text inputs -- `createDelegatedSetupWizardStatusResolvers(...)`, - `createDelegatedPrepare(...)`, `createDelegatedFinalize(...)`, and - `createDelegatedResolveConfigured(...)` when `setupEntry` needs to forward to - a heavier full wizard lazily -- `createDelegatedTextInputShouldPrompt(...)` when `setupEntry` only needs to - delegate a `textInputs[*].shouldPrompt` decision + + ## Publishing and installing **External plugins:** publish to [ClawHub](/tools/clawhub) or npm, then install: -```bash -openclaw plugins install @myorg/openclaw-my-plugin -``` + + + ```bash + openclaw plugins install @myorg/openclaw-my-plugin + ``` -OpenClaw tries ClawHub first and falls back to npm automatically. You can also -force ClawHub explicitly: + OpenClaw tries ClawHub first and falls back to npm automatically. -```bash -openclaw plugins install clawhub:@myorg/openclaw-my-plugin # ClawHub only -``` + + + ```bash + openclaw plugins install clawhub:@myorg/openclaw-my-plugin + ``` + + + There is no matching `npm:` override. Use the normal npm package spec when you want the npm path after ClawHub fallback: -There is no matching `npm:` override. Use the normal npm package spec when you -want the npm path after ClawHub fallback: + ```bash + openclaw plugins install @myorg/openclaw-my-plugin + ``` -```bash -openclaw plugins install @myorg/openclaw-my-plugin -``` + + -**In-repo plugins:** place under the bundled plugin workspace tree and they are automatically -discovered during build. +**In-repo plugins:** place under the bundled plugin workspace tree and they are automatically discovered during build. **Users can install:** @@ -553,20 +510,15 @@ openclaw plugins install ``` - For npm-sourced installs, `openclaw plugins install` runs - project-local `npm install --ignore-scripts` (no lifecycle scripts), ignoring - inherited global npm install settings. Keep plugin dependency trees pure JS/TS - and avoid packages that require `postinstall` builds. +For npm-sourced installs, `openclaw plugins install` runs project-local `npm install --ignore-scripts` (no lifecycle scripts), ignoring inherited global npm install settings. Keep plugin dependency trees pure JS/TS and avoid packages that require `postinstall` builds. -Bundled OpenClaw-owned plugins are the only startup repair exception: when a -packaged install sees one enabled by plugin config, legacy channel config, or -its bundled default-enabled manifest, startup installs that plugin's missing -runtime dependencies before import. Third-party plugins should not rely on -startup installs; keep using the explicit plugin installer. + +Bundled OpenClaw-owned plugins are the only startup repair exception: when a packaged install sees one enabled by plugin config, legacy channel config, or its bundled default-enabled manifest, startup installs that plugin's missing runtime dependencies before import. Third-party plugins should not rely on startup installs; keep using the explicit plugin installer. + ## Related -- [SDK entry points](/plugins/sdk-entrypoints) — `definePluginEntry` and `defineChannelPluginEntry` -- [Plugin manifest](/plugins/manifest) — full manifest schema reference - [Building plugins](/plugins/building-plugins) — step-by-step getting started guide +- [Plugin manifest](/plugins/manifest) — full manifest schema reference +- [SDK entry points](/plugins/sdk-entrypoints) — `definePluginEntry` and `defineChannelPluginEntry` From 0b6ebf33434be038ffbb988e96a85ed35d641b63 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 08:39:12 +0100 Subject: [PATCH 16/25] fix(doctor): honor external service repair policy --- .../doctor-gateway-daemon-flow.test.ts | 71 +++++++++++++++++++ src/commands/doctor-gateway-daemon-flow.ts | 65 +++++++++++++---- src/commands/doctor-gateway-services.test.ts | 56 +++++++++++++++ src/commands/doctor-gateway-services.ts | 63 ++++++++++++---- src/commands/doctor-prompter.ts | 13 ++++ src/commands/doctor-service-repair-policy.ts | 48 +++++++++++++ 6 files changed, 290 insertions(+), 26 deletions(-) create mode 100644 src/commands/doctor-service-repair-policy.ts diff --git a/src/commands/doctor-gateway-daemon-flow.test.ts b/src/commands/doctor-gateway-daemon-flow.test.ts index 2aac08f2155..d8d66f31527 100644 --- a/src/commands/doctor-gateway-daemon-flow.test.ts +++ b/src/commands/doctor-gateway-daemon-flow.test.ts @@ -1,5 +1,8 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import * as launchd from "../daemon/launchd.js"; +import { withEnvAsync } from "../test-utils/env.js"; import { createDoctorPrompter } from "./doctor-prompter.js"; +import { EXTERNAL_SERVICE_REPAIR_NOTE } from "./doctor-service-repair-policy.js"; const service = vi.hoisted(() => ({ isLoaded: vi.fn(), @@ -187,6 +190,22 @@ describe("maybeRepairGatewayDaemon", () => { }); } + async function runAutoRepair() { + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + await maybeRepairGatewayDaemon({ + cfg: { gateway: {} }, + runtime, + prompter: createDoctorPrompter({ + runtime, + options: { repair: true }, + }), + options: { deep: false, repair: true }, + gatewayDetailsMessage: "details", + healthOk: false, + }); + return runtime; + } + async function runScheduledGatewayRepair(confirmMessage: string) { setPlatform("linux"); service.restart.mockResolvedValueOnce({ outcome: "scheduled" }); @@ -235,4 +254,56 @@ describe("maybeRepairGatewayDaemon", () => { expect(service.restart).not.toHaveBeenCalled(); }); + + it("skips gateway service install when service repair policy is external", async () => { + setPlatform("linux"); + service.isLoaded.mockResolvedValue(false); + + await withEnvAsync({ OPENCLAW_SERVICE_REPAIR_POLICY: "external" }, async () => { + await runAutoRepair(); + }); + + expect(service.install).not.toHaveBeenCalled(); + expect(service.restart).not.toHaveBeenCalled(); + expect(note).toHaveBeenCalledWith(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway"); + }); + + it("skips gateway service start when service repair policy is external", async () => { + setPlatform("linux"); + service.readRuntime.mockResolvedValue({ status: "stopped" }); + + await withEnvAsync({ OPENCLAW_SERVICE_REPAIR_POLICY: "external" }, async () => { + await runAutoRepair(); + }); + + expect(service.restart).not.toHaveBeenCalled(); + expect(note).toHaveBeenCalledWith(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway"); + }); + + it("skips gateway service restart when service repair policy is external", async () => { + setPlatform("linux"); + + await withEnvAsync({ OPENCLAW_SERVICE_REPAIR_POLICY: "external" }, async () => { + await runAutoRepair(); + }); + + expect(service.restart).not.toHaveBeenCalled(); + expect(note).toHaveBeenCalledWith(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway"); + }); + + it("skips LaunchAgent bootstrap repair when service repair policy is external", async () => { + setPlatform("darwin"); + service.isLoaded.mockResolvedValue(false); + vi.mocked(launchd.isLaunchAgentListed).mockResolvedValue(true); + vi.mocked(launchd.isLaunchAgentLoaded).mockResolvedValue(false); + vi.mocked(launchd.launchAgentPlistExists).mockResolvedValue(true); + + await withEnvAsync({ OPENCLAW_SERVICE_REPAIR_POLICY: "external" }, async () => { + await runAutoRepair(); + }); + + expect(launchd.repairLaunchAgentBootstrap).not.toHaveBeenCalled(); + expect(service.install).not.toHaveBeenCalled(); + expect(note).toHaveBeenCalledWith(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway LaunchAgent"); + }); }); diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts index ccc865bd3b6..ff03a068fa9 100644 --- a/src/commands/doctor-gateway-daemon-flow.ts +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -28,6 +28,12 @@ import { } from "./daemon-runtime.js"; import { buildGatewayRuntimeHints, formatGatewayRuntimeSummary } from "./doctor-format.js"; import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js"; +import { + confirmDoctorServiceRepair, + EXTERNAL_SERVICE_REPAIR_NOTE, + isServiceRepairExternallyManaged, + resolveServiceRepairPolicy, +} from "./doctor-service-repair-policy.js"; import { resolveGatewayInstallToken } from "./gateway-install-token.js"; import { formatHealthCheckFailure } from "./health-format.js"; import { healthCommand } from "./health.js"; @@ -37,6 +43,7 @@ async function maybeRepairLaunchAgentBootstrap(params: { title: string; runtime: RuntimeEnv; prompter: DoctorPrompter; + serviceRepairExternal: boolean; }): Promise { if (process.platform !== "darwin") { return false; @@ -58,8 +65,12 @@ async function maybeRepairLaunchAgentBootstrap(params: { } note("LaunchAgent is listed but not loaded in launchd.", `${params.title} LaunchAgent`); + if (params.serviceRepairExternal) { + note(EXTERNAL_SERVICE_REPAIR_NOTE, `${params.title} LaunchAgent`); + return false; + } - const shouldFix = await params.prompter.confirmRuntimeRepair({ + const shouldFix = await confirmDoctorServiceRepair(params.prompter, { message: `Repair ${params.title} LaunchAgent bootstrap now?`, initialValue: true, }); @@ -98,6 +109,8 @@ export async function maybeRepairGatewayDaemon(params: { return; } + const serviceRepairPolicy = resolveServiceRepairPolicy(); + const serviceRepairExternal = isServiceRepairExternallyManaged(serviceRepairPolicy); const service = resolveGatewayService(); // systemd can throw in containers/WSL; treat as "not loaded" and fall back to hints. let loaded = false; @@ -117,6 +130,7 @@ export async function maybeRepairGatewayDaemon(params: { title: "Gateway", runtime: params.runtime, prompter: params.prompter, + serviceRepairExternal, }); await maybeRepairLaunchAgentBootstrap({ env: { @@ -126,6 +140,7 @@ export async function maybeRepairGatewayDaemon(params: { title: "Node", runtime: params.runtime, prompter: params.prompter, + serviceRepairExternal, }); if (gatewayRepaired) { loaded = await service.isLoaded({ env: process.env }); @@ -162,10 +177,18 @@ export async function maybeRepairGatewayDaemon(params: { } note("Gateway service not installed.", "Gateway"); if (params.cfg.gateway?.mode !== "remote") { - const install = await params.prompter.confirmRuntimeRepair({ - message: "Install gateway service now?", - initialValue: true, - }); + if (serviceRepairExternal) { + note(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway"); + return; + } + const install = await confirmDoctorServiceRepair( + params.prompter, + { + message: "Install gateway service now?", + initialValue: true, + }, + serviceRepairPolicy, + ); if (install) { const daemonRuntime = await params.prompter.select( { @@ -233,10 +256,18 @@ export async function maybeRepairGatewayDaemon(params: { } if (serviceRuntime?.status !== "running") { - const start = await params.prompter.confirmRuntimeRepair({ - message: "Start gateway service now?", - initialValue: true, - }); + if (serviceRepairExternal) { + note(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway"); + return; + } + const start = await confirmDoctorServiceRepair( + params.prompter, + { + message: "Start gateway service now?", + initialValue: true, + }, + serviceRepairPolicy, + ); if (start) { const restartResult = await service.restart({ env: process.env, @@ -260,10 +291,18 @@ export async function maybeRepairGatewayDaemon(params: { } if (serviceRuntime?.status === "running") { - const restart = await params.prompter.confirmRuntimeRepair({ - message: "Restart gateway service now?", - initialValue: true, - }); + if (serviceRepairExternal) { + note(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway"); + return; + } + const restart = await confirmDoctorServiceRepair( + params.prompter, + { + message: "Restart gateway service now?", + initialValue: true, + }, + serviceRepairPolicy, + ); if (restart) { const restartResult = await service.restart({ env: process.env, diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 9da95453d9f..6b09c866fc8 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -99,6 +99,7 @@ import { maybeRepairGatewayServiceConfig, maybeScanExtraGatewayServices, } from "./doctor-gateway-services.js"; +import { EXTERNAL_SERVICE_REPAIR_NOTE } from "./doctor-service-repair-policy.js"; const originalStdinIsTTY = process.stdin.isTTY; const originalUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS; @@ -593,6 +594,31 @@ describe("maybeRepairGatewayServiceConfig", () => { }, ); }); + + it("reports service config drift but skips service rewrite when service repair policy is external", async () => { + await withEnvAsync({ OPENCLAW_SERVICE_REPAIR_POLICY: "external" }, async () => { + setupGatewayEntrypointRepairScenario({ + currentEntrypoint: "/Users/test/Library/npm/node_modules/openclaw/dist/entry.js", + installEntrypoint: "/Users/test/Library/npm/node_modules/openclaw/dist/index.js", + installWorkingDirectory: "/tmp", + }); + + await runRepair({ gateway: {} }); + + expect(mocks.auditGatewayServiceConfig).toHaveBeenCalledTimes(1); + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("Gateway service entrypoint does not match the current install."), + "Gateway service config", + ); + expect(mocks.note).toHaveBeenCalledWith( + EXTERNAL_SERVICE_REPAIR_NOTE, + "Gateway service config", + ); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + expect(mocks.stage).not.toHaveBeenCalled(); + expect(mocks.install).not.toHaveBeenCalled(); + }); + }); }); describe("maybeScanExtraGatewayServices", () => { @@ -655,4 +681,34 @@ describe("maybeScanExtraGatewayServices", () => { "Legacy gateway services removed. Installing OpenClaw gateway next.", ); }); + + it("reports legacy services but skips cleanup when service repair policy is external", async () => { + await withEnvAsync({ OPENCLAW_SERVICE_REPAIR_POLICY: "external" }, async () => { + mocks.findExtraGatewayServices.mockResolvedValue([ + { + platform: "linux", + label: "clawdbot-gateway.service", + detail: "unit: /home/test/.config/systemd/user/clawdbot-gateway.service", + scope: "user", + legacy: true, + }, + ]); + + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + await maybeScanExtraGatewayServices({ deep: false }, runtime, makeDoctorPrompts()); + + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("clawdbot-gateway.service"), + "Other gateway-like services detected", + ); + expect(mocks.note).toHaveBeenCalledWith( + EXTERNAL_SERVICE_REPAIR_NOTE, + "Legacy gateway cleanup skipped", + ); + expect(mocks.uninstallLegacySystemdUnits).not.toHaveBeenCalled(); + expect(runtime.log).not.toHaveBeenCalledWith( + "Legacy gateway services removed. Installing OpenClaw gateway next.", + ); + }); + }); }); diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 4aa79502059..35d411d2211 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -31,6 +31,12 @@ import { DEFAULT_GATEWAY_DAEMON_RUNTIME, type GatewayDaemonRuntime } from "./dae import { resolveGatewayAuthTokenForService } from "./doctor-gateway-auth-token.js"; import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js"; import { isDoctorUpdateRepairMode } from "./doctor-repair-mode.js"; +import { + confirmDoctorServiceRepair, + EXTERNAL_SERVICE_REPAIR_NOTE, + isServiceRepairExternallyManaged, + resolveServiceRepairPolicy, +} from "./doctor-service-repair-policy.js"; const execFileAsync = promisify(execFile); @@ -302,6 +308,9 @@ export async function maybeRepairGatewayServiceConfig( return; } + const serviceRepairPolicy = resolveServiceRepairPolicy(); + const serviceRepairExternal = isServiceRepairExternallyManaged(serviceRepairPolicy); + note( audit.issues .map((issue) => @@ -321,15 +330,32 @@ export async function maybeRepairGatewayServiceConfig( ); } - const repair = needsAggressive - ? await prompter.confirmAggressiveAutoFix({ - message: "Overwrite gateway service config with current defaults now?", - initialValue: prompter.shouldForce, - }) - : await prompter.confirmAutoFix({ - message: "Update gateway service config to the recommended defaults now?", - initialValue: true, - }); + if (serviceRepairExternal) { + note(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway service config"); + return; + } + + const repair = + serviceRepairPolicy === "prompt" + ? await confirmDoctorServiceRepair( + prompter, + { + message: needsAggressive + ? "Overwrite gateway service config with current defaults now?" + : "Update gateway service config to the recommended defaults now?", + initialValue: needsAggressive ? prompter.shouldForce : true, + }, + serviceRepairPolicy, + ) + : needsAggressive + ? await prompter.confirmAggressiveAutoFix({ + message: "Overwrite gateway service config with current defaults now?", + initialValue: prompter.shouldForce, + }) + : await prompter.confirmAutoFix({ + message: "Update gateway service config to the recommended defaults now?", + initialValue: true, + }); if (!repair) { return; } @@ -414,10 +440,21 @@ export async function maybeScanExtraGatewayServices( const legacyServices = extraServices.filter((svc) => svc.legacy === true); if (legacyServices.length > 0) { - const shouldRemove = await prompter.confirmRuntimeRepair({ - message: "Remove legacy gateway services now?", - initialValue: true, - }); + const serviceRepairPolicy = resolveServiceRepairPolicy(); + const serviceRepairExternal = isServiceRepairExternallyManaged(serviceRepairPolicy); + if (serviceRepairExternal) { + note(EXTERNAL_SERVICE_REPAIR_NOTE, "Legacy gateway cleanup skipped"); + } + const shouldRemove = serviceRepairExternal + ? false + : await confirmDoctorServiceRepair( + prompter, + { + message: "Remove legacy gateway services now?", + initialValue: true, + }, + serviceRepairPolicy, + ); if (shouldRemove) { const removed: string[] = []; const { darwinUserServices, linuxUserServices, failed } = diff --git a/src/commands/doctor-prompter.ts b/src/commands/doctor-prompter.ts index 32f08f53d5b..cfd40385d5c 100644 --- a/src/commands/doctor-prompter.ts +++ b/src/commands/doctor-prompter.ts @@ -16,6 +16,7 @@ export type DoctorPrompter = { confirmAutoFix: (params: Parameters[0]) => Promise; confirmAggressiveAutoFix: (params: Parameters[0]) => Promise; confirmRuntimeRepair: (params: Parameters[0]) => Promise; + confirmServiceRepair?: (params: Parameters[0]) => Promise; select: (params: Parameters[0], fallback: T) => Promise; shouldRepair: boolean; shouldForce: boolean; @@ -88,6 +89,18 @@ export function createDoctorPrompter(params: { params.runtime, ); }, + confirmServiceRepair: async (p) => { + if (repairMode.nonInteractive || !repairMode.canPrompt) { + return false; + } + return guardCancel( + await confirm({ + ...p, + message: stylePromptMessage(p.message), + }), + params.runtime, + ); + }, select: async (p: Parameters[0], fallback: T) => { if (!repairMode.canPrompt || repairMode.shouldRepair) { return fallback; diff --git a/src/commands/doctor-service-repair-policy.ts b/src/commands/doctor-service-repair-policy.ts new file mode 100644 index 00000000000..f2b64d967e3 --- /dev/null +++ b/src/commands/doctor-service-repair-policy.ts @@ -0,0 +1,48 @@ +import type { DoctorPrompter } from "./doctor-prompter.js"; + +export type ServiceRepairPolicy = "auto" | "prompt" | "external" | "disabled"; + +export const SERVICE_REPAIR_POLICY_ENV = "OPENCLAW_SERVICE_REPAIR_POLICY"; + +export const EXTERNAL_SERVICE_REPAIR_NOTE = + "Gateway service is managed externally; skipped service install/start repair. Start or repair the gateway through your supervisor."; + +export function resolveServiceRepairPolicy( + env: NodeJS.ProcessEnv = process.env, +): ServiceRepairPolicy { + const value = env[SERVICE_REPAIR_POLICY_ENV]?.trim().toLowerCase(); + switch (value) { + case "auto": + case "prompt": + case "external": + case "disabled": + return value; + default: + return "auto"; + } +} + +export function isServiceRepairExternallyManaged( + policy: ServiceRepairPolicy = resolveServiceRepairPolicy(), +): boolean { + return policy === "external" || policy === "disabled"; +} + +export async function confirmDoctorServiceRepair( + prompter: DoctorPrompter, + params: Parameters[0], + policy: ServiceRepairPolicy = resolveServiceRepairPolicy(), +): Promise { + if (isServiceRepairExternallyManaged(policy)) { + return false; + } + + if (policy === "prompt") { + if (!prompter.repairMode.canPrompt) { + return false; + } + return await (prompter.confirmServiceRepair?.(params) ?? prompter.confirmRuntimeRepair(params)); + } + + return await prompter.confirmRuntimeRepair(params); +} From 6cf5a5fbcd5023a330ad145bb81f72e5c2348e3c Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 08:40:30 +0100 Subject: [PATCH 17/25] docs: document external service repair policy --- CHANGELOG.md | 1 + docs/cli/doctor.md | 1 + docs/gateway/doctor.md | 1 + 3 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3983166843b..e936aac2aba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ Docs: https://docs.openclaw.ai ### 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. - 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/cli/doctor.md b/docs/cli/doctor.md index 6e61b15860f..c3ac4c0c16b 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -44,6 +44,7 @@ Notes: - State integrity checks now detect orphan transcript files in the sessions directory and can archive them as `.deleted.` to reclaim space safely. - Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime. - Doctor repairs missing bundled plugin runtime dependencies without writing into packaged global installs. For root-owned npm installs or hardened systemd units, set `OPENCLAW_PLUGIN_STAGE_DIR` to a writable directory such as `/var/lib/openclaw/plugin-runtime-deps`. +- Set `OPENCLAW_SERVICE_REPAIR_POLICY=external` when another supervisor owns the gateway lifecycle. Doctor still reports gateway/service health and applies non-service repairs, but skips service install/start/restart/bootstrap and legacy service cleanup. - Doctor auto-migrates legacy flat Talk config (`talk.voiceId`, `talk.modelId`, and friends) into `talk.provider` + `talk.providers.`. - Repeat `doctor --fix` runs no longer report/apply Talk normalization when the only difference is object key order. - Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 8be867834c4..ccce6cc8de1 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -430,6 +430,7 @@ That stages grounded durable candidates into the short-term dreaming store while - `openclaw doctor --yes` accepts the default repair prompts. - `openclaw doctor --repair` applies recommended fixes without prompts. - `openclaw doctor --repair --force` overwrites custom supervisor configs. + - `OPENCLAW_SERVICE_REPAIR_POLICY=external` keeps doctor read-only for gateway service lifecycle. It still reports service health and runs non-service repairs, but skips service install/start/restart/bootstrap, supervisor config rewrites, and legacy service cleanup because an external supervisor owns that lifecycle. - If token auth requires a token and `gateway.auth.token` is SecretRef-managed, doctor service install/repair validates the SecretRef but does not persist resolved plaintext token values into supervisor service environment metadata. - If token auth requires a token and the configured token SecretRef is unresolved, doctor blocks the install/repair path with actionable guidance. - If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, doctor blocks install/repair until mode is set explicitly. From 5c0dc93d1e7f2256da50eb02f24d939eea3b465c Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 08:46:42 +0100 Subject: [PATCH 18/25] fix(doctor): keep service repair policy scoped --- CHANGELOG.md | 5 +++- src/commands/doctor-gateway-services.ts | 30 ++++++-------------- src/commands/doctor-prompter.ts | 13 --------- src/commands/doctor-service-repair-policy.ts | 13 ++------- 4 files changed, 15 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e936aac2aba..e8bb30387a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai ## Unreleased +### 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. + ## 2026.4.25 ### Changes @@ -79,7 +83,6 @@ Docs: https://docs.openclaw.ai ### 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. - 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/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 35d411d2211..6eb81e2ce45 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -335,27 +335,15 @@ export async function maybeRepairGatewayServiceConfig( return; } - const repair = - serviceRepairPolicy === "prompt" - ? await confirmDoctorServiceRepair( - prompter, - { - message: needsAggressive - ? "Overwrite gateway service config with current defaults now?" - : "Update gateway service config to the recommended defaults now?", - initialValue: needsAggressive ? prompter.shouldForce : true, - }, - serviceRepairPolicy, - ) - : needsAggressive - ? await prompter.confirmAggressiveAutoFix({ - message: "Overwrite gateway service config with current defaults now?", - initialValue: prompter.shouldForce, - }) - : await prompter.confirmAutoFix({ - message: "Update gateway service config to the recommended defaults now?", - initialValue: true, - }); + const repair = needsAggressive + ? await prompter.confirmAggressiveAutoFix({ + message: "Overwrite gateway service config with current defaults now?", + initialValue: prompter.shouldForce, + }) + : await prompter.confirmAutoFix({ + message: "Update gateway service config to the recommended defaults now?", + initialValue: true, + }); if (!repair) { return; } diff --git a/src/commands/doctor-prompter.ts b/src/commands/doctor-prompter.ts index cfd40385d5c..32f08f53d5b 100644 --- a/src/commands/doctor-prompter.ts +++ b/src/commands/doctor-prompter.ts @@ -16,7 +16,6 @@ export type DoctorPrompter = { confirmAutoFix: (params: Parameters[0]) => Promise; confirmAggressiveAutoFix: (params: Parameters[0]) => Promise; confirmRuntimeRepair: (params: Parameters[0]) => Promise; - confirmServiceRepair?: (params: Parameters[0]) => Promise; select: (params: Parameters[0], fallback: T) => Promise; shouldRepair: boolean; shouldForce: boolean; @@ -89,18 +88,6 @@ export function createDoctorPrompter(params: { params.runtime, ); }, - confirmServiceRepair: async (p) => { - if (repairMode.nonInteractive || !repairMode.canPrompt) { - return false; - } - return guardCancel( - await confirm({ - ...p, - message: stylePromptMessage(p.message), - }), - params.runtime, - ); - }, select: async (p: Parameters[0], fallback: T) => { if (!repairMode.canPrompt || repairMode.shouldRepair) { return fallback; diff --git a/src/commands/doctor-service-repair-policy.ts b/src/commands/doctor-service-repair-policy.ts index f2b64d967e3..683fe6cdfe0 100644 --- a/src/commands/doctor-service-repair-policy.ts +++ b/src/commands/doctor-service-repair-policy.ts @@ -1,6 +1,6 @@ import type { DoctorPrompter } from "./doctor-prompter.js"; -export type ServiceRepairPolicy = "auto" | "prompt" | "external" | "disabled"; +export type ServiceRepairPolicy = "auto" | "external"; export const SERVICE_REPAIR_POLICY_ENV = "OPENCLAW_SERVICE_REPAIR_POLICY"; @@ -13,9 +13,7 @@ export function resolveServiceRepairPolicy( const value = env[SERVICE_REPAIR_POLICY_ENV]?.trim().toLowerCase(); switch (value) { case "auto": - case "prompt": case "external": - case "disabled": return value; default: return "auto"; @@ -25,7 +23,7 @@ export function resolveServiceRepairPolicy( export function isServiceRepairExternallyManaged( policy: ServiceRepairPolicy = resolveServiceRepairPolicy(), ): boolean { - return policy === "external" || policy === "disabled"; + return policy === "external"; } export async function confirmDoctorServiceRepair( @@ -37,12 +35,5 @@ export async function confirmDoctorServiceRepair( return false; } - if (policy === "prompt") { - if (!prompter.repairMode.canPrompt) { - return false; - } - return await (prompter.confirmServiceRepair?.(params) ?? prompter.confirmRuntimeRepair(params)); - } - return await prompter.confirmRuntimeRepair(params); } From c99d72575eced4f93ad0e4fb1ebe5c9855587bc8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:07:53 +0100 Subject: [PATCH 19/25] fix(release): reject staged runtime deps in packs --- CHANGELOG.md | 1 + package.json | 1 + scripts/openclaw-cross-os-release-checks.ts | 4 +- scripts/release-check.ts | 2 + src/infra/package-dist-inventory.test.ts | 162 ++++++++++++++++++ src/infra/package-dist-inventory.ts | 108 +++++++++++- test/release-check.test.ts | 2 + .../openclaw-cross-os-release-checks.test.ts | 25 +++ 8 files changed, 302 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8bb30387a8..0b4dda8396c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ Docs: https://docs.openclaw.ai - Gateway/install: refresh loaded gateway service installs when the current service embeds stale gateway auth instead of returning already-installed, avoiding LaunchAgent token-mismatch loops after token rotation. Fixes #70752. Thanks @hyspacex. - Update: ignore bundled plugin `.openclaw-install-stage` directories during global install verification and packaged dist pruning so leftover runtime-dep staging files do not turn successful updates into `unexpected packaged dist file` failures. Fixes #71752. Thanks @waynegault. - CLI/update: fail package updates when post-update plugin sync fails and refresh legacy npm plugin install records before trusting unchanged artifacts, preventing successful updates from restarting with stale or failed plugin state. Thanks @vincentkoc and @shakkernerd. +- Release/update: reject pre-populated bundled plugin `.openclaw-install-stage` directories, including mixed-case path variants, before package inventory generation so release tarballs cannot ship poisoned runtime-dependency staging debris. Fixes #71752. Thanks @hclsys. - Node runtime: keep node-host retry timers alive across Gateway restarts and exit on terminal credential pauses so supervised nodes do not become silent zombies. Fixes #69800. Thanks @meroli28. - Gateway/plugins: stop persisted WhatsApp auth state from activating bundled channel runtime-dependency repair during startup when `channels.whatsapp` is absent, avoiding npm/git stalls on packaged Linux installs. Fixes #71994. Thanks @xiao398008. - Gateway/device tokens: enforce caller-scope containment inside token rotation and revocation so pairing-only sessions cannot mutate higher-scope operator tokens. Fixes #71990. Thanks @coygeek. diff --git a/package.json b/package.json index 5ccadca226b..843784ef09d 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "dist/", "!dist/**/*.map", "!dist/plugin-sdk/.tsbuildinfo", + "!dist/extensions/*/.openclaw-install-stage*/**", "!dist/extensions/*/.openclaw-runtime-deps-*/**", "!dist/extensions/*/.openclaw-runtime-deps-stamp.json", "!dist/extensions/node_modules/**", diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts index 252bd813d24..e095964a5cb 100644 --- a/scripts/openclaw-cross-os-release-checks.ts +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -18,6 +18,7 @@ import { createConnection as createNetConnection, createServer as createNetServe import { tmpdir } from "node:os"; import { dirname, join, resolve, win32 as pathWin32 } from "node:path"; import { fileURLToPath } from "node:url"; +import { assertNoBundledRuntimeDepsStagingDebris } from "../src/infra/package-dist-inventory.ts"; const SCRIPT_PATH = fileURLToPath(import.meta.url); const PUBLISHED_INSTALLER_BASE_URL = "https://openclaw.ai"; @@ -482,7 +483,8 @@ function isPackagedDistPath(relativePath) { return true; } -async function writePackageDistInventoryForCandidate(params) { +export async function writePackageDistInventoryForCandidate(params) { + await assertNoBundledRuntimeDepsStagingDebris(params.sourceDir); const dryRun = await runCommand( npmCommand(), ["pack", "--dry-run", "--ignore-scripts", "--json"], diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 3a239eb720a..1c6746f9e97 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -15,6 +15,7 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { + isBundledRuntimeDepsInstallStagePath, PACKAGE_DIST_INVENTORY_RELATIVE_PATH, writePackageDistInventory, } from "../src/infra/package-dist-inventory.ts"; @@ -585,6 +586,7 @@ export function collectForbiddenPackPaths(paths: Iterable): string[] { return [...paths] .filter( (path) => + isBundledRuntimeDepsInstallStagePath(path) || forbiddenPrefixes.some((prefix) => path.startsWith(prefix)) || /(^|\/)\.openclaw-runtime-deps-[^/]+(\/|$)/u.test(path) || path.endsWith("/.openclaw-runtime-deps-stamp.json") || diff --git a/src/infra/package-dist-inventory.test.ts b/src/infra/package-dist-inventory.test.ts index 0196c506e43..292077d1883 100644 --- a/src/infra/package-dist-inventory.test.ts +++ b/src/infra/package-dist-inventory.test.ts @@ -3,9 +3,12 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { + assertNoBundledRuntimeDepsStagingDebris, + collectBundledRuntimeDepsStagingDebrisPaths, collectPackageDistInventoryErrors, PACKAGE_DIST_INVENTORY_RELATIVE_PATH, collectPackageDistInventory, + isBundledRuntimeDepsInstallStagePath, writePackageDistInventory, } from "./package-dist-inventory.js"; @@ -152,6 +155,165 @@ describe("package dist inventory", () => { ]); }); }); + + it("ignores runtime-created install staging dirs during installed dist verification", async () => { + await withTempDir({ prefix: "openclaw-dist-inventory-stage-" }, async (packageRoot) => { + const realFile = path.join(packageRoot, "dist", "real-AbC123.js"); + await fs.mkdir(path.dirname(realFile), { recursive: true }); + await fs.writeFile(realFile, "export {};\n", "utf8"); + await writePackageDistInventory(packageRoot); + + const bareStageFile = path.join( + packageRoot, + "dist", + "extensions", + "brave", + ".openclaw-install-stage", + "node_modules", + "typebox", + "build", + "compile", + "code.mjs", + ); + const suffixedStageFile = path.join( + packageRoot, + "dist", + "extensions", + "browser", + ".openclaw-install-stage-AbC123", + "node_modules", + "playwright-core", + "package.json", + ); + await fs.mkdir(path.dirname(bareStageFile), { recursive: true }); + await fs.writeFile(bareStageFile, "// staged\n", "utf8"); + await fs.mkdir(path.dirname(suffixedStageFile), { recursive: true }); + await fs.writeFile(suffixedStageFile, "{}", "utf8"); + + await expect(collectPackageDistInventoryErrors(packageRoot)).resolves.toEqual([]); + }); + }); + + it("matches install-stage paths case-insensitively across path segments", () => { + expect( + isBundledRuntimeDepsInstallStagePath( + "dist/extensions/brave/.openclaw-install-stage/node_modules/typebox/package.json", + ), + ).toBe(true); + expect( + isBundledRuntimeDepsInstallStagePath( + "dist/Extensions/browser/.OPENCLAW-INSTALL-STAGE-AbC123/node_modules/playwright-core/package.json", + ), + ).toBe(true); + expect( + isBundledRuntimeDepsInstallStagePath( + "Dist/Extensions/browser/.OpenClaw-Install-Stage/package.json", + ), + ).toBe(true); + expect( + isBundledRuntimeDepsInstallStagePath( + "dist/extensions/browser/.openclaw-runtime-deps-copy-AbC123/package.json", + ), + ).toBe(false); + expect(isBundledRuntimeDepsInstallStagePath("dist/extensions/.openclaw-install-stage")).toBe( + false, + ); + }); + + it("rejects pre-populated install-stage debris at publish time", async () => { + await withTempDir({ prefix: "openclaw-dist-inventory-stage-publish-" }, async (packageRoot) => { + const seededStagePackageJson = path.join( + packageRoot, + "dist", + "extensions", + "evil", + ".openclaw-install-stage", + "package.json", + ); + const suffixedSeed = path.join( + packageRoot, + "dist", + "extensions", + "browser", + ".openclaw-install-stage-AbC123", + "node_modules", + "playwright-core", + "package.json", + ); + await fs.mkdir(path.dirname(seededStagePackageJson), { recursive: true }); + await fs.writeFile(seededStagePackageJson, "{}", "utf8"); + await fs.mkdir(path.dirname(suffixedSeed), { recursive: true }); + await fs.writeFile(suffixedSeed, "{}", "utf8"); + + await expect(collectBundledRuntimeDepsStagingDebrisPaths(packageRoot)).resolves.toEqual([ + "dist/extensions/browser/.openclaw-install-stage-AbC123", + "dist/extensions/evil/.openclaw-install-stage", + ]); + await expect(assertNoBundledRuntimeDepsStagingDebris(packageRoot)).rejects.toThrow( + /unexpected bundled-runtime-deps install staging debris/, + ); + await expect(writePackageDistInventory(packageRoot)).rejects.toThrow( + /unexpected bundled-runtime-deps install staging debris/, + ); + }); + }); + + it("rejects mixed-case install-stage debris on case-sensitive release builders", async () => { + await withTempDir( + { prefix: "openclaw-dist-inventory-stage-extensions-case-" }, + async (packageRoot) => { + const mixedCaseStage = path.join( + packageRoot, + "dist", + "Extensions", + "evil", + ".OpenClaw-Install-Stage", + "package.json", + ); + await fs.mkdir(path.dirname(mixedCaseStage), { recursive: true }); + await fs.writeFile(mixedCaseStage, "{}", "utf8"); + + await expect(collectBundledRuntimeDepsStagingDebrisPaths(packageRoot)).resolves.toEqual([ + "dist/Extensions/evil/.OpenClaw-Install-Stage", + ]); + await expect(writePackageDistInventory(packageRoot)).rejects.toThrow( + /unexpected bundled-runtime-deps install staging debris/, + ); + }, + ); + + await withTempDir( + { prefix: "openclaw-dist-inventory-stage-root-case-" }, + async (packageRoot) => { + const mixedCaseStage = path.join( + packageRoot, + "Dist", + "Extensions", + "browser", + ".OPENCLAW-INSTALL-STAGE-AbC123", + "package.json", + ); + await fs.mkdir(path.dirname(mixedCaseStage), { recursive: true }); + await fs.writeFile(mixedCaseStage, "{}", "utf8"); + + await expect(collectBundledRuntimeDepsStagingDebrisPaths(packageRoot)).resolves.toEqual([ + "Dist/Extensions/browser/.OPENCLAW-INSTALL-STAGE-AbC123", + ]); + await expect(writePackageDistInventory(packageRoot)).rejects.toThrow( + /unexpected bundled-runtime-deps install staging debris/, + ); + }, + ); + }); + + it("treats a missing dist/extensions tree as no staging debris", async () => { + await withTempDir({ prefix: "openclaw-dist-inventory-no-extensions-" }, async (packageRoot) => { + await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true }); + await expect(collectBundledRuntimeDepsStagingDebrisPaths(packageRoot)).resolves.toEqual([]); + await expect(assertNoBundledRuntimeDepsStagingDebris(packageRoot)).resolves.toBeUndefined(); + }); + }); + it("fails closed when the inventory is missing", async () => { await withTempDir({ prefix: "openclaw-dist-inventory-missing-" }, async (packageRoot) => { await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true }); diff --git a/src/infra/package-dist-inventory.ts b/src/infra/package-dist-inventory.ts index 3a4f30b316c..34b893e968c 100644 --- a/src/infra/package-dist-inventory.ts +++ b/src/infra/package-dist-inventory.ts @@ -26,16 +26,31 @@ const OMITTED_PRIVATE_QA_DIST_PREFIXES = ["dist/qa-runtime-"]; const OMITTED_DIST_SUBTREE_PATTERNS = [ /^dist\/extensions\/node_modules(?:\/|$)/u, /^dist\/extensions\/[^/]+\/node_modules(?:\/|$)/u, - /^dist\/extensions\/[^/]+\/\.openclaw-install-stage(?:-[^/]+)?(?:\/|$)/u, /^dist\/extensions\/[^/]+\/\.openclaw-runtime-deps-[^/]+(?:\/|$)/u, /^dist\/extensions\/qa-matrix(?:\/|$)/u, new RegExp(`^dist/plugin-sdk/extensions/${LEGACY_QA_LAB_DIR}(?:/|$)`, "u"), ] as const; +const INSTALL_STAGE_DEBRIS_DIR_PATTERN = /^\.openclaw-install-stage(?:-[^/]+)?$/iu; function normalizeRelativePath(value: string): string { return value.replace(/\\/g, "/"); } +function isInstallStageDirName(value: string): boolean { + return INSTALL_STAGE_DEBRIS_DIR_PATTERN.test(value); +} + +export function isBundledRuntimeDepsInstallStagePath(relativePath: string): boolean { + const parts = normalizeRelativePath(relativePath).split("/"); + return ( + parts.length >= 4 && + parts[0]?.toLowerCase() === "dist" && + parts[1]?.toLowerCase() === "extensions" && + Boolean(parts[2]) && + isInstallStageDirName(parts[3] ?? "") + ); +} + function isPackagedDistPath(relativePath: string): boolean { if (!relativePath.startsWith("dist/")) { return false; @@ -69,7 +84,10 @@ function isPackagedDistPath(relativePath: string): boolean { } function isOmittedDistSubtree(relativePath: string): boolean { - return OMITTED_DIST_SUBTREE_PATTERNS.some((pattern) => pattern.test(relativePath)); + return ( + isBundledRuntimeDepsInstallStagePath(relativePath) || + OMITTED_DIST_SUBTREE_PATTERNS.some((pattern) => pattern.test(relativePath)) + ); } async function collectRelativeFiles(rootDir: string, baseDir: string): Promise { @@ -114,7 +132,93 @@ export async function collectPackageDistInventory(packageRoot: string): Promise< return await collectRelativeFiles(path.join(packageRoot, "dist"), packageRoot); } +export async function collectBundledRuntimeDepsStagingDebrisPaths( + packageRoot: string, +): Promise { + const distDirs: string[] = []; + try { + const packageRootEntries = await fs.readdir(packageRoot, { withFileTypes: true }); + for (const entry of packageRootEntries) { + if (entry.isDirectory() && entry.name.toLowerCase() === "dist") { + distDirs.push(path.join(packageRoot, entry.name)); + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + throw error; + } + + const debris: string[] = []; + for (const distDir of distDirs) { + let distEntries: import("node:fs").Dirent[]; + try { + distEntries = await fs.readdir(distDir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } + throw error; + } + + for (const distEntry of distEntries) { + if (!distEntry.isDirectory() || distEntry.name.toLowerCase() !== "extensions") { + continue; + } + const extensionsDir = path.join(distDir, distEntry.name); + let extensionEntries: import("node:fs").Dirent[]; + try { + extensionEntries = await fs.readdir(extensionsDir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } + throw error; + } + + for (const extensionEntry of extensionEntries) { + if (!extensionEntry.isDirectory()) { + continue; + } + const extensionPath = path.join(extensionsDir, extensionEntry.name); + let stagingEntries: import("node:fs").Dirent[]; + try { + stagingEntries = await fs.readdir(extensionPath, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } + throw error; + } + for (const stagingEntry of stagingEntries) { + if (!isInstallStageDirName(stagingEntry.name)) { + continue; + } + debris.push( + normalizeRelativePath( + path.relative(packageRoot, path.join(extensionPath, stagingEntry.name)), + ), + ); + } + } + } + } + return debris.toSorted((left, right) => left.localeCompare(right)); +} + +export async function assertNoBundledRuntimeDepsStagingDebris(packageRoot: string): Promise { + const debris = await collectBundledRuntimeDepsStagingDebrisPaths(packageRoot); + if (debris.length === 0) { + return; + } + throw new Error( + `unexpected bundled-runtime-deps install staging debris in package dist: ${debris.join(", ")}`, + ); +} + export async function writePackageDistInventory(packageRoot: string): Promise { + await assertNoBundledRuntimeDepsStagingDebris(packageRoot); const inventory = [ ...new Set([ ...(await collectPackageDistInventory(packageRoot)), diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 3e9fd02cfc3..da894422940 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -434,10 +434,12 @@ describe("collectForbiddenPackPaths", () => { expect( collectForbiddenPackPaths([ "dist/index.js", + "dist/extensions/browser/.OpenClaw-Install-Stage/package.json", "dist/extensions/codex/.openclaw-runtime-deps-backup-node_modules-old/zod/index.js", "dist/extensions/discord/.openclaw-runtime-deps-stamp.json", ]), ).toEqual([ + "dist/extensions/browser/.OpenClaw-Install-Stage/package.json", "dist/extensions/codex/.openclaw-runtime-deps-backup-node_modules-old/zod/index.js", "dist/extensions/discord/.openclaw-runtime-deps-stamp.json", ]); diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index 2fb0c6c5c68..cb132ab9bef 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -35,6 +35,7 @@ import { shouldUseManagedGatewayForInstallerRuntime, shouldUseManagedGatewayService, verifyDevUpdateStatus, + writePackageDistInventoryForCandidate, } from "../../scripts/openclaw-cross-os-release-checks.ts"; describe("scripts/openclaw-cross-os-release-checks", () => { @@ -418,6 +419,30 @@ describe("scripts/openclaw-cross-os-release-checks", () => { } }); + it("rejects bundled runtime-deps staging debris before candidate inventory generation", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "openclaw-cross-os-stage-debris-")); + try { + mkdirSync( + join(packageRoot, "dist", "Extensions", "demo", ".OpenClaw-Install-Stage", "node_modules"), + { recursive: true }, + ); + writeFileSync( + join(packageRoot, "dist", "Extensions", "demo", ".OpenClaw-Install-Stage", "package.json"), + "{}\n", + "utf8", + ); + + await expect( + writePackageDistInventoryForCandidate({ + sourceDir: packageRoot, + logPath: join(packageRoot, "npm-pack-dry-run.log"), + }), + ).rejects.toThrow("unexpected bundled-runtime-deps install staging debris"); + } finally { + rmSync(packageRoot, { recursive: true, force: true }); + } + }); + it("accepts a git main dev-channel update status payload", () => { expect(() => verifyDevUpdateStatus( From e2ef5e2329301fd5a439e02b5704eb2044c28f78 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:09:02 +0100 Subject: [PATCH 20/25] test: keep path alias temp dirs out of repo --- src/infra/path-alias-guards.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/infra/path-alias-guards.test.ts b/src/infra/path-alias-guards.test.ts index 3019b97c130..b8e5e9106f0 100644 --- a/src/infra/path-alias-guards.test.ts +++ b/src/infra/path-alias-guards.test.ts @@ -5,10 +5,7 @@ import { withTempDir } from "../test-helpers/temp-dir.js"; import { assertNoPathAliasEscape } from "./path-alias-guards.js"; async function withAliasRoot(cb: (root: string) => Promise): Promise { - await withTempDir( - { prefix: "openclaw-path-alias-", parentDir: process.cwd(), subdir: "root" }, - cb, - ); + await withTempDir({ prefix: "openclaw-path-alias-", subdir: "root" }, cb); } describe("assertNoPathAliasEscape", () => { From 7e376e5aba32eeea8191e70d5e43e293f8b8398a Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 26 Apr 2026 13:37:43 +0530 Subject: [PATCH 21/25] ci: build npm telegram e2e image after approval --- .github/workflows/npm-telegram-beta-e2e.yml | 63 ++++----------------- 1 file changed, 10 insertions(+), 53 deletions(-) diff --git a/.github/workflows/npm-telegram-beta-e2e.yml b/.github/workflows/npm-telegram-beta-e2e.yml index 1734d373a8e..d69c0f5e9dd 100644 --- a/.github/workflows/npm-telegram-beta-e2e.yml +++ b/.github/workflows/npm-telegram-beta-e2e.yml @@ -59,16 +59,14 @@ jobs: PACKAGE_SPEC: ${{ inputs.package_spec }} run: echo "Approved npm Telegram beta E2E for ${PACKAGE_SPEC}" - prepare_docker_e2e_image: - name: Prepare Docker E2E image - needs: validate_dispatch_ref + run_npm_telegram_beta_e2e: + name: Run published npm Telegram E2E + needs: approve_release_manager runs-on: blacksmith-32vcpu-ubuntu-2404 - timeout-minutes: 90 + timeout-minutes: 60 + environment: qa-live-shared permissions: contents: read - packages: write - outputs: - image: ${{ steps.image.outputs.image }} env: DOCKER_BUILD_SUMMARY: "false" DOCKER_BUILD_RECORD_UPLOAD: "false" @@ -79,61 +77,20 @@ jobs: ref: ${{ github.sha }} fetch-depth: 1 - - name: Resolve Docker E2E image tag - id: image - shell: bash - env: - SELECTED_SHA: ${{ github.sha }} - run: | - set -euo pipefail - repository="${GITHUB_REPOSITORY,,}" - image="ghcr.io/${repository}-docker-e2e:${SELECTED_SHA}" - echo "image=$image" >> "$GITHUB_OUTPUT" - echo "Docker E2E image: \`$image\`" >> "$GITHUB_STEP_SUMMARY" - - name: Set up Blacksmith Docker Builder uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1 - - name: Log in to GHCR - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ github.token }} - - - name: Build and push Docker E2E image + - name: Build Docker E2E image uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2 with: context: . file: ./scripts/e2e/Dockerfile target: build platforms: linux/amd64 - tags: ${{ steps.image.outputs.image }} + tags: openclaw-docker-e2e:local + load: true + push: false provenance: false - push: true - - run_npm_telegram_beta_e2e: - name: Run published npm Telegram E2E - needs: [approve_release_manager, prepare_docker_e2e_image] - runs-on: blacksmith-32vcpu-ubuntu-2404 - timeout-minutes: 60 - environment: qa-live-shared - permissions: - contents: read - packages: read - steps: - - name: Checkout main - uses: actions/checkout@v6 - with: - ref: ${{ github.sha }} - fetch-depth: 1 - - - name: Log in to GHCR - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ github.token }} - name: Setup Node environment uses: ./.github/actions/setup-node-env @@ -178,7 +135,7 @@ jobs: env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENCLAW_SKIP_DOCKER_BUILD: "1" - OPENCLAW_DOCKER_E2E_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.image }} + OPENCLAW_DOCKER_E2E_IMAGE: openclaw-docker-e2e:local OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC: ${{ inputs.package_spec }} OPENCLAW_NPM_TELEGRAM_PROVIDER_MODE: ${{ inputs.provider_mode }} OPENCLAW_NPM_TELEGRAM_CREDENTIAL_SOURCE: convex From 1323683d7213f5cf91038665356ff0c1d2d2e6d6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:13:18 +0100 Subject: [PATCH 22/25] fix: stabilize qa lab capture store cleanup --- extensions/qa-lab/src/lab-server.test.ts | 4 +++ extensions/qa-lab/src/lab-server.ts | 10 +++++-- src/plugin-sdk/proxy-capture.ts | 2 ++ src/proxy-capture/store.sqlite.test.ts | 38 +++++++++++++++++++++++- src/proxy-capture/store.sqlite.ts | 37 ++++++++++++++++++++++- 5 files changed, 86 insertions(+), 5 deletions(-) diff --git a/extensions/qa-lab/src/lab-server.test.ts b/extensions/qa-lab/src/lab-server.test.ts index 9160df70eea..038da1da940 100644 --- a/extensions/qa-lab/src/lab-server.test.ts +++ b/extensions/qa-lab/src/lab-server.test.ts @@ -113,6 +113,10 @@ const captureMock = vi.hoisted(() => { }); vi.mock("openclaw/plugin-sdk/proxy-capture", () => ({ + acquireDebugProxyCaptureStore: () => ({ + store: captureMock.store, + release: captureMock.store.close, + }), getDebugProxyCaptureStore: () => captureMock.store, resolveDebugProxySettings: () => ({ dbPath: process.env.OPENCLAW_DEBUG_PROXY_DB_PATH ?? "", diff --git a/extensions/qa-lab/src/lab-server.ts b/extensions/qa-lab/src/lab-server.ts index 11d544a238f..cca29c3edc4 100644 --- a/extensions/qa-lab/src/lab-server.ts +++ b/extensions/qa-lab/src/lab-server.ts @@ -3,7 +3,7 @@ import { createServer, type IncomingMessage } from "node:http"; import path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { - getDebugProxyCaptureStore, + acquireDebugProxyCaptureStore, resolveDebugProxySettings, } from "openclaw/plugin-sdk/proxy-capture"; import { closeQaHttpServer, handleQaBusRequest, writeError, writeJson } from "./bus-server.js"; @@ -168,7 +168,11 @@ export async function startQaLabServer( ): Promise { const repoRoot = path.resolve(params?.repoRoot ?? process.cwd()); const captureSettings = resolveDebugProxySettings(); - const captureStore = getDebugProxyCaptureStore(captureSettings.dbPath, captureSettings.blobDir); + const captureStoreLease = acquireDebugProxyCaptureStore( + captureSettings.dbPath, + captureSettings.blobDir, + ); + const captureStore = captureStoreLease.store; const state = createQaBusState(); let latestReport: QaLabLatestReport | null = null; let latestScenarioRun: QaLabScenarioRun | null = null; @@ -639,7 +643,7 @@ export async function startQaLabServer( await runnerModelCatalogPromise?.catch(() => undefined); await gateway?.stop(); await closeQaHttpServer(server); - captureStore.close(); + captureStoreLease.release(); }, }; labHandle = lab; diff --git a/src/plugin-sdk/proxy-capture.ts b/src/plugin-sdk/proxy-capture.ts index f653c85964a..f2f2dc2bde5 100644 --- a/src/plugin-sdk/proxy-capture.ts +++ b/src/plugin-sdk/proxy-capture.ts @@ -4,7 +4,9 @@ export { resolveEffectiveDebugProxyUrl, } from "../proxy-capture/env.js"; export { + acquireDebugProxyCaptureStore, DebugProxyCaptureStore, + closeDebugProxyCaptureStore, getDebugProxyCaptureStore, } from "../proxy-capture/store.sqlite.js"; export { diff --git a/src/proxy-capture/store.sqlite.test.ts b/src/proxy-capture/store.sqlite.test.ts index 2a602b0b65e..84e1017ecc6 100644 --- a/src/proxy-capture/store.sqlite.test.ts +++ b/src/proxy-capture/store.sqlite.test.ts @@ -2,11 +2,18 @@ import { mkdtempSync, rmSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { DebugProxyCaptureStore, persistEventPayload } from "./store.sqlite.js"; +import { + acquireDebugProxyCaptureStore, + closeDebugProxyCaptureStore, + DebugProxyCaptureStore, + getDebugProxyCaptureStore, + persistEventPayload, +} from "./store.sqlite.js"; const cleanupDirs: string[] = []; afterEach(() => { + closeDebugProxyCaptureStore(); while (cleanupDirs.length > 0) { const dir = cleanupDirs.pop(); if (dir) { @@ -22,6 +29,35 @@ function makeStore() { } describe("DebugProxyCaptureStore", () => { + it("keeps the cached store open until the last lease releases", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-proxy-capture-lease-")); + cleanupDirs.push(root); + const dbPath = path.join(root, "capture.sqlite"); + const blobDir = path.join(root, "blobs"); + + const first = acquireDebugProxyCaptureStore(dbPath, blobDir); + const second = acquireDebugProxyCaptureStore(dbPath, blobDir); + + expect(second.store).toBe(first.store); + first.release(); + expect(first.store.isClosed).toBe(false); + + second.release(); + expect(first.store.isClosed).toBe(true); + + const reopened = getDebugProxyCaptureStore(dbPath, blobDir); + expect(Object.is(reopened, first.store)).toBe(false); + expect(reopened.isClosed).toBe(false); + }); + + it("ignores duplicate close calls", () => { + const store = makeStore(); + + store.close(); + expect(() => store.close()).not.toThrow(); + expect(store.isClosed).toBe(true); + }); + it("stores sessions, blobs, and duplicate-send query results", () => { const store = makeStore(); store.upsertSession({ diff --git a/src/proxy-capture/store.sqlite.ts b/src/proxy-capture/store.sqlite.ts index 535186ca16c..d0e433de516 100644 --- a/src/proxy-capture/store.sqlite.ts +++ b/src/proxy-capture/store.sqlite.ts @@ -93,6 +93,7 @@ function sortObservedCounts(counts: Map): CaptureObservedDimensi export class DebugProxyCaptureStore { readonly db: DatabaseSync; + private closed = false; constructor( readonly dbPath: string, @@ -102,7 +103,15 @@ export class DebugProxyCaptureStore { } close(): void { + if (this.closed) { + return; + } this.db.close(); + this.closed = true; + } + + get isClosed(): boolean { + return this.closed; } upsertSession(session: CaptureSessionRecord): void { @@ -448,12 +457,14 @@ export class DebugProxyCaptureStore { let cachedStore: DebugProxyCaptureStore | null = null; let cachedKey = ""; +let cachedStoreLeases = 0; export function getDebugProxyCaptureStore(dbPath: string, blobDir: string): DebugProxyCaptureStore { const key = `${dbPath}:${blobDir}`; - if (!cachedStore || cachedKey !== key) { + if (!cachedStore || cachedStore.isClosed || cachedKey !== key) { cachedStore = new DebugProxyCaptureStore(dbPath, blobDir); cachedKey = key; + cachedStoreLeases = 0; } return cachedStore; } @@ -465,6 +476,30 @@ export function closeDebugProxyCaptureStore(): void { cachedStore.close(); cachedStore = null; cachedKey = ""; + cachedStoreLeases = 0; +} + +export function acquireDebugProxyCaptureStore( + dbPath: string, + blobDir: string, +): { store: DebugProxyCaptureStore; release: () => void } { + const store = getDebugProxyCaptureStore(dbPath, blobDir); + const key = cachedKey; + cachedStoreLeases += 1; + let released = false; + return { + store, + release: () => { + if (released) { + return; + } + released = true; + cachedStoreLeases = Math.max(0, cachedStoreLeases - 1); + if (cachedStoreLeases === 0 && cachedStore === store && cachedKey === key) { + closeDebugProxyCaptureStore(); + } + }, + }; } export function persistEventPayload( From 2f5e5e9a71655dfcb7eae09773018f93f5141dd7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:15:43 +0100 Subject: [PATCH 23/25] fix: break plugin command spec import cycle --- src/agents/acp-spawn.test.ts | 3 ++- src/gateway/server-methods/commands.test.ts | 2 +- src/gateway/server-methods/commands.ts | 2 +- src/plugin-sdk/command-auth.ts | 6 ++---- src/plugins/command-registration.ts | 8 +------- src/plugins/command-registry-state.ts | 20 ------------------- src/plugins/command-specs.ts | 22 +++++++++++++++++++++ src/plugins/commands.ts | 2 +- src/plugins/loader.test.ts | 3 ++- 9 files changed, 32 insertions(+), 36 deletions(-) create mode 100644 src/plugins/command-specs.ts diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index 059dfd96afc..cbbc3dbb7b8 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -2454,7 +2454,8 @@ describe("spawnAcpDirect", () => { conversation: expect.objectContaining({ channel: "telegram", accountId: "default", - conversationId: "-1003342490704:topic:2", + conversationId: "2", + parentConversationId: "-1003342490704", }), }), ); diff --git a/src/gateway/server-methods/commands.test.ts b/src/gateway/server-methods/commands.test.ts index 3abe62bdf91..37c087e2a97 100644 --- a/src/gateway/server-methods/commands.test.ts +++ b/src/gateway/server-methods/commands.test.ts @@ -79,7 +79,7 @@ vi.mock("../../auto-reply/commands-registry.js", () => ({ vi.mock("../../auto-reply/skill-commands.js", () => ({ listSkillCommandsForAgents: vi.fn(() => mockSkillCommands), })); -vi.mock("../../plugins/command-registry-state.js", () => ({ +vi.mock("../../plugins/command-specs.js", () => ({ getPluginCommandSpecs: vi.fn((provider?: string) => { if (provider === "whatsapp") { return []; diff --git a/src/gateway/server-methods/commands.ts b/src/gateway/server-methods/commands.ts index 83ff99b53b3..946ce068b24 100644 --- a/src/gateway/server-methods/commands.ts +++ b/src/gateway/server-methods/commands.ts @@ -9,7 +9,7 @@ import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; import { loadConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { getPluginCommandSpecs } from "../../plugins/command-registry-state.js"; +import { getPluginCommandSpecs } from "../../plugins/command-specs.js"; import { listPluginCommands } from "../../plugins/commands.js"; import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; import type { CommandEntry, CommandsListResult } from "../protocol/index.js"; diff --git a/src/plugin-sdk/command-auth.ts b/src/plugin-sdk/command-auth.ts index 9f15d842a12..e3eaf56ff02 100644 --- a/src/plugin-sdk/command-auth.ts +++ b/src/plugin-sdk/command-auth.ts @@ -77,10 +77,8 @@ export { listSkillCommandsForWorkspace, resolveSkillCommandInvocation, } from "../auto-reply/skill-commands.js"; -export { - getPluginCommandSpecs, - listProviderPluginCommandSpecs, -} from "../plugins/command-registration.js"; +export { getPluginCommandSpecs } from "../plugins/command-specs.js"; +export { listProviderPluginCommandSpecs } from "../plugins/command-registry-state.js"; export type { SkillCommandSpec } from "../agents/skills.js"; export { buildModelsProviderData, diff --git a/src/plugins/command-registration.ts b/src/plugins/command-registration.ts index 7a6e3f63762..945a26f67a2 100644 --- a/src/plugins/command-registration.ts +++ b/src/plugins/command-registration.ts @@ -6,7 +6,6 @@ import { import { clearPluginCommands, clearPluginCommandsForPlugin, - getPluginCommandSpecs, isPluginCommandRegistryLocked, listProviderPluginCommandSpecs, pluginCommands, @@ -197,10 +196,5 @@ export function registerPluginCommand( return { ok: true }; } -export { - clearPluginCommands, - clearPluginCommandsForPlugin, - getPluginCommandSpecs, - listProviderPluginCommandSpecs, -}; +export { clearPluginCommands, clearPluginCommandsForPlugin, listProviderPluginCommandSpecs }; export type { RegisteredPluginCommand }; diff --git a/src/plugins/command-registry-state.ts b/src/plugins/command-registry-state.ts index 0a783974d27..fb1f8cc0db4 100644 --- a/src/plugins/command-registry-state.ts +++ b/src/plugins/command-registry-state.ts @@ -1,5 +1,3 @@ -import { getLoadedChannelPlugin } from "../channels/plugins/index.js"; -import { resolveReadOnlyChannelCommandDefaults } from "../channels/plugins/read-only-command-defaults.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import type { OpenClawPluginCommandDefinition } from "./types.js"; @@ -83,24 +81,6 @@ function resolvePluginNativeName( return command.name; } -export function getPluginCommandSpecs(provider?: string): Array<{ - name: string; - description: string; - acceptsArgs: boolean; -}> { - const providerName = normalizeOptionalLowercaseString(provider); - if ( - providerName && - ( - getLoadedChannelPlugin(providerName)?.commands ?? - resolveReadOnlyChannelCommandDefaults(providerName) - )?.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/command-specs.ts b/src/plugins/command-specs.ts new file mode 100644 index 00000000000..3b8aaf25b8a --- /dev/null +++ b/src/plugins/command-specs.ts @@ -0,0 +1,22 @@ +import { getLoadedChannelPlugin } from "../channels/plugins/index.js"; +import { resolveReadOnlyChannelCommandDefaults } from "../channels/plugins/read-only.js"; +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { listProviderPluginCommandSpecs } from "./command-registry-state.js"; + +export function getPluginCommandSpecs(provider?: string): Array<{ + name: string; + description: string; + acceptsArgs: boolean; +}> { + const providerName = normalizeOptionalLowercaseString(provider); + if ( + providerName && + ( + getLoadedChannelPlugin(providerName)?.commands ?? + resolveReadOnlyChannelCommandDefaults(providerName) + )?.nativeCommandsAutoEnabled !== true + ) { + return []; + } + return listProviderPluginCommandSpecs(provider); +} diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 225e1fb5ab0..8cfa4762e08 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -12,7 +12,6 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { clearPluginCommands, clearPluginCommandsForPlugin, - getPluginCommandSpecs, listPluginInvocationKeys, listProviderPluginCommandSpecs, registerPluginCommand, @@ -24,6 +23,7 @@ import { setPluginCommandRegistryLocked, type RegisteredPluginCommand, } from "./command-registry-state.js"; +import { getPluginCommandSpecs } from "./command-specs.js"; import { detachPluginConversationBinding, getCurrentPluginConversationBinding, diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 17a1ebff203..71272bdebc0 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -22,7 +22,8 @@ import { type DetachedTaskLifecycleRuntime, } from "../tasks/detached-task-runtime-state.js"; import { withEnv } from "../test-utils/env.js"; -import { clearPluginCommands, getPluginCommandSpecs } from "./command-registry-state.js"; +import { clearPluginCommands } from "./command-registry-state.js"; +import { getPluginCommandSpecs } from "./command-specs.js"; import { getGlobalHookRunner, getGlobalPluginRegistry, From 0c020cdb7ae739ea1a88d9b65e358c5d64881988 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:16:45 +0100 Subject: [PATCH 24/25] test: update ci expectation drift --- src/commands/status-json.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/status-json.test.ts b/src/commands/status-json.test.ts index 49da0cea473..56ee27f3e06 100644 --- a/src/commands/status-json.test.ts +++ b/src/commands/status-json.test.ts @@ -110,6 +110,7 @@ describe("statusJsonCommand", () => { deep: false, includeFilesystem: true, includeChannelSecurity: true, + loadPluginSecurityCollectors: false, plugins: expect.any(Array), }); expect(logs).toHaveLength(1); From 134cc64aff0da73d22443aedc261cc07c3e54bd6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:17:34 +0100 Subject: [PATCH 25/25] fix: keep host plugin registry out of live Docker state --- scripts/lib/live-docker-stage.sh | 6 ++++-- scripts/test-projects.test-support.mjs | 2 ++ test/scripts/live-docker-stage.test.ts | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 test/scripts/live-docker-stage.test.ts diff --git a/scripts/lib/live-docker-stage.sh b/scripts/lib/live-docker-stage.sh index b79791f4e39..4b3d3f2ccd8 100644 --- a/scripts/lib/live-docker-stage.sh +++ b/scripts/lib/live-docker-stage.sh @@ -69,14 +69,16 @@ openclaw_live_stage_state_dir() { mkdir -p "$dest_dir" if [ -d "$source_dir" ]; then # Sandbox workspaces can accumulate root-owned artifacts from prior Docker - # runs. They are not needed for live-test auth/config staging and can make - # temp-dir cleanup fail on exit, so keep them out of the staged state copy. + # runs. The persisted plugin registry contains host-absolute paths that are + # not portable into Linux containers. Neither is needed for live-test + # auth/config staging, so keep them out of the staged state copy. set +e tar -C "$source_dir" \ --warning=no-file-changed \ --ignore-failed-read \ --exclude=workspace \ --exclude=sandboxes \ + --exclude=plugins/installs.json \ --exclude=relay.sock \ --exclude='*.sock' \ --exclude='*/*.sock' \ diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 825d68edd00..01bb27ecddc 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -228,6 +228,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["scripts/github/barnacle-auto-response.mjs", ["test/scripts/barnacle-auto-response.test.ts"]], ["scripts/changed-lanes.mjs", ["test/scripts/changed-lanes.test.ts"]], ["scripts/check-changed.mjs", ["test/scripts/changed-lanes.test.ts"]], + ["scripts/lib/live-docker-stage.sh", ["test/scripts/live-docker-stage.test.ts"]], ["scripts/lib/vitest-local-scheduling.mjs", ["test/scripts/vitest-local-scheduling.test.ts"]], [ "scripts/run-vitest.mjs", @@ -251,6 +252,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ const TOOLING_TEST_TARGETS = new Map([ ["test/scripts/barnacle-auto-response.test.ts", ["test/scripts/barnacle-auto-response.test.ts"]], ["test/scripts/changed-lanes.test.ts", ["test/scripts/changed-lanes.test.ts"]], + ["test/scripts/live-docker-stage.test.ts", ["test/scripts/live-docker-stage.test.ts"]], ["test/scripts/test-projects.test.ts", ["test/scripts/test-projects.test.ts"]], [ "test/scripts/vitest-local-scheduling.test.ts", diff --git a/test/scripts/live-docker-stage.test.ts b/test/scripts/live-docker-stage.test.ts new file mode 100644 index 00000000000..e785bba5694 --- /dev/null +++ b/test/scripts/live-docker-stage.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const stageScriptPath = path.join(repoRoot, "scripts/lib/live-docker-stage.sh"); + +describe("live Docker state staging", () => { + it("keeps host-only generated registry state out of the container copy", () => { + const script = readFileSync(stageScriptPath, "utf8"); + + expect(script).toContain("--exclude=workspace"); + expect(script).toContain("--exclude=sandboxes"); + expect(script).toContain("--exclude=plugins/installs.json"); + expect(script).toContain("host-absolute paths"); + }); +});