Compare commits

..

101 Commits

Author SHA1 Message Date
Josh Lehman
53199c0c37 test: fix codex contract refresh fallback
Point the stale contract-test mock at the current Codex runtime module and instantiate the provider directly for the one mock-sensitive fallback case. This keeps the fallback covered without changing production refresh behavior or the separate registry contract coverage.

Regeneration-Prompt: |
  Fix MAR-231, the unrelated prep blocker on PR #54392. The failing surface was src/plugins/contracts/runtime.contract.test.ts for the OpenAI Codex refresh fallback case. Keep production behavior unchanged. The contract loader uses Jiti for bundled providers, which bypasses the Vitest mock of the runtime wrapper and caused the test to hit the real OAuth refresh path. Update the stale runtime mock path and make only that fallback assertion use the direct provider module, while leaving registry coverage in src/plugins/contracts/registry.contract.test.ts intact. Validate with the focused Codex runtime contract test and the registry contract test.
2026-03-26 20:20:35 -07:00
Peter Steinberger
f6de4cd766 refactor: remove memory-core runtime barrel 2026-03-27 02:54:23 +00:00
Peter Steinberger
c9ab095099 refactor: deduplicate plugin config schemas 2026-03-27 02:53:08 +00:00
Peter Steinberger
a331270f8a fix: restore green build after upstream API drift 2026-03-27 02:49:53 +00:00
Peter Steinberger
bd6c7969ea refactor: extract memory host sdk package 2026-03-27 02:49:33 +00:00
Peter Steinberger
dff3ca2018 fix: stabilize ci after deps refresh 2026-03-27 02:44:05 +00:00
Kinfey
c959ac3a25 fix: WSL2 Ollama networking and provider discovery diagnostics (#55435)
- Fix Ollama stream handling for WSL2 environments
- Update undici global dispatcher for WSL2 networking compatibility
- Adjust provider discovery configuration
- Add WSL2 networking tests
2026-03-26 21:41:05 -05:00
Peter Steinberger
9df9bd436e refactor(config): dedupe legacy migration metadata 2026-03-27 02:37:47 +00:00
Peter Steinberger
66e7e29219 refactor(config): simplify version and allowed-value resolution 2026-03-27 02:37:17 +00:00
Peter Steinberger
417b3dd5e0 refactor: move channel prefer-over metadata into manifests 2026-03-27 02:36:56 +00:00
Peter Steinberger
b96f5d94db chore(telegram): downgrade default network logs 2026-03-27 02:29:32 +00:00
Peter Steinberger
465f830bcd fix(config): support uri formats in schema validation 2026-03-27 02:29:32 +00:00
Peter Steinberger
0b94382930 fix(plugins): prefer runtime version for host compatibility 2026-03-27 02:29:32 +00:00
Peter Steinberger
dd098596cf refactor: collapse bundled channel metadata into plugin manifests 2026-03-27 02:29:19 +00:00
Peter Steinberger
ea60bc01b9 refactor(config): drop stale legacy migrations 2026-03-27 02:29:08 +00:00
Peter Steinberger
10527ff8a3 build: refresh deps and vitest cache lanes 2026-03-27 02:26:07 +00:00
Peter Steinberger
b49accc273 test: add websocket replay planning coverage 2026-03-27 02:16:01 +00:00
Peter Steinberger
86bac4ee2a refactor: split openai websocket message conversion 2026-03-27 02:16:01 +00:00
Vincent Koc
fa2a318f40 Align ACPX built-in agent registry with latest acpx (#55476)
* Add Cursor CLI to ACP allowedAgents

- acpx: add cursor to ACPX_BUILTIN_AGENT_COMMANDS (agent acp)
- docs: add cursor to acp-agents harness list and allowedAgents example

Fixes #28321

Made-with: Cursor

* ACP Cursor: add to acp-router skill, system-prompt, and schema help

- acp-router SKILL: add Cursor to description, intent, agentId mapping,
  harness aliases, and built-in adapter commands (agent acp)
- system-prompt: add cursor to ACP harness example
- schema.help: add cursor to runtime.acp.agent example

Fixes #28321

Made-with: Cursor

* fix(acpx): align built-in agent registry with latest acpx

---------

Co-authored-by: Rob MacDonald <rob@robmacdonald.com>
2026-03-26 19:15:17 -07:00
Peter Steinberger
e9f54ca815 docs: update parallels smoke guidance 2026-03-27 02:15:15 +00:00
Peter Steinberger
1ff1679984 test: harden parallels windows smoke polling 2026-03-27 02:15:15 +00:00
Tak Hoffman
3e5d86384e fix: initialize reset transcript files 2026-03-26 21:12:36 -05:00
Peter Steinberger
77d15841d7 refactor: move manifest legacy migration into doctor 2026-03-27 02:09:58 +00:00
Tak Hoffman
5404b0eaa6 fix(msteams): preserve timezone on memory upsert 2026-03-26 21:06:16 -05:00
Tak Hoffman
708b9339a5 fix: assign reset transcript paths to new sessions 2026-03-26 21:05:21 -05:00
Peter Steinberger
14b3360c22 chore: bump versions to 2026.3.26 2026-03-27 02:03:22 +00:00
Peter Steinberger
7a35bca2ec refactor: make memory embedding adapters generic 2026-03-27 02:02:24 +00:00
Peter Steinberger
42be3fb059 refactor: collapse manifest contract mirrors 2026-03-27 02:01:59 +00:00
Peter Steinberger
b666ce692f refactor: extract openai ws replay helpers 2026-03-27 02:00:51 +00:00
Peter Steinberger
60a8dd95de refactor: split compaction safeguard quality helpers 2026-03-27 02:00:09 +00:00
Peter Steinberger
40bd36e35d refactor: move channel config metadata into plugin-owned manifests 2026-03-27 01:59:30 +00:00
Tak Hoffman
e1ff753790 fix: persist create transcript paths in session entries 2026-03-26 20:54:15 -05:00
Peter Steinberger
ca01595699 refactor: split tool display exec parsing 2026-03-27 01:50:32 +00:00
Peter Steinberger
d7b61228e2 fix: tighten openai ws reasoning replay (#53856) 2026-03-27 01:49:55 +00:00
Peter Steinberger
ad21d84940 docs: add apply_patch changelog note 2026-03-27 01:46:30 +00:00
Peter Steinberger
ab6ddf7245 refactor: slim plugin sdk provider entrypoints 2026-03-27 01:45:53 +00:00
Peter Steinberger
485bfe95ed fix: clean bundled contract metadata follow-ups 2026-03-27 01:45:53 +00:00
Peter Steinberger
ba7804df50 refactor: derive bundled contracts from extension manifests 2026-03-27 01:45:52 +00:00
Peter Steinberger
b75be09144 refactor: split subagent announce delivery helpers 2026-03-27 01:44:59 +00:00
Tak Hoffman
320c0d65a1 fix: keep session settings in chat lifecycle events 2026-03-26 20:38:35 -05:00
Peter Steinberger
3fdd7c9e00 refactor: split compaction hooks 2026-03-27 01:36:13 +00:00
Peter Steinberger
f862685ed8 refactor: split subagent registry lifecycle 2026-03-27 01:33:13 +00:00
MiloStack
b33ad4d7cb fix: guarantee heartbeat timer re-arm with try/finally (#52270)
Merged via squash.

Prepared head SHA: cd0bcc2fb8
Co-authored-by: MiloStack <265805734+MiloStack@users.noreply.github.com>
Co-authored-by: grp06 <1573959+grp06@users.noreply.github.com>
Reviewed-by: @grp06
2026-03-26 18:29:33 -07:00
Peter Steinberger
cfbef8035d refactor: split subagent run manager 2026-03-27 01:26:07 +00:00
Peter Steinberger
18dc98b00e refactor: split embedded run auth controller 2026-03-27 01:21:10 +00:00
Peter Steinberger
f3b152e0d9 refactor: split channel setup into shared flow modules 2026-03-27 01:17:39 +00:00
Peter Steinberger
7d6d642cb8 refactor: move doctor orchestration into flow contributions 2026-03-27 01:17:39 +00:00
Peter Steinberger
23aded30d8 refactor: add provider and search flow contributions 2026-03-27 01:17:39 +00:00
Peter Steinberger
5e35e6a95f fix: lazy-load zca-js at the zalouser runtime boundary 2026-03-27 01:14:42 +00:00
Peter Steinberger
b9c60fd37a fix: default and gate apply_patch like write 2026-03-27 01:14:42 +00:00
Tak Hoffman
c326083ad8 fix: keep session settings in gateway live events 2026-03-26 20:11:13 -05:00
Tak Hoffman
48ff617169 fix: keep fast mode in gateway session rows 2026-03-26 20:11:13 -05:00
Peter Steinberger
ba60154826 fix: unify upload-file message actions 2026-03-27 01:04:01 +00:00
Peter Steinberger
046a950877 refactor: split agent command execution helpers 2026-03-27 01:02:40 +00:00
Peter Steinberger
6fd1725a06 fix: preserve ws reasoning replay (#53856) (thanks @xujingchen1996) 2026-03-26 17:53:59 -07:00
scoootscooob
cc359d4c9d fix: use runtime model and per-agent thinking defaults in status (thanks @scoootscooob, @xaeon2026, @ysfbsf) (#55425)
Merged via squash.

Prepared head SHA: 061d7c7ac0
Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com>
Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com>
Reviewed-by: @scoootscooob
2026-03-26 17:49:21 -07:00
Peter Steinberger
c9556c257e docs: clarify memory plugin adapter ids 2026-03-27 00:47:01 +00:00
Peter Steinberger
dbf78de7c6 refactor: move memory engine behind plugin adapters 2026-03-27 00:47:01 +00:00
Peter Steinberger
aed6283faa refactor: split embedded run setup helpers 2026-03-27 00:46:01 +00:00
Peter Steinberger
d4da878d64 fix: preserve plugin sdk api baseline source link 2026-03-27 00:44:24 +00:00
Peter Steinberger
7de494fcec test: stabilize discord monitor ci isolation 2026-03-27 00:44:24 +00:00
Peter Steinberger
89e6b91b89 fix: decouple moonshot stream wrappers from provider runtime 2026-03-27 00:40:51 +00:00
Peter Steinberger
770c462c47 refactor: split subagent registry helpers 2026-03-27 00:35:41 +00:00
R. Desmond
fa0835dd32 test(agents): cover undersized model dispatch guard (#55369) 2026-03-26 20:30:20 -04:00
Peter Steinberger
5a98a1dbe2 refactor: split embedded run helpers 2026-03-27 00:27:49 +00:00
Peter Steinberger
540b98b23f refactor: split subagent announce output helpers 2026-03-27 00:23:32 +00:00
Peter Steinberger
0d9e4f20d5 refactor: split embedded attempt helpers 2026-03-27 00:23:32 +00:00
Peter Steinberger
48ae976333 refactor: split cli runner pipeline 2026-03-27 00:19:24 +00:00
Peter Steinberger
4329c93f85 test: wire discord monitor runtime seams 2026-03-27 00:05:49 +00:00
Craig Allan-McWilliams
984f98be95 Fix: treat HTTP 500 as a transient failover error (#55332)
HTTP 500 (Internal Server Error) was not triggering model fallback,
causing agents to fail outright instead of trying the next candidate.
This is inconsistent with TRANSIENT_HTTP_ERROR_CODES which already
includes 500. Aligns the direct status check with that constant.

Co-authored-by: Craig McWilliams <craigamcw@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:05:15 -04:00
Peter Steinberger
da845ce598 test: align discord lifecycle shutdown expectations 2026-03-27 00:03:00 +00:00
Peter Steinberger
2f43c6b334 refactor: split discord monitor startup and lifecycle 2026-03-27 00:03:00 +00:00
rustam
e3cd209889 chore: remove stale README-header.png packlist entry (#55325) 2026-03-26 16:59:36 -07:00
Coy Geek
8e285d112d fix(cr-mbx-feishu-encryptkey-config-redaction-bypass): apply security fix (#53414)
Generated by staged fix workflow.
2026-03-26 19:58:37 -04:00
Saurabh
afc649255c fix: match guild-level entries in Discord exec allowlist (#55175) 2026-03-26 16:56:58 -07:00
Peter Steinberger
bdeb7d859b test: stabilize discord monitor ci mocks 2026-03-26 23:54:59 +00:00
Marko Jak
b8ff152a98 fix(cli): isolate claude MCP config 2026-03-26 16:52:17 -07:00
Peter Steinberger
85b169c453 fix: clamp copilot auth refresh overflow (#55360) (thanks @michael-abdo) 2026-03-26 16:48:06 -07:00
Peter Steinberger
f0c1057f68 fix: restore reveal-to-edit raw config flow 2026-03-26 23:45:10 +00:00
Peter Steinberger
d25c4fd6c5 test: tighten discord lifecycle gateway mocks 2026-03-26 23:44:43 +00:00
Peter Steinberger
4726593d6d test: refresh planner batching expectations 2026-03-26 23:44:43 +00:00
Peter Steinberger
96a44d979d fix: route memory test harness through plugin sdk 2026-03-26 23:44:43 +00:00
felear2022
623f4d3056 fix: use stream-json output for Claude CLI backend to prevent watchdog timeouts
The Claude CLI backend uses `--output-format json`, which produces no
stdout until the entire request completes. When session context is large
(100K+ tokens) or API response is slow, the no-output watchdog timer
(max 180s for resume sessions) kills the process before it finishes,
resulting in "CLI produced no output for 180s and was terminated" errors.

Switch to `--output-format stream-json --verbose` so Claude CLI emits
NDJSON events throughout processing (init, assistant, rate_limit, result).
Each event resets the watchdog timer, which is the intended behavior —
the watchdog detects truly stuck processes, not slow-but-progressing ones.

Changes:
- cli-backends.ts: `json` → `stream-json --verbose`, `output: "jsonl"`
- helpers.ts: teach parseCliJsonl to extract text from Claude's
  `{"type":"result","result":"..."}` NDJSON line

Note: `--verbose` is required for stream-json in `-p` (print) mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:39:15 -07:00
kenantan32
4ad7d51c01 fix: preserve CLI session ID in text output mode
When the CLI backend output mode is "text", sessionId was hardcoded to
undefined. This caused the fallback chain to store the OpenClaw internal
UUID as the CLI session ID. On resume, --resume was called with the
wrong UUID, resulting in "No conversation found with session ID".

Return resolvedSessionId instead of undefined so the correct CLI session
ID is persisted and resume works correctly.
2026-03-26 16:34:02 -07:00
Peter Steinberger
e46e655451 test: restore memory test seams (#55324) (thanks @joelnishanth) 2026-03-26 16:33:43 -07:00
joelnishanth
5b85d0efa4 discord: fix stale-socket reconnect crash from uncaught reconnect-exhausted error 2026-03-26 16:33:43 -07:00
dhi13man
9f8c4efa9b fix(agents): use claude-cli backend in tools-disabled regression test
codex-cli lacks systemPromptArg, so the system prompt is never
serialized into argv — making the not-toContain assertion pass
vacuously even on pre-fix code. Switch to claude-cli which defines
systemPromptArg ("--append-system-prompt") and add a positive
assertion that the user-supplied prompt IS present in argv.

Co-Authored-By: Dhiman's Agentic Suite <dhiman.seal@hotmail.com>
2026-03-26 16:30:19 -07:00
dhi13man
99f0ea8d43 fix(agents): remove unconditional "Tools are disabled" prompt injection in CLI runner
`runCliAgent()` unconditionally appended "Tools are disabled in this
session. Do not call tools." to `extraSystemPrompt` for every CLI
backend session. The intent was to prevent the LLM from calling
OpenClaw's embedded API tools (since CLI backends manage their own
tools natively). However, CLI agents like Claude Code interpret this
text as a blanket prohibition on ALL tools, including their own native
Bash, Read, and Write tools.

This caused silent failures across cron jobs, group chats, and DM
sessions when using any CLI backend: the agent would see the injected
text in the system prompt and refuse to execute tools, returning text
responses instead. Cron jobs reported `lastStatus: "ok"` despite the
agent failing to run scripts.

The fix removes the hardcoded string entirely. CLI backends already
receive `tools: []` (no OpenClaw embedded tools in the API call), so
the text was redundant at best.

Closes #44135

Co-Authored-By: Dhiman's Agentic Suite <dhiman.seal@hotmail.com>
2026-03-26 16:30:19 -07:00
Peter Steinberger
16565020a1 refactor: finish browser test path cleanup 2026-03-26 23:28:46 +00:00
Peter Steinberger
0ef2a9c8b5 refactor: remove core browser test duplicates 2026-03-26 23:28:34 +00:00
Peter Steinberger
9a7ceceffa refactor: move browser tests into plugin 2026-03-26 23:26:37 +00:00
Peter Steinberger
22348914cf refactor: centralize discord gateway ownership 2026-03-26 23:25:27 +00:00
Peter Steinberger
01bcbcf8d5 refactor: require legacy config migration on read 2026-03-26 23:23:47 +00:00
Peter Steinberger
cad83db8b2 refactor: move memory engine into memory plugin 2026-03-26 23:20:35 +00:00
Peter Steinberger
0e182dd3e1 refactor: share top-level setup dm policies 2026-03-26 23:20:26 +00:00
Peter Steinberger
abf95c5f99 refactor: share build copy script helpers 2026-03-26 23:20:26 +00:00
Peter Steinberger
0106b0488a refactor: share config section card rendering 2026-03-26 23:20:26 +00:00
Peter Steinberger
4890656d9d refactor: share matrix state file path helper 2026-03-26 23:20:26 +00:00
Peter Steinberger
bfad32aa16 refactor: share directory config listers 2026-03-26 23:20:26 +00:00
Peter Steinberger
4151b48d6c style(browser): format profiles service test 2026-03-26 23:18:57 +00:00
Peter Steinberger
8eeccb116d test(planner): refresh extension batch expectations 2026-03-26 23:16:22 +00:00
776 changed files with 42737 additions and 22549 deletions

View File

@@ -59,6 +59,7 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Multi-word `openclaw agent --message ...` checks should call `& $openclaw ...` inside PowerShell, not `Start-Process ... -ArgumentList` against `openclaw.cmd`, or Commander can see split argv and throw `too many arguments for 'agent'`.
- Windows installer/tgz phases now retry once after guest-ready recheck; keep new Windows smoke steps idempotent so a transport-flake retry is safe.
- Windows global `npm install -g` phases can stay quiet for a minute or more even when healthy; inspect the phase log before calling it hung, and only treat it as a regression once the retry wrapper or timeout trips.
- Fresh Windows ref-mode onboard should use the same background PowerShell runner plus done-file/log-drain pattern as the npm-update helper, including startup materialization checks, host-side timeouts on short poll `prlctl exec` calls, and retry-on-poll-failure behavior for transient transport flakes.
- Keep onboarding and status output ASCII-clean in logs; fancy punctuation becomes mojibake in current capture paths.
- If you hit an older run with `rc=255` plus an empty `fresh.install-main.log` or `upgrade.install-main.log`, treat it as a likely `prlctl exec` transport drop after guest start-up, not immediate proof of an npm/package failure.

View File

@@ -7,11 +7,13 @@ Docs: https://docs.openclaw.ai
### Breaking
- Providers/Qwen: remove the deprecated `qwen-portal-auth` OAuth integration for `portal.qwen.ai`; migrate to Model Studio with `openclaw onboard --auth-choice modelstudio-api-key`. (#52709) Thanks @pomelo-nwu.
- Config/Doctor: drop automatic config migrations older than two months; very old legacy keys now fail validation instead of being rewritten on load or by `openclaw doctor`.
### Changes
- MiniMax: add image generation provider for `image-01` model, supporting generate and image-to-image editing with aspect ratio control. (#54487) Thanks @liyuan97.
- Slack/tool actions: add an explicit `upload-file` Slack action that routes file uploads through the existing Slack upload transport, with optional filename/title/comment overrides for channels and DMs.
- Message actions/files: start unifying file-first sends on the canonical `upload-file` action by adding explicit support for Microsoft Teams and Google Chat, and by exposing BlueBubbles file sends through `upload-file` while keeping the legacy `sendAttachment` alias.
- Plugins/Matrix TTS: send auto-TTS replies as native Matrix voice bubbles instead of generic audio attachments. (#37080) thanks @Matthew19990919.
- Memory/plugins: move the pre-compaction memory flush plan behind the active memory plugin contract so `memory-core` owns flush prompts and target-path policy instead of hardcoded core logic.
- MiniMax: trim model catalog to M2.7 only, removing legacy M2, M2.1, M2.5, and VL-01 models. (#54487) Thanks @liyuan97.
@@ -21,9 +23,13 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: surface safeguard-specific cancel reasons and relabel benign manual `/compact` no-op cases as skipped instead of failed. (#51072) Thanks @afurm.
- Plugins/CLI backends: move bundled Claude CLI, Codex CLI, and Gemini CLI inference defaults onto the plugin surface, add bundled Gemini CLI backend support, and replace `gateway run --claude-cli-logs` with generic `--cli-backend-logs` while keeping the old flag as a compatibility alias.
- Plugins/startup: auto-load bundled provider and CLI-backend plugins from explicit config refs, so bundled Claude CLI, Codex CLI, and Gemini CLI message-provider setups no longer need manual `plugins.allow` entries.
- Config/TTS: auto-migrate legacy speech config on normal reads and secret resolution, keep legacy diagnostics for Doctor, and remove regular-mode runtime fallback for old bundled `tts.<provider>` API-key shapes.
- OpenAI/apply_patch: enable `apply_patch` by default for OpenAI and OpenAI Codex models, and align its sandbox policy access with `write` permissions.
### Fixes
- ACP/ACPX agent registry: align OpenClaw's ACPX built-in agent mirror with the latest `openclaw/acpx` command defaults and built-in aliases, pin versioned `npx` built-ins to exact versions, and stop unknown ACP agent ids from falling through to raw `--agent` command execution on the MCP-proxy path. (#28321) Thanks @m0nkmaster and @vincentkoc.
- Control UI/config: keep sensitive raw config hidden by default, replace the blank blocked editor with an explicit reveal-to-edit state, and restore raw JSON editing without auto-exposing secrets. Fixes #55322.
- WhatsApp: fix infinite echo loop in self-chat DM mode where the bot's own outbound replies were re-processed as new inbound user messages. (#54570) Thanks @joelnishanth
- OpenAI Codex/image tools: register Codex for media understanding and route image prompts through Codex instructions so image analysis no longer fails on missing provider registration or missing `instructions`. (#54829) Thanks @neeravmakwana.
- Agents/image tool: restore the generic image-runtime fallback when no provider-specific media-understanding provider is registered, so image analysis works again for providers like `openrouter` and `minimax-portal`. (#54858) Thanks @MonkeyLeeT.
@@ -36,7 +42,11 @@ Docs: https://docs.openclaw.ai
- CLI/message send: write manual `openclaw message send` deliveries into the resolved agent session transcript again by always threading the default CLI agent through outbound mirroring. (#54187) Thanks @KevInTheCloud5617.
- CLI/onboarding: show the Kimi Code API key option again in the Moonshot setup menu so the interactive picker includes all Kimi setup paths together. Fixes #54412 Thanks @sparkyrider
- Agents/status: use provider-aware context window lookup for fresh Anthropic 4.6 model overrides so `/status` shows the correct 1.0m window instead of an underreported shared-cache minimum. (#54796) Thanks @neeravmakwana.
- OpenAI/WebSocket: preserve reasoning replay metadata and tool-call item ids on WebSocket tool turns, and start a fresh response chain when full-context resend is required. (#53856) Thanks @xujingchen1996.
- OpenAI/WS: restore reasoning blocks for Responses WebSocket runs and keep reasoning/tool-call replay metadata intact so resumed sessions do not lose or break follow-up reasoning-capable turns. (#53856) Thanks @xujingchen1996.
- Agents/errors: surface provider quota/reset details when available, but keep HTML/Cloudflare rate-limit pages on the generic fallback so raw error pages are not shown to users. (#54512) Thanks @bugkill3r.
- Claude CLI: switch the bundled Claude CLI backend to `stream-json` output so watchdogs see progress on long runs, and keep session/usage metadata even when Claude finishes with an empty result line. (#49698) Thanks @felear2022.
- Claude CLI/MCP: always pass a strict generated `--mcp-config` overlay for background Claude CLI runs, including the empty-server case, so Claude does not inherit ambient user/global MCP servers. (#54961) Thanks @markojak.
- Agents/embedded replies: surface mid-turn 429 and overload failures when embedded runs end without a user-visible reply, while preserving successful media-only replies that still use legacy `mediaUrl`. (#50930) Thanks @infichen.
- WhatsApp/allowFrom: show a specific allowFrom policy error for valid blocked targets instead of the misleading `<E.164|group JID>` format hint. Thanks @mcaxtr.
- Agents/cooldowns: scope rate-limit cooldowns per model so one 429 no longer blocks every model on the same auth profile, replace the exponential 1 min -> 1 h escalation with a stepped 30 s / 1 min / 5 min ladder, and surface a user-facing countdown message when all models are rate-limited. (#49834) Thanks @kiranvk-2011.
@@ -61,6 +71,10 @@ Docs: https://docs.openclaw.ai
- Agents/failover: classify Codex accountId token extraction failures as auth errors so model fallback continues to the next configured candidate. (#55206) Thanks @cosmicnet.
- Talk/macOS: stop direct system-voice failures from replaying system speech, use app-locale fallback for shared watchdog timing, and add regression coverage for the macOS fallback route and language-aware timeout policy. (#53511) thanks @hongsw.
- Discord/gateway cleanup: keep late Carbon reconnect-exhausted errors suppressed through startup/dispose cleanup so Discord monitor shutdown no longer crashes on late gateway close events. (#55373) Thanks @Takhoffman.
- Discord/gateway shutdown: treat expected reconnect-exhausted events during intentional lifecycle stop as clean shutdowns so startup-abort cleanup no longer surfaces false gateway failures. (#55324) Thanks @joelnishanth.
- GitHub Copilot/auth refresh: treat large `expires_at` values as seconds epochs and clamp far-future runtime auth refresh timers so Copilot token refresh cannot fall into a `setTimeout` overflow hot loop. (#55360) Thanks @michael-abdo.
- Agents/status: use the persisted runtime session model in `session_status` when no explicit override exists, and honor per-agent `thinkingDefault` in both `session_status` and `/status`. (#55425) Thanks @scoootscooob, @xaeon2026, and @ysfbsf.
- Heartbeat/runner: guarantee the interval timer is re-armed after heartbeat runs and unexpected runner errors so scheduled heartbeats do not silently stop after an interrupted cycle. (#52270) Thanks @MiloStack.
## 2026.3.24

View File

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

View File

@@ -1,8 +1,8 @@
// Shared iOS version defaults.
// Generated overrides live in build/Version.xcconfig (git-ignored).
OPENCLAW_GATEWAY_VERSION = 2026.3.25
OPENCLAW_MARKETING_VERSION = 2026.3.25
OPENCLAW_BUILD_VERSION = 202603250
OPENCLAW_GATEWAY_VERSION = 2026.3.26
OPENCLAW_MARKETING_VERSION = 2026.3.26
OPENCLAW_BUILD_VERSION = 202603260
#include? "../build/Version.xcconfig"

View File

@@ -65,9 +65,9 @@ Release behavior:
- Beta release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, and `OpenClawPushAPNsEnvironment=production`.
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- Root `package.json.version` is the only version source for iOS.
- A root version like `2026.3.22-beta.1` becomes:
- `CFBundleShortVersionString = 2026.3.22`
- `CFBundleVersion = next TestFlight build number for 2026.3.22`
- A root version like `2026.3.26-beta.1` becomes:
- `CFBundleShortVersionString = 2026.3.26`
- `CFBundleVersion = next TestFlight build number for 2026.3.26`
Required env for beta builds:

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.25</string>
<string>2026.3.26</string>
<key>CFBundleVersion</key>
<string>202603250</string>
<string>202603260</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -8773,13 +8773,8 @@
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
@@ -9325,13 +9320,8 @@
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
@@ -11140,13 +11130,8 @@
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
@@ -11408,13 +11393,8 @@
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
@@ -11783,14 +11763,8 @@
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"channels",
"media",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
@@ -14450,14 +14424,8 @@
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"channels",
"media",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
@@ -17055,12 +17023,8 @@
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
@@ -17118,12 +17082,8 @@
"type": "object",
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
@@ -17736,12 +17696,8 @@
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
@@ -17799,12 +17755,8 @@
"type": "object",
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
@@ -19878,13 +19830,8 @@
"type": "string",
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
@@ -19933,13 +19880,8 @@
"type": "string",
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
@@ -20700,13 +20642,8 @@
"type": "string",
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
@@ -23611,13 +23548,8 @@
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
@@ -27753,13 +27685,8 @@
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
@@ -27851,13 +27778,8 @@
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
@@ -28706,13 +28628,8 @@
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
@@ -28902,13 +28819,8 @@
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
@@ -30116,13 +30028,8 @@
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
@@ -30712,13 +30619,8 @@
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
@@ -32417,13 +32319,8 @@
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
@@ -34567,13 +34464,8 @@
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"sensitive": false,
"tags": [],
"hasChildren": true
},
{

View File

@@ -767,7 +767,7 @@
{"recordType":"path","path":"channels.bluebubbles.accounts.*.mediaLocalRoots.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.accounts.*.mediaMaxMb","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.accounts.*.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.accounts.*.password","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.bluebubbles.accounts.*.password","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.bluebubbles.accounts.*.password.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.accounts.*.password.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.accounts.*.password.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -817,7 +817,7 @@
{"recordType":"path","path":"channels.bluebubbles.mediaLocalRoots.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.mediaMaxMb","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.password","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.bluebubbles.password","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.bluebubbles.password.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.password.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.password.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -988,7 +988,7 @@
{"recordType":"path","path":"channels.discord.accounts.*.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.pluralkit","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.pluralkit.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.pluralkit.token","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.pluralkit.token","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.pluralkit.token.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.pluralkit.token.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.pluralkit.token.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1012,7 +1012,7 @@
{"recordType":"path","path":"channels.discord.accounts.*.threadBindings.maxAgeHours","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.threadBindings.spawnAcpSessions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.threadBindings.spawnSubagentSessions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.token","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.token","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.token.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.token.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.token.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1047,7 +1047,7 @@
{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.providers.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.providers.*.*","kind":"channel","type":["array","boolean","null","number","object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.providers.*.*.*","kind":"channel","type":[],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.providers.*.apiKey","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","media","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.providers.*.apiKey","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.providers.*.apiKey.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.providers.*.apiKey.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.providers.*.apiKey.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1273,7 +1273,7 @@
{"recordType":"path","path":"channels.discord.voice.tts.providers.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.voice.tts.providers.*.*","kind":"channel","type":["array","boolean","null","number","object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.voice.tts.providers.*.*.*","kind":"channel","type":[],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.voice.tts.providers.*.apiKey","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","media","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.discord.voice.tts.providers.*.apiKey","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.voice.tts.providers.*.apiKey.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.voice.tts.providers.*.apiKey.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.voice.tts.providers.*.apiKey.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1509,13 +1509,13 @@
{"recordType":"path","path":"channels.googlechat.accounts.*.replyToMode","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccount","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccount","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccount.*","kind":"channel","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccount.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccount.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccount.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccountFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccountRef","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":true,"tags":["channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccountRef","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccountRef.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccountRef.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccountRef.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1572,13 +1572,13 @@
{"recordType":"path","path":"channels.googlechat.replyToMode","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.serviceAccount","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.serviceAccount","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.serviceAccount.*","kind":"channel","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.serviceAccount.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.serviceAccount.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.serviceAccount.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.serviceAccountFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.serviceAccountRef","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":true,"tags":["channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.serviceAccountRef","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.serviceAccountRef.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.serviceAccountRef.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.serviceAccountRef.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1773,12 +1773,12 @@
{"recordType":"path","path":"channels.irc.accounts.*.nick","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.nickserv","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.irc.accounts.*.nickserv.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.nickserv.password","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.nickserv.password","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.nickserv.passwordFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.nickserv.register","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.nickserv.registerEmail","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.nickserv.service","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.password","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.password","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.passwordFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.port","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.realname","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1847,7 +1847,7 @@
{"recordType":"path","path":"channels.irc.nickserv.register","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"IRC NickServ Register","help":"If true, send NickServ REGISTER on every connect. Use once for initial registration, then disable.","hasChildren":false}
{"recordType":"path","path":"channels.irc.nickserv.registerEmail","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"IRC NickServ Register Email","help":"Email used with NickServ REGISTER (required when register=true).","hasChildren":false}
{"recordType":"path","path":"channels.irc.nickserv.service","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"IRC NickServ Service","help":"NickServ service nick (default: NickServ).","hasChildren":false}
{"recordType":"path","path":"channels.irc.password","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":false}
{"recordType":"path","path":"channels.irc.password","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.passwordFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.port","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.realname","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2110,7 +2110,7 @@
{"recordType":"path","path":"channels.msteams.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.msteams.allowFrom.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.appId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.appPassword","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.msteams.appPassword","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.msteams.appPassword.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.appPassword.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.appPassword.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2500,7 +2500,7 @@
{"recordType":"path","path":"channels.slack.accounts.*.allowBots","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.appToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.appToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.appToken.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.appToken.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.appToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2509,7 +2509,7 @@
{"recordType":"path","path":"channels.slack.accounts.*.blockStreamingCoalesce.idleMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.blockStreamingCoalesce.maxChars","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.blockStreamingCoalesce.minChars","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.botToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.botToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.botToken.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.botToken.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.botToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2588,7 +2588,7 @@
{"recordType":"path","path":"channels.slack.accounts.*.replyToModeByChatType.group","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.signingSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.signingSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.signingSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.signingSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.signingSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2605,7 +2605,7 @@
{"recordType":"path","path":"channels.slack.accounts.*.thread.inheritParent","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.thread.initialHistoryLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.typingReaction","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.userToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.userToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.userToken.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.userToken.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.userToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2713,7 +2713,7 @@
{"recordType":"path","path":"channels.slack.replyToModeByChatType.group","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.signingSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.slack.signingSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.slack.signingSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.signingSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.signingSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2764,7 +2764,7 @@
{"recordType":"path","path":"channels.telegram.accounts.*.blockStreamingCoalesce.idleMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.blockStreamingCoalesce.maxChars","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.blockStreamingCoalesce.minChars","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.botToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.telegram.accounts.*.botToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.telegram.accounts.*.botToken.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.botToken.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.botToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2922,7 +2922,7 @@
{"recordType":"path","path":"channels.telegram.accounts.*.webhookHost","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.webhookSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.telegram.accounts.*.webhookSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.telegram.accounts.*.webhookSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.webhookSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.webhookSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -3107,7 +3107,7 @@
{"recordType":"path","path":"channels.telegram.webhookHost","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.webhookSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.telegram.webhookSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.telegram.webhookSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.webhookSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.webhookSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -266,8 +266,9 @@ Available actions:
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
- **leaveGroup**: Leave a group chat (`chatGuid`)
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`, `asVoice`)
- **upload-file**: Send media/files (`to`, `buffer`, `filename`, `asVoice`)
- Voice memos: set `asVoice: true` with **MP3** or **CAF** audio to send as an iMessage voice message. BlueBubbles converts MP3 → CAF when sending voice memos.
- Legacy alias: `sendAttachment` still works, but `upload-file` is the canonical action name.
### Message IDs (short vs full)

View File

@@ -201,6 +201,7 @@ Notes:
- Default webhook path is `/googlechat` if `webhookPath` isnt set.
- `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode).
- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled.
- Message actions expose `send` for text and `upload-file` for explicit attachment sends. `upload-file` accepts `media` / `filePath` / `path` plus optional `message`, `filename`, and thread targeting.
- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth).
- Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`).

View File

@@ -11,7 +11,7 @@ title: "Microsoft Teams"
Updated: 2026-01-21
Status: text + DM attachments are supported; channel/group file sending requires `sharePointSiteId` + Graph permissions (see [Sending files in group chats](#sending-files-in-group-chats)). Polls are sent via Adaptive Cards.
Status: text + DM attachments are supported; channel/group file sending requires `sharePointSiteId` + Graph permissions (see [Sending files in group chats](#sending-files-in-group-chats)). Polls are sent via Adaptive Cards. Message actions expose explicit `upload-file` for file-first sends.
## Plugin required
@@ -527,6 +527,7 @@ Teams recently introduced two channel UI styles over the same underlying data mo
- **DMs:** Images and file attachments work via Teams bot file APIs.
- **Channels/groups:** Attachments live in M365 storage (SharePoint/OneDrive). The webhook payload only includes an HTML stub, not the actual file bytes. **Graph API permissions are required** to download channel attachments.
- For explicit file-first sends, use `action=upload-file` with `media` / `filePath` / `path`; optional `message` becomes the accompanying text/comment, and `filename` overrides the uploaded name.
Without Graph permissions, channel messages with images will be received as text-only (the image content is not accessible to the bot).
By default, OpenClaw only downloads media from Microsoft/Teams hostnames. Override with `channels.msteams.mediaAllowHosts` (use `["*"]` to allow any host).

View File

@@ -100,9 +100,10 @@ semantic queries can find related notes even when wording differs. Hybrid search
(BM25 + vector) is available for combining semantic matching with exact keyword
lookups.
Memory search supports multiple embedding providers (OpenAI, Gemini, Voyage,
Mistral, Ollama, and local GGUF models), an optional QMD sidecar backend for
advanced retrieval, and post-processing features like MMR diversity re-ranking
Memory search adapter ids come from the active memory plugin. The default
`memory-core` plugin ships built-ins for OpenAI, Gemini, Voyage, Mistral,
Ollama, and local GGUF models, plus an optional QMD sidecar backend for
advanced retrieval and post-processing features like MMR diversity re-ranking
and temporal decay.
For the full configuration reference -- including embedding provider setup, QMD

View File

@@ -92,8 +92,8 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- `pnpm test:max` exposes that same planner profile for a full local run.
- On supported local Node versions, including Node 25, the normal profile can use top-level lane parallelism. `pnpm test:max` still pushes the planner harder when you want a more aggressive local run.
- The base Vitest config marks the wrapper manifests/config files as `forceRerunTriggers` so changed-mode reruns stay correct when scheduler inputs change.
- Vitest's filesystem module cache is now enabled by default for Node-side test reruns.
- Opt out with `OPENCLAW_VITEST_FS_MODULE_CACHE=0` or `OPENCLAW_VITEST_FS_MODULE_CACHE=false` if you suspect stale transform cache behavior.
- The wrapper keeps `OPENCLAW_VITEST_FS_MODULE_CACHE` enabled on supported hosts, but assigns a lane-local `OPENCLAW_VITEST_FS_MODULE_CACHE_PATH` so concurrent Vitest processes do not race on one shared experimental cache directory.
- Set `OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=/abs/path` if you want one explicit cache location for direct single-run profiling.
- Perf-debug note:
- `pnpm test:perf:imports` enables Vitest import-duration reporting plus import-breakdown output.
- `pnpm test:perf:imports:changed` scopes the same profiling view to files changed since `origin/main`.

View File

@@ -48,7 +48,7 @@ update **without** changing your persisted channel:
```bash
# Install a specific version
openclaw update --tag 2026.3.22
openclaw update --tag 2026.3.26
# Install from the beta dist-tag (one-off, does not persist)
openclaw update --tag beta
@@ -57,7 +57,7 @@ openclaw update --tag beta
openclaw update --tag main
# Install a specific npm package spec
openclaw update --tag openclaw@2026.3.22
openclaw update --tag openclaw@2026.3.26
```
Notes:
@@ -75,7 +75,7 @@ Preview what `openclaw update` would do without making changes:
```bash
openclaw update --dry-run
openclaw update --channel beta --dry-run
openclaw update --tag 2026.3.22 --dry-run
openclaw update --tag 2026.3.26 --dry-run
openclaw update --dry-run --json
```

View File

@@ -46,6 +46,8 @@ Use it for:
- config validation
- auth and onboarding metadata that should be available without booting plugin
runtime
- static capability ownership snapshots used for bundled compat wiring and
contract coverage
- config UI hints
Do not use it for:
@@ -129,6 +131,7 @@ Those belong in your plugin code and `package.json`.
| `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. |
| `providerAuthEnvVars` | No | `Record<string, string[]>` | Cheap provider-auth env metadata that OpenClaw can inspect without loading plugin code. |
| `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. |
| `contracts` | No | `object` | Static bundled capability snapshot for speech, media-understanding, image-generation, web search, and tool ownership. |
| `skills` | No | `string[]` | Skill directories to load, relative to the plugin root. |
| `name` | No | `string` | Human-readable plugin name. |
| `description` | No | `string` | Short summary shown in plugin surfaces. |
@@ -184,6 +187,38 @@ Each field hint can include:
| `sensitive` | `boolean` | Marks the field as secret or sensitive. |
| `placeholder` | `string` | Placeholder text for form inputs. |
## contracts reference
Use `contracts` only for static capability ownership metadata that OpenClaw can
read without importing the plugin runtime.
```json
{
"contracts": {
"speechProviders": ["openai"],
"mediaUnderstandingProviders": ["openai", "openai-codex"],
"imageGenerationProviders": ["openai"],
"webSearchProviders": ["gemini"],
"tools": ["firecrawl_search", "firecrawl_scrape"]
}
}
```
Each list is optional:
| Field | Type | What it means |
| ----------------------------- | ---------- | -------------------------------------------------------------- |
| `speechProviders` | `string[]` | Speech provider ids this plugin owns. |
| `mediaUnderstandingProviders` | `string[]` | Media-understanding provider ids this plugin owns. |
| `imageGenerationProviders` | `string[]` | Image-generation provider ids this plugin owns. |
| `webSearchProviders` | `string[]` | Web-search provider ids this plugin owns. |
| `tools` | `string[]` | Agent tool names this plugin owns for bundled contract checks. |
Legacy top-level `speechProviders`, `mediaUnderstandingProviders`, and
`imageGenerationProviders` are deprecated. Use `openclaw doctor --fix` to move
them under `contracts`; normal manifest loading no longer treats them as
capability ownership.
## Manifest versus package.json
The two files serve different jobs:

View File

@@ -159,6 +159,22 @@ AI CLI backend such as `claude-cli` or `codex-cli`.
| `api.registerContextEngine(id, factory)` | Context engine (one active at a time) |
| `api.registerMemoryPromptSection(builder)` | Memory prompt section builder |
| `api.registerMemoryFlushPlan(resolver)` | Memory flush plan resolver |
| `api.registerMemoryRuntime(runtime)` | Memory runtime adapter |
### Memory embedding adapters
| Method | What it registers |
| ---------------------------------------------- | ---------------------------------------------- |
| `api.registerMemoryEmbeddingProvider(adapter)` | Memory embedding adapter for the active plugin |
- `registerMemoryPromptSection`, `registerMemoryFlushPlan`, and
`registerMemoryRuntime` are exclusive to memory plugins.
- `registerMemoryEmbeddingProvider` lets the active memory plugin register one
or more embedding adapter ids (for example `openai`, `gemini`, or a custom
plugin-defined id).
- User config such as `agents.defaults.memorySearch.provider` and
`agents.defaults.memorySearch.fallback` resolves against those registered
adapter ids.
### Events and lifecycle

View File

@@ -20,7 +20,12 @@ automatic flush), see [Memory](/concepts/memory).
- Watches memory files for changes (debounced).
- Configure memory search under `agents.defaults.memorySearch` (not top-level
`memorySearch`).
- Uses remote embeddings by default. If `memorySearch.provider` is not set, OpenClaw auto-selects:
- `memorySearch.provider` and `memorySearch.fallback` accept **adapter ids**
registered by the active memory plugin.
- The default `memory-core` plugin registers these built-in adapter ids:
`local`, `openai`, `gemini`, `voyage`, `mistral`, and `ollama`.
- With the default `memory-core` plugin, if `memorySearch.provider` is not set,
OpenClaw auto-selects:
1. `local` if a `memorySearch.local.modelPath` is configured and the file exists.
2. `openai` if an OpenAI key can be resolved.
3. `gemini` if a Gemini key can be resolved.
@@ -29,8 +34,9 @@ automatic flush), see [Memory](/concepts/memory).
6. Otherwise memory search stays disabled until configured.
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
- `memorySearch.provider = "ollama"` is also supported for local/self-hosted
Ollama embeddings (`/api/embeddings`), but it is not auto-selected.
- With the default `memory-core` plugin, `memorySearch.provider = "ollama"` is
also supported for local/self-hosted Ollama embeddings (`/api/embeddings`),
but it is not auto-selected.
Remote embeddings **require** an API key for the embedding provider. OpenClaw
resolves keys from auth profiles, `models.providers.*.apiKey`, or environment
@@ -317,15 +323,16 @@ If you don't want to set an API key, use `memorySearch.provider = "local"` or se
### Fallbacks
- `memorySearch.fallback` can be `openai`, `gemini`, `voyage`, `mistral`, `ollama`, `local`, or `none`.
- `memorySearch.fallback` can be any registered memory embedding adapter id, or `none`.
- With the default `memory-core` plugin, valid built-in fallback ids are `openai`, `gemini`, `voyage`, `mistral`, `ollama`, and `local`.
- The fallback provider is only used when the primary embedding provider fails.
### Batch indexing (OpenAI + Gemini + Voyage)
### Batch indexing
- Disabled by default. Set `agents.defaults.memorySearch.remote.batch.enabled = true` to enable for large-corpus indexing (OpenAI, Gemini, and Voyage).
- Disabled by default. Set `agents.defaults.memorySearch.remote.batch.enabled = true` to enable batch indexing for providers whose adapter exposes batch support.
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
- Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2).
- Batch mode applies when `memorySearch.provider = "openai"` or `"gemini"` and uses the corresponding API key.
- With the default `memory-core` plugin, batch indexing is available for `openai`, `gemini`, and `voyage`.
- Gemini batch jobs use the async embeddings batch endpoint and require Gemini Batch API availability.
Why OpenAI batch is fast and cheap:

View File

@@ -40,7 +40,7 @@ For local PR land/gate checks, run:
If `pnpm test` flakes on a loaded host, rerun once before treating it as a regression, then isolate with `pnpm vitest run <path/to/test>`. For memory-constrained hosts, use:
- `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test`
- `OPENCLAW_VITEST_FS_MODULE_CACHE=0 pnpm test:changed`
- `OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=/tmp/openclaw-vitest-cache pnpm test:changed`
## Model latency bench (local keys)

View File

@@ -1,5 +1,5 @@
---
summary: "Use ACP runtime sessions for Pi, Claude Code, Codex, OpenCode, Gemini CLI, and other harness agents"
summary: "Use ACP runtime sessions for Codex, Claude Code, Cursor, Gemini CLI, OpenClaw ACP, and other harness agents"
read_when:
- Running coding harnesses through ACP
- Setting up thread-bound ACP sessions on thread-capable channels
@@ -11,7 +11,7 @@ title: "ACP Agents"
# ACP agents
[Agent Client Protocol (ACP)](https://agentclientprotocol.com/) sessions let OpenClaw run external coding harnesses (for example Pi, Claude Code, Codex, OpenCode, and Gemini CLI) through an ACP backend plugin.
[Agent Client Protocol (ACP)](https://agentclientprotocol.com/) sessions let OpenClaw run external coding harnesses (for example Pi, Claude Code, Codex, Cursor, Copilot, OpenClaw ACP, OpenCode, Gemini CLI, and other supported ACPX harnesses) through an ACP backend plugin.
If you ask OpenClaw in plain language to "run this in Codex" or "start Claude Code in a thread", OpenClaw should route that request to the ACP runtime (not the native sub-agent runtime).
@@ -441,14 +441,23 @@ Equivalent operations:
Current acpx built-in harness aliases:
- `pi`
- `claude`
- `codex`
- `opencode`
- `copilot`
- `cursor` (Cursor CLI: `cursor-agent acp`)
- `droid`
- `gemini`
- `iflow`
- `kilocode`
- `kimi`
- `kiro`
- `openclaw`
- `opencode`
- `pi`
- `qwen`
When OpenClaw uses the acpx backend, prefer these values for `agentId` unless your acpx config defines custom agent aliases.
If your local Cursor install still exposes ACP as `agent acp`, override the `cursor` agent command in your acpx config instead of changing the built-in default.
Direct acpx CLI usage can also target arbitrary adapters via `--agent <command>`, but that raw escape hatch is an acpx CLI feature (not the normal OpenClaw `agentId` path).
@@ -464,7 +473,22 @@ Core ACP baseline:
dispatch: { enabled: true },
backend: "acpx",
defaultAgent: "codex",
allowedAgents: ["pi", "claude", "codex", "opencode", "gemini", "kimi"],
allowedAgents: [
"claude",
"codex",
"copilot",
"cursor",
"droid",
"gemini",
"iflow",
"kilocode",
"kimi",
"kiro",
"openclaw",
"opencode",
"pi",
"qwen",
],
maxConcurrentSessions: 8,
stream: {
coalesceIdleMs: 300,

View File

@@ -36,8 +36,9 @@ The tool accepts a single `input` string that wraps one or more file operations:
- `tools.exec.applyPatch.workspaceOnly` defaults to `true` (workspace-contained). Set it to `false` only if you intentionally want `apply_patch` to write/delete outside the workspace directory.
- Use `*** Move to:` within an `*** Update File:` hunk to rename files.
- `*** End of File` marks an EOF-only insert when needed.
- Experimental and disabled by default. Enable with `tools.exec.applyPatch.enabled`.
- OpenAI-only (including OpenAI Codex). Optionally gate by model via
- Available by default for OpenAI and OpenAI Codex models. Set
`tools.exec.applyPatch.enabled: false` to disable it.
- Optionally gate by model via
`tools.exec.applyPatch.allowModels`.
- Config is only under `tools.exec`.

View File

@@ -184,16 +184,17 @@ Paste (bracketed by default):
{ "tool": "process", "action": "paste", "sessionId": "<id>", "text": "line1\nline2\n" }
```
## apply_patch (experimental)
## apply_patch
`apply_patch` is a subtool of `exec` for structured multi-file edits.
Enable it explicitly:
It is enabled by default for OpenAI and OpenAI Codex models. Use config only
when you want to disable it or restrict it to specific models:
```json5
{
tools: {
exec: {
applyPatch: { enabled: true, workspaceOnly: true, allowModels: ["gpt-5.2"] },
applyPatch: { workspaceOnly: true, allowModels: ["gpt-5.2"] },
},
},
}
@@ -202,6 +203,7 @@ Enable it explicitly:
Notes:
- Only available for OpenAI/OpenAI Codex models.
- Tool policy still applies; `allow: ["exec"]` implicitly allows `apply_patch`.
- Tool policy still applies; `allow: ["write"]` implicitly allows `apply_patch`.
- Config lives under `tools.exec.applyPatch`.
- `tools.exec.applyPatch.enabled` defaults to `true`; set it to `false` to disable the tool for OpenAI models.
- `tools.exec.applyPatch.workspaceOnly` defaults to `true` (workspace-contained). Set it to `false` only if you intentionally want `apply_patch` to write/delete outside the workspace directory.

View File

@@ -15,7 +15,7 @@ It works anywhere OpenClaw can send audio.
## Supported services
- **ElevenLabs** (primary or fallback provider)
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`, default when no API keys)
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`)
- **OpenAI** (primary or fallback provider; also used for summaries)
### Microsoft speech notes
@@ -38,9 +38,7 @@ If you want OpenAI or ElevenLabs:
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
- `OPENAI_API_KEY`
Microsoft speech does **not** require an API key. If no API keys are found,
OpenClaw defaults to Microsoft (unless disabled via
`messages.tts.microsoft.enabled=false` or `messages.tts.edge.enabled=false`).
Microsoft speech does **not** require an API key.
If multiple providers are configured, the selected provider is used first and the others are fallback options.
Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`),
@@ -60,8 +58,8 @@ so that provider must also be authenticated if you enable summaries.
No. AutoTTS is **off** by default. Enable it in config with
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`).
Microsoft speech **is** enabled by default once TTS is on, and is used automatically
when no OpenAI or ElevenLabs API keys are available.
When `messages.tts.provider` is unset, OpenClaw picks the first configured
speech provider in registry auto-select order.
## Config
@@ -93,26 +91,28 @@ Full schema is in [Gateway configuration](/gateway/configuration).
modelOverrides: {
enabled: true,
},
openai: {
apiKey: "openai_api_key",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
},
elevenlabs: {
apiKey: "elevenlabs_api_key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0,
providers: {
openai: {
apiKey: "openai_api_key",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
},
elevenlabs: {
apiKey: "elevenlabs_api_key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0,
},
},
},
},
@@ -128,13 +128,15 @@ Full schema is in [Gateway configuration](/gateway/configuration).
tts: {
auto: "always",
provider: "microsoft",
microsoft: {
enabled: true,
voice: "en-US-MichelleNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
rate: "+10%",
pitch: "-5%",
providers: {
microsoft: {
enabled: true,
voice: "en-US-MichelleNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
rate: "+10%",
pitch: "-5%",
},
},
},
},
@@ -147,8 +149,10 @@ Full schema is in [Gateway configuration](/gateway/configuration).
{
messages: {
tts: {
microsoft: {
enabled: false,
providers: {
microsoft: {
enabled: false,
},
},
},
},
@@ -208,37 +212,37 @@ Then run:
- `enabled`: legacy toggle (doctor migrates this to `auto`).
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
- `provider`: speech provider id such as `"elevenlabs"`, `"microsoft"`, or `"openai"` (fallback is automatic).
- If `provider` is **unset**, OpenClaw prefers `openai` (if key), then `elevenlabs` (if key),
otherwise `microsoft`.
- If `provider` is **unset**, OpenClaw uses the first configured speech provider in registry auto-select order.
- Legacy `provider: "edge"` still works and is normalized to `microsoft`.
- `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`.
- Accepts `provider/model` or a configured model alias.
- `modelOverrides`: allow the model to emit TTS directives (on by default).
- `allowProvider` defaults to `false` (provider switching is opt-in).
- `providers.<id>`: provider-owned settings keyed by speech provider id.
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
- `timeoutMs`: request timeout (ms).
- `prefsPath`: override the local prefs JSON path (provider/limit/summary).
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `openai.baseUrl`: override the OpenAI TTS endpoint.
- Resolution order: `messages.tts.openai.baseUrl` -> `OPENAI_TTS_BASE_URL` -> `https://api.openai.com/v1`
- `providers.elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `providers.openai.baseUrl`: override the OpenAI TTS endpoint.
- Resolution order: `messages.tts.providers.openai.baseUrl` -> `OPENAI_TTS_BASE_URL` -> `https://api.openai.com/v1`
- Non-default values are treated as OpenAI-compatible TTS endpoints, so custom model and voice names are accepted.
- `elevenlabs.voiceSettings`:
- `providers.elevenlabs.voiceSettings`:
- `stability`, `similarityBoost`, `style`: `0..1`
- `useSpeakerBoost`: `true|false`
- `speed`: `0.5..2.0` (1.0 = normal)
- `elevenlabs.applyTextNormalization`: `auto|on|off`
- `elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
- `microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
- `microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
- `microsoft.lang`: language code (e.g. `en-US`).
- `microsoft.outputFormat`: Microsoft output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
- `providers.elevenlabs.applyTextNormalization`: `auto|on|off`
- `providers.elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `providers.elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
- `providers.microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
- `providers.microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
- `providers.microsoft.lang`: language code (e.g. `en-US`).
- `providers.microsoft.outputFormat`: Microsoft output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
- See Microsoft Speech output formats for valid values; not all formats are supported by the bundled Edge-backed transport.
- `microsoft.rate` / `microsoft.pitch` / `microsoft.volume`: percent strings (e.g. `+10%`, `-5%`).
- `microsoft.saveSubtitles`: write JSON subtitles alongside the audio file.
- `microsoft.proxy`: proxy URL for Microsoft speech requests.
- `microsoft.timeoutMs`: request timeout override (ms).
- `providers.microsoft.rate` / `providers.microsoft.pitch` / `providers.microsoft.volume`: percent strings (e.g. `+10%`, `-5%`).
- `providers.microsoft.saveSubtitles`: write JSON subtitles alongside the audio file.
- `providers.microsoft.proxy`: proxy URL for Microsoft speech requests.
- `providers.microsoft.timeoutMs`: request timeout override (ms).
- `edge.*`: legacy alias for the same Microsoft settings.
## Model-driven overrides (default on)

View File

@@ -15,7 +15,7 @@ It works anywhere OpenClaw can send audio.
## Supported services
- **ElevenLabs** (primary or fallback provider)
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`, default when no API keys)
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`)
- **OpenAI** (primary or fallback provider; also used for summaries)
### Microsoft speech notes
@@ -38,9 +38,7 @@ If you want OpenAI or ElevenLabs:
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
- `OPENAI_API_KEY`
Microsoft speech does **not** require an API key. If no API keys are found,
OpenClaw defaults to Microsoft (unless disabled via
`messages.tts.microsoft.enabled=false` or `messages.tts.edge.enabled=false`).
Microsoft speech does **not** require an API key.
If multiple providers are configured, the selected provider is used first and the others are fallback options.
Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`),
@@ -60,8 +58,8 @@ so that provider must also be authenticated if you enable summaries.
No. AutoTTS is **off** by default. Enable it in config with
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`).
Microsoft speech **is** enabled by default once TTS is on, and is used automatically
when no OpenAI or ElevenLabs API keys are available.
When `messages.tts.provider` is unset, OpenClaw picks the first configured
speech provider in registry auto-select order.
## Config
@@ -93,26 +91,28 @@ Full schema is in [Gateway configuration](/gateway/configuration).
modelOverrides: {
enabled: true,
},
openai: {
apiKey: "openai_api_key",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
},
elevenlabs: {
apiKey: "elevenlabs_api_key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0,
providers: {
openai: {
apiKey: "openai_api_key",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
},
elevenlabs: {
apiKey: "elevenlabs_api_key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0,
},
},
},
},
@@ -128,13 +128,15 @@ Full schema is in [Gateway configuration](/gateway/configuration).
tts: {
auto: "always",
provider: "microsoft",
microsoft: {
enabled: true,
voice: "en-US-MichelleNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
rate: "+10%",
pitch: "-5%",
providers: {
microsoft: {
enabled: true,
voice: "en-US-MichelleNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
rate: "+10%",
pitch: "-5%",
},
},
},
},
@@ -147,8 +149,10 @@ Full schema is in [Gateway configuration](/gateway/configuration).
{
messages: {
tts: {
microsoft: {
enabled: false,
providers: {
microsoft: {
enabled: false,
},
},
},
},
@@ -208,37 +212,37 @@ Then run:
- `enabled`: legacy toggle (doctor migrates this to `auto`).
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
- `provider`: speech provider id such as `"elevenlabs"`, `"microsoft"`, or `"openai"` (fallback is automatic).
- If `provider` is **unset**, OpenClaw prefers `openai` (if key), then `elevenlabs` (if key),
otherwise `microsoft`.
- If `provider` is **unset**, OpenClaw uses the first configured speech provider in registry auto-select order.
- Legacy `provider: "edge"` still works and is normalized to `microsoft`.
- `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`.
- Accepts `provider/model` or a configured model alias.
- `modelOverrides`: allow the model to emit TTS directives (on by default).
- `allowProvider` defaults to `false` (provider switching is opt-in).
- `providers.<id>`: provider-owned settings keyed by speech provider id.
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
- `timeoutMs`: request timeout (ms).
- `prefsPath`: override the local prefs JSON path (provider/limit/summary).
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `openai.baseUrl`: override the OpenAI TTS endpoint.
- Resolution order: `messages.tts.openai.baseUrl` -> `OPENAI_TTS_BASE_URL` -> `https://api.openai.com/v1`
- `providers.elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `providers.openai.baseUrl`: override the OpenAI TTS endpoint.
- Resolution order: `messages.tts.providers.openai.baseUrl` -> `OPENAI_TTS_BASE_URL` -> `https://api.openai.com/v1`
- Non-default values are treated as OpenAI-compatible TTS endpoints, so custom model and voice names are accepted.
- `elevenlabs.voiceSettings`:
- `providers.elevenlabs.voiceSettings`:
- `stability`, `similarityBoost`, `style`: `0..1`
- `useSpeakerBoost`: `true|false`
- `speed`: `0.5..2.0` (1.0 = normal)
- `elevenlabs.applyTextNormalization`: `auto|on|off`
- `elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
- `microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
- `microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
- `microsoft.lang`: language code (e.g. `en-US`).
- `microsoft.outputFormat`: Microsoft output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
- `providers.elevenlabs.applyTextNormalization`: `auto|on|off`
- `providers.elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `providers.elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
- `providers.microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
- `providers.microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
- `providers.microsoft.lang`: language code (e.g. `en-US`).
- `providers.microsoft.outputFormat`: Microsoft output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
- See Microsoft Speech output formats for valid values; not all formats are supported by the bundled Edge-backed transport.
- `microsoft.rate` / `microsoft.pitch` / `microsoft.volume`: percent strings (e.g. `+10%`, `-5%`).
- `microsoft.saveSubtitles`: write JSON subtitles alongside the audio file.
- `microsoft.proxy`: proxy URL for Microsoft speech requests.
- `microsoft.timeoutMs`: request timeout override (ms).
- `providers.microsoft.rate` / `providers.microsoft.pitch` / `providers.microsoft.volume`: percent strings (e.g. `+10%`, `-5%`).
- `providers.microsoft.saveSubtitles`: write JSON subtitles alongside the audio file.
- `providers.microsoft.proxy`: proxy URL for Microsoft speech requests.
- `providers.microsoft.timeoutMs`: request timeout override (ms).
- `edge.*`: legacy alias for the same Microsoft settings.
## Model-driven overrides (default on)

View File

@@ -8,13 +8,16 @@
"additionalProperties": false,
"properties": {
"command": {
"type": "string"
"type": "string",
"minLength": 1
},
"expectedVersion": {
"type": "string"
"type": "string",
"minLength": 1
},
"cwd": {
"type": "string"
"type": "string",
"minLength": 1
},
"permissionMode": {
"type": "string",
@@ -42,6 +45,7 @@
"properties": {
"command": {
"type": "string",
"minLength": 1,
"description": "Command to run the MCP server"
},
"args": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/acpx",
"version": "2026.3.22",
"version": "2026.3.26",
"description": "OpenClaw ACP runtime backend via acpx",
"type": "module",
"dependencies": {

View File

@@ -1,25 +1,25 @@
---
name: acp-router
description: Route plain-language requests for Pi, Claude Code, Codex, OpenCode, Gemini CLI, or ACP harness work into either OpenClaw ACP runtime sessions or direct acpx-driven sessions ("telephone game" flow). For coding-agent thread requests, read this skill first, then use only `sessions_spawn` for thread creation.
description: Route plain-language requests for Pi, Claude Code, Codex, Cursor, Copilot, OpenClaw ACP, OpenCode, Gemini CLI, Qwen, Kiro, Kimi, iFlow, Factory Droid, Kilocode, or ACP harness work into either OpenClaw ACP runtime sessions or direct acpx-driven sessions ("telephone game" flow). For coding-agent thread requests, read this skill first, then use only `sessions_spawn` for thread creation.
user-invocable: false
---
# ACP Harness Router
When user intent is "run this in Pi/Claude Code/Codex/OpenCode/Gemini/Kimi (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
When user intent is "run this in Pi/Claude Code/Codex/Cursor/Copilot/OpenClaw/OpenCode/Gemini/Qwen/Kiro/Kimi/iFlow/Droid/Kilocode (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
## Intent detection
Trigger this skill when the user asks OpenClaw to:
- run something in Pi / Claude Code / Codex / OpenCode / Gemini
- run something in Pi / Claude Code / Codex / Cursor / Copilot / OpenClaw / OpenCode / Gemini / Qwen / Kiro / Kimi / iFlow / Droid / Kilocode
- continue existing harness work
- relay instructions to an external coding harness
- keep an external harness conversation in a thread-like conversation
Mandatory preflight for coding-agent thread requests:
- Before creating any thread for Pi/Claude/Codex/OpenCode/Gemini work, read this skill first in the same turn.
- Before creating any thread for ACP harness work, read this skill first in the same turn.
- After reading, follow `OpenClaw ACP runtime path` below; do not use `message(action="thread-create")` for ACP harness thread spawn.
## Mode selection
@@ -39,18 +39,26 @@ Do not use:
- `subagents` runtime for harness control
- `/acp` command delegation as a requirement for the user
- PTY scraping of pi/claude/codex/opencode/gemini/kimi CLIs when `acpx` is available
- PTY scraping of supported ACP harness CLIs when `acpx` is available
## AgentId mapping
Use these defaults when user names a harness directly:
- "pi" -> `agentId: "pi"`
- "openclaw" -> `agentId: "openclaw"`
- "claude" or "claude code" -> `agentId: "claude"`
- "codex" -> `agentId: "codex"`
- "copilot" or "github copilot" -> `agentId: "copilot"`
- "cursor" or "cursor cli" -> `agentId: "cursor"`
- "droid" or "factory droid" -> `agentId: "droid"`
- "opencode" -> `agentId: "opencode"`
- "gemini" or "gemini cli" -> `agentId: "gemini"`
- "iflow" -> `agentId: "iflow"`
- "kilocode" -> `agentId: "kilocode"`
- "kimi" or "kimi cli" -> `agentId: "kimi"`
- "kiro" or "kiro cli" -> `agentId: "kiro"`
- "qwen" or "qwen code" -> `agentId: "qwen"`
These defaults match current acpx built-in aliases.
@@ -88,7 +96,7 @@ Call:
## Thread spawn recovery policy
When the user asks to start a coding harness in a thread (for example "start a codex/claude/pi/kimi thread"), treat that as an ACP runtime request and try to satisfy it end-to-end.
When the user asks to start a coding harness in a thread, treat that as an ACP runtime request and try to satisfy it end-to-end.
Required behavior when ACP backend is unavailable:
@@ -179,25 +187,42 @@ ${ACPX_CMD} codex sessions close oc-codex-<conversationId>
### Harness aliases in acpx
- `pi`
- `claude`
- `codex`
- `opencode`
- `copilot`
- `cursor`
- `droid`
- `gemini`
- `iflow`
- `kilocode`
- `kimi`
- `kiro`
- `openclaw`
- `opencode`
- `pi`
- `qwen`
### Built-in adapter commands in acpx
Defaults are:
- `pi -> npx pi-acp`
- `claude -> npx -y @zed-industries/claude-agent-acp`
- `codex -> npx @zed-industries/codex-acp`
- `opencode -> npx -y opencode-ai acp`
- `gemini -> gemini`
- `openclaw -> openclaw acp`
- `claude -> npx -y @zed-industries/claude-agent-acp@0.21.0`
- `codex -> npx @zed-industries/codex-acp@^0.9.5`
- `copilot -> copilot --acp --stdio`
- `cursor -> cursor-agent acp`
- `droid -> droid exec --output-format acp`
- `gemini -> gemini --acp`
- `iflow -> iflow --experimental-acp`
- `kilocode -> npx -y @kilocode/cli acp`
- `kimi -> kimi acp`
- `kiro -> kiro-cli acp`
- `opencode -> npx -y opencode-ai acp`
- `pi -> npx pi-acp@^0.0.22`
- `qwen -> qwen --acp`
If `~/.acpx/config.json` overrides `agents`, those overrides replace defaults.
If your local Cursor install still exposes ACP as `agent acp`, set that as the `cursor` agent override explicitly.
### Failure handling

View File

@@ -187,4 +187,12 @@ describe("acpx plugin config parsing", () => {
}),
).toThrow("strictWindowsCmdWrapper must be a boolean");
});
it("keeps the runtime json schema in sync with the manifest config schema", () => {
const manifest = JSON.parse(
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
) as { configSchema?: unknown };
expect(createAcpxPluginConfigSchema().jsonSchema).toEqual(manifest.configSchema);
});
});

View File

@@ -1,6 +1,8 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildPluginConfigSchema } from "openclaw/plugin-sdk/core";
import { z } from "zod";
import type { OpenClawPluginConfigSchema } from "../runtime-api.js";
export const ACPX_PERMISSION_MODES = ["approve-all", "approve-reads", "deny-all"] as const;
@@ -111,169 +113,79 @@ type ParseResult =
| { ok: true; value: AcpxPluginConfig | undefined }
| { ok: false; message: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const nonEmptyTrimmedString = (message: string) =>
z.string({ error: message }).trim().min(1, { error: message });
function isPermissionMode(value: string): value is AcpxPermissionMode {
return ACPX_PERMISSION_MODES.includes(value as AcpxPermissionMode);
}
const McpServerConfigSchema = z.object({
command: nonEmptyTrimmedString("command must be a non-empty string").describe(
"Command to run the MCP server",
),
args: z
.array(z.string({ error: "args must be an array of strings" }), {
error: "args must be an array of strings",
})
.optional()
.describe("Arguments to pass to the command"),
env: z
.record(z.string(), z.string({ error: "env values must be strings" }), {
error: "env must be an object of strings",
})
.optional()
.describe("Environment variables for the MCP server"),
});
function isNonInteractivePermissionPolicy(
value: string,
): value is AcpxNonInteractivePermissionPolicy {
return ACPX_NON_INTERACTIVE_POLICIES.includes(value as AcpxNonInteractivePermissionPolicy);
}
const AcpxPluginConfigSchema = z.strictObject({
command: nonEmptyTrimmedString("command must be a non-empty string").optional(),
expectedVersion: nonEmptyTrimmedString("expectedVersion must be a non-empty string").optional(),
cwd: nonEmptyTrimmedString("cwd must be a non-empty string").optional(),
permissionMode: z
.enum(ACPX_PERMISSION_MODES, {
error: `permissionMode must be one of: ${ACPX_PERMISSION_MODES.join(", ")}`,
})
.optional(),
nonInteractivePermissions: z
.enum(ACPX_NON_INTERACTIVE_POLICIES, {
error: `nonInteractivePermissions must be one of: ${ACPX_NON_INTERACTIVE_POLICIES.join(", ")}`,
})
.optional(),
strictWindowsCmdWrapper: z
.boolean({ error: "strictWindowsCmdWrapper must be a boolean" })
.optional(),
timeoutSeconds: z
.number({ error: "timeoutSeconds must be a number >= 0.001" })
.min(0.001, { error: "timeoutSeconds must be a number >= 0.001" })
.optional(),
queueOwnerTtlSeconds: z
.number({ error: "queueOwnerTtlSeconds must be a number >= 0" })
.min(0, { error: "queueOwnerTtlSeconds must be a number >= 0" })
.optional(),
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
});
function isMcpServerConfig(value: unknown): value is McpServerConfig {
if (!isRecord(value)) {
return false;
function formatAcpxConfigIssue(issue: z.ZodIssue | undefined): string {
if (!issue) {
return "invalid config";
}
if (typeof value.command !== "string" || value.command.trim() === "") {
return false;
if (issue.code === "unrecognized_keys" && issue.keys.length > 0) {
return `unknown config key: ${issue.keys[0]}`;
}
if (value.args !== undefined) {
if (!Array.isArray(value.args)) {
return false;
}
for (const arg of value.args) {
if (typeof arg !== "string") {
return false;
}
}
if (issue.code === "invalid_type" && issue.path.length === 0) {
return "expected config object";
}
if (value.env !== undefined) {
if (!isRecord(value.env)) {
return false;
}
for (const envValue of Object.values(value.env)) {
if (typeof envValue !== "string") {
return false;
}
}
}
return true;
return issue.message;
}
function parseAcpxPluginConfig(value: unknown): ParseResult {
if (value === undefined) {
return { ok: true, value: undefined };
}
if (!isRecord(value)) {
return { ok: false, message: "expected config object" };
const parsed = AcpxPluginConfigSchema.safeParse(value);
if (!parsed.success) {
return { ok: false, message: formatAcpxConfigIssue(parsed.error.issues[0]) };
}
const allowedKeys = new Set([
"command",
"expectedVersion",
"cwd",
"permissionMode",
"nonInteractivePermissions",
"strictWindowsCmdWrapper",
"timeoutSeconds",
"queueOwnerTtlSeconds",
"mcpServers",
]);
for (const key of Object.keys(value)) {
if (!allowedKeys.has(key)) {
return { ok: false, message: `unknown config key: ${key}` };
}
}
const command = value.command;
if (command !== undefined && (typeof command !== "string" || command.trim() === "")) {
return { ok: false, message: "command must be a non-empty string" };
}
const expectedVersion = value.expectedVersion;
if (
expectedVersion !== undefined &&
(typeof expectedVersion !== "string" || expectedVersion.trim() === "")
) {
return { ok: false, message: "expectedVersion must be a non-empty string" };
}
const cwd = value.cwd;
if (cwd !== undefined && (typeof cwd !== "string" || cwd.trim() === "")) {
return { ok: false, message: "cwd must be a non-empty string" };
}
const permissionMode = value.permissionMode;
if (
permissionMode !== undefined &&
(typeof permissionMode !== "string" || !isPermissionMode(permissionMode))
) {
return {
ok: false,
message: `permissionMode must be one of: ${ACPX_PERMISSION_MODES.join(", ")}`,
};
}
const nonInteractivePermissions = value.nonInteractivePermissions;
if (
nonInteractivePermissions !== undefined &&
(typeof nonInteractivePermissions !== "string" ||
!isNonInteractivePermissionPolicy(nonInteractivePermissions))
) {
return {
ok: false,
message: `nonInteractivePermissions must be one of: ${ACPX_NON_INTERACTIVE_POLICIES.join(", ")}`,
};
}
const timeoutSeconds = value.timeoutSeconds;
if (
timeoutSeconds !== undefined &&
(typeof timeoutSeconds !== "number" || !Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)
) {
return { ok: false, message: "timeoutSeconds must be a positive number" };
}
const strictWindowsCmdWrapper = value.strictWindowsCmdWrapper;
if (strictWindowsCmdWrapper !== undefined && typeof strictWindowsCmdWrapper !== "boolean") {
return { ok: false, message: "strictWindowsCmdWrapper must be a boolean" };
}
const queueOwnerTtlSeconds = value.queueOwnerTtlSeconds;
if (
queueOwnerTtlSeconds !== undefined &&
(typeof queueOwnerTtlSeconds !== "number" ||
!Number.isFinite(queueOwnerTtlSeconds) ||
queueOwnerTtlSeconds < 0)
) {
return { ok: false, message: "queueOwnerTtlSeconds must be a non-negative number" };
}
const mcpServers = value.mcpServers;
if (mcpServers !== undefined) {
if (!isRecord(mcpServers)) {
return { ok: false, message: "mcpServers must be an object" };
}
for (const [key, serverConfig] of Object.entries(mcpServers)) {
if (!isMcpServerConfig(serverConfig)) {
return {
ok: false,
message: `mcpServers.${key} must have a command string, optional args array, and optional env object`,
};
}
}
}
return {
ok: true,
value: {
command: typeof command === "string" ? command.trim() : undefined,
expectedVersion: typeof expectedVersion === "string" ? expectedVersion.trim() : undefined,
cwd: typeof cwd === "string" ? cwd.trim() : undefined,
permissionMode: typeof permissionMode === "string" ? permissionMode : undefined,
nonInteractivePermissions:
typeof nonInteractivePermissions === "string" ? nonInteractivePermissions : undefined,
strictWindowsCmdWrapper:
typeof strictWindowsCmdWrapper === "boolean" ? strictWindowsCmdWrapper : undefined,
timeoutSeconds: typeof timeoutSeconds === "number" ? timeoutSeconds : undefined,
queueOwnerTtlSeconds:
typeof queueOwnerTtlSeconds === "number" ? queueOwnerTtlSeconds : undefined,
mcpServers: mcpServers as Record<string, McpServerConfig> | undefined,
},
value: parsed.data as AcpxPluginConfig,
};
}
@@ -290,63 +202,7 @@ function resolveConfiguredCommand(params: { configured?: string; workspaceDir?:
}
export function createAcpxPluginConfigSchema(): OpenClawPluginConfigSchema {
return {
safeParse(value: unknown):
| { success: true; data?: unknown }
| {
success: false;
error: { issues: Array<{ path: Array<string | number>; message: string }> };
} {
const parsed = parseAcpxPluginConfig(value);
if (parsed.ok) {
return { success: true, data: parsed.value };
}
return {
success: false,
error: {
issues: [{ path: [], message: parsed.message }],
},
};
},
jsonSchema: {
type: "object",
additionalProperties: false,
properties: {
command: { type: "string" },
expectedVersion: { type: "string" },
cwd: { type: "string" },
permissionMode: {
type: "string",
enum: [...ACPX_PERMISSION_MODES],
},
nonInteractivePermissions: {
type: "string",
enum: [...ACPX_NON_INTERACTIVE_POLICIES],
},
strictWindowsCmdWrapper: { type: "boolean" },
timeoutSeconds: { type: "number", minimum: 0.001 },
queueOwnerTtlSeconds: { type: "number", minimum: 0 },
mcpServers: {
type: "object",
additionalProperties: {
type: "object",
properties: {
command: { type: "string" },
args: {
type: "array",
items: { type: "string" },
},
env: {
type: "object",
additionalProperties: { type: "string" },
},
},
required: ["command"],
},
},
},
},
};
return buildPluginConfigSchema(AcpxPluginConfigSchema);
}
export function toAcpMcpServers(mcpServers: Record<string, McpServerConfig>): AcpxMcpServer[] {

View File

@@ -11,6 +11,48 @@ vi.mock("./process.js", () => ({
import { __testing, resolveAcpxAgentCommand } from "./mcp-agent-command.js";
describe("resolveAcpxAgentCommand", () => {
it.each([
["cursor", "cursor-agent acp"],
["gemini", "gemini --acp"],
["openclaw", "openclaw acp"],
["copilot", "copilot --acp --stdio"],
["pi", "npx -y pi-acp@0.0.22"],
["codex", "npx -y @zed-industries/codex-acp@0.9.5"],
["claude", "npx -y @zed-industries/claude-agent-acp@0.21.0"],
])("uses the current acpx built-in for %s by default", async (agent, expected) => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: JSON.stringify({ agents: {} }),
stderr: "",
code: 0,
error: null,
});
const command = await resolveAcpxAgentCommand({
acpxCommand: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
agent,
});
expect(command).toBe(expected);
});
it("returns null for unknown agent ids instead of falling back to raw commands", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: JSON.stringify({ agents: {} }),
stderr: "",
code: 0,
error: null,
});
const command = await resolveAcpxAgentCommand({
acpxCommand: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
agent: "sh -c whoami",
});
expect(command).toBeNull();
});
it("threads stripProviderAuthEnvVars through the config show probe", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: JSON.stringify({

View File

@@ -2,12 +2,22 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawnAndCollect, type SpawnCommandOptions } from "./process.js";
// Keep this mirror aligned with openclaw/acpx src/agent-registry.ts built-ins.
const ACPX_BUILTIN_AGENT_COMMANDS: Record<string, string> = {
codex: "npx @zed-industries/codex-acp",
claude: "npx -y @zed-industries/claude-agent-acp",
gemini: "gemini",
pi: "npx -y pi-acp@0.0.22",
openclaw: "openclaw acp",
codex: "npx -y @zed-industries/codex-acp@0.9.5",
claude: "npx -y @zed-industries/claude-agent-acp@0.21.0",
gemini: "gemini --acp",
cursor: "cursor-agent acp",
copilot: "copilot --acp --stdio",
droid: "droid exec --output-format acp",
iflow: "iflow --experimental-acp",
kilocode: "npx -y @kilocode/cli acp",
kimi: "kimi acp",
kiro: "kiro-cli acp",
opencode: "npx -y opencode-ai acp",
pi: "npx pi-acp",
qwen: "qwen --acp",
};
const MCP_PROXY_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "mcp-proxy.mjs");
@@ -95,7 +105,7 @@ export async function resolveAcpxAgentCommand(params: {
agent: string;
stripProviderAuthEnvVars?: boolean;
spawnOptions?: SpawnCommandOptions;
}): Promise<string> {
}): Promise<string | null> {
const normalizedAgent = normalizeAgentName(params.agent);
const overrides = await loadAgentOverrides({
acpxCommand: params.acpxCommand,
@@ -103,7 +113,7 @@ export async function resolveAcpxAgentCommand(params: {
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
spawnOptions: params.spawnOptions,
});
return overrides[normalizedAgent] ?? ACPX_BUILTIN_AGENT_COMMANDS[normalizedAgent] ?? params.agent;
return overrides[normalizedAgent] ?? ACPX_BUILTIN_AGENT_COMMANDS[normalizedAgent] ?? null;
}
export function buildMcpProxyAgentCommand(params: {

View File

@@ -551,6 +551,31 @@ describe("AcpxRuntime", () => {
}
});
it("does not pass unknown agent ids through acpx --agent when MCP servers are configured", async () => {
const { runtime, logPath } = await createMockRuntimeFixture({
mcpServers: {
canva: {
command: "npx",
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
env: {
CANVA_TOKEN: "secret", // pragma: allowlist secret
},
},
},
});
await runtime.ensureSession({
sessionKey: "agent:sh:acp:mcp",
agent: "sh -c whoami",
mode: "persistent",
});
const logs = await readMockRuntimeLogEntries(logPath);
const ensureArgs = (logs.find((entry) => entry.kind === "ensure")?.args as string[]) ?? [];
expect(ensureArgs).not.toContain("--agent");
expect(ensureArgs).toContain("sh -c whoami");
});
it("skips prompt execution when runTurn starts with an already-aborted signal", async () => {
const { runtime, logPath } = await createMockRuntimeFixture();
const handle = await runtime.ensureSession({

View File

@@ -936,6 +936,9 @@ export class AcpxRuntime implements AcpRuntime {
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
spawnOptions: this.spawnCommandOptions,
});
if (!targetCommand) {
return null;
}
const resolved = buildMcpProxyAgentCommand({
targetCommand,
mcpServers: toAcpMcpServers(this.config.mcpServers),

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.3.22",
"version": "2026.3.26",
"private": true,
"description": "OpenClaw Amazon Bedrock provider plugin",
"type": "module",

View File

@@ -3,102 +3,49 @@ import {
CLI_FRESH_WATCHDOG_DEFAULTS,
CLI_RESUME_WATCHDOG_DEFAULTS,
} from "openclaw/plugin-sdk/cli-backend";
const CLAUDE_MODEL_ALIASES: Record<string, string> = {
opus: "opus",
"opus-4.6": "opus",
"opus-4.5": "opus",
"opus-4": "opus",
"claude-opus-4-6": "opus",
"claude-opus-4-5": "opus",
"claude-opus-4": "opus",
sonnet: "sonnet",
"sonnet-4.6": "sonnet",
"sonnet-4.5": "sonnet",
"sonnet-4.1": "sonnet",
"sonnet-4.0": "sonnet",
"claude-sonnet-4-6": "sonnet",
"claude-sonnet-4-5": "sonnet",
"claude-sonnet-4-1": "sonnet",
"claude-sonnet-4-0": "sonnet",
haiku: "haiku",
"haiku-3.5": "haiku",
"claude-haiku-3-5": "haiku",
};
const CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG = "--dangerously-skip-permissions";
const CLAUDE_PERMISSION_MODE_ARG = "--permission-mode";
const CLAUDE_BYPASS_PERMISSIONS_MODE = "bypassPermissions";
function normalizeClaudePermissionArgs(args?: string[]): string[] | undefined {
if (!args) {
return args;
}
const normalized: string[] = [];
let sawLegacySkip = false;
let hasPermissionMode = false;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG) {
sawLegacySkip = true;
continue;
}
if (arg === CLAUDE_PERMISSION_MODE_ARG) {
hasPermissionMode = true;
normalized.push(arg);
const maybeValue = args[i + 1];
if (typeof maybeValue === "string") {
normalized.push(maybeValue);
i += 1;
}
continue;
}
if (arg.startsWith(`${CLAUDE_PERMISSION_MODE_ARG}=`)) {
hasPermissionMode = true;
}
normalized.push(arg);
}
if (sawLegacySkip && !hasPermissionMode) {
normalized.push(CLAUDE_PERMISSION_MODE_ARG, CLAUDE_BYPASS_PERMISSIONS_MODE);
}
return normalized;
}
function normalizeClaudeBackendConfig(config: CliBackendConfig): CliBackendConfig {
return {
...config,
args: normalizeClaudePermissionArgs(config.args),
resumeArgs: normalizeClaudePermissionArgs(config.resumeArgs),
};
}
import {
CLAUDE_CLI_BACKEND_ID,
CLAUDE_CLI_CLEAR_ENV,
CLAUDE_CLI_MODEL_ALIASES,
CLAUDE_CLI_SESSION_ID_FIELDS,
normalizeClaudeBackendConfig,
} from "./cli-shared.js";
export function buildAnthropicCliBackend(): CliBackendPlugin {
return {
id: "claude-cli",
id: CLAUDE_CLI_BACKEND_ID,
bundleMcp: true,
config: {
command: "claude",
args: ["-p", "--output-format", "json", "--permission-mode", "bypassPermissions"],
args: [
"-p",
"--output-format",
"stream-json",
"--verbose",
"--permission-mode",
"bypassPermissions",
],
resumeArgs: [
"-p",
"--output-format",
"json",
"stream-json",
"--verbose",
"--permission-mode",
"bypassPermissions",
"--resume",
"{sessionId}",
],
output: "json",
output: "jsonl",
input: "arg",
modelArg: "--model",
modelAliases: CLAUDE_MODEL_ALIASES,
modelAliases: CLAUDE_CLI_MODEL_ALIASES,
sessionArg: "--session-id",
sessionMode: "always",
sessionIdFields: ["session_id", "sessionId", "conversation_id", "conversationId"],
sessionIdFields: [...CLAUDE_CLI_SESSION_ID_FIELDS],
systemPromptArg: "--append-system-prompt",
systemPromptMode: "append",
systemPromptWhen: "first",
clearEnv: ["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY_OLD"],
clearEnv: [...CLAUDE_CLI_CLEAR_ENV],
reliability: {
watchdog: {
fresh: { ...CLI_FRESH_WATCHDOG_DEFAULTS },

View File

@@ -0,0 +1,84 @@
import type { CliBackendConfig } from "openclaw/plugin-sdk/cli-backend";
export const CLAUDE_CLI_BACKEND_ID = "claude-cli";
export const CLAUDE_CLI_MODEL_ALIASES: Record<string, string> = {
opus: "opus",
"opus-4.6": "opus",
"opus-4.5": "opus",
"opus-4": "opus",
"claude-opus-4-6": "opus",
"claude-opus-4-5": "opus",
"claude-opus-4": "opus",
sonnet: "sonnet",
"sonnet-4.6": "sonnet",
"sonnet-4.5": "sonnet",
"sonnet-4.1": "sonnet",
"sonnet-4.0": "sonnet",
"claude-sonnet-4-6": "sonnet",
"claude-sonnet-4-5": "sonnet",
"claude-sonnet-4-1": "sonnet",
"claude-sonnet-4-0": "sonnet",
haiku: "haiku",
"haiku-3.5": "haiku",
"claude-haiku-3-5": "haiku",
};
export const CLAUDE_CLI_SESSION_ID_FIELDS = [
"session_id",
"sessionId",
"conversation_id",
"conversationId",
] as const;
export const CLAUDE_CLI_CLEAR_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY_OLD"] as const;
const CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG = "--dangerously-skip-permissions";
const CLAUDE_PERMISSION_MODE_ARG = "--permission-mode";
const CLAUDE_BYPASS_PERMISSIONS_MODE = "bypassPermissions";
export function isClaudeCliProvider(providerId: string): boolean {
return providerId.trim().toLowerCase() === CLAUDE_CLI_BACKEND_ID;
}
export function normalizeClaudePermissionArgs(args?: string[]): string[] | undefined {
if (!args) {
return args;
}
const normalized: string[] = [];
let sawLegacySkip = false;
let hasPermissionMode = false;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG) {
sawLegacySkip = true;
continue;
}
if (arg === CLAUDE_PERMISSION_MODE_ARG) {
hasPermissionMode = true;
normalized.push(arg);
const maybeValue = args[i + 1];
if (typeof maybeValue === "string") {
normalized.push(maybeValue);
i += 1;
}
continue;
}
if (arg.startsWith(`${CLAUDE_PERMISSION_MODE_ARG}=`)) {
hasPermissionMode = true;
}
normalized.push(arg);
}
if (sawLegacySkip && !hasPermissionMode) {
normalized.push(CLAUDE_PERMISSION_MODE_ARG, CLAUDE_BYPASS_PERMISSIONS_MODE);
}
return normalized;
}
export function normalizeClaudeBackendConfig(config: CliBackendConfig): CliBackendConfig {
return {
...config,
args: normalizeClaudePermissionArgs(config.args),
resumeArgs: normalizeClaudePermissionArgs(config.resumeArgs),
};
}

View File

@@ -1,7 +1,6 @@
{
"id": "anthropic",
"providers": ["anthropic"],
"mediaUnderstandingProviders": ["anthropic"],
"cliBackends": ["claude-cli"],
"providerAuthEnvVars": {
"anthropic": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]
@@ -41,6 +40,9 @@
"cliDescription": "Anthropic API key"
}
],
"contracts": {
"mediaUnderstandingProviders": ["anthropic"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-provider",
"version": "2026.3.22",
"version": "2026.3.26",
"private": true,
"description": "OpenClaw Anthropic provider plugin",
"type": "module",

View File

@@ -0,0 +1 @@
export { BlueBubblesChannelConfigSchema } from "./src/config-schema.js";

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/bluebubbles",
"version": "2026.3.22",
"version": "2026.3.26",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"dependencies": {
@@ -10,7 +10,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.22"
"openclaw": ">=2026.3.26"
},
"peerDependenciesMeta": {
"openclaw": {
@@ -43,7 +43,7 @@
"npmSpec": "@openclaw/bluebubbles",
"localPath": "extensions/bluebubbles",
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.22"
"minHostVersion": ">=2026.3.26"
},
"release": {
"publishToNpm": true

View File

@@ -135,7 +135,8 @@ describe("bluebubblesMessageActions", () => {
},
};
const actions = describeMessageTool({ cfg })?.actions ?? [];
expect(actions).toContain("sendAttachment");
expect(actions).toContain("upload-file");
expect(actions).not.toContain("sendAttachment");
expect(actions).not.toContain("react");
expect(actions).not.toContain("reply");
expect(actions).not.toContain("sendWithEffect");
@@ -165,6 +166,7 @@ describe("bluebubblesMessageActions", () => {
expect(supportsAction({ action: "removeParticipant" })).toBe(true);
expect(supportsAction({ action: "leaveGroup" })).toBe(true);
expect(supportsAction({ action: "sendAttachment" })).toBe(true);
expect(supportsAction({ action: "upload-file" })).toBe(true);
});
it("returns false for unsupported actions", () => {
@@ -204,6 +206,36 @@ describe("bluebubblesMessageActions", () => {
});
describe("handleAction", () => {
it("maps upload-file to the attachment runtime using canonical naming", async () => {
const result = await callHandleAction({
action: "upload-file",
params: {
to: "+15551234567",
filename: "photo.png",
buffer: Buffer.from("img").toString("base64"),
message: "caption",
contentType: "image/png",
},
cfg: blueBubblesConfig(),
accountId: null,
});
expect(sendBlueBubblesAttachment).toHaveBeenCalledWith(
expect.objectContaining({
to: "+15551234567",
filename: "photo.png",
caption: "caption",
contentType: "image/png",
}),
);
expect(result).toMatchObject({
details: {
ok: true,
messageId: "att-msg-123",
},
});
});
it("throws for unsupported actions", async () => {
const cfg: OpenClawConfig = {
channels: {

View File

@@ -52,7 +52,10 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
}
/** Supported action names for BlueBubbles */
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>([
...BLUEBUBBLES_ACTION_NAMES,
"upload-file",
]);
const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
"react",
"edit",
@@ -107,6 +110,9 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
}
}
}
if (actions.delete("sendAttachment")) {
actions.add("upload-file");
}
return { actions: Array.from(actions) };
},
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
@@ -428,11 +434,11 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
return jsonResult({ ok: true, left: resolvedChatGuid });
}
// Handle sendAttachment action
if (action === "sendAttachment") {
// Handle sendAttachment action (legacy) and upload-file (canonical)
if (action === "sendAttachment" || action === "upload-file") {
const to = readStringParam(params, "to", { required: true });
const filename = readStringParam(params, "filename", { required: true });
const caption = readStringParam(params, "caption");
const caption = readStringParam(params, "caption") ?? readStringParam(params, "message");
const contentType =
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
const asVoice = readBooleanParam(params, "asVoice");
@@ -448,10 +454,10 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
} else if (filePath) {
// Read file from path (will be handled by caller providing buffer)
throw new Error(
"BlueBubbles sendAttachment: filePath not supported in action, provide buffer as base64.",
`BlueBubbles ${action}: filePath not supported in action, provide buffer as base64.`,
);
} else {
throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter.");
throw new Error(`BlueBubbles ${action} requires buffer (base64) parameter.`);
}
const result = await runtime.sendBlueBubblesAttachment({

View File

@@ -4,14 +4,13 @@ import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
import {
listBlueBubblesAccountIds,
type ResolvedBlueBubblesAccount,
resolveBlueBubblesAccount,
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
import { BlueBubblesChannelConfigSchema } from "./config-schema.js";
import type { ChannelPlugin } from "./runtime-api.js";
import { normalizeBlueBubblesHandle } from "./targets.js";
@@ -41,7 +40,7 @@ export const bluebubblesCapabilities: ChannelPlugin<ResolvedBlueBubblesAccount>[
};
export const bluebubblesReload = { configPrefixes: ["channels.bluebubbles"] };
export const bluebubblesConfigSchema = buildChannelConfigSchema(BlueBubblesConfigSchema);
export const bluebubblesConfigSchema = BlueBubblesChannelConfigSchema;
export const bluebubblesConfigAdapter =
createScopedChannelConfigAdapter<ResolvedBlueBubblesAccount>({

View File

@@ -1,5 +1,6 @@
import {
AllowFromListSchema,
buildChannelConfigSchema,
buildCatchallMultiAccountChannelSchema,
DmPolicySchema,
GroupPolicySchema,
@@ -7,6 +8,7 @@ import {
ToolPolicySchema,
} from "openclaw/plugin-sdk/channel-config-schema";
import { z } from "zod";
import { bluebubblesChannelConfigUiHints } from "./config-ui-hints.js";
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
const bluebubblesActionSchema = z
@@ -71,3 +73,7 @@ export const BlueBubblesConfigSchema = buildCatchallMultiAccountChannelSchema(
).extend({
actions: bluebubblesActionSchema,
});
export const BlueBubblesChannelConfigSchema = buildChannelConfigSchema(BlueBubblesConfigSchema, {
uiHints: bluebubblesChannelConfigUiHints,
});

View File

@@ -0,0 +1,12 @@
import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/core";
export const bluebubblesChannelConfigUiHints = {
"": {
label: "BlueBubbles",
help: "BlueBubbles channel provider configuration used for Apple messaging bridge integrations. Keep DM policy aligned with your trusted sender model in shared deployments.",
},
dmPolicy: {
label: "BlueBubbles DM Policy",
help: 'Direct message access control ("pairing" recommended). "open" requires channels.bluebubbles.allowFrom=["*"].',
},
} satisfies Record<string, ChannelConfigUiHint>;

View File

@@ -15,6 +15,9 @@
"help": "Brave Search mode: web or llm-context."
}
},
"contracts": {
"webSearchProviders": ["brave"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/brave-plugin",
"version": "2026.3.22",
"version": "2026.3.26",
"private": true,
"description": "OpenClaw Brave plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/browser-plugin",
"version": "2026.3.25",
"version": "2026.3.26",
"private": true,
"description": "OpenClaw browser tool plugin",
"type": "module",

View File

@@ -27,7 +27,7 @@ const browserClientMocks = vi.hoisted(() => ({
browserStop: vi.fn(async (..._args: unknown[]) => ({})),
browserTabs: vi.fn(async (..._args: unknown[]): Promise<Array<Record<string, unknown>>> => []),
}));
vi.mock("../../../extensions/browser/src/browser/client.js", () => browserClientMocks);
vi.mock("./browser/client.js", () => browserClientMocks);
const browserActionsMocks = vi.hoisted(() => ({
browserAct: vi.fn(async () => ({ ok: true })),
@@ -48,7 +48,7 @@ const browserActionsMocks = vi.hoisted(() => ({
browserPdfSave: vi.fn(async () => ({ ok: true, path: "/tmp/test.pdf" })),
browserScreenshotAction: vi.fn(async () => ({ ok: true, path: "/tmp/test.png" })),
}));
vi.mock("../../../extensions/browser/src/browser/client-actions.js", () => browserActionsMocks);
vi.mock("./browser/client-actions.js", () => browserActionsMocks);
const browserConfigMocks = vi.hoisted(() => ({
resolveBrowserConfig: vi.fn(() => ({
@@ -89,13 +89,15 @@ const browserConfigMocks = vi.hoisted(() => ({
};
}),
}));
vi.mock("../../../extensions/browser/src/browser/config.js", () => browserConfigMocks);
vi.mock("./browser/config.js", () => browserConfigMocks);
const nodesUtilsMocks = vi.hoisted(() => ({
listNodes: vi.fn(async (..._args: unknown[]): Promise<Array<Record<string, unknown>>> => []),
}));
vi.mock("./nodes-utils.js", async () => {
const actual = await vi.importActual<typeof import("./nodes-utils.js")>("./nodes-utils.js");
vi.mock("../../../src/agents/tools/nodes-utils.js", async () => {
const actual = await vi.importActual<typeof import("../../../src/agents/tools/nodes-utils.js")>(
"../../../src/agents/tools/nodes-utils.js",
);
return {
...actual,
listNodes: nodesUtilsMocks.listNodes,
@@ -108,39 +110,35 @@ const gatewayMocks = vi.hoisted(() => ({
payload: { result: { ok: true, running: true } },
})),
}));
vi.mock("./gateway.js", () => gatewayMocks);
vi.mock("../../../src/agents/tools/gateway.js", () => gatewayMocks);
const configMocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({ browser: {} })),
}));
vi.mock("../../config/config.js", () => configMocks);
vi.mock("../../../src/config/config.js", () => configMocks);
const sessionTabRegistryMocks = vi.hoisted(() => ({
trackSessionBrowserTab: vi.fn(),
untrackSessionBrowserTab: vi.fn(),
}));
vi.mock(
"../../../extensions/browser/src/browser/session-tab-registry.js",
() => sessionTabRegistryMocks,
);
vi.mock("./browser/session-tab-registry.js", () => sessionTabRegistryMocks);
const toolCommonMocks = vi.hoisted(() => ({
imageResultFromFile: vi.fn(),
}));
vi.mock("./common.js", async () => {
const actual = await vi.importActual<typeof import("./common.js")>("./common.js");
vi.mock("../../../src/agents/tools/common.js", async () => {
const actual = await vi.importActual<typeof import("../../../src/agents/tools/common.js")>(
"../../../src/agents/tools/common.js",
);
return {
...actual,
imageResultFromFile: toolCommonMocks.imageResultFromFile,
};
});
import { __testing as browserToolActionsTesting } from "../../../extensions/browser/src/browser-tool.actions.js";
import {
__testing as browserToolTesting,
createBrowserTool,
} from "../../../extensions/browser/src/browser-tool.js";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../../extensions/browser/src/browser/constants.js";
import { __testing as browserToolActionsTesting } from "./browser-tool.actions.js";
import { __testing as browserToolTesting, createBrowserTool } from "./browser-tool.js";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./browser/constants.js";
function mockSingleBrowserProxyNode() {
nodesUtilsMocks.listNodes.mockResolvedValue([

View File

@@ -1,13 +1,10 @@
import { afterEach, describe, expect, it } from "vitest";
import {
startBrowserBridgeServer,
stopBrowserBridgeServer,
} from "../../extensions/browser/src/browser/bridge-server.js";
import type { ResolvedBrowserConfig } from "../../extensions/browser/src/browser/config.js";
import { startBrowserBridgeServer, stopBrowserBridgeServer } from "./bridge-server.js";
import type { ResolvedBrowserConfig } from "./config.js";
import {
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "../../extensions/browser/src/browser/constants.js";
} from "./constants.js";
function buildResolvedConfig(): ResolvedBrowserConfig {
return {

View File

@@ -3,17 +3,14 @@ import {
appendCdpPath,
getHeadersWithAuth,
normalizeCdpHttpBaseForJsonEndpoints,
} from "../../extensions/browser/src/browser/cdp.helpers.js";
import { __test } from "../../extensions/browser/src/browser/client-fetch.js";
import {
resolveBrowserConfig,
resolveProfile,
} from "../../extensions/browser/src/browser/config.js";
import { shouldRejectBrowserMutation } from "../../extensions/browser/src/browser/csrf.js";
import { toBoolean } from "../../extensions/browser/src/browser/routes/utils.js";
import type { BrowserServerState } from "../../extensions/browser/src/browser/server-context.js";
import { listKnownProfileNames } from "../../extensions/browser/src/browser/server-context.js";
import { resolveTargetIdFromTabs } from "../../extensions/browser/src/browser/target-id.js";
} from "./cdp.helpers.js";
import { __test } from "./client-fetch.js";
import { resolveBrowserConfig, resolveProfile } from "./config.js";
import { shouldRejectBrowserMutation } from "./csrf.js";
import { toBoolean } from "./routes/utils.js";
import type { BrowserServerState } from "./server-context.js";
import { listKnownProfileNames } from "./server-context.js";
import { resolveTargetIdFromTabs } from "./target-id.js";
describe("toBoolean", () => {
it("parses yes/no and 1/0", () => {

View File

@@ -6,7 +6,7 @@ import {
hasProxyEnv,
withNoProxyForCdpUrl,
withNoProxyForLocalhost,
} from "../../extensions/browser/src/browser/cdp-proxy-bypass.js";
} from "./cdp-proxy-bypass.js";
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -202,8 +202,7 @@ describe("cdp-proxy-bypass", () => {
describe("withNoProxyForLocalhost concurrency", () => {
it("does not leak NO_PROXY when called concurrently", async () => {
await withIsolatedNoProxyEnv(async () => {
const { withNoProxyForLocalhost } =
await import("../../extensions/browser/src/browser/cdp-proxy-bypass.js");
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
// Simulate concurrent calls
const callA = withNoProxyForLocalhost(async () => {
@@ -230,8 +229,7 @@ describe("withNoProxyForLocalhost concurrency", () => {
describe("withNoProxyForLocalhost reverse exit order", () => {
it("restores NO_PROXY when first caller exits before second", async () => {
await withIsolatedNoProxyEnv(async () => {
const { withNoProxyForLocalhost } =
await import("../../extensions/browser/src/browser/cdp-proxy-bypass.js");
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
// Call A enters first, exits first (short task)
// Call B enters second, exits last (long task)
@@ -261,8 +259,7 @@ describe("withNoProxyForLocalhost preserves user-configured NO_PROXY", () => {
process.env.HTTP_PROXY = "http://proxy:8080";
try {
const { withNoProxyForLocalhost } =
await import("../../extensions/browser/src/browser/cdp-proxy-bypass.js");
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
await withNoProxyForLocalhost(async () => {
// Should not modify since loopback is already covered

View File

@@ -4,7 +4,7 @@ import {
PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS,
PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS,
resolveCdpReachabilityTimeouts,
} from "../../extensions/browser/src/browser/cdp-timeouts.js";
} from "./cdp-timeouts.js";
describe("resolveCdpReachabilityTimeouts", () => {
it("uses loopback defaults when timeout is omitted", () => {

View File

@@ -1,17 +1,12 @@
import { createServer } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import { type WebSocket, WebSocketServer } from "ws";
import { isWebSocketUrl } from "../../extensions/browser/src/browser/cdp.helpers.js";
import {
createTargetViaCdp,
evaluateJavaScript,
normalizeCdpWsUrl,
snapshotAria,
} from "../../extensions/browser/src/browser/cdp.js";
import { parseHttpUrl } from "../../extensions/browser/src/browser/config.js";
import { InvalidBrowserNavigationUrlError } from "../../extensions/browser/src/browser/navigation-guard.js";
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { rawDataToString } from "../infra/ws.js";
import { isWebSocketUrl } from "./cdp.helpers.js";
import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
import { parseHttpUrl } from "./config.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
describe("cdp", () => {
let httpServer: ReturnType<typeof createServer> | null = null;

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import {
buildAiSnapshotFromChromeMcpSnapshot,
flattenChromeMcpSnapshotToAriaNodes,
} from "../../extensions/browser/src/browser/chrome-mcp.snapshot.js";
} from "./chrome-mcp.snapshot.js";
const snapshot = {
id: "root",

View File

@@ -6,7 +6,7 @@ import {
openChromeMcpTab,
resetChromeMcpSessionsForTest,
setChromeMcpSessionFactoryForTest,
} from "../../extensions/browser/src/browser/chrome-mcp.js";
} from "./chrome-mcp.js";
type ToolCall = {
name: string;

View File

@@ -25,7 +25,7 @@ import * as fs from "node:fs";
import os from "node:os";
async function loadResolveBrowserExecutableForPlatform() {
const mod = await import("../../extensions/browser/src/browser/chrome.executables.js");
const mod = await import("./chrome.executables.js");
return mod.resolveBrowserExecutableForPlatform;
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildOpenClawChromeLaunchArgs } from "../../extensions/browser/src/browser/chrome.js";
import { buildOpenClawChromeLaunchArgs } from "./chrome.js";
describe("browser chrome launch args", () => {
it("does not force an about:blank tab at startup", () => {

View File

@@ -15,11 +15,11 @@ import {
isChromeReachable,
resolveBrowserExecutableForPlatform,
stopOpenClawChrome,
} from "../../extensions/browser/src/browser/chrome.js";
} from "./chrome.js";
import {
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "../../extensions/browser/src/browser/constants.js";
} from "./constants.js";
type StopChromeTarget = Parameters<typeof stopOpenClawChrome>[0];

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { BrowserDispatchResponse } from "../../extensions/browser/src/browser/routes/dispatcher.js";
import type { BrowserDispatchResponse } from "./routes/dispatcher.js";
function okDispatchResponse(): BrowserDispatchResponse {
return { status: 200, body: { ok: true } };
@@ -22,35 +22,34 @@ const mocks = vi.hoisted(() => ({
dispatch: vi.fn(async (): Promise<BrowserDispatchResponse> => okDispatchResponse()),
}));
vi.mock("../../extensions/browser/src/config/config.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../extensions/browser/src/config/config.js")>();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: mocks.loadConfig,
};
});
vi.mock("../../extensions/browser/src/browser/control-service.js", () => ({
vi.mock("./control-service.js", () => ({
createBrowserControlContext: vi.fn(() => ({})),
startBrowserControlServiceFromConfig: mocks.startBrowserControlServiceFromConfig,
}));
vi.mock("../../extensions/browser/src/browser/control-auth.js", () => ({
vi.mock("./control-auth.js", () => ({
resolveBrowserControlAuth: mocks.resolveBrowserControlAuth,
}));
vi.mock("../../extensions/browser/src/browser/bridge-auth-registry.js", () => ({
vi.mock("./bridge-auth-registry.js", () => ({
getBridgeAuthForPort: mocks.getBridgeAuthForPort,
}));
vi.mock("../../extensions/browser/src/browser/routes/dispatcher.js", () => ({
vi.mock("./routes/dispatcher.js", () => ({
createBrowserRouteDispatcher: vi.fn(() => ({
dispatch: mocks.dispatch,
})),
}));
let fetchBrowserJson: typeof import("../../extensions/browser/src/browser/client-fetch.js").fetchBrowserJson;
let fetchBrowserJson: typeof import("./client-fetch.js").fetchBrowserJson;
function stubJsonFetchOk() {
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
@@ -88,7 +87,7 @@ async function expectThrownBrowserFetchError(
describe("fetchBrowserJson loopback auth", () => {
beforeAll(async () => {
vi.resetModules();
({ fetchBrowserJson } = await import("../../extensions/browser/src/browser/client-fetch.js"));
({ fetchBrowserJson } = await import("./client-fetch.js"));
});
beforeEach(() => {

View File

@@ -7,13 +7,8 @@ import {
browserNavigate,
browserPdfSave,
browserScreenshotAction,
} from "../../extensions/browser/src/browser/client-actions.js";
import {
browserOpenTab,
browserSnapshot,
browserStatus,
browserTabs,
} from "../../extensions/browser/src/browser/client.js";
} from "./client-actions.js";
import { browserOpenTab, browserSnapshot, browserStatus, browserTabs } from "./client.js";
describe("browser client", () => {
function stubSnapshotFetch(calls: string[]) {

View File

@@ -1,12 +1,8 @@
import { describe, expect, it } from "vitest";
import {
resolveBrowserConfig,
resolveProfile,
shouldStartLocalBrowserServer,
} from "../../extensions/browser/src/browser/config.js";
import { getBrowserProfileCapabilities } from "../../extensions/browser/src/browser/profile-capabilities.js";
import { withEnv } from "../test-utils/env.js";
import { withEnv } from "../../test-support.js";
import { resolveUserPath } from "../utils.js";
import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
describe("browser config", () => {
it("defaults to enabled with loopback defaults and lobster-orange color", () => {

View File

@@ -1,10 +1,28 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { expectGeneratedTokenPersistedToGatewayAuth } from "../../test-support.js";
import type { OpenClawConfig } from "../config/config.js";
import { expectGeneratedTokenPersistedToGatewayAuth } from "../test-utils/auth-token-assertions.js";
const mocks = vi.hoisted(() => ({
loadConfig: vi.fn<() => OpenClawConfig>(),
writeConfigFile: vi.fn(async (_cfg: OpenClawConfig) => {}),
ensureGatewayStartupAuth: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg: {
...cfg,
gateway: {
...cfg.gateway,
auth: {
...cfg.gateway?.auth,
mode: "token" as const,
token: "a".repeat(48),
},
},
},
auth: {
mode: "token" as const,
token: "a".repeat(48),
},
generatedToken: "a".repeat(48),
persistedGeneratedToken: true,
})),
}));
vi.mock("../config/config.js", async (importOriginal) => {
@@ -12,11 +30,14 @@ vi.mock("../config/config.js", async (importOriginal) => {
return {
...actual,
loadConfig: mocks.loadConfig,
writeConfigFile: mocks.writeConfigFile,
};
});
let ensureBrowserControlAuth: typeof import("../../extensions/browser/src/browser/control-auth.js").ensureBrowserControlAuth;
vi.mock("../gateway/startup-auth.js", () => ({
ensureGatewayStartupAuth: mocks.ensureGatewayStartupAuth,
}));
let ensureBrowserControlAuth: typeof import("./control-auth.js").ensureBrowserControlAuth;
describe("ensureBrowserControlAuth", () => {
const expectExplicitModeSkipsAutoAuth = async (mode: "password" | "none") => {
@@ -32,28 +53,28 @@ describe("ensureBrowserControlAuth", () => {
const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(result).toEqual({ auth: {} });
expect(mocks.loadConfig).not.toHaveBeenCalled();
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
expect(mocks.ensureGatewayStartupAuth).not.toHaveBeenCalled();
};
const expectGeneratedTokenPersisted = (result: {
const expectGeneratedTokenPersisted = async (result: {
generatedToken?: string;
auth: { token?: string };
}) => {
expect(mocks.writeConfigFile).toHaveBeenCalledTimes(1);
expect(mocks.ensureGatewayStartupAuth).toHaveBeenCalledTimes(1);
const ensured = await mocks.ensureGatewayStartupAuth.mock.results[0]?.value;
expectGeneratedTokenPersistedToGatewayAuth({
generatedToken: result.generatedToken,
authToken: result.auth.token,
persistedConfig: mocks.writeConfigFile.mock.calls[0]?.[0],
persistedConfig: ensured?.cfg,
});
};
beforeEach(async () => {
vi.resetModules();
({ ensureBrowserControlAuth } =
await import("../../extensions/browser/src/browser/control-auth.js"));
({ ensureBrowserControlAuth } = await import("./control-auth.js"));
vi.restoreAllMocks();
mocks.loadConfig.mockClear();
mocks.writeConfigFile.mockClear();
mocks.ensureGatewayStartupAuth.mockClear();
});
it("returns existing auth and skips writes", async () => {
@@ -69,7 +90,7 @@ describe("ensureBrowserControlAuth", () => {
expect(result).toEqual({ auth: { token: "already-set" } });
expect(mocks.loadConfig).not.toHaveBeenCalled();
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
expect(mocks.ensureGatewayStartupAuth).not.toHaveBeenCalled();
});
it("auto-generates and persists a token when auth is missing", async () => {
@@ -85,7 +106,7 @@ describe("ensureBrowserControlAuth", () => {
});
const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expectGeneratedTokenPersisted(result);
await expectGeneratedTokenPersisted(result);
});
it("skips auto-generation in test env", async () => {
@@ -102,7 +123,7 @@ describe("ensureBrowserControlAuth", () => {
expect(result).toEqual({ auth: {} });
expect(mocks.loadConfig).not.toHaveBeenCalled();
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
expect(mocks.ensureGatewayStartupAuth).not.toHaveBeenCalled();
});
it("respects explicit password mode", async () => {
@@ -133,7 +154,7 @@ describe("ensureBrowserControlAuth", () => {
const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(result).toEqual({ auth: { token: "latest-token" } });
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
expect(mocks.ensureGatewayStartupAuth).not.toHaveBeenCalled();
});
it("fails when gateway.auth.token SecretRef is unresolved", async () => {
@@ -154,10 +175,11 @@ describe("ensureBrowserControlAuth", () => {
},
};
mocks.loadConfig.mockReturnValue(cfg);
mocks.ensureGatewayStartupAuth.mockRejectedValueOnce(new Error("MISSING_GW_TOKEN"));
await expect(ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow(
/MISSING_GW_TOKEN/i,
);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
expect(mocks.ensureGatewayStartupAuth).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { ensureBrowserControlAuth } from "../../extensions/browser/src/browser/control-auth.js";
import type { OpenClawConfig } from "../config/types.js";
import type { OpenClawConfig } from "../../test-support.js";
import { ensureBrowserControlAuth } from "./control-auth.js";
describe("ensureBrowserControlAuth", () => {
async function expectNoAutoGeneratedAuth(cfg: OpenClawConfig): Promise<void> {

View File

@@ -3,28 +3,29 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
ensureBrowserControlAuth: vi.fn(async () => ({ generatedToken: false })),
createBrowserRuntimeState: vi.fn(async () => ({ ok: true })),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
browser: {
enabled: true,
},
plugins: {
entries: {
browser: {
enabled: false,
},
loadConfig: vi.fn(() => ({
browser: {
enabled: true,
},
plugins: {
entries: {
browser: {
enabled: false,
},
},
}),
},
})),
}));
vi.mock("openclaw/plugin-sdk/browser-support", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/browser-support")>();
return {
...actual,
loadConfig: mocks.loadConfig,
};
});
vi.mock("../../extensions/browser/src/browser/config.js", () => ({
vi.mock("./config.js", () => ({
resolveBrowserConfig: vi.fn(() => ({
enabled: true,
controlPort: 18791,
@@ -32,24 +33,24 @@ vi.mock("../../extensions/browser/src/browser/config.js", () => ({
})),
}));
vi.mock("../../extensions/browser/src/browser/control-auth.js", () => ({
vi.mock("./control-auth.js", () => ({
ensureBrowserControlAuth: mocks.ensureBrowserControlAuth,
}));
vi.mock("../../extensions/browser/src/browser/runtime-lifecycle.js", () => ({
vi.mock("./runtime-lifecycle.js", () => ({
createBrowserRuntimeState: mocks.createBrowserRuntimeState,
stopBrowserRuntime: vi.fn(async () => {}),
}));
let startBrowserControlServiceFromConfig: typeof import("../../extensions/browser/src/browser/control-service.js").startBrowserControlServiceFromConfig;
let startBrowserControlServiceFromConfig: typeof import("../control-service.js").startBrowserControlServiceFromConfig;
describe("startBrowserControlServiceFromConfig", () => {
beforeEach(async () => {
mocks.ensureBrowserControlAuth.mockClear();
mocks.createBrowserRuntimeState.mockClear();
mocks.loadConfig.mockClear();
vi.resetModules();
({ startBrowserControlServiceFromConfig } =
await import("../../extensions/browser/src/browser/control-service.js"));
({ startBrowserControlServiceFromConfig } = await import("../control-service.js"));
});
it("does not start the default service when the browser plugin is disabled", async () => {

View File

@@ -1,12 +1,12 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { SsrFBlockedError, type LookupFn } from "../infra/net/ssrf.js";
import {
assertBrowserNavigationAllowed,
assertBrowserNavigationRedirectChainAllowed,
assertBrowserNavigationResultAllowed,
InvalidBrowserNavigationUrlError,
requiresInspectableBrowserNavigationRedirects,
} from "../../extensions/browser/src/browser/navigation-guard.js";
import { SsrFBlockedError, type LookupFn } from "../infra/net/ssrf.js";
} from "./navigation-guard.js";
function createLookupFn(address: string): LookupFn {
const family = address.includes(":") ? 6 : 4;

View File

@@ -8,7 +8,7 @@ import {
resolvePathWithinRoot,
resolveStrictExistingPathsWithinRoot,
resolveWritablePathWithinRoot,
} from "../../extensions/browser/src/browser/paths.js";
} from "./paths.js";
async function createFixtureRoot(): Promise<{ baseDir: string; uploadsDir: string }> {
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-browser-paths-"));

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { isDefaultBrowserPluginEnabled } from "../../extensions/browser/src/browser/plugin-enabled.js";
import type { OpenClawConfig } from "../config/config.js";
import { isDefaultBrowserPluginEnabled } from "./plugin-enabled.js";
describe("isDefaultBrowserPluginEnabled", () => {
it("defaults to enabled", () => {

View File

@@ -1,17 +1,13 @@
import fs from "node:fs";
import path from "node:path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type {
BrowserRouteContext,
BrowserServerState,
} from "../../extensions/browser/src/browser/server-context.js";
import { resolveOpenClawUserDataDir } from "../../extensions/browser/src/browser/chrome.js";
import { movePathToTrash } from "../../extensions/browser/src/browser/trash.js";
import { loadConfig, writeConfigFile } from "../../extensions/browser/src/config/config.js";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { resolveOpenClawUserDataDir } from "./chrome.js";
import type { BrowserRouteContext, BrowserServerState } from "./server-context.js";
import { movePathToTrash } from "./trash.js";
vi.mock("../../extensions/browser/src/config/config.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../extensions/browser/src/config/config.js")>();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: vi.fn(),
@@ -19,16 +15,16 @@ vi.mock("../../extensions/browser/src/config/config.js", async (importOriginal)
};
});
vi.mock("../../extensions/browser/src/browser/trash.js", () => ({
vi.mock("./trash.js", () => ({
movePathToTrash: vi.fn(async (targetPath: string) => targetPath),
}));
vi.mock("../../extensions/browser/src/browser/chrome.js", () => ({
vi.mock("./chrome.js", () => ({
resolveOpenClawUserDataDir: vi.fn(() => "/tmp/openclaw-test/openclaw/user-data"),
}));
let resolveBrowserConfig: typeof import("../../extensions/browser/src/browser/config.js").resolveBrowserConfig;
let createBrowserProfilesService: typeof import("../../extensions/browser/src/browser/profiles-service.js").createBrowserProfilesService;
let resolveBrowserConfig: typeof import("./config.js").resolveBrowserConfig;
let createBrowserProfilesService: typeof import("./profiles-service.js").createBrowserProfilesService;
function createCtx(resolved: BrowserServerState["resolved"]) {
const state: BrowserServerState = {
@@ -63,9 +59,8 @@ async function createWorkProfileWithConfig(params: {
describe("BrowserProfilesService", () => {
beforeAll(async () => {
vi.resetModules();
({ resolveBrowserConfig } = await import("../../extensions/browser/src/browser/config.js"));
({ createBrowserProfilesService } =
await import("../../extensions/browser/src/browser/profiles-service.js"));
({ resolveBrowserConfig } = await import("./config.js"));
({ createBrowserProfilesService } = await import("./profiles-service.js"));
});
beforeEach(() => {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveBrowserConfig } from "../../extensions/browser/src/browser/config.js";
import { resolveBrowserConfig } from "./config.js";
import {
allocateCdpPort,
allocateColor,
@@ -9,7 +9,7 @@ import {
getUsedPorts,
isValidProfileName,
PROFILE_COLORS,
} from "../../extensions/browser/src/browser/profiles.js";
} from "./profiles.js";
describe("profile name validation", () => {
it.each(["openclaw", "work", "my-profile", "test123", "a", "a-b-c-1-2-3", "1test"])(

View File

@@ -1,9 +1,9 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { persistBrowserProxyFiles } from "../../extensions/browser/src/browser/proxy-files.js";
import { MEDIA_MAX_BYTES } from "../media/store.js";
import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js";
import { MEDIA_MAX_BYTES } from "../../../../src/media/store.js";
import { createTempHomeEnv, type TempHomeEnv } from "../../test-support.js";
import { persistBrowserProxyFiles } from "./proxy-files.js";
describe("persistBrowserProxyFiles", () => {
let tempHome: TempHomeEnv;

View File

@@ -55,19 +55,16 @@ function createBrowser(pages: unknown[]) {
}
let chromiumMock: typeof import("playwright-core").chromium;
let snapshotAiViaPlaywright: typeof import("../../extensions/browser/src/browser/pw-tools-core.snapshot.js").snapshotAiViaPlaywright;
let clickViaPlaywright: typeof import("../../extensions/browser/src/browser/pw-tools-core.interactions.js").clickViaPlaywright;
let closePlaywrightBrowserConnection: typeof import("../../extensions/browser/src/browser/pw-session.js").closePlaywrightBrowserConnection;
let snapshotAiViaPlaywright: typeof import("./pw-tools-core.snapshot.js").snapshotAiViaPlaywright;
let clickViaPlaywright: typeof import("./pw-tools-core.interactions.js").clickViaPlaywright;
let closePlaywrightBrowserConnection: typeof import("./pw-session.js").closePlaywrightBrowserConnection;
beforeAll(async () => {
const pw = await import("playwright-core");
chromiumMock = pw.chromium;
({ snapshotAiViaPlaywright } =
await import("../../extensions/browser/src/browser/pw-tools-core.snapshot.js"));
({ clickViaPlaywright } =
await import("../../extensions/browser/src/browser/pw-tools-core.interactions.js"));
({ closePlaywrightBrowserConnection } =
await import("../../extensions/browser/src/browser/pw-session.js"));
({ snapshotAiViaPlaywright } = await import("./pw-tools-core.snapshot.js"));
({ clickViaPlaywright } = await import("./pw-tools-core.interactions.js"));
({ closePlaywrightBrowserConnection } = await import("./pw-session.js"));
});
afterEach(async () => {

View File

@@ -4,7 +4,7 @@ import {
buildRoleSnapshotFromAriaSnapshot,
getRoleSnapshotStats,
parseRoleRef,
} from "../../extensions/browser/src/browser/pw-role-snapshot.js";
} from "./pw-role-snapshot.js";
describe("pw-role-snapshot", () => {
it("adds refs for interactive elements", () => {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { isLiveTestEnabled } from "../agents/live-test-helpers.js";
import { isLiveTestEnabled } from "../../test-support.js";
const LIVE = isLiveTestEnabled();
const CDP_URL = process.env.OPENCLAW_LIVE_BROWSER_CDP_URL?.trim() || "";
@@ -14,7 +14,7 @@ async function waitFor(
describeLive("browser (live): remote CDP tab persistence", () => {
it("creates, lists, focuses, and closes tabs via Playwright", { timeout: 60_000 }, async () => {
const pw = await import("../../extensions/browser/src/browser/pw-ai.js");
const pw = await import("./pw-ai.js");
await pw.closePlaywrightBrowserConnection().catch(() => {});
const created = await pw.createPageViaPlaywright({ cdpUrl: CDP_URL, url: "about:blank" });

View File

@@ -1,10 +1,7 @@
import { chromium } from "playwright-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as chromeModule from "../../extensions/browser/src/browser/chrome.js";
import {
closePlaywrightBrowserConnection,
listPagesViaPlaywright,
} from "../../extensions/browser/src/browser/pw-session.js";
import * as chromeModule from "./chrome.js";
import { closePlaywrightBrowserConnection, listPagesViaPlaywright } from "./pw-session.js";
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");

View File

@@ -1,12 +1,9 @@
import { chromium } from "playwright-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as chromeModule from "../../extensions/browser/src/browser/chrome.js";
import { InvalidBrowserNavigationUrlError } from "../../extensions/browser/src/browser/navigation-guard.js";
import {
closePlaywrightBrowserConnection,
createPageViaPlaywright,
} from "../../extensions/browser/src/browser/pw-session.js";
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import * as chromeModule from "./chrome.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
import { closePlaywrightBrowserConnection, createPageViaPlaywright } from "./pw-session.js";
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");

View File

@@ -1,10 +1,7 @@
import { chromium } from "playwright-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as chromeModule from "../../extensions/browser/src/browser/chrome.js";
import {
closePlaywrightBrowserConnection,
getPageForTargetId,
} from "../../extensions/browser/src/browser/pw-session.js";
import * as chromeModule from "./chrome.js";
import { closePlaywrightBrowserConnection, getPageForTargetId } from "./pw-session.js";
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withPageScopedCdpClient } from "../../extensions/browser/src/browser/pw-session.page-cdp.js";
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
describe("pw-session page-scoped CDP client", () => {
beforeEach(() => {

View File

@@ -5,7 +5,7 @@ import {
refLocator,
rememberRoleRefsForTarget,
restoreRoleRefsForTarget,
} from "../../extensions/browser/src/browser/pw-session.js";
} from "./pw-session.js";
function fakePage(): {
page: Page;

View File

@@ -3,15 +3,15 @@ import {
installPwToolsCoreTestHooks,
setPwToolsCoreCurrentPage,
setPwToolsCoreCurrentRefLocator,
} from "../../extensions/browser/src/browser/pw-tools-core.test-harness.js";
} from "./pw-tools-core.test-harness.js";
installPwToolsCoreTestHooks();
let mod: typeof import("../../extensions/browser/src/browser/pw-tools-core.js");
let mod: typeof import("./pw-tools-core.js");
describe("pw-tools-core", () => {
beforeAll(async () => {
vi.resetModules();
mod = await import("../../extensions/browser/src/browser/pw-tools-core.js");
mod = await import("./pw-tools-core.js");
});
it("clamps timeoutMs for scrollIntoView", async () => {

View File

@@ -18,7 +18,7 @@ const restoreRoleRefsForTarget = vi.fn(() => {});
const closePageViaPlaywright = vi.fn(async () => {});
const resizeViewportViaPlaywright = vi.fn(async () => {});
vi.mock("../../extensions/browser/src/browser/pw-session.js", () => ({
vi.mock("./pw-session.js", () => ({
ensurePageState,
forceDisconnectPlaywrightForTarget,
getPageForTargetId,
@@ -26,18 +26,17 @@ vi.mock("../../extensions/browser/src/browser/pw-session.js", () => ({
restoreRoleRefsForTarget,
}));
vi.mock("../../extensions/browser/src/browser/pw-tools-core.snapshot.js", () => ({
vi.mock("./pw-tools-core.snapshot.js", () => ({
closePageViaPlaywright,
resizeViewportViaPlaywright,
}));
let batchViaPlaywright: typeof import("../../extensions/browser/src/browser/pw-tools-core.interactions.js").batchViaPlaywright;
let batchViaPlaywright: typeof import("./pw-tools-core.interactions.js").batchViaPlaywright;
describe("batchViaPlaywright", () => {
beforeAll(async () => {
vi.resetModules();
({ batchViaPlaywright } =
await import("../../extensions/browser/src/browser/pw-tools-core.interactions.js"));
({ batchViaPlaywright } = await import("./pw-tools-core.interactions.js"));
});
beforeEach(() => {

View File

@@ -19,7 +19,7 @@ const refLocator = vi.fn(() => {
return locator;
});
vi.mock("../../extensions/browser/src/browser/pw-session.js", () => {
vi.mock("./pw-session.js", () => {
return {
ensurePageState,
forceDisconnectPlaywrightForTarget,
@@ -29,7 +29,7 @@ vi.mock("../../extensions/browser/src/browser/pw-session.js", () => {
};
});
let evaluateViaPlaywright: typeof import("../../extensions/browser/src/browser/pw-tools-core.interactions.js").evaluateViaPlaywright;
let evaluateViaPlaywright: typeof import("./pw-tools-core.interactions.js").evaluateViaPlaywright;
function createPendingEval() {
let evalCalled!: () => void;
@@ -48,8 +48,7 @@ describe("evaluateViaPlaywright (abort)", () => {
vi.clearAllMocks();
page = null;
locator = null;
({ evaluateViaPlaywright } =
await import("../../extensions/browser/src/browser/pw-tools-core.interactions.js"));
({ evaluateViaPlaywright } = await import("./pw-tools-core.interactions.js"));
});
it.each([

View File

@@ -20,11 +20,9 @@ const refLocator = vi.fn(() => {
const forceDisconnectPlaywrightForTarget = vi.fn(async () => {});
const resolveStrictExistingPathsWithinRoot =
vi.fn<
typeof import("../../extensions/browser/src/browser/paths.js").resolveStrictExistingPathsWithinRoot
>();
vi.fn<typeof import("./paths.js").resolveStrictExistingPathsWithinRoot>();
vi.mock("../../extensions/browser/src/browser/pw-session.js", () => {
vi.mock("./pw-session.js", () => {
return {
ensurePageState,
forceDisconnectPlaywrightForTarget,
@@ -34,14 +32,14 @@ vi.mock("../../extensions/browser/src/browser/pw-session.js", () => {
};
});
vi.mock("../../extensions/browser/src/browser/paths.js", () => {
vi.mock("./paths.js", () => {
return {
DEFAULT_UPLOAD_DIR: "/tmp/openclaw/uploads",
resolveStrictExistingPathsWithinRoot,
};
});
let setInputFilesViaPlaywright: typeof import("../../extensions/browser/src/browser/pw-tools-core.interactions.js").setInputFilesViaPlaywright;
let setInputFilesViaPlaywright: typeof import("./pw-tools-core.interactions.js").setInputFilesViaPlaywright;
function seedSingleLocatorPage(): { setInputFiles: ReturnType<typeof vi.fn> } {
const setInputFiles = vi.fn(async () => {});
@@ -58,8 +56,7 @@ function seedSingleLocatorPage(): { setInputFiles: ReturnType<typeof vi.fn> } {
describe("setInputFilesViaPlaywright", () => {
beforeEach(async () => {
vi.resetModules();
({ setInputFilesViaPlaywright } =
await import("../../extensions/browser/src/browser/pw-tools-core.interactions.js"));
({ setInputFilesViaPlaywright } = await import("./pw-tools-core.interactions.js"));
vi.clearAllMocks();
page = null;
locator = null;

View File

@@ -2,19 +2,19 @@ import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { DEFAULT_UPLOAD_DIR } from "../../extensions/browser/src/browser/paths.js";
import { DEFAULT_UPLOAD_DIR } from "./paths.js";
import {
installPwToolsCoreTestHooks,
setPwToolsCoreCurrentPage,
} from "../../extensions/browser/src/browser/pw-tools-core.test-harness.js";
} from "./pw-tools-core.test-harness.js";
installPwToolsCoreTestHooks();
let mod: typeof import("../../extensions/browser/src/browser/pw-tools-core.js");
let mod: typeof import("./pw-tools-core.js");
describe("pw-tools-core", () => {
beforeAll(async () => {
vi.resetModules();
mod = await import("../../extensions/browser/src/browser/pw-tools-core.js");
mod = await import("./pw-tools-core.js");
});
it("last file-chooser arm wins", async () => {

View File

@@ -2,17 +2,17 @@ import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_UPLOAD_DIR } from "../../extensions/browser/src/browser/paths.js";
import { DEFAULT_UPLOAD_DIR } from "./paths.js";
import {
getPwToolsCoreSessionMocks,
installPwToolsCoreTestHooks,
setPwToolsCoreCurrentPage,
setPwToolsCoreCurrentRefLocator,
} from "../../extensions/browser/src/browser/pw-tools-core.test-harness.js";
} from "./pw-tools-core.test-harness.js";
installPwToolsCoreTestHooks();
const sessionMocks = getPwToolsCoreSessionMocks();
let mod: typeof import("../../extensions/browser/src/browser/pw-tools-core.js");
let mod: typeof import("./pw-tools-core.js");
function createFileChooserPageMocks() {
const fileChooser = { setFiles: vi.fn(async () => {}) };
@@ -28,7 +28,7 @@ function createFileChooserPageMocks() {
describe("pw-tools-core", () => {
beforeAll(async () => {
vi.resetModules();
mod = await import("../../extensions/browser/src/browser/pw-tools-core.js");
mod = await import("./pw-tools-core.js");
});
beforeEach(() => {

View File

@@ -1,14 +1,14 @@
import { describe, expect, it, vi } from "vitest";
import { InvalidBrowserNavigationUrlError } from "../../extensions/browser/src/browser/navigation-guard.js";
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
import {
getPwToolsCoreSessionMocks,
installPwToolsCoreTestHooks,
setPwToolsCoreCurrentPage,
} from "../../extensions/browser/src/browser/pw-tools-core.test-harness.js";
import { SsrFBlockedError } from "../infra/net/ssrf.js";
} from "./pw-tools-core.test-harness.js";
installPwToolsCoreTestHooks();
const mod = await import("../../extensions/browser/src/browser/pw-tools-core.snapshot.js");
const mod = await import("./pw-tools-core.snapshot.js");
describe("pw-tools-core.snapshot navigate guard", () => {
it("blocks unsupported non-network URLs before page lookup", async () => {

View File

@@ -7,7 +7,7 @@ import {
installPwToolsCoreTestHooks,
setPwToolsCoreCurrentPage,
setPwToolsCoreCurrentRefLocator,
} from "../../extensions/browser/src/browser/pw-tools-core.test-harness.js";
} from "./pw-tools-core.test-harness.js";
installPwToolsCoreTestHooks();
const sessionMocks = getPwToolsCoreSessionMocks();
@@ -15,12 +15,12 @@ const tmpDirMocks = vi.hoisted(() => ({
resolvePreferredOpenClawTmpDir: vi.fn(() => "/tmp/openclaw"),
}));
vi.mock("../infra/tmp-openclaw-dir.js", () => tmpDirMocks);
let mod: typeof import("../../extensions/browser/src/browser/pw-tools-core.js");
let mod: typeof import("./pw-tools-core.js");
describe("pw-tools-core", () => {
beforeAll(async () => {
vi.resetModules();
mod = await import("../../extensions/browser/src/browser/pw-tools-core.js");
mod = await import("./pw-tools-core.js");
});
beforeEach(() => {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { isPersistentBrowserProfileMutation } from "../../extensions/browser/src/browser/request-policy.js";
import { isPersistentBrowserProfileMutation } from "./request-policy.js";
describe("isPersistentBrowserProfileMutation", () => {
it.each([

View File

@@ -1,9 +1,6 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
createBrowserRouteApp,
createBrowserRouteResponse,
} from "../../../extensions/browser/src/browser/routes/test-helpers.js";
import type { BrowserRequest } from "../../../extensions/browser/src/browser/routes/types.js";
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
import type { BrowserRequest } from "./types.js";
const routeState = vi.hoisted(() => ({
profileCtx: {
@@ -36,7 +33,7 @@ const chromeMcpMocks = vi.hoisted(() => ({
})),
}));
vi.mock("../../../extensions/browser/src/browser/chrome-mcp.js", () => ({
vi.mock("../chrome-mcp.js", () => ({
clickChromeMcpElement: vi.fn(async () => {}),
closeChromeMcpTab: vi.fn(async () => {}),
dragChromeMcpElement: vi.fn(async () => {}),
@@ -51,18 +48,18 @@ vi.mock("../../../extensions/browser/src/browser/chrome-mcp.js", () => ({
takeChromeMcpSnapshot: chromeMcpMocks.takeChromeMcpSnapshot,
}));
vi.mock("../../../extensions/browser/src/browser/cdp.js", () => ({
vi.mock("../cdp.js", () => ({
captureScreenshot: vi.fn(),
snapshotAria: vi.fn(),
}));
vi.mock("../../../extensions/browser/src/browser/navigation-guard.js", () => ({
vi.mock("../navigation-guard.js", () => ({
assertBrowserNavigationAllowed: vi.fn(async () => {}),
assertBrowserNavigationResultAllowed: vi.fn(async () => {}),
withBrowserNavigationPolicy: vi.fn(() => ({})),
}));
vi.mock("../../../extensions/browser/src/browser/screenshot.js", () => ({
vi.mock("../screenshot.js", () => ({
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
normalizeBrowserScreenshot: vi.fn(async (buffer: Buffer) => ({
@@ -76,7 +73,7 @@ vi.mock("../../media/store.js", () => ({
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
}));
vi.mock("../../../extensions/browser/src/browser/routes/agent.shared.js", () => ({
vi.mock("./agent.shared.js", () => ({
getPwAiModule: vi.fn(async () => null),
handleRouteError: vi.fn(),
readBody: vi.fn((req: BrowserRequest) => req.body ?? {}),
@@ -97,15 +94,13 @@ vi.mock("../../../extensions/browser/src/browser/routes/agent.shared.js", () =>
}),
}));
let registerBrowserAgentActRoutes: typeof import("../../../extensions/browser/src/browser/routes/agent.act.js").registerBrowserAgentActRoutes;
let registerBrowserAgentSnapshotRoutes: typeof import("../../../extensions/browser/src/browser/routes/agent.snapshot.js").registerBrowserAgentSnapshotRoutes;
let registerBrowserAgentActRoutes: typeof import("./agent.act.js").registerBrowserAgentActRoutes;
let registerBrowserAgentSnapshotRoutes: typeof import("./agent.snapshot.js").registerBrowserAgentSnapshotRoutes;
beforeAll(async () => {
vi.resetModules();
({ registerBrowserAgentActRoutes } =
await import("../../../extensions/browser/src/browser/routes/agent.act.js"));
({ registerBrowserAgentSnapshotRoutes } =
await import("../../../extensions/browser/src/browser/routes/agent.snapshot.js"));
({ registerBrowserAgentActRoutes } = await import("./agent.act.js"));
({ registerBrowserAgentSnapshotRoutes } = await import("./agent.snapshot.js"));
});
function getSnapshotGetHandler() {

View File

@@ -1,10 +1,6 @@
import { describe, expect, it } from "vitest";
import {
readBody,
resolveTargetIdFromBody,
resolveTargetIdFromQuery,
} from "../../../extensions/browser/src/browser/routes/agent.shared.js";
import type { BrowserRequest } from "../../../extensions/browser/src/browser/routes/types.js";
import { readBody, resolveTargetIdFromBody, resolveTargetIdFromQuery } from "./agent.shared.js";
import type { BrowserRequest } from "./types.js";
function requestWithBody(body: unknown): BrowserRequest {
return {

View File

@@ -1,9 +1,6 @@
import { describe, expect, it } from "vitest";
import {
resolveBrowserConfig,
resolveProfile,
} from "../../../extensions/browser/src/browser/config.js";
import { resolveSnapshotPlan } from "../../../extensions/browser/src/browser/routes/agent.snapshot.plan.js";
import { resolveBrowserConfig, resolveProfile } from "../config.js";
import { resolveSnapshotPlan } from "./agent.snapshot.plan.js";
describe("resolveSnapshotPlan", () => {
it("defaults existing-session snapshots to ai when format is omitted", () => {

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveTargetIdAfterNavigate } from "../../../extensions/browser/src/browser/routes/agent.snapshot.js";
import { resolveTargetIdAfterNavigate } from "./agent.snapshot.js";
type Tab = { targetId: string; url: string };

View File

@@ -3,7 +3,7 @@ import {
parseRequiredStorageMutationRequest,
parseStorageKind,
parseStorageMutationRequest,
} from "../../../extensions/browser/src/browser/routes/agent.storage.js";
} from "./agent.storage.js";
describe("browser storage route parsing", () => {
describe("parseStorageKind", () => {

View File

@@ -1,15 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createBrowserRouteApp,
createBrowserRouteResponse,
} from "../../../extensions/browser/src/browser/routes/test-helpers.js";
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
vi.mock("../../../extensions/browser/src/browser/chrome-mcp.js", () => ({
vi.mock("../chrome-mcp.js", () => ({
getChromeMcpPid: vi.fn(() => 4321),
}));
let registerBrowserBasicRoutes: typeof import("../../../extensions/browser/src/browser/routes/basic.js").registerBrowserBasicRoutes;
let BrowserProfileUnavailableError: typeof import("../../../extensions/browser/src/browser/errors.js").BrowserProfileUnavailableError;
let registerBrowserBasicRoutes: typeof import("./basic.js").registerBrowserBasicRoutes;
let BrowserProfileUnavailableError: typeof import("../errors.js").BrowserProfileUnavailableError;
function createExistingSessionProfileState(params?: { isHttpReachable?: () => Promise<boolean> }) {
return {
@@ -57,10 +54,8 @@ async function callBasicRouteWithState(params: {
beforeEach(async () => {
vi.resetModules();
({ BrowserProfileUnavailableError } =
await import("../../../extensions/browser/src/browser/errors.js"));
({ registerBrowserBasicRoutes } =
await import("../../../extensions/browser/src/browser/routes/basic.js"));
({ BrowserProfileUnavailableError } = await import("../errors.js"));
({ registerBrowserBasicRoutes } = await import("./basic.js"));
});
describe("basic browser routes", () => {

View File

@@ -1,12 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { BrowserRouteContext } from "../../../extensions/browser/src/browser/server-context.js";
import type { BrowserRouteContext } from "../server-context.js";
let createBrowserRouteDispatcher: typeof import("../../../extensions/browser/src/browser/routes/dispatcher.js").createBrowserRouteDispatcher;
let createBrowserRouteDispatcher: typeof import("./dispatcher.js").createBrowserRouteDispatcher;
describe("browser route dispatcher (abort)", () => {
beforeEach(async () => {
vi.resetModules();
vi.doMock("../../../extensions/browser/src/browser/routes/index.js", () => {
vi.doMock("./index.js", () => {
return {
registerBrowserRoutes(app: { get: (path: string, handler: unknown) => void }) {
app.get(
@@ -40,8 +40,7 @@ describe("browser route dispatcher (abort)", () => {
},
};
});
({ createBrowserRouteDispatcher } =
await import("../../../extensions/browser/src/browser/routes/dispatcher.js"));
({ createBrowserRouteDispatcher } = await import("./dispatcher.js"));
});
it("propagates AbortSignal and lets handlers observe abort", async () => {

View File

@@ -1,6 +1,6 @@
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { normalizeBrowserScreenshot } from "../../extensions/browser/src/browser/screenshot.js";
import { normalizeBrowserScreenshot } from "./screenshot.js";
describe("browser screenshot normalization", () => {
it("shrinks oversized images to <=2000x2000 and <=5MB", async () => {

View File

@@ -6,15 +6,15 @@ vi.hoisted(() => {
vi.resetModules();
});
import "../../extensions/browser/src/browser/server-context.chrome-test-harness.js";
import "./server-context.chrome-test-harness.js";
import {
PROFILE_ATTACH_RETRY_TIMEOUT_MS,
PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
} from "../../extensions/browser/src/browser/cdp-timeouts.js";
import * as chromeModule from "../../extensions/browser/src/browser/chrome.js";
import type { RunningChrome } from "../../extensions/browser/src/browser/chrome.js";
import type { BrowserServerState } from "../../extensions/browser/src/browser/server-context.js";
import { createBrowserRouteContext } from "../../extensions/browser/src/browser/server-context.js";
} from "./cdp-timeouts.js";
import * as chromeModule from "./chrome.js";
import type { RunningChrome } from "./chrome.js";
import type { BrowserServerState } from "./server-context.js";
import { createBrowserRouteContext } from "./server-context.js";
function makeBrowserState(): BrowserServerState {
return {

View File

@@ -1,8 +1,8 @@
import fs from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BrowserServerState } from "../../extensions/browser/src/browser/server-context.js";
import type { BrowserServerState } from "./server-context.js";
vi.mock("../../extensions/browser/src/browser/chrome-mcp.js", () => ({
vi.mock("./chrome-mcp.js", () => ({
closeChromeMcpSession: vi.fn(async () => true),
ensureChromeMcpAvailable: vi.fn(async () => {}),
focusChromeMcpTab: vi.fn(async () => {}),
@@ -19,8 +19,8 @@ vi.mock("../../extensions/browser/src/browser/chrome-mcp.js", () => ({
getChromeMcpPid: vi.fn(() => 4321),
}));
let createBrowserRouteContext: typeof import("../../extensions/browser/src/browser/server-context.js").createBrowserRouteContext;
let chromeMcp: typeof import("../../extensions/browser/src/browser/chrome-mcp.js");
let createBrowserRouteContext: typeof import("./server-context.js").createBrowserRouteContext;
let chromeMcp: typeof import("./chrome-mcp.js");
function makeState(): BrowserServerState {
return {
@@ -64,9 +64,8 @@ afterEach(() => {
beforeEach(async () => {
vi.resetModules();
({ createBrowserRouteContext } =
await import("../../extensions/browser/src/browser/server-context.js"));
chromeMcp = await import("../../extensions/browser/src/browser/chrome-mcp.js");
({ createBrowserRouteContext } = await import("./server-context.js"));
chromeMcp = await import("./chrome-mcp.js");
});
describe("browser server-context existing-session profile", () => {

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { BrowserServerState } from "../../extensions/browser/src/browser/server-context.types.js";
import type { BrowserServerState } from "./server-context.types.js";
let cfgProfiles: Record<string, { cdpPort?: number; cdpUrl?: string; color?: string }> = {};
@@ -42,18 +42,17 @@ vi.mock("../config/config.js", async (importOriginal) => {
describe("server-context hot-reload profiles", () => {
let loadConfig: typeof import("../config/config.js").loadConfig;
let resolveBrowserConfig: typeof import("../../extensions/browser/src/browser/config.js").resolveBrowserConfig;
let resolveProfile: typeof import("../../extensions/browser/src/browser/config.js").resolveProfile;
let refreshResolvedBrowserConfigFromDisk: typeof import("../../extensions/browser/src/browser/resolved-config-refresh.js").refreshResolvedBrowserConfigFromDisk;
let resolveBrowserProfileWithHotReload: typeof import("../../extensions/browser/src/browser/resolved-config-refresh.js").resolveBrowserProfileWithHotReload;
let resolveBrowserConfig: typeof import("./config.js").resolveBrowserConfig;
let resolveProfile: typeof import("./config.js").resolveProfile;
let refreshResolvedBrowserConfigFromDisk: typeof import("./resolved-config-refresh.js").refreshResolvedBrowserConfigFromDisk;
let resolveBrowserProfileWithHotReload: typeof import("./resolved-config-refresh.js").resolveBrowserProfileWithHotReload;
beforeEach(async () => {
vi.resetModules();
({ loadConfig } = await import("../config/config.js"));
({ resolveBrowserConfig, resolveProfile } =
await import("../../extensions/browser/src/browser/config.js"));
({ resolveBrowserConfig, resolveProfile } = await import("./config.js"));
({ refreshResolvedBrowserConfigFromDisk, resolveBrowserProfileWithHotReload } =
await import("../../extensions/browser/src/browser/resolved-config-refresh.js"));
await import("./resolved-config-refresh.js"));
vi.clearAllMocks();
cfgProfiles = {
openclaw: { cdpPort: 18800, color: "#FF4500" },

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