Compare commits

...

80 Commits

Author SHA1 Message Date
Peter Steinberger
a448042c2e test(qa): extend restart boundary wait 2026-04-30 21:40:32 +01:00
Peter Steinberger
a911070444 chore(release): prepare 2026.4.29 2026-04-30 21:27:03 +01:00
Peter Steinberger
d052f85b55 test(release): align beta validation harness 2026-04-30 21:26:38 +01:00
Peter Steinberger
f68c232294 chore(release): prepare 2026.4.29-beta.4 2026-04-30 20:43:46 +01:00
Peter Steinberger
b0483510a9 fix(auth): scope external CLI credential discovery 2026-04-30 20:41:54 +01:00
Peter Steinberger
cad90268c0 fix(codex): time out silent app-server turns 2026-04-30 19:23:54 +01:00
Peter Steinberger
25cb61cacb fix(plugins): harden runtime mirrors 2026-04-30 19:16:20 +01:00
Logan Ye
a4d338c170 fix: guard blank prompt submissions (#74168)
Fixes #74137.\n\nThanks @yelog.
2026-04-30 19:15:19 +01:00
NVIDIAN
6a18a2edc9 fix(deepseek): expose V4 max thinking levels (#73008)
Merged via squash.

Prepared head SHA: ef561a59de
Co-authored-by: ai-hpc <183861985+ai-hpc@users.noreply.github.com>
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Reviewed-by: @hxy91819
2026-04-30 19:14:54 +01:00
Peter Steinberger
8be5a7eed7 fix: keep telegram polling timeout above long poll 2026-04-30 19:14:48 +01:00
Peter Steinberger
62809b2560 fix(browser): use source config for proxy decisions 2026-04-30 19:14:13 +01:00
Peter Steinberger
56c9b18457 fix: reduce runtime mirror and signal group regressions 2026-04-30 19:14:06 +01:00
Peter Steinberger
33d77a08d1 fix(signal): match group allowlists against group ids 2026-04-30 19:13:17 +01:00
keshavbotagent
7a7de82c58 fix: preserve OpenAI Codex OAuth transport (#75111)
Preserve the existing wrapped OpenAI Codex stream so PI OAuth bearer injection reaches ChatGPT/Codex Responses, and scope native Codex payload sanitization to the ChatGPT backend.\n\nThanks @keshavbotagent.
2026-04-30 19:05:41 +01:00
Peter Steinberger
6bad056c18 docs(config): refresh compaction baseline 2026-04-30 19:04:54 +01:00
marchpure
3f0e6182e5 feat(agents): add mid-turn compaction precheck (#73499)
Co-authored-by: haoxingjun <haoxingjun@bytedance.com>
2026-04-30 18:55:41 +01:00
Peter Steinberger
9204476b19 chore(release): prepare 2026.4.29-beta.3 2026-04-30 18:47:09 +01:00
Peter Steinberger
cdea49eb56 test(parallels): fix macos npm update sudo fallback 2026-04-30 18:42:03 +01:00
Peter Steinberger
46f6b14385 fix(release): keep tool guard backport scoped 2026-04-30 18:35:29 +01:00
Peter Steinberger
9ee651e1f5 fix: align tool-result guard budget
(cherry picked from commit 1a2228d291)
2026-04-30 18:34:36 +01:00
Peter Steinberger
a7be4b3aff test(gateway): align lock conflict success expectation
(cherry picked from commit 46888f5afb)
2026-04-30 18:33:41 +01:00
Peter Steinberger
76bb2c6a45 fix(gateway): stop systemd EADDRINUSE restart loops
(cherry picked from commit 9289a502bb)
2026-04-30 18:33:41 +01:00
Peter Steinberger
c874749ccb test(release): install kitchen sink before channel config 2026-04-30 18:29:13 +01:00
Peter Steinberger
1e78689ed8 chore(release): refresh plugin sdk api baseline 2026-04-30 17:50:07 +01:00
Peter Steinberger
eadd054348 fix(release): satisfy lint preflight 2026-04-30 17:34:50 +01:00
Peter Steinberger
1d8769604e chore(release): prepare 2026.4.29-beta.2 2026-04-30 17:25:54 +01:00
Peter Steinberger
10c39c33bf test(parallels): accept silent Windows background starts 2026-04-30 17:06:30 +01:00
Peter Steinberger
d1708b8003 test(parallels): stabilize Windows background launches 2026-04-30 16:56:31 +01:00
Peter Steinberger
15f95e3823 test(parallels): harden npm update guest scripts 2026-04-30 16:37:34 +01:00
Peter Steinberger
6c0ce491af test(parallels): retry missing Windows background launch 2026-04-30 16:13:48 +01:00
Peter Steinberger
38e49d2fdb test(infra): cover fallback tmp chmod race 2026-04-30 15:51:09 +01:00
Peter Steinberger
7420453611 fix(infra): tolerate concurrent tmp dir repair 2026-04-30 15:51:07 +01:00
Peter Steinberger
c8a0db0599 docs(changelog): credit refresh guard contributors 2026-04-30 15:50:43 +01:00
Peter Steinberger
53f977bd79 fix(browser): share control runtime state 2026-04-30 15:50:40 +01:00
Peter Steinberger
a223d62828 fix(signal): harden signal-cli installer downloads 2026-04-30 15:42:25 +01:00
Peter Steinberger
6b2bb4167a fix(auto-reply): preserve visible fallback for requested modes 2026-04-30 15:41:59 +01:00
Peter Steinberger
501ac000e3 fix: retain local memory runtime deps 2026-04-30 15:41:32 +01:00
Peter Steinberger
2dbb2bf9b7 docs(changelog): note Signal regression fixes 2026-04-30 15:16:07 +01:00
Peter Steinberger
c75ff17f06 fix(signal): handle attachment and SSE regressions 2026-04-30 15:15:44 +01:00
Peter Steinberger
147a42fe66 fix: avoid provider policy runtime deps 2026-04-30 15:15:02 +01:00
Peter Steinberger
8826f38545 test(gateway): cover web fetch startup bind 2026-04-30 15:05:46 +01:00
Peter Steinberger
d9a7459511 docs(release): dedupe codex mini changelog note 2026-04-30 15:05:41 +01:00
Peter Steinberger
47cd0d758d fix(models): restore codex mini oauth route 2026-04-30 15:02:28 +01:00
Peter Steinberger
3b4bb28f03 fix(plugins): keep runtime deps manifest complete
Co-authored-by: HCL <chenglunhu@gmail.com>
2026-04-30 14:59:51 +01:00
Peter Steinberger
3c5370879d fix(auto-reply): surface private group replies 2026-04-30 14:59:34 +01:00
Peter Steinberger
6f5a9cbf9e fix(slack): gate bot room relays on owner presence 2026-04-30 14:52:18 +01:00
Peter Steinberger
f66320efcf fix(secrets): skip optional web fetch discovery before bind 2026-04-30 14:52:05 +01:00
Peter Steinberger
5d838b0d0f fix(models): restore codex mini oauth route 2026-04-30 14:51:48 +01:00
Peter Steinberger
838d0c02e3 fix(agents): bound subagent orphan recovery 2026-04-30 14:51:35 +01:00
Peter Steinberger
c81b9547de docs(release): fold backports into 2026.4.29 notes 2026-04-30 14:23:25 +01:00
Vincent Koc
f39c5e4b04 fix(telegram): remove unused draft stream helper 2026-04-30 14:23:04 +01:00
Vincent Koc
bbf6b911b0 docs(tools): note explicit alsoAllow needed under restrictive profiles (4aa08e9d79) 2026-04-30 14:23:04 +01:00
Ayaan Zaidi
3d8946e8dd fix: remove Telegram native draft previews (#75073) 2026-04-30 14:23:04 +01:00
Ayaan Zaidi
0a98aad6c6 docs(telegram): remove native draft fallback note 2026-04-30 14:23:04 +01:00
Ayaan Zaidi
fb7db3a156 test(telegram): cover message-only previews 2026-04-30 14:23:04 +01:00
Ayaan Zaidi
90d875ce97 fix(telegram): remove native draft preview transport 2026-04-30 14:23:04 +01:00
Alex Knight
55dc865d75 fix(security): stop implicit tool grants from config sections (#47487) (#75055)
* fix(security): stop implicit tool grants from config sections (#47487)

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

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

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

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

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

Fixes #47487

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

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

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

View File

@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Security/tools: configured tool sections (`tools.exec`, `tools.fs`) no longer implicitly widen restrictive profiles (`messaging`, `minimal`). Users who need those tools under a restricted profile must add explicit `alsoAllow` entries; a startup warning identifies affected configs. Fixes #47487. Thanks @amknight.
- Agents/commitments: add opt-in inferred follow-up commitments with hidden batched extraction, per-agent/per-channel scoping, heartbeat delivery, CLI management, a simple `commitments.enabled`/`commitments.maxPerDay` config, and heartbeat-interval due-time clamping so magical check-ins do not echo immediately. (#74189) Thanks @vignesh07.
- Messages/queue: make `steer` drain all pending Pi steering messages at the next model boundary, keep legacy one-at-a-time steering as `queue`, and add a dedicated steering queue docs page. Thanks @vincentkoc.
- Messages/queue: default active-run queueing to `steer` with a 500ms followup fallback debounce, and document the queue modes, precedence, and drop policies on the command queue page. Thanks @vincentkoc.
@@ -41,6 +42,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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
c3bcb3a3da46bbbe15a7798869911cab109df950ee51c79fd86c96bb809dfdf1 config-baseline.json
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

View File

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

View File

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

View File

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

View File

@@ -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).

View File

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

View File

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

View File

@@ -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"`

View File

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

View File

@@ -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

View File

@@ -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`.

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -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

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

@@ -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.

View File

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

View File

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

View File

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

View File

@@ -1,36 +1,32 @@
import {
createBrowserControlContext,
ensureBrowserControlRuntime,
getBrowserControlState,
stopBrowserControlRuntime,
} from "./browser-control-state.js";
import { loadBrowserConfigForRuntimeRefresh } from "./browser/config-refresh-source.js";
import { resolveBrowserConfig } from "./browser/config.js";
import { ensureBrowserControlAuth } from "./browser/control-auth.js";
import { createBrowserRuntimeState, stopBrowserRuntime } from "./browser/runtime-lifecycle.js";
import { type BrowserServerState, createBrowserRouteContext } from "./browser/server-context.js";
import type { BrowserServerState } from "./browser/server-context.js";
import { getRuntimeConfig } from "./config/config.js";
import { createSubsystemLogger } from "./logging/subsystem.js";
import { isDefaultBrowserPluginEnabled } from "./plugin-enabled.js";
let state: BrowserServerState | null = null;
const log = createSubsystemLogger("browser");
const logService = log.child("service");
export function getBrowserControlState(): BrowserServerState | null {
return state;
}
export function createBrowserControlContext() {
return createBrowserRouteContext({
getState: () => state,
refreshConfigFromDisk: true,
});
}
export async function startBrowserControlServiceFromConfig(): Promise<BrowserServerState | null> {
if (state) {
return state;
const current = getBrowserControlState();
if (current) {
return current;
}
const cfg = getRuntimeConfig();
if (!isDefaultBrowserPluginEnabled(cfg)) {
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 };

View File

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

View File

@@ -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: {},

View File

@@ -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);

View File

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

View File

@@ -1,6 +1,13 @@
import type { Server } from "node:http";
import express from "express";
import {
createBrowserControlContext,
ensureBrowserControlRuntime,
getBrowserControlState,
stopBrowserControlRuntime,
} from "./browser-control-state.js";
import { deleteBridgeAuthForPort, setBridgeAuthForPort } from "./browser/bridge-auth-registry.js";
import { loadBrowserConfigForRuntimeRefresh } from "./browser/config-refresh-source.js";
import { resolveBrowserConfig } from "./browser/config.js";
import {
ensureBrowserControlAuth,
@@ -9,8 +16,7 @@ import {
} from "./browser/control-auth.js";
import { registerBrowserRoutes } from "./browser/routes/index.js";
import type { BrowserRouteRegistrar } from "./browser/routes/types.js";
import { createBrowserRuntimeState, stopBrowserRuntime } from "./browser/runtime-lifecycle.js";
import { type BrowserServerState, createBrowserRouteContext } from "./browser/server-context.js";
import type { BrowserServerState } from "./browser/server-context.js";
import {
installBrowserAuthMiddleware,
installBrowserCommonMiddleware,
@@ -19,20 +25,21 @@ import { getRuntimeConfig } from "./config/config.js";
import { createSubsystemLogger } from "./logging/subsystem.js";
import { isDefaultBrowserPluginEnabled } from "./plugin-enabled.js";
let state: BrowserServerState | null = null;
const log = createSubsystemLogger("browser");
const logServer = log.child("server");
export async function startBrowserControlServerFromConfig(): Promise<BrowserServerState | null> {
if (state) {
return state;
const current = getBrowserControlState();
if (current?.server) {
return current;
}
const cfg = getRuntimeConfig();
if (!isDefaultBrowserPluginEnabled(cfg)) {
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),
});

View File

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

View File

@@ -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",

View File

@@ -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,

View File

@@ -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 = (

View File

@@ -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)),
},
});

View File

@@ -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)
);
}

View File

@@ -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,
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({}),

View File

@@ -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")}`,
};
}

View File

@@ -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 },
}),
);
});

View File

@@ -52,6 +52,7 @@ const OPENAI_CODEX_GPT_55_MODEL_ID = "gpt-5.5";
const OPENAI_CODEX_GPT_55_PRO_MODEL_ID = "gpt-5.5-pro";
const OPENAI_CODEX_GPT_54_MODEL_ID = "gpt-5.4";
const OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID = "gpt-5.4-codex";
const OPENAI_CODEX_GPT_54_MINI_MODEL_ID = "gpt-5.4-mini";
const OPENAI_CODEX_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro";
const OPENAI_CODEX_GPT_55_CODEX_CONTEXT_TOKENS = 400_000;
const OPENAI_CODEX_GPT_55_DEFAULT_RUNTIME_CONTEXT_TOKENS = 272_000;
@@ -59,6 +60,7 @@ const OPENAI_CODEX_GPT_55_PRO_NATIVE_CONTEXT_TOKENS = 1_000_000;
const OPENAI_CODEX_GPT_55_PRO_DEFAULT_CONTEXT_TOKENS = 272_000;
const OPENAI_CODEX_GPT_54_NATIVE_CONTEXT_TOKENS = 1_050_000;
const OPENAI_CODEX_GPT_54_DEFAULT_CONTEXT_TOKENS = 272_000;
const OPENAI_CODEX_GPT_54_MINI_NATIVE_CONTEXT_TOKENS = 400_000;
const OPENAI_CODEX_GPT_54_MAX_TOKENS = 128_000;
const OPENAI_CODEX_GPT_55_PRO_COST = {
input: 30,
@@ -78,6 +80,12 @@ const OPENAI_CODEX_GPT_54_PRO_COST = {
cacheRead: 0,
cacheWrite: 0,
} as const;
const OPENAI_CODEX_GPT_54_MINI_COST = {
input: 0.75,
output: 4.5,
cacheRead: 0.075,
cacheWrite: 0,
} as const;
const OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.3-codex", "gpt-5.2-codex"] as const;
/** Legacy codex rows first; fall back to catalog `gpt-5.4` when the API omits 5.3/5.2. */
const OPENAI_CODEX_GPT_54_CATALOG_SYNTH_TEMPLATE_MODEL_IDS = [
@@ -105,6 +113,7 @@ const OPENAI_CODEX_MODERN_MODEL_IDS = [
OPENAI_CODEX_GPT_55_PRO_MODEL_ID,
OPENAI_CODEX_GPT_54_MODEL_ID,
OPENAI_CODEX_GPT_54_PRO_MODEL_ID,
OPENAI_CODEX_GPT_54_MINI_MODEL_ID,
"gpt-5.2",
"gpt-5.2-codex",
OPENAI_CODEX_GPT_53_MODEL_ID,
@@ -227,6 +236,14 @@ function resolveCodexForwardCompatModel(ctx: ProviderResolveDynamicModelContext)
maxTokens: OPENAI_CODEX_GPT_54_MAX_TOKENS,
cost: OPENAI_CODEX_GPT_54_PRO_COST,
};
} else if (lower === OPENAI_CODEX_GPT_54_MINI_MODEL_ID) {
templateIds = OPENAI_CODEX_GPT_54_CATALOG_SYNTH_TEMPLATE_MODEL_IDS;
patch = {
contextWindow: OPENAI_CODEX_GPT_54_MINI_NATIVE_CONTEXT_TOKENS,
contextTokens: OPENAI_CODEX_GPT_54_DEFAULT_CONTEXT_TOKENS,
maxTokens: OPENAI_CODEX_GPT_54_MAX_TOKENS,
cost: OPENAI_CODEX_GPT_54_MINI_COST,
};
} else if (lower === OPENAI_CODEX_GPT_53_MODEL_ID) {
templateIds = OPENAI_CODEX_TEMPLATE_MODEL_IDS;
} else {
@@ -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);
},
};

View File

@@ -785,7 +785,11 @@ describe("buildOpenAIProvider", () => {
payload,
});
expect(mocks.openAIResponsesTransportStreamFn).toHaveBeenCalledTimes(1);
expect(mocks.openAIResponsesTransportStreamFn).not.toHaveBeenCalled();
expect(result.options?.headers).toMatchObject({
originator: "openclaw",
"User-Agent": expect.stringMatching(/^openclaw\//u),
});
expect(result.payload.store).toBe(false);
expect(result.payload.service_tier).toBe("priority");
expect(result.payload.text).toEqual({ verbosity: "high" });

View File

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

View File

@@ -44,6 +44,7 @@ import type { QaTransportAdapter } from "./qa-transport.js";
export type { QaCliBackendAuthMode } from "./providers/env.js";
const QA_GATEWAY_CHILD_STARTUP_MAX_ATTEMPTS = 5;
const QA_GATEWAY_CHILD_RPC_RETRY_HEALTH_TIMEOUT_MS = 60_000;
const QA_GATEWAY_CHILD_RESTART_BOUNDARY_TIMEOUT_MS = 90_000;
const QA_GATEWAY_CHILD_BLOCKED_SECRET_ENV_VARS = Object.freeze([
"OPENCLAW_QA_CONVEX_SECRET_CI",
"OPENCLAW_QA_CONVEX_SECRET_MAINTAINER",
@@ -276,7 +277,7 @@ async function waitForQaGatewayRestartBoundary(params: {
pollMs?: number;
timeoutMs?: number;
}) {
const timeoutMs = params.timeoutMs ?? 30_000;
const timeoutMs = params.timeoutMs ?? QA_GATEWAY_CHILD_RESTART_BOUNDARY_TIMEOUT_MS;
const pollMs = params.pollMs ?? 100;
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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({

View File

@@ -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" }));

View File

@@ -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({

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 =

View File

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

View File

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

View File

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

View File

@@ -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: {

View File

@@ -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>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
});

View File

@@ -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,

View File

@@ -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,

View File

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

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