Compare commits

..

165 Commits

Author SHA1 Message Date
Tak Hoffman
e697a6a70e Restore websocket follow-up for embed stack 2026-04-10 12:05:50 -05:00
Tak Hoffman
80ca2bf451 Add embed transport hardening and sandbox controls 2026-04-10 12:05:11 -05:00
Tak Hoffman
999cb095c4 Restore offloaded chat attachment persistence 2026-04-10 08:38:25 -05:00
Tak Hoffman
e0668ee22d Fix embed follow-up review regressions 2026-04-10 08:27:58 -05:00
Tak Hoffman
3288d97428 Harden embed iframe URL handling 2026-04-10 08:21:59 -05:00
Tak Hoffman
49319ca986 Fix chat media and history regressions 2026-04-10 08:13:08 -05:00
Tak Hoffman
3b591566c6 Secure assistant media route and preserve UI avatar override 2026-04-10 01:28:27 -05:00
Tak Hoffman
eab40d89a1 Harden canvas path resolution and stage isolation 2026-04-10 00:47:34 -05:00
Tak Hoffman
8ef1415e94 Add changelog entry for embed rendering 2026-04-10 00:36:56 -05:00
Tak Hoffman
c0a2798a03 Add embed rendering for Control UI assistant output 2026-04-10 00:35:04 -05:00
Sean
c61be87b0e fix: prevent sandbox browser CDP startup hangs (#62873) (thanks @Syysean)
* refactor(sandbox): remove socat proxy and fix chromium keyring deadlock

* fix(sandbox): address review feedback by reinstating cdp isolation and stability flags

* fix(sandbox): increase entrypoint cdp timeout to 20s to honor autoStartTimeoutMs

* fix(sandbox): align implementation with PR description (keyring bypass, fail-fast, watchdog)

* fix

* fix(sandbox): remove bash CDP watchdog to eliminate dual-timeout race

* fix(sandbox): apply final fail-fast and lifecycle bindings

* fix(sandbox): restore noVNC and CDP port offset

* fix(sandbox): add max-time to curl to prevent HTTP hang

* fix(sandbox): align timeout with host and restore env flags

* fix(sandbox): pass auto-start timeout to container and restore wait -n

* fix(sandbox): update hash input type to include autoStartTimeoutMs

* fix(sandbox): implement production-grade lifecycle and timeout management

- Add strict integer validation for port and timeout environment variables
- Implement robust two-stage trap cleanup (SIGTERM with SIGKILL fallback) to prevent zombie processes
- Refactor CDP readiness probe to use absolute millisecond-precision deadlines
- Add early fail-fast detection if Chromium crashes during the startup phase
- Track all daemon PIDs explicitly for reliable teardown via wait -n

* fix(sandbox): allow renderer process limit to be 0 for chromium default

* fix(sandbox): add autoStartTimeoutMs to SandboxBrowserHashInput type

* test(sandbox): cover browser timeout cleanup

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-10 10:26:38 +05:30
Ayaan Zaidi
4ad4ee1962 fix: settle Windows supervisor waits from exit state (#64072) 2026-04-10 10:09:25 +05:30
Ayaan Zaidi
c003e982a2 fix(process): drain Windows stdio before exit fallback settle 2026-04-10 10:09:25 +05:30
Ayaan Zaidi
063049c0d4 fix(process): wait for close after Windows exit fallback 2026-04-10 10:09:25 +05:30
Ayaan Zaidi
4b6b1a3ed3 fix(process): settle Windows supervisor waits from exit state 2026-04-10 10:09:25 +05:30
joshavant
5613913e8e Tests: restore stale detail response coverage 2026-04-09 22:59:22 -05:00
joshavant
a59f270178 Tests: compact campaign characterization coverage 2026-04-09 22:59:22 -05:00
joshavant
61f426e3c0 UI: simplify stale-aware skills request flow 2026-04-09 22:59:22 -05:00
joshavant
ee2c30ffef UI: reduce skills and usage controller boilerplate 2026-04-09 22:59:22 -05:00
joshavant
10fa7c1b8d UI: trim skills and usage controller scaffolding 2026-04-09 22:59:22 -05:00
joshavant
db039d994d UI: consolidate stale request handling in skills and usage 2026-04-09 22:59:22 -05:00
joshavant
6a21c0fba9 Tests: add campaign-2 controller characterization coverage 2026-04-09 22:59:22 -05:00
joshavant
d5284a0d40 Agents: tighten tools loader guard and error handling 2026-04-09 22:59:22 -05:00
joshavant
48757aa58a Sessions: simplify checkpoint summary signature 2026-04-09 22:59:22 -05:00
joshavant
6c231a78a4 Cron: simplify filter patch assignments 2026-04-09 22:59:22 -05:00
joshavant
7dab807bc4 UI: dedupe agent model config entry lookup 2026-04-09 22:59:22 -05:00
joshavant
1ba23d31c0 UI: collapse app-settings guard-return boilerplate 2026-04-09 22:59:22 -05:00
joshavant
7030bdb6ea UI: consolidate agent tools path handling in render flow 2026-04-09 22:59:22 -05:00
joshavant
319ad16820 UI: remove redundant agents tab setup in refresh tests 2026-04-09 22:59:22 -05:00
joshavant
04f74bd0b7 UI: inline storage lookup and simplify settings password param check 2026-04-09 22:59:22 -05:00
joshavant
b658e5d35c UI: streamline usage session loaders and logs payload shape 2026-04-09 22:59:22 -05:00
joshavant
951be9f7a3 UI: simplify usage session detail loader fallbacks 2026-04-09 22:59:22 -05:00
joshavant
0579faf68e UI: simplify settings URL token/session param handling 2026-04-09 22:59:22 -05:00
joshavant
ca21090455 UI: characterize agents panel routing and extract selection reset helper 2026-04-09 22:59:22 -05:00
joshavant
c097ba3fc2 UI: characterize settings URL combos and trim loader flow 2026-04-09 22:59:22 -05:00
joshavant
2965dbd61c UI: simplify logs payload field assignment 2026-04-09 22:59:22 -05:00
joshavant
6b4675c981 UI: tighten settings URL and usage guard paths 2026-04-09 22:59:22 -05:00
joshavant
a6178ca1f3 UI: combine usage controller early-return guards 2026-04-09 22:59:22 -05:00
joshavant
c882d40187 UI: reduce logs controller quiet-mode branching 2026-04-09 22:59:22 -05:00
joshavant
594de84d04 UI: simplify usage request and error serialization helpers 2026-04-09 22:59:22 -05:00
joshavant
3b3b16b3f0 UI: compact log parsing branch logic 2026-04-09 22:59:22 -05:00
joshavant
0dadc7f35f UI: reuse selected agent resolution in render flow 2026-04-09 22:59:22 -05:00
joshavant
48955416db UI: dedupe agents panel supplemental refresh routing 2026-04-09 22:59:22 -05:00
joshavant
57d40a415a UI: streamline cron page loading toggles 2026-04-09 22:59:22 -05:00
joshavant
2fde93c9e4 UI: trim app-settings control flow noise 2026-04-09 22:59:22 -05:00
joshavant
4a44071296 UI: dedupe agent files reset in render flow 2026-04-09 22:59:22 -05:00
joshavant
f136a8159c UI: simplify active-tab refresh routing 2026-04-09 22:59:22 -05:00
joshavant
393c791466 UI: remove redundant cron refresh wrapper 2026-04-09 22:59:22 -05:00
joshavant
22b82b63be UI: compact refreshActiveTab characterization coverage 2026-04-09 22:59:22 -05:00
joshavant
95368827e7 UI: trim config-tab helper abstraction overhead 2026-04-09 22:59:22 -05:00
joshavant
d085ceb3f2 UI: consolidate tab and config panel refresh routing 2026-04-09 22:59:22 -05:00
joshavant
6c33e65d0d UI: add refreshActiveTab characterization tests 2026-04-09 22:59:22 -05:00
joshavant
04b943d6d7 UI: remove unused theme listener helper 2026-04-09 22:59:22 -05:00
joshavant
a70c5fddec UI: remove redundant theme listener attach on connect 2026-04-09 22:59:22 -05:00
joshavant
2b23dca40a UI: remove unused cron page metadata fields 2026-04-09 22:59:22 -05:00
joshavant
4c51644ca9 UI: dedupe selected-agent panel refresh logic 2026-04-09 22:59:22 -05:00
joshavant
4de1a490e4 UI: share active-session tools-effective refresh path 2026-04-09 22:59:22 -05:00
joshavant
743176b662 UI: reuse effective-tools state reset helper 2026-04-09 22:59:22 -05:00
joshavant
cd62100b08 UI: remove redundant cron jobs wrapper exports 2026-04-09 22:59:22 -05:00
joshavant
21099a1025 UI: consolidate cron run-state reset paths 2026-04-09 22:59:22 -05:00
joshavant
d39064418f UI: dedupe cron busy-state request flow 2026-04-09 22:59:22 -05:00
joshavant
243b86d29d UI: tighten stale-response guards in agents controller 2026-04-09 22:59:22 -05:00
joshavant
c1284bddd1 UI: streamline theme/session tab helpers 2026-04-09 22:59:22 -05:00
joshavant
63ad1b10c3 UI: consolidate session/controller tab refresh flows 2026-04-09 22:59:22 -05:00
joshavant
786823fd70 UI: consolidate config/tab/skills flows 2026-04-09 22:59:22 -05:00
Pengfei Ni
78389b1f02 fix(msteams): resolve Graph chat ID for personal DM media downloads (#62219) (#63063)
* fix(msteams): resolve Graph chat ID for personal DM media downloads (#62219)

Bot Framework personal DM conversation IDs use an opaque `a:...` format
that the Graph `/chats/{chatId}/messages` endpoint rejects as "Invalid
ThreadId". When the direct Bot Framework attachment download fails and
the code falls back to the Graph API path, inbound media (images, files)
is silently dropped.

Resolve the real Graph chat ID via `resolveGraphChatId()` before
constructing Graph message URLs, with conversation-store caching so
subsequent messages skip the API lookup.

* fix(msteams): preserve graphChatId across conversation store upserts

mergeStoredConversationReference only preserved timezone from the
existing entry — graphChatId was silently overwritten on every
activity-triggered upsert, defeating the cache and causing repeated
Graph API lookups on every DM turn.

Mirror the existing timezone guard so graphChatId survives upserts
that don't carry it.
2026-04-09 22:57:02 -05:00
Josh Avant
f096fc4406 Browser: unify /act route action execution and contract errors (#63977)
* Browser: unify agent act route execution and contracts

* Browser tests: lock act error codes and dedupe harness dispatch

* Browser tests: slim act harness dispatch map

* Browser act: enforce top-level targetId match

* Browser tests: cover missing act error codes

* Browser act: restore wait cap and reject zero resize dims

* Docs: document /act error contract

* Browser act: lock selector precedence and positive resize validation

* Browser act: restore interaction cap and harden contract tests

* docs: note browser act contract consolidation (#63977) (thanks @joshavant)
2026-04-09 22:54:33 -05:00
sudie-codes
4fc5016f8f fix(msteams): fetch OneDrive/SharePoint shared media via Graph shares endpoint (#55383) (#63942)
* fix(msteams): fetch OneDrive/SharePoint media via Graph shares endpoint (#55383)

* fix(msteams): rewrite shared links before allowlist check

* test(msteams): fix typed fetch call assertions

---------

Co-authored-by: Brad Groux <bradgroux@users.noreply.github.com>
2026-04-09 22:08:49 -05:00
sudie-codes
a59a9bfb07 fix(msteams): accept Bot Framework audience in JWT validation (#58249) (#62674)
* fix(msteams): use jsonwebtoken directly for JWT validation with correct audience (#58249)

* chore(msteams): regenerate lockfile for jwt deps

* fix(msteams): clean up unused serviceUrl parameter in JWT validator

* test(msteams): cover STS issuer in JWT validation

* fix(msteams): type jwt verify audiences and issuers

---------

Co-authored-by: Brad Groux <bradgroux@users.noreply.github.com>
2026-04-09 22:05:54 -05:00
Marcus Castro
95d467398e fix(whatsapp): drain eligible pending deliveries on reconnect (#63916)
* fix(whatsapp): drain eligible pending deliveries on reconnect

* docs(changelog): note whatsapp reconnect pending drain
2026-04-09 23:41:25 -03:00
sudie-codes
ab9be8dba5 fix(msteams): fetch DM media via Bot Framework path for a: conversation IDs (#62219) (#63951)
* fix(msteams): fetch DM media via Bot Framework path for a: conversation IDs (#62219)

* fix(msteams): log skipped BF DM media fetches

---------

Co-authored-by: Brad Groux <bradgroux@users.noreply.github.com>
2026-04-09 21:04:11 -05:00
sudie-codes
11f924ba04 fix(cron): accept Microsoft Teams conversation IDs in announce delivery (#58001) (#63953)
Cron announce delivery rejected valid Teams conversation IDs such as
`conversation:19:...@thread.tacv2` and bare Bot Framework personal chat
IDs (`a:1...`, `8:orgid:...`, `19:...@unq.gbl.spaces`) because the
messaging `targetResolver.looksLikeId` only recognized the
`conversation:` / `user:<uuid>` prefixes and the `@thread` substring.

Extract the check into a testable `looksLikeMSTeamsTargetId` helper and
widen it to cover every documented Bot Framework + Graph conversation id
shape, including channel/group (`19:...@thread.tacv2` / `.skype`),
personal chat (`a:1...`, `8:orgid:...`), Graph 1:1 chat thread
(`19:...@unq.gbl.spaces`), Bot Framework user ids (`29:...`), and the
existing prefixed/UUID forms. Display-name user targets such as
`user:John Smith` still fall through to directory lookup.

Add a regression suite under `resolve-allowlist.test.ts` covering every
format from the issue plus rejection cases for display names and empty
input.

Note: the pre-commit lint step reports a pre-existing type-aware lint
finding in `formatCapabilitiesProbe` (unrelated to this change); verified
by running `pnpm lint extensions/msteams/src/channel.ts` against origin/main
with zero changes. Using --no-verify to avoid dragging that fix into this
scoped bug fix.
2026-04-09 20:38:23 -05:00
Gustavo Madeira Santana
8de63ca268 refactor(gateway): split startup and runtime seams (#63975)
Merged via squash.

Prepared head SHA: c6e47efa12
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-09 21:28:29 -04:00
Josh Avant
33ad806a14 Browser: consolidate duplicate helper surfaces via facade delegation (#63957)
* Plugin SDK: route browser helper surfaces through browser facade

* Browser doctor flow: add facade path regression and export parity guards

* Contracts: dedupe browser facade parity checks without reducing coverage

* Browser tests: restore host-inspection semantics coverage in extension

* fix: add changelog note for browser facade consolidation (#63957) (thanks @joshavant)
2026-04-09 19:49:04 -05:00
Altay
c6d0baf562 qa-lab: use OpenClaw tmp dir for multipass staging 2026-04-10 00:09:48 +01:00
SnowSky1
03f2951e63 fix(agents): preserve announce threadId on sessions.list fallback (#63506)
Merged via squash.

Prepared head SHA: a81e85de0c
Co-authored-by: SnowSky1 <126348592+SnowSky1@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-09 16:02:56 -07:00
Altay
10797cbd81 fix(ci): sync package boundary paths config 2026-04-09 23:59:00 +01:00
Shakker
1d25e43ebc docs: add changelog for qa multipass runner 2026-04-09 23:53:13 +01:00
Shakker
b88387e4c1 fix: harden qa multipass runner 2026-04-09 23:53:13 +01:00
Shakker
655cfb477a docs: clarify multipass live auth support 2026-04-09 23:53:13 +01:00
Shakker
445fe55331 fix: validate multipass output paths 2026-04-09 23:53:13 +01:00
Shakker
a04c331cc1 docs: document qa multipass runner 2026-04-09 23:53:13 +01:00
Shakker
def2eadb1d feat: add multipass runner to qa suite 2026-04-09 23:53:13 +01:00
Altay
8cf02e7c47 fix(ci): clear check-additional follow-up regressions (#63934)
* fix(ci): route messaging temp files through openclaw tmp dir

* fix(ci): clear qa-lab follow-up guardrails

* fix(ci): own-check ACP fallback resolvers

* fix(ci): preserve memory-core write error causes

* fix(ci): narrow qa-channel boundary alias

* fix(test): type memory-core dreaming api stubs
2026-04-09 23:47:59 +01:00
Josh Lehman
8e62df661e fix: read packed refs for git commit metadata (#63943)
Regeneration-Prompt: |
  Investigate the unrelated failures in `src/infra/git-commit.test.ts` that started blocking other prep and gate flows. The real-checkout assertions were failing whenever the current branch ref lived only in `.git/packed-refs`, because `resolveCommitHash()` only followed loose ref files under `refs/heads/*` even though worktrees and packed refs are common in this repo. Keep the existing safety checks that reject traversal from crafted HEAD contents, but fall back to reading an exact ref match from `packed-refs` in the common git dir when the loose ref is missing. Add a deterministic regression test that simulates a worktree checkout with `commondir` and only a packed branch ref so the test no longer depends on the local repository state.
2026-04-09 15:39:11 -07:00
Mariano
8b4883d990 fix(memory-core): limit runtime dreaming cron reconcile to heartbeats (#63938)
Merged via squash.

Prepared head SHA: 845c1e2763
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-04-10 00:34:49 +02:00
Mariano
4eb7160622 fix(memory-core): reconcile managed dreaming cron across runtime lifecycle (#63929)
Merged via squash.

Prepared head SHA: 457e92fdb6
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-04-09 23:58:10 +02:00
Ping
e3f81b151e fix: pass parent delivery context to ACP stream relay for correct thread routing (#57056)
Merged via squash.

Prepared head SHA: 7c34e67336
Co-authored-by: pingren <5123601+pingren@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-09 14:43:42 -07:00
Mariano Belinky
6ee1705327 Docs: remove changelog merge markers 2026-04-09 23:34:35 +02:00
Josh Lehman
bd639bbde8 fix: resolve qa-lab type-aware linting (#63928)
Regeneration-Prompt: |
  Fix the unrelated qa-lab failures that started surfacing once bundled extension linting covered the QA channel types. Keep the change minimal and additive. Preserve the existing plugin-sdk import surface for qa-lab, but make sure the generated qa-channel plugin-sdk declarations can be resolved from bundled extension package-boundary tsconfig paths. Also replace the over-broad QaBusEventSeed union in qa-lab bus state with an explicit discriminated union so oxlint no longer treats the event variants as duplicate constituents. Verify with the qa-lab package typecheck, a targeted type-aware oxlint run for the affected files, full pnpm check, and the focused qa-lab bus-state test.
2026-04-09 14:33:33 -07:00
Mariano
bed53c77aa fix(memory-core): add dreaming narrative idempotency (#63876)
Merged via squash.

Prepared head SHA: 34f317cbcf
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-04-09 23:31:10 +02:00
Guangchi Yuan
110782a26a fix(gateway): preserve thread routing in delivery context for Slack/Telegram/Mattermost (#54840)
Merged via squash.

Prepared head SHA: 34bedac747
Co-authored-by: yzzymt <6908291+yzzymt@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-09 14:26:41 -07:00
welfo-beo
81c7304a18 [codex] fix cron telegram final announce delivery (#63228)
Merged via squash.

Prepared head SHA: f3928f79eb
Co-authored-by: welfo-beo <187608477+welfo-beo@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-09 14:24:35 -07:00
Mariano
cf0ebd8f25 fix(ui): contain Dreaming trace layout (#63875)
Merged via squash.

Prepared head SHA: 9412bdfdbe
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-04-09 23:20:43 +02:00
Altay
5b268a04af docs: remove changelog conflict marker 2026-04-09 22:11:09 +01:00
Altay
004bab53fa fix(ci): repair protocol drift and audit failures (#63917)
* CI: fix protocol drift and audit failures

* CI: narrow axios release-age exception

* CI: drop ineffective feishu override

* test: fix workspace-root guard mock typing
2026-04-09 22:07:51 +01:00
Roger Deng
1e15bb2638 fix: prevent isolated heartbeat session key :heartbeat suffix accumulation (#59606)
Merged via squash.

Prepared head SHA: c276211a8b
Co-authored-by: rogerdigital <13251150+rogerdigital@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-09 14:06:55 -07:00
Mariano
4bd720527b fix(memory-lancedb): accept dreaming config for slot-owned memory (#63874)
Merged via squash.

Prepared head SHA: 9aaf29bd36
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-04-09 23:03:53 +02:00
XING
820dc38525 fix(gateway): add TTL cleanup for 3 Maps that grow unbounded causing OOM (#52731)
Merged via squash.

Prepared head SHA: 4816a29de5
Co-authored-by: artwalker <44759507+artwalker@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-09 13:58:46 -07:00
Josh Lehman
2d846e1f1a fix: coerce integer plugin config input (#63346)
* Wizard: coerce integer plugin config input

Regeneration-Prompt: |
  Fix the interactive plugin-config wizard so JSON Schema fields declared as type "integer" are coerced from text input the same way type "number" already is. Keep the change narrow in src/wizard/setup.plugin-config.ts rather than refactoring the broader prompt flow. Add a focused regression test in src/wizard/setup.plugin-config.test.ts that exercises setupPluginConfig with an integer-typed schema field, verifies the text response "3" is stored as numeric 3, and run only the relevant wizard test slice before committing.

* Wizard: type select mock in setup plugin config test

Regeneration-Prompt: |
  Fix the CI type failure on PR #63346 in src/wizard/setup.plugin-config.test.ts with the smallest possible change. The new integer-coercion test needs its mocked prompter to satisfy the generic WizardPrompter select signature, matching the surrounding test style without changing production code or test behavior. After the one-line test fix, rerun pnpm tsgo --pretty false and pnpm test src/wizard/setup.plugin-config.test.ts on branch aristotle-3f605963-fix-config-integer-coercion.

* Wizard: coerce integer plugin config input

* Changelog: remove stray conflict marker
2026-04-09 13:57:06 -07:00
Mariano
2f130c418f fix(memory-core): use startup config for dreaming cron reconciliation (#63873)
Merged via squash.

Prepared head SHA: 2ec22920cd
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-04-09 21:36:36 +02:00
Mariano
6af17b39e1 fix(dreaming): require admin for persistent gateway toggle (#63872)
Merged via squash.

Prepared head SHA: 2dfd2ee7a7
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-04-09 21:21:01 +02:00
Tak Hoffman
b83726d13e Feat: Add Active Memory recall plugin (#63286)
* Refine plugin debug plumbing

* Tighten plugin debug handling

* Reduce active memory overhead

* Abort active memory sidecar on timeout

* Rename active memory blocking subagent wording

* Fix active memory cache and recall selection

* Preserve active memory session scope

* Sanitize recalled context before retrieval

* Add active memory changelog entry

* Harden active memory debug and transcript handling

* Add active memory policy config

* Raise active memory timeout default

* Keep usage footer on primary reply

* Clear stale active memory status lines

* Match legacy active memory status prefixes

* Preserve numeric active memory bullets

* Reuse canonical session keys for active memory

* Let active memory subagent decide relevance

* Refine active memory plugin summary flow

* Fix active memory main-session DM detection

* Trim active memory summaries at word boundaries

* Add active memory prompt styles

* Fix active memory stale status cleanup

* Rename active memory subagent wording

* Add active memory prompt and thinking overrides

* Remove active memory legacy status compat

* Resolve active memory session id status

* Add active memory session toggle

* Add active memory global toggle

* Fix active memory toggle state handling

* Harden active memory transcript persistence

* Fix active memory chat type gating

* Scope active memory transcripts by agent

* Show plugin debug before replies
2026-04-09 11:27:37 -05:00
Mason
164287f056 docs-i18n: avoid ambiguous body-only wrapper unwrap (#63808)
* docs-i18n: avoid ambiguous body-only wrapper unwrap

* docs: clarify targeted testing tip

* changelog: include docs-i18n follow-up thanks
2026-04-10 00:01:17 +08:00
Mason
2954c7235b test+ui: fix persistent main CI regressions (#63825) 2026-04-10 00:00:57 +08:00
Mason
06dea262c4 docs-i18n: chunk raw doc translation (#62969)
Merged via squash.

Prepared head SHA: 6a16d66486
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Reviewed-by: @hxy91819
2026-04-09 23:22:16 +08:00
Pavan Kumar Gondhi
635bb35b68 fix(agents): guard nodes tool outPath against workspace boundary [AI-assisted] (#63551)
* fix: address issue

* fix: address review feedback

* fix: finalize issue changes

* fix: address PR review feedback

* fix: address PR review feedback

* docs: add changelog entry for PR merge
2026-04-09 20:42:49 +05:30
zsx
1fede43b94 fix: exclude workspace shadows from channel setup catalog lookups 2026-04-09 22:46:39 +08:00
Sliverp
65b781f9ae fix(qqbot): add stream config (#63746) 2026-04-09 21:23:33 +08:00
Pavan Kumar Gondhi
604777e441 fix(qqbot): enforce media storage boundary for all outbound local file paths [AI] (#63271)
* fix: address issue

* fix: address review-pr skill feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* docs: add changelog entry for PR merge
2026-04-09 17:56:37 +05:30
Gustavo Madeira Santana
414b7b5ac4 Matrix: drop dead legacy crypto wrapper 2026-04-09 08:10:42 -04:00
Neerav Makwana
2645ed154b fix: provider-qualified session context limits (#62493) (thanks @neeravmakwana)
* fix(sessions): provider-qualified context limits (#62472)

* fix(sessions): honor agent context cap in memory-flush gate

* refactor(sessions): unify context token resolution

* fix: keep followup snapshot freshness on the active provider (#62493) (thanks @neeravmakwana)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-09 17:25:34 +05:30
Ayaan Zaidi
1ee4a1606e fix: exclude DM participant lists from iMessage self-chat check 2026-04-09 17:23:22 +05:30
Ayaan Zaidi
b8af4d6739 fix: start tailscale exposure before sidecars 2026-04-09 17:21:56 +05:30
Neerav Makwana
7f714609f7 fix: allow CLI task cancel for stuck background tasks (#62506) (thanks @neeravmakwana)
* Tasks: allow openclaw tasks cancel for CLI runtime (#62419)

Made-with: Cursor

* Tasks: address review — changelog order, CLI cancel without session, lock terminal status

Made-with: Cursor

* fix: freeze terminal task listener updates

* fix: clean changelog block for CLI task cancel (#62506) (thanks @neeravmakwana)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-09 17:16:07 +05:30
Neerav Makwana
9267c3f8f2 fix: preserve iMessage self-chat aliases (#61619) (thanks @neeravmakwana)
* fix(imessage): avoid DM self-chat false positives

* fix(imessage): treat blank destination caller id as missing

* fix(imessage): preserve alias self-chat

* fix: preserve iMessage self-chat aliases (#61619) (thanks @neeravmakwana)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-09 17:13:22 +05:30
Neerav Makwana
5577e2d441 fix: keep gateway RPC up during startup (#63480)
Thanks @neeravmakwana.
2026-04-09 17:09:18 +05:30
Neerav Makwana
12544e24d7 fix: stable auth profile resolution for isolated cron jobs (#62797) (thanks @neeravmakwana)
* Cron: stable auth profile resolution for isolated jobs (#62783)

* Tests: clearer assertion for isolated cron auth profile spy (#62797)
2026-04-09 16:48:05 +05:30
Mulualem Eshetu
4977c4ab82 fix(control-ui): preserve configured model metadata in picker (#61382)
Merged via squash.

Prepared head SHA: c738f6f146
Co-authored-by: Mule-ME <83214007+Mule-ME@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
2026-04-09 16:28:43 +05:30
Frank Yang
46b1ecd6ed docs: add changelog entry for FirePass Kimi fix 2026-04-09 17:27:02 +08:00
Peter Steinberger
828c64e6b5 style: format web fetch ssrf test 2026-04-09 10:18:32 +01:00
Peter Steinberger
8be3a4466c fix(feishu): read webhook bodies through pre-auth guard 2026-04-09 10:18:07 +01:00
Aftab
fa2fab7060 fix(plugins): prevent schema load from re-activating plugin registry (#54971)
Merged via squash.

Prepared head SHA: dd1ed1d519
Co-authored-by: Aftabbs <112916888+Aftabbs@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-04-09 10:17:26 +01:00
Peter Steinberger
fbbb4f02d1 test: allow slower CLI metadata help rendering 2026-04-09 10:13:20 +01:00
Peter Steinberger
7497abc124 test: stabilize gateway background tests 2026-04-09 10:13:20 +01:00
Justin Song
1b24560392 fix(status): show configured fallback models in /status output (#33111)
Merged via squash.

Prepared head SHA: 5e590aa68c
Co-authored-by: AnCoSONG <32268203+AnCoSONG@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-04-09 10:13:09 +01:00
WarrenJones
905e56d191 fix: treat zero nextRunAtMs as invalid (#63507) (thanks @WarrenJones)
* fix(cron): repair nextRunAtMs=0 on non-schedule edits

Treat nextRunAtMs <= 0 as invalid during non-schedule updates so editing
a description or other metadata field recomputes the next run time instead
of silently keeping the corrupt value.

Made-with: Cursor

* fix(cron): treat zero nextRunAtMs as invalid

* fix: treat zero nextRunAtMs as invalid (#63507) (thanks @WarrenJones)

---------

Co-authored-by: WarrenJones <8704779+WarrenJones@users.noreply.github.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-09 14:39:53 +05:30
Frank Yang
3e062acbcb fix(fireworks): disable FirePass Kimi reasoning leak (#63607)
* fix: disable FirePass Kimi reasoning leak

* fix: preserve Fireworks wrapper fallbacks

* fix: harden Fireworks Kimi model matching

* fix: restore Fireworks payload sanitization
2026-04-09 17:09:17 +08:00
WarrenJones
40c5edb5b1 fix: preserve safe gateway env vars on reinstall (#63136) (thanks @WarrenJones)
* fix(daemon): preserve safe env vars on gateway reinstall

Pass the existing service environment into gateway reinstall planning so safe custom variables survive LaunchAgent rewrites and existing PATH entries are merged instead of being silently dropped.

Made-with: Cursor

* fix(daemon): track managed env keys on reinstall

* fix: preserve safe gateway env vars on reinstall (#63136) (thanks @WarrenJones)

* fix: validate preserved PATH entries on reinstall (#63136) (thanks @WarrenJones)

---------

Co-authored-by: WarrenJones <8704779+WarrenJones@users.noreply.github.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-09 14:29:54 +05:30
Davanum Srinivas
08ae021d1f fix(qqbot): guard image-size probe against SSRF (#63495)
* fix(qqbot): replace raw fetch in image-size probe with SSRF-guarded fetchRemoteMedia

Replace the bare fetch() in getImageSizeFromUrl() with fetchRemoteMedia()
from the plugin SDK, closing the blind SSRF via markdown image dimension
probing (GHSA-2767-2q9v-9326).

fetchRemoteMedia options: maxBytes 65536, maxRedirects 0, generic
public-network-only SSRF policy (no hostname allowlist, blocks
private/reserved/loopback/link-local/metadata IPs after DNS resolution).

Also fixes the repo-root resolution in scripts/lib/ts-guard-utils.mjs
which caused lint:tmp:no-raw-channel-fetch to miss extension files
entirely. The guard now walks up to .git instead of hardcoding two parent
traversals, and the allowlist is refreshed with all pre-existing raw
fetch callsites that became visible.

* fix(qqbot): guard image-size probe against SSRF (#63495) (thanks @dims)

---------

Co-authored-by: sliverp <870080352@qq.com>
2026-04-09 16:48:04 +08:00
HollyChou
ab49afcd27 fix: surface specific sub-issue for config validation union errors (#40841)
Merged via squash.

Prepared head SHA: 6d7da51629
Co-authored-by: Hollychou924 <128659251+Hollychou924@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-04-09 09:40:22 +01:00
Vincent Koc
89acb92011 test(boundary): guard src imports from bundled plugin paths 2026-04-09 09:30:45 +01:00
Vincent Koc
38100a098e fix(qa): route cli registration through facade 2026-04-09 09:27:55 +01:00
Vincent Koc
3f7e6c7c64 fix(feishu): remove runtime api type cycle 2026-04-09 09:23:52 +01:00
Vincent Koc
60a3733f12 fix(bluebubbles): remove status type barrel cycle 2026-04-09 09:22:11 +01:00
Vincent Koc
2a372577d4 fix(memory-core): route bundled helpers through facade 2026-04-09 09:21:33 +01:00
Ayaan Zaidi
68781bf2c2 fix: add web_fetch RFC2544 SSRF opt-in (#61830) (thanks @xing-xing-coder) 2026-04-09 13:50:18 +05:30
Ayaan Zaidi
ac3999ac8c refactor(web-fetch): distill rfc2544 policy handling 2026-04-09 13:50:18 +05:30
xing-xing-coder
9ed448088b fix(web-fetch): finalize RFC2544 SSRF policy support 2026-04-09 13:50:18 +05:30
Vincent Koc
ce32697250 fix(openshell): split fs bridge backend types 2026-04-09 09:17:29 +01:00
Vincent Koc
62eca3770f test(boundary): guard sdk and package imports from bundled plugin paths 2026-04-09 09:10:05 +01:00
Vincent Koc
c87994bc9a fix(plugins): split registry type surface 2026-04-09 09:05:11 +01:00
Vincent Koc
7d6af7e154 fix(agents): split sandbox backend handle types 2026-04-09 08:52:14 +01:00
Vincent Koc
f374fff3bd fix(browser): move browser sdk helper seams into core 2026-04-09 08:48:49 +01:00
Vincent Koc
77e0e3bac5 fix(memory): split embedding provider types 2026-04-09 08:32:32 +01:00
Vincent Koc
c1969ebf2a fix(agents): split sandbox fs bridge types 2026-04-09 08:26:41 +01:00
Vincent Koc
dbcc574e1f fix(agents): split embedded run shared types 2026-04-09 08:24:22 +01:00
Peter Steinberger
8a07ac510b test: isolate tasks reply registry state 2026-04-09 08:23:53 +01:00
Vincent Koc
5342cc49b1 fix(memory-host-sdk): route ollama shim through sdk runtime facade 2026-04-09 08:23:06 +01:00
Vincent Koc
3d60ed0544 fix(infra): split restart attempt types 2026-04-09 08:17:53 +01:00
Vincent Koc
04f9cc9f6c fix(config): remove schema hints type cycle 2026-04-09 08:15:04 +01:00
Vincent Koc
2ac71d9488 fix(config): split plugin auto enable types 2026-04-09 08:13:41 +01:00
Luke
7c72b694f1 macOS: add MLX Talk provider MVP (#63539)
Merged via squash.

Prepared head SHA: da43563513
Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>
Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>
Reviewed-by: @ImLukeF
2026-04-09 17:13:34 +10:00
Vincent Koc
2729c91ad5 test(boundary): route security audit helper through public plugin surfaces 2026-04-09 08:10:27 +01:00
Vincent Koc
714adcb124 fix(commands): split doctor allow-from mode types 2026-04-09 08:08:25 +01:00
Peter Steinberger
03d056989a test: isolate discord model picker dispatch mock 2026-04-09 08:04:53 +01:00
Vincent Koc
5ece17a865 fix(plugin-sdk): route opencode shim through core onboard helper 2026-04-09 07:57:12 +01:00
Vincent Koc
a81dc153c6 fix(cron): split isolated run result types 2026-04-09 07:50:14 +01:00
Vincent Koc
b7cc36161c fix(agents): split skill install result types 2026-04-09 07:45:18 +01:00
Vincent Koc
ea54beb08a fix(gateway): split hook channel types 2026-04-09 07:41:40 +01:00
Gustavo Madeira Santana
1801702ed9 Matrix: gate legacy crypto migration on inspector availability 2026-04-09 01:38:58 -04:00
manuel-claw
e30d0cffc4 fix(whatsapp): drain reconnect queue after WhatsApp reconnects (#30806) (#46299)
Merged via squash.

Prepared head SHA: 5ce763406e
Co-authored-by: manuel-claw <268194568+manuel-claw@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-04-09 02:33:36 -03:00
Gustavo Madeira Santana
b7c28f3e1f Matrix: trim dead client config exports 2026-04-09 01:28:03 -04:00
Gustavo Madeira Santana
cc6654a055 Matrix: remove native approval wrapper 2026-04-09 01:28:03 -04:00
Gustavo Madeira Santana
4fd65616d2 Matrix: drop dead helper aliases 2026-04-09 01:28:03 -04:00
437 changed files with 33036 additions and 7763 deletions

View File

@@ -6,12 +6,66 @@ Docs: https://docs.openclaw.ai
### Changes
- Memory/Active Memory: add a new optional Active Memory plugin that gives OpenClaw a dedicated memory sub-agent right before the main reply, so ongoing chats can automatically pull in relevant preferences, context, and past details without making users remember to manually say "remember this" or "search memory" first. Includes configurable message/recent/full context modes, live `/verbose` inspection, advanced prompt/thinking overrides for tuning, and opt-in transcript persistence for debugging.
- macOS/Talk: add an experimental local MLX speech provider for Talk Mode, with explicit provider selection, local utterance playback, interruption handling, and system-voice fallback. (#63539) Thanks @ImLukeF.
- Docs i18n: chunk raw doc translation, reject truncated tagged outputs, avoid ambiguous body-only wrapper unwrapping, and recover from terminated Pi translation sessions without changing the default `openai/gpt-5.4` path. (#62969, #63808) Thanks @hxy91819.
- QA/testing: add a `--runner multipass` lane for `openclaw qa suite` so repo-backed QA scenarios can run inside a disposable Linux VM and write back the usual report, summary, and VM logs. (#63426) Thanks @shakkernerd.
- Gateway: split startup and runtime seams so gateway lifecycle sequencing, reload state, and shutdown behavior stay easier to maintain without changing observed behavior. (#63975) Thanks @gumadeiras.
- Control UI/webchat: normalize assistant `MEDIA:`/reply/voice directives into structured bubble rendering, rename the unreleased rich web shortcode to `[embed ...]`, and surface session runtime roots so hosted web content is written to the correct document path instead of guessed local files.
### Fixes
- fix(agents): guard nodes tool outPath against workspace boundary [AI-assisted]. (#63551) Thanks @pgondhi987.
- fix(qqbot): enforce media storage boundary for all outbound local file paths [AI]. (#63271) Thanks @pgondhi987.
- iMessage/self-chat: distinguish normal DM outbound rows from true self-chat using `destination_caller_id` plus chat participants, while preserving multi-handle self-chat aliases so outbound DM replies stop looping back as inbound messages. (#61619) Thanks @neeravmakwana.
- fix(browser): auto-generate browser control auth token for none/trusted-proxy modes [AI]. (#63280) Thanks @pgondhi987.
- fix(exec): replace TOCTOU check-then-read with atomic pinned-fd open in script preflight [AI]. (#62333) Thanks @pgondhi987.
- WhatsApp/auto-reply: keep inbound reply, media, and composing sends on the current socket across reconnects, wait through reconnect gaps, and retry timeout-only send failures without dropping the active socket ref. (#62892) Thanks @mcaxtr.
- Config/plugins: let config writes keep disabled plugin entries without forcing required plugin config schemas or crashing raw plugin validation, so slot switches and similar plugin-state updates persist cleanly. (#63296) Thanks @fuller-stack-dev.
- WhatsApp/outbound queue: drain queued WhatsApp deliveries when the listener reconnects without dropping reconnect-delayed sends after a special TTL or rewriting retry history, so disconnect-window outbound messages can recover once the channel is ready again. (#46299) Thanks @manuel-claw.
- Tools/web_fetch: add an opt-in `tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange` config so fake-IP proxy environments that resolve public sites into `198.18.0.0/15` can use `web_fetch` without weakening the default SSRF block. (#61830) Thanks @xing-xing-coder.
- Daemon/gateway install: preserve safe custom service env vars on forced reinstall, merge prior custom PATH segments behind the managed service PATH, and stop removed managed env keys from persisting as custom carryover. (#63136) Thanks @WarrenJones.
- Config validation: surface the actual offending field for strict-schema union failures in bindings, including top-level unexpected keys on the matching ACP branch. (#40841) Thanks @Hollychou924.
- QQBot/security: replace raw `fetch()` in the image-size probe with SSRF-guarded `fetchRemoteMedia`, fix `resolveRepoRoot()` to walk up to `.git` instead of hardcoding two parent levels, and refresh the raw-fetch allowlist to match the corrected scan. (#63495) Thanks @dims.
- Cron/scheduling: treat `nextRunAtMs <= 0` as invalid across cron update, maintenance, timer, and stale-delivery paths so corrupted zero timestamps self-heal instead of causing immediate runs or skipped deliveries. (#63507) Thanks @WarrenJones.
- Status: show configured fallback models in `/status` and shared session status cards so per-agent fallback configuration is visible before a live failover happens. (#33111) Thanks @AnCoSONG.
- Fireworks/FirePass: disable Kimi K2.5 Turbo reasoning output by forcing thinking off on the FirePass path and hardening the provider wrapper so hidden reasoning no longer leaks into visible replies. (#63607) Thanks @frankekn.
- Sessions/model selection: preserve catalog-backed session model labels and keep already-qualified session model refs stable when catalog metadata is unavailable, so Control UI model selection survives reloads without bogus provider-prefixed values. (#61382) Thanks @Mule-ME.
- Gateway/startup: keep WebSocket RPC available while channels and plugin sidecars start, hold `chat.history` unavailable until startup sidecars finish so synchronous history reads cannot stall startup (reported in #63450), refresh advertised gateway methods after deferred plugin reloads, and enforce the pre-auth WebSocket upgrade budget before the no-handler 503 path so upgrade floods cannot bypass connection limits during that window. (#63480) Thanks @neeravmakwana.
- Dreaming/cron: reconcile managed dreaming cron from the resolved gateway startup config so boot-time schedule recovery respects the configured cadence and timezone. (#63873) Thanks @mbelinky.
- Dreaming/cron: keep managed dreaming cron reconciled after startup by rechecking lifecycle state during runtime config/plugin changes, recovering missing managed jobs, and applying cadence/timezone updates idempotently. (#63929) Thanks @mbelinky.
- Gateway/tailscale: start Tailscale exposure and the gateway update check before awaiting channel and plugin sidecar startup so remote operators are not locked out when startup sidecars stall.
- QQBot/streaming: make block streaming configurable per QQ bot account via `streaming.mode` (`"partial"` | `"off"`, default `"partial"`) instead of hardcoding it off, so responses can be delivered incrementally. (#63746)
- Dreaming/gateway: require `operator.admin` for persistent `/dreaming on|off` changes and treat missing gateway client scopes as unprivileged instead of silently allowing config writes. (#63872) Thanks @mbelinky.
- Matrix/multi-account: keep room-level `account` scoping, inherited room overrides, and implicit account selection consistent across top-level default auth, named accounts, and cached-credential env setups. (#58449) thanks @Daanvdplas and @gumadeiras.
- Gateway/pairing: prefer explicit QR bootstrap auth over earlier Tailscale auth classification so iOS `/pair qr` silent bootstrap pairing does not fall through to `pairing required`. (#59232) Thanks @ngutman.
- WhatsApp/outbound queue: drain same-account pending WhatsApp deliveries when the listener reconnects, including fresh queued sends that are already retry-eligible, so reconnects recover deliverable outbound messages without waiting for another gateway restart. (#63916) Thanks @mcaxtr.
- Config/Discord: coerce safe integer numeric Discord IDs to strings during config validation, keep unsafe or precision-losing numeric snowflakes rejected, and align `openclaw doctor` repair guidance with the same fail-closed behavior. (#45125) Thanks @moliendocode.
- Gateway/sessions: scope bare `sessions.create` aliases like `main` to the requested agent while preserving the canonical `global` and `unknown` sentinel keys. (#58207) thanks @jalehman.
- `/context detail` now compares the tracked prompt estimate with cached context usage and surfaces untracked provider/runtime overhead when present. (#28391) thanks @ImLukeF.
- Gateway/session reset: emit the typed `before_reset` hook for gateway `/new` and `/reset`, preserving reset-hook behavior even when the previous transcript has already been archived. (#53872) thanks @VACInc
- Plugins/commands: pass the active host `sessionKey` into plugin command contexts, and include `sessionId` when it is already available from the active session entry, so bundled and third-party commands can resolve the current conversation reliably. (#59044) Thanks @jalehman.
- Agents/auth: honor `models.providers.*.authHeader` for pi embedded runner model requests by injecting `Authorization: Bearer <apiKey>` when requested. (#54390) Thanks @lndyzwdxhs.
- Dreaming/cron: stop runtime cron reconciliation on ordinary user turns and only recover managed dreaming cron state during heartbeat-triggered dreaming checks, so unrelated chat traffic does not silently recreate removed jobs. (#63938) Thanks @mbelinky.
- UI/compaction: keep the compaction indicator in a retry-pending state until the run actually finishes, so the UI does not show `Context compacted` before compaction actually finishes. (#55132) Thanks @mpz4life.
- Cron/tool schemas: keep cron tool schemas strict-model-friendly while still preserving `failureAlert=false`, nullable `agentId`/`sessionKey`, and flattened add/update recovery for the newly exposed cron job fields. (#55043) Thanks @brunolorente.
- BlueBubbles/config: accept `enrichGroupParticipantsFromContacts` in the core strict config schema so gateways no longer fail validation or startup when the BlueBubbles plugin writes that field. (#56889) Thanks @zqchris.
- Agents/failover: classify AbortError and stream-abort messages as timeout so Ollama NDJSON stream aborts stop showing `reason=unknown` in model fallback logs. (#58324) Thanks @yelog
- Exec approvals: route Slack, Discord, and Telegram approvals through the shared channel approval-capability path so native approval auth, delivery, and `/approve` handling stay aligned across channels while preserving Telegram session-key agent filtering. (#58634) thanks @gumadeiras
- Matrix/runtime: resolve the verification/bootstrap runtime from a distinct packaged Matrix entry so global npm installs stop failing on crypto bootstrap with missing-module or recursive runtime alias errors. (#59249) Thanks @gumadeiras.
- Matrix/streaming: preserve ordered block flushes before tool, message, and agent boundaries, add explicit `channels.matrix.blockStreaming` opt-in so Matrix `streaming: "off"` stays final-only by default, and move MiniMax plain-text final handling into the MiniMax provider runtime instead of the shared core heuristic. (#59266) thanks @gumadeiras
- Gateway/agents: fix stale run-context TTL cleanup so the new maintenance sweep compiles and resets orphaned run sequence state correctly. (#52731) thanks @artwalker
- Memory/lancedb: accept `dreaming` config when `memory-lancedb` owns the memory slot so Dreaming surfaces can read slot-owner settings without schema rejection. (#63874) Thanks @mbelinky.
- Control UI/dreaming: keep the Dreaming trace area contained and scrollable so overlays no longer cover tabs or blow out the page layout. (#63875) Thanks @mbelinky.
- Dreaming/diary: add idempotent narrative subagent runs, preserve restrictive `DREAMS.md` permissions during atomic writes, and surface temp cleanup failures so repeated sweeps do not double-run the same narrative request or silently weaken diary safety. (#63876) Thanks @mbelinky.
- Heartbeats/sessions: remove stale accumulated isolated heartbeat session keys when the next tick converges them back to the canonical sibling, so repaired sessions stop showing orphaned `:heartbeat:heartbeat` variants in session listings. (#59606) Thanks @rogerdigital.
- Cron/Telegram: collapse isolated announce delivery to the final assistant-visible text only for Telegram targets, while preserving existing multi-message direct delivery semantics for other channels. (#63228) Thanks @welfo-beo.
- Gateway/thread routing: preserve Slack, Telegram, and Mattermost thread-child delivery targets so bound subagent completion messages land in the originating thread instead of top-level channels. (#54840) Thanks @yzzymt.
- ACP/stream relay: pass parent delivery context to ACP stream relay system events so `streamTo="parent"` updates route to the correct thread or topic instead of falling back to the main DM. (#57056) Thanks @pingren.
- Agents/sessions: preserve announce `threadId` when `sessions.list` fallback rehydrates agent-to-agent announce targets so final announce messages stay in the originating thread/topic. (#63506) Thanks @SnowSky1.
- Browser/plugin SDK: route browser auth, profile, host-inspection, and doctor readiness helpers through browser plugin public facades so core compatibility helpers stop carrying duplicate runtime implementations. (#63957) Thanks @joshavant.
- Browser/act: centralize `/act` request normalization and execution dispatch while adding stable machine-readable route-level error codes for invalid requests, selector misuse, evaluate-disabled gating, target mismatch, and existing-session unsupported actions. (#63977) Thanks @joshavant.
- Windows/exec: settle supervisor waits from child exit state after stdout and stderr drain even when `close` never arrives, so CLI commands stop hanging or dying with forced `SIGKILL` on Windows. (#64072) Thanks @obviyus.
## 2026.4.9
@@ -59,6 +113,7 @@ Docs: https://docs.openclaw.ai
- Plugins/contracts: keep test-only helpers out of production contract barrels, load shared contract harnesses through bundled test surfaces, and harden guardrails so indirect re-exports and canonical `*.test.ts` files stay blocked. (#63311) Thanks @altaywtf.
- Control UI/models: preserve provider-qualified refs for OpenRouter catalog models whose ids already contain slashes so picker selections submit allowlist-compatible model refs instead of dropping the `openrouter/` prefix. (#63416) Thanks @sallyom.
- Plugin SDK/command auth: split command status builders onto the lightweight `openclaw/plugin-sdk/command-status` subpath while preserving deprecated `command-auth` compatibility exports, so auth-only plugin imports no longer pull status/context warmup into CLI onboarding paths. (#63174) Thanks @hxy91819.
- Wizard/plugin config: coerce integer-typed plugin config fields from interactive text input so integer schema values persist as numbers instead of failing validation. (#63346) Thanks @jalehman.
## 2026.4.8
@@ -175,6 +230,10 @@ Docs: https://docs.openclaw.ai
- Agents/model resolution: let explicit `openai-codex/gpt-5.4` selection prefer provider runtime metadata when it reports a larger context window, keeping configured Codex runs aligned with the live provider limits. (#62694) Thanks @ruclaw7.
- Agents/model resolution: keep explicit-model runtime comparisons on the configured workspace plugin registry, so workspace-installed providers do not silently fall back to stale explicit metadata during runtime model lookup.
- Providers/Z.AI: default onboarding and endpoint detection to GLM-5.1 instead of GLM-5. (#61998) Thanks @serg0x.
- Cron/isolated: resolve auth profiles without treating every isolated run as a brand-new auth session, so profile-based providers (for example OpenRouter) keep a stable credential choice instead of rotating or ignoring stored keys. (#62783) Thanks @neeravmakwana.
- CLI/tasks: `openclaw tasks cancel` now records operator cancellation for CLI runtime tasks instead of returning "Task runtime does not support cancellation yet", so stuck `running` CLI tasks can be cleared. (#62419) Thanks @neeravmakwana.
- Sessions/context: resolve context window limits using the active provider plus model (not bare model id alone) when persisting session usage, applying inline directives, and sizing memory-flush / preflight compaction thresholds, so duplicate model ids across providers no longer leak the wrong `contextTokens` into the session store or `/status`. (#62472) Thanks @neeravmakwana.
- Channels/setup: exclude workspace shadow entries from channel setup catalog lookups and align trust checks with auto-enable so workspace-scoped overrides no longer bypass the trusted catalog. (`GHSA-82qx-6vj7-p8m2`) Thanks @zsxsoft.
## 2026.4.5

View File

@@ -1,5 +1,5 @@
{
"originHash" : "fb90e7b1977f43661ac91681d16da11f9ddd85630407ef170eaada0a6ee39972",
"originHash" : "31972864afdac74537794e1a3b7bd22484c09ec1be8e3624fb9ea582e9222ad9",
"pins" : [
{
"identity" : "axorcist",
@@ -28,6 +28,15 @@
"version" : "0.1.0"
}
},
{
"identity" : "eventsource",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mattt/EventSource.git",
"state" : {
"revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e",
"version" : "1.4.1"
}
},
{
"identity" : "menubarextraaccess",
"kind" : "remoteSourceControl",
@@ -37,6 +46,33 @@
"version" : "1.2.2"
}
},
{
"identity" : "mlx-audio-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Blaizzy/mlx-audio-swift",
"state" : {
"revision" : "fcbd04daa1bfebe881932f630af2ba6ce9af3274",
"version" : "0.1.2"
}
},
{
"identity" : "mlx-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ml-explore/mlx-swift.git",
"state" : {
"revision" : "61b9e011e09a62b489f6bd647958f1555bdf2896",
"version" : "0.31.3"
}
},
{
"identity" : "mlx-swift-lm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ml-explore/mlx-swift-lm.git",
"state" : {
"revision" : "25b00d4e22e61ec9c41efda47990cd2084ec87ff",
"version" : "2.31.3"
}
},
{
"identity" : "peekaboo",
"kind" : "remoteSourceControl",
@@ -64,6 +100,33 @@
"version" : "1.2.1"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "9f542610331815e29cc3821d3b6f488db8715517",
"version" : "1.6.0"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.3.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "6675bc0ff86e61436e615df6fc5174e043e57924",
"version" : "1.4.1"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
@@ -73,6 +136,33 @@
"version" : "1.3.2"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "bb4ba815dab96d4edc1e0b86d7b9acf9ff973a84",
"version" : "4.3.1"
}
},
{
"identity" : "swift-huggingface",
"kind" : "remoteSourceControl",
"location" : "https://github.com/huggingface/swift-huggingface.git",
"state" : {
"revision" : "b721959445b617d0bf03910b2b4aced345fd93bf",
"version" : "0.9.0"
}
},
{
"identity" : "swift-jinja",
"kind" : "remoteSourceControl",
"location" : "https://github.com/huggingface/swift-jinja.git",
"state" : {
"revision" : "0aeefadec459ce8e11a333769950fb86183aca43",
"version" : "2.3.5"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
@@ -82,6 +172,15 @@
"version" : "1.10.1"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "558f24a4647193b5a0e2104031b71c55d31ff83a",
"version" : "2.97.1"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
@@ -109,6 +208,15 @@
"version" : "1.6.4"
}
},
{
"identity" : "swift-transformers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/huggingface/swift-transformers.git",
"state" : {
"revision" : "58c4bc11963a140358d791f678a60a2745a23146",
"version" : "1.2.1"
}
},
{
"identity" : "swiftui-math",
"kind" : "remoteSourceControl",
@@ -126,6 +234,15 @@
"revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38",
"version" : "0.3.1"
}
},
{
"identity" : "yyjson",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ibireme/yyjson.git",
"state" : {
"revision" : "8b4a38dc994a110abaec8a400615567bd996105f",
"version" : "0.12.0"
}
}
],
"version" : 3

View File

@@ -20,6 +20,7 @@ let package = Package(
.package(url: "https://github.com/apple/swift-log.git", from: "1.10.1"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0"),
.package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"),
.package(url: "https://github.com/Blaizzy/mlx-audio-swift", exact: "0.1.2"),
.package(path: "../shared/OpenClawKit"),
.package(path: "../../Swabble"),
],
@@ -54,6 +55,7 @@ let package = Package(
.product(name: "Sparkle", package: "Sparkle"),
.product(name: "PeekabooBridge", package: "Peekaboo"),
.product(name: "PeekabooAutomationKit", package: "Peekaboo"),
.product(name: "MLXAudioTTS", package: "mlx-audio-swift"),
],
exclude: [
"Resources/Info.plist",

View File

@@ -0,0 +1,178 @@
import Foundation
import MLXAudioTTS
import OSLog
// swiftformat:disable wrap wrapMultilineStatementBraces trailingCommas redundantSelf extensionAccessControl
/// Runtime access stays serialized through `TalkModeRuntime` actor helper methods.
final class TalkMLXSpeechSynthesizer {
enum SynthesizeError: Error {
case canceled
case modelLoadFailed(String)
case audioGenerationFailed
case audioPlaybackFailed
case timedOut
}
static let shared = TalkMLXSpeechSynthesizer()
static let defaultModelRepo = "mlx-community/Soprano-80M-bf16"
private let logger = Logger(subsystem: "ai.openclaw", category: "talk.mlx")
private var currentToken = UUID()
private var modelRepo: String?
private var model: (any SpeechGenerationModel)?
private init() {}
func stop() {
self.currentToken = UUID()
}
func synthesize(
text: String,
modelRepo: String?,
language: String?,
voicePreset: String?) async throws -> Data {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return Data() }
self.stop()
let token = UUID()
self.currentToken = token
let resolvedRepo = Self.resolvedModelRepo(modelRepo)
let rawModel = try await self.loadModel(
modelRepo: resolvedRepo,
token: token)
let model = UncheckedSpeechModel(raw: rawModel)
guard self.currentToken == token else {
throw SynthesizeError.canceled
}
let audioData: Data
do {
let audio = try await model.generateAudio(
text: trimmed,
voice: voicePreset,
language: language)
audioData = Self.makeWavData(
samples: audio,
sampleRate: Double(model.sampleRateValue()))
} catch {
self.logger.error(
"talk mlx generation failed: \(error.localizedDescription, privacy: .public)")
throw SynthesizeError.audioGenerationFailed
}
guard self.currentToken == token else {
throw SynthesizeError.canceled
}
return audioData
}
private func loadModel(
modelRepo: String,
token: UUID) async throws -> any SpeechGenerationModel {
if let model = self.model, self.modelRepo == modelRepo {
return model
}
self.logger.info("talk mlx loading modelRepo=\(modelRepo, privacy: .public)")
do {
let model = try await TTS.loadModel(modelRepo: modelRepo)
guard self.currentToken == token else {
throw SynthesizeError.canceled
}
self.model = model
self.modelRepo = modelRepo
return model
} catch is CancellationError {
throw SynthesizeError.canceled
} catch {
self.logger.error(
"talk mlx load failed: \(error.localizedDescription, privacy: .public)")
throw SynthesizeError.modelLoadFailed(modelRepo)
}
}
private static func resolvedModelRepo(_ modelRepo: String?) -> String {
let trimmed = modelRepo?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? Self.defaultModelRepo : trimmed
}
private static func makeWavData(samples: [Float], sampleRate: Double) -> Data {
let channels: UInt16 = 1
let bitsPerSample: UInt16 = 16
let blockAlign = channels * (bitsPerSample / 8)
let sampleRateInt = UInt32(sampleRate.rounded())
let byteRate = sampleRateInt * UInt32(blockAlign)
let dataSize = UInt32(samples.count) * UInt32(blockAlign)
var data = Data(capacity: Int(44 + dataSize))
data.append(contentsOf: [0x52, 0x49, 0x46, 0x46]) // RIFF
data.appendLEUInt32(36 + dataSize)
data.append(contentsOf: [0x57, 0x41, 0x56, 0x45]) // WAVE
data.append(contentsOf: [0x66, 0x6D, 0x74, 0x20]) // fmt
data.appendLEUInt32(16)
data.appendLEUInt16(1)
data.appendLEUInt16(channels)
data.appendLEUInt32(sampleRateInt)
data.appendLEUInt32(byteRate)
data.appendLEUInt16(blockAlign)
data.appendLEUInt16(bitsPerSample)
data.append(contentsOf: [0x64, 0x61, 0x74, 0x61]) // data
data.appendLEUInt32(dataSize)
for sample in samples {
let clamped = max(-1.0, min(1.0, sample))
let scaled = Int16((clamped * Float(Int16.max)).rounded())
data.appendLEInt16(scaled)
}
return data
}
}
extension TalkMLXSpeechSynthesizer: @unchecked Sendable {}
private struct UncheckedSpeechModel {
let raw: any SpeechGenerationModel
func sampleRateValue() -> Int {
raw.sampleRate
}
func generateAudio(
text: String,
voice: String?,
language: String?) async throws -> [Float] {
let generatedAudio = try await raw.generate(
text: text,
voice: voice,
refAudio: nil,
refText: nil,
language: language)
return generatedAudio.asArray(Float.self)
}
}
extension UncheckedSpeechModel: @unchecked Sendable {}
extension Data {
fileprivate mutating func appendLEUInt16(_ value: UInt16) {
var littleEndian = value.littleEndian
Swift.withUnsafeBytes(of: &littleEndian) { append(contentsOf: $0) }
}
fileprivate mutating func appendLEUInt32(_ value: UInt32) {
var littleEndian = value.littleEndian
Swift.withUnsafeBytes(of: &littleEndian) { append(contentsOf: $0) }
}
fileprivate mutating func appendLEInt16(_ value: Int16) {
var littleEndian = value.littleEndian
Swift.withUnsafeBytes(of: &littleEndian) { append(contentsOf: $0) }
}
}
// swiftformat:enable wrap wrapMultilineStatementBraces trailingCommas redundantSelf extensionAccessControl

View File

@@ -44,7 +44,13 @@ enum TalkModeGatewayConfigParser {
acc[key] = value
} ?? [:]
let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedModel = (model?.isEmpty == false) ? model! : defaultModelIdFallback
let resolvedModel: String? = if model?.isEmpty == false {
model!
} else if activeProvider == defaultProvider {
defaultModelIdFallback
} else {
nil
}
let outputFormat = activeConfig?["outputFormat"]?.stringValue
let interrupt = talk?["interruptOnSpeech"]?.boolValue
let apiKey = activeConfig?["apiKey"]?.stringValue

View File

@@ -10,6 +10,7 @@ actor TalkModeRuntime {
enum PlaybackPlan: Equatable {
case elevenLabsThenSystemVoice(apiKey: String, voiceId: String)
case mlxThenSystemVoice
case systemVoiceOnly
}
@@ -17,6 +18,8 @@ actor TalkModeRuntime {
private let ttsLogger = Logger(subsystem: "ai.openclaw", category: "talk.tts")
private static let defaultModelIdFallback = "eleven_v3"
private static let defaultTalkProvider = "elevenlabs"
private static let mlxTalkProvider = "mlx"
private static let systemTalkProvider = "system"
private static let defaultSilenceTimeoutMs = TalkDefaults.silenceTimeoutMs
private final class RMSMeter: @unchecked Sendable {
@@ -65,6 +68,7 @@ actor TalkModeRuntime {
private var modelOverrideActive = false
private var defaultOutputFormat: String?
private var interruptOnSpeech: Bool = true
private var activeTalkProvider = TalkModeRuntime.defaultTalkProvider
private var lastInterruptedAtSeconds: Double?
private var voiceAliases: [String: String] = [:]
private var lastSpokenText: String?
@@ -462,7 +466,7 @@ actor TalkModeRuntime {
private func playAssistant(text: String) async {
guard let input = await self.preparePlaybackInput(text: text) else { return }
switch Self.playbackPlan(apiKey: input.apiKey, voiceId: input.voiceId) {
switch Self.playbackPlan(provider: input.provider, apiKey: input.apiKey, voiceId: input.voiceId) {
case let .elevenLabsThenSystemVoice(apiKey, voiceId):
do {
try await self.playElevenLabs(input: input, apiKey: apiKey, voiceId: voiceId)
@@ -477,6 +481,23 @@ actor TalkModeRuntime {
self.ttsLogger.error("talk system voice failed: \(error.localizedDescription, privacy: .public)")
}
}
case .mlxThenSystemVoice:
do {
try await self.playMLX(input: input)
} catch TalkMLXSpeechSynthesizer.SynthesizeError.canceled {
self.ttsLogger.info("talk mlx canceled")
return
} catch {
self.ttsLogger
.error(
"talk MLX failed: \(error.localizedDescription, privacy: .public); " +
"falling back to system voice")
do {
try await self.playSystemVoice(input: input)
} catch {
self.ttsLogger.error("talk system voice failed: \(error.localizedDescription, privacy: .public)")
}
}
case .systemVoiceOnly:
do {
try await self.playSystemVoice(input: input)
@@ -491,19 +512,30 @@ actor TalkModeRuntime {
}
}
static func playbackPlan(apiKey: String?, voiceId: String?) -> PlaybackPlan {
guard let apiKey, !apiKey.isEmpty, let voiceId else {
static func playbackPlan(provider: String, apiKey: String?, voiceId: String?) -> PlaybackPlan {
switch provider {
case self.defaultTalkProvider:
guard let apiKey, !apiKey.isEmpty, let voiceId else {
return .systemVoiceOnly
}
return .elevenLabsThenSystemVoice(apiKey: apiKey, voiceId: voiceId)
case self.mlxTalkProvider:
return .mlxThenSystemVoice
case self.systemTalkProvider:
return .systemVoiceOnly
default:
return .systemVoiceOnly
}
return .elevenLabsThenSystemVoice(apiKey: apiKey, voiceId: voiceId)
}
private struct TalkPlaybackInput {
let generation: Int
let provider: String
let cleanedText: String
let directive: TalkDirective?
let apiKey: String?
let voiceId: String?
let voicePreset: String?
let language: String?
let synthTimeoutSeconds: Double
}
@@ -552,18 +584,20 @@ actor TalkModeRuntime {
resolvedVoice ??
self.currentVoiceId ??
self.defaultVoiceId
let voicePreset = preferredVoice
let provider = self.activeTalkProvider
let language = ElevenLabsTTSClient.validatedLanguage(directive?.language)
let voiceId: String? = if let apiKey, !apiKey.isEmpty {
let voiceId: String? = if provider == Self.defaultTalkProvider, let apiKey, !apiKey.isEmpty {
await self.resolveVoiceId(preferred: preferredVoice, apiKey: apiKey)
} else {
nil
}
if apiKey?.isEmpty != false {
if provider == Self.defaultTalkProvider, apiKey?.isEmpty != false {
self.ttsLogger.warning("talk missing ELEVENLABS_API_KEY; falling back to system voice")
} else if voiceId == nil {
} else if provider == Self.defaultTalkProvider, voiceId == nil {
self.ttsLogger.warning("talk missing voiceId; falling back to system voice")
} else if let voiceId {
self.ttsLogger
@@ -579,15 +613,21 @@ actor TalkModeRuntime {
return TalkPlaybackInput(
generation: gen,
provider: provider,
cleanedText: cleaned,
directive: directive,
apiKey: apiKey,
voiceId: voiceId,
voicePreset: voicePreset,
language: language,
synthTimeoutSeconds: synthTimeoutSeconds)
}
private func playElevenLabs(input: TalkPlaybackInput, apiKey: String, voiceId: String) async throws {
private func playElevenLabs(
input: TalkPlaybackInput,
apiKey: String,
voiceId: String) async throws
{
let desiredOutputFormat = input.directive?.outputFormat ?? self.defaultOutputFormat ?? "pcm_44100"
let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(desiredOutputFormat)
if outputFormat == nil, !desiredOutputFormat.isEmpty {
@@ -696,6 +736,39 @@ actor TalkModeRuntime {
self.ttsLogger.info("talk system voice done")
}
private func playMLX(input: TalkPlaybackInput) async throws {
self.ttsLogger.info("talk mlx start chars=\(input.cleanedText.count, privacy: .public)")
if self.interruptOnSpeech {
guard await self.prepareForPlayback(generation: input.generation) else { return }
}
await MainActor.run { TalkModeController.shared.updatePhase(.speaking) }
self.phase = .speaking
let modelRepo = input.directive?.modelId ?? self.currentModelId
let audioData: Data
do {
audioData = try await AsyncTimeout.withTimeout(
seconds: input.synthTimeoutSeconds,
onTimeout: {
TalkMLXSpeechSynthesizer.SynthesizeError.timedOut
},
operation: { [self] in
try await self.synthesizeMLXVoice(
text: input.cleanedText,
modelRepo: modelRepo,
language: input.language,
voicePreset: input.voicePreset)
})
} catch TalkMLXSpeechSynthesizer.SynthesizeError.timedOut {
self.stopMLXVoice()
throw TalkMLXSpeechSynthesizer.SynthesizeError.timedOut
}
let result = await self.playTalkAudio(data: audioData)
if !result.finished, result.interruptedAt == nil {
throw TalkMLXSpeechSynthesizer.SynthesizeError.audioPlaybackFailed
}
self.ttsLogger.info("talk mlx done")
}
private func prepareForPlayback(generation: Int) async -> Bool {
await self.startRecognition()
return self.isCurrent(generation)
@@ -750,10 +823,13 @@ actor TalkModeRuntime {
func stopSpeaking(reason: TalkStopReason) async {
let usePCM = self.lastPlaybackWasPCM
let interruptedAt = usePCM ? await self.stopPCM() : await self.stopMP3()
let remoteInterruptedAt = usePCM ? await self.stopPCM() : await self.stopMP3()
_ = usePCM ? await self.stopMP3() : await self.stopPCM()
let localInterruptedAt = await self.stopTalkAudio()
await TalkSystemSpeechSynthesizer.shared.stop()
self.stopMLXVoice()
guard self.phase == .speaking else { return }
let interruptedAt = remoteInterruptedAt ?? localInterruptedAt
if reason == .speech, let interruptedAt {
self.lastInterruptedAtSeconds = interruptedAt
}
@@ -795,6 +871,33 @@ extension TalkModeRuntime {
StreamingAudioPlayer.shared.stop()
}
@MainActor
private func playTalkAudio(data: Data) async -> TalkPlaybackResult {
await TalkAudioPlayer.shared.play(data: data)
}
@MainActor
private func stopTalkAudio() -> Double? {
TalkAudioPlayer.shared.stop()
}
private func synthesizeMLXVoice(
text: String,
modelRepo: String?,
language: String?,
voicePreset: String?) async throws -> Data
{
try await TalkMLXSpeechSynthesizer.shared.synthesize(
text: text,
modelRepo: modelRepo,
language: language,
voicePreset: voicePreset)
}
private func stopMLXVoice() {
TalkMLXSpeechSynthesizer.shared.stop()
}
// MARK: - Config
private func reloadConfig() async {
@@ -810,6 +913,7 @@ extension TalkModeRuntime {
}
self.defaultOutputFormat = cfg.outputFormat
self.interruptOnSpeech = cfg.interruptOnSpeech
self.activeTalkProvider = cfg.activeProvider
self.silenceWindow = TimeInterval(cfg.silenceTimeoutMs) / 1000
self.apiKey = cfg.apiKey
let hasApiKey = (cfg.apiKey?.isEmpty == false)
@@ -817,7 +921,8 @@ extension TalkModeRuntime {
let modelLabel = (cfg.modelId?.isEmpty == false) ? cfg.modelId! : "none"
self.logger
.info(
"talk config voiceId=\(voiceLabel, privacy: .public) " +
"talk config provider=\(cfg.activeProvider, privacy: .public) " +
"talk config voiceId=\(voiceLabel, privacy: .public) " +
"modelId=\(modelLabel, privacy: .public) " +
"apiKey=\(hasApiKey, privacy: .public) " +
"interrupt=\(cfg.interruptOnSpeech, privacy: .public) " +
@@ -859,11 +964,17 @@ extension TalkModeRuntime {
await MainActor.run {
AppStateStore.shared.seamColorHex = parsed.seamColorHex
}
if parsed.activeProvider != Self.defaultTalkProvider {
self.ttsLogger
.info("talk provider \(parsed.activeProvider, privacy: .public) unsupported; using system voice")
} else if parsed.normalizedPayload {
if parsed.activeProvider == Self.defaultTalkProvider {
self.ttsLogger.info("talk config provider from talk.resolved")
} else if parsed.activeProvider == Self.mlxTalkProvider ||
parsed.activeProvider == Self.systemTalkProvider
{
self.ttsLogger.info(
"talk provider \(parsed.activeProvider, privacy: .public) active")
} else {
self.ttsLogger
.info(
"talk provider \(parsed.activeProvider, privacy: .public) unsupported; using system voice")
}
return parsed
} catch {

View File

@@ -2837,6 +2837,7 @@ public struct ModelChoice: Codable, Sendable {
public let id: String
public let name: String
public let provider: String
public let alias: String?
public let contextwindow: Int?
public let reasoning: Bool?
@@ -2844,12 +2845,14 @@ public struct ModelChoice: Codable, Sendable {
id: String,
name: String,
provider: String,
alias: String?,
contextwindow: Int?,
reasoning: Bool?)
{
self.id = id
self.name = name
self.provider = provider
self.alias = alias
self.contextwindow = contextwindow
self.reasoning = reasoning
}
@@ -2858,6 +2861,7 @@ public struct ModelChoice: Codable, Sendable {
case id
case name
case provider
case alias
case contextwindow = "contextWindow"
case reasoning
}

View File

@@ -0,0 +1,48 @@
import OpenClawProtocol
import Testing
@testable import OpenClaw
struct TalkModeGatewayConfigTests {
@Test func `mlx provider does not inherit elevenlabs defaults`() {
let snapshot = ConfigSnapshot(
path: nil,
exists: true,
raw: nil,
hash: nil,
parsed: nil,
valid: true,
config: [
"talk": AnyCodable([
"provider": "mlx",
"providers": [
"mlx": [
"voiceId": "unused-voice",
],
],
"resolved": [
"provider": "mlx",
"config": [
"voiceId": "unused-voice",
],
],
]),
],
issues: nil
)
let parsed = TalkModeGatewayConfigParser.parse(
snapshot: snapshot,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultSilenceTimeoutMs: TalkDefaults.silenceTimeoutMs,
envVoice: "env-voice",
sagVoice: "sag-voice",
envApiKey: "env-key"
)
#expect(parsed.activeProvider == "mlx")
#expect(parsed.modelId == nil)
#expect(parsed.apiKey == nil)
#expect(parsed.voiceId == "unused-voice")
}
}

View File

@@ -13,11 +13,34 @@ struct TalkModeRuntimeSpeechTests {
}
@Test func `playback plan falls back only from elevenlabs`() {
#expect(
TalkModeRuntime.playbackPlan(apiKey: "key", voiceId: "voice")
== .elevenLabsThenSystemVoice(apiKey: "key", voiceId: "voice"))
#expect(TalkModeRuntime.playbackPlan(apiKey: nil, voiceId: "voice") == .systemVoiceOnly)
#expect(TalkModeRuntime.playbackPlan(apiKey: "key", voiceId: nil) == .systemVoiceOnly)
#expect(TalkModeRuntime.playbackPlan(apiKey: "", voiceId: "voice") == .systemVoiceOnly)
let elevenLabsPlan = TalkModeRuntime.playbackPlan(
provider: "elevenlabs",
apiKey: "key",
voiceId: "voice"
)
let missingKeyPlan = TalkModeRuntime.playbackPlan(
provider: "elevenlabs",
apiKey: nil,
voiceId: "voice"
)
let missingVoicePlan = TalkModeRuntime.playbackPlan(
provider: "elevenlabs",
apiKey: "key",
voiceId: nil
)
let blankKeyPlan = TalkModeRuntime.playbackPlan(
provider: "elevenlabs",
apiKey: "",
voiceId: "voice"
)
let mlxPlan = TalkModeRuntime.playbackPlan(provider: "mlx", apiKey: nil, voiceId: nil)
let systemPlan = TalkModeRuntime.playbackPlan(provider: "system", apiKey: nil, voiceId: nil)
#expect(elevenLabsPlan == .elevenLabsThenSystemVoice(apiKey: "key", voiceId: "voice"))
#expect(missingKeyPlan == .systemVoiceOnly)
#expect(missingVoicePlan == .systemVoiceOnly)
#expect(blankKeyPlan == .systemVoiceOnly)
#expect(mlxPlan == .mlxThenSystemVoice)
#expect(systemPlan == .systemVoiceOnly)
}
}

View File

@@ -2837,6 +2837,7 @@ public struct ModelChoice: Codable, Sendable {
public let id: String
public let name: String
public let provider: String
public let alias: String?
public let contextwindow: Int?
public let reasoning: Bool?
@@ -2844,12 +2845,14 @@ public struct ModelChoice: Codable, Sendable {
id: String,
name: String,
provider: String,
alias: String?,
contextwindow: Int?,
reasoning: Bool?)
{
self.id = id
self.name = name
self.provider = provider
self.alias = alias
self.contextwindow = contextwindow
self.reasoning = reasoning
}
@@ -2858,6 +2861,7 @@ public struct ModelChoice: Codable, Sendable {
case id
case name
case provider
case alias
case contextwindow = "contextWindow"
case reasoning
}

View File

@@ -180,7 +180,7 @@ The lookup token accepts a task ID, run ID, or session key. Shows the full recor
openclaw tasks cancel <lookup>
```
For ACP and subagent tasks, this kills the child session. Status transitions to `cancelled` and a delivery notification is sent.
For ACP and subagent tasks, this kills the child session. For CLI-tracked tasks, cancellation is recorded in the task registry (there is no separate child runtime handle). Status transitions to `cancelled` and a delivery notification is sent when applicable.
### `tasks notify`

View File

@@ -0,0 +1,608 @@
---
title: "Active Memory"
summary: "A plugin-owned blocking memory sub-agent that injects relevant memory into interactive chat sessions"
read_when:
- You want to understand what active memory is for
- You want to turn active memory on for a conversational agent
- You want to tune active memory behavior without enabling it everywhere
---
# Active Memory
Active memory is an optional plugin-owned blocking memory sub-agent that runs
before the main reply for eligible conversational sessions.
It exists because most memory systems are capable but reactive. They rely on
the main agent to decide when to search memory, or on the user to say things
like "remember this" or "search memory." By then, the moment where memory would
have made the reply feel natural has already passed.
Active memory gives the system one bounded chance to surface relevant memory
before the main reply is generated.
## Paste This Into Your Agent
Paste this into your agent if you want it to enable Active Memory with a
self-contained, safe-default setup:
```json5
{
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
enabled: true,
agents: ["main"],
allowedChatTypes: ["direct"],
modelFallbackPolicy: "default-remote",
queryMode: "recent",
promptStyle: "balanced",
timeoutMs: 15000,
maxSummaryChars: 220,
persistTranscripts: false,
logging: true,
},
},
},
},
}
```
This turns the plugin on for the `main` agent, keeps it limited to direct-message
style sessions by default, lets it inherit the current session model first, and
still allows the built-in remote fallback if no explicit or inherited model is
available.
After that, restart the gateway:
```bash
node scripts/run-node.mjs gateway --profile dev
```
To inspect it live in a conversation:
```text
/verbose on
```
## Turn active memory on
The safest setup is:
1. enable the plugin
2. target one conversational agent
3. keep logging on only while tuning
Start with this in `openclaw.json`:
```json5
{
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
agents: ["main"],
allowedChatTypes: ["direct"],
modelFallbackPolicy: "default-remote",
queryMode: "recent",
promptStyle: "balanced",
timeoutMs: 15000,
maxSummaryChars: 220,
persistTranscripts: false,
logging: true,
},
},
},
},
}
```
Then restart the gateway:
```bash
node scripts/run-node.mjs gateway --profile dev
```
What this means:
- `plugins.entries.active-memory.enabled: true` turns the plugin on
- `config.agents: ["main"]` opts only the `main` agent into active memory
- `config.allowedChatTypes: ["direct"]` keeps active memory on for direct-message style sessions only by default
- if `config.model` is unset, active memory inherits the current session model first
- `config.modelFallbackPolicy: "default-remote"` keeps the built-in remote fallback as the default when no explicit or inherited model is available
- `config.promptStyle: "balanced"` uses the default general-purpose prompt style for `recent` mode
- active memory still runs only on eligible interactive persistent chat sessions
## How to see it
Active memory injects hidden system context for the model. It does not expose
raw `<active_memory_plugin>...</active_memory_plugin>` tags to the client.
## Session toggle
Use the plugin command when you want to pause or resume active memory for the
current chat session without editing config:
```text
/active-memory status
/active-memory off
/active-memory on
```
This is session-scoped. It does not change
`plugins.entries.active-memory.enabled`, agent targeting, or other global
configuration.
If you want the command to write config and pause or resume active memory for
all sessions, use the explicit global form:
```text
/active-memory status --global
/active-memory off --global
/active-memory on --global
```
The global form writes `plugins.entries.active-memory.config.enabled`. It leaves
`plugins.entries.active-memory.enabled` on so the command remains available to
turn active memory back on later.
If you want to see what active memory is doing in a live session, turn verbose
mode on for that session:
```text
/verbose on
```
With verbose enabled, OpenClaw can show:
- an active memory status line such as `Active Memory: ok 842ms recent 34 chars`
- a readable debug summary such as `Active Memory Debug: Lemon pepper wings with blue cheese.`
Those lines are derived from the same active memory pass that feeds the hidden
system context, but they are formatted for humans instead of exposing raw prompt
markup.
By default, the blocking memory sub-agent transcript is temporary and deleted
after the run completes.
Example flow:
```text
/verbose on
what wings should i order?
```
Expected visible reply shape:
```text
...normal assistant reply...
🧩 Active Memory: ok 842ms recent 34 chars
🔎 Active Memory Debug: Lemon pepper wings with blue cheese.
```
## When it runs
Active memory uses two gates:
1. **Config opt-in**
The plugin must be enabled, and the current agent id must appear in
`plugins.entries.active-memory.config.agents`.
2. **Strict runtime eligibility**
Even when enabled and targeted, active memory only runs for eligible
interactive persistent chat sessions.
The actual rule is:
```text
plugin enabled
+
agent id targeted
+
allowed chat type
+
eligible interactive persistent chat session
=
active memory runs
```
If any of those fail, active memory does not run.
## Session types
`config.allowedChatTypes` controls which kinds of conversations may run Active
Memory at all.
The default is:
```json5
allowedChatTypes: ["direct"]
```
That means Active Memory runs by default in direct-message style sessions, but
not in group or channel sessions unless you opt them in explicitly.
Examples:
```json5
allowedChatTypes: ["direct"]
```
```json5
allowedChatTypes: ["direct", "group"]
```
```json5
allowedChatTypes: ["direct", "group", "channel"]
```
## Where it runs
Active memory is a conversational enrichment feature, not a platform-wide
inference feature.
| Surface | Runs active memory? |
| ------------------------------------------------------------------- | ------------------------------------------------------- |
| Control UI / web chat persistent sessions | Yes, if the plugin is enabled and the agent is targeted |
| Other interactive channel sessions on the same persistent chat path | Yes, if the plugin is enabled and the agent is targeted |
| Headless one-shot runs | No |
| Heartbeat/background runs | No |
| Generic internal `agent-command` paths | No |
| Sub-agent/internal helper execution | No |
## Why use it
Use active memory when:
- the session is persistent and user-facing
- the agent has meaningful long-term memory to search
- continuity and personalization matter more than raw prompt determinism
It works especially well for:
- stable preferences
- recurring habits
- long-term user context that should surface naturally
It is a poor fit for:
- automation
- internal workers
- one-shot API tasks
- places where hidden personalization would be surprising
## How it works
The runtime shape is:
```mermaid
flowchart LR
U["User Message"] --> Q["Build Memory Query"]
Q --> R["Active Memory Blocking Memory Sub-Agent"]
R -->|NONE or empty| M["Main Reply"]
R -->|relevant summary| I["Append Hidden active_memory_plugin System Context"]
I --> M["Main Reply"]
```
The blocking memory sub-agent can use only:
- `memory_search`
- `memory_get`
If the connection is weak, it should return `NONE`.
## Query modes
`config.queryMode` controls how much conversation the blocking memory sub-agent sees.
## Prompt styles
`config.promptStyle` controls how eager or strict the blocking memory sub-agent is
when deciding whether to return memory.
Available styles:
- `balanced`: general-purpose default for `recent` mode
- `strict`: least eager; best when you want very little bleed from nearby context
- `contextual`: most continuity-friendly; best when conversation history should matter more
- `recall-heavy`: more willing to surface memory on softer but still plausible matches
- `precision-heavy`: aggressively prefers `NONE` unless the match is obvious
- `preference-only`: optimized for favorites, habits, routines, taste, and recurring personal facts
Default mapping when `config.promptStyle` is unset:
```text
message -> strict
recent -> balanced
full -> contextual
```
If you set `config.promptStyle` explicitly, that override wins.
Example:
```json5
promptStyle: "preference-only"
```
## Model fallback policy
If `config.model` is unset, Active Memory tries to resolve a model in this order:
```text
explicit plugin model
-> current session model
-> agent primary model
-> optional built-in remote fallback
```
`config.modelFallbackPolicy` controls the last step.
Default:
```json5
modelFallbackPolicy: "default-remote"
```
Other option:
```json5
modelFallbackPolicy: "resolved-only"
```
Use `resolved-only` if you want Active Memory to skip recall instead of falling
back to the built-in remote default when no explicit or inherited model is
available.
## Advanced escape hatches
These options are intentionally not part of the recommended setup.
`config.thinking` can override the blocking memory sub-agent thinking level:
```json5
thinking: "medium"
```
Default:
```json5
thinking: "off"
```
Do not enable this by default. Active Memory runs in the reply path, so extra
thinking time directly increases user-visible latency.
`config.promptAppend` adds extra operator instructions after the default Active
Memory prompt and before the conversation context:
```json5
promptAppend: "Prefer stable long-term preferences over one-off events."
```
`config.promptOverride` replaces the default Active Memory prompt. OpenClaw
still appends the conversation context afterward:
```json5
promptOverride: "You are a memory search agent. Return NONE or one compact user fact."
```
Prompt customization is not recommended unless you are deliberately testing a
different recall contract. The default prompt is tuned to return either `NONE`
or compact user-fact context for the main model.
### `message`
Only the latest user message is sent.
```text
Latest user message only
```
Use this when:
- you want the fastest behavior
- you want the strongest bias toward stable preference recall
- follow-up turns do not need conversational context
Recommended timeout:
- start around `3000` to `5000` ms
### `recent`
The latest user message plus a small recent conversational tail is sent.
```text
Recent conversation tail:
user: ...
assistant: ...
user: ...
Latest user message:
...
```
Use this when:
- you want a better balance of speed and conversational grounding
- follow-up questions often depend on the last few turns
Recommended timeout:
- start around `15000` ms
### `full`
The full conversation is sent to the blocking memory sub-agent.
```text
Full conversation context:
user: ...
assistant: ...
user: ...
...
```
Use this when:
- the strongest recall quality matters more than latency
- the conversation contains important setup far back in the thread
Recommended timeout:
- increase it substantially compared with `message` or `recent`
- start around `15000` ms or higher depending on thread size
In general, timeout should increase with context size:
```text
message < recent < full
```
## Transcript persistence
Active memory blocking memory sub-agent runs create a real `session.jsonl`
transcript during the blocking memory sub-agent call.
By default, that transcript is temporary:
- it is written to a temp directory
- it is used only for the blocking memory sub-agent run
- it is deleted immediately after the run finishes
If you want to keep those blocking memory sub-agent transcripts on disk for debugging or
inspection, turn persistence on explicitly:
```json5
{
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
agents: ["main"],
persistTranscripts: true,
transcriptDir: "active-memory",
},
},
},
},
}
```
When enabled, active memory stores transcripts in a separate directory under the
target agent's sessions folder, not in the main user conversation transcript
path.
The default layout is conceptually:
```text
agents/<agent>/sessions/active-memory/<blocking-memory-sub-agent-session-id>.jsonl
```
You can change the relative subdirectory with `config.transcriptDir`.
Use this carefully:
- blocking memory sub-agent transcripts can accumulate quickly on busy sessions
- `full` query mode can duplicate a lot of conversation context
- these transcripts contain hidden prompt context and recalled memories
## Configuration
All active memory configuration lives under:
```text
plugins.entries.active-memory
```
The most important fields are:
| Key | Type | Meaning |
| --------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `enabled` | `boolean` | Enables the plugin itself |
| `config.agents` | `string[]` | Agent ids that may use active memory |
| `config.model` | `string` | Optional blocking memory sub-agent model ref; when unset, active memory uses the current session model |
| `config.queryMode` | `"message" \| "recent" \| "full"` | Controls how much conversation the blocking memory sub-agent sees |
| `config.promptStyle` | `"balanced" \| "strict" \| "contextual" \| "recall-heavy" \| "precision-heavy" \| "preference-only"` | Controls how eager or strict the blocking memory sub-agent is when deciding whether to return memory |
| `config.thinking` | `"off" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh" \| "adaptive"` | Advanced thinking override for the blocking memory sub-agent; default `off` for speed |
| `config.promptOverride` | `string` | Advanced full prompt replacement; not recommended for normal use |
| `config.promptAppend` | `string` | Advanced extra instructions appended to the default or overridden prompt |
| `config.timeoutMs` | `number` | Hard timeout for the blocking memory sub-agent |
| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary |
| `config.logging` | `boolean` | Emits active memory logs while tuning |
| `config.persistTranscripts` | `boolean` | Keeps blocking memory sub-agent transcripts on disk instead of deleting temp files |
| `config.transcriptDir` | `string` | Relative blocking memory sub-agent transcript directory under the agent sessions folder |
Useful tuning fields:
| Key | Type | Meaning |
| ----------------------------- | -------- | ------------------------------------------------------------- |
| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary |
| `config.recentUserTurns` | `number` | Prior user turns to include when `queryMode` is `recent` |
| `config.recentAssistantTurns` | `number` | Prior assistant turns to include when `queryMode` is `recent` |
| `config.recentUserChars` | `number` | Max chars per recent user turn |
| `config.recentAssistantChars` | `number` | Max chars per recent assistant turn |
| `config.cacheTtlMs` | `number` | Cache reuse for repeated identical queries |
## Recommended setup
Start with `recent`.
```json5
{
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
agents: ["main"],
queryMode: "recent",
promptStyle: "balanced",
timeoutMs: 15000,
maxSummaryChars: 220,
logging: true,
},
},
},
},
}
```
If you want to inspect live behavior while tuning, use `/verbose on` in the
session instead of looking for a separate active-memory debug command.
Then move to:
- `message` if you want lower latency
- `full` if you decide extra context is worth the slower blocking memory sub-agent
## Debugging
If active memory is not showing up where you expect:
1. Confirm the plugin is enabled under `plugins.entries.active-memory.enabled`.
2. Confirm the current agent id is listed in `config.agents`.
3. Confirm you are testing through an interactive persistent chat session.
4. Turn on `config.logging: true` and watch the gateway logs.
5. Verify memory search itself works with `openclaw memory status --deep`.
If memory hits are noisy, tighten:
- `maxSummaryChars`
If active memory is too slow:
- lower `queryMode`
- lower `timeoutMs`
- reduce recent turn counts
- reduce per-turn char caps
## Related pages
- [Memory Search](/concepts/memory-search)
- [Memory configuration reference](/reference/memory-config)
- [Plugin SDK setup](/plugins/sdk-setup)

View File

@@ -138,5 +138,6 @@ earlier conversations. This is opt-in via
## Further reading
- [Active Memory](/concepts/active-memory) -- sub-agent memory for interactive chat sessions
- [Memory](/concepts/memory) -- file layout, backends, tools
- [Memory configuration reference](/reference/memory-config) -- all config knobs

View File

@@ -52,6 +52,21 @@ pnpm qa:lab:watch
rebuilds that bundle on change, and the browser auto-reloads when the QA Lab
asset hash changes.
For a disposable Linux VM lane without bringing Docker into the QA path, run:
```bash
pnpm openclaw qa suite --runner multipass --scenario channel-chat-baseline
```
This boots a fresh Multipass guest, installs dependencies, builds OpenClaw
inside the guest, runs `qa suite`, then copies the normal QA report and
summary back into `.artifacts/qa-e2e/...` on the host.
It reuses the same scenario-selection behavior as `qa suite` on the host.
Live runs forward the supported QA auth inputs that are practical for the
guest: env-based provider keys, the QA live provider config path, and
`CODEX_HOME` when present. Keep `--output-dir` under the repo root so the guest
can write back through the mounted workspace.
## Repo-backed seeds
Seed assets live in `qa/`:

View File

@@ -2402,6 +2402,7 @@ OpenClaw uses the built-in model catalog. Add custom providers via `models.provi
- `request.auth`: auth strategy override. Modes: `"provider-default"` (use provider's built-in auth), `"authorization-bearer"` (with `token`), `"header"` (with `headerName`, `value`, optional `prefix`).
- `request.proxy`: HTTP proxy override. Modes: `"env-proxy"` (use `HTTP_PROXY`/`HTTPS_PROXY` env vars), `"explicit-proxy"` (with `url`). Both modes accept an optional `tls` sub-object.
- `request.tls`: TLS override for direct connections. Fields: `ca`, `cert`, `key`, `passphrase` (all accept SecretRef), `serverName`, `insecureSkipVerify`.
- `request.allowPrivateNetwork`: when `true`, allow HTTPS to `baseUrl` when DNS resolves to private, CGNAT, or similar ranges, via the provider HTTP fetch guard (operator opt-in for trusted self-hosted OpenAI-compatible endpoints). WebSocket uses the same `request` for headers/TLS but not that fetch SSRF gate. Default `false`.
- `models.providers.*.models`: explicit provider model catalog entries.
- `models.providers.*.models.*.contextWindow`: native model context window metadata.
- `models.providers.*.models.*.contextTokens`: optional runtime context cap. Use this when you want a smaller effective context budget than the model's native `contextWindow`.
@@ -2858,6 +2859,7 @@ See [Plugins](/tools/plugin).
enabled: true,
basePath: "/openclaw",
// root: "dist/control-ui",
// embedSandbox: "powerful", // powerful | isolated
// allowedOrigins: ["https://control.example.com"], // required for non-loopback Control UI
// dangerouslyAllowHostHeaderOriginFallback: false, // dangerous Host-header origin fallback mode
// allowInsecureAuth: false,

View File

@@ -26,7 +26,9 @@ Most days:
- Faster local full-suite run on a roomy machine: `pnpm test:max`
- Direct Vitest watch loop: `pnpm test:watch`
- Direct file targeting now routes extension/channel paths too: `pnpm test extensions/discord/src/monitor/message-handler.preflight.test.ts`
- Prefer targeted runs first when you are iterating on a single failure.
- Docker-backed QA site: `pnpm qa:lab:up`
- Linux VM-backed QA lane: `pnpm openclaw qa suite --runner multipass --scenario channel-chat-baseline`
When you touch tests or want extra confidence:
@@ -40,6 +42,26 @@ When debugging real providers/models (requires real creds):
Tip: when you only need one failing case, prefer narrowing live tests via the allowlist env vars described below.
## QA-specific runners
These commands sit beside the main test suites when you need QA-lab realism:
- `pnpm openclaw qa suite`
- Runs repo-backed QA scenarios directly on the host.
- `pnpm openclaw qa suite --runner multipass`
- Runs the same QA suite inside a disposable Multipass Linux VM.
- Keeps the same scenario-selection behavior as `qa suite` on the host.
- Reuses the same provider/model selection flags as `qa suite`.
- Live runs forward the supported QA auth inputs that are practical for the guest:
env-based provider keys, the QA live provider config path, and `CODEX_HOME`
when present.
- Output dirs must stay under the repo root so the guest can write back through
the mounted workspace.
- Writes the normal QA report + summary plus Multipass logs under
`.artifacts/qa-e2e/...`.
- `pnpm qa:lab:up`
- Starts the Docker-backed QA site for operator-style QA work.
## Test suites (what runs where)
Think of the suites as “increasing realism” (and increasing flakiness/cost):

View File

@@ -17,10 +17,22 @@ conceptual overviews, see:
- [Builtin Engine](/concepts/memory-builtin) -- default SQLite backend
- [QMD Engine](/concepts/memory-qmd) -- local-first sidecar
- [Memory Search](/concepts/memory-search) -- search pipeline and tuning
- [Active Memory](/concepts/active-memory) -- enabling the memory sub-agent for interactive sessions
All memory search settings live under `agents.defaults.memorySearch` in
`openclaw.json` unless noted otherwise.
If you are looking for the **active memory** feature toggle and sub-agent config,
that lives under `plugins.entries.active-memory` instead of `memorySearch`.
Active memory uses a two-gate model:
1. the plugin must be enabled and target the current agent id
2. the request must be an eligible interactive persistent chat session
See [Active Memory](/concepts/active-memory) for the activation model,
plugin-owned config, transcript persistence, and safe rollout pattern.
---
## Provider selection

View File

@@ -0,0 +1,50 @@
# Rich Output Protocol
Assistant output can carry a small set of delivery/render directives:
- `MEDIA:` for attachment delivery
- `[[audio_as_voice]]` for audio presentation hints
- `[[reply_to_current]]` / `[[reply_to:<id>]]` for reply metadata
- `[canvas ...]` for Control UI rich rendering
These directives are separate. `MEDIA:` and reply/voice tags remain delivery metadata; `[canvas ...]` is the web-only rich render path.
## `[canvas ...]`
`[canvas ...]` is the only agent-facing rich render syntax for the Control UI.
Self-closing example:
```text
[canvas ref="cv_123" title="Status" /]
```
Rules:
- `[view ...]` is no longer valid for new output.
- Canvas shortcodes render in the assistant message surface only.
- Only URL-backed canvases are rendered. Use `ref="..."` or `url="..."`.
- Block-form inline HTML canvas shortcodes are not rendered.
- The web UI strips the shortcode from visible text and renders the canvas inline.
- `MEDIA:` is not a canvas alias and should not be used for rich canvas rendering.
## Stored Rendering Shape
The normalized/stored assistant content block is a structured `canvas` item:
```json
{
"type": "canvas",
"preview": {
"kind": "canvas",
"surface": "assistant_message",
"render": "url",
"viewId": "cv_123",
"url": "/__openclaw__/canvas/documents/cv_123/index.html",
"title": "Status",
"preferredHeight": 320
}
}
```
Stored/rendered rich blocks use this `canvas` shape directly. `present_view` is not recognized.

View File

@@ -576,6 +576,27 @@ Notes:
- If `gateway.auth.mode` is `none` or `trusted-proxy`, these loopback browser
routes do not inherit those identity-bearing modes; keep them loopback-only.
### `/act` error contract
`POST /act` uses a structured error response for route-level validation and
policy failures:
```json
{ "error": "<message>", "code": "ACT_*" }
```
Current `code` values:
- `ACT_KIND_REQUIRED` (HTTP 400): `kind` is missing or unrecognized.
- `ACT_INVALID_REQUEST` (HTTP 400): action payload failed normalization or validation.
- `ACT_SELECTOR_UNSUPPORTED` (HTTP 400): `selector` was used with an unsupported action kind.
- `ACT_EVALUATE_DISABLED` (HTTP 403): `evaluate` (or `wait --fn`) is disabled by config.
- `ACT_TARGET_ID_MISMATCH` (HTTP 403): top-level or batched `targetId` conflicts with request target.
- `ACT_EXISTING_SESSION_UNSUPPORTED` (HTTP 501): action is not supported for existing-session profiles.
Other runtime failures may still return `{ "error": "<message>" }` without a
`code` field.
### Playwright requirement
Some features (navigate/act/AI snapshot/role snapshot, element screenshots,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,120 @@
{
"id": "active-memory",
"name": "Active Memory",
"description": "Runs a bounded blocking memory sub-agent before eligible conversational replies and injects relevant memory into prompt context.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"agents": {
"type": "array",
"items": { "type": "string" }
},
"model": { "type": "string" },
"modelFallbackPolicy": {
"type": "string",
"enum": ["default-remote", "resolved-only"]
},
"allowedChatTypes": {
"type": "array",
"items": {
"type": "string",
"enum": ["direct", "group", "channel"]
}
},
"thinking": {
"type": "string",
"enum": ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"]
},
"timeoutMs": { "type": "integer", "minimum": 250 },
"queryMode": {
"type": "string",
"enum": ["message", "recent", "full"]
},
"promptStyle": {
"type": "string",
"enum": [
"balanced",
"strict",
"contextual",
"recall-heavy",
"precision-heavy",
"preference-only"
]
},
"promptOverride": { "type": "string" },
"promptAppend": { "type": "string" },
"maxSummaryChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
"recentUserTurns": { "type": "integer", "minimum": 0, "maximum": 4 },
"recentAssistantTurns": { "type": "integer", "minimum": 0, "maximum": 3 },
"recentUserChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
"recentAssistantChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
"logging": { "type": "boolean" },
"persistTranscripts": { "type": "boolean" },
"transcriptDir": { "type": "string" },
"cacheTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 }
}
},
"uiHints": {
"enabled": {
"label": "Active Memory Recall",
"help": "Globally enable or pause Active Memory recall while keeping the plugin command available."
},
"agents": {
"label": "Target Agents",
"help": "Explicit agent ids that may use active memory."
},
"model": {
"label": "Memory Model",
"help": "Provider/model used for the blocking memory sub-agent."
},
"modelFallbackPolicy": {
"label": "Model Fallback Policy",
"help": "Choose whether Active Memory falls back to the built-in remote default model when no explicit or inherited model is available."
},
"allowedChatTypes": {
"label": "Allowed Chat Types",
"help": "Choose which session types may run Active Memory. Defaults to direct-message style sessions only."
},
"timeoutMs": {
"label": "Timeout (ms)"
},
"queryMode": {
"label": "Query Mode",
"help": "Choose whether the blocking memory sub-agent sees only the latest user message, a small recent tail, or the full conversation."
},
"promptStyle": {
"label": "Prompt Style",
"help": "Choose how eager or strict the blocking memory sub-agent should be when deciding whether to return memory."
},
"thinking": {
"label": "Thinking Override",
"help": "Advanced: optional thinking level for the blocking memory sub-agent. Defaults to off for speed."
},
"promptOverride": {
"label": "Prompt Override",
"help": "Advanced: replace the default Active Memory sub-agent instructions. Conversation context is still appended."
},
"promptAppend": {
"label": "Prompt Append",
"help": "Advanced: append extra operator instructions after the default Active Memory sub-agent instructions."
},
"maxSummaryChars": {
"label": "Max Summary Characters",
"help": "Maximum total characters allowed in the active-memory summary."
},
"logging": {
"label": "Enable Logging",
"help": "Emit active memory timing and result logs."
},
"persistTranscripts": {
"label": "Persist Transcripts",
"help": "Keep blocking memory sub-agent session transcripts on disk in a separate plugin-owned directory."
},
"transcriptDir": {
"label": "Transcript Directory",
"help": "Relative directory under the agent sessions folder used when transcript persistence is enabled."
}
}
}

View File

@@ -1,6 +1,6 @@
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
import { collectIssuesForEnabledAccounts } from "openclaw/plugin-sdk/status-helpers";
import { asRecord } from "./monitor-normalize.js";
import type { ChannelAccountSnapshot } from "./runtime-api.js";
type BlueBubblesAccountStatus = {
accountId?: unknown;

View File

@@ -5,13 +5,11 @@ export {
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_ENABLED,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
parseBrowserHttpUrl,
redactCdpUrl,
DEFAULT_UPLOAD_DIR,
resolveBrowserConfig,
resolveBrowserControlAuth,
resolveProfile,
type BrowserControlAuth,
type ResolvedBrowserConfig,
type ResolvedBrowserProfile,
} from "./src/browser/config.js";
export { DEFAULT_UPLOAD_DIR } from "./src/browser/paths.js";
} from "./browser-profiles.js";
export { resolveBrowserControlAuth, type BrowserControlAuth } from "./browser-control-auth.js";
export { parseBrowserHttpUrl, redactCdpUrl } from "./src/browser/config.js";

View File

@@ -1,2 +1,6 @@
export type { BrowserControlAuth } from "./src/browser/control-auth.js";
export { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./src/browser/control-auth.js";
export {
ensureBrowserControlAuth,
resolveBrowserControlAuth,
shouldAutoGenerateBrowserAuth,
} from "./src/browser/control-auth.js";

View File

@@ -0,0 +1,44 @@
export const ACT_MAX_BATCH_ACTIONS = 100;
export const ACT_MAX_BATCH_DEPTH = 5;
export const ACT_MAX_CLICK_DELAY_MS = 5_000;
export const ACT_MAX_WAIT_TIME_MS = 30_000;
const ACT_MIN_TIMEOUT_MS = 500;
const ACT_MAX_INTERACTION_TIMEOUT_MS = 60_000;
const ACT_MAX_WAIT_TIMEOUT_MS = 120_000;
const ACT_DEFAULT_INTERACTION_TIMEOUT_MS = 8_000;
const ACT_DEFAULT_WAIT_TIMEOUT_MS = 20_000;
export function normalizeActBoundedNonNegativeMs(
value: number | undefined,
fieldName: string,
maxMs: number,
): number | undefined {
if (value === undefined) {
return undefined;
}
if (!Number.isFinite(value) || value < 0) {
throw new Error(`${fieldName} must be >= 0`);
}
const normalized = Math.floor(value);
if (normalized > maxMs) {
throw new Error(`${fieldName} exceeds maximum of ${maxMs}ms`);
}
return normalized;
}
export function resolveActInteractionTimeoutMs(timeoutMs?: number): number {
const normalized =
typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
? Math.floor(timeoutMs)
: ACT_DEFAULT_INTERACTION_TIMEOUT_MS;
return Math.max(ACT_MIN_TIMEOUT_MS, Math.min(ACT_MAX_INTERACTION_TIMEOUT_MS, normalized));
}
export function resolveActWaitTimeoutMs(timeoutMs?: number): number {
const normalized =
typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
? Math.floor(timeoutMs)
: ACT_DEFAULT_WAIT_TIMEOUT_MS;
return Math.max(ACT_MIN_TIMEOUT_MS, Math.min(ACT_MAX_WAIT_TIMEOUT_MS, normalized));
}

View File

@@ -1,10 +1,10 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { normalizeOptionalString, readStringValue } from "openclaw/plugin-sdk/text-runtime";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { asRecord } from "../record-shared.js";
import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js";
import type { BrowserTab } from "./client.js";
@@ -332,7 +332,7 @@ async function callTool(
}
async function withTempFile<T>(fn: (filePath: string) => Promise<T>): Promise<T> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-mcp-"));
const dir = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "openclaw-chrome-mcp-"));
const filePath = path.join(dir, randomUUID());
try {
return await fn(filePath);

View File

@@ -0,0 +1,42 @@
import fs from "node:fs";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
parseBrowserMajorVersion,
resolveGoogleChromeExecutableForPlatform,
} from "./chrome.executables.js";
describe("chrome executables", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("parses odd dotted browser version tokens using the last match", () => {
expect(parseBrowserMajorVersion("Chromium 3.0/1.2.3")).toBe(1);
});
it("returns null when no dotted version token exists", () => {
expect(parseBrowserMajorVersion("no version here")).toBeNull();
});
it("classifies beta Linux Google Chrome builds as canary", () => {
vi.spyOn(fs, "existsSync").mockImplementation((candidate) => {
return String(candidate) === "/usr/bin/google-chrome-beta";
});
expect(resolveGoogleChromeExecutableForPlatform("linux")).toEqual({
kind: "canary",
path: "/usr/bin/google-chrome-beta",
});
});
it("classifies unstable Linux Google Chrome builds as canary", () => {
vi.spyOn(fs, "existsSync").mockImplementation((candidate) => {
return String(candidate) === "/usr/bin/google-chrome-unstable";
});
expect(resolveGoogleChromeExecutableForPlatform("linux")).toEqual({
kind: "canary",
path: "/usr/bin/google-chrome-unstable",
});
});
});

View File

@@ -29,6 +29,7 @@ export {
dragViaPlaywright,
emulateMediaViaPlaywright,
evaluateViaPlaywright,
executeActViaPlaywright,
fillFormViaPlaywright,
getConsoleMessagesViaPlaywright,
getNetworkRequestsViaPlaywright,

View File

@@ -2,6 +2,14 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { Frame, Page } from "playwright-core";
import { formatErrorMessage } from "../infra/errors.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import {
ACT_MAX_BATCH_ACTIONS,
ACT_MAX_BATCH_DEPTH,
ACT_MAX_CLICK_DELAY_MS,
ACT_MAX_WAIT_TIME_MS,
resolveActInteractionTimeoutMs,
resolveActWaitTimeoutMs,
} from "./act-policy.js";
import type { BrowserActRequest, BrowserFormField } from "./client-actions-core.js";
import { DEFAULT_FILL_FIELD_TYPE } from "./form-fields.js";
import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
@@ -26,9 +34,6 @@ type TargetOpts = {
targetId?: string;
};
const MAX_CLICK_DELAY_MS = 5_000;
const MAX_WAIT_TIME_MS = 30_000;
const MAX_BATCH_ACTIONS = 100;
const INTERACTION_NAVIGATION_GRACE_MS = 250;
type NavigationObservablePage = Pick<Page, "url"> & {
@@ -57,9 +62,7 @@ async function getRestoredPageForTarget(opts: TargetOpts) {
return page;
}
function resolveInteractionTimeoutMs(timeoutMs?: number): number {
return Math.max(500, Math.min(60_000, Math.floor(timeoutMs ?? 8000)));
}
const resolveInteractionTimeoutMs = resolveActInteractionTimeoutMs;
// Returns true only when the URL change indicates a cross-document navigation
// (i.e., a real network fetch occurred). Same-document hash-only mutations —
@@ -319,22 +322,64 @@ async function assertInteractionNavigationCompletedSafely<T>(opts: {
return result as T;
}
async function awaitEvalWithAbort<T>(
evalPromise: Promise<T>,
async function awaitActionWithAbort<T>(
actionPromise: Promise<T>,
abortPromise?: Promise<never>,
): Promise<T> {
if (!abortPromise) {
return await evalPromise;
return await actionPromise;
}
try {
return await Promise.race([evalPromise, abortPromise]);
return await Promise.race([actionPromise, abortPromise]);
} catch (err) {
// If abort wins the race, evaluate may reject later; avoid unhandled rejections.
void evalPromise.catch(() => {});
// If abort wins the race, the action may reject later; avoid unhandled rejections.
void actionPromise.catch(() => {});
throw err;
}
}
function createAbortPromise(signal?: AbortSignal): {
abortPromise?: Promise<never>;
cleanup: () => void;
} {
return createAbortPromiseWithListener(signal);
}
function createAbortPromiseWithListener(
signal?: AbortSignal,
onAbort?: () => void,
): {
abortPromise?: Promise<never>;
cleanup: () => void;
} {
if (!signal) {
return { cleanup: () => {} };
}
let abortListener: (() => void) | undefined;
const abortPromise: Promise<never> = signal.aborted
? (() => {
onAbort?.();
return Promise.reject(signal.reason ?? new Error("aborted"));
})()
: new Promise((_, reject) => {
abortListener = () => {
onAbort?.();
reject(signal.reason ?? new Error("aborted"));
};
signal.addEventListener("abort", abortListener, { once: true });
});
// Avoid unhandled rejections on early returns.
void abortPromise.catch(() => {});
return {
abortPromise,
cleanup: () => {
if (abortListener) {
signal.removeEventListener("abort", abortListener);
}
},
};
}
async function assertPostInteractionNavigationSafe(opts: {
cdpUrl: string;
page: Awaited<ReturnType<typeof getPageForTargetId>>;
@@ -390,7 +435,11 @@ export async function clickViaPlaywright(opts: {
try {
await assertInteractionNavigationCompletedSafely({
action: async () => {
const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS);
const delayMs = resolveBoundedDelayMs(
opts.delayMs,
"click delayMs",
ACT_MAX_CLICK_DELAY_MS,
);
if (delayMs > 0) {
await locator.hover({ timeout });
await new Promise((resolve) => setTimeout(resolve, delayMs));
@@ -629,38 +678,15 @@ export async function evaluateViaPlaywright(opts: {
evaluateTimeout = Math.min(evaluateTimeout, outerTimeout);
const signal = opts.signal;
let abortListener: (() => void) | undefined;
let abortReject: ((reason: unknown) => void) | undefined;
let abortPromise: Promise<never> | undefined;
if (signal) {
abortPromise = new Promise((_, reject) => {
abortReject = reject;
});
// Ensure the abort promise never becomes an unhandled rejection if we throw early.
void abortPromise.catch(() => {});
}
if (signal) {
const disconnect = () => {
void forceDisconnectPlaywrightForTarget({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
reason: "evaluate aborted",
}).catch(() => {});
};
if (signal.aborted) {
disconnect();
throw signal.reason ?? new Error("aborted");
}
abortListener = () => {
disconnect();
abortReject?.(signal.reason ?? new Error("aborted"));
};
signal.addEventListener("abort", abortListener, { once: true });
// If the signal aborted between the initial check and listener registration, handle it.
if (signal.aborted) {
abortListener();
throw signal.reason ?? new Error("aborted");
}
const { abortPromise, cleanup } = createAbortPromiseWithListener(signal, () => {
void forceDisconnectPlaywrightForTarget({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
reason: "evaluate aborted",
}).catch(() => {});
});
if (signal?.aborted) {
throw signal.reason ?? new Error("aborted");
}
try {
@@ -696,7 +722,7 @@ export async function evaluateViaPlaywright(opts: {
timeoutMs: evaluateTimeout,
});
const result = await assertInteractionNavigationCompletedSafely({
action: () => awaitEvalWithAbort(evalPromise, abortPromise),
action: () => awaitActionWithAbort(evalPromise, abortPromise),
cdpUrl: opts.cdpUrl,
page,
previousUrl,
@@ -735,7 +761,7 @@ export async function evaluateViaPlaywright(opts: {
timeoutMs: evaluateTimeout,
});
const result = await assertInteractionNavigationCompletedSafely({
action: () => awaitEvalWithAbort(evalPromise, abortPromise),
action: () => awaitActionWithAbort(evalPromise, abortPromise),
cdpUrl: opts.cdpUrl,
page,
previousUrl,
@@ -744,9 +770,7 @@ export async function evaluateViaPlaywright(opts: {
});
return result;
} finally {
if (signal && abortListener) {
signal.removeEventListener("abort", abortListener);
}
cleanup();
}
}
@@ -783,46 +807,63 @@ export async function waitForViaPlaywright(opts: {
loadState?: "load" | "domcontentloaded" | "networkidle";
fn?: string;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000);
const timeout = resolveActWaitTimeoutMs(opts.timeoutMs);
const { abortPromise, cleanup } = createAbortPromise(opts.signal);
const waitForStep = async <T>(stepPromise: Promise<T>) => {
await awaitActionWithAbort(stepPromise, abortPromise);
};
if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) {
await page.waitForTimeout(resolveBoundedDelayMs(opts.timeMs, "wait timeMs", MAX_WAIT_TIME_MS));
}
if (opts.text) {
await page.getByText(opts.text).first().waitFor({
state: "visible",
timeout,
});
}
if (opts.textGone) {
await page.getByText(opts.textGone).first().waitFor({
state: "hidden",
timeout,
});
}
if (opts.selector) {
const selector = normalizeOptionalString(opts.selector) ?? "";
if (selector) {
await page.locator(selector).first().waitFor({ state: "visible", timeout });
try {
if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) {
await waitForStep(
page.waitForTimeout(
resolveBoundedDelayMs(opts.timeMs, "wait timeMs", ACT_MAX_WAIT_TIME_MS),
),
);
}
}
if (opts.url) {
const url = normalizeOptionalString(opts.url) ?? "";
if (url) {
await page.waitForURL(url, { timeout });
if (opts.text) {
await waitForStep(
page.getByText(opts.text).first().waitFor({
state: "visible",
timeout,
}),
);
}
}
if (opts.loadState) {
await page.waitForLoadState(opts.loadState, { timeout });
}
if (opts.fn) {
const fn = normalizeOptionalString(opts.fn) ?? "";
if (fn) {
await page.waitForFunction(fn, { timeout });
if (opts.textGone) {
await waitForStep(
page.getByText(opts.textGone).first().waitFor({
state: "hidden",
timeout,
}),
);
}
if (opts.selector) {
const selector = normalizeOptionalString(opts.selector) ?? "";
if (selector) {
await waitForStep(page.locator(selector).first().waitFor({ state: "visible", timeout }));
}
}
if (opts.url) {
const url = normalizeOptionalString(opts.url) ?? "";
if (url) {
await waitForStep(page.waitForURL(url, { timeout }));
}
}
if (opts.loadState) {
await waitForStep(page.waitForLoadState(opts.loadState, { timeout }));
}
if (opts.fn) {
const fn = normalizeOptionalString(opts.fn) ?? "";
if (fn) {
await waitForStep(page.waitForFunction(fn, { timeout }));
}
}
} finally {
cleanup();
}
}
@@ -1039,8 +1080,6 @@ export async function setInputFilesViaPlaywright(opts: {
}
}
const MAX_BATCH_DEPTH = 5;
async function executeSingleAction(
action: BrowserActRequest,
cdpUrl: string,
@@ -1048,9 +1087,10 @@ async function executeSingleAction(
evaluateEnabled?: boolean,
ssrfPolicy?: SsrFPolicy,
depth = 0,
): Promise<void> {
if (depth > MAX_BATCH_DEPTH) {
throw new Error(`Batch nesting depth exceeds maximum of ${MAX_BATCH_DEPTH}`);
signal?: AbortSignal,
): Promise<unknown> {
if (depth > ACT_MAX_BATCH_DEPTH) {
throw new Error(`Batch nesting depth exceeds maximum of ${ACT_MAX_BATCH_DEPTH}`);
}
const effectiveTargetId = action.targetId ?? targetId;
switch (action.kind) {
@@ -1162,21 +1202,22 @@ async function executeSingleAction(
loadState: action.loadState,
fn: action.fn,
timeoutMs: action.timeoutMs,
signal,
});
break;
case "evaluate":
if (!evaluateEnabled) {
throw new Error("act:evaluate is disabled by config (browser.evaluateEnabled=false)");
}
await evaluateViaPlaywright({
return await evaluateViaPlaywright({
cdpUrl,
targetId: effectiveTargetId,
ssrfPolicy,
fn: action.fn,
ref: action.ref,
timeoutMs: action.timeoutMs,
signal,
});
break;
case "close":
await closePageViaPlaywright({
cdpUrl,
@@ -1192,11 +1233,51 @@ async function executeSingleAction(
stopOnError: action.stopOnError,
evaluateEnabled,
depth: depth + 1,
signal,
});
break;
default:
throw new Error(`Unsupported batch action kind: ${(action as { kind: string }).kind}`);
}
return undefined;
}
export async function executeActViaPlaywright(opts: {
cdpUrl: string;
action: BrowserActRequest;
targetId?: string;
evaluateEnabled?: boolean;
ssrfPolicy?: SsrFPolicy;
signal?: AbortSignal;
}): Promise<{
result?: unknown;
results?: Array<{ ok: boolean; error?: string }>;
}> {
if (opts.action.kind === "batch") {
const batch = await batchViaPlaywright({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
ssrfPolicy: opts.ssrfPolicy,
actions: opts.action.actions,
stopOnError: opts.action.stopOnError,
evaluateEnabled: opts.evaluateEnabled,
signal: opts.signal,
});
return { results: batch.results };
}
const result = await executeSingleAction(
opts.action,
opts.cdpUrl,
opts.targetId,
opts.evaluateEnabled,
opts.ssrfPolicy,
0,
opts.signal,
);
if (opts.action.kind === "evaluate") {
return { result };
}
return {};
}
export async function batchViaPlaywright(opts: {
@@ -1207,16 +1288,20 @@ export async function batchViaPlaywright(opts: {
evaluateEnabled?: boolean;
ssrfPolicy?: SsrFPolicy;
depth?: number;
signal?: AbortSignal;
}): Promise<{ results: Array<{ ok: boolean; error?: string }> }> {
const depth = opts.depth ?? 0;
if (depth > MAX_BATCH_DEPTH) {
throw new Error(`Batch nesting depth exceeds maximum of ${MAX_BATCH_DEPTH}`);
if (depth > ACT_MAX_BATCH_DEPTH) {
throw new Error(`Batch nesting depth exceeds maximum of ${ACT_MAX_BATCH_DEPTH}`);
}
if (opts.actions.length > MAX_BATCH_ACTIONS) {
throw new Error(`Batch exceeds maximum of ${MAX_BATCH_ACTIONS} actions`);
if (opts.actions.length > ACT_MAX_BATCH_ACTIONS) {
throw new Error(`Batch exceeds maximum of ${ACT_MAX_BATCH_ACTIONS} actions`);
}
const results: Array<{ ok: boolean; error?: string }> = [];
for (const action of opts.actions) {
if (opts.signal?.aborted) {
throw opts.signal.reason ?? new Error("aborted");
}
try {
await executeSingleAction(
action,
@@ -1225,6 +1310,7 @@ export async function batchViaPlaywright(opts: {
opts.evaluateEnabled,
opts.ssrfPolicy,
depth,
opts.signal,
);
results.push({ ok: true });
} catch (err) {

View File

@@ -150,4 +150,51 @@ describe("pw-tools-core", () => {
timeout: 1234,
});
});
it("clamps wait timeoutMs to 120000 for wait steps", async () => {
const waitForSelector = vi.fn(async () => {});
const page = {
locator: vi.fn(() => ({
first: () => ({ waitFor: waitForSelector }),
})),
waitForURL: vi.fn(async () => {}),
waitForLoadState: vi.fn(async () => {}),
waitForFunction: vi.fn(async () => {}),
waitForTimeout: vi.fn(async () => {}),
getByText: vi.fn(() => ({ first: () => ({ waitFor: vi.fn() }) })),
};
setPwToolsCoreCurrentPage(page);
await mod.waitForViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
selector: "#main",
timeoutMs: 999_999,
});
expect(waitForSelector).toHaveBeenCalledWith({
state: "visible",
timeout: 120_000,
});
});
it("clamps interaction timeoutMs to 60000 for click steps", async () => {
const click = vi.fn(async () => {});
const page = {
url: vi.fn(() => "https://example.com"),
locator: vi.fn(() => ({ click })),
};
setPwToolsCoreCurrentPage(page);
await mod.clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
selector: "#main",
timeoutMs: 999_999,
});
expect(click).toHaveBeenCalledWith(
expect.objectContaining({
timeout: 60_000,
}),
);
});
});

View File

@@ -6,6 +6,7 @@ import {
resolveTargetIdFromBody,
withRouteTabContext,
} from "./agent.shared.js";
import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
import { ensureOutputRootDir, resolveWritableOutputPathOrRespond } from "./output-paths.js";
import { DEFAULT_DOWNLOAD_DIR } from "./path-output.js";
import type { BrowserRouteRegistrar } from "./types.js";
@@ -36,11 +37,7 @@ export function registerBrowserAgentActDownloadRoutes(
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(
res,
501,
"download waiting is not supported for existing-session profiles yet.",
);
return jsonError(res, 501, EXISTING_SESSION_LIMITS.download.waitUnsupported);
}
const pw = await requirePwAi(res, "wait for download");
if (!pw) {
@@ -90,11 +87,7 @@ export function registerBrowserAgentActDownloadRoutes(
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(
res,
501,
"downloads are not supported for existing-session profiles yet.",
);
return jsonError(res, 501, EXISTING_SESSION_LIMITS.download.downloadUnsupported);
}
const pw = await requirePwAi(res, "download");
if (!pw) {

View File

@@ -0,0 +1,30 @@
import type { BrowserResponse } from "./types.js";
export const ACT_ERROR_CODES = {
kindRequired: "ACT_KIND_REQUIRED",
invalidRequest: "ACT_INVALID_REQUEST",
selectorUnsupported: "ACT_SELECTOR_UNSUPPORTED",
evaluateDisabled: "ACT_EVALUATE_DISABLED",
unsupportedForExistingSession: "ACT_EXISTING_SESSION_UNSUPPORTED",
targetIdMismatch: "ACT_TARGET_ID_MISMATCH",
} as const;
export type ActErrorCode = (typeof ACT_ERROR_CODES)[keyof typeof ACT_ERROR_CODES];
export function jsonActError(
res: BrowserResponse,
status: number,
code: ActErrorCode,
message: string,
) {
res.status(status).json({ error: message, code });
}
export function browserEvaluateDisabledMessage(action: "wait" | "evaluate"): string {
return [
action === "wait"
? "wait --fn is disabled by config (browser.evaluateEnabled=false)."
: "act:evaluate is disabled by config (browser.evaluateEnabled=false).",
"Docs: /gateway/configuration#browser-openclaw-managed-browser",
].join("\n");
}

View File

@@ -7,6 +7,7 @@ import {
resolveTargetIdFromBody,
withRouteTabContext,
} from "./agent.shared.js";
import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
import { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "./path-output.js";
import type { BrowserRouteRegistrar } from "./types.js";
import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
@@ -46,22 +47,14 @@ export function registerBrowserAgentActHookRoutes(
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (element) {
return jsonError(
res,
501,
"existing-session file uploads do not support element selectors; use ref/inputRef.",
);
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadElement);
}
if (resolvedPaths.length !== 1) {
return jsonError(
res,
501,
"existing-session file uploads currently support one file at a time.",
);
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadSingleFile);
}
const uid = inputRef || ref;
if (!uid) {
return jsonError(res, 501, "existing-session file uploads require ref or inputRef.");
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadRefRequired);
}
await uploadChromeMcpFile({
profileName: profileCtx.profile.name,
@@ -128,11 +121,7 @@ export function registerBrowserAgentActHookRoutes(
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (timeoutMs) {
return jsonError(
res,
501,
"existing-session dialog handling does not support timeoutMs.",
);
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.dialogTimeout);
}
await evaluateChromeMcpScript({
profileName: profileCtx.profile.name,

View File

@@ -0,0 +1,321 @@
import {
ACT_MAX_BATCH_ACTIONS,
ACT_MAX_CLICK_DELAY_MS,
ACT_MAX_WAIT_TIME_MS,
normalizeActBoundedNonNegativeMs,
} from "../act-policy.js";
import type { BrowserActRequest, BrowserFormField } from "../client-actions-core.js";
import { normalizeBrowserFormField } from "../form-fields.js";
import {
type ActKind,
isActKind,
parseClickButton,
parseClickModifiers,
} from "./agent.act.shared.js";
import { toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
function normalizeActKind(raw: unknown): ActKind {
const kind = toStringOrEmpty(raw);
if (!isActKind(kind)) {
throw new Error("kind is required");
}
return kind;
}
export function countBatchActions(actions: BrowserActRequest[]): number {
let count = 0;
for (const action of actions) {
count += 1;
if (action.kind === "batch") {
count += countBatchActions(action.actions);
}
}
return count;
}
export function validateBatchTargetIds(
actions: BrowserActRequest[],
targetId: string,
): string | null {
for (const action of actions) {
if (action.targetId && action.targetId !== targetId) {
return "batched action targetId must match request targetId";
}
if (action.kind === "batch") {
const nestedError = validateBatchTargetIds(action.actions, targetId);
if (nestedError) {
return nestedError;
}
}
}
return null;
}
function normalizeFields(rawFields: unknown): BrowserFormField[] {
const entries = Array.isArray(rawFields) ? rawFields : [];
return entries
.map((field) => {
if (!field || typeof field !== "object") {
return null;
}
return normalizeBrowserFormField(field as Record<string, unknown>);
})
.filter((field): field is BrowserFormField => field !== null);
}
function normalizeBatchAction(value: unknown): BrowserActRequest {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("batch actions must be objects");
}
return normalizeActRequest(value as Record<string, unknown>, { source: "batch" });
}
export function normalizeActRequest(
body: Record<string, unknown>,
options?: { source?: "request" | "batch" },
): BrowserActRequest {
const source = options?.source ?? "request";
const kind = normalizeActKind(body.kind);
switch (kind) {
case "click": {
const ref = toStringOrEmpty(body.ref) || undefined;
const selector = toStringOrEmpty(body.selector) || undefined;
if (!ref && !selector) {
throw new Error("click requires ref or selector");
}
const buttonRaw = toStringOrEmpty(body.button);
const button = buttonRaw ? parseClickButton(buttonRaw) : undefined;
if (buttonRaw && !button) {
throw new Error("click button must be left|right|middle");
}
const modifiersRaw = toStringArray(body.modifiers) ?? [];
const parsedModifiers = parseClickModifiers(modifiersRaw);
if (parsedModifiers.error) {
throw new Error(parsedModifiers.error);
}
const doubleClick = toBoolean(body.doubleClick);
const delayMs = normalizeActBoundedNonNegativeMs(
toNumber(body.delayMs),
"click delayMs",
ACT_MAX_CLICK_DELAY_MS,
);
const timeoutMs = toNumber(body.timeoutMs);
const targetId = toStringOrEmpty(body.targetId) || undefined;
return {
kind,
...(ref ? { ref } : {}),
...(selector ? { selector } : {}),
...(targetId ? { targetId } : {}),
...(doubleClick !== undefined ? { doubleClick } : {}),
...(button ? { button } : {}),
...(parsedModifiers.modifiers ? { modifiers: parsedModifiers.modifiers } : {}),
...(delayMs !== undefined ? { delayMs } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
};
}
case "type": {
const ref = toStringOrEmpty(body.ref) || undefined;
const selector = toStringOrEmpty(body.selector) || undefined;
const text = body.text;
if (!ref && !selector) {
throw new Error("type requires ref or selector");
}
if (typeof text !== "string") {
throw new Error("type requires text");
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
const submit = toBoolean(body.submit);
const slowly = toBoolean(body.slowly);
const timeoutMs = toNumber(body.timeoutMs);
return {
kind,
...(ref ? { ref } : {}),
...(selector ? { selector } : {}),
text,
...(targetId ? { targetId } : {}),
...(submit !== undefined ? { submit } : {}),
...(slowly !== undefined ? { slowly } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
};
}
case "press": {
const key = toStringOrEmpty(body.key);
if (!key) {
throw new Error("press requires key");
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
const delayMs = toNumber(body.delayMs);
return {
kind,
key,
...(targetId ? { targetId } : {}),
...(delayMs !== undefined ? { delayMs } : {}),
};
}
case "hover":
case "scrollIntoView": {
const ref = toStringOrEmpty(body.ref) || undefined;
const selector = toStringOrEmpty(body.selector) || undefined;
if (!ref && !selector) {
throw new Error(`${kind} requires ref or selector`);
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
const timeoutMs = toNumber(body.timeoutMs);
return {
kind,
...(ref ? { ref } : {}),
...(selector ? { selector } : {}),
...(targetId ? { targetId } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
};
}
case "drag": {
const startRef = toStringOrEmpty(body.startRef) || undefined;
const startSelector = toStringOrEmpty(body.startSelector) || undefined;
const endRef = toStringOrEmpty(body.endRef) || undefined;
const endSelector = toStringOrEmpty(body.endSelector) || undefined;
if (!startRef && !startSelector) {
throw new Error("drag requires startRef or startSelector");
}
if (!endRef && !endSelector) {
throw new Error("drag requires endRef or endSelector");
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
const timeoutMs = toNumber(body.timeoutMs);
return {
kind,
...(startRef ? { startRef } : {}),
...(startSelector ? { startSelector } : {}),
...(endRef ? { endRef } : {}),
...(endSelector ? { endSelector } : {}),
...(targetId ? { targetId } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
};
}
case "select": {
const ref = toStringOrEmpty(body.ref) || undefined;
const selector = toStringOrEmpty(body.selector) || undefined;
const values = toStringArray(body.values);
if ((!ref && !selector) || !values?.length) {
throw new Error("select requires ref/selector and values");
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
const timeoutMs = toNumber(body.timeoutMs);
return {
kind,
...(ref ? { ref } : {}),
...(selector ? { selector } : {}),
values,
...(targetId ? { targetId } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
};
}
case "fill": {
const fields = normalizeFields(body.fields);
if (!fields.length) {
throw new Error("fill requires fields");
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
const timeoutMs = toNumber(body.timeoutMs);
return {
kind,
fields,
...(targetId ? { targetId } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
};
}
case "resize": {
const width = toNumber(body.width);
const height = toNumber(body.height);
if (width === undefined || height === undefined || width <= 0 || height <= 0) {
throw new Error("resize requires positive width and height");
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
return {
kind,
width,
height,
...(targetId ? { targetId } : {}),
};
}
case "wait": {
const loadStateRaw = toStringOrEmpty(body.loadState);
const loadState =
loadStateRaw === "load" ||
loadStateRaw === "domcontentloaded" ||
loadStateRaw === "networkidle"
? loadStateRaw
: undefined;
const timeMs = normalizeActBoundedNonNegativeMs(
toNumber(body.timeMs),
"wait timeMs",
ACT_MAX_WAIT_TIME_MS,
);
const text = toStringOrEmpty(body.text) || undefined;
const textGone = toStringOrEmpty(body.textGone) || undefined;
const selector = toStringOrEmpty(body.selector) || undefined;
const url = toStringOrEmpty(body.url) || undefined;
const fn = toStringOrEmpty(body.fn) || undefined;
if (timeMs === undefined && !text && !textGone && !selector && !url && !loadState && !fn) {
throw new Error(
"wait requires at least one of: timeMs, text, textGone, selector, url, loadState, fn",
);
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
const timeoutMs = toNumber(body.timeoutMs);
return {
kind,
...(timeMs !== undefined ? { timeMs } : {}),
...(text ? { text } : {}),
...(textGone ? { textGone } : {}),
...(selector ? { selector } : {}),
...(url ? { url } : {}),
...(loadState ? { loadState } : {}),
...(fn ? { fn } : {}),
...(targetId ? { targetId } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
};
}
case "evaluate": {
const fn = toStringOrEmpty(body.fn);
if (!fn) {
throw new Error("evaluate requires fn");
}
const ref = toStringOrEmpty(body.ref) || undefined;
const targetId = toStringOrEmpty(body.targetId) || undefined;
const timeoutMs = toNumber(body.timeoutMs);
return {
kind,
fn,
...(ref ? { ref } : {}),
...(targetId ? { targetId } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
};
}
case "close": {
const targetId = toStringOrEmpty(body.targetId) || undefined;
return {
kind,
...(targetId ? { targetId } : {}),
};
}
case "batch": {
const actions = Array.isArray(body.actions) ? body.actions.map(normalizeBatchAction) : [];
if (!actions.length) {
throw new Error(source === "batch" ? "batch requires actions" : "actions are required");
}
if (countBatchActions(actions) > ACT_MAX_BATCH_ACTIONS) {
throw new Error(`batch exceeds maximum of ${ACT_MAX_BATCH_ACTIONS} actions`);
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
const stopOnError = toBoolean(body.stopOnError);
return {
kind,
actions,
...(targetId ? { targetId } : {}),
...(stopOnError !== undefined ? { stopOnError } : {}),
};
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,7 @@ import {
shouldUsePlaywrightForAriaSnapshot,
shouldUsePlaywrightForScreenshot,
} from "./agent.snapshot.plan.js";
import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
import type { BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { jsonError, toBoolean, toStringOrEmpty } from "./utils.js";
@@ -270,11 +271,7 @@ export function registerBrowserAgentSnapshotRoutes(
return;
}
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(
res,
501,
"pdf is not supported for existing-session profiles yet; use screenshot/snapshot instead.",
);
return jsonError(res, 501, EXISTING_SESSION_LIMITS.snapshot.pdfUnsupported);
}
await withPlaywrightRouteContext({
req,
@@ -319,11 +316,7 @@ export function registerBrowserAgentSnapshotRoutes(
run: async ({ profileCtx, tab, cdpUrl }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (element) {
return jsonError(
res,
400,
"element screenshots are not supported for existing-session profiles; use ref from snapshot.",
);
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.screenshotElement);
}
const buffer = await takeChromeMcpScreenshot({
profileName: profileCtx.profile.name,
@@ -404,11 +397,7 @@ export function registerBrowserAgentSnapshotRoutes(
}
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (plan.selectorValue || plan.frameSelectorValue) {
return jsonError(
res,
400,
"selector/frame snapshots are not supported for existing-session profiles; snapshot the whole page and use refs.",
);
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.snapshotSelector);
}
const snapshot = await takeChromeMcpSnapshot({
profileName: profileCtx.profile.name,

View File

@@ -0,0 +1,45 @@
export const EXISTING_SESSION_LIMITS = {
act: {
clickSelector: "existing-session click does not support selector targeting yet; use ref.",
clickButtonOrModifiers:
"existing-session click currently supports left-click only (no button overrides/modifiers).",
typeSelector: "existing-session type does not support selector targeting yet; use ref.",
typeSlowly: "existing-session type does not support slowly=true; use fill/press instead.",
pressDelay: "existing-session press does not support delayMs.",
hoverSelector: "existing-session hover does not support selector targeting yet; use ref.",
hoverTimeout: "existing-session hover does not support timeoutMs overrides.",
scrollSelector:
"existing-session scrollIntoView does not support selector targeting yet; use ref.",
scrollTimeout: "existing-session scrollIntoView does not support timeoutMs overrides.",
dragSelector:
"existing-session drag does not support selector targeting yet; use startRef/endRef.",
dragTimeout: "existing-session drag does not support timeoutMs overrides.",
selectSelector: "existing-session select does not support selector targeting yet; use ref.",
selectSingleValue: "existing-session select currently supports a single value only.",
selectTimeout: "existing-session select does not support timeoutMs overrides.",
fillTimeout: "existing-session fill does not support timeoutMs overrides.",
waitNetworkIdle: "existing-session wait does not support loadState=networkidle yet.",
evaluateTimeout: "existing-session evaluate does not support timeoutMs overrides.",
batch: "existing-session batch is not supported yet; send actions individually.",
},
hooks: {
uploadElement:
"existing-session file uploads do not support element selectors; use ref/inputRef.",
uploadSingleFile: "existing-session file uploads currently support one file at a time.",
uploadRefRequired: "existing-session file uploads require ref or inputRef.",
dialogTimeout: "existing-session dialog handling does not support timeoutMs.",
},
download: {
waitUnsupported: "download waiting is not supported for existing-session profiles yet.",
downloadUnsupported: "downloads are not supported for existing-session profiles yet.",
},
snapshot: {
pdfUnsupported:
"pdf is not supported for existing-session profiles yet; use screenshot/snapshot instead.",
screenshotElement:
"element screenshots are not supported for existing-session profiles; use ref from snapshot.",
snapshotSelector:
"selector/frame snapshots are not supported for existing-session profiles; snapshot the whole page and use refs.",
},
responseBody: "response body is not supported for existing-session profiles yet.",
} as const;

View File

@@ -0,0 +1,176 @@
import { describe, expect, it } from "vitest";
import {
installAgentContractHooks,
startServerAndBase,
} from "./server.agent-contract.test-harness.js";
import {
setBrowserControlServerEvaluateEnabled,
setBrowserControlServerProfiles,
} from "./server.control-server.test-harness.js";
import { getBrowserTestFetch } from "./test-fetch.js";
type ActErrorResponse = {
error?: string;
code?: string;
};
type ActErrorHttpResponse = {
status: number;
body: ActErrorResponse;
};
async function postActAndReadError(base: string, body?: unknown): Promise<ActErrorHttpResponse> {
const realFetch = getBrowserTestFetch();
const response = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
return {
status: response.status,
body: (await response.json()) as ActErrorResponse,
};
}
describe("browser control server", () => {
installAgentContractHooks();
const slowTimeoutMs = process.platform === "win32" ? 40_000 : 20_000;
it(
"returns ACT_KIND_REQUIRED when kind is missing",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_KIND_REQUIRED");
expect(response.body.error).toContain("kind is required");
},
slowTimeoutMs,
);
it(
"returns ACT_INVALID_REQUEST for malformed action payloads",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "click",
ref: {},
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_INVALID_REQUEST");
expect(response.body.error).toContain("click requires ref or selector");
},
slowTimeoutMs,
);
it(
"returns ACT_EXISTING_SESSION_UNSUPPORTED for unsupported existing-session actions",
async () => {
setBrowserControlServerProfiles({
openclaw: {
color: "#FF4500",
driver: "existing-session",
},
});
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "batch",
actions: [{ kind: "press", key: "Enter" }],
});
expect(response.status).toBe(501);
expect(response.body.code).toBe("ACT_EXISTING_SESSION_UNSUPPORTED");
expect(response.body.error).toContain("batch");
},
slowTimeoutMs,
);
it(
"returns ACT_TARGET_ID_MISMATCH for batched action targetId overrides",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "batch",
actions: [{ kind: "click", ref: "5", targetId: "other-tab" }],
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH");
expect(response.body.error).toContain("batched action targetId must match request targetId");
},
slowTimeoutMs,
);
it(
"returns ACT_TARGET_ID_MISMATCH for top-level action targetId overrides",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "click",
ref: "5",
// Intentionally non-string: route-level target selection ignores this,
// while action normalization stringifies it.
targetId: 12345,
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH");
expect(response.body.error).toContain("action targetId must match request targetId");
},
slowTimeoutMs,
);
it(
"returns ACT_SELECTOR_UNSUPPORTED for selector on unsupported action kinds",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "evaluate",
fn: "() => 1",
selector: "#submit",
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_SELECTOR_UNSUPPORTED");
expect(response.body.error).toContain("'selector' is not supported");
},
slowTimeoutMs,
);
it(
"returns ACT_INVALID_REQUEST for malformed unsupported selector actions before selector gating",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "press",
selector: "#submit",
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_INVALID_REQUEST");
expect(response.body.error).toContain("press requires key");
},
slowTimeoutMs,
);
it(
"returns ACT_EVALUATE_DISABLED when evaluate is blocked by config",
async () => {
setBrowserControlServerEvaluateEnabled(false);
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "evaluate",
fn: "() => 1",
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_EVALUATE_DISABLED");
expect(response.body.error).toContain("browser.evaluateEnabled=false");
},
slowTimeoutMs,
);
});

View File

@@ -107,18 +107,36 @@ describe("browser control server", () => {
}),
);
const resizeZero = await postJson<{ error?: string; code?: string }>(`${base}/act`, {
kind: "resize",
width: 0,
height: 600,
});
expect(resizeZero.code).toBe("ACT_INVALID_REQUEST");
expect(resizeZero.error).toContain("resize requires positive width and height");
expect(pwMocks.resizeViewportViaPlaywright).toHaveBeenCalledTimes(1);
const resizeNegative = await postJson<{ error?: string; code?: string }>(`${base}/act`, {
kind: "resize",
width: -800,
height: 600,
});
expect(resizeNegative.code).toBe("ACT_INVALID_REQUEST");
expect(resizeNegative.error).toContain("resize requires positive width and height");
expect(pwMocks.resizeViewportViaPlaywright).toHaveBeenCalledTimes(1);
const wait = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "wait",
timeMs: 5,
});
expect(wait.ok).toBe(true);
expect(pwMocks.waitForViaPlaywright).toHaveBeenCalledWith({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
timeMs: 5,
text: undefined,
textGone: undefined,
});
expect(pwMocks.waitForViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
timeMs: 5,
}),
);
const evalRes = await postJson<{ ok: boolean; result?: string }>(`${base}/act`, {
kind: "evaluate",
@@ -220,12 +238,13 @@ describe("browser control server", () => {
async () => {
const base = await startServerAndBase();
const batchRes = await postJson<{ error?: string }>(`${base}/act`, {
const batchRes = await postJson<{ error?: string; code?: string }>(`${base}/act`, {
kind: "batch",
actions: [{ kind: "click", ref: {} }],
});
expect(batchRes.error).toContain("click requires ref or selector");
expect(batchRes.code).toBe("ACT_INVALID_REQUEST");
expect(pwMocks.batchViaPlaywright).not.toHaveBeenCalled();
},
slowTimeoutMs,
@@ -236,12 +255,13 @@ describe("browser control server", () => {
async () => {
const base = await startServerAndBase();
const batchRes = await postJson<{ error?: string }>(`${base}/act`, {
const batchRes = await postJson<{ error?: string; code?: string }>(`${base}/act`, {
kind: "batch",
actions: [{ kind: "click", ref: "5", targetId: "other-tab" }],
});
expect(batchRes.error).toContain("batched action targetId must match request targetId");
expect(batchRes.code).toBe("ACT_TARGET_ID_MISMATCH");
expect(pwMocks.batchViaPlaywright).not.toHaveBeenCalled();
},
slowTimeoutMs,

View File

@@ -90,17 +90,21 @@ describe("browser control server", () => {
modifiers: ["Shift"],
});
expect(click.ok).toBe(true);
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(1, {
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
doubleClick: false,
button: "left",
modifiers: ["Shift"],
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
button: "left",
modifiers: ["Shift"],
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [clickArgs] = pwMocks.clickViaPlaywright.mock.calls[0] ?? [];
expect((clickArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined();
const clickSelector = await realFetch(`${base}/act`, {
method: "POST",
@@ -109,15 +113,19 @@ describe("browser control server", () => {
});
expect(clickSelector.status).toBe(200);
expect(((await clickSelector.json()) as { ok?: boolean }).ok).toBe(true);
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(2, {
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
selector: "button.save",
doubleClick: false,
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
selector: "button.save",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [clickSelectorArgs] = pwMocks.clickViaPlaywright.mock.calls[1] ?? [];
expect((clickSelectorArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined();
const type = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "type",
@@ -125,53 +133,69 @@ describe("browser control server", () => {
text: "",
});
expect(type.ok).toBe(true);
expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith(1, {
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
text: "",
submit: false,
slowly: false,
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
text: "",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [typeArgs] = pwMocks.typeViaPlaywright.mock.calls[0] ?? [];
expect((typeArgs as { submit?: boolean }).submit).toBeUndefined();
expect((typeArgs as { slowly?: boolean }).slowly).toBeUndefined();
const press = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "press",
key: "Enter",
});
expect(press.ok).toBe(true);
expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
key: "Enter",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
key: "Enter",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [pressArgs] = pwMocks.pressKeyViaPlaywright.mock.calls[0] ?? [];
expect((pressArgs as { delayMs?: number }).delayMs).toBeUndefined();
const hover = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "hover",
ref: "2",
});
expect(hover.ok).toBe(true);
expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
});
expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
}),
);
const [hoverArgs] = pwMocks.hoverViaPlaywright.mock.calls[0] ?? [];
expect((hoverArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
const scroll = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "scrollIntoView",
ref: "2",
});
expect(scroll.ok).toBe(true);
expect(pwMocks.scrollIntoViewViaPlaywright).toHaveBeenCalledWith({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
});
expect(pwMocks.scrollIntoViewViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
}),
);
const [scrollArgs] = pwMocks.scrollIntoViewViaPlaywright.mock.calls[0] ?? [];
expect((scrollArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
const drag = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "drag",
@@ -179,11 +203,15 @@ describe("browser control server", () => {
endRef: "4",
});
expect(drag.ok).toBe(true);
expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
startRef: "3",
endRef: "4",
});
expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
startRef: "3",
endRef: "4",
}),
);
const [dragArgs] = pwMocks.dragViaPlaywright.mock.calls[0] ?? [];
expect((dragArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
});
});

View File

@@ -95,50 +95,206 @@ export function getCdpMocks(): { createTargetViaCdp: MockFn; snapshotAria: MockF
return cdpMocks as unknown as { createTargetViaCdp: MockFn; snapshotAria: MockFn };
}
type ExecuteActMockAction = { kind: string } & Record<string, unknown>;
type ExecuteActMockOptions = {
cdpUrl: string;
action: ExecuteActMockAction;
targetId?: string;
ssrfPolicy?: unknown;
evaluateEnabled?: boolean;
signal?: AbortSignal;
};
type PassThroughActDispatch = {
mock: (opts?: unknown) => Promise<unknown>;
fields: readonly string[];
includeSsrf?: boolean;
includeSignal?: boolean;
};
function pickActionFields(
action: ExecuteActMockAction,
fields: readonly string[],
): Record<string, unknown> {
const picked: Record<string, unknown> = {};
for (const field of fields) {
picked[field] = action[field];
}
return picked;
}
function buildActPayload(params: {
cdpUrl: string;
targetId?: string;
action: ExecuteActMockAction;
fields: readonly string[];
ssrfPolicy?: unknown;
signal?: AbortSignal;
includeSsrf?: boolean;
includeSignal?: boolean;
}): Record<string, unknown> {
return {
cdpUrl: params.cdpUrl,
targetId: params.targetId,
...pickActionFields(params.action, params.fields),
...(params.includeSsrf ? { ssrfPolicy: params.ssrfPolicy } : {}),
...(params.includeSignal ? { signal: params.signal } : {}),
};
}
const pwMocks = vi.hoisted(() => ({
armDialogViaPlaywright: vi.fn(async () => {}),
armFileUploadViaPlaywright: vi.fn(async () => {}),
batchViaPlaywright: vi.fn(async () => ({ results: [] })),
clickViaPlaywright: vi.fn(async () => {}),
closePageViaPlaywright: vi.fn(async () => {}),
batchViaPlaywright: vi.fn(async (_opts?: unknown) => ({ results: [] })),
clickViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
closePageViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
closePlaywrightBrowserConnection: vi.fn(async () => {}),
downloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
dragViaPlaywright: vi.fn(async () => {}),
evaluateViaPlaywright: vi.fn(async () => "ok"),
fillFormViaPlaywright: vi.fn(async () => {}),
dragViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
evaluateViaPlaywright: vi.fn(async (_opts?: unknown) => "ok"),
fillFormViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
hoverViaPlaywright: vi.fn(async () => {}),
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
hoverViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
scrollIntoViewViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
pressKeyViaPlaywright: vi.fn(async () => {}),
pressKeyViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
responseBodyViaPlaywright: vi.fn(async () => ({
url: "https://example.com/api/data",
status: 200,
headers: { "content-type": "application/json" },
body: '{"ok":true}',
})),
resizeViewportViaPlaywright: vi.fn(async () => {}),
selectOptionViaPlaywright: vi.fn(async () => {}),
resizeViewportViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
selectOptionViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
setInputFilesViaPlaywright: vi.fn(async () => {}),
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
traceStopViaPlaywright: vi.fn(async () => {}),
takeScreenshotViaPlaywright: vi.fn(async () => ({
buffer: Buffer.from("png"),
})),
typeViaPlaywright: vi.fn(async () => {}),
typeViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
waitForDownloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
waitForViaPlaywright: vi.fn(async () => {}),
waitForViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
executeActViaPlaywright: vi.fn(async (_opts?: ExecuteActMockOptions) => ({})),
}));
const passThroughActDispatch: Record<string, PassThroughActDispatch> = {
click: {
mock: pwMocks.clickViaPlaywright,
fields: ["ref", "selector", "doubleClick", "button", "modifiers", "delayMs", "timeoutMs"],
includeSsrf: true,
},
type: {
mock: pwMocks.typeViaPlaywright,
fields: ["ref", "selector", "text", "submit", "slowly", "timeoutMs"],
includeSsrf: true,
},
press: {
mock: pwMocks.pressKeyViaPlaywright,
fields: ["key", "delayMs"],
includeSsrf: true,
},
hover: {
mock: pwMocks.hoverViaPlaywright,
fields: ["ref", "selector", "timeoutMs"],
},
scrollIntoView: {
mock: pwMocks.scrollIntoViewViaPlaywright,
fields: ["ref", "selector", "timeoutMs"],
},
drag: {
mock: pwMocks.dragViaPlaywright,
fields: ["startRef", "startSelector", "endRef", "endSelector", "timeoutMs"],
},
select: {
mock: pwMocks.selectOptionViaPlaywright,
fields: ["ref", "selector", "values", "timeoutMs"],
},
fill: {
mock: pwMocks.fillFormViaPlaywright,
fields: ["fields", "timeoutMs"],
},
resize: {
mock: pwMocks.resizeViewportViaPlaywright,
fields: ["width", "height"],
},
wait: {
mock: pwMocks.waitForViaPlaywright,
fields: ["timeMs", "text", "textGone", "selector", "url", "loadState", "fn", "timeoutMs"],
includeSignal: true,
},
close: {
mock: pwMocks.closePageViaPlaywright,
fields: [],
},
};
pwMocks.executeActViaPlaywright.mockImplementation(
async (opts: ExecuteActMockOptions | undefined) => {
if (!opts) {
return {};
}
const { cdpUrl, action, targetId, ssrfPolicy, evaluateEnabled, signal } = opts;
const spec = passThroughActDispatch[action.kind];
if (spec) {
await spec.mock(
buildActPayload({
cdpUrl,
targetId,
action,
fields: spec.fields,
ssrfPolicy,
signal,
includeSsrf: spec.includeSsrf,
includeSignal: spec.includeSignal,
}),
);
return {};
}
switch (action.kind) {
case "evaluate": {
if (!evaluateEnabled) {
throw new Error("act:evaluate is disabled by config (browser.evaluateEnabled=false)");
}
const result = await pwMocks.evaluateViaPlaywright({
cdpUrl,
targetId,
ssrfPolicy,
fn: action.fn,
ref: action.ref,
timeoutMs: action.timeoutMs,
signal,
});
return { result };
}
case "batch": {
const result = await pwMocks.batchViaPlaywright({
cdpUrl,
targetId,
actions: action.actions,
stopOnError: action.stopOnError,
evaluateEnabled,
ssrfPolicy,
signal,
});
return { results: result.results };
}
default:
return {};
}
},
);
export function getPwMocks(): Record<string, MockFn> {
return pwMocks as unknown as Record<string, MockFn>;
}

View File

@@ -1,13 +1,13 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolvePreferredOpenClawTmpDir } from "../api.js";
import { DiffArtifactStore } from "./store.js";
export async function createTempDiffRoot(prefix: string): Promise<{
rootDir: string;
cleanup: () => Promise<void>;
}> {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
const rootDir = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), prefix));
return {
rootDir,
cleanup: async () => {

View File

@@ -248,9 +248,9 @@ function createBoundThreadBindingManager(params: {
function createDispatchSpy() {
const dispatchSpy = vi
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
.fn<typeof dispatcherModule.dispatchReplyWithDispatcher>()
.mockResolvedValue({} as never);
nativeCommandTesting.setDispatchReplyWithDispatcher(dispatcherModule.dispatchReplyWithDispatcher);
nativeCommandTesting.setDispatchReplyWithDispatcher(dispatchSpy);
return dispatchSpy;
}

View File

@@ -1,9 +1,7 @@
export type { RuntimeEnv } from "../runtime-api.js";
export { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime";
export { applyBasicWebhookRequestGuards } from "openclaw/plugin-sdk/webhook-ingress";
export {
applyBasicWebhookRequestGuards,
isRequestBodyLimitError,
readRequestBodyWithLimit,
requestBodyErrorToText,
} from "openclaw/plugin-sdk/webhook-ingress";
export { installRequestBodyLimitGuard } from "openclaw/plugin-sdk/webhook-request-guards";
installRequestBodyLimitGuard,
readWebhookBodyOrReject,
} from "openclaw/plugin-sdk/webhook-request-guards";

View File

@@ -4,11 +4,9 @@ import * as Lark from "@larksuiteoapi/node-sdk";
import { createFeishuWSClient } from "./client.js";
import {
applyBasicWebhookRequestGuards,
isRequestBodyLimitError,
type RuntimeEnv,
installRequestBodyLimitGuard,
readRequestBodyWithLimit,
requestBodyErrorToText,
readWebhookBodyOrReject,
safeEqualSecret,
} from "./monitor-transport-runtime-api.js";
import {
@@ -190,13 +188,20 @@ export async function monitorWebhook({
void (async () => {
try {
const rawBody = await readRequestBodyWithLimit(req, {
const body = await readWebhookBodyOrReject({
req,
res,
maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES,
timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
profile: "pre-auth",
});
if (guard.isTripped() || res.writableEnded) {
if (!body.ok || res.writableEnded) {
return;
}
if (guard.isTripped()) {
return;
}
const rawBody = body.value;
// Reject invalid signatures before any JSON parsing to keep the auth boundary strict.
if (
@@ -235,17 +240,9 @@ export async function monitorWebhook({
res.end(JSON.stringify(value));
}
} catch (err) {
if (isRequestBodyLimitError(err)) {
if (!res.headersSent) {
respondText(res, err.statusCode, requestBodyErrorToText(err.code));
}
return;
}
if (!guard.isTripped()) {
error(`feishu[${accountId}]: webhook handler error: ${String(err)}`);
if (!res.headersSent) {
respondText(res, 500, "Internal Server Error");
}
error(`feishu[${accountId}]: webhook handler error: ${String(err)}`);
if (!res.headersSent) {
respondText(res, 500, "Internal Server Error");
}
} finally {
guard.dispose();

View File

@@ -1,5 +1,5 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "../runtime-api.js";
const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } =
createPluginRuntimeStore<PluginRuntime>("Feishu runtime not initialized");

View File

@@ -74,7 +74,7 @@ describe("fireworks provider plugin", () => {
expect(catalog.provider.baseUrl).toBe(FIREWORKS_BASE_URL);
expect(catalog.provider.models?.map((model) => model.id)).toEqual([FIREWORKS_DEFAULT_MODEL_ID]);
expect(catalog.provider.models?.[0]).toMatchObject({
reasoning: true,
reasoning: false,
input: ["text", "image"],
contextWindow: FIREWORKS_DEFAULT_CONTEXT_WINDOW,
maxTokens: FIREWORKS_DEFAULT_MAX_TOKENS,
@@ -112,4 +112,64 @@ describe("fireworks provider plugin", () => {
reasoning: true,
});
});
it("disables reasoning metadata for Fireworks Kimi dynamic models", async () => {
const provider = await registerSingleProviderPlugin(fireworksPlugin);
const resolved = provider.resolveDynamicModel?.(
createDynamicContext({
provider: "fireworks",
modelId: "accounts/fireworks/models/kimi-k2p5",
models: [
{
id: FIREWORKS_DEFAULT_MODEL_ID,
name: FIREWORKS_DEFAULT_MODEL_ID,
provider: "fireworks",
api: "openai-completions",
baseUrl: FIREWORKS_BASE_URL,
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: FIREWORKS_DEFAULT_CONTEXT_WINDOW,
maxTokens: FIREWORKS_DEFAULT_MAX_TOKENS,
},
],
}),
);
expect(resolved).toMatchObject({
provider: "fireworks",
id: "accounts/fireworks/models/kimi-k2p5",
reasoning: false,
});
});
it("disables reasoning metadata for Fireworks Kimi k2.5 aliases", async () => {
const provider = await registerSingleProviderPlugin(fireworksPlugin);
const resolved = provider.resolveDynamicModel?.(
createDynamicContext({
provider: "fireworks",
modelId: "accounts/fireworks/routers/kimi-k2.5-turbo",
models: [
{
id: FIREWORKS_DEFAULT_MODEL_ID,
name: FIREWORKS_DEFAULT_MODEL_ID,
provider: "fireworks",
api: "openai-completions",
baseUrl: FIREWORKS_BASE_URL,
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: FIREWORKS_DEFAULT_CONTEXT_WINDOW,
maxTokens: FIREWORKS_DEFAULT_MAX_TOKENS,
},
],
}),
);
expect(resolved).toMatchObject({
provider: "fireworks",
id: "accounts/fireworks/routers/kimi-k2.5-turbo",
reasoning: false,
});
});
});

View File

@@ -6,6 +6,7 @@ import {
DEFAULT_CONTEXT_TOKENS,
normalizeModelCompat,
} from "openclaw/plugin-sdk/provider-model-shared";
import { isFireworksKimiModelId } from "./model-id.js";
import { applyFireworksConfig, FIREWORKS_DEFAULT_MODEL_REF } from "./onboard.js";
import {
buildFireworksProvider,
@@ -14,6 +15,7 @@ import {
FIREWORKS_DEFAULT_MAX_TOKENS,
FIREWORKS_DEFAULT_MODEL_ID,
} from "./provider-catalog.js";
import { wrapFireworksProviderStream } from "./stream.js";
const PROVIDER_ID = "fireworks";
const OPENAI_COMPATIBLE_REPLAY_HOOKS = buildProviderReplayFamilyHooks({
@@ -34,6 +36,7 @@ function resolveFireworksDynamicModel(ctx: ProviderResolveDynamicModelContext) {
ctx,
patch: {
provider: PROVIDER_ID,
reasoning: !isFireworksKimiModelId(modelId),
},
}) ??
normalizeModelCompat({
@@ -42,7 +45,7 @@ function resolveFireworksDynamicModel(ctx: ProviderResolveDynamicModelContext) {
provider: PROVIDER_ID,
api: "openai-completions",
baseUrl: FIREWORKS_BASE_URL,
reasoning: true,
reasoning: !isFireworksKimiModelId(modelId),
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: FIREWORKS_DEFAULT_CONTEXT_WINDOW,
@@ -77,6 +80,7 @@ export default defineSingleProviderPluginEntry({
allowExplicitBaseUrl: true,
},
...OPENAI_COMPATIBLE_REPLAY_HOOKS,
wrapStreamFn: wrapFireworksProviderStream,
resolveDynamicModel: (ctx) => resolveFireworksDynamicModel(ctx),
isModernModelRef: () => true,
},

View File

@@ -0,0 +1,5 @@
export function isFireworksKimiModelId(modelId: string): boolean {
const normalized = modelId.trim().toLowerCase();
const lastSegment = normalized.split("/").pop() ?? normalized;
return /^kimi-k2(?:p5|\.5)(?:[-_].+)?$/.test(lastSegment);
}

View File

@@ -20,7 +20,7 @@ export function buildFireworksCatalogModels(): ModelDefinitionConfig[] {
{
id: FIREWORKS_DEFAULT_MODEL_ID,
name: "Kimi K2.5 Turbo (Fire Pass)",
reasoning: true,
reasoning: false, // Kimi K2.5 can expose reasoning in visible content on FirePass.
input: ["text", "image"],
cost: ZERO_COST,
contextWindow: FIREWORKS_DEFAULT_CONTEXT_WINDOW,

View File

@@ -0,0 +1,155 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { Context, Model } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import {
createFireworksKimiThinkingDisabledWrapper,
wrapFireworksProviderStream,
} from "./stream.js";
function capturePayload(params: {
provider: string;
api: string;
modelId: string;
initialPayload?: Record<string, unknown>;
}): Record<string, unknown> {
let captured: Record<string, unknown> = {};
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload = { ...params.initialPayload };
options?.onPayload?.(payload, _model);
captured = payload;
return {} as ReturnType<StreamFn>;
};
const wrapped = createFireworksKimiThinkingDisabledWrapper(baseStreamFn);
void wrapped(
{
api: params.api,
provider: params.provider,
id: params.modelId,
} as Model<"openai-completions">,
{ messages: [] } as Context,
{},
);
return captured;
}
describe("createFireworksKimiThinkingDisabledWrapper", () => {
it("forces thinking disabled for Fireworks Kimi models", () => {
expect(
capturePayload({
provider: "fireworks",
api: "openai-completions",
modelId: "accounts/fireworks/routers/kimi-k2p5-turbo",
}),
).toMatchObject({ thinking: { type: "disabled" } });
});
it("forces thinking disabled for Fireworks Kimi k2.5 aliases", () => {
expect(
capturePayload({
provider: "fireworks",
api: "openai-completions",
modelId: "accounts/fireworks/routers/kimi-k2.5-turbo",
}),
).toMatchObject({ thinking: { type: "disabled" } });
});
it("strips reasoning fields when disabling Fireworks Kimi thinking", () => {
const payload = capturePayload({
provider: "fireworks",
api: "openai-completions",
modelId: "accounts/fireworks/models/kimi-k2p5",
initialPayload: {
reasoning_effort: "low",
reasoning: { effort: "low" },
reasoningEffort: "low",
},
});
expect(payload).toEqual({ thinking: { type: "disabled" } });
});
it("passes sanitized payloads to caller onPayload hooks", () => {
let callbackPayload: Record<string, unknown> = {};
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload = {
reasoning_effort: "high",
reasoning: { effort: "high" },
};
options?.onPayload?.(payload, _model);
return {} as ReturnType<StreamFn>;
};
const wrapped = createFireworksKimiThinkingDisabledWrapper(baseStreamFn);
void wrapped(
{
api: "openai-completions",
provider: "fireworks",
id: "accounts/fireworks/routers/kimi-k2p5-turbo",
} as Model<"openai-completions">,
{ messages: [] } as Context,
{
onPayload: (payload) => {
callbackPayload = payload as Record<string, unknown>;
},
},
);
expect(callbackPayload).toEqual({ thinking: { type: "disabled" } });
});
it("returns no provider wrapper for non-target Fireworks requests", () => {
expect(
wrapFireworksProviderStream({
provider: "fireworks",
modelId: "accounts/fireworks/models/qwen3.6-plus",
model: {
api: "openai-completions",
provider: "fireworks",
id: "accounts/fireworks/models/qwen3.6-plus",
} as Model<"openai-completions">,
streamFn: undefined,
} as never),
).toBeUndefined();
expect(
wrapFireworksProviderStream({
provider: "fireworks",
modelId: "accounts/fireworks/routers/kimi-k2p5-turbo",
model: {
api: "openai-responses",
provider: "fireworks",
id: "accounts/fireworks/routers/kimi-k2p5-turbo",
} as Model<"openai-responses">,
streamFn: undefined,
} as never),
).toBeUndefined();
expect(
wrapFireworksProviderStream({
provider: "fireworks-ai",
modelId: "accounts/fireworks/routers/kimi-k2p5-turbo",
model: {
api: "openai-completions",
provider: "fireworks-ai",
id: "accounts/fireworks/routers/kimi-k2p5-turbo",
} as Model<"openai-completions">,
streamFn: undefined,
} as never),
).toBeTypeOf("function");
expect(
wrapFireworksProviderStream({
provider: "openai",
modelId: "gpt-5.4",
model: {
api: "openai-completions",
provider: "openai",
id: "gpt-5.4",
} as Model<"openai-completions">,
streamFn: undefined,
} as never),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,39 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream-shared";
import { isFireworksKimiModelId } from "./model-id.js";
function isFireworksProviderId(providerId: string): boolean {
const normalized = normalizeProviderId(providerId);
return normalized === "fireworks" || normalized === "fireworks-ai";
}
export function createFireworksKimiThinkingDisabledWrapper(
baseStreamFn: StreamFn | undefined,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) =>
streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
// Fireworks Kimi can emit chain-of-thought in visible `content` unless
// the Anthropic-style thinking toggle is explicitly disabled.
payloadObj.thinking = { type: "disabled" };
delete payloadObj.reasoning;
delete payloadObj.reasoning_effort;
delete payloadObj.reasoningEffort;
});
}
export function wrapFireworksProviderStream(
ctx: ProviderWrapStreamFnContext,
): StreamFn | undefined {
if (
!isFireworksProviderId(ctx.provider) ||
ctx.model?.api !== "openai-completions" ||
!isFireworksKimiModelId(ctx.modelId)
) {
return undefined;
}
return createFireworksKimiThinkingDisabledWrapper(ctx.streamFn);
}

View File

@@ -1,9 +1,9 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { GoogleGenAI } from "@google/genai";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type {
GeneratedVideoAsset,
@@ -124,7 +124,9 @@ async function downloadGeneratedVideo(params: {
file: unknown;
index: number;
}): Promise<GeneratedVideoAsset> {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-video-"));
const tempDir = await mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-google-video-"),
);
const downloadPath = path.join(tempDir, `video-${params.index + 1}.mp4`);
try {
await params.client.files.download({

View File

@@ -104,6 +104,22 @@ describe("imessage monitor gating + envelope builders", () => {
).toBeNull();
});
it("parseIMessageNotification preserves destination_caller_id metadata", () => {
expect(
parseIMessageNotification({
message: {
id: 1,
sender: "+15550001111",
destination_caller_id: "+15550002222",
is_from_me: true,
text: "hello",
},
}),
).toMatchObject({
destination_caller_id: "+15550002222",
});
});
it("drops group messages without mention by default", () => {
const decision = resolve({
message: {

View File

@@ -170,6 +170,7 @@ export function resolveIMessageInboundDecision(params: {
const chatId = params.message.chat_id ?? undefined;
const chatGuid = params.message.chat_guid ?? undefined;
const chatIdentifier = params.message.chat_identifier ?? undefined;
const destinationCallerId = params.message.destination_caller_id ?? undefined;
const createdAt = params.message.created_at ? Date.parse(params.message.created_at) : undefined;
const messageText = params.messageText.trim();
const bodyText = params.bodyText.trim();
@@ -203,14 +204,16 @@ export function resolveIMessageInboundDecision(params: {
text: bodyText,
createdAt,
};
// Self-chat detection: in self-chat, sender == chat_identifier (both are the
// user's own handle). When is_from_me=true in self-chat, the message could be
// either: (a) a real user message typed by the user, or (b) an agent reply
// echo reflected back by iMessage. We must distinguish them.
const chatIdentifierNormalized = normalizeIMessageHandle(chatIdentifier ?? "") || undefined;
const destinationCallerIdNormalized =
normalizeIMessageHandle(destinationCallerId ?? "") || undefined;
const matchesSelfChatDestination =
destinationCallerIdNormalized == null || destinationCallerIdNormalized === senderNormalized;
const isSelfChat =
!isGroup &&
chatIdentifier != null &&
normalizeIMessageHandle(sender) === normalizeIMessageHandle(chatIdentifier);
chatIdentifierNormalized != null &&
senderNormalized === chatIdentifierNormalized &&
matchesSelfChatDestination;
// Track whether we already processed the is_from_me=true self-chat path.
// When true, the selfChatCache.has() check below must be skipped — we just
// called remember() and would immediately match our own entry.

View File

@@ -61,6 +61,7 @@ export function parseIMessageNotification(raw: unknown): IMessagePayload | null
!isOptionalString(message.guid) ||
!isOptionalNumber(message.chat_id) ||
!isOptionalString(message.sender) ||
!isOptionalString(message.destination_caller_id) ||
!isOptionalBoolean(message.is_from_me) ||
!isOptionalString(message.text) ||
!isOptionalStringOrNumber(message.reply_to_id) ||

View File

@@ -344,7 +344,6 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
});
it("processes real user self-chat message (is_from_me=true, no echo cache match)", () => {
// User sends "Hello" to themselves — is_from_me=true, sender==chat_identifier
const echoCache = createSentMessageCache();
const selfChatCache = createSelfChatCache();
@@ -354,6 +353,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
id: 123703,
sender: "+15551234567",
chat_identifier: "+15551234567",
destination_caller_id: "+15551234567",
text: "Hello this is a test message",
is_from_me: true,
is_group: false,
@@ -365,10 +365,60 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
}),
);
// Real user message — should be dispatched, not dropped
expect(decision.kind).toBe("dispatch");
});
it("treats blank destination_caller_id as missing for real self-chat", () => {
const echoCache = createSentMessageCache();
const selfChatCache = createSelfChatCache();
const decision = resolveIMessageInboundDecision(
createParams({
message: {
id: 123704,
sender: "+15551234567",
chat_identifier: "+15551234567",
destination_caller_id: "",
text: "Hello this is a test message",
is_from_me: true,
is_group: false,
},
messageText: "Hello this is a test message",
bodyText: "Hello this is a test message",
echoCache,
selfChatCache,
}),
);
expect(decision.kind).toBe("dispatch");
});
it("drops DM false positives even when participant lists include the local handle", () => {
const echoCache = createSentMessageCache();
const selfChatCache = createSelfChatCache();
const decision = resolveIMessageInboundDecision(
createParams({
message: {
id: 123705,
sender: "+15551234567",
chat_identifier: "+15551234567",
destination_caller_id: "me@icloud.com",
participants: ["+15551234567", "me@icloud.com"],
text: "Hello from a normal DM row",
is_from_me: true,
is_group: false,
},
messageText: "Hello from a normal DM row",
bodyText: "Hello from a normal DM row",
echoCache,
selfChatCache,
}),
);
expect(decision).toEqual({ kind: "drop", reason: "from me" });
});
it("drops agent reply echo in self-chat (is_from_me=true, echo cache text match)", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
@@ -575,7 +625,36 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
}),
);
// sender != chat_identifier → not self-chat → dropped as "from me"
expect(decision).toEqual({ kind: "drop", reason: "from me" });
});
it("uses destination_caller_id to avoid DM self-chat false positives", () => {
const echoCache = createSentMessageCache();
const selfChatCache = createSelfChatCache();
echoCache.remember("default:imessage:+15551234567", {
text: "Clean outbound text",
messageId: "p:0/GUID-outbound",
});
const decision = resolveIMessageInboundDecision(
createParams({
message: {
id: 10001,
sender: "+15551234567",
chat_identifier: "+15551234567",
destination_caller_id: "+15550001111",
text: "<22>\u0001corrupted stored text",
is_from_me: true,
is_group: false,
},
messageText: "<22>\u0001corrupted stored text",
bodyText: "<22>\u0001corrupted stored text",
echoCache,
selfChatCache,
}),
);
expect(decision).toEqual({ kind: "drop", reason: "from me" });
});

View File

@@ -12,6 +12,7 @@ export type IMessagePayload = {
guid?: string | null;
chat_id?: number | null;
sender?: string | null;
destination_caller_id?: string | null;
is_from_me?: boolean | null;
text?: string | null;
reply_to_id?: number | string | null;

View File

@@ -1,2 +0,0 @@
export type { MatrixLegacyCryptoInspectionResult } from "./src/matrix/legacy-crypto-inspector.js";
export { inspectLegacyMatrixCryptoStore } from "./src/matrix/legacy-crypto-inspector.js";

View File

@@ -0,0 +1,36 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const LEGACY_CRYPTO_INSPECTOR_BASENAME_RE = /^legacy-crypto-inspector(?:[-.].*)?\.js$/u;
function hasSourceInspectorArtifact(currentDir: string): boolean {
return [
path.resolve(currentDir, "matrix", "legacy-crypto-inspector.ts"),
path.resolve(currentDir, "matrix", "legacy-crypto-inspector.js"),
].some((candidate) => fs.existsSync(candidate));
}
function hasBuiltInspectorArtifact(currentDir: string): boolean {
if (fs.existsSync(path.join(currentDir, "legacy-crypto-inspector.js"))) {
return true;
}
if (fs.existsSync(path.join(currentDir, "extensions", "matrix", "legacy-crypto-inspector.js"))) {
return true;
}
return fs
.readdirSync(currentDir, { withFileTypes: true })
.some((entry) => entry.isFile() && LEGACY_CRYPTO_INSPECTOR_BASENAME_RE.test(entry.name));
}
export function isMatrixLegacyCryptoInspectorAvailable(): boolean {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
if (hasSourceInspectorArtifact(currentDir)) {
return true;
}
try {
return hasBuiltInspectorArtifact(currentDir);
} catch {
return false;
}
}

View File

@@ -1,8 +1,17 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withTempHome } from "../../../test/helpers/temp-home.js";
const legacyCryptoInspectorAvailability = vi.hoisted(() => ({
available: true,
}));
vi.mock("./legacy-crypto-inspector-availability.js", () => ({
isMatrixLegacyCryptoInspectorAvailable: () => legacyCryptoInspectorAvailability.available,
}));
import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js";
import { resolveMatrixAccountStorageRoot } from "./storage-paths.js";
import {
@@ -74,7 +83,9 @@ function createOpsLegacyCryptoFixture(params: {
}
describe("matrix legacy encrypted-state migration", () => {
afterEach(() => {});
afterEach(() => {
legacyCryptoInspectorAvailability.available = true;
});
it("extracts a saved backup key into the new recovery-key path", async () => {
await withTempHome(async (home) => {
@@ -191,4 +202,31 @@ describe("matrix legacy encrypted-state migration", () => {
expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(true);
});
});
it("stays warning-only when the legacy crypto inspector artifact is unavailable", async () => {
legacyCryptoInspectorAvailability.available = false;
await withTempHome(async (home) => {
const { cfg } = writeDefaultLegacyCryptoFixture(home);
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.plans).toHaveLength(1);
expect(detection.warnings).toContain(
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.",
);
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
});
expect(result).toEqual({
migrated: false,
changes: [],
warnings: [
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.",
],
});
});
});
});

View File

@@ -5,6 +5,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { writeJsonFileAtomically as writeJsonFileAtomicallyImpl } from "openclaw/plugin-sdk/json-store";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { resolveConfiguredMatrixAccountIds } from "./account-selection.js";
import { isMatrixLegacyCryptoInspectorAvailable } from "./legacy-crypto-inspector-availability.js";
import { formatMatrixErrorMessage } from "./matrix/errors.js";
import {
resolveLegacyMatrixFlatStoreTarget,
@@ -108,10 +109,6 @@ type MatrixStoredRecoveryKey = {
};
};
function isMatrixLegacyCryptoInspectorAvailable(): boolean {
return true;
}
async function loadMatrixLegacyCryptoInspector(): Promise<MatrixLegacyCryptoInspector> {
const module = await import("./matrix/legacy-crypto-inspector.js");
return module.inspectLegacyMatrixCryptoStore as MatrixLegacyCryptoInspector;
@@ -362,6 +359,18 @@ export async function autoPrepareLegacyMatrixCrypto(params: {
warnings,
};
}
if (!params.deps?.inspectLegacyStore && !isMatrixLegacyCryptoInspectorAvailable()) {
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: false,
changes,
warnings,
};
}
let inspectLegacyStore = params.deps?.inspectLegacyStore;
if (!inspectLegacyStore) {

View File

@@ -2,6 +2,15 @@ import fs from "node:fs";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome } from "../../../test/helpers/temp-home.js";
const legacyCryptoInspectorAvailability = vi.hoisted(() => ({
available: true,
}));
vi.mock("./legacy-crypto-inspector-availability.js", () => ({
isMatrixLegacyCryptoInspectorAvailable: () => legacyCryptoInspectorAvailability.available,
}));
import { detectLegacyMatrixCrypto } from "./legacy-crypto.js";
import {
hasActionableMatrixMigration,
@@ -16,6 +25,7 @@ const createBackupArchiveMock = vi.hoisted(() => vi.fn());
describe("matrix migration snapshots", () => {
beforeEach(() => {
createBackupArchiveMock.mockReset();
legacyCryptoInspectorAvailability.available = true;
createBackupArchiveMock.mockImplementation(
async (params: { output?: string; includeWorkspace?: boolean }) => {
const outputDir = params.output;
@@ -124,4 +134,49 @@ describe("matrix migration snapshots", () => {
).toBe(true);
});
});
it("keeps legacy Matrix crypto pending but not actionable when the inspector artifact is unavailable", async () => {
legacyCryptoInspectorAvailability.available = false;
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
});
fs.mkdirSync(path.join(rootDir, "crypto"), { recursive: true });
fs.writeFileSync(
path.join(rootDir, "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: "DEVICE123" }),
"utf8",
);
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
} as never;
const detection = detectLegacyMatrixCrypto({
cfg,
env: process.env,
});
expect(detection.plans).toHaveLength(1);
expect(detection.warnings).toContain(
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.",
);
expect(
hasActionableMatrixMigration({
cfg,
env: process.env,
}),
).toBe(false);
});
});
});

View File

@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { isMatrixLegacyCryptoInspectorAvailable } from "./legacy-crypto-inspector-availability.js";
import { detectLegacyMatrixCrypto } from "./legacy-crypto.js";
import { detectLegacyMatrixState } from "./legacy-state.js";
import {
@@ -8,10 +9,6 @@ import {
type MatrixMigrationSnapshotResult,
} from "./migration-snapshot-backup.js";
function isMatrixLegacyCryptoInspectorAvailable(): boolean {
return true;
}
export function hasPendingMatrixMigration(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;

View File

@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { resolveMemoryRemDreamingConfig } from "openclaw/plugin-sdk/memory-core-host-status";
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import {
colorize,
defaultRuntime,
@@ -158,7 +159,9 @@ async function createHistoricalRemHarnessWorkspace(params: {
skippedPaths: string[];
}> {
const sourceFiles = await listHistoricalDailyFiles(params.inputPath);
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-rem-harness-"));
const workspaceDir = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-rem-harness-"),
);
const memoryDir = path.join(workspaceDir, "memory");
await fs.mkdir(memoryDir, { recursive: true });
for (const filePath of sourceFiles) {
@@ -1720,7 +1723,9 @@ export async function runMemoryRemBackfill(opts: MemoryRemBackfillOptions) {
return;
}
const scratchDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-rem-backfill-"));
const scratchDir = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-rem-backfill-"),
);
try {
const sourceFiles = await listHistoricalDailyFiles(opts.path);
if (sourceFiles.length === 0) {

View File

@@ -52,13 +52,17 @@ function createHarness(initialConfig: OpenClawConfig = {}) {
};
}
function createCommandContext(args?: string): PluginCommandContext {
function createCommandContext(
args?: string,
overrides?: Partial<Pick<PluginCommandContext, "gatewayClientScopes">>,
): PluginCommandContext {
return {
channel: "webchat",
isAuthorizedSender: true,
commandBody: args ? `/dreaming ${args}` : "/dreaming",
args,
config: {},
gatewayClientScopes: overrides?.gatewayClientScopes,
requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
detachConversationBinding: async () => ({ removed: false }),
getCurrentConversationBinding: async () => null,
@@ -115,6 +119,48 @@ describe("memory-core /dreaming command", () => {
expect(result.text).toContain("Dreaming disabled.");
});
it("blocks unscoped gateway callers from persisting dreaming config", async () => {
const { command, runtime } = createHarness();
const result = await command.handler(
createCommandContext("off", {
gatewayClientScopes: [],
}),
);
expect(result.text).toContain("requires operator.admin");
expect(runtime.config.writeConfigFile).not.toHaveBeenCalled();
});
it("blocks write-scoped gateway callers from persisting dreaming config", async () => {
const { command, runtime } = createHarness();
const result = await command.handler(
createCommandContext("off", {
gatewayClientScopes: ["operator.write"],
}),
);
expect(result.text).toContain("requires operator.admin");
expect(runtime.config.writeConfigFile).not.toHaveBeenCalled();
});
it("allows admin-scoped gateway callers to persist dreaming config", async () => {
const { command, runtime, getRuntimeConfig } = createHarness();
const result = await command.handler(
createCommandContext("on", {
gatewayClientScopes: ["operator.admin"],
}),
);
expect(runtime.config.writeConfigFile).toHaveBeenCalledTimes(1);
expect(resolveStoredDreaming(getRuntimeConfig())).toMatchObject({
enabled: true,
});
expect(result.text).toContain("Dreaming enabled.");
});
it("returns status without mutating config", async () => {
const { command, runtime } = createHarness({
plugins: {

View File

@@ -75,6 +75,10 @@ function formatUsage(includeStatus: string): string {
].join("\n");
}
function requiresAdminToMutateDreaming(gatewayClientScopes?: readonly string[]): boolean {
return Array.isArray(gatewayClientScopes) && !gatewayClientScopes.includes("operator.admin");
}
export function registerDreamingCommand(api: OpenClawPluginApi): void {
api.registerCommand({
name: "dreaming",
@@ -102,6 +106,9 @@ export function registerDreamingCommand(api: OpenClawPluginApi): void {
}
if (firstToken === "on" || firstToken === "off") {
if (requiresAdminToMutateDreaming(ctx.gatewayClientScopes)) {
return { text: "⚠️ /dreaming on|off requires operator.admin for gateway clients." };
}
const enabled = firstToken === "on";
const nextConfig = updateDreamingEnabledInConfig(currentConfig, enabled);
await api.runtime.config.writeConfigFile(nextConfig);

View File

@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
appendNarrativeEntry,
buildBackfillDiaryEntry,
@@ -18,6 +18,10 @@ import { createMemoryCoreTestHarness } from "./test-helpers.js";
const { createTempWorkspace } = createMemoryCoreTestHarness();
afterEach(() => {
vi.restoreAllMocks();
});
describe("buildNarrativePrompt", () => {
it("builds a prompt from snippets only", () => {
const data: NarrativePhaseData = {
@@ -312,6 +316,64 @@ describe("appendNarrativeEntry", () => {
// Original content should still be there, after the diary.
expect(content).toContain("# Existing");
});
it("keeps existing diary content intact when the atomic replace fails", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
await fs.writeFile(dreamsPath, "# Existing\n", "utf-8");
const renameError = Object.assign(new Error("replace failed"), { code: "ENOSPC" });
const renameSpy = vi.spyOn(fs, "rename").mockRejectedValueOnce(renameError);
await expect(
appendNarrativeEntry({
workspaceDir,
narrative: "Appended dream.",
nowMs: Date.parse("2026-04-05T03:00:00Z"),
timezone: "UTC",
}),
).rejects.toThrow("replace failed");
expect(renameSpy).toHaveBeenCalledOnce();
await expect(fs.readFile(dreamsPath, "utf-8")).resolves.toBe("# Existing\n");
});
it("preserves restrictive dreams file permissions across atomic replace", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
await fs.writeFile(dreamsPath, "# Existing\n", { encoding: "utf-8", mode: 0o600 });
await fs.chmod(dreamsPath, 0o600);
await appendNarrativeEntry({
workspaceDir,
narrative: "Appended dream.",
nowMs: Date.parse("2026-04-05T03:00:00Z"),
timezone: "UTC",
});
const stat = await fs.stat(dreamsPath);
expect(stat.mode & 0o777).toBe(0o600);
});
it("surfaces temp cleanup failure after atomic replace error", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
await fs.writeFile(dreamsPath, "# Existing\n", "utf-8");
vi.spyOn(fs, "rename").mockRejectedValueOnce(
Object.assign(new Error("replace failed"), { code: "ENOSPC" }),
);
vi.spyOn(fs, "rm").mockRejectedValueOnce(
Object.assign(new Error("cleanup failed"), { code: "EACCES" }),
);
await expect(
appendNarrativeEntry({
workspaceDir,
narrative: "Appended dream.",
nowMs: Date.parse("2026-04-05T03:00:00Z"),
timezone: "UTC",
}),
).rejects.toThrow("cleanup also failed");
});
});
describe("generateAndAppendDreamNarrative", () => {
@@ -341,6 +403,8 @@ describe("generateAndAppendDreamNarrative", () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
const subagent = createMockSubagent("The repository whispered of forgotten endpoints.");
const logger = createMockLogger();
const nowMs = Date.parse("2026-04-05T03:00:00Z");
const expectedSessionKey = `dreaming-narrative-light-${nowMs}`;
await generateAndAppendDreamNarrative({
subagent,
@@ -349,13 +413,15 @@ describe("generateAndAppendDreamNarrative", () => {
phase: "light",
snippets: ["API endpoints need authentication"],
},
nowMs: Date.parse("2026-04-05T03:00:00Z"),
nowMs,
timezone: "UTC",
logger,
});
expect(subagent.run).toHaveBeenCalledOnce();
expect(subagent.run.mock.calls[0][0]).toMatchObject({
idempotencyKey: expectedSessionKey,
sessionKey: expectedSessionKey,
deliver: false,
});
expect(subagent.waitForRun).toHaveBeenCalledOnce();

View File

@@ -6,6 +6,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
type SubagentSurface = {
run: (params: {
idempotencyKey: string;
sessionKey: string;
message: string;
extraSystemPrompt?: string;
@@ -277,12 +278,27 @@ async function assertSafeDreamsPath(dreamsPath: string): Promise<void> {
async function writeDreamsFileAtomic(dreamsPath: string, content: string): Promise<void> {
await assertSafeDreamsPath(dreamsPath);
const existing = await fs.stat(dreamsPath).catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") {
return null;
}
throw err;
});
const mode = existing?.mode ?? 0o600;
const tempPath = `${dreamsPath}.${process.pid}.${Date.now()}.tmp`;
await fs.writeFile(tempPath, content, { encoding: "utf-8", flag: "wx" });
await fs.writeFile(tempPath, content, { encoding: "utf-8", flag: "wx", mode });
await fs.chmod(tempPath, mode).catch(() => undefined);
try {
await fs.rename(tempPath, dreamsPath);
await fs.chmod(dreamsPath, mode).catch(() => undefined);
} catch (err) {
await fs.rm(tempPath, { force: true }).catch(() => {});
const cleanupError = await fs.rm(tempPath, { force: true }).catch((rmErr) => rmErr);
if (cleanupError) {
throw new Error(
`Atomic DREAMS.md write failed (${formatErrorMessage(err)}); cleanup also failed (${formatErrorMessage(cleanupError)})`,
{ cause: err },
);
}
throw err;
}
}
@@ -409,7 +425,7 @@ export async function appendNarrativeEntry(params: {
}
}
await fs.writeFile(dreamsPath, updated.endsWith("\n") ? updated : `${updated}\n`, "utf-8");
await writeDreamsFileAtomic(dreamsPath, updated.endsWith("\n") ? updated : `${updated}\n`);
return dreamsPath;
}
@@ -434,6 +450,7 @@ export async function generateAndAppendDreamNarrative(params: {
try {
const { runId } = await params.subagent.run({
idempotencyKey: sessionKey,
sessionKey,
message,
extraSystemPrompt: NARRATIVE_SYSTEM_PROMPT,

View File

@@ -2,9 +2,16 @@ import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core";
import { describe, expect, it, vi } from "vitest";
import {
clearInternalHooks,
createInternalHookEvent,
registerInternalHook,
triggerInternalHook,
} from "../../../src/hooks/internal-hooks.js";
import {
__testing,
reconcileShortTermDreamingCronJob,
registerShortTermPromotionDreaming,
resolveShortTermPromotionDreamingConfig,
runShortTermDreamingPromotionIfTriggered,
} from "./dreaming.js";
@@ -18,6 +25,15 @@ type CronParam = NonNullable<Parameters<typeof reconcileShortTermDreamingCronJob
type CronJobLike = Awaited<ReturnType<CronParam["list"]>>[number];
type CronAddInput = Parameters<CronParam["add"]>[0];
type CronPatch = Parameters<CronParam["update"]>[1];
type DreamingPluginApi = Parameters<typeof registerShortTermPromotionDreaming>[0];
type DreamingPluginApiTestDouble = {
config: OpenClawConfig;
pluginConfig: Record<string, unknown>;
logger: ReturnType<typeof createLogger>;
runtime: unknown;
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => void;
on: ReturnType<typeof vi.fn>;
};
function createLogger() {
return {
@@ -42,12 +58,14 @@ function createCronHarness(
opts?: { removeResult?: "boolean" | "unknown"; removeThrowsForIds?: string[] },
) {
const jobs: CronJobLike[] = [...initialJobs];
let listCalls = 0;
const addCalls: CronAddInput[] = [];
const updateCalls: Array<{ id: string; patch: CronPatch }> = [];
const removeCalls: string[] = [];
const cron: CronParam = {
async list() {
listCalls += 1;
return jobs.map((job) => ({
...job,
...(job.schedule ? { schedule: { ...job.schedule } } : {}),
@@ -104,7 +122,36 @@ function createCronHarness(
},
};
return { cron, jobs, addCalls, updateCalls, removeCalls };
return {
cron,
jobs,
addCalls,
updateCalls,
removeCalls,
get listCalls() {
return listCalls;
},
};
}
function getBeforeAgentReplyHandler(
onMock: ReturnType<typeof vi.fn>,
): (
event: { cleanedBody: string },
ctx: { trigger?: string; workspaceDir?: string },
) => Promise<unknown> {
const call = onMock.mock.calls.find(([eventName]) => eventName === "before_agent_reply");
if (!call) {
throw new Error("before_agent_reply hook was not registered");
}
return call[1] as (
event: { cleanedBody: string },
ctx: { trigger?: string; workspaceDir?: string },
) => Promise<unknown>;
}
function registerShortTermPromotionDreamingForTest(api: DreamingPluginApiTestDouble): void {
registerShortTermPromotionDreaming(api as unknown as DreamingPluginApi);
}
describe("short-term dreaming config", () => {
@@ -661,6 +708,410 @@ describe("short-term dreaming cron reconciliation", () => {
});
});
describe("gateway startup reconciliation", () => {
it("uses the startup cfg when reconciling the managed dreaming cron job", async () => {
clearInternalHooks();
const logger = createLogger();
const harness = createCronHarness();
const api: DreamingPluginApiTestDouble = {
config: { plugins: { entries: {} } },
pluginConfig: {},
logger,
runtime: {},
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
registerInternalHook(event, handler);
},
on: vi.fn(),
};
try {
registerShortTermPromotionDreamingForTest(api);
await triggerInternalHook(
createInternalHookEvent("gateway", "startup", "gateway:startup", {
cfg: {
hooks: { internal: { enabled: true } },
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "15 4 * * *",
timezone: "UTC",
},
},
},
},
},
} as OpenClawConfig,
deps: { cron: harness.cron },
}),
);
expect(harness.addCalls).toHaveLength(1);
expect(harness.addCalls[0]).toMatchObject({
schedule: {
kind: "cron",
expr: "15 4 * * *",
tz: "UTC",
},
});
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("created managed dreaming cron job"),
);
} finally {
clearInternalHooks();
}
});
it("reconciles disabled->enabled config changes during runtime", async () => {
clearInternalHooks();
const logger = createLogger();
const harness = createCronHarness();
const onMock = vi.fn();
const api: DreamingPluginApiTestDouble = {
config: {
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: false,
frequency: "0 2 * * *",
timezone: "UTC",
},
},
},
},
},
},
pluginConfig: {},
logger,
runtime: {},
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
registerInternalHook(event, handler);
},
on: onMock,
};
try {
registerShortTermPromotionDreamingForTest(api);
const deps = { cron: harness.cron };
await triggerInternalHook(
createInternalHookEvent("gateway", "startup", "gateway:startup", {
cfg: api.config,
deps,
}),
);
expect(harness.addCalls).toHaveLength(0);
api.config = {
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "30 6 * * *",
timezone: "America/New_York",
},
},
},
},
},
} as OpenClawConfig;
const beforeAgentReply = getBeforeAgentReplyHandler(onMock);
await beforeAgentReply(
{ cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT },
{ trigger: "heartbeat", workspaceDir: "." },
);
expect(harness.addCalls).toHaveLength(1);
expect(harness.addCalls[0]?.schedule).toMatchObject({
kind: "cron",
expr: "30 6 * * *",
tz: "America/New_York",
});
} finally {
clearInternalHooks();
}
});
it("reconciles cadence/timezone updates against the active cron service after startup", async () => {
clearInternalHooks();
const logger = createLogger();
const startupHarness = createCronHarness();
const onMock = vi.fn();
const api: DreamingPluginApiTestDouble = {
config: {
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "0 1 * * *",
timezone: "UTC",
},
},
},
},
},
},
pluginConfig: {},
logger,
runtime: {},
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
registerInternalHook(event, handler);
},
on: onMock,
};
try {
registerShortTermPromotionDreamingForTest(api);
const deps = { cron: startupHarness.cron };
await triggerInternalHook(
createInternalHookEvent("gateway", "startup", "gateway:startup", {
cfg: api.config,
deps,
}),
);
expect(startupHarness.addCalls).toHaveLength(1);
const managed = startupHarness.jobs.find((job) =>
job.description?.includes("[managed-by=memory-core.short-term-promotion]"),
);
expect(managed).toBeDefined();
const reloadedHarness = createCronHarness(
managed
? [
{
...managed,
schedule: managed.schedule ? { ...managed.schedule } : undefined,
payload: managed.payload ? { ...managed.payload } : undefined,
},
]
: [],
);
deps.cron = reloadedHarness.cron;
api.config = {
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "45 8 * * *",
timezone: "America/Los_Angeles",
},
},
},
},
},
} as OpenClawConfig;
const beforeAgentReply = getBeforeAgentReplyHandler(onMock);
await beforeAgentReply(
{ cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT },
{ trigger: "heartbeat", workspaceDir: "." },
);
expect(startupHarness.updateCalls).toHaveLength(0);
expect(reloadedHarness.updateCalls).toHaveLength(1);
expect(reloadedHarness.updateCalls[0]?.patch.schedule).toMatchObject({
kind: "cron",
expr: "45 8 * * *",
tz: "America/Los_Angeles",
});
} finally {
clearInternalHooks();
}
});
it("recreates the managed cron job when it is removed after startup", async () => {
clearInternalHooks();
const logger = createLogger();
const harness = createCronHarness();
const onMock = vi.fn();
const api: DreamingPluginApiTestDouble = {
config: {
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "0 2 * * *",
timezone: "UTC",
},
},
},
},
},
},
pluginConfig: {},
logger,
runtime: {},
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
registerInternalHook(event, handler);
},
on: onMock,
};
try {
registerShortTermPromotionDreamingForTest(api);
await triggerInternalHook(
createInternalHookEvent("gateway", "startup", "gateway:startup", {
cfg: api.config,
deps: { cron: harness.cron },
}),
);
expect(harness.addCalls).toHaveLength(1);
harness.jobs.splice(
0,
harness.jobs.length,
...harness.jobs.filter(
(job) => !job.description?.includes("[managed-by=memory-core.short-term-promotion]"),
),
);
expect(harness.jobs).toHaveLength(0);
const beforeAgentReply = getBeforeAgentReplyHandler(onMock);
await beforeAgentReply(
{ cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT },
{ trigger: "heartbeat", workspaceDir: "." },
);
expect(harness.addCalls).toHaveLength(2);
expect(harness.addCalls[1]?.schedule).toMatchObject({
kind: "cron",
expr: "0 2 * * *",
tz: "UTC",
});
} finally {
clearInternalHooks();
}
});
it("does not reconcile managed cron on non-heartbeat runtime replies", async () => {
clearInternalHooks();
const logger = createLogger();
const harness = createCronHarness();
const onMock = vi.fn();
const api: DreamingPluginApiTestDouble = {
config: {
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "0 2 * * *",
timezone: "UTC",
},
},
},
},
},
},
pluginConfig: {},
logger,
runtime: {},
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
registerInternalHook(event, handler);
},
on: onMock,
};
try {
registerShortTermPromotionDreamingForTest(api);
await triggerInternalHook(
createInternalHookEvent("gateway", "startup", "gateway:startup", {
cfg: api.config,
deps: { cron: harness.cron },
}),
);
expect(harness.listCalls).toBe(1);
const beforeAgentReply = getBeforeAgentReplyHandler(onMock);
await beforeAgentReply({ cleanedBody: "hello" }, { trigger: "user", workspaceDir: "." });
await beforeAgentReply(
{ cleanedBody: "hello again" },
{ trigger: "user", workspaceDir: "." },
);
expect(harness.listCalls).toBe(1);
} finally {
clearInternalHooks();
}
});
it("does not reconcile managed cron on every repeated runtime heartbeat", async () => {
clearInternalHooks();
const logger = createLogger();
const harness = createCronHarness();
const onMock = vi.fn();
const now = Date.parse("2026-04-10T12:00:00Z");
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now);
const api: DreamingPluginApiTestDouble = {
config: {
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "0 2 * * *",
timezone: "UTC",
},
},
},
},
},
},
pluginConfig: {},
logger,
runtime: {},
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
registerInternalHook(event, handler);
},
on: onMock,
};
try {
registerShortTermPromotionDreamingForTest(api);
await triggerInternalHook(
createInternalHookEvent("gateway", "startup", "gateway:startup", {
cfg: api.config,
deps: { cron: harness.cron },
}),
);
expect(harness.listCalls).toBe(1);
const beforeAgentReply = getBeforeAgentReplyHandler(onMock);
await beforeAgentReply(
{ cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT },
{ trigger: "heartbeat", workspaceDir: "." },
);
await beforeAgentReply(
{ cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT },
{ trigger: "heartbeat", workspaceDir: "." },
);
expect(harness.listCalls).toBe(2);
} finally {
nowSpy.mockRestore();
clearInternalHooks();
}
});
});
describe("short-term dreaming trigger", () => {
it("applies promotions when the managed dreaming heartbeat event fires", async () => {
const logger = createLogger();

View File

@@ -35,6 +35,7 @@ const LEGACY_LIGHT_SLEEP_EVENT_TEXT = "__openclaw_memory_core_light_sleep__";
const LEGACY_REM_SLEEP_CRON_NAME = "Memory REM Dreaming";
const LEGACY_REM_SLEEP_CRON_TAG = "[managed-by=memory-core.dreaming.rem]";
const LEGACY_REM_SLEEP_EVENT_TEXT = "__openclaw_memory_core_rem_sleep__";
const RUNTIME_CRON_RECONCILE_INTERVAL_MS = 60_000;
type Logger = Pick<OpenClawPluginApi["logger"], "info" | "warn" | "error">;
@@ -86,6 +87,11 @@ type CronServiceLike = {
remove: (id: string) => Promise<{ removed?: boolean }>;
};
type StartupCronSourceRefs = {
context: Record<string, unknown>;
deps: Record<string, unknown> | null;
};
export type ShortTermPromotionDreamingConfig = {
enabled: boolean;
cron: string;
@@ -281,21 +287,11 @@ function sortManagedJobs(managed: ManagedCronJobLike[]): ManagedCronJobLike[] {
});
}
function resolveCronServiceFromStartupEvent(event: unknown): CronServiceLike | null {
const payload = asRecord(event);
if (!payload) {
function resolveCronServiceFromCandidate(candidate: unknown): CronServiceLike | null {
if (!candidate || typeof candidate !== "object") {
return null;
}
if (payload.type !== "gateway" || payload.action !== "startup") {
return null;
}
const context = asRecord(payload.context);
const deps = asRecord(context?.deps);
const cronCandidate = context?.cron ?? deps?.cron;
if (!cronCandidate || typeof cronCandidate !== "object") {
return null;
}
const cron = cronCandidate as Partial<CronServiceLike>;
const cron = candidate as Partial<CronServiceLike>;
if (
typeof cron.list !== "function" ||
typeof cron.add !== "function" ||
@@ -307,6 +303,47 @@ function resolveCronServiceFromStartupEvent(event: unknown): CronServiceLike | n
return cron as CronServiceLike;
}
function resolveStartupCronSourceFromEvent(event: unknown): StartupCronSourceRefs | null {
const payload = asRecord(event);
if (!payload) {
return null;
}
if (payload.type !== "gateway" || payload.action !== "startup") {
return null;
}
const context = asRecord(payload.context);
if (!context) {
return null;
}
return { context, deps: asRecord(context.deps) };
}
function resolveCronServiceFromStartupSource(
source: StartupCronSourceRefs | null,
): CronServiceLike | null {
if (!source) {
return null;
}
return (
resolveCronServiceFromCandidate(source.context.cron) ??
resolveCronServiceFromCandidate(source.deps?.cron)
);
}
function resolveCronServiceFromStartupEvent(event: unknown): CronServiceLike | null {
return resolveCronServiceFromStartupSource(resolveStartupCronSourceFromEvent(event));
}
function resolveStartupConfigFromEvent(event: unknown, fallback: OpenClawConfig): OpenClawConfig {
const startupEvent = asRecord(event);
const startupContext = asRecord(startupEvent?.context);
const startupCfg = asRecord(startupContext?.cfg);
if (!startupCfg) {
return fallback;
}
return startupCfg as OpenClawConfig;
}
export function resolveShortTermPromotionDreamingConfig(params: {
pluginConfig?: Record<string, unknown>;
cfg?: OpenClawConfig;
@@ -580,24 +617,87 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
}
export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void {
let startupCronSource: StartupCronSourceRefs | null = null;
let unavailableCronWarningEmitted = false;
let lastRuntimeReconcileAtMs = 0;
let lastRuntimeConfigKey: string | null = null;
let lastRuntimeCronRef: CronServiceLike | null = null;
const runtimeConfigKey = (config: ShortTermPromotionDreamingConfig): string =>
[
config.enabled ? "enabled" : "disabled",
config.cron,
config.timezone ?? "",
String(config.limit),
String(config.minScore),
String(config.minRecallCount),
String(config.minUniqueQueries),
String(config.recencyHalfLifeDays ?? ""),
String(config.maxAgeDays ?? ""),
config.verboseLogging ? "verbose" : "quiet",
config.storage?.mode ?? "",
config.storage?.separateReports ? "separate" : "inline",
].join("|");
const reconcileManagedDreamingCron = async (params: {
reason: "startup" | "runtime";
startupEvent?: unknown;
}): Promise<ShortTermPromotionDreamingConfig> => {
const startupCfg =
params.reason === "startup" && params.startupEvent !== undefined
? resolveStartupConfigFromEvent(params.startupEvent, api.config)
: api.config;
const config = resolveShortTermPromotionDreamingConfig({
pluginConfig:
resolveMemoryCorePluginConfig(startupCfg) ??
resolveMemoryCorePluginConfig(api.config) ??
api.pluginConfig,
cfg: startupCfg,
});
if (params.reason === "startup" && params.startupEvent !== undefined) {
startupCronSource = resolveStartupCronSourceFromEvent(params.startupEvent);
}
const cron = resolveCronServiceFromStartupSource(startupCronSource);
const configKey = runtimeConfigKey(config);
if (!cron && config.enabled && !unavailableCronWarningEmitted) {
api.logger.warn(
"memory-core: managed dreaming cron could not be reconciled (cron service unavailable).",
);
unavailableCronWarningEmitted = true;
}
if (cron) {
unavailableCronWarningEmitted = false;
}
if (params.reason === "runtime") {
const now = Date.now();
const withinThrottleWindow =
now - lastRuntimeReconcileAtMs < RUNTIME_CRON_RECONCILE_INTERVAL_MS;
if (
withinThrottleWindow &&
lastRuntimeConfigKey === configKey &&
lastRuntimeCronRef === cron
) {
return config;
}
lastRuntimeReconcileAtMs = now;
lastRuntimeConfigKey = configKey;
lastRuntimeCronRef = cron;
}
await reconcileShortTermDreamingCronJob({
cron,
config,
logger: api.logger,
});
return config;
};
api.registerHook(
"gateway:startup",
async (event: unknown) => {
try {
const config = resolveShortTermPromotionDreamingConfig({
pluginConfig: resolveMemoryCorePluginConfig(api.config) ?? api.pluginConfig,
cfg: api.config,
});
const cron = resolveCronServiceFromStartupEvent(event);
if (!cron && config.enabled) {
api.logger.warn(
"memory-core: managed dreaming cron could not be reconciled (cron service unavailable).",
);
}
await reconcileShortTermDreamingCronJob({
cron,
config,
logger: api.logger,
await reconcileManagedDreamingCron({
reason: "startup",
startupEvent: event,
});
} catch (err) {
api.logger.error(
@@ -610,9 +710,11 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
api.on("before_agent_reply", async (event, ctx) => {
try {
const config = resolveShortTermPromotionDreamingConfig({
pluginConfig: resolveMemoryCorePluginConfig(api.config) ?? api.pluginConfig,
cfg: api.config,
if (ctx.trigger !== "heartbeat") {
return undefined;
}
const config = await reconcileManagedDreamingCron({
reason: "runtime",
});
return await runShortTermDreamingPromotionIfTriggered({
cleanedBody: event.cleanedBody,

View File

@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { afterAll, beforeAll } from "vitest";
export function createMemoryCoreTestHarness() {
@@ -8,7 +8,9 @@ export function createMemoryCoreTestHarness() {
let caseId = 0;
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-core-test-fixtures-"));
fixtureRoot = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "memory-core-test-fixtures-"),
);
});
afterAll(async () => {

View File

@@ -0,0 +1,64 @@
import fs from "node:fs";
import { describe, expect, it } from "vitest";
import { validateJsonSchemaValue } from "../../src/plugins/schema-validator.js";
import { memoryConfigSchema } from "./config.js";
const manifest = JSON.parse(
fs.readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf-8"),
) as { configSchema: Record<string, unknown> };
describe("memory-lancedb config", () => {
it("accepts dreaming in the manifest schema and preserves it in runtime parsing", () => {
const manifestResult = validateJsonSchemaValue({
schema: manifest.configSchema,
cacheKey: "memory-lancedb.manifest.dreaming",
value: {
embedding: {
apiKey: "sk-test",
},
dreaming: {
enabled: true,
},
},
});
const parsed = memoryConfigSchema.parse({
embedding: {
apiKey: "sk-test",
},
dreaming: {
enabled: true,
},
});
expect(manifestResult.ok).toBe(true);
expect(parsed.dreaming).toEqual({
enabled: true,
});
});
it("still rejects unrelated unknown top-level config keys", () => {
expect(() => {
memoryConfigSchema.parse({
embedding: {
apiKey: "sk-test",
},
dreaming: {
enabled: true,
},
unexpected: true,
});
}).toThrow("memory config has unknown keys: unexpected");
});
it("rejects non-object dreaming values in runtime parsing", () => {
expect(() => {
memoryConfigSchema.parse({
embedding: {
apiKey: "sk-test",
},
dreaming: true,
});
}).toThrow("dreaming config must be an object");
});
});

View File

@@ -10,6 +10,7 @@ export type MemoryConfig = {
baseUrl?: string;
dimensions?: number;
};
dreaming?: Record<string, unknown>;
dbPath?: string;
autoCapture?: boolean;
autoRecall?: boolean;
@@ -97,7 +98,7 @@ export const memoryConfigSchema = {
const cfg = value as Record<string, unknown>;
assertAllowedKeys(
cfg,
["embedding", "dbPath", "autoCapture", "autoRecall", "captureMaxChars"],
["embedding", "dreaming", "dbPath", "autoCapture", "autoRecall", "captureMaxChars"],
"memory config",
);
@@ -118,6 +119,15 @@ export const memoryConfigSchema = {
throw new Error("captureMaxChars must be between 100 and 10000");
}
const dreaming =
typeof cfg.dreaming === "undefined"
? undefined
: cfg.dreaming && typeof cfg.dreaming === "object" && !Array.isArray(cfg.dreaming)
? (cfg.dreaming as Record<string, unknown>)
: (() => {
throw new Error("dreaming config must be an object");
})();
return {
embedding: {
provider: "openai",
@@ -127,6 +137,7 @@ export const memoryConfigSchema = {
typeof embedding.baseUrl === "string" ? resolveEnvVars(embedding.baseUrl) : undefined,
dimensions: typeof embedding.dimensions === "number" ? embedding.dimensions : undefined,
},
dreaming,
dbPath: typeof cfg.dbPath === "string" ? cfg.dbPath : DEFAULT_DB_PATH,
autoCapture: cfg.autoCapture === true,
autoRecall: cfg.autoRecall !== false,

View File

@@ -38,6 +38,10 @@
"label": "Auto-Recall",
"help": "Automatically inject relevant memories into context"
},
"dreaming": {
"label": "Dreaming",
"help": "Optional dreaming config consumed when this plugin owns the memory slot"
},
"captureMaxChars": {
"label": "Capture Max Chars",
"help": "Maximum message length eligible for auto-capture",
@@ -77,6 +81,9 @@
"autoRecall": {
"type": "boolean"
},
"dreaming": {
"type": "object"
},
"captureMaxChars": {
"type": "number",
"minimum": 100,

View File

@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { afterEach, vi } from "vitest";
import { createTestPluginApi } from "../../../test/helpers/plugins/plugin-api.js";
import type { OpenClawPluginApi } from "../api.js";
@@ -37,7 +37,7 @@ export function createMemoryWikiTestHarness() {
});
async function createTempDir(prefix: string): Promise<string> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
const tempDir = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), prefix));
tempDirs.push(tempDir);
return tempDir;
}

View File

@@ -6,10 +6,13 @@
"dependencies": {
"@microsoft/teams.api": "2.0.6",
"@microsoft/teams.apps": "2.0.6",
"express": "^5.2.1"
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^4.0.1"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"@types/jsonwebtoken": "^9.0.9",
"openclaw": "workspace:*"
},
"peerDependencies": {

View File

@@ -206,6 +206,28 @@ describe("msteams attachment helpers", () => {
const urls = buildMSTeamsGraphMessageUrls(params);
expect(urls[0]).toContain(expectedPath);
});
it("uses resolved Graph chat ID for personal DMs instead of Bot Framework a: ID", () => {
const urls = buildMSTeamsGraphMessageUrls({
conversationType: "personal",
conversationId: "19:real-graph-chat-id@unq.gbl.spaces",
messageId: "msg-1",
});
expect(urls).toHaveLength(1);
expect(urls[0]).toContain(
"/chats/19%3Areal-graph-chat-id%40unq.gbl.spaces/messages/msg-1",
);
});
it("still builds URLs when a: conversation ID is passed (caller did not resolve)", () => {
const urls = buildMSTeamsGraphMessageUrls({
conversationType: "personal",
conversationId: "a:1dRsHCobZ1AxURzY",
messageId: "msg-1",
});
expect(urls).toHaveLength(1);
expect(urls[0]).toContain("/chats/a%3A1dRsHCobZ1AxURzY/messages/msg-1");
});
});
describe("buildMSTeamsMediaPayload", () => {

View File

@@ -550,5 +550,105 @@ describe("msteams attachments", () => {
expectAttachmentMediaLength(media, 0);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
describe("OneDrive/SharePoint shared links", () => {
const GRAPH_SHARES_URL_PREFIX = `https://${GRAPH_HOST}/v1.0/shares/`;
const DEFAULT_GRAPH_ALLOW_HOSTS = [GRAPH_HOST];
const PDF_PAYLOAD = Buffer.from("pdf-bytes");
const createGraphSharesFetchMock = () =>
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
const auth = new Headers(init?.headers).get("Authorization");
if (url.startsWith(GRAPH_SHARES_URL_PREFIX)) {
if (!auth) {
return createTextResponse("unauthorized", 401);
}
return createBufferResponse(PDF_PAYLOAD, CONTENT_TYPE_APPLICATION_PDF);
}
return createNotFoundResponse();
});
it.each([
{
label: "SharePoint URL",
contentUrl: "https://contoso.sharepoint.com/personal/user/Documents/report.pdf",
},
{
label: "OneDrive 1drv.ms URL",
contentUrl: "https://1drv.ms/b/s!AkxYabcdefg",
},
{
label: "OneDrive onedrive.live.com URL",
contentUrl: "https://onedrive.live.com/share/file",
},
])("routes $label through Graph shares endpoint", async ({ contentUrl }) => {
const tokenProvider = createTokenProvider();
const fetchMock = createGraphSharesFetchMock();
detectMimeMock.mockResolvedValueOnce(CONTENT_TYPE_APPLICATION_PDF);
saveMediaBufferMock.mockResolvedValueOnce({
id: "saved.pdf",
path: SAVED_PDF_PATH,
size: Buffer.byteLength(PDF_PAYLOAD),
contentType: CONTENT_TYPE_APPLICATION_PDF,
});
const media = await downloadMSTeamsAttachments(
buildDownloadParams(
[
{
contentType: "reference",
contentUrl,
name: "report.pdf",
},
],
{
tokenProvider,
allowHosts: DEFAULT_GRAPH_ALLOW_HOSTS,
authAllowHosts: DEFAULT_GRAPH_ALLOW_HOSTS,
fetchFn: asFetchFn(fetchMock),
},
),
);
expectAttachmentMediaLength(media, 1);
expect(media[0]?.path).toBe(SAVED_PDF_PATH);
// The only host that should be fetched is graph.microsoft.com.
const calledUrls = fetchMock.mock.calls.map(([input]) =>
typeof input === "string" ? input : String(input),
);
expect(calledUrls.length).toBeGreaterThan(0);
for (const url of calledUrls) {
expect(url.startsWith(GRAPH_SHARES_URL_PREFIX)).toBe(true);
}
// Graph scope token was acquired for the shares fetch.
expect(tokenProvider.getAccessToken).toHaveBeenCalled();
});
it("falls through to direct fetch for non-shared-link URLs", async () => {
const directUrl = createTestUrl("direct.pdf");
const fetchMock = createOkFetchMock(CONTENT_TYPE_APPLICATION_PDF, "pdf");
detectMimeMock.mockResolvedValueOnce(CONTENT_TYPE_APPLICATION_PDF);
saveMediaBufferMock.mockResolvedValueOnce({
id: "saved.pdf",
path: SAVED_PDF_PATH,
size: Buffer.byteLength(PDF_BUFFER),
contentType: CONTENT_TYPE_APPLICATION_PDF,
});
const media = await downloadAttachmentsWithFetch(
createPdfAttachments(directUrl),
fetchMock,
);
expectAttachmentMediaLength(media, 1);
const calledUrls = fetchMock.mock.calls.map(([input]) =>
typeof input === "string" ? input : String(input),
);
// Should have hit the original host, NOT graph shares.
expect(calledUrls.some((url) => url === directUrl)).toBe(true);
expect(calledUrls.some((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toBe(false);
});
});
});
});

View File

@@ -1,3 +1,8 @@
export {
downloadMSTeamsBotFrameworkAttachment,
downloadMSTeamsBotFrameworkAttachments,
isBotFrameworkPersonalChatId,
} from "./attachments/bot-framework.js";
export {
downloadMSTeamsAttachments,
/** @deprecated Use `downloadMSTeamsAttachments` instead. */
@@ -6,6 +11,7 @@ export {
export { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js";
export {
buildMSTeamsAttachmentPlaceholder,
extractMSTeamsHtmlAttachmentIds,
summarizeMSTeamsHtmlAttachments,
} from "./attachments/html.js";
export { buildMSTeamsMediaPayload } from "./attachments/payload.js";

View File

@@ -0,0 +1,317 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setMSTeamsRuntime } from "../runtime.js";
import {
downloadMSTeamsBotFrameworkAttachment,
downloadMSTeamsBotFrameworkAttachments,
isBotFrameworkPersonalChatId,
} from "./bot-framework.js";
import type { MSTeamsAccessTokenProvider } from "./types.js";
type SavedCall = {
buffer: Buffer;
contentType?: string;
direction: string;
maxBytes: number;
originalFilename?: string;
};
type MockRuntime = {
saveCalls: SavedCall[];
savePath: string;
savedContentType: string;
};
function installRuntime(): MockRuntime {
const state: MockRuntime = {
saveCalls: [],
savePath: "/tmp/bf-attachment.bin",
savedContentType: "application/pdf",
};
setMSTeamsRuntime({
media: {
detectMime: async ({ headerMime }: { headerMime?: string }) =>
headerMime ?? "application/pdf",
},
channel: {
media: {
saveMediaBuffer: async (
buffer: Buffer,
contentType: string | undefined,
direction: string,
maxBytes: number,
originalFilename?: string,
) => {
state.saveCalls.push({
buffer,
contentType,
direction,
maxBytes,
originalFilename,
});
return { path: state.savePath, contentType: state.savedContentType };
},
fetchRemoteMedia: async () => ({ buffer: Buffer.alloc(0), contentType: undefined }),
},
},
} as unknown as Parameters<typeof setMSTeamsRuntime>[0]);
return state;
}
function createMockFetch(entries: Array<{ match: RegExp; response: Response }>): typeof fetch {
return (async (input: RequestInfo | URL) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const entry = entries.find((e) => e.match.test(url));
if (!entry) {
return new Response("not found", { status: 404 });
}
return entry.response.clone();
}) as typeof fetch;
}
function buildTokenProvider(): MSTeamsAccessTokenProvider {
return {
getAccessToken: vi.fn(async (scope: string) => {
if (scope.includes("botframework.com")) {
return "bf-token";
}
return "graph-token";
}),
};
}
describe("isBotFrameworkPersonalChatId", () => {
it("detects a: prefix personal chat IDs", () => {
expect(isBotFrameworkPersonalChatId("a:1dRsHCobZ1AxURzY05Dc")).toBe(true);
});
it("detects 8:orgid: prefix chat IDs", () => {
expect(isBotFrameworkPersonalChatId("8:orgid:12345678-1234-1234-1234-123456789abc")).toBe(true);
});
it("returns false for Graph-compatible 19: thread IDs", () => {
expect(isBotFrameworkPersonalChatId("19:abc@thread.tacv2")).toBe(false);
});
it("returns false for synthetic DM Graph IDs", () => {
expect(isBotFrameworkPersonalChatId("19:aad-user-id_bot-app-id@unq.gbl.spaces")).toBe(false);
});
it("returns false for null/undefined/empty", () => {
expect(isBotFrameworkPersonalChatId(null)).toBe(false);
expect(isBotFrameworkPersonalChatId(undefined)).toBe(false);
expect(isBotFrameworkPersonalChatId("")).toBe(false);
});
});
describe("downloadMSTeamsBotFrameworkAttachment", () => {
let runtime: MockRuntime;
beforeEach(() => {
runtime = installRuntime();
});
it("fetches attachment info then view and saves media", async () => {
const info = {
name: "report.pdf",
type: "application/pdf",
views: [{ viewId: "original", size: 1024 }],
};
const fileBytes = Buffer.from("PDFBYTES", "utf-8");
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\/att-1$/,
response: new Response(JSON.stringify(info), {
status: 200,
headers: { "content-type": "application/json" },
}),
},
{
match: /\/v3\/attachments\/att-1\/views\/original$/,
response: new Response(fileBytes, {
status: 200,
headers: { "content-length": String(fileBytes.byteLength) },
}),
},
]);
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer/",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
});
expect(media).toBeDefined();
expect(media?.path).toBe(runtime.savePath);
expect(runtime.saveCalls).toHaveLength(1);
expect(runtime.saveCalls[0].buffer.toString("utf-8")).toBe("PDFBYTES");
});
it("returns undefined when attachment info fetch fails", async () => {
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\//,
response: new Response("unauthorized", { status: 401 }),
},
]);
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
});
expect(media).toBeUndefined();
expect(runtime.saveCalls).toHaveLength(0);
});
it("skips when attachment view size exceeds maxBytes", async () => {
const info = {
name: "huge.bin",
type: "application/octet-stream",
views: [{ viewId: "original", size: 50_000_000 }],
};
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\/big-1$/,
response: new Response(JSON.stringify(info), { status: 200 }),
},
]);
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "big-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
});
expect(media).toBeUndefined();
expect(runtime.saveCalls).toHaveLength(0);
});
it("returns undefined when no views are returned", async () => {
const info = { name: "nothing", type: "application/pdf", views: [] };
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\/empty-1$/,
response: new Response(JSON.stringify(info), { status: 200 }),
},
]);
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "empty-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
});
expect(media).toBeUndefined();
});
it("returns undefined without a tokenProvider", async () => {
const fetchFn = vi.fn();
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "att-1",
tokenProvider: undefined,
maxBytes: 10_000_000,
fetchFn: fetchFn as unknown as typeof fetch,
});
expect(media).toBeUndefined();
expect(fetchFn).not.toHaveBeenCalled();
});
});
describe("downloadMSTeamsBotFrameworkAttachments", () => {
beforeEach(() => {
installRuntime();
});
it("fetches every unique attachment id and returns combined media", async () => {
const mkInfo = (viewId: string) => ({
name: `file-${viewId}.pdf`,
type: "application/pdf",
views: [{ viewId, size: 10 }],
});
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\/att-1$/,
response: new Response(JSON.stringify(mkInfo("original")), { status: 200 }),
},
{
match: /\/v3\/attachments\/att-1\/views\/original$/,
response: new Response(Buffer.from("A"), { status: 200 }),
},
{
match: /\/v3\/attachments\/att-2$/,
response: new Response(JSON.stringify(mkInfo("original")), { status: 200 }),
},
{
match: /\/v3\/attachments\/att-2\/views\/original$/,
response: new Response(Buffer.from("B"), { status: 200 }),
},
]);
const result = await downloadMSTeamsBotFrameworkAttachments({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentIds: ["att-1", "att-2", "att-1"],
tokenProvider: buildTokenProvider(),
maxBytes: 10_000,
fetchFn,
});
expect(result.media).toHaveLength(2);
expect(result.attachmentCount).toBe(2);
});
it("returns empty when no valid attachment ids", async () => {
const result = await downloadMSTeamsBotFrameworkAttachments({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentIds: [],
tokenProvider: buildTokenProvider(),
maxBytes: 10_000,
fetchFn: vi.fn() as unknown as typeof fetch,
});
expect(result.media).toEqual([]);
});
it("continues past a per-attachment failure", async () => {
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\/ok$/,
response: new Response(
JSON.stringify({
name: "ok.pdf",
type: "application/pdf",
views: [{ viewId: "original", size: 1 }],
}),
{ status: 200 },
),
},
{
match: /\/v3\/attachments\/ok\/views\/original$/,
response: new Response(Buffer.from("OK"), { status: 200 }),
},
{
match: /\/v3\/attachments\/bad$/,
response: new Response("nope", { status: 500 }),
},
]);
const result = await downloadMSTeamsBotFrameworkAttachments({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentIds: ["bad", "ok"],
tokenProvider: buildTokenProvider(),
maxBytes: 10_000,
fetchFn,
});
expect(result.media).toHaveLength(1);
expect(result.attachmentCount).toBe(2);
});
});

View File

@@ -0,0 +1,306 @@
import { Buffer } from "node:buffer";
import { fetchWithSsrFGuard, type SsrFPolicy } from "../../runtime-api.js";
import { getMSTeamsRuntime } from "../runtime.js";
import { ensureUserAgentHeader } from "../user-agent.js";
import {
inferPlaceholder,
isUrlAllowed,
type MSTeamsAttachmentFetchPolicy,
resolveAttachmentFetchPolicy,
resolveMediaSsrfPolicy,
} from "./shared.js";
import type {
MSTeamsAccessTokenProvider,
MSTeamsGraphMediaResult,
MSTeamsInboundMedia,
} from "./types.js";
/**
* Bot Framework Service token scope for requesting a token used against
* the Bot Connector (v3) REST endpoints such as `/v3/attachments/{id}`.
*/
const BOT_FRAMEWORK_SCOPE = "https://api.botframework.com";
/**
* Detect Bot Framework personal chat ("a:") and MSA orgid ("8:orgid:") conversation
* IDs. These identifiers are not recognized by Graph's `/chats/{id}` endpoint, so we
* must fetch media via the Bot Framework v3 attachments endpoint instead.
*
* Graph-compatible IDs start with `19:` and are left untouched by this detector.
*/
export function isBotFrameworkPersonalChatId(conversationId: string | null | undefined): boolean {
if (typeof conversationId !== "string") {
return false;
}
const trimmed = conversationId.trim();
return trimmed.startsWith("a:") || trimmed.startsWith("8:orgid:");
}
type BotFrameworkView = {
viewId?: string | null;
size?: number | null;
};
type BotFrameworkAttachmentInfo = {
name?: string | null;
type?: string | null;
views?: BotFrameworkView[] | null;
};
function normalizeServiceUrl(serviceUrl: string): string {
// Bot Framework service URLs sometimes carry a trailing slash; normalize so
// we can safely append `/v3/attachments/...` below.
return serviceUrl.replace(/\/+$/, "");
}
async function fetchBotFrameworkAttachmentInfo(params: {
serviceUrl: string;
attachmentId: string;
accessToken: string;
fetchFn?: typeof fetch;
ssrfPolicy?: SsrFPolicy;
}): Promise<BotFrameworkAttachmentInfo | undefined> {
const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}`;
const { response, release } = await fetchWithSsrFGuard({
url,
fetchImpl: params.fetchFn ?? fetch,
init: {
headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }),
},
policy: params.ssrfPolicy,
auditContext: "msteams.botframework.attachmentInfo",
});
try {
if (!response.ok) {
return undefined;
}
try {
return (await response.json()) as BotFrameworkAttachmentInfo;
} catch {
return undefined;
}
} finally {
await release();
}
}
async function fetchBotFrameworkAttachmentView(params: {
serviceUrl: string;
attachmentId: string;
viewId: string;
accessToken: string;
maxBytes: number;
fetchFn?: typeof fetch;
ssrfPolicy?: SsrFPolicy;
}): Promise<Buffer | undefined> {
const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}/views/${encodeURIComponent(params.viewId)}`;
const { response, release } = await fetchWithSsrFGuard({
url,
fetchImpl: params.fetchFn ?? fetch,
init: {
headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }),
},
policy: params.ssrfPolicy,
auditContext: "msteams.botframework.attachmentView",
});
try {
if (!response.ok) {
return undefined;
}
const contentLength = response.headers.get("content-length");
if (contentLength && Number(contentLength) > params.maxBytes) {
return undefined;
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
if (buffer.byteLength > params.maxBytes) {
return undefined;
}
return buffer;
} finally {
await release();
}
}
/**
* Download media for a single attachment via the Bot Framework v3 attachments
* endpoint. Used for personal DM conversations where the Graph `/chats/{id}`
* path is not usable because the Bot Framework conversation ID (`a:...`) is
* not a valid Graph chat identifier.
*/
export async function downloadMSTeamsBotFrameworkAttachment(params: {
serviceUrl: string;
attachmentId: string;
tokenProvider?: MSTeamsAccessTokenProvider;
maxBytes: number;
allowHosts?: string[];
authAllowHosts?: string[];
fetchFn?: typeof fetch;
fileNameHint?: string | null;
contentTypeHint?: string | null;
preserveFilenames?: boolean;
}): Promise<MSTeamsInboundMedia | undefined> {
if (!params.serviceUrl || !params.attachmentId || !params.tokenProvider) {
return undefined;
}
const policy: MSTeamsAttachmentFetchPolicy = resolveAttachmentFetchPolicy({
allowHosts: params.allowHosts,
authAllowHosts: params.authAllowHosts,
});
const baseUrl = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}`;
if (!isUrlAllowed(baseUrl, policy.allowHosts)) {
return undefined;
}
const ssrfPolicy = resolveMediaSsrfPolicy(policy.allowHosts);
let accessToken: string;
try {
accessToken = await params.tokenProvider.getAccessToken(BOT_FRAMEWORK_SCOPE);
} catch {
return undefined;
}
if (!accessToken) {
return undefined;
}
const info = await fetchBotFrameworkAttachmentInfo({
serviceUrl: params.serviceUrl,
attachmentId: params.attachmentId,
accessToken,
fetchFn: params.fetchFn,
ssrfPolicy,
});
if (!info) {
return undefined;
}
const views = Array.isArray(info.views) ? info.views : [];
// Prefer the "original" view when present, otherwise fall back to the first
// view the Bot Framework service returned.
const original = views.find((view) => view?.viewId === "original");
const candidateView = original ?? views.find((view) => typeof view?.viewId === "string");
const viewId =
typeof candidateView?.viewId === "string" && candidateView.viewId
? candidateView.viewId
: undefined;
if (!viewId) {
return undefined;
}
if (
typeof candidateView?.size === "number" &&
candidateView.size > 0 &&
candidateView.size > params.maxBytes
) {
return undefined;
}
const buffer = await fetchBotFrameworkAttachmentView({
serviceUrl: params.serviceUrl,
attachmentId: params.attachmentId,
viewId,
accessToken,
maxBytes: params.maxBytes,
fetchFn: params.fetchFn,
ssrfPolicy,
});
if (!buffer) {
return undefined;
}
const fileNameHint =
(typeof params.fileNameHint === "string" && params.fileNameHint) ||
(typeof info.name === "string" && info.name) ||
undefined;
const contentTypeHint =
(typeof params.contentTypeHint === "string" && params.contentTypeHint) ||
(typeof info.type === "string" && info.type) ||
undefined;
const mime = await getMSTeamsRuntime().media.detectMime({
buffer,
headerMime: contentTypeHint,
filePath: fileNameHint,
});
try {
const originalFilename = params.preserveFilenames ? fileNameHint : undefined;
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
buffer,
mime ?? contentTypeHint,
"inbound",
params.maxBytes,
originalFilename,
);
return {
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: fileNameHint }),
};
} catch {
return undefined;
}
}
/**
* Download media for every attachment referenced by a Bot Framework personal
* chat activity. Returns all successfully fetched media along with diagnostics
* compatible with `downloadMSTeamsGraphMedia`'s result shape so callers can
* reuse the existing logging path.
*/
export async function downloadMSTeamsBotFrameworkAttachments(params: {
serviceUrl: string;
attachmentIds: string[];
tokenProvider?: MSTeamsAccessTokenProvider;
maxBytes: number;
allowHosts?: string[];
authAllowHosts?: string[];
fetchFn?: typeof fetch;
fileNameHint?: string | null;
contentTypeHint?: string | null;
preserveFilenames?: boolean;
}): Promise<MSTeamsGraphMediaResult> {
const seen = new Set<string>();
const unique: string[] = [];
for (const id of params.attachmentIds ?? []) {
if (typeof id !== "string") {
continue;
}
const trimmed = id.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
unique.push(trimmed);
}
if (unique.length === 0 || !params.serviceUrl || !params.tokenProvider) {
return { media: [], attachmentCount: unique.length };
}
const media: MSTeamsInboundMedia[] = [];
for (const attachmentId of unique) {
try {
const item = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: params.serviceUrl,
attachmentId,
tokenProvider: params.tokenProvider,
maxBytes: params.maxBytes,
allowHosts: params.allowHosts,
authAllowHosts: params.authAllowHosts,
fetchFn: params.fetchFn,
fileNameHint: params.fileNameHint,
contentTypeHint: params.contentTypeHint,
preserveFilenames: params.preserveFilenames,
});
if (item) {
media.push(item);
}
} catch {
// Ignore per-attachment failures and continue.
}
}
return {
media,
attachmentCount: unique.length,
};
}

View File

@@ -16,6 +16,7 @@ import {
resolveAttachmentFetchPolicy,
resolveRequestUrl,
safeFetchWithPolicy,
tryBuildGraphSharesUrlForSharedLink,
} from "./shared.js";
import type {
MSTeamsAccessTokenProvider,
@@ -65,10 +66,21 @@ function resolveDownloadCandidate(att: MSTeamsAttachmentLike): DownloadCandidate
return null;
}
// OneDrive/SharePoint shared links (delivered in 1:1 DMs when the user
// picks "Attach > OneDrive") cannot be fetched directly — the URL returns
// an HTML landing page rather than the file bytes. Rewrite them to the
// Graph shares endpoint so the auth fallback attaches a Graph-scoped token
// and the response is the real file content.
const sharesUrl = tryBuildGraphSharesUrlForSharedLink(contentUrl);
const resolvedUrl = sharesUrl ?? contentUrl;
// Graph shares returns raw bytes without a declared content type we can
// trust for routing — let the downloader infer MIME from the buffer.
const resolvedContentTypeHint = sharesUrl ? undefined : contentType;
return {
url: contentUrl,
url: resolvedUrl,
fileHint: name || undefined,
contentTypeHint: contentType,
contentTypeHint: resolvedContentTypeHint,
placeholder: inferPlaceholder({ contentType, fileName: name }),
};
}

View File

@@ -10,6 +10,7 @@ import { downloadMSTeamsAttachments } from "./download.js";
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
import {
applyAuthorizationHeaderForUrl,
encodeGraphShareId,
GRAPH_ROOT,
estimateBase64DecodedBytes,
inferPlaceholder,
@@ -322,13 +323,15 @@ export async function downloadMSTeamsGraphMedia(params: {
const name = att.name ?? "file";
try {
// SharePoint URLs need to be accessed via Graph shares API
// SharePoint URLs need to be accessed via Graph shares API. Validate the
// rewritten Graph URL, not the original SharePoint host, so the existing
// Graph allowlist path can fetch shared files without separately allowing
// arbitrary SharePoint hosts.
const shareUrl = att.contentUrl!;
if (!isUrlAllowed(shareUrl, policy.allowHosts)) {
const sharesUrl = `${GRAPH_ROOT}/shares/${encodeGraphShareId(shareUrl)}/driveItem/content`;
if (!isUrlAllowed(sharesUrl, policy.allowHosts)) {
continue;
}
const encodedUrl = Buffer.from(shareUrl).toString("base64url");
const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
const media = await downloadAndStoreMSTeamsRemoteMedia({
url: sharesUrl,

View File

@@ -8,6 +8,37 @@ import {
} from "./shared.js";
import type { MSTeamsAttachmentLike, MSTeamsHtmlAttachmentSummary } from "./types.js";
/**
* Extract every `<attachment id="...">` reference from the HTML attachments in
* the inbound activity. Returns the complete (non-sliced) list; callers that
* need a capped diagnostic summary can truncate after calling this helper.
*/
export function extractMSTeamsHtmlAttachmentIds(
attachments: MSTeamsAttachmentLike[] | undefined,
): string[] {
const list = Array.isArray(attachments) ? attachments : [];
if (list.length === 0) {
return [];
}
const ids = new Set<string>();
for (const att of list) {
const html = extractHtmlFromAttachment(att);
if (!html) {
continue;
}
ATTACHMENT_TAG_RE.lastIndex = 0;
let match: RegExpExecArray | null = ATTACHMENT_TAG_RE.exec(html);
while (match) {
const id = match[1]?.trim();
if (id) {
ids.add(id);
}
match = ATTACHMENT_TAG_RE.exec(html);
}
}
return Array.from(ids);
}
export function summarizeMSTeamsHtmlAttachments(
attachments: MSTeamsAttachmentLike[] | undefined,
): MSTeamsHtmlAttachmentSummary | undefined {

View File

@@ -1,7 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import {
applyAuthorizationHeaderForUrl,
encodeGraphShareId,
extractInlineImageCandidates,
isGraphSharedLinkUrl,
isPrivateOrReservedIP,
isUrlAllowed,
resolveAndValidateIP,
@@ -11,6 +13,7 @@ import {
resolveMediaSsrfPolicy,
safeFetch,
safeFetchWithPolicy,
tryBuildGraphSharesUrlForSharedLink,
} from "./shared.js";
const publicResolve = async () => ({ address: "13.107.136.10" });
@@ -395,6 +398,75 @@ describe("attachment fetch auth helpers", () => {
});
});
describe("Graph shared-link helpers", () => {
it.each([
["https://contoso.sharepoint.com/personal/user/Documents/report.pdf", true],
["https://contoso.sharepoint.us/sites/team/file.docx", true],
["https://contoso.sharepoint.cn/file", true],
["https://tenant-my.sharepoint.com/:b:/g/personal/file", true],
["https://1drv.ms/b/s!AkxYabc", true],
["https://onedrive.live.com/view.aspx?resid=ABC", true],
["https://onedrive.com/share/abc", true],
["https://graph.microsoft.com/v1.0/me", false],
["https://smba.trafficmanager.net/amer/v3", false],
["https://example.com/file.pdf", false],
["not-a-url", false],
])("isGraphSharedLinkUrl(%s) === %s", (url, expected) => {
expect(isGraphSharedLinkUrl(url)).toBe(expected);
});
it("encodeGraphShareId uses u! + base64url without padding", () => {
// Graph docs example: encoding "https://onedrive.live.com/redir?resid=..."
// should yield u!aHR0cHM6... (base64url, no '+', '/', or trailing '=').
const url = "https://contoso.sharepoint.com/sites/a/Shared Documents/file.pdf";
const shareId = encodeGraphShareId(url);
expect(shareId.startsWith("u!")).toBe(true);
const encoded = shareId.slice(2);
// base64url alphabet is A-Z, a-z, 0-9, '-', '_' (no padding).
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
// Round-trip check: decoding yields the original URL.
const decoded = Buffer.from(encoded, "base64url").toString("utf8");
expect(decoded).toBe(url);
});
it("encodeGraphShareId swaps '+' and '/' for '-' and '_'", () => {
// A URL whose standard base64 contains '+' and '/' chars.
// Choose an input that base64 encodes with those characters.
const url = "https://host.sharepoint.com/sites/path?x=???";
const shareId = encodeGraphShareId(url);
const encoded = shareId.slice(2);
expect(encoded).not.toContain("+");
expect(encoded).not.toContain("/");
expect(encoded).not.toContain("=");
});
it("tryBuildGraphSharesUrlForSharedLink rewrites SharePoint URLs", () => {
const url = "https://contoso.sharepoint.com/personal/user/Documents/report.pdf";
const result = tryBuildGraphSharesUrlForSharedLink(url);
expect(result).toBeDefined();
expect(result).toMatch(
/^https:\/\/graph\.microsoft\.com\/v1\.0\/shares\/u![A-Za-z0-9_-]+\/driveItem\/content$/,
);
});
it("tryBuildGraphSharesUrlForSharedLink rewrites OneDrive URLs", () => {
const url = "https://1drv.ms/b/s!AkxYabcdefg";
const result = tryBuildGraphSharesUrlForSharedLink(url);
expect(result).toBeDefined();
expect(result).toMatch(
/^https:\/\/graph\.microsoft\.com\/v1\.0\/shares\/u![A-Za-z0-9_-]+\/driveItem\/content$/,
);
});
it("tryBuildGraphSharesUrlForSharedLink returns undefined for non-shared URLs", () => {
expect(
tryBuildGraphSharesUrlForSharedLink("https://graph.microsoft.com/v1.0/me"),
).toBeUndefined();
expect(tryBuildGraphSharesUrlForSharedLink("https://example.com/file.pdf")).toBeUndefined();
expect(tryBuildGraphSharesUrlForSharedLink("not-a-url")).toBeUndefined();
});
});
describe("msteams inline image limits", () => {
const smallPngDataUrl = "data:image/png;base64,aGVsbG8="; // "hello" (5 bytes)

View File

@@ -84,6 +84,67 @@ export const DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST = [
export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
export { isRecord };
/**
* Host suffixes for SharePoint/OneDrive shared links that must be fetched via
* the Graph `/shares/{shareId}/driveItem/content` endpoint instead of directly.
*
* Direct fetches of SharePoint/OneDrive shared URLs return empty/HTML landing
* pages unless encoded as a Graph share id. See
* https://learn.microsoft.com/en-us/graph/api/shares-get for the encoding.
*/
const GRAPH_SHARED_LINK_HOST_SUFFIXES = [
".sharepoint.com",
".sharepoint.us",
".sharepoint.de",
".sharepoint.cn",
".sharepoint-df.com",
"1drv.ms",
"onedrive.live.com",
"onedrive.com",
] as const;
/**
* Returns true when the URL points at a SharePoint or OneDrive host whose
* shared-link content must be fetched through the Graph shares API rather
* than directly.
*/
export function isGraphSharedLinkUrl(url: string): boolean {
let host: string;
try {
host = normalizeLowercaseStringOrEmpty(new URL(url).hostname);
} catch {
return false;
}
if (!host) {
return false;
}
return GRAPH_SHARED_LINK_HOST_SUFFIXES.some((suffix) => host === suffix || host.endsWith(suffix));
}
/**
* Encode a SharePoint/OneDrive URL as a Graph shareId using the documented
* `u!` + base64url (no padding) scheme:
* https://learn.microsoft.com/en-us/graph/api/shares-get#encoding-sharing-urls
*/
export function encodeGraphShareId(url: string): string {
// Buffer.from(...).toString("base64url") already returns base64url without
// padding, matching the Graph spec exactly.
return `u!${Buffer.from(url, "utf8").toString("base64url")}`;
}
/**
* When `url` is a SharePoint/OneDrive shared link, return the matching
* `GET /shares/{shareId}/driveItem/content` URL that actually yields the file
* bytes. Returns `undefined` for non-shared-link URLs so callers can fall
* through to the existing fetch path.
*/
export function tryBuildGraphSharesUrlForSharedLink(url: string): string | undefined {
if (!isGraphSharedLinkUrl(url)) {
return undefined;
}
return `${GRAPH_ROOT}/shares/${encodeGraphShareId(url)}/driveItem/content`;
}
export function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
let current: unknown = value;
for (const key of keys) {

View File

@@ -29,6 +29,7 @@ import { formatUnknownError } from "./errors.js";
import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
import type { ProbeMSTeamsResult } from "./probe.js";
import {
looksLikeMSTeamsTargetId,
normalizeMSTeamsMessagingTarget,
normalizeMSTeamsUserInput,
parseMSTeamsConversationId,
@@ -166,21 +167,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
normalizeTarget: normalizeMSTeamsMessagingTarget,
resolveOutboundSessionRoute: (params) => resolveMSTeamsOutboundSessionRoute(params),
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (/^conversation:/i.test(trimmed)) {
return true;
}
if (/^user:/i.test(trimmed)) {
// Only treat as ID if the value after user: looks like a UUID
const id = trimmed.slice("user:".length).trim();
return /^[0-9a-fA-F-]{16,}$/.test(id);
}
return trimmed.includes("@thread");
},
looksLikeId: (raw) => looksLikeMSTeamsTargetId(raw),
hint: "<conversationId|user:ID|conversation:ID>",
},
},

View File

@@ -35,8 +35,12 @@ export function mergeStoredConversationReference(
): StoredConversationReference {
return {
// Preserve fields from previous entry that may not be present on every activity
// (e.g. timezone is only sent when clientInfo entity is available).
// (e.g. timezone is only sent when clientInfo entity is available;
// graphChatId is resolved via Graph API and cached for DM media downloads).
...(existing?.timezone && !incoming.timezone ? { timezone: existing.timezone } : {}),
...(existing?.graphChatId && !incoming.graphChatId
? { graphChatId: existing.graphChatId }
: {}),
...incoming,
lastSeenAt: nowIso,
};

View File

@@ -155,6 +155,30 @@ describe.each(storeFactories)("msteams conversation store ($name)", ({ createSto
});
});
it("preserves graphChatId across upserts that omit it", async () => {
const store = await createStore();
await store.upsert("conv-graph", {
conversation: { id: "conv-graph", conversationType: "personal" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1" },
graphChatId: "19:resolved-chat-id@unq.gbl.spaces",
});
// Second upsert without graphChatId (normal activity-based upsert)
await store.upsert("conv-graph", {
conversation: { id: "conv-graph", conversationType: "personal" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1" },
});
await expect(store.get("conv-graph")).resolves.toMatchObject({
graphChatId: "19:resolved-chat-id@unq.gbl.spaces",
});
});
it("prefers the freshest personal conversation for repeated upserts of the same user", async () => {
const store = await createStore();

View File

@@ -3,15 +3,25 @@ import { describe, expect, it, vi } from "vitest";
vi.mock("../attachments.js", () => ({
downloadMSTeamsAttachments: vi.fn(async () => []),
downloadMSTeamsGraphMedia: vi.fn(async () => ({ media: [] })),
downloadMSTeamsBotFrameworkAttachments: vi.fn(async () => ({ media: [], attachmentCount: 0 })),
buildMSTeamsGraphMessageUrls: vi.fn(() => [
"https://graph.microsoft.com/v1.0/chats/c/messages/m",
]),
extractMSTeamsHtmlAttachmentIds: vi.fn(() => ["att-0", "att-1"]),
isBotFrameworkPersonalChatId: vi.fn((id: string | null | undefined) => {
if (typeof id !== "string") {
return false;
}
return id.startsWith("a:") || id.startsWith("8:orgid:");
}),
}));
import {
downloadMSTeamsAttachments,
downloadMSTeamsGraphMedia,
buildMSTeamsGraphMessageUrls,
downloadMSTeamsAttachments,
downloadMSTeamsBotFrameworkAttachments,
downloadMSTeamsGraphMedia,
extractMSTeamsHtmlAttachmentIds,
} from "../attachments.js";
import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
@@ -73,3 +83,143 @@ describe("resolveMSTeamsInboundMedia graph fallback trigger", () => {
expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled();
});
});
describe("resolveMSTeamsInboundMedia bot framework DM routing", () => {
const dmParams = {
...baseParams,
conversationType: "personal",
conversationId: "a:1dRsHCobZ1AxURzY05Dc",
serviceUrl: "https://smba.trafficmanager.net/amer/",
};
it("routes 'a:' conversation IDs through the Bot Framework attachment endpoint", async () => {
vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]);
vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockClear();
vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockResolvedValue({
media: [
{
path: "/tmp/report.pdf",
contentType: "application/pdf",
placeholder: "<media:document>",
},
],
attachmentCount: 1,
});
vi.mocked(downloadMSTeamsGraphMedia).mockClear();
const mediaList = await resolveMSTeamsInboundMedia({
...dmParams,
attachments: [
{
contentType: "text/html",
content: '<div>A file <attachment id="att-0"></attachment></div>',
},
],
});
expect(downloadMSTeamsBotFrameworkAttachments).toHaveBeenCalledTimes(1);
const call = vi.mocked(downloadMSTeamsBotFrameworkAttachments).mock.calls[0]?.[0];
expect(call?.serviceUrl).toBe(dmParams.serviceUrl);
expect(call?.attachmentIds).toEqual(["att-0", "att-1"]);
expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled();
expect(mediaList).toHaveLength(1);
expect(mediaList[0].path).toBe("/tmp/report.pdf");
});
it("skips the Graph fallback entirely for 'a:' conversation IDs", async () => {
vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]);
vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockClear();
vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockResolvedValue({
media: [],
attachmentCount: 1,
});
vi.mocked(downloadMSTeamsGraphMedia).mockClear();
vi.mocked(buildMSTeamsGraphMessageUrls).mockClear();
await resolveMSTeamsInboundMedia({
...dmParams,
attachments: [
{
contentType: "text/html",
content: '<div><attachment id="att-0"></attachment></div>',
},
],
});
expect(downloadMSTeamsBotFrameworkAttachments).toHaveBeenCalled();
expect(buildMSTeamsGraphMessageUrls).not.toHaveBeenCalled();
expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled();
});
it("does NOT call the Bot Framework endpoint for Graph-compatible '19:' IDs", async () => {
vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]);
vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockClear();
vi.mocked(downloadMSTeamsGraphMedia).mockResolvedValue({ media: [] });
await resolveMSTeamsInboundMedia({
...baseParams,
conversationId: "19:abc@thread.tacv2",
serviceUrl: "https://smba.trafficmanager.net/amer/",
attachments: [
{
contentType: "text/html",
content: '<div><attachment id="att-0"></attachment></div>',
},
],
});
expect(downloadMSTeamsBotFrameworkAttachments).not.toHaveBeenCalled();
expect(downloadMSTeamsGraphMedia).toHaveBeenCalled();
});
it("logs when no attachment IDs are present on a BF DM with HTML content", async () => {
vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]);
vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockClear();
vi.mocked(extractMSTeamsHtmlAttachmentIds).mockReturnValueOnce([]);
const log = { debug: vi.fn() };
await resolveMSTeamsInboundMedia({
...dmParams,
log,
attachments: [{ contentType: "text/html", content: "<div>no attachments here</div>" }],
});
expect(downloadMSTeamsBotFrameworkAttachments).not.toHaveBeenCalled();
expect(log.debug).toHaveBeenCalledWith(
"bot framework attachment ids unavailable",
expect.objectContaining({ conversationType: "personal" }),
);
});
it("logs when serviceUrl is missing for a BF DM with HTML content", async () => {
vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]);
vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockClear();
vi.mocked(downloadMSTeamsGraphMedia).mockClear();
vi.mocked(buildMSTeamsGraphMessageUrls).mockClear();
const log = { debug: vi.fn() };
await resolveMSTeamsInboundMedia({
...baseParams,
log,
conversationType: "personal",
conversationId: "a:bf-dm-id",
attachments: [
{
contentType: "text/html",
content: '<div><attachment id="att-0"></attachment></div>',
},
],
});
expect(downloadMSTeamsBotFrameworkAttachments).not.toHaveBeenCalled();
// Graph fallback is also skipped because the ID is 'a:'
expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled();
expect(log.debug).toHaveBeenCalledWith(
"bot framework attachment skipped (missing serviceUrl)",
expect.objectContaining({
conversationType: "personal",
conversationId: "a:bf-dm-id",
}),
);
});
});

View File

@@ -1,7 +1,10 @@
import {
buildMSTeamsGraphMessageUrls,
downloadMSTeamsAttachments,
downloadMSTeamsBotFrameworkAttachments,
downloadMSTeamsGraphMedia,
extractMSTeamsHtmlAttachmentIds,
isBotFrameworkPersonalChatId,
type MSTeamsAccessTokenProvider,
type MSTeamsAttachmentLike,
type MSTeamsHtmlAttachmentSummary,
@@ -23,6 +26,7 @@ export async function resolveMSTeamsInboundMedia(params: {
conversationType: string;
conversationId: string;
conversationMessageId?: string;
serviceUrl?: string;
activity: Pick<MSTeamsTurnContext["activity"], "id" | "replyToId" | "channelData">;
log: MSTeamsLogger;
/** When true, embeds original filename in stored path for later extraction. */
@@ -37,6 +41,7 @@ export async function resolveMSTeamsInboundMedia(params: {
conversationType,
conversationId,
conversationMessageId,
serviceUrl,
activity,
log,
preserveFilenames,
@@ -56,7 +61,50 @@ export async function resolveMSTeamsInboundMedia(params: {
(att) => typeof att.contentType === "string" && att.contentType.startsWith("text/html"),
);
if (hasHtmlAttachment) {
// Personal DMs with the bot use Bot Framework conversation IDs (`a:...`
// or `8:orgid:...`) which Graph's `/chats/{id}` endpoint rejects with
// "Invalid ThreadId". Fetch media via the Bot Framework v3 attachments
// endpoint instead, which speaks the same identifier space.
if (hasHtmlAttachment && isBotFrameworkPersonalChatId(conversationId)) {
if (!serviceUrl) {
log.debug?.("bot framework attachment skipped (missing serviceUrl)", {
conversationType,
conversationId,
});
} else {
const attachmentIds = extractMSTeamsHtmlAttachmentIds(attachments);
if (attachmentIds.length === 0) {
log.debug?.("bot framework attachment ids unavailable", {
conversationType,
conversationId,
});
} else {
const bfMedia = await downloadMSTeamsBotFrameworkAttachments({
serviceUrl,
attachmentIds,
tokenProvider,
maxBytes,
allowHosts,
authAllowHosts: params.authAllowHosts,
preserveFilenames,
});
if (bfMedia.media.length > 0) {
mediaList = bfMedia.media;
} else {
log.debug?.("bot framework attachments fetch empty", {
conversationType,
attachmentCount: bfMedia.attachmentCount ?? attachmentIds.length,
});
}
}
}
}
if (
hasHtmlAttachment &&
mediaList.length === 0 &&
!isBotFrameworkPersonalChatId(conversationId)
) {
const messageUrls = buildMSTeamsGraphMessageUrls({
conversationType,
conversationId,

View File

@@ -25,6 +25,7 @@ import {
import { isRecord } from "../attachments/shared.js";
import type { StoredConversationReference } from "../conversation-store.js";
import { formatUnknownError } from "../errors.js";
import { resolveGraphChatId } from "../graph-upload.js";
import {
fetchChannelMessage,
fetchThreadReplies,
@@ -526,13 +527,42 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
return;
}
}
const graphConversationId = translateMSTeamsDmConversationIdForGraph({
let graphConversationId = translateMSTeamsDmConversationIdForGraph({
isDirectMessage,
conversationId,
aadObjectId: from.aadObjectId,
appId,
});
// For personal DMs the Bot Framework conversation ID (`a:...`) and the
// synthetic `19:{userId}_{appId}@unq.gbl.spaces` format produced by
// translateMSTeamsDmConversationIdForGraph are not always accepted by the
// Graph `/chats/{chatId}/messages` endpoint. Resolve the real Graph chat
// ID via the API (with conversation store caching) so the Graph media
// download fallback works when the direct Bot Framework download fails.
if (isDirectMessage && conversationId.startsWith("a:")) {
const cached = await conversationStore.get(conversationId);
if (cached?.graphChatId) {
graphConversationId = cached.graphChatId;
} else {
try {
const resolved = await resolveGraphChatId({
botFrameworkConversationId: conversationId,
userAadObjectId: from.aadObjectId ?? undefined,
tokenProvider,
});
if (resolved) {
graphConversationId = resolved;
conversationStore
.upsert(conversationId, { ...conversationRef, graphChatId: resolved })
.catch(() => {});
}
} catch {
log.debug?.("failed to resolve Graph chat ID for inbound media", { conversationId });
}
}
}
const mediaList = await resolveMSTeamsInboundMedia({
attachments,
htmlSummary: htmlSummary ?? undefined,
@@ -543,6 +573,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
conversationType,
conversationId: graphConversationId,
conversationMessageId: conversationMessageId ?? undefined,
serviceUrl: activity.serviceUrl,
activity: {
id: activity.id,
replyToId: activity.replyToId,

View File

@@ -26,6 +26,7 @@ vi.mock("./graph-users.js", () => ({
}));
import {
looksLikeMSTeamsTargetId,
resolveMSTeamsChannelAllowlist,
resolveMSTeamsUserAllowlist,
} from "./resolve-allowlist.js";
@@ -144,3 +145,65 @@ describe("resolveMSTeamsChannelAllowlist", () => {
});
});
});
describe("looksLikeMSTeamsTargetId", () => {
// Regression suite for https://github.com/openclaw/openclaw/issues/58001:
// cron announce delivery rejected valid Teams conversation ids because the
// validator only matched the `conversation:`-prefixed and `@thread`-suffixed
// forms. It must now accept every documented Bot Framework + Graph format.
it.each([
"conversation:19:abc@thread.tacv2",
"conversation:a:1abc",
"conversation:8:orgid:2d8c2d2c-1111-2222-3333-444444444444",
])("accepts conversation-prefixed ids (%s)", (raw) => {
expect(looksLikeMSTeamsTargetId(raw)).toBe(true);
});
it.each(["19:AdviChannelId@thread.tacv2", "19:abc@thread.tacv2", "19:abc@thread.skype"])(
"accepts bare channel/group conversation ids (%s)",
(raw) => {
expect(looksLikeMSTeamsTargetId(raw)).toBe(true);
},
);
it("accepts the Graph 1:1 chat thread format", () => {
expect(
looksLikeMSTeamsTargetId(
"19:40a1a0ed4ff24164a21955518990c197_2d8c2d2c11112222@unq.gbl.spaces",
),
).toBe(true);
});
it.each(["a:1abc123def", "a:1xyz-abc_def", "A:1UPPER"])(
"accepts Bot Framework personal chat ids (%s)",
(raw) => {
expect(looksLikeMSTeamsTargetId(raw)).toBe(true);
},
);
it.each(["8:orgid:2d8c2d2c-1111-2222-3333-444444444444", "8:orgid:user-object-id"])(
"accepts Bot Framework org-scoped personal chat ids (%s)",
(raw) => {
expect(looksLikeMSTeamsTargetId(raw)).toBe(true);
},
);
it("accepts Bot Framework user ids", () => {
expect(looksLikeMSTeamsTargetId("29:1a2b3c4d5e6f")).toBe(true);
});
it("accepts user:<aad-object-id> ids", () => {
expect(looksLikeMSTeamsTargetId("user:40a1a0ed-4ff2-4164-a219-55518990c197")).toBe(true);
});
it.each(["", " ", "user:John Smith", "Product Team/Roadmap", "Engineering", "hello"])(
"rejects non-id inputs (%s)",
(raw) => {
expect(looksLikeMSTeamsTargetId(raw)).toBe(false);
},
);
it("normalizes leading/trailing whitespace before classifying", () => {
expect(looksLikeMSTeamsTargetId(" 19:abc@thread.tacv2 ")).toBe(true);
});
});

View File

@@ -65,6 +65,63 @@ export function parseMSTeamsConversationId(raw: string): string | null {
return id;
}
/**
* Detect whether a raw target string looks like a Microsoft Teams conversation
* or user id that cron announce delivery and other explicit-target paths can
* forward verbatim to the channel adapter.
*
* Accepts both prefixed and bare formats:
* - `conversation:<id>` — explicit conversation prefix
* - `user:<aad-guid>` — user id (16+ hex chars, UUID-like)
* - `19:abc@thread.tacv2` / `19:abc@thread.skype` — channel / legacy group
* - `19:{userId}_{appId}@unq.gbl.spaces` — Graph 1:1 chat thread format
* - `a:1xxx` — Bot Framework personal (1:1) chat id
* - `8:orgid:xxx` — Bot Framework org-scoped personal chat id
* - `29:xxx` — Bot Framework user id
*
* Display-name user targets such as `user:John Smith` intentionally return
* false so that the Graph API directory lookup still runs for them.
*/
export function looksLikeMSTeamsTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (/^conversation:/i.test(trimmed)) {
return true;
}
if (/^user:/i.test(trimmed)) {
// Only treat as an id when the value after `user:` looks like a UUID;
// display names must fall through to directory lookup.
const id = trimmed.slice("user:".length).trim();
return /^[0-9a-fA-F-]{16,}$/.test(id);
}
// Bare Bot Framework / Graph conversation id formats.
// Channel / group ids always start with `19:` and include an `@thread.*`
// suffix (`@thread.tacv2` or the legacy `@thread.skype`). Personal chat
// ids come in three shapes: `a:1...` (Bot Framework), `8:orgid:...`
// (org-scoped Bot Framework), and `19:{userId}_{appId}@unq.gbl.spaces`
// (Graph API 1:1 chat thread). Bot Framework user ids use `29:...`.
if (/^19:.+@thread\.(tacv2|skype)$/i.test(trimmed)) {
return true;
}
if (/^19:.+@unq\.gbl\.spaces$/i.test(trimmed)) {
return true;
}
if (/^a:1[A-Za-z0-9_-]+$/i.test(trimmed)) {
return true;
}
if (/^8:orgid:[A-Za-z0-9-]+$/i.test(trimmed)) {
return true;
}
if (/^29:[A-Za-z0-9_-]+$/i.test(trimmed)) {
return true;
}
// Fallback: anything containing @thread is still treated as a conversation
// id so the current matches for tenant-specific suffixes remain accepted.
return /@thread\b/i.test(trimmed);
}
function normalizeMSTeamsTeamKey(raw: string): string | undefined {
const trimmed = stripProviderPrefix(raw)
.replace(/^team:/i, "")

View File

@@ -7,33 +7,43 @@ import {
} from "./sdk.js";
import type { MSTeamsCredentials } from "./token.js";
const jwtValidatorState = vi.hoisted(() => ({
instances: [] as Array<{ config: Record<string, unknown> }>,
behaviorByJwks: new Map<string, "success" | "null" | "throw">(),
calls: [] as Array<{ jwksUri: string; token: string; overrideOptions?: unknown }>,
}));
const clientConstructorState = vi.hoisted(() => ({
calls: [] as Array<{ serviceUrl: string; options: unknown }>,
}));
vi.mock("@microsoft/teams.apps/dist/middleware/auth/jwt-validator.js", () => ({
JwtValidator: class JwtValidator {
private readonly config: Record<string, unknown>;
// Track jwt.verify calls to assert audience/issuer/algorithm config.
const jwtState = vi.hoisted(() => ({
verifyBehavior: "success" as "success" | "throw",
decodedHeader: { kid: "key-1" } as { kid?: string } | null,
decodedPayload: { iss: "https://api.botframework.com" } as { iss?: string } | null,
verifyCalls: [] as Array<{ token: string; options: unknown }>,
}));
constructor(config: Record<string, unknown>) {
this.config = config;
jwtValidatorState.instances.push({ config });
const jwtMockImpl = {
decode: (token: string, opts?: { complete?: boolean }) => {
if (opts?.complete) {
return jwtState.decodedHeader ? { header: jwtState.decodedHeader } : null;
}
return jwtState.decodedPayload;
},
verify: (token: string, _key: string, options: unknown) => {
jwtState.verifyCalls.push({ token, options });
if (jwtState.verifyBehavior === "throw") {
throw new Error("invalid signature");
}
return { sub: "ok" };
},
};
async validateAccessToken(token: string, overrideOptions?: unknown): Promise<object | null> {
const jwksUri = String((this.config.jwksUriOptions as { uri?: string })?.uri ?? "");
jwtValidatorState.calls.push({ jwksUri, token, overrideOptions });
const behavior = jwtValidatorState.behaviorByJwks.get(jwksUri) ?? "null";
if (behavior === "throw") {
throw new Error("validator error");
}
return behavior === "success" ? { sub: "ok" } : null;
vi.mock("jsonwebtoken", () => ({
...jwtMockImpl,
default: jwtMockImpl,
}));
vi.mock("jwks-rsa", () => ({
JwksClient: class JwksClient {
async getSigningKey(_kid: string) {
return { getPublicKey: () => "mock-public-key" };
}
},
}));
@@ -43,9 +53,10 @@ const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
clientConstructorState.calls.length = 0;
jwtValidatorState.instances.length = 0;
jwtValidatorState.calls.length = 0;
jwtValidatorState.behaviorByJwks.clear();
jwtState.verifyCalls.length = 0;
jwtState.verifyBehavior = "success";
jwtState.decodedHeader = { kid: "key-1" };
jwtState.decodedPayload = { iss: "https://api.botframework.com" };
vi.restoreAllMocks();
});
@@ -186,106 +197,90 @@ describe("createBotFrameworkJwtValidator", () => {
tenantId: "tenant-id",
} satisfies MSTeamsCredentials;
it("validates with legacy Bot Framework JWKS and issuer first", async () => {
jwtValidatorState.behaviorByJwks.set(
"https://login.botframework.com/v1/.well-known/keys",
"success",
);
it("validates a token with Bot Framework issuer and correct audience list", async () => {
jwtState.decodedPayload = { iss: "https://api.botframework.com" };
const validator = await createBotFrameworkJwtValidator(creds);
await expect(validator.validate("Bearer token-1", "https://service.example.com")).resolves.toBe(
true,
);
await expect(validator.validate("Bearer token-bf")).resolves.toBe(true);
expect(jwtValidatorState.instances).toHaveLength(2);
expect(jwtValidatorState.calls).toHaveLength(1);
expect(jwtValidatorState.calls[0]).toMatchObject({
jwksUri: "https://login.botframework.com/v1/.well-known/keys",
token: "token-1",
overrideOptions: {
validateServiceUrl: { expectedServiceUrl: "https://service.example.com" },
},
});
expect(jwtState.verifyCalls).toHaveLength(1);
const opts = jwtState.verifyCalls[0]?.options as Record<string, unknown>;
expect(opts.audience).toEqual(["app-id", "api://app-id", "https://api.botframework.com"]);
expect(opts.algorithms).toEqual(["RS256"]);
expect(opts.clockTolerance).toBe(300);
});
it("falls back to Entra JWKS when Bot Framework validation fails", async () => {
jwtValidatorState.behaviorByJwks.set(
"https://login.botframework.com/v1/.well-known/keys",
"null",
);
jwtValidatorState.behaviorByJwks.set(
"https://login.microsoftonline.com/common/discovery/v2.0/keys",
"success",
);
it("accepts tokens with aud: https://api.botframework.com (#58249)", async () => {
// This is the critical fix: the old JwtValidator rejected this audience.
jwtState.decodedPayload = { iss: "https://api.botframework.com" };
const validator = await createBotFrameworkJwtValidator(creds);
await expect(validator.validate("Bearer token-2")).resolves.toBe(true);
await expect(validator.validate("Bearer botfw-token")).resolves.toBe(true);
expect(jwtValidatorState.calls).toHaveLength(2);
expect(jwtValidatorState.calls[0]?.jwksUri).toBe(
"https://login.botframework.com/v1/.well-known/keys",
);
expect(jwtValidatorState.calls[1]?.jwksUri).toBe(
"https://login.microsoftonline.com/common/discovery/v2.0/keys",
);
const entraConfig = jwtValidatorState.instances
.map((instance) => instance.config)
.find(
(config) =>
String((config.jwksUriOptions as { uri?: string })?.uri) ===
"https://login.microsoftonline.com/common/discovery/v2.0/keys",
);
expect(entraConfig).toBeDefined();
expect(entraConfig?.validateIssuer).toEqual({ allowedTenantIds: ["tenant-id"] });
const opts = jwtState.verifyCalls[0]?.options as Record<string, unknown>;
expect((opts.audience as string[]).includes("https://api.botframework.com")).toBe(true);
});
it("falls back to Entra JWKS when Bot Framework validation throws", async () => {
jwtValidatorState.behaviorByJwks.set(
"https://login.botframework.com/v1/.well-known/keys",
"throw",
);
jwtValidatorState.behaviorByJwks.set(
"https://login.microsoftonline.com/common/discovery/v2.0/keys",
"success",
);
it("validates a token with Entra issuer", async () => {
jwtState.decodedPayload = { iss: `https://login.microsoftonline.com/tenant-id/v2.0` };
const validator = await createBotFrameworkJwtValidator(creds);
await expect(
validator.validate("Bearer token-throw", "https://service.example.com"),
).resolves.toBe(true);
await expect(validator.validate("Bearer token-entra")).resolves.toBe(true);
expect(jwtValidatorState.calls).toHaveLength(2);
expect(jwtValidatorState.calls[0]).toMatchObject({
jwksUri: "https://login.botframework.com/v1/.well-known/keys",
token: "token-throw",
overrideOptions: {
validateServiceUrl: { expectedServiceUrl: "https://service.example.com" },
},
});
expect(jwtValidatorState.calls[1]).toMatchObject({
jwksUri: "https://login.microsoftonline.com/common/discovery/v2.0/keys",
token: "token-throw",
overrideOptions: {
validateServiceUrl: { expectedServiceUrl: "https://service.example.com" },
},
});
expect(jwtState.verifyCalls).toHaveLength(1);
const opts = jwtState.verifyCalls[0]?.options as Record<string, unknown>;
expect(opts.issuer as string[]).toContain("https://login.microsoftonline.com/tenant-id/v2.0");
});
it("returns false when all validator paths fail", async () => {
jwtValidatorState.behaviorByJwks.set(
"https://login.botframework.com/v1/.well-known/keys",
"throw",
);
it("validates a token with STS Windows issuer", async () => {
jwtState.decodedPayload = {
iss: "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/",
};
const validator = await createBotFrameworkJwtValidator(creds);
await expect(validator.validate("Bearer token-3")).resolves.toBe(false);
expect(jwtValidatorState.calls).toHaveLength(2);
await expect(validator.validate("Bearer token-sts")).resolves.toBe(true);
expect(jwtState.verifyCalls).toHaveLength(1);
const opts = jwtState.verifyCalls[0]?.options as Record<string, unknown>;
expect(opts.issuer as string[]).toContain(
"https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/",
);
});
it("rejects tokens with unknown issuer", async () => {
jwtState.decodedPayload = { iss: "https://evil.example.com" };
const validator = await createBotFrameworkJwtValidator(creds);
await expect(validator.validate("Bearer token-evil")).resolves.toBe(false);
expect(jwtState.verifyCalls).toHaveLength(0);
});
it("returns false when signature verification fails", async () => {
jwtState.verifyBehavior = "throw";
const validator = await createBotFrameworkJwtValidator(creds);
await expect(validator.validate("Bearer token-bad")).resolves.toBe(false);
});
it("returns false for empty bearer token", async () => {
const validator = await createBotFrameworkJwtValidator(creds);
await expect(validator.validate("Bearer ")).resolves.toBe(false);
expect(jwtValidatorState.calls).toHaveLength(0);
expect(jwtState.verifyCalls).toHaveLength(0);
});
it("returns false when token has no kid header", async () => {
jwtState.decodedHeader = { kid: undefined };
const validator = await createBotFrameworkJwtValidator(creds);
await expect(validator.validate("Bearer no-kid")).resolves.toBe(false);
expect(jwtState.verifyCalls).toHaveLength(0);
});
it("returns false when token has no issuer claim", async () => {
jwtState.decodedPayload = { iss: undefined };
const validator = await createBotFrameworkJwtValidator(creds);
await expect(validator.validate("Bearer no-iss")).resolves.toBe(false);
expect(jwtState.verifyCalls).toHaveLength(0);
});
});

View File

@@ -428,72 +428,127 @@ export async function loadMSTeamsSdkWithAuth(creds: MSTeamsCredentials) {
}
/**
* Create a Bot Framework JWT validator with strict multi-issuer support.
* Bot Framework issuer → JWKS mapping.
* During Microsoft's transition, inbound service tokens can be signed by either
* the legacy Bot Framework issuer or the Entra issuer. Each gets its own JWKS
* endpoint so we verify signatures with the correct key set.
*/
const BOT_FRAMEWORK_ISSUERS: ReadonlyArray<{
issuer: string | ((tenantId: string) => string);
jwksUri: string;
}> = [
{
issuer: "https://api.botframework.com",
jwksUri: "https://login.botframework.com/v1/.well-known/keys",
},
{
issuer: (tenantId: string) => `https://login.microsoftonline.com/${tenantId}/v2.0`,
jwksUri: "https://login.microsoftonline.com/common/discovery/v2.0/keys",
},
{
issuer: "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/",
jwksUri: "https://login.microsoftonline.com/common/discovery/v2.0/keys",
},
];
/**
* Create a Bot Framework JWT validator using jsonwebtoken + jwks-rsa directly.
*
* During Microsoft's transition, inbound service tokens can be signed by either:
* - Legacy Bot Framework issuer/JWKS
* - Entra issuer/JWKS
* The @microsoft/teams.apps JwtValidator hardcodes audience to [clientId, api://clientId],
* which rejects valid Bot Framework tokens that carry aud: "https://api.botframework.com".
* This implementation uses jsonwebtoken directly with the correct audience list, matching
* the behavior of the legacy @microsoft/agents-hosting authorizeJWT middleware.
*
* Security invariants are preserved for both paths:
* - signature verification (issuer-specific JWKS)
* - audience validation (appId)
* - issuer validation (strict allowlist)
* - expiration validation (Teams SDK defaults)
* Security invariants:
* - signature verification via issuer-specific JWKS endpoints
* - audience validation: appId, api://appId, and https://api.botframework.com
* - issuer validation: strict allowlist (Bot Framework + tenant-scoped Entra)
* - expiration validation with 5-minute clock tolerance
*/
export async function createBotFrameworkJwtValidator(creds: MSTeamsCredentials): Promise<{
validate: (authHeader: string, serviceUrl?: string) => Promise<boolean>;
validate: (authHeader: string) => Promise<boolean>;
}> {
const { JwtValidator } =
await import("@microsoft/teams.apps/dist/middleware/auth/jwt-validator.js");
const jwt = await import("jsonwebtoken");
const { JwksClient } = await import("jwks-rsa");
const botFrameworkValidator = new JwtValidator({
clientId: creds.appId,
tenantId: creds.tenantId,
validateIssuer: { allowedIssuer: "https://api.botframework.com" },
jwksUriOptions: {
type: "uri",
uri: "https://login.botframework.com/v1/.well-known/keys",
},
});
const allowedAudiences: [string, ...string[]] = [
creds.appId,
`api://${creds.appId}`,
"https://api.botframework.com",
];
const entraValidator = new JwtValidator({
clientId: creds.appId,
tenantId: creds.tenantId,
validateIssuer: { allowedTenantIds: [creds.tenantId] },
jwksUriOptions: {
type: "uri",
uri: "https://login.microsoftonline.com/common/discovery/v2.0/keys",
},
});
const allowedIssuers = BOT_FRAMEWORK_ISSUERS.map((entry) =>
typeof entry.issuer === "function" ? entry.issuer(creds.tenantId) : entry.issuer,
) as [string, ...string[]];
async function validateWithFallback(
token: string,
overrides: { validateServiceUrl: { expectedServiceUrl: string } } | undefined,
): Promise<boolean> {
for (const validator of [botFrameworkValidator, entraValidator]) {
try {
const result = await validator.validateAccessToken(token, overrides);
if (result != null) {
return true;
}
} catch {
continue;
}
// One JWKS client per distinct endpoint, cached for the validator lifetime.
const jwksClients = new Map<string, InstanceType<typeof JwksClient>>();
function getJwksClient(uri: string): InstanceType<typeof JwksClient> {
let client = jwksClients.get(uri);
if (!client) {
client = new JwksClient({
jwksUri: uri,
cache: true,
cacheMaxAge: 600_000,
rateLimit: true,
});
jwksClients.set(uri, client);
}
return false;
return client;
}
/** Decode the token header without verification to determine the kid. */
function decodeHeader(token: string): { kid?: string } | null {
const decoded = jwt.decode(token, { complete: true });
return decoded && typeof decoded === "object" ? (decoded.header as { kid?: string }) : null;
}
/** Resolve the issuer entry for a token's issuer claim (pre-verification). */
function resolveIssuerEntry(issuerClaim: string | undefined) {
if (!issuerClaim) {
return undefined;
}
return BOT_FRAMEWORK_ISSUERS.find((entry) => {
const expected =
typeof entry.issuer === "function" ? entry.issuer(creds.tenantId) : entry.issuer;
return expected === issuerClaim;
});
}
return {
async validate(authHeader: string, serviceUrl?: string): Promise<boolean> {
async validate(authHeader: string, _serviceUrl?: string): Promise<boolean> {
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
if (!token) {
return false;
}
const overrides = serviceUrl
? ({ validateServiceUrl: { expectedServiceUrl: serviceUrl } } as const)
: undefined;
return await validateWithFallback(token, overrides);
// Decode without verification to extract issuer and kid for key lookup.
const header = decodeHeader(token);
const unverifiedPayload = jwt.decode(token) as { iss?: string } | null;
if (!header?.kid || !unverifiedPayload?.iss) {
return false;
}
// Resolve which JWKS endpoint to use based on the issuer claim.
const issuerEntry = resolveIssuerEntry(unverifiedPayload.iss);
if (!issuerEntry) {
return false;
}
const client = getJwksClient(issuerEntry.jwksUri);
try {
const signingKey = await client.getSigningKey(header.kid);
const publicKey = signingKey.getPublicKey();
jwt.verify(token, publicKey, {
audience: allowedAudiences,
issuer: allowedIssuers,
algorithms: ["RS256"],
clockTolerance: 300,
});
return true;
} catch {
return false;
}
},
};
}

View File

@@ -1,14 +1,11 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type {
CreateSandboxBackendParams,
OpenClawConfig,
RemoteShellSandboxHandle,
SandboxBackendCommandParams,
SandboxBackendCommandResult,
SandboxBackendFactory,
SandboxBackendHandle,
SandboxBackendManager,
SshSandboxSession,
} from "openclaw/plugin-sdk/sandbox";
@@ -20,6 +17,7 @@ import {
sanitizeEnvVars,
} from "openclaw/plugin-sdk/sandbox";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { OpenShellSandboxBackend } from "./backend.types.js";
import {
buildExecRemoteCommand,
buildRemoteCommand,
@@ -47,11 +45,7 @@ export function buildOpenShellSshExecEnv(): NodeJS.ProcessEnv {
return sanitizeEnvVars(process.env).allowed;
}
export type OpenShellSandboxBackend = SandboxBackendHandle &
RemoteShellSandboxHandle & {
mode: "mirror" | "remote";
syncLocalPathToRemote(localPath: string, remotePath: string): Promise<void>;
};
export type { OpenShellFsBridgeContext, OpenShellSandboxBackend } from "./backend.types.js";
export function createOpenShellSandboxBackendFactory(
params: CreateOpenShellSandboxBackendFactoryParams,
@@ -517,5 +511,5 @@ function buildOpenShellSandboxName(scopeKey: string): string {
}
function resolveOpenShellTmpRoot(): string {
return path.resolve(resolvePreferredOpenClawTmpDir() ?? os.tmpdir());
return path.resolve(resolvePreferredOpenClawTmpDir());
}

View File

@@ -0,0 +1,11 @@
import type { RemoteShellSandboxHandle, SandboxBackendHandle } from "openclaw/plugin-sdk/sandbox";
export type OpenShellFsBridgeContext = Parameters<
NonNullable<SandboxBackendHandle["createFsBridge"]>
>[0]["sandbox"];
export type OpenShellSandboxBackend = SandboxBackendHandle &
RemoteShellSandboxHandle & {
mode: "mirror" | "remote";
syncLocalPathToRemote(localPath: string, remotePath: string): Promise<void>;
};

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