mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
77 Commits
josh/gmail
...
v2026.4.29
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f68c232294 | ||
|
|
b0483510a9 | ||
|
|
cad90268c0 | ||
|
|
25cb61cacb | ||
|
|
a4d338c170 | ||
|
|
6a18a2edc9 | ||
|
|
8be5a7eed7 | ||
|
|
62809b2560 | ||
|
|
56c9b18457 | ||
|
|
33d77a08d1 | ||
|
|
7a7de82c58 | ||
|
|
6bad056c18 | ||
|
|
3f0e6182e5 | ||
|
|
9204476b19 | ||
|
|
cdea49eb56 | ||
|
|
46f6b14385 | ||
|
|
9ee651e1f5 | ||
|
|
a7be4b3aff | ||
|
|
76bb2c6a45 | ||
|
|
c874749ccb | ||
|
|
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 |
30
CHANGELOG.md
30
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,25 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Providers/OpenAI Codex: preserve existing wrapped Codex streams during OpenAI attribution so PI OAuth bearer injection reaches ChatGPT/Codex Responses, and strip native Codex-only unsupported payload fields without touching custom compatible endpoints. (#75111) Thanks @keshavbotagent.
|
||||
- Agents/tool-result guard: use the resolved runtime context token budget for non-context-engine tool-result overflow checks, so long tool-heavy sessions no longer compact early when `contextTokens` is larger than native `contextWindow`. Fixes #74917. Thanks @kAIborg24.
|
||||
- Gateway/systemd: exit with sysexits 78 for supervised lock and `EADDRINUSE` conflicts so `RestartPreventExitStatus=78` stops `Restart=always` restart loops instead of repeatedly reloading plugins against an occupied port. Fixes #75115. Thanks @yhyatt.
|
||||
- Agents/runtime: skip blank visible user prompts at the embedded-runner boundary before provider submission while still allowing internal runtime-only turns and media-only prompts, so Telegram/group sessions no longer leak raw empty-input provider errors when replay history exists. Fixes #74137. Thanks @yelog, @Gracker, and @nhaener.
|
||||
- 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.
|
||||
- Signal: match group allowlists against inbound Signal group ids as well as sender ids, and process explicitly configured Signal groups without requiring mentions unless `requireMention` is set. Fixes #53308. Thanks @minupla and @juan-flores077.
|
||||
- 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: replace stale symlinked mirror target roots before writing runtime-mirror temp files and skip rewriting already materialized hardlinks, so cross-version container upgrades no longer crash-loop on read-only image-layer paths while warm mirrors do less churn. Fixes #75108; refs #75069. Thanks @coletebou and @xiaohuaxi.
|
||||
- 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 +72,10 @@ 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.
|
||||
- Telegram: clamp low long-polling client timeouts so configured `timeoutSeconds` values below the `getUpdates` poll window no longer force a fresh HTTPS connection every few seconds. Fixes #75114. Thanks @hpinho77.
|
||||
- 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 +85,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.
|
||||
@@ -108,6 +132,7 @@ Docs: https://docs.openclaw.ai
|
||||
- ACP/resolver: fall through to thread-bound session resolution when an explicit `--session` token cannot be resolved while preserving the bad-token diagnostic when no thread binding exists, so Discord slash commands that auto-fill the current thread ID as the positional ACP target no longer return "Unable to resolve session target" errors. Fixes #66299. Thanks @hclsys, @kindomLee, and @martingarramon.
|
||||
- Agents/sessions: emit a terminal lifecycle backstop when embedded timeout/error turns return without `agent_end`, so Gateway sessions no longer stay stuck in `running` after failover surfaces a timeout. Fixes #74607. Thanks @millerc79.
|
||||
- Gateway/diagnostics: include stuck-session reason hints and recovery skip causes in warnings, so operators can tell whether a lane is waiting on active work, queued work, or stale bookkeeping. Thanks @vincentkoc.
|
||||
- Providers/DeepSeek: expose native DeepSeek V4 `xhigh` and `max` thinking levels through the provider `resolveThinkingProfile` hook so `/think xhigh|max` applies the intended effort instead of falling back to base levels. (#73008) Thanks @ai-hpc.
|
||||
- Agents/Codex: bound embedded-run cleanup, trajectory flushing, and command-lane task timeouts after runtime failures, so Discord and other chat sessions return to idle instead of staying stuck in processing. Thanks @vincentkoc.
|
||||
- Heartbeat/exec: consume successful metadata-only async exec completions silently so Telegram and other chat surfaces no longer ask users for missing command logs after `No session found`. Fixes #74595. Thanks @gkoch02.
|
||||
- Web fetch: add a documented `tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange` opt-in and thread it through cache keys and DNS/IP checks so trusted fake-IP proxy stacks using `fc00::/7` can work without broad private-network access. Fixes #74351. Thanks @jeffrey701.
|
||||
@@ -118,7 +143,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 +353,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 +544,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
|
||||
8f573caa7f4cf01ae9d4805d3d14e1ba6772f651f6da182baaf2b469592749a4 config-baseline.core.json
|
||||
f2f5dc47ab9572fa5f80eb01b5a176edb04ca91c7a25bea3b9ea8e19dd21904b config-baseline.json
|
||||
d81f9cadab9762a4b542795ed1f01f27e374f9811cf176f08cbbb7a20b044c15 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
|
||||
@@ -254,6 +257,7 @@ Control how group/room messages are handled per channel:
|
||||
<Accordion title="Per-channel notes">
|
||||
- `groupPolicy` is separate from mention-gating (which requires @mentions).
|
||||
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams/Zalo: use `groupAllowFrom` (fallback: explicit `allowFrom`).
|
||||
- Signal: `groupAllowFrom` can match either the inbound Signal group id or the sender phone/UUID.
|
||||
- DM pairing approvals (`*-allowFrom` store entries) apply to DM access only; group sender authorization stays explicit to group allowlists.
|
||||
- Discord: allowlist uses `channels.discord.guilds.<id>.channels`.
|
||||
- Slack: allowlist uses `channels.slack.channels`.
|
||||
|
||||
@@ -194,9 +194,10 @@ DMs:
|
||||
Groups:
|
||||
|
||||
- `channels.signal.groupPolicy = open | allowlist | disabled`.
|
||||
- `channels.signal.groupAllowFrom` controls who can trigger in groups when `allowlist` is set.
|
||||
- `channels.signal.groupAllowFrom` controls which groups or senders can trigger group replies when `allowlist` is set; entries can be Signal group IDs (raw, `group:<id>`, or `signal:group:<id>`), sender phone numbers, `uuid:<id>` values, or `*`.
|
||||
- `channels.signal.groups["<group-id>" | "*"]` can override group behavior with `requireMention`, `tools`, and `toolsBySender`.
|
||||
- Use `channels.signal.accounts.<id>.groups` for per-account overrides in multi-account setups.
|
||||
- Allowlisting a Signal group through `groupAllowFrom` does not disable mention gating by itself. A specifically configured `channels.signal.groups["<group-id>"]` entry processes every group message unless `requireMention=true` is set.
|
||||
- Runtime note: if `channels.signal` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set).
|
||||
|
||||
## How it works (behavior)
|
||||
@@ -314,7 +315,7 @@ Provider options:
|
||||
- `channels.signal.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:<id>`). `open` requires `"*"`. Signal has no usernames; use phone/UUID ids.
|
||||
- `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.signal.groupAllowFrom`: group sender allowlist.
|
||||
- `channels.signal.groupAllowFrom`: group allowlist; accepts Signal group IDs (raw, `group:<id>`, or `signal:group:<id>`), sender E.164 numbers, or `uuid:<id>` values.
|
||||
- `channels.signal.groups`: per-group overrides keyed by Signal group id (or `"*"`). Supported fields: `requireMention`, `tools`, `toolsBySender`.
|
||||
- `channels.signal.accounts.<id>.groups`: per-account version of `channels.signal.groups` for multi-account setups.
|
||||
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
|
||||
|
||||
@@ -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
|
||||
@@ -726,7 +724,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- `channels.telegram.textChunkLimit` default is 4000.
|
||||
- `channels.telegram.chunkMode="newline"` prefers paragraph boundaries (blank lines) before length splitting.
|
||||
- `channels.telegram.mediaMaxMb` (default 100) caps inbound and outbound Telegram media size.
|
||||
- `channels.telegram.timeoutSeconds` overrides Telegram API client timeout (if unset, grammY default applies).
|
||||
- `channels.telegram.timeoutSeconds` overrides Telegram API client timeout (if unset, grammY default applies). Long-polling bot clients clamp configured values below the 45-second `getUpdates` request guard so idle polls are not aborted before the 30-second poll window completes.
|
||||
- `channels.telegram.pollingStallThresholdMs` defaults to `120000`; tune between `30000` and `600000` only for false-positive polling-stall restarts.
|
||||
- group context history uses `channels.telegram.historyLimit` or `messages.groupChat.historyLimit` (default 50); `0` disables.
|
||||
- reply/quote/forward supplemental context is currently passed as received.
|
||||
@@ -866,6 +864,7 @@ Per-account, per-group, and per-topic overrides are supported (same inheritance
|
||||
- Node 22+ + custom fetch/proxy can trigger immediate abort behavior if AbortSignal types mismatch.
|
||||
- Some hosts resolve `api.telegram.org` to IPv6 first; broken IPv6 egress can cause intermittent Telegram API failures.
|
||||
- If logs include `TypeError: fetch failed` or `Network request for 'getUpdates' failed!`, OpenClaw now retries these as recoverable network errors.
|
||||
- If Telegram sockets recycle on a short fixed cadence, check for a low `channels.telegram.timeoutSeconds`; long-polling bot clients clamp configured values below the `getUpdates` request guard, but older releases could abort every poll when this was set below the long-poll timeout.
|
||||
- If logs include `Polling stall detected`, OpenClaw restarts polling and rebuilds the Telegram transport after 120 seconds without completed long-poll liveness by default.
|
||||
- `openclaw channels status --probe` and `openclaw doctor` warn when a running polling account has not completed `getUpdates` after startup grace, when a running webhook account has not completed `setWebhook` after startup grace, or when the last successful polling transport activity is stale.
|
||||
- Increase `channels.telegram.pollingStallThresholdMs` only when long-running `getUpdates` calls are healthy but your host still reports false polling-stall restarts. Persistent stalls usually point to proxy, DNS, IPv6, or TLS egress issues between the host and `api.telegram.org`.
|
||||
|
||||
@@ -32,6 +32,7 @@ wired end-to-end.
|
||||
- resolves model + auth profile and builds the pi session
|
||||
- subscribes to pi events and streams assistant/tool deltas
|
||||
- enforces timeout -> aborts run if exceeded
|
||||
- for Codex app-server turns, aborts an accepted turn that stops producing app-server progress before a terminal event
|
||||
- returns payloads + usage metadata
|
||||
4. `subscribeEmbeddedPiSession` bridges pi-agent-core events to OpenClaw `agent` stream:
|
||||
- tool events => `stream: "tool"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -114,6 +114,7 @@ keys.
|
||||
|
||||
- If commands seem stuck, enable verbose logs and look for “queued for …ms” lines to confirm the queue is draining.
|
||||
- If you need queue depth, enable verbose logs and watch for queue timing lines.
|
||||
- Codex app-server runs that accept a turn and then stop emitting progress are interrupted by the Codex adapter so the active session lane can release instead of waiting for the outer run timeout.
|
||||
- When diagnostics are enabled, sessions that remain in `processing` past `diagnostics.stuckSessionWarnMs` log a stuck-session warning. Active embedded runs, active reply operations, and active lane tasks remain warning-only by default; stale startup bookkeeping with no active session work can release the affected session lane so queued work drains.
|
||||
|
||||
## Related
|
||||
|
||||
@@ -560,6 +560,7 @@ Periodic heartbeat runs.
|
||||
identifierPolicy: "strict", // strict | off | custom
|
||||
identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom
|
||||
qualityGuard: { enabled: true, maxRetries: 1 },
|
||||
midTurnPrecheck: { enabled: false }, // optional Pi tool-loop pressure check
|
||||
postCompactionSections: ["Session Startup", "Red Lines"], // [] disables reinjection
|
||||
model: "openrouter/anthropic/claude-sonnet-4-6", // optional compaction-only model override
|
||||
truncateAfterCompaction: true, // rotate to a smaller successor JSONL after compaction
|
||||
@@ -585,6 +586,7 @@ Periodic heartbeat runs.
|
||||
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
|
||||
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
|
||||
- `qualityGuard`: retry-on-malformed-output checks for safeguard summaries. Enabled by default in safeguard mode; set `enabled: false` to skip the audit.
|
||||
- `midTurnPrecheck`: optional Pi tool-loop pressure check. When `enabled: true`, OpenClaw checks context pressure after tool results are appended and before the next model call. If the context no longer fits, it aborts the current attempt before submitting the prompt and reuses the existing precheck recovery path to truncate tool results or compact and retry. Works with both `default` and `safeguard` compaction modes. Default: disabled.
|
||||
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Defaults to `["Session Startup", "Red Lines"]`; set `[]` to disable reinjection. When unset or explicitly set to that default pair, older `Every Session`/`Safety` headings are also accepted as a legacy fallback.
|
||||
- `model`: optional `provider/model-id` override for compaction summarization only. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
|
||||
- `maxActiveTranscriptBytes`: optional byte threshold (`number` or strings like `"20mb"`) that triggers normal local compaction before a run when the active JSONL grows past the threshold. Requires `truncateAfterCompaction` so successful compaction can rotate to a smaller successor transcript. Disabled when unset or `0`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -28,7 +28,7 @@ title: "Gateway lock"
|
||||
## Operational notes
|
||||
|
||||
- If the port is occupied by _another_ process, the error is the same; free the port or choose another with `openclaw gateway --port <port>`.
|
||||
- Under a service supervisor, a new gateway process that sees an existing healthy `/healthz` responder exits successfully and leaves that process in control. If the existing process never becomes healthy, retries are bounded and startup fails with a clear lock error instead of looping forever.
|
||||
- Under a service supervisor, a new gateway process that sees an existing healthy `/healthz` responder leaves that process in control. On systemd, the duplicate starter exits with code 78 so the default `RestartPreventExitStatus=78` stops `Restart=always` from looping on a lock or `EADDRINUSE` conflict. If the existing process never becomes healthy, retries are bounded and startup fails with a clear lock error instead of looping forever.
|
||||
- The macOS app still maintains its own lightweight PID guard before spawning the gateway; the runtime lock is enforced by the lock file plus HTTP/WebSocket bind.
|
||||
|
||||
## Related
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -79,6 +79,8 @@ is available to that process (for example, in `~/.openclaw/.env` or via
|
||||
V4 models support DeepSeek's `thinking` control. OpenClaw also replays
|
||||
DeepSeek `reasoning_content` on follow-up turns so thinking sessions with tool
|
||||
calls can continue.
|
||||
Use `/think xhigh` or `/think max` with DeepSeek V4 models to request DeepSeek's
|
||||
maximum `reasoning_effort`.
|
||||
</Tip>
|
||||
|
||||
## Thinking and tools
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -272,6 +272,20 @@ reopen cost, not raw archival: OpenClaw still runs normal semantic compaction,
|
||||
and it requires `truncateAfterCompaction` so the compacted summary can become a
|
||||
new successor transcript.
|
||||
|
||||
For embedded Pi runs, `agents.defaults.compaction.midTurnPrecheck.enabled: true`
|
||||
adds an opt-in tool-loop guard. After a tool result is appended and before the
|
||||
next model call, OpenClaw estimates the prompt pressure using the same preflight
|
||||
budget logic used at turn start. If the context no longer fits, the guard does
|
||||
not compact inside Pi's `transformContext` hook. It raises a structured
|
||||
mid-turn precheck signal, stops the current prompt submission, and lets the
|
||||
outer run loop use the existing recovery path: truncate oversized tool results
|
||||
when that is enough, or trigger the configured compaction mode and retry. The
|
||||
option is disabled by default and works with both `default` and `safeguard`
|
||||
compaction modes, including provider-backed safeguard compaction.
|
||||
This is independent of `maxActiveTranscriptBytes`: the byte-size guard runs
|
||||
before a turn opens, while mid-turn precheck runs later in the embedded Pi tool
|
||||
loop after new tool results have been appended.
|
||||
|
||||
---
|
||||
|
||||
## Compaction settings (`reserveTokens`, `keepRecentTokens`)
|
||||
@@ -298,6 +312,11 @@ OpenClaw also enforces a safety floor for embedded runs:
|
||||
and keeps Pi's recent-tail cut point. Without an explicit keep budget,
|
||||
manual compaction remains a hard checkpoint and rebuilt context starts from
|
||||
the new summary.
|
||||
- Set `agents.defaults.compaction.midTurnPrecheck.enabled: true` to run the
|
||||
optional tool-loop precheck after new tool results and before the next model
|
||||
call. This is a trigger only; summary generation still uses the configured
|
||||
compaction path. It is independent of `maxActiveTranscriptBytes`, which is a
|
||||
turn-start active-transcript byte-size guard.
|
||||
- Set `agents.defaults.compaction.maxActiveTranscriptBytes` to a byte value or
|
||||
string such as `"20mb"` to run local compaction before a turn when the active
|
||||
transcript gets large. This guard is active only when
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -26,6 +26,7 @@ title: "Thinking levels"
|
||||
- Anthropic Claude Opus 4.7 does not default to adaptive thinking. Its API effort default remains provider-owned unless you explicitly set a thinking level.
|
||||
- Anthropic Claude Opus 4.7 maps `/think xhigh` to adaptive thinking plus `output_config.effort: "xhigh"`, because `/think` is a thinking directive and `xhigh` is the Opus 4.7 effort setting.
|
||||
- Anthropic Claude Opus 4.7 also exposes `/think max`; it maps to the same provider-owned max effort path.
|
||||
- DeepSeek V4 models expose `/think xhigh|max`; both map to DeepSeek `reasoning_effort: "max"` while lower non-off levels map to `high`.
|
||||
- Ollama thinking-capable models expose `/think low|medium|high|max`; `max` maps to native `think: "high"` because Ollama's native API accepts `low`, `medium`, and `high` effort strings.
|
||||
- OpenAI GPT models map `/think` through model-specific Responses API effort support. `/think off` sends `reasoning.effort: "none"` only when the target model supports it; otherwise OpenClaw omits the disabled reasoning payload instead of sending an unsupported value.
|
||||
- Custom OpenAI-compatible catalog entries can opt into `/think xhigh` by setting `models.providers.<provider>.models[].compat.supportedReasoningEfforts` to include `"xhigh"`. This uses the same compat metadata that maps outbound OpenAI reasoning effort payloads, so menus, session validation, agent CLI, and `llm-task` agree with transport behavior.
|
||||
|
||||
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)) {
|
||||
const browserCfg = loadBrowserConfigForRuntimeRefresh();
|
||||
if (!isDefaultBrowserPluginEnabled(browserCfg)) {
|
||||
return null;
|
||||
}
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,17 +17,19 @@ const configMocks = vi.hoisted(() => ({
|
||||
browser: {},
|
||||
nodeHost: { browserProxy: { enabled: true, allowProfiles: [] as string[] } },
|
||||
})),
|
||||
sourceConfig: null as Record<string, unknown> | null,
|
||||
}));
|
||||
|
||||
const browserConfigMocks = vi.hoisted(() => ({
|
||||
resolveBrowserConfig: vi.fn(() => ({
|
||||
resolveBrowserConfig: vi.fn((browser?: { defaultProfile?: string }) => ({
|
||||
enabled: true,
|
||||
defaultProfile: "openclaw",
|
||||
defaultProfile: browser?.defaultProfile ?? "openclaw",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../sdk-config.js", () => ({
|
||||
getRuntimeConfig: configMocks.loadConfig,
|
||||
getRuntimeConfigSourceSnapshot: () => configMocks.sourceConfig,
|
||||
loadConfig: configMocks.loadConfig,
|
||||
}));
|
||||
|
||||
@@ -150,6 +152,7 @@ describe("runBrowserProxyCommand", () => {
|
||||
}));
|
||||
controlServiceMocks.createBrowserControlContext.mockReset().mockReturnValue({ control: true });
|
||||
controlServiceMocks.startBrowserControlServiceFromConfig.mockReset().mockResolvedValue(true);
|
||||
configMocks.sourceConfig = null;
|
||||
configMocks.loadConfig.mockReset().mockReturnValue({
|
||||
browser: {},
|
||||
nodeHost: { browserProxy: { enabled: true, allowProfiles: [] as string[] } },
|
||||
@@ -304,6 +307,41 @@ describe("runBrowserProxyCommand", () => {
|
||||
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the browser source snapshot for proxy default-profile decisions", async () => {
|
||||
configMocks.loadConfig.mockReturnValue({
|
||||
browser: { defaultProfile: "openclaw" },
|
||||
nodeHost: { browserProxy: { enabled: true, allowProfiles: ["work"] } },
|
||||
});
|
||||
configMocks.sourceConfig = {
|
||||
browser: { defaultProfile: "work" },
|
||||
nodeHost: { browserProxy: { enabled: true, allowProfiles: ["work"] } },
|
||||
};
|
||||
browserConfigMocks.resolveBrowserConfig.mockImplementation(
|
||||
(browser?: { defaultProfile?: string }) => ({
|
||||
enabled: true,
|
||||
defaultProfile: browser?.defaultProfile ?? "openclaw",
|
||||
}),
|
||||
);
|
||||
dispatcherMocks.dispatch.mockResolvedValue({
|
||||
status: 200,
|
||||
body: { ok: true },
|
||||
});
|
||||
|
||||
await runBrowserProxyCommand(
|
||||
JSON.stringify({
|
||||
method: "GET",
|
||||
path: "/snapshot",
|
||||
timeoutMs: 50,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(dispatcherMocks.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: "/snapshot",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unauthorized body.profile when allowProfiles is configured", async () => {
|
||||
configMocks.loadConfig.mockReturnValue({
|
||||
browser: {},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fsPromises from "node:fs/promises";
|
||||
import { redactCdpUrl } from "../browser/cdp.helpers.js";
|
||||
import { loadBrowserConfigForRuntimeRefresh } from "../browser/config-refresh-source.js";
|
||||
import { resolveBrowserConfig } from "../browser/config.js";
|
||||
import {
|
||||
isPersistentBrowserProfileMutation,
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
createBrowserControlContext,
|
||||
startBrowserControlServiceFromConfig,
|
||||
} from "../control-service.js";
|
||||
import { getRuntimeConfig } from "../sdk-config.js";
|
||||
import { withTimeout } from "../sdk-node-runtime.js";
|
||||
import { detectMime } from "../sdk-setup-tools.js";
|
||||
|
||||
@@ -44,7 +44,7 @@ function normalizeProfileAllowlist(raw?: string[]): string[] {
|
||||
}
|
||||
|
||||
function resolveBrowserProxyConfig() {
|
||||
const cfg = getRuntimeConfig();
|
||||
const cfg = loadBrowserConfigForRuntimeRefresh();
|
||||
const proxy = cfg.nodeHost?.browserProxy;
|
||||
const allowProfiles = normalizeProfileAllowlist(proxy?.allowProfiles);
|
||||
const enabled = proxy?.enabled !== false;
|
||||
@@ -64,7 +64,7 @@ async function ensureBrowserControlService(): Promise<void> {
|
||||
return browserControlReady;
|
||||
}
|
||||
browserControlReady = (async () => {
|
||||
const cfg = getRuntimeConfig();
|
||||
const cfg = loadBrowserConfigForRuntimeRefresh();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
if (!resolved.enabled) {
|
||||
throw new Error("browser control disabled");
|
||||
@@ -231,7 +231,7 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
|
||||
}
|
||||
|
||||
await ensureBrowserControlService();
|
||||
const cfg = getRuntimeConfig();
|
||||
const cfg = loadBrowserConfigForRuntimeRefresh();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
|
||||
const path = normalizeBrowserRequestPath(pathValue);
|
||||
|
||||
@@ -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)) {
|
||||
const browserCfg = loadBrowserConfigForRuntimeRefresh();
|
||||
if (!isDefaultBrowserPluginEnabled(browserCfg)) {
|
||||
return null;
|
||||
}
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
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({
|
||||
|
||||
@@ -443,6 +443,33 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(queueAgentHarnessMessage("session-1", "after timeout")).toBe(false);
|
||||
});
|
||||
|
||||
it("releases the session when Codex accepts a turn but never sends progress", async () => {
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.timeoutMs = 60_000;
|
||||
|
||||
const run = runCodexAppServerAttempt(params, { turnTerminalIdleTimeoutMs: 5 });
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
await expect(run).resolves.toMatchObject({
|
||||
aborted: true,
|
||||
timedOut: true,
|
||||
promptError: "codex app-server turn idle timed out waiting for turn/completed",
|
||||
});
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(harness.request).toHaveBeenCalledWith("turn/interrupt", {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
}),
|
||||
{ interval: 1 },
|
||||
);
|
||||
expect(queueAgentHarnessMessage("session-1", "after silent turn")).toBe(false);
|
||||
});
|
||||
|
||||
it("applies before_prompt_build to Codex developer instructions and turn input", async () => {
|
||||
const beforePromptBuild = vi.fn(async () => ({
|
||||
systemPrompt: "custom codex system",
|
||||
|
||||
@@ -87,6 +87,7 @@ import { filterToolsForVisionInputs } from "./vision-tools.js";
|
||||
|
||||
const CODEX_DYNAMIC_TOOL_TIMEOUT_MS = 30_000;
|
||||
const CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS = 60_000;
|
||||
const CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS = 30 * 60_000;
|
||||
const CODEX_STEER_ALL_DEBOUNCE_MS = 500;
|
||||
|
||||
type OpenClawCodingToolsOptions = NonNullable<
|
||||
@@ -226,6 +227,7 @@ export async function runCodexAppServerAttempt(
|
||||
hookTimeoutSec?: number;
|
||||
};
|
||||
turnCompletionIdleTimeoutMs?: number;
|
||||
turnTerminalIdleTimeoutMs?: number;
|
||||
} = {},
|
||||
): Promise<EmbeddedRunAttemptResult> {
|
||||
const attemptStartedAt = Date.now();
|
||||
@@ -471,8 +473,13 @@ export async function runCodexAppServerAttempt(
|
||||
const turnCompletionIdleTimeoutMs = resolveCodexTurnCompletionIdleTimeoutMs(
|
||||
options.turnCompletionIdleTimeoutMs,
|
||||
);
|
||||
const turnTerminalIdleTimeoutMs = resolveCodexTurnTerminalIdleTimeoutMs(
|
||||
options.turnTerminalIdleTimeoutMs,
|
||||
);
|
||||
let turnCompletionIdleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let turnCompletionIdleWatchArmed = false;
|
||||
let turnTerminalIdleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let turnTerminalIdleWatchArmed = false;
|
||||
let turnCompletionLastActivityAt = Date.now();
|
||||
let turnCompletionLastActivityReason = "startup";
|
||||
let activeAppServerTurnRequests = 0;
|
||||
@@ -484,6 +491,13 @@ export async function runCodexAppServerAttempt(
|
||||
}
|
||||
};
|
||||
|
||||
const clearTurnTerminalIdleTimer = () => {
|
||||
if (turnTerminalIdleTimer) {
|
||||
clearTimeout(turnTerminalIdleTimer);
|
||||
turnTerminalIdleTimer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const fireTurnCompletionIdleTimeout = () => {
|
||||
if (
|
||||
completed ||
|
||||
@@ -520,6 +534,42 @@ export async function runCodexAppServerAttempt(
|
||||
runAbortController.abort("turn_completion_idle_timeout");
|
||||
};
|
||||
|
||||
const fireTurnTerminalIdleTimeout = () => {
|
||||
if (
|
||||
completed ||
|
||||
runAbortController.signal.aborted ||
|
||||
!turnTerminalIdleWatchArmed ||
|
||||
activeAppServerTurnRequests > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const idleMs = Math.max(0, Date.now() - turnCompletionLastActivityAt);
|
||||
if (idleMs < turnTerminalIdleTimeoutMs) {
|
||||
scheduleTurnTerminalIdleWatch();
|
||||
return;
|
||||
}
|
||||
timedOut = true;
|
||||
turnCompletionIdleTimedOut = true;
|
||||
turnCompletionIdleTimeoutMessage =
|
||||
"codex app-server turn idle timed out waiting for turn/completed";
|
||||
projector?.markTimedOut();
|
||||
trajectoryRecorder?.recordEvent("turn.terminal_idle_timeout", {
|
||||
threadId: thread.threadId,
|
||||
turnId,
|
||||
idleMs,
|
||||
timeoutMs: turnTerminalIdleTimeoutMs,
|
||||
lastActivityReason: turnCompletionLastActivityReason,
|
||||
});
|
||||
embeddedAgentLog.warn("codex app-server turn idle timed out waiting for terminal event", {
|
||||
threadId: thread.threadId,
|
||||
turnId,
|
||||
idleMs,
|
||||
timeoutMs: turnTerminalIdleTimeoutMs,
|
||||
lastActivityReason: turnCompletionLastActivityReason,
|
||||
});
|
||||
runAbortController.abort("turn_terminal_idle_timeout");
|
||||
};
|
||||
|
||||
function scheduleTurnCompletionIdleWatch() {
|
||||
clearTurnCompletionIdleTimer();
|
||||
if (
|
||||
@@ -536,6 +586,22 @@ export async function runCodexAppServerAttempt(
|
||||
turnCompletionIdleTimer.unref?.();
|
||||
}
|
||||
|
||||
function scheduleTurnTerminalIdleWatch() {
|
||||
clearTurnTerminalIdleTimer();
|
||||
if (
|
||||
completed ||
|
||||
runAbortController.signal.aborted ||
|
||||
!turnTerminalIdleWatchArmed ||
|
||||
activeAppServerTurnRequests > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const elapsedMs = Math.max(0, Date.now() - turnCompletionLastActivityAt);
|
||||
const delayMs = Math.max(1, turnTerminalIdleTimeoutMs - elapsedMs);
|
||||
turnTerminalIdleTimer = setTimeout(fireTurnTerminalIdleTimeout, delayMs);
|
||||
turnTerminalIdleTimer.unref?.();
|
||||
}
|
||||
|
||||
const touchTurnCompletionActivity = (reason: string, options?: { arm?: boolean }) => {
|
||||
turnCompletionLastActivityAt = Date.now();
|
||||
turnCompletionLastActivityReason = reason;
|
||||
@@ -543,6 +609,7 @@ export async function runCodexAppServerAttempt(
|
||||
turnCompletionIdleWatchArmed = true;
|
||||
}
|
||||
scheduleTurnCompletionIdleWatch();
|
||||
scheduleTurnTerminalIdleWatch();
|
||||
};
|
||||
|
||||
const emitLifecycleStart = () => {
|
||||
@@ -595,6 +662,7 @@ export async function runCodexAppServerAttempt(
|
||||
}
|
||||
completed = true;
|
||||
clearTurnCompletionIdleTimer();
|
||||
clearTurnTerminalIdleTimer();
|
||||
resolveCompletion?.();
|
||||
}
|
||||
}
|
||||
@@ -839,6 +907,7 @@ export async function runCodexAppServerAttempt(
|
||||
abort: () => runAbortController.abort("aborted"),
|
||||
};
|
||||
setActiveEmbeddedRun(params.sessionId, handle, params.sessionKey);
|
||||
turnTerminalIdleWatchArmed = true;
|
||||
touchTurnCompletionActivity("turn:start");
|
||||
|
||||
const timeout = setTimeout(
|
||||
@@ -1005,6 +1074,7 @@ export async function runCodexAppServerAttempt(
|
||||
userInputBridge?.cancelPending();
|
||||
clearTimeout(timeout);
|
||||
clearTurnCompletionIdleTimer();
|
||||
clearTurnTerminalIdleTimer();
|
||||
notificationCleanup();
|
||||
requestCleanup();
|
||||
nativeHookRelay?.unregister();
|
||||
@@ -1305,6 +1375,16 @@ function resolveCodexTurnCompletionIdleTimeoutMs(value: number | undefined): num
|
||||
return Math.max(1, Math.floor(value));
|
||||
}
|
||||
|
||||
function resolveCodexTurnTerminalIdleTimeoutMs(value: number | undefined): number {
|
||||
if (value === undefined) {
|
||||
return CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS;
|
||||
}
|
||||
if (!Number.isFinite(value)) {
|
||||
return CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS;
|
||||
}
|
||||
return Math.max(1, Math.floor(value));
|
||||
}
|
||||
|
||||
function readDynamicToolCallParams(
|
||||
value: JsonValue | undefined,
|
||||
): CodexDynamicToolCallParams | undefined {
|
||||
@@ -1417,6 +1497,7 @@ function handleApprovalRequest(params: {
|
||||
export const __testing = {
|
||||
CODEX_DYNAMIC_TOOL_TIMEOUT_MS,
|
||||
CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS,
|
||||
CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS,
|
||||
buildCodexNativeHookRelayId,
|
||||
filterToolsForVisionInputs,
|
||||
handleDynamicToolCallWithTimeout,
|
||||
|
||||
@@ -110,6 +110,37 @@ describe("deepseek provider plugin", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("advertises max thinking levels for DeepSeek V4 models only", async () => {
|
||||
const provider = await registerSingleProviderPlugin(deepseekPlugin);
|
||||
const resolveThinkingProfile = provider.resolveThinkingProfile!;
|
||||
const expectedV4Levels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
expect(
|
||||
resolveThinkingProfile({
|
||||
provider: "deepseek",
|
||||
modelId: "deepseek-v4-pro",
|
||||
} as never)?.levels.map((level) => level.id),
|
||||
).toEqual(expectedV4Levels);
|
||||
expect(
|
||||
resolveThinkingProfile({
|
||||
provider: "deepseek",
|
||||
modelId: "deepseek-v4-flash",
|
||||
} as never)?.defaultLevel,
|
||||
).toBe("high");
|
||||
expect(
|
||||
resolveThinkingProfile({
|
||||
provider: "deepseek",
|
||||
modelId: "deepseek-v4-flash",
|
||||
} as never)?.levels.map((level) => level.id),
|
||||
).toEqual(expectedV4Levels);
|
||||
expect(
|
||||
resolveThinkingProfile({ provider: "deepseek", modelId: "deepseek-chat" } as never),
|
||||
).toBe(undefined);
|
||||
expect(
|
||||
resolveThinkingProfile({ provider: "deepseek", modelId: "deepseek-reasoner" } as never),
|
||||
).toBe(undefined);
|
||||
});
|
||||
|
||||
it("maps thinking levels to DeepSeek V4 payload controls", async () => {
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const baseStreamFn = (
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import type { ProviderThinkingProfile } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { readConfiguredProviderCatalogEntries } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
|
||||
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { isDeepSeekV4ModelId } from "./models.js";
|
||||
import { applyDeepSeekConfig, DEEPSEEK_DEFAULT_MODEL_REF } from "./onboard.js";
|
||||
import { buildDeepSeekProvider } from "./provider-catalog.js";
|
||||
import { createDeepSeekV4ThinkingWrapper } from "./stream.js";
|
||||
|
||||
const PROVIDER_ID = "deepseek";
|
||||
const V4_THINKING_LEVEL_IDS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
||||
|
||||
function buildDeepSeekV4ThinkingLevel(id: (typeof V4_THINKING_LEVEL_IDS)[number]) {
|
||||
return { id };
|
||||
}
|
||||
|
||||
const DEEPSEEK_V4_THINKING_PROFILE = {
|
||||
levels: V4_THINKING_LEVEL_IDS.map(buildDeepSeekV4ThinkingLevel),
|
||||
defaultLevel: "high",
|
||||
} satisfies ProviderThinkingProfile;
|
||||
|
||||
function resolveDeepSeekV4ThinkingProfile(modelId: string): ProviderThinkingProfile | undefined {
|
||||
return isDeepSeekV4ModelId(modelId) ? DEEPSEEK_V4_THINKING_PROFILE : undefined;
|
||||
}
|
||||
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
@@ -46,9 +62,7 @@ export default defineSingleProviderPluginEntry({
|
||||
/\bdeepseek\b.*(?:input.*too long|context.*exceed)/i.test(errorMessage),
|
||||
...buildProviderReplayFamilyHooks({ family: "openai-compatible" }),
|
||||
wrapStreamFn: (ctx) => createDeepSeekV4ThinkingWrapper(ctx.streamFn, ctx.thinkingLevel),
|
||||
isModernModelRef: ({ modelId }) => {
|
||||
const lower = modelId.toLowerCase();
|
||||
return lower === "deepseek-v4-flash" || lower === "deepseek-v4-pro";
|
||||
},
|
||||
resolveThinkingProfile: ({ modelId }) => resolveDeepSeekV4ThinkingProfile(modelId),
|
||||
isModernModelRef: ({ modelId }) => Boolean(resolveDeepSeekV4ThinkingProfile(modelId)),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -19,3 +19,15 @@ export function buildDeepSeekModelDefinition(
|
||||
api: "openai-completions",
|
||||
};
|
||||
}
|
||||
|
||||
const DEEPSEEK_V4_MODEL_IDS = new Set(["deepseek-v4-flash", "deepseek-v4-pro"]);
|
||||
|
||||
export function isDeepSeekV4ModelId(modelId: string): boolean {
|
||||
return DEEPSEEK_V4_MODEL_IDS.has(modelId.toLowerCase());
|
||||
}
|
||||
|
||||
export function isDeepSeekV4ModelRef(model: { provider?: string; id?: unknown }): boolean {
|
||||
return (
|
||||
model.provider === "deepseek" && typeof model.id === "string" && isDeepSeekV4ModelId(model.id)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createDeepSeekV4OpenAICompatibleThinkingWrapper } from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
|
||||
function isDeepSeekV4ModelId(modelId: unknown): boolean {
|
||||
return modelId === "deepseek-v4-flash" || modelId === "deepseek-v4-pro";
|
||||
}
|
||||
import { isDeepSeekV4ModelRef } from "./models.js";
|
||||
|
||||
export function createDeepSeekV4ThinkingWrapper(
|
||||
baseStreamFn: ProviderWrapStreamFnContext["streamFn"],
|
||||
@@ -12,6 +9,6 @@ export function createDeepSeekV4ThinkingWrapper(
|
||||
return createDeepSeekV4OpenAICompatibleThinkingWrapper({
|
||||
baseStreamFn,
|
||||
thinkingLevel,
|
||||
shouldPatchModel: (model) => model.provider === "deepseek" && isDeepSeekV4ModelId(model.id),
|
||||
shouldPatchModel: isDeepSeekV4ModelRef,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -24,6 +24,27 @@ describe("resolveCodexAuthIdentity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts account and plan metadata from the JWT auth claim", () => {
|
||||
const identity = resolveCodexAuthIdentity({
|
||||
accessToken: createJwt({
|
||||
"https://api.openai.com/profile": {
|
||||
email: "jwt-user@example.com",
|
||||
},
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct-123",
|
||||
chatgpt_plan_type: "prolite",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(identity).toEqual({
|
||||
accountId: "acct-123",
|
||||
chatgptPlanType: "prolite",
|
||||
email: "jwt-user@example.com",
|
||||
profileName: "jwt-user@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to credential email before synthetic ids", () => {
|
||||
const identity = resolveCodexAuthIdentity({
|
||||
accessToken: createJwt({}),
|
||||
|
||||
@@ -10,6 +10,7 @@ type CodexJwtPayload = {
|
||||
"https://api.openai.com/auth"?: {
|
||||
chatgpt_account_id?: unknown;
|
||||
chatgpt_account_user_id?: unknown;
|
||||
chatgpt_plan_type?: unknown;
|
||||
chatgpt_user_id?: unknown;
|
||||
user_id?: unknown;
|
||||
};
|
||||
@@ -67,23 +68,33 @@ export function resolveCodexAccessTokenExpiry(accessToken: string): number | und
|
||||
}
|
||||
|
||||
export function resolveCodexAuthIdentity(params: { accessToken: string; email?: string | null }): {
|
||||
accountId?: string;
|
||||
chatgptPlanType?: string;
|
||||
email?: string;
|
||||
profileName?: string;
|
||||
} {
|
||||
const payload = decodeCodexJwtPayload(params.accessToken);
|
||||
const auth = payload?.["https://api.openai.com/auth"];
|
||||
const accountId = trimNonEmptyString(auth?.chatgpt_account_id);
|
||||
const chatgptPlanType = trimNonEmptyString(auth?.chatgpt_plan_type);
|
||||
const email =
|
||||
trimNonEmptyString(payload?.["https://api.openai.com/profile"]?.email) ??
|
||||
trimNonEmptyString(params.email);
|
||||
const metadata = {
|
||||
...(accountId ? { accountId } : {}),
|
||||
...(chatgptPlanType ? { chatgptPlanType } : {}),
|
||||
};
|
||||
if (email) {
|
||||
return { email, profileName: email };
|
||||
return { ...metadata, email, profileName: email };
|
||||
}
|
||||
|
||||
const stableSubject = resolveCodexStableSubject(payload);
|
||||
if (!stableSubject) {
|
||||
return {};
|
||||
return metadata;
|
||||
}
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
profileName: `id-${Buffer.from(stableSubject).toString("base64url")}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -225,13 +225,13 @@ describe("openai codex provider", () => {
|
||||
access:
|
||||
"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjdC1kZXZpY2UtMTIzIn19.signature",
|
||||
refresh: "device-refresh-token",
|
||||
accountId: "acct-device-123",
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultModel: "openai-codex/gpt-5.5",
|
||||
});
|
||||
expect(result?.profiles[0]?.credential).not.toHaveProperty("idToken");
|
||||
expect(result?.profiles[0]?.credential).not.toHaveProperty("accountId");
|
||||
});
|
||||
|
||||
it("does not log the device pairing code in remote mode", async () => {
|
||||
@@ -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 {
|
||||
@@ -287,17 +304,33 @@ function withDefaultCodexContextMetadata(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildCodexCredentialExtra(identity: {
|
||||
accountId?: string;
|
||||
chatgptPlanType?: string;
|
||||
}): Record<string, unknown> | undefined {
|
||||
const extra = {
|
||||
...(identity.accountId ? { accountId: identity.accountId } : {}),
|
||||
...(identity.chatgptPlanType ? { chatgptPlanType: identity.chatgptPlanType } : {}),
|
||||
};
|
||||
return Object.keys(extra).length > 0 ? extra : undefined;
|
||||
}
|
||||
|
||||
async function refreshOpenAICodexOAuthCredential(cred: OAuthCredential) {
|
||||
try {
|
||||
const { refreshOpenAICodexToken } = await import("./openai-codex-provider.runtime.js");
|
||||
const refreshed = await refreshOpenAICodexToken(cred.refresh);
|
||||
const identity = resolveCodexAuthIdentity({
|
||||
accessToken: refreshed.access,
|
||||
email: cred.email,
|
||||
});
|
||||
return {
|
||||
...cred,
|
||||
...refreshed,
|
||||
type: "oauth" as const,
|
||||
provider: PROVIDER_ID,
|
||||
email: cred.email,
|
||||
email: identity.email ?? cred.email,
|
||||
displayName: cred.displayName,
|
||||
...buildCodexCredentialExtra(identity),
|
||||
};
|
||||
} catch (error) {
|
||||
const message = formatErrorMessage(error);
|
||||
@@ -342,6 +375,7 @@ async function runOpenAICodexOAuth(ctx: ProviderAuthContext) {
|
||||
expires: creds.expires,
|
||||
email: identity.email,
|
||||
profileName: identity.profileName,
|
||||
credentialExtra: buildCodexCredentialExtra(identity),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -392,6 +426,7 @@ async function runOpenAICodexDeviceCode(ctx: ProviderAuthContext) {
|
||||
expires: creds.expires,
|
||||
email: identity.email,
|
||||
profileName: identity.profileName,
|
||||
credentialExtra: buildCodexCredentialExtra(identity),
|
||||
});
|
||||
} catch (error) {
|
||||
spin.stop("OpenAI device code failed");
|
||||
@@ -495,6 +530,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 +591,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) => {
|
||||
|
||||
@@ -1,5 +1,105 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { handleSignalDirectMessageAccess } from "./access-policy.js";
|
||||
import { handleSignalDirectMessageAccess, resolveSignalAccessState } from "./access-policy.js";
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("openclaw/plugin-sdk/security-runtime")>()),
|
||||
readStoreAllowFromForDmPolicy: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
const SIGNAL_GROUP_ID = "signal-group-id";
|
||||
const OTHER_SIGNAL_GROUP_ID = "other-signal-group-id";
|
||||
const SIGNAL_SENDER = {
|
||||
kind: "phone" as const,
|
||||
e164: "+15551230000",
|
||||
raw: "+15551230000",
|
||||
};
|
||||
|
||||
async function resolveGroupAccess(params: {
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
groupId?: string;
|
||||
}) {
|
||||
const access = await resolveSignalAccessState({
|
||||
accountId: "default",
|
||||
dmPolicy: "allowlist",
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: params.allowFrom ?? [],
|
||||
groupAllowFrom: params.groupAllowFrom ?? [],
|
||||
sender: SIGNAL_SENDER,
|
||||
groupId: params.groupId,
|
||||
});
|
||||
return {
|
||||
...access,
|
||||
groupDecision: access.resolveAccessDecision(true),
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveSignalAccessState", () => {
|
||||
it("allows group messages when groupAllowFrom contains the inbound Signal group id", async () => {
|
||||
const { groupDecision } = await resolveGroupAccess({
|
||||
groupAllowFrom: [SIGNAL_GROUP_ID],
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
});
|
||||
|
||||
expect(groupDecision.decision).toBe("allow");
|
||||
});
|
||||
|
||||
it("allows Signal group target forms in groupAllowFrom", async () => {
|
||||
const groupTargetDecision = await resolveGroupAccess({
|
||||
groupAllowFrom: [`group:${SIGNAL_GROUP_ID}`],
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
});
|
||||
const signalGroupTargetDecision = await resolveGroupAccess({
|
||||
groupAllowFrom: [`signal:group:${SIGNAL_GROUP_ID}`],
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
});
|
||||
|
||||
expect(groupTargetDecision.groupDecision.decision).toBe("allow");
|
||||
expect(signalGroupTargetDecision.groupDecision.decision).toBe("allow");
|
||||
});
|
||||
|
||||
it("blocks group messages when groupAllowFrom contains a different Signal group id", async () => {
|
||||
const { groupDecision } = await resolveGroupAccess({
|
||||
groupAllowFrom: [OTHER_SIGNAL_GROUP_ID],
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
});
|
||||
|
||||
expect(groupDecision.decision).toBe("block");
|
||||
});
|
||||
|
||||
it("keeps sender allowlist compatibility for Signal group messages", async () => {
|
||||
const { groupDecision } = await resolveGroupAccess({
|
||||
groupAllowFrom: [SIGNAL_SENDER.e164],
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
});
|
||||
|
||||
expect(groupDecision.decision).toBe("allow");
|
||||
});
|
||||
|
||||
it("does not match group ids against direct-message allowFrom entries", async () => {
|
||||
const { dmAccess } = await resolveSignalAccessState({
|
||||
accountId: "default",
|
||||
dmPolicy: "allowlist",
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: [SIGNAL_GROUP_ID],
|
||||
groupAllowFrom: [],
|
||||
sender: SIGNAL_SENDER,
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
});
|
||||
|
||||
expect(dmAccess.decision).toBe("block");
|
||||
});
|
||||
|
||||
it("does not let group ids in allowFrom satisfy an explicit groupAllowFrom mismatch", async () => {
|
||||
const { groupDecision } = await resolveGroupAccess({
|
||||
allowFrom: [SIGNAL_GROUP_ID],
|
||||
groupAllowFrom: [OTHER_SIGNAL_GROUP_ID],
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
});
|
||||
|
||||
expect(groupDecision.decision).toBe("block");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleSignalDirectMessageAccess", () => {
|
||||
it("returns true for already-allowed direct messages", async () => {
|
||||
|
||||
@@ -9,6 +9,14 @@ import { isSignalSenderAllowed, type SignalSender } from "../identity.js";
|
||||
type SignalDmPolicy = "open" | "pairing" | "allowlist" | "disabled";
|
||||
type SignalGroupPolicy = "open" | "allowlist" | "disabled";
|
||||
|
||||
function isSignalGroupAllowed(groupId: string | undefined, allowEntries: string[]): boolean {
|
||||
if (!groupId) {
|
||||
return false;
|
||||
}
|
||||
const candidates = new Set([groupId, `group:${groupId}`, `signal:group:${groupId}`]);
|
||||
return allowEntries.some((entry) => candidates.has(entry));
|
||||
}
|
||||
|
||||
export async function resolveSignalAccessState(params: {
|
||||
accountId: string;
|
||||
dmPolicy: SignalDmPolicy;
|
||||
@@ -16,12 +24,17 @@ export async function resolveSignalAccessState(params: {
|
||||
allowFrom: string[];
|
||||
groupAllowFrom: string[];
|
||||
sender: SignalSender;
|
||||
groupId?: string;
|
||||
}) {
|
||||
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
|
||||
provider: "signal",
|
||||
accountId: params.accountId,
|
||||
dmPolicy: params.dmPolicy,
|
||||
});
|
||||
const isSenderAllowed = (allowEntries: string[]) =>
|
||||
isSignalSenderAllowed(params.sender, allowEntries);
|
||||
const isSenderOrGroupAllowed = (allowEntries: string[]) =>
|
||||
isSenderAllowed(allowEntries) || isSignalGroupAllowed(params.groupId, allowEntries);
|
||||
const resolveAccessDecision = (isGroup: boolean) =>
|
||||
resolveDmGroupAccessWithLists({
|
||||
isGroup,
|
||||
@@ -30,11 +43,12 @@ export async function resolveSignalAccessState(params: {
|
||||
allowFrom: params.allowFrom,
|
||||
groupAllowFrom: params.groupAllowFrom,
|
||||
storeAllowFrom,
|
||||
isSenderAllowed: (allowEntries) => isSignalSenderAllowed(params.sender, allowEntries),
|
||||
isSenderAllowed: isGroup ? isSenderOrGroupAllowed : isSenderAllowed,
|
||||
});
|
||||
const dmAccess = resolveAccessDecision(false);
|
||||
return {
|
||||
resolveAccessDecision,
|
||||
isGroupAllowed: isSenderOrGroupAllowed,
|
||||
dmAccess,
|
||||
effectiveDmAllow: dmAccess.effectiveAllowFrom,
|
||||
effectiveGroupAllow: dmAccess.effectiveGroupAllowFrom,
|
||||
|
||||
@@ -1,32 +1,38 @@
|
||||
import { expectChannelInboundContextContract as expectInboundContextContract } from "openclaw/plugin-sdk/channel-contract-testing";
|
||||
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SignalReactionMessage } from "./event-handler.types.js";
|
||||
vi.useRealTimers();
|
||||
const [
|
||||
{ createBaseSignalEventHandlerDeps, createSignalReceiveEvent },
|
||||
{ createSignalEventHandler },
|
||||
] = await Promise.all([import("./event-handler.test-harness.js"), import("./event-handler.js")]);
|
||||
|
||||
const { sendTypingMock, sendReadReceiptMock, dispatchInboundMessageMock, capture } = vi.hoisted(
|
||||
() => {
|
||||
const captureState: { ctx?: MsgContext } = {};
|
||||
return {
|
||||
sendTypingMock: vi.fn(),
|
||||
sendReadReceiptMock: vi.fn(),
|
||||
dispatchInboundMessageMock: vi.fn(
|
||||
async (params: {
|
||||
ctx: MsgContext;
|
||||
replyOptions?: { onReplyStart?: () => void | Promise<void> };
|
||||
}) => {
|
||||
captureState.ctx = params.ctx;
|
||||
await Promise.resolve(params.replyOptions?.onReplyStart?.());
|
||||
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
|
||||
},
|
||||
),
|
||||
capture: captureState,
|
||||
};
|
||||
},
|
||||
);
|
||||
const {
|
||||
sendTypingMock,
|
||||
sendReadReceiptMock,
|
||||
dispatchInboundMessageMock,
|
||||
enqueueSystemEventMock,
|
||||
capture,
|
||||
} = vi.hoisted(() => {
|
||||
const captureState: { ctx?: MsgContext } = {};
|
||||
return {
|
||||
sendTypingMock: vi.fn(),
|
||||
sendReadReceiptMock: vi.fn(),
|
||||
enqueueSystemEventMock: vi.fn(),
|
||||
dispatchInboundMessageMock: vi.fn(
|
||||
async (params: {
|
||||
ctx: MsgContext;
|
||||
replyOptions?: { onReplyStart?: () => void | Promise<void> };
|
||||
}) => {
|
||||
captureState.ctx = params.ctx;
|
||||
await Promise.resolve(params.replyOptions?.onReplyStart?.());
|
||||
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
|
||||
},
|
||||
),
|
||||
capture: captureState,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../send.js", () => ({
|
||||
sendMessageSignal: vi.fn(),
|
||||
@@ -57,11 +63,22 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/system-event-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/system-event-runtime")>(
|
||||
"openclaw/plugin-sdk/system-event-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
enqueueSystemEvent: enqueueSystemEventMock,
|
||||
};
|
||||
});
|
||||
|
||||
describe("signal createSignalEventHandler inbound context", () => {
|
||||
beforeEach(() => {
|
||||
delete capture.ctx;
|
||||
sendTypingMock.mockReset().mockResolvedValue(true);
|
||||
sendReadReceiptMock.mockReset().mockResolvedValue(true);
|
||||
enqueueSystemEventMock.mockReset();
|
||||
dispatchInboundMessageMock.mockClear();
|
||||
});
|
||||
|
||||
@@ -197,6 +214,154 @@ describe("signal createSignalEventHandler inbound context", () => {
|
||||
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows Signal groups whose id is listed in groupAllowFrom", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: {
|
||||
messages: { inbound: { debounceMs: 0 } },
|
||||
channels: {
|
||||
signal: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["g1"],
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
},
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["g1"],
|
||||
historyLimit: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
dataMessage: {
|
||||
message: "hello from allowed group",
|
||||
groupInfo: { groupId: "g1", groupName: "Test Group" },
|
||||
attachments: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capture.ctx).toBeTruthy();
|
||||
expect(capture.ctx?.ChatType).toBe("group");
|
||||
expect(capture.ctx?.From).toBe("group:g1");
|
||||
});
|
||||
|
||||
it("blocks Signal groups whose id is not listed in groupAllowFrom", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: {
|
||||
messages: { inbound: { debounceMs: 0 } },
|
||||
channels: {
|
||||
signal: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["g2"],
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
},
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["g2"],
|
||||
historyLimit: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
dataMessage: {
|
||||
message: "hello from blocked group",
|
||||
groupInfo: { groupId: "g1", groupName: "Test Group" },
|
||||
attachments: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capture.ctx).toBeUndefined();
|
||||
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("authorizes group control commands when groupAllowFrom matches the Signal group id", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: {
|
||||
messages: {
|
||||
inbound: { debounceMs: 0 },
|
||||
groupChat: { mentionPatterns: ["@bot"] },
|
||||
},
|
||||
channels: {
|
||||
signal: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["g1"],
|
||||
groups: { "*": { requireMention: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["g1"],
|
||||
historyLimit: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
dataMessage: {
|
||||
message: "/status",
|
||||
groupInfo: { groupId: "g1", groupName: "Test Group" },
|
||||
attachments: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capture.ctx).toBeTruthy();
|
||||
expect(capture.ctx?.CommandAuthorized).toBe(true);
|
||||
});
|
||||
|
||||
it("allows reaction-only group events when groupAllowFrom matches the reaction group id", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: {
|
||||
messages: { inbound: { debounceMs: 0 } },
|
||||
channels: {
|
||||
signal: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["g1"],
|
||||
},
|
||||
},
|
||||
},
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["g1"],
|
||||
reactionMode: "all",
|
||||
isSignalReactionMessage: (reaction): reaction is SignalReactionMessage => Boolean(reaction),
|
||||
shouldEmitSignalReactionNotification: () => true,
|
||||
resolveSignalReactionTargets: () => [
|
||||
{ kind: "phone", id: "+15550001111", display: "+15550001111" },
|
||||
],
|
||||
buildSignalReactionSystemEventText: () => "reaction added",
|
||||
historyLimit: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
reactionMessage: {
|
||||
emoji: "+1",
|
||||
targetSentTimestamp: 1700000000000,
|
||||
groupInfo: { groupId: "g1", groupName: "Test Group" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
|
||||
"reaction added",
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:signal:group:g1",
|
||||
trusted: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops quote-only group context from non-allowlisted quoted senders in allowlist mode", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
|
||||
@@ -134,6 +134,32 @@ describe("signal mention gating", () => {
|
||||
expect(getCapturedCtx()?.WasMentioned).toBe(false);
|
||||
});
|
||||
|
||||
it("allows explicitly configured Signal groups by group id without a mention", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: {
|
||||
messages: {
|
||||
inbound: { debounceMs: 0 },
|
||||
groupChat: { mentionPatterns: ["@bot"] },
|
||||
},
|
||||
channels: {
|
||||
signal: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["group:g1"],
|
||||
groups: { g1: {} },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["group:g1"],
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(makeGroupEvent({ message: "hello everyone" }));
|
||||
expect(capturedCtx).toBeTruthy();
|
||||
expect(getCapturedCtx()?.WasMentioned).toBe(false);
|
||||
});
|
||||
|
||||
it("records pending history for skipped group messages", async () => {
|
||||
const { handler, groupHistories } = createMentionGatedHistoryHandler();
|
||||
await handler(makeGroupEvent({ message: "hello from alice" }));
|
||||
|
||||
@@ -552,19 +552,25 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
const rawMessage = dataMessage?.message ?? "";
|
||||
const normalizedMessage = renderSignalMentions(rawMessage, dataMessage?.mentions);
|
||||
const messageText = normalizedMessage.trim();
|
||||
const groupId = dataMessage?.groupInfo?.groupId ?? undefined;
|
||||
const groupId = dataMessage?.groupInfo?.groupId ?? reaction?.groupInfo?.groupId ?? undefined;
|
||||
const isGroup = Boolean(groupId);
|
||||
|
||||
const senderDisplay = formatSignalSenderDisplay(sender);
|
||||
const { resolveAccessDecision, dmAccess, effectiveDmAllow, effectiveGroupAllow } =
|
||||
await resolveSignalAccessState({
|
||||
accountId: deps.accountId,
|
||||
dmPolicy: deps.dmPolicy,
|
||||
groupPolicy: deps.groupPolicy,
|
||||
allowFrom: deps.allowFrom,
|
||||
groupAllowFrom: deps.groupAllowFrom,
|
||||
sender,
|
||||
});
|
||||
const {
|
||||
resolveAccessDecision,
|
||||
isGroupAllowed,
|
||||
dmAccess,
|
||||
effectiveDmAllow,
|
||||
effectiveGroupAllow,
|
||||
} = await resolveSignalAccessState({
|
||||
accountId: deps.accountId,
|
||||
dmPolicy: deps.dmPolicy,
|
||||
groupPolicy: deps.groupPolicy,
|
||||
allowFrom: deps.allowFrom,
|
||||
groupAllowFrom: deps.groupAllowFrom,
|
||||
sender,
|
||||
groupId,
|
||||
});
|
||||
const quoteText = normalizeOptionalString(dataMessage?.quote?.text) ?? "";
|
||||
const { contextVisibilityMode, quoteSenderAllowed, visibleQuoteText, visibleQuoteSender } =
|
||||
resolveSignalQuoteContext({
|
||||
@@ -650,7 +656,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
const useAccessGroups = deps.cfg.commands?.useAccessGroups !== false;
|
||||
const commandDmAllow = isGroup ? deps.allowFrom : effectiveDmAllow;
|
||||
const ownerAllowedForCommands = isSignalSenderAllowed(sender, commandDmAllow);
|
||||
const groupAllowedForCommands = isSignalSenderAllowed(sender, effectiveGroupAllow);
|
||||
const groupAllowedForCommands = isGroupAllowed(effectiveGroupAllow);
|
||||
const hasControlCommandInMessage = hasControlCommand(messageText, deps.cfg);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
@@ -688,6 +694,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
channel: "signal",
|
||||
groupId,
|
||||
accountId: deps.accountId,
|
||||
configuredGroupDefaultsToNoMention: true,
|
||||
});
|
||||
const canDetectMention = mentionRegexes.length > 0;
|
||||
const mentionDecision = resolveInboundMentionDecision({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -135,11 +135,25 @@ const TELEGRAM_TIMEOUT_FALLBACK_METHODS = new Set([
|
||||
"setmycommands",
|
||||
"setwebhook",
|
||||
]);
|
||||
|
||||
function shouldRetryTimedOutTelegramControlRequest(method: string | null): boolean {
|
||||
return method !== null && TELEGRAM_TIMEOUT_FALLBACK_METHODS.has(method);
|
||||
}
|
||||
|
||||
function resolveTelegramClientTimeoutSeconds(params: {
|
||||
value: unknown;
|
||||
minimum?: number;
|
||||
}): number | undefined {
|
||||
const { value, minimum } = params;
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const configured = Math.max(1, Math.floor(value));
|
||||
if (typeof minimum !== "number" || !Number.isFinite(minimum)) {
|
||||
return configured;
|
||||
}
|
||||
return Math.max(configured, Math.max(1, Math.floor(minimum)));
|
||||
}
|
||||
|
||||
export function createTelegramBotCore(
|
||||
opts: TelegramBotOptions & { telegramDeps: TelegramBotDeps },
|
||||
): TelegramBotInstance {
|
||||
@@ -298,10 +312,10 @@ export function createTelegramBotCore(
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutSeconds =
|
||||
typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds)
|
||||
? Math.max(1, Math.floor(telegramCfg.timeoutSeconds))
|
||||
: undefined;
|
||||
const timeoutSeconds = resolveTelegramClientTimeoutSeconds({
|
||||
value: telegramCfg?.timeoutSeconds,
|
||||
minimum: opts.minimumClientTimeoutSeconds,
|
||||
});
|
||||
const apiRoot = normalizeOptionalString(telegramCfg.apiRoot);
|
||||
const normalizedApiRoot = apiRoot ? normalizeTelegramApiRoot(apiRoot) : undefined;
|
||||
const client: ApiClientOptions | undefined =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -248,6 +248,36 @@ describe("createTelegramBot", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("honors low timeoutSeconds when no polling floor is requested", () => {
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: { dmPolicy: "open", allowFrom: ["*"], timeoutSeconds: 10 },
|
||||
},
|
||||
});
|
||||
createTelegramBot({ token: "tok" });
|
||||
expect(botCtorSpy).toHaveBeenCalledWith(
|
||||
"tok",
|
||||
expect.objectContaining({
|
||||
client: expect.objectContaining({ timeoutSeconds: 10 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps polling client timeout above the getUpdates request guard", () => {
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: { dmPolicy: "open", allowFrom: ["*"], timeoutSeconds: 10 },
|
||||
},
|
||||
});
|
||||
createTelegramBot({ token: "tok", minimumClientTimeoutSeconds: 45 });
|
||||
expect(botCtorSpy).toHaveBeenCalledWith(
|
||||
"tok",
|
||||
expect.objectContaining({
|
||||
client: expect.objectContaining({ timeoutSeconds: 45 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes full Telegram bot endpoint apiRoot before passing it to grammY", () => {
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
|
||||
@@ -16,6 +16,8 @@ export type TelegramBotOptions = {
|
||||
config?: OpenClawConfig;
|
||||
/** Signal to abort in-flight Telegram API fetch requests (e.g. getUpdates) on shutdown. */
|
||||
fetchAbortSignal?: AbortSignal;
|
||||
/** Minimum grammY client timeout when timeoutSeconds is configured on long-polling bots. */
|
||||
minimumClientTimeoutSeconds?: number;
|
||||
updateOffset?: {
|
||||
lastUpdateId?: number | null;
|
||||
onUpdateId?: (updateId: number) => void | Promise<void>;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -276,6 +276,9 @@ describe("TelegramPollingSession", () => {
|
||||
await session.runUntilAbort();
|
||||
|
||||
expect(runMock).toHaveBeenCalledTimes(2);
|
||||
expect(createTelegramBotMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ minimumClientTimeoutSeconds: 45 }),
|
||||
);
|
||||
expect(computeBackoffMock).toHaveBeenCalledTimes(1);
|
||||
expect(sleepWithAbortMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { isRecoverableTelegramNetworkError } from "./network-errors.js";
|
||||
import { TelegramPollingLivenessTracker } from "./polling-liveness.js";
|
||||
import { createTelegramPollingStatusPublisher } from "./polling-status.js";
|
||||
import { TelegramPollingTransportState } from "./polling-transport-state.js";
|
||||
import { TELEGRAM_GET_UPDATES_REQUEST_TIMEOUT_MS } from "./request-timeouts.js";
|
||||
|
||||
const TELEGRAM_POLL_RESTART_POLICY = {
|
||||
initialMs: 2000,
|
||||
@@ -27,6 +28,9 @@ const MIN_POLL_STALL_THRESHOLD_MS = 30_000;
|
||||
const MAX_POLL_STALL_THRESHOLD_MS = 600_000;
|
||||
const POLL_WATCHDOG_INTERVAL_MS = 30_000;
|
||||
const POLL_STOP_GRACE_MS = 15_000;
|
||||
const TELEGRAM_POLLING_CLIENT_TIMEOUT_FLOOR_SECONDS = Math.ceil(
|
||||
TELEGRAM_GET_UPDATES_REQUEST_TIMEOUT_MS / 1000,
|
||||
);
|
||||
|
||||
type TelegramBot = ReturnType<typeof createTelegramBot>;
|
||||
|
||||
@@ -184,6 +188,7 @@ export class TelegramPollingSession {
|
||||
config: this.opts.config,
|
||||
accountId: this.opts.accountId,
|
||||
fetchAbortSignal: fetchAbortController.signal,
|
||||
minimumClientTimeoutSeconds: TELEGRAM_POLLING_CLIENT_TIMEOUT_FLOOR_SECONDS,
|
||||
updateOffset: {
|
||||
lastUpdateId: this.opts.getLastUpdateId(),
|
||||
onUpdateId: this.opts.persistUpdateId,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const TELEGRAM_GET_UPDATES_REQUEST_TIMEOUT_MS = 45_000;
|
||||
|
||||
const TELEGRAM_REQUEST_TIMEOUTS_MS = {
|
||||
// Bound startup/control-plane calls so the gateway cannot report Telegram as
|
||||
// healthy while provider startup is still hung on Bot API setup.
|
||||
@@ -9,7 +11,7 @@ const TELEGRAM_REQUEST_TIMEOUTS_MS = {
|
||||
getchat: 15_000,
|
||||
getfile: 30_000,
|
||||
getme: 15_000,
|
||||
getupdates: 45_000,
|
||||
getupdates: TELEGRAM_GET_UPDATES_REQUEST_TIMEOUT_MS,
|
||||
pinchatmessage: 15_000,
|
||||
sendanimation: 30_000,
|
||||
sendaudio: 30_000,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw",
|
||||
"version": "2026.4.27",
|
||||
"version": "2026.4.29-beta.4",
|
||||
"description": "Multi-channel AI gateway with extensible messaging integrations",
|
||||
"keywords": [],
|
||||
"homepage": "https://github.com/openclaw/openclaw#readme",
|
||||
|
||||
@@ -75,8 +75,8 @@ assert_kitchen_sink_removed() {
|
||||
|
||||
run_success_scenario() {
|
||||
echo "Testing ${KITCHEN_SINK_LABEL} install from ${KITCHEN_SINK_SPEC}..."
|
||||
configure_kitchen_sink_runtime
|
||||
run_logged_print "kitchen-sink-install-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins install "$KITCHEN_SINK_SPEC"
|
||||
configure_kitchen_sink_runtime
|
||||
run_logged_print "kitchen-sink-enable-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins enable "$KITCHEN_SINK_ID"
|
||||
node "$OPENCLAW_ENTRY" plugins list --json >"/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-plugins.json"
|
||||
node "$OPENCLAW_ENTRY" plugins inspect "$KITCHEN_SINK_ID" --json >"/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-inspect.json"
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user