mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
57 Commits
codex/fix-
...
v2026.4.29
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e78689ed8 | ||
|
|
eadd054348 | ||
|
|
1d8769604e | ||
|
|
10c39c33bf | ||
|
|
d1708b8003 | ||
|
|
15f95e3823 | ||
|
|
6c0ce491af | ||
|
|
38e49d2fdb | ||
|
|
7420453611 | ||
|
|
c8a0db0599 | ||
|
|
53f977bd79 | ||
|
|
a223d62828 | ||
|
|
6b2bb4167a | ||
|
|
501ac000e3 | ||
|
|
2dbb2bf9b7 | ||
|
|
c75ff17f06 | ||
|
|
147a42fe66 | ||
|
|
8826f38545 | ||
|
|
d9a7459511 | ||
|
|
47cd0d758d | ||
|
|
3b4bb28f03 | ||
|
|
3c5370879d | ||
|
|
6f5a9cbf9e | ||
|
|
f66320efcf | ||
|
|
5d838b0d0f | ||
|
|
838d0c02e3 | ||
|
|
c81b9547de | ||
|
|
f39c5e4b04 | ||
|
|
bbf6b911b0 | ||
|
|
3d8946e8dd | ||
|
|
0a98aad6c6 | ||
|
|
fb7db3a156 | ||
|
|
90d875ce97 | ||
|
|
55dc865d75 | ||
|
|
3cf3230277 | ||
|
|
68568c23fc | ||
|
|
2a324ce072 | ||
|
|
24600e24ee | ||
|
|
66164e51c0 | ||
|
|
1c0879a462 | ||
|
|
f677c8c201 | ||
|
|
bd739cf851 | ||
|
|
328782f5f3 | ||
|
|
41ab6dda15 | ||
|
|
ff4526d78b | ||
|
|
aa728d0c29 | ||
|
|
456350c5f4 | ||
|
|
ed42f6ae49 | ||
|
|
2077855627 | ||
|
|
0f40bbe4a4 | ||
|
|
3adce23d89 | ||
|
|
a898a8e926 | ||
|
|
eb250c1e59 | ||
|
|
21521cd19c | ||
|
|
5a6e2f1270 | ||
|
|
255d5b3b9a | ||
|
|
f6086965f0 |
22
CHANGELOG.md
22
CHANGELOG.md
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2026.4.27"
|
||||
"version": "2026.4.29"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (128–512 tokens) while bounding non-weight VRAM. Lower to 1024–2048 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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
70
extensions/browser/src/browser-control-state.ts
Normal file
70
extensions/browser/src/browser-control-state.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
getRuntimeConfig,
|
||||
getRuntimeConfigSnapshot,
|
||||
getRuntimeConfigSourceSnapshot,
|
||||
replaceConfigFile,
|
||||
type BrowserConfig,
|
||||
type BrowserProfileConfig,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"), () => ({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"contracts": {
|
||||
"memoryEmbeddingProviders": ["local"]
|
||||
},
|
||||
"runtimeDependencies": {
|
||||
"localMemoryEmbedding": ["node-llama-cpp@3.18.1"]
|
||||
},
|
||||
"commandAliases": [
|
||||
{
|
||||
"name": "dreaming",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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?:
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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_" })],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"kind": "channel",
|
||||
"openclaw": {
|
||||
"channel": {
|
||||
"id": "openclaw-plugin-yuanbao",
|
||||
"id": "yuanbao",
|
||||
"label": "Yuanbao",
|
||||
"selectionLabel": "Yuanbao (元宝)",
|
||||
"detailLabel": "Yuanbao",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
117
src/agents/subagent-recovery-state.ts
Normal file
117
src/agents/subagent-recovery-state.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" } } } },
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -44,7 +44,7 @@ describeChannelCatalogEntryContract({
|
||||
});
|
||||
|
||||
describeChannelCatalogEntryContract({
|
||||
channelId: "openclaw-plugin-yuanbao",
|
||||
channelId: "yuanbao",
|
||||
npmSpec: "openclaw-plugin-yuanbao@2.11.0",
|
||||
alias: "yb",
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user