Compare commits

...

10 Commits

Author SHA1 Message Date
Fedor
2cbe4e2808 feat: add claw approval MVP with privileged broker
Some checks failed
Stale / stale (push) Has been cancelled
Stale / lock-closed-issues (push) Has been cancelled
Implement Postgres-backed claw approval flow and integrate gateway methods for create/list/get/approve/reject/execute/audit. Add a minimal systemd-run privileged broker with bearer auth, strict scope and exact-command validation, dangerous-shell blocking, atomic once-grant consumption, and execution audit updates.
2026-03-13 12:41:23 +00:00
Peter Steinberger
70d7a0854c chore: update appcast for 2026.3.12 release 2026-03-13 04:26:20 +00:00
Peter Steinberger
fc2b796f02 test(proxy): make env proxy tests windows-safe 2026-03-13 04:17:10 +00:00
Peter Steinberger
6472949f25 fix(plugins): normalize bundled provider ids 2026-03-13 04:10:06 +00:00
Cypherm
61d219cb39 feat: show status reaction during context compaction (#35474)
Merged via squash.

Prepared head SHA: 145a7b7c4e1939718c41a300899ae813bd9c511b
Co-authored-by: Cypherm <28184436+Cypherm@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-12 21:06:15 -07:00
Peter Steinberger
4e872521f0 fix(ui): restore native web /status 2026-03-13 04:04:09 +00:00
Peter Steinberger
0c8ea8d987 test(ui): add jsdom runtime for vitest dom suites 2026-03-13 03:50:52 +00:00
Peter Steinberger
bffce8ea4f fix(ui): harden avatar fallback regressions 2026-03-13 03:46:30 +00:00
Peter Steinberger
4656317770 fix(ui): resolve control chat avatar fallback 2026-03-13 03:42:11 +00:00
Vincent Koc
7509c4a057 UI: fix mounted avatar meta fallback 2026-03-12 23:39:53 -04:00
39 changed files with 2198 additions and 548 deletions

View File

@@ -84,6 +84,7 @@ Docs: https://docs.openclaw.ai
- Agents/compaction safeguard: route missing-model and missing-API-key cancellation warnings through the shared subsystem logger so they land in structured and file logs. (#9974) Thanks @dinakars777.
- Cron/doctor: stop flagging canonical `agentTurn` and `systemEvent` payload kinds as legacy cron storage, while still normalizing whitespace-padded and non-canonical variants. (#44012) Thanks @shuicici.
- ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving `end_turn`, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby.
- Telegram/Discord status reactions: show a temporary compacting reaction during auto-compaction pauses and restore thinking afterward so the bot no longer appears frozen while context is being compacted. (#35474) thanks @Cypherm.
## 2026.3.11

View File

@@ -2,6 +2,98 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.3.12</title>
<pubDate>Fri, 13 Mar 2026 04:25:50 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026031290</sparkle:version>
<sparkle:shortVersionString>2026.3.12</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.3.12</h2>
<h3>Changes</h3>
<ul>
<li>Control UI/dashboard-v2: refresh the gateway dashboard with modular overview, chat, config, agent, and session views, plus a command palette, mobile bottom tabs, and richer chat tools like slash commands, search, export, and pinned messages. (#41503) Thanks @BunsDev.</li>
<li>OpenAI/GPT-5.4 fast mode: add configurable session-level fast toggles across <code>/fast</code>, TUI, Control UI, and ACP, with per-model config defaults and OpenAI/Codex request shaping.</li>
<li>Anthropic/Claude fast mode: map the shared <code>/fast</code> toggle and <code>params.fastMode</code> to direct Anthropic API-key <code>service_tier</code> requests, with live verification for both Anthropic and OpenAI fast-mode tiers.</li>
<li>Models/plugins: move Ollama, vLLM, and SGLang onto the provider-plugin architecture, with provider-owned onboarding, discovery, model-picker setup, and post-selection hooks so core provider wiring is more modular.</li>
<li>Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi</li>
<li>Agents/subagents: add <code>sessions_yield</code> so orchestrators can end the current turn immediately, skip queued tool work, and carry a hidden follow-up payload into the next session turn. (#36537) thanks @jriff</li>
<li>Slack/agent replies: support <code>channelData.slack.blocks</code> in the shared reply delivery path so agents can send Block Kit messages through standard Slack outbound delivery. (#44592) Thanks @vincentkoc.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Security/device pairing: switch <code>/pair</code> and <code>openclaw qr</code> setup codes to short-lived bootstrap tokens so the next release no longer embeds shared gateway credentials in chat or QR pairing payloads. Thanks @lintsinghua.</li>
<li>Security/plugins: disable implicit workspace plugin auto-load so cloned repositories cannot execute workspace plugin code without an explicit trust decision. (<code>GHSA-99qw-6mr3-36qr</code>)(#44174) Thanks @lintsinghua and @vincentkoc.</li>
<li>Models/Kimi Coding: send <code>anthropic-messages</code> tools in native Anthropic format again so <code>kimi-coding</code> stops degrading tool calls into XML/plain-text pseudo invocations instead of real <code>tool_use</code> blocks. (#38669, #39907, #40552) Thanks @opriz.</li>
<li>TUI/chat log: reuse the active assistant message component for the same streaming run so <code>openclaw tui</code> no longer renders duplicate assistant replies. (#35364) Thanks @lisitan.</li>
<li>Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in <code>/models</code> button validation. (#40105) Thanks @avirweb.</li>
<li>Cron/proactive delivery: keep isolated direct cron sends out of the write-ahead resend queue so transient-send retries do not replay duplicate proactive messages after restart. (#40646) Thanks @openperf and @vincentkoc.</li>
<li>Models/Kimi Coding: send the built-in <code>User-Agent: claude-code/0.1.0</code> header by default for <code>kimi-coding</code> while still allowing explicit provider headers to override it, so Kimi Code subscription auth can work without a local header-injection proxy. (#30099) Thanks @Amineelfarssi and @vincentkoc.</li>
<li>Models/OpenAI Codex Spark: keep <code>gpt-5.3-codex-spark</code> working on the <code>openai-codex/*</code> path via resolver fallbacks and clearer Codex-only handling, while continuing to suppress the stale direct <code>openai/*</code> Spark row that OpenAI rejects live.</li>
<li>Ollama/Kimi Cloud: apply the Moonshot Kimi payload compatibility wrapper to Ollama-hosted Kimi models like <code>kimi-k2.5:cloud</code>, so tool routing no longer breaks when thinking is enabled. (#41519) Thanks @vincentkoc.</li>
<li>Moonshot CN API: respect explicit <code>baseUrl</code> (api.moonshot.cn) in implicit provider resolution so platform.moonshot.cn API keys authenticate correctly instead of returning HTTP 401. (#33637) Thanks @chengzhichao-xydt.</li>
<li>Kimi Coding/provider config: respect explicit <code>models.providers["kimi-coding"].baseUrl</code> when resolving the implicit provider so custom Kimi Coding endpoints no longer get overwritten by the built-in default. (#36353) Thanks @2233admin.</li>
<li>Gateway/main-session routing: keep TUI and other <code>mode:UI</code> main-session sends on the internal surface when <code>deliver</code> is enabled, so replies no longer inherit the session's persisted Telegram/WhatsApp route. (#43918) Thanks @obviyus.</li>
<li>BlueBubbles/self-chat echo dedupe: drop reflected duplicate webhook copies only when a matching <code>fromMe</code> event was just seen for the same chat, body, and timestamp, preventing self-chat loops without broad webhook suppression. Related to #32166. (#38442) Thanks @vincentkoc.</li>
<li>iMessage/self-chat echo dedupe: drop reflected duplicate copies only when a matching <code>is_from_me</code> event was just seen for the same chat, text, and <code>created_at</code>, preventing self-chat loops without broad text-only suppression. Related to #32166. (#38440) Thanks @vincentkoc.</li>
<li>Subagents/completion announce retries: raise the default announce timeout to 90 seconds and stop retrying gateway-timeout failures for externally delivered completion announces, preventing duplicate user-facing completion messages after slow gateway responses. Fixes #41235. Thanks @vasujain00 and @vincentkoc.</li>
<li>Mattermost/block streaming: fix duplicate message delivery (one threaded, one top-level) when block streaming is active by excluding <code>replyToId</code> from the block reply dedup key and adding an explicit <code>threading</code> dock to the Mattermost plugin. (#41362) Thanks @mathiasnagler and @vincentkoc.</li>
<li>Mattermost/reply media delivery: pass agent-scoped <code>mediaLocalRoots</code> through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.</li>
<li>macOS/Reminders: add the missing <code>NSRemindersUsageDescription</code> to the bundled app so <code>apple-reminders</code> can trigger the system permission prompt from OpenClaw.app. (#8559) Thanks @dinakars777.</li>
<li>Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated <code>session.store</code> roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.</li>
<li>Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process <code>HOME</code>/<code>OPENCLAW_HOME</code> changes no longer reuse stale plugin state or misreport <code>~/...</code> plugins as untracked. (#44046) thanks @gumadeiras.</li>
<li>Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and <code>models list --plain</code>, and migrate legacy duplicated <code>openrouter/openrouter/...</code> config entries forward on write.</li>
<li>Windows/native update: make package installs use the npm update path instead of the git path, carry portable Git into native Windows updates, and mirror the installer's Windows npm env so <code>openclaw update</code> no longer dies early on missing <code>git</code> or <code>node-llama-cpp</code> download setup.</li>
<li>Sandbox/write: preserve pinned mutation-helper payload stdin so sandboxed <code>write</code> no longer reports success while creating empty files. (#43876) Thanks @glitch418x.</li>
<li>Security/exec approvals: escape invisible Unicode format characters in approval prompts so zero-width command text renders as visible <code>\u{...}</code> escapes instead of spoofing the reviewed command. (<code>GHSA-pcqg-f7rg-xfvv</code>)(#43687) Thanks @EkiXu and @vincentkoc.</li>
<li>Hooks/loader: fail closed when workspace hook paths cannot be resolved with <code>realpath</code>, so unreadable or broken internal hook paths are skipped instead of falling back to unresolved imports. (#44437) Thanks @vincentkoc.</li>
<li>Hooks/agent deliveries: dedupe repeated hook requests by optional idempotency key so webhook retries can reuse the first run instead of launching duplicate agent executions. (#44438) Thanks @vincentkoc.</li>
<li>Security/exec detection: normalize compatibility Unicode and strip invisible formatting code points before obfuscation checks so zero-width and fullwidth command tricks no longer suppress heuristic detection. (<code>GHSA-9r3v-37xh-2cf6</code>)(#44091) Thanks @wooluo and @vincentkoc.</li>
<li>Security/exec allowlist: preserve POSIX case sensitivity and keep <code>?</code> within a single path segment so exact-looking allowlist patterns no longer overmatch executables across case or directory boundaries. (<code>GHSA-f8r2-vg7x-gh8m</code>)(#43798) Thanks @zpbrent and @vincentkoc.</li>
<li>Security/commands: require sender ownership for <code>/config</code> and <code>/debug</code> so authorized non-owner senders can no longer reach owner-only config and runtime debug surfaces. (<code>GHSA-r7vr-gr74-94p8</code>)(#44305) Thanks @tdjackey and @vincentkoc.</li>
<li>Security/gateway auth: clear unbound client-declared scopes on shared-token WebSocket connects so device-less shared-token operators cannot self-declare elevated scopes. (<code>GHSA-rqpp-rjj8-7wv8</code>)(#44306) Thanks @LUOYEcode and @vincentkoc.</li>
<li>Security/browser.request: block persistent browser profile create/delete routes from write-scoped <code>browser.request</code> so callers can no longer persist admin-only browser profile changes through the browser control surface. (<code>GHSA-vmhq-cqm9-6p7q</code>)(#43800) Thanks @tdjackey and @vincentkoc.</li>
<li>Security/agent: reject public spawned-run lineage fields and keep workspace inheritance on the internal spawned-session path so external <code>agent</code> callers can no longer override the gateway workspace boundary. (<code>GHSA-2rqg-gjgv-84jm</code>)(#43801) Thanks @tdjackey and @vincentkoc.</li>
<li>Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via <code>session_status</code>. (<code>GHSA-wcxr-59v9-rxr8</code>)(#43754) Thanks @tdjackey and @vincentkoc.</li>
<li>Security/agent tools: mark <code>nodes</code> as explicitly owner-only and document/test that <code>canvas</code> remains a shared trusted-operator surface unless a real boundary bypass exists.</li>
<li>Security/exec approvals: fail closed for Ruby approval flows that use <code>-r</code>, <code>--require</code>, or <code>-I</code> so approval-backed commands no longer bind only the main script while extra local code-loading flags remain outside the reviewed file snapshot.</li>
<li>Security/device pairing: cap issued and verified device-token scopes to each paired device's approved scope baseline so stale or overbroad tokens cannot exceed approved access. (<code>GHSA-2pwv-x786-56f8</code>)(#43686) Thanks @tdjackey and @vincentkoc.</li>
<li>Docs/onboarding: align the legacy wizard reference and <code>openclaw onboard</code> command docs with the Ollama onboarding flow so all onboarding reference paths now document <code>--auth-choice ollama</code>, Cloud + Local mode, and non-interactive usage. (#43473) Thanks @BruceMacD.</li>
<li>Models/secrets: enforce source-managed SecretRef markers in generated <code>models.json</code> so runtime-resolved provider secrets are not persisted when runtime projection is skipped. (#43759) Thanks @joshavant.</li>
<li>Security/WebSocket preauth: shorten unauthenticated handshake retention and reject oversized pre-auth frames before application-layer parsing to reduce pre-pairing exposure on unsupported public deployments. (<code>GHSA-jv4g-m82p-2j93</code>)(#44089) (<code>GHSA-xwx2-ppv2-wx98</code>)(#44089) Thanks @ez-lbz and @vincentkoc.</li>
<li>Security/proxy attachments: restore the shared media-store size cap for persisted browser proxy files so oversized payloads are rejected instead of overriding the intended 5 MB limit. (<code>GHSA-6rph-mmhp-h7h9</code>)(#43684) Thanks @tdjackey and @vincentkoc.</li>
<li>Security/host env: block inherited <code>GIT_EXEC_PATH</code> from sanitized host exec environments so Git helper resolution cannot be steered by host environment state. (<code>GHSA-jf5v-pqgw-gm5m</code>)(#43685) Thanks @zpbrent and @vincentkoc.</li>
<li>Security/Feishu webhook: require <code>encryptKey</code> alongside <code>verificationToken</code> in webhook mode so unsigned forged events are rejected instead of being processed with token-only configuration. (<code>GHSA-g353-mgv3-8pcj</code>)(#44087) Thanks @lintsinghua and @vincentkoc.</li>
<li>Security/Feishu reactions: preserve looked-up group chat typing and fail closed on ambiguous reaction context so group authorization and mention gating cannot be bypassed through synthetic <code>p2p</code> reactions. (<code>GHSA-m69h-jm2f-2pv8</code>)(#44088) Thanks @zpbrent and @vincentkoc.</li>
<li>Security/LINE webhook: require signatures for empty-event POST probes too so unsigned requests no longer confirm webhook reachability with a <code>200</code> response. (<code>GHSA-mhxh-9pjm-w7q5</code>)(#44090) Thanks @TerminalsandCoffee and @vincentkoc.</li>
<li>Security/Zalo webhook: rate limit invalid secret guesses before auth so weak webhook secrets cannot be brute-forced through unauthenticated churned requests without pre-auth <code>429</code> responses. (<code>GHSA-5m9r-p9g7-679c</code>)(#44173) Thanks @zpbrent and @vincentkoc.</li>
<li>Security/Zalouser groups: require stable group IDs for allowlist auth by default and gate mutable group-name matching behind <code>channels.zalouser.dangerouslyAllowNameMatching</code>. Thanks @zpbrent.</li>
<li>Security/Slack and Teams routing: require stable channel and team IDs for allowlist routing by default, with mutable name matching only via each channel's <code>dangerouslyAllowNameMatching</code> break-glass flag.</li>
<li>Security/exec approvals: fail closed for ambiguous inline loader and shell-payload script execution, bind the real script after POSIX shell value-taking flags, and unwrap <code>pnpm</code>/<code>npm exec</code>/<code>npx</code> script runners before approval binding. (<code>GHSA-57jw-9722-6rf2</code>)(<code>GHSA-jvqh-rfmh-jh27</code>)(<code>GHSA-x7pp-23xv-mmr4</code>)(<code>GHSA-jc5j-vg4r-j5jx</code>)(#44247) Thanks @tdjackey and @vincentkoc.</li>
<li>Doctor/gateway service audit: canonicalize service entrypoint paths before comparing them so symlink-vs-realpath installs no longer trigger false "entrypoint does not match the current install" repair prompts. (#43882) Thanks @ngutman.</li>
<li>Doctor/gateway service audit: earlier groundwork for this fix landed in the superseded #28338 branch. Thanks @realriphub.</li>
<li>Gateway/session stores: regenerate the Swift push-test protocol models and align Windows native session-store realpath handling so protocol checks and sync session discovery stop drifting on Windows. (#44266) thanks @jalehman.</li>
<li>Context engine/session routing: forward optional <code>sessionKey</code> through context-engine lifecycle calls so plugins can see structured routing metadata during bootstrap, assembly, post-turn ingestion, and compaction. (#44157) thanks @jalehman.</li>
<li>Agents/failover: classify z.ai <code>network_error</code> stop reasons as retryable timeouts so provider connectivity failures trigger fallback instead of surfacing raw unhandled-stop-reason errors. (#43884) Thanks @hougangdev.</li>
<li>Memory/session sync: add mode-aware post-compaction session reindexing with <code>agents.defaults.compaction.postIndexSync</code> plus <code>agents.defaults.memorySearch.sync.sessions.postCompactionForce</code>, so compacted session memory can refresh immediately without forcing every deployment into synchronous reindexing. (#25561) thanks @rodrigouroz.</li>
<li>Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in <code>/models</code> button validation. (#40105) Thanks @avirweb.</li>
<li>Telegram/native command sync: suppress expected <code>BOT_COMMANDS_TOO_MUCH</code> retry error noise, add a final fallback summary log, and document the difference between command-menu overflow and real Telegram network failures.</li>
<li>Mattermost/reply media delivery: pass agent-scoped <code>mediaLocalRoots</code> through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.</li>
<li>Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process <code>HOME</code>/<code>OPENCLAW_HOME</code> changes no longer reuse stale plugin state or misreport <code>~/...</code> plugins as untracked. (#44046) thanks @gumadeiras.</li>
<li>Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated <code>session.store</code> roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.</li>
<li>Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and <code>models list --plain</code>, and migrate legacy duplicated <code>openrouter/openrouter/...</code> config entries forward on write.</li>
<li>Gateway/hooks: bucket hook auth failures by forwarded client IP behind trusted proxies and warn when <code>hooks.allowedAgentIds</code> leaves hook routing unrestricted.</li>
<li>Agents/compaction: skip the post-compaction <code>cache-ttl</code> marker write when a compaction completed in the same attempt, preventing the next turn from immediately triggering a second tiny compaction. (#28548) thanks @MoerAI.</li>
<li>Native chat/macOS: add <code>/new</code>, <code>/reset</code>, and <code>/clear</code> reset triggers, keep shared main-session aliases aligned, and ignore stale model-selection completions so native chat state stays in sync across reset and fast model changes. (#10898) Thanks @Nachx639.</li>
<li>Agents/compaction safeguard: route missing-model and missing-API-key cancellation warnings through the shared subsystem logger so they land in structured and file logs. (#9974) Thanks @dinakars777.</li>
<li>Cron/doctor: stop flagging canonical <code>agentTurn</code> and <code>systemEvent</code> payload kinds as legacy cron storage, while still normalizing whitespace-padded and non-canonical variants. (#44012) Thanks @shuicici.</li>
<li>ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving <code>end_turn</code>, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby.</li>
<li>Telegram/Discord status reactions: show a temporary compacting reaction during auto-compaction pauses and restore thinking afterward so the bot no longer appears frozen while context is being compacted. (#35474) thanks @Cypherm.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.12/OpenClaw-2026.3.12.zip" length="23628700" type="application/octet-stream" sparkle:edSignature="o6Zdcw36l3I0jUg14H+RBqNwrhuuSsq1WMDi4tBRa1+5TC3VCVdFKZ2hzmH2Xjru9lDEzVMP8v2A6RexSbOCBQ=="/>
</item>
<item>
<title>2026.3.8-beta.1</title>
<pubDate>Mon, 09 Mar 2026 07:19:57 +0000</pubDate>
@@ -438,225 +530,5 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.7/OpenClaw-2026.3.7.zip" length="23263833" type="application/octet-stream" sparkle:edSignature="SO0zedZMzrvSDltLkuaSVQTWFPPPe1iu/enS4TGGb5EGckhqRCmNJWMKNID5lKwFC8vefTbfG9JTlSrZedP4Bg=="/>
</item>
<item>
<title>2026.3.2</title>
<pubDate>Tue, 03 Mar 2026 04:30:29 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026030290</sparkle:version>
<sparkle:shortVersionString>2026.3.2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.3.2</h2>
<h3>Changes</h3>
<ul>
<li>Secrets/SecretRef coverage: expand SecretRef support across the full supported user-supplied credential surface (64 targets total), including runtime collectors, <code>openclaw secrets</code> planning/apply/audit flows, onboarding SecretInput UX, and related docs; unresolved refs now fail fast on active surfaces while inactive surfaces report non-blocking diagnostics. (#29580) Thanks @joshavant.</li>
<li>Tools/PDF analysis: add a first-class <code>pdf</code> tool with native Anthropic and Google PDF provider support, extraction fallback for non-native models, configurable defaults (<code>agents.defaults.pdfModel</code>, <code>pdfMaxBytesMb</code>, <code>pdfMaxPages</code>), and docs/tests covering routing, validation, and registration. (#31319) Thanks @tyler6204.</li>
<li>Outbound adapters/plugins: add shared <code>sendPayload</code> support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.</li>
<li>Models/MiniMax: add first-class <code>MiniMax-M2.5-highspeed</code> support across built-in provider catalogs, onboarding flows, and MiniMax OAuth plugin defaults, while keeping legacy <code>MiniMax-M2.5-Lightning</code> compatibility for existing configs.</li>
<li>Sessions/Attachments: add inline file attachment support for <code>sessions_spawn</code> (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via <code>tools.sessions_spawn.attachments</code>. (#16761) Thanks @napetrov.</li>
<li>Telegram/Streaming defaults: default <code>channels.telegram.streaming</code> to <code>partial</code> (from <code>off</code>) so new Telegram setups get live preview streaming out of the box, with runtime fallback to message-edit preview when native drafts are unavailable.</li>
<li>Telegram/DM streaming: use <code>sendMessageDraft</code> for private preview streaming, keep reasoning/answer preview lanes separated in DM reasoning-stream mode. (#31824) Thanks @obviyus.</li>
<li>Telegram/voice mention gating: add optional <code>disableAudioPreflight</code> on group/topic config to skip mention-detection preflight transcription for inbound voice notes where operators want text-only mention checks. (#23067) Thanks @yangnim21029.</li>
<li>CLI/Config validation: add <code>openclaw config validate</code> (with <code>--json</code>) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.</li>
<li>Tools/Diffs: add PDF file output support and rendering quality customization controls (<code>fileQuality</code>, <code>fileScale</code>, <code>fileMaxWidth</code>) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.</li>
<li>Memory/Ollama embeddings: add <code>memorySearch.provider = "ollama"</code> and <code>memorySearch.fallback = "ollama"</code> support, honor <code>models.providers.ollama</code> settings for memory embedding requests, and document Ollama embedding usage. (#26349) Thanks @nico-hoff.</li>
<li>Zalo Personal plugin (<code>@openclaw/zalouser</code>): rebuilt channel runtime to use native <code>zca-js</code> integration in-process, removing external CLI transport usage and keeping QR/login + send/listen flows fully inside OpenClaw.</li>
<li>Plugin SDK/channel extensibility: expose <code>channelRuntime</code> on <code>ChannelGatewayContext</code> so external channel plugins can access shared runtime helpers (reply/routing/session/text/media/commands) without internal imports. (#25462) Thanks @guxiaobo.</li>
<li>Plugin runtime/STT: add <code>api.runtime.stt.transcribeAudioFile(...)</code> so extensions can transcribe local audio files through OpenClaw's configured media-understanding audio providers. (#22402) Thanks @benthecarman.</li>
<li>Plugin hooks/session lifecycle: include <code>sessionKey</code> in <code>session_start</code>/<code>session_end</code> hook events and contexts so plugins can correlate lifecycle callbacks with routing identity. (#26394) Thanks @tempeste.</li>
<li>Hooks/message lifecycle: add internal hook events <code>message:transcribed</code> and <code>message:preprocessed</code>, plus richer outbound <code>message:sent</code> context (<code>isGroup</code>, <code>groupId</code>) for group-conversation correlation and post-transcription automations. (#9859) Thanks @Drickon.</li>
<li>Media understanding/audio echo: add optional <code>tools.media.audio.echoTranscript</code> + <code>echoFormat</code> to send a pre-agent transcript confirmation message to the originating chat, with echo disabled by default. (#32150) Thanks @AytuncYildizli.</li>
<li>Plugin runtime/system: expose <code>runtime.system.requestHeartbeatNow(...)</code> so extensions can wake targeted sessions immediately after enqueueing system events. (#19464) Thanks @AustinEral.</li>
<li>Plugin runtime/events: expose <code>runtime.events.onAgentEvent</code> and <code>runtime.events.onSessionTranscriptUpdate</code> for extension-side subscriptions, and isolate transcript-listener failures so one faulty listener cannot break the entire update fanout. (#16044) Thanks @scifantastic.</li>
<li>CLI/Banner taglines: add <code>cli.banner.taglineMode</code> (<code>random</code> | <code>default</code> | <code>off</code>) to control funny tagline behavior in startup output, with docs + FAQ guidance and regression tests for config override behavior.</li>
</ul>
<h3>Breaking</h3>
<ul>
<li><strong>BREAKING:</strong> Onboarding now defaults <code>tools.profile</code> to <code>messaging</code> for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.</li>
<li><strong>BREAKING:</strong> ACP dispatch now defaults to enabled unless explicitly disabled (<code>acp.dispatch.enabled=false</code>). If you need to pause ACP turn routing while keeping <code>/acp</code> controls, set <code>acp.dispatch.enabled=false</code>. Docs: https://docs.openclaw.ai/tools/acp-agents</li>
<li><strong>BREAKING:</strong> Plugin SDK removed <code>api.registerHttpHandler(...)</code>. Plugins must register explicit HTTP routes via <code>api.registerHttpRoute({ path, auth, match, handler })</code>, and dynamic webhook lifecycles should use <code>registerPluginHttpRoute(...)</code>.</li>
<li><strong>BREAKING:</strong> Zalo Personal plugin (<code>@openclaw/zalouser</code>) no longer depends on external <code>zca</code>-compatible CLI binaries (<code>openzca</code>, <code>zca-cli</code>) for runtime send/listen/login; operators should use <code>openclaw channels login --channel zalouser</code> after upgrade to refresh sessions in the new JS-native path.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (<code>trim</code> on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.</li>
<li>Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing <code>token.trim()</code> crashes during status/start flows. (#31973) Thanks @ningding97.</li>
<li>Discord/lifecycle startup status: push an immediate <code>connected</code> status snapshot when the gateway is already connected before lifecycle debug listeners attach, with abort-guarding to avoid contradictory status flips during pre-aborted startup. (#32336) Thanks @mitchmcalister.</li>
<li>Feishu/LINE group system prompts: forward per-group <code>systemPrompt</code> config into inbound context <code>GroupSystemPrompt</code> for Feishu and LINE group/room events so configured group-specific behavior actually applies at dispatch time. (#31713) Thanks @whiskyboy.</li>
<li>Mentions/Slack formatting hardening: add null-safe guards for runtime text normalization paths so malformed/undefined text payloads do not crash mention stripping or mrkdwn conversion. (#31865) Thanks @stone-jin.</li>
<li>Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older <code>openclaw/plugin-sdk</code> builds omit webhook default constants. (#31606)</li>
<li>Feishu/group broadcast dispatch: add configurable multi-agent group broadcast dispatch with observer-session isolation, cross-account dedupe safeguards, and non-mention history buffering rules that avoid duplicate replay in broadcast/topic workflows. (#29575) Thanks @ohmyskyhigh.</li>
<li>Gateway/Subagent TLS pairing: allow authenticated local <code>gateway-client</code> backend self-connections to skip device pairing while still requiring pairing for non-local/direct-host paths, restoring <code>sessions_spawn</code> with <code>gateway.tls.enabled=true</code> in Docker/LAN setups. Fixes #30740. Thanks @Sid-Qin and @vincentkoc.</li>
<li>Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.</li>
<li>Synology Chat/webhook ingress hardening: enforce bounded body reads (size + timeout) via shared request-body guards to prevent unauthenticated slow-body hangs before token validation. (#25831) Thanks @bmendonca3.</li>
<li>Feishu/Dedup restart resilience: warm persistent dedup state into memory on monitor startup so retry events after gateway restart stay suppressed without requiring initial on-disk probe misses. (#31605)</li>
<li>Voice-call/runtime lifecycle: prevent <code>EADDRINUSE</code> loops by resetting failed runtime promises, making webhook <code>start()</code> idempotent with the actual bound port, and fully cleaning up webhook/tunnel/tailscale resources after startup failures. (#32395) Thanks @scoootscooob.</li>
<li>Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when <code>gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback</code> accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example <code>(a|aa)+</code>), and bound large regex-evaluation inputs for session-filter and log-redaction paths.</li>
<li>Gateway/Plugin HTTP hardening: require explicit <code>auth</code> for plugin route registration, add route ownership guards for duplicate <code>path+match</code> registrations, centralize plugin path matching/auth logic into dedicated modules, and share webhook target-route lifecycle wiring across channel monitors to avoid stale or conflicting registrations. Thanks @tdjackey for reporting.</li>
<li>Browser/Profile defaults: prefer <code>openclaw</code> profile over <code>chrome</code> in headless/no-sandbox environments unless an explicit <code>defaultProfile</code> is configured. (#14944) Thanks @BenediktSchackenberg.</li>
<li>Gateway/WS security: keep plaintext <code>ws://</code> loopback-only by default, with explicit break-glass private-network opt-in via <code>OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1</code>; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc.</li>
<li>OpenAI Codex OAuth/TLS prerequisites: add an OAuth TLS cert-chain preflight with actionable remediation for cert trust failures, and gate doctor TLS prerequisite probing to OpenAI Codex OAuth-configured installs (or explicit <code>doctor --deep</code>) to avoid unconditional outbound probe latency. (#32051) Thanks @alexfilatov.</li>
<li>Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.</li>
<li>CLI/Config validation and routing hardening: dedupe <code>openclaw config validate</code> failures to a single authoritative report, expose allowed-values metadata/hints across core Zod and plugin AJV validation (including <code>--json</code> fields), sanitize terminal-rendered validation text, and make command-path parsing root-option-aware across preaction/route/lazy registration (including routed <code>config get/unset</code> with split root options). Thanks @gumadeiras.</li>
<li>Browser/Extension relay reconnect tolerance: keep <code>/json/version</code> and <code>/cdp</code> reachable during short MV3 worker disconnects when attached targets still exist, and retain clients across reconnect grace windows. (#30232) Thanks @Sid-Qin.</li>
<li>CLI/Browser start timeout: honor <code>openclaw browser --timeout <ms> start</code> and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.</li>
<li>Synology Chat/gateway lifecycle: keep <code>startAccount</code> pending until abort for inactive and active account paths to prevent webhook route restart loops under gateway supervision. (#23074) Thanks @druide67.</li>
<li>Exec approvals/allowlist matching: escape regex metacharacters in path-pattern literals (while preserving glob wildcards), preventing crashes on allowlisted executables like <code>/usr/bin/g++</code> and correctly matching mixed wildcard/literal token paths. (#32162) Thanks @stakeswky.</li>
<li>Synology Chat/webhook compatibility: accept JSON and alias payload fields, allow token resolution from body/query/header sources, and ACK webhook requests with <code>204</code> to avoid persistent <code>Processing...</code> states in Synology Chat clients. (#26635) Thanks @memphislee09-source.</li>
<li>Voice-call/Twilio signature verification: retry signature validation across deterministic URL port variants (with/without port) to handle mixed Twilio signing behavior behind reverse proxies and non-standard ports. (#25140) Thanks @drvoss.</li>
<li>Slack/Bolt startup compatibility: remove invalid <code>message.channels</code> and <code>message.groups</code> event registrations so Slack providers no longer crash on startup with Bolt 4.6+; channel/group traffic continues through the unified <code>message</code> handler (<code>channel_type</code>). (#32033) Thanks @mahopan.</li>
<li>Slack/socket auth failure handling: fail fast on non-recoverable auth errors (<code>account_inactive</code>, <code>invalid_auth</code>, etc.) during startup and reconnect instead of retry-looping indefinitely, including <code>unable_to_socket_mode_start</code> error payload propagation. (#32377) Thanks @scoootscooob.</li>
<li>Gateway/macOS LaunchAgent hardening: write <code>Umask=077</code> in generated gateway LaunchAgent plists so npm upgrades preserve owner-only default file permissions for gateway-created state files. (#31919) Fixes #31905. Thanks @liuxiaopai-ai.</li>
<li>macOS/LaunchAgent security defaults: write <code>Umask=63</code> (octal <code>077</code>) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system <code>022</code>. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.</li>
<li>Media understanding/provider HTTP proxy routing: pass a proxy-aware fetch function from <code>HTTPS_PROXY</code>/<code>HTTP_PROXY</code> env vars into audio/video provider calls (with graceful malformed-proxy fallback) so transcription/video requests honor configured outbound proxies. (#27093) Thanks @mcaxtr.</li>
<li>Sandbox/workspace mount permissions: make primary <code>/workspace</code> bind mounts read-only whenever <code>workspaceAccess</code> is not <code>rw</code> (including <code>none</code>) across both core sandbox container and sandbox browser create flows. (#32227) Thanks @guanyu-zhang.</li>
<li>Tools/fsPolicy propagation: honor <code>tools.fs.workspaceOnly</code> for image/pdf local-root allowlists so non-sandbox media paths outside workspace are rejected when workspace-only mode is enabled. (#31882) Thanks @justinhuangcode.</li>
<li>Daemon/Homebrew runtime pinning: resolve Homebrew Cellar Node paths to stable Homebrew-managed symlinks (including versioned formulas like <code>node@22</code>) so gateway installs keep the intended runtime across brew upgrades. (#32185) Thanks @scoootscooob.</li>
<li>Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.</li>
<li>Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded <code>/api/channels/*</code> variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting.</li>
<li>Browser/Gateway hardening: preserve env credentials for <code>OPENCLAW_GATEWAY_URL</code> / <code>CLAWDBOT_GATEWAY_URL</code> while treating explicit <code>--url</code> as override-only auth, and make container browser hardening flags optional with safer defaults for Docker/LXC stability. (#31504) Thanks @vincentkoc.</li>
<li>Gateway/Control UI basePath webhook passthrough: let non-read methods under configured <code>controlUiBasePath</code> fall through to plugin routes (instead of returning Control UI 405), restoring webhook handlers behind basePath mounts. (#32311) Thanks @ademczuk.</li>
<li>Control UI/Legacy browser compatibility: replace <code>toSorted</code>-dependent cron suggestion sorting in <code>app-render</code> with a compatibility helper so older browsers without <code>Array.prototype.toSorted</code> no longer white-screen. (#31775) Thanks @liuxiaopai-ai.</li>
<li>macOS/PeekabooBridge: add compatibility socket symlinks for legacy <code>clawdbot</code>, <code>clawdis</code>, and <code>moltbot</code> Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.</li>
<li>Gateway/message tool reliability: avoid false <code>Unknown channel</code> failures when <code>message.*</code> actions receive platform-specific channel ids by falling back to <code>toolContext.currentChannelProvider</code>, and prevent health-monitor restart thrash for channels that just (re)started by adding a per-channel startup-connect grace window. (from #32367) Thanks @MunemHashmi.</li>
<li>Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for <code>.cmd</code> shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.</li>
<li>Security/ACP sandbox inheritance: enforce fail-closed runtime guardrails for <code>sessions_spawn</code> with <code>runtime="acp"</code> by rejecting ACP spawns from sandboxed requester sessions and rejecting <code>sandbox="require"</code> for ACP runtime, preventing sandbox-boundary bypass via host-side ACP initialization. (#32254) Thanks @tdjackey for reporting, and @dutifulbob for the fix.</li>
<li>Security/Web tools SSRF guard: keep DNS pinning for untrusted <code>web_fetch</code> and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting.</li>
<li>Gemini schema sanitization: coerce malformed JSON Schema <code>properties</code> values (<code>null</code>, arrays, primitives) to <code>{}</code> before provider validation, preventing downstream strict-validator crashes on invalid plugin/tool schemas. (#32332) Thanks @webdevtodayjason.</li>
<li>Media understanding/malformed attachment guards: harden attachment selection and decision summary formatting against non-array or malformed attachment payloads to prevent runtime crashes on invalid inbound metadata shapes. (#28024) Thanks @claw9267.</li>
<li>Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.</li>
<li>Browser/Extension relay stale tabs: evict stale cached targets from <code>/json/list</code> when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.</li>
<li>Browser/CDP startup readiness: wait for CDP websocket readiness after launching Chrome and cleanly stop/reset when readiness never arrives, reducing follow-up <code>PortInUseError</code> races after <code>browser start</code>/<code>open</code>. (#29538) Thanks @AaronWander.</li>
<li>OpenAI/Responses WebSocket tool-call id hygiene: normalize blank/whitespace streamed tool-call ids before persistence, and block empty <code>function_call_output.call_id</code> payloads in the WS conversion path to avoid OpenAI 400 errors (<code>Invalid 'input[n].call_id': empty string</code>), with regression coverage for both inbound stream normalization and outbound payload guards.</li>
<li>Security/Nodes camera URL downloads: bind node <code>camera.snap</code>/<code>camera.clip</code> URL payload downloads to the resolved node host, enforce fail-closed behavior when node <code>remoteIp</code> is unavailable, and use SSRF-guarded fetch with redirect host/protocol checks to prevent off-node fetch pivots. Thanks @tdjackey for reporting.</li>
<li>Config/backups hardening: enforce owner-only (<code>0600</code>) permissions on rotated config backups and clean orphan <code>.bak.*</code> files outside the managed backup ring, reducing credential leakage risk from stale or permissive backup artifacts. (#31718) Thanks @YUJIE2002.</li>
<li>Telegram/inbound media filenames: preserve original <code>file_name</code> metadata for document/audio/video/animation downloads (with fetch/path fallbacks), so saved inbound attachments keep sender-provided names instead of opaque Telegram file paths. (#31837) Thanks @Kay-051.</li>
<li>Gateway/OpenAI chat completions: honor <code>x-openclaw-message-channel</code> when building <code>agentCommand</code> input for <code>/v1/chat/completions</code>, preserving caller channel identity instead of forcing <code>webchat</code>. (#30462) Thanks @bmendonca3.</li>
<li>Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @Glucksberg.</li>
<li>Media/MIME normalization: normalize parameterized/case-variant MIME strings in <code>kindFromMime</code> (for example <code>Audio/Ogg; codecs=opus</code>) so WhatsApp voice notes are classified as audio and routed through transcription correctly. (#32280) Thanks @Lucenx9.</li>
<li>Discord/audio preflight mentions: detect audio attachments via Discord <code>content_type</code> and gate preflight transcription on typed text (not media placeholders), so guild voice-note mentions are transcribed and matched correctly. (#32136) Thanks @jnMetaCode.</li>
<li>Feishu/topic session routing: use <code>thread_id</code> as topic session scope fallback when <code>root_id</code> is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun.</li>
<li>Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of <code>NO_REPLY</code> and keep final-message buffering in sync, preventing partial <code>NO</code> leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai.</li>
<li>Telegram/models picker callbacks: keep long model buttons selectable by falling back to compact callback payloads and resolving provider ids on selection (with provider re-prompt on ambiguity), avoiding Telegram 64-byte callback truncation failures. (#31857) Thanks @bmendonca3.</li>
<li>Context-window metadata warmup: add exponential config-load retry backoff (1s -> 2s -> 4s, capped at 60s) so transient startup failures recover automatically without hot-loop retries.</li>
<li>Voice-call/Twilio external outbound: auto-register webhook-first <code>outbound-api</code> calls (initiated outside OpenClaw) so media streams are accepted and call direction metadata stays accurate. (#31181) Thanks @scoootscooob.</li>
<li>Feishu/topic root replies: prefer <code>root_id</code> as outbound <code>replyTargetMessageId</code> when present, and parse millisecond <code>message_create_time</code> values correctly so topic replies anchor to the root message in grouped thread flows. (#29968) Thanks @bmendonca3.</li>
<li>Feishu/DM pairing reply target: send pairing challenge replies to <code>chat:<chat_id></code> instead of <code>user:<sender_open_id></code> so Lark/Feishu private chats with user-id-only sender payloads receive pairing messages reliably. (#31403) Thanks @stakeswky.</li>
<li>Feishu/Lark private DM routing: treat inbound <code>chat_type: "private"</code> as direct-message context for pairing/mention-forward/reaction synthetic handling so Lark private chats behave like Feishu p2p DMs. (#31400) Thanks @stakeswky.</li>
<li>Signal/message actions: allow <code>react</code> to fall back to <code>toolContext.currentMessageId</code> when <code>messageId</code> is omitted, matching Telegram behavior and unblocking agent-initiated reactions on inbound turns. (#32217) Thanks @dunamismax.</li>
<li>Discord/message actions: allow <code>react</code> to fall back to <code>toolContext.currentMessageId</code> when <code>messageId</code> is omitted, matching Telegram/Signal reaction ergonomics in inbound turns.</li>
<li>Synology Chat/reply delivery: resolve webhook usernames to Chat API <code>user_id</code> values for outbound chatbot replies, avoiding mismatches between webhook user IDs and <code>method=chatbot</code> recipient IDs in multi-account setups. (#23709) Thanks @druide67.</li>
<li>Slack/thread context payloads: only inject thread starter/history text on first thread turn for new sessions while preserving thread metadata, reducing repeated context-token bloat on long-lived thread sessions. (#32133) Thanks @sourman.</li>
<li>Slack/session routing: keep top-level channel messages in one shared session when <code>replyToMode=off</code>, while preserving thread-scoped keys for true thread replies and non-off modes. (#32193) Thanks @bmendonca3.</li>
<li>Voice-call/webhook routing: require exact webhook path matches (instead of prefix matches) so lookalike paths cannot reach provider verification/dispatch logic. (#31930) Thanks @afurm.</li>
<li>Zalo/Pairing auth tests: add webhook regression coverage asserting DM pairing-store reads/writes remain account-scoped, preventing cross-account authorization bleed in multi-account setups. (#26121) Thanks @bmendonca3.</li>
<li>Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (<code>monitor.account-scope.test.ts</code>) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3.</li>
<li>Feishu/Send target prefixes: normalize explicit <code>group:</code>/<code>dm:</code> send targets and preserve explicit receive-id routing hints when resolving outbound Feishu targets. (#31594) Thanks @liuxiaopai-ai.</li>
<li>Webchat/Feishu session continuation: preserve routable <code>OriginatingChannel</code>/<code>OriginatingTo</code> metadata from session delivery context in <code>chat.send</code>, and prefer provider-normalized channel when deciding cross-channel route dispatch so Webchat replies continue on the selected Feishu session instead of falling back to main/internal session routing. (#31573)</li>
<li>Telegram/implicit mention forum handling: exclude Telegram forum system service messages (<code>forum_topic_*</code>, <code>general_forum_topic_*</code>) from reply-chain implicit mention detection so <code>requireMention</code> does not get bypassed inside bot-created topic lifecycle events. (#32262) Thanks @scoootscooob.</li>
<li>Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.</li>
<li>Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (<code>provider: "message"</code>) and normalize <code>lark</code>/<code>feishu</code> provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)</li>
<li>Webchat/silent token leak: filter assistant <code>NO_REPLY</code>-only transcript entries from <code>chat.history</code> responses and add client-side defense-in-depth guards in the chat controller so internal silent tokens never render as visible chat bubbles. (#32015) Consolidates overlap from #32183, #32082, #32045, #32052, #32172, and #32112. Thanks @ademczuk, @liuxiaopai-ai, @ningding97, @bmendonca3, and @x4v13r1120.</li>
<li>Doctor/local memory provider checks: stop false-positive local-provider warnings when <code>provider=local</code> and no explicit <code>modelPath</code> is set by honoring default local model fallback while still warning when gateway probe reports local embeddings not ready. (#32014) Fixes #31998. Thanks @adhishthite.</li>
<li>Media understanding/parakeet CLI output parsing: read <code>parakeet-mlx</code> transcripts from <code>--output-dir/<media-basename>.txt</code> when txt output is requested (or default), with stdout fallback for non-txt formats. (#9177) Thanks @mac-110.</li>
<li>Media understanding/audio transcription guard: skip tiny/empty audio files (<1024 bytes) before provider/CLI transcription to avoid noisy invalid-audio failures and preserve clean fallback behavior. (#8388) Thanks @Glucksberg.</li>
<li>Gateway/Plugin HTTP route precedence: run explicit plugin HTTP routes before the Control UI SPA catch-all so registered plugin webhook/custom paths remain reachable, while unmatched paths still fall through to Control UI handling. (#31885) Thanks @Sid-Qin.</li>
<li>Gateway/Node browser proxy routing: honor <code>profile</code> from <code>browser.request</code> JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin.</li>
<li>Gateway/Control UI basePath POST handling: return 405 for <code>POST</code> on exact basePath routes (for example <code>/openclaw</code>) instead of redirecting, and add end-to-end regression coverage that root-mounted webhook POST paths still pass through to plugin handlers. (#31349) Thanks @Sid-Qin.</li>
<li>Browser/default profile selection: default <code>browser.defaultProfile</code> behavior now prefers <code>openclaw</code> (managed standalone CDP) when no explicit default is configured, while still auto-provisioning the <code>chrome</code> relay profile for explicit opt-in use. (#32031) Fixes #31907. Thanks @liuxiaopai-ai.</li>
<li>Sandbox/mkdirp boundary checks: allow existing in-boundary directories to pass mkdirp boundary validation when directory open probes return platform-specific I/O errors, with regression coverage for directory-safe fallback behavior. (#31547) Thanks @stakeswky.</li>
<li>Models/config env propagation: apply <code>config.env.vars</code> before implicit provider discovery in models bootstrap so config-scoped credentials are visible to implicit provider resolution paths. (#32295) Thanks @hsiaoa.</li>
<li>Models/Codex usage labels: infer weekly secondary usage windows from reset cadence when API window seconds are ambiguously reported as 24h, so <code>openclaw models status</code> no longer mislabels weekly limits as daily. (#31938) Thanks @bmendonca3.</li>
<li>Gateway/Heartbeat model reload: treat <code>models.*</code> and <code>agents.defaults.model</code> config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.</li>
<li>Memory/LanceDB embeddings: forward configured <code>embedding.dimensions</code> into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.</li>
<li>Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.</li>
<li>Browser/CDP status accuracy: require a successful <code>Browser.getVersion</code> response over the CDP websocket (not just socket-open) before reporting <code>cdpReady</code>, so stale idle command channels are surfaced as unhealthy. (#23427) Thanks @vincentkoc.</li>
<li>Daemon/systemd checks in containers: treat missing <code>systemctl</code> invocations (including <code>spawn systemctl ENOENT</code>/<code>EACCES</code>) as unavailable service state during <code>is-enabled</code> checks, preventing container flows from failing with <code>Gateway service check failed</code> before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.</li>
<li>Security/Node exec approvals: revalidate approval-bound <code>cwd</code> identity immediately before execution/forwarding and fail closed with an explicit denial when <code>cwd</code> drifts after approval hardening.</li>
<li>Security audit/skills workspace hardening: add <code>skills.workspace.symlink_escape</code> warning in <code>openclaw security audit</code> when workspace <code>skills/**/SKILL.md</code> resolves outside the workspace root (for example symlink-chain drift), plus docs coverage in the security glossary.</li>
<li>Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example <code>env sh -c ...</code>) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.</li>
<li>Security/fs-safe write hardening: make <code>writeFileWithinRoot</code> use same-directory temp writes plus atomic rename, add post-write inode/hardlink revalidation with security warnings on boundary drift, and avoid truncating existing targets when final rename fails.</li>
<li>Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.</li>
<li>Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like <code>[System Message]</code> and line-leading <code>System:</code> in untrusted message content. (#30448)</li>
<li>Sandbox/Docker setup command parsing: accept <code>agents.*.sandbox.docker.setupCommand</code> as either a string or a string array, and normalize arrays to newline-delimited shell scripts so multi-step setup commands no longer concatenate without separators. (#31953) Thanks @liuxiaopai-ai.</li>
<li>Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction <code>AGENTS.md</code> context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.</li>
<li>Agents/Sandbox workdir mapping: map container workdir paths (for example <code>/workspace</code>) back to the host workspace before sandbox path validation so exec requests keep the intended directory in containerized runs instead of falling back to an unavailable host path. (#31841) Thanks @liuxiaopai-ai.</li>
<li>Docker/Sandbox bootstrap hardening: make <code>OPENCLAW_SANDBOX</code> opt-in parsing explicit (<code>1|true|yes|on</code>), support custom Docker socket paths via <code>OPENCLAW_DOCKER_SOCKET</code>, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to <code>off</code> when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.</li>
<li>Hooks/webhook ACK compatibility: return <code>200</code> (instead of <code>202</code>) for successful <code>/hooks/agent</code> requests so providers that require <code>200</code> (for example Forward Email) accept dispatched agent hook deliveries. (#28204) Thanks @Glucksberg.</li>
<li>Feishu/Run channel fallback: prefer <code>Provider</code> over <code>Surface</code> when inferring queued run <code>messageProvider</code> fallback (when <code>OriginatingChannel</code> is missing), preventing Feishu turns from being mislabeled as <code>webchat</code> in mixed relay metadata contexts. (#31880) Fixes #31859. Thanks @liuxiaopai-ai.</li>
<li>Skills/sherpa-onnx-tts: run the <code>sherpa-onnx-tts</code> bin under ESM (replace CommonJS <code>require</code> imports) and add regression coverage to prevent <code>require is not defined in ES module scope</code> startup crashes. (#31965) Thanks @bmendonca3.</li>
<li>Inbound metadata/direct relay context: restore direct-channel conversation metadata blocks for external channels (for example WhatsApp) while preserving webchat-direct suppression, so relay agents recover sender/message identifiers without reintroducing internal webchat metadata noise. (#31969) Fixes #29972. Thanks @Lucenx9.</li>
<li>Slack/Channel message subscriptions: register explicit <code>message.channels</code> and <code>message.groups</code> monitor handlers (alongside generic <code>message</code>) so channel/group event subscriptions are consumed even when Slack dispatches typed message event names. Fixes #31674.</li>
<li>Hooks/session-scoped memory context: expose ephemeral <code>sessionId</code> in embedded plugin tool contexts and <code>before_tool_call</code>/<code>after_tool_call</code> hook contexts (including compaction and client-tool wiring) so plugins can isolate per-conversation state across <code>/new</code> and <code>/reset</code>. Related #31253 and #31304. Thanks @Sid-Qin and @Servo-AIpex.</li>
<li>Voice-call/Twilio inbound greeting: run answered-call initial notify greeting for Twilio instead of skipping the manager speak path, with regression coverage for both Twilio and Plivo notify flows. (#29121) Thanks @xinhuagu.</li>
<li>Voice-call/stale call hydration: verify active calls with the provider before loading persisted in-progress calls so stale locally persisted records do not block or misroute new call handling after restarts. (#4325) Thanks @garnetlyx.</li>
<li>Feishu/File upload filenames: percent-encode non-ASCII/special-character <code>file_name</code> values in Feishu multipart uploads so Chinese/symbol-heavy filenames are sent as proper attachments instead of plain text links. (#31179) Thanks @Kay-051.</li>
<li>Media/MIME channel parity: route Telegram/Signal/iMessage media-kind checks through normalized <code>kindFromMime</code> so mixed-case/parameterized MIME values classify consistently across message channels.</li>
<li>WhatsApp/inbound self-message context: propagate inbound <code>fromMe</code> through the web inbox pipeline and annotate direct self messages as <code>(self)</code> in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.</li>
<li>Webchat/stream finalization: persist streamed assistant text when final events omit <code>message</code>, while keeping final payload precedence and skipping empty stream buffers to prevent disappearing replies after tool turns. (#31920) Thanks @Sid-Qin.</li>
<li>Feishu/Inbound ordering: serialize message handling per chat while preserving cross-chat concurrency to avoid same-chat race drops under bursty inbound traffic. (#31807)</li>
<li>Feishu/Typing notification suppression: skip typing keepalive reaction re-adds when the indicator is already active, preventing duplicate notification pings from repeated identical emoji adds. (#31580)</li>
<li>Feishu/Probe failure backoff: cache API and timeout probe failures for one minute per account key while preserving abort-aware probe timeouts, reducing repeated health-check retries during transient credential/network outages. (#29970)</li>
<li>Feishu/Streaming block fallback: preserve markdown block stream text as final streaming-card content when final payload text is missing, while still suppressing non-card internal block chunk delivery. (#30663)</li>
<li>Feishu/Bitable API errors: unify Feishu Bitable tool error handling with structured <code>LarkApiError</code> responses and consistent API/context attribution across wiki/base metadata, field, and record operations. (#31450)</li>
<li>Feishu/Missing-scope grant URL fix: rewrite known invalid scope aliases (<code>contact:contact.base:readonly</code>) to valid scope names in permission grant links, so remediation URLs open with correct Feishu consent scopes. (#31943)</li>
<li>BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound <code>message_id</code> selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.</li>
<li>WebChat/markdown tables: ensure GitHub-flavored markdown table parsing is explicitly enabled at render time and add horizontal overflow handling for wide tables, with regression coverage for table-only and mixed text+table content. (#32365) Thanks @BlueBirdBack.</li>
<li>Feishu/default account resolution: always honor explicit <code>channels.feishu.defaultAccount</code> during outbound account selection (including top-level-credential setups where the preferred id is not present in <code>accounts</code>), instead of silently falling back to another account id. (#32253) Thanks @bmendonca3.</li>
<li>Feishu/Sender lookup permissions: suppress user-facing grant prompts for stale non-existent scope errors (<code>contact:contact.base:readonly</code>) during best-effort sender-name resolution so inbound messages continue without repeated false permission notices. (#31761)</li>
<li>Discord/dispatch + Slack formatting: restore parallel outbound dispatch across Discord channels with per-channel queues while preserving in-channel ordering, and run Slack preview/stream update text through mrkdwn normalization for consistent formatting. (#31927) Thanks @Sid-Qin.</li>
<li>Feishu/Inbound debounce: debounce rapid same-chat sender bursts into one ordered dispatch turn, skip already-processed retries when composing merged text, and preserve bot-mention intent across merged entries to reduce duplicate or late inbound handling. (#31548)</li>
<li>Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale.</li>
<li>Browser/Extension re-announce reliability: keep relay state in <code>connecting</code> when re-announce forwarding fails and extend debugger re-attach retries after navigation to reduce false attached states and post-nav disconnect loops. (#27630) Thanks @markmusson.</li>
<li>Browser/Act request compatibility: accept legacy flattened <code>action="act"</code> params (<code>kind/ref/text/...</code>) in addition to <code>request={...}</code> so browser act calls no longer fail with <code>request required</code>. (#15120) Thanks @vincentkoc.</li>
<li>OpenRouter/x-ai compatibility: skip <code>reasoning.effort</code> injection for <code>x-ai/*</code> models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.</li>
<li>Models/openai-completions developer-role compatibility: force <code>supportsDeveloperRole=false</code> for non-native endpoints, treat unparseable <code>baseUrl</code> values as non-native, and add regression coverage for empty/malformed baseUrl plus explicit-true override behavior. (#29479) thanks @akramcodez.</li>
<li>Browser/Profile attach-only override: support <code>browser.profiles.<name>.attachOnly</code> (fallback to global <code>browser.attachOnly</code>) so loopback proxy profiles can skip local launch/port-ownership checks without forcing attach-only mode for every profile. (#20595) Thanks @unblockedgamesstudio and @vincentkoc.</li>
<li>Sessions/Lock recovery: detect recycled Linux PIDs by comparing lock-file <code>starttime</code> with <code>/proc/<pid>/stat</code> starttime, so stale <code>.jsonl.lock</code> files are reclaimed immediately in containerized PID-reuse scenarios while preserving compatibility for older lock files. (#26443) Fixes #27252. Thanks @HirokiKobayashi-R and @vincentkoc.</li>
<li>Cron/isolated delivery target fallback: remove early unresolved-target return so cron delivery can flow through shared outbound target resolution (including per-channel <code>resolveDefaultTo</code> fallback) when <code>delivery.to</code> is omitted. (#32364) Thanks @hclsys.</li>
<li>OpenAI media capabilities: include <code>audio</code> in the OpenAI provider capability list so audio transcription models are eligible in media-understanding provider selection. (#12717) Thanks @openjay.</li>
<li>Browser/Managed tab cap: limit loopback managed <code>openclaw</code> page tabs to 8 via best-effort cleanup after tab opens to reduce long-running renderer buildup while preserving attach-only and remote profile behavior. (#29724) Thanks @pandego.</li>
<li>Docker/Image health checks: add Dockerfile <code>HEALTHCHECK</code> that probes gateway <code>GET /healthz</code> so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.</li>
<li>Gateway/Node dangerous-command parity: include <code>sms.send</code> in default onboarding node <code>denyCommands</code>, share onboarding deny defaults with the gateway dangerous-command source of truth, and include <code>sms.send</code> in phone-control <code>/phone arm writes</code> handling so SMS follows the same break-glass flow as other dangerous node commands. Thanks @zpbrent.</li>
<li>Pairing/AllowFrom account fallback: handle omitted <code>accountId</code> values in <code>readChannelAllowFromStore</code> and <code>readChannelAllowFromStoreSync</code> as <code>default</code>, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.</li>
<li>Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.</li>
<li>Browser/CDP proxy bypass: force direct loopback agent paths and scoped <code>NO_PROXY</code> expansion for localhost CDP HTTP/WS connections when proxy env vars are set, so browser relay/control still works behind global proxy settings. (#31469) Thanks @widingmarcus-cyber.</li>
<li>Sessions/idle reset correctness: preserve existing <code>updatedAt</code> during inbound metadata-only writes so idle-reset boundaries are not unintentionally refreshed before actual user turns. (#32379) Thanks @romeodiaz.</li>
<li>Sessions/lock recovery: reclaim orphan legacy same-PID lock files missing <code>starttime</code> when no in-process lock ownership exists, avoiding false lock timeouts after PID reuse while preserving active lock safety checks. (#32081) Thanks @bmendonca3.</li>
<li>Sessions/store cache invalidation: reload cached session stores when file size changes within the same mtime tick by keying cache validation on a single file-stat snapshot (<code>mtimeMs</code> + <code>sizeBytes</code>), with regression coverage for same-tick rewrites. (#32191) Thanks @jalehman.</li>
<li>Agents/Subagents <code>sessions_spawn</code>: reject malformed <code>agentId</code> inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf.</li>
<li>CLI/installer Node preflight: enforce Node.js <code>v22.12+</code> consistently in both <code>openclaw.mjs</code> runtime bootstrap and installer active-shell checks, with actionable nvm recovery guidance for mismatched shell PATH/defaults. (#32356) Thanks @jasonhargrove.</li>
<li>Web UI/config form: support SecretInput string-or-secret-ref unions in map <code>additionalProperties</code>, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.</li>
<li>Auto-reply/inline command cleanup: preserve newline structure when stripping inline <code>/status</code> and extracting inline slash commands by collapsing only horizontal whitespace, preventing paragraph flattening in multi-line replies. (#32224) Thanks @scoootscooob.</li>
<li>Config/raw redaction safety: preserve non-sensitive literals during raw redaction round-trips, scope SecretRef redaction to secret IDs (not structural fields like <code>source</code>/<code>provider</code>), and fall back to structured raw redaction when text replacement cannot restore the original config shape. (#32174) Thanks @bmendonca3.</li>
<li>Hooks/runtime stability: keep the internal hook handler registry on a <code>globalThis</code> singleton so hook registration/dispatch remains consistent when bundling emits duplicate module copies. (#32292) Thanks @Drickon.</li>
<li>Hooks/after_tool_call: include embedded session context (<code>sessionKey</code>, <code>agentId</code>) and fire the hook exactly once per tool execution by removing duplicate adapter-path dispatch in embedded runs. (#32201) Thanks @jbeno, @scoootscooob, @vincentkoc.</li>
<li>Hooks/tool-call correlation: include <code>runId</code> and <code>toolCallId</code> in plugin tool hook payloads/context and scope tool start/adjusted-param tracking by run to prevent cross-run collisions in <code>before_tool_call</code> and <code>after_tool_call</code>. (#32360) Thanks @vincentkoc.</li>
<li>Plugins/install diagnostics: reject legacy plugin package shapes without <code>openclaw.extensions</code> and return an explicit upgrade hint with troubleshooting docs for repackaging. (#32055) Thanks @liuxiaopai-ai.</li>
<li>Hooks/plugin context parity: ensure <code>llm_input</code> hooks in embedded attempts receive the same <code>trigger</code> and <code>channelId</code>-aware <code>hookCtx</code> used by the other hook phases, preserving channel/trigger-scoped plugin behavior. (#28623) Thanks @davidrudduck and @vincentkoc.</li>
<li>Plugins/hardlink install compatibility: allow bundled plugin manifests and entry files to load when installed via hardlink-based package managers (<code>pnpm</code>, <code>bun</code>) while keeping hardlink rejection enabled for non-bundled plugin sources. (#32119) Fixes #28175, #28404, #29455. Thanks @markfietje.</li>
<li>Cron/session reaper reliability: move cron session reaper sweeps into <code>onTimer</code> <code>finally</code> and keep pruning active even when timer ticks fail early (for example cron store parse failures), preventing stale isolated run sessions from accumulating indefinitely. (#31996) Fixes #31946. Thanks @scoootscooob.</li>
<li>Cron/HEARTBEAT_OK summary leak: suppress fallback main-session enqueue for heartbeat/internal ack summaries in isolated announce mode so <code>HEARTBEAT_OK</code> noise never appears in user chat while real summaries still forward. (#32093) Thanks @scoootscooob.</li>
<li>Authentication: classify <code>permission_error</code> as <code>auth_permanent</code> for profile fallback. (#31324) Thanks @Sid-Qin.</li>
<li>Agents/host edit reliability: treat host edit-tool throws as success only when on-disk post-check confirms replacement likely happened (<code>newText</code> present and <code>oldText</code> absent), preventing false failure reports while avoiding pre-write false positives. (#32383) Thanks @polooooo.</li>
<li>Plugins/install fallback safety: resolve bare install specs to bundled plugin ids before npm lookup (for example <code>diffs</code> -> bundled <code>@openclaw/diffs</code>), keep npm fallback limited to true package-not-found errors, and continue rejecting non-plugin npm packages that fail manifest validation. (#32096) Thanks @scoootscooob.</li>
<li>Web UI/inline code copy fidelity: disable forced mid-token wraps on inline <code><code></code> spans so copied UUID/hash/token strings preserve exact content instead of inserting line-break spaces. (#32346) Thanks @hclsys.</li>
<li>Restart sentinel formatting: avoid duplicate <code>Reason:</code> lines when restart message text already matches <code>stats.reason</code>, keeping restart notifications concise for users and downstream parsers. (#32083) Thanks @velamints2.</li>
<li>Auto-reply/followup queue: avoid stale callback reuse across idle-window restarts by caching the followup runner only when a drain actually starts, preserving enqueue ordering after empty-finalize paths. (#31902) Thanks @Lanfei.</li>
<li>Agents/tool-result guard: always clear pending tool-call state on interruptions even when synthetic tool results are disabled, preventing orphaned tool-use transcripts that cause follow-up provider request failures. (#32120) Thanks @jnMetaCode.</li>
<li>Failover/error classification: treat HTTP <code>529</code> (provider overloaded, common with Anthropic-compatible APIs) as <code>rate_limit</code> so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.</li>
<li>Logging: use local time for logged timestamps instead of UTC, aligning log output with documented local timezone behavior and avoiding confusion during local diagnostics. (#28434) Thanks @liuy.</li>
<li>Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.</li>
<li>Secrets/exec resolver timeout defaults: use provider <code>timeoutMs</code> as the default inactivity (<code>noOutputTimeoutMs</code>) watchdog for exec secret providers, preventing premature no-output kills for resolvers that start producing output after 2s. (#32235) Thanks @bmendonca3.</li>
<li>Auto-reply/reminder guard note suppression: when a turn makes reminder-like commitments but schedules no new cron jobs, suppress the unscheduled-reminder warning note only if an enabled cron already exists for the same session; keep warnings for unrelated sessions, disabled jobs, or unreadable cron store paths. (#32255) Thanks @scoootscooob.</li>
<li>Cron/isolated announce heartbeat suppression: treat multi-payload runs as skippable when any payload is a heartbeat ack token and no payload has media, preventing internal narration + trailing <code>HEARTBEAT_OK</code> from being delivered to users. (#32131) Thanks @adhishthite.</li>
<li>Cron/store migration: normalize legacy cron jobs with string <code>schedule</code> and top-level <code>command</code>/<code>timeout</code> fields into canonical schedule/payload/session-target shape on load, preventing schedule-error loops on old persisted stores. (#31926) Thanks @bmendonca3.</li>
<li>Tests/Windows backup rotation: skip chmod-only backup permission assertions on Windows while retaining compose/rotation/prune coverage across platforms to avoid false CI failures from Windows non-POSIX mode semantics. (#32286) Thanks @jalehman.</li>
<li>Tests/Subagent announce: set <code>OPENCLAW_TEST_FAST=1</code> before importing <code>subagent-announce</code> format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.2/OpenClaw-2026.3.2.zip" length="23181513" type="application/octet-stream" sparkle:edSignature="THMgkcoMgz2vv5zse3Po3K7l3Or2RhBKurXZIi8iYVXN76yJy1YXAY6kXi6ovD+dbYn68JKYDIKA1Ya78bO7BQ=="/>
<!-- pragma: allowlist secret -->
</item>
</channel>
</rss>

View File

@@ -382,6 +382,7 @@
"opusscript": "^0.1.1",
"osc-progress": "^0.3.0",
"pdfjs-dist": "^5.5.207",
"pg": "^8.20.0",
"playwright-core": "1.58.2",
"qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5",
@@ -400,11 +401,13 @@
"@types/express": "^5.0.6",
"@types/markdown-it": "^14.1.2",
"@types/node": "^25.5.0",
"@types/pg": "^8.18.0",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260312.1",
"@vitest/coverage-v8": "^4.1.0",
"jscpd": "4.0.8",
"jsdom": "^28.1.0",
"lit": "^3.3.2",
"oxfmt": "0.40.0",
"oxlint": "^1.55.0",

368
pnpm-lock.yaml generated
View File

@@ -163,6 +163,9 @@ importers:
pdfjs-dist:
specifier: ^5.5.207
version: 5.5.207
pg:
specifier: ^8.20.0
version: 8.20.0
playwright-core:
specifier: 1.58.2
version: 1.58.2
@@ -212,6 +215,9 @@ importers:
'@types/node':
specifier: ^25.5.0
version: 25.5.0
'@types/pg':
specifier: ^8.18.0
version: 8.18.0
'@types/qrcode-terminal':
specifier: ^0.12.2
version: 0.12.2
@@ -227,6 +233,9 @@ importers:
jscpd:
specifier: 4.0.8
version: 4.0.8
jsdom:
specifier: ^28.1.0
version: 28.1.0(@noble/hashes@2.0.1)
lit:
specifier: ^3.3.2
version: 3.3.2
@@ -648,10 +657,6 @@ packages:
resolution: {integrity: sha512-t8cl+bPLlHZQD2Sw1a4hSLUybqJZU71+m8znkyeU8CHntFqEp2mMbuLKdHKaAYQ1fAApXMsvzenCAkDzNeeJlw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-bedrock@3.1007.0':
resolution: {integrity: sha512-49hH8o6ALKkCiBUgg20HkwxNamP1yYA/n8Si73Z438EqhZGpCfScP3FfxVhrfD5o+4bV4Whi9BTzPKCa/PfUww==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-bedrock@3.1008.0':
resolution: {integrity: sha512-mzxO/DplpZZT7AIZUCG7Q78OlaeHeDybYz+ZlWZPaXFjGDJwUv1E3SKskmaaQvTsMeieie0WX7gzueYrCx4YfQ==}
engines: {node: '>=20.0.0'}
@@ -708,10 +713,6 @@ packages:
resolution: {integrity: sha512-dFqh7nfX43B8dO1aPQHOcjC0SnCJ83H3F+1LoCh3X1P7E7N09I+0/taID0asU6GCddfDExqnEvQtDdkuMe5tKQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.972.18':
resolution: {integrity: sha512-vthIAXJISZnj2576HeyLBj4WTeX+I7PwWeRkbOa0mVX39K13SCGxCgOFuKj2ytm9qTlLOmXe4cdEnroteFtJfw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.972.19':
resolution: {integrity: sha512-pVJVjWqVrPqjpFq7o0mCmeZu1Y0c94OCHSYgivdCD2wfmYVtBbwQErakruhgOD8pcMcx9SCqRw1pzHKR7OGBcA==}
engines: {node: '>=20.0.0'}
@@ -724,10 +725,6 @@ packages:
resolution: {integrity: sha512-gf2E5b7LpKb+JX2oQsRIDxdRZjBFZt2olCGlWCdb3vBERbXIPgm2t1R5mEnwd4j0UEO/Tbg5zN2KJbHXttJqwA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.18':
resolution: {integrity: sha512-kINzc5BBxdYBkPZ0/i1AMPMOk5b5QaFNbYMElVw5QTX13AKj6jcxnv/YNl9oW9mg+Y08ti19hh01HhyEAxsSJQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.19':
resolution: {integrity: sha512-jOXdZ1o+CywQKr6gyxgxuUmnGwTTnY2Kxs1PM7fI6AYtDWDnmW/yKXayNqkF8KjP1unflqMWKVbVt5VgmE3L0g==}
engines: {node: '>=20.0.0'}
@@ -740,10 +737,6 @@ packages:
resolution: {integrity: sha512-ZDJa2gd1xiPg/nBDGhUlat02O8obaDEnICBAVS8qieZ0+nDfaB0Z3ec6gjZj27OqFTjnB/Q5a0GwQwb7rMVViw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.19':
resolution: {integrity: sha512-yDWQ9dFTr+IMxwanFe7+tbN5++q8psZBjlUwOiCXn1EzANoBgtqBwcpYcHaMGtn0Wlfj4NuXdf2JaEx1lz5RaQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.20':
resolution: {integrity: sha512-0xHca2BnPY0kzjDYPH7vk8YbfdBPpWVS67rtqQMalYDQUCBYS37cZ55K6TuFxCoIyNZgSCFrVKr9PXC5BVvQQw==}
engines: {node: '>=20.0.0'}
@@ -768,10 +761,6 @@ packages:
resolution: {integrity: sha512-wGtte+48xnhnhHMl/MsxzacBPs5A+7JJedjiP452IkHY7vsbYKcvQBqFye8LwdTJVeHtBHv+JFeTscnwepoWGg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.972.18':
resolution: {integrity: sha512-YHYEfj5S2aqInRt5ub8nDOX8vAxgMvd84wm2Y3WVNfFa/53vOv9T7WOAqXI25qjj3uEcV46xxfqdDQk04h5XQA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.972.19':
resolution: {integrity: sha512-kVjQsEU3b///q7EZGrUzol9wzwJFKbEzqJKSq82A9ShrUTEO7FNylTtby3sPV19ndADZh1H3FB3+5ZrvKtEEeg==}
engines: {node: '>=20.0.0'}
@@ -784,10 +773,6 @@ packages:
resolution: {integrity: sha512-8aiVJh6fTdl8gcyL+sVNcNwTtWpmoFa1Sh7xlj6Z7L/cZ/tYMEBHq44wTYG8Kt0z/PpGNopD89nbj3FHl9QmTA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.18':
resolution: {integrity: sha512-OqlEQpJ+J3T5B96qtC1zLLwkBloechP+fezKbCH0sbd2cCc0Ra55XpxWpk/hRj69xAOYtHvoC4orx6eTa4zU7g==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.19':
resolution: {integrity: sha512-BV1BlTFdG4w4tAihxN7iXDBoNcNewXD4q8uZlNQiUrnqxwGWUhKHODIQVSPlQGxXClEj+63m+cqZskw+ESmeZg==}
engines: {node: '>=20.0.0'}
@@ -872,10 +857,6 @@ packages:
resolution: {integrity: sha512-MlGWA8uPaOs5AiTZ5JLM4uuWDm9EEAnm9cqwvqQIc6kEgel/8s1BaOWm9QgUcfc9K8qd7KkC3n43yDbeXOA2tg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.996.8':
resolution: {integrity: sha512-6HlLm8ciMW8VzfB80kfIx16PBA9lOa9Dl+dmCBi78JDhvGlx3I7Rorwi5PpVRkL31RprXnYna3yBf6UKkD/PqA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.996.9':
resolution: {integrity: sha512-+RpVtpmQbbtzFOKhMlsRcXM/3f1Z49qTOHaA8gEpHOYruERmog6f2AUtf/oTRLCWjR9H2b3roqryV/hI7QMW8w==}
engines: {node: '>=20.0.0'}
@@ -900,14 +881,6 @@ packages:
resolution: {integrity: sha512-j9BwZZId9sFp+4GPhf6KrwO8Tben2sXibZA8D1vv2I1zBdvkUHcBA2g4pkqIpTRalMTLC0NPkBPX0gERxfy/iA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1005.0':
resolution: {integrity: sha512-vMxd+ivKqSxU9bHx5vmAlFKDAkjGotFU56IOkDa5DaTu1WWwbcse0yFHEm9I537oVvodaiwMl3VBwgHfzQ2rvw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1007.0':
resolution: {integrity: sha512-kKvVyr53vvVc5k6RbvI6jhafxufxO2SkEw8QeEzJqwOXH/IMY7Cm0IyhnBGdqj80iiIIiIM2jGe7Fn3TIdwdrw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1008.0':
resolution: {integrity: sha512-TulwlHQBWcJs668kNUDMZHN51DeLrDsYT59Ux4a/nbvr025gM6HjKJJ3LvnZccam7OS/ZKUVkWomCneRQKJbBg==}
engines: {node: '>=20.0.0'}
@@ -976,15 +949,6 @@ packages:
aws-crt:
optional: true
'@aws-sdk/util-user-agent-node@3.973.5':
resolution: {integrity: sha512-Dyy38O4GeMk7UQ48RupfHif//gqnOPbq/zlvRssc11E2mClT+aUfc3VS2yD8oLtzqO3RsqQ9I3gOBB4/+HjPOw==}
engines: {node: '>=20.0.0'}
peerDependencies:
aws-crt: '>=1.0.0'
peerDependenciesMeta:
aws-crt:
optional: true
'@aws-sdk/util-user-agent-node@3.973.6':
resolution: {integrity: sha512-iF7G0prk7AvmOK64FcLvc/fW+Ty1H+vttajL7PvJFReU8urMxfYmynTTuFKDTA76Wgpq3FzTPKwabMQIXQHiXQ==}
engines: {node: '>=20.0.0'}
@@ -3583,6 +3547,9 @@ packages:
'@types/node@25.5.0':
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
'@types/pg@8.18.0':
resolution: {integrity: sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==}
'@types/qrcode-terminal@0.12.2':
resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==}
@@ -5842,6 +5809,40 @@ packages:
performance-now@2.1.0:
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
pg-cloudflare@1.3.0:
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
pg-connection-string@2.12.0:
resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==}
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
pg-pool@3.13.0:
resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==}
peerDependencies:
pg: '>=8.0'
pg-protocol@1.13.0:
resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==}
pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
pg@8.20.0:
resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==}
engines: {node: '>= 16.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -5889,6 +5890,22 @@ packages:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
postgres-bytea@1.0.1:
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
engines: {node: '>=0.10.0'}
postgres-date@1.0.7:
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
engines: {node: '>=0.10.0'}
postgres-interval@1.2.0:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
postgres@3.4.8:
resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==}
engines: {node: '>=12'}
@@ -6664,10 +6681,6 @@ packages:
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
undici@7.22.0:
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
engines: {node: '>=20.18.1'}
undici@7.24.0:
resolution: {integrity: sha512-jxytwMHhsbdpBXxLAcuu0fzlQeXCNnWdDyRHpvWsUl8vd98UwYdl9YTyn8/HcpcJPC3pwUveefsa3zTxyD/ERg==}
engines: {node: '>=20.18.1'}
@@ -6925,6 +6938,10 @@ packages:
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -7117,51 +7134,6 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/client-bedrock@3.1007.0':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
'@aws-crypto/sha256-js': 5.2.0
'@aws-sdk/core': 3.973.19
'@aws-sdk/credential-provider-node': 3.972.19
'@aws-sdk/middleware-host-header': 3.972.7
'@aws-sdk/middleware-logger': 3.972.7
'@aws-sdk/middleware-recursion-detection': 3.972.7
'@aws-sdk/middleware-user-agent': 3.972.20
'@aws-sdk/region-config-resolver': 3.972.7
'@aws-sdk/token-providers': 3.1007.0
'@aws-sdk/types': 3.973.5
'@aws-sdk/util-endpoints': 3.996.4
'@aws-sdk/util-user-agent-browser': 3.972.7
'@aws-sdk/util-user-agent-node': 3.973.5
'@smithy/config-resolver': 4.4.10
'@smithy/core': 3.23.9
'@smithy/fetch-http-handler': 5.3.13
'@smithy/hash-node': 4.2.11
'@smithy/invalid-dependency': 4.2.11
'@smithy/middleware-content-length': 4.2.11
'@smithy/middleware-endpoint': 4.4.23
'@smithy/middleware-retry': 4.4.40
'@smithy/middleware-serde': 4.2.12
'@smithy/middleware-stack': 4.2.11
'@smithy/node-config-provider': 4.3.11
'@smithy/node-http-handler': 4.4.14
'@smithy/protocol-http': 5.3.11
'@smithy/smithy-client': 4.12.3
'@smithy/types': 4.13.0
'@smithy/url-parser': 4.2.11
'@smithy/util-base64': 4.3.2
'@smithy/util-body-length-browser': 4.2.2
'@smithy/util-body-length-node': 4.2.3
'@smithy/util-defaults-mode-browser': 4.3.39
'@smithy/util-defaults-mode-node': 4.2.42
'@smithy/util-endpoints': 3.3.2
'@smithy/util-middleware': 4.2.11
'@smithy/util-retry': 4.2.11
'@smithy/util-utf8': 4.2.2
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/client-bedrock@3.1008.0':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
@@ -7421,25 +7393,6 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-ini@3.972.18':
dependencies:
'@aws-sdk/core': 3.973.19
'@aws-sdk/credential-provider-env': 3.972.17
'@aws-sdk/credential-provider-http': 3.972.19
'@aws-sdk/credential-provider-login': 3.972.18
'@aws-sdk/credential-provider-process': 3.972.17
'@aws-sdk/credential-provider-sso': 3.972.18
'@aws-sdk/credential-provider-web-identity': 3.972.18
'@aws-sdk/nested-clients': 3.996.8
'@aws-sdk/types': 3.973.5
'@smithy/credential-provider-imds': 4.2.11
'@smithy/property-provider': 4.2.11
'@smithy/shared-ini-file-loader': 4.4.6
'@smithy/types': 4.13.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-ini@3.972.19':
dependencies:
'@aws-sdk/core': 3.973.19
@@ -7485,19 +7438,6 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-login@3.972.18':
dependencies:
'@aws-sdk/core': 3.973.19
'@aws-sdk/nested-clients': 3.996.8
'@aws-sdk/types': 3.973.5
'@smithy/property-provider': 4.2.11
'@smithy/protocol-http': 5.3.11
'@smithy/shared-ini-file-loader': 4.4.6
'@smithy/types': 4.13.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-login@3.972.19':
dependencies:
'@aws-sdk/core': 3.973.19
@@ -7545,23 +7485,6 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-node@3.972.19':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.17
'@aws-sdk/credential-provider-http': 3.972.19
'@aws-sdk/credential-provider-ini': 3.972.18
'@aws-sdk/credential-provider-process': 3.972.17
'@aws-sdk/credential-provider-sso': 3.972.18
'@aws-sdk/credential-provider-web-identity': 3.972.18
'@aws-sdk/types': 3.973.5
'@smithy/credential-provider-imds': 4.2.11
'@smithy/property-provider': 4.2.11
'@smithy/shared-ini-file-loader': 4.4.6
'@smithy/types': 4.13.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-node@3.972.20':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.17
@@ -7632,19 +7555,6 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-sso@3.972.18':
dependencies:
'@aws-sdk/core': 3.973.19
'@aws-sdk/nested-clients': 3.996.8
'@aws-sdk/token-providers': 3.1005.0
'@aws-sdk/types': 3.973.5
'@smithy/property-provider': 4.2.11
'@smithy/shared-ini-file-loader': 4.4.6
'@smithy/types': 4.13.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-sso@3.972.19':
dependencies:
'@aws-sdk/core': 3.973.19
@@ -7682,18 +7592,6 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-web-identity@3.972.18':
dependencies:
'@aws-sdk/core': 3.973.19
'@aws-sdk/nested-clients': 3.996.8
'@aws-sdk/types': 3.973.5
'@smithy/property-provider': 4.2.11
'@smithy/shared-ini-file-loader': 4.4.6
'@smithy/types': 4.13.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-web-identity@3.972.19':
dependencies:
'@aws-sdk/core': 3.973.19
@@ -7958,49 +7856,6 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/nested-clients@3.996.8':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
'@aws-crypto/sha256-js': 5.2.0
'@aws-sdk/core': 3.973.19
'@aws-sdk/middleware-host-header': 3.972.7
'@aws-sdk/middleware-logger': 3.972.7
'@aws-sdk/middleware-recursion-detection': 3.972.7
'@aws-sdk/middleware-user-agent': 3.972.20
'@aws-sdk/region-config-resolver': 3.972.7
'@aws-sdk/types': 3.973.5
'@aws-sdk/util-endpoints': 3.996.4
'@aws-sdk/util-user-agent-browser': 3.972.7
'@aws-sdk/util-user-agent-node': 3.973.5
'@smithy/config-resolver': 4.4.10
'@smithy/core': 3.23.9
'@smithy/fetch-http-handler': 5.3.13
'@smithy/hash-node': 4.2.11
'@smithy/invalid-dependency': 4.2.11
'@smithy/middleware-content-length': 4.2.11
'@smithy/middleware-endpoint': 4.4.23
'@smithy/middleware-retry': 4.4.40
'@smithy/middleware-serde': 4.2.12
'@smithy/middleware-stack': 4.2.11
'@smithy/node-config-provider': 4.3.11
'@smithy/node-http-handler': 4.4.14
'@smithy/protocol-http': 5.3.11
'@smithy/smithy-client': 4.12.3
'@smithy/types': 4.13.0
'@smithy/url-parser': 4.2.11
'@smithy/util-base64': 4.3.2
'@smithy/util-body-length-browser': 4.2.2
'@smithy/util-body-length-node': 4.2.3
'@smithy/util-defaults-mode-browser': 4.3.39
'@smithy/util-defaults-mode-node': 4.2.42
'@smithy/util-endpoints': 3.3.2
'@smithy/util-middleware': 4.2.11
'@smithy/util-retry': 4.2.11
'@smithy/util-utf8': 4.2.2
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/nested-clients@3.996.9':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
@@ -8092,30 +7947,6 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/token-providers@3.1005.0':
dependencies:
'@aws-sdk/core': 3.973.19
'@aws-sdk/nested-clients': 3.996.8
'@aws-sdk/types': 3.973.5
'@smithy/property-provider': 4.2.11
'@smithy/shared-ini-file-loader': 4.4.6
'@smithy/types': 4.13.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/token-providers@3.1007.0':
dependencies:
'@aws-sdk/core': 3.973.19
'@aws-sdk/nested-clients': 3.996.8
'@aws-sdk/types': 3.973.5
'@smithy/property-provider': 4.2.11
'@smithy/shared-ini-file-loader': 4.4.6
'@smithy/types': 4.13.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/token-providers@3.1008.0':
dependencies:
'@aws-sdk/core': 3.973.19
@@ -8222,14 +8053,6 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
'@aws-sdk/util-user-agent-node@3.973.5':
dependencies:
'@aws-sdk/middleware-user-agent': 3.972.20
'@aws-sdk/types': 3.973.5
'@smithy/node-config-provider': 4.3.11
'@smithy/types': 4.13.0
tslib: 2.8.1
'@aws-sdk/util-user-agent-node@3.973.6':
dependencies:
'@aws-sdk/middleware-user-agent': 3.972.20
@@ -11164,6 +10987,12 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/pg@8.18.0':
dependencies:
'@types/node': 25.5.0
pg-protocol: 1.13.0
pg-types: 2.2.0
'@types/qrcode-terminal@0.12.2': {}
'@types/qs@6.14.0': {}
@@ -13497,7 +13326,7 @@ snapshots:
openclaw@2026.3.11(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3)):
dependencies:
'@agentclientprotocol/sdk': 0.16.1(zod@4.3.6)
'@aws-sdk/client-bedrock': 3.1007.0
'@aws-sdk/client-bedrock': 3.1008.0
'@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1)
'@clack/prompts': 1.1.0
'@discordjs/voice': 0.19.1(@discordjs/opus@0.10.0)(opusscript@0.1.1)
@@ -13548,7 +13377,7 @@ snapshots:
sqlite-vec: 0.1.7-alpha.2
tar: 7.5.11
tslog: 4.10.2
undici: 7.22.0
undici: 7.24.0
ws: 8.19.0
yaml: 2.8.2
zod: 4.3.6
@@ -13753,6 +13582,41 @@ snapshots:
performance-now@2.1.0: {}
pg-cloudflare@1.3.0:
optional: true
pg-connection-string@2.12.0: {}
pg-int8@1.0.1: {}
pg-pool@3.13.0(pg@8.20.0):
dependencies:
pg: 8.20.0
pg-protocol@1.13.0: {}
pg-types@2.2.0:
dependencies:
pg-int8: 1.0.1
postgres-array: 2.0.0
postgres-bytea: 1.0.1
postgres-date: 1.0.7
postgres-interval: 1.2.0
pg@8.20.0:
dependencies:
pg-connection-string: 2.12.0
pg-pool: 3.13.0(pg@8.20.0)
pg-protocol: 1.13.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.3.0
pgpass@1.0.5:
dependencies:
split2: 4.2.0
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@@ -13803,6 +13667,16 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postgres-array@2.0.0: {}
postgres-bytea@1.0.1: {}
postgres-date@1.0.7: {}
postgres-interval@1.2.0:
dependencies:
xtend: 4.0.2
postgres@3.4.8: {}
pretty-bytes@6.1.1: {}
@@ -14722,8 +14596,6 @@ snapshots:
undici-types@7.18.2: {}
undici@7.22.0: {}
undici@7.24.0: {}
unist-util-is@6.0.1:
@@ -14922,6 +14794,8 @@ snapshots:
xmlchars@2.2.0: {}
xtend@4.0.2: {}
y18n@5.0.8: {}
yallist@4.0.0: {}

View File

@@ -0,0 +1,11 @@
CLAW_BROKER_BIND=127.0.0.1
CLAW_BROKER_PORT=8787
CLAW_BROKER_TOKEN=change-me
CLAW_BROKER_CMD_TIMEOUT_MS=120000
CLAW_BROKER_MAX_SUMMARY_CHARS=2000
PGHOST=147.45.189.234
PGPORT=5432
PGDATABASE=default_db
PGUSER=gen_user
PGPASSWORD=change-me

View File

@@ -0,0 +1,43 @@
# Claw Broker (MVP)
Minimal privileged broker for claw.approvals.execute.
## API
- POST /v1/execute
- Bearer token via CLAW_BROKER_TOKEN
Request fields:
- executionId
- approvalRequestId
- approvalGrantId
- exactCommand
- targetHost
- targetUser
- requestedBy
- channel
- chatId
- humanUserId
- sessionId
Response fields:
- executionId
- status
- exitCode
- stdoutSummary
- stderrSummary
- startedAt
- finishedAt
## Validation
Broker re-checks in Postgres before execution:
- request/grant exist
- status allows execution
- once grant atomic consume
- command exact match
- scope match (targetHost, targetUser, channel, chatId, humanUserId, sessionId)
- dangerous shell policy

View File

@@ -0,0 +1,437 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import http from "node:http";
import pg from "pg";
const { Pool } = pg;
const MAX_SUMMARY_CHARS = Number(process.env.CLAW_BROKER_MAX_SUMMARY_CHARS ?? "2000");
const CMD_TIMEOUT_MS = Number(process.env.CLAW_BROKER_CMD_TIMEOUT_MS ?? "120000");
const BIND_HOST = process.env.CLAW_BROKER_BIND ?? "127.0.0.1";
const BIND_PORT = Number(process.env.CLAW_BROKER_PORT ?? "8787");
const REQUIRED_TOKEN = (process.env.CLAW_BROKER_TOKEN ?? "").trim();
function env(name, fallback = undefined) {
return process.env[`CLAW_${name}`] ?? process.env[name] ?? fallback;
}
function requiredEnv(name) {
const value = env(name, "");
if (!value || !String(value).trim()) {
throw new Error(`missing env: ${name} (or CLAW_${name})`);
}
return String(value);
}
const pool = new Pool({
host: requiredEnv("PGHOST"),
port: Number(env("PGPORT", "5432")),
user: requiredEnv("PGUSER"),
password: requiredEnv("PGPASSWORD"),
database: requiredEnv("PGDATABASE"),
max: 10,
});
if (!REQUIRED_TOKEN) {
throw new Error("missing CLAW_BROKER_TOKEN");
}
function json(res, code, body) {
const payload = JSON.stringify(body);
res.writeHead(code, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(payload),
});
res.end(payload);
}
function normalizeCommand(input) {
return String(input).trim().replace(/\s+/g, " ");
}
function hasDangerousShellConstruct(command) {
const source = String(command).toLowerCase();
const checks = [
/\bbash\s+-c\b/,
/\bsh\s+-c\b/,
/\bsudo\s+su\b/,
/\bsudo\s+-i\b/,
/&&/,
/\|\|/,
/;/,
/\|/,
/>/,
/</,
/\$\(/,
/`/,
/<<[-\w]*/,
];
return checks.some((r) => r.test(source));
}
function summarize(text) {
const value = String(text ?? "");
return value.length <= MAX_SUMMARY_CHARS ? value : `${value.slice(0, MAX_SUMMARY_CHARS)}`;
}
async function insertAudit(client, args) {
await client.query(
`INSERT INTO claw_audit_events (
event_type, request_id, grant_id, execution_id,
actor_type, actor_id, target_host, target_user,
command_snapshot, status, exit_code, stdout_summary, stderr_summary, metadata
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb
)`,
[
args.eventType,
args.requestId ?? null,
args.grantId ?? null,
args.executionId ?? null,
args.actorType,
args.actorId,
args.targetHost ?? null,
args.targetUser ?? null,
args.commandSnapshot ?? null,
args.status ?? null,
args.exitCode ?? null,
args.stdoutSummary ?? null,
args.stderrSummary ?? null,
JSON.stringify(args.metadata ?? {}),
],
);
}
function requireString(body, key) {
const value = body?.[key];
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${key} is required`);
}
return value.trim();
}
async function verifyAndMarkStarted(body) {
const executionId = requireString(body, "executionId");
const approvalRequestId = requireString(body, "approvalRequestId");
const approvalGrantId = requireString(body, "approvalGrantId");
const exactCommand = requireString(body, "exactCommand");
const targetHost = requireString(body, "targetHost");
const targetUser = requireString(body, "targetUser");
const requestedBy = requireString(body, "requestedBy");
const channel = requireString(body, "channel");
const chatId = requireString(body, "chatId");
const humanUserId = requireString(body, "humanUserId");
const sessionId = requireString(body, "sessionId");
if (hasDangerousShellConstruct(exactCommand)) {
throw new Error("dangerous shell policy violation");
}
const client = await pool.connect();
try {
await client.query("BEGIN");
const reqRes = await client.query(
`SELECT * FROM claw_approval_requests WHERE id = $1 FOR UPDATE`,
[approvalRequestId],
);
if (reqRes.rowCount === 0) {
throw new Error("approval request not found");
}
const request = reqRes.rows[0];
if (!["approved_once", "approved_always"].includes(String(request.status))) {
throw new Error(`request status does not allow execution: ${request.status}`);
}
const grantRes = await client.query(
`SELECT * FROM claw_approval_grants WHERE id = $1 AND request_id = $2 FOR UPDATE`,
[approvalGrantId, approvalRequestId],
);
if (grantRes.rowCount === 0) {
throw new Error("approval grant not found");
}
const grant = grantRes.rows[0];
const dbExact = String(request.exact_command);
if (normalizeCommand(dbExact) !== normalizeCommand(exactCommand)) {
throw new Error("exact command mismatch");
}
if (normalizeCommand(String(grant.exact_command)) !== normalizeCommand(exactCommand)) {
throw new Error("grant command mismatch");
}
const scopeChecks = [
[String(request.target_host), targetHost, "targetHost"],
[String(request.target_user), targetUser, "targetUser"],
[String(request.channel), channel, "channel"],
[String(request.chat_id), chatId, "chatId"],
[String(request.human_user_id), humanUserId, "humanUserId"],
[String(request.session_id), sessionId, "sessionId"],
[String(grant.target_host), targetHost, "grant.targetHost"],
[String(grant.target_user), targetUser, "grant.targetUser"],
[String(grant.channel), channel, "grant.channel"],
[String(grant.chat_id), chatId, "grant.chatId"],
[String(grant.human_user_id), humanUserId, "grant.humanUserId"],
[String(grant.session_id), sessionId, "grant.sessionId"],
];
for (const [db, incoming, label] of scopeChecks) {
if (db !== incoming) {
throw new Error(`scope mismatch: ${label}`);
}
}
if (hasDangerousShellConstruct(String(request.exact_command))) {
throw new Error("dangerous shell policy violation (request)");
}
if (String(grant.grant_type) === "once") {
const consumeRes = await client.query(
`UPDATE claw_approval_grants
SET used_at = now()
WHERE id = $1
AND grant_type = 'once'
AND used_at IS NULL
AND revoked_at IS NULL
AND expires_at > now()
RETURNING id`,
[approvalGrantId],
);
if (consumeRes.rowCount === 0) {
throw new Error("once grant expired/revoked/already used");
}
await insertAudit(client, {
eventType: "grant_consumed",
actorType: "broker",
actorId: requestedBy,
requestId: approvalRequestId,
grantId: approvalGrantId,
executionId,
targetHost,
targetUser,
commandSnapshot: exactCommand,
status: "grant_consumed",
});
}
await client.query(
`UPDATE claw_approval_requests SET execution_id = $2, updated_at = now() WHERE id = $1`,
[approvalRequestId, executionId],
);
await insertAudit(client, {
eventType: "execution_started",
actorType: "broker",
actorId: requestedBy,
requestId: approvalRequestId,
grantId: approvalGrantId,
executionId,
targetHost,
targetUser,
commandSnapshot: exactCommand,
status: "execution_started",
});
await client.query("COMMIT");
return {
executionId,
approvalRequestId,
approvalGrantId,
exactCommand,
targetHost,
targetUser,
requestedBy,
cwd: request.cwd ? String(request.cwd) : undefined,
};
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
async function runCommand(command, cwd) {
return await new Promise((resolve) => {
const child = spawn("bash", ["-lc", command], {
cwd: cwd || undefined,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, CMD_TIMEOUT_MS);
child.stdout.on("data", (chunk) => {
stdout += chunk.toString("utf8");
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({
exitCode: timedOut ? 124 : Number(code ?? 1),
stdout,
stderr: timedOut ? `${stderr}\nCommand timed out.` : stderr,
});
});
});
}
async function finalizeExecution({
executionId,
approvalRequestId,
approvalGrantId,
exactCommand,
targetHost,
targetUser,
requestedBy,
ok,
exitCode,
stdoutSummary,
stderrSummary,
}) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const finalStatus = ok ? "executed" : "execution_failed";
const lastError = ok ? null : stderrSummary;
await client.query(
`UPDATE claw_approval_requests
SET status = $2::claw_approval_status,
executed_at = now(),
updated_at = now(),
last_error = $3
WHERE id = $1`,
[approvalRequestId, finalStatus, lastError],
);
await insertAudit(client, {
eventType: ok ? "execution_succeeded" : "execution_failed",
actorType: "broker",
actorId: requestedBy,
requestId: approvalRequestId,
grantId: approvalGrantId,
executionId,
targetHost,
targetUser,
commandSnapshot: exactCommand,
status: ok ? "executed" : "execution_failed",
exitCode,
stdoutSummary,
stderrSummary,
});
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
async function handleExecute(body) {
const startedAt = new Date().toISOString();
const validated = await verifyAndMarkStarted(body);
const run = await runCommand(validated.exactCommand, validated.cwd);
const ok = run.exitCode === 0;
const stdoutSummary = summarize(run.stdout);
const stderrSummary = summarize(run.stderr);
await finalizeExecution({
executionId: validated.executionId,
approvalRequestId: validated.approvalRequestId,
approvalGrantId: validated.approvalGrantId,
exactCommand: validated.exactCommand,
targetHost: validated.targetHost,
targetUser: validated.targetUser,
requestedBy: validated.requestedBy,
ok,
exitCode: run.exitCode,
stdoutSummary,
stderrSummary,
});
const finishedAt = new Date().toISOString();
return {
ok,
executionId: validated.executionId,
status: ok ? "executed" : "execution_failed",
exitCode: run.exitCode,
stdoutSummary,
stderrSummary,
startedAt,
finishedAt,
};
}
function getBearerToken(req) {
const raw = String(req.headers.authorization ?? "");
if (!raw.toLowerCase().startsWith("bearer ")) {
return "";
}
return raw.slice(7).trim();
}
const server = http.createServer((req, res) => {
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
if (req.method === "GET" && url.pathname === "/healthz") {
json(res, 200, { ok: true, service: "claw-broker" });
return;
}
if (req.method !== "POST" || url.pathname !== "/v1/execute") {
json(res, 404, { ok: false, error: "not_found" });
return;
}
const token = getBearerToken(req);
if (!token || token !== REQUIRED_TOKEN) {
json(res, 401, { ok: false, error: "unauthorized" });
return;
}
let raw = "";
req.on("data", (chunk) => {
raw += chunk.toString("utf8");
if (raw.length > 1_000_000) {
req.destroy();
}
});
req.on("end", async () => {
const fallbackExecutionId = randomUUID();
try {
const body = raw.length ? JSON.parse(raw) : {};
if (!body.executionId) {
body.executionId = fallbackExecutionId;
}
const result = await handleExecute(body);
json(res, 200, result);
} catch (err) {
console.error("[claw-broker] execute error:", err);
const nowIso = new Date().toISOString();
json(res, 400, {
ok: false,
executionId: fallbackExecutionId,
status: "execution_failed",
exitCode: 1,
stdoutSummary: "",
stderrSummary: String(err),
startedAt: nowIso,
finishedAt: nowIso,
});
}
});
});
server.listen(BIND_PORT, BIND_HOST, () => {
process.stdout.write(`claw-broker listening on http://${BIND_HOST}:${BIND_PORT}\n`);
});

View File

@@ -0,0 +1,20 @@
[Unit]
Description=OpenClaw Privileged Broker (MVP)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/home/negodiy/claw-broker
EnvironmentFile=/home/negodiy/claw-broker/.env
ExecStart=/usr/bin/node /home/negodiy/claw-broker/broker.mjs
Restart=always
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=no
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,13 @@
{
"name": "claw-broker",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "broker.mjs",
"scripts": {
"start": "node broker.mjs"
},
"dependencies": {
"pg": "^8.20.0"
}
}

View File

@@ -393,11 +393,15 @@ export async function runAgentTurnWithFallback(params: {
await params.opts?.onToolStart?.({ name, phase });
}
}
// Track auto-compaction completion
// Track auto-compaction completion and notify UI layer
if (evt.stream === "compaction") {
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
if (phase === "start") {
await params.opts?.onCompactionStart?.();
}
if (phase === "end") {
autoCompactionCompleted = true;
await params.opts?.onCompactionEnd?.();
}
}
},

View File

@@ -54,6 +54,10 @@ export type GetReplyOptions = {
onToolResult?: (payload: ReplyPayload) => Promise<void> | void;
/** Called when a tool phase starts/updates, before summary payloads are emitted. */
onToolStart?: (payload: { name?: string; phase?: string }) => Promise<void> | void;
/** Called when context auto-compaction starts (allows UX feedback during the pause). */
onCompactionStart?: () => Promise<void> | void;
/** Called when context auto-compaction completes. */
onCompactionEnd?: () => Promise<void> | void;
/** Called when the actual model is selected (including after fallback).
* Use this to get model/provider/thinkLevel for responsePrefix template interpolation. */
onModelSelected?: (ctx: ModelSelectedContext) => void;

View File

@@ -148,6 +148,15 @@ describe("createStatusReactionController", () => {
expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.thinking });
});
it("should debounce setCompacting and eventually call adapter", async () => {
const { calls, controller } = createEnabledController();
void controller.setCompacting();
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.compacting });
});
it("should classify tool name and debounce", async () => {
const { calls, controller } = createEnabledController();
@@ -245,6 +254,19 @@ describe("createStatusReactionController", () => {
expect(calls.length).toBe(callsAfterFirst);
});
it("should cancel a pending compacting emoji before resuming thinking", async () => {
const { calls, controller } = createEnabledController();
void controller.setCompacting();
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs - 1);
controller.cancelPending();
void controller.setThinking();
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
const setEmojis = calls.filter((call) => call.method === "set").map((call) => call.emoji);
expect(setEmojis).toEqual([DEFAULT_EMOJIS.thinking]);
});
it("should call removeReaction when adapter supports it and emoji changes", async () => {
const { calls, controller } = createEnabledController();
@@ -446,6 +468,7 @@ describe("constants", () => {
const emojiKeys = [
"queued",
"thinking",
"compacting",
"tool",
"coding",
"web",

View File

@@ -24,6 +24,7 @@ export type StatusReactionEmojis = {
error?: string; // Default: "❌"
stallSoft?: string; // Default: "⏳"
stallHard?: string; // Default: "⚠️"
compacting?: string; // Default: "✍"
};
export type StatusReactionTiming = {
@@ -38,6 +39,9 @@ export type StatusReactionController = {
setQueued: () => Promise<void> | void;
setThinking: () => Promise<void> | void;
setTool: (toolName?: string) => Promise<void> | void;
setCompacting: () => Promise<void> | void;
/** Cancel any pending debounced emoji (useful before forcing a state transition). */
cancelPending: () => void;
setDone: () => Promise<void>;
setError: () => Promise<void>;
clear: () => Promise<void>;
@@ -58,6 +62,7 @@ export const DEFAULT_EMOJIS: Required<StatusReactionEmojis> = {
error: "😱",
stallSoft: "🥱",
stallHard: "😨",
compacting: "✍",
};
export const DEFAULT_TIMING: Required<StatusReactionTiming> = {
@@ -162,6 +167,7 @@ export function createStatusReactionController(params: {
emojis.error,
emojis.stallSoft,
emojis.stallHard,
emojis.compacting,
]);
/**
@@ -306,6 +312,15 @@ export function createStatusReactionController(params: {
scheduleEmoji(emoji);
}
function setCompacting(): void {
scheduleEmoji(emojis.compacting);
}
function cancelPending(): void {
clearDebounceTimer();
pendingEmoji = "";
}
function finishWithEmoji(emoji: string): Promise<void> {
if (!enabled) {
return Promise.resolve();
@@ -375,6 +390,8 @@ export function createStatusReactionController(params: {
setQueued,
setThinking,
setTool,
setCompacting,
cancelPending,
setDone,
setError,
clear,

View File

@@ -1481,7 +1481,7 @@ export const FIELD_HELP: Record<string, string> = {
"messages.statusReactions.enabled":
"Enable lifecycle status reactions for Telegram. When enabled, the ack reaction becomes the initial 'queued' state and progresses through thinking, tool, done/error automatically. Default: false.",
"messages.statusReactions.emojis":
"Override default status reaction emojis. Keys: thinking, tool, coding, web, done, error, stallSoft, stallHard. Must be valid Telegram reaction emojis.",
"Override default status reaction emojis. Keys: thinking, compacting, tool, coding, web, done, error, stallSoft, stallHard. Must be valid Telegram reaction emojis.",
"messages.statusReactions.timing":
"Override default timing. Keys: debounceMs (700), stallSoftMs (25000), stallHardMs (60000), doneHoldMs (1500), errorHoldMs (2500).",
"messages.inbound.debounceMs":

View File

@@ -58,6 +58,7 @@ export type StatusReactionsEmojiConfig = {
error?: string;
stallSoft?: string;
stallHard?: string;
compacting?: string;
};
export type StatusReactionsTimingConfig = {

View File

@@ -169,6 +169,7 @@ export const MessagesSchema = z
error: z.string().optional(),
stallSoft: z.string().optional(),
stallHard: z.string().optional(),
compacting: z.string().optional(),
})
.strict()
.optional(),

View File

@@ -47,15 +47,19 @@ type DispatchInboundParams = {
onReasoningStream?: () => Promise<void> | void;
onReasoningEnd?: () => Promise<void> | void;
onToolStart?: (payload: { name?: string }) => Promise<void> | void;
onCompactionStart?: () => Promise<void> | void;
onCompactionEnd?: () => Promise<void> | void;
onPartialReply?: (payload: { text?: string }) => Promise<void> | void;
onAssistantMessageStart?: () => Promise<void> | void;
};
};
const dispatchInboundMessage = vi.fn(async (_params?: DispatchInboundParams) => ({
queuedFinal: false,
counts: { final: 0, tool: 0, block: 0 },
}));
const recordInboundSession = vi.fn(async () => {});
const dispatchInboundMessage = vi.hoisted(() =>
vi.fn(async (_params?: DispatchInboundParams) => ({
queuedFinal: false,
counts: { final: 0, tool: 0, block: 0 },
})),
);
const recordInboundSession = vi.hoisted(() => vi.fn(async () => {}));
const configSessionsMocks = vi.hoisted(() => ({
readSessionUpdatedAt: vi.fn(() => undefined),
resolveStorePath: vi.fn(() => "/tmp/openclaw-discord-process-test-sessions.json"),
@@ -346,6 +350,39 @@ describe("processDiscordMessage ack reactions", () => {
expect(emojis).toContain("🏁");
});
it("shows compacting reaction during auto-compaction and resumes thinking", async () => {
vi.useFakeTimers();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onCompactionStart?.();
await new Promise((resolve) => setTimeout(resolve, 1_000));
await params?.replyOptions?.onCompactionEnd?.();
await new Promise((resolve) => setTimeout(resolve, 1_000));
return createNoQueuedDispatchResult();
});
const ctx = await createBaseContext({
cfg: {
messages: {
ackReaction: "👀",
statusReactions: {
timing: { debounceMs: 0 },
},
},
session: { store: "/tmp/openclaw-discord-process-test-sessions.json" },
},
});
// oxlint-disable-next-line typescript/no-explicit-any
const runPromise = processDiscordMessage(ctx as any);
await vi.advanceTimersByTimeAsync(2_500);
await vi.runAllTimersAsync();
await runPromise;
const emojis = getReactionEmojis();
expect(emojis).toContain(DEFAULT_EMOJIS.compacting);
expect(emojis).toContain(DEFAULT_EMOJIS.thinking);
});
it("clears status reactions when dispatch aborts and removeAckAfterReply is enabled", async () => {
const abortController = new AbortController();
dispatchInboundMessage.mockImplementationOnce(async () => {

View File

@@ -769,6 +769,19 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
}
await statusReactions.setTool(payload.name);
},
onCompactionStart: async () => {
if (isProcessAborted(abortSignal)) {
return;
}
await statusReactions.setCompacting();
},
onCompactionEnd: async () => {
if (isProcessAborted(abortSignal)) {
return;
}
statusReactions.cancelPending();
await statusReactions.setThinking();
},
},
});
if (isProcessAborted(abortSignal)) {

View File

@@ -0,0 +1,682 @@
import { randomUUID } from "node:crypto";
import { Pool, type PoolClient } from "pg";
export type ClawApprovalStatus =
| "pending"
| "approved_once"
| "approved_always"
| "rejected"
| "expired"
| "executed"
| "execution_failed";
export type ClawRiskLevel = "low" | "medium" | "high";
export type ClawApprovalRequestRow = {
id: string;
createdAt: string;
updatedAt: string;
requestedByAgent: string;
sessionId: string;
channel: string;
chatId: string;
humanUserId: string;
targetHost: string;
targetUser: string;
cwd: string | null;
humanSummary: string;
reason: string;
exactCommand: string;
normalizedCommand: string;
riskLevel: ClawRiskLevel;
rollbackHint: string | null;
requiresPrivilege: boolean;
dangerousFlags: Record<string, boolean>;
status: ClawApprovalStatus;
statusReason: string | null;
approvedBy: string | null;
approvedAt: string | null;
rejectedBy: string | null;
rejectedAt: string | null;
expiredAt: string | null;
executedAt: string | null;
executionId: string | null;
lastError: string | null;
};
export type CreateClawApprovalRequestInput = {
requestedByAgent: string;
sessionId: string;
channel: string;
chatId: string;
humanUserId: string;
targetHost: string;
targetUser: string;
cwd?: string | null;
humanSummary: string;
reason: string;
exactCommand: string;
riskLevel: ClawRiskLevel;
rollbackHint?: string | null;
dangerousFlags?: Record<string, boolean>;
};
export type ClawApproveInput = {
id: string;
actorId: string;
};
export type ClawApproveOnceInput = ClawApproveInput & {
ttlSeconds: number;
};
export type ClawRejectInput = ClawApproveInput & {
reason?: string | null;
};
export type ClawExecuteInput = {
id: string;
grantId: string;
actorId: string;
};
export type BrokerExecutePayload = {
executionId: string;
approvalRequestId: string;
approvalGrantId: string;
exactCommand: string;
targetHost: string;
targetUser: string;
requestedBy: string;
channel: string;
chatId: string;
humanUserId: string;
sessionId: string;
};
export type BrokerExecuteResult = {
executionId: string;
status: string;
ok: boolean;
exitCode: number;
stdoutSummary?: string;
stderrSummary?: string;
startedAt: string;
finishedAt: string;
};
let pool: Pool | null = null;
function resolveEnv(name: string): string | undefined {
return process.env[`CLAW_${name}`] ?? process.env[name];
}
function requireEnv(name: string): string {
const v = resolveEnv(name);
if (!v || !v.trim()) {
throw new Error(`missing required environment variable: ${name} (or CLAW_${name})`);
}
return v;
}
function getPool(): Pool {
if (pool) {
return pool;
}
const host = requireEnv("PGHOST");
const portRaw = resolveEnv("PGPORT") ?? "5432";
const user = requireEnv("PGUSER");
const password = requireEnv("PGPASSWORD");
const database = requireEnv("PGDATABASE");
const port = Number(portRaw);
if (!Number.isFinite(port) || port <= 0) {
throw new Error(`invalid PGPORT: ${portRaw}`);
}
pool = new Pool({
host,
port,
user,
password,
database,
max: 10,
});
return pool;
}
export function normalizeCommand(input: string): string {
return input.trim().replace(/\s+/g, " ");
}
export function hasDangerousShellConstruct(command: string): boolean {
const source = command.toLowerCase();
const checks: RegExp[] = [
/\bbash\s+-c\b/,
/\bsh\s+-c\b/,
/\bsudo\s+su\b/,
/\bsudo\s+-i\b/,
/&&/,
/\|\|/,
/;/,
/\|/,
/>/,
/</,
/\$\(/,
/`/,
/<<[-\w]*/,
];
return checks.some((r) => r.test(source));
}
function mapRequestRow(row: Record<string, unknown>): ClawApprovalRequestRow {
return {
id: String(row.id),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at),
requestedByAgent: String(row.requested_by_agent),
sessionId: String(row.session_id),
channel: String(row.channel),
chatId: String(row.chat_id),
humanUserId: String(row.human_user_id),
targetHost: String(row.target_host),
targetUser: String(row.target_user),
cwd: (row.cwd as string | null) ?? null,
humanSummary: String(row.human_summary),
reason: String(row.reason),
exactCommand: String(row.exact_command),
normalizedCommand: String(row.normalized_command),
riskLevel: String(row.risk_level) as ClawRiskLevel,
rollbackHint: (row.rollback_hint as string | null) ?? null,
requiresPrivilege: Boolean(row.requires_privilege),
dangerousFlags: (row.dangerous_flags as Record<string, boolean> | null) ?? {},
status: String(row.status) as ClawApprovalStatus,
statusReason: (row.status_reason as string | null) ?? null,
approvedBy: (row.approved_by as string | null) ?? null,
approvedAt: (row.approved_at as string | null) ?? null,
rejectedBy: (row.rejected_by as string | null) ?? null,
rejectedAt: (row.rejected_at as string | null) ?? null,
expiredAt: (row.expired_at as string | null) ?? null,
executedAt: (row.executed_at as string | null) ?? null,
executionId: (row.execution_id as string | null) ?? null,
lastError: (row.last_error as string | null) ?? null,
};
}
async function insertAudit(
client: PoolClient,
args: {
eventType: string;
actorType: "agent" | "human" | "backend" | "broker" | "system";
actorId: string;
requestId?: string;
grantId?: string;
executionId?: string;
targetHost?: string;
targetUser?: string;
commandSnapshot?: string;
status?: string;
exitCode?: number;
stdoutSummary?: string;
stderrSummary?: string;
metadata?: Record<string, unknown>;
},
): Promise<void> {
await client.query(
`INSERT INTO claw_audit_events (
event_type,
request_id,
grant_id,
execution_id,
actor_type,
actor_id,
target_host,
target_user,
command_snapshot,
status,
exit_code,
stdout_summary,
stderr_summary,
metadata
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb
)`,
[
args.eventType,
args.requestId ?? null,
args.grantId ?? null,
args.executionId ?? null,
args.actorType,
args.actorId,
args.targetHost ?? null,
args.targetUser ?? null,
args.commandSnapshot ?? null,
args.status ?? null,
args.exitCode ?? null,
args.stdoutSummary ?? null,
args.stderrSummary ?? null,
JSON.stringify(args.metadata ?? {}),
],
);
}
export class ClawApprovalsStore {
async createApprovalRequest(
input: CreateClawApprovalRequestInput,
): Promise<ClawApprovalRequestRow> {
const normalizedCommand = normalizeCommand(input.exactCommand);
const dangerousFlags = {
hasDangerousShell: hasDangerousShellConstruct(input.exactCommand),
...input.dangerousFlags,
};
const db = getPool();
const client = await db.connect();
try {
await client.query("BEGIN");
const res = await client.query(
`INSERT INTO claw_approval_requests (
requested_by_agent, session_id, channel, chat_id, human_user_id,
target_host, target_user, cwd,
human_summary, reason, exact_command, normalized_command,
risk_level, rollback_hint, requires_privilege, dangerous_flags, status
) VALUES (
$1,$2,$3,$4,$5,
$6,$7,$8,
$9,$10,$11,$12,
$13,$14,TRUE,$15::jsonb,'pending'
) RETURNING *`,
[
input.requestedByAgent,
input.sessionId,
input.channel,
input.chatId,
input.humanUserId,
input.targetHost,
input.targetUser,
input.cwd ?? null,
input.humanSummary,
input.reason,
input.exactCommand,
normalizedCommand,
input.riskLevel,
input.rollbackHint ?? null,
JSON.stringify(dangerousFlags),
],
);
const row = mapRequestRow(res.rows[0]);
await insertAudit(client, {
eventType: "request_created",
actorType: "agent",
actorId: input.requestedByAgent,
requestId: row.id,
targetHost: row.targetHost,
targetUser: row.targetUser,
commandSnapshot: row.exactCommand,
status: row.status,
});
await client.query("COMMIT");
return row;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
async listApprovalRequests(status?: string): Promise<ClawApprovalRequestRow[]> {
const db = getPool();
const res = await db.query(
status && status.trim().length > 0
? `SELECT * FROM claw_approval_requests WHERE status = $1 ORDER BY created_at DESC LIMIT 200`
: `SELECT * FROM claw_approval_requests ORDER BY created_at DESC LIMIT 200`,
status && status.trim().length > 0 ? [status.trim()] : [],
);
return res.rows.map(mapRequestRow);
}
async getApprovalRequest(id: string): Promise<ClawApprovalRequestRow | null> {
const db = getPool();
const res = await db.query(`SELECT * FROM claw_approval_requests WHERE id = $1 LIMIT 1`, [id]);
if (res.rowCount === 0) {
return null;
}
return mapRequestRow(res.rows[0]);
}
async approveOnce(
input: ClawApproveOnceInput,
): Promise<{ request: ClawApprovalRequestRow; grantId: string; expiresAt: string }> {
const db = getPool();
const client = await db.connect();
try {
await client.query("BEGIN");
const reqRes = await client.query(
`SELECT * FROM claw_approval_requests WHERE id = $1 FOR UPDATE`,
[input.id],
);
if (reqRes.rowCount === 0) {
throw new Error("approval request not found");
}
const req = mapRequestRow(reqRes.rows[0]);
if (req.status !== "pending") {
throw new Error(`cannot approve_once from status=${req.status}`);
}
const ttl = Math.max(120, Math.min(300, Math.trunc(input.ttlSeconds || 180)));
const grantRes = await client.query(
`INSERT INTO claw_approval_grants (
request_id, grant_type, match_type,
target_host, target_user, channel, chat_id, human_user_id, session_id,
exact_command, normalized_command, approved_by, expires_at
) VALUES (
$1,'once','exact',$2,$3,$4,$5,$6,$7,$8,$9,$10, now() + ($11 || ' seconds')::interval
) RETURNING id, expires_at`,
[
req.id,
req.targetHost,
req.targetUser,
req.channel,
req.chatId,
req.humanUserId,
req.sessionId,
req.exactCommand,
req.normalizedCommand,
input.actorId,
String(ttl),
],
);
await client.query(
`UPDATE claw_approval_requests
SET status='approved_once', approved_by=$2, approved_at=now(), updated_at=now()
WHERE id = $1`,
[req.id, input.actorId],
);
await insertAudit(client, {
eventType: "request_approved_once",
actorType: "human",
actorId: input.actorId,
requestId: req.id,
grantId: String(grantRes.rows[0].id),
targetHost: req.targetHost,
targetUser: req.targetUser,
commandSnapshot: req.exactCommand,
status: "approved_once",
});
await client.query("COMMIT");
const next = await this.getApprovalRequest(req.id);
if (!next) {
throw new Error("approval request not found after approve_once");
}
return {
request: next,
grantId: String(grantRes.rows[0].id),
expiresAt: String(grantRes.rows[0].expires_at),
};
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
async approveAlways(
input: ClawApproveInput,
): Promise<{ request: ClawApprovalRequestRow; grantId: string; allowRuleId: string }> {
const db = getPool();
const client = await db.connect();
try {
await client.query("BEGIN");
const reqRes = await client.query(
`SELECT * FROM claw_approval_requests WHERE id = $1 FOR UPDATE`,
[input.id],
);
if (reqRes.rowCount === 0) {
throw new Error("approval request not found");
}
const req = mapRequestRow(reqRes.rows[0]);
if (req.status !== "pending") {
throw new Error(`cannot approve_always from status=${req.status}`);
}
if (hasDangerousShellConstruct(req.exactCommand)) {
throw new Error("always allow is forbidden for dangerous shell constructs");
}
const grantRes = await client.query(
`INSERT INTO claw_approval_grants (
request_id, grant_type, match_type,
target_host, target_user, channel, chat_id, human_user_id, session_id,
exact_command, normalized_command, approved_by
) VALUES (
$1,'always','exact',$2,$3,$4,$5,$6,$7,$8,$9,$10
) RETURNING id`,
[
req.id,
req.targetHost,
req.targetUser,
req.channel,
req.chatId,
req.humanUserId,
req.sessionId,
req.exactCommand,
req.normalizedCommand,
input.actorId,
],
);
const ruleRes = await client.query(
`INSERT INTO claw_allow_rules (
created_by, source_request_id,
target_host, target_user, channel, chat_id, human_user_id,
command_pattern_type, command_pattern, normalized_pattern, enabled
) VALUES (
$1,$2,$3,$4,$5,$6,$7,'exact',$8,$9,TRUE
)
ON CONFLICT ON CONSTRAINT uq_claw_allow_rules_active_exact
DO UPDATE SET updated_at=now(), enabled=TRUE
RETURNING id`,
[
input.actorId,
req.id,
req.targetHost,
req.targetUser,
req.channel,
req.chatId,
req.humanUserId,
req.exactCommand,
req.normalizedCommand,
],
);
await client.query(
`UPDATE claw_approval_requests
SET status='approved_always', approved_by=$2, approved_at=now(), updated_at=now()
WHERE id = $1`,
[req.id, input.actorId],
);
await insertAudit(client, {
eventType: "request_approved_always",
actorType: "human",
actorId: input.actorId,
requestId: req.id,
grantId: String(grantRes.rows[0].id),
targetHost: req.targetHost,
targetUser: req.targetUser,
commandSnapshot: req.exactCommand,
status: "approved_always",
metadata: { allowRuleId: String(ruleRes.rows[0].id) },
});
await client.query("COMMIT");
const next = await this.getApprovalRequest(req.id);
if (!next) {
throw new Error("approval request not found after approve_always");
}
return {
request: next,
grantId: String(grantRes.rows[0].id),
allowRuleId: String(ruleRes.rows[0].id),
};
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
async reject(input: ClawRejectInput): Promise<ClawApprovalRequestRow> {
const db = getPool();
const client = await db.connect();
try {
await client.query("BEGIN");
const reqRes = await client.query(
`SELECT * FROM claw_approval_requests WHERE id = $1 FOR UPDATE`,
[input.id],
);
if (reqRes.rowCount === 0) {
throw new Error("approval request not found");
}
const req = mapRequestRow(reqRes.rows[0]);
if (req.status !== "pending") {
throw new Error(`cannot reject from status=${req.status}`);
}
await client.query(
`UPDATE claw_approval_requests
SET status='rejected', rejected_by=$2, rejected_at=now(), status_reason=$3, updated_at=now()
WHERE id = $1`,
[req.id, input.actorId, input.reason ?? null],
);
await insertAudit(client, {
eventType: "request_rejected",
actorType: "human",
actorId: input.actorId,
requestId: req.id,
targetHost: req.targetHost,
targetUser: req.targetUser,
commandSnapshot: req.exactCommand,
status: "rejected",
metadata: { reason: input.reason ?? null },
});
await client.query("COMMIT");
const next = await this.getApprovalRequest(req.id);
if (!next) {
throw new Error("approval request not found after reject");
}
return next;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
async executeApproved(
input: ClawExecuteInput,
runBroker: (payload: BrokerExecutePayload) => Promise<BrokerExecuteResult>,
): Promise<{
request: ClawApprovalRequestRow;
executionId: string;
broker: BrokerExecuteResult;
}> {
const db = getPool();
const client = await db.connect();
let request: ClawApprovalRequestRow | null = null;
try {
await client.query("BEGIN");
const reqRes = await client.query(
`SELECT * FROM claw_approval_requests WHERE id = $1 FOR UPDATE`,
[input.id],
);
if (reqRes.rowCount === 0) {
throw new Error("approval request not found");
}
request = mapRequestRow(reqRes.rows[0]);
if (request.status !== "approved_once" && request.status !== "approved_always") {
throw new Error(`cannot execute from status=${request.status}`);
}
const grantRes = await client.query(
`SELECT * FROM claw_approval_grants WHERE id = $1 AND request_id = $2 FOR UPDATE`,
[input.grantId, request.id],
);
if (grantRes.rowCount === 0) {
throw new Error("grant not found");
}
const grant = grantRes.rows[0] as Record<string, unknown>;
const exactCommand = String(grant.exact_command);
if (normalizeCommand(exactCommand) !== request.normalizedCommand) {
throw new Error("grant command mismatch with request");
}
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
if (!request) {
throw new Error("request resolution failed");
}
const broker = await runBroker({
executionId: randomUUID(),
approvalRequestId: request.id,
approvalGrantId: input.grantId,
exactCommand: request.exactCommand,
targetHost: request.targetHost,
targetUser: request.targetUser,
requestedBy: input.actorId,
channel: request.channel,
chatId: request.chatId,
humanUserId: request.humanUserId,
sessionId: request.sessionId,
});
const latest = await this.getApprovalRequest(request.id);
if (!latest) {
throw new Error("approval request not found after execution");
}
return {
request: latest,
executionId: broker.executionId,
broker,
};
}
async getAuditTrail(requestId: string): Promise<Record<string, unknown>[]> {
const db = getPool();
const res = await db.query(
`SELECT * FROM claw_audit_events WHERE request_id = $1 ORDER BY occurred_at ASC, id ASC`,
[requestId],
);
return res.rows as Record<string, unknown>[];
}
}
let singleton: ClawApprovalsStore | null = null;
export function getClawApprovalsStore(): ClawApprovalsStore {
if (!singleton) {
singleton = new ClawApprovalsStore();
}
return singleton;
}

View File

@@ -34,6 +34,14 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"exec.approval.request",
"exec.approval.waitDecision",
"exec.approval.resolve",
"claw.approvals.create",
"claw.approvals.list",
"claw.approvals.get",
"claw.approvals.approveOnce",
"claw.approvals.approveAlways",
"claw.approvals.reject",
"claw.approvals.execute",
"claw.approvals.audit",
],
[PAIRING_SCOPE]: [
"node.pair.request",

View File

@@ -9,6 +9,7 @@ import { agentsHandlers } from "./server-methods/agents.js";
import { browserHandlers } from "./server-methods/browser.js";
import { channelsHandlers } from "./server-methods/channels.js";
import { chatHandlers } from "./server-methods/chat.js";
import { clawApprovalsHandlers } from "./server-methods/claw-approvals.js";
import { configHandlers } from "./server-methods/config.js";
import { connectHandlers } from "./server-methods/connect.js";
import { cronHandlers } from "./server-methods/cron.js";
@@ -76,6 +77,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
...deviceHandlers,
...doctorHandlers,
...execApprovalsHandlers,
...clawApprovalsHandlers,
...webHandlers,
...modelsHandlers,
...configHandlers,

View File

@@ -0,0 +1,330 @@
import {
getClawApprovalsStore,
type BrokerExecutePayload,
type BrokerExecuteResult,
type ClawRiskLevel,
} from "../claw-approvals-store.js";
import { ErrorCodes, errorShape } from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";
type JsonMap = Record<string, unknown>;
function asObject(value: unknown): JsonMap | null {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonMap) : null;
}
function getRequiredString(params: JsonMap, key: string): string {
const value = params[key];
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${key} is required`);
}
return value.trim();
}
function getOptionalString(params: JsonMap, key: string): string | null {
const value = params[key];
if (value == null) {
return null;
}
if (typeof value !== "string") {
throw new Error(`${key} must be a string`);
}
return value.trim();
}
function getRiskLevel(params: JsonMap): ClawRiskLevel {
const value = params.riskLevel;
if (value === "low" || value === "medium" || value === "high") {
return value;
}
throw new Error("riskLevel must be low|medium|high");
}
async function invokeBroker(payload: BrokerExecutePayload): Promise<BrokerExecuteResult> {
const brokerUrl = process.env.CLAW_BROKER_URL ?? "http://127.0.0.1:8787/v1/execute";
const brokerToken = process.env.CLAW_BROKER_TOKEN?.trim();
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (brokerToken) {
headers.authorization = `Bearer ${brokerToken}`;
}
const res = await fetch(brokerUrl, {
method: "POST",
headers,
body: JSON.stringify(payload),
});
let body: JsonMap | null = null;
try {
body = (await res.json()) as JsonMap;
} catch {
body = null;
}
if (!res.ok) {
const nowIso = new Date().toISOString();
return {
executionId: payload.executionId,
status: "broker_error",
ok: false,
exitCode: 1,
stderrSummary: `broker error ${res.status}`,
startedAt: nowIso,
finishedAt: nowIso,
};
}
const ok = body?.ok === true;
const exitCodeRaw = body?.exitCode;
const exitCode = typeof exitCodeRaw === "number" ? exitCodeRaw : ok ? 0 : 1;
const startedAt =
typeof body?.startedAt === "string" && body.startedAt.length > 0
? body.startedAt
: new Date().toISOString();
const finishedAt =
typeof body?.finishedAt === "string" && body.finishedAt.length > 0
? body.finishedAt
: startedAt;
return {
executionId:
typeof body?.executionId === "string" && body.executionId.length > 0
? body.executionId
: payload.executionId,
status: typeof body?.status === "string" ? body.status : ok ? "executed" : "execution_failed",
ok,
exitCode,
stdoutSummary: typeof body?.stdoutSummary === "string" ? body.stdoutSummary : "",
stderrSummary: typeof body?.stderrSummary === "string" ? body.stderrSummary : "",
startedAt,
finishedAt,
};
}
export const clawApprovalsHandlers: GatewayRequestHandlers = {
"claw.approvals.create": async ({ params, respond }) => {
try {
const p = asObject(params);
if (!p) {
throw new Error("params object is required");
}
const store = getClawApprovalsStore();
const request = await store.createApprovalRequest({
requestedByAgent: getRequiredString(p, "requestedByAgent"),
sessionId: getRequiredString(p, "sessionId"),
channel: getRequiredString(p, "channel"),
chatId: getRequiredString(p, "chatId"),
humanUserId: getRequiredString(p, "humanUserId"),
targetHost: getRequiredString(p, "targetHost"),
targetUser: getRequiredString(p, "targetUser"),
cwd: getOptionalString(p, "cwd"),
humanSummary: getRequiredString(p, "humanSummary"),
reason: getRequiredString(p, "reason"),
exactCommand: getRequiredString(p, "exactCommand"),
riskLevel: getRiskLevel(p),
rollbackHint: getOptionalString(p, "rollbackHint"),
dangerousFlags: asObject(p.dangerousFlags) as Record<string, boolean> | undefined,
});
respond(true, { request }, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `claw.approvals.create failed: ${String(err)}`),
);
}
},
"claw.approvals.list": async ({ params, respond }) => {
try {
const p = asObject(params) ?? {};
const status = getOptionalString(p, "status");
const store = getClawApprovalsStore();
const requests = await store.listApprovalRequests(status ?? undefined);
respond(true, { requests }, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `claw.approvals.list failed: ${String(err)}`),
);
}
},
"claw.approvals.get": async ({ params, respond }) => {
try {
const p = asObject(params);
if (!p) {
throw new Error("params object is required");
}
const id = getRequiredString(p, "id");
const store = getClawApprovalsStore();
const request = await store.getApprovalRequest(id);
if (!request) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "request not found"));
return;
}
respond(true, { request }, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `claw.approvals.get failed: ${String(err)}`),
);
}
},
"claw.approvals.approveOnce": async ({ params, respond }) => {
try {
const p = asObject(params);
if (!p) {
throw new Error("params object is required");
}
const ttlRaw = p.ttlSeconds;
const ttlSeconds = typeof ttlRaw === "number" && Number.isFinite(ttlRaw) ? ttlRaw : 180;
const store = getClawApprovalsStore();
const result = await store.approveOnce({
id: getRequiredString(p, "id"),
actorId: getRequiredString(p, "actorId"),
ttlSeconds,
});
respond(
true,
{
request: result.request,
grant: {
grantId: result.grantId,
grantType: "once",
expiresAt: result.expiresAt,
},
},
undefined,
);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `claw.approvals.approveOnce failed: ${String(err)}`),
);
}
},
"claw.approvals.approveAlways": async ({ params, respond }) => {
try {
const p = asObject(params);
if (!p) {
throw new Error("params object is required");
}
const store = getClawApprovalsStore();
const result = await store.approveAlways({
id: getRequiredString(p, "id"),
actorId: getRequiredString(p, "actorId"),
});
respond(
true,
{
request: result.request,
grant: {
grantId: result.grantId,
grantType: "always",
},
allowRuleId: result.allowRuleId,
},
undefined,
);
} catch (err) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`claw.approvals.approveAlways failed: ${String(err)}`,
),
);
}
},
"claw.approvals.reject": async ({ params, respond }) => {
try {
const p = asObject(params);
if (!p) {
throw new Error("params object is required");
}
const store = getClawApprovalsStore();
const request = await store.reject({
id: getRequiredString(p, "id"),
actorId: getRequiredString(p, "actorId"),
reason: getOptionalString(p, "reason"),
});
respond(true, { request }, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `claw.approvals.reject failed: ${String(err)}`),
);
}
},
"claw.approvals.execute": async ({ params, respond }) => {
try {
const p = asObject(params);
if (!p) {
throw new Error("params object is required");
}
const store = getClawApprovalsStore();
const result = await store.executeApproved(
{
id: getRequiredString(p, "id"),
grantId: getRequiredString(p, "grantId"),
actorId: getRequiredString(p, "actorId"),
},
invokeBroker,
);
respond(
true,
{
request: result.request,
execution: {
executionId: result.executionId,
ok: result.broker.ok,
status: result.broker.status,
exitCode: result.broker.exitCode,
stdoutSummary: result.broker.stdoutSummary ?? "",
stderrSummary: result.broker.stderrSummary ?? "",
startedAt: result.broker.startedAt,
finishedAt: result.broker.finishedAt,
},
},
undefined,
);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, `claw.approvals.execute failed: ${String(err)}`),
);
}
},
"claw.approvals.audit": async ({ params, respond }) => {
try {
const p = asObject(params);
if (!p) {
throw new Error("params object is required");
}
const id = getRequiredString(p, "id");
const store = getClawApprovalsStore();
const events = await store.getAuditTrail(id);
respond(true, { events }, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `claw.approvals.audit failed: ${String(err)}`),
);
}
},
};

View File

@@ -64,20 +64,16 @@ describe("resolveProxyFetchFromEnv", () => {
afterEach(() => vi.unstubAllEnvs());
it("returns undefined when no proxy env vars are set", () => {
vi.stubEnv("HTTPS_PROXY", "");
vi.stubEnv("HTTP_PROXY", "");
vi.stubEnv("https_proxy", "");
vi.stubEnv("http_proxy", "");
expect(resolveProxyFetchFromEnv()).toBeUndefined();
expect(resolveProxyFetchFromEnv({})).toBeUndefined();
});
it("returns proxy fetch using EnvHttpProxyAgent when HTTPS_PROXY is set", async () => {
vi.stubEnv("HTTP_PROXY", "");
vi.stubEnv("HTTPS_PROXY", "http://proxy.test:8080");
undiciFetch.mockResolvedValue({ ok: true });
const fetchFn = resolveProxyFetchFromEnv();
const fetchFn = resolveProxyFetchFromEnv({
HTTP_PROXY: "",
HTTPS_PROXY: "http://proxy.test:8080",
});
expect(fetchFn).toBeDefined();
expect(envAgentSpy).toHaveBeenCalled();
@@ -89,46 +85,47 @@ describe("resolveProxyFetchFromEnv", () => {
});
it("returns proxy fetch when HTTP_PROXY is set", () => {
vi.stubEnv("HTTPS_PROXY", "");
vi.stubEnv("HTTP_PROXY", "http://fallback.test:3128");
const fetchFn = resolveProxyFetchFromEnv();
const fetchFn = resolveProxyFetchFromEnv({
HTTPS_PROXY: "",
HTTP_PROXY: "http://fallback.test:3128",
});
expect(fetchFn).toBeDefined();
expect(envAgentSpy).toHaveBeenCalled();
});
it("returns proxy fetch when lowercase https_proxy is set", () => {
vi.stubEnv("HTTPS_PROXY", "");
vi.stubEnv("HTTP_PROXY", "");
vi.stubEnv("http_proxy", "");
vi.stubEnv("https_proxy", "http://lower.test:1080");
const fetchFn = resolveProxyFetchFromEnv();
const fetchFn = resolveProxyFetchFromEnv({
HTTPS_PROXY: "",
HTTP_PROXY: "",
http_proxy: "",
https_proxy: "http://lower.test:1080",
});
expect(fetchFn).toBeDefined();
expect(envAgentSpy).toHaveBeenCalled();
});
it("returns proxy fetch when lowercase http_proxy is set", () => {
vi.stubEnv("HTTPS_PROXY", "");
vi.stubEnv("HTTP_PROXY", "");
vi.stubEnv("https_proxy", "");
vi.stubEnv("http_proxy", "http://lower-http.test:1080");
const fetchFn = resolveProxyFetchFromEnv();
const fetchFn = resolveProxyFetchFromEnv({
HTTPS_PROXY: "",
HTTP_PROXY: "",
https_proxy: "",
http_proxy: "http://lower-http.test:1080",
});
expect(fetchFn).toBeDefined();
expect(envAgentSpy).toHaveBeenCalled();
});
it("returns undefined when EnvHttpProxyAgent constructor throws", () => {
vi.stubEnv("HTTP_PROXY", "");
vi.stubEnv("https_proxy", "");
vi.stubEnv("http_proxy", "");
vi.stubEnv("HTTPS_PROXY", "not-a-valid-url");
envAgentSpy.mockImplementationOnce(() => {
throw new Error("Invalid URL");
});
const fetchFn = resolveProxyFetchFromEnv();
const fetchFn = resolveProxyFetchFromEnv({
HTTP_PROXY: "",
https_proxy: "",
http_proxy: "",
HTTPS_PROXY: "not-a-valid-url",
});
expect(fetchFn).toBeUndefined();
});
});

View File

@@ -51,8 +51,10 @@ export function getProxyUrlFromFetch(fetchImpl?: typeof fetch): string | undefin
* Returns undefined when no proxy is configured.
* Gracefully returns undefined if the proxy URL is malformed.
*/
export function resolveProxyFetchFromEnv(): typeof fetch | undefined {
if (!hasEnvHttpProxyConfigured("https")) {
export function resolveProxyFetchFromEnv(
env: NodeJS.ProcessEnv = process.env,
): typeof fetch | undefined {
if (!hasEnvHttpProxyConfigured("https", env)) {
return undefined;
}
try {

View File

@@ -200,6 +200,29 @@ describe("discoverOpenClawPlugins", () => {
expect(ids).toContain("voice-call");
});
it("normalizes bundled provider package ids to canonical plugin ids", async () => {
const stateDir = makeTempDir();
const globalExt = path.join(stateDir, "extensions", "ollama-provider-pack");
mkdirSafe(path.join(globalExt, "src"));
writePluginPackageManifest({
packageDir: globalExt,
packageName: "@openclaw/ollama-provider",
extensions: ["./src/index.ts"],
});
fs.writeFileSync(
path.join(globalExt, "src", "index.ts"),
"export default function () {}",
"utf-8",
);
const { candidates } = await discoverWithStateDir(stateDir, {});
const ids = candidates.map((c) => c.idHint);
expect(ids).toContain("ollama");
expect(ids).not.toContain("ollama-provider");
});
it("treats configured directory paths as plugin packages", async () => {
const stateDir = makeTempDir();
const packDir = path.join(stateDir, "packs", "demo-plugin-dir");

View File

@@ -333,11 +333,17 @@ function deriveIdHint(params: {
const unscoped = rawPackageName.includes("/")
? (rawPackageName.split("/").pop() ?? rawPackageName)
: rawPackageName;
const canonicalPackageId =
{
"ollama-provider": "ollama",
"sglang-provider": "sglang",
"vllm-provider": "vllm",
}[unscoped] ?? unscoped;
if (!params.hasMultipleExtensions) {
return unscoped;
return canonicalPackageId;
}
return `${unscoped}/${base}`;
return `${canonicalPackageId}/${base}`;
}
function addCandidate(params: {

View File

@@ -2182,4 +2182,41 @@ describe("dispatchTelegramMessage draft streaming", () => {
);
expect(finalTextSentViaDeliverReplies).toBe(true);
});
it("shows compacting reaction during auto-compaction and resumes thinking", async () => {
const statusReactionController = {
setThinking: vi.fn(async () => {}),
setCompacting: vi.fn(async () => {}),
setTool: vi.fn(async () => {}),
setDone: vi.fn(async () => {}),
setError: vi.fn(async () => {}),
setQueued: vi.fn(async () => {}),
cancelPending: vi.fn(() => {}),
clear: vi.fn(async () => {}),
restoreInitial: vi.fn(async () => {}),
};
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
await replyOptions?.onCompactionStart?.();
await replyOptions?.onCompactionEnd?.();
return { queuedFinal: true };
});
deliverReplies.mockResolvedValue({ delivered: true });
await dispatchWithContext({
context: createContext({
statusReactionController: statusReactionController as never,
}),
streamMode: "off",
});
expect(statusReactionController.setCompacting).toHaveBeenCalledTimes(1);
expect(statusReactionController.cancelPending).toHaveBeenCalledTimes(1);
expect(statusReactionController.setThinking).toHaveBeenCalledTimes(2);
expect(statusReactionController.setCompacting.mock.invocationCallOrder[0]).toBeLessThan(
statusReactionController.cancelPending.mock.invocationCallOrder[0],
);
expect(statusReactionController.cancelPending.mock.invocationCallOrder[0]).toBeLessThan(
statusReactionController.setThinking.mock.invocationCallOrder[1],
);
});
});

View File

@@ -713,6 +713,15 @@ export const dispatchTelegramMessage = async ({
await statusReactionController.setTool(payload.name);
}
: undefined,
onCompactionStart: statusReactionController
? () => statusReactionController.setCompacting()
: undefined,
onCompactionEnd: statusReactionController
? async () => {
statusReactionController.cancelPending();
await statusReactionController.setThinking();
}
: undefined,
onModelSelected,
},
}));

View File

@@ -90,6 +90,7 @@ export const TELEGRAM_STATUS_REACTION_VARIANTS: Record<StatusReactionEmojiKey, s
error: ["😱", "😨", "🤯"],
stallSoft: ["🥱", "😴", "🤔"],
stallHard: ["😨", "😱", "⚡"],
compacting: ["✍", "🤔", "🤯"],
};
const STATUS_REACTION_EMOJI_KEYS: StatusReactionEmojiKey[] = [
@@ -102,6 +103,7 @@ const STATUS_REACTION_EMOJI_KEYS: StatusReactionEmojiKey[] = [
"error",
"stallSoft",
"stallHard",
"compacting",
];
function normalizeEmoji(value: string | undefined): string | undefined {
@@ -129,6 +131,7 @@ export function resolveTelegramStatusReactionEmojis(params: {
error: normalizeEmoji(overrides?.error) ?? DEFAULT_EMOJIS.error,
stallSoft: normalizeEmoji(overrides?.stallSoft) ?? DEFAULT_EMOJIS.stallSoft,
stallHard: normalizeEmoji(overrides?.stallHard) ?? DEFAULT_EMOJIS.stallHard,
compacting: normalizeEmoji(overrides?.compacting) ?? DEFAULT_EMOJIS.compacting,
};
}

View File

@@ -0,0 +1,65 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from "vitest";
import { refreshChatAvatar, type ChatHost } from "./app-chat.ts";
function makeHost(overrides?: Partial<ChatHost>): ChatHost {
return {
client: null,
chatMessages: [],
chatStream: null,
connected: true,
chatMessage: "",
chatAttachments: [],
chatQueue: [],
chatRunId: null,
chatSending: false,
lastError: null,
sessionKey: "agent:main",
basePath: "",
hello: null,
chatAvatarUrl: null,
refreshSessionsAfterChat: new Set<string>(),
...overrides,
};
}
describe("refreshChatAvatar", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("uses a route-relative avatar endpoint before basePath bootstrap finishes", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ avatarUrl: "/avatar/main" }),
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const host = makeHost({ basePath: "", sessionKey: "agent:main" });
await refreshChatAvatar(host);
expect(fetchMock).toHaveBeenCalledWith(
"avatar/main?meta=1",
expect.objectContaining({ method: "GET" }),
);
expect(host.chatAvatarUrl).toBe("/avatar/main");
});
it("keeps mounted dashboard avatar endpoints under the normalized base path", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({}),
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const host = makeHost({ basePath: "/openclaw/", sessionKey: "agent:ops:main" });
await refreshChatAvatar(host);
expect(fetchMock).toHaveBeenCalledWith(
"/openclaw/avatar/ops?meta=1",
expect.objectContaining({ method: "GET" }),
);
expect(host.chatAvatarUrl).toBeNull();
});
});

View File

@@ -372,7 +372,7 @@ function resolveAgentIdForSession(host: ChatHost): string | null {
function buildAvatarMetaUrl(basePath: string, agentId: string): string {
const base = normalizeBasePath(basePath);
const encoded = encodeURIComponent(agentId);
return base ? `${base}/avatar/${encoded}?meta=1` : `/avatar/${encoded}?meta=1`;
return base ? `${base}/avatar/${encoded}?meta=1` : `avatar/${encoded}?meta=1`;
}
export async function refreshChatAvatar(host: ChatHost) {

View File

@@ -10,7 +10,6 @@ import {
normalizeVerboseLevel,
resolveThinkingDefaultForModel,
} from "../../../../src/auto-reply/thinking.js";
import type { HealthSummary } from "../../../../src/commands/health.js";
import {
DEFAULT_AGENT_ID,
DEFAULT_MAIN_KEY,
@@ -45,8 +44,6 @@ export async function executeSlashCommand(
switch (commandName) {
case "help":
return executeHelp();
case "status":
return await executeStatus(client);
case "new":
return { content: "Starting new session...", action: "new-session" };
case "reset":
@@ -101,27 +98,6 @@ function executeHelp(): SlashCommandResult {
return { content: lines.join("\n") };
}
async function executeStatus(client: GatewayBrowserClient): Promise<SlashCommandResult> {
try {
const health = await client.request<HealthSummary>("health", {});
const status = health.ok ? "Healthy" : "Degraded";
const agentCount = health.agents?.length ?? 0;
const sessionCount = health.sessions?.count ?? 0;
const lines = [
`**System Status:** ${status}`,
`**Agents:** ${agentCount}`,
`**Sessions:** ${sessionCount}`,
`**Default Agent:** ${health.defaultAgentId || "none"}`,
];
if (health.durationMs) {
lines.push(`**Response:** ${health.durationMs}ms`);
}
return { content: lines.join("\n") };
} catch (err) {
return { content: `Failed to fetch status: ${String(err)}` };
}
}
async function executeCompact(
client: GatewayBrowserClient,
sessionKey: string,

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { parseSlashCommand } from "./slash-commands.ts";
import { parseSlashCommand, SLASH_COMMANDS } from "./slash-commands.ts";
describe("parseSlashCommand", () => {
it("parses commands with an optional colon separator", () => {
@@ -30,4 +30,13 @@ describe("parseSlashCommand", () => {
args: "on",
});
});
it("keeps /status on the agent path", () => {
const status = SLASH_COMMANDS.find((entry) => entry.name === "status");
expect(status?.executeLocal).not.toBe(true);
expect(parseSlashCommand("/status")).toMatchObject({
command: { name: "status" },
args: "",
});
});
});

View File

@@ -108,10 +108,9 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
},
{
name: "status",
description: "Show system status",
description: "Show session status",
icon: "barChart",
category: "tools",
executeLocal: true,
},
{
name: "export",

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
agentLogoUrl,
resolveConfiguredCronModelSuggestions,
resolveAgentAvatarUrl,
resolveEffectiveModelFallbacks,
sortLocaleStrings,
} from "./agents-utils.ts";
@@ -110,3 +111,23 @@ describe("agentLogoUrl", () => {
expect(agentLogoUrl("")).toBe("favicon.svg");
});
});
describe("resolveAgentAvatarUrl", () => {
it("prefers a runtime avatar URL over non-URL identity avatars", () => {
expect(
resolveAgentAvatarUrl(
{ identity: { avatar: "A", avatarUrl: "/avatar/main" } },
{
agentId: "main",
avatar: "A",
name: "Main",
},
),
).toBe("/avatar/main");
});
it("returns null for initials or emoji avatar values without a URL", () => {
expect(resolveAgentAvatarUrl({ identity: { avatar: "A" } })).toBeNull();
expect(resolveAgentAvatarUrl({ identity: { avatar: "🦞" } })).toBeNull();
});
});

View File

@@ -200,15 +200,18 @@ export function resolveAgentAvatarUrl(
agent: { identity?: { avatar?: string; avatarUrl?: string } },
agentIdentity?: AgentIdentityResult | null,
): string | null {
const url =
agentIdentity?.avatar?.trim() ??
agent.identity?.avatarUrl?.trim() ??
agent.identity?.avatar?.trim();
if (!url) {
return null;
}
if (AVATAR_URL_RE.test(url)) {
return url;
const candidates = [
agentIdentity?.avatar?.trim(),
agent.identity?.avatarUrl?.trim(),
agent.identity?.avatar?.trim(),
];
for (const candidate of candidates) {
if (!candidate) {
continue;
}
if (AVATAR_URL_RE.test(candidate)) {
return candidate;
}
}
return null;
}

View File

@@ -1,3 +1,5 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { SessionsListResult } from "../types.ts";
@@ -54,6 +56,95 @@ function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
}
describe("chat view", () => {
it("uses the assistant avatar URL for the welcome state when the identity avatar is only initials", () => {
const container = document.createElement("div");
render(
renderChat(
createProps({
assistantName: "Assistant",
assistantAvatar: "A",
assistantAvatarUrl: "/avatar/main",
}),
),
container,
);
const welcomeImage = container.querySelector<HTMLImageElement>(".agent-chat__welcome > img");
expect(welcomeImage).not.toBeNull();
expect(welcomeImage?.getAttribute("src")).toBe("/avatar/main");
});
it("falls back to the bundled logo in the welcome state when the assistant avatar is not a URL", () => {
const container = document.createElement("div");
render(
renderChat(
createProps({
assistantName: "Assistant",
assistantAvatar: "A",
assistantAvatarUrl: null,
}),
),
container,
);
const welcomeImage = container.querySelector<HTMLImageElement>(".agent-chat__welcome > img");
const logoImage = container.querySelector<HTMLImageElement>(
".agent-chat__welcome .agent-chat__avatar--logo img",
);
expect(welcomeImage).toBeNull();
expect(logoImage).not.toBeNull();
expect(logoImage?.getAttribute("src")).toBe("favicon.svg");
});
it("keeps the welcome logo fallback under the mounted base path", () => {
const container = document.createElement("div");
render(
renderChat(
createProps({
assistantName: "Assistant",
assistantAvatar: "A",
assistantAvatarUrl: null,
basePath: "/openclaw/",
}),
),
container,
);
const logoImage = container.querySelector<HTMLImageElement>(
".agent-chat__welcome .agent-chat__avatar--logo img",
);
expect(logoImage).not.toBeNull();
expect(logoImage?.getAttribute("src")).toBe("/openclaw/favicon.svg");
});
it("keeps grouped assistant avatar fallbacks under the mounted base path", () => {
const container = document.createElement("div");
render(
renderChat(
createProps({
assistantName: "Assistant",
assistantAvatar: "A",
assistantAvatarUrl: null,
basePath: "/openclaw/",
messages: [
{
role: "assistant",
content: "hello",
timestamp: 1000,
},
],
}),
),
container,
);
const groupedLogo = container.querySelector<HTMLImageElement>(
".chat-group.assistant .chat-avatar--logo",
);
expect(groupedLogo).not.toBeNull();
expect(groupedLogo?.getAttribute("src")).toBe("/openclaw/favicon.svg");
});
it("renders compacting indicator as a badge", () => {
const container = document.createElement("div");
render(

View File

@@ -31,7 +31,7 @@ import { detectTextDirection } from "../text-direction.ts";
import type { GatewaySessionRow, SessionsListResult } from "../types.ts";
import type { ChatItem, MessageGroup } from "../types/chat-types.ts";
import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts";
import { agentLogoUrl } from "./agents-utils.ts";
import { agentLogoUrl, resolveAgentAvatarUrl } from "./agents-utils.ts";
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
import "../components/resizable-divider.ts";
@@ -566,7 +566,12 @@ const WELCOME_SUGGESTIONS = [
function renderWelcomeState(props: ChatProps): TemplateResult {
const name = props.assistantName || "Assistant";
const avatar = props.assistantAvatar ?? props.assistantAvatarUrl;
const avatar = resolveAgentAvatarUrl({
identity: {
avatar: props.assistantAvatar ?? undefined,
avatarUrl: props.assistantAvatarUrl ?? undefined,
},
});
const logoUrl = agentLogoUrl(props.basePath ?? "");
return html`
@@ -802,7 +807,13 @@ export function renderChat(props: ChatProps) {
const showReasoning = props.showThinking && reasoningLevel !== "off";
const assistantIdentity = {
name: props.assistantName,
avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null,
avatar:
resolveAgentAvatarUrl({
identity: {
avatar: props.assistantAvatar ?? undefined,
avatarUrl: props.assistantAvatarUrl ?? undefined,
},
}) ?? null,
};
const pinned = getPinnedMessages(props.sessionKey);
const deleted = getDeletedMessages(props.sessionKey);

View File

@@ -82,7 +82,9 @@ export default defineConfig({
"src/**/*.test.ts",
"extensions/**/*.test.ts",
"test/**/*.test.ts",
"ui/src/ui/app-chat.test.ts",
"ui/src/ui/views/agents-utils.test.ts",
"ui/src/ui/views/chat.test.ts",
"ui/src/ui/views/usage-render-details.test.ts",
"ui/src/ui/controllers/agents.test.ts",
"ui/src/ui/controllers/chat.test.ts",