Compare commits

...

47 Commits

Author SHA1 Message Date
Peter Steinberger
e93216080a test(release): expect public latest in installer smoke 2026-05-30 20:01:03 +01:00
Peter Steinberger
907e66d497 test(release): harden live release checks 2026-05-30 19:14:11 +01:00
Peter Steinberger
01c1d8c10a test(release): wait for live probe cleanup 2026-05-30 18:20:44 +01:00
Peter Steinberger
0d657d2e27 test(release): skip unavailable anthropic live models 2026-05-30 17:57:01 +01:00
Peter Steinberger
5b8cc7d6bf fix(release): remove net policy split from 2026.5.28 2026-05-30 17:04:58 +01:00
Vincent Koc
ea8c052bcf fix(ci): serialize gateway server vitest project 2026-05-30 16:12:39 +01:00
Vincent Koc
bbfe287836 test(sdk): resolve local package deps in pack smoke 2026-05-30 15:53:16 +01:00
Peter Steinberger
56227067b5 test(imessage): align SMS route expectations 2026-05-30 15:17:52 +01:00
Peter Steinberger
8fa3c8189a test(agents): stabilize run wait timeout fallback 2026-05-30 14:47:01 +01:00
Peter Steinberger
420bfad613 chore(release): refresh generated 2026.5.28 baselines 2026-05-30 14:25:32 +01:00
Peter Steinberger
5e40a49852 chore(release): prepare 2026.5.28 stable 2026-05-30 14:19:53 +01:00
Peter Steinberger
d2d6bf2d66 docs(changelog): refresh 2026.5.28 release notes 2026-05-30 14:15:29 +01:00
Vincent Koc
49d6efc65b fix(deps): remove sharp from root package 2026-05-30 14:14:02 +01:00
Merlin
9fc0e9659e fix(gateway): resolve message actions against runtime config (#84535)
* fix(gateway): resolve message action config from runtime snapshot

* fix(gateway): preserve runtime config matching through auto-enable

* fix(gateway): preserve auto-enabled message action fallback

* fix(gateway): use canonical runtime snapshot for message actions

* fix(discord): route credential actions through gateway

---------

Co-authored-by: Merlin <258679497+funmerlin@users.noreply.github.com>
Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-05-30 14:12:21 +01:00
Peter Steinberger
fa530f8028 fix: bound default heartbeat run timeout (#88133)
Fixes #87438.

Bound unset heartbeat run timeouts so background heartbeat turns no longer inherit the built-in 48-hour interactive agent default. Timeout precedence is explicit heartbeat timeout, explicit global agent timeout, then heartbeat cadence capped at 600 seconds.

Verification:
- git diff --check
- Testbox tbx_01kstna69zvznn4fq7zrqr04a1: corepack pnpm test src/infra/heartbeat-runner.model-override.test.ts -- --reporter=verbose passed 13 tests
- Direct node --import tsx runtime probe verified 300s, 600s, 60s, and 45s timeout precedence cases
- Autoreview clean

Known CI state:
- PR CI run 26661465248 has failures matching latest main CI run 26661386468 at a7820b2f54; failures are outside this six-file heartbeat/docs diff.
2026-05-30 14:12:21 +01:00
Peter Steinberger
91df655e69 fix: keep live OpenClaw session locks during cleanup (#88129)
Keep session lock cleanup from removing live OpenClaw-owned locks solely because they are old. Cleanup now reports age-only stale locks without deleting them, while still removing dead, orphaned, recycled, malformed-old, and non-OpenClaw-owned locks.

Update doctor docs and regression coverage for the cleanup/repair contract.

Refs #87779
2026-05-30 14:12:21 +01:00
Marvinthebored
116f084169 fix(plugins): preserve single-pass plugin env config
Resolve raw plugin config environment references before plugin discovery and validation, while preserving the existing single-pass behavior for configs already loaded through config IO.

The loader now resolves raw config opt-ins with config.env vars included, bypasses active/cache reuse for that mode, and redacts plugin entry config from raw-mode cache keys so resolved secrets do not enter registry keys or reentry errors.

Verification:
- OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs src/plugins/loader.test.ts src/plugins/loader.runtime-registry.test.ts
- autoreview --mode branch --base origin/main
- pnpm check:changed on Blacksmith Testbox tbx_01ksw36bp7zygwxgq3jcsvjv3b / GitHub Actions run 26680322889
- PR CI green on facb77634e

Co-authored-by: Peter Lindsey <peter@lindsey.jp>
(cherry picked from commit 6b41a0692f)
2026-05-30 10:39:52 +01:00
Peter Steinberger
ee0756f1ee fix(release): harden Parallels Discord smoke 2026-05-30 09:19:28 +01:00
Peter Steinberger
11ffe36441 fix(plugins): cap CLI node invoke timeout 2026-05-30 07:48:37 +01:00
Peter Steinberger
768a64dd51 perf: skip session store clones in turn hot paths 2026-05-30 07:48:37 +01:00
Peter Steinberger
82560fa1ba fix(agents): cap provider request timeouts 2026-05-30 07:48:37 +01:00
Peter Steinberger
9d12dbb00b fix(cron): cap explicit job timeouts 2026-05-30 07:48:37 +01:00
Peter Steinberger
31a46638ad fix: show chat errors as visible messages
Surface gateway chat failures as visible assistant messages in the Control UI, with regression coverage and Crabbox/WebVNC proof.
2026-05-30 07:48:37 +01:00
Peter Steinberger
bf42c73d18 fix(agents): cap session wait timeouts 2026-05-30 07:47:38 +01:00
Peter Steinberger
81533ff9d9 fix(web): cap provider timeout seconds 2026-05-30 07:46:49 +01:00
Peter Steinberger
ca78397386 fix(telegram): cap configured request timeouts 2026-05-30 07:46:49 +01:00
Peter Steinberger
bc1db759ac fix(acpx): cap service timer timeouts 2026-05-30 07:46:49 +01:00
Peter Steinberger
e86f86f207 fix(copilot): avoid bundling platform binaries 2026-05-30 07:46:49 +01:00
Peter Steinberger
228bed7da5 fix(codex): cap app-server idle timers 2026-05-30 07:46:49 +01:00
Peter Steinberger
9cf6376043 fix(agents): cap exec reviewer timeout 2026-05-30 07:46:49 +01:00
Nimrod Gutman
7965644da0 fix(ios): guard websocket ping continuation (#88231)
Merged via squash.

Prepared head SHA: b4cee97b8a
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
2026-05-30 07:46:49 +01:00
Ayaan Zaidi
ae0a2ecf4d perf(agents): bound claude orphan transcript scan 2026-05-30 07:46:49 +01:00
Marcus Castro
139b52ad9c fix(whatsapp): retry QR login 408 timeouts (#88183) 2026-05-30 07:46:11 +01:00
Kevin Lin
352396f487 fix(imessage): preserve SMS approval reply routes
Preserve iMessage SMS reply routes for approval replies so a direct SMS /approve response can acknowledge and return results to the same SMS conversation.

Verification: gateway-only build, extension type checks, CI build-artifacts/check-prod-types/check-test-types/check-lint/check-additional-extension-package-boundary, and live prod iMessage SMS approval proof. checks-node-core-fast was waived by maintainer request after unrelated flaky failures in non-iMessage tests.
2026-05-30 07:46:11 +01:00
Josh Avant
00d87c7b5d fix subagent dm completion delivery (#88182) 2026-05-30 07:46:11 +01:00
Peter Steinberger
400fee7857 fix(release): verify plugin npm readmes 2026-05-30 07:12:55 +01:00
Peter Steinberger
8c417e0021 fix(release): build beta smoke REST curl command 2026-05-29 23:50:01 +01:00
Peter Steinberger
3b1169c235 fix(release): avoid gh api in beta smoke 2026-05-29 23:49:35 +01:00
Peter Steinberger
b717704a0b fix(feishu): reopen retryable bot menu replay 2026-05-29 22:52:10 +01:00
Peter Steinberger
dd658474a5 test(wizard): include tokenjuice optional plugin 2026-05-29 22:25:10 +01:00
Peter Steinberger
806dac4f3d test: satisfy release test typecheck 2026-05-29 22:05:08 +01:00
Peter Steinberger
bac13419a6 fix(codex-supervisor): satisfy release lint 2026-05-29 21:54:30 +01:00
Peter Steinberger
ae248bc267 fix(release): harden candidate run status polling 2026-05-29 21:42:43 +01:00
Peter Steinberger
28390f8ca1 fix(release): avoid gh api for candidate reads 2026-05-29 21:35:24 +01:00
Peter Steinberger
4f6865b29d docs(changelog): fold latest main release notes 2026-05-29 21:30:44 +01:00
Peter Steinberger
a77db84384 chore(release): prepare 2026.5.28 beta 4 2026-05-29 21:28:49 +01:00
Peter Steinberger
4fbb276bc1 docs(changelog): refresh 2026.5.28 notes 2026-05-29 21:28:45 +01:00
164 changed files with 2434 additions and 1489 deletions

View File

@@ -9,7 +9,6 @@ queries:
paths:
- src
- extensions
- packages/net-policy/src
paths-ignore:
- "**/node_modules"

View File

@@ -15,6 +15,7 @@ query-filters:
paths:
- src/infra/net
- src/shared/net
- src/agents/tools/web-fetch.ts
- src/agents/tools/web-guarded-fetch.ts
- src/agents/tools/web-shared.ts
@@ -22,7 +23,6 @@ paths:
- src/web-fetch
- src/web/provider-runtime-shared.ts
- packages/memory-host-sdk/src/host/ssrf-policy.ts
- packages/net-policy/src
paths-ignore:
- "**/node_modules"

View File

@@ -33,7 +33,6 @@ on:
- "packages/plugin-package-contract/**"
- "packages/plugin-sdk/**"
- "packages/memory-host-sdk/**"
- "packages/net-policy/**"
- "src/*.ts"
- "src/**/*.ts"
- "src/config/**"
@@ -302,7 +301,7 @@ jobs:
esac
case "${file}" in
src/*.ts|src/**/*.ts|extensions/*.ts|extensions/**/*.ts|packages/net-policy/src/*|packages/net-policy/src/**/*)
src/*.ts|src/**/*.ts|extensions/*.ts|extensions/**/*.ts)
network_runtime=true
;;
esac

View File

@@ -2,29 +2,17 @@
Docs: https://docs.openclaw.ai
## Unreleased
### Changes
- Plugins: externalize Tokenjuice as the official `@openclaw/tokenjuice` plugin with npm and ClawHub publish metadata.
- Plugins: externalize the GitHub Copilot agent runtime as the official `@openclaw/copilot` plugin with npm and ClawHub publish metadata.
### Fixes
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
- Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.
## 2026.5.28
### Highlights
- Agent and Codex runtime recovery is steadier: subagents keep cwd/workspace separation, hook context stays prompt-local, session locks release on timeout abort, stale restart continuations are avoided, and Codex app-server/helper failures no longer tear down shared runtime state. (#87218, #86875, #87409, #87399, #87375)
- Channel delivery and session identity got safer across outbound plugin hooks, Matrix room ids, iMessage reactions/approvals, Slack final replies, Discord recovered tool warnings, and Microsoft Teams service URL trust checks. (#73706, #75670, #87366, #87451, #87334)
- Mobile and chat surfaces got a broader refresh: the iOS Pro UI, Gateway chat transport, onboarding, Talk permissions, WebChat reconnect delivery, and session picker behavior now preserve more state across reconnects and empty searches. (#87367, #87531, #87682)
- CLI, auth, doctor, and provider paths fail faster and recover more clearly: malformed numeric/version options are rejected, OAuth and local service startup requests are bounded, legacy `api_key` auth profiles migrate to canonical form, and restart guidance is actionable. (#87398, #86281, #87361)
- Plugin and Gateway hot paths do less repeated work while preserving cache correctness for install records, config JSON parsing, tool search catalogs, session stores, manifest model rows, auto-enabled plugin config, browser tokens, and viewer assets. (#86699)
- Agent and Codex runtime recovery is steadier: subagents keep cwd/workspace separation, hook context stays prompt-local, session locks release on timeout abort while live OpenClaw locks survive cleanup, stale restart continuations are avoided, and Codex app-server/helper failures no longer tear down shared runtime state. (#87218, #86875, #87409, #87399, #87375, #88129)
- Channel delivery and session identity got safer across outbound plugin hooks, Matrix room ids, iMessage reactions/approvals, Slack final replies, Discord recovered tool warnings, runtime-config message actions, WhatsApp profile auth roots, Telegram polling, and Microsoft Teams service URL trust checks. (#73706, #75670, #87366, #87451, #87334, #84535, #82492, #83304, #87160)
- Mobile and chat surfaces got a broader refresh: the iOS Pro UI, hosted push relay default, realtime Talk tab playback, Gateway chat transport, onboarding, Talk permissions, WebChat reconnect delivery, and session picker behavior now preserve more state across reconnects and empty searches. (#87367, #87531, #87682, #88096, #88105) Thanks @ngutman.
- Browser, channel, and automation inputs are stricter: Browser tool timeouts, viewport/tab indices, Gateway ports, cron retry handling, Discord component ids, schema array refs, Telegram callback pages, and channel progress callbacks now reject malformed values earlier and preserve the intended delivery context. (#82887)
- Provider, media, and document coverage expands with Claude Opus 4.8, Fal Krea image schemas, NVIDIA featured models, MiniMax streaming music responses, encrypted PDF extraction, voice model catalogs, GitHub Copilot agent runtime support, and a Codex Supervisor plugin path for delegated Codex workflows. (#87845, #87890, #80775, #84764, #87751, #87794)
- CLI, auth, doctor, and provider paths fail faster and recover more clearly: malformed numeric/version options are rejected, workspace dotenv provider credentials are ignored, heartbeat defaults, OAuth/token lifetimes, and local service startup requests are bounded, agent auth health labels are clearer, legacy `api_key` auth profiles migrate to canonical form, and restart guidance is actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #88088, #85924) Thanks @vincentkoc and @giodl73-repo.
- Plugin and Gateway hot paths do less repeated work while preserving cache correctness for install records, config JSON parsing, tool search catalogs, session stores, manifest model rows, auto-enabled plugin config, browser tokens, viewer assets, and release-split external plugin packages. (#86699)
- Release, QA, and E2E validation now bound more log, artifact, harness, and cross-OS waits so failing lanes produce proof instead of hanging or false-greening.
### Changes
@@ -32,25 +20,41 @@ Docs: https://docs.openclaw.ai
- Status: show active subagent details in status output.
- Diffs: split the default language pack and expand default Diffs language coverage while keeping the host floor aligned. (#87370, #87372) Thanks @RomneyDa.
- ClawHub: add plugin display names plus skill verification and trust surfaces. (#87354, #86699) Thanks @thewilloftheshadow and @Patrick-Erichsen.
- iOS: refresh the dev app with Pro Command, Chat, Agents, and Settings tabs wired to gateway sessions, diagnostics, chat, and realtime Talk. (#87367) Thanks @Solvely-Colin.
- Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, and backport targets. (#87313, #63050) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.
- PDF/tools: use ClawPDF for PDF extraction and surface MCP structured content in agent tool results. (#87670)
- iOS: refresh the dev app with Pro Command, Chat, Agents, Settings, hosted push relay defaults, and realtime Talk playback wired to gateway sessions, diagnostics, chat, and realtime Talk. (#87367, #88096, #88105) Thanks @Solvely-Colin and @ngutman.
- Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, CLI setup flow compatibility, Notte cloud browser CDP setup, and backport targets. (#87313, #63050, #87685) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.
- PDF/tools: use ClawPDF for PDF extraction, support encrypted PDF extraction, and surface MCP structured content in agent tool results. (#87670, #87751)
- Providers: add Claude Opus 4.8 support, Fal Krea image model schemas, NVIDIA featured model catalogs, MiniMax streaming music responses, and provider-backed voice model catalogs. (#87845, #87890, #80775, #84764, #87794) Thanks @eleqtrizit and @vincentkoc.
- Codex/GitHub: add the GitHub Copilot agent runtime and the Codex Supervisor plugin package.
- Plugins: externalize GitHub Copilot and Tokenjuice as official install-on-demand plugins with npm and ClawHub publish metadata.
- Workboard: add agent coordination tools for tracking and handing off active agent work.
- Discord: show commentary in progress drafts so live Discord runs expose useful in-progress context. (#85200)
- Plugin SDK: add a reply payload sending hook for plugins that need to deliver channel-owned replies and flatten package types for SDK declarations. (#82823, #87165) Thanks @RomneyDa.
- Policy: add policy comparison, ingress-channel conformance, and sandbox-posture conformance checks. (#85572, #85744, #86768)
### Fixes
- Agents: fall back to local config pruning when the optional `agents delete` Gateway probe cannot authenticate, so offline installs can still delete agents without removing shared workspaces.
- Tighten phone-control mutation authorization [AI]. (#87150) Thanks @pgondhi987.
- Clarify directive persistence authorization policy [AI]. (#86369) Thanks @pgondhi987.
- Agents/Codex: keep spawned agent cwd/workspace state separated, keep hook context prompt-local, release session locks on timeout abort, avoid session event queue self-wait, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format `skills` command output, and bound compaction/steering retries. (#87218, #86875, #86123, #87399, #87375, #87383, #87400) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, and @sjf.
- Codex Supervisor: keep real-home app-server MCP session listing on the loaded/state-DB path, bound stored history scans, and close WebSocket probes cleanly.
- Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, preserve Telegram SecretRef prompt config, suppress Discord recovered tool warnings, and block untrusted Teams service URLs. (#73706, #75670, #87366, #87451, #87334) Thanks @zeroaltitude, @lukeboyett, @xiaotian, and @eleqtrizit.
- CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, wait for respawn child shutdown, bound Codex and GitHub Copilot OAuth/token requests, warm provider auth off the main thread, honor Codex response timeouts, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical `api_key` auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, and @alkor2000.
- Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, and evict current plugin-state namespaces at row caps.
- Config/parsing/network: reject partial numeric parsing, parse provider/Discord retry headers and dates strictly, honor IPv6 and bare IPv6 `no_proxy` entries, canonicalize secret target array indexes, and reject malformed media content lengths, inspected TCP ports, marketplace content lengths, cron epochs, and sandbox stat fields.
- Providers/agents: preserve seeded Anthropic signatures, concatenate signature-delta chunks, preserve DeepSeek `reasoning_content` replay across tier suffixes, apply OpenRouter strict9 ids to Mistral routes, promote Ollama plain-text tool calls, and recover empty preflight compaction. (#87593)
- Agents/Codex: keep spawned agent cwd/workspace state separated, forward ACP spawn attachments, keep hook context prompt-local, release session locks on timeout abort and runtime teardown without deleting live OpenClaw-owned locks during cleanup, avoid session event queue self-wait, clean up exec abort listeners, stream assistant deltas incrementally, recover raw missing-thread compaction failures, preserve rotated compaction session identity, keep compaction-timeout snapshots continuable, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts and prune stale bridge files, close native hook relay replacement races, keep Claude live tool progress visible for watchdog recovery, suppress abandoned requester completion handoff, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format `skills` command output, bind node auto-review to prepared plans, retry Claude CLI transcript probes, and bound compaction/steering retries. (#87218, #86875, #86123, #88129, #87399, #87375, #72574, #87383, #87400, #83022, #87671, #87738, #87747, #87706, #87546, #87541, #81048) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, @sjf, @joshavant, and @benjamin1492.
- Codex Supervisor: keep real-home app-server MCP session listing on the loaded state path, bound stored history scans, and close WebSocket probes cleanly.
- Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, resolve Gateway message actions against the active runtime config, preserve Telegram SecretRef prompt config and polling keepalives, preserve WhatsApp profile auth roots, QR display, document filenames, and plugin hook config, suppress Discord recovered tool warnings, preserve the Discord voice outbound helper, cap Discord/Signal/Zalo channel request and container timeouts, and block untrusted Teams service URLs while keeping TeamsSDK patterns aligned. (#73706, #75670, #87366, #87451, #87465, #87334, #84535, #76262, #83304, #82492, #87581, #77114, #86426, #85529, #87160) Thanks @zeroaltitude, @lukeboyett, @xiaotian, @funmerlin, @joshavant, @eleqtrizit, @heyitsaamir, @amittell, @liorb-mountapps, @masatohoshino, @bladin, and @giodl73-repo.
- CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, ignore workspace dotenv provider credentials, wait for respawn child shutdown, bound heartbeat defaults plus Codex, GitHub Copilot, OpenAI, Anthropic, Google, Feishu, LM Studio, MiniMax, Xiaomi TTS, and local-provider OAuth/token/model requests, harden Codex auth probes, label auth health by agent, preserve explicit agentRuntime pins during Codex model migration, warm provider auth off the main thread, honor Codex response timeouts, stop migrating current Claude Haiku 4.5 profiles to Sonnet, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical `api_key` auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #87719, #88088, #85924, #84362) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, @alkor2000, @mmaps, @nxmxbbd, and @vincentkoc.
- Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks and stale rate-limit cooldown probes, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, clear completed session active runs, clear stale chat stream buffers, and evict current plugin-state namespaces at row caps. (#87810, #87833, #75089) Thanks @joshavant and @litang9.
- Config/parsing/network: reject partial numeric parsing, parse provider/Discord retry headers and dates strictly, honor IPv6 and bare IPv6 `no_proxy` entries, preserve empty plugin allowlists, canonicalize secret target array indexes, and reject malformed media content lengths, inspected TCP ports, marketplace content lengths, cron epochs, sandbox stat fields, unsafe duration values, empty config path segments, noncanonical schema array refs, unsafe Telegram callback pages, and invalid Teams attachment-fetch DNS targets. (#87883) Thanks @zhangguiping-xydt.
- Browser/input hardening: reject invalid tab indexes, excessive viewport resizes, explicit zero CDP ports, malformed geolocation options, unsafe screenshot or permission-grant timeouts, loose response-body limits, invalid cookie expiries, and non-finite Browser tool delays/timeouts.
- Cron/automation: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot, and preflight model fallbacks before skipping scheduled work. (#82887)
- Auto-reply/directives: respect provider and relayed channel metadata during directive persistence so channel-originated decisions keep their intended context. (#87683)
- WhatsApp: resolve the auth directory from the active profile so profile-scoped WhatsApp installs do not drift to the wrong credential root. (#82492)
- Gateway/session state: clear completed session active runs, avoid cold-loading providers for MCP inventory, cache single-session child indexes, cap handshake timers, and bound preauth, auth-guard, media, transcript, readiness, and port options.
- Channels/replies: preserve channel-owned progress callbacks when verbose output is off, keep group-room progress suppression intact, prefer external session delivery context, escape Discord component id delimiters, force final TUI chat repaints, show Slack reasoning previews, and normalize Discord/Matrix/Mattermost channel numeric options. (#87476, #87423)
- Agents/tool args: harden smart-quoted argument repair for edit arrays and exact escaped arguments so model-produced tool calls recover without corrupting valid input. (#86611)
- Providers/agents: preserve seeded Anthropic signatures, preserve signed thinking payloads, concatenate signature-delta chunks, preserve DeepSeek `reasoning_content` replay across tier suffixes, apply OpenRouter strict9 ids to Mistral routes, promote Ollama plain-text tool calls, load NVIDIA featured model catalogs, stream MiniMax music generation responses, and recover empty preflight compaction. (#87593, #87493, #80775, #84764) Thanks @eleqtrizit.
- Media/images: skip CLI image cache refs when resolving generated images, allow trusted generated HTML attachments, and bound generated video downloads so stale refs and slow providers fail cleanly. (#87523, #87982)
- File transfer: handle late tar stdin pipe errors after archive validation or unpacking has already settled.
- Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, skip unchanged store serialization, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, and slim current metadata identity caches.
- Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, and release scenario logs, and keep release/google live guards current.
- Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, reuse gateway session and plugin metadata paths, skip unchanged store serialization, patch single-entry session writes, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, avoid full session snapshots for entry reads, defer configured Slack full startup, prefer bundled plugin dist entries, and slim current metadata identity caches. (#87760)
- Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, isolate npm plugin installs per package, reject incompatible package plugin API installs, drop the leftover root Sharp dependency from package manifests after the Rastermill migration, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, QA-Lab credential broker calls, QA Matrix substrate requests, and release scenario logs, and keep release/google live guards current. (#87647, #87477) Thanks @rohitjavvadi and @vincentkoc.
- Release/CI: bound manual git fetches, ClawHub verifier responses, ClawHub owner metadata, dependency-guard error bodies, Parallels limits, startup/test/memory budget parsing, and diffs viewer build warnings so release lanes fail with useful proof instead of hanging. (#87839)
## 2026.5.27

View File

@@ -14,6 +14,22 @@ public protocol WebSocketTasking: AnyObject {
extension URLSessionWebSocketTask: WebSocketTasking {}
private final class WebSocketPingContinuationGate: @unchecked Sendable {
private let lock = NSLock()
private var didResume = false
func resumeOnce(_ resume: () -> Void) {
self.lock.lock()
if self.didResume {
self.lock.unlock()
return
}
self.didResume = true
self.lock.unlock()
resume()
}
}
public struct WebSocketTaskBox: @unchecked Sendable {
public let task: any WebSocketTasking
public init(task: any WebSocketTasking) {
@@ -48,8 +64,13 @@ public struct WebSocketTaskBox: @unchecked Sendable {
public func sendPing() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
let gate = WebSocketPingContinuationGate()
self.task.sendPing { error in
ThrowingContinuationSupport.resumeVoid(continuation, error: error)
// URLSession can race ping callbacks with cancellation; only the first
// pong result owns this checked continuation or Swift traps the app.
gate.resumeOnce {
ThrowingContinuationSupport.resumeVoid(continuation, error: error)
}
}
}
}

View File

@@ -11,6 +11,42 @@ private extension NSLock {
}
}
private final class DoubleCallbackPingWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let callbacks: [Error?]
init(callbacks: [Error?]) {
self.callbacks = callbacks
}
var state: URLSessionTask.State { .running }
func resume() {}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
_ = message
}
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
for callback in self.callbacks {
pongReceiveHandler(callback)
}
}
func receive() async throws -> URLSessionWebSocketTask.Message {
throw URLError(.badServerResponse)
}
func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
completionHandler(.failure(URLError(.badServerResponse)))
}
}
private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let lock = NSLock()
private let helloAuth: [String: Any]?
@@ -193,6 +229,25 @@ private actor SeqGapProbe {
@Suite(.serialized)
struct GatewayNodeSessionTests {
@Test
func websocketPingIgnoresDuplicateSuccessCallbacks() async throws {
let task = DoubleCallbackPingWebSocketTask(callbacks: [nil, nil])
try await WebSocketTaskBox(task: task).sendPing()
}
@Test
func websocketPingIgnoresDuplicateCallbacksAfterFirstError() async throws {
let firstError = URLError(.networkConnectionLost)
let task = DoubleCallbackPingWebSocketTask(callbacks: [firstError, nil])
do {
try await WebSocketTaskBox(task: task).sendPing()
Issue.record("sendPing unexpectedly succeeded")
} catch let error as URLError {
#expect(error.code == firstError.code)
}
}
@Test
func scannedSetupCodePrefersBootstrapAuthOverStoredDeviceToken() async throws {
let tempDir = FileManager.default.temporaryDirectory

View File

@@ -176,10 +176,6 @@ const config = {
entry: ["src/index.ts!", "src/schema.ts!"],
project: ["src/**/*.ts!"],
},
"packages/net-policy": {
entry: ["src/index.ts!", "src/ip.ts!"],
project: ["src/**/*.ts!"],
},
"packages/speech-core": {
entry: ["api.ts!", "runtime-api.ts!", "speaker.ts!", "voice-models.ts!"],
project: ["**/*.ts!"],

View File

@@ -1,4 +1,4 @@
c80dea63b0a3786c8999d06aae62c110786f440b4d6748f9838577aaa2816971 config-baseline.json
948323a1507817b6580ed976f9f9449239008f40283cc7e6005148ecf0ca4582 config-baseline.core.json
f833ffca6bd88162f062bbea4f0eede783373f46674ebbfc3a390c80353930a2 config-baseline.channel.json
bc38b58b67132401a030b3b3a77efdb6c88f207ea1fab9abcb4599e1f9552dda config-baseline.plugin.json
5b30f43bee4a40f8365e1471ecf43d671a54527c014b93e919e81e8a8dd9f15a config-baseline.json
7bd4b4416321c09280af245f5deda1f5075364de2a271d5bb4bee0ce6c5ca7f1 config-baseline.core.json
6fea71ca36eafc07c4a67abef806177fd58914b57b1bf9a73502b2efc8f873a3 config-baseline.channel.json
6add0a3051a081880313949da8c91500159a8bc29395c322eec0bee1987e808f config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
59de21361cab0622926ad313caf3f8dc43c28d420a82ba060680ecc30c472453 plugin-sdk-api-baseline.json
05adee9037669db4e834d1a0ca9705d5d94df770083862ab149d2f3e559010d2 plugin-sdk-api-baseline.jsonl
95980fd2e863f4142d694b6b5ee551d24de221a399aff6c3850543c747aae074 plugin-sdk-api-baseline.json
a76b021246574d525fce78c186b396244381b687b42e69876d16b3034b72237c plugin-sdk-api-baseline.jsonl

View File

@@ -617,7 +617,7 @@ Periodic heartbeat runs.
- `every`: duration string (ms/s/m/h). Default: `30m` (API-key auth) or `1h` (OAuth auth). Set to `0m` to disable.
- `includeSystemPromptSection`: when false, omits the Heartbeat section from the system prompt and skips `HEARTBEAT.md` injection into bootstrap context. Default: `true`.
- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs.
- `timeoutSeconds`: maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use `agents.defaults.timeoutSeconds`.
- `timeoutSeconds`: maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use `agents.defaults.timeoutSeconds` when set, otherwise the heartbeat cadence capped at 600 seconds.
- `directPolicy`: direct/DM delivery policy. `allow` (default) permits direct-target delivery. `block` suppresses direct-target delivery and emits `reason=dm-blocked`.
- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
- `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Same isolation pattern as cron `sessionTarget: "isolated"`. Reduces per-heartbeat token cost from ~100K to ~2-5K tokens.

View File

@@ -382,7 +382,7 @@ That stages grounded durable candidates into the short-term dreaming store while
</Accordion>
<Accordion title="3c. Session lock cleanup">
Doctor scans every agent session directory for stale write-lock files — files left behind when a session exited abnormally. For each lock file found it reports: the path, PID, whether the PID is still alive, lock age, and whether it is considered stale (dead PID, older than 30 minutes, or a live PID that can be proven to belong to a non-OpenClaw process). In `--fix` / `--repair` mode it removes stale lock files automatically; otherwise it prints a note and instructs you to rerun with `--fix`.
Doctor scans every agent session directory for stale write-lock files — files left behind when a session exited abnormally. For each lock file found it reports: the path, PID, whether the PID is still alive, lock age, and whether it is considered stale (dead PID, malformed owner metadata, older than 30 minutes, or a live PID that can be proven to belong to a non-OpenClaw process). In `--fix` / `--repair` mode it removes locks with dead, orphaned, recycled, malformed-old, or non-OpenClaw owners automatically. Old locks that are still owned by a live OpenClaw process are reported but left in place so doctor does not cut off an active transcript writer.
</Accordion>
<Accordion title="3d. Session transcript branch repair">
Doctor scans agent session JSONL files for the duplicated branch shape created by the 2026.4.24 prompt transcript rewrite bug: an abandoned user turn with OpenClaw internal runtime context plus an active sibling containing the same visible user prompt. In `--fix` / `--repair` mode, doctor backs up each affected file next to the original and rewrites the transcript to the active branch so gateway history and memory readers no longer see duplicate turns.

View File

@@ -63,6 +63,7 @@ Example config:
- Interval: `30m` (or `1h` when Anthropic OAuth/token auth is the detected auth mode, including Claude CLI reuse). Set `agents.defaults.heartbeat.every` or per-agent `agents.list[].heartbeat.every`; use `0m` to disable.
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`): `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
- Timeout: unset heartbeat turns use `agents.defaults.timeoutSeconds` when set. Otherwise, they use the heartbeat cadence capped at 600 seconds. Set `agents.defaults.heartbeat.timeoutSeconds` or per-agent `agents.list[].heartbeat.timeoutSeconds` for longer heartbeat work.
- The heartbeat prompt is sent **verbatim** as the user message. The system prompt includes a "Heartbeat" section only when heartbeats are enabled for the default agent, and the run is flagged internally.
- When heartbeats are disabled with `0m`, normal runs also omit `HEARTBEAT.md` from bootstrap context so the model does not see heartbeat-only instructions.
- Active hours (`heartbeat.activeHours`) are checked in the configured timezone. Outside the window, heartbeats are skipped until the next tick inside the window.
@@ -274,6 +275,10 @@ Use `accountId` to target a specific account on multi-account channels like Tele
<ParamField path="suppressToolErrorWarnings" type="boolean">
When true, suppresses tool error warning payloads during heartbeat runs.
</ParamField>
<ParamField path="timeoutSeconds" type="number" default="global timeout or min(every, 600)">
Maximum seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use `agents.defaults.timeoutSeconds` when set, otherwise the heartbeat cadence capped at 600 seconds.
</ParamField>
<ParamField path="activeHours" type="object">
Restricts heartbeat runs to a time window. Object with `start` (HH:MM, inclusive; use `00:00` for start-of-day), `end` (HH:MM exclusive; `24:00` allowed for end-of-day), and optional `timezone`.

View File

@@ -18,6 +18,10 @@ Supervise Codex app-server sessions from OpenClaw.
contracts: tools
<!-- openclaw-plugin-reference:manual-start -->
## Session Listing
`codex_sessions_list` defaults to loaded Codex sessions only. Set `include_stored` to include stored history; the plugin uses Codex app-server's state-DB-only listing path and caps stored results at 200 by default. Pass `max_stored_sessions` to lower or raise that cap, up to 1000.
<!-- openclaw-plugin-reference:manual-end -->

View File

@@ -134,7 +134,7 @@ Configure the proxy to:
Use this denylist as the starting point for any forward proxy, firewall, or egress policy.
OpenClaw application-level classifier logic lives in `src/infra/net/ssrf.ts` and `packages/net-policy/src/ip.ts`. The relevant parity hooks are `BLOCKED_HOSTNAMES`, `BLOCKED_IPV4_SPECIAL_USE_RANGES`, `BLOCKED_IPV6_SPECIAL_USE_RANGES`, `RFC2544_BENCHMARK_PREFIX`, and the embedded IPv4 sentinel handling for NAT64, 6to4, Teredo, ISATAP, and IPv4-mapped forms. Those files are useful references when maintaining an external proxy policy, but OpenClaw does not automatically export or enforce those rules in your proxy.
OpenClaw application-level classifier logic lives in `src/infra/net/ssrf.ts` and `src/shared/net/ip.ts`. The relevant parity hooks are `BLOCKED_HOSTNAMES`, `BLOCKED_IPV4_SPECIAL_USE_RANGES`, `BLOCKED_IPV6_SPECIAL_USE_RANGES`, `RFC2544_BENCHMARK_PREFIX`, and the embedded IPv4 sentinel handling for NAT64, 6to4, Teredo, ISATAP, and IPv4-mapped forms. Those files are useful references when maintaining an external proxy policy, but OpenClaw does not automatically export or enforce those rules in your proxy.
| Range or host | Why to block |
| ------------------------------------------------------------------------------------ | ---------------------------------------------------- |

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
const { runtimeRegistry } = vi.hoisted(() => ({
@@ -90,7 +91,7 @@ vi.mock("./process-reaper.js", () => ({
import { getAcpRuntimeBackend } from "../runtime-api.js";
import type { OpenClawPluginServiceContext } from "../runtime-api.js";
import { createAcpxRuntimeService } from "./service.js";
import { createAcpxRuntimeService, resolveAcpxTimerTimeoutMs } from "./service.js";
const tempDirs: string[] = [];
const previousEnv = {
@@ -196,6 +197,11 @@ function readFirstRuntimeFactoryInput(runtimeFactory: { mock: { calls: Array<Arr
}
describe("createAcpxRuntimeService", () => {
it("caps configured timeout seconds to timer-safe milliseconds", () => {
expect(resolveAcpxTimerTimeoutMs(0.001)).toBe(1);
expect(resolveAcpxTimerTimeoutMs(Number.MAX_SAFE_INTEGER)).toBe(MAX_TIMER_TIMEOUT_MS);
});
it("registers and unregisters the embedded backend", async () => {
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
@@ -580,6 +586,36 @@ describe("createAcpxRuntimeService", () => {
await service.stop?.(ctx);
});
it("caps oversized plugin timeouts before constructing the default acpx runtime", async () => {
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "0";
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const service = createAcpxRuntimeService({
pluginConfig: { timeoutSeconds: Number.MAX_SAFE_INTEGER },
});
await service.start(ctx);
const backend = getAcpRuntimeBackend("acpx");
if (!backend) {
throw new Error("expected ACPX runtime backend");
}
const backendRuntime = backend.runtime as {
ensureSession(input: { agent: string; mode: string; sessionKey: string }): Promise<unknown>;
};
await backendRuntime.ensureSession({
agent: "codex",
mode: "oneshot",
sessionKey: "agent:codex:acp:test",
});
const [options] = acpxRuntimeConstructorMock.mock.calls[0] ?? [];
expect(options).toHaveProperty("timeoutMs", MAX_TIMER_TIMEOUT_MS);
await service.stop?.(ctx);
});
it("runs the embedded runtime probe at startup when explicitly enabled and reports health", async () => {
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "1";
const workspaceDir = await makeTempDir();

View File

@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { inspect } from "node:util";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { finiteSecondsToTimerSafeMilliseconds } from "openclaw/plugin-sdk/number-runtime";
import type {
AcpRuntime,
OpenClawPluginService,
@@ -60,6 +61,13 @@ function loadRuntimeModule(): Promise<AcpxRuntimeModule> {
return runtimeModulePromise;
}
export function resolveAcpxTimerTimeoutMs(timeoutSeconds: number | undefined): number | undefined {
if (timeoutSeconds === undefined) {
return undefined;
}
return finiteSecondsToTimerSafeMilliseconds(timeoutSeconds) ?? 1;
}
function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike {
let runtime: AcpxRuntimeLike | null = null;
let runtimePromise: Promise<AcpxRuntimeLike> | null = null;
@@ -84,10 +92,7 @@ function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntime
mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers),
permissionMode: params.pluginConfig.permissionMode,
nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions,
timeoutMs:
params.pluginConfig.timeoutSeconds != null
? params.pluginConfig.timeoutSeconds * 1_000
: undefined,
timeoutMs: resolveAcpxTimerTimeoutMs(params.pluginConfig.timeoutSeconds),
}) as AcpxRuntimeLike;
return runtime;
});
@@ -201,7 +206,7 @@ async function withStartupProbeTimeout<T>(params: {
timeoutSeconds: number;
}): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | undefined;
const timeoutMs = Math.max(1, params.timeoutSeconds * 1_000);
const timeoutMs = resolveAcpxTimerTimeoutMs(params.timeoutSeconds) ?? 1;
try {
return await Promise.race([
params.promise,

View File

@@ -73,13 +73,13 @@ function readIntegerParam(params: Record<string, unknown>, key: string): number
if (value === undefined) {
return undefined;
}
if (!Number.isInteger(value)) {
if (typeof value !== "number" || !Number.isInteger(value)) {
throw new Error(`${key} must be an integer`);
}
if (value < 1 || value > 1000) {
throw new Error(`${key} must be between 1 and 1000`);
}
return value as number;
return value;
}
function readModeParam(params: Record<string, unknown>): CodexSupervisorTurnMode | undefined {

View File

@@ -808,7 +808,14 @@ describe("connectCodexAppServerEndpoint", () => {
const sawProbeRequest = new Promise<void>((resolve) => {
server.once("connection", (socket) => {
socket.on("message", (data) => {
const request = JSON.parse(data.toString()) as Record<string, unknown>;
const payload = Array.isArray(data)
? Buffer.concat(data).toString("utf8")
: typeof data === "string"
? data
: Buffer.isBuffer(data)
? data.toString("utf8")
: Buffer.from(data).toString("utf8");
const request = JSON.parse(payload) as Record<string, unknown>;
if (request.method === "initialize") {
socket.send(JSON.stringify({ id: request.id, result: {} }));
}

View File

@@ -482,7 +482,9 @@ export class CodexSupervisor {
}
}
if (endpointIds.size === 1) {
return Array.from(endpointIds)[0]!;
for (const endpointId of endpointIds) {
return endpointId;
}
}
if (endpointIds.size > 1) {
throw new Error(`Codex thread id is ambiguous across endpoints: ${params.threadId}`);

View File

@@ -1,3 +1,4 @@
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
CODEX_APP_SERVER_STARTUP_TIMEOUT_FLOOR_MS,
@@ -27,6 +28,15 @@ describe("Codex app-server attempt timeouts", () => {
CODEX_APP_SERVER_STARTUP_TIMEOUT_FLOOR_MS,
);
expect(resolveCodexStartupTimeoutMs({ timeoutMs: 500, timeoutFloorMs: Number.NaN })).toBe(500);
expect(resolveCodexStartupTimeoutMs({ timeoutMs: Number.MAX_SAFE_INTEGER })).toBe(
MAX_TIMER_TIMEOUT_MS,
);
expect(
resolveCodexStartupTimeoutMs({
timeoutMs: Number.MAX_SAFE_INTEGER,
timeoutFloorMs: Number.MAX_SAFE_INTEGER,
}),
).toBe(MAX_TIMER_TIMEOUT_MS);
expect(
resolveCodexStartupTimeoutMs({
timeoutMs: Number.NaN,
@@ -44,6 +54,9 @@ describe("Codex app-server attempt timeouts", () => {
);
expect(resolveCodexTurnCompletionIdleTimeoutMs(2.9)).toBe(2);
expect(resolveCodexTurnCompletionIdleTimeoutMs(0)).toBe(1);
expect(resolveCodexTurnCompletionIdleTimeoutMs(Number.MAX_SAFE_INTEGER)).toBe(
MAX_TIMER_TIMEOUT_MS,
);
expect(resolveCodexTurnAssistantCompletionIdleTimeoutMs(undefined)).toBe(
CODEX_TURN_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS,
@@ -59,6 +72,12 @@ describe("Codex app-server attempt timeouts", () => {
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(undefined, Number.NaN)).toBe(1);
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(7.9, 123)).toBe(7);
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(0, 123)).toBe(1);
expect(
resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(
Number.MAX_SAFE_INTEGER,
Number.MAX_SAFE_INTEGER,
),
).toBe(MAX_TIMER_TIMEOUT_MS);
expect(resolveCodexTurnTerminalIdleTimeoutMs(undefined)).toBe(
CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS,
@@ -68,6 +87,9 @@ describe("Codex app-server attempt timeouts", () => {
);
expect(resolveCodexTurnTerminalIdleTimeoutMs(3.7)).toBe(3);
expect(resolveCodexTurnTerminalIdleTimeoutMs(-1)).toBe(1);
expect(resolveCodexTurnTerminalIdleTimeoutMs(Number.MAX_SAFE_INTEGER)).toBe(
MAX_TIMER_TIMEOUT_MS,
);
});
it("returns the startup operation result before timeout", async () => {

View File

@@ -1,4 +1,4 @@
import { parseFiniteNumber } from "openclaw/plugin-sdk/number-runtime";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
export const CODEX_APP_SERVER_STARTUP_TIMEOUT_FLOOR_MS = 100;
export const CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS = 60_000;
@@ -7,9 +7,8 @@ export const CODEX_POST_REASONING_SOURCE_REPLY_IDLE_TIMEOUT_MS = 5 * 60_000;
export const CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS = 30 * 60_000;
function resolvePositiveIntegerTimeoutMs(value: number | undefined, fallbackMs: number): number {
const fallback = parseFiniteNumber(fallbackMs) ?? 1;
const candidate = parseFiniteNumber(value) ?? fallback;
return Math.max(1, Math.floor(candidate));
const fallback = resolveTimerTimeoutMs(fallbackMs, 1);
return resolveTimerTimeoutMs(value, fallback);
}
export async function withCodexStartupTimeout<T>(params: {

View File

@@ -32,6 +32,7 @@
"bundledDist": false
},
"release": {
"bundleRuntimeDependencies": false,
"publishToClawHub": true,
"publishToNpm": true
}

View File

@@ -356,21 +356,27 @@ describe("discordMessageActions", () => {
expect(discovery?.schema).toBeUndefined();
});
it.each(["read", "search"])("routes %s actions through gateway execution mode", (action) => {
expect(discordMessageActions.resolveExecutionMode?.({ action: action as never })).toBe(
"gateway",
);
});
it.each(["send", "upload-file", "edit", "delete", "react", "pin", "poll"])(
"routes %s actions through local execution mode",
it.each(["read", "search", "edit", "delete", "react", "pin", "poll", "channel-info"])(
"routes %s actions through gateway execution mode",
(action) => {
expect(discordMessageActions.resolveExecutionMode?.({ action: action as never })).toBe(
"local",
"gateway",
);
},
);
it.each([
"send",
"upload-file",
"thread-reply",
"sticker",
"emoji-upload",
"sticker-upload",
"event-create",
])("keeps %s on local execution mode", (action) => {
expect(discordMessageActions.resolveExecutionMode?.({ action: action as never })).toBe("local");
});
it("extracts send targets for message and thread reply actions", () => {
expect(
discordMessageActions.extractToolSend?.({

View File

@@ -27,6 +27,20 @@ const trustedRequesterGuildAdminActions = new Set<ChannelMessageActionName>([
"event-create",
]);
const localExecutionActions = new Set<ChannelMessageActionName>([
"send",
"upload-file",
"thread-reply",
"sticker",
"emoji-upload",
"sticker-upload",
"event-create",
]);
function resolveDiscordActionExecutionMode({ action }: { action: ChannelMessageActionName }) {
return localExecutionActions.has(action) ? "local" : "gateway";
}
let discordChannelActionsRuntimePromise:
| Promise<typeof import("./channel-actions.runtime.js")>
| undefined;
@@ -178,8 +192,10 @@ function describeDiscordMessageTool({
}
export const discordMessageActions: ChannelMessageActionAdapter = {
resolveExecutionMode: ({ action }) =>
action === "read" || action === "search" ? "gateway" : "local",
// Credential-only Discord actions run in the gateway when one is available.
// Send/file-style actions stay local because core owns their thread, media,
// component, and client-local payload semantics.
resolveExecutionMode: resolveDiscordActionExecutionMode,
describeMessageTool: describeDiscordMessageTool,
requiresTrustedRequesterSender: ({ action, toolContext }) =>
normalizeOptionalString(toolContext?.currentChannelProvider)?.toLowerCase() === "discord" &&

View File

@@ -180,7 +180,7 @@ describe("discordPlugin outbound", () => {
expect(discordPlugin.outbound?.preferFinalAssistantVisibleText).toBe(true);
});
it("routes read and search actions through the gateway", () => {
it("routes Discord message actions through the gateway", () => {
expect(discordPlugin.actions?.resolveExecutionMode?.({ action: "read" as never })).toBe(
"gateway",
);
@@ -190,6 +190,15 @@ describe("discordPlugin outbound", () => {
expect(discordPlugin.actions?.resolveExecutionMode?.({ action: "send" as never })).toBe(
"local",
);
expect(discordPlugin.actions?.resolveExecutionMode?.({ action: "upload-file" as never })).toBe(
"local",
);
expect(discordPlugin.actions?.resolveExecutionMode?.({ action: "thread-reply" as never })).toBe(
"local",
);
expect(discordPlugin.actions?.resolveExecutionMode?.({ action: "channel-info" as never })).toBe(
"gateway",
);
});
it("adds Discord mention formatting to agent prompt hints", () => {

View File

@@ -13,7 +13,7 @@
"zod": "4.4.3"
},
"peerDependencies": {
"openclaw": ">=2026.5.29"
"openclaw": ">=2026.5.28"
},
"peerDependenciesMeta": {
"openclaw": {

View File

@@ -17,7 +17,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.5.29"
"openclaw": ">=2026.5.28"
},
"peerDependenciesMeta": {
"openclaw": {
@@ -51,7 +51,7 @@
"minHostVersion": ">=2026.5.29"
},
"compat": {
"pluginApi": ">=2026.5.29"
"pluginApi": ">=2026.5.28"
},
"build": {
"openclawVersion": "2026.5.28"

View File

@@ -216,6 +216,26 @@ export async function recordProcessedFeishuMessage(
return await tryRecordMessagePersistent(normalizedMessageId, namespace, log);
}
export async function forgetProcessedFeishuMessage(
messageId: string | undefined | null,
namespace = "global",
log?: (...args: unknown[]) => void,
): Promise<boolean> {
const normalizedNamespace = normalizeNamespace(namespace);
const normalizedMessageId = normalizeMessageId(messageId);
if (!normalizedMessageId) {
return false;
}
memory.delete(memoryKey(normalizedNamespace, normalizedMessageId));
const key = dedupeStoreKey(normalizedNamespace, normalizedMessageId);
try {
return openDedupStore(normalizedNamespace).delete(key);
} catch (error) {
log?.(`feishu-dedup: persistent delete failed: ${String(error)}`);
return false;
}
}
export async function hasProcessedFeishuMessage(
messageId: string | undefined | null,
namespace = "global",

View File

@@ -4,6 +4,7 @@ import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js";
import { maybeHandleFeishuQuickActionMenu } from "./card-ux-launcher.js";
import {
claimUnprocessedFeishuMessage,
forgetProcessedFeishuMessage,
recordProcessedFeishuMessage,
releaseFeishuMessageProcessing,
} from "./dedup.js";
@@ -131,18 +132,20 @@ export function createFeishuBotMenuHandler(params: {
.then(async (handledMenu) => {
if (handledMenu) {
await recordProcessedFeishuMessage(syntheticMessageId, accountId, log);
releaseFeishuMessageProcessing(syntheticMessageId, accountId);
return;
}
return await handleLegacyMenu();
})
.catch(async (err) => {
if (isFeishuRetryableSyntheticEventError(err)) {
releaseFeishuMessageProcessing(syntheticMessageId, accountId);
await forgetProcessedFeishuMessage(syntheticMessageId, accountId, log);
} else {
await recordProcessedFeishuMessage(syntheticMessageId, accountId, log);
}
throw err;
})
.finally(() => {
releaseFeishuMessageProcessing(syntheticMessageId, accountId);
});
if (fireAndForget) {
promise.catch((err) => {

View File

@@ -1,5 +1,5 @@
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../runtime-api.js";
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
import { expectFirstSentCardUsesFillWidthOnly } from "./card-test-helpers.js";
import { createFeishuBotMenuHandler } from "./monitor.bot-menu-handler.js";
@@ -40,15 +40,18 @@ function createBotMenuEvent(params: { eventKey: string; timestamp: string }) {
};
}
async function registerHandlers() {
return createFeishuBotMenuHandler({
cfg: {} as ClawdbotConfig,
accountId: "default",
runtime: {
async function registerHandlers(params: { runtime?: RuntimeEnv } = {}) {
const runtime =
params.runtime ??
({
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
} as RuntimeEnv);
return createFeishuBotMenuHandler({
cfg: {} as ClawdbotConfig,
accountId: "default",
runtime,
chatHistories: new Map(),
fireAndForget: true,
getBotOpenId: () => "ou_bot",
@@ -163,7 +166,8 @@ describe("Feishu bot menu handler", () => {
});
it("reopens replay for explicit retryable fallback failures", async () => {
const onBotMenu = await registerHandlers();
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as RuntimeEnv;
const onBotMenu = await registerHandlers({ runtime });
sendCardFeishuMock
.mockImplementationOnce(async () => {
throw new Error("boom");
@@ -180,9 +184,16 @@ describe("Feishu bot menu handler", () => {
.mockResolvedValueOnce(undefined);
await onBotMenu(createBotMenuEvent({ eventKey: "quick-actions", timestamp: "1700000000004" }));
await vi.waitFor(() => {
expect(runtime.error).toHaveBeenCalledWith(
"feishu[default]: error handling bot menu event: FeishuRetryableSyntheticEventError: retry me",
);
});
await onBotMenu(createBotMenuEvent({ eventKey: "quick-actions", timestamp: "1700000000004" }));
expect(sendCardFeishuMock).toHaveBeenCalledTimes(2);
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
await vi.waitFor(() => {
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -142,6 +142,14 @@ function resolveIMessageOutboundSessionRoute(params: {
if (!handle) {
return null;
}
const account = resolveIMessageAccount({ cfg: params.cfg, accountId: params.accountId });
const service =
parsed.serviceExplicit || parsed.service !== "auto"
? parsed.service
: account.config.service === "sms"
? "sms"
: "imessage";
const directTarget = `${service}:${handle}`;
const peer: RoutePeer = { kind: "direct", id: handle };
const baseSessionKey = buildIMessageBaseSessionKey({
cfg: params.cfg,
@@ -154,8 +162,8 @@ function resolveIMessageOutboundSessionRoute(params: {
baseSessionKey,
peer,
chatType: "direct" as const,
from: `imessage:${handle}`,
to: `imessage:${handle}`,
from: directTarget,
to: directTarget,
};
}

View File

@@ -168,7 +168,7 @@ describe("iMessage monitor last-route updates", () => {
expect(recordParams?.updateLastRoute?.sessionKey).toBe(recordParams?.sessionKey);
expect(recordParams?.updateLastRoute?.sessionKey).not.toBe("agent:main:main");
expect(recordParams?.updateLastRoute?.channel).toBe("imessage");
expect(recordParams?.updateLastRoute?.to).toBe("+15550001111");
expect(recordParams?.updateLastRoute?.to).toBe("imessage:+15550001111");
expect(recordParams?.updateLastRoute?.mainDmOwnerPin).toBeUndefined();
});

View File

@@ -29,6 +29,7 @@ import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { sanitizeTerminalText } from "openclaw/plugin-sdk/text-chunking";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { resolveIMessageAccount } from "../accounts.js";
import { resolveIMessageConversationRoute } from "../conversation-route.js";
import {
isKnownFromMeIMessageMessageId,
@@ -902,7 +903,17 @@ export async function buildIMessageInboundContext(params: {
});
}
const imessageTo = (decision.isGroup ? chatTarget : undefined) || `imessage:${decision.sender}`;
const imessageTo = decision.isGroup
? chatTarget || `imessage:${decision.sender}`
: buildDirectIMessageReplyTarget({
cfg: params.cfg,
accountId: decision.route.accountId,
sender: decision.sender,
});
// Async follow-ups can resume from the stored origin instead of the immediate
// reply target. Keep direct SMS origins service-qualified the same way as To,
// or the final resumed message can fall back to imessage:<phone>.
const imessageFrom = decision.isGroup ? `imessage:group:${chatId ?? "unknown"}` : imessageTo;
const inboundHistory =
!decision.isGroup && params.dmHistory?.inboundHistory
? params.dmHistory.inboundHistory
@@ -940,9 +951,7 @@ export async function buildIMessageInboundContext(params: {
messageId: messageSid,
messageIdFull: messageGuid,
timestamp: decision.createdAt,
from: decision.isGroup
? `imessage:group:${chatId ?? "unknown"}`
: `imessage:${decision.sender}`,
from: imessageFrom,
sender: {
id: decision.sender,
name: decision.senderNormalized,
@@ -1023,6 +1032,19 @@ function buildIMessageEchoScope(params: {
return scopes;
}
function buildDirectIMessageReplyTarget(params: {
cfg: OpenClawConfig;
accountId?: string | null;
sender: string;
}): string {
const account = resolveIMessageAccount({ cfg: params.cfg, accountId: params.accountId });
const configuredService = account.config.service;
if (configuredService === "sms") {
return `sms:${params.sender}`;
}
return `imessage:${params.sender}`;
}
export function describeIMessageEchoDropLog(params: {
messageText: string;
messageId?: string;

View File

@@ -654,7 +654,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
logVerbose,
})
: undefined;
const { ctxPayload, chatTarget } = await buildIMessageInboundContext({
const { ctxPayload, chatTarget, imessageTo } = await buildIMessageInboundContext({
cfg,
decision,
message,
@@ -671,7 +671,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
},
});
const updateTarget = chatTarget || decision.sender;
const updateTarget = chatTarget || imessageTo;
const pinnedMainDmOwner = resolvePinnedMainDmOwnerFromAllowlist({
dmScope: cfg.session?.dmScope,
allowFrom,

View File

@@ -236,6 +236,16 @@ function isNumericMessageRowId(value: string | null | undefined): value is strin
return typeof value === "string" && /^\d+$/.test(value.trim());
}
function resolveTargetService(target: ParsedIMessageTarget): IMessageService | undefined {
if (target.kind !== "handle") {
return undefined;
}
if (target.serviceExplicit || target.service !== "auto") {
return target.service;
}
return undefined;
}
function normalizeResolvedMessageGuid(value: unknown): string | null {
if (typeof value !== "string") {
return null;
@@ -799,8 +809,9 @@ export async function sendMessageIMessage(
const target = parseIMessageTarget(opts.chatId ? formatIMessageChatTarget(opts.chatId) : to);
const service =
opts.service ??
(target.kind === "handle" ? target.service : undefined) ??
resolveTargetService(target) ??
(account.config.service as IMessageService | undefined);
const timeoutMs = opts.timeoutMs ?? account.config.probeTimeoutMs;
const region = opts.region?.trim() || account.config.region?.trim() || "US";
const maxBytes =
typeof opts.maxBytes === "number"
@@ -852,7 +863,7 @@ export async function sendMessageIMessage(
const resolvedReplyToId = sanitizeReplyToId(opts.replyToId);
const runCliJson =
opts.runCliJson ??
((args: readonly string[]) => runIMessageCliJson(cliPath, dbPath, args, opts.timeoutMs));
((args: readonly string[]) => runIMessageCliJson(cliPath, dbPath, args, timeoutMs));
if (filePath && !message.trim() && !resolvedReplyToId) {
const attachmentResult = await trySendAttachmentForExplicitChat({
@@ -904,7 +915,7 @@ export async function sendMessageIMessage(
try {
try {
result = await client.request<Record<string, unknown>>("send", params, {
timeoutMs: opts.timeoutMs,
timeoutMs,
});
} catch (error) {
if (filePath || resolvedReplyToId || !isIMessageRpcSendTimeout(error)) {

View File

@@ -28,7 +28,7 @@ describe("imessage targets", () => {
it("parses sms handles with service", () => {
const target = parseIMessageTarget("sms:+1555");
expect(target).toEqual({ kind: "handle", to: "+1555", service: "sms" });
expect(target).toEqual({ kind: "handle", to: "+1555", service: "sms", serviceExplicit: true });
});
it("normalizes handles", () => {

View File

@@ -15,7 +15,7 @@ export type IMessageTarget =
| { kind: "chat_id"; chatId: number }
| { kind: "chat_guid"; chatGuid: string }
| { kind: "chat_identifier"; chatIdentifier: string }
| { kind: "handle"; to: string; service: IMessageService };
| { kind: "handle"; to: string; service: IMessageService; serviceExplicit?: boolean };
export type IMessageAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string };
@@ -91,6 +91,9 @@ export function parseIMessageTarget(raw: string): IMessageTarget {
parseTarget: parseIMessageTarget,
});
if (servicePrefixed) {
if (servicePrefixed.kind === "handle") {
return { ...servicePrefixed, serviceExplicit: true };
}
return servicePrefixed;
}

View File

@@ -1,3 +1,4 @@
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { describe, expect, it } from "vitest";
import {
resolveTelegramLongPollTimeoutSeconds,
@@ -33,6 +34,15 @@ describe("resolveTelegramRequestTimeoutMs", () => {
expect(resolveTelegramRequestTimeoutMs("getupdates", 90)).toBe(45_000);
});
it("caps oversized configured timeoutSeconds before outbound timers use them", () => {
expect(resolveTelegramRequestTimeoutMs("sendmessage", Number.MAX_SAFE_INTEGER)).toBe(
MAX_TIMER_TIMEOUT_MS,
);
expect(resolveTelegramRequestTimeoutMs("sendmessage", Number.MAX_VALUE)).toBe(
MAX_TIMER_TIMEOUT_MS,
);
});
it("does not let low timeoutSeconds shorten method guards", () => {
expect(resolveTelegramRequestTimeoutMs("sendmessage", 10)).toBe(60_000);
expect(resolveTelegramRequestTimeoutMs("getme", 10)).toBe(15_000);
@@ -70,4 +80,11 @@ describe("resolveTelegramStartupProbeTimeoutMs", () => {
it("honors higher configured timeoutSeconds", () => {
expect(resolveTelegramStartupProbeTimeoutMs(60)).toBe(60_000);
});
it("caps oversized configured timeoutSeconds before startup probe timers use them", () => {
expect(resolveTelegramStartupProbeTimeoutMs(Number.MAX_SAFE_INTEGER)).toBe(
MAX_TIMER_TIMEOUT_MS,
);
expect(resolveTelegramStartupProbeTimeoutMs(Number.MAX_VALUE)).toBe(MAX_TIMER_TIMEOUT_MS);
});
});

View File

@@ -1,3 +1,8 @@
import {
finiteSecondsToTimerSafeMilliseconds,
MAX_TIMER_TIMEOUT_MS,
} from "openclaw/plugin-sdk/number-runtime";
export const TELEGRAM_GET_UPDATES_REQUEST_TIMEOUT_MS = 45_000;
const TELEGRAM_OUTBOUND_TEXT_REQUEST_TIMEOUT_MS = 60_000;
const TELEGRAM_DEFAULT_LONG_POLL_TIMEOUT_SECONDS = 30;
@@ -34,7 +39,11 @@ function resolveConfiguredTelegramRequestTimeoutMs(timeoutSeconds: unknown): num
if (typeof timeoutSeconds !== "number" || !Number.isFinite(timeoutSeconds)) {
return undefined;
}
return Math.max(1, Math.floor(timeoutSeconds)) * 1000;
return (
finiteSecondsToTimerSafeMilliseconds(Math.max(1, timeoutSeconds), {
floorSeconds: true,
}) ?? MAX_TIMER_TIMEOUT_MS
);
}
export function resolveTelegramRequestTimeoutMs(
@@ -70,6 +79,6 @@ export function resolveTelegramStartupProbeTimeoutMs(timeoutSeconds: unknown): n
if (typeof timeoutSeconds !== "number" || !Number.isFinite(timeoutSeconds)) {
return getMeTimeoutMs;
}
const configuredTimeoutMs = Math.max(1, Math.floor(timeoutSeconds)) * 1000;
const configuredTimeoutMs = resolveConfiguredTelegramRequestTimeoutMs(timeoutSeconds) ?? 1_000;
return Math.max(getMeTimeoutMs, configuredTimeoutMs);
}

View File

@@ -1,7 +1,12 @@
import { EventEmitter } from "node:events";
import { DisconnectReason } from "baileys";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
import { closeWaSocket, WhatsAppConnectionController } from "./connection-controller.js";
import {
closeWaSocket,
waitForWhatsAppLoginResult,
WhatsAppConnectionController,
} from "./connection-controller.js";
import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js";
import { createWaSocket, waitForWaConnection } from "./session.js";
@@ -127,6 +132,120 @@ describe("WhatsAppConnectionController", () => {
expect(callOrder).toEqual(["create", "wait-for-connection"]);
});
it("restarts login once on status 408 and preserves replacement socket options", async () => {
const initialSock = createSocketWithTransportEmitter();
const replacementSock = createSocketWithTransportEmitter();
const waitForConnection = vi
.fn()
.mockRejectedValueOnce({ output: { statusCode: DisconnectReason.timedOut } })
.mockResolvedValueOnce(undefined);
const onQr = vi.fn();
const onSocketReplaced = vi.fn();
const createSocket = vi.fn(
async (_printQr: boolean, _verbose: boolean, opts?: { onQr?: (qr: string) => void }) => {
opts?.onQr?.("qr-after-timeout");
return replacementSock;
},
);
const result = await waitForWhatsAppLoginResult({
sock: initialSock as never,
authDir: "/tmp/wa-auth",
isLegacyAuthDir: false,
verbose: true,
runtime: { log: vi.fn() } as never,
waitForConnection: waitForConnection as never,
createSocket: createSocket as never,
socketTiming: {
connectTimeoutMs: 10_000,
defaultQueryTimeoutMs: 20_000,
keepAliveIntervalMs: 30_000,
},
onQr,
onSocketReplaced,
});
expect(result).toEqual({
outcome: "connected",
restarted: true,
sock: replacementSock,
});
expect(initialSock.end).toHaveBeenCalledOnce();
expect(createSocket).toHaveBeenCalledWith(false, true, {
authDir: "/tmp/wa-auth",
connectTimeoutMs: 10_000,
defaultQueryTimeoutMs: 20_000,
keepAliveIntervalMs: 30_000,
onQr,
});
expect(onQr).toHaveBeenCalledWith("qr-after-timeout");
expect(onSocketReplaced).toHaveBeenCalledWith(replacementSock);
});
it("still honors the post-pairing 515 restart after a status 408 recovery", async () => {
const initialSock = createSocketWithTransportEmitter();
const afterTimeoutSock = createSocketWithTransportEmitter();
const afterPairingRestartSock = createSocketWithTransportEmitter();
const waitForConnection = vi
.fn()
.mockRejectedValueOnce({ output: { statusCode: DisconnectReason.timedOut } })
.mockRejectedValueOnce({ output: { statusCode: 515 } })
.mockResolvedValueOnce(undefined);
const createSocket = vi
.fn()
.mockResolvedValueOnce(afterTimeoutSock)
.mockResolvedValueOnce(afterPairingRestartSock);
const result = await waitForWhatsAppLoginResult({
sock: initialSock as never,
authDir: "/tmp/wa-auth",
isLegacyAuthDir: false,
verbose: false,
runtime: { log: vi.fn() } as never,
waitForConnection: waitForConnection as never,
createSocket: createSocket as never,
});
expect(result).toEqual({
outcome: "connected",
restarted: true,
sock: afterPairingRestartSock,
});
expect(createSocket).toHaveBeenCalledTimes(2);
expect(waitForConnection).toHaveBeenCalledTimes(3);
expect(initialSock.end).toHaveBeenCalledOnce();
expect(afterTimeoutSock.end).toHaveBeenCalledOnce();
});
it("does not keep recreating sockets when login status 408 persists", async () => {
const initialSock = createSocketWithTransportEmitter();
const replacementSock = createSocketWithTransportEmitter();
const timeoutError = { output: { statusCode: DisconnectReason.timedOut } };
const waitForConnection = vi
.fn()
.mockRejectedValueOnce(timeoutError)
.mockRejectedValueOnce(timeoutError);
const createSocket = vi.fn(async () => replacementSock);
const result = await waitForWhatsAppLoginResult({
sock: initialSock as never,
authDir: "/tmp/wa-auth",
isLegacyAuthDir: false,
verbose: false,
runtime: { log: vi.fn() } as never,
waitForConnection: waitForConnection as never,
createSocket: createSocket as never,
});
expect(result).toMatchObject({
outcome: "failed",
statusCode: DisconnectReason.timedOut,
error: timeoutError,
});
expect(createSocket).toHaveBeenCalledOnce();
expect(waitForConnection).toHaveBeenCalledTimes(2);
});
it("keeps the previous registered controller until a replacement listener is ready", async () => {
const liveController = new WhatsAppConnectionController({
accountId: "work",

View File

@@ -17,8 +17,12 @@ import {
import type { WhatsAppSocketTimingOptions } from "./socket-timing.js";
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
const POST_PAIRING_RESTART_STATUS = 515;
const TIMED_OUT_STATUS = DisconnectReason?.timedOut ?? 408;
const WHATSAPP_LOGIN_RESTART_MESSAGE =
"WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…";
const WHATSAPP_LOGIN_TIMEOUT_RESTART_MESSAGE =
"WhatsApp connection timed out before login; retrying with a fresh socket…";
const WHATSAPP_LOGGED_OUT_RELINK_MESSAGE =
"WhatsApp reported the session is logged out. Cleared cached web session; please rerun openclaw channels login and scan the QR again.";
export const WHATSAPP_LOGGED_OUT_QR_MESSAGE =
@@ -85,10 +89,28 @@ type WhatsAppReconnectAttemptDecision = {
healthState: "stopped" | "reconnecting";
};
type LoginSocketRestartKind = "post-pairing" | "timeout";
function createNeverResolvePromise<T>(): Promise<T> {
return new Promise<T>(() => {});
}
function getLoginSocketRestartKind(statusCode: number | undefined): LoginSocketRestartKind | null {
if (statusCode === POST_PAIRING_RESTART_STATUS) {
return "post-pairing";
}
if (statusCode === TIMED_OUT_STATUS) {
return "timeout";
}
return null;
}
function getLoginSocketRestartMessage(kind: LoginSocketRestartKind): string {
return kind === "timeout"
? WHATSAPP_LOGIN_TIMEOUT_RESTART_MESSAGE
: WHATSAPP_LOGIN_RESTART_MESSAGE;
}
type SocketActivityEmitter = {
on?: (event: string, listener: (...args: unknown[]) => void) => void;
off?: (event: string, listener: (...args: unknown[]) => void) => void;
@@ -201,21 +223,30 @@ export async function waitForWhatsAppLoginResult(params: {
const wait = params.waitForConnection ?? waitForWaConnection;
const createSocket = params.createSocket ?? createWaSocket;
let currentSock = params.sock;
let restarted = false;
let postPairingRestarted = false;
let timeoutRestarted = false;
while (true) {
try {
await wait(currentSock);
return {
outcome: "connected",
restarted,
restarted: postPairingRestarted || timeoutRestarted,
sock: currentSock,
};
} catch (err) {
const statusCode = getStatusCode(err);
if (statusCode === 515 && !restarted) {
restarted = true;
params.runtime.log(info(WHATSAPP_LOGIN_RESTART_MESSAGE));
const restartKind = getLoginSocketRestartKind(statusCode);
const canRestart =
(restartKind === "post-pairing" && !postPairingRestarted) ||
(restartKind === "timeout" && !timeoutRestarted);
if (restartKind && canRestart) {
if (restartKind === "post-pairing") {
postPairingRestarted = true;
} else {
timeoutRestarted = true;
}
params.runtime.log(info(getLoginSocketRestartMessage(restartKind)));
closeWaSocket(currentSock);
try {
currentSock = await createSocket(false, params.verbose, {

View File

@@ -129,6 +129,37 @@ describe("login-qr", () => {
expect(logoutWebMock).not.toHaveBeenCalled();
});
it("returns a replacement QR when status 408 happens before the first QR", async () => {
const accountId = "timeout-before-first-qr";
createWaSocketMock
.mockImplementationOnce(async () => ({ ws: { close: vi.fn() } }) as never)
.mockImplementationOnce(
async (
_printQr: boolean,
_verbose: boolean,
opts?: { authDir?: string; onQr?: (qr: string) => void },
) => {
const sock = { ws: { close: vi.fn() } };
setImmediate(() => opts?.onQr?.("qr-after-timeout"));
return sock as never;
},
);
waitForWaConnectionMock
.mockRejectedValueOnce({ output: { statusCode: 408 } })
.mockImplementation(() => new Promise(() => {}));
const start = await startWebLoginWithQr({
timeoutMs: 5000,
accountId,
});
expect(start).toEqual({
qrDataUrl: "data:image/png;base64,encoded:qr-after-timeout",
message: "Scan this QR in WhatsApp → Linked Devices.",
});
expect(createWaSocketMock).toHaveBeenCalledTimes(2);
});
it("clears auth and reports a relink message when WhatsApp is logged out", async () => {
waitForWaConnectionMock.mockRejectedValueOnce({
output: { statusCode: 401 },

View File

@@ -187,6 +187,7 @@ function attachLoginWaiter(accountId: string, login: ActiveLogin) {
return;
}
const qrVersion = updateLoginQrState(current, qr);
notifyQrUpdate(current);
renderLatestQrDataUrlInBackground({
accountId,
loginId: login.id,
@@ -269,8 +270,29 @@ async function waitForQrOrRecoveredLogin(params: {
message: latest.error ? `WhatsApp login failed: ${latest.error}` : "WhatsApp login failed.",
} as const;
});
const qrUpdateResult = params.login.qrUpdatePromise.then(() => {
const current = activeLogins.get(params.accountId);
if (current?.id !== params.login.id) {
return {
outcome: "failed",
message: "WhatsApp login was replaced by a newer request.",
} as const;
}
if (current.qr) {
return { outcome: "qr", qr: current.qr } as const;
}
if (current.connected) {
return { outcome: "connected" } as const;
}
return {
outcome: "failed",
message: current.error
? `WhatsApp login failed: ${current.error}`
: "WhatsApp QR update ended without an active QR.",
} as const;
});
return await Promise.race([qrResult, loginResult]);
return await Promise.race([qrResult, loginResult, qrUpdateResult]);
}
export async function startWebLoginWithQr(

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/workboard",
"version": "2026.5.22",
"version": "2026.5.28",
"private": true,
"description": "OpenClaw dashboard workboard plugin",
"type": "module",
@@ -12,7 +12,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.5.22"
"openclaw": ">=2026.5.28"
},
"peerDependenciesMeta": {
"openclaw": {

View File

@@ -214,7 +214,7 @@ describe("buildXiaomiSpeechProvider", () => {
const audio = Buffer.from("fake-mp3-audio").toString("base64");
const timeoutSpy = vi
.spyOn(globalThis, "setTimeout")
.mockImplementation((() => 1) as typeof setTimeout);
.mockImplementation((() => 1) as unknown as typeof setTimeout);
const clearTimeoutSpy = vi
.spyOn(globalThis, "clearTimeout")
.mockImplementation(() => undefined);

View File

@@ -97,7 +97,7 @@ describe("Zalo API request methods", () => {
it("caps oversized sendChatAction timeouts before scheduling the timer", async () => {
const setTimeoutMock = vi
.spyOn(globalThis, "setTimeout")
.mockImplementation((() => 1) as typeof setTimeout);
.mockImplementation((() => 1) as unknown as typeof setTimeout);
const clearTimeoutMock = vi
.spyOn(globalThis, "clearTimeout")
.mockImplementation(() => undefined);

580
npm-shrinkwrap.json generated
View File

@@ -41,6 +41,7 @@
"highlight.js": "11.11.1",
"hosted-git-info": "9.0.3",
"ignore": "7.0.5",
"ipaddr.js": "2.4.0",
"jiti": "2.7.0",
"json5": "2.2.3",
"jszip": "3.10.1",
@@ -57,7 +58,6 @@
"quickjs-wasi": "3.0.0",
"rastermill": "0.3.0",
"tar": "7.5.15",
"tokenjuice": "0.8.0",
"tree-sitter-bash": "0.25.1",
"tslog": "4.10.2",
"typebox": "1.1.38",
@@ -76,7 +76,6 @@
"node": ">=22.19.0"
},
"optionalDependencies": {
"sharp": "0.34.5",
"sqlite-vec": "0.1.9"
}
},
@@ -170,16 +169,6 @@
"node": ">=22.19.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@google/genai": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.6.0.tgz",
@@ -267,472 +256,6 @@
"hono": "^4"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -1483,16 +1006,6 @@
"node": ">= 0.8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/diff": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
@@ -2330,12 +1843,12 @@
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
"integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
"node": ">= 10"
}
},
"node_modules/is-fullwidth-code-point": {
@@ -3049,6 +2562,15 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-addr/node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
@@ -3307,19 +2829,6 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
@@ -3383,51 +2892,6 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3750,22 +3214,6 @@
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/tokenjuice": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/tokenjuice/-/tokenjuice-0.8.0.tgz",
"integrity": "sha512-8jSOhyW3NzYNx7HbbGDkNVltQPiGaZB10Tty5Ovqpsw1VOBw7y+FikykNZ4+Gp9Ze94UubtcPDak7kkyv6F2cg==",
"license": "MIT",
"bin": {
"tokenjuice": "dist/cli/main.js"
},
"engines": {
"node": ">=20"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/vincentkoc"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",

View File

@@ -1861,6 +1861,7 @@
"highlight.js": "11.11.1",
"hosted-git-info": "9.0.3",
"ignore": "7.0.5",
"ipaddr.js": "2.4.0",
"jiti": "2.7.0",
"json5": "2.2.3",
"jszip": "3.10.1",
@@ -1922,7 +1923,6 @@
"vitest": "4.1.7"
},
"optionalDependencies": {
"sharp": "0.34.5",
"sqlite-vec": "0.1.9"
},
"overrides": {

View File

@@ -1,44 +0,0 @@
{
"name": "@openclaw/net-policy",
"version": "0.0.0-private",
"private": true,
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
},
"./ip": {
"types": "./dist/ip.d.mts",
"import": "./dist/ip.mjs",
"default": "./dist/ip.mjs"
},
"./ipv4": {
"types": "./dist/ipv4.d.mts",
"import": "./dist/ipv4.mjs",
"default": "./dist/ipv4.mjs"
},
"./redact-sensitive-url": {
"types": "./dist/redact-sensitive-url.d.mts",
"import": "./dist/redact-sensitive-url.mjs",
"default": "./dist/redact-sensitive-url.mjs"
},
"./url-userinfo": {
"types": "./dist/url-userinfo.d.mts",
"import": "./dist/url-userinfo.mjs",
"default": "./dist/url-userinfo.mjs"
}
},
"scripts": {
"build": "tsdown src/index.ts src/ip.ts src/ipv4.ts src/redact-sensitive-url.ts src/url-userinfo.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
},
"dependencies": {
"ipaddr.js": "2.4.0"
}
}

View File

@@ -1,4 +0,0 @@
export * from "./ip.js";
export * from "./ipv4.js";
export * from "./redact-sensitive-url.js";
export * from "./url-userinfo.js";

View File

@@ -17,8 +17,5 @@
},
"scripts": {
"build": "tsdown src/index.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
},
"dependencies": {
"@openclaw/gateway-client": "workspace:*"
}
}

View File

@@ -1,5 +1,7 @@
import { spawn } from "node:child_process";
import { createReadStream } from "node:fs";
import fs from "node:fs/promises";
import { createServer, type Server } from "node:http";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@@ -12,6 +14,23 @@ type CommandResult = {
const COMMAND_TIMEOUT_MS = 120_000;
const tempDirs: string[] = [];
const WORKSPACE_PACKAGE_NAMES = [
"@openclaw/gateway-protocol",
"@openclaw/gateway-client",
"@openclaw/sdk",
] as const;
type PackageManifest = {
name: string;
version: string;
dependencies?: Record<string, string>;
[key: string]: unknown;
};
type PackedPackage = {
manifest: PackageManifest;
tarball: string;
};
function runCommand(
command: string,
@@ -62,6 +81,105 @@ function runCommand(
});
}
function normalizeWorkspaceDependencies(
dependencies: Record<string, string> | undefined,
): Record<string, string> | undefined {
if (!dependencies) {
return undefined;
}
const normalized: Record<string, string> = {};
for (const [name, spec] of Object.entries(dependencies)) {
normalized[name] =
name.startsWith("@openclaw/") && spec === "workspace:*" ? "0.0.0-private" : spec;
}
return normalized;
}
async function readPackageManifest(packageRoot: string): Promise<PackageManifest> {
const packageJson = await fs.readFile(path.join(packageRoot, "package.json"), "utf8");
const manifest = JSON.parse(packageJson) as PackageManifest;
return {
...manifest,
dependencies: normalizeWorkspaceDependencies(manifest.dependencies),
};
}
function tarballFileName(manifest: PackageManifest): string {
return `${manifest.name.replace(/^@/, "").replace("/", "-")}-${manifest.version}.tgz`;
}
function closeServer(server: Server): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
async function startOpenClawRegistry(packages: PackedPackage[]): Promise<{
registryUrl: string;
close: () => Promise<void>;
}> {
const byName = new Map(packages.map((pkg) => [pkg.manifest.name, pkg]));
const byTarball = new Map(packages.map((pkg) => [path.basename(pkg.tarball), pkg]));
const server = createServer((req, res) => {
const host = req.headers.host ?? "127.0.0.1";
const url = new URL(req.url ?? "/", `http://${host}`);
const decodedPath = decodeURIComponent(url.pathname);
if (decodedPath.startsWith("/tarballs/")) {
const fileName = decodedPath.slice("/tarballs/".length);
const pkg = byTarball.get(fileName);
if (!pkg) {
res.writeHead(404).end();
return;
}
res.writeHead(200, { "content-type": "application/octet-stream" });
createReadStream(pkg.tarball).pipe(res);
return;
}
const packageName = decodedPath.slice(1);
const pkg = byName.get(packageName);
if (!pkg) {
res.writeHead(404).end();
return;
}
const baseUrl = `http://${host}`;
const body = {
name: pkg.manifest.name,
"dist-tags": { latest: pkg.manifest.version },
versions: {
[pkg.manifest.version]: {
...pkg.manifest,
dist: {
tarball: `${baseUrl}/tarballs/${encodeURIComponent(path.basename(pkg.tarball))}`,
},
},
},
};
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") {
await closeServer(server);
throw new Error("registry server did not bind to a TCP port");
}
return {
registryUrl: `http://127.0.0.1:${address.port}/`,
close: () => closeServer(server),
};
}
describe("OpenClaw SDK package e2e", () => {
afterEach(async () => {
await Promise.all(
@@ -71,29 +189,54 @@ describe("OpenClaw SDK package e2e", () => {
it("packs and imports from an external temp consumer", async () => {
const repoRoot = process.cwd();
const packageRoot = path.join(repoRoot, "packages", "sdk");
const packageRoots = [
path.join(repoRoot, "packages", "gateway-protocol"),
path.join(repoRoot, "packages", "gateway-client"),
path.join(repoRoot, "packages", "sdk"),
];
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sdk-consumer-"));
tempDirs.push(tempDir);
await runCommand("pnpm", ["--filter", "@openclaw/sdk", "build"], {
cwd: repoRoot,
timeoutMs: 180_000,
});
await runCommand("pnpm", ["pack", "--pack-destination", tempDir], {
cwd: packageRoot,
});
for (const packageName of WORKSPACE_PACKAGE_NAMES) {
await runCommand("pnpm", ["--filter", packageName, "build"], {
cwd: repoRoot,
timeoutMs: 180_000,
});
}
for (const packageRoot of packageRoots) {
await runCommand("pnpm", ["pack", "--pack-destination", tempDir], {
cwd: packageRoot,
});
}
const packedFiles = (await fs.readdir(tempDir)).filter((file) => file.endsWith(".tgz"));
expect(packedFiles).toHaveLength(1);
const tarball = path.join(tempDir, packedFiles[0] ?? "");
const packedPackages: PackedPackage[] = [];
for (const packageRoot of packageRoots) {
const manifest = await readPackageManifest(packageRoot);
const tarball = path.join(tempDir, tarballFileName(manifest));
await fs.stat(tarball);
packedPackages.push({ manifest, tarball });
}
const sdkTarball =
packedPackages.find((pkg) => pkg.manifest.name === "@openclaw/sdk")?.tarball ?? "";
expect(sdkTarball).not.toBe("");
const registry = await startOpenClawRegistry(packedPackages);
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ private: true, type: "module" }),
);
await runCommand("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", tarball], {
cwd: tempDir,
});
await fs.writeFile(path.join(tempDir, ".npmrc"), `@openclaw:registry=${registry.registryUrl}`);
try {
await runCommand(
"npm",
["install", "--ignore-scripts", "--no-audit", "--no-fund", sdkTarball],
{
cwd: tempDir,
},
);
} finally {
await registry.close();
}
const importScript = `
import { GatewayClientTransport, OpenClaw, normalizeGatewayEvent } from "@openclaw/sdk";

View File

@@ -1,4 +1,4 @@
import { GatewayClient } from "@openclaw/gateway-client";
import { GatewayClient } from "../../../src/gateway/client.js";
import { EventHub } from "./event-hub.js";
import type {
ConnectableOpenClawTransport,

309
pnpm-lock.yaml generated
View File

@@ -134,6 +134,9 @@ importers:
ignore:
specifier: 7.0.5
version: 7.0.5
ipaddr.js:
specifier: 2.4.0
version: 2.4.0
jiti:
specifier: 2.7.0
version: 2.7.0
@@ -307,9 +310,6 @@ importers:
specifier: 4.1.7
version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))
optionalDependencies:
sharp:
specifier: 0.34.5
version: 0.34.5
sqlite-vec:
specifier: 0.1.9
version: 0.1.9
@@ -1685,7 +1685,7 @@ importers:
version: 2.2.3
baileys:
specifier: 7.0.0-rc13
version: 7.0.0-rc13(audio-decode@2.2.3)(sharp@0.34.5)
version: 7.0.0-rc13(audio-decode@2.2.3)
typebox:
specifier: 1.1.38
version: 1.1.38
@@ -1799,21 +1799,11 @@ importers:
packages/memory-host-sdk: {}
packages/net-policy:
dependencies:
ipaddr.js:
specifier: 2.4.0
version: 2.4.0
packages/plugin-package-contract: {}
packages/plugin-sdk: {}
packages/sdk:
dependencies:
'@openclaw/gateway-client':
specifier: workspace:*
version: link:../gateway-client
packages/sdk: {}
packages/speech-core:
dependencies:
@@ -2690,159 +2680,6 @@ packages:
peerDependencies:
hono: 4.12.18
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@isaacs/fs-minipass@4.0.1':
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
@@ -6554,10 +6391,6 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -8314,103 +8147,6 @@ snapshots:
dependencies:
hono: 4.12.18
'@img/colour@1.1.0':
optional: true
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.10.0
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@isaacs/fs-minipass@4.0.1':
dependencies:
minipass: 7.1.3
@@ -10061,7 +9797,7 @@ snapshots:
bail@2.0.2: {}
baileys@7.0.0-rc13(audio-decode@2.2.3)(sharp@0.34.5):
baileys@7.0.0-rc13(audio-decode@2.2.3):
dependencies:
'@cacheable/node-cache': 1.7.6
'@hapi/boom': 9.1.4
@@ -10076,7 +9812,6 @@ snapshots:
ws: 8.21.0
optionalDependencies:
audio-decode: 2.2.3
sharp: 0.34.5
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -12512,38 +12247,6 @@ snapshots:
setprototypeof@1.2.0: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.0
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
optional: true
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0

View File

@@ -106,7 +106,6 @@ allowBuilds:
koffi: false
node-llama-cpp: true
protobufjs: true
sharp: true
tree-sitter-bash: false
openclaw: true
"@openclaw/proxyline": true

View File

@@ -41,6 +41,17 @@ ${this.input.guestNode} ${this.input.guestOpenClawEntry} config set channels.dis
${this.input.guestNode} ${this.input.guestOpenClawEntry} config set channels.discord.groupPolicy allowlist
${this.input.guestNode} ${this.input.guestOpenClawEntry} config set channels.discord.guilds ${shellQuote(guilds)} --strict-json
${this.input.guestNode} ${this.input.guestOpenClawEntry} doctor --fix --yes --non-interactive
${this.input.guestNode} - <<'JS'
const fs = require("node:fs");
const path = require("node:path");
const configPath = path.join(process.env.HOME || "", ".openclaw", "openclaw.json");
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
config.plugins = config.plugins && typeof config.plugins === "object" ? config.plugins : {};
const allow = Array.isArray(config.plugins.allow) ? config.plugins.allow : [];
config.plugins.allow = Array.from(new Set([...allow, "discord"]));
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\\n");
JS
${this.input.guestNode} ${this.input.guestOpenClawEntry} plugins enable discord
${this.input.guestNode} ${this.input.guestOpenClawEntry} gateway restart
${this.input.guestNode} ${this.input.guestOpenClawEntry} channels status --probe --json`);
}

View File

@@ -491,6 +491,7 @@ class MacosSmoke {
if (this.discordEnabled()) {
this.status.freshDiscord = "fail";
await this.phase("fresh.discord-config", 600, () => this.configureDiscord());
await this.phase("fresh.discord-gateway-ready", 180, () => this.ensureDiscordGatewayReady());
await this.phase("fresh.discord-roundtrip", 180, () => this.runDiscordRoundtrip("fresh"));
this.status.freshDiscord = "pass";
}
@@ -542,6 +543,9 @@ class MacosSmoke {
if (this.discordEnabled()) {
this.status.upgradeDiscord = "fail";
await this.phase("upgrade.discord-config", 600, () => this.configureDiscord());
await this.phase("upgrade.discord-gateway-ready", 180, () =>
this.ensureDiscordGatewayReady(),
);
await this.phase("upgrade.discord-roundtrip", 180, () => this.runDiscordRoundtrip("upgrade"));
this.status.upgradeDiscord = "pass";
}
@@ -1069,6 +1073,15 @@ fi`,
this.discord?.configure();
}
private ensureDiscordGatewayReady(): void {
this.startManualGatewayIfNeeded();
this.verifyGateway();
const status = this.guestOpenClawEntryExec(["channels", "status", "--probe", "--json"]);
if (!status.includes('"discord"')) {
throw new Error("Discord channel unavailable after gateway restart");
}
}
private async runDiscordRoundtrip(phase: "fresh" | "upgrade"): Promise<void> {
if (!this.discord) {
throw new Error("Discord smoke is not configured");

View File

@@ -14,7 +14,6 @@
"dist/extensions/signal/runtime-api.js",
"dist/extensions/telegram/runtime-api.js",
"dist/extensions/telegram/runtime-setter-api.js",
"dist/extensions/tokenjuice/runtime-api.js",
"dist/extensions/webhooks/runtime-api.js",
"dist/extensions/workboard/runtime-api.js",
"dist/extensions/zai/runtime-api.js"

View File

@@ -72,6 +72,11 @@
"class": "core-runtime",
"risk": ["network", "proxy"]
},
"ipaddr.js": {
"owner": "core:ssrf-guard",
"class": "core-runtime",
"risk": ["network-policy"]
},
"jiti": {
"owner": "core:plugin-loader",
"class": "core-runtime",

View File

@@ -213,7 +213,32 @@ function runParallels(beta: string, model: string): void {
}
function ghJson(repo: string, pathSuffix: string): unknown {
return JSON.parse(run("gh", ["api", `repos/${repo}/${pathSuffix}`], { capture: true }));
const url = `https://api.github.com/repos/${repo}/${pathSuffix}`;
const result = spawnSync(
"bash",
[
"-lc",
[
"set -euo pipefail",
'token="$(gh auth token)"',
'curl -fsS -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "${OPENCLAW_GITHUB_REST_URL}"',
].join("\n"),
],
{
encoding: "utf8",
env: { ...process.env, OPENCLAW_GITHUB_REST_URL: url },
killSignal: "SIGKILL",
maxBuffer: CAPTURE_MAX_BUFFER_BYTES,
stdio: ["ignore", "pipe", "pipe"],
timeout: DEFAULT_COMMAND_TIMEOUT_MS,
},
);
if (result.error || result.status !== 0) {
const reason = result.status ?? result.signal ?? result.error?.message ?? "unknown";
const stderr = result.stderr ? `\n${result.stderr}` : "";
throw new Error(`GitHub REST request failed for ${pathSuffix} with ${reason}${stderr}`);
}
return JSON.parse(result.stdout ?? "");
}
export function parseWorkflowRunIdFromOutput(output: string): string | undefined {

View File

@@ -180,6 +180,21 @@ function readJson(path, label) {
}
}
async function githubApi(path) {
const token = run("gh", ["auth", "token"], { capture: true }).trim();
const response = await fetch(`https://api.github.com/${path}`, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!response.ok) {
throw new Error(`GitHub API ${path} failed with ${response.status}: ${await response.text()}`);
}
return response.json();
}
function currentBranch() {
return run("git", ["branch", "--show-current"], { capture: true }).trim();
}
@@ -188,34 +203,24 @@ function gitRevParse(ref) {
return run("git", ["rev-parse", ref], { capture: true }).trim();
}
function workflowRuns(repo, workflowFile) {
return JSON.parse(
run(
"gh",
[
"api",
`repos/${repo}/actions/workflows/${workflowFile}/runs?event=workflow_dispatch&per_page=100`,
"--jq",
".workflow_runs | map({databaseId:.id, workflowName:.name, event:.event, createdAt:.created_at})",
],
{ capture: true },
),
async function workflowRuns(repo, workflowFile) {
const data = await githubApi(
`repos/${repo}/actions/workflows/${workflowFile}/runs?event=workflow_dispatch&per_page=100`,
);
return (data.workflow_runs ?? []).map((run) => ({
databaseId: run.id,
workflowName: run.name,
event: run.event,
createdAt: run.created_at,
}));
}
function runArtifacts(repo, runId) {
return JSON.parse(
run(
"gh",
[
"api",
`repos/${repo}/actions/runs/${runId}/artifacts?per_page=100`,
"--jq",
".artifacts | map({name:.name, expired:.expired})",
],
{ capture: true },
),
);
async function runArtifacts(repo, runId) {
const data = await githubApi(`repos/${repo}/actions/runs/${runId}/artifacts?per_page=100`);
return (data.artifacts ?? []).map((artifact) => ({
name: artifact.name,
expired: artifact.expired,
}));
}
export function resolveArtifactName(artifacts, preferredName, prefix) {
@@ -237,12 +242,12 @@ export function resolveArtifactName(artifacts, preferredName, prefix) {
);
}
function resolveRunArtifactName(repo, runId, preferredName, prefix) {
return resolveArtifactName(runArtifacts(repo, runId), preferredName, prefix);
async function resolveRunArtifactName(repo, runId, preferredName, prefix) {
return resolveArtifactName(await runArtifacts(repo, runId), preferredName, prefix);
}
function beforeRunIds(repo, workflowFile) {
return new Set(workflowRuns(repo, workflowFile).map((run) => String(run.databaseId)));
async function beforeRunIds(repo, workflowFile) {
return new Set((await workflowRuns(repo, workflowFile)).map((run) => String(run.databaseId)));
}
function runAndEcho(command, args) {
@@ -284,7 +289,7 @@ async function wait(ms) {
async function findNewRunId(repo, workflowFile, workflowName, beforeIds) {
for (let attempt = 0; attempt < 60; attempt += 1) {
const match = workflowRuns(repo, workflowFile)
const match = (await workflowRuns(repo, workflowFile))
.filter(
(run) =>
run.workflowName === workflowName &&
@@ -308,31 +313,32 @@ function dispatchWorkflow(repo, workflowFile, workflowRef, fields) {
return parseRunIdFromDispatchOutput(runAndEcho("gh", args));
}
function runInfo(repo, runId) {
return JSON.parse(
run(
"gh",
[
"run",
"view",
runId,
"--repo",
repo,
"--json",
"databaseId,workflowName,headBranch,headSha,event,status,conclusion,url,jobs",
],
{ capture: true },
),
);
async function runInfo(repo, runId) {
const [runData, jobsData] = await Promise.all([
githubApi(`repos/${repo}/actions/runs/${runId}`),
githubApi(`repos/${repo}/actions/runs/${runId}/jobs?per_page=100`),
]);
return {
databaseId: runData.id,
workflowName: runData.name,
headBranch: runData.head_branch,
headSha: runData.head_sha,
event: runData.event,
status: runData.status,
conclusion: runData.conclusion,
url: runData.html_url,
jobs: (jobsData.jobs ?? []).map((job) => ({
name: job.name,
status: job.status,
conclusion: job.conclusion,
url: job.html_url,
})),
};
}
function pendingDeployments(repo, runId) {
async function pendingDeployments(repo, runId) {
try {
return JSON.parse(
run("gh", ["api", "-X", "GET", `repos/${repo}/actions/runs/${runId}/pending_deployments`], {
capture: true,
}),
);
return await githubApi(`repos/${repo}/actions/runs/${runId}/pending_deployments`);
} catch {
return [];
}
@@ -366,13 +372,17 @@ function summarizeFailedRun(info) {
async function waitForSuccessfulRun(repo, runId, expected) {
let lastState = "";
for (;;) {
const info = runInfo(repo, runId);
const info = await runInfo(repo, runId);
const state = `${info.status}:${info.conclusion ?? ""}`;
if (state !== lastState) {
console.log(
`${info.workflowName} ${runId}: ${info.status}${info.conclusion ? `/${info.conclusion}` : ""} ${info.url}`,
);
const pending = summarizePendingDeployments(repo, runId, pendingDeployments(repo, runId));
const pending = summarizePendingDeployments(
repo,
runId,
await pendingDeployments(repo, runId),
);
if (pending) {
console.log(pending);
}
@@ -404,8 +414,8 @@ function downloadArtifact(repo, runId, name, dir) {
run("gh", ["run", "download", runId, "--repo", repo, "--name", name, "--dir", dir]);
}
function downloadResolvedArtifact(repo, runId, preferredName, prefix, dir) {
const name = resolveRunArtifactName(repo, runId, preferredName, prefix);
async function downloadResolvedArtifact(repo, runId, preferredName, prefix, dir) {
const name = await resolveRunArtifactName(repo, runId, preferredName, prefix);
downloadArtifact(repo, runId, name, dir);
return name;
}
@@ -550,7 +560,7 @@ async function runTelegramIfNeeded(options, artifactName) {
return { status: "skipped" };
}
const workflowFile = "npm-telegram-beta-e2e.yml";
const before = beforeRunIds(options.repo, workflowFile);
const before = await beforeRunIds(options.repo, workflowFile);
const dispatchedRunId = dispatchWorkflow(options.repo, workflowFile, options.workflowRef, {
package_spec: `openclaw@${options.tag.replace(/^v/u, "")}`,
package_label: options.tag,
@@ -584,7 +594,7 @@ async function main() {
if (!options.fullReleaseRunId && !options.skipDispatch) {
const workflowFile = "full-release-validation.yml";
const before = beforeRunIds(options.repo, workflowFile);
const before = await beforeRunIds(options.repo, workflowFile);
const dispatchedRunId = dispatchWorkflow(options.repo, workflowFile, options.workflowRef, {
ref: options.tag,
provider: options.provider,
@@ -600,7 +610,7 @@ async function main() {
if (!options.npmPreflightRunId && !options.skipDispatch) {
const workflowFile = "openclaw-npm-release.yml";
const before = beforeRunIds(options.repo, workflowFile);
const before = await beforeRunIds(options.repo, workflowFile);
const dispatchedRunId = dispatchWorkflow(options.repo, workflowFile, options.workflowRef, {
tag: options.tag,
preflight_only: "true",
@@ -627,14 +637,14 @@ async function main() {
const npmDir = join(options.outputDir, "npm-preflight");
const fullDir = join(options.outputDir, "full-release-validation");
const npmArtifactName = downloadResolvedArtifact(
const npmArtifactName = await downloadResolvedArtifact(
options.repo,
options.npmPreflightRunId,
`openclaw-npm-preflight-${options.tag}`,
"openclaw-npm-preflight-",
npmDir,
);
const fullArtifactName = downloadResolvedArtifact(
const fullArtifactName = await downloadResolvedArtifact(
options.repo,
options.fullReleaseRunId,
`full-release-validation-${options.fullReleaseRunId}`,

View File

@@ -5,12 +5,10 @@ import {
} from "./lib/bundled-plugin-paths.mjs";
const RUN_NODE_PACKAGE_SOURCE_ROOTS = [
// Root runtime code imports these package sources through tsconfig aliases,
// while pnpm dev/watch still runs the root dist entrypoint. Treat them like
// src/ so edits restart the same process that consumes them.
// Gateway runtime code now lives in package sources, but pnpm dev/watch still
// runs the root dist entrypoint. Treat these package roots like src/.
"packages/gateway-client/src",
"packages/gateway-protocol/src",
"packages/net-policy/src",
];
export const runNodeSourceRoots = [

View File

@@ -444,6 +444,10 @@ else
if [[ -f "$LATEST_FILE" ]]; then
LATEST_VERSION="$(cat "$LATEST_FILE")"
fi
public_latest_version="$(quiet_npm view "$PACKAGE_NAME" version 2>/dev/null || true)"
if [[ -n "$public_latest_version" ]]; then
LATEST_VERSION="$public_latest_version"
fi
echo "==> Run update smoke (${UPDATE_BASELINE_VERSION} -> ${UPDATE_EXPECT_VERSION})"
run_install_smoke_container --rm -t \

View File

@@ -212,6 +212,23 @@ function npmPack(spec, destinationDir) {
return path.isAbsolute(filename) ? filename : path.join(destinationDir, filename);
}
export function parseNpmReadmeMetadata(raw) {
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return "";
}
return typeof parsed === "string" ? parsed.trim() : "";
}
function npmViewReadme(spec) {
return execFileSync("npm", ["view", spec, "readme", "--json", "--prefer-online"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -236,6 +253,36 @@ async function packPublishedPackage(spec, destinationDir) {
throw lastError;
}
async function verifyPublishedPackageReadme(spec) {
const attempts = Number.parseInt(
process.env.OPENCLAW_PLUGIN_NPM_README_VERIFY_ATTEMPTS ?? "6",
10,
);
const delayMs = Number.parseInt(
process.env.OPENCLAW_PLUGIN_NPM_README_VERIFY_DELAY_MS ?? "10000",
10,
);
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const readme = parseNpmReadmeMetadata(npmViewReadme(spec));
if (readme) {
return readme;
}
lastError = new Error(`npm view ${spec} readme returned empty metadata`);
} catch (error) {
lastError = error;
}
if (attempt < attempts) {
console.error(
`npm readme metadata for ${spec} not ready (attempt ${attempt}/${attempts}); retrying in ${delayMs}ms...`,
);
await sleep(delayMs);
}
}
throw lastError;
}
function listFiles(rootDir, prefix = "") {
const files = [];
for (const entry of fs.readdirSync(path.join(rootDir, prefix), { withFileTypes: true })) {
@@ -273,10 +320,12 @@ export async function verifyPublishedPluginRuntime(spec) {
if (errors.length > 0) {
throw new Error(errors.join("\n"));
}
const readme = await verifyPublishedPackageReadme(spec);
return {
packageName: packedPackage.packageJson.name,
version: packedPackage.packageJson.version,
fileCount: packedPackage.files.length,
readmeLength: readme.length,
};
} finally {
fs.rmSync(workingDir, { force: true, recursive: true });
@@ -290,7 +339,7 @@ async function main(argv) {
}
const result = await verifyPublishedPluginRuntime(spec);
console.log(
`plugin-npm-published-runtime-check: ${result.packageName}@${result.version} OK (${result.fileCount} files)`,
`plugin-npm-published-runtime-check: ${result.packageName}@${result.version} OK (${result.fileCount} files, ${result.readmeLength} readme chars)`,
);
}

View File

@@ -11,7 +11,8 @@ const hoisted = vi.hoisted(() => {
});
vi.mock("../../config/sessions/store-load.js", () => ({
loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath),
loadSessionStore: (storePath: string, opts?: unknown) =>
hoisted.loadSessionStoreMock(storePath, opts),
}));
vi.mock("../../config/sessions/targets.js", () => ({
@@ -57,7 +58,10 @@ describe("listAcpSessionEntries", () => {
const entries = await listAcpSessionEntries({ cfg });
expect(hoisted.resolveAllAgentSessionStoreTargetsMock).toHaveBeenCalledWith(cfg, undefined);
expect(hoisted.loadSessionStoreMock).toHaveBeenCalledWith("/custom/sessions/ops.json");
expect(hoisted.loadSessionStoreMock).toHaveBeenCalledWith(
"/custom/sessions/ops.json",
undefined,
);
expect(entries).toEqual([
{
acp: storedEntry.acp,
@@ -69,4 +73,18 @@ describe("listAcpSessionEntries", () => {
},
]);
});
it("can skip cloning for maintenance callers that only inspect ACP entries", async () => {
const cfg = { session: { store: "/custom/sessions/{agentId}.json" } } as OpenClawConfig;
hoisted.resolveAllAgentSessionStoreTargetsMock.mockResolvedValue([
{ agentId: "ops", storePath: "/custom/sessions/ops.json" },
]);
hoisted.loadSessionStoreMock.mockReturnValue({});
await listAcpSessionEntries({ cfg, clone: false });
expect(hoisted.loadSessionStoreMock).toHaveBeenCalledWith("/custom/sessions/ops.json", {
clone: false,
});
});
});

View File

@@ -99,6 +99,7 @@ export function readAcpSessionEntry(params: {
export async function listAcpSessionEntries(params: {
cfg?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
clone?: boolean;
}): Promise<AcpSessionStoreEntry[]> {
const cfg = params.cfg ?? getRuntimeConfig();
const storeTargets = await resolveAllAgentSessionStoreTargets(
@@ -111,7 +112,7 @@ export async function listAcpSessionEntries(params: {
const storePath = target.storePath;
let store: Record<string, SessionEntry>;
try {
store = loadSessionStore(storePath);
store = loadSessionStore(storePath, params.clone === false ? { clone: false } : undefined);
} catch {
continue;
}

View File

@@ -9,7 +9,6 @@ import type {
JsonSchemaValidator,
jsonSchemaValidator,
} from "@modelcontextprotocol/sdk/validation/types.js";
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import { Compile } from "typebox/compile";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { logWarn } from "../logger.js";
@@ -18,6 +17,7 @@ import {
findJsonSchemaShapeError,
normalizeJsonSchemaForTypeBox,
} from "../shared/json-schema-defaults.js";
import { redactSensitiveUrlLikeString } from "../shared/net/redact-sensitive-url.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { sanitizeServerName } from "./agent-bundle-mcp-names.js";
import type {

View File

@@ -16,6 +16,21 @@ type MockAllowlistResult = {
segments: MockAllowlistSegment[];
segmentAllowlistEntries: unknown[];
};
type MockExecApprovalEntry = {
pattern: string;
source?: string;
commandText?: string;
};
type MockResolvedExecApprovals = {
allowlist: MockExecApprovalEntry[];
file: { version: number; agents: Record<string, unknown> };
agent: {
security: string;
ask: string;
askFallback: string;
autoAllowSkills: boolean;
};
};
const INLINE_EVAL_HIT = {
executable: "python3",
@@ -54,16 +69,18 @@ const evaluateShellAllowlistMock = vi.hoisted(() =>
),
);
const resolveExecApprovalsFromFileMock = vi.hoisted(() =>
vi.fn(() => ({
allowlist: [],
file: { version: 1, agents: {} },
agent: {
security: "full",
ask: "off",
askFallback: "deny",
autoAllowSkills: false,
},
})),
vi.fn(
(): MockResolvedExecApprovals => ({
allowlist: [],
file: { version: 1, agents: {} },
agent: {
security: "full",
ask: "off",
askFallback: "deny",
autoAllowSkills: false,
},
}),
),
);
const requiresExecApprovalMock = vi.hoisted(() => vi.fn(() => true));
const hasDurableExecApprovalMock = vi.hoisted(() => vi.fn(() => false));

View File

@@ -8,6 +8,7 @@ import {
startsWithSilentToken,
stripLeadingSilentToken,
} from "../../auto-reply/tokens.js";
import { resolveToolUseId, type ToolContentBlock } from "../../chat/tool-content.js";
import {
type ClaudeCliFallbackSeed,
readClaudeCliFallbackSeed,
@@ -105,6 +106,7 @@ export function claudeCliSessionTranscriptPath(params: {
}
const CLAUDE_CLI_TRANSCRIPT_FLUSH_GRACE_MS = 250;
const CLAUDE_CLI_ORPHAN_PROBE_TAIL_BYTES = 1024 * 1024;
export async function claudeCliSessionTranscriptHasContent(params: {
sessionId: string | undefined;
@@ -137,6 +139,125 @@ export async function claudeCliSessionTranscriptHasContent(params: {
return false;
}
function toToolContentBlocks(content: unknown): ToolContentBlock[] | undefined {
if (!Array.isArray(content)) {
return undefined;
}
return content.filter((item): item is ToolContentBlock =>
Boolean(item && typeof item === "object"),
);
}
function isClaudeTranscriptToolUseBlock(block: ToolContentBlock): boolean {
const type = block.type;
return type === "tool_use" || type === "server_tool_use" || type === "mcp_tool_use";
}
function isClaudeTranscriptToolResultBlock(block: ToolContentBlock): boolean {
const type = block.type;
return type === "tool_result" || (typeof type === "string" && type.endsWith("_tool_result"));
}
async function jsonlFileHasOrphanedTrailingToolUse(filePath: string): Promise<boolean> {
try {
const stat = await fs.lstat(filePath);
if (stat.isSymbolicLink() || !stat.isFile()) {
return false;
}
const fh = await fs.open(filePath, "r");
try {
const tailBytes = Math.min(stat.size, CLAUDE_CLI_ORPHAN_PROBE_TAIL_BYTES);
const start = stat.size - tailBytes;
const buffer = Buffer.alloc(tailBytes);
const { bytesRead } = await fh.read(buffer, 0, tailBytes, start);
let tailText = buffer.toString("utf-8", 0, bytesRead);
if (start > 0) {
const firstNewline = tailText.indexOf("\n");
tailText = firstNewline === -1 ? "" : tailText.slice(firstNewline + 1);
}
let lastAssistantToolUseIds: Set<string> = new Set();
let answeredToolResultIds: Set<string> = new Set();
for (const line of tailText.split(/\r?\n/)) {
if (!line.trim()) {
continue;
}
let obj: unknown;
try {
obj = JSON.parse(line);
} catch {
continue;
}
const rec = obj as Record<string, unknown> | null;
if (rec?.isSidechain === true) {
continue;
}
const message = rec?.message as Record<string, unknown> | undefined;
const role = message?.role;
if (role === "assistant") {
lastAssistantToolUseIds = new Set();
answeredToolResultIds = new Set();
const blocks = toToolContentBlocks(message?.content);
if (!blocks) {
continue;
}
for (const block of blocks) {
if (isClaudeTranscriptToolUseBlock(block)) {
const id = resolveToolUseId(block);
if (id) {
lastAssistantToolUseIds.add(id);
}
} else if (isClaudeTranscriptToolResultBlock(block)) {
const id = resolveToolUseId(block);
if (id) {
answeredToolResultIds.add(id);
}
}
}
} else if (role === "user") {
const blocks = toToolContentBlocks(message?.content);
if (!blocks) {
continue;
}
for (const block of blocks) {
if (isClaudeTranscriptToolResultBlock(block)) {
const id = resolveToolUseId(block);
if (id) {
answeredToolResultIds.add(id);
}
}
}
}
}
for (const id of lastAssistantToolUseIds) {
if (!answeredToolResultIds.has(id)) {
return true;
}
}
return false;
} finally {
await fh.close();
}
} catch {
return false;
}
}
export async function claudeCliSessionTranscriptHasOrphanedToolUse(params: {
sessionId: string | undefined;
workspaceDir: string | undefined;
homeDir?: string;
}): Promise<boolean> {
const expectedPath = claudeCliSessionTranscriptPath({
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
homeDir: params.homeDir,
});
if (!expectedPath) {
return false;
}
return await jsonlFileHasOrphanedTrailingToolUse(expectedPath);
}
export function resolveFallbackRetryPrompt(params: {
body: string;
isFallbackRetry: boolean;

View File

@@ -2,6 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MAX_TIMER_TIMEOUT_MS } from "../../shared/number-coercion.js";
import { discoverAuthStorage, discoverModels } from "../agent-model-discovery.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
@@ -1446,6 +1447,38 @@ describe("resolveModel", () => {
);
});
it("caps oversized provider request timeout metadata at the timer-safe ceiling", () => {
mockDiscoveredModel(discoverModels, {
provider: "openai",
modelId: "gpt-5.5",
templateModel: {
...makeModel("gpt-5.5"),
provider: "openai",
},
});
const cfg = {
models: {
providers: {
openai: {
timeoutSeconds: Number.MAX_SAFE_INTEGER,
},
},
},
} satisfies OpenClawConfigInput;
const result = resolveModelForTest(
"openai",
"gpt-5.5",
"/tmp/agent",
cfg as unknown as OpenClawConfig,
);
expect(result.error).toBeUndefined();
expect((result.model as { requestTimeoutMs?: number } | undefined)?.requestTimeoutMs).toBe(
MAX_TIMER_TIMEOUT_MS,
);
});
it("uses provider-level context defaults over discovered metadata", () => {
mockDiscoveredModel(discoverModels, {
provider: "ollama",

View File

@@ -12,6 +12,7 @@ import {
normalizeProviderResolvedModelWithPlugin,
shouldPreferProviderRuntimeResolvedModel,
} from "../../plugins/provider-runtime.js";
import { finiteSecondsToTimerSafeMilliseconds } from "../../shared/number-coercion.js";
import { discoverAuthStorage, discoverModels } from "../agent-model-discovery.js";
import { resolveDefaultAgentDir } from "../agent-scope.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
@@ -338,14 +339,7 @@ function resolveConfiguredProviderDefaultApi(
}
function resolveProviderRequestTimeoutMs(timeoutSeconds: unknown): number | undefined {
if (
typeof timeoutSeconds !== "number" ||
!Number.isFinite(timeoutSeconds) ||
timeoutSeconds <= 0
) {
return undefined;
}
return Math.floor(timeoutSeconds) * 1000;
return finiteSecondsToTimerSafeMilliseconds(timeoutSeconds, { floorSeconds: true });
}
function mergeModelMediaInput(

View File

@@ -1,5 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { createModelExecAutoReviewer, parseExecAutoReviewResponse } from "./exec-auto-reviewer.js";
import { MAX_TIMER_TIMEOUT_MS } from "../shared/number-coercion.js";
import {
createModelExecAutoReviewer,
parseExecAutoReviewResponse,
resolveExecReviewerTimeoutMs,
} from "./exec-auto-reviewer.js";
const input = {
command: "git status",
@@ -304,6 +309,12 @@ describe("createModelExecAutoReviewer", () => {
}
});
it("caps oversized reviewer timeouts before scheduling timers", () => {
expect(resolveExecReviewerTimeoutMs({ timeoutMs: Number.MAX_SAFE_INTEGER })).toBe(
MAX_TIMER_TIMEOUT_MS,
);
});
it("gives reviewer completion a fresh timeout after slow model preparation", async () => {
vi.useFakeTimers();
try {

View File

@@ -8,6 +8,7 @@ import {
type ExecAutoReviewInput,
type ExecAutoReviewer,
} from "../infra/exec-auto-review.js";
import { resolveTimerTimeoutMs } from "../shared/number-coercion.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { DEFAULT_EXEC_REVIEWER_SYSTEM_PROMPT } from "./exec-auto-reviewer.prompt.js";
import {
@@ -186,10 +187,8 @@ function resolveReviewerModelRef(config?: ExecReviewerConfig): string | undefine
return coerceToolModelConfig(config?.model).primary;
}
function resolveReviewerTimeoutMs(config?: ExecReviewerConfig): number {
return typeof config?.timeoutMs === "number" && Number.isFinite(config.timeoutMs)
? Math.max(1_000, Math.floor(config.timeoutMs))
: DEFAULT_EXEC_REVIEWER_TIMEOUT_MS;
export function resolveExecReviewerTimeoutMs(config?: ExecReviewerConfig): number {
return resolveTimerTimeoutMs(config?.timeoutMs, DEFAULT_EXEC_REVIEWER_TIMEOUT_MS, 1_000);
}
function buildReviewerTimeoutDecision(timeoutMs: number): ExecAutoReviewDecision {
@@ -240,7 +239,7 @@ export function createModelExecAutoReviewer(params: {
params.deps?.completeWithPreparedSimpleCompletionModel ??
completeWithPreparedSimpleCompletionModel;
const modelRef = resolveReviewerModelRef(params.reviewer);
const timeoutMs = resolveReviewerTimeoutMs(params.reviewer);
const timeoutMs = resolveExecReviewerTimeoutMs(params.reviewer);
return async (input) => {
let completionController: AbortController | undefined;
try {

View File

@@ -1,7 +1,7 @@
import {
redactSensitiveUrl,
redactSensitiveUrlLikeString,
} from "@openclaw/net-policy/redact-sensitive-url";
} from "../shared/net/redact-sensitive-url.js";
import { isMcpConfigRecord, toMcpStringRecord } from "./mcp-config-shared.js";
export type HttpMcpTransportType = "sse" | "streamable-http";

View File

@@ -1,8 +1,3 @@
import {
isCloudMetadataIpAddress,
isLinkLocalIpAddress,
parseCanonicalIpAddress,
} from "@openclaw/net-policy/ip";
import {
fetchWithSsrFGuard,
withTrustedEnvProxyGuardedFetchMode,
@@ -17,6 +12,11 @@ import {
import type { Model } from "../llm/types.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveDebugProxySettings } from "../proxy-capture/env.js";
import {
isCloudMetadataIpAddress,
isLinkLocalIpAddress,
parseCanonicalIpAddress,
} from "../shared/net/ip.js";
import {
asFiniteNumberInRange,
clampTimerTimeoutMs,
@@ -42,6 +42,8 @@ const BLOCKED_EXACT_ORIGIN_TRUST_HOSTNAME_LABELS = new Set(["instance-data"]);
const PLAIN_DECIMAL_NUMBER_RE = /^\d+(?:\.\d+)?$/;
const RETRY_AFTER_HTTP_DATE_RE =
/^(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \d{2}-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\d{2} \d{2}:\d{2}:\d{2} GMT|(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [ \d]\d \d{2}:\d{2}:\d{2} \d{4})$/;
const RETRY_AFTER_ASCTIME_HTTP_DATE_RE =
/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [ \d]\d \d{2}:\d{2}:\d{2} \d{4}$/;
function hasReadableSseData(block: string): boolean {
const dataLines = block
@@ -268,7 +270,11 @@ function parseRetryAfterSeconds(headers: Headers): number | undefined {
return undefined;
}
const retryAt = Date.parse(trimmedRetryAfter);
const retryAt = Date.parse(
RETRY_AFTER_ASCTIME_HTTP_DATE_RE.test(trimmedRetryAfter)
? `${trimmedRetryAfter} GMT`
: trimmedRetryAfter,
);
if (Number.isNaN(retryAt)) {
return undefined;
}

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MAX_TIMER_TIMEOUT_MS } from "../shared/number-coercion.js";
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
@@ -57,8 +58,10 @@ function expectAgentWaitRequest(
const paramTimeoutMs = expectNumber(request.params?.timeoutMs, `${runId} param timeoutMs`);
const requestTimeoutMs = expectNumber(request.timeoutMs, `${runId} request timeoutMs`);
expect(requestTimeoutMs).toBe(paramTimeoutMs + 2_000);
expect(requestTimeoutMs).toBeLessThanOrEqual(maxParamTimeoutMs + 2_000);
expect(requestTimeoutMs).toBe(Math.min(paramTimeoutMs + 2_000, MAX_TIMER_TIMEOUT_MS));
expect(requestTimeoutMs).toBeLessThanOrEqual(
Math.min(maxParamTimeoutMs + 2_000, MAX_TIMER_TIMEOUT_MS),
);
expect(paramTimeoutMs).toBeGreaterThanOrEqual(1);
expect(paramTimeoutMs).toBeLessThanOrEqual(maxParamTimeoutMs);
}
@@ -268,7 +271,26 @@ describe("waitForAgentRun", () => {
});
});
it("preserves timing metadata from agent.wait", async () => {
it("caps oversized wait timeouts before sending agent.wait", async () => {
callGatewayMock.mockResolvedValue({ status: "ok" });
const result = await waitForAgentRun({
runId: "run-huge",
timeoutMs: Number.MAX_SAFE_INTEGER,
});
expect(result).toEqual({ status: "ok" });
expect(callGatewayMock).toHaveBeenCalledWith({
method: "agent.wait",
params: {
runId: "run-huge",
timeoutMs: MAX_TIMER_TIMEOUT_MS,
},
timeoutMs: MAX_TIMER_TIMEOUT_MS,
});
});
it("preserves timing metadata on provider-attributed wait timeouts", async () => {
callGatewayMock.mockResolvedValue({
status: "ok",
startedAt: 100,
@@ -475,20 +497,26 @@ describe("waitForAgentRunsToDrain", () => {
});
it("defaults non-finite drain timeouts before computing the deadline", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-30T00:00:00Z"));
callGatewayMock.mockResolvedValue({ status: "ok" });
let activeRunIds = ["run-1"];
const result = await waitForAgentRunsToDrain({
timeoutMs: Number.NaN,
getPendingRunIds: () => {
const current = activeRunIds;
activeRunIds = [];
return current;
},
});
try {
const result = await waitForAgentRunsToDrain({
timeoutMs: Number.NaN,
getPendingRunIds: () => {
const current = activeRunIds;
activeRunIds = [];
return current;
},
});
expect(result.timedOut).toBe(false);
expect(Number.isFinite(result.deadlineAtMs)).toBe(true);
expectAgentWaitRequest(requireRequestAt(gatewayWaitRequests(), 0), "run-1", 1);
expect(result.timedOut).toBe(false);
expect(Number.isFinite(result.deadlineAtMs)).toBe(true);
expectAgentWaitRequest(requireRequestAt(gatewayWaitRequests(), 0), "run-1", 1);
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -1,7 +1,7 @@
import { callGateway } from "../gateway/call.js";
import { formatErrorMessage } from "../infra/errors.js";
import { normalizeBlockedLivenessWaitStatus } from "../shared/agent-liveness.js";
import { parseFiniteNumber } from "../shared/number-coercion.js";
import { clampTimerTimeoutMs, parseFiniteNumber } from "../shared/number-coercion.js";
import { AGENT_RUN_ABORTED_ERROR, isAbortedAgentStopReason } from "./run-termination.js";
import {
normalizeAgentRunTimeoutPhase,
@@ -21,7 +21,7 @@ let runWaitDeps: {
} = defaultRunWaitDeps;
function resolveRunWaitTimeoutMs(value: number | undefined): number {
return Math.max(1, Math.floor(parseFiniteNumber(value) ?? 1));
return clampTimerTimeoutMs(parseFiniteNumber(value) ?? 1) ?? 1;
}
export type AssistantReplySnapshot = {
@@ -189,7 +189,7 @@ export async function waitForAgentRun(params: {
runId: params.runId,
timeoutMs,
},
timeoutMs: timeoutMs + 2000,
timeoutMs: clampTimerTimeoutMs(timeoutMs + 2000),
});
if (wait?.status === "timeout") {
return normalizeAgentWaitResult("timeout", wait);

View File

@@ -668,7 +668,7 @@ describe("acquireSessionWriteLock", () => {
},
{
name: "old-live.jsonl.lock",
removed: true,
removed: false,
stale: true,
staleReasons: ["too-old"],
},
@@ -680,22 +680,87 @@ describe("acquireSessionWriteLock", () => {
stale: true,
staleReasons: ["dead-pid", "too-old"],
},
{
name: "old-live.jsonl.lock",
removed: true,
stale: true,
staleReasons: ["too-old"],
},
]);
await expectPathMissing(staleDeadLock);
await expectPathMissing(staleAliveLock);
await expect(fs.access(staleAliveLock)).resolves.toBeUndefined();
await expect(fs.access(freshAliveLock)).resolves.toBeUndefined();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("cleans old live .jsonl lock files owned by non-OpenClaw processes", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-"));
const sessionsDir = path.join(root, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const nowMs = Date.now();
const lockPath = path.join(sessionsDir, "old-non-openclaw.jsonl.lock");
try {
await fs.writeFile(
lockPath,
JSON.stringify({
pid: process.pid,
createdAt: new Date(nowMs - 120_000).toISOString(),
}),
"utf8",
);
const result = await cleanStaleLockFiles({
sessionsDir,
staleMs: 30_000,
nowMs,
removeStale: true,
readOwnerProcessArgs: () => ["python", "worker.py"],
});
expect(lockCleanupRecords(result.cleaned)).toEqual([
{
name: "old-non-openclaw.jsonl.lock",
removed: true,
stale: true,
staleReasons: ["too-old", "non-openclaw-owner"],
},
]);
await expectPathMissing(lockPath);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("does not clean fresh malformed .jsonl lock files during cleanup sweeps", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-"));
const sessionsDir = path.join(root, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const nowMs = Date.now();
const lockPath = path.join(sessionsDir, "fresh-malformed.jsonl.lock");
try {
await fs.writeFile(lockPath, "{}", "utf8");
const result = await cleanStaleLockFiles({
sessionsDir,
staleMs: 30_000,
nowMs,
removeStale: true,
});
expect(lockCleanupRecords(result.locks)).toEqual([
{
name: "fresh-malformed.jsonl.lock",
removed: false,
stale: true,
staleReasons: ["missing-pid", "invalid-createdAt"],
},
]);
expect(result.cleaned).toEqual([]);
await expect(fs.access(lockPath)).resolves.toBeUndefined();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("cleans fresh live .jsonl lock files owned by a non-OpenClaw process", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-"));
const sessionsDir = path.join(root, "sessions");

View File

@@ -43,6 +43,7 @@ export const DEFAULT_SESSION_WRITE_LOCK_MAX_HOLD_MS = 5 * 60 * 1000;
export const DEFAULT_SESSION_WRITE_LOCK_ACQUIRE_TIMEOUT_MS = 60_000;
const DEFAULT_WATCHDOG_INTERVAL_MS = 60_000;
const DEFAULT_TIMEOUT_GRACE_MS = 2 * 60 * 1000;
const REPORT_ONLY_STALE_LOCK_REASONS = new Set(["too-old", "hold-exceeded"]);
/**
* Yield control to the event loop so other sessions can make progress
@@ -530,7 +531,10 @@ function shouldTreatAsNonOpenClawOwner(params: {
heldByThisProcess: boolean;
readOwnerProcessArgs: SessionLockOwnerProcessArgsReader;
}): boolean {
if (params.inspected.stale || params.inspected.pid === null || !params.inspected.pidAlive) {
if (params.inspected.pid === null || !params.inspected.pidAlive) {
return false;
}
if (params.inspected.staleReasons.includes("recycled-pid")) {
return false;
}
if (params.inspected.pid === process.pid && params.heldByThisProcess) {
@@ -578,6 +582,21 @@ async function shouldReclaimContendedLockFile(
}
}
async function shouldRemoveLockDuringCleanup(
lockPath: string,
details: LockInspectionDetails,
staleMs: number,
nowMs: number,
): Promise<boolean> {
if (!details.stale) {
return false;
}
if (details.staleReasons.every((reason) => REPORT_ONLY_STALE_LOCK_REASONS.has(reason))) {
return false;
}
return await shouldReclaimContendedLockFile(lockPath, details, staleMs, nowMs);
}
function sessionLockHeldByThisProcess(normalizedSessionFile: string): boolean {
return SESSION_LOCKS.heldEntries().some(
(entry) => entry.normalizedTargetPath === normalizedSessionFile,
@@ -728,7 +747,7 @@ export async function cleanStaleLockFiles(params: {
removed: false,
};
if (lockInfo.stale && removeStale) {
if (removeStale && (await shouldRemoveLockDuringCleanup(lockPath, lockInfo, staleMs, nowMs))) {
await fs.rm(lockPath, { force: true });
lockInfo.removed = true;
cleaned.push(lockInfo);

View File

@@ -1214,6 +1214,56 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
);
});
it("directly delivers direct-message subagent text when the announce agent omits the result", async () => {
const callGateway = createGatewayMock({
result: {
payloads: [{ text: "TG88042_NO_REOUTPUT" }],
},
});
const sendMessage = createSendMessageMock();
const result = await deliverDiscordDirectMessageCompletion({
callGateway,
sendMessage,
internalEvents: [
{
type: "task_completion",
source: "subagent",
childSessionKey: "agent:worker:subagent:child",
childSessionId: "child-session-id",
announceType: "subagent task",
taskLabel: "direct completion smoke",
status: "ok",
statusLabel: "completed successfully",
result: "TG88042_CHILD",
replyInstruction: "Summarize the result.",
},
],
});
expectRecordFields(result, {
delivered: true,
path: "direct",
});
expect(sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
channel: "discord",
accountId: "acct-1",
to: "dm:U123",
content: "TG88042_CHILD",
idempotencyKey: "announce-dm-fallback-empty:text-direct",
}),
);
expectGatewayAgentParams(callGateway, {
deliver: false,
channel: "discord",
accountId: "acct-1",
to: "dm:U123",
threadId: undefined,
sourceReplyDeliveryMode: "message_tool_only",
});
});
it("does not directly deliver failed subagent placeholder output", async () => {
const callGateway = createGatewayMock({
result: {
@@ -3503,14 +3553,18 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
});
});
it("keeps automatic final delivery for direct subagent completions", async () => {
it("requires message-tool delivery for direct subagent completions", async () => {
const callGateway = createGatewayMock({
result: {
payloads: [{ text: "The subagent is done." }],
payloads: [{ text: "The subagent is done: child completion output" }],
didSendViaMessagingTool: true,
messagingToolSentTexts: ["The subagent is done: child completion output"],
},
});
const sendMessage = createSendMessageMock();
const result = await deliverDiscordDirectMessageCompletion({
callGateway,
sendMessage,
sourceTool: "subagent_announce",
internalEvents: [
{
@@ -3533,12 +3587,14 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
path: "direct",
});
expectGatewayAgentParams(callGateway, {
deliver: true,
deliver: false,
channel: "discord",
accountId: "acct-1",
to: "dm:U123",
threadId: undefined,
sourceReplyDeliveryMode: "message_tool_only",
});
expect(sendMessage).not.toHaveBeenCalled();
});
it("falls back to the external requester route when completion origin is internal", async () => {

View File

@@ -1066,9 +1066,15 @@ async function sendSubagentAnnounceDirectly(params: {
directOrigin: effectiveDirectOrigin,
requesterSessionOrigin,
});
const subagentDirectMessageCompletionRequiresMessageTool =
params.expectsCompletionMessage &&
isSubagentCompletion &&
deliveryTarget.deliver &&
isDirectMessageDeliveryTarget(deliveryTarget, canonicalRequesterSessionKey);
const requiresMessageToolDelivery =
completionRouteRequiresMessageToolDelivery ||
(agentMediatedCompletion && expectedMediaUrls.length > 0);
(agentMediatedCompletion && expectedMediaUrls.length > 0) ||
subagentDirectMessageCompletionRequiresMessageTool;
const requesterActivity = resolveRequesterSessionActivity(canonicalRequesterSessionKey);
if (
params.expectsCompletionMessage &&
@@ -1233,7 +1239,7 @@ async function sendSubagentAnnounceDirectly(params: {
}
if (
params.expectsCompletionMessage &&
shouldDeliverAgentFinal &&
(shouldDeliverAgentFinal || subagentDirectMessageCompletionRequiresMessageTool) &&
isSubagentCompletion &&
isIncompleteAnnounceAgentResultError(err)
) {
@@ -1284,6 +1290,10 @@ async function sendSubagentAnnounceDirectly(params: {
};
}
const directDeliveryFailure =
shouldDeliverAgentFinal || requiresMessageToolDelivery
? getGatewayAgentCommandDeliveryFailure(directAnnounceResponse)
: undefined;
if (
agentMediatedCompletion &&
expectedMediaUrls.length > 0 &&
@@ -1304,9 +1314,6 @@ async function sendSubagentAnnounceDirectly(params: {
error: "completion agent did not deliver generated media",
};
}
const directDeliveryFailure = shouldDeliverAgentFinal
? getGatewayAgentCommandDeliveryFailure(directAnnounceResponse)
: undefined;
if (directDeliveryFailure) {
return {
delivered: false,
@@ -1346,6 +1353,25 @@ async function sendSubagentAnnounceDirectly(params: {
!hasGatewayAgentMessagingToolDeliveryEvidence(directAnnounceResponse) &&
!hasIntentionalSilentGatewayAgentPayload(directAnnounceResponse)
) {
if (hasFailedSubagentNoOutputCompletion(params.internalEvents)) {
return {
delivered: false,
path: "direct",
error: "completion agent did not produce a visible reply",
};
}
if (subagentDirectMessageCompletionRequiresMessageTool) {
const textDelivery = await deliverTextCompletionDirect({
cfg,
requesterSessionKey: canonicalRequesterSessionKey,
directIdempotencyKey: params.directIdempotencyKey,
deliveryTarget,
internalEvents: params.internalEvents,
});
if (textDelivery) {
return textDelivery;
}
}
return {
delivered: false,
path: "direct",

View File

@@ -215,16 +215,6 @@ function isBashToolEventName(value: unknown): boolean {
return value === "bash" || value === "exec";
}
function readToolResultStatus(result: unknown): string | undefined {
const details =
result && typeof result === "object" ? (result as { details?: unknown }).details : undefined;
if (!details || typeof details !== "object") {
return undefined;
}
const { status } = details as { status?: unknown };
return typeof status === "string" ? status : undefined;
}
function createGatewayClient(params: {
port: number;
token: string;
@@ -598,37 +588,6 @@ describeLive("subagent announce live", () => {
)}`,
).toBe("accepted");
const originalBashResult = await waitFor(
"original active child bash abort result",
() => {
if (initialError) {
throw initialError;
}
return agentEvents.find(
(event) =>
event.runId === runBeforeSteer.runId &&
event.stream === "tool" &&
event.data.phase === "result" &&
isBashToolEventName(event.data.name),
);
},
30_000,
).catch((error: unknown) => {
throw new Error(
`timed out waiting for original active child bash abort; events=${summarizeAgentEvents(
agentEvents,
runBeforeSteer.runId,
)}`,
{ cause: error },
);
});
const originalBashResultText = JSON.stringify(originalBashResult.data.result ?? "");
expect(
readToolResultStatus(originalBashResult.data.result),
summarizeAgentEvents(agentEvents, runBeforeSteer.runId),
).toBe("failed");
expect(originalBashResultText).not.toContain(unsteeredToken);
const steeredRun = await waitFor("steered child completion", () => {
if (initialError) {
throw initialError;
@@ -648,6 +607,8 @@ describeLive("subagent announce live", () => {
});
expect(steeredRun.endedReason).toBe("subagent-complete");
expect(steeredRun.delivery?.lastError).toBeUndefined();
expect(summarizeSubagentRuns(listSteeredChildRuns())).not.toContain(unsteeredToken);
expect(summarizeAgentEvents(agentEvents, runBeforeSteer.runId)).not.toContain(unsteeredToken);
await waitFor("in-process subagent completion agent dispatch start", () => {
if (initialError) {

View File

@@ -14,6 +14,7 @@ import {
} from "../../routing/session-key.js";
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
import { finiteSecondsToTimerSafeMilliseconds } from "../../shared/number-coercion.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import {
type GatewayMessageChannel,
@@ -436,7 +437,10 @@ export function createSessionsSendTool(opts?: {
// Normalize sessionKey/sessionId input into a canonical session key.
const resolvedKey = visibleSession.key;
const displayKey = visibleSession.displayKey;
const timeoutMs = timeoutSeconds * 1000;
const timeoutMs =
finiteSecondsToTimerSafeMilliseconds(timeoutSeconds, {
floorSeconds: true,
}) ?? 0;
const announceTimeoutMs = timeoutSeconds === 0 ? 30_000 : timeoutMs;
const idempotencyKey = crypto.randomUUID();
let runId: string = idempotencyKey;

View File

@@ -2,6 +2,7 @@ import os from "node:os";
import path from "node:path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelMessagingAdapter } from "../../channels/plugins/types.js";
import { MAX_TIMER_TIMEOUT_MS } from "../../shared/number-coercion.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { extractAssistantText, sanitizeTextContent } from "./sessions-helpers.js";
@@ -927,4 +928,39 @@ describe("sessions_send gating", () => {
expect(details.reply).toBeUndefined();
expect(details.sessionKey).toBe(MAIN_AGENT_SESSION_KEY);
});
it("caps oversized timeoutSeconds before waiting for the target run", async () => {
const tool = createMainSessionsSendTool();
const waitTimeouts: unknown[] = [];
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; timeoutMs?: unknown };
if (request.method === "sessions.list") {
return {
path: "/tmp/sessions.json",
sessions: [{ key: MAIN_AGENT_SESSION_KEY, kind: "direct" }],
};
}
if (request.method === "agent") {
return { runId: "run-huge-timeout", acceptedAt: 123 };
}
if (request.method === "agent.wait") {
waitTimeouts.push(request.timeoutMs);
return { runId: "run-huge-timeout", status: "ok" };
}
if (request.method === "chat.history") {
return { messages: [] };
}
return {};
});
const result = await tool.execute("call-huge-timeout", {
sessionKey: MAIN_AGENT_SESSION_KEY,
message: "ping",
timeoutSeconds: Number.MAX_SAFE_INTEGER,
});
expect(requireDetails(result).status).toBe("ok");
expect(waitTimeouts).toEqual([MAX_TIMER_TIMEOUT_MS]);
});
});

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { MAX_TIMER_TIMEOUT_SECONDS } from "../../shared/number-coercion.js";
import { resolvePositiveTimeoutSeconds, resolveTimeoutSeconds } from "./web-shared.js";
describe("web shared timeout seconds", () => {
it("caps timeoutSeconds at the shared timer-safe ceiling", () => {
expect(resolveTimeoutSeconds(Number.MAX_SAFE_INTEGER, 30)).toBe(MAX_TIMER_TIMEOUT_SECONDS);
expect(resolvePositiveTimeoutSeconds(Number.MAX_SAFE_INTEGER, 30)).toBe(
MAX_TIMER_TIMEOUT_SECONDS,
);
});
it("preserves fallback and minimum behavior", () => {
expect(resolveTimeoutSeconds(Number.NaN, 30)).toBe(30);
expect(resolveTimeoutSeconds(0, 30)).toBe(1);
expect(resolvePositiveTimeoutSeconds(0, 30)).toBe(30);
expect(resolvePositiveTimeoutSeconds(1.9, 30)).toBe(1);
});
});

View File

@@ -1,3 +1,4 @@
import { MAX_TIMER_TIMEOUT_SECONDS } from "../../shared/number-coercion.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
export type CacheEntry<T> = {
@@ -12,13 +13,13 @@ const DEFAULT_CACHE_MAX_ENTRIES = 100;
export function resolveTimeoutSeconds(value: unknown, fallback: number): number {
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
return Math.max(1, Math.floor(parsed));
return Math.min(MAX_TIMER_TIMEOUT_SECONDS, Math.max(1, Math.floor(parsed)));
}
export function resolvePositiveTimeoutSeconds(value: unknown, fallback: number): number {
const parsed =
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
return Math.max(1, Math.floor(parsed));
return Math.min(MAX_TIMER_TIMEOUT_SECONDS, Math.max(1, Math.floor(parsed)));
}
export function resolveCacheTtlMs(value: unknown, fallbackMinutes: number): number {

View File

@@ -1004,7 +1004,7 @@ function refreshSessionEntryFromStore(params: {
return fallbackEntry;
}
try {
const latestStore = loadSessionStore(storePath, { skipCache: true });
const latestStore = loadSessionStore(storePath, { skipCache: true, clone: false });
const latestEntry = latestStore?.[sessionKey];
if (!latestEntry) {
return fallbackEntry;

View File

@@ -215,6 +215,7 @@ export function initFastReplySessionState(params: {
const storePath = resolveStorePath(cfg.session?.store, { agentId });
const sessionStore: Record<string, SessionEntry> = loadSessionStore(storePath, {
skipCache: true,
clone: false,
});
const existingEntry = sessionStore[sessionKey];
const commandSource = ctx.BodyForCommands ?? ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "";

View File

@@ -284,6 +284,7 @@ export async function initSessionState(params: {
const sessionStoreLoadStartMs = ingressTimingEnabled ? Date.now() : 0;
const sessionStore: Record<string, SessionEntry> = loadSessionStore(storePath, {
skipCache: true,
clone: false,
});
if (ingressTimingEnabled) {
log.info(

View File

@@ -1,4 +1,4 @@
import { stripUrlUserInfo } from "@openclaw/net-policy/url-userinfo";
import { stripUrlUserInfo } from "../shared/net/url-userinfo.js";
import { asFiniteNumber } from "../shared/number-coercion.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { normalizeStringEntries } from "../shared/string-normalization.js";

View File

@@ -1,4 +1,3 @@
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import { normalizeChannelId } from "../../channels/plugins/index.js";
import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolution.js";
import { formatCliCommand } from "../../cli/command-format.js";
@@ -12,6 +11,7 @@ import { formatErrorMessage } from "../../infra/errors.js";
import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
import { listConfiguredChannelIdsForReadOnlyScope } from "../../plugins/channel-plugin-ids.js";
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
import { redactSensitiveUrlLikeString } from "../../shared/net/redact-sensitive-url.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import {

View File

@@ -1,4 +1,3 @@
import { validateIPv4AddressInput } from "@openclaw/net-policy/ipv4";
import { formatPortRangeHint } from "../cli/error-format.js";
import { parsePort } from "../cli/shared/parse-port.js";
import { resolveGatewayPort } from "../config/config.js";
@@ -13,6 +12,7 @@ import {
import { findTailscaleBinary } from "../infra/tailscale.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js";
import { validateIPv4AddressInput } from "../shared/net/ipv4.js";
import { normalizeOptionalString, readStringValue } from "../shared/string-coerce.js";
import { normalizeStringEntries } from "../shared/string-normalization.js";
import { note } from "../terminal/note.js";

View File

@@ -104,7 +104,7 @@ describe("noteSessionLockHealth", () => {
await expect(fs.access(freshLock)).resolves.toBeUndefined();
});
it("uses configured stale threshold when repairing lock files", async () => {
it("uses configured stale threshold without removing live OpenClaw lock files", async () => {
const sessionsDir = state.sessionsDir();
await fs.mkdir(sessionsDir, { recursive: true });
@@ -124,8 +124,8 @@ describe("noteSessionLockHealth", () => {
expect(note).toHaveBeenCalledTimes(1);
const [message] = firstNoteCall();
expect(message).toContain("stale=yes (too-old)");
expect(message).toContain("[removed]");
await expectPathMissing(configuredStaleLock);
expect(message).not.toContain("[removed]");
await expect(fs.access(configuredStaleLock)).resolves.toBeUndefined();
});
it("removes fresh live locks when the owner is not an OpenClaw process", async () => {

View File

@@ -1,5 +1,4 @@
import { existsSync } from "node:fs";
import { isLoopbackIpAddress } from "@openclaw/net-policy/ip";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
@@ -12,6 +11,7 @@ import type { GatewayProbeResult, probeGateway as probeGatewayFn } from "../gate
import type { MemoryProviderStatus } from "../memory-host-sdk/engine-storage.js";
import { defaultSlotIdForKey } from "../plugins/slots.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
import { isLoopbackIpAddress } from "../shared/net/ip.js";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,

View File

@@ -1,10 +1,10 @@
import { createSubsystemLogger } from "../logging/subsystem.js";
import { type ConfigUiHints } from "../shared/config-ui-hints-types.js";
import {
hasSensitiveUrlHintTag,
isSensitiveUrlConfigPath,
redactSensitiveUrlLikeString,
} from "@openclaw/net-policy/redact-sensitive-url";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { type ConfigUiHints } from "../shared/config-ui-hints-types.js";
} from "../shared/net/redact-sensitive-url.js";
import { isRecord as isObjectRecord } from "../shared/record-coerce.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import {

View File

@@ -1,4 +1,4 @@
import { isSensitiveUrlConfigPath } from "@openclaw/net-policy/redact-sensitive-url";
import { isSensitiveUrlConfigPath } from "../shared/net/redact-sensitive-url.js";
import { VERSION } from "../version.js";
import { FIELD_HELP } from "./schema.help.js";
import type { ConfigUiHints } from "./schema.hints.js";

View File

@@ -1,5 +1,5 @@
import { SENSITIVE_URL_HINT_TAG } from "@openclaw/net-policy/redact-sensitive-url";
import { describe, expect, it } from "vitest";
import { SENSITIVE_URL_HINT_TAG } from "../shared/net/redact-sensitive-url.js";
import { computeBaseConfigSchemaResponse } from "./schema-base.js";
type TestJsonSchema = {

View File

@@ -306,9 +306,9 @@ export const FIELD_HELP: Record<string, string> = {
"agents.list[].heartbeat.suppressToolErrorWarnings":
"Suppress tool error warning payloads during heartbeat runs.",
"agents.defaults.heartbeat.timeoutSeconds":
"Maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use agents.defaults.timeoutSeconds.",
"Maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use agents.defaults.timeoutSeconds when set, otherwise the heartbeat cadence capped at 600 seconds.",
"agents.list[].heartbeat.timeoutSeconds":
"Per-agent maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to inherit the merged heartbeat/default agent timeout.",
"Per-agent maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to inherit the merged heartbeat timeout, then agents.defaults.timeoutSeconds when set, otherwise the heartbeat cadence capped at 600 seconds.",
"agents.defaults.heartbeat.skipWhenBusy":
"When true, defer heartbeat turns on this agent's extra busy lanes: its own session-keyed subagent or nested command work. Cron lanes always defer heartbeat turns.",
"agents.list[].heartbeat.skipWhenBusy":

View File

@@ -1,7 +1,7 @@
import { isSensitiveUrlConfigPath } from "@openclaw/net-policy/redact-sensitive-url";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { buildSecretInputSchema } from "../plugin-sdk/secret-input-schema.js";
import { isSensitiveUrlConfigPath } from "../shared/net/redact-sensitive-url.js";
import { FIELD_HELP } from "./schema.help.js";
import { testApi, isPluginOwnedChannelHintPath, isSensitiveConfigPath } from "./schema.hints.js";
import { FIELD_LABELS } from "./schema.labels.js";

View File

@@ -1,10 +1,10 @@
import {
isSensitiveUrlConfigPath,
SENSITIVE_URL_HINT_TAG,
} from "@openclaw/net-policy/redact-sensitive-url";
import { z } from "zod";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { ConfigUiHints } from "../shared/config-ui-hints-types.js";
import {
isSensitiveUrlConfigPath,
SENSITIVE_URL_HINT_TAG,
} from "../shared/net/redact-sensitive-url.js";
import { FIELD_HELP } from "./schema.help.js";
import { FIELD_LABELS } from "./schema.labels.js";
import { applyDerivedTags } from "./schema.tags.js";

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