Compare commits

..

145 Commits

Author SHA1 Message Date
Peter Steinberger
6e5160fe4c fix: stabilize plugin-sdk export release guard (#28575) (thanks @Glucksberg) 2026-03-02 21:30:27 +00:00
Glucksberg
4c6abc9958 fix(release-check): add 4 missing plugin-sdk exports to align with check script 2026-03-02 21:28:05 +00:00
Glucksberg
f4bb16eb8a fix(plugin-sdk): add export verification tests and release guard (#27569) 2026-03-02 21:28:04 +00:00
Peter Steinberger
21d6d878ce fix: harden exec allowlist regex literal handling (#32162) (thanks @stakeswky) 2026-03-02 21:26:24 +00:00
User
8da8756f76 fix(exec): escape regex literals in allowlist path matching 2026-03-02 21:26:24 +00:00
George Pickett
a4927ed8ee fix: OpenAI OAuth TLS preflight gating (#32051) (thanks @alexfilatov) 2026-03-02 13:24:49 -08:00
George Pickett
1f24323583 Auth: gate OpenAI OAuth TLS preflight in doctor 2026-03-02 13:24:49 -08:00
Alex Filatov
dc8a56c857 Fix TLS cert preflight classification false positive 2026-03-02 13:24:49 -08:00
Alex Filatov
f181b7dbe6 Add OpenAI OAuth TLS preflight and doctor prerequisite check 2026-03-02 13:24:49 -08:00
scoootscooob
0f1388fa15 fix(gateway): hot-reload channelHealthCheckMinutes without full restart
The health monitor was created once at startup and never touched by
applyHotReload(), so changing channelHealthCheckMinutes only took
effect after a full gateway restart.

Wire up a "restart-health-monitor" reload action so hot-reload can
stop the old monitor and (re)create one with the updated interval —
or disable it entirely when set to 0.

Closes #32105

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:23:20 +00:00
Peter Steinberger
b782ecb7eb refactor: harden plugin install flow and main DM route pinning 2026-03-02 21:22:38 +00:00
Peter Steinberger
af637deed1 fix: propagate whatsapp inbound fromMe context (#32167) (thanks @scoootscooob) 2026-03-02 21:20:21 +00:00
scoootscooob
73e6dc361e fix(whatsapp): propagate fromMe through inbound message pipeline
The `fromMe` flag from Baileys' WAMessage.key was only used for
access-control filtering and then discarded.  This meant agents
could not distinguish owner-sent messages from contact messages
in DM conversations (everything appeared as from the contact).

Add `fromMe` to `WebInboundMessage`, store it during message
construction, and thread it through `buildInboundLine` →
`formatInboundEnvelope` so DM transcripts prefix owner messages
with `(self):`.

Closes #32061

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:20:21 +00:00
Peter Steinberger
866bd91c65 refactor: harden msteams lifecycle and attachment flows 2026-03-02 21:19:23 +00:00
Peter Steinberger
d98a61a977 fix(config): move sensitive-schema hint warnings to debug 2026-03-02 21:13:58 +00:00
Peter Steinberger
d01e04bcec test(perf): reduce heavy fixture and guardrail overhead 2026-03-02 21:07:52 +00:00
Peter Steinberger
5a32a66aa8 perf(core): speed up routing, pairing, slack, and security scans 2026-03-02 21:07:52 +00:00
Peter Steinberger
3a08e69a05 refactor: unify queueing and normalize telegram slack flows 2026-03-02 20:55:15 +00:00
Peter Steinberger
320920d523 fix: harden bundled plugin install fallback semantics (#32096) (thanks @scoootscooob) 2026-03-02 20:49:50 +00:00
Peter Steinberger
ad12d1fbce fix(plugins): prefer bundled plugin ids over bare npm specs 2026-03-02 20:49:50 +00:00
scoootscooob
bfb6c6290f fix: distinguish warning message for non-OpenClaw vs missing npm package
Address Greptile review: show "not a valid OpenClaw plugin" when the
npm package was found but lacks openclaw.extensions, instead of the
misleading "npm package unavailable" message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 20:49:50 +00:00
scoootscooob
da8a17d8de fix(plugins): fall back to bundled plugin when npm spec resolves to non-OpenClaw package (#32019)
When `openclaw plugins install diffs` downloads the unrelated npm
package `diffs@0.1.1` (which lacks `openclaw.extensions`), the install
fails without trying the bundled `@openclaw/diffs` plugin.

Two fixes:
1. Broaden the bundled-fallback trigger to also fire on
   "missing openclaw.extensions" errors (not just npm 404s)
2. Match bundled plugins by pluginId in addition to npmSpec so
   unscoped names like "diffs" resolve to `@openclaw/diffs`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 20:49:50 +00:00
Peter Steinberger
089a8785b9 fix: harden msteams revoked-context fallback delivery (#27224) (thanks @openperf) 2026-03-02 20:49:03 +00:00
root
e0b91067e3 fix(msteams): add proactive fallback for revoked turn context
Fixes #27189

When an inbound message is debounced, the Bot Framework turn context is
revoked before the debouncer flushes and the reply is dispatched. Any
attempt to use the revoked context proxy throws a TypeError, causing the
reply to fail silently.

This commit fixes the issue by adding a fallback to proactive messaging
when the turn context is revoked:

- `isRevokedProxyError()`: New error utility to reliably detect when a
  proxy has been revoked.

- `reply-dispatcher.ts`: `sendTypingIndicator` now catches revoked proxy
  errors and falls back to sending the typing indicator via
  `adapter.continueConversation`.

- `messenger.ts`: `sendMSTeamsMessages` now catches revoked proxy errors
  when `replyStyle` is `thread` and falls back to proactive messaging.

This ensures that replies are delivered reliably even when the inbound
message was debounced, resolving the core issue where the bot appeared
to ignore messages.
2026-03-02 20:49:03 +00:00
Peter Steinberger
d2bb04b436 fix: document msteams auth redirect scoping hardening (#25045) (thanks @bmendonca3) 2026-03-02 20:45:09 +00:00
bmendonca3
4a414c5e53 fix(msteams): scope auth across media redirects 2026-03-02 20:45:09 +00:00
bmendonca3
da22a9113c test(msteams): cover auth stripping on graph redirect hops 2026-03-02 20:45:09 +00:00
bmendonca3
8937c10f1f fix(msteams): scope graph auth redirects 2026-03-02 20:45:09 +00:00
Peter Steinberger
259f6543b4 fix: harden config backup permissions and cleanup (#31718) (thanks @YUJIE2002) 2026-03-02 20:40:15 +00:00
YUJIE2002
3c0ec76e8e fix(config): harden backup file permissions and clean orphan .bak files
Addresses #31699 — config .bak files persist with sensitive data.

Changes:
- Explicitly chmod 0o600 on all .bak files after creation, instead of
  relying on copyFile to preserve source permissions (not guaranteed on
  all platforms, e.g. Windows, NFS mounts).
- Clean up orphan .bak files that fall outside the managed 5-deep
  rotation ring (e.g. PID-stamped leftovers from interrupted writes,
  manual backups like .bak.before-marketing).
- Add tests for permission hardening and orphan cleanup.

The backup ring itself is preserved — it's a valuable recovery mechanism.
This PR hardens the security surface by ensuring backup files are
always owner-only and stale copies don't accumulate indefinitely.
2026-03-02 20:40:15 +00:00
Peter Steinberger
d80144f572 fix: keep long Telegram model callbacks selectable (#31857) (thanks @bmendonca3) 2026-03-02 20:38:43 +00:00
bmendonca3
54eb13893f Telegram: support compact model callback fallback 2026-03-02 20:38:43 +00:00
bmendonca3
c582a54554 fix(msteams): preserve guarded dispatcher redirects 2026-03-02 20:37:47 +00:00
bmendonca3
cceecc8bd4 msteams: enforce guarded redirect ownership in safeFetch 2026-03-02 20:37:47 +00:00
Jason Separovic
00347bda75 fix(tools): strip xAI-unsupported JSON Schema keywords from tool definitions
xAI rejects minLength, maxLength, minItems, maxItems, minContains, and
maxContains in tool schemas with a 502 error instead of ignoring them.
This causes all requests to fail when any tool definition includes these
validation-constraint keywords (e.g. sessions_spawn uses maxLength and
maxItems on its attachment fields).

Add stripXaiUnsupportedKeywords() in schema/clean-for-xai.ts, mirroring
the existing cleanSchemaForGemini() pattern. Apply it in normalizeToolParameters()
when the provider is xai directly, or openrouter with an x-ai/* model id.

Fixes tool calls for x-ai/grok-* models both direct and via OpenRouter.
2026-03-02 20:37:07 +00:00
Kay-051
da05395c2a fix(telegram): preserve original filename from Telegram document/audio/video uploads
The downloadAndSaveTelegramFile inner function only used the server-side
file path (e.g. "documents/file_42.pdf") or the Content-Disposition
header (which Telegram doesn't send) to derive the saved filename.
The original filename provided by Telegram via msg.document.file_name,
msg.audio.file_name, msg.video.file_name, and msg.animation.file_name
was never passed through, causing all inbound files to lose their
user-provided names.

Now downloadAndSaveTelegramFile accepts an optional telegramFileName
parameter that takes priority over the fetched/server-side name.
The resolveMedia call site extracts the original name from the message
and passes it through.

Closes #31768

Made-with: Cursor
2026-03-02 20:36:39 +00:00
Altay
e45d26b9ed chore(gitignore): add .claude folder to gitignore (#32141) 2026-03-02 12:35:56 -08:00
bmendonca3
16e7fc2563 fix(models): infer codex weekly usage labels from reset cadence 2026-03-02 20:35:45 +00:00
SidQin-cyber
479095bcfb fix(discord): use per-channel message queues to restore parallel agent dispatch
Replace the single per-account messageQueue Promise chain in
DiscordMessageListener with per-channel queues. This restores parallel
processing for channel-bound agents that regressed in 2026.3.1.

Messages within the same channel remain serialized to preserve ordering,
while messages to different channels now proceed independently. Completed
queue entries are cleaned up to prevent memory accumulation.

Closes #31530
2026-03-02 20:34:41 +00:00
SidQin-cyber
5b63417fec fix(slack): apply mrkdwn conversion in streaming and preview paths
The native streaming path (chatStream) and preview final edit path
(chat.update) send raw Markdown text without converting to Slack
mrkdwn format. This causes **bold** to appear as literal asterisks
instead of rendered bold text.

Apply markdownToSlackMrkdwn() in streaming.ts (start/append/stop) and
in dispatch.ts (preview final edit via chat.update) to match the
non-streaming delivery path behavior.

Closes #31892
2026-03-02 20:34:41 +00:00
bmendonca3
6945ba189d msteams: harden webhook ingress timeouts 2026-03-02 20:34:05 +00:00
webdevtodayjason
ab0b2c21f3 WhatsApp: guard main DM last-route to single owner 2026-03-02 20:33:59 +00:00
Mitch McAlister
f534ea9906 fix: prevent reasoning text leak through handleMessageEnd fallback
When enforceFinalTag is active (Google providers), stripBlockTags
correctly returns empty for text without <final> tags. However, the
handleMessageEnd fallback recovered raw text, bypassing this protection
and leaking internal reasoning (e.g. "**Applying single-bot mention
rule**NO_REPLY") to Discord.

Guard the fallback with enforceFinalTag check: if the provider is
supposed to use <final> tags and none were seen, the text is treated
as leaked reasoning and suppressed.

Also harden stripSilentToken regex to allow bold markdown (**) as
separator before NO_REPLY, matching the pattern Gemini Flash Lite
produces.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 20:32:01 +00:00
chilu18
15677133c1 test(msteams): remove tuple-unsafe spread in lifecycle mocks 2026-03-02 20:31:26 +00:00
chilu18
c9d0e345cb fix(msteams): keep monitor alive until shutdown 2026-03-02 20:31:26 +00:00
liuxiaopai-ai
bf0653846e Gateway: suppress NO_REPLY lead-fragment chat leaks 2026-03-02 20:27:49 +00:00
Peter Steinberger
3de7768b11 perf(routing): cache normalized agent-id lookups 2026-03-02 20:19:10 +00:00
Peter Steinberger
2937fe0351 perf(config): skip redundant schema and session-store work 2026-03-02 20:19:10 +00:00
Peter Steinberger
fb5d8a9cd1 perf(slack): memoize allow-from and mention paths 2026-03-02 20:19:10 +00:00
Peter Steinberger
2f352306fe perf(security): cache scanner directory walks 2026-03-02 20:19:10 +00:00
Peter Steinberger
f7765bc151 perf(cron): cache schedule evaluators and stagger offsets 2026-03-02 20:19:10 +00:00
Jean-Marc
b52561bfa3 fix(synology-chat): prevent restart loop in startAccount (#23074)
* fix(synology-chat): prevent restart loop in startAccount

startAccount must return a Promise that stays pending while the channel
is running. The gateway wraps the return value in Promise.resolve(), and
when it resolves, the gateway thinks the channel crashed and auto-restarts
with exponential backoff (5s → 10s → 20s..., up to 10 attempts).

Replace the synchronous { stop } return with a Promise<void> that resolves
only when ctx.abortSignal fires, keeping the channel alive until shutdown.

Tested on Synology DS923+ with DSM 7.2 — single startup, no restart loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(synology-chat): add type guards for startAccount return value

startAccount returns `void | { stop: () => void }` — TypeScript requires
a type guard before accessing .stop on the union type. Added proper checks
in both integration and unit tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(synology-chat): use Readable stream in integration test for Windows compat

Replace EventEmitter + process.nextTick with Readable stream for
request body simulation. The process.nextTick approach caused the test
to hang on Windows CI (120s timeout) because events were not reliably
delivered to readBody() listeners.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: stabilize synology gateway account lifecycle (#23074) (thanks @druide67)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-02 20:06:16 +00:00
Peter Steinberger
4b50018406 fix: restore helper imports and plugin hook test exports 2026-03-02 19:57:33 +00:00
Peter Steinberger
7003615972 fix: resolve rebase conflict markers 2026-03-02 19:57:33 +00:00
Peter Steinberger
eb816e0551 refactor: dedupe extension and ui helpers 2026-03-02 19:57:33 +00:00
Peter Steinberger
b1c30f0ba9 refactor: dedupe cli config cron and install flows 2026-03-02 19:57:33 +00:00
Peter Steinberger
9d30159fcd refactor: dedupe channel and gateway surfaces 2026-03-02 19:57:33 +00:00
Peter Steinberger
9617ac9dd5 refactor: dedupe agent and reply runtimes 2026-03-02 19:57:33 +00:00
Peter Steinberger
8768487aee refactor(shared): dedupe protocol schema typing and session/media helpers 2026-03-02 19:57:33 +00:00
Peter Steinberger
ee0d7ba6d6 chore: normalize changelog credit for #31841 (thanks @liuxiaopai-ai) 2026-03-02 19:56:18 +00:00
liuxiaopai-ai
c48a0621ff fix(agents): map sandbox workdir from container path 2026-03-02 19:56:18 +00:00
Peter Steinberger
b1cc8ffe9e fix: migrate legacy cron store shapes (#31926) (thanks @bmendonca3) 2026-03-02 19:55:19 +00:00
bmendonca3
4cd04e4652 fix(cron): migrate legacy string schedule and command jobs 2026-03-02 19:55:19 +00:00
Peter Steinberger
c424836fbe refactor: harden outbound, matrix bootstrap, and plugin entry resolution 2026-03-02 19:55:09 +00:00
Peter Steinberger
a351ab2481 fix: persist webchat stream-only finals (#31920) (thanks @Sid-Qin) 2026-03-02 19:54:26 +00:00
SidQin-cyber
15226b0b83 fix(gateway): persist streamed text when webchat final event lacks message
When an agent streams text and then immediately runs tool calls, the
webchat UI drops the streamed content: the "final" event arrives with
message: undefined (buffer consumed by sub-run), and the client clears
chatStream without saving it to chatMessages.

Before clearing chatStream on a "final" event, check whether the stream
buffer has content. If no finalMessage was provided but the stream is
non-empty, synthesize an assistant message from the buffered text —
mirroring the existing "aborted" handler's preservation logic.

Closes #31895
2026-03-02 19:54:26 +00:00
Peter Steinberger
0cf533ac61 fix: recover orphan same-pid session locks (#32081) (thanks @bmendonca3) 2026-03-02 19:53:41 +00:00
bmendonca3
4985c561df sessions: reclaim orphan self-pid lock files 2026-03-02 19:53:41 +00:00
Peter Steinberger
160dad56c4 fix: suppress HEARTBEAT_OK fallback leak (#32093) (thanks @scoootscooob) 2026-03-02 19:51:51 +00:00
scoootscooob
a3c5d21b4d fix(cron): suppress HEARTBEAT_OK summary from leaking into main session (#32013)
When an isolated cron agent returns HEARTBEAT_OK (nothing to announce),
the direct delivery is correctly skipped, but the fallback path in
timer.ts still enqueues the summary as a system event to the main
session. Filter out heartbeat-only summaries using isCronSystemEvent
before enqueuing, so internal ack tokens never reach user conversations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:51:51 +00:00
Jean-Marc
9a3800d8e6 fix(synology-chat): resolve Chat API user_id for reply delivery (#23709)
* fix(synology-chat): resolve Chat API user_id for reply delivery

Synology Chat outgoing webhooks use a per-integration user_id that
differs from the global Chat API user_id required by method=chatbot.
This caused reply messages to fail silently when the IDs diverged.

Changes:
- Add fetchChatUsers() and resolveChatUserId() to resolve the correct
  Chat API user_id via the user_list endpoint (cached 5min)
- Use resolved user_id for all sendMessage() calls in webhook handler
  and channel dispatcher
- Add Provider field to MsgContext so the agent runner correctly
  identifies the message channel (was "unknown", now "synology-chat")
- Log warnings when user_list API fails or when falling back to
  unresolved webhook user_id
- Add 5 tests for user_id resolution (nickname, username, case,
  not-found, URL rewrite)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(synology-chat): use Readable stream in integration test for Windows compat

Replace EventEmitter + process.nextTick with Readable stream for
request body simulation. The process.nextTick approach caused the test
to hang on Windows CI (120s timeout) because events were not reliably
delivered to readBody() listeners.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: harden synology reply user resolution and cache scope (#23709) (thanks @druide67)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-02 19:50:58 +00:00
Peter Steinberger
39afcee864 test(perf): trim cron and audit fixture overhead 2026-03-02 19:48:02 +00:00
Peter Steinberger
d979eeda9f perf(runtime): reduce slack prep and qmd cache-key overhead 2026-03-02 19:48:02 +00:00
Peter Steinberger
8e48f7e353 fix(tui): honor explicit gateway auth for url overrides 2026-03-02 19:48:02 +00:00
Peter Steinberger
2a2e2c3630 fix: land synology webhook payload compatibility ACK (#26635) (thanks @memphislee09-source) 2026-03-02 19:45:55 +00:00
memphislee09-source
92bf77d9a0 fix(synology-chat): accept JSON/aliases and ACK webhook with 204 2026-03-02 19:45:55 +00:00
Peter Steinberger
a3bb7a5ee5 fix: land synology webhook bounded body reads (#25831) (thanks @bmendonca3) 2026-03-02 19:42:56 +00:00
bmendonca3
2b088ca125 test(synology-chat): use real plugin-sdk helper exports 2026-03-02 19:42:56 +00:00
bmendonca3
aeeb0474c6 test(synology-chat): match request destroy typing 2026-03-02 19:42:56 +00:00
bmendonca3
6df36a8b35 fix(synology-chat): bound webhook body read time 2026-03-02 19:42:56 +00:00
Mark L
fbd1210ec2 fix(plugins): support legacy install entry fallback (#32055)
* fix(plugins): fallback install entrypoints for legacy manifests

* Voice Call: enforce exact webhook path match

* Tests: isolate webhook path suite and reset cron auth state

* chore: keep #31930 scoped to voice webhook path fix

* fix: add changelog for exact voice webhook path match (#31930) (thanks @afurm)

* fix: handle HTTP 529 (Anthropic overloaded) in failover error classification

Classify Anthropic's 529 status code as "rate_limit" so model fallback
triggers reliably without depending on fragile message-based detection.

Closes #28502

* fix: add changelog for HTTP 529 failover classification (#31854) (thanks @bugkill3r)

* fix(slack): guard against undefined text in includes calls during mention handling

* fix: add changelog for mentions/slack null-safe guards (#31865) (thanks @stone-jin)

* fix(memory-lancedb): pass dimensions to embedding API call

- Add dimensions parameter to Embeddings constructor
- Pass dimensions to OpenAI embeddings.create() API call
- Fixes dimension mismatch when using custom embedding models like DashScope text-embedding-v4

* fix: add regression for memory-lancedb dimensions pass-through (#32036) (thanks @scotthuang)

* fix(telegram): guard malformed native menu specs

* fix: harden plugin command registration + telegram menu guard (#31997) (thanks @liuxiaopai-ai)

* fix(gateway): restart heartbeat on model config changes

* fix: add changelog credit for heartbeat model reload (#32046) (thanks @stakeswky)

* test(process): replace no-output timer subprocess with spawn mock

* test(perf): trim repeated setup in cron memory and config suites

* test(perf): reduce per-case setup in script and git-hook tests

* fix(slack): scope debounce key by message timestamp to prevent cross-thread collisions

Top-level channel messages from the same sender shared a bare channel
debounce key, causing concurrent messages in different threads to merge
into a single reply on the wrong thread. Now the debounce key includes
the message timestamp for top-level messages, matching how the downstream
session layer already scopes by canonicalThreadId.

Extracted buildSlackDebounceKey() for testability.

Closes #31935

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: harden slack debounce key routing and ordering (#31951) (thanks @scoootscooob)

* fix(openrouter): skip reasoning.effort injection for x-ai/grok models

x-ai/grok models on OpenRouter do not support the reasoning.effort
parameter and reject payloads containing it with "Invalid arguments
passed to the model." Skip reasoning injection for these models, the
same way we already skip it for the dynamic "auto" routing model.

Closes #32039

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add changelog credit for openrouter x-ai reasoning guard (#32054) (thanks @scoootscooob)

* fix(agents): scope volcengine-plan/byteplus-plan auth lookup to profile resolution

The configure flow stores auth credentials under `provider: "volcengine"`,
but the coding model uses `volcengine-plan` as its provider. Add a scoped
`normalizeProviderIdForAuth` function used only by `listProfilesForProvider`
so coding-plan variants resolve to their base provider for auth credential
lookup without affecting global provider routing.

Closes #31731

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tools): honor fsPolicy.workspaceOnly in image/pdf tool localRoots

PR #28822 fixed the Write/Edit tools to respect `tools.fs.workspaceOnly`,
but the image and PDF tools still unconditionally include default local
roots (`~/.openclaw/media`, `~/.openclaw/agents`, etc.) when computing
the `localRoots` allowlist for non-sandbox mode.

When `fsPolicy.workspaceOnly` is true, restrict `localRoots` to only the
workspace directory so that files outside the workspace are rejected by
`assertLocalMediaAllowed()`.

Relates to #31716

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add changelog credit for fsPolicy image/pdf propagation (#31882) (thanks @justinhuangcode)

* fix: skip Telegram command sync when menu is unchanged (#32017)

Hash the command list and cache it to disk per account. On restart,
compare the current hash against the cached one and skip the
deleteMyCommands + setMyCommands round-trip when nothing changed.
This prevents 429 rate-limit errors when the gateway restarts
several times in quick succession.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(telegram): scope command-sync hash cache by bot identity (#32059)

* fix: normalize coding-plan providers in auth order validation

* feat(security): Harden Docker browser container chromium flags (#23889) (#31504)

* Gateway: honor OPENCLAW_GATEWAY_URL override for remote/local calls

* Agents: fix sandbox sessionKey usage for PI embedded subagent calls

* Sandbox: tighten browser container Chromium runtime flags

* fix: add sandbox browser defaults for container hardening

* docs: expand sandbox browser default flags list

* fix: make sandbox browser flags optional and preserve gateway env auth overrides

* docs: scope PR 31504 changelog entry

* style: format gateway call override handling

* fix: dedupe sandbox browser chrome args

* fix: preserve remote tls fingerprint for env gateway override

* fix: enforce auth for env gateway URL override

* chore: document gateway override auth security expectations

* fix(delivery): strip HTML tags for plain-text messaging surfaces

Models occasionally produce HTML tags in their output. While these render
fine on web surfaces, they appear as literal text on WhatsApp, Signal,
SMS, IRC, and Telegram.

Add sanitizeForPlainText() utility that converts common inline HTML to
lightweight-markup equivalents and strips remaining tags. Applied in the
outbound delivery pipeline for non-HTML surfaces only.

Closes #31884
See also: #18558

* fix(outbound): harden plain-text HTML sanitization paths (#32034)

* fix(security): harden file installs and race-path tests

* matrix: bootstrap crypto runtime when npm scripts are skipped

* fix(matrix): keep plugin register sync while bootstrapping crypto runtime (#31989)

* perf(runtime): reduce cron persistence and logger overhead

* test(perf): use prebuilt plugin install archive fixtures

* test(perf): increase guardrail scan read concurrency

* fix(queue): restart drain when message enqueued after idle window

After a drain loop empties the queue it deletes the key from
FOLLOWUP_QUEUES.  If a new message arrives at that moment
enqueueFollowupRun creates a fresh queue object with draining:false
but never starts a drain, leaving the message stranded until the
next run completes and calls finalizeWithFollowup.

Fix: persist the most recent runFollowup callback per queue key in
FOLLOWUP_RUN_CALLBACKS (drain.ts).  enqueueFollowupRun now calls
kickFollowupDrainIfIdle after a successful push; if a cached
callback exists and no drain is running it calls scheduleFollowupDrain
to restart immediately.  clearSessionQueues cleans up the callback
cache alongside the queue state.

* fix: avoid stale followup drain callbacks (#31902) (thanks @Lanfei)

* fix(synology-chat): read cfg from outbound context so incomingUrl resolves

* fix: require openclaw.extensions for plugin installs (#32055) (thanks @liuxiaopai-ai)

---------

Co-authored-by: Andrii Furmanets <furmanets.andriy@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Saurabh <skmishra1991@gmail.com>
Co-authored-by: stone-jin <1520006273@qq.com>
Co-authored-by: scotthuang <scotthuang@tencent.com>
Co-authored-by: User <user@example.com>
Co-authored-by: scoootscooob <zhentongfan@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: justinhuangcode <justinhuangcode@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
Co-authored-by: AytuncYildizli <cryptosquanch@gmail.com>
Co-authored-by: bmendonca3 <bmendonca3@users.noreply.github.com>
Co-authored-by: Jealous <CooLanfei@163.com>
Co-authored-by: white-rm <zhang.xujin@xydigit.com>
2026-03-02 19:41:05 +00:00
xtao
26b8a70a52 fix(synology-chat): use finalizeInboundContext for proper normalization 2026-03-02 19:39:14 +00:00
xtao
e391646043 fix(synology-chat): add missing context fields for message delivery 2026-03-02 19:39:14 +00:00
white-rm
e513714103 fix(synology-chat): read cfg from outbound context so incomingUrl resolves 2026-03-02 19:38:14 +00:00
Peter Steinberger
b645654923 fix: avoid stale followup drain callbacks (#31902) (thanks @Lanfei) 2026-03-02 19:38:08 +00:00
Jealous
60130203e1 fix(queue): restart drain when message enqueued after idle window
After a drain loop empties the queue it deletes the key from
FOLLOWUP_QUEUES.  If a new message arrives at that moment
enqueueFollowupRun creates a fresh queue object with draining:false
but never starts a drain, leaving the message stranded until the
next run completes and calls finalizeWithFollowup.

Fix: persist the most recent runFollowup callback per queue key in
FOLLOWUP_RUN_CALLBACKS (drain.ts).  enqueueFollowupRun now calls
kickFollowupDrainIfIdle after a successful push; if a cached
callback exists and no drain is running it calls scheduleFollowupDrain
to restart immediately.  clearSessionQueues cleans up the callback
cache alongside the queue state.
2026-03-02 19:38:08 +00:00
Peter Steinberger
c4511df283 test(perf): increase guardrail scan read concurrency 2026-03-02 19:34:04 +00:00
Peter Steinberger
64abf9a925 test(perf): use prebuilt plugin install archive fixtures 2026-03-02 19:34:04 +00:00
Peter Steinberger
1616113170 perf(runtime): reduce cron persistence and logger overhead 2026-03-02 19:34:04 +00:00
Peter Steinberger
fcec2e364d fix(matrix): keep plugin register sync while bootstrapping crypto runtime (#31989) 2026-03-02 19:33:22 +00:00
bmendonca3
66c1da45d4 matrix: bootstrap crypto runtime when npm scripts are skipped 2026-03-02 19:33:22 +00:00
Peter Steinberger
dbbd41a2ed fix(security): harden file installs and race-path tests 2026-03-02 19:30:02 +00:00
Peter Steinberger
e1bc5cad25 fix(outbound): harden plain-text HTML sanitization paths (#32034) 2026-03-02 19:28:47 +00:00
AytuncYildizli
62d0cfeee7 fix(delivery): strip HTML tags for plain-text messaging surfaces
Models occasionally produce HTML tags in their output. While these render
fine on web surfaces, they appear as literal text on WhatsApp, Signal,
SMS, IRC, and Telegram.

Add sanitizeForPlainText() utility that converts common inline HTML to
lightweight-markup equivalents and strips remaining tags. Applied in the
outbound delivery pipeline for non-HTML surfaces only.

Closes #31884
See also: #18558
2026-03-02 19:28:47 +00:00
Vincent Koc
a19a7f5e6e feat(security): Harden Docker browser container chromium flags (#23889) (#31504)
* Gateway: honor OPENCLAW_GATEWAY_URL override for remote/local calls

* Agents: fix sandbox sessionKey usage for PI embedded subagent calls

* Sandbox: tighten browser container Chromium runtime flags

* fix: add sandbox browser defaults for container hardening

* docs: expand sandbox browser default flags list

* fix: make sandbox browser flags optional and preserve gateway env auth overrides

* docs: scope PR 31504 changelog entry

* style: format gateway call override handling

* fix: dedupe sandbox browser chrome args

* fix: preserve remote tls fingerprint for env gateway override

* fix: enforce auth for env gateway URL override

* chore: document gateway override auth security expectations
2026-03-02 11:28:27 -08:00
Peter Steinberger
ea1fe77c83 fix: normalize coding-plan providers in auth order validation 2026-03-02 19:26:09 +00:00
Peter Steinberger
d486b0a925 fix(telegram): scope command-sync hash cache by bot identity (#32059) 2026-03-02 19:25:19 +00:00
scoootscooob
10fb632c9e fix: skip Telegram command sync when menu is unchanged (#32017)
Hash the command list and cache it to disk per account. On restart,
compare the current hash against the cached one and skip the
deleteMyCommands + setMyCommands round-trip when nothing changed.
This prevents 429 rate-limit errors when the gateway restarts
several times in quick succession.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:25:19 +00:00
Peter Steinberger
4a2329e0af fix: add changelog credit for fsPolicy image/pdf propagation (#31882) (thanks @justinhuangcode) 2026-03-02 19:24:33 +00:00
justinhuangcode
14baadda2c fix(tools): honor fsPolicy.workspaceOnly in image/pdf tool localRoots
PR #28822 fixed the Write/Edit tools to respect `tools.fs.workspaceOnly`,
but the image and PDF tools still unconditionally include default local
roots (`~/.openclaw/media`, `~/.openclaw/agents`, etc.) when computing
the `localRoots` allowlist for non-sandbox mode.

When `fsPolicy.workspaceOnly` is true, restrict `localRoots` to only the
workspace directory so that files outside the workspace are rejected by
`assertLocalMediaAllowed()`.

Relates to #31716

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:24:33 +00:00
justinhuangcode
aab87ec880 fix(agents): scope volcengine-plan/byteplus-plan auth lookup to profile resolution
The configure flow stores auth credentials under `provider: "volcengine"`,
but the coding model uses `volcengine-plan` as its provider. Add a scoped
`normalizeProviderIdForAuth` function used only by `listProfilesForProvider`
so coding-plan variants resolve to their base provider for auth credential
lookup without affecting global provider routing.

Closes #31731

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:22:19 +00:00
Peter Steinberger
a71b8d23be fix: add changelog credit for openrouter x-ai reasoning guard (#32054) (thanks @scoootscooob) 2026-03-02 19:20:11 +00:00
scoootscooob
6c7d012320 fix(openrouter): skip reasoning.effort injection for x-ai/grok models
x-ai/grok models on OpenRouter do not support the reasoning.effort
parameter and reject payloads containing it with "Invalid arguments
passed to the model." Skip reasoning injection for these models, the
same way we already skip it for the dynamic "auto" routing model.

Closes #32039

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:20:11 +00:00
Peter Steinberger
0956b599e1 fix: harden slack debounce key routing and ordering (#31951) (thanks @scoootscooob) 2026-03-02 19:18:25 +00:00
scoootscooob
d4b20f5295 fix(slack): scope debounce key by message timestamp to prevent cross-thread collisions
Top-level channel messages from the same sender shared a bare channel
debounce key, causing concurrent messages in different threads to merge
into a single reply on the wrong thread. Now the debounce key includes
the message timestamp for top-level messages, matching how the downstream
session layer already scopes by canonicalThreadId.

Extracted buildSlackDebounceKey() for testability.

Closes #31935

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:18:25 +00:00
Peter Steinberger
07eaeb7350 test(perf): reduce per-case setup in script and git-hook tests 2026-03-02 19:16:46 +00:00
Peter Steinberger
83ec545bed test(perf): trim repeated setup in cron memory and config suites 2026-03-02 19:16:46 +00:00
Peter Steinberger
6add2bcc15 test(process): replace no-output timer subprocess with spawn mock 2026-03-02 19:16:46 +00:00
Peter Steinberger
fbb343ab30 fix: add changelog credit for heartbeat model reload (#32046) (thanks @stakeswky) 2026-03-02 19:13:57 +00:00
User
e1e93d932f fix(gateway): restart heartbeat on model config changes 2026-03-02 19:13:57 +00:00
Peter Steinberger
ee68fa86b5 fix: harden plugin command registration + telegram menu guard (#31997) (thanks @liuxiaopai-ai) 2026-03-02 19:04:56 +00:00
liuxiaopai-ai
0958d11478 fix(telegram): guard malformed native menu specs 2026-03-02 19:04:56 +00:00
Peter Steinberger
ed55b63684 fix: add regression for memory-lancedb dimensions pass-through (#32036) (thanks @scotthuang) 2026-03-02 19:02:11 +00:00
scotthuang
31bc2cc202 fix(memory-lancedb): pass dimensions to embedding API call
- Add dimensions parameter to Embeddings constructor
- Pass dimensions to OpenAI embeddings.create() API call
- Fixes dimension mismatch when using custom embedding models like DashScope text-embedding-v4
2026-03-02 19:02:11 +00:00
Peter Steinberger
c146748d7a fix: add changelog for mentions/slack null-safe guards (#31865) (thanks @stone-jin) 2026-03-02 19:00:08 +00:00
stone-jin
2a98fd3d0b fix(slack): guard against undefined text in includes calls during mention handling 2026-03-02 19:00:08 +00:00
Peter Steinberger
ce4faedad6 fix: add changelog for HTTP 529 failover classification (#31854) (thanks @bugkill3r) 2026-03-02 18:59:10 +00:00
Saurabh
1ef9a2a8ea fix: handle HTTP 529 (Anthropic overloaded) in failover error classification
Classify Anthropic's 529 status code as "rate_limit" so model fallback
triggers reliably without depending on fragile message-based detection.

Closes #28502
2026-03-02 18:59:10 +00:00
Peter Steinberger
84d9b64326 fix: add changelog for exact voice webhook path match (#31930) (thanks @afurm) 2026-03-02 18:57:46 +00:00
Peter Steinberger
99392f9868 chore: keep #31930 scoped to voice webhook path fix 2026-03-02 18:57:46 +00:00
Andrii Furmanets
662f389f45 Tests: isolate webhook path suite and reset cron auth state 2026-03-02 18:57:46 +00:00
Andrii Furmanets
3bd0505433 Voice Call: enforce exact webhook path match 2026-03-02 18:57:46 +00:00
SidQin-cyber
dde43121c0 fix(deps): add strip-ansi runtime dependency
Add strip-ansi as an explicit root dependency so pi-coding-agent runtime imports do not fail with ERR_MODULE_NOT_FOUND in strict pnpm installs.
2026-03-02 18:49:17 +00:00
Peter Steinberger
6a5041f3ff test(exec): deflake no-output timeout heartbeat scenario 2026-03-02 18:41:59 +00:00
Peter Steinberger
bcb1eb2f03 perf(test): speed up setup and config path resolution 2026-03-02 18:41:58 +00:00
Peter Steinberger
842087319b perf(logging): skip config/fs work in default silent test path 2026-03-02 18:41:58 +00:00
Lucenx9
5c1eb071ca fix(whatsapp): restore direct inbound metadata for relay agents (#31969)
* fix(whatsapp): restore direct inbound metadata for relay agents

* fix(auto-reply): use shared inbound channel resolver for direct metadata

* chore(ci): retrigger checks after base update

* fix: add changelog attribution for inbound metadata relay fix (#31969) (thanks @Lucenx9)

---------

Co-authored-by: Simone <simone@example.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-02 18:40:04 +00:00
scoootscooob
4030de6c73 fix(cron): move session reaper to finally block so it runs reliably (#31996)
* fix(cron): move session reaper to finally block so it runs reliably

The cron session reaper was placed inside the try block of onTimer(),
after job execution and state updates. If the locked persist section
threw, the reaper was skipped — causing isolated cron run sessions to
accumulate indefinitely in sessions.json.

Move the reaper into the finally block so it always executes after a
timer tick, regardless of whether job execution succeeded. The reaper
is already self-throttled (MIN_SWEEP_INTERVAL_MS = 5 min) so calling
it more reliably has no performance impact.

Closes #31946

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: strengthen cron reaper failure-path coverage and changelog (#31996) (thanks @scoootscooob)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-02 18:38:59 +00:00
liuxiaopai-ai
c9558cdcd7 fix(launchd): set restrictive umask in gateway plist 2026-03-02 18:38:56 +00:00
liuxiaopai-ai
740bb77c8c fix(reply): prefer provider over surface for run channel fallback 2026-03-02 18:37:00 +00:00
Adhish Thite
63734df3b0 fix(doctor): resolve false positive for local memory search when no explicit modelPath (#32014)
* fix(doctor): resolve false positive for local memory search when no explicit modelPath

When memorySearch.provider is 'local' (or 'auto') and no explicit
local.modelPath is configured, the runtime auto-resolves to
DEFAULT_LOCAL_MODEL (embeddinggemma-300m via HuggingFace). However,
the doctor's hasLocalEmbeddings() check only inspected the config
value and returned false when modelPath was empty, triggering a
misleading warning.

Fix: fall back to DEFAULT_LOCAL_MODEL in hasLocalEmbeddings(), matching
the runtime behavior in createLocalEmbeddingProvider().

Closes #31998

* fix: scope DEFAULT_LOCAL_MODEL fallback to explicit provider:local only

Address review feedback: canAutoSelectLocal() in the runtime skips
local for empty/hf: model paths in auto mode. The DEFAULT_LOCAL_MODEL
fallback should only apply when provider is explicitly 'local', not
when provider is 'auto' — otherwise users with no local file and no
API keys would get a clean doctor report but no working embeddings.

Add useDefaultFallback parameter to hasLocalEmbeddings() to
distinguish the two code paths.

* fix: preserve gateway probe warning for local provider with default model

When hasLocalEmbeddings returns true via DEFAULT_LOCAL_MODEL fallback,
also check the gateway memory probe if available. If the probe reports
not-ready (e.g. node-llama-cpp missing or model download failed),
emit a warning instead of silently reporting healthy.

Addresses review feedback about bypassing probe-based validation.

* fix: add changelog attribution for doctor local fallback fix (#32014) (thanks @adhishthite)

---------

Co-authored-by: Adhish <adhishthite@Adhishs-MacBook-Pro.local>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-02 18:35:40 +00:00
Peter Steinberger
534168a7a7 fix: add changelog entry for config-form secret union (#31866) (thanks @ningding97) 2026-03-02 18:35:15 +00:00
ningding97
9c1312b5e4 fix(ui): handle SecretInput union in config form analyzer
The config form marks models.providers as unsupported because
SecretInputSchema creates a oneOf union that the form analyzer
cannot handle. Add detection for secret-ref union variants and
normalize them to plain string inputs for form display.

Closes #31490
2026-03-02 18:35:15 +00:00
Mark L
1727279598 fix(browser): default to openclaw profile when unspecified (#32031) 2026-03-02 18:34:37 +00:00
Peter Steinberger
d52e5e1d85 fix: add regression tests for telegram token guard (#31973) (thanks @ningding97) 2026-03-02 18:33:49 +00:00
ningding97
c1c20491da fix(telegram): guard token.trim() against undefined to prevent startup crash
When account.token is undefined (e.g. missing botToken config),
calling .trim() directly throws "Cannot read properties of undefined".
Use nullish coalescing to fall back to empty string before trimming.

Closes #31944
2026-03-02 18:33:49 +00:00
Maho
d21cf44452 fix(slack): remove message.channels/message.groups handlers that crash Bolt 4.6 (#32033)
* fix(slack): remove message.channels/message.groups handlers that crash Bolt 4.6

Bolt 4.6 rejects app.event() calls with event names starting with
"message." (e.g. "message.channels", "message.groups"), throwing
AppInitializationError on startup. These handlers were added in #31701
based on the incorrect assumption that Slack dispatches typed event
names to Bolt. In reality, Slack always delivers events with
type:"message" regardless of the Event Subscription name; the
channel_type field distinguishes the source.

The generic app.event("message") handler already receives all channel,
group, IM, and MPIM messages. The additional typed handlers were
unreachable even if Bolt allowed them, since no event payload ever
carries type:"message.channels".

This preserves the handleIncomingMessageEvent refactor from #31701
(extracting the handler into a named function) while removing only
the broken registrations.

Fixes the Slack provider crash loop affecting all accounts on
@slack/bolt >= 4.6.0.

Closes #31674 (original issue was not caused by missing handlers)

* fix: document Slack Bolt 4.6 startup handler fix (#32033) (thanks @mahopan)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-02 18:32:42 +00:00
bmendonca3
738f5d4533 skills: make sherpa-onnx-tts bin ESM-compatible 2026-03-02 18:30:42 +00:00
Peter Steinberger
a8fe8b6bf8 test(guardrails): exclude suite files and harden auth temp identity naming 2026-03-02 18:21:13 +00:00
Peter Steinberger
82f01d6081 perf(runtime): reduce startup import overhead in logging and schema validation 2026-03-02 18:21:13 +00:00
Sid
41c8734afd fix(gateway): move plugin HTTP routes before Control UI SPA catch-all (#31885)
* fix(gateway): move plugin HTTP routes before Control UI SPA catch-all

The Control UI handler (`handleControlUiHttpRequest`) acts as an SPA
catch-all that matches every path, returning HTML for GET requests and
405 for other methods.  Because it ran before `handlePluginRequest` in
the request chain, any plugin HTTP route that did not live under
`/plugins` or `/api` was unreachable — shadowed by the catch-all.

Reorder the handlers so plugin routes are evaluated first.  Core
built-in routes (hooks, tools, Slack, Canvas, etc.) still take
precedence because they are checked even earlier in the chain.
Unmatched plugin paths continue to fall through to Control UI as before.

Closes #31766

* fix: add changelog for plugin route precedence landing (#31885) (thanks @Sid-Qin)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-02 18:16:14 +00:00
Peter Steinberger
cf5702233c docs(security)!: document messaging-only onboarding default and hook/model risk 2026-03-02 18:15:49 +00:00
Mark L
718d418b32 fix(daemon): harden launchd plist with umask 077 (#31919)
* fix(daemon): add launchd umask hardening

* fix: finalize launchd umask changelog + thanks (#31919) (thanks @liuxiaopai-ai)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-02 18:13:41 +00:00
Peter Steinberger
16df7ef4a9 feat(onboarding)!: default tools profile to messaging 2026-03-02 18:12:11 +00:00
Mark L
9b8e642475 Config: newline-join sandbox setupCommand arrays (#31953) 2026-03-02 18:11:32 +00:00
448 changed files with 15175 additions and 6765 deletions

2
.gitignore vendored
View File

@@ -94,7 +94,7 @@ USER.md
!.agent/workflows/
/local/
package-lock.json
.claude/settings.local.json
.claude/
.agents/
.agents
.agent/

View File

@@ -114,6 +114,17 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
}
};
const renderPromptMatch = (ctx: ExtensionContext, match: PromptMatch) => {
setWidget(ctx, match);
applySessionName(ctx, match);
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
const title = meta?.title?.trim();
const authorText = formatAuthor(meta?.author);
setWidget(ctx, match, title, authorText);
applySessionName(ctx, match, title);
});
};
pi.on("before_agent_start", async (event, ctx) => {
if (!ctx.hasUI) {
return;
@@ -123,14 +134,7 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
return;
}
setWidget(ctx, match);
applySessionName(ctx, match);
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
const title = meta?.title?.trim();
const authorText = formatAuthor(meta?.author);
setWidget(ctx, match, title, authorText);
applySessionName(ctx, match, title);
});
renderPromptMatch(ctx, match);
});
pi.on("session_switch", async (_event, ctx) => {
@@ -177,14 +181,7 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
return;
}
setWidget(ctx, match);
applySessionName(ctx, match);
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
const title = meta?.title?.trim();
const authorText = formatAuthor(meta?.author);
setWidget(ctx, match, title, authorText);
applySessionName(ctx, match, title);
});
renderPromptMatch(ctx, match);
};
pi.on("session_start", async (_event, ctx) => {

View File

@@ -40,13 +40,55 @@ Docs: https://docs.openclaw.ai
### Breaking
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
- **BREAKING:** Onboarding now defaults `tools.profile` to `messaging` for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
### Fixes
- Plugin SDK/release guard: add explicit `openclaw/plugin-sdk` export verification in tests and release checks to prevent missing runtime exports from shipping and breaking channel extensions. (#28575) Thanks @Glucksberg.
- Synology Chat/webhook compatibility: accept JSON and alias payload fields, allow token resolution from body/query/header sources, and ACK webhook requests with `204` to avoid persistent `Processing...` states in Synology Chat clients. (#26635) Thanks @memphislee09-source.
- OpenAI Codex OAuth/TLS prerequisites: add an OAuth TLS cert-chain preflight with actionable remediation for cert trust failures, and gate doctor TLS prerequisite probing to OpenAI Codex OAuth-configured installs (or explicit `doctor --deep`) to avoid unconditional outbound probe latency. (#32051) Thanks @alexfilatov.
- Synology Chat/webhook ingress hardening: enforce bounded body reads (size + timeout) via shared request-body guards to prevent unauthenticated slow-body hangs before token validation. (#25831) Thanks @bmendonca3.
- Synology Chat/reply delivery: resolve webhook usernames to Chat API `user_id` values for outbound chatbot replies, avoiding mismatches between webhook user IDs and `method=chatbot` recipient IDs in multi-account setups. (#23709) Thanks @druide67.
- Synology Chat/gateway lifecycle: keep `startAccount` pending until abort for inactive and active account paths to prevent webhook route restart loops under gateway supervision. (#23074) Thanks @druide67.
- Auto-reply/followup queue: avoid stale callback reuse across idle-window restarts by caching the followup runner only when a drain actually starts, preserving enqueue ordering after empty-finalize paths. (#31902) Thanks @Lanfei.
- Cron/HEARTBEAT_OK summary leak: suppress fallback main-session enqueue for heartbeat/internal ack summaries in isolated announce mode so `HEARTBEAT_OK` noise never appears in user chat while real summaries still forward. (#32093) Thanks @scoootscooob.
- Sessions/lock recovery: reclaim orphan legacy same-PID lock files missing `starttime` when no in-process lock ownership exists, avoiding false lock timeouts after PID reuse while preserving active lock safety checks. (#32081) Thanks @bmendonca3.
- Discord/dispatch + Slack formatting: restore parallel outbound dispatch across Discord channels with per-channel queues while preserving in-channel ordering, and run Slack preview/stream update text through mrkdwn normalization for consistent formatting. (#31927) Thanks @Sid-Qin.
- Models/Codex usage labels: infer weekly secondary usage windows from reset cadence when API window seconds are ambiguously reported as 24h, so `openclaw models status` no longer mislabels weekly limits as daily. (#31938) Thanks @bmendonca3.
- Telegram/inbound media filenames: preserve original `file_name` metadata for document/audio/video/animation downloads (with fetch/path fallbacks), so saved inbound attachments keep sender-provided names instead of opaque Telegram file paths. (#31837) Thanks @Kay-051.
- Telegram/models picker callbacks: keep long model buttons selectable by falling back to compact callback payloads and resolving provider ids on selection (with provider re-prompt on ambiguity), avoiding Telegram 64-byte callback truncation failures. (#31857) Thanks @bmendonca3.
- Config/backups hardening: enforce owner-only (`0600`) permissions on rotated config backups and clean orphan `.bak.*` files outside the managed backup ring, reducing credential leakage risk from stale or permissive backup artifacts. (#31718) Thanks @YUJIE2002.
- WhatsApp/inbound self-message context: propagate inbound `fromMe` through the web inbox pipeline and annotate direct self messages as `(self)` in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.
- Exec approvals/allowlist matching: escape regex metacharacters in path-pattern literals (while preserving glob wildcards), preventing crashes on allowlisted executables like `/usr/bin/g++` and correctly matching mixed wildcard/literal token paths. (#32162) Thanks @stakeswky.
- Webchat/stream finalization: persist streamed assistant text when final events omit `message`, while keeping final payload precedence and skipping empty stream buffers to prevent disappearing replies after tool turns. (#31920) Thanks @Sid-Qin.
- Cron/store migration: normalize legacy cron jobs with string `schedule` and top-level `command`/`timeout` fields into canonical schedule/payload/session-target shape on load, preventing schedule-error loops on old persisted stores. (#31926) Thanks @bmendonca3.
- Gateway/Heartbeat model reload: treat `models.*` and `agents.defaults.model` config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.
- Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of `NO_REPLY` and keep final-message buffering in sync, preventing partial `NO` leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai.
- Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.
- OpenRouter/x-ai compatibility: skip `reasoning.effort` injection for `x-ai/*` models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.
- Tools/fsPolicy propagation: honor `tools.fs.workspaceOnly` for image/pdf local-root allowlists so non-sandbox media paths outside workspace are rejected when workspace-only mode is enabled. (#31882) Thanks @justinhuangcode.
- Memory/LanceDB embeddings: forward configured `embedding.dimensions` into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.
- Mentions/Slack formatting hardening: add null-safe guards for runtime text normalization paths so malformed/undefined text payloads do not crash mention stripping or mrkdwn conversion. (#31865) Thanks @stone-jin.
- Failover/error classification: treat HTTP `529` (provider overloaded, common with Anthropic-compatible APIs) as `rate_limit` so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.
- Voice-call/webhook routing: require exact webhook path matches (instead of prefix matches) so lookalike paths cannot reach provider verification/dispatch logic. (#31930) Thanks @afurm.
- Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (`trim` on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.
- Web UI/config form: support SecretInput string-or-secret-ref unions in map `additionalProperties`, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.
- Slack/Bolt startup compatibility: remove invalid `message.channels` and `message.groups` event registrations so Slack providers no longer crash on startup with Bolt 4.6+; channel/group traffic continues through the unified `message` handler (`channel_type`). (#32033) Thanks @mahopan.
- Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing `token.trim()` crashes during status/start flows. (#31973) Thanks @ningding97.
- Plugins/install diagnostics: reject legacy plugin package shapes without `openclaw.extensions` and return an explicit upgrade hint with troubleshooting docs for repackaging. (#32055) Thanks @liuxiaopai-ai.
- Plugins/install fallback safety: resolve bare install specs to bundled plugin ids before npm lookup (for example `diffs` -> bundled `@openclaw/diffs`), keep npm fallback limited to true package-not-found errors, and continue rejecting non-plugin npm packages that fail manifest validation. (#32096) Thanks @scoootscooob.
- Skills/sherpa-onnx-tts: run the `sherpa-onnx-tts` bin under ESM (replace CommonJS `require` imports) and add regression coverage to prevent `require is not defined in ES module scope` startup crashes. (#31965) Thanks @bmendonca3.
- Browser/default profile selection: default `browser.defaultProfile` behavior now prefers `openclaw` (managed standalone CDP) when no explicit default is configured, while still auto-provisioning the `chrome` relay profile for explicit opt-in use. (#32031) Fixes #31907. Thanks @liuxiaopai-ai.
- Doctor/local memory provider checks: stop false-positive local-provider warnings when `provider=local` and no explicit `modelPath` is set by honoring default local model fallback while still warning when gateway probe reports local embeddings not ready. (#32014) Fixes #31998. Thanks @adhishthite.
- Feishu/Run channel fallback: prefer `Provider` over `Surface` when inferring queued run `messageProvider` fallback (when `OriginatingChannel` is missing), preventing Feishu turns from being mislabeled as `webchat` in mixed relay metadata contexts. (#31880) Fixes #31859. Thanks @liuxiaopai-ai.
- Cron/session reaper reliability: move cron session reaper sweeps into `onTimer` `finally` and keep pruning active even when timer ticks fail early (for example cron store parse failures), preventing stale isolated run sessions from accumulating indefinitely. (#31996) Fixes #31946. Thanks @scoootscooob.
- Inbound metadata/direct relay context: restore direct-channel conversation metadata blocks for external channels (for example WhatsApp) while preserving webchat-direct suppression, so relay agents recover sender/message identifiers without reintroducing internal webchat metadata noise. (#31969) Fixes #29972. Thanks @Lucenx9.
- Sandbox/Docker setup command parsing: accept `agents.*.sandbox.docker.setupCommand` as either a string or a string array, and normalize arrays to newline-delimited shell scripts so multi-step setup commands no longer concatenate without separators. (#31953) Thanks @liuxiaopai-ai.
- Gateway/Plugin HTTP route precedence: run explicit plugin HTTP routes before the Control UI SPA catch-all so registered plugin webhook/custom paths remain reachable, while unmatched paths still fall through to Control UI handling. (#31885) Thanks @Sid-Qin.
- macOS/LaunchAgent security defaults: write `Umask=63` (octal `077`) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system `022`. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.
- Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example `env sh -c ...`) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.
- Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction `AGENTS.md` context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.
- Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.
@@ -69,6 +111,7 @@ Docs: https://docs.openclaw.ai
- Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (`provider: "message"`) and normalize `lark`/`feishu` provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)
- Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older `openclaw/plugin-sdk` builds omit webhook default constants. (#31606)
- Pairing/AllowFrom account fallback: handle omitted `accountId` values in `readChannelAllowFromStore` and `readChannelAllowFromStoreSync` as `default`, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.
- Agents/Sandbox workdir mapping: map container workdir paths (for example `/workspace`) back to the host workspace before sandbox path validation so exec requests keep the intended directory in containerized runs instead of falling back to an unavailable host path. (#31841) Thanks @liuxiaopai-ai.
- Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.
- BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.
- Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.
@@ -92,6 +135,7 @@ Docs: https://docs.openclaw.ai
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
- Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
- Browser/Gateway hardening: preserve env credentials for `OPENCLAW_GATEWAY_URL` / `CLAWDBOT_GATEWAY_URL` while treating explicit `--url` as override-only auth, and make container browser hardening flags optional with safer defaults for Docker/LXC stability. (#31504) Thanks @vincentkoc.
- Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.
- Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.
- Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for `.cmd` shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.
@@ -142,6 +186,7 @@ Docs: https://docs.openclaw.ai
- Plugins/Install: clear stale install errors when an npm package is not found so follow-up install attempts report current state correctly. (#25073) Thanks @dalefrieswthat.
- Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3.
- Gateway/macOS supervised restart: actively `launchctl kickstart -k` during intentional supervised restarts to bypass LaunchAgent `ThrottleInterval` delays, and fall back to in-process restart when kickstart fails. Landed from contributor PR #29078 by @cathrynlavery. Thanks @cathrynlavery.
- Gateway/macOS LaunchAgent hardening: write `Umask=077` in generated gateway LaunchAgent plists so npm upgrades preserve owner-only default file permissions for gateway-created state files. (#31919) Fixes #31905. Thanks @liuxiaopai-ai.
- Daemon/macOS TLS certs: default LaunchAgent service env `NODE_EXTRA_CA_CERTS` to `/etc/ssl/cert.pem` (while preserving explicit overrides) so HTTPS clients no longer fail with local-issuer errors under launchd. (#27915) Thanks @Lukavyi.
- Discord/Components wildcard handlers: use distinct internal registration sentinel IDs and parse those sentinels as wildcard keys so select/user/role/channel/mentionable/modal interactions are not dropped by raw customId dedupe paths. Landed from contributor PR #29459 by @Sid-Qin. Thanks @Sid-Qin.
- Feishu/Reaction notifications: add `channels.feishu.reactionNotifications` (`off | own | all`, default `own`) so operators can disable reaction ingress or allow all verified reaction events (not only bot-authored message reactions). (#28529) Thanks @cowboy129.
@@ -950,6 +995,8 @@ Docs: https://docs.openclaw.ai
- Security/Control UI avatars: harden `/avatar/:agentId` local avatar serving by rejecting symlink paths and requiring fd-level file identity + size checks before reads. Thanks @tdjackey for reporting.
- Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. Thanks @tdjackey for reporting.
- Security/MSTeams media: route attachment auth-retry and Graph SharePoint download redirects through shared `safeFetch` so each hop is validated with allowlist + DNS/IP checks across the full redirect chain. (#23598) Thanks @Asm3r96 and @lewiswigmore.
- Security/MSTeams auth redirect scoping: strip bearer auth on redirect hops outside `authAllowHosts` and gate SharePoint Graph auth-header injection by auth allowlist to prevent token bleed across redirect targets. (#25045) Thanks @bmendonca3.
- MSTeams/reply reliability: when Bot Framework revokes thread turn-context proxies (for example debounced flush paths), fall back to proactive messaging/typing and continue pending sends without duplicating already delivered messages. (#27224) Thanks @openperf.
- Security/macOS discovery: fail closed for unresolved discovery endpoints by clearing stale remote selection values, use resolved service host only for SSH target derivation, and keep remote URL config aligned with resolved endpoint availability. (#21618) Thanks @bmendonca3.
- Chat/Usage/TUI: strip synthetic inbound metadata blocks (including `Conversation info` and trailing `Untrusted context` channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs.
- CI/Tests: fix TypeScript case-table typing and lint assertion regressions so `pnpm check` passes again after Synology Chat landing. (#23012) Thanks @druide67.

View File

@@ -149,6 +149,8 @@ OpenClaw's security model is "personal assistant" (one trusted operator, potenti
- The model/agent is **not** a trusted principal. Assume prompt/content injection can manipulate behavior.
- Security boundaries come from host/config trust, auth, tool policy, sandboxing, and exec approvals.
- Prompt injection by itself is not a vulnerability report unless it crosses one of those boundaries.
- Hook/webhook-driven payloads should be treated as untrusted content; keep unsafe bypass flags disabled unless doing tightly scoped debugging (`hooks.gmail.allowUnsafeExternalContent`, `hooks.mappings[].allowUnsafeExternalContent`).
- Weak model tiers are generally easier to prompt-inject. For tool-enabled or hook-driven agents, prefer strong modern model tiers and strict tool policy (for example `tools.profile: "messaging"` or stricter), plus sandboxing where possible.
## Gateway and Node trust concept

View File

@@ -41,6 +41,19 @@ Examples:
- `agent:main:telegram:group:-1001234567890:topic:42`
- `agent:main:discord:channel:123456:thread:987654`
## Main DM route pinning
When `session.dmScope` is `main`, direct messages may share one main session.
To prevent the sessions `lastRoute` from being overwritten by non-owner DMs,
OpenClaw infers a pinned owner from `allowFrom` when all of these are true:
- `allowFrom` has exactly one non-wildcard entry.
- The entry can be normalized to a concrete sender ID for that channel.
- The inbound DM sender does not match that pinned owner.
In that mismatch case, OpenClaw still records inbound session metadata, but it
skips updating the main session `lastRoute`.
## Routing rules (how an agent is chosen)
Routing picks **one agent** for each inbound message:

View File

@@ -48,6 +48,10 @@ Security note: treat plugin installs like running code. Prefer pinned versions.
Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file
specs are rejected. Dependency installs run with `--ignore-scripts` for safety.
If a bare install spec matches a bundled plugin id (for example `diffs`), OpenClaw
installs the bundled plugin directly. To install an npm package with the same
name, use an explicit scoped spec (for example `@scope/diffs`).
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):

View File

@@ -1177,6 +1177,35 @@ noVNC observer access uses VNC auth by default and OpenClaw emits a short-lived
- `network` defaults to `openclaw-sandbox-browser` (dedicated bridge network). Set to `bridge` only when you explicitly want global bridge connectivity.
- `cdpSourceRange` optionally restricts CDP ingress at the container edge to a CIDR range (for example `172.21.0.1/32`).
- `sandbox.browser.binds` mounts additional host directories into the sandbox browser container only. When set (including `[]`), it replaces `docker.binds` for the browser container.
- Launch defaults are defined in `scripts/sandbox-browser-entrypoint.sh` and tuned for container hosts:
- `--remote-debugging-address=127.0.0.1`
- `--remote-debugging-port=<derived from OPENCLAW_BROWSER_CDP_PORT>`
- `--user-data-dir=${HOME}/.chrome`
- `--no-first-run`
- `--no-default-browser-check`
- `--disable-3d-apis`
- `--disable-gpu`
- `--disable-software-rasterizer`
- `--disable-dev-shm-usage`
- `--disable-background-networking`
- `--disable-features=TranslateUI`
- `--disable-breakpad`
- `--disable-crash-reporter`
- `--renderer-process-limit=2`
- `--no-zygote`
- `--metrics-recording-only`
- `--disable-extensions` (default enabled)
- `--disable-3d-apis`, `--disable-software-rasterizer`, and `--disable-gpu` are
enabled by default and can be disabled with
`OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` if WebGL/3D usage requires it.
- `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` re-enables extensions if your workflow
depends on them.
- `--renderer-process-limit=2` can be changed with
`OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT=<N>`; set `0` to use Chromium's
default process limit.
- plus `--no-sandbox` and `--disable-setuid-sandbox` when `noSandbox` is enabled.
- Defaults are the container image baseline; use a custom browser image with a custom
entrypoint to change container defaults.
</Accordion>
@@ -1587,6 +1616,8 @@ Defaults for Talk mode (macOS/iOS/Android).
`tools.profile` sets a base allowlist before `tools.allow`/`tools.deny`:
Local onboarding defaults new local configs to `tools.profile: "messaging"` when unset (existing explicit profiles are preserved).
| Profile | Includes |
| ----------- | ----------------------------------------------------------------------------------------- |
| `minimal` | `session_status` only |
@@ -2249,6 +2280,7 @@ See [Plugins](/tools/plugin).
color: "#FF4500",
// headless: false,
// noSandbox: false,
// extraArgs: [],
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
// attachOnly: false,
},
@@ -2263,6 +2295,8 @@ See [Plugins](/tools/plugin).
- Remote profiles are attach-only (start/stop/reset disabled).
- Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary.
- Control service: loopback only (port derived from `gateway.port`, default `18791`).
- `extraArgs` appends extra launch flags to local Chromium startup (for example
`--disable-gpu`, window sizing, or debug flags).
---

View File

@@ -291,6 +291,11 @@ When validation fails:
}
```
Security note:
- Treat all hook/webhook payload content as untrusted input.
- Keep unsafe-content bypass flags disabled (`hooks.gmail.allowUnsafeExternalContent`, `hooks.mappings[].allowUnsafeExternalContent`) unless doing tightly scoped debugging.
- For hook-driven agents, prefer strong modern model tiers and strict tool policy (for example messaging-only plus sandboxing where possible).
See [full reference](/gateway/configuration-reference#hooks) for all mapping options and Gmail integration.
</Accordion>

View File

@@ -148,6 +148,40 @@ scripts/sandbox-browser-setup.sh
By default, sandbox containers run with **no network**.
Override with `agents.defaults.sandbox.docker.network`.
The bundled sandbox browser image also applies conservative Chromium startup defaults
for containerized workloads. Current container defaults include:
- `--remote-debugging-address=127.0.0.1`
- `--remote-debugging-port=<derived from OPENCLAW_BROWSER_CDP_PORT>`
- `--user-data-dir=${HOME}/.chrome`
- `--no-first-run`
- `--no-default-browser-check`
- `--disable-3d-apis`
- `--disable-gpu`
- `--disable-dev-shm-usage`
- `--disable-background-networking`
- `--disable-extensions`
- `--disable-features=TranslateUI`
- `--disable-breakpad`
- `--disable-crash-reporter`
- `--disable-software-rasterizer`
- `--no-zygote`
- `--metrics-recording-only`
- `--renderer-process-limit=2`
- `--no-sandbox` and `--disable-setuid-sandbox` when `noSandbox` is enabled.
- The three graphics hardening flags (`--disable-3d-apis`,
`--disable-software-rasterizer`, `--disable-gpu`) are optional and are useful
when containers lack GPU support. Set `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0`
if your workload requires WebGL or other 3D/browser features.
- `--disable-extensions` is enabled by default and can be disabled with
`OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` for extension-reliant flows.
- `--renderer-process-limit=2` is controlled by
`OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT=<N>`, where `0` keeps Chromium's default.
If you need a different runtime profile, use a custom browser image and provide
your own entrypoint. For local (non-container) Chromium profiles, use
`browser.extraArgs` to append additional startup flags.
Security defaults:
- `network: "host"` is blocked.

View File

@@ -538,6 +538,11 @@ Guidance:
- Only enable temporarily for tightly scoped debugging.
- If enabled, isolate that agent (sandbox + minimal tools + dedicated session namespace).
Hooks risk note:
- Hook payloads are untrusted content, even when delivery comes from systems you control (mail/docs/web content can carry prompt injection).
- Weak model tiers increase this risk. For hook-driven automation, prefer strong modern model tiers and keep tool policy tight (`tools.profile: "messaging"` or stricter), plus sandboxing where possible.
### Prompt injection does not require public DMs
Even if **only you** can message the bot, prompt injection can still happen via

View File

@@ -40,6 +40,31 @@ If you see:
`HTTP 429: rate_limit_error: Extra usage is required for long context requests`,
go to [/gateway/troubleshooting#anthropic-429-extra-usage-required-for-long-context](/gateway/troubleshooting#anthropic-429-extra-usage-required-for-long-context).
## Plugin install fails with missing openclaw extensions
If install fails with `package.json missing openclaw.extensions`, the plugin package
is using an old shape that OpenClaw no longer accepts.
Fix in the plugin package:
1. Add `openclaw.extensions` to `package.json`.
2. Point entries at built runtime files (usually `./dist/index.js`).
3. Republish the plugin and run `openclaw plugins install <npm-spec>` again.
Example:
```json
{
"name": "@openclaw/my-plugin",
"version": "1.2.3",
"openclaw": {
"extensions": ["./dist/index.js"]
}
}
```
Reference: [/tools/plugin#distribution-npm](/tools/plugin#distribution-npm)
## Decision tree
```mermaid

View File

@@ -64,6 +64,13 @@ Optional env vars:
- `OPENCLAW_DOCKER_SOCKET` — override Docker socket path (default: `DOCKER_HOST=unix://...` path, else `/var/run/docker.sock`)
- `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` — break-glass: allow trusted private-network
`ws://` targets for CLI/onboarding client paths (default is loopback-only)
- `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` — disable container browser hardening flags
`--disable-3d-apis`, `--disable-software-rasterizer`, `--disable-gpu` when you need
WebGL/3D compatibility.
- `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` — keep extensions enabled when browser
flows require them (default keeps extensions disabled in sandbox browser).
- `OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT=<N>` — set Chromium renderer process
limit; set to `0` to skip the flag and use Chromium default behavior.
After it finishes:
@@ -672,6 +679,38 @@ Notes:
- Browser containers default to a dedicated Docker network (`openclaw-sandbox-browser`) instead of global `bridge`.
- Optional `agents.defaults.sandbox.browser.cdpSourceRange` restricts container-edge CDP ingress by CIDR (for example `172.21.0.1/32`).
- noVNC observer access is password-protected by default; OpenClaw provides a short-lived observer token URL that serves a local bootstrap page and keeps the password in URL fragment (instead of URL query).
- Browser container startup defaults are conservative for shared/container workloads, including:
- `--remote-debugging-address=127.0.0.1`
- `--remote-debugging-port=<derived from OPENCLAW_BROWSER_CDP_PORT>`
- `--user-data-dir=${HOME}/.chrome`
- `--no-first-run`
- `--no-default-browser-check`
- `--disable-3d-apis`
- `--disable-software-rasterizer`
- `--disable-gpu`
- `--disable-dev-shm-usage`
- `--disable-background-networking`
- `--disable-features=TranslateUI`
- `--disable-breakpad`
- `--disable-crash-reporter`
- `--metrics-recording-only`
- `--renderer-process-limit=2`
- `--no-zygote`
- `--disable-extensions`
- If `agents.defaults.sandbox.browser.noSandbox` is set, `--no-sandbox` and
`--disable-setuid-sandbox` are also appended.
- The three graphics hardening flags above are optional. If your workload needs
WebGL/3D, set `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` to run without
`--disable-3d-apis`, `--disable-software-rasterizer`, and `--disable-gpu`.
- Extension behavior is controlled by `--disable-extensions` and can be disabled
(enables extensions) via `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` for
extension-dependent pages or extensions-heavy workflows.
- `--renderer-process-limit=2` is also configurable with
`OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT`; set `0` to let Chromium choose its
default process limit when browser concurrency needs tuning.
Defaults are applied by default in the bundled image. If you need different
Chromium flags, use a custom browser image and provide your own entrypoint.
Use config:

View File

@@ -245,6 +245,7 @@ Typical fields in `~/.openclaw/openclaw.json`:
- `agents.defaults.workspace`
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
- `tools.profile` (local onboarding defaults to `"messaging"` when unset; existing explicit values are preserved)
- `gateway.*` (mode, bind, auth, tailscale)
- `session.dmScope` (behavior details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals))
- `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*`

View File

@@ -34,6 +34,8 @@ Security trust model:
- By default, OpenClaw is a personal agent: one trusted operator boundary.
- Shared/multi-user setups require lock-down (split trust boundaries, keep tool access minimal, and follow [Security](/gateway/security)).
- Local onboarding now defaults new configs to `tools.profile: "messaging"` so broad runtime/filesystem tools are opt-in.
- If hooks/webhooks or other untrusted content feeds are enabled, use a strong modern model tier and keep strict tool policy/sandboxing.
</Step>
<Step title="Local vs Remote">

View File

@@ -236,6 +236,7 @@ Typical fields in `~/.openclaw/openclaw.json`:
- `agents.defaults.workspace`
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
- `tools.profile` (local onboarding defaults to `"messaging"` when unset; existing explicit values are preserved)
- `gateway.*` (mode, bind, auth, tailscale)
- `session.dmScope` (local onboarding defaults this to `per-channel-peer` when unset; existing explicit values are preserved)
- `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*`

View File

@@ -50,6 +50,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
- Workspace default (or existing workspace)
- Gateway port **18789**
- Gateway auth **Token** (autogenerated, even on loopback)
- Tool policy default for new local setups: `tools.profile: "messaging"` (existing explicit profile is preserved)
- DM isolation default: local onboarding writes `session.dmScope: "per-channel-peer"` when unset. Details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals)
- Tailscale exposure **Off**
- Telegram + WhatsApp DMs default to **allowlist** (you'll be prompted for your phone number)
@@ -65,6 +66,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
1. **Model/Auth** — Anthropic API key (recommended), OpenAI, or Custom Provider
(OpenAI-compatible, Anthropic-compatible, or Unknown auto-detect). Pick a default model.
Security note: if this agent will run tools or process webhook/hooks content, prefer a strong modern model tier and keep tool policy strict. Weaker model tiers are easier to prompt-inject.
For non-interactive runs, `--secret-input-mode ref` stores env-backed refs in auth profiles instead of plaintext API key values.
In non-interactive `ref` mode, the provider env var must be set; passing inline key flags without that env var fails fast.
In interactive runs, choosing secret reference mode lets you point at either an environment variable or a configured provider ref (`file` or `exec`), with a fast preflight validation before saving.

View File

@@ -97,7 +97,7 @@ Notes:
- `browser.ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias for compatibility.
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
- `color` + per-profile `color` tint the browser UI so you can see which profile is active.
- Default profile is `chrome` (extension relay). Use `defaultProfile: "openclaw"` for the managed browser.
- Default profile is `openclaw` (OpenClaw-managed standalone browser). Use `defaultProfile: "chrome"` to opt into the Chrome extension relay.
- Auto-detect order: system default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
- Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP.

View File

@@ -76,6 +76,28 @@ function resolveVersionFromPackage(command: string, cwd: string): string | null
}
}
function resolveVersionCheckResult(params: {
expectedVersion?: string;
installedVersion: string;
installCommand: string;
}): AcpxVersionCheckResult {
if (params.expectedVersion && params.installedVersion !== params.expectedVersion) {
return {
ok: false,
reason: "version-mismatch",
message: `acpx version mismatch: found ${params.installedVersion}, expected ${params.expectedVersion}`,
expectedVersion: params.expectedVersion,
installCommand: params.installCommand,
installedVersion: params.installedVersion,
};
}
return {
ok: true,
version: params.installedVersion,
expectedVersion: params.expectedVersion,
};
}
export async function checkAcpxVersion(params: {
command: string;
cwd?: string;
@@ -131,21 +153,7 @@ export async function checkAcpxVersion(params: {
if (hasExpectedVersion && isUnsupportedVersionProbe(result.stdout, result.stderr)) {
const installedVersion = resolveVersionFromPackage(params.command, cwd);
if (installedVersion) {
if (expectedVersion && installedVersion !== expectedVersion) {
return {
ok: false,
reason: "version-mismatch",
message: `acpx version mismatch: found ${installedVersion}, expected ${expectedVersion}`,
expectedVersion,
installCommand,
installedVersion,
};
}
return {
ok: true,
version: installedVersion,
expectedVersion,
};
return resolveVersionCheckResult({ expectedVersion, installedVersion, installCommand });
}
}
const stderr = result.stderr.trim();
@@ -179,22 +187,7 @@ export async function checkAcpxVersion(params: {
};
}
if (expectedVersion && installedVersion !== expectedVersion) {
return {
ok: false,
reason: "version-mismatch",
message: `acpx version mismatch: found ${installedVersion}, expected ${expectedVersion}`,
expectedVersion,
installCommand,
installedVersion,
};
}
return {
ok: true,
version: installedVersion,
expectedVersion,
};
return resolveVersionCheckResult({ expectedVersion, installedVersion, installCommand });
}
let pendingEnsure: Promise<void> | null = null;

View File

@@ -14,6 +14,8 @@ export const NOOP_LOGGER = {
};
const tempDirs: string[] = [];
let sharedMockCliScriptPath: Promise<string> | null = null;
let logFileSequence = 0;
const MOCK_CLI_SCRIPT = String.raw`#!/usr/bin/env node
const fs = require("node:fs");
@@ -263,14 +265,9 @@ export async function createMockRuntimeFixture(params?: {
logPath: string;
config: ResolvedAcpxPluginConfig;
}> {
const dir = await mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-acpx-runtime-test-"),
);
tempDirs.push(dir);
const scriptPath = path.join(dir, "mock-acpx.cjs");
const logPath = path.join(dir, "calls.log");
await writeFile(scriptPath, MOCK_CLI_SCRIPT, "utf8");
await chmod(scriptPath, 0o755);
const scriptPath = await ensureMockCliScriptPath();
const dir = path.dirname(scriptPath);
const logPath = path.join(dir, `calls-${logFileSequence++}.log`);
process.env.MOCK_ACPX_LOG = logPath;
const config: ResolvedAcpxPluginConfig = {
@@ -294,6 +291,23 @@ export async function createMockRuntimeFixture(params?: {
};
}
async function ensureMockCliScriptPath(): Promise<string> {
if (sharedMockCliScriptPath) {
return await sharedMockCliScriptPath;
}
sharedMockCliScriptPath = (async () => {
const dir = await mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-acpx-runtime-test-"),
);
tempDirs.push(dir);
const scriptPath = path.join(dir, "mock-acpx.cjs");
await writeFile(scriptPath, MOCK_CLI_SCRIPT, "utf8");
await chmod(scriptPath, 0o755);
return scriptPath;
})();
return await sharedMockCliScriptPath;
}
export async function readMockRuntimeLogEntries(
logPath: string,
): Promise<Array<Record<string, unknown>>> {
@@ -310,6 +324,8 @@ export async function readMockRuntimeLogEntries(
export async function cleanupMockRuntimeFixtures(): Promise<void> {
delete process.env.MOCK_ACPX_LOG;
sharedMockCliScriptPath = null;
logFileSequence = 0;
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {

View File

@@ -22,6 +22,7 @@ describe("AcpxRuntime", () => {
agentId: "codex",
successPrompt: "contract-pass",
errorPrompt: "trigger-error",
includeControlChecks: false,
assertSuccessEvents: (events) => {
expect(events.some((event) => event.type === "done")).toBe(true);
},
@@ -32,9 +33,6 @@ describe("AcpxRuntime", () => {
const logs = await readMockRuntimeLogEntries(fixture.logPath);
expect(logs.some((entry) => entry.kind === "ensure")).toBe(true);
expect(logs.some((entry) => entry.kind === "status")).toBe(true);
expect(logs.some((entry) => entry.kind === "set-mode")).toBe(true);
expect(logs.some((entry) => entry.kind === "set")).toBe(true);
expect(logs.some((entry) => entry.kind === "cancel")).toBe(true);
expect(logs.some((entry) => entry.kind === "close")).toBe(true);
});

View File

@@ -1,6 +1,7 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { matrixPlugin } from "./src/channel.js";
import { ensureMatrixCryptoRuntime } from "./src/matrix/deps.js";
import { setMatrixRuntime } from "./src/runtime.js";
const plugin = {
@@ -10,6 +11,10 @@ const plugin = {
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
setMatrixRuntime(api.runtime);
void ensureMatrixCryptoRuntime({ log: api.logger.info }).catch((err) => {
const message = err instanceof Error ? err.message : String(err);
api.logger.warn?.(`matrix: crypto runtime bootstrap failed: ${message}`);
});
api.registerChannel({ plugin: matrixPlugin });
},
};

View File

@@ -1,6 +1,6 @@
import { LogService } from "@vector-im/matrix-bot-sdk";
import { createMatrixClient } from "./client/create-client.js";
import { startMatrixClientWithGrace } from "./client/startup.js";
import { getMatrixLogService } from "./sdk-runtime.js";
type MatrixClientBootstrapAuth = {
homeserver: string;
@@ -39,6 +39,7 @@ export async function createPreparedMatrixClient(opts: {
await startMatrixClientWithGrace({
client,
onError: (err: unknown) => {
const LogService = getMatrixLogService();
LogService.error("MatrixClientBootstrap", "client.start() error:", err);
},
});

View File

@@ -1,7 +1,7 @@
import { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { loadMatrixSdk } from "../sdk-runtime.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
@@ -119,6 +119,7 @@ export async function resolveMatrixAuth(params?: {
if (!userId) {
// Fetch userId from access token via whoami
ensureMatrixSdkLoggingConfigured();
const { MatrixClient } = loadMatrixSdk();
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
const whoami = await tempClient.getUserId();
userId = whoami;

View File

@@ -1,11 +1,10 @@
import fs from "node:fs";
import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk";
import {
LogService,
import type {
IStorageProvider,
ICryptoStorageProvider,
MatrixClient,
SimpleFsStorageProvider,
RustSdkCryptoStorageProvider,
} from "@vector-im/matrix-bot-sdk";
import { loadMatrixSdk } from "../sdk-runtime.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import {
maybeMigrateLegacyStorage,
@@ -14,6 +13,7 @@ import {
} from "./storage.js";
function sanitizeUserIdList(input: unknown, label: string): string[] {
const LogService = loadMatrixSdk().LogService;
if (input == null) {
return [];
}
@@ -44,6 +44,8 @@ export async function createMatrixClient(params: {
localTimeoutMs?: number;
accountId?: string | null;
}): Promise<MatrixClient> {
const { MatrixClient, SimpleFsStorageProvider, RustSdkCryptoStorageProvider, LogService } =
loadMatrixSdk();
ensureMatrixSdkLoggingConfigured();
const env = process.env;

View File

@@ -1,7 +1,15 @@
import { ConsoleLogger, LogService } from "@vector-im/matrix-bot-sdk";
import { loadMatrixSdk } from "../sdk-runtime.js";
let matrixSdkLoggingConfigured = false;
const matrixSdkBaseLogger = new ConsoleLogger();
let matrixSdkBaseLogger:
| {
trace: (module: string, ...messageOrObject: unknown[]) => void;
debug: (module: string, ...messageOrObject: unknown[]) => void;
info: (module: string, ...messageOrObject: unknown[]) => void;
warn: (module: string, ...messageOrObject: unknown[]) => void;
error: (module: string, ...messageOrObject: unknown[]) => void;
}
| undefined;
function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean {
if (module !== "MatrixHttpClient") {
@@ -19,18 +27,20 @@ export function ensureMatrixSdkLoggingConfigured(): void {
if (matrixSdkLoggingConfigured) {
return;
}
const { ConsoleLogger, LogService } = loadMatrixSdk();
matrixSdkBaseLogger = new ConsoleLogger();
matrixSdkLoggingConfigured = true;
LogService.setLogger({
trace: (module, ...messageOrObject) => matrixSdkBaseLogger.trace(module, ...messageOrObject),
debug: (module, ...messageOrObject) => matrixSdkBaseLogger.debug(module, ...messageOrObject),
info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject),
warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject),
trace: (module, ...messageOrObject) => matrixSdkBaseLogger?.trace(module, ...messageOrObject),
debug: (module, ...messageOrObject) => matrixSdkBaseLogger?.debug(module, ...messageOrObject),
info: (module, ...messageOrObject) => matrixSdkBaseLogger?.info(module, ...messageOrObject),
warn: (module, ...messageOrObject) => matrixSdkBaseLogger?.warn(module, ...messageOrObject),
error: (module, ...messageOrObject) => {
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) {
return;
}
matrixSdkBaseLogger.error(module, ...messageOrObject);
matrixSdkBaseLogger?.error(module, ...messageOrObject);
},
});
}

View File

@@ -1,7 +1,7 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { LogService } from "@vector-im/matrix-bot-sdk";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { CoreConfig } from "../../types.js";
import { getMatrixLogService } from "../sdk-runtime.js";
import { resolveMatrixAuth } from "./config.js";
import { createMatrixClient } from "./create-client.js";
import { startMatrixClientWithGrace } from "./startup.js";
@@ -81,6 +81,7 @@ async function ensureSharedClientStarted(params: {
params.state.cryptoReady = true;
}
} catch (err) {
const LogService = getMatrixLogService();
LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err);
}
}
@@ -89,6 +90,7 @@ async function ensureSharedClientStarted(params: {
client,
onError: (err: unknown) => {
params.state.started = false;
const LogService = getMatrixLogService();
LogService.error("MatrixClientLite", "client.start() error:", err);
},
});

View File

@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest";
import { ensureMatrixCryptoRuntime } from "./deps.js";
const logStub = vi.fn();
describe("ensureMatrixCryptoRuntime", () => {
it("returns immediately when matrix SDK loads", async () => {
const runCommand = vi.fn();
const requireFn = vi.fn(() => ({}));
await ensureMatrixCryptoRuntime({
log: logStub,
requireFn,
runCommand,
resolveFn: () => "/tmp/download-lib.js",
nodeExecutable: "/usr/bin/node",
});
expect(requireFn).toHaveBeenCalledTimes(1);
expect(runCommand).not.toHaveBeenCalled();
});
it("bootstraps missing crypto runtime and retries matrix SDK load", async () => {
let bootstrapped = false;
const requireFn = vi.fn(() => {
if (!bootstrapped) {
throw new Error(
"Cannot find module '@matrix-org/matrix-sdk-crypto-nodejs-linux-x64-gnu' (required by matrix sdk)",
);
}
return {};
});
const runCommand = vi.fn(async () => {
bootstrapped = true;
return { code: 0, stdout: "", stderr: "" };
});
await ensureMatrixCryptoRuntime({
log: logStub,
requireFn,
runCommand,
resolveFn: () => "/tmp/download-lib.js",
nodeExecutable: "/usr/bin/node",
});
expect(runCommand).toHaveBeenCalledWith({
argv: ["/usr/bin/node", "/tmp/download-lib.js"],
cwd: "/tmp",
timeoutMs: 300_000,
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
});
expect(requireFn).toHaveBeenCalledTimes(2);
});
it("rethrows non-crypto module errors without bootstrapping", async () => {
const runCommand = vi.fn();
const requireFn = vi.fn(() => {
throw new Error("Cannot find module '@vector-im/matrix-bot-sdk'");
});
await expect(
ensureMatrixCryptoRuntime({
log: logStub,
requireFn,
runCommand,
resolveFn: () => "/tmp/download-lib.js",
nodeExecutable: "/usr/bin/node",
}),
).rejects.toThrow("Cannot find module '@vector-im/matrix-bot-sdk'");
expect(runCommand).not.toHaveBeenCalled();
expect(requireFn).toHaveBeenCalledTimes(1);
});
});

View File

@@ -5,6 +5,27 @@ import { fileURLToPath } from "node:url";
import { runPluginCommandWithTimeout, type RuntimeEnv } from "openclaw/plugin-sdk";
const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk";
const MATRIX_CRYPTO_DOWNLOAD_HELPER = "@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js";
function formatCommandError(result: { stderr: string; stdout: string }): string {
const stderr = result.stderr.trim();
if (stderr) {
return stderr;
}
const stdout = result.stdout.trim();
if (stdout) {
return stdout;
}
return "unknown error";
}
function isMissingMatrixCryptoRuntimeError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err ?? "");
return (
message.includes("Cannot find module") &&
message.includes("@matrix-org/matrix-sdk-crypto-nodejs-")
);
}
export function isMatrixSdkAvailable(): boolean {
try {
@@ -21,6 +42,51 @@ function resolvePluginRoot(): string {
return path.resolve(currentDir, "..", "..");
}
export async function ensureMatrixCryptoRuntime(
params: {
log?: (message: string) => void;
requireFn?: (id: string) => unknown;
resolveFn?: (id: string) => string;
runCommand?: typeof runPluginCommandWithTimeout;
nodeExecutable?: string;
} = {},
): Promise<void> {
const req = createRequire(import.meta.url);
const requireFn = params.requireFn ?? ((id: string) => req(id));
const resolveFn = params.resolveFn ?? ((id: string) => req.resolve(id));
const runCommand = params.runCommand ?? runPluginCommandWithTimeout;
const nodeExecutable = params.nodeExecutable ?? process.execPath;
try {
requireFn(MATRIX_SDK_PACKAGE);
return;
} catch (err) {
if (!isMissingMatrixCryptoRuntimeError(err)) {
throw err;
}
}
const scriptPath = resolveFn(MATRIX_CRYPTO_DOWNLOAD_HELPER);
params.log?.("matrix: crypto runtime missing; downloading platform library…");
const result = await runCommand({
argv: [nodeExecutable, scriptPath],
cwd: path.dirname(scriptPath),
timeoutMs: 300_000,
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
});
if (result.code !== 0) {
throw new Error(`Matrix crypto runtime bootstrap failed: ${formatCommandError(result)}`);
}
try {
requireFn(MATRIX_SDK_PACKAGE);
} catch (err) {
throw new Error(
`Matrix crypto runtime remains unavailable after bootstrap: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
export async function ensureMatrixSdkInstalled(params: {
runtime: RuntimeEnv;
confirm?: (message: string) => Promise<boolean>;

View File

@@ -1,8 +1,8 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { AutojoinRoomsMixin } from "@vector-im/matrix-bot-sdk";
import type { RuntimeEnv } from "openclaw/plugin-sdk";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { loadMatrixSdk } from "../sdk-runtime.js";
export function registerMatrixAutoJoin(params: {
client: MatrixClient;
@@ -26,6 +26,7 @@ export function registerMatrixAutoJoin(params: {
if (autoJoin === "always") {
// Use the built-in autojoin mixin for "always" mode
const { AutojoinRoomsMixin } = loadMatrixSdk();
AutojoinRoomsMixin.setupOnClient(client);
logVerbose("matrix: auto-join enabled for all invites");
return;

View File

@@ -0,0 +1,18 @@
import { createRequire } from "node:module";
type MatrixSdkRuntime = typeof import("@vector-im/matrix-bot-sdk");
let cachedMatrixSdkRuntime: MatrixSdkRuntime | null = null;
export function loadMatrixSdk(): MatrixSdkRuntime {
if (cachedMatrixSdkRuntime) {
return cachedMatrixSdkRuntime;
}
const req = createRequire(import.meta.url);
cachedMatrixSdkRuntime = req("@vector-im/matrix-bot-sdk") as MatrixSdkRuntime;
return cachedMatrixSdkRuntime;
}
export function getMatrixLogService() {
return loadMatrixSdk().LogService;
}

View File

@@ -1,3 +1,5 @@
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
export const DEFAULT_SEND_GAP_MS = 150;
type MatrixSendQueueOptions = {
@@ -6,37 +8,19 @@ type MatrixSendQueueOptions = {
};
// Serialize sends per room to preserve Matrix delivery order.
const roomQueues = new Map<string, Promise<void>>();
const roomQueues = new KeyedAsyncQueue();
export async function enqueueSend<T>(
export function enqueueSend<T>(
roomId: string,
fn: () => Promise<T>,
options?: MatrixSendQueueOptions,
): Promise<T> {
const gapMs = options?.gapMs ?? DEFAULT_SEND_GAP_MS;
const delayFn = options?.delayFn ?? delay;
const previous = roomQueues.get(roomId) ?? Promise.resolve();
const next = previous
.catch(() => {})
.then(async () => {
await delayFn(gapMs);
return await fn();
});
const queueMarker = next.then(
() => {},
() => {},
);
roomQueues.set(roomId, queueMarker);
queueMarker.finally(() => {
if (roomQueues.get(roomId) === queueMarker) {
roomQueues.delete(roomId);
}
return roomQueues.enqueue(roomId, async () => {
await delayFn(gapMs);
return await fn();
});
return await next;
}
function delay(ms: number): Promise<void> {

View File

@@ -11,7 +11,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "test-key";
const HAS_OPENAI_KEY = Boolean(process.env.OPENAI_API_KEY);
@@ -135,6 +135,89 @@ describe("memory plugin e2e", () => {
expect(config?.autoRecall).toBe(true);
});
test("passes configured dimensions to OpenAI embeddings API", async () => {
const embeddingsCreate = vi.fn(async () => ({
data: [{ embedding: [0.1, 0.2, 0.3] }],
}));
const toArray = vi.fn(async () => []);
const limit = vi.fn(() => ({ toArray }));
const vectorSearch = vi.fn(() => ({ limit }));
vi.resetModules();
vi.doMock("openai", () => ({
default: class MockOpenAI {
embeddings = { create: embeddingsCreate };
},
}));
vi.doMock("@lancedb/lancedb", () => ({
connect: vi.fn(async () => ({
tableNames: vi.fn(async () => ["memories"]),
openTable: vi.fn(async () => ({
vectorSearch,
countRows: vi.fn(async () => 0),
add: vi.fn(async () => undefined),
delete: vi.fn(async () => undefined),
})),
})),
}));
try {
const { default: memoryPlugin } = await import("./index.js");
// oxlint-disable-next-line typescript/no-explicit-any
const registeredTools: any[] = [];
const mockApi = {
id: "memory-lancedb",
name: "Memory (LanceDB)",
source: "test",
config: {},
pluginConfig: {
embedding: {
apiKey: OPENAI_API_KEY,
model: "text-embedding-3-small",
dimensions: 1024,
},
dbPath,
autoCapture: false,
autoRecall: false,
},
runtime: {},
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
// oxlint-disable-next-line typescript/no-explicit-any
registerTool: (tool: any, opts: any) => {
registeredTools.push({ tool, opts });
},
// oxlint-disable-next-line typescript/no-explicit-any
registerCli: vi.fn(),
// oxlint-disable-next-line typescript/no-explicit-any
registerService: vi.fn(),
// oxlint-disable-next-line typescript/no-explicit-any
on: vi.fn(),
resolvePath: (p: string) => p,
};
// oxlint-disable-next-line typescript/no-explicit-any
memoryPlugin.register(mockApi as any);
const recallTool = registeredTools.find((t) => t.opts?.name === "memory_recall")?.tool;
expect(recallTool).toBeDefined();
await recallTool.execute("test-call-dims", { query: "hello dimensions" });
expect(embeddingsCreate).toHaveBeenCalledWith({
model: "text-embedding-3-small",
input: "hello dimensions",
dimensions: 1024,
});
} finally {
vi.doUnmock("openai");
vi.doUnmock("@lancedb/lancedb");
vi.resetModules();
}
});
test("shouldCapture applies real capture rules", async () => {
const { shouldCapture } = await import("./index.js");

View File

@@ -167,15 +167,20 @@ class Embeddings {
apiKey: string,
private model: string,
baseUrl?: string,
private dimensions?: number,
) {
this.client = new OpenAI({ apiKey, baseURL: baseUrl });
}
async embed(text: string): Promise<number[]> {
const response = await this.client.embeddings.create({
const params: { model: string; input: string; dimensions?: number } = {
model: this.model,
input: text,
});
};
if (this.dimensions) {
params.dimensions = this.dimensions;
}
const response = await this.client.embeddings.create(params);
return response.data[0].embedding;
}
}
@@ -298,7 +303,7 @@ const memoryPlugin = {
const vectorDim = dimensions ?? vectorDimsForModel(model);
const db = new MemoryDB(resolvedDbPath, vectorDim);
const embeddings = new Embeddings(apiKey, model, baseUrl);
const embeddings = new Embeddings(apiKey, model, baseUrl, dimensions);
api.logger.info(`memory-lancedb: plugin registered (db: ${resolvedDbPath}, lazy init)`);

View File

@@ -164,7 +164,13 @@ const IMAGE_ATTACHMENT = { contentType: CONTENT_TYPE_IMAGE_PNG, contentUrl: TEST
const PNG_BUFFER = Buffer.from("png");
const PNG_BASE64 = PNG_BUFFER.toString("base64");
const PDF_BUFFER = Buffer.from("pdf");
const createTokenProvider = () => ({ getAccessToken: vi.fn(async () => "token") });
const createTokenProvider = (
tokenOrResolver: string | ((scope: string) => string | Promise<string>) = "token",
) => ({
getAccessToken: vi.fn(async (scope: string) =>
typeof tokenOrResolver === "function" ? await tokenOrResolver(scope) : tokenOrResolver,
),
});
const asSingleItemArray = <T>(value: T) => [value];
const withLabel = <T extends object>(label: string, fields: T): T & LabeledCase => ({
label,
@@ -694,6 +700,121 @@ describe("msteams attachments", () => {
runAttachmentAuthRetryCase,
);
it("preserves auth fallback when dispatcher-mode fetch returns a redirect", async () => {
const redirectedUrl = createTestUrl("redirected.png");
const tokenProvider = createTokenProvider();
const fetchMock = vi.fn(async (url: string, opts?: RequestInit) => {
const hasAuth = Boolean(new Headers(opts?.headers).get("Authorization"));
if (url === TEST_URL_IMAGE) {
return hasAuth
? createRedirectResponse(redirectedUrl)
: createTextResponse("unauthorized", 401);
}
if (url === redirectedUrl) {
return createBufferResponse(PNG_BUFFER, CONTENT_TYPE_IMAGE_PNG);
}
return createNotFoundResponse();
});
fetchRemoteMediaMock.mockImplementationOnce(async (params) => {
const fetchFn = params.fetchImpl ?? fetch;
let currentUrl = params.url;
for (let i = 0; i < MAX_REDIRECT_HOPS; i += 1) {
const res = await fetchFn(currentUrl, {
redirect: "manual",
dispatcher: {},
} as RequestInit);
if (REDIRECT_STATUS_CODES.includes(res.status)) {
const location = res.headers.get("location");
if (!location) {
throw new Error("redirect missing location");
}
currentUrl = new URL(location, currentUrl).toString();
continue;
}
return readRemoteMediaResponse(res, params);
}
throw new Error("too many redirects");
});
const media = await downloadAttachmentsWithFetch(
createImageAttachments(TEST_URL_IMAGE),
fetchMock,
{ tokenProvider, authAllowHosts: [TEST_HOST] },
);
expectAttachmentMediaLength(media, 1);
expect(tokenProvider.getAccessToken).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls.map(([calledUrl]) => String(calledUrl))).toContain(redirectedUrl);
});
it("continues scope fallback after non-auth failure and succeeds on later scope", async () => {
let authAttempt = 0;
const tokenProvider = createTokenProvider((scope) => `token:${scope}`);
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
const auth = new Headers(opts?.headers).get("Authorization");
if (!auth) {
return createTextResponse("unauthorized", 401);
}
authAttempt += 1;
if (authAttempt === 1) {
return createTextResponse("upstream transient", 500);
}
return createBufferResponse(PNG_BUFFER, CONTENT_TYPE_IMAGE_PNG);
});
const media = await downloadAttachmentsWithFetch(
createImageAttachments(TEST_URL_IMAGE),
fetchMock,
{ tokenProvider, authAllowHosts: [TEST_HOST] },
);
expectAttachmentMediaLength(media, 1);
expect(tokenProvider.getAccessToken).toHaveBeenCalledTimes(2);
});
it("does not forward Authorization to redirects outside auth allowlist", async () => {
const tokenProvider = createTokenProvider("top-secret-token");
const graphFileUrl = createUrlForHost(GRAPH_HOST, "file");
const seen: Array<{ url: string; auth: string }> = [];
const fetchMock = vi.fn(async (url: string, opts?: RequestInit) => {
const auth = new Headers(opts?.headers).get("Authorization") ?? "";
seen.push({ url, auth });
if (url === graphFileUrl && !auth) {
return new Response("unauthorized", { status: 401 });
}
if (url === graphFileUrl && auth) {
return new Response("", {
status: 302,
headers: { location: "https://attacker.azureedge.net/collect" },
});
}
if (url === "https://attacker.azureedge.net/collect") {
return new Response(Buffer.from("png"), {
status: 200,
headers: { "content-type": CONTENT_TYPE_IMAGE_PNG },
});
}
return createNotFoundResponse();
});
const media = await downloadMSTeamsAttachments(
buildDownloadParams([{ contentType: CONTENT_TYPE_IMAGE_PNG, contentUrl: graphFileUrl }], {
tokenProvider,
allowHosts: [GRAPH_HOST, AZUREEDGE_HOST],
authAllowHosts: [GRAPH_HOST],
fetchFn: asFetchFn(fetchMock),
}),
);
expectSingleMedia(media);
const redirected = seen.find(
(entry) => entry.url === "https://attacker.azureedge.net/collect",
);
expect(redirected).toBeDefined();
expect(redirected?.auth).toBe("");
});
it("skips urls outside the allowlist", async () => {
const fetchMock = vi.fn();
const media = await downloadAttachmentsWithFetch(
@@ -744,6 +865,49 @@ describe("msteams attachments", () => {
describe("downloadMSTeamsGraphMedia", () => {
it.each<GraphMediaSuccessCase>(GRAPH_MEDIA_SUCCESS_CASES)("$label", runGraphMediaSuccessCase);
it("does not forward Authorization for SharePoint redirects outside auth allowlist", async () => {
const tokenProvider = createTokenProvider("top-secret-token");
const escapedUrl = "https://example.com/collect";
const seen: Array<{ url: string; auth: string }> = [];
const referenceAttachment = createReferenceAttachment();
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const auth = new Headers(init?.headers).get("Authorization") ?? "";
seen.push({ url, auth });
if (url === DEFAULT_MESSAGE_URL) {
return createJsonResponse({ attachments: [referenceAttachment] });
}
if (url === `${DEFAULT_MESSAGE_URL}/hostedContents`) {
return createGraphCollectionResponse([]);
}
if (url === `${DEFAULT_MESSAGE_URL}/attachments`) {
return createGraphCollectionResponse([referenceAttachment]);
}
if (url.startsWith(GRAPH_SHARES_URL_PREFIX)) {
return createRedirectResponse(escapedUrl);
}
if (url === escapedUrl) {
return createPdfResponse();
}
return createNotFoundResponse();
});
const media = await downloadMSTeamsGraphMedia({
messageUrl: DEFAULT_MESSAGE_URL,
tokenProvider,
maxBytes: DEFAULT_MAX_BYTES,
allowHosts: [...DEFAULT_SHAREPOINT_ALLOW_HOSTS, "example.com"],
authAllowHosts: DEFAULT_SHAREPOINT_ALLOW_HOSTS,
fetchFn: asFetchFn(fetchMock),
});
expectAttachmentMediaLength(media.media, 1);
const redirected = seen.find((entry) => entry.url === escapedUrl);
expect(redirected).toBeDefined();
expect(redirected?.auth).toBe("");
});
it("blocks SharePoint redirects to hosts outside allowHosts", async () => {
const escapedUrl = "https://evil.example/internal.pdf";
const { fetchMock, media } = await downloadGraphMediaWithMockOptions(

View File

@@ -1,4 +1,3 @@
import { fetchWithBearerAuthScopeFallback } from "openclaw/plugin-sdk";
import { getMSTeamsRuntime } from "../runtime.js";
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
import {
@@ -7,11 +6,12 @@ import {
isDownloadableAttachment,
isRecord,
isUrlAllowed,
type MSTeamsAttachmentFetchPolicy,
normalizeContentType,
resolveMediaSsrfPolicy,
resolveAttachmentFetchPolicy,
resolveRequestUrl,
resolveAuthAllowedHosts,
resolveAllowedHosts,
safeFetchWithPolicy,
} from "./shared.js";
import type {
MSTeamsAccessTokenProvider,
@@ -86,22 +86,69 @@ function scopeCandidatesForUrl(url: string): string[] {
}
}
function isRedirectStatus(status: number): boolean {
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
}
async function fetchWithAuthFallback(params: {
url: string;
tokenProvider?: MSTeamsAccessTokenProvider;
fetchFn?: typeof fetch;
requestInit?: RequestInit;
authAllowHosts: string[];
policy: MSTeamsAttachmentFetchPolicy;
}): Promise<Response> {
return await fetchWithBearerAuthScopeFallback({
const firstAttempt = await safeFetchWithPolicy({
url: params.url,
scopes: scopeCandidatesForUrl(params.url),
tokenProvider: params.tokenProvider,
policy: params.policy,
fetchFn: params.fetchFn,
requestInit: params.requestInit,
requireHttps: true,
shouldAttachAuth: (url) => isUrlAllowed(url, params.authAllowHosts),
});
if (firstAttempt.ok) {
return firstAttempt;
}
if (!params.tokenProvider) {
return firstAttempt;
}
if (firstAttempt.status !== 401 && firstAttempt.status !== 403) {
return firstAttempt;
}
if (!isUrlAllowed(params.url, params.policy.authAllowHosts)) {
return firstAttempt;
}
const scopes = scopeCandidatesForUrl(params.url);
const fetchFn = params.fetchFn ?? fetch;
for (const scope of scopes) {
try {
const token = await params.tokenProvider.getAccessToken(scope);
const authHeaders = new Headers(params.requestInit?.headers);
authHeaders.set("Authorization", `Bearer ${token}`);
const authAttempt = await safeFetchWithPolicy({
url: params.url,
policy: params.policy,
fetchFn,
requestInit: {
...params.requestInit,
headers: authHeaders,
},
});
if (authAttempt.ok) {
return authAttempt;
}
if (isRedirectStatus(authAttempt.status)) {
// Redirects in guarded fetch mode must propagate to the outer guard.
return authAttempt;
}
if (authAttempt.status !== 401 && authAttempt.status !== 403) {
// Preserve scope fallback semantics for non-auth failures.
continue;
}
} catch {
// Try the next scope.
}
}
return firstAttempt;
}
/**
@@ -122,8 +169,11 @@ export async function downloadMSTeamsAttachments(params: {
if (list.length === 0) {
return [];
}
const allowHosts = resolveAllowedHosts(params.allowHosts);
const authAllowHosts = resolveAuthAllowedHosts(params.authAllowHosts);
const policy = resolveAttachmentFetchPolicy({
allowHosts: params.allowHosts,
authAllowHosts: params.authAllowHosts,
});
const allowHosts = policy.allowHosts;
const ssrfPolicy = resolveMediaSsrfPolicy(allowHosts);
// Download ANY downloadable attachment (not just images)
@@ -200,7 +250,7 @@ export async function downloadMSTeamsAttachments(params: {
tokenProvider: params.tokenProvider,
fetchFn: params.fetchFn,
requestInit: init,
authAllowHosts,
policy,
}),
});
out.push(media);

View File

@@ -3,14 +3,17 @@ import { getMSTeamsRuntime } from "../runtime.js";
import { downloadMSTeamsAttachments } from "./download.js";
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
import {
applyAuthorizationHeaderForUrl,
GRAPH_ROOT,
inferPlaceholder,
isRecord,
isUrlAllowed,
type MSTeamsAttachmentFetchPolicy,
normalizeContentType,
resolveMediaSsrfPolicy,
resolveAttachmentFetchPolicy,
resolveRequestUrl,
resolveAllowedHosts,
safeFetchWithPolicy,
} from "./shared.js";
import type {
MSTeamsAccessTokenProvider,
@@ -241,8 +244,11 @@ export async function downloadMSTeamsGraphMedia(params: {
if (!params.messageUrl || !params.tokenProvider) {
return { media: [] };
}
const allowHosts = resolveAllowedHosts(params.allowHosts);
const ssrfPolicy = resolveMediaSsrfPolicy(allowHosts);
const policy: MSTeamsAttachmentFetchPolicy = resolveAttachmentFetchPolicy({
allowHosts: params.allowHosts,
authAllowHosts: params.authAllowHosts,
});
const ssrfPolicy = resolveMediaSsrfPolicy(policy.allowHosts);
const messageUrl = params.messageUrl;
let accessToken: string;
try {
@@ -288,7 +294,7 @@ export async function downloadMSTeamsGraphMedia(params: {
try {
// SharePoint URLs need to be accessed via Graph shares API
const shareUrl = att.contentUrl!;
if (!isUrlAllowed(shareUrl, allowHosts)) {
if (!isUrlAllowed(shareUrl, policy.allowHosts)) {
continue;
}
const encodedUrl = Buffer.from(shareUrl).toString("base64url");
@@ -304,8 +310,21 @@ export async function downloadMSTeamsGraphMedia(params: {
fetchImpl: async (input, init) => {
const requestUrl = resolveRequestUrl(input);
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${accessToken}`);
return await fetchFn(requestUrl, { ...init, headers });
applyAuthorizationHeaderForUrl({
headers,
url: requestUrl,
authAllowHosts: policy.authAllowHosts,
bearerToken: accessToken,
});
return await safeFetchWithPolicy({
url: requestUrl,
policy,
fetchFn,
requestInit: {
...init,
headers,
},
});
},
});
sharePointMedia.push(media);
@@ -357,8 +376,8 @@ export async function downloadMSTeamsGraphMedia(params: {
attachments: filteredAttachments,
maxBytes: params.maxBytes,
tokenProvider: params.tokenProvider,
allowHosts,
authAllowHosts: params.authAllowHosts,
allowHosts: policy.allowHosts,
authAllowHosts: policy.authAllowHosts,
fetchFn: params.fetchFn,
preserveFilenames: params.preserveFilenames,
});

View File

@@ -1,17 +1,54 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
applyAuthorizationHeaderForUrl,
isPrivateOrReservedIP,
isUrlAllowed,
resolveAndValidateIP,
resolveAttachmentFetchPolicy,
resolveAllowedHosts,
resolveAuthAllowedHosts,
resolveMediaSsrfPolicy,
safeFetch,
safeFetchWithPolicy,
} from "./shared.js";
const publicResolve = async () => ({ address: "13.107.136.10" });
const privateResolve = (ip: string) => async () => ({ address: ip });
const failingResolve = async () => {
throw new Error("DNS failure");
};
function mockFetchWithRedirect(redirectMap: Record<string, string>, finalBody = "ok") {
return vi.fn(async (url: string, init?: RequestInit) => {
const target = redirectMap[url];
if (target && init?.redirect === "manual") {
return new Response(null, {
status: 302,
headers: { location: target },
});
}
return new Response(finalBody, { status: 200 });
});
}
describe("msteams attachment allowlists", () => {
it("normalizes wildcard host lists", () => {
expect(resolveAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
expect(resolveAuthAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
});
it("resolves a normalized attachment fetch policy", () => {
expect(
resolveAttachmentFetchPolicy({
allowHosts: ["sharepoint.com"],
authAllowHosts: ["graph.microsoft.com"],
}),
).toEqual({
allowHosts: ["sharepoint.com"],
authAllowHosts: ["graph.microsoft.com"],
});
});
it("requires https and host suffix match", () => {
const allowHosts = resolveAllowedHosts(["sharepoint.com"]);
expect(isUrlAllowed("https://contoso.sharepoint.com/file.png", allowHosts)).toBe(true);
@@ -25,4 +62,317 @@ describe("msteams attachment allowlists", () => {
});
expect(resolveMediaSsrfPolicy(["*"])).toBeUndefined();
});
it.each([
["999.999.999.999", true],
["256.0.0.1", true],
["10.0.0.256", true],
["-1.0.0.1", false],
["1.2.3.4.5", false],
["0:0:0:0:0:0:0:1", true],
] as const)("malformed/expanded %s → %s (SDK fails closed)", (ip, expected) => {
expect(isPrivateOrReservedIP(ip)).toBe(expected);
});
});
// ─── resolveAndValidateIP ────────────────────────────────────────────────────
describe("resolveAndValidateIP", () => {
it("accepts a hostname resolving to a public IP", async () => {
const ip = await resolveAndValidateIP("teams.sharepoint.com", publicResolve);
expect(ip).toBe("13.107.136.10");
});
it("rejects a hostname resolving to 10.x.x.x", async () => {
await expect(resolveAndValidateIP("evil.test", privateResolve("10.0.0.1"))).rejects.toThrow(
"private/reserved IP",
);
});
it("rejects a hostname resolving to 169.254.169.254", async () => {
await expect(
resolveAndValidateIP("evil.test", privateResolve("169.254.169.254")),
).rejects.toThrow("private/reserved IP");
});
it("rejects a hostname resolving to loopback", async () => {
await expect(resolveAndValidateIP("evil.test", privateResolve("127.0.0.1"))).rejects.toThrow(
"private/reserved IP",
);
});
it("rejects a hostname resolving to IPv6 loopback", async () => {
await expect(resolveAndValidateIP("evil.test", privateResolve("::1"))).rejects.toThrow(
"private/reserved IP",
);
});
it("throws on DNS resolution failure", async () => {
await expect(resolveAndValidateIP("nonexistent.test", failingResolve)).rejects.toThrow(
"DNS resolution failed",
);
});
});
// ─── safeFetch ───────────────────────────────────────────────────────────────
describe("safeFetch", () => {
it("fetches a URL directly when no redirect occurs", async () => {
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
return new Response("ok", { status: 200 });
});
const res = await safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledOnce();
// Should have used redirect: "manual"
expect(fetchMock.mock.calls[0][1]).toHaveProperty("redirect", "manual");
});
it("follows a redirect to an allowlisted host with public IP", async () => {
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file.pdf": "https://cdn.sharepoint.com/storage/file.pdf",
});
const res = await safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("returns the redirect response when dispatcher is provided by an outer guard", async () => {
const redirectedTo = "https://cdn.sharepoint.com/storage/file.pdf";
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file.pdf": redirectedTo,
});
const res = await safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
requestInit: { dispatcher: {} } as RequestInit,
resolveFn: publicResolve,
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe(redirectedTo);
expect(fetchMock).toHaveBeenCalledOnce();
});
it("still enforces allowlist checks before returning dispatcher-mode redirects", async () => {
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file.pdf": "https://evil.example.com/steal",
});
await expect(
safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
requestInit: { dispatcher: {} } as RequestInit,
resolveFn: publicResolve,
}),
).rejects.toThrow("blocked by allowlist");
expect(fetchMock).toHaveBeenCalledOnce();
});
it("blocks a redirect to a non-allowlisted host", async () => {
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file.pdf": "https://evil.example.com/steal",
});
await expect(
safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
}),
).rejects.toThrow("blocked by allowlist");
// Should not have fetched the evil URL
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("blocks a redirect to an allowlisted host that resolves to a private IP (DNS rebinding)", async () => {
let callCount = 0;
const rebindingResolve = async () => {
callCount++;
// First call (initial URL) resolves to public IP
if (callCount === 1) return { address: "13.107.136.10" };
// Second call (redirect target) resolves to private IP
return { address: "169.254.169.254" };
};
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file.pdf": "https://evil.trafficmanager.net/metadata",
});
await expect(
safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com", "trafficmanager.net"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: rebindingResolve,
}),
).rejects.toThrow("private/reserved IP");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("blocks when the initial URL resolves to a private IP", async () => {
const fetchMock = vi.fn();
await expect(
safeFetch({
url: "https://evil.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: privateResolve("10.0.0.1"),
}),
).rejects.toThrow("Initial download URL blocked");
expect(fetchMock).not.toHaveBeenCalled();
});
it("blocks when initial URL DNS resolution fails", async () => {
const fetchMock = vi.fn();
await expect(
safeFetch({
url: "https://nonexistent.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: failingResolve,
}),
).rejects.toThrow("Initial download URL blocked");
expect(fetchMock).not.toHaveBeenCalled();
});
it("follows multiple redirects when all are valid", async () => {
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url === "https://a.sharepoint.com/1" && init?.redirect === "manual") {
return new Response(null, {
status: 302,
headers: { location: "https://b.sharepoint.com/2" },
});
}
if (url === "https://b.sharepoint.com/2" && init?.redirect === "manual") {
return new Response(null, {
status: 302,
headers: { location: "https://c.sharepoint.com/3" },
});
}
return new Response("final", { status: 200 });
});
const res = await safeFetch({
url: "https://a.sharepoint.com/1",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("throws on too many redirects", async () => {
let counter = 0;
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.redirect === "manual") {
counter++;
return new Response(null, {
status: 302,
headers: { location: `https://loop${counter}.sharepoint.com/x` },
});
}
return new Response("ok", { status: 200 });
});
await expect(
safeFetch({
url: "https://start.sharepoint.com/x",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
}),
).rejects.toThrow("Too many redirects");
});
it("blocks redirect to HTTP (non-HTTPS)", async () => {
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file": "http://internal.sharepoint.com/file",
});
await expect(
safeFetch({
url: "https://teams.sharepoint.com/file",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
}),
).rejects.toThrow("blocked by allowlist");
});
it("strips authorization across redirects outside auth allowlist", async () => {
const seenAuth: string[] = [];
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
const auth = new Headers(init?.headers).get("authorization") ?? "";
seenAuth.push(`${url}|${auth}`);
if (url === "https://teams.sharepoint.com/file.pdf") {
return new Response(null, {
status: 302,
headers: { location: "https://cdn.sharepoint.com/storage/file.pdf" },
});
}
return new Response("ok", { status: 200 });
});
const headers = new Headers({ Authorization: "Bearer secret" });
const res = await safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
authorizationAllowHosts: ["graph.microsoft.com"],
fetchFn: fetchMock as unknown as typeof fetch,
requestInit: { headers },
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
expect(seenAuth[0]).toContain("Bearer secret");
expect(seenAuth[1]).toMatch(/\|$/);
});
});
describe("attachment fetch auth helpers", () => {
it("sets and clears authorization header by auth allowlist", () => {
const headers = new Headers();
applyAuthorizationHeaderForUrl({
headers,
url: "https://graph.microsoft.com/v1.0/me",
authAllowHosts: ["graph.microsoft.com"],
bearerToken: "token-1",
});
expect(headers.get("authorization")).toBe("Bearer token-1");
applyAuthorizationHeaderForUrl({
headers,
url: "https://evil.example.com/collect",
authAllowHosts: ["graph.microsoft.com"],
bearerToken: "token-1",
});
expect(headers.get("authorization")).toBeNull();
});
it("safeFetchWithPolicy forwards policy allowlists", async () => {
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
return new Response("ok", { status: 200 });
});
const res = await safeFetchWithPolicy({
url: "https://teams.sharepoint.com/file.pdf",
policy: resolveAttachmentFetchPolicy({
allowHosts: ["sharepoint.com"],
authAllowHosts: ["graph.microsoft.com"],
}),
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledOnce();
});
});

View File

@@ -1,6 +1,8 @@
import { lookup } from "node:dns/promises";
import {
buildHostnameAllowlistPolicyFromSuffixAllowlist,
isHttpsUrlAllowedByHostnameSuffixAllowlist,
isPrivateIpAddress,
normalizeHostnameSuffixAllowlist,
} from "openclaw/plugin-sdk";
import type { SsrFPolicy } from "openclaw/plugin-sdk";
@@ -264,10 +266,194 @@ export function resolveAuthAllowedHosts(input?: string[]): string[] {
return normalizeHostnameSuffixAllowlist(input, DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST);
}
export type MSTeamsAttachmentFetchPolicy = {
allowHosts: string[];
authAllowHosts: string[];
};
export function resolveAttachmentFetchPolicy(params?: {
allowHosts?: string[];
authAllowHosts?: string[];
}): MSTeamsAttachmentFetchPolicy {
return {
allowHosts: resolveAllowedHosts(params?.allowHosts),
authAllowHosts: resolveAuthAllowedHosts(params?.authAllowHosts),
};
}
export function isUrlAllowed(url: string, allowlist: string[]): boolean {
return isHttpsUrlAllowedByHostnameSuffixAllowlist(url, allowlist);
}
export function applyAuthorizationHeaderForUrl(params: {
headers: Headers;
url: string;
authAllowHosts: string[];
bearerToken?: string;
}): void {
if (!params.bearerToken) {
params.headers.delete("Authorization");
return;
}
if (isUrlAllowed(params.url, params.authAllowHosts)) {
params.headers.set("Authorization", `Bearer ${params.bearerToken}`);
return;
}
params.headers.delete("Authorization");
}
export function resolveMediaSsrfPolicy(allowHosts: string[]): SsrFPolicy | undefined {
return buildHostnameAllowlistPolicyFromSuffixAllowlist(allowHosts);
}
/**
* Returns true if the given IPv4 or IPv6 address is in a private, loopback,
* or link-local range that must never be reached from media downloads.
*
* Delegates to the SDK's `isPrivateIpAddress` which handles IPv4-mapped IPv6,
* expanded notation, NAT64, 6to4, Teredo, octal IPv4, and fails closed on
* parse errors.
*/
export const isPrivateOrReservedIP: (ip: string) => boolean = isPrivateIpAddress;
/**
* Resolve a hostname via DNS and reject private/reserved IPs.
* Throws if the resolved IP is private or resolution fails.
*/
export async function resolveAndValidateIP(
hostname: string,
resolveFn?: (hostname: string) => Promise<{ address: string }>,
): Promise<string> {
const resolve = resolveFn ?? lookup;
let resolved: { address: string };
try {
resolved = await resolve(hostname);
} catch {
throw new Error(`DNS resolution failed for "${hostname}"`);
}
if (isPrivateOrReservedIP(resolved.address)) {
throw new Error(`Hostname "${hostname}" resolves to private/reserved IP (${resolved.address})`);
}
return resolved.address;
}
/** Maximum number of redirects to follow in safeFetch. */
const MAX_SAFE_REDIRECTS = 5;
/**
* Fetch a URL with redirect: "manual", validating each redirect target
* against the hostname allowlist and optional DNS-resolved IP (anti-SSRF).
*
* This prevents:
* - Auto-following redirects to non-allowlisted hosts
* - DNS rebinding attacks when a lookup function is provided
*/
export async function safeFetch(params: {
url: string;
allowHosts: string[];
/**
* Optional allowlist for forwarding Authorization across redirects.
* When set, Authorization is stripped before following redirects to hosts
* outside this list.
*/
authorizationAllowHosts?: string[];
fetchFn?: typeof fetch;
requestInit?: RequestInit;
resolveFn?: (hostname: string) => Promise<{ address: string }>;
}): Promise<Response> {
const fetchFn = params.fetchFn ?? fetch;
const resolveFn = params.resolveFn;
const hasDispatcher = Boolean(
params.requestInit &&
typeof params.requestInit === "object" &&
"dispatcher" in (params.requestInit as Record<string, unknown>),
);
const currentHeaders = new Headers(params.requestInit?.headers);
let currentUrl = params.url;
if (!isUrlAllowed(currentUrl, params.allowHosts)) {
throw new Error(`Initial download URL blocked: ${currentUrl}`);
}
if (resolveFn) {
try {
const initialHost = new URL(currentUrl).hostname;
await resolveAndValidateIP(initialHost, resolveFn);
} catch {
throw new Error(`Initial download URL blocked: ${currentUrl}`);
}
}
for (let i = 0; i <= MAX_SAFE_REDIRECTS; i++) {
const res = await fetchFn(currentUrl, {
...params.requestInit,
headers: currentHeaders,
redirect: "manual",
});
if (![301, 302, 303, 307, 308].includes(res.status)) {
return res;
}
const location = res.headers.get("location");
if (!location) {
return res;
}
let redirectUrl: string;
try {
redirectUrl = new URL(location, currentUrl).toString();
} catch {
throw new Error(`Invalid redirect URL: ${location}`);
}
// Validate redirect target against hostname allowlist
if (!isUrlAllowed(redirectUrl, params.allowHosts)) {
throw new Error(`Media redirect target blocked by allowlist: ${redirectUrl}`);
}
// Prevent credential bleed: only keep Authorization on redirect hops that
// are explicitly auth-allowlisted.
if (
currentHeaders.has("authorization") &&
params.authorizationAllowHosts &&
!isUrlAllowed(redirectUrl, params.authorizationAllowHosts)
) {
currentHeaders.delete("authorization");
}
// When a pinned dispatcher is already injected by an upstream guard
// (for example fetchWithSsrFGuard), let that guard own redirect handling
// after this allowlist validation step.
if (hasDispatcher) {
return res;
}
// Validate redirect target's resolved IP
if (resolveFn) {
const redirectHost = new URL(redirectUrl).hostname;
await resolveAndValidateIP(redirectHost, resolveFn);
}
currentUrl = redirectUrl;
}
throw new Error(`Too many redirects (>${MAX_SAFE_REDIRECTS})`);
}
export async function safeFetchWithPolicy(params: {
url: string;
policy: MSTeamsAttachmentFetchPolicy;
fetchFn?: typeof fetch;
requestInit?: RequestInit;
resolveFn?: (hostname: string) => Promise<{ address: string }>;
}): Promise<Response> {
return await safeFetch({
url: params.url,
allowHosts: params.policy.allowHosts,
authorizationAllowHosts: params.policy.authAllowHosts,
fetchFn: params.fetchFn,
requestInit: params.requestInit,
resolveFn: params.resolveFn,
});
}

View File

@@ -3,6 +3,7 @@ import {
classifyMSTeamsSendError,
formatMSTeamsSendErrorHint,
formatUnknownError,
isRevokedProxyError,
} from "./errors.js";
describe("msteams errors", () => {
@@ -42,4 +43,28 @@ describe("msteams errors", () => {
expect(formatMSTeamsSendErrorHint({ kind: "auth" })).toContain("msteams");
expect(formatMSTeamsSendErrorHint({ kind: "throttled" })).toContain("throttled");
});
describe("isRevokedProxyError", () => {
it("returns true for revoked proxy TypeError", () => {
expect(
isRevokedProxyError(new TypeError("Cannot perform 'set' on a proxy that has been revoked")),
).toBe(true);
expect(
isRevokedProxyError(new TypeError("Cannot perform 'get' on a proxy that has been revoked")),
).toBe(true);
});
it("returns false for non-TypeError errors", () => {
expect(isRevokedProxyError(new Error("proxy that has been revoked"))).toBe(false);
});
it("returns false for unrelated TypeErrors", () => {
expect(isRevokedProxyError(new TypeError("undefined is not a function"))).toBe(false);
});
it("returns false for non-error values", () => {
expect(isRevokedProxyError(null)).toBe(false);
expect(isRevokedProxyError("proxy that has been revoked")).toBe(false);
});
});
});

View File

@@ -174,6 +174,21 @@ export function classifyMSTeamsSendError(err: unknown): MSTeamsSendErrorClassifi
};
}
/**
* Detect whether an error is caused by a revoked Proxy.
*
* The Bot Framework SDK wraps TurnContext in a Proxy that is revoked once the
* turn handler returns. Any later access (e.g. from a debounced callback)
* throws a TypeError whose message contains the distinctive "proxy that has
* been revoked" string.
*/
export function isRevokedProxyError(err: unknown): boolean {
if (!(err instanceof TypeError)) {
return false;
}
return /proxy that has been revoked/i.test(err.message);
}
export function formatMSTeamsSendErrorHint(
classification: MSTeamsSendErrorClassification,
): string | undefined {

View File

@@ -291,6 +291,79 @@ describe("msteams messenger", () => {
).rejects.toMatchObject({ statusCode: 400 });
});
it("falls back to proactive messaging when thread context is revoked", async () => {
const proactiveSent: string[] = [];
const ctx = {
sendActivity: async () => {
throw new TypeError("Cannot perform 'set' on a proxy that has been revoked");
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async (_appId, _reference, logic) => {
await logic({
sendActivity: createRecordedSendActivity(proactiveSent),
});
},
process: async () => {},
};
const ids = await sendMSTeamsMessages({
replyStyle: "thread",
adapter,
appId: "app123",
conversationRef: baseRef,
context: ctx,
messages: [{ text: "hello" }],
});
// Should have fallen back to proactive messaging
expect(proactiveSent).toEqual(["hello"]);
expect(ids).toEqual(["id:hello"]);
});
it("falls back only for remaining thread messages after context revocation", async () => {
const threadSent: string[] = [];
const proactiveSent: string[] = [];
let attempt = 0;
const ctx = {
sendActivity: async (activity: unknown) => {
const { text } = activity as { text?: string };
const content = text ?? "";
attempt += 1;
if (attempt === 1) {
threadSent.push(content);
return { id: `id:${content}` };
}
throw new TypeError("Cannot perform 'set' on a proxy that has been revoked");
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async (_appId, _reference, logic) => {
await logic({
sendActivity: createRecordedSendActivity(proactiveSent),
});
},
process: async () => {},
};
const ids = await sendMSTeamsMessages({
replyStyle: "thread",
adapter,
appId: "app123",
conversationRef: baseRef,
context: ctx,
messages: [{ text: "one" }, { text: "two" }, { text: "three" }],
});
expect(threadSent).toEqual(["one"]);
expect(proactiveSent).toEqual(["two", "three"]);
expect(ids).toEqual(["id:one", "id:two", "id:three"]);
});
it("retries top-level sends on transient (5xx)", async () => {
const attempts: string[] = [];

View File

@@ -20,6 +20,7 @@ import {
} from "./graph-upload.js";
import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
import { parseMentions } from "./mentions.js";
import { withRevokedProxyFallback } from "./revoked-context.js";
import { getMSTeamsRuntime } from "./runtime.js";
/**
@@ -441,44 +442,83 @@ export async function sendMSTeamsMessages(params: {
}
};
const sendMessagesInContext = async (ctx: SendContext): Promise<string[]> => {
const messageIds: string[] = [];
for (const [idx, message] of messages.entries()) {
const response = await sendWithRetry(
async () =>
await ctx.sendActivity(
await buildActivity(
message,
params.conversationRef,
params.tokenProvider,
params.sharePointSiteId,
params.mediaMaxBytes,
),
const sendMessageInContext = async (
ctx: SendContext,
message: MSTeamsRenderedMessage,
messageIndex: number,
): Promise<string> => {
const response = await sendWithRetry(
async () =>
await ctx.sendActivity(
await buildActivity(
message,
params.conversationRef,
params.tokenProvider,
params.sharePointSiteId,
params.mediaMaxBytes,
),
{ messageIndex: idx, messageCount: messages.length },
);
messageIds.push(extractMessageId(response) ?? "unknown");
),
{ messageIndex, messageCount: messages.length },
);
return extractMessageId(response) ?? "unknown";
};
const sendMessageBatchInContext = async (
ctx: SendContext,
batch: MSTeamsRenderedMessage[],
startIndex: number,
): Promise<string[]> => {
const messageIds: string[] = [];
for (const [idx, message] of batch.entries()) {
messageIds.push(await sendMessageInContext(ctx, message, startIndex + idx));
}
return messageIds;
};
const sendProactively = async (
batch: MSTeamsRenderedMessage[],
startIndex: number,
): Promise<string[]> => {
const baseRef = buildConversationReference(params.conversationRef);
const proactiveRef: MSTeamsConversationReference = {
...baseRef,
activityId: undefined,
};
const messageIds: string[] = [];
await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => {
messageIds.push(...(await sendMessageBatchInContext(ctx, batch, startIndex)));
});
return messageIds;
};
if (params.replyStyle === "thread") {
const ctx = params.context;
if (!ctx) {
throw new Error("Missing context for replyStyle=thread");
}
return await sendMessagesInContext(ctx);
const messageIds: string[] = [];
for (const [idx, message] of messages.entries()) {
const result = await withRevokedProxyFallback({
run: async () => ({
ids: [await sendMessageInContext(ctx, message, idx)],
fellBack: false,
}),
onRevoked: async () => {
const remaining = messages.slice(idx);
return {
ids: remaining.length > 0 ? await sendProactively(remaining, idx) : [],
fellBack: true,
};
},
});
messageIds.push(...result.ids);
if (result.fellBack) {
return messageIds;
}
}
return messageIds;
}
const baseRef = buildConversationReference(params.conversationRef);
const proactiveRef: MSTeamsConversationReference = {
...baseRef,
activityId: undefined,
};
const messageIds: string[] = [];
await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => {
messageIds.push(...(await sendMessagesInContext(ctx)));
});
return messageIds;
return await sendProactively(messages, 0);
}

View File

@@ -155,10 +155,7 @@ describe("msteams file consent invoke authz", () => {
}),
);
// Wait for async upload to complete
await vi.waitFor(() => {
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
});
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledWith(
expect.objectContaining({
@@ -192,12 +189,9 @@ describe("msteams file consent invoke authz", () => {
}),
);
// Wait for async handler to complete
await vi.waitFor(() => {
expect(sendActivity).toHaveBeenCalledWith(
"The file upload request has expired. Please try sending the file again.",
);
});
expect(sendActivity).toHaveBeenCalledWith(
"The file upload request has expired. Please try sending the file again.",
);
expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
expect(getPendingUpload(uploadId)).toBeDefined();

View File

@@ -7,6 +7,7 @@ import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.j
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
import { getPendingUpload, removePendingUpload } from "./pending-uploads.js";
import type { MSTeamsPollStore } from "./polls.js";
import { withRevokedProxyFallback } from "./revoked-context.js";
import type { MSTeamsTurnContext } from "./sdk-types.js";
export type MSTeamsAccessTokenProvider = {
@@ -146,10 +147,19 @@ export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
// Send invoke response IMMEDIATELY to prevent Teams timeout
await ctx.sendActivity({ type: "invokeResponse", value: { status: 200 } });
// Handle file upload asynchronously (don't await)
handleFileConsentInvoke(ctx, deps.log).catch((err) => {
try {
await withRevokedProxyFallback({
run: async () => await handleFileConsentInvoke(ctx, deps.log),
onRevoked: async () => true,
onRevokedLog: () => {
deps.log.debug?.(
"turn context revoked during file consent invoke; skipping delayed response",
);
},
});
} catch (err) {
deps.log.debug?.("file consent handler error", { error: String(err) });
});
}
return;
}
return originalRun.call(handler, context);

View File

@@ -0,0 +1,208 @@
import { EventEmitter } from "node:events";
import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { MSTeamsConversationStore } from "./conversation-store.js";
import type { MSTeamsPollStore } from "./polls.js";
type FakeServer = EventEmitter & {
close: (callback?: (err?: Error | null) => void) => void;
setTimeout: (msecs: number) => FakeServer;
requestTimeout: number;
headersTimeout: number;
};
const expressControl = vi.hoisted(() => ({
mode: { value: "listening" as "listening" | "error" },
}));
vi.mock("openclaw/plugin-sdk", () => ({
DEFAULT_WEBHOOK_MAX_BODY_BYTES: 1024 * 1024,
keepHttpServerTaskAlive: vi.fn(
async (params: { abortSignal?: AbortSignal; onAbort?: () => Promise<void> | void }) => {
await new Promise<void>((resolve) => {
if (params.abortSignal?.aborted) {
resolve();
return;
}
params.abortSignal?.addEventListener("abort", () => resolve(), { once: true });
});
await params.onAbort?.();
},
),
mergeAllowlist: (params: { existing?: string[]; additions?: string[] }) =>
Array.from(new Set([...(params.existing ?? []), ...(params.additions ?? [])])),
summarizeMapping: vi.fn(),
}));
vi.mock("express", () => {
const json = vi.fn(() => {
return (_req: unknown, _res: unknown, next?: (err?: unknown) => void) => {
next?.();
};
});
const factory = () => ({
use: vi.fn(),
post: vi.fn(),
listen: vi.fn((_port: number) => {
const server = new EventEmitter() as FakeServer;
server.setTimeout = vi.fn((_msecs: number) => server);
server.requestTimeout = 0;
server.headersTimeout = 0;
server.close = (callback?: (err?: Error | null) => void) => {
queueMicrotask(() => {
server.emit("close");
callback?.(null);
});
};
queueMicrotask(() => {
if (expressControl.mode.value === "error") {
server.emit("error", new Error("listen EADDRINUSE"));
return;
}
server.emit("listening");
});
return server;
}),
});
return {
default: factory,
json,
};
});
const registerMSTeamsHandlers = vi.hoisted(() =>
vi.fn(() => ({
run: vi.fn(async () => {}),
})),
);
const createMSTeamsAdapter = vi.hoisted(() =>
vi.fn(() => ({
process: vi.fn(async () => {}),
})),
);
const loadMSTeamsSdkWithAuth = vi.hoisted(() =>
vi.fn(async () => ({
sdk: {
ActivityHandler: class {},
MsalTokenProvider: class {},
authorizeJWT:
() => (_req: unknown, _res: unknown, next: ((err?: unknown) => void) | undefined) =>
next?.(),
},
authConfig: {},
})),
);
vi.mock("./monitor-handler.js", () => ({
registerMSTeamsHandlers: () => registerMSTeamsHandlers(),
}));
vi.mock("./resolve-allowlist.js", () => ({
resolveMSTeamsChannelAllowlist: vi.fn(async () => []),
resolveMSTeamsUserAllowlist: vi.fn(async () => []),
}));
vi.mock("./sdk.js", () => ({
createMSTeamsAdapter: () => createMSTeamsAdapter(),
loadMSTeamsSdkWithAuth: () => loadMSTeamsSdkWithAuth(),
}));
vi.mock("./runtime.js", () => ({
getMSTeamsRuntime: () => ({
logging: {
getChildLogger: () => ({
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
},
channel: {
text: {
resolveTextChunkLimit: () => 4000,
},
},
}),
}));
import { monitorMSTeamsProvider } from "./monitor.js";
function createConfig(port: number): OpenClawConfig {
return {
channels: {
msteams: {
enabled: true,
appId: "app-id",
appPassword: "app-password",
tenantId: "tenant-id",
webhook: {
port,
path: "/api/messages",
},
},
},
} as OpenClawConfig;
}
function createRuntime(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
};
}
function createStores() {
return {
conversationStore: {} as MSTeamsConversationStore,
pollStore: {} as MSTeamsPollStore,
};
}
describe("monitorMSTeamsProvider lifecycle", () => {
afterEach(() => {
vi.clearAllMocks();
expressControl.mode.value = "listening";
});
it("stays active until aborted", async () => {
const abort = new AbortController();
const stores = createStores();
const task = monitorMSTeamsProvider({
cfg: createConfig(0),
runtime: createRuntime(),
abortSignal: abort.signal,
conversationStore: stores.conversationStore,
pollStore: stores.pollStore,
});
const early = await Promise.race([
task.then(() => "resolved"),
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 50)),
]);
expect(early).toBe("pending");
abort.abort();
await expect(task).resolves.toEqual(
expect.objectContaining({
shutdown: expect.any(Function),
}),
);
});
it("rejects startup when webhook port is already in use", async () => {
expressControl.mode.value = "error";
await expect(
monitorMSTeamsProvider({
cfg: createConfig(3978),
runtime: createRuntime(),
abortSignal: new AbortController().signal,
conversationStore: createStores().conversationStore,
pollStore: createStores().pollStore,
}),
).rejects.toThrow(/EADDRINUSE/);
});
});

View File

@@ -0,0 +1,85 @@
import { once } from "node:events";
import type { Server } from "node:http";
import { createConnection, type AddressInfo } from "node:net";
import express from "express";
import { describe, expect, it } from "vitest";
import { applyMSTeamsWebhookTimeouts } from "./monitor.js";
async function closeServer(server: Server): Promise<void> {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
async function waitForSlowBodySocketClose(port: number, timeoutMs: number): Promise<number> {
return new Promise<number>((resolve, reject) => {
const startedAt = Date.now();
const socket = createConnection({ host: "127.0.0.1", port }, () => {
socket.write("POST /api/messages HTTP/1.1\r\n");
socket.write("Host: localhost\r\n");
socket.write("Content-Type: application/json\r\n");
socket.write("Content-Length: 1048576\r\n");
socket.write("\r\n");
socket.write('{"type":"message"');
});
socket.on("error", () => {
// ECONNRESET is expected once the server drops the socket.
});
const failTimer = setTimeout(() => {
socket.destroy();
reject(new Error(`socket stayed open for ${timeoutMs}ms`));
}, timeoutMs);
socket.on("close", () => {
clearTimeout(failTimer);
resolve(Date.now() - startedAt);
});
});
}
describe("msteams monitor webhook hardening", () => {
it("applies explicit webhook timeout values", async () => {
const app = express();
const server = app.listen(0, "127.0.0.1");
await once(server, "listening");
try {
applyMSTeamsWebhookTimeouts(server, {
inactivityTimeoutMs: 3210,
requestTimeoutMs: 6543,
headersTimeoutMs: 9876,
});
expect(server.timeout).toBe(3210);
expect(server.requestTimeout).toBe(6543);
expect(server.headersTimeout).toBe(6543);
} finally {
await closeServer(server);
}
});
it("drops slow-body webhook requests within configured inactivity timeout", async () => {
const app = express();
app.use(express.json({ limit: "1mb" }));
app.use((_req, res, _next) => {
res.status(401).end("unauthorized");
});
app.post("/api/messages", (_req, res) => {
res.end("ok");
});
const server = app.listen(0, "127.0.0.1");
await once(server, "listening");
try {
applyMSTeamsWebhookTimeouts(server, {
inactivityTimeoutMs: 400,
requestTimeoutMs: 1500,
headersTimeoutMs: 1500,
});
const port = (server.address() as AddressInfo).port;
const closedMs = await waitForSlowBodySocketClose(port, 3000);
expect(closedMs).toBeLessThan(2500);
} finally {
await closeServer(server);
}
});
});

View File

@@ -1,6 +1,8 @@
import type { Server } from "node:http";
import type { Request, Response } from "express";
import {
DEFAULT_WEBHOOK_MAX_BODY_BYTES,
keepHttpServerTaskAlive,
mergeAllowlist,
summarizeMapping,
type OpenClawConfig,
@@ -34,6 +36,31 @@ export type MonitorMSTeamsResult = {
};
const MSTEAMS_WEBHOOK_MAX_BODY_BYTES = DEFAULT_WEBHOOK_MAX_BODY_BYTES;
const MSTEAMS_WEBHOOK_INACTIVITY_TIMEOUT_MS = 30_000;
const MSTEAMS_WEBHOOK_REQUEST_TIMEOUT_MS = 30_000;
const MSTEAMS_WEBHOOK_HEADERS_TIMEOUT_MS = 15_000;
export type ApplyMSTeamsWebhookTimeoutsOpts = {
inactivityTimeoutMs?: number;
requestTimeoutMs?: number;
headersTimeoutMs?: number;
};
export function applyMSTeamsWebhookTimeouts(
httpServer: Server,
opts?: ApplyMSTeamsWebhookTimeoutsOpts,
): void {
const inactivityTimeoutMs = opts?.inactivityTimeoutMs ?? MSTEAMS_WEBHOOK_INACTIVITY_TIMEOUT_MS;
const requestTimeoutMs = opts?.requestTimeoutMs ?? MSTEAMS_WEBHOOK_REQUEST_TIMEOUT_MS;
const headersTimeoutMs = Math.min(
opts?.headersTimeoutMs ?? MSTEAMS_WEBHOOK_HEADERS_TIMEOUT_MS,
requestTimeoutMs,
);
httpServer.setTimeout(inactivityTimeoutMs);
httpServer.requestTimeout = requestTimeoutMs;
httpServer.headersTimeout = headersTimeoutMs;
}
export async function monitorMSTeamsProvider(
opts: MonitorMSTeamsOpts,
@@ -273,10 +300,23 @@ export async function monitorMSTeamsProvider(
fallback: "/api/messages",
});
// Start listening and capture the HTTP server handle
const httpServer = expressApp.listen(port, () => {
log.info(`msteams provider started on port ${port}`);
// Start listening and fail fast if bind/listen fails.
const httpServer = expressApp.listen(port);
await new Promise<void>((resolve, reject) => {
const onListening = () => {
httpServer.off("error", onError);
log.info(`msteams provider started on port ${port}`);
resolve();
};
const onError = (err: unknown) => {
httpServer.off("listening", onListening);
log.error("msteams server error", { error: String(err) });
reject(err);
};
httpServer.once("listening", onListening);
httpServer.once("error", onError);
});
applyMSTeamsWebhookTimeouts(httpServer);
httpServer.on("error", (err) => {
log.error("msteams server error", { error: String(err) });
@@ -294,12 +334,12 @@ export async function monitorMSTeamsProvider(
});
};
// Handle abort signal
if (opts.abortSignal) {
opts.abortSignal.addEventListener("abort", () => {
void shutdown();
});
}
// Keep this task alive until close so gateway runtime does not treat startup as exit.
await keepHttpServerTaskAlive({
server: httpServer,
abortSignal: opts.abortSignal,
onAbort: shutdown,
});
return { app: expressApp, shutdown };
}

View File

@@ -15,11 +15,13 @@ import {
formatUnknownError,
} from "./errors.js";
import {
buildConversationReference,
type MSTeamsAdapter,
renderReplyPayloadsToMessages,
sendMSTeamsMessages,
} from "./messenger.js";
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
import { withRevokedProxyFallback } from "./revoked-context.js";
import { getMSTeamsRuntime } from "./runtime.js";
import type { MSTeamsTurnContext } from "./sdk-types.js";
@@ -42,9 +44,35 @@ export function createMSTeamsReplyDispatcher(params: {
sharePointSiteId?: string;
}) {
const core = getMSTeamsRuntime();
/**
* Send a typing indicator.
*
* First tries the live turn context (cheapest path). When the context has
* been revoked (debounced messages) we fall back to proactive messaging via
* the stored conversation reference so the user still sees the "…" bubble.
*/
const sendTypingIndicator = async () => {
await params.context.sendActivity({ type: "typing" });
await withRevokedProxyFallback({
run: async () => {
await params.context.sendActivity({ type: "typing" });
},
onRevoked: async () => {
const baseRef = buildConversationReference(params.conversationRef);
await params.adapter.continueConversation(
params.appId,
{ ...baseRef, activityId: undefined },
async (ctx) => {
await ctx.sendActivity({ type: "typing" });
},
);
},
onRevokedLog: () => {
params.log.debug?.("turn context revoked, sending typing via proactive messaging");
},
});
};
const typingCallbacks = createTypingCallbacks({
start: sendTypingIndicator,
onStartError: (err) => {

View File

@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from "vitest";
import { withRevokedProxyFallback } from "./revoked-context.js";
describe("msteams revoked context helper", () => {
it("returns primary result when no error occurs", async () => {
await expect(
withRevokedProxyFallback({
run: async () => "ok",
onRevoked: async () => "fallback",
}),
).resolves.toBe("ok");
});
it("uses fallback when proxy-revoked TypeError is thrown", async () => {
const onRevokedLog = vi.fn();
await expect(
withRevokedProxyFallback({
run: async () => {
throw new TypeError("Cannot perform 'get' on a proxy that has been revoked");
},
onRevoked: async () => "fallback",
onRevokedLog,
}),
).resolves.toBe("fallback");
expect(onRevokedLog).toHaveBeenCalledOnce();
});
it("rethrows non-revoked errors", async () => {
const err = Object.assign(new Error("boom"), { statusCode: 500 });
await expect(
withRevokedProxyFallback({
run: async () => {
throw err;
},
onRevoked: async () => "fallback",
}),
).rejects.toBe(err);
});
});

View File

@@ -0,0 +1,17 @@
import { isRevokedProxyError } from "./errors.js";
export async function withRevokedProxyFallback<T>(params: {
run: () => Promise<T>;
onRevoked: () => Promise<T>;
onRevokedLog?: () => void;
}): Promise<T> {
try {
return await params.run();
} catch (err) {
if (!isRevokedProxyError(err)) {
throw err;
}
params.onRevokedLog?.();
return await params.onRevoked();
}
}

View File

@@ -11,17 +11,21 @@ type RegisteredRoute = {
const registerPluginHttpRouteMock = vi.fn<(params: RegisteredRoute) => () => void>(() => vi.fn());
const dispatchReplyWithBufferedBlockDispatcher = vi.fn().mockResolvedValue({ counts: {} });
vi.mock("openclaw/plugin-sdk", () => ({
DEFAULT_ACCOUNT_ID: "default",
setAccountEnabledInConfigSection: vi.fn((_opts: any) => ({})),
registerPluginHttpRoute: registerPluginHttpRouteMock,
buildChannelConfigSchema: vi.fn((schema: any) => ({ schema })),
createFixedWindowRateLimiter: vi.fn(() => ({
isRateLimited: vi.fn(() => false),
size: vi.fn(() => 0),
clear: vi.fn(),
})),
}));
vi.mock("openclaw/plugin-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk")>();
return {
...actual,
DEFAULT_ACCOUNT_ID: "default",
setAccountEnabledInConfigSection: vi.fn((_opts: any) => ({})),
registerPluginHttpRoute: registerPluginHttpRouteMock,
buildChannelConfigSchema: vi.fn((schema: any) => ({ schema })),
createFixedWindowRateLimiter: vi.fn(() => ({
isRateLimited: vi.fn(() => false),
size: vi.fn(() => 0),
clear: vi.fn(),
})),
};
});
vi.mock("./runtime.js", () => ({
getSynologyRuntime: vi.fn(() => ({
@@ -40,7 +44,6 @@ vi.mock("./client.js", () => ({
}));
const { createSynologyChatPlugin } = await import("./channel.js");
describe("Synology channel wiring integration", () => {
beforeEach(() => {
registerPluginHttpRouteMock.mockClear();
@@ -49,6 +52,7 @@ describe("Synology channel wiring integration", () => {
it("registers real webhook handler with resolved account config and enforces allowlist", async () => {
const plugin = createSynologyChatPlugin();
const abortController = new AbortController();
const ctx = {
cfg: {
channels: {
@@ -69,9 +73,10 @@ describe("Synology channel wiring integration", () => {
},
accountId: "alerts",
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
abortSignal: abortController.signal,
};
const started = await plugin.gateway.startAccount(ctx);
const started = plugin.gateway.startAccount(ctx);
expect(registerPluginHttpRouteMock).toHaveBeenCalledTimes(1);
const firstCall = registerPluginHttpRouteMock.mock.calls[0];
@@ -97,7 +102,7 @@ describe("Synology channel wiring integration", () => {
expect(res._status).toBe(403);
expect(res._body).toContain("not authorized");
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
started.stop();
abortController.abort();
await started;
});
});

View File

@@ -268,18 +268,10 @@ describe("createSynologyChatPlugin", () => {
const plugin = createSynologyChatPlugin();
await expect(
plugin.outbound.sendText({
account: {
accountId: "default",
enabled: true,
token: "t",
incomingUrl: "",
nasHost: "h",
webhookPath: "/w",
dmPolicy: "open",
allowedUserIds: [],
rateLimitPerMinute: 30,
botName: "Bot",
allowInsecureSsl: true,
cfg: {
channels: {
"synology-chat": { enabled: true, token: "t", incomingUrl: "" },
},
},
text: "hello",
to: "user1",
@@ -290,18 +282,15 @@ describe("createSynologyChatPlugin", () => {
it("sendText returns OutboundDeliveryResult on success", async () => {
const plugin = createSynologyChatPlugin();
const result = await plugin.outbound.sendText({
account: {
accountId: "default",
enabled: true,
token: "t",
incomingUrl: "https://nas/incoming",
nasHost: "h",
webhookPath: "/w",
dmPolicy: "open",
allowedUserIds: [],
rateLimitPerMinute: 30,
botName: "Bot",
allowInsecureSsl: true,
cfg: {
channels: {
"synology-chat": {
enabled: true,
token: "t",
incomingUrl: "https://nas/incoming",
allowInsecureSsl: true,
},
},
},
text: "hello",
to: "user1",
@@ -315,18 +304,10 @@ describe("createSynologyChatPlugin", () => {
const plugin = createSynologyChatPlugin();
await expect(
plugin.outbound.sendMedia({
account: {
accountId: "default",
enabled: true,
token: "t",
incomingUrl: "",
nasHost: "h",
webhookPath: "/w",
dmPolicy: "open",
allowedUserIds: [],
rateLimitPerMinute: 30,
botName: "Bot",
allowInsecureSsl: true,
cfg: {
channels: {
"synology-chat": { enabled: true, token: "t", incomingUrl: "" },
},
},
mediaUrl: "https://example.com/img.png",
to: "user1",
@@ -336,35 +317,56 @@ describe("createSynologyChatPlugin", () => {
});
describe("gateway", () => {
it("startAccount returns stop function for disabled account", async () => {
it("startAccount returns pending promise for disabled account", async () => {
const plugin = createSynologyChatPlugin();
const abortController = new AbortController();
const ctx = {
cfg: {
channels: { "synology-chat": { enabled: false } },
},
accountId: "default",
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
abortSignal: abortController.signal,
};
const result = await plugin.gateway.startAccount(ctx);
expect(typeof result.stop).toBe("function");
const result = plugin.gateway.startAccount(ctx);
expect(result).toBeInstanceOf(Promise);
// Promise should stay pending (never resolve) to prevent restart loop
const resolved = await Promise.race([
result,
new Promise((r) => setTimeout(() => r("pending"), 50)),
]);
expect(resolved).toBe("pending");
abortController.abort();
await result;
});
it("startAccount returns stop function for account without token", async () => {
it("startAccount returns pending promise for account without token", async () => {
const plugin = createSynologyChatPlugin();
const abortController = new AbortController();
const ctx = {
cfg: {
channels: { "synology-chat": { enabled: true } },
},
accountId: "default",
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
abortSignal: abortController.signal,
};
const result = await plugin.gateway.startAccount(ctx);
expect(typeof result.stop).toBe("function");
const result = plugin.gateway.startAccount(ctx);
expect(result).toBeInstanceOf(Promise);
// Promise should stay pending (never resolve) to prevent restart loop
const resolved = await Promise.race([
result,
new Promise((r) => setTimeout(() => r("pending"), 50)),
]);
expect(resolved).toBe("pending");
abortController.abort();
await result;
});
it("startAccount refuses allowlist accounts with empty allowedUserIds", async () => {
const registerMock = vi.mocked(registerPluginHttpRoute);
registerMock.mockClear();
const abortController = new AbortController();
const plugin = createSynologyChatPlugin();
const ctx = {
@@ -381,12 +383,20 @@ describe("createSynologyChatPlugin", () => {
},
accountId: "default",
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
abortSignal: abortController.signal,
};
const result = await plugin.gateway.startAccount(ctx);
expect(typeof result.stop).toBe("function");
const result = plugin.gateway.startAccount(ctx);
expect(result).toBeInstanceOf(Promise);
const resolved = await Promise.race([
result,
new Promise((r) => setTimeout(() => r("pending"), 50)),
]);
expect(resolved).toBe("pending");
expect(ctx.log.warn).toHaveBeenCalledWith(expect.stringContaining("empty allowedUserIds"));
expect(registerMock).not.toHaveBeenCalled();
abortController.abort();
await result;
});
it("deregisters stale route before re-registering same account/path", async () => {
@@ -396,7 +406,9 @@ describe("createSynologyChatPlugin", () => {
registerMock.mockReturnValueOnce(unregisterFirst).mockReturnValueOnce(unregisterSecond);
const plugin = createSynologyChatPlugin();
const ctx = {
const abortFirst = new AbortController();
const abortSecond = new AbortController();
const makeCtx = (abortCtrl: AbortController) => ({
cfg: {
channels: {
"synology-chat": {
@@ -411,18 +423,25 @@ describe("createSynologyChatPlugin", () => {
},
accountId: "default",
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
};
abortSignal: abortCtrl.signal,
});
const first = await plugin.gateway.startAccount(ctx);
const second = await plugin.gateway.startAccount(ctx);
// Start first account (returns a pending promise)
const firstPromise = plugin.gateway.startAccount(makeCtx(abortFirst));
// Start second account on same path — should deregister the first route
const secondPromise = plugin.gateway.startAccount(makeCtx(abortSecond));
// Give microtasks time to settle
await new Promise((r) => setTimeout(r, 10));
expect(registerMock).toHaveBeenCalledTimes(2);
expect(unregisterFirst).toHaveBeenCalledTimes(1);
expect(unregisterSecond).not.toHaveBeenCalled();
// Clean up active route map so this module-level state doesn't leak across tests.
first.stop();
second.stop();
// Clean up: abort both to resolve promises and prevent test leak
abortFirst.abort();
abortSecond.abort();
await Promise.allSettled([firstPromise, secondPromise]);
});
});
});

View File

@@ -22,6 +22,23 @@ const SynologyChatConfigSchema = buildChannelConfigSchema(z.object({}).passthrou
const activeRouteUnregisters = new Map<string, () => void>();
function waitUntilAbort(signal?: AbortSignal, onAbort?: () => void): Promise<void> {
return new Promise((resolve) => {
const complete = () => {
onAbort?.();
resolve();
};
if (!signal) {
return;
}
if (signal.aborted) {
complete();
return;
}
signal.addEventListener("abort", complete, { once: true });
});
}
export function createSynologyChatPlugin() {
return {
id: CHANNEL_ID,
@@ -178,8 +195,8 @@ export function createSynologyChatPlugin() {
deliveryMode: "gateway" as const,
textChunkLimit: 2000,
sendText: async ({ to, text, accountId, account: ctxAccount }: any) => {
const account: ResolvedSynologyChatAccount = ctxAccount ?? resolveAccount({}, accountId);
sendText: async ({ to, text, accountId, cfg }: any) => {
const account: ResolvedSynologyChatAccount = resolveAccount(cfg ?? {}, accountId);
if (!account.incomingUrl) {
throw new Error("Synology Chat incoming URL not configured");
@@ -192,8 +209,8 @@ export function createSynologyChatPlugin() {
return { channel: CHANNEL_ID, messageId: `sc-${Date.now()}`, chatId: to };
},
sendMedia: async ({ to, mediaUrl, accountId, account: ctxAccount }: any) => {
const account: ResolvedSynologyChatAccount = ctxAccount ?? resolveAccount({}, accountId);
sendMedia: async ({ to, mediaUrl, accountId, cfg }: any) => {
const account: ResolvedSynologyChatAccount = resolveAccount(cfg ?? {}, accountId);
if (!account.incomingUrl) {
throw new Error("Synology Chat incoming URL not configured");
@@ -217,20 +234,20 @@ export function createSynologyChatPlugin() {
if (!account.enabled) {
log?.info?.(`Synology Chat account ${accountId} is disabled, skipping`);
return { stop: () => {} };
return waitUntilAbort(ctx.abortSignal);
}
if (!account.token || !account.incomingUrl) {
log?.warn?.(
`Synology Chat account ${accountId} not fully configured (missing token or incomingUrl)`,
);
return { stop: () => {} };
return waitUntilAbort(ctx.abortSignal);
}
if (account.dmPolicy === "allowlist" && account.allowedUserIds.length === 0) {
log?.warn?.(
`Synology Chat account ${accountId} has dmPolicy=allowlist but empty allowedUserIds; refusing to start route`,
);
return { stop: () => {} };
return waitUntilAbort(ctx.abortSignal);
}
log?.info?.(
@@ -243,18 +260,30 @@ export function createSynologyChatPlugin() {
const rt = getSynologyRuntime();
const currentCfg = await rt.config.loadConfig();
// Build MsgContext (same format as LINE/Signal/etc.)
const msgCtx = {
// The Chat API user_id (for sending) may differ from the webhook
// user_id (used for sessions/pairing). Use chatUserId for API calls.
const sendUserId = msg.chatUserId ?? msg.from;
// Build MsgContext using SDK's finalizeInboundContext for proper normalization
const msgCtx = rt.channel.reply.finalizeInboundContext({
Body: msg.body,
From: msg.from,
To: account.botName,
RawBody: msg.body,
CommandBody: msg.body,
From: `synology-chat:${msg.from}`,
To: `synology-chat:${msg.from}`,
SessionKey: msg.sessionKey,
AccountId: account.accountId,
OriginatingChannel: CHANNEL_ID as any,
OriginatingTo: msg.from,
OriginatingChannel: CHANNEL_ID,
OriginatingTo: `synology-chat:${msg.from}`,
ChatType: msg.chatType,
SenderName: msg.senderName,
};
SenderId: msg.from,
Provider: CHANNEL_ID,
Surface: CHANNEL_ID,
ConversationLabel: msg.senderName || msg.from,
Timestamp: Date.now(),
CommandAuthorized: true,
});
// Dispatch via the SDK's buffered block dispatcher
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
@@ -267,7 +296,7 @@ export function createSynologyChatPlugin() {
await sendMessage(
account.incomingUrl,
text,
msg.from,
sendUserId,
account.allowInsecureSsl,
);
}
@@ -306,13 +335,14 @@ export function createSynologyChatPlugin() {
log?.info?.(`Registered HTTP route: ${account.webhookPath} for Synology Chat`);
return {
stop: () => {
log?.info?.(`Stopping Synology Chat channel (account: ${accountId})`);
if (typeof unregister === "function") unregister();
activeRouteUnregisters.delete(routeKey);
},
};
// Keep alive until abort signal fires.
// The gateway expects a Promise that stays pending while the channel is running.
// Resolving immediately triggers a restart loop.
return waitUntilAbort(ctx.abortSignal, () => {
log?.info?.(`Stopping Synology Chat channel (account: ${accountId})`);
if (typeof unregister === "function") unregister();
activeRouteUnregisters.delete(routeKey);
});
},
stopAccount: async (ctx: any) => {

View File

@@ -4,16 +4,18 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock http and https modules before importing the client
vi.mock("node:https", () => {
const mockRequest = vi.fn();
return { default: { request: mockRequest }, request: mockRequest };
const mockGet = vi.fn();
return { default: { request: mockRequest, get: mockGet }, request: mockRequest, get: mockGet };
});
vi.mock("node:http", () => {
const mockRequest = vi.fn();
return { default: { request: mockRequest }, request: mockRequest };
const mockGet = vi.fn();
return { default: { request: mockRequest, get: mockGet }, request: mockRequest, get: mockGet };
});
// Import after mocks are set up
const { sendMessage, sendFileUrl } = await import("./client.js");
const { sendMessage, sendFileUrl, fetchChatUsers, resolveChatUserId } = await import("./client.js");
const https = await import("node:https");
let fakeNowMs = 1_700_000_000_000;
@@ -111,3 +113,122 @@ describe("sendFileUrl", () => {
expect(result).toBe(false);
});
});
// Helper to mock the user_list API response for fetchChatUsers / resolveChatUserId
function mockUserListResponse(
users: Array<{ user_id: number; username: string; nickname: string }>,
) {
const httpsGet = vi.mocked((https as any).get);
httpsGet.mockImplementation((_url: any, _opts: any, callback: any) => {
const res = new EventEmitter() as any;
res.statusCode = 200;
process.nextTick(() => {
callback(res);
res.emit("data", Buffer.from(JSON.stringify({ success: true, data: { users } })));
res.emit("end");
});
const req = new EventEmitter() as any;
req.destroy = vi.fn();
return req;
});
}
function mockUserListResponseOnce(
users: Array<{ user_id: number; username: string; nickname: string }>,
) {
const httpsGet = vi.mocked((https as any).get);
httpsGet.mockImplementationOnce((_url: any, _opts: any, callback: any) => {
const res = new EventEmitter() as any;
res.statusCode = 200;
process.nextTick(() => {
callback(res);
res.emit("data", Buffer.from(JSON.stringify({ success: true, data: { users } })));
res.emit("end");
});
const req = new EventEmitter() as any;
req.destroy = vi.fn();
return req;
});
}
describe("resolveChatUserId", () => {
const baseUrl =
"https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=chatbot&version=2&token=%22test%22";
const baseUrl2 =
"https://nas2.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=chatbot&version=2&token=%22test-2%22";
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
// Advance time to invalidate any cached user list from previous tests
fakeNowMs += 10 * 60 * 1000;
vi.setSystemTime(fakeNowMs);
});
afterEach(() => {
vi.useRealTimers();
});
it("resolves user by nickname (webhook username = Chat nickname)", async () => {
mockUserListResponse([
{ user_id: 4, username: "jmn67", nickname: "jmn" },
{ user_id: 7, username: "she67", nickname: "sarah" },
]);
const result = await resolveChatUserId(baseUrl, "jmn");
expect(result).toBe(4);
});
it("resolves user by username when nickname does not match", async () => {
mockUserListResponse([
{ user_id: 4, username: "jmn67", nickname: "" },
{ user_id: 7, username: "she67", nickname: "sarah" },
]);
// Advance time to invalidate cache
fakeNowMs += 10 * 60 * 1000;
vi.setSystemTime(fakeNowMs);
const result = await resolveChatUserId(baseUrl, "jmn67");
expect(result).toBe(4);
});
it("is case-insensitive", async () => {
mockUserListResponse([{ user_id: 4, username: "JMN67", nickname: "JMN" }]);
fakeNowMs += 10 * 60 * 1000;
vi.setSystemTime(fakeNowMs);
const result = await resolveChatUserId(baseUrl, "jmn");
expect(result).toBe(4);
});
it("returns undefined when user is not found", async () => {
mockUserListResponse([{ user_id: 4, username: "jmn67", nickname: "jmn" }]);
fakeNowMs += 10 * 60 * 1000;
vi.setSystemTime(fakeNowMs);
const result = await resolveChatUserId(baseUrl, "unknown_user");
expect(result).toBeUndefined();
});
it("uses method=user_list instead of method=chatbot in the API URL", async () => {
mockUserListResponse([]);
fakeNowMs += 10 * 60 * 1000;
vi.setSystemTime(fakeNowMs);
await resolveChatUserId(baseUrl, "anyone");
const httpsGet = vi.mocked((https as any).get);
expect(httpsGet).toHaveBeenCalledWith(
expect.stringContaining("method=user_list"),
expect.any(Object),
expect.any(Function),
);
});
it("keeps user cache scoped per incoming URL", async () => {
mockUserListResponseOnce([{ user_id: 4, username: "jmn67", nickname: "jmn" }]);
mockUserListResponseOnce([{ user_id: 9, username: "jmn67", nickname: "jmn" }]);
const result1 = await resolveChatUserId(baseUrl, "jmn");
const result2 = await resolveChatUserId(baseUrl2, "jmn");
expect(result1).toBe(4);
expect(result2).toBe(9);
const httpsGet = vi.mocked((https as any).get);
expect(httpsGet).toHaveBeenCalledTimes(2);
});
});

View File

@@ -9,6 +9,28 @@ import * as https from "node:https";
const MIN_SEND_INTERVAL_MS = 500;
let lastSendTime = 0;
// --- Chat user_id resolution ---
// Synology Chat uses two different user_id spaces:
// - Outgoing webhook user_id: per-integration sequential ID (e.g. 1)
// - Chat API user_id: global internal ID (e.g. 4)
// The chatbot API (method=chatbot) requires the Chat API user_id in the
// user_ids array. We resolve via the user_list API and cache the result.
interface ChatUser {
user_id: number;
username: string;
nickname: string;
}
type ChatUserCacheEntry = {
users: ChatUser[];
cachedAt: number;
};
// Cache user lists per bot endpoint to avoid cross-account bleed.
const chatUserCache = new Map<string, ChatUserCacheEntry>();
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
/**
* Send a text message to Synology Chat via the incoming webhook.
*
@@ -92,6 +114,107 @@ export async function sendFileUrl(
}
}
/**
* Fetch the list of Chat users visible to this bot via the user_list API.
* Results are cached for CACHE_TTL_MS to avoid excessive API calls.
*
* The user_list endpoint uses the same base URL as the chatbot API but
* with method=user_list instead of method=chatbot.
*/
export async function fetchChatUsers(
incomingUrl: string,
allowInsecureSsl = true,
log?: { warn: (...args: unknown[]) => void },
): Promise<ChatUser[]> {
const now = Date.now();
const listUrl = incomingUrl.replace(/method=\w+/, "method=user_list");
const cached = chatUserCache.get(listUrl);
if (cached && now - cached.cachedAt < CACHE_TTL_MS) {
return cached.users;
}
return new Promise((resolve) => {
let parsedUrl: URL;
try {
parsedUrl = new URL(listUrl);
} catch {
log?.warn("fetchChatUsers: invalid user_list URL, using cached data");
resolve(cached?.users ?? []);
return;
}
const transport = parsedUrl.protocol === "https:" ? https : http;
transport
.get(listUrl, { rejectUnauthorized: !allowInsecureSsl } as any, (res) => {
let data = "";
res.on("data", (c: Buffer) => {
data += c.toString();
});
res.on("end", () => {
try {
const result = JSON.parse(data);
if (result.success && result.data?.users) {
const users = result.data.users.map((u: any) => ({
user_id: u.user_id,
username: u.username || "",
nickname: u.nickname || "",
}));
chatUserCache.set(listUrl, {
users,
cachedAt: now,
});
resolve(users);
} else {
log?.warn(
`fetchChatUsers: API returned success=${result.success}, using cached data`,
);
resolve(cached?.users ?? []);
}
} catch {
log?.warn("fetchChatUsers: failed to parse user_list response");
resolve(cached?.users ?? []);
}
});
})
.on("error", (err) => {
log?.warn(`fetchChatUsers: HTTP error — ${err instanceof Error ? err.message : err}`);
resolve(cached?.users ?? []);
});
});
}
/**
* Resolve a webhook username to the correct Chat API user_id.
*
* Synology Chat outgoing webhooks send a user_id that may NOT match the
* Chat-internal user_id needed by the chatbot API (method=chatbot).
* The webhook's "username" field corresponds to the Chat user's "nickname".
*
* @param incomingUrl - Bot incoming webhook URL (used to derive user_list URL)
* @param webhookUsername - The username from the outgoing webhook payload
* @param allowInsecureSsl - Skip TLS verification
* @returns The correct Chat user_id, or undefined if not found
*/
export async function resolveChatUserId(
incomingUrl: string,
webhookUsername: string,
allowInsecureSsl = true,
log?: { warn: (...args: unknown[]) => void },
): Promise<number | undefined> {
const users = await fetchChatUsers(incomingUrl, allowInsecureSsl, log);
const lower = webhookUsername.toLowerCase();
// Match by nickname first (webhook "username" field = Chat "nickname")
const byNickname = users.find((u) => u.nickname.toLowerCase() === lower);
if (byNickname) return byNickname.user_id;
// Then by username
const byUsername = users.find((u) => u.username.toLowerCase() === lower);
if (byUsername) return byUsername.user_id;
return undefined;
}
function doPost(url: string, body: string, allowInsecureSsl = true): Promise<boolean> {
return new Promise((resolve, reject) => {
let parsedUrl: URL;

View File

@@ -2,10 +2,22 @@ import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
export function makeReq(method: string, body: string): IncomingMessage {
const req = new EventEmitter() as IncomingMessage;
const req = new EventEmitter() as IncomingMessage & { destroyed: boolean };
req.method = method;
req.headers = {};
req.socket = { remoteAddress: "127.0.0.1" } as unknown as IncomingMessage["socket"];
req.destroyed = false;
req.destroy = ((_: Error | undefined) => {
if (req.destroyed) {
return req;
}
req.destroyed = true;
return req;
}) as IncomingMessage["destroy"];
process.nextTick(() => {
if (req.destroyed) {
return;
}
req.emit("data", Buffer.from(body));
req.emit("end");
});

View File

@@ -1,14 +1,16 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { makeFormBody, makeReq, makeRes } from "./test-http-utils.js";
import type { ResolvedSynologyChatAccount } from "./types.js";
import {
clearSynologyWebhookRateLimiterStateForTest,
createWebhookHandler,
} from "./webhook-handler.js";
// Mock sendMessage to prevent real HTTP calls
// Mock sendMessage and resolveChatUserId to prevent real HTTP calls
vi.mock("./client.js", () => ({
sendMessage: vi.fn().mockResolvedValue(true),
resolveChatUserId: vi.fn().mockResolvedValue(undefined),
}));
function makeAccount(
@@ -30,6 +32,76 @@ function makeAccount(
};
}
function makeReq(
method: string,
body: string,
opts: { headers?: Record<string, string>; url?: string } = {},
): IncomingMessage {
const req = new EventEmitter() as IncomingMessage & {
destroyed: boolean;
};
req.method = method;
req.headers = opts.headers ?? {};
req.url = opts.url ?? "/webhook/synology";
req.socket = { remoteAddress: "127.0.0.1" } as any;
req.destroyed = false;
req.destroy = ((_: Error | undefined) => {
if (req.destroyed) {
return req;
}
req.destroyed = true;
return req;
}) as IncomingMessage["destroy"];
// Simulate body delivery
process.nextTick(() => {
if (req.destroyed) {
return;
}
req.emit("data", Buffer.from(body));
req.emit("end");
});
return req;
}
function makeStalledReq(method: string): IncomingMessage {
const req = new EventEmitter() as IncomingMessage & {
destroyed: boolean;
};
req.method = method;
req.headers = {};
req.socket = { remoteAddress: "127.0.0.1" } as any;
req.destroyed = false;
req.destroy = ((_: Error | undefined) => {
if (req.destroyed) {
return req;
}
req.destroyed = true;
return req;
}) as IncomingMessage["destroy"];
return req;
}
function makeRes(): ServerResponse & { _status: number; _body: string } {
const res = {
_status: 0,
_body: "",
writeHead(statusCode: number, _headers?: Record<string, string>) {
res._status = statusCode;
},
end(body?: string) {
res._body = body ?? "";
},
} as any;
return res;
}
function makeFormBody(fields: Record<string, string>): string {
return Object.entries(fields)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
}
const validBody = makeFormBody({
token: "valid-token",
user_id: "123",
@@ -95,6 +167,29 @@ describe("createWebhookHandler", () => {
expect(res._status).toBe(400);
});
it("returns 408 when request body times out", async () => {
vi.useFakeTimers();
try {
const handler = createWebhookHandler({
account: makeAccount(),
deliver: vi.fn(),
log,
});
const req = makeStalledReq("POST");
const res = makeRes();
const run = handler(req, res);
await vi.advanceTimersByTimeAsync(30_000);
await run;
expect(res._status).toBe(408);
expect(res._body).toContain("timeout");
} finally {
vi.useRealTimers();
}
});
it("returns 401 for invalid token", async () => {
const handler = createWebhookHandler({
account: makeAccount(),
@@ -115,6 +210,85 @@ describe("createWebhookHandler", () => {
expect(res._status).toBe(401);
});
it("accepts application/json with alias fields", async () => {
const deliver = vi.fn().mockResolvedValue(null);
const handler = createWebhookHandler({
account: makeAccount({ accountId: "json-test-" + Date.now() }),
deliver,
log,
});
const req = makeReq(
"POST",
JSON.stringify({
token: "valid-token",
userId: "123",
name: "json-user",
message: "Hello from json",
}),
{ headers: { "content-type": "application/json" } },
);
const res = makeRes();
await handler(req, res);
expect(res._status).toBe(204);
expect(deliver).toHaveBeenCalledWith(
expect.objectContaining({
body: "Hello from json",
from: "123",
senderName: "json-user",
}),
);
});
it("accepts token from query when body token is absent", async () => {
const deliver = vi.fn().mockResolvedValue(null);
const handler = createWebhookHandler({
account: makeAccount({ accountId: "query-token-test-" + Date.now() }),
deliver,
log,
});
const req = makeReq(
"POST",
makeFormBody({ user_id: "123", username: "testuser", text: "hello" }),
{
headers: { "content-type": "application/x-www-form-urlencoded" },
url: "/webhook/synology?token=valid-token",
},
);
const res = makeRes();
await handler(req, res);
expect(res._status).toBe(204);
expect(deliver).toHaveBeenCalled();
});
it("accepts token from authorization header when body token is absent", async () => {
const deliver = vi.fn().mockResolvedValue(null);
const handler = createWebhookHandler({
account: makeAccount({ accountId: "header-token-test-" + Date.now() }),
deliver,
log,
});
const req = makeReq(
"POST",
makeFormBody({ user_id: "123", username: "testuser", text: "hello" }),
{
headers: {
"content-type": "application/x-www-form-urlencoded",
authorization: "Bearer valid-token",
},
},
);
const res = makeRes();
await handler(req, res);
expect(res._status).toBe(204);
expect(deliver).toHaveBeenCalled();
});
it("returns 403 for unauthorized user with allowlist policy", async () => {
await expectForbiddenByPolicy({
account: {
@@ -167,7 +341,7 @@ describe("createWebhookHandler", () => {
const req1 = makeReq("POST", validBody);
const res1 = makeRes();
await handler(req1, res1);
expect(res1._status).toBe(200);
expect(res1._status).toBe(204);
// Second request should be rate limited
const req2 = makeReq("POST", validBody);
@@ -196,12 +370,12 @@ describe("createWebhookHandler", () => {
const res = makeRes();
await handler(req, res);
expect(res._status).toBe(200);
expect(res._status).toBe(204);
// deliver should have been called with the stripped text
expect(deliver).toHaveBeenCalledWith(expect.objectContaining({ body: "Hello there" }));
});
it("responds 200 immediately and delivers async", async () => {
it("responds 204 immediately and delivers async", async () => {
const deliver = vi.fn().mockResolvedValue("Bot reply");
const handler = createWebhookHandler({
account: makeAccount({ accountId: "async-test-" + Date.now() }),
@@ -213,8 +387,8 @@ describe("createWebhookHandler", () => {
const res = makeRes();
await handler(req, res);
expect(res._status).toBe(200);
expect(res._body).toContain("Processing");
expect(res._status).toBe(204);
expect(res._body).toBe("");
expect(deliver).toHaveBeenCalledWith(
expect.objectContaining({
body: "Hello bot",

View File

@@ -1,11 +1,16 @@
/**
* Inbound webhook handler for Synology Chat outgoing webhooks.
* Parses form-urlencoded body, validates security, delivers to agent.
* Parses form-urlencoded/JSON body, validates security, delivers to agent.
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import * as querystring from "node:querystring";
import { sendMessage } from "./client.js";
import {
isRequestBodyLimitError,
readRequestBodyWithLimit,
requestBodyErrorToText,
} from "openclaw/plugin-sdk";
import { sendMessage, resolveChatUserId } from "./client.js";
import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js";
import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js";
@@ -34,56 +39,182 @@ export function getSynologyWebhookRateLimiterCountForTest(): number {
}
/** Read the full request body as a string. */
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let size = 0;
const maxSize = 1_048_576; // 1MB
req.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > maxSize) {
req.destroy();
reject(new Error("Request body too large"));
return;
}
chunks.push(chunk);
async function readBody(req: IncomingMessage): Promise<
| { ok: true; body: string }
| {
ok: false;
statusCode: number;
error: string;
}
> {
try {
const body = await readRequestBodyWithLimit(req, {
maxBytes: 1_048_576,
timeoutMs: 30_000,
});
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
req.on("error", reject);
});
return { ok: true, body };
} catch (err) {
if (isRequestBodyLimitError(err)) {
return {
ok: false,
statusCode: err.statusCode,
error: requestBodyErrorToText(err.code),
};
}
return {
ok: false,
statusCode: 400,
error: "Invalid request body",
};
}
}
/** Parse form-urlencoded body into SynologyWebhookPayload. */
function parsePayload(body: string): SynologyWebhookPayload | null {
const parsed = querystring.parse(body);
function firstNonEmptyString(value: unknown): string | undefined {
if (Array.isArray(value)) {
for (const item of value) {
const normalized = firstNonEmptyString(item);
if (normalized) return normalized;
}
return undefined;
}
if (value === null || value === undefined) return undefined;
const str = String(value).trim();
return str.length > 0 ? str : undefined;
}
const token = String(parsed.token ?? "");
const userId = String(parsed.user_id ?? "");
const username = String(parsed.username ?? "unknown");
const text = String(parsed.text ?? "");
function pickAlias(record: Record<string, unknown>, aliases: string[]): string | undefined {
for (const alias of aliases) {
const normalized = firstNonEmptyString(record[alias]);
if (normalized) return normalized;
}
return undefined;
}
function parseQueryParams(req: IncomingMessage): Record<string, unknown> {
try {
const url = new URL(req.url ?? "", "http://localhost");
const out: Record<string, unknown> = {};
for (const [key, value] of url.searchParams.entries()) {
out[key] = value;
}
return out;
} catch {
return {};
}
}
function parseFormBody(body: string): Record<string, unknown> {
return querystring.parse(body) as Record<string, unknown>;
}
function parseJsonBody(body: string): Record<string, unknown> {
if (!body.trim()) return {};
const parsed = JSON.parse(body);
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
throw new Error("Invalid JSON body");
}
return parsed as Record<string, unknown>;
}
function headerValue(header: string | string[] | undefined): string | undefined {
return firstNonEmptyString(header);
}
function extractTokenFromHeaders(req: IncomingMessage): string | undefined {
const explicit =
headerValue(req.headers["x-synology-token"]) ??
headerValue(req.headers["x-webhook-token"]) ??
headerValue(req.headers["x-openclaw-token"]);
if (explicit) return explicit;
const auth = headerValue(req.headers.authorization);
if (!auth) return undefined;
const bearerMatch = auth.match(/^Bearer\s+(.+)$/i);
if (bearerMatch?.[1]) return bearerMatch[1].trim();
return auth.trim();
}
/**
* Parse/normalize incoming webhook payload.
*
* Supports:
* - application/x-www-form-urlencoded
* - application/json
*
* Token resolution order: body.token -> query.token -> headers
* Field aliases:
* - user_id <- user_id | userId | user
* - text <- text | message | content
*/
function parsePayload(req: IncomingMessage, body: string): SynologyWebhookPayload | null {
const contentType = String(req.headers["content-type"] ?? "").toLowerCase();
let bodyFields: Record<string, unknown> = {};
if (contentType.includes("application/json")) {
bodyFields = parseJsonBody(body);
} else if (contentType.includes("application/x-www-form-urlencoded")) {
bodyFields = parseFormBody(body);
} else {
// Fallback for clients with missing/incorrect content-type.
// Try JSON first, then form-urlencoded.
try {
bodyFields = parseJsonBody(body);
} catch {
bodyFields = parseFormBody(body);
}
}
const queryFields = parseQueryParams(req);
const headerToken = extractTokenFromHeaders(req);
const token =
pickAlias(bodyFields, ["token"]) ?? pickAlias(queryFields, ["token"]) ?? headerToken;
const userId =
pickAlias(bodyFields, ["user_id", "userId", "user"]) ??
pickAlias(queryFields, ["user_id", "userId", "user"]);
const text =
pickAlias(bodyFields, ["text", "message", "content"]) ??
pickAlias(queryFields, ["text", "message", "content"]);
if (!token || !userId || !text) return null;
return {
token,
channel_id: parsed.channel_id ? String(parsed.channel_id) : undefined,
channel_name: parsed.channel_name ? String(parsed.channel_name) : undefined,
channel_id:
pickAlias(bodyFields, ["channel_id"]) ?? pickAlias(queryFields, ["channel_id"]) ?? undefined,
channel_name:
pickAlias(bodyFields, ["channel_name"]) ??
pickAlias(queryFields, ["channel_name"]) ??
undefined,
user_id: userId,
username,
post_id: parsed.post_id ? String(parsed.post_id) : undefined,
timestamp: parsed.timestamp ? String(parsed.timestamp) : undefined,
username:
pickAlias(bodyFields, ["username", "user_name", "name"]) ??
pickAlias(queryFields, ["username", "user_name", "name"]) ??
"unknown",
post_id: pickAlias(bodyFields, ["post_id"]) ?? pickAlias(queryFields, ["post_id"]) ?? undefined,
timestamp:
pickAlias(bodyFields, ["timestamp"]) ?? pickAlias(queryFields, ["timestamp"]) ?? undefined,
text,
trigger_word: parsed.trigger_word ? String(parsed.trigger_word) : undefined,
trigger_word:
pickAlias(bodyFields, ["trigger_word", "triggerWord"]) ??
pickAlias(queryFields, ["trigger_word", "triggerWord"]) ??
undefined,
};
}
/** Send a JSON response. */
function respond(res: ServerResponse, statusCode: number, body: Record<string, unknown>) {
function respondJson(res: ServerResponse, statusCode: number, body: Record<string, unknown>) {
res.writeHead(statusCode, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
}
/** Send a no-content ACK. */
function respondNoContent(res: ServerResponse) {
res.writeHead(204);
res.end();
}
export interface WebhookHandlerDeps {
account: ResolvedSynologyChatAccount;
deliver: (msg: {
@@ -94,6 +225,8 @@ export interface WebhookHandlerDeps {
chatType: string;
sessionKey: string;
accountId: string;
/** Chat API user_id for sending replies (may differ from webhook user_id) */
chatUserId?: string;
}) => Promise<string | null>;
log?: {
info: (...args: unknown[]) => void;
@@ -106,13 +239,13 @@ export interface WebhookHandlerDeps {
* Create an HTTP request handler for Synology Chat outgoing webhooks.
*
* This handler:
* 1. Parses form-urlencoded body
* 1. Parses form-urlencoded/JSON payload
* 2. Validates token (constant-time)
* 3. Checks user allowlist
* 4. Checks rate limit
* 5. Sanitizes input
* 6. Delivers to the agent via deliver()
* 7. Sends the agent response back to Synology Chat
* 6. Immediately ACKs request (204)
* 7. Delivers to the agent asynchronously and sends final reply via incomingUrl
*/
export function createWebhookHandler(deps: WebhookHandlerDeps) {
const { account, deliver, log } = deps;
@@ -121,31 +254,36 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
return async (req: IncomingMessage, res: ServerResponse) => {
// Only accept POST
if (req.method !== "POST") {
respond(res, 405, { error: "Method not allowed" });
respondJson(res, 405, { error: "Method not allowed" });
return;
}
// Parse body
let body: string;
try {
body = await readBody(req);
} catch (err) {
log?.error("Failed to read request body", err);
respond(res, 400, { error: "Invalid request body" });
const bodyResult = await readBody(req);
if (!bodyResult.ok) {
log?.error("Failed to read request body", bodyResult.error);
respondJson(res, bodyResult.statusCode, { error: bodyResult.error });
return;
}
// Parse payload
const payload = parsePayload(body);
let payload: SynologyWebhookPayload | null = null;
try {
payload = parsePayload(req, bodyResult.body);
} catch (err) {
log?.warn("Failed to parse webhook payload", err);
respondJson(res, 400, { error: "Invalid request body" });
return;
}
if (!payload) {
respond(res, 400, { error: "Missing required fields (token, user_id, text)" });
respondJson(res, 400, { error: "Missing required fields (token, user_id, text)" });
return;
}
// Token validation
if (!validateToken(payload.token, account.token)) {
log?.warn(`Invalid token from ${req.socket?.remoteAddress}`);
respond(res, 401, { error: "Invalid token" });
respondJson(res, 401, { error: "Invalid token" });
return;
}
@@ -153,25 +291,25 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
const auth = authorizeUserForDm(payload.user_id, account.dmPolicy, account.allowedUserIds);
if (!auth.allowed) {
if (auth.reason === "disabled") {
respond(res, 403, { error: "DMs are disabled" });
respondJson(res, 403, { error: "DMs are disabled" });
return;
}
if (auth.reason === "allowlist-empty") {
log?.warn("Synology Chat allowlist is empty while dmPolicy=allowlist; rejecting message");
respond(res, 403, {
respondJson(res, 403, {
error: "Allowlist is empty. Configure allowedUserIds or use dmPolicy=open.",
});
return;
}
log?.warn(`Unauthorized user: ${payload.user_id}`);
respond(res, 403, { error: "User not authorized" });
respondJson(res, 403, { error: "User not authorized" });
return;
}
// Rate limit
if (!rateLimiter.check(payload.user_id)) {
log?.warn(`Rate limit exceeded for user: ${payload.user_id}`);
respond(res, 429, { error: "Rate limit exceeded" });
respondJson(res, 429, { error: "Rate limit exceeded" });
return;
}
@@ -184,18 +322,39 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
}
if (!cleanText) {
respond(res, 200, { text: "" });
respondNoContent(res);
return;
}
const preview = cleanText.length > 100 ? `${cleanText.slice(0, 100)}...` : cleanText;
log?.info(`Message from ${payload.username} (${payload.user_id}): ${preview}`);
// Respond 200 immediately to avoid Synology Chat timeout
respond(res, 200, { text: "Processing..." });
// ACK immediately so Synology Chat won't remain in "Processing..."
respondNoContent(res);
// Default to webhook user_id; may be replaced with Chat API user_id below.
let replyUserId = payload.user_id;
// Deliver to agent asynchronously (with 120s timeout to match nginx proxy_read_timeout)
try {
// Resolve the Chat-internal user_id for sending replies.
// Synology Chat outgoing webhooks use a per-integration user_id that may
// differ from the global Chat API user_id required by method=chatbot.
// We resolve via the user_list API, matching by nickname/username.
const chatUserId = await resolveChatUserId(
account.incomingUrl,
payload.username,
account.allowInsecureSsl,
log,
);
if (chatUserId !== undefined) {
replyUserId = String(chatUserId);
} else {
log?.warn(
`Could not resolve Chat API user_id for "${payload.username}" — falling back to webhook user_id ${payload.user_id}. Reply delivery may fail.`,
);
}
const sessionKey = `synology-chat-${payload.user_id}`;
const deliverPromise = deliver({
body: cleanText,
@@ -205,6 +364,7 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
chatType: "direct",
sessionKey,
accountId: account.accountId,
chatUserId: replyUserId,
});
const timeoutPromise = new Promise<null>((_, reject) =>
@@ -213,11 +373,11 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
const reply = await Promise.race([deliverPromise, timeoutPromise]);
// Send reply back to Synology Chat
// Send reply back to Synology Chat using the resolved Chat user_id
if (reply) {
await sendMessage(account.incomingUrl, reply, payload.user_id, account.allowInsecureSsl);
await sendMessage(account.incomingUrl, reply, replyUserId, account.allowInsecureSsl);
const replyPreview = reply.length > 100 ? `${reply.slice(0, 100)}...` : reply;
log?.info(`Reply sent to ${payload.username} (${payload.user_id}): ${replyPreview}`);
log?.info(`Reply sent to ${payload.username} (${replyUserId}): ${replyPreview}`);
}
} catch (err) {
const errMsg = err instanceof Error ? `${err.message}\n${err.stack}` : String(err);
@@ -225,7 +385,7 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
await sendMessage(
account.incomingUrl,
"Sorry, an error occurred while processing your message.",
payload.user_id,
replyUserId,
account.allowInsecureSsl,
);
}

View File

@@ -182,4 +182,47 @@ describe("telegramPlugin duplicate token guard", () => {
);
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-1" });
});
it("ignores accounts with missing tokens during duplicate-token checks", async () => {
const cfg = createCfg();
cfg.channels!.telegram!.accounts!.ops = {} as never;
const alertsAccount = telegramPlugin.config.resolveAccount(cfg, "alerts");
expect(await telegramPlugin.config.isConfigured!(alertsAccount, cfg)).toBe(true);
});
it("does not crash startup when a resolved account token is undefined", async () => {
const monitorTelegramProvider = vi.fn(async () => undefined);
const probeTelegram = vi.fn(async () => ({ ok: false }));
const runtime = {
channel: {
telegram: {
monitorTelegramProvider,
probeTelegram,
},
},
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime;
setTelegramRuntime(runtime);
const cfg = createCfg();
const ctx = createStartAccountCtx({
cfg,
accountId: "ops",
runtime: createRuntimeEnv(),
});
ctx.account = {
...ctx.account,
token: undefined as unknown as string,
} as ResolvedTelegramAccount;
await expect(telegramPlugin.gateway!.startAccount!(ctx)).resolves.toBeUndefined();
expect(monitorTelegramProvider).toHaveBeenCalledWith(
expect.objectContaining({
token: "",
}),
);
});
});

View File

@@ -44,7 +44,7 @@ function findTelegramTokenOwnerAccountId(params: {
const tokenOwners = new Map<string, string>();
for (const id of listTelegramAccountIds(params.cfg)) {
const account = resolveTelegramAccount({ cfg: params.cfg, accountId: id });
const token = account.token.trim();
const token = (account.token ?? "").trim();
if (!token) {
continue;
}
@@ -465,7 +465,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
ctx.log?.error?.(`[${account.accountId}] ${reason}`);
throw new Error(reason);
}
const token = account.token.trim();
const token = (account.token ?? "").trim();
let telegramBotLabel = "";
try {
const probe = await getTelegramRuntime().channel.telegram.probeTelegram(

View File

@@ -134,6 +134,45 @@ describe("VoiceCallWebhookServer stale call reaper", () => {
});
});
describe("VoiceCallWebhookServer path matching", () => {
it("rejects lookalike webhook paths that only match by prefix", async () => {
const verifyWebhook = vi.fn(() => ({ ok: true, verifiedRequestKey: "verified:req:prefix" }));
const parseWebhookEvent = vi.fn(() => ({ events: [], statusCode: 200 }));
const strictProvider: VoiceCallProvider = {
...provider,
verifyWebhook,
parseWebhookEvent,
};
const { manager } = createManager([]);
const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } });
const server = new VoiceCallWebhookServer(config, manager, strictProvider);
try {
const baseUrl = await server.start();
const address = (
server as unknown as { server?: { address?: () => unknown } }
).server?.address?.();
const requestUrl = new URL(baseUrl);
if (address && typeof address === "object" && "port" in address && address.port) {
requestUrl.port = String(address.port);
}
requestUrl.pathname = "/voice/webhook-evil";
const response = await fetch(requestUrl.toString(), {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "CallSid=CA123&SpeechResult=hello",
});
expect(response.status).toBe(404);
expect(verifyWebhook).not.toHaveBeenCalled();
expect(parseWebhookEvent).not.toHaveBeenCalled();
} finally {
await server.stop();
}
});
});
describe("VoiceCallWebhookServer replay handling", () => {
it("acknowledges replayed webhook requests and skips event side effects", async () => {
const replayProvider: VoiceCallProvider = {

View File

@@ -255,6 +255,25 @@ export class VoiceCallWebhookServer {
}
}
private normalizeWebhookPathForMatch(pathname: string): string {
const trimmed = pathname.trim();
if (!trimmed) {
return "/";
}
const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
if (prefixed === "/") {
return prefixed;
}
return prefixed.endsWith("/") ? prefixed.slice(0, -1) : prefixed;
}
private isWebhookPathMatch(requestPath: string, configuredPath: string): boolean {
return (
this.normalizeWebhookPathForMatch(requestPath) ===
this.normalizeWebhookPathForMatch(configuredPath)
);
}
/**
* Handle incoming HTTP request.
*/
@@ -266,7 +285,7 @@ export class VoiceCallWebhookServer {
const url = new URL(req.url || "/", `http://${req.headers.host}`);
// Check path
if (!url.pathname.startsWith(webhookPath)) {
if (!this.isWebhookPathMatch(url.pathname, webhookPath)) {
res.statusCode = 404;
res.end("Not Found");
return;

View File

@@ -44,6 +44,10 @@
"types": "./dist/plugin-sdk/account-id.d.ts",
"default": "./dist/plugin-sdk/account-id.js"
},
"./plugin-sdk/keyed-async-queue": {
"types": "./dist/plugin-sdk/keyed-async-queue.d.ts",
"default": "./dist/plugin-sdk/keyed-async-queue.js"
},
"./cli-entry": "./openclaw.mjs"
},
"scripts": {
@@ -209,6 +213,7 @@
"qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5",
"sqlite-vec": "0.1.7-alpha.2",
"strip-ansi": "^7.2.0",
"tar": "7.5.9",
"tslog": "^4.10.2",
"undici": "^7.22.0",

3
pnpm-lock.yaml generated
View File

@@ -180,6 +180,9 @@ importers:
sqlite-vec:
specifier: 0.1.7-alpha.2
version: 0.1.7-alpha.2
strip-ansi:
specifier: ^7.2.0
version: 7.2.0
tar:
specifier: 7.5.9
version: 7.5.9

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* Verifies that critical plugin-sdk exports are present in the compiled dist output.
* Regression guard for #27569 where isDangerousNameMatchingEnabled was missing
* from the compiled output, breaking channel extension plugins at runtime.
*
* Run after `pnpm build` to catch missing exports before release.
*/
import { readFileSync, existsSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const distFile = resolve(__dirname, "..", "dist", "plugin-sdk", "index.js");
if (!existsSync(distFile)) {
console.error("ERROR: dist/plugin-sdk/index.js not found. Run `pnpm build` first.");
process.exit(1);
}
const content = readFileSync(distFile, "utf-8");
// Extract the final export statement from the compiled output.
// tsdown/rolldown emits a single `export { ... }` at the end of the file.
const exportMatch = content.match(/export\s*\{([^}]+)\}\s*;?\s*$/);
if (!exportMatch) {
console.error("ERROR: Could not find export statement in dist/plugin-sdk/index.js");
process.exit(1);
}
const exportedNames = exportMatch[1]
.split(",")
.map((s) => {
// Handle `foo as bar` aliases — the exported name is the `bar` part
const parts = s.trim().split(/\s+as\s+/);
return (parts[parts.length - 1] || "").trim();
})
.filter(Boolean);
const exportSet = new Set(exportedNames);
// Critical functions that channel extension plugins import from openclaw/plugin-sdk.
// If any of these are missing, plugins will fail at runtime with:
// TypeError: (0 , _pluginSdk.<name>) is not a function
const requiredExports = [
"isDangerousNameMatchingEnabled",
"createAccountListHelpers",
"buildAgentMediaPayload",
"createReplyPrefixOptions",
"createTypingCallbacks",
"logInboundDrop",
"logTypingFailure",
"buildPendingHistoryContextFromMap",
"clearHistoryEntriesIfEnabled",
"recordPendingHistoryEntryIfEnabled",
"resolveControlCommandGate",
"resolveDmGroupAccessWithLists",
"resolveAllowlistProviderRuntimeGroupPolicy",
"resolveDefaultGroupPolicy",
"resolveChannelMediaMaxBytes",
"warnMissingProviderGroupPolicyFallbackOnce",
"emptyPluginConfigSchema",
"normalizePluginHttpPath",
"registerPluginHttpRoute",
"DEFAULT_ACCOUNT_ID",
"DEFAULT_GROUP_HISTORY_LIMIT",
];
let missing = 0;
for (const name of requiredExports) {
if (!exportSet.has(name)) {
console.error(`MISSING EXPORT: ${name}`);
missing += 1;
}
}
if (missing > 0) {
console.error(`\nERROR: ${missing} required export(s) missing from dist/plugin-sdk/index.js.`);
console.error("This will break channel extension plugins at runtime.");
console.error("Check src/plugin-sdk/index.ts and rebuild.");
process.exit(1);
}
console.log(`OK: All ${requiredExports.length} required plugin-sdk exports verified.`);

View File

@@ -182,6 +182,12 @@ type LoadedState = {
};
type LabelTarget = "issue" | "pr";
type LabelItemBatch = {
batchIndex: number;
items: LabelItem[];
totalCount: number;
fetchedCount: number;
};
function parseArgs(argv: string[]): ScriptOptions {
let limit = Number.POSITIVE_INFINITY;
@@ -408,9 +414,22 @@ function fetchPullRequestPage(repo: RepoInfo, after: string | null): PullRequest
return pullRequests;
}
function* fetchOpenIssueBatches(limit: number): Generator<IssueBatch> {
function mapNodeToLabelItem(node: IssuePage["nodes"][number]): LabelItem {
return {
number: node.number,
title: node.title,
body: node.body ?? "",
labels: node.labels?.nodes ?? [],
};
}
function* fetchOpenLabelItemBatches(params: {
limit: number;
kindPlural: "issues" | "pull requests";
fetchPage: (repo: RepoInfo, after: string | null) => IssuePage | PullRequestPage;
}): Generator<LabelItemBatch> {
const repo = resolveRepo();
const results: Issue[] = [];
const results: LabelItem[] = [];
let page = 1;
let after: string | null = null;
let totalCount = 0;
@@ -419,33 +438,28 @@ function* fetchOpenIssueBatches(limit: number): Generator<IssueBatch> {
logStep(`Repository: ${repo.owner}/${repo.name}`);
while (fetchedCount < limit) {
const pageData = fetchIssuePage(repo, after);
while (fetchedCount < params.limit) {
const pageData = params.fetchPage(repo, after);
const nodes = pageData.nodes ?? [];
totalCount = pageData.totalCount ?? totalCount;
if (page === 1) {
logSuccess(`Found ${totalCount} open issues.`);
logSuccess(`Found ${totalCount} open ${params.kindPlural}.`);
}
logInfo(`Fetched page ${page} (${nodes.length} issues).`);
logInfo(`Fetched page ${page} (${nodes.length} ${params.kindPlural}).`);
for (const node of nodes) {
if (fetchedCount >= limit) {
if (fetchedCount >= params.limit) {
break;
}
results.push({
number: node.number,
title: node.title,
body: node.body ?? "",
labels: node.labels?.nodes ?? [],
});
results.push(mapNodeToLabelItem(node));
fetchedCount += 1;
if (results.length >= WORK_BATCH_SIZE) {
yield {
batchIndex,
issues: results.splice(0, results.length),
items: results.splice(0, results.length),
totalCount,
fetchedCount,
};
@@ -464,72 +478,39 @@ function* fetchOpenIssueBatches(limit: number): Generator<IssueBatch> {
if (results.length) {
yield {
batchIndex,
issues: results,
items: results,
totalCount,
fetchedCount,
};
}
}
function* fetchOpenPullRequestBatches(limit: number): Generator<PullRequestBatch> {
const repo = resolveRepo();
const results: PullRequest[] = [];
let page = 1;
let after: string | null = null;
let totalCount = 0;
let fetchedCount = 0;
let batchIndex = 1;
logStep(`Repository: ${repo.owner}/${repo.name}`);
while (fetchedCount < limit) {
const pageData = fetchPullRequestPage(repo, after);
const nodes = pageData.nodes ?? [];
totalCount = pageData.totalCount ?? totalCount;
if (page === 1) {
logSuccess(`Found ${totalCount} open pull requests.`);
}
logInfo(`Fetched page ${page} (${nodes.length} pull requests).`);
for (const node of nodes) {
if (fetchedCount >= limit) {
break;
}
results.push({
number: node.number,
title: node.title,
body: node.body ?? "",
labels: node.labels?.nodes ?? [],
});
fetchedCount += 1;
if (results.length >= WORK_BATCH_SIZE) {
yield {
batchIndex,
pullRequests: results.splice(0, results.length),
totalCount,
fetchedCount,
};
batchIndex += 1;
}
}
if (!pageData.pageInfo.hasNextPage) {
break;
}
after = pageData.pageInfo.endCursor ?? null;
page += 1;
}
if (results.length) {
function* fetchOpenIssueBatches(limit: number): Generator<IssueBatch> {
for (const batch of fetchOpenLabelItemBatches({
limit,
kindPlural: "issues",
fetchPage: fetchIssuePage,
})) {
yield {
batchIndex,
pullRequests: results,
totalCount,
fetchedCount,
batchIndex: batch.batchIndex,
issues: batch.items,
totalCount: batch.totalCount,
fetchedCount: batch.fetchedCount,
};
}
}
function* fetchOpenPullRequestBatches(limit: number): Generator<PullRequestBatch> {
for (const batch of fetchOpenLabelItemBatches({
limit,
kindPlural: "pull requests",
fetchPage: fetchPullRequestPage,
})) {
yield {
batchIndex: batch.batchIndex,
pullRequests: batch.items,
totalCount: batch.totalCount,
fetchedCount: batch.fetchedCount,
};
}
}

View File

@@ -169,9 +169,71 @@ function checkAppcastSparkleVersions() {
}
}
// Critical functions that channel extension plugins import from openclaw/plugin-sdk.
// If any are missing from the compiled output, plugins crash at runtime (#27569).
const requiredPluginSdkExports = [
"isDangerousNameMatchingEnabled",
"createAccountListHelpers",
"buildAgentMediaPayload",
"createReplyPrefixOptions",
"createTypingCallbacks",
"logInboundDrop",
"logTypingFailure",
"buildPendingHistoryContextFromMap",
"clearHistoryEntriesIfEnabled",
"recordPendingHistoryEntryIfEnabled",
"resolveControlCommandGate",
"resolveDmGroupAccessWithLists",
"resolveAllowlistProviderRuntimeGroupPolicy",
"resolveDefaultGroupPolicy",
"resolveChannelMediaMaxBytes",
"warnMissingProviderGroupPolicyFallbackOnce",
"emptyPluginConfigSchema",
"normalizePluginHttpPath",
"registerPluginHttpRoute",
"DEFAULT_ACCOUNT_ID",
"DEFAULT_GROUP_HISTORY_LIMIT",
];
function checkPluginSdkExports() {
const distPath = resolve("dist", "plugin-sdk", "index.js");
let content: string;
try {
content = readFileSync(distPath, "utf8");
} catch {
console.error("release-check: dist/plugin-sdk/index.js not found (build missing?).");
process.exit(1);
return;
}
const exportMatch = content.match(/export\s*\{([^}]+)\}\s*;?\s*$/);
if (!exportMatch) {
console.error("release-check: could not find export statement in dist/plugin-sdk/index.js.");
process.exit(1);
return;
}
const exportedNames = new Set(
exportMatch[1].split(",").map((s) => {
const parts = s.trim().split(/\s+as\s+/);
return (parts[parts.length - 1] || "").trim();
}),
);
const missingExports = requiredPluginSdkExports.filter((name) => !exportedNames.has(name));
if (missingExports.length > 0) {
console.error("release-check: missing critical plugin-sdk exports (#27569):");
for (const name of missingExports) {
console.error(` - ${name}`);
}
process.exit(1);
}
}
function main() {
checkPluginVersions();
checkAppcastSparkleVersions();
checkPluginSdkExports();
const results = runPackDry();
const files = results.flatMap((entry) => entry.files ?? []);

View File

@@ -1,6 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
dedupe_chrome_args() {
local -A seen_args=()
local -a unique_args=()
for arg in "${CHROME_ARGS[@]}"; do
if [[ -n "${seen_args["$arg"]:+x}" ]]; then
continue
fi
seen_args["$arg"]=1
unique_args+=("$arg")
done
CHROME_ARGS=("${unique_args[@]}")
}
export DISPLAY=:1
export HOME=/tmp/openclaw-home
export XDG_CONFIG_HOME="${HOME}/.config"
@@ -14,6 +29,9 @@ ENABLE_NOVNC="${OPENCLAW_BROWSER_ENABLE_NOVNC:-${CLAWDBOT_BROWSER_ENABLE_NOVNC:-
HEADLESS="${OPENCLAW_BROWSER_HEADLESS:-${CLAWDBOT_BROWSER_HEADLESS:-0}}"
ALLOW_NO_SANDBOX="${OPENCLAW_BROWSER_NO_SANDBOX:-${CLAWDBOT_BROWSER_NO_SANDBOX:-0}}"
NOVNC_PASSWORD="${OPENCLAW_BROWSER_NOVNC_PASSWORD:-${CLAWDBOT_BROWSER_NOVNC_PASSWORD:-}}"
DISABLE_GRAPHICS_FLAGS="${OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS:-1}"
DISABLE_EXTENSIONS="${OPENCLAW_BROWSER_DISABLE_EXTENSIONS:-1}"
RENDERER_PROCESS_LIMIT="${OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT:-2}"
mkdir -p "${HOME}" "${HOME}/.chrome" "${XDG_CONFIG_HOME}" "${XDG_CACHE_HOME}"
@@ -22,7 +40,6 @@ Xvfb :1 -screen 0 1280x800x24 -ac -nolisten tcp &
if [[ "${HEADLESS}" == "1" ]]; then
CHROME_ARGS=(
"--headless=new"
"--disable-gpu"
)
else
CHROME_ARGS=()
@@ -45,9 +62,30 @@ CHROME_ARGS+=(
"--disable-features=TranslateUI"
"--disable-breakpad"
"--disable-crash-reporter"
"--no-zygote"
"--metrics-recording-only"
)
DISABLE_GRAPHICS_FLAGS_LOWER="${DISABLE_GRAPHICS_FLAGS,,}"
if [[ "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "1" || "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "true" || "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "yes" || "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "on" ]]; then
CHROME_ARGS+=(
"--disable-3d-apis"
"--disable-gpu"
"--disable-software-rasterizer"
)
fi
DISABLE_EXTENSIONS_LOWER="${DISABLE_EXTENSIONS,,}"
if [[ "${DISABLE_EXTENSIONS_LOWER}" == "1" || "${DISABLE_EXTENSIONS_LOWER}" == "true" || "${DISABLE_EXTENSIONS_LOWER}" == "yes" || "${DISABLE_EXTENSIONS_LOWER}" == "on" ]]; then
CHROME_ARGS+=(
"--disable-extensions"
)
fi
if [[ "${RENDERER_PROCESS_LIMIT}" =~ ^[0-9]+$ && "${RENDERER_PROCESS_LIMIT}" -gt 0 ]]; then
CHROME_ARGS+=("--renderer-process-limit=${RENDERER_PROCESS_LIMIT}")
fi
if [[ "${ALLOW_NO_SANDBOX}" == "1" ]]; then
CHROME_ARGS+=(
"--no-sandbox"
@@ -55,6 +93,7 @@ if [[ "${ALLOW_NO_SANDBOX}" == "1" ]]; then
)
fi
dedupe_chrome_args
chromium "${CHROME_ARGS[@]}" about:blank &
for _ in $(seq 1 50); do

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
function usage(message) {
if (message) {

View File

@@ -1,8 +1,8 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import type { RequestPermissionRequest } from "@agentclientprotocol/sdk";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import {
resolveAcpClientSpawnEnv,
resolveAcpClientSpawnInvocation,
@@ -35,22 +35,11 @@ function makePermissionRequest(
};
}
const tempDirs: string[] = [];
async function createTempDir(): Promise<string> {
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-acp-client-test-"));
tempDirs.push(dir);
return dir;
}
const tempDirs = createTrackedTempDirs();
const createTempDir = () => tempDirs.make("openclaw-acp-client-test-");
afterEach(async () => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
continue;
}
await rm(dir, { recursive: true, force: true });
}
await tempDirs.cleanup();
});
describe("resolveAcpClientSpawnEnv", () => {

View File

@@ -1,9 +1,11 @@
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
export class SessionActorQueue {
private readonly tailBySession = new Map<string, Promise<void>>();
private readonly queue = new KeyedAsyncQueue();
private readonly pendingBySession = new Map<string, number>();
getTailMapForTesting(): Map<string, Promise<void>> {
return this.tailBySession;
return this.queue.getTailMapForTesting();
}
getTotalPendingCount(): number {
@@ -19,35 +21,18 @@ export class SessionActorQueue {
}
async run<T>(actorKey: string, op: () => Promise<T>): Promise<T> {
const previous = this.tailBySession.get(actorKey) ?? Promise.resolve();
this.pendingBySession.set(actorKey, (this.pendingBySession.get(actorKey) ?? 0) + 1);
let release: () => void = () => {};
const marker = new Promise<void>((resolve) => {
release = resolve;
return this.queue.enqueue(actorKey, op, {
onEnqueue: () => {
this.pendingBySession.set(actorKey, (this.pendingBySession.get(actorKey) ?? 0) + 1);
},
onSettle: () => {
const pending = (this.pendingBySession.get(actorKey) ?? 1) - 1;
if (pending <= 0) {
this.pendingBySession.delete(actorKey);
} else {
this.pendingBySession.set(actorKey, pending);
}
},
});
const queuedTail = previous
.catch(() => {
// Keep actor queue alive after an operation failure.
})
.then(() => marker);
this.tailBySession.set(actorKey, queuedTail);
await previous.catch(() => {
// Previous failures should not block newer commands.
});
try {
return await op();
} finally {
const pending = (this.pendingBySession.get(actorKey) ?? 1) - 1;
if (pending <= 0) {
this.pendingBySession.delete(actorKey);
} else {
this.pendingBySession.set(actorKey, pending);
}
release();
if (this.tailBySession.get(actorKey) === queuedTail) {
this.tailBySession.delete(actorKey);
}
}
}
}

View File

@@ -8,6 +8,7 @@ export type AcpRuntimeAdapterContractParams = {
agentId?: string;
successPrompt?: string;
errorPrompt?: string;
includeControlChecks?: boolean;
assertSuccessEvents?: (events: AcpRuntimeEvent[]) => void | Promise<void>;
assertErrorOutcome?: (params: {
events: AcpRuntimeEvent[];
@@ -51,23 +52,25 @@ export async function runAcpRuntimeAdapterContract(
).toBe(true);
await params.assertSuccessEvents?.(successEvents);
if (runtime.getStatus) {
const status = await runtime.getStatus({ handle });
expect(status).toBeDefined();
expect(typeof status).toBe("object");
}
if (runtime.setMode) {
await runtime.setMode({
handle,
mode: "contract",
});
}
if (runtime.setConfigOption) {
await runtime.setConfigOption({
handle,
key: "contract_key",
value: "contract_value",
});
if (params.includeControlChecks ?? true) {
if (runtime.getStatus) {
const status = await runtime.getStatus({ handle });
expect(status).toBeDefined();
expect(typeof status).toBe("object");
}
if (runtime.setMode) {
await runtime.setMode({
handle,
mode: "contract",
});
}
if (runtime.setConfigOption) {
await runtime.setConfigOption({
handle,
key: "contract_key",
value: "contract_value",
});
}
}
let errorThrown: unknown = null;

View File

@@ -150,17 +150,9 @@ export class AcpGatewayAgent implements Agent {
const sessionId = randomUUID();
const meta = parseSessionMeta(params._meta);
const sessionKey = await resolveSessionKey({
const sessionKey = await this.resolveSessionKeyFromMeta({
meta,
fallbackKey: `acp:${sessionId}`,
gateway: this.gateway,
opts: this.opts,
});
await resetSessionIfNeeded({
meta,
sessionKey,
gateway: this.gateway,
opts: this.opts,
});
const session = this.sessionStore.createSession({
@@ -182,17 +174,9 @@ export class AcpGatewayAgent implements Agent {
}
const meta = parseSessionMeta(params._meta);
const sessionKey = await resolveSessionKey({
const sessionKey = await this.resolveSessionKeyFromMeta({
meta,
fallbackKey: params.sessionId,
gateway: this.gateway,
opts: this.opts,
});
await resetSessionIfNeeded({
meta,
sessionKey,
gateway: this.gateway,
opts: this.opts,
});
const session = this.sessionStore.createSession({
@@ -328,6 +312,25 @@ export class AcpGatewayAgent implements Agent {
}
}
private async resolveSessionKeyFromMeta(params: {
meta: ReturnType<typeof parseSessionMeta>;
fallbackKey: string;
}): Promise<string> {
const sessionKey = await resolveSessionKey({
meta: params.meta,
fallbackKey: params.fallbackKey,
gateway: this.gateway,
opts: this.opts,
});
await resetSessionIfNeeded({
meta: params.meta,
sessionKey,
gateway: this.gateway,
opts: this.opts,
});
return sessionKey;
}
private async handleAgentEvent(evt: EventFrame): Promise<void> {
const payload = evt.payload as Record<string, unknown> | undefined;
if (!payload) {

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { resolveAuthProfileOrder } from "./order.js";
import type { AuthProfileStore } from "./types.js";
describe("resolveAuthProfileOrder", () => {
it("accepts base-provider credentials for volcengine-plan auth lookup", () => {
const store: AuthProfileStore = {
version: 1,
profiles: {
"volcengine:default": {
type: "api_key",
provider: "volcengine",
key: "sk-test",
},
},
};
const order = resolveAuthProfileOrder({
store,
provider: "volcengine-plan",
});
expect(order).toEqual(["volcengine:default"]);
});
});

View File

@@ -1,5 +1,9 @@
import type { OpenClawConfig } from "../../config/config.js";
import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js";
import {
findNormalizedProviderValue,
normalizeProviderId,
normalizeProviderIdForAuth,
} from "../model-selection.js";
import { dedupeProfileIds, listProfilesForProvider } from "./profiles.js";
import type { AuthProfileStore } from "./types.js";
import {
@@ -16,6 +20,7 @@ export function resolveAuthProfileOrder(params: {
}): string[] {
const { cfg, store, provider, preferredProfile } = params;
const providerKey = normalizeProviderId(provider);
const providerAuthKey = normalizeProviderIdForAuth(provider);
const now = Date.now();
// Clear any cooldowns that have expired since the last check so profiles
@@ -27,12 +32,12 @@ export function resolveAuthProfileOrder(params: {
const explicitOrder = storedOrder ?? configuredOrder;
const explicitProfiles = cfg?.auth?.profiles
? Object.entries(cfg.auth.profiles)
.filter(([, profile]) => normalizeProviderId(profile.provider) === providerKey)
.filter(([, profile]) => normalizeProviderIdForAuth(profile.provider) === providerAuthKey)
.map(([profileId]) => profileId)
: [];
const baseOrder =
explicitOrder ??
(explicitProfiles.length > 0 ? explicitProfiles : listProfilesForProvider(store, providerKey));
(explicitProfiles.length > 0 ? explicitProfiles : listProfilesForProvider(store, provider));
if (baseOrder.length === 0) {
return [];
}
@@ -42,12 +47,12 @@ export function resolveAuthProfileOrder(params: {
if (!cred) {
return false;
}
if (normalizeProviderId(cred.provider) !== providerKey) {
if (normalizeProviderIdForAuth(cred.provider) !== providerAuthKey) {
return false;
}
const profileConfig = cfg?.auth?.profiles?.[profileId];
if (profileConfig) {
if (normalizeProviderId(profileConfig.provider) !== providerKey) {
if (normalizeProviderIdForAuth(profileConfig.provider) !== providerAuthKey) {
return false;
}
if (profileConfig.mode !== cred.type) {
@@ -86,7 +91,7 @@ export function resolveAuthProfileOrder(params: {
// provider's stored credentials and use any valid entries.
const allBaseProfilesMissing = baseOrder.every((profileId) => !store.profiles[profileId]);
if (filtered.length === 0 && explicitProfiles.length > 0 && allBaseProfilesMissing) {
const storeProfiles = listProfilesForProvider(store, providerKey);
const storeProfiles = listProfilesForProvider(store, provider);
filtered = storeProfiles.filter(isValidProfile);
}

View File

@@ -1,5 +1,5 @@
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
import { normalizeProviderId } from "../model-selection.js";
import { normalizeProviderId, normalizeProviderIdForAuth } from "../model-selection.js";
import {
ensureAuthProfileStore,
saveAuthProfileStore,
@@ -79,9 +79,9 @@ export async function upsertAuthProfileWithLock(params: {
}
export function listProfilesForProvider(store: AuthProfileStore, provider: string): string[] {
const providerKey = normalizeProviderId(provider);
const providerKey = normalizeProviderIdForAuth(provider);
return Object.entries(store.profiles)
.filter(([, cred]) => normalizeProviderId(cred.provider) === providerKey)
.filter(([, cred]) => normalizeProviderIdForAuth(cred.provider) === providerKey)
.map(([id]) => id);
}

View File

@@ -241,16 +241,9 @@ export async function markAuthProfileUsed(params: {
if (!freshStore.profiles[profileId]) {
return false;
}
freshStore.usageStats = freshStore.usageStats ?? {};
freshStore.usageStats[profileId] = {
...freshStore.usageStats[profileId],
lastUsed: Date.now(),
errorCount: 0,
cooldownUntil: undefined,
disabledUntil: undefined,
disabledReason: undefined,
failureCounts: undefined,
};
updateUsageStatsEntry(freshStore, profileId, (existing) =>
resetUsageStats(existing, { lastUsed: Date.now() }),
);
return true;
},
});
@@ -262,16 +255,9 @@ export async function markAuthProfileUsed(params: {
return;
}
store.usageStats = store.usageStats ?? {};
store.usageStats[profileId] = {
...store.usageStats[profileId],
lastUsed: Date.now(),
errorCount: 0,
cooldownUntil: undefined,
disabledUntil: undefined,
disabledReason: undefined,
failureCounts: undefined,
};
updateUsageStatsEntry(store, profileId, (existing) =>
resetUsageStats(existing, { lastUsed: Date.now() }),
);
saveAuthProfileStore(store, agentDir);
}
@@ -360,6 +346,30 @@ export function resolveProfileUnusableUntilForDisplay(
return resolveProfileUnusableUntil(stats);
}
function resetUsageStats(
existing: ProfileUsageStats | undefined,
overrides?: Partial<ProfileUsageStats>,
): ProfileUsageStats {
return {
...existing,
errorCount: 0,
cooldownUntil: undefined,
disabledUntil: undefined,
disabledReason: undefined,
failureCounts: undefined,
...overrides,
};
}
function updateUsageStatsEntry(
store: AuthProfileStore,
profileId: string,
updater: (existing: ProfileUsageStats | undefined) => ProfileUsageStats,
): void {
store.usageStats = store.usageStats ?? {};
store.usageStats[profileId] = updater(store.usageStats[profileId]);
}
function keepActiveWindowOrRecompute(params: {
existingUntil: number | undefined;
now: number;
@@ -448,9 +458,6 @@ export async function markAuthProfileFailure(params: {
if (!profile || isAuthCooldownBypassedForProvider(profile.provider)) {
return false;
}
freshStore.usageStats = freshStore.usageStats ?? {};
const existing = freshStore.usageStats[profileId] ?? {};
const now = Date.now();
const providerKey = normalizeProviderId(profile.provider);
const cfgResolved = resolveAuthCooldownConfig({
@@ -458,12 +465,14 @@ export async function markAuthProfileFailure(params: {
providerId: providerKey,
});
freshStore.usageStats[profileId] = computeNextProfileUsageStats({
existing,
now,
reason,
cfgResolved,
});
updateUsageStatsEntry(freshStore, profileId, (existing) =>
computeNextProfileUsageStats({
existing: existing ?? {},
now,
reason,
cfgResolved,
}),
);
return true;
},
});
@@ -475,8 +484,6 @@ export async function markAuthProfileFailure(params: {
return;
}
store.usageStats = store.usageStats ?? {};
const existing = store.usageStats[profileId] ?? {};
const now = Date.now();
const providerKey = normalizeProviderId(store.profiles[profileId]?.provider ?? "");
const cfgResolved = resolveAuthCooldownConfig({
@@ -484,12 +491,14 @@ export async function markAuthProfileFailure(params: {
providerId: providerKey,
});
store.usageStats[profileId] = computeNextProfileUsageStats({
existing,
now,
reason,
cfgResolved,
});
updateUsageStatsEntry(store, profileId, (existing) =>
computeNextProfileUsageStats({
existing: existing ?? {},
now,
reason,
cfgResolved,
}),
);
saveAuthProfileStore(store, agentDir);
}
@@ -528,14 +537,7 @@ export async function clearAuthProfileCooldown(params: {
return false;
}
freshStore.usageStats[profileId] = {
...freshStore.usageStats[profileId],
errorCount: 0,
cooldownUntil: undefined,
disabledUntil: undefined,
disabledReason: undefined,
failureCounts: undefined,
};
updateUsageStatsEntry(freshStore, profileId, (existing) => resetUsageStats(existing));
return true;
},
});
@@ -547,13 +549,6 @@ export async function clearAuthProfileCooldown(params: {
return;
}
store.usageStats[profileId] = {
...store.usageStats[profileId],
errorCount: 0,
cooldownUntil: undefined,
disabledUntil: undefined,
disabledReason: undefined,
failureCounts: undefined,
};
updateUsageStatsEntry(store, profileId, (existing) => resetUsageStats(existing));
saveAuthProfileStore(store, agentDir);
}

View File

@@ -6,12 +6,9 @@ import {
type ExecSecurity,
buildEnforcedShellCommand,
evaluateShellAllowlist,
maxAsk,
minSecurity,
recordAllowlistUse,
requiresExecApproval,
resolveAllowAlwaysPatterns,
resolveExecApprovals,
} from "../infra/exec-approvals.js";
import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js";
@@ -19,10 +16,13 @@ import { logInfo } from "../logger.js";
import { markBackgrounded, tail } from "./bash-process-registry.js";
import {
buildExecApprovalRequesterContext,
resolveRegisteredExecApprovalDecision,
buildExecApprovalTurnSourceContext,
registerExecApprovalRequestForHostOrThrow,
} from "./bash-tools.exec-approval-request.js";
import {
resolveApprovalDecisionOrUndefined,
resolveExecHostApprovalContext,
} from "./bash-tools.exec-host-shared.js";
import {
DEFAULT_APPROVAL_TIMEOUT_MS,
DEFAULT_NOTIFY_TAIL_CHARS,
@@ -67,16 +67,12 @@ export type ProcessGatewayAllowlistResult = {
export async function processGatewayAllowlist(
params: ProcessGatewayAllowlistParams,
): Promise<ProcessGatewayAllowlistResult> {
const approvals = resolveExecApprovals(params.agentId, {
const { approvals, hostSecurity, hostAsk, askFallback } = resolveExecHostApprovalContext({
agentId: params.agentId,
security: params.security,
ask: params.ask,
host: "gateway",
});
const hostSecurity = minSecurity(params.security, approvals.agent.security);
const hostAsk = maxAsk(params.ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
if (hostSecurity === "deny") {
throw new Error("exec denied: host=gateway security=deny");
}
const allowlistEval = evaluateShellAllowlist({
command: params.command,
allowlist: approvals.allowlist,
@@ -172,20 +168,19 @@ export async function processGatewayAllowlist(
preResolvedDecision = registration.finalDecision;
void (async () => {
let decision: string | null = null;
try {
decision = await resolveRegisteredExecApprovalDecision({
approvalId,
preResolvedDecision,
});
} catch {
emitExecSystemEvent(
`Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
);
const decision = await resolveApprovalDecisionOrUndefined({
approvalId,
preResolvedDecision,
onFailure: () =>
emitExecSystemEvent(
`Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
),
});
if (decision === undefined) {
return;
}

View File

@@ -5,10 +5,7 @@ import {
type ExecAsk,
type ExecSecurity,
evaluateShellAllowlist,
maxAsk,
minSecurity,
requiresExecApproval,
resolveExecApprovals,
resolveExecApprovalsFromFile,
} from "../infra/exec-approvals.js";
import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
@@ -17,10 +14,13 @@ import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-cont
import { logInfo } from "../logger.js";
import {
buildExecApprovalRequesterContext,
resolveRegisteredExecApprovalDecision,
buildExecApprovalTurnSourceContext,
registerExecApprovalRequestForHostOrThrow,
} from "./bash-tools.exec-approval-request.js";
import {
resolveApprovalDecisionOrUndefined,
resolveExecHostApprovalContext,
} from "./bash-tools.exec-host-shared.js";
import {
DEFAULT_APPROVAL_TIMEOUT_MS,
createApprovalSlug,
@@ -56,16 +56,12 @@ export type ExecuteNodeHostCommandParams = {
export async function executeNodeHostCommand(
params: ExecuteNodeHostCommandParams,
): Promise<AgentToolResult<ExecToolDetails>> {
const approvals = resolveExecApprovals(params.agentId, {
const { hostSecurity, hostAsk, askFallback } = resolveExecHostApprovalContext({
agentId: params.agentId,
security: params.security,
ask: params.ask,
host: "node",
});
const hostSecurity = minSecurity(params.security, approvals.agent.security);
const hostAsk = maxAsk(params.ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
if (hostSecurity === "deny") {
throw new Error("exec denied: host=node security=deny");
}
if (params.boundNode && params.requestedNode && params.boundNode !== params.requestedNode) {
throw new Error(`exec node not allowed (bound to ${params.boundNode})`);
}
@@ -243,17 +239,16 @@ export async function executeNodeHostCommand(
preResolvedDecision = registration.finalDecision;
void (async () => {
let decision: string | null = null;
try {
decision = await resolveRegisteredExecApprovalDecision({
approvalId,
preResolvedDecision,
});
} catch {
emitExecSystemEvent(
`Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
{ sessionKey: params.notifySessionKey, contextKey },
);
const decision = await resolveApprovalDecisionOrUndefined({
approvalId,
preResolvedDecision,
onFailure: () =>
emitExecSystemEvent(
`Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
{ sessionKey: params.notifySessionKey, contextKey },
),
});
if (decision === undefined) {
return;
}

View File

@@ -0,0 +1,52 @@
import {
maxAsk,
minSecurity,
resolveExecApprovals,
type ExecAsk,
type ExecSecurity,
} from "../infra/exec-approvals.js";
import { resolveRegisteredExecApprovalDecision } from "./bash-tools.exec-approval-request.js";
type ResolvedExecApprovals = ReturnType<typeof resolveExecApprovals>;
export type ExecHostApprovalContext = {
approvals: ResolvedExecApprovals;
hostSecurity: ExecSecurity;
hostAsk: ExecAsk;
askFallback: ResolvedExecApprovals["agent"]["askFallback"];
};
export function resolveExecHostApprovalContext(params: {
agentId?: string;
security: ExecSecurity;
ask: ExecAsk;
host: "gateway" | "node";
}): ExecHostApprovalContext {
const approvals = resolveExecApprovals(params.agentId, {
security: params.security,
ask: params.ask,
});
const hostSecurity = minSecurity(params.security, approvals.agent.security);
const hostAsk = maxAsk(params.ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
if (hostSecurity === "deny") {
throw new Error(`exec denied: host=${params.host} security=deny`);
}
return { approvals, hostSecurity, hostAsk, askFallback };
}
export async function resolveApprovalDecisionOrUndefined(params: {
approvalId: string;
preResolvedDecision: string | null | undefined;
onFailure: () => void;
}): Promise<string | null | undefined> {
try {
return await resolveRegisteredExecApprovalDecision({
approvalId: params.approvalId,
preResolvedDecision: params.preResolvedDecision,
});
} catch {
params.onFailure();
return undefined;
}
}

View File

@@ -0,0 +1,77 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveSandboxWorkdir } from "./bash-tools.shared.js";
async function withTempDir(run: (dir: string) => Promise<void>) {
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-bash-workdir-"));
try {
await run(dir);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
describe("resolveSandboxWorkdir", () => {
it("maps container root workdir to host workspace", async () => {
await withTempDir(async (workspaceDir) => {
const warnings: string[] = [];
const resolved = await resolveSandboxWorkdir({
workdir: "/workspace",
sandbox: {
containerName: "sandbox-1",
workspaceDir,
containerWorkdir: "/workspace",
},
warnings,
});
expect(resolved.hostWorkdir).toBe(workspaceDir);
expect(resolved.containerWorkdir).toBe("/workspace");
expect(warnings).toEqual([]);
});
});
it("maps nested container workdir under the container workspace", async () => {
await withTempDir(async (workspaceDir) => {
const nested = path.join(workspaceDir, "scripts", "runner");
await mkdir(nested, { recursive: true });
const warnings: string[] = [];
const resolved = await resolveSandboxWorkdir({
workdir: "/workspace/scripts/runner",
sandbox: {
containerName: "sandbox-2",
workspaceDir,
containerWorkdir: "/workspace",
},
warnings,
});
expect(resolved.hostWorkdir).toBe(nested);
expect(resolved.containerWorkdir).toBe("/workspace/scripts/runner");
expect(warnings).toEqual([]);
});
});
it("supports custom container workdir prefixes", async () => {
await withTempDir(async (workspaceDir) => {
const nested = path.join(workspaceDir, "project");
await mkdir(nested, { recursive: true });
const warnings: string[] = [];
const resolved = await resolveSandboxWorkdir({
workdir: "/sandbox-root/project",
sandbox: {
containerName: "sandbox-3",
workspaceDir,
containerWorkdir: "/sandbox-root",
},
warnings,
});
expect(resolved.hostWorkdir).toBe(nested);
expect(resolved.containerWorkdir).toBe("/sandbox-root/project");
expect(warnings).toEqual([]);
});
});
});

View File

@@ -85,9 +85,14 @@ export async function resolveSandboxWorkdir(params: {
warnings: string[];
}) {
const fallback = params.sandbox.workspaceDir;
const mappedHostWorkdir = mapContainerWorkdirToHost({
workdir: params.workdir,
sandbox: params.sandbox,
});
const candidateWorkdir = mappedHostWorkdir ?? params.workdir;
try {
const resolved = await assertSandboxPath({
filePath: params.workdir,
filePath: candidateWorkdir,
cwd: process.cwd(),
root: params.sandbox.workspaceDir,
});
@@ -113,6 +118,36 @@ export async function resolveSandboxWorkdir(params: {
}
}
function mapContainerWorkdirToHost(params: {
workdir: string;
sandbox: BashSandboxConfig;
}): string | undefined {
const workdir = normalizeContainerPath(params.workdir);
const containerRoot = normalizeContainerPath(params.sandbox.containerWorkdir);
if (containerRoot === ".") {
return undefined;
}
if (workdir === containerRoot) {
return path.resolve(params.sandbox.workspaceDir);
}
if (!workdir.startsWith(`${containerRoot}/`)) {
return undefined;
}
const rel = workdir
.slice(containerRoot.length + 1)
.split("/")
.filter(Boolean);
return path.resolve(params.sandbox.workspaceDir, ...rel);
}
function normalizeContainerPath(input: string): string {
const normalized = input.trim().replace(/\\/g, "/");
if (!normalized) {
return ".";
}
return path.posix.normalize(normalized);
}
export function resolveWorkdir(workdir: string, warnings: string[]) {
const current = safeCwd();
const fallback = current ?? homedir();

View File

@@ -7,6 +7,7 @@ import type { ImageContent } from "@mariozechner/pi-ai";
import type { ThinkLevel } from "../../auto-reply/thinking.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { CliBackendConfig } from "../../config/types.js";
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
import { isRecord } from "../../utils.js";
import { buildModelAliasLines } from "../model-alias-lines.js";
@@ -18,20 +19,9 @@ import { buildSystemPromptParams } from "../system-prompt-params.js";
import { buildAgentSystemPrompt } from "../system-prompt.js";
export { buildCliSupervisorScopeKey, resolveCliNoOutputTimeoutMs } from "./reliability.js";
const CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
const CLI_RUN_QUEUE = new KeyedAsyncQueue();
export function enqueueCliRun<T>(key: string, task: () => Promise<T>): Promise<T> {
const prior = CLI_RUN_QUEUE.get(key) ?? Promise.resolve();
const chained = prior.catch(() => undefined).then(task);
// Keep queue continuity even when a run rejects, without emitting unhandled rejections.
const tracked = chained
.catch(() => undefined)
.finally(() => {
if (CLI_RUN_QUEUE.get(key) === tracked) {
CLI_RUN_QUEUE.delete(key);
}
});
CLI_RUN_QUEUE.set(key, tracked);
return chained;
return CLI_RUN_QUEUE.enqueue(key, task);
}
type CliUsage = {

View File

@@ -18,6 +18,8 @@ describe("failover-error", () => {
expect(resolveFailoverReasonFromError({ status: 502 })).toBe("timeout");
expect(resolveFailoverReasonFromError({ status: 503 })).toBe("timeout");
expect(resolveFailoverReasonFromError({ status: 504 })).toBe("timeout");
// Anthropic 529 (overloaded) should trigger failover as rate_limit.
expect(resolveFailoverReasonFromError({ status: 529 })).toBe("rate_limit");
});
it("infers format errors from error messages", () => {

View File

@@ -1,3 +1,4 @@
import { readErrorName } from "../infra/errors.js";
import {
classifyFailoverReason,
isAuthPermanentErrorMessage,
@@ -82,13 +83,6 @@ function getStatusCode(err: unknown): number | undefined {
return undefined;
}
function getErrorName(err: unknown): string {
if (!err || typeof err !== "object") {
return "";
}
return "name" in err ? String(err.name) : "";
}
function getErrorCode(err: unknown): string | undefined {
if (!err || typeof err !== "object") {
return undefined;
@@ -127,7 +121,7 @@ function hasTimeoutHint(err: unknown): boolean {
if (!err) {
return false;
}
if (getErrorName(err) === "TimeoutError") {
if (readErrorName(err) === "TimeoutError") {
return true;
}
const message = getErrorMessage(err);
@@ -141,7 +135,7 @@ export function isTimeoutError(err: unknown): boolean {
if (!err || typeof err !== "object") {
return false;
}
if (getErrorName(err) !== "AbortError") {
if (readErrorName(err) !== "AbortError") {
return false;
}
const message = getErrorMessage(err);
@@ -178,6 +172,9 @@ export function resolveFailoverReasonFromError(err: unknown): FailoverReason | n
if (status === 502 || status === 503 || status === 504) {
return "timeout";
}
if (status === 529) {
return "rate_limit";
}
if (status === 400) {
return "format";
}

View File

@@ -2,6 +2,7 @@ import { completeSimple, getModel } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { describe, expect, it } from "vitest";
import { isTruthyEnvValue } from "../infra/env.js";
import { makeZeroUsageSnapshot } from "./usage.js";
const GEMINI_KEY = process.env.GEMINI_API_KEY ?? "";
const LIVE = isTruthyEnvValue(process.env.GEMINI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
@@ -39,20 +40,7 @@ describeLive("gemini live switch", () => {
api: "google-gemini-cli",
provider: "google-antigravity",
model: "claude-sonnet-4-20250514",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
usage: makeZeroUsageSnapshot(),
stopReason: "stop",
timestamp: now,
},

View File

@@ -8,6 +8,7 @@ import {
buildModelAliasIndex,
normalizeModelSelection,
normalizeProviderId,
normalizeProviderIdForAuth,
modelKey,
resolveAllowedModelRef,
resolveConfiguredModelRef,
@@ -64,6 +65,14 @@ describe("model-selection", () => {
});
});
describe("normalizeProviderIdForAuth", () => {
it("maps coding-plan variants to base provider for auth lookup", () => {
expect(normalizeProviderIdForAuth("volcengine-plan")).toBe("volcengine");
expect(normalizeProviderIdForAuth("byteplus-plan")).toBe("byteplus");
expect(normalizeProviderIdForAuth("openai")).toBe("openai");
});
});
describe("parseModelRef", () => {
it("should parse full model refs", () => {
expect(parseModelRef("anthropic/claude-3-5-sonnet", "openai")).toEqual({

View File

@@ -61,6 +61,18 @@ export function normalizeProviderId(provider: string): string {
return normalized;
}
/** Normalize provider ID for auth lookup. Coding-plan variants share auth with base. */
export function normalizeProviderIdForAuth(provider: string): string {
const normalized = normalizeProviderId(provider);
if (normalized === "volcengine-plan") {
return "volcengine";
}
if (normalized === "byteplus-plan") {
return "byteplus";
}
return normalized;
}
export function findNormalizedProviderValue<T>(
entries: Record<string, T> | undefined,
provider: string,

View File

@@ -1,10 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPerSenderSessionConfig } from "./test-helpers/session-config.js";
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
session: {
mainKey: "main",
scope: "per-sender",
},
session: createPerSenderSessionConfig(),
};
vi.mock("../config/config.js", async (importOriginal) => {
@@ -24,10 +22,7 @@ describe("agents_list", () => {
function setConfigWithAgentList(agentList: AgentConfig[]) {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
},
session: createPerSenderSessionConfig(),
agents: {
list: agentList,
},
@@ -51,10 +46,7 @@ describe("agents_list", () => {
beforeEach(() => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
},
session: createPerSenderSessionConfig(),
};
});

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { addSubagentRunForTests, resetSubagentRegistryForTests } from "./subagent-registry.js";
import { createPerSenderSessionConfig } from "./test-helpers/session-config.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
const callGatewayMock = vi.fn();
@@ -13,10 +14,7 @@ vi.mock("../gateway/call.js", () => ({
let storeTemplatePath = "";
let configOverride: Record<string, unknown> = {
session: {
mainKey: "main",
scope: "per-sender",
},
session: createPerSenderSessionConfig(),
};
vi.mock("../config/config.js", async (importOriginal) => {
@@ -35,11 +33,7 @@ function writeStore(agentId: string, store: Record<string, unknown>) {
function setSubagentLimits(subagents: Record<string, unknown>) {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
store: storeTemplatePath,
},
session: createPerSenderSessionConfig({ store: storeTemplatePath }),
agents: {
defaults: {
subagents,
@@ -75,11 +69,7 @@ describe("sessions_spawn depth + child limits", () => {
`openclaw-subagent-depth-${Date.now()}-${Math.random().toString(16).slice(2)}-{agentId}.json`,
);
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
store: storeTemplatePath,
},
session: createPerSenderSessionConfig({ store: storeTemplatePath }),
};
callGatewayMock.mockImplementation(async (opts: unknown) => {
@@ -177,11 +167,7 @@ describe("sessions_spawn depth + child limits", () => {
it("rejects when active children for requester session reached maxChildrenPerAgent", async () => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
store: storeTemplatePath,
},
session: createPerSenderSessionConfig({ store: storeTemplatePath }),
agents: {
defaults: {
subagents: {
@@ -214,11 +200,7 @@ describe("sessions_spawn depth + child limits", () => {
it("does not use subagent maxConcurrent as a per-parent spawn gate", async () => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
store: storeTemplatePath,
},
session: createPerSenderSessionConfig({ store: storeTemplatePath }),
agents: {
defaults: {
subagents: {

View File

@@ -55,6 +55,40 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
return tool.execute(callId, { task: "do thing", agentId, sandbox });
}
function setResearchUnsandboxedConfig(params?: { includeSandboxedDefault?: boolean }) {
setSessionsSpawnConfigOverride({
session: {
mainKey: "main",
scope: "per-sender",
},
agents: {
...(params?.includeSandboxedDefault
? {
defaults: {
sandbox: {
mode: "all",
},
},
}
: {}),
list: [
{
id: "main",
subagents: {
allowAgents: ["research"],
},
},
{
id: "research",
sandbox: {
mode: "off",
},
},
],
},
});
}
async function expectAllowedSpawn(params: {
allowAgents: string[];
agentId: string;
@@ -156,33 +190,7 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
});
it("forbids sandboxed cross-agent spawns that would unsandbox the child", async () => {
setSessionsSpawnConfigOverride({
session: {
mainKey: "main",
scope: "per-sender",
},
agents: {
defaults: {
sandbox: {
mode: "all",
},
},
list: [
{
id: "main",
subagents: {
allowAgents: ["research"],
},
},
{
id: "research",
sandbox: {
mode: "off",
},
},
],
},
});
setResearchUnsandboxedConfig({ includeSandboxedDefault: true });
const result = await executeSpawn("call11", "research");
const details = result.details as { status?: string; error?: string };
@@ -193,28 +201,7 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
});
it('forbids sandbox="require" when target runtime is unsandboxed', async () => {
setSessionsSpawnConfigOverride({
session: {
mainKey: "main",
scope: "per-sender",
},
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["research"],
},
},
{
id: "research",
sandbox: {
mode: "off",
},
},
],
},
});
setResearchUnsandboxedConfig();
const result = await executeSpawn("call12", "research", "require");
const details = result.details as { status?: string; error?: string };

View File

@@ -317,6 +317,38 @@ describe("applyExtraParamsToAgent", () => {
expect(payloads[0]).toEqual({ reasoning: { max_tokens: 256 } });
});
it("does not inject reasoning.effort for x-ai/grok models on OpenRouter (#32039)", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {};
options?.onPayload?.(payload);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(
agent,
undefined,
"openrouter",
"x-ai/grok-4.1-fast",
undefined,
"medium",
);
const model = {
api: "openai-completions",
provider: "openrouter",
id: "x-ai/grok-4.1-fast",
} as Model<"openai-completions">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, {});
expect(payloads).toHaveLength(1);
expect(payloads[0]).not.toHaveProperty("reasoning");
expect(payloads[0]).not.toHaveProperty("reasoning_effort");
});
it("normalizes thinking=off to null for SiliconFlow Pro models", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {

View File

@@ -1,17 +1,6 @@
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { describe, expect, it } from "vitest";
import { splitSdkTools } from "./pi-embedded-runner.js";
function createStubTool(name: string): AgentTool {
return {
name,
label: name,
description: "",
parameters: Type.Object({}),
execute: async () => ({}) as AgentToolResult<unknown>,
};
}
import { createStubTool } from "./test-helpers/pi-tool-stubs.js";
describe("splitSdkTools", () => {
const tools = [

View File

@@ -369,7 +369,7 @@ export async function compactEmbeddedPiSessionDirect(
sandbox,
messageProvider: params.messageChannel ?? params.messageProvider,
agentAccountId: params.agentAccountId,
sessionKey: params.sessionKey ?? params.sessionId,
sessionKey: sandboxSessionKey,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,

View File

@@ -620,6 +620,15 @@ function createOpenRouterWrapper(
};
}
/**
* Models on OpenRouter that do not support the `reasoning.effort` parameter.
* Injecting it causes "Invalid arguments passed to the model" errors.
*/
function isOpenRouterReasoningUnsupported(modelId: string): boolean {
const id = modelId.toLowerCase();
return id.startsWith("x-ai/");
}
function isGemini31Model(modelId: string): boolean {
const normalized = modelId.toLowerCase();
return normalized.includes("gemini-3.1-pro") || normalized.includes("gemini-3.1-flash");
@@ -807,7 +816,13 @@ export function applyExtraParamsToAgent(
// which would cause a 400 on models where reasoning is mandatory.
// Users who need reasoning control should target a specific model ID.
// See: openclaw/openclaw#24851
const openRouterThinkingLevel = modelId === "auto" ? undefined : thinkingLevel;
//
// x-ai/grok models do not support OpenRouter's reasoning.effort parameter
// and reject payloads containing it with "Invalid arguments passed to the
// model." Skip reasoning injection for these models.
// See: openclaw/openclaw#32039
const skipReasoningInjection = modelId === "auto" || isOpenRouterReasoningUnsupported(modelId);
const openRouterThinkingLevel = skipReasoningInjection ? undefined : thinkingLevel;
agent.streamFn = createOpenRouterWrapper(agent.streamFn, openRouterThinkingLevel);
agent.streamFn = createOpenRouterSystemCacheWrapper(agent.streamFn);
}

View File

@@ -584,7 +584,7 @@ export async function runEmbeddedAttempt(
senderUsername: params.senderUsername,
senderE164: params.senderE164,
senderIsOwner: params.senderIsOwner,
sessionKey: params.sessionKey ?? params.sessionId,
sessionKey: sandboxSessionKey,
agentDir,
workspaceDir: effectiveWorkspace,
config: params.config,
@@ -751,7 +751,7 @@ export async function runEmbeddedAttempt(
sandbox: (() => {
const runtime = resolveSandboxRuntimeStatus({
cfg: params.config,
sessionKey: params.sessionKey ?? params.sessionId,
sessionKey: sandboxSessionKey,
});
return { mode: runtime.mode, sandboxed: runtime.sandboxed };
})(),
@@ -1185,7 +1185,7 @@ export async function runEmbeddedAttempt(
onAgentEvent: params.onAgentEvent,
enforceFinalTag: params.enforceFinalTag,
config: params.config,
sessionKey: params.sessionKey ?? params.sessionId,
sessionKey: sandboxSessionKey,
});
const {

View File

@@ -182,6 +182,16 @@ export function emitAssistantLifecycleErrorAndEnd(params: {
params.emit({ type: "agent_end" });
}
export function createReasoningFinalAnswerMessage(): AssistantMessage {
return {
role: "assistant",
content: [
{ type: "thinking", thinking: "Because it helps" },
{ type: "text", text: "Final answer" },
],
} as AssistantMessage;
}
type LifecycleErrorAgentEvent = {
stream?: unknown;
data?: {

View File

@@ -288,7 +288,7 @@ export function handleMessageEnd(
let mediaUrls = parsedText?.mediaUrls;
let hasMedia = Boolean(mediaUrls && mediaUrls.length > 0);
if (!cleanedText && !hasMedia) {
if (!cleanedText && !hasMedia && !ctx.params.enforceFinalTag) {
const rawTrimmed = rawText.trim();
const rawStrippedFinal = rawTrimmed.replace(/<\s*\/?\s*final\s*>/gi, "").trim();
const rawCandidate = rawStrippedFinal || rawTrimmed;
@@ -346,6 +346,33 @@ export function handleMessageEnd(
maybeEmitReasoning();
}
const emitSplitResultAsBlockReply = (
splitResult: ReturnType<typeof ctx.consumeReplyDirectives> | null | undefined,
) => {
if (!splitResult || !onBlockReply) {
return;
}
const {
text: cleanedText,
mediaUrls,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
} = splitResult;
// Emit if there's content OR audioAsVoice flag (to propagate the flag).
if (cleanedText || (mediaUrls && mediaUrls.length > 0) || audioAsVoice) {
void onBlockReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
});
}
};
if (
(ctx.state.blockReplyBreak === "message_end" ||
(ctx.blockChunker ? ctx.blockChunker.hasBuffered() : ctx.state.blockBuffer.length > 0)) &&
@@ -369,28 +396,7 @@ export function handleMessageEnd(
);
} else {
ctx.state.lastBlockReplyText = text;
const splitResult = ctx.consumeReplyDirectives(text, { final: true });
if (splitResult) {
const {
text: cleanedText,
mediaUrls,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
} = splitResult;
// Emit if there's content OR audioAsVoice flag (to propagate the flag).
if (cleanedText || (mediaUrls && mediaUrls.length > 0) || audioAsVoice) {
void onBlockReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
});
}
}
emitSplitResultAsBlockReply(ctx.consumeReplyDirectives(text, { final: true }));
}
}
}
@@ -403,27 +409,7 @@ export function handleMessageEnd(
}
if (ctx.state.blockReplyBreak === "text_end" && onBlockReply) {
const tailResult = ctx.consumeReplyDirectives("", { final: true });
if (tailResult) {
const {
text: cleanedText,
mediaUrls,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
} = tailResult;
if (cleanedText || (mediaUrls && mediaUrls.length > 0) || audioAsVoice) {
void onBlockReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
});
}
}
emitSplitResultAsBlockReply(ctx.consumeReplyDirectives("", { final: true }));
}
ctx.state.deltaBuffer = "";

View File

@@ -2,6 +2,7 @@ import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import {
THINKING_TAG_CASES,
createReasoningFinalAnswerMessage,
createStubSessionHarness,
} from "./pi-embedded-subscribe.e2e-harness.js";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
@@ -31,13 +32,7 @@ describe("subscribeEmbeddedPiSession", () => {
it("emits reasoning as a separate message when enabled", () => {
const { emit, onBlockReply } = createReasoningBlockReplyHarness();
const assistantMessage = {
role: "assistant",
content: [
{ type: "thinking", thinking: "Because it helps" },
{ type: "text", text: "Final answer" },
],
} as AssistantMessage;
const assistantMessage = createReasoningFinalAnswerMessage();
emit({ type: "message_end", message: assistantMessage });

View File

@@ -4,7 +4,7 @@ import {
createStubSessionHarness,
emitAssistantTextDelta,
emitMessageStartAndEndForAssistantText,
expectSingleAgentEventText,
extractAgentEventPayloads,
} from "./pi-embedded-subscribe.e2e-harness.js";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
@@ -37,7 +37,7 @@ describe("subscribeEmbeddedPiSession", () => {
expect(onPartialReply).not.toHaveBeenCalled();
});
it("emits agent events on message_end even without <final> tags", () => {
it("suppresses agent events on message_end without <final> tags when enforced", () => {
const { session, emit } = createStubSessionHarness();
const onAgentEvent = vi.fn();
@@ -49,7 +49,34 @@ describe("subscribeEmbeddedPiSession", () => {
onAgentEvent,
});
emitMessageStartAndEndForAssistantText({ emit, text: "Hello world" });
expectSingleAgentEventText(onAgentEvent.mock.calls, "Hello world");
// With enforceFinalTag, text without <final> tags is treated as leaked
// reasoning and should NOT be recovered by the message_end fallback.
const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls);
expect(payloads).toHaveLength(0);
});
it("emits via streaming when <final> tags are present and enforcement is on", () => {
const { session, emit } = createStubSessionHarness();
const onPartialReply = vi.fn();
const onAgentEvent = vi.fn();
subscribeEmbeddedPiSession({
session,
runId: "run",
enforceFinalTag: true,
onPartialReply,
onAgentEvent,
});
// With enforceFinalTag, content is emitted via streaming (text_delta path),
// NOT recovered from message_end fallback. extractAssistantText strips
// <final> tags, so message_end would see plain text with no <final> markers
// and correctly suppress it (treated as reasoning leak).
emit({ type: "message_start", message: { role: "assistant" } });
emitAssistantTextDelta({ emit, delta: "<final>Hello world</final>" });
expect(onPartialReply).toHaveBeenCalled();
expect(onPartialReply.mock.calls[0][0].text).toBe("Hello world");
});
it("does not require <final> when enforcement is off", () => {
const { session, emit } = createStubSessionHarness();

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