Compare commits

...

57 Commits

Author SHA1 Message Date
Peter Steinberger
1e78689ed8 chore(release): refresh plugin sdk api baseline 2026-04-30 17:50:07 +01:00
Peter Steinberger
eadd054348 fix(release): satisfy lint preflight 2026-04-30 17:34:50 +01:00
Peter Steinberger
1d8769604e chore(release): prepare 2026.4.29-beta.2 2026-04-30 17:25:54 +01:00
Peter Steinberger
10c39c33bf test(parallels): accept silent Windows background starts 2026-04-30 17:06:30 +01:00
Peter Steinberger
d1708b8003 test(parallels): stabilize Windows background launches 2026-04-30 16:56:31 +01:00
Peter Steinberger
15f95e3823 test(parallels): harden npm update guest scripts 2026-04-30 16:37:34 +01:00
Peter Steinberger
6c0ce491af test(parallels): retry missing Windows background launch 2026-04-30 16:13:48 +01:00
Peter Steinberger
38e49d2fdb test(infra): cover fallback tmp chmod race 2026-04-30 15:51:09 +01:00
Peter Steinberger
7420453611 fix(infra): tolerate concurrent tmp dir repair 2026-04-30 15:51:07 +01:00
Peter Steinberger
c8a0db0599 docs(changelog): credit refresh guard contributors 2026-04-30 15:50:43 +01:00
Peter Steinberger
53f977bd79 fix(browser): share control runtime state 2026-04-30 15:50:40 +01:00
Peter Steinberger
a223d62828 fix(signal): harden signal-cli installer downloads 2026-04-30 15:42:25 +01:00
Peter Steinberger
6b2bb4167a fix(auto-reply): preserve visible fallback for requested modes 2026-04-30 15:41:59 +01:00
Peter Steinberger
501ac000e3 fix: retain local memory runtime deps 2026-04-30 15:41:32 +01:00
Peter Steinberger
2dbb2bf9b7 docs(changelog): note Signal regression fixes 2026-04-30 15:16:07 +01:00
Peter Steinberger
c75ff17f06 fix(signal): handle attachment and SSE regressions 2026-04-30 15:15:44 +01:00
Peter Steinberger
147a42fe66 fix: avoid provider policy runtime deps 2026-04-30 15:15:02 +01:00
Peter Steinberger
8826f38545 test(gateway): cover web fetch startup bind 2026-04-30 15:05:46 +01:00
Peter Steinberger
d9a7459511 docs(release): dedupe codex mini changelog note 2026-04-30 15:05:41 +01:00
Peter Steinberger
47cd0d758d fix(models): restore codex mini oauth route 2026-04-30 15:02:28 +01:00
Peter Steinberger
3b4bb28f03 fix(plugins): keep runtime deps manifest complete
Co-authored-by: HCL <chenglunhu@gmail.com>
2026-04-30 14:59:51 +01:00
Peter Steinberger
3c5370879d fix(auto-reply): surface private group replies 2026-04-30 14:59:34 +01:00
Peter Steinberger
6f5a9cbf9e fix(slack): gate bot room relays on owner presence 2026-04-30 14:52:18 +01:00
Peter Steinberger
f66320efcf fix(secrets): skip optional web fetch discovery before bind 2026-04-30 14:52:05 +01:00
Peter Steinberger
5d838b0d0f fix(models): restore codex mini oauth route 2026-04-30 14:51:48 +01:00
Peter Steinberger
838d0c02e3 fix(agents): bound subagent orphan recovery 2026-04-30 14:51:35 +01:00
Peter Steinberger
c81b9547de docs(release): fold backports into 2026.4.29 notes 2026-04-30 14:23:25 +01:00
Vincent Koc
f39c5e4b04 fix(telegram): remove unused draft stream helper 2026-04-30 14:23:04 +01:00
Vincent Koc
bbf6b911b0 docs(tools): note explicit alsoAllow needed under restrictive profiles (4aa08e9d79) 2026-04-30 14:23:04 +01:00
Ayaan Zaidi
3d8946e8dd fix: remove Telegram native draft previews (#75073) 2026-04-30 14:23:04 +01:00
Ayaan Zaidi
0a98aad6c6 docs(telegram): remove native draft fallback note 2026-04-30 14:23:04 +01:00
Ayaan Zaidi
fb7db3a156 test(telegram): cover message-only previews 2026-04-30 14:23:04 +01:00
Ayaan Zaidi
90d875ce97 fix(telegram): remove native draft preview transport 2026-04-30 14:23:04 +01:00
Alex Knight
55dc865d75 fix(security): stop implicit tool grants from config sections (#47487) (#75055)
* fix(security): stop implicit tool grants from config sections (#47487)

Configured tool sections (tools.exec, tools.fs) no longer implicitly
widen restrictive profiles (messaging, minimal). Previously, having a
tools.exec section anywhere in config — even just safety settings like
security: "allowlist" — would automatically add exec and process to the
profile's allowed tools, defeating the purpose of the restrictive
profile.

The same pattern existed in tool-fs-policy.ts where tools.fs presence
would add read/write/edit to the profile allowlist for root expansion.

Changes:
- pi-tools.policy.ts: Stop merging implicit grants into profileAlsoAllow.
  Renamed resolveImplicitProfileAlsoAllow → detectImplicitProfileGrants
  and use it only for a startup warning that tells users to add explicit
  alsoAllow entries.
- tool-fs-policy.ts: Remove the implicit read/write/edit grant from
  resolveEffectiveToolFsRootExpansionAllowed when tools.fs is present.
  Root expansion now requires actual read access via profile or alsoAllow.
- Updated 4 existing tests and added 3 new regression tests.

Migration: users who relied on tools.exec or tools.fs implicitly granting
access under a restrictive profile should add explicit alsoAllow entries:

  tools:
    profile: "messaging"
    alsoAllow: ["exec", "process"]  # was implicit, now required
    exec: { security: "allowlist" }

Fixes #47487

* fix: address tool policy review feedback
2026-04-30 14:23:04 +01:00
Nimrod Gutman
3cf3230277 fix(macos): keep A2UI canvas content visible (#75039) 2026-04-30 14:23:04 +01:00
Nimrod Gutman
68568c23fc fix(macos): repair stale gateway tls pins (#75038)
Merged via squash.

Prepared head SHA: 35196f8f71
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
2026-04-30 14:23:04 +01:00
Radek Sienkiewicz
2a324ce072 fix(cli): avoid progress spinners in active TUI input (#75003)
Merged via squash.

Prepared head SHA: 129e23e716
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-04-30 14:23:04 +01:00
clawsweeper[bot]
24600e24ee fix(channels): align Yuanbao catalog id
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-30 14:23:04 +01:00
Peter Steinberger
66164e51c0 chore(release): prepare 2026.4.29 stable 2026-04-30 14:14:15 +01:00
Peter Steinberger
1c0879a462 test: stabilize cli respawn policy test 2026-04-30 10:17:43 +01:00
Peter Steinberger
f677c8c201 test: stabilize release test shards 2026-04-30 10:17:43 +01:00
Peter Steinberger
bd739cf851 test(google): isolate vertex appdata adc fallback 2026-04-30 10:17:43 +01:00
Peter Steinberger
328782f5f3 fix(release): isolate workspace bootstrap smoke 2026-04-30 10:17:43 +01:00
Peter Steinberger
41ab6dda15 fix(release): hide dynamic import smoke from tsx 2026-04-30 10:17:43 +01:00
Peter Steinberger
ff4526d78b fix(release): backport release smoke parsing 2026-04-30 10:17:42 +01:00
Peter Steinberger
aa728d0c29 test(cli): stabilize hook runner reliability test 2026-04-30 10:17:42 +01:00
Peter Steinberger
456350c5f4 test(google): reset vertex adc token cache 2026-04-30 10:17:42 +01:00
Peter Steinberger
ed42f6ae49 fix(codex): keep app server service tier optional 2026-04-30 10:17:42 +01:00
Peter Steinberger
2077855627 test(discord): update provider account mock 2026-04-30 10:17:42 +01:00
Peter Steinberger
0f40bbe4a4 test(plugins): include file transfer startup metadata 2026-04-30 10:17:42 +01:00
Peter Steinberger
3adce23d89 style(release): simplify packed runtime import smoke 2026-04-30 10:17:42 +01:00
Peter Steinberger
a898a8e926 fix(release): isolate workspace bootstrap smoke from plugins 2026-04-30 10:17:42 +01:00
Peter Steinberger
eb250c1e59 fix(cli): register commitments as core command 2026-04-30 10:17:42 +01:00
Peter Steinberger
21521cd19c fix(release): keep dynamic import smoke opaque to tsx 2026-04-30 10:17:42 +01:00
Peter Steinberger
5a6e2f1270 chore(release): refresh config baseline for 2026.4.29-beta.1 2026-04-30 10:17:42 +01:00
Peter Steinberger
255d5b3b9a chore(release): prepare 2026.4.29-beta.1 2026-04-30 10:17:42 +01:00
Peter Steinberger
f6086965f0 chore(release): prepare 2026.4.29 2026-04-30 10:17:42 +01:00
130 changed files with 3515 additions and 1008 deletions

View File

@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Security/tools: configured tool sections (`tools.exec`, `tools.fs`) no longer implicitly widen restrictive profiles (`messaging`, `minimal`). Users who need those tools under a restricted profile must add explicit `alsoAllow` entries; a startup warning identifies affected configs. Fixes #47487. Thanks @amknight.
- Agents/commitments: add opt-in inferred follow-up commitments with hidden batched extraction, per-agent/per-channel scoping, heartbeat delivery, CLI management, a simple `commitments.enabled`/`commitments.maxPerDay` config, and heartbeat-interval due-time clamping so magical check-ins do not echo immediately. (#74189) Thanks @vignesh07.
- Messages/queue: make `steer` drain all pending Pi steering messages at the next model boundary, keep legacy one-at-a-time steering as `queue`, and add a dedicated steering queue docs page. Thanks @vincentkoc.
- Messages/queue: default active-run queueing to `steer` with a 500ms followup fallback debounce, and document the queue modes, precedence, and drop policies on the command queue page. Thanks @vincentkoc.
@@ -41,6 +42,19 @@ Docs: https://docs.openclaw.ai
### Fixes
- Auto-reply/group chats: fall back to automatic source delivery when a channel precomputes message-tool-only replies but the `message` tool is unavailable, so Discord/Slack-style group turns do not silently complete without a visible reply. Fixes #74868. Thanks @kagura-agent.
- Browser/gateway: share one browser control runtime across the HTTP control server and `browser.request`, and refresh browser profile config from the source snapshot, so CLI status/start honors configured `browser.executablePath`, `headless`, and `noSandbox` instead of falling back to stale auto-detection. Fixes #75087; repairs #73617. Thanks @civiltox and @martingarramon.
- Agents/subagents: bound automatic orphan recovery with persisted recovery attempts and a wedged-session tombstone, and teach task maintenance/doctor to reconcile those sessions so restart loops no longer require manual `sessions.json` surgery. Fixes #74864. Thanks @solosage1.
- Gateway/startup: skip pre-bind web-fetch provider discovery for credential-free `tools.web.fetch` config, so Docker/Kubernetes gateways bind even when optional fetch limits are present. Fixes #74896. Thanks @KoykL.
- Infra/tmp: tolerate concurrent temp-dir permission repairs by rechecking directories that another process already tightened, so parallel ACP subprocess startup no longer throws `Unsafe fallback OpenClaw temp dir`. Fixes #66867. Thanks @Kane808-AI and @jarvisz8.
- Slack: require bot-authored room messages with `allowBots=true` to come from an explicitly channel-allowlisted bot or from a room where an explicit Slack owner is present, so broad bot relays cannot run unattended. Fixes #59284. Thanks @andrewhong-translucent.
- Signal: bound `signal-cli` installer release and archive downloads with explicit timeouts, declared and streamed size checks, and partial-file cleanup. Fixes #54153. Thanks @jinduwang1001-max and @juan-flores077.
- Signal: derive `getAttachment` HTTP response caps from `channels.signal.mediaMaxMb` with base64 headroom, so inbound photos and videos no longer drop behind the 1 MiB RPC default. Fixes #73564. Thanks @heyhudson.
- Signal: keep the long-lived receive SSE monitor open while idle instead of applying the 10s RPC/check deadline, so `signal-cli` 0.14.3 event streams no longer reconnect before inbound messages arrive. Fixes #74741. Thanks @fgabelmannjr and @k7n4n5t3w4rt.
- Models/OpenAI Codex: restore `openai-codex/gpt-5.4-mini` for ChatGPT/Codex OAuth PI runs after live OAuth proof, and align the manifest, forward-compat metadata, docs, and regression tests so stale cron and heartbeat configs resolve again. Fixes #74451. Thanks @0xCyda, @hclsys, and @Marvae.
- Memory/runtime-deps: retain the native `node-llama-cpp` runtime only when local memory search is configured, so packaged installs can repair local embeddings without relying on unreachable global npm installs. Fixes #74777. Thanks @LLagoon3.
- Plugins/runtime-deps: keep bundled provider policy config loading from staging plugin runtime dependencies, so config reads no longer fail on locked-down `/var/lib/openclaw/plugin-runtime-deps` directories. Fixes #74971. Thanks @eurojojo.
- Plugins/runtime-deps: always write a dependency map in generated runtime-deps install manifests, so npm does not crash or prune staged bundled-plugin packages when the plan is empty. Fixes #74949. Thanks @hclsys.
- Security/outbound: strip re-formed HTML tags during plain-text sanitization so nested tag fragments cannot leave a CodeQL-detected `<script>` sequence behind. Thanks @vincentkoc.
- Security/secrets: compare credential bytes with padded timing-safe buffers instead of hashing candidate passwords before equality checks. Thanks @vincentkoc.
- Security/QQBot: sanitize debug log arguments before writing to `console.*`, so gateway payload fields cannot forge extra log lines when debug logging is enabled. Thanks @vincentkoc.
@@ -52,7 +66,9 @@ Docs: https://docs.openclaw.ai
- Config: accept documented `browser.tabCleanup` keys in strict root config validation, so configured tab cleanup no longer fails before runtime reads it. Fixes #74577. Thanks @lonexreb and @ezdlp.
- Cron: validate disabled job schedule edits before persisting updates, so invalid cron changes no longer partially mutate stored jobs. Fixes #74459. Thanks @yfge.
- CLI/cron: warn when `openclaw cron add --message` omits a nonblank `--agent`, including blank agent values and session-key jobs, so scheduled agent-turn jobs make default-agent fallback explicit while system events stay quiet. Fixes #42196; carries forward #42245. Thanks @ethanclaw.
- CLI/progress: suppress nested progress spinners and line clears while TUI input owns raw stdin, so Crestodian `/status` no longer disturbs the active input row. (#75003) Thanks @velvet-shark.
- Channels/status: keep Telegram, Slack, and Google Chat read-only allowlist/default-target accessors on config-only paths, so status and channel summaries do not resolve SecretRef-backed runtime credentials. Thanks @eusine.
- Telegram: use durable message edits for streaming previews instead of native draft state, so generated replies no longer flicker through draft-to-message transitions that look like duplicates. (#75073) Thanks @obviyus.
- Active Memory: clarify the deprecated `modelFallbackPolicy` warning and config help so `modelFallback` is described as a chain-resolution last resort, not runtime failover. (#74602) Thanks @jeffrey701.
- Channels/Discord: keep read-only allowlist/default-target accessors from resolving SecretRef-backed bot tokens, so status and channel summaries no longer fail when tokens are only available in gateway runtime. (#74737) Thanks @eusine.
- Gateway/sessions: align session abort wait semantics across `chat`, `agent`, and `sessions` server methods so abort RPCs return after the targeted sessions actually halt instead of resolving early while runs are still draining. (#74751) Thanks @BunsDev.
@@ -62,6 +78,7 @@ Docs: https://docs.openclaw.ai
- Feishu: skip empty-text messages (e.g. `{"text":""}`) that carry no media, so no blank user turn is written to the session and downstream LLM providers cannot reject the request with "messages must not be empty". (#74634) Thanks @xdengli and @hclsys.
- Feishu/Bitable: clean up newly created placeholder rows whose fields contain only default empty values while preserving meaningful link, attachment, user, number, boolean, and location values during create-app cleanup. (#73920) Carries forward #40602. Thanks @boat2moon.
- macOS app: keep attach-only mode and the Debug Settings launchd toggle marker-only, so launching with `--attach-only`/`--no-launchd` no longer uninstalls the Gateway LaunchAgent or drops active sessions. (#72174) Thanks @DolencLuka.
- macOS Canvas: stop auto-reloading the current A2UI host during push/eval/snapshot flows, so pushed A2UI content remains visible instead of returning to the empty Canvas shell. Fixes #73337. Thanks @Gr4via.
- Plugin SDK: restore the deprecated `plugin-sdk/zalouser` command-auth facade so published Lark/Zalo plugins that import it load on current hosts. Fixes #74702. Thanks @Goron01.
- Plugins/runtime-deps: include bundled provider plugins when `models.providers`, auth profiles, agent defaults, or subagent model refs configure that provider, while keeping inactive default-enabled provider plugins out of doctor repair. Refs #74307. Thanks @Skeptomenos.
- Plugins/runtime: resolve relative plugin `api.resolvePath` inputs against the plugin root instead of the host working directory, while keeping absolute and home paths user-resolved. Fixes #74718. Thanks @jimdawdy-hub.
@@ -118,7 +135,7 @@ Docs: https://docs.openclaw.ai
- Sandbox/Docker: tolerate Docker daemon unavailability when sandbox mode is off, so doctor and preflight checks no longer fail on installs that do not run the Docker daemon. Fixes #73671. Thanks @kaseonedge.
- Control UI/mobile: persist mobile chat settings through Lit-managed state and route mobile navigation through the same view-state path so chat panel toggles survive transitions on small viewports. Thanks @BunsDev.
- Control UI/exports: align sidebar trigger affordances across the resizable divider, mobile layout, and exported-HTML transcript template so the sidebar toggle and exported transcript sidebar render with consistent hit areas and styling. Thanks @BunsDev.
- Control UI/chat: disable the page refresh affordance while a chat run is active so accidental refreshes do not abort an in-flight reply. Thanks @BunsDev.
- Control UI/chat: disable the page refresh affordance while a chat run is active so accidental refreshes do not abort an in-flight reply. Thanks @Angfr95 and @BunsDev.
- Memory/LanceDB: return real memory records from `openclaw ltm list` (with optional `--limit` and createdAt ordering) instead of an empty placeholder, so the CLI surface matches the documented LTM listing contract. (#67952) Thanks @zhangyue19921010.
- Media: include redacted per-attempt resize failures and resolved model input capabilities in vision-pipeline errors so ARM64 image failures are diagnosable without closing the remaining routing investigation. Refs #74552. Thanks @1yihui.
- Control UI/i18n: route zh-CN agent, debug, channel-refresh, and exec-approval copy through the locale source while preserving the English `Cron Jobs` agent tab label and the security-audit command styling. Carries forward #39692 repair context. Thanks @hepeng154833488 and @vincentkoc.
@@ -328,6 +345,7 @@ Docs: https://docs.openclaw.ai
- Providers/GitHub Copilot: support the GUI/RPC wizard device-code auth flow so onboarding from non-TTY clients (gateway RPC bridge, GUI wizards) completes instead of returning empty profiles. Dangerous-state handling now distinguishes `access_denied` and `expired_token` from transport errors. (#73290) Thanks @indierawk2k2.
- Installer/Linux: warn before switching an unwritable npm global prefix to `~/.npm-global`, then tell users to run future global updates with `npm i -g openclaw@latest` without `sudo` so npm keeps using the redirected user prefix. Fixes #44365; carries forward #50479. Thanks @Sayeem3051.
- Gateway/plugins: enable the native `require()` fast path on Windows for bundled plugin modules so plugin loading uses `require()` instead of Jiti's transform pipeline, reducing startup from ~39s to ~2s on typical 6-plugin setups. Fixes #68656. (#74173) Thanks @galiniliev.
- macOS app: detect stale Gateway TLS certificate pins, automatically repair trusted Tailscale Serve rotations, and surface paired-but-disconnected Mac companion nodes so partial Gateway connections no longer look healthy. Thanks @guti.
## 2026.4.27
@@ -518,7 +536,7 @@ Docs: https://docs.openclaw.ai
- Doctor/channels: suppress disabled bundled-plugin blocker warnings when a trusted external plugin owns the configured channel, so Lark/Feishu installs no longer get Feishu repair noise after switching to `openclaw-lark`. Fixes #56794. Thanks @wuji-tech-dev.
- CLI/status: show skipped fast-path memory checks as `not checked` and report active custom memory plugin runtime status from `status --json --all` without requiring built-in `agents.defaults.memorySearch`, so plugins such as memory-lancedb-pro and memory-cms no longer look unavailable when their own runtime is healthy. Fixes #56968. Thanks @Tony-ooo and @aderius.
- Gateway/channels: record and log unexpected clean channel monitor exits so channels that return without throwing no longer appear stopped with no error. Fixes #73099. Thanks @balaji1968-kingler.
- Discord/group chats: keep group/channel replies private by default unless the agent explicitly uses the message tool, so always-on rooms can lurk without leaking automatic final, block, preview, or status-reaction output; `messages.groupChat.visibleReplies: "automatic"` restores legacy auto-posting. (#73046) Thanks @scoootscooob.
- Group/channel chats (all channels): keep group/channel replies private by default unless the agent explicitly uses the message tool, so always-on rooms can lurk without leaking automatic final, block, preview, or status-reaction output; `messages.groupChat.visibleReplies: "automatic"` restores legacy auto-posting. (#73046) Thanks @scoootscooob.
- Plugins/package: force nested bundled-plugin runtime dependency installs out of inherited npm dry-run mode during prepack and package smoke checks, so packed installs materialize required plugin modules instead of reporting missing bundled files. Refs #73128. Thanks @Adam-Researchh.
- Discord: skip reaction events before REST channel fetch when notifications are off, guild reactions are disabled, or allowlist mode cannot match without channel overrides, reducing reconnect bursts that caused slow listener warnings. Fixes #73133. Thanks @isaacsummers.
- Channels/Telegram: centralize polling update tracking so accepted offsets remain durable across restarts, same-process handler failures can still retry, and slow offset writes cannot overwrite newer accepted watermarks. Refs #73115. Thanks @vdruts.

View File

@@ -65,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026042700
versionName = "2026.4.27"
versionCode = 2026042900
versionName = "2026.4.29"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -1,5 +1,9 @@
# OpenClaw iOS Changelog
## 2026.4.29 - 2026-04-29
Maintenance update for the current OpenClaw development release.
## 2026.4.27 - 2026-04-27
Maintenance update for the current OpenClaw development release.

View File

@@ -2,8 +2,8 @@
// Source of truth: apps/ios/version.json
// Generated by scripts/ios-sync-versioning.ts.
OPENCLAW_IOS_VERSION = 2026.4.27
OPENCLAW_MARKETING_VERSION = 2026.4.27
OPENCLAW_IOS_VERSION = 2026.4.29
OPENCLAW_MARKETING_VERSION = 2026.4.29
OPENCLAW_BUILD_VERSION = 1
#include? "../build/Version.xcconfig"

View File

@@ -1,3 +1,3 @@
{
"version": "2026.4.27"
"version": "2026.4.29"
}

View File

@@ -184,7 +184,9 @@ final class CanvasManager {
private func maybeAutoNavigateToA2UI(controller: CanvasWindowController, a2uiUrl: String?) {
guard let a2uiUrl else { return }
let shouldNavigate = controller.shouldAutoNavigateToA2UI(lastAutoTarget: self.lastAutoA2UIUrl)
let shouldNavigate = controller.shouldAutoNavigateToA2UI(
lastAutoTarget: self.lastAutoA2UIUrl,
candidateTarget: a2uiUrl)
guard shouldNavigate else {
Self.logger.debug("canvas auto-nav skipped; target unchanged")
return

View File

@@ -319,12 +319,14 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
self.sessionDir.path
}
func shouldAutoNavigateToA2UI(lastAutoTarget: String?) -> Bool {
let trimmed = (self.currentTarget ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty || trimmed == "/" { return true }
func shouldAutoNavigateToA2UI(lastAutoTarget: String?, candidateTarget: String) -> Bool {
let current = (self.currentTarget ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let candidate = candidateTarget.trimmingCharacters(in: .whitespacesAndNewlines)
if current.isEmpty || current == "/" { return true }
if !candidate.isEmpty, current == candidate { return false }
if let lastAuto = lastAutoTarget?.trimmingCharacters(in: .whitespacesAndNewlines),
!lastAuto.isEmpty,
trimmed == lastAuto
current == lastAuto
{
return true
}

View File

@@ -2,6 +2,7 @@ import AppKit
import AVFoundation
import Foundation
import Observation
import OpenClawKit
import SwiftUI
/// Menu contents for the OpenClaw menu bar extra.
@@ -14,6 +15,7 @@ struct MenuContent: View {
private let heartbeatStore = HeartbeatStore.shared
private let controlChannel = ControlChannel.shared
private let activityStore = WorkActivityStore.shared
private let nodesStore = NodesStore.shared
@Bindable private var pairingPrompter = NodePairingApprovalPrompter.shared
@Bindable private var devicePairingPrompter = DevicePairingApprovalPrompter.shared
@Environment(\.openSettings) private var openSettings
@@ -44,6 +46,9 @@ struct MenuContent: View {
VStack(alignment: .leading, spacing: 2) {
Text(self.connectionLabel)
self.statusLine(label: self.healthStatus.label, color: self.healthStatus.color)
if let macNodeStatus = self.macNodeStatus {
self.statusLine(label: macNodeStatus.label, color: macNodeStatus.color)
}
if self.pairingPrompter.pendingCount > 0 {
let repairCount = self.pairingPrompter.pendingRepairCount
let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : ""
@@ -351,6 +356,31 @@ struct MenuContent: View {
}
}
private var macNodeStatus: (label: String, color: Color)? {
guard self.state.connectionMode != .unconfigured else { return nil }
guard case .connected = self.controlChannel.state else { return nil }
let deviceId = DeviceIdentityStore.loadOrCreate().deviceId
if let entry = self.nodesStore.nodes.first(where: { $0.nodeId == deviceId }) {
guard entry.isConnected else {
return ("Mac capabilities offline", .orange)
}
let commands = Set(entry.commands ?? [])
let missingRequiredCommands = [
OpenClawSystemCommand.notify.rawValue,
OpenClawSystemCommand.run.rawValue,
OpenClawSystemCommand.which.rawValue,
].filter { !commands.contains($0) }
if !missingRequiredCommands.isEmpty {
return ("Mac capabilities incomplete", .orange)
}
return nil
}
guard !self.nodesStore.isLoading, !self.nodesStore.nodes.isEmpty else { return nil }
return ("Mac capabilities offline", .orange)
}
private var healthStatus: (label: String, color: Color) {
if let activity = self.activityStore.current {
let color: Color = activity.role == .main ? .accentColor : .gray

View File

@@ -1156,7 +1156,7 @@ extension MenuSessionsInjector {
}
private func sortedNodeEntries() -> [NodeInfo] {
let entries = self.nodesStore.nodes.filter(\.isConnected)
let entries = self.nodesStore.nodes.filter { $0.isConnected || $0.isPaired }
return entries.sorted { lhs, rhs in
if lhs.isConnected != rhs.isConnected { return lhs.isConnected }
if lhs.isPaired != rhs.isPaired { return lhs.isPaired }
@@ -1239,5 +1239,9 @@ extension MenuSessionsInjector {
func testingFindNodesInsertIndex(in menu: NSMenu) -> Int? {
self.findNodesInsertIndex(in: menu)
}
func testingSortedNodeEntries() -> [NodeInfo] {
self.sortedNodeEntries()
}
}
#endif

View File

@@ -10,6 +10,7 @@ final class MacNodeModeCoordinator {
private var task: Task<Void, Never>?
private let runtime = MacNodeRuntime()
private let session = GatewayNodeSession()
private var autoRepairedTLSFingerprintsByStoreKey: [String: String] = [:]
func start() {
guard self.task == nil else { return }
@@ -58,8 +59,10 @@ final class MacNodeModeCoordinator {
try? await Task.sleep(nanoseconds: 200_000_000)
}
var attemptedURL: URL?
do {
let config = try await GatewayEndpointStore.shared.requireConfig()
attemptedURL = config.url
let caps = self.currentCaps()
let commands = self.currentCommands(caps: caps)
let permissions = await self.currentPermissions()
@@ -109,6 +112,10 @@ final class MacNodeModeCoordinator {
retryDelay = 1_000_000_000
try? await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
if await self.autoRepairStaleTLSPinIfNeeded(error: error, url: attemptedURL) {
retryDelay = 1_000_000_000
continue
}
self.logger.error("mac node gateway connect failed: \(error.localizedDescription, privacy: .public)")
try? await Task.sleep(nanoseconds: min(retryDelay, 10_000_000_000))
retryDelay = min(retryDelay * 2, 10_000_000_000)
@@ -188,11 +195,49 @@ final class MacNodeModeCoordinator {
Self.resolvedCommands(caps: caps)
}
nonisolated static func tlsPinStoreKey(for url: URL) -> String {
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "gateway"
let port = url.port ?? 443
return "\(host):\(port)"
}
nonisolated static func shouldAutoRepairStaleTLSPin(url: URL, failure: GatewayTLSValidationFailure) -> Bool {
guard failure.kind == .pinMismatch else { return false }
guard url.scheme?.lowercased() == "wss" else { return false }
guard failure.storeKey == nil || failure.storeKey == self.tlsPinStoreKey(for: url) else { return false }
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !host.isEmpty
else { return false }
if LoopbackHost.isLoopback(host) {
return failure.systemTrustOk
}
// Tailscale Serve uses publicly trusted, rotating certificates for *.ts.net names.
// A stale legacy leaf pin should not leave the companion app half-connected forever.
if host == "ts.net" || host.hasSuffix(".ts.net") {
return failure.systemTrustOk
}
return false
}
private func autoRepairStaleTLSPinIfNeeded(error: Error, url: URL?) async -> Bool {
guard let tlsError = error as? GatewayTLSValidationError, let url else { return false }
guard Self.shouldAutoRepairStaleTLSPin(url: url, failure: tlsError.failure) else { return false }
let storeKey = tlsError.failure.storeKey ?? Self.tlsPinStoreKey(for: url)
guard let observedFingerprint = tlsError.failure.observedFingerprint else { return false }
guard self.autoRepairedTLSFingerprintsByStoreKey[storeKey] != observedFingerprint else { return false }
guard GatewayTLSStore.replaceFingerprint(observedFingerprint, stableID: storeKey) else { return false }
self.autoRepairedTLSFingerprintsByStoreKey[storeKey] = observedFingerprint
self.logger.info("replaced stale gateway TLS pin storeKey=\(storeKey, privacy: .public)")
await self.session.disconnect()
return true
}
private func buildSessionBox(url: URL) -> WebSocketSessionBox? {
guard url.scheme?.lowercased() == "wss" else { return nil }
let host = url.host ?? "gateway"
let port = url.port ?? 443
let stableID = "\(host):\(port)"
let stableID = Self.tlsPinStoreKey(for: url)
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
let params = GatewayTLSParams(
required: true,

View File

@@ -44,10 +44,12 @@ struct NodeMenuEntryFormatter {
}
static func roleText(_ entry: NodeInfo) -> String {
if entry.isConnected { return "connected" }
if self.isGateway(entry) { return "disconnected" }
if entry.isPaired { return "paired" }
return "unpaired"
if self.isGateway(entry) {
return entry.isConnected ? "connected" : "disconnected"
}
let pairing = entry.isPaired ? "paired" : "unpaired"
let connection = entry.isConnected ? "connected" : "disconnected"
return "\(pairing) · \(connection)"
}
static func detailLeft(_ entry: NodeInfo) -> String {

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.4.27</string>
<string>2026.4.29</string>
<key>CFBundleVersion</key>
<string>2026042700</string>
<string>2026042900</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -46,4 +46,37 @@ struct CanvasWindowSmokeTests {
controller.hideCanvas()
controller.close()
}
@Test func `A2UI auto navigation is idempotent for current host target`() throws {
let root = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-canvas-test-\(UUID().uuidString)")
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager().removeItem(at: root) }
let controller = try CanvasWindowController(
sessionKey: "main",
root: root,
presentation: .window)
defer { controller.close() }
let oldTarget = "http://127.0.0.1:18789/__openclaw__/a2ui/?platform=macos"
let currentTarget = "http://127.0.0.1:18790/__openclaw__/a2ui/?platform=macos"
let userTarget = "https://github.com/openclaw/openclaw"
#expect(controller.shouldAutoNavigateToA2UI(lastAutoTarget: nil, candidateTarget: currentTarget) == true)
controller.load(target: "/")
#expect(controller.shouldAutoNavigateToA2UI(lastAutoTarget: nil, candidateTarget: currentTarget) == true)
controller.load(target: currentTarget)
#expect(controller
.shouldAutoNavigateToA2UI(lastAutoTarget: currentTarget, candidateTarget: currentTarget) == false)
controller.load(target: oldTarget)
#expect(controller.shouldAutoNavigateToA2UI(lastAutoTarget: oldTarget, candidateTarget: currentTarget) == true)
controller.load(target: userTarget)
#expect(controller
.shouldAutoNavigateToA2UI(lastAutoTarget: currentTarget, candidateTarget: currentTarget) == false)
}
}

View File

@@ -4,6 +4,30 @@ import Testing
@testable import OpenClaw
struct GatewayChannelConnectTests {
private final class TLSFailureSession: WebSocketSessioning, GatewayTLSFailureProviding, @unchecked Sendable {
private var failure: GatewayTLSValidationFailure?
init(failure: GatewayTLSValidationFailure) {
self.failure = failure
}
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
_ = url
let task = GatewayTestWebSocketTask(receiveHook: { _, receiveIndex in
if receiveIndex == 0 {
return .data(GatewayWebSocketTestSupport.connectChallengeData())
}
throw URLError(.userCancelledAuthentication)
})
return WebSocketTaskBox(task: task)
}
func consumeLastTLSFailure() -> GatewayTLSValidationFailure? {
defer { self.failure = nil }
return self.failure
}
}
private enum FakeResponse {
case helloOk(delayMs: Int)
case invalid(delayMs: Int)
@@ -109,4 +133,28 @@ struct GatewayChannelConnectTests {
Issue.record("unexpected error: \(error)")
}
}
@Test func `connect maps user cancelled authentication with cached TLS failure`() async throws {
let failure = GatewayTLSValidationFailure(
kind: .pinMismatch,
host: "gateway.example.ts.net",
storeKey: "gateway.example.ts.net:443",
expectedFingerprint: "old",
observedFingerprint: "new",
systemTrustOk: true)
let session = TLSFailureSession(failure: failure)
let channel = try GatewayChannelActor(
url: #require(URL(string: "wss://gateway.example.ts.net")),
token: nil,
session: WebSocketSessionBox(session: session))
do {
try await channel.connect()
Issue.record("expected GatewayTLSValidationError")
} catch let error as GatewayTLSValidationError {
#expect(error.failure == failure)
} catch {
Issue.record("unexpected error: \(error)")
}
}
}

View File

@@ -29,4 +29,61 @@ struct MacNodeModeCoordinatorTests {
#expect(caps.contains(OpenClawCapability.browser.rawValue))
#expect(commands.contains(OpenClawBrowserCommand.proxy.rawValue))
}
@Test func `tls pin store key uses default wss port`() throws {
let url = try #require(URL(string: "wss://gateway.example.ts.net"))
#expect(MacNodeModeCoordinator.tlsPinStoreKey(for: url) == "gateway.example.ts.net:443")
}
@Test func `auto repairs trusted tailscale serve pin mismatch`() throws {
let url = try #require(URL(string: "wss://gateway.example.ts.net"))
let failure = GatewayTLSValidationFailure(
kind: .pinMismatch,
host: "gateway.example.ts.net",
storeKey: "gateway.example.ts.net:443",
expectedFingerprint: "old",
observedFingerprint: "new",
systemTrustOk: true)
#expect(MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
}
@Test func `does not auto repair untrusted remote pin mismatch`() throws {
let url = try #require(URL(string: "wss://gateway.example.com"))
let failure = GatewayTLSValidationFailure(
kind: .pinMismatch,
host: "gateway.example.com",
storeKey: "gateway.example.com:443",
expectedFingerprint: "old",
observedFingerprint: "new",
systemTrustOk: true)
#expect(!MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
}
@Test func `auto repairs trusted loopback pin mismatch`() throws {
let url = try #require(URL(string: "wss://127.0.0.1:18789"))
let failure = GatewayTLSValidationFailure(
kind: .pinMismatch,
host: "127.0.0.1",
storeKey: "127.0.0.1:18789",
expectedFingerprint: "old",
observedFingerprint: "new",
systemTrustOk: true)
#expect(MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
}
@Test func `does not auto repair untrusted loopback pin mismatch`() throws {
let url = try #require(URL(string: "wss://127.0.0.1:18789"))
let failure = GatewayTLSValidationFailure(
kind: .pinMismatch,
host: "127.0.0.1",
storeKey: "127.0.0.1:18789",
expectedFingerprint: "old",
observedFingerprint: "new",
systemTrustOk: false)
#expect(!MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
}
}

View File

@@ -165,4 +165,50 @@ struct MenuSessionsInjectorTests {
#expect(usageCostItem?.submenu != nil)
#expect(usageCostItem?.submenu?.delegate == nil)
}
@Test func `node status text distinguishes paired disconnected nodes`() {
let pairedDisconnected = Self.node(id: "paired", paired: true, connected: false)
let unpairedDisconnected = Self.node(id: "unpaired", paired: false, connected: false)
let connected = Self.node(id: "connected", paired: true, connected: true)
#expect(NodeMenuEntryFormatter.roleText(pairedDisconnected) == "paired · disconnected")
#expect(NodeMenuEntryFormatter.roleText(unpairedDisconnected) == "unpaired · disconnected")
#expect(NodeMenuEntryFormatter.roleText(connected) == "paired · connected")
}
@Test func `sorted node entries include paired disconnected nodes`() {
let injector = MenuSessionsInjector()
defer { NodesStore.shared.nodes = [] }
NodesStore.shared.nodes = [
Self.node(id: "ignored", paired: false, connected: false, displayName: "Ignored"),
Self.node(id: "paired", paired: true, connected: false, displayName: "MacBook"),
Self.node(id: "connected", paired: true, connected: true, displayName: "iPhone"),
]
let entries = injector.testingSortedNodeEntries()
#expect(entries.map(\.nodeId) == ["connected", "paired"])
}
private static func node(
id: String,
paired: Bool,
connected: Bool,
displayName: String? = nil) -> NodeInfo
{
NodeInfo(
nodeId: id,
displayName: displayName ?? id,
platform: "macOS 26.3.1",
version: nil,
coreVersion: nil,
uiVersion: nil,
deviceFamily: "Mac",
modelIdentifier: nil,
remoteIp: nil,
caps: nil,
commands: nil,
permissions: nil,
paired: paired,
connected: connected)
}
}

View File

@@ -1010,10 +1010,13 @@ public actor GatewayChannelActor {
/// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
private func wrap(_ error: Error, context: String) -> Error {
if error is GatewayConnectAuthError || error is GatewayResponseError || error is GatewayDecodingError {
if error is GatewayConnectAuthError || error is GatewayResponseError || error is GatewayDecodingError || error is GatewayTLSValidationError {
return error
}
if let urlError = error as? URLError {
if let failure = (self.session as? GatewayTLSFailureProviding)?.consumeLastTLSFailure() {
return GatewayTLSValidationError(failure: failure, context: context)
}
let desc = urlError.localizedDescription.isEmpty ? "cancelled" : urlError.localizedDescription
return NSError(
domain: URLError.errorDomain,

View File

@@ -30,6 +30,9 @@ public struct GatewayConnectionProblem: Equatable, Sendable {
case connectionRefused
case reachabilityFailed
case websocketCancelled
case tlsPinMismatch
case tlsCertificateUntrusted
case tlsCertificateUnavailable
case unknown
}
@@ -170,6 +173,9 @@ public enum GatewayConnectionProblemMapper {
if let responseError = error as? GatewayResponseError {
return self.map(responseError)
}
if let tlsError = error as? GatewayTLSValidationError {
return self.map(tlsError)
}
return self.mapTransportError(error)
}
@@ -518,6 +524,51 @@ public enum GatewayConnectionProblemMapper {
return nil
}
private static func map(_ tlsError: GatewayTLSValidationError) -> GatewayConnectionProblem {
let failure = tlsError.failure
switch failure.kind {
case .pinMismatch:
let trustedSuffix = failure.systemTrustOk
? " The new certificate is trusted by this device; this is commonly caused by certificate rotation."
: " This device could not verify the new certificate."
return GatewayConnectionProblem(
kind: .tlsPinMismatch,
owner: failure.systemTrustOk ? .network : .unknown,
title: "Gateway certificate changed",
message: "The saved TLS certificate pin for \(failure.host) no longer matches the gateway certificate.\(trustedSuffix)",
actionLabel: "Review certificate",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: false,
pauseReconnect: true,
technicalDetails: tlsError.localizedDescription)
case .certificateUnavailable:
return GatewayConnectionProblem(
kind: .tlsCertificateUnavailable,
owner: .network,
title: "Gateway certificate unavailable",
message: "OpenClaw could not read the gateway certificate for \(failure.host).",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: tlsError.localizedDescription)
case .untrustedCertificate:
return GatewayConnectionProblem(
kind: .tlsCertificateUntrusted,
owner: .network,
title: "Gateway certificate is not trusted",
message: "This device does not trust the TLS certificate presented by \(failure.host).",
actionLabel: "Check certificate",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: false,
pauseReconnect: true,
technicalDetails: tlsError.localizedDescription)
}
}
private static func mapTransportError(_ error: Error) -> GatewayConnectionProblem? {
let nsError = error as NSError
let rawMessage = nsError.userInfo[NSLocalizedDescriptionKey] as? String ?? nsError.localizedDescription

View File

@@ -16,6 +16,65 @@ public struct GatewayTLSParams: Sendable {
}
}
public enum GatewayTLSValidationFailureKind: String, Sendable {
case pinMismatch
case certificateUnavailable
case untrustedCertificate
}
public struct GatewayTLSValidationFailure: Equatable, Sendable {
public let kind: GatewayTLSValidationFailureKind
public let host: String
public let storeKey: String?
public let expectedFingerprint: String?
public let observedFingerprint: String?
public let systemTrustOk: Bool
public init(
kind: GatewayTLSValidationFailureKind,
host: String,
storeKey: String?,
expectedFingerprint: String?,
observedFingerprint: String?,
systemTrustOk: Bool)
{
self.kind = kind
self.host = host
self.storeKey = storeKey
self.expectedFingerprint = expectedFingerprint
self.observedFingerprint = observedFingerprint
self.systemTrustOk = systemTrustOk
}
}
public struct GatewayTLSValidationError: LocalizedError, Sendable {
public let failure: GatewayTLSValidationFailure
public let context: String
public init(failure: GatewayTLSValidationFailure, context: String) {
self.failure = failure
self.context = context
}
public var errorDescription: String? {
let prefix = self.context.trimmingCharacters(in: .whitespacesAndNewlines)
switch self.failure.kind {
case .pinMismatch:
let expected = self.failure.expectedFingerprint ?? "unknown"
let observed = self.failure.observedFingerprint ?? "unknown"
return "\(prefix): TLS certificate pin mismatch for \(self.failure.host) (expected \(expected), observed \(observed))"
case .certificateUnavailable:
return "\(prefix): TLS certificate unavailable for \(self.failure.host)"
case .untrustedCertificate:
return "\(prefix): TLS certificate is not trusted for \(self.failure.host)"
}
}
}
public protocol GatewayTLSFailureProviding: AnyObject {
func consumeLastTLSFailure() -> GatewayTLSValidationFailure?
}
public enum GatewayTLSStore {
private static let keychainService = "ai.openclaw.tls-pinning"
@@ -35,6 +94,15 @@ public enum GatewayTLSStore {
_ = GenericPasswordKeychainStore.saveString(value, service: self.keychainService, account: stableID)
}
@discardableResult
public static func replaceFingerprint(_ value: String, stableID: String) -> Bool {
guard GenericPasswordKeychainStore.saveString(value, service: self.keychainService, account: stableID) else {
return false
}
self.clearLegacyFingerprint(stableID: stableID)
return true
}
@discardableResult
public static func clearFingerprint(stableID: String) -> Bool {
let removedKeychain = GenericPasswordKeychainStore.delete(
@@ -87,8 +155,10 @@ public enum GatewayTLSStore {
}
}
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, @unchecked Sendable {
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, GatewayTLSFailureProviding, @unchecked Sendable {
private let params: GatewayTLSParams
private let failureLock = NSLock()
private var lastTLSFailure: GatewayTLSValidationFailure?
private lazy var session: URLSession = {
let config = URLSessionConfiguration.default
config.waitsForConnectivity = true
@@ -100,6 +170,26 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
super.init()
}
public func consumeLastTLSFailure() -> GatewayTLSValidationFailure? {
self.failureLock.lock()
defer { self.failureLock.unlock() }
let failure = self.lastTLSFailure
self.lastTLSFailure = nil
return failure
}
private func recordTLSFailure(_ failure: GatewayTLSValidationFailure) {
self.failureLock.lock()
self.lastTLSFailure = failure
self.failureLock.unlock()
}
private func clearTLSFailure() {
self.failureLock.lock()
self.lastTLSFailure = nil
self.failureLock.unlock()
}
public func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
let task = self.session.webSocketTask(with: url)
task.maximumMessageSize = 16 * 1024 * 1024
@@ -118,12 +208,23 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
return
}
let host = challenge.protectionSpace.host
let systemTrustOk = SecTrustEvaluateWithError(trust, nil)
let expected = self.params.expectedFingerprint.map(normalizeFingerprint)
if let fingerprint = certificateFingerprint(trust) {
let fingerprint = certificateFingerprint(trust)
if let fingerprint {
if let expected {
if fingerprint == expected {
self.clearTLSFailure()
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
self.recordTLSFailure(GatewayTLSValidationFailure(
kind: .pinMismatch,
host: host,
storeKey: self.params.storeKey,
expectedFingerprint: expected,
observedFingerprint: fingerprint,
systemTrustOk: systemTrustOk))
completionHandler(.cancelAuthenticationChallenge, nil)
}
return
@@ -132,15 +233,23 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
if let storeKey = params.storeKey {
GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey)
}
self.clearTLSFailure()
completionHandler(.useCredential, URLCredential(trust: trust))
return
}
}
let ok = SecTrustEvaluateWithError(trust, nil)
if ok || !self.params.required {
if systemTrustOk || !self.params.required {
self.clearTLSFailure()
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
self.recordTLSFailure(GatewayTLSValidationFailure(
kind: fingerprint == nil ? .certificateUnavailable : .untrustedCertificate,
host: host,
storeKey: self.params.storeKey,
expectedFingerprint: expected,
observedFingerprint: fingerprint,
systemTrustOk: false))
completionHandler(.cancelAuthenticationChallenge, nil)
}
}

View File

@@ -89,4 +89,41 @@ import Testing
#expect(mapped == nil)
}
@Test func tlsPinMismatchMapsToActionableProblem() {
let error = GatewayTLSValidationError(
failure: GatewayTLSValidationFailure(
kind: .pinMismatch,
host: "gateway.example.ts.net",
storeKey: "gateway.example.ts.net:443",
expectedFingerprint: "old",
observedFingerprint: "new",
systemTrustOk: true),
context: "connect to gateway")
let problem = GatewayConnectionProblemMapper.map(error: error)
#expect(problem?.kind == .tlsPinMismatch)
#expect(problem?.retryable == false)
#expect(problem?.pauseReconnect == true)
#expect(problem?.actionLabel == "Review certificate")
}
@Test func untrustedTLSCertificatePausesReconnect() {
let error = GatewayTLSValidationError(
failure: GatewayTLSValidationFailure(
kind: .untrustedCertificate,
host: "gateway.example.com",
storeKey: "gateway.example.com:443",
expectedFingerprint: nil,
observedFingerprint: nil,
systemTrustOk: false),
context: "connect to gateway")
let problem = GatewayConnectionProblemMapper.map(error: error)
#expect(problem?.kind == .tlsCertificateUntrusted)
#expect(problem?.retryable == false)
#expect(problem?.pauseReconnect == true)
}
}

View File

@@ -1,4 +1,4 @@
c3bcb3a3da46bbbe15a7798869911cab109df950ee51c79fd86c96bb809dfdf1 config-baseline.json
d4b34f6fd2c39132bf4feff4be5ddfd226fa52c4596d6bdc438031456dde18d4 config-baseline.json
8f573caa7f4cf01ae9d4805d3d14e1ba6772f651f6da182baaf2b469592749a4 config-baseline.core.json
92712871defa92eeda8161b516db85574681f2b70678b940508a808b987aeae2 config-baseline.channel.json
aca3215b7382af82b5060d73c631a7f82661c6e99193fa5eb1c5b4b499fb657b config-baseline.plugin.json
6005cf9f6e8c9f25ef97207b5eee29ae0e506cf910cdeca77fc9894ad1755b1f config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
e94362ae9caa948c50ad0dc9a99c801750c9dd24ef687cdbc0e6996cdec1ad2b plugin-sdk-api-baseline.json
83f9fdc048267705b4a5cf5d68860b39bbb00985f3f01dd6d6ba28e12587b997 plugin-sdk-api-baseline.jsonl
851a39b442a4a15e78d27d8a3e1ee66ff61a061356d412051e205f6c07f54c34 plugin-sdk-api-baseline.json
d3106b731a3a13f7dddaa0b1916f223c1757fa8d1df3476914f70502c9532c2f plugin-sdk-api-baseline.jsonl

View File

@@ -247,6 +247,7 @@ openclaw tasks notify <lookup> state_changes
Reconciliation is runtime-aware:
- ACP/subagent tasks check their backing child session.
- Subagent tasks whose child session has a restart-recovery tombstone are marked lost instead of being treated as recoverable backing sessions.
- Cron tasks check whether the cron runtime still owns the job, then recover terminal status from persisted cron run logs/job state before falling back to `lost`. Only the Gateway process is authoritative for the in-memory cron active-job set; offline CLI audit uses durable history but does not mark a cron task lost solely because that local Set is empty.
- Chat-backed CLI tasks check the owning live run context, not just the chat session row.

View File

@@ -61,6 +61,9 @@ To restore legacy automatic final replies for group/channel rooms:
}
```
The gateway hot-reloads `messages` config after the file is saved. Restart only
when file watching or config reload is disabled in the deployment.
To require visible output to go through the message tool for every source chat:
```json5

View File

@@ -582,6 +582,8 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
- `toolsBySender` key format: `id:`, `e164:`, `username:`, `name:`, or `"*"` wildcard
(legacy unprefixed keys still map to `id:` only)
`allowBots` is conservative for channels and private channels: bot-authored room messages are accepted only when the sending bot is explicitly listed in that room's `users` allowlist, or when at least one explicit Slack owner ID from `channels.slack.allowFrom` is currently a room member. Wildcards and display-name owner entries do not satisfy owner presence. Owner presence uses Slack `conversations.members`; make sure the app has the matching read scope for the room type (`channels:read` for public channels, `groups:read` for private channels). If the member lookup fails, OpenClaw drops the bot-authored room message.
</Tab>
</Tabs>

View File

@@ -310,8 +310,6 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
Preview streaming is separate from block streaming. When block streaming is explicitly enabled for Telegram, OpenClaw skips the preview stream to avoid double-streaming.
If native draft transport is unavailable/rejected, OpenClaw automatically falls back to `sendMessage` + `editMessageText`.
Telegram-only reasoning stream:
- `/reasoning stream` sends reasoning to the live preview while generating

View File

@@ -33,8 +33,9 @@ For multi-endpoint setups, `provider` can also be a custom
`models.providers.<id>` entry, such as `ollama-5080`, when that provider sets
`api: "ollama"` or another embedding adapter owner.
For local embeddings with no API key, install the optional `node-llama-cpp`
runtime package next to OpenClaw and use `provider: "local"`.
For local embeddings with no API key, set `provider: "local"`. Packaged
installs retain the native `node-llama-cpp` runtime in OpenClaw's managed plugin
runtime-deps tree; run `openclaw doctor --fix` if that tree needs repair.
Some OpenAI-compatible embedding endpoints require asymmetric labels such as
`input_type: "query"` for searches and `input_type: "document"` or `"passage"`

View File

@@ -772,6 +772,8 @@ Group messages default to **require mention** (metadata mention or safe regex pa
Visible replies are controlled separately. Group/channel rooms default to `messages.groupChat.visibleReplies: "message_tool"`: OpenClaw still processes the turn, but normal final replies stay private and visible room output requires `message(action=send)`. Set `"automatic"` only when you want the legacy behavior where normal replies are posted back to the room. To apply the same tool-only visible-reply behavior to direct chats too, set `messages.visibleReplies: "message_tool"`.
The gateway hot-reloads `messages` config after the file is saved. Restart only when file watching or config reload is disabled in the deployment.
**Mention types:**
- **Metadata mentions**: Native platform @-mentions. Ignored in WhatsApp self-chat mode.

View File

@@ -93,6 +93,7 @@ cat ~/.openclaw/openclaw.json
<Accordion title="State and integrity">
- Session lock file inspection and stale lock cleanup.
- Session transcript repair for duplicated prompt-rewrite branches created by affected 2026.4.24 builds.
- Wedged subagent restart-recovery tombstone detection, with `--fix` support for clearing stale aborted recovery flags so startup does not keep treating the child as restart-aborted.
- State integrity and permissions checks (sessions, transcripts, state dir).
- Config file permission checks (chmod 600) when running locally.
- Model auth health: checks OAuth expiry, can refresh expiring tokens, and reports auth-profile cooldown/disabled states.

View File

@@ -83,6 +83,7 @@ node.
- **Health probe failed**: check SSH reachability, PATH, and that Baileys is logged in (`openclaw status --json`).
- **Web Chat stuck**: confirm the gateway is running on the remote host and the forwarded port matches the gateway WS port; the UI requires a healthy WS connection.
- **Node IP shows 127.0.0.1**: expected with the SSH tunnel. Switch **Transport** to **Direct (ws/wss)** if you want the gateway to see the real client IP.
- **Dashboard works but Mac capabilities are offline**: this means the app's operator/control connection is healthy, but the companion node connection is not connected or is missing its command surface. Open the menu bar device section and check whether the Mac is `paired · disconnected`. For `wss://*.ts.net` Tailscale Serve endpoints, the app detects stale legacy TLS leaf pins after certificate rotation, clears the stale pin when macOS trusts the new certificate, and retries automatically. If the certificate is not system-trusted or the host is not a Tailscale Serve name, review the certificate or switch to **Remote over SSH**.
- **Voice Wake**: trigger phrases are forwarded automatically in remote mode; no separate forwarder is needed.
## Notification sounds

View File

@@ -208,6 +208,7 @@ Choose your preferred auth method and follow the setup steps.
| Model ref | Runtime config | Route | Auth |
|-----------|----------------|-------|------|
| `openai-codex/gpt-5.5` | omitted / `runtime: "pi"` | ChatGPT/Codex OAuth through PI | Codex sign-in |
| `openai-codex/gpt-5.4-mini` | omitted / `runtime: "pi"` | ChatGPT/Codex OAuth through PI | Codex sign-in |
| `openai-codex/gpt-5.5` | `runtime: "auto"` | Still PI unless a plugin explicitly claims `openai-codex` | Codex sign-in |
| `openai/gpt-5.5` | `agentRuntime.id: "codex"` | Codex app-server harness | Codex app-server auth |
@@ -217,12 +218,6 @@ Choose your preferred auth method and follow the setup steps.
It does not select or auto-enable the bundled Codex app-server harness.
</Note>
<Warning>
`openai-codex/gpt-5.4-mini` is not a supported Codex OAuth route. Use
`openai/gpt-5.4-mini` with an OpenAI API key, or use
`openai-codex/gpt-5.5` with Codex OAuth.
</Warning>
### Config example
```json5

View File

@@ -284,7 +284,7 @@ For custom OpenAI-compatible endpoints or overriding provider defaults:
| `local.modelCacheDir` | `string` | node-llama-cpp default | Cache dir for downloaded models |
| `local.contextSize` | `number \| "auto"` | `4096` | Context window size for the embedding context. 4096 covers typical chunks (128512 tokens) while bounding non-weight VRAM. Lower to 10242048 on constrained hosts. `"auto"` uses the model's trained maximum — not recommended for 8B+ models (Qwen3-Embedding-8B: 40 960 tokens → ~32 GB VRAM vs ~8.8 GB at 4096). |
Default model: `embeddinggemma-300m-qat-Q8_0.gguf` (~0.6 GB, auto-downloaded). Requires native build: `pnpm approve-builds` then `pnpm rebuild node-llama-cpp`.
Default model: `embeddinggemma-300m-qat-Q8_0.gguf` (~0.6 GB, auto-downloaded). Packaged installs repair the native `node-llama-cpp` runtime through managed plugin runtime deps when `provider: "local"` is configured. Source checkouts still require native build approval: `pnpm approve-builds` then `pnpm rebuild node-llama-cpp`.
Use the standalone CLI to verify the same provider path the Gateway uses:

View File

@@ -159,6 +159,10 @@ sessions and logged-in profiles, so add it explicitly with
`tools.alsoAllow: ["browser"]` or a per-agent
`agents.list[].tools.alsoAllow: ["browser"]`.
<Note>
Configuring `tools.exec` or `tools.fs` under a restrictive profile (`messaging`, `minimal`) does not implicitly widen the profile's allowlist. Add explicit `tools.alsoAllow` entries (for example `["exec", "process"]` for exec, or `["read", "write", "edit"]` for fs) when you want a restrictive profile to use those configured sections. OpenClaw logs a startup warning when a config section is present without a matching `alsoAllow` grant.
</Note>
The `coding` and `messaging` profiles also allow configured bundle MCP tools
under the plugin key `bundle-mcp`. Add `tools.deny: ["bundle-mcp"]` when you
want a profile to keep its normal built-ins but hide all configured MCP tools.

View File

@@ -512,6 +512,14 @@ restart-aborted child sessions remain recoverable through the sub-agent
orphan recovery flow, which sends a synthetic resume message before
clearing the aborted marker.
Automatic restart recovery is bounded per child session. If the same
sub-agent child is accepted for orphan recovery repeatedly inside the
rapid re-wedge window, OpenClaw persists a recovery tombstone on that
session and stops auto-resuming it on later restarts. Run
`openclaw tasks maintenance --apply` to reconcile the task record, or
`openclaw doctor --fix` to clear stale aborted recovery flags on
tombstoned sessions.
<Note>
If a sub-agent spawn fails with Gateway `PAIRING_REQUIRED` /
`scope-upgrade`, check the RPC caller before editing pairing state.

View File

@@ -0,0 +1,70 @@
import type { Server } from "node:http";
import { createBrowserRuntimeState, stopBrowserRuntime } from "./browser/runtime-lifecycle.js";
import { type BrowserServerState, createBrowserRouteContext } from "./browser/server-context.js";
type BrowserControlOwner = "server" | "service";
let state: BrowserServerState | null = null;
let owner: BrowserControlOwner | null = null;
export function getBrowserControlState(): BrowserServerState | null {
return state;
}
export function createBrowserControlContext() {
return createBrowserRouteContext({
getState: () => state,
refreshConfigFromDisk: true,
});
}
export async function ensureBrowserControlRuntime(params: {
server?: Server | null;
port: number;
resolved: BrowserServerState["resolved"];
owner: BrowserControlOwner;
onWarn: (message: string) => void;
}): Promise<BrowserServerState> {
if (state) {
if (params.server) {
state.server = params.server;
state.port = params.port;
state.resolved = { ...params.resolved, controlPort: params.port };
owner = "server";
}
return state;
}
state = await createBrowserRuntimeState({
server: params.server ?? null,
port: params.port,
resolved: params.resolved,
onWarn: params.onWarn,
});
owner = params.owner;
return state;
}
export async function stopBrowserControlRuntime(params: {
requestedBy: BrowserControlOwner;
closeServer?: boolean;
onWarn: (message: string) => void;
}): Promise<void> {
const current = state;
if (!current) {
return;
}
if (params.requestedBy === "service" && current.server && owner === "server") {
return;
}
await stopBrowserRuntime({
current,
getState: () => state,
clearState: () => {
state = null;
owner = null;
},
closeServer: params.closeServer,
onWarn: params.onWarn,
});
}

View File

@@ -1,5 +1,9 @@
import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js";
import {
getRuntimeConfig,
getRuntimeConfigSourceSnapshot,
type OpenClawConfig,
} from "../config/config.js";
export function loadBrowserConfigForRuntimeRefresh(): OpenClawConfig {
return getRuntimeConfig();
return getRuntimeConfigSourceSnapshot() ?? getRuntimeConfig();
}

View File

@@ -1,6 +1,7 @@
export {
getRuntimeConfig,
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
replaceConfigFile,
type BrowserConfig,
type BrowserProfileConfig,

View File

@@ -1,36 +1,32 @@
import {
createBrowserControlContext,
ensureBrowserControlRuntime,
getBrowserControlState,
stopBrowserControlRuntime,
} from "./browser-control-state.js";
import { loadBrowserConfigForRuntimeRefresh } from "./browser/config-refresh-source.js";
import { resolveBrowserConfig } from "./browser/config.js";
import { ensureBrowserControlAuth } from "./browser/control-auth.js";
import { createBrowserRuntimeState, stopBrowserRuntime } from "./browser/runtime-lifecycle.js";
import { type BrowserServerState, createBrowserRouteContext } from "./browser/server-context.js";
import type { BrowserServerState } from "./browser/server-context.js";
import { getRuntimeConfig } from "./config/config.js";
import { createSubsystemLogger } from "./logging/subsystem.js";
import { isDefaultBrowserPluginEnabled } from "./plugin-enabled.js";
let state: BrowserServerState | null = null;
const log = createSubsystemLogger("browser");
const logService = log.child("service");
export function getBrowserControlState(): BrowserServerState | null {
return state;
}
export function createBrowserControlContext() {
return createBrowserRouteContext({
getState: () => state,
refreshConfigFromDisk: true,
});
}
export async function startBrowserControlServiceFromConfig(): Promise<BrowserServerState | null> {
if (state) {
return state;
const current = getBrowserControlState();
if (current) {
return current;
}
const cfg = getRuntimeConfig();
if (!isDefaultBrowserPluginEnabled(cfg)) {
return null;
}
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const browserCfg = loadBrowserConfigForRuntimeRefresh();
const resolved = resolveBrowserConfig(browserCfg.browser, browserCfg);
if (!resolved.enabled) {
return null;
}
@@ -43,10 +39,11 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
logService.warn(`failed to auto-configure browser auth: ${String(err)}`);
}
state = await createBrowserRuntimeState({
const state = await ensureBrowserControlRuntime({
server: null,
port: resolved.controlPort,
resolved,
owner: "service",
onWarn: (message) => logService.warn(message),
});
@@ -57,13 +54,10 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
}
export async function stopBrowserControlService(): Promise<void> {
const current = state;
await stopBrowserRuntime({
current,
getState: () => state,
clearState: () => {
state = null;
},
await stopBrowserControlRuntime({
requestedBy: "service",
onWarn: (message) => logService.warn(message),
});
}
export { createBrowserControlContext, getBrowserControlState };

View File

@@ -0,0 +1,145 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getFreePort } from "../browser/test-port.js";
import type { OpenClawConfig } from "../config/config.js";
const mocks = vi.hoisted(() => ({
runtimeConfig: {} as OpenClawConfig,
runtimeSourceConfig: null as OpenClawConfig | null,
ensureBrowserControlAuth: vi.fn(async () => ({ auth: {} })),
resolveBrowserControlAuth: vi.fn(() => ({})),
shouldAutoGenerateBrowserAuth: vi.fn(() => false),
ensureExtensionRelayForProfiles: vi.fn(async () => {}),
stopKnownBrowserProfiles: vi.fn(async () => {}),
isChromeReachable: vi.fn(async () => false),
isChromeCdpReady: vi.fn(async () => false),
}));
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
getRuntimeConfig: () => mocks.runtimeConfig,
getRuntimeConfigSourceSnapshot: () => mocks.runtimeSourceConfig,
loadConfig: () => mocks.runtimeConfig,
};
});
vi.mock("../browser/control-auth.js", () => ({
ensureBrowserControlAuth: mocks.ensureBrowserControlAuth,
resolveBrowserControlAuth: mocks.resolveBrowserControlAuth,
shouldAutoGenerateBrowserAuth: mocks.shouldAutoGenerateBrowserAuth,
}));
vi.mock("../browser/server-lifecycle.js", () => ({
ensureExtensionRelayForProfiles: mocks.ensureExtensionRelayForProfiles,
stopKnownBrowserProfiles: mocks.stopKnownBrowserProfiles,
}));
vi.mock("../browser/chrome.js", () => ({
diagnoseChromeCdp: vi.fn(async () => ({ ok: false })),
formatChromeCdpDiagnostic: vi.fn(() => "not reachable"),
isChromeCdpReady: mocks.isChromeCdpReady,
isChromeReachable: mocks.isChromeReachable,
launchOpenClawChrome: vi.fn(async () => {
throw new Error("launch should not be needed for status");
}),
resolveOpenClawUserDataDir: vi.fn(() => "/tmp/openclaw-browser"),
stopOpenClawChrome: vi.fn(async () => {}),
}));
vi.mock("../browser/pw-ai-state.js", () => ({
isPwAiLoaded: vi.fn(() => false),
}));
const { startBrowserControlServerFromConfig, stopBrowserControlServer } =
await import("../server.js");
const { stopBrowserControlService } = await import("../control-service.js");
const { browserHandlers } = await import("./browser-request.js");
function browserConfig(params: {
gatewayPort: number;
executablePath?: string;
headless?: boolean;
noSandbox?: boolean;
}): OpenClawConfig {
return {
gateway: {
port: params.gatewayPort,
},
browser: {
enabled: true,
defaultProfile: "openclaw",
...(params.executablePath ? { executablePath: params.executablePath } : {}),
...(typeof params.headless === "boolean" ? { headless: params.headless } : {}),
...(typeof params.noSandbox === "boolean" ? { noSandbox: params.noSandbox } : {}),
profiles: {
openclaw: {
cdpPort: params.gatewayPort + 11,
color: "#FF4500",
},
},
},
};
}
async function browserRequestStatus(): Promise<unknown> {
const respond = vi.fn();
await browserHandlers["browser.request"]({
params: {
method: "GET",
path: "/",
query: { profile: "openclaw" },
},
respond: respond as never,
context: {
nodeRegistry: {
listConnected: () => [],
},
} as never,
client: null,
req: { type: "req", id: "req-1", method: "browser.request" },
isWebchatConnect: () => false,
});
const call = respond.mock.calls[0];
expect(call?.[0]).toBe(true);
return call?.[1];
}
describe("browser.request local control state", () => {
afterEach(async () => {
await stopBrowserControlService();
await stopBrowserControlServer();
mocks.runtimeSourceConfig = null;
vi.clearAllMocks();
});
it("uses the same resolved browser config as the HTTP control service", async () => {
const controlPort = await getFreePort();
const gatewayPort = controlPort - 2;
mocks.runtimeConfig = browserConfig({
gatewayPort,
executablePath: "/usr/bin/google-chrome",
headless: true,
noSandbox: true,
});
mocks.runtimeSourceConfig = mocks.runtimeConfig;
const httpState = await startBrowserControlServerFromConfig();
expect(httpState?.resolved.executablePath).toBe("/usr/bin/google-chrome");
expect(httpState?.resolved.noSandbox).toBe(true);
// The runtime snapshot can lag behind source config after gateway startup;
// browser.request must not fork a second stale control state from it.
mocks.runtimeConfig = browserConfig({
gatewayPort,
headless: false,
noSandbox: false,
});
await expect(browserRequestStatus()).resolves.toMatchObject({
executablePath: "/usr/bin/google-chrome",
headless: true,
noSandbox: true,
});
});
});

View File

@@ -3,6 +3,7 @@ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runti
export {
getRuntimeConfig,
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
} from "openclaw/plugin-sdk/runtime-config-snapshot";
export { replaceConfigFile } from "openclaw/plugin-sdk/config-mutation";
export {

View File

@@ -1,6 +1,13 @@
import type { Server } from "node:http";
import express from "express";
import {
createBrowserControlContext,
ensureBrowserControlRuntime,
getBrowserControlState,
stopBrowserControlRuntime,
} from "./browser-control-state.js";
import { deleteBridgeAuthForPort, setBridgeAuthForPort } from "./browser/bridge-auth-registry.js";
import { loadBrowserConfigForRuntimeRefresh } from "./browser/config-refresh-source.js";
import { resolveBrowserConfig } from "./browser/config.js";
import {
ensureBrowserControlAuth,
@@ -9,8 +16,7 @@ import {
} from "./browser/control-auth.js";
import { registerBrowserRoutes } from "./browser/routes/index.js";
import type { BrowserRouteRegistrar } from "./browser/routes/types.js";
import { createBrowserRuntimeState, stopBrowserRuntime } from "./browser/runtime-lifecycle.js";
import { type BrowserServerState, createBrowserRouteContext } from "./browser/server-context.js";
import type { BrowserServerState } from "./browser/server-context.js";
import {
installBrowserAuthMiddleware,
installBrowserCommonMiddleware,
@@ -19,20 +25,21 @@ import { getRuntimeConfig } from "./config/config.js";
import { createSubsystemLogger } from "./logging/subsystem.js";
import { isDefaultBrowserPluginEnabled } from "./plugin-enabled.js";
let state: BrowserServerState | null = null;
const log = createSubsystemLogger("browser");
const logServer = log.child("server");
export async function startBrowserControlServerFromConfig(): Promise<BrowserServerState | null> {
if (state) {
return state;
const current = getBrowserControlState();
if (current?.server) {
return current;
}
const cfg = getRuntimeConfig();
if (!isDefaultBrowserPluginEnabled(cfg)) {
return null;
}
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const browserCfg = loadBrowserConfigForRuntimeRefresh();
const resolved = resolveBrowserConfig(browserCfg.browser, browserCfg);
if (!resolved.enabled) {
return null;
}
@@ -70,10 +77,7 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
installBrowserCommonMiddleware(app);
installBrowserAuthMiddleware(app, browserAuth);
const ctx = createBrowserRouteContext({
getState: () => state,
refreshConfigFromDisk: true,
});
const ctx = createBrowserControlContext();
registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx);
const port = resolved.controlPort;
@@ -89,10 +93,11 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
return null;
}
state = await createBrowserRuntimeState({
const state = await ensureBrowserControlRuntime({
server,
port,
resolved,
owner: "server",
onWarn: (message) => logServer.warn(message),
});
setBridgeAuthForPort(port, browserAuth);
@@ -103,16 +108,12 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
}
export async function stopBrowserControlServer(): Promise<void> {
const current = state;
const current = getBrowserControlState();
if (current?.port) {
deleteBridgeAuthForPort(current.port);
}
await stopBrowserRuntime({
current,
getState: () => state,
clearState: () => {
state = null;
},
await stopBrowserControlRuntime({
requestedBy: "server",
closeServer: true,
onWarn: (message) => logServer.warn(message),
});

View File

@@ -120,10 +120,12 @@ const codexAppServerApprovalPolicySchema = z.enum([
]);
const codexAppServerSandboxSchema = z.enum(["read-only", "workspace-write", "danger-full-access"]);
const codexAppServerApprovalsReviewerSchema = z.enum(["user", "auto_review", "guardian_subagent"]);
const codexAppServerServiceTierSchema = z.preprocess(
(value) => (value === null ? null : resolveServiceTier(value)),
z.enum(["fast", "flex"]).nullable().optional(),
);
const codexAppServerServiceTierSchema = z
.preprocess(
(value) => (value === null ? null : resolveServiceTier(value)),
z.enum(["fast", "flex"]).nullable().optional(),
)
.optional();
const codexPluginConfigSchema = z
.object({

View File

@@ -451,6 +451,8 @@ vi.mock("openclaw/plugin-sdk/error-runtime", async () => {
vi.mock(buildDiscordSourceModuleId("accounts.js"), () => ({
resolveDiscordAccount: resolveDiscordAccountMock,
resolveDiscordAccountAllowFrom: () => undefined,
resolveDiscordAccountDmPolicy: () => undefined,
}));
vi.mock(buildDiscordSourceModuleId("probe.js"), () => ({

View File

@@ -18,6 +18,7 @@ let buildGoogleGenerativeAiParams: typeof import("./transport-stream.js").buildG
let createGoogleGenerativeAiTransportStreamFn: typeof import("./transport-stream.js").createGoogleGenerativeAiTransportStreamFn;
let createGoogleVertexTransportStreamFn: typeof import("./transport-stream.js").createGoogleVertexTransportStreamFn;
let hasGoogleVertexAuthorizedUserAdcSync: typeof import("./vertex-adc.js").hasGoogleVertexAuthorizedUserAdcSync;
let resetGoogleVertexAuthorizedUserTokenCacheForTest: typeof import("./vertex-adc.js").resetGoogleVertexAuthorizedUserTokenCacheForTest;
const MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL = Symbol.for(
"openclaw.modelProviderRequestTransport",
@@ -91,13 +92,15 @@ describe("google transport stream", () => {
createGoogleGenerativeAiTransportStreamFn,
createGoogleVertexTransportStreamFn,
} = await import("./transport-stream.js"));
({ hasGoogleVertexAuthorizedUserAdcSync } = await import("./vertex-adc.js"));
({ hasGoogleVertexAuthorizedUserAdcSync, resetGoogleVertexAuthorizedUserTokenCacheForTest } =
await import("./vertex-adc.js"));
});
beforeEach(() => {
buildGuardedModelFetchMock.mockReset();
guardedFetchMock.mockReset();
buildGuardedModelFetchMock.mockReturnValue(guardedFetchMock);
resetGoogleVertexAuthorizedUserTokenCacheForTest();
});
afterEach(() => {
@@ -377,7 +380,7 @@ describe("google transport stream", () => {
}),
"utf8",
);
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", undefined);
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", "");
vi.stubEnv("HOME", homeDir);
vi.stubEnv("APPDATA", appDataDir);
vi.stubEnv("GOOGLE_CLOUD_PROJECT", "vertex-project");

View File

@@ -22,6 +22,10 @@ const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
let cachedGoogleVertexAuthorizedUserToken: GoogleVertexAuthorizedUserToken | undefined;
export function resetGoogleVertexAuthorizedUserTokenCacheForTest(): void {
cachedGoogleVertexAuthorizedUserToken = undefined;
}
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}

View File

@@ -7,6 +7,9 @@
"contracts": {
"memoryEmbeddingProviders": ["local"]
},
"runtimeDependencies": {
"localMemoryEmbedding": ["node-llama-cpp@3.18.1"]
},
"commandAliases": [
{
"name": "dreaming",

View File

@@ -59,7 +59,7 @@ function formatLocalSetupError(err: unknown): string {
"To enable local embeddings:",
"1) Use Node 24 (recommended for installs/updates; Node 22 LTS, currently 22.14+, remains supported)",
missing
? `2) Install optional local embedding runtime next to OpenClaw: npm i -g ${NODE_LLAMA_CPP_INSTALL_SPEC}`
? `2) Run openclaw doctor --fix to repair managed plugin runtime deps for ${NODE_LLAMA_CPP_INSTALL_SPEC}`
: null,
`3) If you use pnpm: pnpm approve-builds (select ${NODE_LLAMA_CPP_RUNTIME_PACKAGE}), then pnpm rebuild ${NODE_LLAMA_CPP_RUNTIME_PACKAGE}`,
...listRemoteEmbeddingSetupHints(),

View File

@@ -439,7 +439,7 @@ describe("openai codex provider", () => {
});
});
it("does not resolve gpt-5.4-mini through the Codex OAuth route", () => {
it("resolves gpt-5.4-mini through the Codex OAuth route", () => {
const provider = buildOpenAICodexProviderPlugin();
const model = provider.resolveDynamicModel?.({
@@ -447,14 +447,25 @@ describe("openai codex provider", () => {
modelId: "gpt-5.4-mini",
modelRegistry: createSingleModelRegistry(
createCodexTemplate({
id: "gpt-5.1-codex-mini",
cost: { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 },
id: "gpt-5.4",
cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
contextWindow: 1_050_000,
contextTokens: 272_000,
}),
null,
) as never,
} as never);
expect(model).toBeUndefined();
expect(model).toMatchObject({
id: "gpt-5.4-mini",
name: "gpt-5.4-mini",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
contextWindow: 400_000,
contextTokens: 272_000,
maxTokens: 128_000,
cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
});
});
it("augments catalog with gpt-5.5-pro and gpt-5.4 native metadata", () => {
@@ -503,9 +514,12 @@ describe("openai codex provider", () => {
cost: { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 },
}),
);
expect(entries).not.toContainEqual(
expect(entries).toContainEqual(
expect.objectContaining({
id: "gpt-5.4-mini",
contextWindow: 400_000,
contextTokens: 272_000,
cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
}),
);
});

View File

@@ -52,6 +52,7 @@ const OPENAI_CODEX_GPT_55_MODEL_ID = "gpt-5.5";
const OPENAI_CODEX_GPT_55_PRO_MODEL_ID = "gpt-5.5-pro";
const OPENAI_CODEX_GPT_54_MODEL_ID = "gpt-5.4";
const OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID = "gpt-5.4-codex";
const OPENAI_CODEX_GPT_54_MINI_MODEL_ID = "gpt-5.4-mini";
const OPENAI_CODEX_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro";
const OPENAI_CODEX_GPT_55_CODEX_CONTEXT_TOKENS = 400_000;
const OPENAI_CODEX_GPT_55_DEFAULT_RUNTIME_CONTEXT_TOKENS = 272_000;
@@ -59,6 +60,7 @@ const OPENAI_CODEX_GPT_55_PRO_NATIVE_CONTEXT_TOKENS = 1_000_000;
const OPENAI_CODEX_GPT_55_PRO_DEFAULT_CONTEXT_TOKENS = 272_000;
const OPENAI_CODEX_GPT_54_NATIVE_CONTEXT_TOKENS = 1_050_000;
const OPENAI_CODEX_GPT_54_DEFAULT_CONTEXT_TOKENS = 272_000;
const OPENAI_CODEX_GPT_54_MINI_NATIVE_CONTEXT_TOKENS = 400_000;
const OPENAI_CODEX_GPT_54_MAX_TOKENS = 128_000;
const OPENAI_CODEX_GPT_55_PRO_COST = {
input: 30,
@@ -78,6 +80,12 @@ const OPENAI_CODEX_GPT_54_PRO_COST = {
cacheRead: 0,
cacheWrite: 0,
} as const;
const OPENAI_CODEX_GPT_54_MINI_COST = {
input: 0.75,
output: 4.5,
cacheRead: 0.075,
cacheWrite: 0,
} as const;
const OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.3-codex", "gpt-5.2-codex"] as const;
/** Legacy codex rows first; fall back to catalog `gpt-5.4` when the API omits 5.3/5.2. */
const OPENAI_CODEX_GPT_54_CATALOG_SYNTH_TEMPLATE_MODEL_IDS = [
@@ -105,6 +113,7 @@ const OPENAI_CODEX_MODERN_MODEL_IDS = [
OPENAI_CODEX_GPT_55_PRO_MODEL_ID,
OPENAI_CODEX_GPT_54_MODEL_ID,
OPENAI_CODEX_GPT_54_PRO_MODEL_ID,
OPENAI_CODEX_GPT_54_MINI_MODEL_ID,
"gpt-5.2",
"gpt-5.2-codex",
OPENAI_CODEX_GPT_53_MODEL_ID,
@@ -227,6 +236,14 @@ function resolveCodexForwardCompatModel(ctx: ProviderResolveDynamicModelContext)
maxTokens: OPENAI_CODEX_GPT_54_MAX_TOKENS,
cost: OPENAI_CODEX_GPT_54_PRO_COST,
};
} else if (lower === OPENAI_CODEX_GPT_54_MINI_MODEL_ID) {
templateIds = OPENAI_CODEX_GPT_54_CATALOG_SYNTH_TEMPLATE_MODEL_IDS;
patch = {
contextWindow: OPENAI_CODEX_GPT_54_MINI_NATIVE_CONTEXT_TOKENS,
contextTokens: OPENAI_CODEX_GPT_54_DEFAULT_CONTEXT_TOKENS,
maxTokens: OPENAI_CODEX_GPT_54_MAX_TOKENS,
cost: OPENAI_CODEX_GPT_54_MINI_COST,
};
} else if (lower === OPENAI_CODEX_GPT_53_MODEL_ID) {
templateIds = OPENAI_CODEX_TEMPLATE_MODEL_IDS;
} else {
@@ -495,6 +512,7 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
OPENAI_CODEX_GPT_55_PRO_MODEL_ID,
OPENAI_CODEX_GPT_54_MODEL_ID,
OPENAI_CODEX_GPT_54_PRO_MODEL_ID,
OPENAI_CODEX_GPT_54_MINI_MODEL_ID,
].includes(id);
},
...buildOpenAIResponsesProviderHooks(),
@@ -555,6 +573,14 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
contextTokens: OPENAI_CODEX_GPT_54_DEFAULT_CONTEXT_TOKENS,
cost: OPENAI_CODEX_GPT_54_PRO_COST,
}),
buildOpenAISyntheticCatalogEntry(gpt54Template, {
id: OPENAI_CODEX_GPT_54_MINI_MODEL_ID,
reasoning: true,
input: ["text", "image"],
contextWindow: OPENAI_CODEX_GPT_54_MINI_NATIVE_CONTEXT_TOKENS,
contextTokens: OPENAI_CODEX_GPT_54_DEFAULT_CONTEXT_TOKENS,
cost: OPENAI_CODEX_GPT_54_MINI_COST,
}),
].filter((entry): entry is NonNullable<typeof entry> => entry !== undefined);
},
};

View File

@@ -645,6 +645,21 @@
"cacheWrite": 0
}
},
{
"id": "gpt-5.4-mini",
"name": "gpt-5.4-mini",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 400000,
"contextTokens": 272000,
"maxTokens": 128000,
"cost": {
"input": 0.75,
"output": 4.5,
"cacheRead": 0.075,
"cacheWrite": 0
}
},
{
"id": "gpt-5.5-pro",
"name": "gpt-5.5-pro",
@@ -688,11 +703,6 @@
"provider": "openai-codex",
"model": "gpt-5.3-codex-spark",
"reason": "gpt-5.3-codex-spark is no longer exposed by the OpenAI or Codex catalogs. Use openai/gpt-5.5."
},
{
"provider": "openai-codex",
"model": "gpt-5.4-mini",
"reason": "gpt-5.4-mini is not supported by the OpenAI Codex OAuth route. Use openai/gpt-5.4-mini with an OpenAI API key or openai-codex/gpt-5.5 with Codex OAuth."
}
]
},

View File

@@ -139,6 +139,65 @@ describe("signalRpcRequest", () => {
).rejects.toThrow("Signal HTTP response exceeded size limit");
});
it("accepts RPC responses larger than the default cap when maxResponseBytes is raised", async () => {
const payload = JSON.stringify({
jsonrpc: "2.0",
result: { data: "y".repeat(1_200_000) },
id: "test-id",
});
const baseUrl = await withSignalServer((_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(payload);
});
const result = await signalRpcRequest<{ data: string }>("getAttachment", undefined, {
baseUrl,
maxResponseBytes: 4_000_000,
});
expect(result.data.length).toBe(1_200_000);
});
it("rejects RPC responses that exceed a custom maxResponseBytes cap", async () => {
const baseUrl = await withSignalServer((_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end("x".repeat(8_193));
});
await expect(
signalRpcRequest("getAttachment", undefined, {
baseUrl,
maxResponseBytes: 8_192,
}),
).rejects.toThrow("Signal HTTP response exceeded size limit");
});
it("falls back to the default cap when maxResponseBytes is zero or non-finite", async () => {
const baseUrl = await withSignalServer((_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end("x".repeat(1_048_577));
});
await expect(
signalRpcRequest("version", undefined, {
baseUrl,
maxResponseBytes: 0,
}),
).rejects.toThrow("Signal HTTP response exceeded size limit");
const baseUrl2 = await withSignalServer((_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end("x".repeat(1_048_577));
});
await expect(
signalRpcRequest("version", undefined, {
baseUrl: baseUrl2,
maxResponseBytes: Number.POSITIVE_INFINITY,
}),
).rejects.toThrow("Signal HTTP response exceeded size limit");
});
it("uses an absolute deadline for slow-drip RPC responses", async () => {
const baseUrl = await withSignalServer((_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
@@ -230,6 +289,25 @@ describe("streamSignalEvents", () => {
).rejects.toThrow("Signal SSE connection timed out after 25ms");
});
it("allows idle event streams to wait for abort when the deadline is disabled", async () => {
const baseUrl = await withSignalServer(() => {
// Leave the request open without response headers, matching signal-cli 0.14.3 before
// its first keepalive flush.
});
const abortController = new AbortController();
const abortTimer = setTimeout(() => abortController.abort(), 25);
abortTimer.unref?.();
await expect(
streamSignalEvents({
baseUrl,
timeoutMs: 0,
abortSignal: abortController.signal,
onEvent: () => {},
}),
).rejects.toMatchObject({ name: "AbortError", message: "Signal SSE aborted" });
});
it("rejects oversized SSE line buffers by byte size", async () => {
const baseUrl = await withSignalServer((_req, res) => {
res.writeHead(200, { "Content-Type": "text/event-stream" });

View File

@@ -7,6 +7,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
export type SignalRpcOptions = {
baseUrl: string;
timeoutMs?: number;
maxResponseBytes?: number;
};
export type SignalRpcError = {
@@ -29,7 +30,7 @@ export type SignalSseEvent = {
};
const DEFAULT_TIMEOUT_MS = 10_000;
const MAX_SIGNAL_HTTP_RESPONSE_BYTES = 1_048_576;
const DEFAULT_SIGNAL_HTTP_RESPONSE_MAX_BYTES = 1_048_576;
const MAX_SIGNAL_SSE_BUFFER_BYTES = 1_048_576;
const MAX_SIGNAL_SSE_EVENT_DATA_BYTES = 1_048_576;
@@ -94,6 +95,20 @@ function assertSignalHttpProtocol(url: URL, label: string): void {
}
}
function normalizeSignalHttpResponseMaxBytes(value: number | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return DEFAULT_SIGNAL_HTTP_RESPONSE_MAX_BYTES;
}
return Math.floor(value);
}
function normalizeSignalSseTimeoutMs(timeoutMs: number): number | null {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return null;
}
return timeoutMs;
}
function requestSignalHttpText(
url: URL,
options: {
@@ -101,6 +116,7 @@ function requestSignalHttpText(
headers?: Record<string, string>;
body?: string;
timeoutMs: number;
maxResponseBytes?: number;
},
): Promise<SignalHttpResponse> {
assertSignalHttpProtocol(url, "HTTP");
@@ -132,6 +148,7 @@ function requestSignalHttpText(
cleanup();
resolve(response);
};
const maxResponseBytes = normalizeSignalHttpResponseMaxBytes(options.maxResponseBytes);
request = client.request(
url,
{
@@ -144,7 +161,7 @@ function requestSignalHttpText(
res.on("data", (chunk: Buffer | string) => {
const next = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
totalBytes += next.byteLength;
if (totalBytes > MAX_SIGNAL_HTTP_RESPONSE_BYTES) {
if (totalBytes > maxResponseBytes) {
const error = new Error("Signal HTTP response exceeded size limit");
request?.destroy(error);
res.destroy(error);
@@ -194,6 +211,7 @@ export async function signalRpcRequest<T = unknown>(
},
body,
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
maxResponseBytes: opts.maxResponseBytes,
});
if (res.status === 201) {
return undefined as T;
@@ -248,15 +266,23 @@ function openSignalEventStream(
let response: IncomingMessage | undefined;
let onAbort: () => void = () => {};
let request: ClientRequest;
const headerDeadline = setTimeout(() => {
const error = new Error(`Signal SSE connection timed out after ${timeoutMs}ms`);
response?.destroy(error);
request.destroy(error);
rejectOnce(error);
}, timeoutMs);
headerDeadline.unref?.();
const effectiveTimeoutMs = normalizeSignalSseTimeoutMs(timeoutMs);
const headerDeadline =
effectiveTimeoutMs === null
? undefined
: setTimeout(() => {
const error = new Error(
`Signal SSE connection timed out after ${effectiveTimeoutMs}ms`,
);
response?.destroy(error);
request.destroy(error);
rejectOnce(error);
}, effectiveTimeoutMs);
headerDeadline?.unref?.();
const cleanup = () => {
clearTimeout(headerDeadline);
if (headerDeadline) {
clearTimeout(headerDeadline);
}
abortSignal?.removeEventListener("abort", onAbort);
};
const rejectOnce = (error: unknown) => {
@@ -284,7 +310,9 @@ function openSignalEventStream(
res.destroy();
return;
}
clearTimeout(headerDeadline);
if (headerDeadline) {
clearTimeout(headerDeadline);
}
settled = true;
response = res;
resolve({ response: res, cleanup });

View File

@@ -2,10 +2,26 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import JSZip from "jszip";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import * as tar from "tar";
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ReleaseAsset } from "./install-signal-cli.js";
import { extractSignalCliArchive, looksLikeArchive, pickAsset } from "./install-signal-cli.js";
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
const {
downloadToFile,
extractSignalCliArchive,
installSignalCliFromRelease,
looksLikeArchive,
pickAsset,
} = await import("./install-signal-cli.js");
const SAMPLE_ASSETS: ReleaseAsset[] = [
{
@@ -39,6 +55,26 @@ const SAMPLE_ASSETS: ReleaseAsset[] = [
},
];
function okDownloadResponse(body: BodyInit, init: ResponseInit = {}) {
return {
response: new Response(body, { status: 200, ...init }),
release: vi.fn().mockResolvedValue(undefined),
};
}
async function withTempFile(run: (filePath: string) => Promise<void>) {
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-download-"));
try {
await run(path.join(workDir, "signal-cli.tgz"));
} finally {
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
}
}
beforeEach(() => {
fetchWithSsrFGuardMock.mockReset();
});
describe("looksLikeArchive", () => {
it("recognises .tar.gz", () => {
expect(looksLikeArchive("foo.tar.gz")).toBe(true);
@@ -131,6 +167,94 @@ describe("pickAsset", () => {
});
});
describe("downloadToFile", () => {
it("downloads through the SSRF guard with an explicit timeout", async () => {
const fetchResult = okDownloadResponse("archive");
fetchWithSsrFGuardMock.mockResolvedValue(fetchResult);
await withTempFile(async (filePath) => {
await downloadToFile("https://example.com/signal-cli.tgz", filePath);
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe("archive");
});
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://example.com/signal-cli.tgz",
requireHttps: true,
timeoutMs: 5 * 60_000,
auditContext: "signal-cli-install-archive",
}),
);
expect(fetchResult.release).toHaveBeenCalledTimes(1);
});
it("rejects declared archives above the download cap", async () => {
const fetchResult = okDownloadResponse("archive", {
headers: { "content-length": "12" },
});
fetchWithSsrFGuardMock.mockResolvedValue(fetchResult);
await withTempFile(async (filePath) => {
await expect(
downloadToFile("https://example.com/signal-cli.tgz", filePath, 5, 8),
).rejects.toThrow("declared 12");
await expect(fs.access(filePath)).rejects.toThrow();
});
expect(fetchResult.release).toHaveBeenCalledTimes(1);
});
it("aborts streamed archives above the download cap and removes partial files", async () => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(6));
controller.enqueue(new Uint8Array(6));
controller.close();
},
});
const fetchResult = okDownloadResponse(body);
fetchWithSsrFGuardMock.mockResolvedValue(fetchResult);
await withTempFile(async (filePath) => {
await expect(
downloadToFile("https://example.com/signal-cli.tgz", filePath, 5, 8),
).rejects.toThrow("8-byte download cap");
await expect(fs.access(filePath)).rejects.toThrow();
});
expect(fetchResult.release).toHaveBeenCalledTimes(1);
});
});
describe("installSignalCliFromRelease", () => {
it("bounds the release metadata request with an explicit timeout", async () => {
const fetchResult = okDownloadResponse(JSON.stringify({ tag_name: "v0.14.3", assets: [] }), {
headers: { "content-type": "application/json" },
});
fetchWithSsrFGuardMock.mockResolvedValue(fetchResult);
await expect(
installSignalCliFromRelease({ log: vi.fn() } as unknown as RuntimeEnv),
).resolves.toMatchObject({
ok: false,
error: "No compatible release asset found for this platform.",
});
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://api.github.com/repos/AsamK/signal-cli/releases/latest",
requireHttps: true,
timeoutMs: 30_000,
auditContext: "signal-cli-release-info",
}),
);
expect(fetchResult.release).toHaveBeenCalledTimes(1);
});
});
describe("extractSignalCliArchive", () => {
async function withArchiveWorkspace(run: (workDir: string) => Promise<void>) {
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-install-"));

View File

@@ -27,6 +27,8 @@ type ReleaseResponse = {
};
const MAX_SIGNAL_CLI_ARCHIVE_BYTES = 256 * 1024 * 1024;
const SIGNAL_CLI_DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
const SIGNAL_CLI_RELEASE_INFO_TIMEOUT_MS = 30_000;
export type SignalInstallResult = {
ok: boolean;
@@ -111,11 +113,19 @@ export function pickAsset(
return archives[0];
}
async function downloadToFile(url: string, dest: string, maxRedirects = 5): Promise<void> {
/** @internal Exported for testing. */
export async function downloadToFile(
url: string,
dest: string,
maxRedirects = 5,
maxBytes = MAX_SIGNAL_CLI_ARCHIVE_BYTES,
): Promise<void> {
let completed = false;
const { response, release } = await fetchWithSsrFGuard({
url,
maxRedirects,
requireHttps: true,
timeoutMs: SIGNAL_CLI_DOWNLOAD_TIMEOUT_MS,
capture: false,
auditContext: "signal-cli-install-archive",
});
@@ -124,14 +134,24 @@ async function downloadToFile(url: string, dest: string, maxRedirects = 5): Prom
throw new Error(`HTTP ${response.status || "?"} downloading file`);
}
const rawLength = response.headers.get("content-length");
if (rawLength !== null) {
const declaredLength = Number(rawLength);
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
throw new Error(
`signal-cli archive exceeds the ${maxBytes}-byte download cap (declared ${declaredLength}).`,
);
}
}
let totalBytes = 0;
const body = response.body;
const readable = isNodeReadableStream(body) ? body : Readable.fromWeb(body as never);
const limiter = new Transform({
transform(chunk: unknown, _encoding, callback) {
totalBytes += chunkByteLength(chunk);
if (totalBytes > MAX_SIGNAL_CLI_ARCHIVE_BYTES) {
callback(new Error("signal-cli archive exceeds 256 MiB limit"));
if (totalBytes > maxBytes) {
callback(new Error(`signal-cli archive exceeded the ${maxBytes}-byte download cap.`));
return;
}
callback(null, chunk);
@@ -140,8 +160,12 @@ async function downloadToFile(url: string, dest: string, maxRedirects = 5): Prom
const out = createWriteStream(dest);
await pipeline(readable, limiter, out);
completed = true;
} finally {
await release();
if (!completed) {
await fs.rm(dest, { force: true }).catch(() => undefined);
}
}
}
@@ -245,12 +269,16 @@ async function installSignalCliViaBrew(runtime: RuntimeEnv): Promise<SignalInsta
// Direct download install (used when an official native asset is available)
// ---------------------------------------------------------------------------
async function installSignalCliFromRelease(runtime: RuntimeEnv): Promise<SignalInstallResult> {
/** @internal Exported for testing. */
export async function installSignalCliFromRelease(
runtime: RuntimeEnv,
): Promise<SignalInstallResult> {
const apiUrl = "https://api.github.com/repos/AsamK/signal-cli/releases/latest";
const { response, release } = await fetchWithSsrFGuard({
url: apiUrl,
maxRedirects: 5,
requireHttps: true,
timeoutMs: SIGNAL_CLI_RELEASE_INFO_TIMEOUT_MS,
capture: false,
auditContext: "signal-cli-release-info",
init: {

View File

@@ -1,3 +1,4 @@
import { Buffer } from "node:buffer";
import { describe, expect, it, vi } from "vitest";
import {
config,
@@ -10,7 +11,7 @@ import {
installSignalToolResultTestHooks();
const { monitorSignalProvider } = await import("./monitor.js");
const { replyMock, sendMock, streamMock, upsertPairingRequestMock } =
const { replyMock, sendMock, streamMock, signalRpcRequestMock, upsertPairingRequestMock } =
getSignalToolResultTestMocks();
type MonitorSignalProviderOptions = Parameters<typeof monitorSignalProvider>[0];
@@ -109,9 +110,55 @@ describe("monitorSignalProvider tool results", () => {
await monitorPromise;
expect(streamMock).toHaveBeenCalledTimes(2);
expect(streamMock.mock.calls[0]?.[0]).toMatchObject({ timeoutMs: 0 });
expect(streamMock.mock.calls[1]?.[0]).toMatchObject({ timeoutMs: 0 });
} finally {
randomSpy.mockRestore();
vi.useRealTimers();
}
});
it("sizes attachment RPC response caps from mediaMaxMb", async () => {
const abortController = new AbortController();
const maxBytes = 2 * 1024 * 1024;
const expectedMaxResponseBytes = Math.ceil((maxBytes * 4) / 3) + 64 * 1024;
replyMock.mockResolvedValue({ text: "ok" });
signalRpcRequestMock.mockResolvedValue({ data: Buffer.from("hello").toString("base64") });
streamMock.mockImplementation(async ({ onEvent }) => {
await onEvent({
event: "receive",
data: JSON.stringify({
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1,
dataMessage: {
message: "",
attachments: [{ id: "attachment-1", size: 1_500_000, contentType: "text/plain" }],
},
},
}),
});
abortController.abort();
});
await monitorSignalProvider({
autoStart: false,
baseUrl: "http://127.0.0.1:8080",
mediaMaxMb: 2,
abortSignal: abortController.signal,
});
await flush();
expect(signalRpcRequestMock).toHaveBeenCalledWith(
"getAttachment",
expect.objectContaining({ id: "attachment-1", recipient: "+15550001111" }),
expect.objectContaining({
baseUrl: "http://127.0.0.1:8080",
maxResponseBytes: expectedMaxResponseBytes,
}),
);
});
});

View File

@@ -255,6 +255,20 @@ async function waitForSignalDaemonReady(params: {
});
}
const SIGNAL_ATTACHMENT_RPC_RESPONSE_HEADROOM_BYTES = 64 * 1024;
const SIGNAL_BASE64_OVERHEAD_NUMERATOR = 4;
const SIGNAL_BASE64_OVERHEAD_DENOMINATOR = 3;
function deriveSignalAttachmentRpcMaxResponseBytes(maxBytes: number): number | undefined {
if (!Number.isFinite(maxBytes) || maxBytes <= 0) {
return undefined;
}
const base64Bytes = Math.ceil(
(maxBytes * SIGNAL_BASE64_OVERHEAD_NUMERATOR) / SIGNAL_BASE64_OVERHEAD_DENOMINATOR,
);
return base64Bytes + SIGNAL_ATTACHMENT_RPC_RESPONSE_HEADROOM_BYTES;
}
async function fetchAttachment(params: {
baseUrl: string;
account?: string;
@@ -288,6 +302,7 @@ async function fetchAttachment(params: {
const result = await signalRpcRequest<{ data?: string }>("getAttachment", rpcParams, {
baseUrl: params.baseUrl,
maxResponseBytes: deriveSignalAttachmentRpcMaxResponseBytes(params.maxBytes),
});
if (!result?.data) {
return null;
@@ -489,6 +504,8 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
account,
abortSignal: daemonLifecycle.abortSignal,
runtime,
// signal-cli can keep the SSE event endpoint idle until the next inbound event.
timeoutMs: 0,
policy: opts.reconnectPolicy,
onEvent: (event) => {
void handleEvent(event).catch((err) => {

View File

@@ -21,6 +21,7 @@ type RunSignalSseLoopParams = {
abortSignal?: AbortSignal;
runtime: RuntimeEnv;
onEvent: (event: SignalSseEvent) => void;
timeoutMs?: number;
policy?: Partial<BackoffPolicy>;
};
@@ -30,6 +31,7 @@ export async function runSignalSseLoop({
abortSignal,
runtime,
onEvent,
timeoutMs,
policy,
}: RunSignalSseLoopParams) {
const reconnectPolicy = {
@@ -54,6 +56,7 @@ export async function runSignalSseLoop({
baseUrl,
account,
abortSignal,
timeoutMs,
onEvent: (event) => {
reconnectAttempts = 0;
onEvent(event);

View File

@@ -1,8 +1,11 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { readStoreAllowFromForDmPolicy } from "openclaw/plugin-sdk/security-runtime";
import {
allowListMatches,
normalizeAllowList,
normalizeAllowListLower,
normalizeSlackAllowOwnerEntry,
resolveSlackAllowListMatch,
resolveSlackUserAllowed,
} from "./allow-list.js";
@@ -24,8 +27,20 @@ type SlackAllowFromCacheState = {
pairingPending?: Promise<ResolvedAllowFromLists>;
};
type SlackChannelMembersCacheEntry = {
expiresAtMs: number;
members?: Set<string>;
pending?: Promise<Set<string>>;
};
let slackAllowFromCache = new WeakMap<SlackMonitorContext, SlackAllowFromCacheState>();
let slackChannelMembersCache = new WeakMap<
SlackMonitorContext,
Map<string, SlackChannelMembersCacheEntry>
>();
const DEFAULT_PAIRING_ALLOW_FROM_CACHE_TTL_MS = 5000;
const DEFAULT_CHANNEL_MEMBERS_CACHE_TTL_MS = 60_000;
const CHANNEL_MEMBERS_CACHE_MAX = 512;
function getPairingAllowFromCacheTtlMs(): number {
const raw = process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS?.trim();
@@ -39,6 +54,18 @@ function getPairingAllowFromCacheTtlMs(): number {
return Math.max(0, Math.floor(parsed));
}
function getChannelMembersCacheTtlMs(): number {
const raw = process.env.OPENCLAW_SLACK_CHANNEL_MEMBERS_CACHE_TTL_MS?.trim();
if (!raw) {
return DEFAULT_CHANNEL_MEMBERS_CACHE_TTL_MS;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
return DEFAULT_CHANNEL_MEMBERS_CACHE_TTL_MS;
}
return Math.max(0, Math.floor(parsed));
}
function getAllowFromCacheState(ctx: SlackMonitorContext): SlackAllowFromCacheState {
const existing = slackAllowFromCache.get(ctx);
if (existing) {
@@ -49,6 +76,28 @@ function getAllowFromCacheState(ctx: SlackMonitorContext): SlackAllowFromCacheSt
return next;
}
function getChannelMembersCache(
ctx: SlackMonitorContext,
): Map<string, SlackChannelMembersCacheEntry> {
const existing = slackChannelMembersCache.get(ctx);
if (existing) {
return existing;
}
const next = new Map<string, SlackChannelMembersCacheEntry>();
slackChannelMembersCache.set(ctx, next);
return next;
}
function pruneChannelMembersCache(cache: Map<string, SlackChannelMembersCacheEntry>): void {
while (cache.size > CHANNEL_MEMBERS_CACHE_MAX) {
const oldest = cache.keys().next();
if (oldest.done) {
return;
}
cache.delete(oldest.value);
}
}
function buildBaseAllowFrom(ctx: SlackMonitorContext): ResolvedAllowFromLists {
const allowFrom = normalizeAllowList(ctx.allowFrom);
return {
@@ -131,6 +180,10 @@ export async function resolveSlackEffectiveAllowFrom(
export function clearSlackAllowFromCacheForTest(): void {
slackAllowFromCache = new WeakMap<SlackMonitorContext, SlackAllowFromCacheState>();
slackChannelMembersCache = new WeakMap<
SlackMonitorContext,
Map<string, SlackChannelMembersCacheEntry>
>();
}
export function isSlackSenderAllowListed(params: {
@@ -151,6 +204,128 @@ export function isSlackSenderAllowListed(params: {
);
}
async function fetchSlackChannelMemberIds(
ctx: SlackMonitorContext,
channelId: string,
): Promise<Set<string>> {
const members = new Set<string>();
let cursor: string | undefined;
do {
const response = await ctx.app.client.conversations.members({
token: ctx.botToken,
channel: channelId,
limit: 999,
...(cursor ? { cursor } : {}),
});
for (const member of normalizeAllowListLower(response.members)) {
members.add(member);
}
const nextCursor = response.response_metadata?.next_cursor?.trim();
cursor = nextCursor ? nextCursor : undefined;
} while (cursor);
return members;
}
async function resolveSlackChannelMemberIds(
ctx: SlackMonitorContext,
channelId: string,
): Promise<Set<string>> {
const cache = getChannelMembersCache(ctx);
const key = `${ctx.accountId}:${channelId}`;
const ttlMs = getChannelMembersCacheTtlMs();
const nowMs = Date.now();
const cached = cache.get(key);
if (ttlMs > 0 && cached?.members && cached.expiresAtMs >= nowMs) {
return cached.members;
}
if (cached?.pending) {
return await cached.pending;
}
const pending = fetchSlackChannelMemberIds(ctx, channelId);
cache.set(key, {
expiresAtMs: ttlMs > 0 ? nowMs + ttlMs : 0,
pending,
});
pruneChannelMembersCache(cache);
try {
const members = await pending;
if (ttlMs > 0) {
cache.set(key, {
expiresAtMs: Date.now() + ttlMs,
members,
});
pruneChannelMembersCache(cache);
} else {
cache.delete(key);
}
return members;
} finally {
const latest = cache.get(key);
if (latest?.pending === pending) {
cache.delete(key);
}
}
}
function resolveExplicitSlackOwnerIds(allowFromLower: string[]): string[] {
const ownerIds = new Set<string>();
for (const entry of allowFromLower) {
const ownerId = normalizeSlackAllowOwnerEntry(entry);
if (ownerId) {
ownerIds.add(ownerId);
}
}
return [...ownerIds];
}
export async function authorizeSlackBotRoomMessage(params: {
ctx: SlackMonitorContext;
channelId: string;
senderId: string;
senderName?: string;
channelUsers?: Array<string | number>;
allowFromLower: string[];
}): Promise<boolean> {
const channelUserAllowList = normalizeAllowListLower(params.channelUsers).filter(
(entry) => entry !== "*",
);
if (
channelUserAllowList.length > 0 &&
allowListMatches({
allowList: channelUserAllowList,
id: params.senderId,
name: params.senderName,
allowNameMatching: params.ctx.allowNameMatching,
})
) {
return true;
}
const explicitOwnerIds = resolveExplicitSlackOwnerIds(params.allowFromLower);
if (explicitOwnerIds.length === 0) {
logVerbose(
`slack: drop bot message ${params.senderId} in ${params.channelId} (no explicit owner id for presence check)`,
);
return false;
}
try {
const channelMemberIds = await resolveSlackChannelMemberIds(params.ctx, params.channelId);
if (explicitOwnerIds.some((ownerId) => channelMemberIds.has(ownerId))) {
return true;
}
logVerbose(
`slack: drop bot message ${params.senderId} in ${params.channelId} (no owner present)`,
);
} catch (error) {
logVerbose(
`slack: drop bot message ${params.senderId} in ${params.channelId} (owner presence lookup failed: ${formatErrorMessage(error)})`,
);
}
return false;
}
export type SlackSystemEventAuthResult = {
allowed: boolean;
reason?:

View File

@@ -17,6 +17,7 @@ import {
recordSlackThreadParticipation,
} from "../../sent-thread-cache.js";
import type { SlackMessageEvent } from "../../types.js";
import { clearSlackAllowFromCacheForTest } from "../auth.js";
import type { SlackMonitorContext } from "../context.js";
import { resetSlackThreadStarterCacheForTest } from "../thread.js";
import { resolveSlackMessageContent } from "./prepare-content.js";
@@ -37,6 +38,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
beforeEach(() => {
resetSlackThreadStarterCacheForTest();
clearSlackThreadParticipationCache();
clearSlackAllowFromCacheForTest();
});
afterAll(() => {
@@ -86,6 +88,37 @@ describe("slack prepareSlackMessage inbound contract", () => {
} as SlackMessageEvent;
}
function createBotRoomMessage(overrides: Partial<SlackMessageEvent> = {}): SlackMessageEvent {
return createSlackMessage({
channel: "C123",
channel_type: "channel",
user: undefined,
bot_id: "B0AGV8EQYA3",
subtype: "bot_message",
username: "deploy-bot",
text: "Readiness probe failed",
...overrides,
});
}
function createOwnerScopedBotRoomCtx(params: { members: string[] }) {
const members = vi.fn().mockResolvedValue({
members: params.members,
response_metadata: { next_cursor: "" },
});
const slackCtx = createInboundSlackCtx({
cfg: {
channels: {
slack: { enabled: true },
},
} as OpenClawConfig,
appClient: { conversations: { members } } as unknown as App["client"],
defaultRequireMention: false,
});
slackCtx.allowFrom = ["UOWNER"];
return { slackCtx, members };
}
async function prepareMessageWith(
ctx: SlackMonitorContext,
account: ResolvedSlackAccount,
@@ -424,6 +457,83 @@ describe("slack prepareSlackMessage inbound contract", () => {
expect(prepared!.ctxPayload.RawBody).toContain("Readiness probe failed");
});
it("drops bot-authored room messages when allowBots is true but no owner is present (#59284)", async () => {
const { slackCtx, members } = createOwnerScopedBotRoomCtx({ members: ["UOTHER"] });
const prepared = await prepareMessageWith(
slackCtx,
createSlackAccount({ allowBots: true }),
createBotRoomMessage(),
);
expect(prepared).toBeNull();
expect(members).toHaveBeenCalledWith(
expect.objectContaining({ token: "token", channel: "C123", limit: 999 }),
);
});
it("allows bot-authored room messages when an explicit owner is present (#59284)", async () => {
const { slackCtx, members } = createOwnerScopedBotRoomCtx({ members: ["UOWNER"] });
const prepared = await prepareMessageWith(
slackCtx,
createSlackAccount({ allowBots: true }),
createBotRoomMessage(),
);
expect(prepared).toBeTruthy();
expect(prepared!.ctxPayload.RawBody).toContain("Readiness probe failed");
expect(members).toHaveBeenCalledTimes(1);
});
it("allows bot-authored room messages when the bot is explicitly channel-allowlisted (#59284)", async () => {
const members = vi.fn();
const slackCtx = createInboundSlackCtx({
cfg: {
channels: {
slack: { enabled: true },
},
} as OpenClawConfig,
appClient: { conversations: { members } } as unknown as App["client"],
defaultRequireMention: false,
channelsConfig: {
C123: { users: ["B0AGV8EQYA3"] },
},
});
const prepared = await prepareMessageWith(
slackCtx,
createSlackAccount({ allowBots: true }),
createBotRoomMessage(),
);
expect(prepared).toBeTruthy();
expect(prepared!.ctxPayload.RawBody).toContain("Readiness probe failed");
expect(members).not.toHaveBeenCalled();
});
it("drops bot-authored room messages when owner presence lookup fails (#59284)", async () => {
const members = vi.fn().mockRejectedValue(new Error("missing_scope"));
const slackCtx = createInboundSlackCtx({
cfg: {
channels: {
slack: { enabled: true },
},
} as OpenClawConfig,
appClient: { conversations: { members } } as unknown as App["client"],
defaultRequireMention: false,
});
slackCtx.allowFrom = ["UOWNER"];
const prepared = await prepareMessageWith(
slackCtx,
createSlackAccount({ allowBots: true }),
createBotRoomMessage(),
);
expect(prepared).toBeNull();
});
it("keeps channel metadata out of GroupSystemPrompt", async () => {
const slackCtx = createInboundSlackCtx({
cfg: {

View File

@@ -41,7 +41,7 @@ import {
resolveSlackAllowListMatch,
resolveSlackUserAllowed,
} from "../allow-list.js";
import { resolveSlackEffectiveAllowFrom } from "../auth.js";
import { authorizeSlackBotRoomMessage, resolveSlackEffectiveAllowFrom } from "../auth.js";
import { resolveSlackChannelConfig } from "../channel-config.js";
import { stripSlackMentionsForCommandDetection } from "../commands.js";
import {
@@ -271,6 +271,7 @@ export async function prepareSlackMessage(params: {
isRoom,
isRoomish,
channelConfig,
allowBots,
isBotMessage,
} = conversation;
const authorization = await authorizeSlackInboundMessage({
@@ -394,6 +395,21 @@ export async function prepareSlackMessage(params: {
logVerbose(`Blocked unauthorized slack sender ${senderId} (not in channel users)`);
return null;
}
if (
isRoom &&
isBotMessage &&
allowBots &&
!(await authorizeSlackBotRoomMessage({
ctx,
channelId: message.channel,
senderId,
senderName: senderNameForAuth,
channelUsers: channelConfig?.users,
allowFromLower,
}))
) {
return null;
}
const allowTextCommands = shouldHandleTextCommands({
cfg,

View File

@@ -747,31 +747,6 @@ describe("dispatchTelegramMessage draft streaming", () => {
);
});
it("does not materialize native draft tool progress before final-only text", async () => {
const draftStream = createTestDraftStream({ previewMode: "draft" });
draftStream.materialize.mockResolvedValue(321);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await dispatcherOptions.deliver({ text: "Done" }, { kind: "final" });
return { queuedFinal: true };
},
);
await dispatchWithContext({ context: createContext(), streamMode: "partial" });
expect(draftStream.update).toHaveBeenCalledWith("Working…\n• `tool: exec`");
expect(draftStream.update).not.toHaveBeenCalledWith("Done");
expect(draftStream.materialize).not.toHaveBeenCalled();
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: "Done" })],
}),
);
expect(draftStream.clear).toHaveBeenCalledTimes(1);
});
it("suppresses Telegram tool progress when explicitly disabled", async () => {
const draftStream = createDraftStream();
createTelegramDraftStream.mockReturnValue(draftStream);
@@ -2463,13 +2438,11 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(createTelegramDraftStream.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
thread: { id: 777, scope: "dm" },
previewTransport: "message",
}),
);
expect(createTelegramDraftStream.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({
thread: { id: 777, scope: "dm" },
previewTransport: "message",
}),
);
});
@@ -2494,7 +2467,6 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(createTelegramDraftStream.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
thread: { id: 777, scope: "dm" },
previewTransport: "message",
}),
);
expect(answerDraftStream.materialize).not.toHaveBeenCalled();
@@ -2638,14 +2610,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
);
});
it("keeps DM draft reasoning block updates in preview flow without sending duplicates", async () => {
it("keeps DM reasoning block updates in preview flow without sending duplicates", async () => {
const answerDraftStream = createDraftStream(999);
let previewRevision = 0;
const reasoningDraftStream = {
update: vi.fn(),
flush: vi.fn().mockResolvedValue(true),
messageId: vi.fn().mockReturnValue(undefined),
previewMode: vi.fn().mockReturnValue("draft"),
messageId: vi.fn().mockReturnValue(111),
previewRevision: vi.fn().mockImplementation(() => previewRevision),
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
@@ -2680,10 +2651,16 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" });
expect(editMessageTelegram).toHaveBeenCalledWith(123, 999, "3", expect.any(Object));
expect(reasoningDraftStream.update).toHaveBeenCalledWith(
expect(editMessageTelegram).toHaveBeenCalledWith(
123,
111,
"Reasoning:\nI am counting letters. The total is 3.",
expect.any(Object),
);
expect(reasoningDraftStream.flush).toHaveBeenCalled();
expect(reasoningDraftStream.update).toHaveBeenCalledWith(
"Reasoning:\nI am counting letters...",
);
expect(reasoningDraftStream.flush).not.toHaveBeenCalled();
expect(deliverReplies).not.toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: expect.stringContaining("Reasoning:\nI am") })],
@@ -2691,14 +2668,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
);
});
it("falls back to normal send when DM draft reasoning flush emits no preview update", async () => {
it("falls back to normal send when DM reasoning preview has no message id", async () => {
const answerDraftStream = createDraftStream(999);
const previewRevision = 0;
const reasoningDraftStream = {
update: vi.fn(),
flush: vi.fn().mockResolvedValue(false),
messageId: vi.fn().mockReturnValue(undefined),
previewMode: vi.fn().mockReturnValue("draft"),
previewRevision: vi.fn().mockReturnValue(previewRevision),
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
@@ -2722,7 +2698,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" });
expect(reasoningDraftStream.flush).toHaveBeenCalled();
expect(reasoningDraftStream.flush).not.toHaveBeenCalled();
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: "Reasoning:\n_step one expanded_" })],

View File

@@ -409,8 +409,6 @@ export const dispatchTelegramMessage = async ({
? (replyQuoteMessageId ?? msg.message_id)
: undefined;
const draftMinInitialChars = DRAFT_MIN_INITIAL_CHARS;
// DM draft previews still duplicate briefly at materialize time.
const useMessagePreviewTransportForDm = threadSpec?.scope === "dm" && canStreamAnswerDraft;
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
const archivedAnswerPreviews: ArchivedPreview[] = [];
const archivedReasoningPreviewIds: number[] = [];
@@ -421,7 +419,6 @@ export const dispatchTelegramMessage = async ({
chatId,
maxChars: draftMaxChars,
thread: threadSpec,
previewTransport: useMessagePreviewTransportForDm ? "message" : "auto",
replyToMessageId: draftReplyToMessageId,
minInitialChars: draftMinInitialChars,
renderText: renderDraftPreview,

View File

@@ -271,7 +271,6 @@ const grammySpies = vi.hoisted(() => ({
sendChatActionSpy: vi.fn(),
editMessageTextSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock,
editMessageReplyMarkupSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock,
sendMessageDraftSpy: vi.fn(async () => true) as AnyAsyncMock,
setMessageReactionSpy: vi.fn(async () => undefined) as AnyAsyncMock,
setMyCommandsSpy: vi.fn(async () => undefined) as AnyAsyncMock,
getMeSpy: vi.fn(async () => ({
@@ -297,7 +296,6 @@ export const answerCallbackQuerySpy: AnyAsyncMock = grammySpies.answerCallbackQu
export const sendChatActionSpy: AnyMock = grammySpies.sendChatActionSpy;
export const editMessageTextSpy: AnyAsyncMock = grammySpies.editMessageTextSpy;
export const editMessageReplyMarkupSpy: AnyAsyncMock = grammySpies.editMessageReplyMarkupSpy;
export const sendMessageDraftSpy: AnyAsyncMock = grammySpies.sendMessageDraftSpy;
export const setMessageReactionSpy: AnyAsyncMock = grammySpies.setMessageReactionSpy;
export const setMyCommandsSpy: AnyAsyncMock = grammySpies.setMyCommandsSpy;
export const getMeSpy: AnyAsyncMock = grammySpies.getMeSpy;
@@ -327,7 +325,6 @@ export const telegramBotRuntimeForTest: TelegramBotRuntimeForTest = {
sendChatAction: grammySpies.sendChatActionSpy,
editMessageText: grammySpies.editMessageTextSpy,
editMessageReplyMarkup: grammySpies.editMessageReplyMarkupSpy,
sendMessageDraft: grammySpies.sendMessageDraftSpy,
setMessageReaction: grammySpies.setMessageReactionSpy,
setMyCommands: grammySpies.setMyCommandsSpy,
getMe: grammySpies.getMeSpy,
@@ -521,8 +518,6 @@ beforeEach(() => {
editMessageTextSpy.mockResolvedValue({ message_id: 88 });
editMessageReplyMarkupSpy.mockReset();
editMessageReplyMarkupSpy.mockResolvedValue({ message_id: 88 });
sendMessageDraftSpy.mockReset();
sendMessageDraftSpy.mockResolvedValue(true);
enqueueSystemEventSpy.mockReset();
wasSentByBot.mockReset();
wasSentByBot.mockReturnValue(false);

View File

@@ -1,13 +1,10 @@
import { vi } from "vitest";
type DraftPreviewMode = "message" | "draft";
export type TestDraftStream = {
update: ReturnType<typeof vi.fn<(text: string) => void>>;
flush: ReturnType<typeof vi.fn<() => Promise<void>>>;
messageId: ReturnType<typeof vi.fn<() => number | undefined>>;
visibleSinceMs: ReturnType<typeof vi.fn<() => number | undefined>>;
previewMode: ReturnType<typeof vi.fn<() => DraftPreviewMode>>;
previewRevision: ReturnType<typeof vi.fn<() => number>>;
lastDeliveredText: ReturnType<typeof vi.fn<() => string>>;
clear: ReturnType<typeof vi.fn<() => Promise<void>>>;
@@ -21,7 +18,6 @@ export type TestDraftStream = {
export function createTestDraftStream(params?: {
messageId?: number;
previewMode?: DraftPreviewMode;
onUpdate?: (text: string) => void;
onStop?: () => void | Promise<void>;
onDiscard?: () => void | Promise<void>;
@@ -41,7 +37,6 @@ export function createTestDraftStream(params?: {
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => messageId),
visibleSinceMs: vi.fn().mockImplementation(() => visibleSinceMs),
previewMode: vi.fn().mockReturnValue(params?.previewMode ?? "message"),
previewRevision: vi.fn().mockImplementation(() => previewRevision),
lastDeliveredText: vi.fn().mockImplementation(() => lastDeliveredText),
clear: vi.fn().mockResolvedValue(undefined),
@@ -84,7 +79,6 @@ export function createSequencedTestDraftStream(startMessageId = 1001): TestDraft
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => activeMessageId),
visibleSinceMs: vi.fn().mockImplementation(() => visibleSinceMs),
previewMode: vi.fn().mockReturnValue("message"),
previewRevision: vi.fn().mockImplementation(() => previewRevision),
lastDeliveredText: vi.fn().mockImplementation(() => lastDeliveredText),
clear: vi.fn().mockResolvedValue(undefined),

View File

@@ -1,14 +1,12 @@
import type { Bot } from "grammy";
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __testing, createTelegramDraftStream } from "./draft-stream.js";
import { createTelegramDraftStream } from "./draft-stream.js";
type TelegramDraftStreamParams = Parameters<typeof createTelegramDraftStream>[0];
function createMockDraftApi(sendMessageImpl?: () => Promise<{ message_id: number }>) {
return {
sendMessage: vi.fn(sendMessageImpl ?? (async () => ({ message_id: 17 }))),
sendMessageDraft: vi.fn().mockResolvedValue(true),
editMessageText: vi.fn().mockResolvedValue(true),
deleteMessage: vi.fn().mockResolvedValue(true),
};
@@ -45,30 +43,6 @@ async function expectInitialForumSend(
);
}
function expectDmMessagePreviewViaSendMessage(
api: ReturnType<typeof createMockDraftApi>,
text = "Hello",
): void {
expect(api.sendMessage).toHaveBeenCalledWith(123, text, { message_thread_id: 42 });
expect(api.editMessageText).not.toHaveBeenCalled();
}
async function createDmDraftTransportStream(params: {
api?: ReturnType<typeof createMockDraftApi>;
previewTransport?: "draft" | "message";
warn?: (message: string) => void;
}) {
const api = params.api ?? createMockDraftApi();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: params.previewTransport ?? "draft",
...(params.warn ? { warn: params.warn } : {}),
});
stream.update("Hello");
await stream.flush();
return { api, stream };
}
function createForceNewMessageHarness(params: { throttleMs?: number } = {}) {
const api = createMockDraftApi();
api.sendMessage
@@ -82,10 +56,6 @@ function createForceNewMessageHarness(params: { throttleMs?: number } = {}) {
}
describe("createTelegramDraftStream", () => {
afterEach(() => {
__testing.resetTelegramDraftStreamForTests();
});
it("sends stream preview message with message_thread_id when provided", async () => {
const api = createMockDraftApi();
const stream = createForumDraftStream(api);
@@ -137,31 +107,20 @@ describe("createTelegramDraftStream", () => {
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledWith(123, "Hello", undefined));
});
it("uses sendMessageDraft for dm threads and does not create a preview message", async () => {
it("uses sendMessage/editMessageText for dm thread previews", async () => {
const api = createMockDraftApi();
const stream = createThreadedDraftStream(api, { id: 42, scope: "dm" });
stream.update("Hello");
await vi.waitFor(() =>
expect(api.sendMessageDraft).toHaveBeenCalledWith(123, expect.any(Number), "Hello", {
message_thread_id: 42,
}),
expect(api.sendMessage).toHaveBeenCalledWith(123, "Hello", { message_thread_id: 42 }),
);
expect(api.sendMessage).not.toHaveBeenCalled();
expect(api.editMessageText).not.toHaveBeenCalled();
await stream.clear();
expect(api.sendMessageDraft).toHaveBeenLastCalledWith(123, expect.any(Number), "", {
message_thread_id: 42,
});
expect(api.deleteMessage).not.toHaveBeenCalled();
});
stream.update("Hello again");
await stream.flush();
it("supports forcing message transport in dm threads", async () => {
const { api } = await createDmDraftTransportStream({ previewTransport: "message" });
expectDmMessagePreviewViaSendMessage(api);
expect(api.sendMessageDraft).not.toHaveBeenCalled();
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "Hello again");
});
it("tracks when a message preview first became visible", async () => {
@@ -169,7 +128,7 @@ describe("createTelegramDraftStream", () => {
try {
vi.setSystemTime(new Date("2026-04-26T01:00:00.000Z"));
const api = createMockDraftApi();
const stream = createDraftStream(api, { previewTransport: "message" });
const stream = createDraftStream(api);
stream.update("Hello");
await stream.flush();
@@ -186,41 +145,6 @@ describe("createTelegramDraftStream", () => {
}
});
it("falls back to message transport when sendMessageDraft is unavailable", async () => {
const api = createMockDraftApi();
delete (api as { sendMessageDraft?: unknown }).sendMessageDraft;
const warn = vi.fn();
await createDmDraftTransportStream({ api, warn });
expectDmMessagePreviewViaSendMessage(api);
expect(warn).toHaveBeenCalledWith(
"telegram stream preview: sendMessageDraft unavailable; falling back to sendMessage/editMessageText",
);
});
it("falls back to message transport when sendMessageDraft is rejected at runtime", async () => {
const api = createMockDraftApi();
api.sendMessageDraft.mockRejectedValueOnce(
new Error(
"Call to 'sendMessageDraft' failed! (400: Bad Request: method sendMessageDraft can be used only in private chats)",
),
);
const warn = vi.fn();
const { stream } = await createDmDraftTransportStream({ api, warn });
expect(api.sendMessageDraft).toHaveBeenCalledTimes(1);
expect(api.sendMessage).toHaveBeenCalledWith(123, "Hello", { message_thread_id: 42 });
expect(stream.previewMode?.()).toBe("message");
expect(warn).toHaveBeenCalledWith(
"telegram stream preview: sendMessageDraft rejected by API; falling back to sendMessage/editMessageText",
);
stream.update("Hello again");
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "Hello again");
});
it("retries DM message preview send without thread when thread is not found", async () => {
const api = createMockDraftApi();
api.sendMessage
@@ -229,7 +153,6 @@ describe("createTelegramDraftStream", () => {
const warn = vi.fn();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: "message",
warn,
});
@@ -247,7 +170,6 @@ describe("createTelegramDraftStream", () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: "message",
replyToMessageId: 411,
});
@@ -261,11 +183,10 @@ describe("createTelegramDraftStream", () => {
});
});
it("materializes draft previews using rendered HTML text", async () => {
it("materializes message previews using rendered HTML text", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: "draft",
renderText: (text) => ({
text: text.replace("**bold**", "<b>bold</b>"),
parseMode: "HTML",
@@ -274,68 +195,20 @@ describe("createTelegramDraftStream", () => {
stream.update("**bold**");
await stream.flush();
await stream.materialize?.();
const materializedId = await stream.materialize?.();
expect(materializedId).toBe(17);
expect(api.sendMessage).toHaveBeenCalledWith(123, "<b>bold</b>", {
message_thread_id: 42,
parse_mode: "HTML",
});
});
it("clears draft after materializing to avoid duplicate display in DM", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: "draft",
});
stream.update("Hello");
await stream.flush();
const materializedId = await stream.materialize?.();
expect(materializedId).toBe(17);
expect(api.sendMessage).toHaveBeenCalledWith(123, "Hello", { message_thread_id: 42 });
// Draft should be cleared with empty string after real message is sent.
const draftCalls = api.sendMessageDraft.mock.calls;
const clearCall = draftCalls.find((call) => call[2] === "");
expect(clearCall).toBeDefined();
expect(clearCall?.[0]).toBe(123);
expect(clearCall?.[3]).toEqual({ message_thread_id: 42 });
});
it("retries materialize send without thread when dm thread lookup fails", async () => {
const api = createMockDraftApi();
api.sendMessage
.mockRejectedValueOnce(new Error("400: Bad Request: message thread not found"))
.mockResolvedValueOnce({ message_id: 55 });
const warn = vi.fn();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: "draft",
warn,
});
stream.update("Hello");
await stream.flush();
const materializedId = await stream.materialize?.();
expect(materializedId).toBe(55);
expect(api.sendMessage).toHaveBeenNthCalledWith(1, 123, "Hello", { message_thread_id: 42 });
expect(api.sendMessage).toHaveBeenNthCalledWith(2, 123, "Hello", undefined);
const draftCalls = api.sendMessageDraft.mock.calls;
const clearCall = draftCalls.find((call) => call[2] === "");
expect(clearCall).toBeDefined();
expect(clearCall?.[3]).toBeUndefined();
expect(warn).toHaveBeenCalledWith(
"telegram stream preview materialize send failed with message_thread_id, retrying without thread",
);
expect(api.sendMessage).toHaveBeenCalledTimes(1);
});
it("returns existing preview id when materializing message transport", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: "message",
});
stream.update("Hello");
@@ -346,7 +219,7 @@ describe("createTelegramDraftStream", () => {
expect(api.sendMessage).toHaveBeenCalledTimes(1);
});
it("does not edit or delete messages after DM draft stream finalization", async () => {
it("deletes message preview on clear after finalization", async () => {
const api = createMockDraftApi();
const stream = createThreadedDraftStream(api, { id: 42, scope: "dm" });
@@ -356,86 +229,9 @@ describe("createTelegramDraftStream", () => {
await stream.stop();
await stream.clear();
expect(api.sendMessageDraft).toHaveBeenCalled();
expect(api.sendMessage).not.toHaveBeenCalled();
expect(api.editMessageText).not.toHaveBeenCalled();
expect(api.deleteMessage).not.toHaveBeenCalled();
});
it("rotates draft_id when forceNewMessage races an in-flight DM draft send", async () => {
let resolveFirstDraft: ((value: boolean) => void) | undefined;
const firstDraftSend = new Promise<boolean>((resolve) => {
resolveFirstDraft = resolve;
});
const api = {
sendMessageDraft: vi.fn().mockReturnValueOnce(firstDraftSend).mockResolvedValueOnce(true),
sendMessage: vi.fn().mockResolvedValue({ message_id: 17 }),
editMessageText: vi.fn().mockResolvedValue(true),
deleteMessage: vi.fn().mockResolvedValue(true),
};
const stream = createThreadedDraftStream(
api as unknown as ReturnType<typeof createMockDraftApi>,
{ id: 42, scope: "dm" },
);
stream.update("Message A");
await vi.waitFor(() => expect(api.sendMessageDraft).toHaveBeenCalledTimes(1));
stream.forceNewMessage();
stream.update("Message B");
resolveFirstDraft?.(true);
await stream.flush();
expect(api.sendMessageDraft).toHaveBeenCalledTimes(2);
const firstDraftId = api.sendMessageDraft.mock.calls[0]?.[1];
const secondDraftId = api.sendMessageDraft.mock.calls[1]?.[1];
expect(typeof firstDraftId).toBe("number");
expect(typeof secondDraftId).toBe("number");
expect(firstDraftId).not.toBe(secondDraftId);
expect(api.sendMessageDraft.mock.calls[1]?.[2]).toBe("Message B");
expect(api.sendMessage).not.toHaveBeenCalled();
expect(api.editMessageText).not.toHaveBeenCalled();
});
it("shares draft-id allocation across distinct module instances", async () => {
const draftA = await importFreshModule<typeof import("./draft-stream.js")>(
import.meta.url,
"./draft-stream.js?scope=shared-a",
);
const draftB = await importFreshModule<typeof import("./draft-stream.js")>(
import.meta.url,
"./draft-stream.js?scope=shared-b",
);
const apiA = createMockDraftApi();
const apiB = createMockDraftApi();
draftA.__testing.resetTelegramDraftStreamForTests();
try {
const streamA = draftA.createTelegramDraftStream({
api: apiA as unknown as Bot["api"],
chatId: 123,
thread: { id: 42, scope: "dm" },
previewTransport: "draft",
});
const streamB = draftB.createTelegramDraftStream({
api: apiB as unknown as Bot["api"],
chatId: 123,
thread: { id: 42, scope: "dm" },
previewTransport: "draft",
});
streamA.update("Message A");
await streamA.flush();
streamB.update("Message B");
await streamB.flush();
expect(apiA.sendMessageDraft.mock.calls[0]?.[1]).toBe(1);
expect(apiB.sendMessageDraft.mock.calls[0]?.[1]).toBe(2);
} finally {
draftA.__testing.resetTelegramDraftStreamForTests();
}
expect(api.sendMessage).toHaveBeenCalledWith(123, "Hello", { message_thread_id: 42 });
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "Hello again");
expect(api.deleteMessage).toHaveBeenCalledWith(123, 17);
});
it("creates new message after forceNewMessage is called", async () => {

View File

@@ -10,21 +10,7 @@ import { normalizeTelegramReplyToMessageId } from "./outbound-params.js";
const TELEGRAM_STREAM_MAX_CHARS = 4096;
const DEFAULT_THROTTLE_MS = 1000;
const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
const THREAD_NOT_FOUND_RE = /400:\s*Bad Request:\s*message thread not found/i;
const DRAFT_METHOD_UNAVAILABLE_RE =
/(unknown method|method .*not (found|available|supported)|unsupported)/i;
const DRAFT_CHAT_UNSUPPORTED_RE = /(can't be used|can be used only)/i;
type TelegramSendMessageDraft = (
chatId: Parameters<Bot["api"]["sendMessage"]>[0],
draftId: number,
text: string,
params?: {
message_thread_id?: number;
parse_mode?: "HTML";
},
) => Promise<unknown>;
type TelegramSendMessageParams = Parameters<Bot["api"]["sendMessage"]>[2];
@@ -38,71 +24,18 @@ function hasNumericMessageThreadId(
);
}
/**
* Keep draft-id allocation shared across bundled chunks so concurrent preview
* lanes do not accidentally reuse draft ids when code-split entries coexist.
*/
const TELEGRAM_DRAFT_STREAM_STATE_KEY = Symbol.for("openclaw.telegramDraftStreamState");
let draftStreamState: { nextDraftId: number } | undefined;
function getDraftStreamState(): { nextDraftId: number } {
if (!draftStreamState) {
const globalStore = globalThis as Record<PropertyKey, unknown>;
draftStreamState = (globalStore[TELEGRAM_DRAFT_STREAM_STATE_KEY] as
| { nextDraftId: number }
| undefined) ?? {
nextDraftId: 0,
};
globalStore[TELEGRAM_DRAFT_STREAM_STATE_KEY] = draftStreamState;
}
return draftStreamState;
}
function allocateTelegramDraftId(): number {
const state = getDraftStreamState();
state.nextDraftId = state.nextDraftId >= TELEGRAM_DRAFT_ID_MAX ? 1 : state.nextDraftId + 1;
return state.nextDraftId;
}
function resolveSendMessageDraftApi(api: Bot["api"]): TelegramSendMessageDraft | undefined {
const sendMessageDraft = (api as Bot["api"] & { sendMessageDraft?: TelegramSendMessageDraft })
.sendMessageDraft;
if (typeof sendMessageDraft !== "function") {
return undefined;
}
return sendMessageDraft.bind(api as object);
}
function shouldFallbackFromDraftTransport(err: unknown): boolean {
const text =
typeof err === "string"
? err
: err instanceof Error
? err.message
: typeof err === "object" && err && "description" in err
? typeof err.description === "string"
? err.description
: ""
: "";
if (!/sendMessageDraft/i.test(text)) {
return false;
}
return DRAFT_METHOD_UNAVAILABLE_RE.test(text) || DRAFT_CHAT_UNSUPPORTED_RE.test(text);
}
export type TelegramDraftStream = {
update: (text: string) => void;
flush: () => Promise<void>;
messageId: () => number | undefined;
visibleSinceMs?: () => number | undefined;
previewMode?: () => "message" | "draft";
previewRevision?: () => number;
lastDeliveredText?: () => string;
clear: () => Promise<void>;
stop: () => Promise<void>;
/** Stop without a final flush or delete. */
discard?: () => Promise<void>;
/** Convert the current draft preview into a permanent message (sendMessage). */
/** Return the current preview message id after pending updates settle. */
materialize?: () => Promise<number | undefined>;
/** Reset internal state so the next update creates a new message instead of editing. */
forceNewMessage: () => void;
@@ -127,7 +60,6 @@ export function createTelegramDraftStream(params: {
chatId: Parameters<Bot["api"]["sendMessage"]>[0];
maxChars?: number;
thread?: TelegramThreadSpec | null;
previewTransport?: "auto" | "message" | "draft";
replyToMessageId?: number;
throttleMs?: number;
/** Minimum chars before sending first message (debounce for push notifications) */
@@ -146,13 +78,6 @@ export function createTelegramDraftStream(params: {
const throttleMs = Math.max(250, params.throttleMs ?? DEFAULT_THROTTLE_MS);
const minInitialChars = params.minInitialChars;
const chatId = params.chatId;
const requestedPreviewTransport = params.previewTransport ?? "auto";
const prefersDraftTransport =
requestedPreviewTransport === "draft"
? true
: requestedPreviewTransport === "message"
? false
: params.thread?.scope === "dm";
const threadParams = buildTelegramThreadParams(params.thread);
const replyToMessageId = normalizeTelegramReplyToMessageId(params.replyToMessageId);
const replyParams =
@@ -163,22 +88,11 @@ export function createTelegramDraftStream(params: {
allow_sending_without_reply: true,
}
: threadParams;
const resolvedDraftApi = prefersDraftTransport
? resolveSendMessageDraftApi(params.api)
: undefined;
const usesDraftTransport = Boolean(prefersDraftTransport && resolvedDraftApi);
if (prefersDraftTransport && !usesDraftTransport) {
params.warn?.(
"telegram stream preview: sendMessageDraft unavailable; falling back to sendMessage/editMessageText",
);
}
const streamState = { stopped: false, final: false };
let messageSendAttempted = false;
let streamMessageId: number | undefined;
let streamVisibleSinceMs: number | undefined;
let streamDraftId = usesDraftTransport ? allocateTelegramDraftId() : undefined;
let previewTransport: "message" | "draft" = usesDraftTransport ? "draft" : "message";
let lastSentText = "";
let lastDeliveredText = "";
let lastSentParseMode: "HTML" | undefined;
@@ -275,26 +189,6 @@ export function createTelegramDraftStream(params: {
streamVisibleSinceMs = visibleSinceMs;
return true;
};
const sendDraftTransportPreview = async ({
renderedText,
renderedParseMode,
}: PreviewSendParams): Promise<boolean> => {
const draftId = streamDraftId ?? allocateTelegramDraftId();
streamDraftId = draftId;
const draftParams = {
...(threadParams?.message_thread_id != null
? { message_thread_id: threadParams.message_thread_id }
: {}),
...(renderedParseMode ? { parse_mode: renderedParseMode } : {}),
};
await resolvedDraftApi!(
chatId,
draftId,
renderedText,
Object.keys(draftParams).length > 0 ? draftParams : undefined,
);
return true;
};
const sendOrEditStreamMessage = async (text: string): Promise<boolean> => {
if (streamState.stopped && !streamState.final) {
@@ -331,36 +225,11 @@ export function createTelegramDraftStream(params: {
lastSentText = renderedText;
lastSentParseMode = renderedParseMode;
try {
let sent = false;
if (previewTransport === "draft") {
try {
sent = await sendDraftTransportPreview({
renderedText,
renderedParseMode,
sendGeneration,
});
} catch (err) {
if (!shouldFallbackFromDraftTransport(err)) {
throw err;
}
previewTransport = "message";
streamDraftId = undefined;
params.warn?.(
"telegram stream preview: sendMessageDraft rejected by API; falling back to sendMessage/editMessageText",
);
sent = await sendMessageTransportPreview({
renderedText,
renderedParseMode,
sendGeneration,
});
}
} else {
sent = await sendMessageTransportPreview({
renderedText,
renderedParseMode,
sendGeneration,
});
}
const sent = await sendMessageTransportPreview({
renderedText,
renderedParseMode,
sendGeneration,
});
if (sent) {
previewRevision += 1;
lastDeliveredText = trimmed;
@@ -396,16 +265,6 @@ export function createTelegramDraftStream(params: {
}
return;
}
if (previewTransport !== "draft" || resolvedDraftApi == null || streamDraftId == null) {
return;
}
const clearDraftId = streamDraftId;
streamDraftId = undefined;
try {
await resolvedDraftApi(chatId, clearDraftId, "", threadParams);
} catch (err) {
params.warn?.(`telegram stream preview cleanup failed: ${formatErrorMessage(err)}`);
}
};
const discard = async () => {
@@ -419,9 +278,6 @@ export function createTelegramDraftStream(params: {
messageSendAttempted = false;
streamMessageId = undefined;
streamVisibleSinceMs = undefined;
if (previewTransport === "draft") {
streamDraftId = allocateTelegramDraftId();
}
lastSentText = "";
lastSentParseMode = undefined;
loop.resetPending();
@@ -430,41 +286,7 @@ export function createTelegramDraftStream(params: {
const materialize = async (): Promise<number | undefined> => {
await stop();
if (previewTransport === "message" && typeof streamMessageId === "number") {
return streamMessageId;
}
const renderedText = lastSentText || lastDeliveredText;
if (!renderedText) {
return undefined;
}
const renderedParseMode = lastSentText ? lastSentParseMode : undefined;
try {
const { sent, usedThreadParams } = await sendRenderedMessageWithThreadFallback({
renderedText,
renderedParseMode,
fallbackWarnMessage:
"telegram stream preview materialize send failed with message_thread_id, retrying without thread",
});
const sentId = sent?.message_id;
if (typeof sentId === "number" && Number.isFinite(sentId)) {
streamMessageId = Math.trunc(sentId);
streamVisibleSinceMs = Date.now();
if (resolvedDraftApi != null && streamDraftId != null) {
const clearDraftId = streamDraftId;
const clearThreadParams =
usedThreadParams && threadParams?.message_thread_id != null
? { message_thread_id: threadParams.message_thread_id }
: undefined;
try {
await resolvedDraftApi(chatId, clearDraftId, "", clearThreadParams);
} catch {}
}
return streamMessageId;
}
} catch (err) {
params.warn?.(`telegram stream preview materialize failed: ${formatErrorMessage(err)}`);
}
return undefined;
return streamMessageId;
};
params.log?.(`telegram stream preview ready (maxChars=${maxChars}, throttleMs=${throttleMs})`);
@@ -474,7 +296,6 @@ export function createTelegramDraftStream(params: {
flush: loop.flush,
messageId: () => streamMessageId,
visibleSinceMs: () => streamVisibleSinceMs,
previewMode: () => previewTransport,
previewRevision: () => previewRevision,
lastDeliveredText: () => lastDeliveredText,
clear,
@@ -485,9 +306,3 @@ export function createTelegramDraftStream(params: {
sendMayHaveLanded: () => messageSendAttempted && typeof streamMessageId !== "number",
};
}
export const __testing = {
resetTelegramDraftStreamForTests() {
getDraftStreamState().nextDraftId = 0;
},
};

View File

@@ -203,8 +203,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
params.activePreviewLifecycleByLane[laneName] = "complete";
params.retainPreviewOnCleanupByLane[laneName] = true;
};
const isDraftPreviewLane = (lane: DraftLaneState) => lane.stream?.previewMode?.() === "draft";
const isMessagePreviewLane = (lane: DraftLaneState) => !isDraftPreviewLane(lane);
const isMessagePreviewLane = (lane: DraftLaneState) => lane.stream != null;
const shouldUseFreshFinalForLane = (lane: DraftLaneState) =>
isMessagePreviewLane(lane) && isLongLivedPreview(lane.stream?.visibleSinceMs?.(), readNow());
const shouldUseFreshFinalForPreview = (lane: DraftLaneState, visibleSinceMs?: number) =>
@@ -219,43 +218,6 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
lane.hasStreamedMessage = false;
lane.stream?.forceNewMessage();
};
const canMaterializeDraftFinal = (
lane: DraftLaneState,
previewButtons?: TelegramInlineButtons,
) => {
const hasPreviewButtons = Boolean(previewButtons && previewButtons.length > 0);
return (
lane.hasStreamedMessage &&
isDraftPreviewLane(lane) &&
!hasPreviewButtons &&
typeof lane.stream?.materialize === "function"
);
};
const tryMaterializeDraftPreviewForFinal = async (args: {
lane: DraftLaneState;
laneName: LaneName;
text: string;
}): Promise<number | undefined> => {
const stream = args.lane.stream;
if (!stream || !isDraftPreviewLane(args.lane)) {
return undefined;
}
// Draft previews have no message_id to edit; materialize the final text
// into a real message and treat that as the finalized delivery.
stream.update(args.text);
const materializedMessageId = await stream.materialize?.();
if (typeof materializedMessageId !== "number") {
params.log(
`telegram: ${args.laneName} draft preview materialize produced no message id; falling back to standard send`,
);
return undefined;
}
args.lane.lastPartialText = args.text;
params.markDelivered();
return materializedMessageId;
};
const tryEditPreviewMessage = async (args: {
laneName: LaneName;
messageId: number;
@@ -578,20 +540,6 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
return archivedResultAfterFlush;
}
}
if (canMaterializeDraftFinal(lane, previewButtons)) {
const materializedMessageId = await tryMaterializeDraftPreviewForFinal({
lane,
laneName,
text,
});
if (typeof materializedMessageId === "number") {
markActivePreviewComplete(laneName);
return result("preview-finalized", {
content: text,
messageId: materializedMessageId,
});
}
}
if (shouldUseFreshFinalForLane(lane)) {
await params.stopDraftLane(lane);
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
@@ -639,24 +587,6 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
}
if (allowPreviewUpdateForNonFinal && canEditViaPreview) {
if (isDraftPreviewLane(lane)) {
// DM draft flow has no message_id to edit; updates are sent via sendMessageDraft.
// Only mark as updated when the draft flush actually emits an update.
const previewRevisionBeforeFlush = lane.stream?.previewRevision?.() ?? 0;
lane.stream?.update(text);
await params.flushDraftLane(lane);
const previewUpdated = (lane.stream?.previewRevision?.() ?? 0) > previewRevisionBeforeFlush;
if (!previewUpdated) {
params.log(
`telegram: ${laneName} draft preview update not emitted; falling back to standard send`,
);
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
return delivered ? result("sent") : result("skipped");
}
lane.lastPartialText = text;
params.markDelivered();
return result("preview-updated");
}
const updated = await tryUpdatePreviewForLane({
lane,
laneName,

View File

@@ -493,171 +493,6 @@ describe("createLaneTextDeliverer", () => {
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
});
it("materializes DM draft streaming final even when text is unchanged", async () => {
const answerStream = createTestDraftStream({ previewMode: "draft", messageId: 321 });
answerStream.materialize.mockResolvedValue(321);
answerStream.update.mockImplementation(() => {});
const harness = createHarness({
answerStream: answerStream as DraftLaneState["stream"],
answerHasStreamedMessage: true,
answerLastPartialText: "Hello final",
});
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Hello final",
payload: { text: "Hello final" },
infoKind: "final",
});
expect(expectPreviewFinalized(result)).toEqual({ content: "Hello final", messageId: 321 });
expect(harness.flushDraftLane).toHaveBeenCalled();
expect(answerStream.materialize).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).not.toHaveBeenCalled();
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
});
it("does not materialize a native draft for final-only text", async () => {
const answerStream = createTestDraftStream({ previewMode: "draft" });
answerStream.materialize.mockResolvedValue(321);
const harness = createHarness({
answerStream: answerStream as DraftLaneState["stream"],
answerHasStreamedMessage: false,
});
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Final only",
payload: { text: "Final only" },
infoKind: "final",
});
expect(result.kind).toBe("sent");
expect(answerStream.update).not.toHaveBeenCalled();
expect(answerStream.materialize).not.toHaveBeenCalled();
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: "Final only" }),
);
});
it("does not materialize native draft tool-progress preview before final-only text", async () => {
const answerStream = createTestDraftStream({ previewMode: "draft" });
answerStream.materialize.mockResolvedValue(321);
const harness = createHarness({
answerStream: answerStream as DraftLaneState["stream"],
answerHasStreamedMessage: false,
answerLastPartialText: "Working...\n- tool: exec",
});
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Final only",
payload: { text: "Final only" },
infoKind: "final",
});
expect(result.kind).toBe("sent");
expect(answerStream.update).not.toHaveBeenCalledWith("Final only");
expect(answerStream.materialize).not.toHaveBeenCalled();
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: "Final only" }),
);
});
it("materializes DM draft streaming final when revision changes", async () => {
let previewRevision = 3;
const answerStream = createTestDraftStream({ previewMode: "draft", messageId: 654 });
answerStream.materialize.mockResolvedValue(654);
answerStream.previewRevision.mockImplementation(() => previewRevision);
answerStream.update.mockImplementation(() => {});
answerStream.flush.mockImplementation(async () => {
previewRevision += 1;
});
const harness = createHarness({
answerStream: answerStream as DraftLaneState["stream"],
answerHasStreamedMessage: true,
answerLastPartialText: "Final answer",
});
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Final answer",
payload: { text: "Final answer" },
infoKind: "final",
});
expect(expectPreviewFinalized(result)).toEqual({ content: "Final answer", messageId: 654 });
expect(answerStream.materialize).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).not.toHaveBeenCalled();
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
});
it("falls back to normal send when draft materialize returns no message id", async () => {
const answerStream = createTestDraftStream({ previewMode: "draft" });
answerStream.materialize.mockResolvedValue(undefined);
const harness = createHarness({
answerStream: answerStream as DraftLaneState["stream"],
answerHasStreamedMessage: true,
answerLastPartialText: "Hello final",
});
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
expect(result.kind).toBe("sent");
expect(answerStream.materialize).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: HELLO_FINAL }),
);
expect(harness.log).toHaveBeenCalledWith(
expect.stringContaining("draft preview materialize produced no message id"),
);
});
it("does not use DM draft final shortcut for media payloads", async () => {
const answerStream = createTestDraftStream({ previewMode: "draft" });
const harness = createHarness({
answerStream: answerStream as DraftLaneState["stream"],
answerHasStreamedMessage: true,
answerLastPartialText: "Image incoming",
});
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Image incoming",
payload: { text: "Image incoming", mediaUrl: "file:///tmp/example.png" },
infoKind: "final",
});
expect(result.kind).toBe("sent");
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: "Image incoming", mediaUrl: "file:///tmp/example.png" }),
);
expect(harness.markDelivered).not.toHaveBeenCalled();
});
it("does not use DM draft final shortcut when inline buttons are present", async () => {
const answerStream = createTestDraftStream({ previewMode: "draft" });
const harness = createHarness({
answerStream: answerStream as DraftLaneState["stream"],
answerHasStreamedMessage: true,
answerLastPartialText: "Choose one",
});
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Choose one",
payload: { text: "Choose one" },
previewButtons: [[{ text: "OK", callback_data: "ok" }]],
infoKind: "final",
});
expect(result.kind).toBe("sent");
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: "Choose one" }),
);
expect(harness.markDelivered).not.toHaveBeenCalled();
});
// ── Duplicate message regression tests ──────────────────────────────────
// During final delivery, only ambiguous post-connect failures keep the
// preview. Definite non-delivery falls back to a real send.

View File

@@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.4.27",
"version": "2026.4.29-beta.2",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",

View File

@@ -29,9 +29,10 @@ entries = plugins.get("entries")
if isinstance(entries, dict):
entries.pop("feishu", None)
entries.pop("whatsapp", None)
entries.pop("openai", None)
allow = plugins.get("allow")
if isinstance(allow, list):
plugins["allow"] = [item for item in allow if item not in {"feishu", "whatsapp"}]
plugins["allow"] = [item for item in allow if item not in {"feishu", "whatsapp", "openai"}]
path.write_text(json.dumps(config, indent=2) + "\n")
PY
}
@@ -85,13 +86,13 @@ function Remove-FuturePluginEntries {
if (-not ($plugins -is [hashtable])) { return }
$entries = $plugins['entries']
if ($entries -is [hashtable]) {
foreach ($pluginId in @('feishu', 'whatsapp')) {
foreach ($pluginId in @('feishu', 'whatsapp', 'openai')) {
if ($entries.ContainsKey($pluginId)) { $entries.Remove($pluginId) }
}
}
$allow = $plugins['allow']
if ($allow -is [array]) {
$plugins['allow'] = @($allow | Where-Object { $_ -notin @('feishu', 'whatsapp') })
$plugins['allow'] = @($allow | Where-Object { $_ -notin @('feishu', 'whatsapp', 'openai') })
}
$config | ConvertTo-Json -Depth 100 | Set-Content -Path $configPath -Encoding UTF8
}
@@ -105,12 +106,32 @@ Remove-FuturePluginEntries
Stop-OpenClawGatewayProcesses
$env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'
Invoke-OpenClaw update --tag ${psSingleQuote(input.updateTarget)} --yes --json
if ($LASTEXITCODE -ne 0) { throw "openclaw update failed with exit code $LASTEXITCODE" }
$updateExit = $LASTEXITCODE
if ($updateExit -ne 0) {
"openclaw update exited with code $updateExit; verifying installed version before failing" | Out-Host
}
$version = Invoke-OpenClaw --version
$version
${windowsVersionCheck(input.expectedNeedle)}
Invoke-OpenClaw gateway restart
Invoke-OpenClaw gateway status --deep --require-rpc
function Wait-OpenClawGateway {
$deadline = (Get-Date).AddSeconds(180)
$attempt = 0
while ((Get-Date) -lt $deadline) {
Invoke-OpenClaw gateway status --deep --require-rpc --timeout 15000
if ($LASTEXITCODE -eq 0) { return }
$attempt += 1
if ($attempt -eq 4) {
Invoke-OpenClaw gateway start *>&1 | Out-Host
}
Start-Sleep -Seconds 5
}
throw "gateway did not become ready after update"
}
Invoke-OpenClaw gateway restart *>&1 | Out-Host
if ($LASTEXITCODE -ne 0) {
"gateway restart exited with code $LASTEXITCODE; probing readiness before failing" | Out-Host
}
Wait-OpenClawGateway
Invoke-OpenClaw models set ${psSingleQuote(input.auth.modelId)}
Invoke-OpenClaw config set agents.defaults.skipBootstrap true --strict-json
${windowsAgentWorkspaceScript("Parallels npm update smoke test assistant.")}
@@ -133,9 +154,10 @@ if (!plugins || typeof plugins !== "object") process.exit(0);
if (plugins.entries && typeof plugins.entries === "object") {
delete plugins.entries.feishu;
delete plugins.entries.whatsapp;
delete plugins.entries.openai;
}
if (Array.isArray(plugins.allow)) {
plugins.allow = plugins.allow.filter((id) => id !== "feishu" && id !== "whatsapp");
plugins.allow = plugins.allow.filter((id) => id !== "feishu" && id !== "whatsapp" && id !== "openai");
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
JS

View File

@@ -441,10 +441,11 @@ class NpmUpdateSmoke {
timeoutMs: number,
ctx: UpdateJobContext,
): Promise<void> {
const scriptPath = this.writePosixGuestScript(macosVm, "macos", script);
const macosExecArgs = this.resolveMacosUpdateExecArgs(ctx);
const status = await this.runStreamingToJobLog(
"prlctl",
["exec", macosVm, ...macosExecArgs, "/bin/bash", "-lc", script],
["exec", macosVm, ...macosExecArgs, "/bin/bash", scriptPath],
timeoutMs,
ctx,
);
@@ -688,9 +689,10 @@ Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath -Force -ErrorActio
timeoutMs: number,
ctx: UpdateJobContext,
): Promise<void> {
const scriptPath = this.writePosixGuestScript(this.linuxVm, "linux", script);
const status = await this.runStreamingToJobLog(
"prlctl",
["exec", this.linuxVm, "/usr/bin/env", "HOME=/root", "bash", "-lc", script],
["exec", this.linuxVm, "/usr/bin/env", "HOME=/root", "bash", scriptPath],
timeoutMs,
ctx,
);
@@ -699,6 +701,19 @@ Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath -Force -ErrorActio
}
}
private writePosixGuestScript(vmName: string, label: string, script: string): string {
const scriptPath = `/tmp/openclaw-parallels-npm-update-${label}-${process.pid}-${Date.now()}.sh`;
run("prlctl", ["exec", vmName, "/bin/dd", `of=${scriptPath}`, "bs=1048576"], {
input: script,
timeoutMs: 120_000,
});
run("prlctl", ["exec", vmName, "/bin/chmod", "700", scriptPath], {
check: false,
timeoutMs: 30_000,
});
return scriptPath;
}
private async runStreamingToJobLog(
command: string,
args: string[],

View File

@@ -666,7 +666,7 @@ if (!(Test-Path $scriptPath)) { throw "background script was not written" }`,
);
let launched = false;
let lastLaunchStatus = 0;
for (let attempt = 1; attempt <= 3; attempt++) {
for (let attempt = 1; attempt <= 5; attempt++) {
this.waitForGuestReady(120);
const launchLogPath = path.join(this.runDir, `${safeLabel}-launch-${attempt}.log`);
const launchStatus = await runStreaming(
@@ -675,17 +675,30 @@ if (!(Test-Path $scriptPath)) { throw "background script was not written" }`,
"exec",
this.options.vmName,
"--current-user",
"cmd.exe",
"/d",
"/s",
"/c",
`start "" /min powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "%TEMP%\\${fileBase}.ps1"`,
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(`${pathsScript}
Start-Process -FilePath powershell.exe -WindowStyle Hidden -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $scriptPath)
'started'`),
],
{ logPath: launchLogPath, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs(20_000) },
{ logPath: launchLogPath, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs(30_000) },
);
const launchLog = await readFile(launchLogPath, "utf8").catch(() => "");
this.log(launchLog);
if (launchStatus === 0 && launchLog.includes("started")) {
launched = true;
break;
}
if (launchStatus === 0 || launchStatus === 124) {
const materialized = this.waitForBackgroundMaterialized(pathsScript, 45_000);
if (!materialized) {
warn(`${label} launch retry ${attempt}: background log/done file did not materialize`);
lastLaunchStatus = launchStatus;
continue;
}
launched = true;
break;
}
@@ -754,6 +767,31 @@ Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath -Force -ErrorActio
throw new Error(`${label} timed out`);
}
private waitForBackgroundMaterialized(pathsScript: string, timeoutMs: number): boolean {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const result = this.guest.run(
[
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(`${pathsScript}
if ((Test-Path $logPath) -or (Test-Path $donePath)) {
'materialized'
}`),
],
{ check: false, timeoutMs: this.remainingPhaseTimeoutMs(15_000) },
);
if (result.stdout.includes("materialized")) {
return true;
}
run("sleep", ["2"], { quiet: true });
}
return false;
}
private runDevChannelUpdate(): void {
this.guestPowerShell(
`$ErrorActionPreference = 'Stop'

View File

@@ -31,7 +31,7 @@
"kind": "channel",
"openclaw": {
"channel": {
"id": "openclaw-plugin-yuanbao",
"id": "yuanbao",
"label": "Yuanbao",
"selectionLabel": "Yuanbao (元宝)",
"detailLabel": "Yuanbao",

View File

@@ -56,6 +56,7 @@ export function createWorkspaceBootstrapSmokeEnv(env, homeDir, overrides = {}) {
OPENCLAW_HOME: homeDir,
OPENCLAW_NO_ONBOARD: "1",
OPENCLAW_SUPPRESS_NOTES: "1",
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1",
AWS_EC2_METADATA_DISABLED: "true",
AWS_SHARED_CREDENTIALS_FILE: join(homeDir, ".aws", "credentials"),
@@ -135,8 +136,9 @@ export function runInstalledWorkspaceBootstrapSmoke(params) {
const workspaceDir = join(homeDir, ".openclaw", "workspace");
const missingFiles = collectMissingBootstrapWorkspaceFiles(workspaceDir);
if (missingFiles.length > 0) {
const outputDetails = combinedOutput.length > 0 ? `\nCommand output:\n${combinedOutput}` : "";
throw new Error(
`installed workspace bootstrap did not create required files in ${workspaceDir}: ${missingFiles.join(", ")}`,
`installed workspace bootstrap did not create required files in ${workspaceDir}: ${missingFiles.join(", ")}${outputDetails}`,
);
}
} finally {

View File

@@ -522,8 +522,12 @@ function runPackedTaskRegistryControlRuntimeSmoke(packageRoot: string): void {
if (!existsSync(runtimePath)) {
throw new Error("release-check: packed task-registry control runtime is missing.");
}
const runtimeImportExpression = [
`(0, Function)("specifier", "return " + "im" + "port(specifier)")`,
`(${JSON.stringify(pathToFileURL(runtimePath).href)})`,
].join("");
const source = `
const runtime = await import(${JSON.stringify(pathToFileURL(runtimePath).href)});
const runtime = await ${runtimeImportExpression};
if (typeof runtime.getAcpSessionManager !== "function") {
throw new Error("missing getAcpSessionManager export");
}

View File

@@ -33,6 +33,26 @@ vi.mock("../tts/tts.js", () => ({
}));
const mockGetGlobalHookRunner = vi.mocked(getGlobalHookRunner);
const hookRunnerGlobalStateKey = Symbol.for("openclaw.plugins.hook-runner-global-state");
type HookRunnerGlobalStateForTest = {
hookRunner: unknown;
registry: unknown;
};
function setHookRunnerForTest(hookRunner: unknown): void {
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
const globalStore = globalThis as Record<PropertyKey, unknown>;
const state = (globalStore[hookRunnerGlobalStateKey] as
| HookRunnerGlobalStateForTest
| undefined) ?? {
hookRunner: null,
registry: null,
};
state.hookRunner = hookRunner;
state.registry = null;
globalStore[hookRunnerGlobalStateKey] = state;
}
function createSessionFile(params?: { history?: Array<{ role: "user"; content: string }> }) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-hooks-"));
@@ -127,6 +147,7 @@ describe("runCliAgent reliability", () => {
afterEach(() => {
replyRunTesting.resetReplyRunRegistry();
mockGetGlobalHookRunner.mockReset();
setHookRunnerForTest(null);
vi.unstubAllEnvs();
});
@@ -217,7 +238,7 @@ describe("runCliAgent reliability", () => {
runLlmOutput: vi.fn(async () => undefined),
runAgentEnd: vi.fn(async () => undefined),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
setHookRunnerForTest(hookRunner);
supervisorSpawnMock.mockClear();
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({
@@ -472,7 +493,7 @@ describe("runCliAgent reliability", () => {
runLlmOutput: vi.fn(async () => undefined),
runAgentEnd: vi.fn(async () => undefined),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
setHookRunnerForTest(hookRunner);
const { dir, sessionFile } = createSessionFile();
supervisorSpawnMock.mockResolvedValueOnce(
@@ -572,7 +593,7 @@ describe("runCliAgent reliability", () => {
runLlmOutput: vi.fn(async () => undefined),
runAgentEnd: vi.fn(async () => undefined),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
setHookRunnerForTest(hookRunner);
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({
@@ -600,7 +621,7 @@ describe("runCliAgent reliability", () => {
runLlmOutput: vi.fn(async () => undefined),
runAgentEnd: vi.fn(async () => undefined),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
setHookRunnerForTest(hookRunner);
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({
@@ -644,7 +665,7 @@ describe("runCliAgent reliability", () => {
runLlmOutput: vi.fn(async () => undefined),
runAgentEnd: vi.fn(async () => undefined),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
setHookRunnerForTest(hookRunner);
const { dir, sessionFile } = createSessionFile({
history: Array.from({ length: MAX_CLI_SESSION_HISTORY_MESSAGES + 5 }, (_, index) => ({
role: "user" as const,
@@ -725,7 +746,7 @@ describe("runCliAgent reliability", () => {
runLlmOutput: vi.fn(async () => undefined),
runAgentEnd: vi.fn(async () => undefined),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
setHookRunnerForTest(hookRunner);
const historySpy = vi.spyOn(sessionHistoryModule, "loadCliSessionHistoryMessages");
supervisorSpawnMock.mockResolvedValueOnce(
@@ -791,7 +812,7 @@ describe("runCliAgent reliability", () => {
runBeforePromptBuild: vi.fn(async () => ({ prependContext: "hook context" })),
runBeforeAgentStart: vi.fn(async () => undefined),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
setHookRunnerForTest(hookRunner);
try {
const context = await prepareCliRunContext({

View File

@@ -266,7 +266,10 @@ function buildDynamicModel(
const template =
lower === "gpt-5.5-pro"
? findTemplate(params, "openai-codex", ["gpt-5.4", "gpt-5.4-pro", "gpt-5.3-codex"])
: lower === "gpt-5.4" || isLegacyGpt54Alias || lower === "gpt-5.4-pro"
: lower === "gpt-5.4" ||
isLegacyGpt54Alias ||
lower === "gpt-5.4-pro" ||
lower === "gpt-5.4-mini"
? findTemplate(params, "openai-codex", ["gpt-5.4", "gpt-5.3-codex", "gpt-5.2-codex"])
: lower === "gpt-5.3-codex-spark"
? findTemplate(params, "openai-codex", ["gpt-5.4", "gpt-5.3-codex", "gpt-5.2-codex"])
@@ -329,6 +332,22 @@ function buildDynamicModel(
fallback,
);
}
if (lower === "gpt-5.4-mini") {
return cloneTemplate(
template,
modelId,
{
provider: "openai-codex",
api: "openai-codex-responses",
baseUrl: OPENAI_CODEX_BASE_URL,
cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
contextWindow: 400_000,
contextTokens: 272_000,
maxTokens: 128_000,
},
fallback,
);
}
if (lower === "gpt-5.3-codex-spark") {
return cloneTemplate(
template,

View File

@@ -75,8 +75,14 @@ export function buildOpenAICodexForwardCompatExpectation(
: isGpt54Mini
? { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }
: OPENAI_CODEX_TEMPLATE_MODEL.cost,
contextWindow: isGpt54 ? 1_050_000 : isGpt55 ? 400_000 : isSpark ? 128_000 : 272000,
...(isGpt54 || isGpt55 ? { contextTokens: 272_000 } : {}),
contextWindow: isGpt54
? 1_050_000
: isGpt55 || isGpt54Mini
? 400_000
: isSpark
? 128_000
: 272000,
...(isGpt54 || isGpt55 || isGpt54Mini ? { contextTokens: 272_000 } : {}),
maxTokens: 128000,
};
}

View File

@@ -60,9 +60,6 @@ vi.mock("../model-suppression.js", () => {
) {
return true;
}
if (provider === "openai-codex" && id?.trim().toLowerCase() === "gpt-5.4-mini") {
return true;
}
return (
(provider === "qwen" || provider === "modelstudio") &&
id?.trim().toLowerCase() === "qwen3.6-plus" &&
@@ -78,9 +75,6 @@ vi.mock("../model-suppression.js", () => {
) {
return true;
}
if (provider === "openai-codex" && id?.trim().toLowerCase() === "gpt-5.4-mini") {
return true;
}
return false;
},
buildSuppressedBuiltInModelError: ({
@@ -99,9 +93,6 @@ vi.mock("../model-suppression.js", () => {
) {
return "Unknown model: qwen/qwen3.6-plus. qwen3.6-plus is not supported on the Qwen Coding Plan endpoint; use a Standard pay-as-you-go Qwen endpoint or choose qwen/qwen3.5-plus.";
}
if (provider === "openai-codex" && id?.trim().toLowerCase() === "gpt-5.4-mini") {
return "Unknown model: openai-codex/gpt-5.4-mini. gpt-5.4-mini is not supported by the OpenAI Codex OAuth route. Use openai/gpt-5.4-mini with an OpenAI API key or openai-codex/gpt-5.5 with Codex OAuth.";
}
if (
(provider === "openai" ||
provider === "azure-openai-responses" ||
@@ -369,7 +360,7 @@ describe("resolveModel", () => {
);
});
it("#74451: suppresses explicitly configured openai-codex/gpt-5.4-mini despite inline entry", () => {
it("#74451: resolves explicitly configured openai-codex/gpt-5.4-mini inline entries", () => {
const cfg = {
models: {
providers: {
@@ -391,10 +382,14 @@ describe("resolveModel", () => {
const result = resolveModelForTest("openai-codex", "gpt-5.4-mini", "/tmp/agent", cfg);
expect(result.model).toBeUndefined();
expect(result.error).toBe(
"Unknown model: openai-codex/gpt-5.4-mini. gpt-5.4-mini is not supported by the OpenAI Codex OAuth route. Use openai/gpt-5.4-mini with an OpenAI API key or openai-codex/gpt-5.5 with Codex OAuth.",
);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "openai-codex",
id: "gpt-5.4-mini",
api: "openai-codex-responses",
contextWindow: 400_000,
maxTokens: 128_000,
});
});
it("normalizes Google fallback baseUrls for custom providers", () => {
@@ -1542,15 +1537,17 @@ describe("resolveModel", () => {
});
});
it("does not build an openai-codex fallback for unsupported gpt-5.4-mini", () => {
it("builds an openai-codex fallback for gpt-5.4-mini", () => {
mockOpenAICodexTemplateModel(discoverModels);
const result = resolveModelForTest("openai-codex", "gpt-5.4-mini", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe(
"Unknown model: openai-codex/gpt-5.4-mini. gpt-5.4-mini is not supported by the OpenAI Codex OAuth route. Use openai/gpt-5.4-mini with an OpenAI API key or openai-codex/gpt-5.5 with Codex OAuth.",
);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
...buildOpenAICodexForwardCompatExpectation("gpt-5.4-mini"),
contextWindow: 400_000,
contextTokens: 272_000,
});
});
it("does not build an openai-codex fallback for removed gpt-5.3-codex-spark", () => {
@@ -1944,7 +1941,7 @@ describe("resolveModel", () => {
});
});
it("rejects stale discovered openai-codex gpt-5.4-mini rows", () => {
it("resolves discovered openai-codex gpt-5.4-mini rows", () => {
mockDiscoveredModel(discoverModels, {
provider: "openai-codex",
modelId: "gpt-5.4-mini",
@@ -1958,10 +1955,14 @@ describe("resolveModel", () => {
const result = resolveModelForTest("openai-codex", "gpt-5.4-mini", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe(
"Unknown model: openai-codex/gpt-5.4-mini. gpt-5.4-mini is not supported by the OpenAI Codex OAuth route. Use openai/gpt-5.4-mini with an OpenAI API key or openai-codex/gpt-5.5 with Codex OAuth.",
);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "openai-codex",
id: "gpt-5.4-mini",
name: "GPT-5.4 Mini",
contextWindow: 64_000,
input: ["text"],
});
});
it("rejects stale direct openai gpt-5.3-codex-spark discovery rows", () => {

View File

@@ -504,7 +504,7 @@ describe("resolveEffectiveToolPolicy", () => {
).toBeUndefined();
});
it("implicitly re-exposes exec and process when tools.exec is configured", () => {
it("does not implicitly re-expose exec when tools.exec is configured (#47487)", () => {
const cfg = {
tools: {
profile: "messaging",
@@ -512,10 +512,10 @@ describe("resolveEffectiveToolPolicy", () => {
},
} as OpenClawConfig;
const result = resolveEffectiveToolPolicy({ config: cfg });
expect(result.profileAlsoAllow).toEqual(["exec", "process"]);
expect(result.profileAlsoAllow).toBeUndefined();
});
it("implicitly re-exposes read, write, and edit when tools.fs is configured", () => {
it("does not implicitly re-expose fs tools when tools.fs is configured (#47487)", () => {
const cfg = {
tools: {
profile: "messaging",
@@ -523,10 +523,10 @@ describe("resolveEffectiveToolPolicy", () => {
},
} as OpenClawConfig;
const result = resolveEffectiveToolPolicy({ config: cfg });
expect(result.profileAlsoAllow).toEqual(["read", "write", "edit"]);
expect(result.profileAlsoAllow).toBeUndefined();
});
it("merges explicit alsoAllow with implicit tool-section exposure", () => {
it("explicit alsoAllow works without implicit widening (#47487)", () => {
const cfg = {
tools: {
profile: "messaging",
@@ -535,10 +535,10 @@ describe("resolveEffectiveToolPolicy", () => {
},
} as OpenClawConfig;
const result = resolveEffectiveToolPolicy({ config: cfg });
expect(result.profileAlsoAllow).toEqual(["web_search", "exec", "process"]);
expect(result.profileAlsoAllow).toEqual(["web_search"]);
});
it("uses agent tool sections when resolving implicit exposure", () => {
it("does not implicitly re-expose fs tools from agent tool sections (#47487)", () => {
const cfg = {
tools: {
profile: "messaging",
@@ -555,6 +555,41 @@ describe("resolveEffectiveToolPolicy", () => {
},
} as OpenClawConfig;
const result = resolveEffectiveToolPolicy({ config: cfg, agentId: "coder" });
expect(result.profileAlsoAllow).toEqual(["read", "write", "edit"]);
expect(result.profileAlsoAllow).toBeUndefined();
});
it("global tools.exec does not widen agent messaging profile (#47487)", () => {
const cfg = {
tools: {
exec: { security: "allowlist" },
},
agents: {
list: [
{
id: "messenger",
tools: {
profile: "messaging",
alsoAllow: ["image"],
},
},
],
},
} as OpenClawConfig;
const result = resolveEffectiveToolPolicy({ config: cfg, agentId: "messenger" });
expect(result.profileAlsoAllow).toEqual(["image"]);
expect(result.profileAlsoAllow).not.toContain("exec");
expect(result.profileAlsoAllow).not.toContain("process");
});
it("explicit alsoAllow with exec still grants exec under messaging profile", () => {
const cfg = {
tools: {
profile: "messaging",
alsoAllow: ["exec", "process"],
exec: { host: "sandbox" },
},
} as OpenClawConfig;
const result = resolveEffectiveToolPolicy({ config: cfg });
expect(result.profileAlsoAllow).toEqual(["exec", "process"]);
});
});

View File

@@ -4,6 +4,7 @@ import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js";
import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { AgentToolsConfig } from "../config/types.tools.js";
import { logWarn } from "../logger.js";
import { normalizeAgentId } from "../routing/session-key.js";
import {
parseRawSessionConversationRef,
@@ -26,7 +27,11 @@ import {
type SubagentSessionRole,
} from "./subagent-capabilities.js";
import { isToolAllowedByPolicies, isToolAllowedByPolicyName } from "./tool-policy-match.js";
import { normalizeToolName } from "./tool-policy.js";
import {
mergeAlsoAllowPolicy,
normalizeToolName,
resolveToolProfilePolicy,
} from "./tool-policy.js";
/**
* Tools always denied for sub-agents regardless of depth.
@@ -367,7 +372,9 @@ function hasExplicitToolSection(section: unknown): boolean {
return section !== undefined && section !== null;
}
function resolveImplicitProfileAlsoAllow(params: {
/** Detect tool config sections that previously widened profiles implicitly.
* Used only for migration warnings — not merged into profileAlsoAllow. #47487 */
function detectImplicitProfileGrants(params: {
globalTools?: OpenClawConfig["tools"];
agentTools?: AgentToolsConfig;
}): string[] | undefined {
@@ -422,13 +429,33 @@ export function resolveEffectiveToolPolicy(params: {
});
const explicitProfileAlsoAllow =
resolveExplicitProfileAlsoAllow(agentTools) ?? resolveExplicitProfileAlsoAllow(globalTools);
const implicitProfileAlsoAllow = resolveImplicitProfileAlsoAllow({ globalTools, agentTools });
const profileAlsoAllow =
explicitProfileAlsoAllow || implicitProfileAlsoAllow
? Array.from(
new Set([...(explicitProfileAlsoAllow ?? []), ...(implicitProfileAlsoAllow ?? [])]),
)
: undefined;
// Warn affected users about removed implicit grants (#47487), but only when
// the active profile/explicit alsoAllow do not already grant those tools.
if (profile) {
const implicitGrants = detectImplicitProfileGrants({ globalTools, agentTools });
if (implicitGrants) {
const profilePolicy = mergeAlsoAllowPolicy(
resolveToolProfilePolicy(profile),
explicitProfileAlsoAllow,
);
const uncovered = implicitGrants.filter(
(toolName) => !isToolAllowedByPolicyName(toolName, profilePolicy),
);
if (uncovered.length > 0) {
logWarn(
`tools policy: profile "${profile}"${agentId ? ` (agent "${agentId}")` : ""} has ` +
`configured tool sections (tools.exec / tools.fs) that no longer implicitly widen ` +
`the profile. Add alsoAllow: [${uncovered.map((t) => `"${t}"`).join(", ")}] ` +
`explicitly if these tools should be available. See #47487.`,
);
}
}
}
const profileAlsoAllow = explicitProfileAlsoAllow
? Array.from(new Set(explicitProfileAlsoAllow))
: undefined;
return {
agentId,
globalPolicy: pickSandboxToolPolicy(globalTools),
@@ -437,7 +464,7 @@ export function resolveEffectiveToolPolicy(params: {
agentProviderPolicy: pickSandboxToolPolicy(agentProviderPolicy),
profile,
providerProfile: agentProviderPolicy?.profile ?? providerPolicy?.profile,
// alsoAllow is applied at the profile stage (to avoid being filtered out early).
// alsoAllow is applied at the profile stage to avoid early filtering.
profileAlsoAllow,
providerProfileAlsoAllow: Array.isArray(agentProviderPolicy?.alsoAllow)
? agentProviderPolicy?.alsoAllow

View File

@@ -247,9 +247,14 @@ describe("buildWorkspaceSkillSnapshot", () => {
);
// We should only have loaded a small subset.
expect(snapshot.skills.length).toBeLessThanOrEqual(5);
expect(snapshot.prompt).toContain("repo-skill-00");
expect(snapshot.prompt).not.toContain("repo-skill-07");
const skillNames = snapshot.skills.map((skill) => skill.name);
expect(skillNames.length).toBeGreaterThan(0);
expect(skillNames.length).toBeLessThanOrEqual(5);
expect(new Set(skillNames).size).toBe(skillNames.length);
for (const name of skillNames) {
expect(name).toMatch(/^repo-skill-\d{2}$/);
expect(snapshot.prompt).toContain(name);
}
});
it("skips skills whose SKILL.md exceeds maxSkillFileBytes", async () => {

View File

@@ -342,6 +342,110 @@ describe("subagent-orphan-recovery", () => {
expect(mockStore["agent:main:subagent:test-session-1"]?.abortedLastRun).toBe(false);
});
it("persists accepted recovery attempts after successful resume", async () => {
vi.mocked(gateway.callGateway).mockResolvedValue({ runId: "resumed-run" } as never);
mockSingleAbortedSession();
await recoverOrphanedSubagentSessions({
getActiveRuns: () => createActiveRuns(createTestRunRecord()),
});
const [, updater] = vi.mocked(sessions.updateSessionStore).mock.calls[0];
const mockStore: ReturnType<typeof sessions.loadSessionStore> = {
"agent:main:subagent:test-session-1": {
sessionId: "session-abc",
updatedAt: 0,
abortedLastRun: true,
},
};
await updater(mockStore);
expect(mockStore["agent:main:subagent:test-session-1"]).toMatchObject({
abortedLastRun: false,
subagentRecovery: {
automaticAttempts: 1,
lastRunId: "run-1",
lastAttemptAt: expect.any(Number),
},
});
});
it("tombstones rapid repeated accepted recovery before resuming again", async () => {
const now = Date.now();
mockSingleAbortedSession({
subagentRecovery: {
automaticAttempts: 2,
lastAttemptAt: now - 30_000,
lastRunId: "previous-run",
},
});
const result = await recoverOrphanedSubagentSessions({
getActiveRuns: () => createActiveRuns(createTestRunRecord()),
});
expect(result).toMatchObject({
recovered: 0,
failed: 0,
skipped: 1,
failedRuns: [
expect.objectContaining({
runId: "run-1",
childSessionKey: "agent:main:subagent:test-session-1",
error: expect.stringContaining("recovery blocked after 2 rapid accepted resume attempts"),
}),
],
});
expect(gateway.callGateway).not.toHaveBeenCalled();
expect(sessions.updateSessionStore).toHaveBeenCalledOnce();
const [, updater] = vi.mocked(sessions.updateSessionStore).mock.calls[0];
const mockStore: ReturnType<typeof sessions.loadSessionStore> = {
"agent:main:subagent:test-session-1": {
sessionId: "session-abc",
updatedAt: 0,
abortedLastRun: true,
subagentRecovery: {
automaticAttempts: 2,
lastAttemptAt: now - 30_000,
lastRunId: "previous-run",
},
},
};
await updater(mockStore);
expect(mockStore["agent:main:subagent:test-session-1"]).toMatchObject({
abortedLastRun: false,
subagentRecovery: {
automaticAttempts: 2,
lastRunId: "run-1",
wedgedAt: expect.any(Number),
wedgedReason: expect.stringContaining("recovery blocked"),
},
});
});
it("skips already tombstoned wedged sessions without rewriting them", async () => {
mockSingleAbortedSession({
subagentRecovery: {
automaticAttempts: 2,
lastAttemptAt: Date.now() - 20_000,
lastRunId: "previous-run",
wedgedAt: Date.now() - 10_000,
wedgedReason: "subagent orphan recovery blocked after 2 rapid accepted resume attempts",
},
});
const result = await recoverOrphanedSubagentSessions({
getActiveRuns: () => createActiveRuns(createTestRunRecord()),
});
expect(result.recovered).toBe(0);
expect(result.failed).toBe(0);
expect(result.skipped).toBe(1);
expect(result.failedRuns).toHaveLength(1);
expect(gateway.callGateway).not.toHaveBeenCalled();
expect(sessions.updateSessionStore).not.toHaveBeenCalled();
});
it("truncates long task descriptions in resume message", async () => {
mockSingleAbortedSession();

View File

@@ -29,6 +29,11 @@ import {
loadRequesterSessionEntry,
} from "./subagent-announce-delivery.js";
import { resolveAnnounceOrigin } from "./subagent-announce-origin.js";
import {
evaluateSubagentRecoveryGate,
markSubagentRecoveryAttempt,
markSubagentRecoveryWedged,
} from "./subagent-recovery-state.js";
import {
finalizeInterruptedSubagentRun,
replaceSubagentRunAfterSteer,
@@ -266,6 +271,7 @@ export async function recoverOrphanedSubagentSessions(params: {
if (!childSessionKey) {
continue;
}
const now = Date.now();
if (resumedSessionKeys.has(childSessionKey)) {
result.skipped++;
continue;
@@ -304,6 +310,44 @@ export async function recoverOrphanedSubagentSessions(params: {
continue;
}
const recoveryGate = evaluateSubagentRecoveryGate(entry, now);
if (!recoveryGate.allowed) {
if (recoveryGate.shouldMarkWedged) {
try {
await updateSessionStore(storePath, (currentStore) => {
const current = currentStore[childSessionKey];
if (current) {
markSubagentRecoveryWedged({
entry: current,
now,
runId,
reason: recoveryGate.reason,
});
currentStore[childSessionKey] = current;
}
});
markSubagentRecoveryWedged({
entry,
now,
runId,
reason: recoveryGate.reason,
});
} catch (err) {
log.warn(
`failed to persist wedged subagent recovery marker for ${childSessionKey}: ${String(err)}`,
);
}
}
log.warn(`skipping orphan recovery for ${childSessionKey}: ${recoveryGate.reason}`);
result.skipped++;
result.failedRuns.push({
runId,
childSessionKey,
error: recoveryGate.reason,
});
continue;
}
log.info(`found orphaned subagent session: ${childSessionKey} (run=${runId})`);
const messages = readSessionMessages(entry.sessionId, storePath, entry.sessionFile);
@@ -352,6 +396,12 @@ export async function recoverOrphanedSubagentSessions(params: {
const current = currentStore[childSessionKey];
if (current) {
current.abortedLastRun = false;
markSubagentRecoveryAttempt({
entry: current,
now: Date.now(),
runId,
attempt: recoveryGate.nextAttempt,
});
current.updatedAt = Date.now();
currentStore[childSessionKey] = current;
}

View File

@@ -0,0 +1,117 @@
import type { SessionEntry } from "../config/sessions.js";
export const SUBAGENT_RECOVERY_MAX_AUTOMATIC_ATTEMPTS = 2;
export const SUBAGENT_RECOVERY_REWEDGE_WINDOW_MS = 2 * 60_000;
export type SubagentRecoveryGate =
| {
allowed: true;
nextAttempt: number;
}
| {
allowed: false;
reason: string;
shouldMarkWedged: boolean;
};
function isRecentRecoveryAttempt(entry: SessionEntry, now: number): boolean {
const lastAttemptAt = entry.subagentRecovery?.lastAttemptAt;
return (
typeof lastAttemptAt === "number" &&
Number.isFinite(lastAttemptAt) &&
now - lastAttemptAt <= SUBAGENT_RECOVERY_REWEDGE_WINDOW_MS
);
}
export function isSubagentRecoveryWedgedEntry(entry: unknown): boolean {
if (!entry || typeof entry !== "object") {
return false;
}
const recovery = (entry as SessionEntry).subagentRecovery;
return (
typeof recovery?.wedgedAt === "number" &&
Number.isFinite(recovery.wedgedAt) &&
recovery.wedgedAt > 0
);
}
export function formatSubagentRecoveryWedgedReason(entry: SessionEntry): string {
return (
entry.subagentRecovery?.wedgedReason?.trim() ||
"subagent orphan recovery is tombstoned for this session"
);
}
export function evaluateSubagentRecoveryGate(
entry: SessionEntry,
now: number,
): SubagentRecoveryGate {
if (isSubagentRecoveryWedgedEntry(entry)) {
return {
allowed: false,
reason: formatSubagentRecoveryWedgedReason(entry),
shouldMarkWedged: false,
};
}
const previousAttempts = isRecentRecoveryAttempt(entry, now)
? Math.max(0, entry.subagentRecovery?.automaticAttempts ?? 0)
: 0;
if (previousAttempts >= SUBAGENT_RECOVERY_MAX_AUTOMATIC_ATTEMPTS) {
return {
allowed: false,
reason:
`subagent orphan recovery blocked after ${previousAttempts} rapid accepted resume attempts; ` +
`run "openclaw tasks maintenance --apply" or "openclaw doctor --fix" to reconcile it`,
shouldMarkWedged: true,
};
}
return {
allowed: true,
nextAttempt: previousAttempts + 1,
};
}
export function markSubagentRecoveryAttempt(params: {
entry: SessionEntry;
now: number;
runId: string;
attempt: number;
}): void {
params.entry.subagentRecovery = {
automaticAttempts: Math.max(1, params.attempt),
lastAttemptAt: params.now,
lastRunId: params.runId,
};
}
export function markSubagentRecoveryWedged(params: {
entry: SessionEntry;
now: number;
runId?: string;
reason: string;
}): void {
params.entry.abortedLastRun = false;
params.entry.subagentRecovery = {
...params.entry.subagentRecovery,
automaticAttempts: Math.max(
params.entry.subagentRecovery?.automaticAttempts ?? 0,
SUBAGENT_RECOVERY_MAX_AUTOMATIC_ATTEMPTS,
),
lastAttemptAt: params.entry.subagentRecovery?.lastAttemptAt ?? params.now,
...(params.runId ? { lastRunId: params.runId } : {}),
wedgedAt: params.now,
wedgedReason: params.reason,
};
params.entry.updatedAt = params.now;
}
export function clearWedgedSubagentRecoveryAbort(entry: SessionEntry, now: number): boolean {
if (!isSubagentRecoveryWedgedEntry(entry) || entry.abortedLastRun !== true) {
return false;
}
entry.abortedLastRun = false;
entry.updatedAt = now;
return true;
}

View File

@@ -64,23 +64,34 @@ describe("resolveEffectiveToolFsRootExpansionAllowed", () => {
expect(resolveEffectiveToolFsRootExpansionAllowed({ cfg, agentId: "main" })).toBe(false);
});
it("re-enables root expansion when tools.fs explicitly allows non-workspace reads", () => {
it("does not re-enable root expansion from tools.fs alone under messaging profile (#47487)", () => {
const cfg: OpenClawConfig = {
tools: {
profile: "messaging",
fs: { workspaceOnly: false },
},
};
expect(resolveEffectiveToolFsRootExpansionAllowed({ cfg, agentId: "main" })).toBe(true);
expect(resolveEffectiveToolFsRootExpansionAllowed({ cfg, agentId: "main" })).toBe(false);
});
it("treats an explicit tools.fs block as a filesystem opt-in", () => {
it("does not treat an explicit tools.fs block as a filesystem opt-in (#47487)", () => {
const cfg: OpenClawConfig = {
tools: {
profile: "messaging",
fs: {},
},
};
expect(resolveEffectiveToolFsRootExpansionAllowed({ cfg, agentId: "main" })).toBe(false);
});
it("re-enables root expansion when alsoAllow explicitly includes read (#47487)", () => {
const cfg: OpenClawConfig = {
tools: {
profile: "messaging",
alsoAllow: ["read"],
fs: { workspaceOnly: false },
},
};
expect(resolveEffectiveToolFsRootExpansionAllowed({ cfg, agentId: "main" })).toBe(true);
});

View File

@@ -46,15 +46,10 @@ export function resolveEffectiveToolFsRootExpansionAllowed(params: {
const profile = agentTools?.profile ?? globalTools?.profile;
const profileAlsoAllow = new Set(agentTools?.alsoAllow ?? globalTools?.alsoAllow ?? []);
const fsConfig = resolveToolFsConfig(params);
const hasExplicitFsConfig = agentTools?.fs !== undefined || globalTools?.fs !== undefined;
if (fsConfig.workspaceOnly === true) {
return false;
}
if (hasExplicitFsConfig) {
profileAlsoAllow.add("read");
profileAlsoAllow.add("write");
profileAlsoAllow.add("edit");
}
// tools.fs presence does not grant access; require profile or alsoAllow (#47487).
const profilePolicy = mergeAlsoAllowPolicy(
resolveToolProfilePolicy(profile),
profileAlsoAllow.size > 0 ? Array.from(profileAlsoAllow) : undefined,

View File

@@ -65,10 +65,38 @@ describe("gateway config mutation guard coverage", () => {
"agents.list[].id",
"agents.list[].model",
"channels.*.requireMention",
"messages.visibleReplies",
"messages.groupChat.visibleReplies",
]),
);
});
it("allows visible reply delivery mode edits via config.patch", () => {
expectAllowed(
{},
{
messages: {
visibleReplies: "automatic",
groupChat: { visibleReplies: "automatic" },
},
},
);
expectAllowed(
{
messages: {
visibleReplies: "automatic",
groupChat: { visibleReplies: "message_tool" },
},
},
{
messages: {
visibleReplies: "message_tool",
groupChat: { visibleReplies: "automatic" },
},
},
);
});
it("blocks disabling sandbox mode via config.patch", () => {
expectBlocked(
{ agents: { defaults: { sandbox: { mode: "all" } } } },

View File

@@ -51,6 +51,10 @@ const ALLOWED_GATEWAY_CONFIG_PATHS = [
"channels.*.*.*.requireMention",
"channels.*.*.*.*.requireMention",
"channels.*.*.*.*.*.requireMention",
// Visible reply delivery mode is a bounded message UX setting, not a secret
// or privilege boundary. Let agents repair silent group/channel rooms.
"messages.visibleReplies",
"messages.groupChat.visibleReplies",
] as const;
/** @internal Exposed for regression tests only; do not import from runtime code. */

View File

@@ -4402,6 +4402,59 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
});
it("falls back to automatic group/channel delivery when the message tool is unavailable", async () => {
setNoAbort();
const dispatcher = createDispatcher();
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
return { text: "visible fallback" } satisfies ReplyPayload;
});
const result = await dispatchReplyFromConfig({
ctx: buildTestCtx({
ChatType: "channel",
SessionKey: "test:discord:channel:C1",
}),
cfg: { tools: { allow: ["read"] } } as OpenClawConfig,
dispatcher,
replyResolver,
});
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(result.queuedFinal).toBe(true);
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(
expect.objectContaining({ text: "visible fallback" }),
);
});
it("falls back when a channel precomputed message-tool-only delivery but the message tool is unavailable", async () => {
setNoAbort();
const dispatcher = createDispatcher();
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
return { text: "requested fallback" } satisfies ReplyPayload;
});
const result = await dispatchReplyFromConfig({
ctx: buildTestCtx({
ChatType: "channel",
SessionKey: "test:discord:channel:C1",
}),
cfg: { tools: { allow: ["read"] } } as OpenClawConfig,
dispatcher,
replyResolver,
replyOptions: {
sourceReplyDeliveryMode: "message_tool_only",
},
});
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(result.queuedFinal).toBe(true);
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(
expect.objectContaining({ text: "requested fallback" }),
);
});
it("keeps native command replies visible in group/channel turns", async () => {
setNoAbort();
const dispatcher = createDispatcher();

View File

@@ -5,6 +5,11 @@ import {
resolveAgentWorkspaceDir,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
import {
isToolAllowedByPolicies,
resolveEffectiveToolPolicy,
} from "../../agents/pi-tools.policy.js";
import { mergeAlsoAllowPolicy, resolveToolProfilePolicy } from "../../agents/tool-policy.js";
import {
resolveConversationBindingRecord,
touchConversationBindingRecord,
@@ -593,6 +598,33 @@ export async function dispatchReplyFromConfig(
undefined,
chatType: sessionStoreEntry.entry?.chatType,
});
const {
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
profile,
providerProfile,
profileAlsoAllow,
providerProfileAlsoAllow,
} = resolveEffectiveToolPolicy({
config: cfg,
sessionKey: acpDispatchSessionKey,
agentId: sessionAgentId,
});
const profilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(profile), profileAlsoAllow);
const providerProfilePolicy = mergeAlsoAllowPolicy(
resolveToolProfilePolicy(providerProfile),
providerProfileAlsoAllow,
);
const messageToolAvailable = isToolAllowedByPolicies("message", [
profilePolicy,
providerProfilePolicy,
globalProviderPolicy,
agentProviderPolicy,
globalPolicy,
agentPolicy,
]);
const sourceReplyPolicy = resolveSourceReplyVisibilityPolicy({
cfg,
ctx,
@@ -601,6 +633,7 @@ export async function dispatchReplyFromConfig(
suppressAcpChildUserDelivery,
explicitSuppressTyping: params.replyOptions?.suppressTyping === true,
shouldSuppressTyping,
messageToolAvailable,
});
const {
sourceReplyDeliveryMode,

View File

@@ -1,6 +1,27 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
const loggerMocks = vi.hoisted(() => ({
warn: vi.fn(),
}));
vi.mock("../../logging/subsystem.js", () => ({
createSubsystemLogger: () => ({
subsystem: "auto-reply",
isEnabled: () => false,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: loggerMocks.warn,
error: vi.fn(),
fatal: vi.fn(),
raw: vi.fn(),
child: vi.fn(),
}),
}));
import {
resetVisibleRepliesPrivateDefaultWarningForTest,
resolveSourceReplyDeliveryMode,
resolveSourceReplyVisibilityPolicy,
} from "./source-reply-delivery-mode.js";
@@ -19,6 +40,11 @@ const globalToolOnlyReplyConfig = {
},
} as const satisfies OpenClawConfig;
beforeEach(() => {
loggerMocks.warn.mockClear();
resetVisibleRepliesPrivateDefaultWarningForTest();
});
describe("resolveSourceReplyDeliveryMode", () => {
it("defaults groups and channels to message-tool-only delivery", () => {
expect(resolveSourceReplyDeliveryMode({ cfg: emptyConfig, ctx: { ChatType: "channel" } })).toBe(
@@ -30,6 +56,10 @@ describe("resolveSourceReplyDeliveryMode", () => {
expect(resolveSourceReplyDeliveryMode({ cfg: emptyConfig, ctx: { ChatType: "direct" } })).toBe(
"automatic",
);
expect(loggerMocks.warn).toHaveBeenCalledTimes(1);
expect(loggerMocks.warn).toHaveBeenCalledWith(
expect.stringContaining("Group/channel replies are private by default"),
);
});
it("honors config and explicit requested mode", () => {
@@ -77,6 +107,50 @@ describe("resolveSourceReplyDeliveryMode", () => {
ctx: { ChatType: "group", CommandSource: "native" },
}),
).toBe("automatic");
expect(loggerMocks.warn).not.toHaveBeenCalled();
});
it("falls back to automatic when message tool is unavailable", () => {
expect(
resolveSourceReplyDeliveryMode({
cfg: emptyConfig,
ctx: { ChatType: "group" },
messageToolAvailable: false,
}),
).toBe("automatic");
expect(
resolveSourceReplyDeliveryMode({
cfg: globalToolOnlyReplyConfig,
ctx: { ChatType: "direct" },
messageToolAvailable: false,
}),
).toBe("automatic");
expect(
resolveSourceReplyDeliveryMode({
cfg: emptyConfig,
ctx: { ChatType: "channel" },
requested: "message_tool_only",
messageToolAvailable: false,
}),
).toBe("automatic");
expect(loggerMocks.warn).not.toHaveBeenCalled();
});
it("keeps message-tool-only delivery when message tool availability is unknown", () => {
expect(
resolveSourceReplyDeliveryMode({
cfg: emptyConfig,
ctx: { ChatType: "group" },
messageToolAvailable: true,
}),
).toBe("message_tool_only");
expect(
resolveSourceReplyDeliveryMode({
cfg: emptyConfig,
ctx: { ChatType: "channel" },
}),
).toBe("message_tool_only");
expect(loggerMocks.warn).toHaveBeenCalledTimes(1);
});
});
@@ -220,4 +294,35 @@ describe("resolveSourceReplyVisibilityPolicy", () => {
suppressTyping: false,
});
});
it("keeps delivery automatic when message-tool-only mode cannot send visibly", () => {
expect(
resolveSourceReplyVisibilityPolicy({
cfg: emptyConfig,
ctx: { ChatType: "group" },
sendPolicy: "allow",
messageToolAvailable: false,
}),
).toMatchObject({
sourceReplyDeliveryMode: "automatic",
suppressAutomaticSourceDelivery: false,
suppressDelivery: false,
suppressHookUserDelivery: false,
deliverySuppressionReason: "",
});
expect(
resolveSourceReplyVisibilityPolicy({
cfg: emptyConfig,
ctx: { ChatType: "channel" },
requested: "message_tool_only",
sendPolicy: "allow",
messageToolAvailable: false,
}),
).toMatchObject({
sourceReplyDeliveryMode: "automatic",
suppressAutomaticSourceDelivery: false,
suppressDelivery: false,
deliverySuppressionReason: "",
});
});
});

View File

@@ -1,31 +1,63 @@
import { normalizeChatType } from "../../channels/chat-type.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import type { SessionSendPolicyDecision } from "../../sessions/send-policy.js";
import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js";
const log = createSubsystemLogger("auto-reply");
let visibleRepliesPrivateDefaultWarned = false;
export type SourceReplyDeliveryModeContext = {
ChatType?: string;
CommandSource?: "text" | "native";
};
/** @internal Test-only reset for the process-level one-shot warning. */
export function resetVisibleRepliesPrivateDefaultWarningForTest(): void {
visibleRepliesPrivateDefaultWarned = false;
}
export function resolveSourceReplyDeliveryMode(params: {
cfg: OpenClawConfig;
ctx: SourceReplyDeliveryModeContext;
requested?: SourceReplyDeliveryMode;
messageToolAvailable?: boolean;
}): SourceReplyDeliveryMode {
let mode: SourceReplyDeliveryMode;
if (params.requested) {
return params.requested;
mode = params.requested;
} else if (params.ctx.CommandSource === "native") {
mode = "automatic";
} else {
const chatType = normalizeChatType(params.ctx.ChatType);
if (chatType === "group" || chatType === "channel") {
const configuredMode =
params.cfg.messages?.groupChat?.visibleReplies ?? params.cfg.messages?.visibleReplies;
mode = configuredMode === "automatic" ? "automatic" : "message_tool_only";
if (
mode === "message_tool_only" &&
configuredMode === undefined &&
params.messageToolAvailable !== false &&
!visibleRepliesPrivateDefaultWarned
) {
visibleRepliesPrivateDefaultWarned = true;
log.warn(
`Group/channel replies are private by default since 2026.4.27. ` +
`To restore automatic room posting, set messages.groupChat.visibleReplies to "automatic" in openclaw.json and save the config. ` +
`The gateway hot-reloads messages config; restart only if file watching/reload is disabled. ` +
`Relates to https://github.com/openclaw/openclaw/issues/74876`,
);
}
} else {
mode =
params.cfg.messages?.visibleReplies === "message_tool" ? "message_tool_only" : "automatic";
}
}
if (params.ctx.CommandSource === "native") {
if (mode === "message_tool_only" && params.messageToolAvailable === false) {
return "automatic";
}
const chatType = normalizeChatType(params.ctx.ChatType);
if (chatType === "group" || chatType === "channel") {
const configuredMode =
params.cfg.messages?.groupChat?.visibleReplies ?? params.cfg.messages?.visibleReplies;
return configuredMode === "automatic" ? "automatic" : "message_tool_only";
}
return params.cfg.messages?.visibleReplies === "message_tool" ? "message_tool_only" : "automatic";
return mode;
}
export type SourceReplyVisibilityPolicy = {
@@ -47,11 +79,13 @@ export function resolveSourceReplyVisibilityPolicy(params: {
suppressAcpChildUserDelivery?: boolean;
explicitSuppressTyping?: boolean;
shouldSuppressTyping?: boolean;
messageToolAvailable?: boolean;
}): SourceReplyVisibilityPolicy {
const sourceReplyDeliveryMode = resolveSourceReplyDeliveryMode({
cfg: params.cfg,
ctx: params.ctx,
requested: params.requested,
messageToolAvailable: params.messageToolAvailable,
});
const sendPolicyDenied = params.sendPolicy === "deny";
const suppressAutomaticSourceDelivery = sourceReplyDeliveryMode === "message_tool_only";

View File

@@ -44,7 +44,7 @@ describeChannelCatalogEntryContract({
});
describeChannelCatalogEntryContract({
channelId: "openclaw-plugin-yuanbao",
channelId: "yuanbao",
npmSpec: "openclaw-plugin-yuanbao@2.11.0",
alias: "yb",
});

View File

@@ -147,6 +147,15 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
policy: { ensureCliPath: false, networkProxy: "bypass" },
route: { id: "sessions" },
},
{
commandPath: ["commitments"],
policy: {
ensureCliPath: false,
routeConfigGuard: "when-suppressed",
loadPlugins: "never",
networkProxy: "bypass",
},
},
{
commandPath: ["agents", "list"],
// Text and JSON output are derived from config plus read-only channel

View File

@@ -121,7 +121,7 @@ const coreEntrySpecs: readonly CommandGroupDescriptorSpec<
...withProgramOnlySpecs(
defineImportedProgramCommandGroupSpecs([
{
commandNames: ["status", "health", "sessions", "tasks"],
commandNames: ["status", "health", "sessions", "commitments", "tasks"],
loadModule: () => import("./register.status-health-sessions.js"),
exportName: "registerStatusHealthSessionsCommands",
},

View File

@@ -32,6 +32,7 @@ vi.mock("./register.status-health-sessions.js", () => ({
program.command("status");
program.command("health");
program.command("sessions");
program.command("commitments");
const tasks = program.command("tasks");
tasks.command("show");
},
@@ -86,6 +87,7 @@ describe("command-registry", () => {
expect(names).toContain("backup");
expect(names).toContain("mcp");
expect(names).toContain("sessions");
expect(names).toContain("commitments");
expect(names).toContain("tasks");
expect(names).not.toContain("agent");
expect(names).not.toContain("crestodian");
@@ -159,9 +161,22 @@ describe("command-registry", () => {
expect(names).toContain("status");
expect(names).toContain("health");
expect(names).toContain("sessions");
expect(names).toContain("commitments");
expect(names).toContain("tasks");
});
it("can eagerly register the status/session command group repeatedly for completion", async () => {
const program = createProgram();
for (const name of ["status", "health", "sessions", "commitments", "tasks"]) {
await expect(registerCoreCliByName(program, testProgramContext, name)).resolves.toBe(true);
}
const names = namesOf(program);
expect(names.filter((name) => name === "commitments")).toHaveLength(1);
expect(names.filter((name) => name === "tasks")).toHaveLength(1);
});
it("replaces placeholders when loading a grouped entry by secondary command name", async () => {
const program = createProgram();
registerCoreCliCommands(program, testProgramContext, ["node", "openclaw", "doctor"]);

View File

@@ -95,6 +95,11 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([
description: "List stored conversation sessions",
hasSubcommands: true,
},
{
name: "commitments",
description: "List and manage inferred follow-up commitments",
hasSubcommands: true,
},
{
name: "tasks",
description: "Inspect durable background task state",

View File

@@ -1,5 +1,22 @@
import { describe, expect, it, vi } from "vitest";
import { createCliProgress } from "./progress.js";
import { createCliProgress, shouldUseInteractiveProgressSpinner } from "./progress.js";
function withStdinIsRaw<T>(isRaw: boolean, run: () => T): T {
const original = Object.getOwnPropertyDescriptor(process.stdin, "isRaw");
Object.defineProperty(process.stdin, "isRaw", {
configurable: true,
value: isRaw,
});
try {
return run();
} finally {
if (original) {
Object.defineProperty(process.stdin, "isRaw", original);
} else {
Reflect.deleteProperty(process.stdin, "isRaw");
}
}
}
describe("cli progress", () => {
it("logs progress when non-tty and fallback=log", () => {
@@ -43,4 +60,45 @@ describe("cli progress", () => {
expect(write).not.toHaveBeenCalled();
});
it("does not use readline-backed spinners while raw TUI input is active", () => {
expect(
shouldUseInteractiveProgressSpinner({
streamIsTty: true,
stdinIsRaw: true,
}),
).toBe(false);
});
it("keeps the normal interactive spinner for regular tty commands", () => {
expect(
shouldUseInteractiveProgressSpinner({
streamIsTty: true,
stdinIsRaw: false,
}),
).toBe(true);
});
it("does not write terminal controls when raw TUI input suppresses the default spinner", () => {
const writes: string[] = [];
const stream = {
isTTY: true,
write: vi.fn((chunk: string) => {
writes.push(chunk);
}),
} as unknown as NodeJS.WriteStream;
withStdinIsRaw(true, () => {
const progress = createCliProgress({
label: "Scanning",
total: 2,
stream,
});
progress.setLabel("Still scanning");
progress.tick();
progress.done();
});
expect(writes).toEqual([]);
});
});

View File

@@ -33,6 +33,15 @@ export type ProgressTotalsUpdate = {
label?: string;
};
export function shouldUseInteractiveProgressSpinner(params: {
fallback?: ProgressOptions["fallback"];
streamIsTty?: boolean;
stdinIsRaw?: boolean;
}): boolean {
const spinnerRequested = params.fallback === undefined || params.fallback === "spinner";
return spinnerRequested && params.streamIsTty === true && params.stdinIsRaw !== true;
}
const noopReporter: ProgressReporter = {
setLabel: () => {},
setPercent: () => {},
@@ -57,8 +66,16 @@ export function createCliProgress(options: ProgressOptions): ProgressReporter {
const delayMs = typeof options.delayMs === "number" ? options.delayMs : DEFAULT_DELAY_MS;
const canOsc = isTty && supportsOscProgress(process.env, isTty);
const allowSpinner = isTty && (options.fallback === undefined || options.fallback === "spinner");
const stdinIsRaw = process.stdin.isRaw;
const allowSpinner = shouldUseInteractiveProgressSpinner({
fallback: options.fallback,
streamIsTty: isTty,
stdinIsRaw,
});
const allowLine = isTty && options.fallback === "line";
if (isTty && stdinIsRaw && (options.fallback === undefined || options.fallback === "spinner")) {
return noopReporter;
}
let started = false;
let label = options.label;

Some files were not shown because too many files have changed in this diff Show More