Compare commits

...

51 Commits

Author SHA1 Message Date
Peter Steinberger
a979721433 fix: stage WhatsApp runtime deps before setup login 2026-04-24 17:05:23 +01:00
Peter Steinberger
9b18ae8770 chore: refresh release schema version 2026-04-24 16:25:33 +01:00
Peter Steinberger
b42c47804b chore: release 2026.4.23 2026-04-24 16:07:24 +01:00
Peter Steinberger
a58ee7c8bc fix(release): accept logged cross-os agent output 2026-04-24 16:05:17 +01:00
Peter Steinberger
855872986e fix(release): harden subagent completion delivery 2026-04-24 15:20:26 +01:00
Peter Steinberger
137e397f9c fix: escape Parallels config scrub script 2026-04-24 14:49:37 +01:00
Peter Steinberger
0d1c97bfcc fix: use node for Parallels config scrub 2026-04-24 14:49:37 +01:00
Peter Steinberger
805d552601 chore: bump release beta to 2026.4.23-beta.6 2026-04-24 11:54:51 +01:00
Peter Steinberger
8b2bbde8a4 test: harden live docker aggregate flakes 2026-04-24 11:50:31 +01:00
Frank Yang
dc48fb756c [codex] fix agent session-id routing (#70985)
Merged via squash.

Prepared head SHA: f092b0c5c8
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-04-24 11:49:24 +01:00
Peter Steinberger
aee7c4ef86 chore: bump release beta to 2026.4.23-beta.5 2026-04-24 10:25:39 +01:00
Peter Steinberger
ba5a2a01c5 docs(release): record npm telegram beta e2e 2026-04-24 10:25:10 +01:00
Peter Steinberger
c52a16989e ci(release): parse logged agent payload text 2026-04-24 10:23:14 +01:00
Peter Steinberger
3385d10ee5 test: fix models list e2e static catalog mock 2026-04-24 10:21:45 +01:00
Peter Steinberger
f672535e0c test: skip ACP marker probes without transcript 2026-04-24 10:21:28 +01:00
Peter Steinberger
9aa1140d6c test: skip ACP bind probes without transcript 2026-04-24 10:21:28 +01:00
Peter Steinberger
b89767f32c test(e2e): run root-owned gateway logging as appuser 2026-04-24 10:21:28 +01:00
Peter Steinberger
5b1bd58bd0 test: make bundled channel docker lane resumable 2026-04-24 08:58:29 +01:00
Peter Steinberger
c27a9073f4 fix(plugins): mirror SDK alias for staged sidecars 2026-04-24 08:30:03 +01:00
Peter Steinberger
ad08170196 test: harden Docker E2E readiness 2026-04-24 07:40:17 +01:00
Peter Steinberger
c858346b63 fix: pass Gemini trust flag 2026-04-24 07:19:47 +01:00
Peter Steinberger
933d6e2c27 fix: adapt models registry fallback to release branch 2026-04-24 06:47:24 +01:00
Peter Steinberger
72f831a05f fix: restore models list registry fallback 2026-04-24 06:45:53 +01:00
Peter Steinberger
18664077b0 fix(channels): defer setup runtime deps until login 2026-04-24 06:41:31 +01:00
Peter Steinberger
6601eef58b test(channels): cover staged setup entry dependency loading 2026-04-24 06:15:30 +01:00
Peter Steinberger
b642d7343a fix(channels): keep bundled setup entries dependency-light 2026-04-24 06:15:29 +01:00
Peter Steinberger
006e07e64a chore: bump release beta to 2026.4.23-beta.4 2026-04-24 06:13:17 +01:00
Peter Steinberger
c6eef47924 test: repair live release backports 2026-04-24 06:12:52 +01:00
Peter Steinberger
bc9a53e533 test: stabilize gateway test helpers 2026-04-24 06:05:13 +01:00
Peter Steinberger
eb61c106ad test(gateway): harden codex live harness 2026-04-24 06:05:13 +01:00
Peter Steinberger
ffd9146f1c test: harden live docker lanes 2026-04-24 06:04:49 +01:00
Peter Steinberger
c9e09dafc6 test: default live model concurrency 2026-04-24 06:04:32 +01:00
Peter Steinberger
98d3b480c7 test: stabilize live model sweeps 2026-04-24 06:04:06 +01:00
Peter Steinberger
9f731f49ea ci(release): tolerate plugin logs before agent json 2026-04-24 05:58:41 +01:00
Peter Steinberger
493306fa58 ci(release): parse agent final text in cross-os checks 2026-04-24 05:46:56 +01:00
Peter Steinberger
d4a92cff60 fix(plugins): avoid plugin sdk alias rewrite races 2026-04-24 04:38:32 +01:00
Peter Steinberger
c41c212591 fix: disable bundled plugins during Parallels update 2026-04-24 04:29:44 +01:00
Peter Steinberger
6926494f71 fix: scrub future plugin entries in Parallels update smoke 2026-04-24 04:29:44 +01:00
Peter Steinberger
6f948d925e ci(release): configure shared docker e2e builder 2026-04-24 04:22:01 +01:00
Peter Steinberger
0e2bd4b3ee docs(release): clarify beta number reuse 2026-04-24 03:34:46 +01:00
Peter Steinberger
8c2235e873 fix(plugins): mirror sdk alias for external bundled deps 2026-04-24 03:33:42 +01:00
Peter Steinberger
99354fc1c9 fix(plugins): clean bundled runtime install stage 2026-04-24 03:33:42 +01:00
simonemacario
080ac622c1 fix(plugins): stage bundled-plugin runtime-dep install outside the plugin root
When a packaged bundled plugin's `pluginRoot` is used directly as the npm
execution cwd, `npm install <specs>` resolves the plugin's own
`package.json` as the project manifest and fails with
`EUNSUPPORTEDPROTOCOL: Unsupported URL Type "workspace:": workspace:*`
whenever that manifest declares a `workspace:` runtime dep (e.g.
`"@openclaw/plugin-sdk": "workspace:*"`). This takes out every plugin
with any runtime deps at gateway startup.

`ensureBundledPluginRuntimeDeps` already filters `workspace:` specs from
the CLI arguments, but npm's own resolver reads the cwd manifest
regardless, so the filter alone is not enough. The existing isolated
execution-root + `replaceNodeModulesDir` machinery handles this exact
problem for source-checkout + cache-hit installs. This change activates
the same staging path for the packaged case: when `installRoot ===
pluginRoot` and we are not in the source-checkout cache path, stage the
install inside `<pluginRoot>/.openclaw-install-stage` (which has a
minimal generated `package.json`) and move the produced `node_modules/`
back to the plugin root as before.

- Add regression test `stages plugin-root install when the plugin's own
  package.json declares workspace:* deps` covering the Docker scenario
  (mixed `workspace:*` + concrete runtime dep, e.g. anthropic-style
  `@openclaw/plugin-sdk` + `@anthropic-ai/sdk`).
- Update existing plugin-root-install expectations (`installs
  plugin-local runtime deps when one is missing`, `skips workspace-only
  runtime deps before npm install`, `installs deps that are only present
  in the package root`, `does not trust runtime deps that only resolve
  from the package root`, `does not treat sibling extension runtime deps
  as satisfying a plugin`) to assert the new `installExecutionRoot`.

Reported in #70844; same root cause as #70701, #70756, #70773, #70818,
#70839 which see the downstream "Cannot find package 'openclaw' from
plugin-runtime-deps" symptom because their
`resolveBundledRuntimeDependencyInstallRoot` resolves to an external
stage dir (clean manifest) so the install succeeds but the resulting
node_modules tree cannot satisfy the filtered-out workspace packages at
ESM import time.

## AI assistance

This PR was AI-assisted with Claude Code.

Testing degree: fully tested for the touched `bundled-runtime-deps`
install staging surface.

- `pnpm exec vitest run --config test/vitest/vitest.plugins.config.ts src/plugins/bundled-runtime-deps.test.ts` (31/31)
- `pnpm exec vitest run --config test/vitest/vitest.plugins.config.ts src/plugins/` (43/43 across 8 files)
- `pnpm exec tsgo --noEmit -p tsconfig.core.json`, `pnpm exec tsgo --noEmit -p tsconfig.core.test.json` (clean)
- `pnpm exec oxlint src/plugins/bundled-runtime-deps.ts src/plugins/bundled-runtime-deps.test.ts` (0 warnings, 0 errors)
- `node scripts/check-src-extension-import-boundary.mjs --json` and `node scripts/check-sdk-package-extension-import-boundary.mjs --json` (both `[]`)

I understand the code path changed here: packaged bundled plugins now
stage their runtime-dep install one directory below `pluginRoot` so npm
never reads the plugin's `workspace:*`-containing manifest during
install; after install completes, the produced `node_modules/` is moved
back to `pluginRoot` via the existing `replaceNodeModulesDir` helper.

Signed-off-by: Simone Macario <simone@sharly.ai>
2026-04-24 03:33:42 +01:00
Peter Steinberger
ef36ca9517 docs(changelog): credit bundled runtime deps fix 2026-04-24 03:31:41 +01:00
Peter Steinberger
c5620ddf9e fix(release): preserve plugin-local runtime deps in postpublish verify 2026-04-24 03:27:35 +01:00
Peter Steinberger
5ab5dc3900 fix(release): harden packed runtime smoke 2026-04-24 02:31:57 +01:00
Peter Steinberger
18c2531d16 chore(release): refresh plugin sdk api baseline 2026-04-24 02:12:38 +01:00
Peter Steinberger
fefc4b3d4e chore(release): refresh config docs baseline 2026-04-24 02:11:08 +01:00
Peter Steinberger
8da230a1b3 chore(release): refresh bundled channel metadata 2026-04-24 02:09:57 +01:00
Peter Steinberger
dc1ce0b2b1 docs(release): clarify beta validation flow 2026-04-24 01:59:49 +01:00
Peter Steinberger
e776922a15 chore(release): prepare 2026.4.23 beta 1 2026-04-24 01:56:25 +01:00
90 changed files with 3088 additions and 598 deletions

View File

@@ -25,15 +25,25 @@ Use this skill for release and publish-time workflow. Keep ordinary development
- Before release branching, commit any dirty files in coherent groups, push,
pull/rebase, then run `/changelog` on `main` and commit/push/pull that
changelog rewrite immediately before creating the release branch.
- Do not delete or rewrite beta tags after they leave the machine. If a
published or pushed beta needs a fix, commit the fix on the release branch and
increment to the next `-beta.N`.
- After the release branch is cut, do not keep rebasing it onto moving `main`.
Treat the branch commit as the release base. If validation finds a concrete
issue, inspect `main` and backport only the low-risk fix commits that directly
address that failure.
- Beta numbers are consumed by npm publication, not by a local tag or canceled
preflight. If a beta tag was pushed but the matching npm package was not
published, confirm `npm view openclaw@YYYY.M.D-beta.N` is missing and cancel
any in-flight preflight/publish workflows before moving or recreating that
beta tag. Once the npm package is published, do not delete or rewrite the
beta tag; commit the fix on the release branch and increment to the next
`-beta.N`.
- For a beta release train, run the full pre-npm test roster before publishing
each beta. After a beta is published, run the smaller published-install roster
focused on install/update/Docker/Parallels. If anything fails, fix it on the
release branch, commit/push/pull, increment beta number, and repeat. Operators
may authorize up to 4 autonomous beta attempts; after 4 failed beta attempts,
stop and report.
focused on install/update plus all Docker and Parallels release checks. If
those published-artifact checks all pass, proceed to the non-beta release only
when the operator asked for the full beta-to-stable train. If anything fails,
fix it on the release branch, commit/push/pull, increment beta number, and
repeat. Operators may authorize up to 4 autonomous beta attempts; after 4
failed published beta attempts, stop and report.
- Use `/changelog` before version/tag preparation so the top changelog section
is deduped and ordered by user impact.
- Do not create beta-specific `CHANGELOG.md` headings. Beta releases use the
@@ -277,6 +287,10 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
coverage or a failure needs local debugging.
- Post-published beta verification roster:
- `node --import tsx scripts/openclaw-npm-postpublish-verify.ts <beta-version>`
- experimental published npm Telegram lane: dispatch
`.github/workflows/npm-telegram-beta-e2e.yml` after the beta is visible on
npm, using `package_spec=openclaw@<beta-version>`; if it fails from workflow
or infrastructure instability, record it as experimental and continue
- install/update smoke against the published beta channel
- Docker install/update coverage that exercises the published beta package
- Parallels published beta install/update coverage with both OpenAI and

View File

@@ -623,6 +623,9 @@ jobs:
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Setup Docker builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
- name: Build and push shared Docker E2E image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:

View File

@@ -2,38 +2,58 @@
Docs: https://docs.openclaw.ai
## Unreleased
## 2026.4.23
### Changes
- Agents/tools: add optional per-call `timeoutMs` support for image, video, music, and TTS generation tools so agents can extend provider request timeouts only when a specific generation needs it.
- Agents/subagents: add optional forked context for native `sessions_spawn` runs so agents can let a child inherit the requester transcript when needed, while keeping clean isolated sessions as the default; includes prompt guidance, context-engine hook metadata, docs, and QA coverage.
- Codex harness: add structured debug logging for embedded harness selection decisions so `/status` stays simple while gateway logs explain auto-selection and Pi fallback reasons. (#70760) Thanks @100yenadmin.
- Dependencies/Pi: update bundled Pi packages to `0.70.0`, use Pi's upstream `gpt-5.5` catalog metadata for OpenAI and OpenAI Codex, and keep only local `gpt-5.5-pro` forward-compat handling.
- Providers/OpenAI: add image generation and reference-image editing through Codex OAuth, so `openai/gpt-image-2` works without an `OPENAI_API_KEY`. Fixes #70703.
- Providers/OpenRouter: add image generation and reference-image editing through `image_generate`, so OpenRouter image models work with `OPENROUTER_API_KEY`. Fixes #55066 via #67668. Thanks @notamicrodose.
- Image generation: let agents request provider-supported quality and output format hints, and pass OpenAI-specific background, moderation, compression, and user hints through the `image_generate` tool. (#70503) Thanks @ottodeng.
- Agents/subagents: add optional forked context for native `sessions_spawn` runs so agents can let a child inherit the requester transcript when needed, while keeping clean isolated sessions as the default; includes prompt guidance, context-engine hook metadata, docs, and QA coverage.
- Agents/tools: add optional per-call `timeoutMs` support for image, video, music, and TTS generation tools so agents can extend provider request timeouts only when a specific generation needs it.
- Memory/local embeddings: add configurable `memorySearch.local.contextSize` with a 4096 default so local embedding contexts can be tuned for constrained hosts without patching the memory host. (#70544) Thanks @aalekh-sarvam.
- Dependencies/Pi: update bundled Pi packages to `0.70.0`, use Pi's upstream `gpt-5.5` catalog metadata for OpenAI and OpenAI Codex, and keep only local `gpt-5.5-pro` forward-compat handling.
- Codex harness: add structured debug logging for embedded harness selection decisions so `/status` stays simple while gateway logs explain auto-selection and Pi fallback reasons. (#70760) Thanks @100yenadmin.
### Fixes
- Codex harness: route native `request_user_input` prompts back to the originating chat, preserve queued follow-up answers, and honor newer app-server command approval amendment decisions.
- Codex harness/context-engine: redact context-engine assembly failures before logging, so fallback warnings do not serialize raw error objects. (#70809) Thanks @jalehman.
- WhatsApp/onboarding: keep first-run setup entry loading off the Baileys runtime dependency path, so packaged QuickStart installs can show WhatsApp setup before runtime deps are staged. Fixes #70932.
- Block streaming: suppress final assembled text after partial block-delivery aborts when the already-sent text chunks exactly cover the final reply, preventing duplicate replies without dropping unrelated short messages. Fixes #70921.
- Codex harness/Windows: resolve npm-installed `codex.cmd` shims through PATHEXT before starting the native app-server, so `codex/*` models work without a manual `.exe` shim. Fixes #70913.
- Slack/groups: classify MPIM group DMs as group chat context and suppress verbose tool/plan progress on Slack non-DM surfaces, so internal "Working…" traces no longer leak into rooms. Fixes #70912.
- Agents/replay: stop OpenAI/Codex transcript replay from synthesizing missing tool results while still preserving synthetic repair on Anthropic, Gemini, and Bedrock transport-owned sessions. (#61556) Thanks @VictorJeon and @vincentkoc.
- Telegram/media replies: parse remote markdown image syntax into outbound media payloads on the final reply path, so Telegram group chats stop falling back to plain-text image URLs when the model or a tool emits `![...](...)` instead of a `MEDIA:` token. (#66191) Thanks @apezam and @vincentkoc.
- Agents/WebChat: surface non-retryable provider failures such as billing, auth, and rate-limit errors from the embedded runner instead of logging `surface_error` and leaving webchat with no rendered error. Fixes #70124. (#70848) Thanks @truffle-dev.
- WhatsApp: unify outbound media normalization across direct sends and auto-replies. Thanks @mcaxtr.
- Memory/CLI: declare the built-in `local` embedding provider in the memory-core manifest, so standalone `openclaw memory status`, `index`, and `search` can resolve local embeddings just like the gateway runtime. Fixes #70836. (#70873) Thanks @mattznojassist.
- Gateway/WebChat: preserve image attachments for text-only primary models by offloading them as media refs instead of dropping them, so configured image tools can still inspect the original file. Fixes #68513, #44276, #51656, #70212.
- Plugins/Google Meet: hang up delegated Twilio calls on leave, clean up Chrome realtime audio bridges when launch fails, and use a flat provider-safe tool schema.
- Media understanding: honor explicit image-model configuration before native-vision skips, including `agents.defaults.imageModel`, `tools.media.image.models`, and provider image defaults such as MiniMax VL when the active chat model is text-only. Fixes #47614, #63722, #69171.
- Codex/media understanding: support `codex/*` image models through bounded Codex app-server image turns, while keeping `openai-codex/*` on the OpenAI Codex OAuth route and validating app-server responses against generated protocol contracts. Fixes #70201.
- Providers/OpenAI Codex: synthesize the `openai-codex/gpt-5.5` OAuth model row when Codex catalog discovery omits it, so cron and subagent runs do not fail with `Unknown model` while the account is authenticated.
- Providers/Google: honor the private-network SSRF opt-in for Gemini image generation requests, so trusted proxy setups that resolve Google API hosts to private addresses can use `image_generate`. Fixes #67216.
- Agents/transport: stop embedded runs from lowering the process-wide undici stream timeouts, so slow Gemini image generation and other long-running provider requests no longer inherit short run-attempt headers timeouts. Fixes #70423. Thanks @giangthb.
- Providers/OpenRouter: send image-understanding prompts as user text before image parts, restoring non-empty vision responses for OpenRouter multimodal models. Fixes #70410.
- Memory/QMD: recreate stale managed QMD collections when startup repair finds the collection name already exists, so root memory narrows back to `MEMORY.md` instead of staying on broad workspace markdown indexing.
- Agents/OpenAI: surface selected-model capacity failures from PI, Codex, and auto-reply harness paths with a model-switch hint instead of the generic empty-response error. Thanks @vincentkoc.
- Models/Codex: preserve Codex provider metadata when adding models from chat or CLI commands, so manually added Codex models keep the right auth and routing behavior. (#70820) Thanks @Takhoffman.
- Providers/OpenAI: route `openai/gpt-image-2` through configured Codex OAuth directly when an `openai-codex` profile is active, instead of probing `OPENAI_API_KEY` first.
- Providers/OpenAI: harden image generation auth routing and Codex OAuth response parsing so fallback only applies to public OpenAI API routes and bounded SSE results. Thanks @Takhoffman.
- OpenAI/image generation: send reference-image edits as guarded multipart uploads instead of JSON data URLs, restoring complex multi-reference `gpt-image-2` edits. Fixes #70642. Thanks @dashhuang.
- Providers/OpenRouter: send image-understanding prompts as user text before image parts, restoring non-empty vision responses for OpenRouter multimodal models. Fixes #70410.
- Providers/Google: honor the private-network SSRF opt-in for Gemini image generation requests, so trusted proxy setups that resolve Google API hosts to private addresses can use `image_generate`. Fixes #67216.
- Agents/transport: stop embedded runs from lowering the process-wide undici stream timeouts, so slow Gemini image generation and other long-running provider requests no longer inherit short run-attempt headers timeouts. Fixes #70423. Thanks @giangthb.
- Providers/OpenAI: honor the private-network SSRF opt-in for OpenAI-compatible image generation endpoints, so trusted LocalAI/LAN `image_generate` routes work without disabling SSRF checks globally. Fixes #62879. Thanks @seitzbg.
- Providers/OpenAI: stop advertising the removed `gpt-5.3-codex-spark` Codex model through fallback catalogs, and suppress stale rows with a GPT-5.5 recovery hint.
- Control UI/chat: persist assistant-generated images as authenticated managed media and accept paired-device tokens for assistant media fetches, so webchat history reloads keep showing generated images. (#70719, #70741) Thanks @Patrick-Erichsen.
- Control UI/chat: queue Stop-button aborts across Gateway reconnects so a disconnected active run is canceled on reconnect instead of only clearing local UI state. (#70673) Thanks @chinar-amrutkar.
- Memory/QMD: recreate stale managed QMD collections when startup repair finds the collection name already exists, so root memory narrows back to `MEMORY.md` instead of staying on broad workspace markdown indexing.
- Agents/OpenAI: surface selected-model capacity failures from PI, Codex, and auto-reply harness paths with a model-switch hint instead of the generic empty-response error. Thanks @vincentkoc.
- Plugins/QR: replace legacy `qrcode-terminal` QR rendering with bounded `qrcode-tui` helpers for plugin login/setup flows. (#65969) Thanks @vincentkoc.
- Voice-call/realtime: wait for OpenAI session configuration before greeting or forwarding buffered audio, and reject non-allowlisted Twilio callers before stream setup. (#43501) Thanks @forrestblount.
- ACPX/Codex: stop materializing `auth.json` bridge files for Codex ACP, Codex app-server, and Codex CLI runs; Codex-owned runtimes now use their normal `CODEX_HOME`/`~/.codex` auth path directly.
- Auto-reply/system events: route async exec-event completion replies through the persisted session delivery context, so long-running command results return to the originating channel instead of being dropped when live origin metadata is missing. (#70258) Thanks @wzfukui.
- OpenAI/image generation: send reference-image edits as guarded multipart uploads instead of JSON data URLs, restoring complex multi-reference `gpt-image-2` edits. Fixes #70642. Thanks @dashhuang.
- Gateway/sessions: extend the webchat session-mutation guard to `sessions.compact` and `sessions.compaction.restore`, so `WEBCHAT_UI` clients are rejected from compaction-side session mutations consistently with the existing patch/delete guards. (#70716) Thanks @drobison00.
- QA channel/security: reject non-HTTP(S) inbound attachment URLs before media fetch, and log rejected schemes so suspicious or misconfigured payloads are visible during debugging. (#70708) Thanks @vincentkoc.
- Plugins/install: link the host OpenClaw package into external plugins that declare `openclaw` as a peer dependency, so peer-only plugin SDK imports resolve after install without bundling a duplicate host package. (#70462) Thanks @anishesg.
- Plugins/Windows: refresh the packaged plugin SDK alias in place during bundled runtime dependency repair, so gateway and CLI plugin startup no longer race on `ENOTEMPTY`/`EPERM` after same-guest npm updates.
- Teams/security: require shared Bot Framework audience tokens to name the configured Teams app via verified `appid` or `azp`, blocking cross-bot token replay on the global audience. (#70724) Thanks @vincentkoc.
- Plugins/startup: resolve bundled plugin Jiti loads relative to the target plugin module instead of the central loader, so Bun global installs no longer hang while discovering bundled image providers. (#70073) Thanks @yidianyiko.
- Anthropic/CLI security: derive Claude CLI `bypassPermissions` from OpenClaw's existing YOLO exec policy, preserve explicit raw Claude `--permission-mode` overrides, and strip malformed permission-mode args instead of silently falling back to a bypass. (#70723) Thanks @vincentkoc.
@@ -43,8 +63,6 @@ Docs: https://docs.openclaw.ai
- Approvals/security: require explicit chat exec-approval enablement instead of auto-enabling approval clients just because approvers resolve from config or owner allowlists. (#70715) Thanks @vincentkoc.
- Discord/security: keep native slash-command channel policy from bypassing configured owner or member restrictions, while preserving channel-policy fallback when no stricter access rule exists. (#70711) Thanks @vincentkoc.
- Android/security: stop `ASK_OPENCLAW` intents from auto-sending injected prompts, so external app actions only prefill the draft instead of dispatching it immediately. (#70714) Thanks @vincentkoc.
- Control UI/chat: persist assistant-generated images as authenticated managed media so webchat history reloads show the image instead of dropping it. (#70719)
- Control UI/chat: queue Stop-button aborts across Gateway reconnects so a disconnected active run is canceled on reconnect instead of only clearing local UI state. (#70673) Thanks @chinar-amrutkar.
- Secrets/Windows: strip UTF-8 BOMs from file-backed secrets and keep unavailable ACL checks fail-closed unless trusted file or exec providers explicitly opt into `allowInsecurePath`. (#70662) Thanks @zhanggpcsu.
- Agents/image generation: escape ignored override values in tool warnings so parsed `MEDIA:` directives cannot be injected through unsupported model options. (#70710) Thanks @vincentkoc.
- QQBot/security: require framework auth for `/bot-approve` so unauthorized QQ senders cannot change exec approval settings through the unauthenticated pre-dispatch slash-command path. (#70706) Thanks @vincentkoc.
@@ -54,7 +72,8 @@ Docs: https://docs.openclaw.ai
- WhatsApp/security: keep contact/vCard/location structured-object free text out of the inline message body and render it through fenced untrusted metadata JSON, limiting hidden prompt-injection payloads in names, phone fields, and location labels/comments.
- Group-chat/security: keep channel-sourced group names and participant labels out of inline group system prompts and render them through fenced untrusted metadata JSON.
- Agents/replay: preserve Kimi-style `functions.<name>:<index>` tool-call IDs during strict replay sanitization so custom OpenAI-compatible Kimi routes keep multi-turn tool use intact. (#70693) Thanks @geri4.
- Plugins/startup: restore bundled plugin `openclaw/plugin-sdk/*` resolution from packaged installs and external runtime-deps stage roots, so Telegram/Discord no longer crash-loop with `Cannot find package 'openclaw'` after missing dependency repair.
- Discord/replies: preserve final reply permission context through outbound delivery so Discord replies keep the same channel/member routing rules at send time.
- Plugins/startup: restore bundled plugin `openclaw/plugin-sdk/*` resolution from packaged installs and external runtime-deps stage roots, so Telegram/Discord no longer crash-loop with `Cannot find package 'openclaw'` after missing dependency repair. (#70852) Thanks @simonemacario.
- CLI/Claude: run the same prompt-build hooks and trigger/channel context on `claude-cli` turns as on direct embedded runs, keeping Claude Code sessions aligned with OpenClaw workspace identity, routing, and hook-driven prompt mutations. (#70625) Thanks @mbelinky.
- Discord/plugin startup: keep subagent hooks lazy behind Discord's channel entry so packaged entry imports stay narrow and report import failures with the channel id and entry path.
- Memory/doctor: keep root durable memory canonicalized on `MEMORY.md`, stop treating lowercase `memory.md` as a runtime fallback, and let `openclaw doctor --fix` merge true split-brain root files into `MEMORY.md` with a backup. (#70621) Thanks @mbelinky.
@@ -62,6 +81,8 @@ Docs: https://docs.openclaw.ai
- Codex harness/status: pin embedded harness selection per session, show active non-PI harness ids such as `codex` in `/status`, and keep legacy transcripts on PI until `/new` or `/reset` so config changes cannot hot-switch existing sessions.
- Gateway/security: fail closed on agent-driven `gateway config.apply`/`config.patch` runtime edits by allowlisting a narrow set of agent-tunable prompt, model, and mention-gating paths (including Telegram topic-level `requireMention`) instead of relying on a hand-maintained denylist of protected subtrees that could miss new sensitive config keys. (#70726) Thanks @drobison00.
- Webhooks/security: re-resolve `SecretRef`-backed webhook route secrets on each request so `openclaw secrets reload` revokes the previous secret immediately instead of waiting for a gateway restart. (#70727) Thanks @drobison00.
- Memory/dreaming: decouple the managed dreaming cron from heartbeat by running it as an isolated lightweight agent turn, so dreaming runs even when heartbeat is disabled for the default agent and is no longer skipped by `heartbeat.activeHours`. `openclaw doctor --fix` migrates stale main-session dreaming jobs in persisted cron configs to the new shape. Fixes #69811, #67397, #68972. (#70737) Thanks @jalehman.
- Agents/CLI: keep `--agent` plus `--session-id` lookup scoped to the requested agent store, so explicit agent resumes cannot select another agent's session. (#70985) Thanks @frankekn.
## 2026.4.22
@@ -258,7 +279,6 @@ Docs: https://docs.openclaw.ai
- Gateway/Control UI: require authenticated Control UI read access before serving `/__openclaw/control-ui-config.json` when `gateway.auth` is enabled, so unauthenticated callers can no longer read bootstrap metadata. (#70247) Thanks @drobison00.
- Gateway/restart: default session-scoped restart sentinels to a one-shot agent continuation, so chat-initiated Gateway restarts acknowledge successful boot automatically. (#70269) Thanks @obviyus.
- Build/npm publish: fail postpublish verification when root `dist/*` files import bundled plugin runtime dependencies without mirroring them in the root package manifest, so Slack-style plugin deps cannot silently ship on the wrong module-resolution path again. (#60112) thanks @medns.
- Gateway/sessions: extend the webchat session-mutation guard to `sessions.compact` and `sessions.compaction.restore`, so `WEBCHAT_UI` clients are rejected from compaction-side session mutations consistently with the existing patch/delete guards. (#70716) Thanks @drobison00.
## 2026.4.21

View File

@@ -1,4 +1,4 @@
6b142e6a8aa513ccd8f9cfbf7e95fa4919fb6fca7aeaa841f57ad9e39e8901a9 config-baseline.json
a4e167f169db58d71c385a31fa2b980772f9fee963e70dd9553f63536cae5aed config-baseline.core.json
22d7cd6d8279146b2d79c9531a55b80b52a2c99c81338c508104729154fdd02d config-baseline.channel.json
276eb2a0554c934b4c57c1734f6f2fafe0fed258b1a0c7e9393aa7081e6291bd config-baseline.json
bf00f7910d8f0d8e12592e8a1c6bd0397f8e62fef2c11eb0cbd3b3a3e2a78ffe config-baseline.core.json
8580cad7a65a9dc04a3e8f98b1e9252992aea2dedff16d5483934e4bc2841d57 config-baseline.channel.json
a91304e3566ecc8906f199b88a2e38eaee86130aad799bf4d62921e2f0ddc1b5 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
1d2767b688414ac41305e88c830858c00947e2d7c713f1a25d86f38cd577620e plugin-sdk-api-baseline.json
e5167477ab6aa2e67bd4361048cf5f6f8fd1cb7ee570544c634d14417f890674 plugin-sdk-api-baseline.jsonl
793ed905cb0ba93b9a2f8c2c85c3cfb4d194dd9263353e74952bf9e382b03dc2 plugin-sdk-api-baseline.json
032e7fd6f48344c9b3b98fd3e877e6d30cab92ed9a39dd309796cf1f0220820f plugin-sdk-api-baseline.jsonl

View File

@@ -975,7 +975,7 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or
- Gateway + dev agent: `pnpm test:docker:live-gateway` (script: `scripts/test-live-gateway-models-docker.sh`)
- Open WebUI live smoke: `pnpm test:docker:openwebui` (script: `scripts/e2e/openwebui-docker.sh`)
- Onboarding wizard (TTY, full scaffolding): `pnpm test:docker:onboard` (script: `scripts/e2e/onboard-docker.sh`)
- Npm tarball onboarding/channel/agent smoke: `pnpm test:docker:npm-onboard-channel-agent` installs the packed OpenClaw tarball globally in Docker, configures OpenAI via env-ref onboarding plus Telegram by default, verifies enabling the plugin installs its runtime deps on demand, runs doctor, and runs one mocked OpenAI agent turn. Reuse a prebuilt tarball with `OPENCLAW_NPM_ONBOARD_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host rebuild with `OPENCLAW_NPM_ONBOARD_HOST_BUILD=0`, or switch channel with `OPENCLAW_NPM_ONBOARD_CHANNEL=discord`.
- Npm tarball onboarding/channel/agent smoke: `pnpm test:docker:npm-onboard-channel-agent` installs the packed OpenClaw tarball globally in Docker, configures OpenAI via env-ref onboarding plus Telegram by default, verifies doctor repairs activated plugin runtime deps, and runs one mocked OpenAI agent turn. Reuse a prebuilt tarball with `OPENCLAW_NPM_ONBOARD_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host rebuild with `OPENCLAW_NPM_ONBOARD_HOST_BUILD=0`, or switch channel with `OPENCLAW_NPM_ONBOARD_CHANNEL=discord`.
- Bun global install smoke: `bash scripts/e2e/bun-global-install-smoke.sh` packs the current tree, installs it with `bun install -g` in an isolated home, and verifies `openclaw infer image providers --json` returns bundled image providers instead of hanging. Reuse a prebuilt tarball with `OPENCLAW_BUN_GLOBAL_SMOKE_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host build with `OPENCLAW_BUN_GLOBAL_SMOKE_HOST_BUILD=0`, or copy `dist/` from a built Docker image with `OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE=openclaw-dockerfile-smoke:local`.
- Installer Docker smoke: `bash scripts/test-install-sh-docker.sh` shares one npm cache across its root, update, and direct-npm containers. Update smoke defaults to npm `latest` as the stable baseline before upgrading to the candidate tarball. Non-root installer checks keep an isolated npm cache so root-owned cache entries do not mask user-local install behavior. Set `OPENCLAW_INSTALL_SMOKE_NPM_CACHE_DIR=/path/to/cache` to reuse the root/update/direct-npm cache across local reruns.
- Install Smoke CI skips the duplicate direct-npm global update with `OPENCLAW_INSTALL_SMOKE_SKIP_NPM_GLOBAL=1`; run the script locally without that env when direct `npm install -g` coverage is needed.

View File

@@ -35,6 +35,12 @@ OpenClaw has three public release lanes:
- Maintainers normally cut releases from a `release/YYYY.M.D` branch created
from current `main`, so release validation and fixes do not block new
development on `main`
- After a release branch is cut, maintainers keep validating that branch instead
of rebasing after every new `main` commit. If validation finds a concrete
release issue, they may inspect `main` and backport only low-risk fixes that
directly address the failure.
- Stable follows a beta only after published-artifact validation passes,
including Docker and Parallels release checks for install/update coverage.
- If a beta tag has been pushed or published and needs a fix, maintainers cut
the next `-beta.N` tag instead of deleting or recreating the old beta tag
- Detailed release procedure, approvals, credentials, and recovery notes are
@@ -88,6 +94,11 @@ OpenClaw has three public release lanes:
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts YYYY.M.D`
(or the matching beta/correction version) to verify the published registry
install path in a fresh temp prefix
- After a beta npm publish, the experimental `NPM Telegram Beta E2E` workflow
(`.github/workflows/npm-telegram-beta-e2e.yml`) can be dispatched with
`package_spec=openclaw@YYYY.M.D-beta.N` after npm sees the package. Treat it
as extra signal; ignore workflow/infrastructure failure unless it exposes a
concrete release bug.
- Maintainer release automation now uses preflight-then-promote:
- real npm publish must pass a successful npm `preflight_run_id`
- the real npm publish must be dispatched from the same `main` or

View File

@@ -27,8 +27,16 @@ export function buildGoogleGeminiCliBackend(): CliBackendPlugin {
bundleMcpMode: "gemini-system-settings",
config: {
command: "gemini",
args: ["--output-format", "json", "--prompt", "{prompt}"],
resumeArgs: ["--resume", "{sessionId}", "--output-format", "json", "--prompt", "{prompt}"],
args: ["--skip-trust", "--output-format", "json", "--prompt", "{prompt}"],
resumeArgs: [
"--skip-trust",
"--resume",
"{sessionId}",
"--output-format",
"json",
"--prompt",
"{prompt}",
],
output: "json",
input: "arg",
imageArg: "@",

View File

@@ -3,8 +3,8 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
exportName: "googlechatPlugin",
specifier: "./setup-plugin-api.js",
exportName: "googlechatSetupPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",

View File

@@ -0,0 +1,3 @@
// Keep bundled setup entry imports narrow so setup loads do not pull the
// broader Google Chat runtime plugin surface.
export { googlechatSetupPlugin } from "./src/channel.setup.js";

View File

@@ -0,0 +1,92 @@
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import {
listGoogleChatAccountIds,
resolveDefaultGoogleChatAccountId,
resolveGoogleChatAccount,
type ResolvedGoogleChatAccount,
} from "./accounts.js";
import { googlechatSetupAdapter } from "./setup-core.js";
import { googlechatSetupWizard } from "./setup-surface.js";
const formatGoogleChatAllowFromEntry = (entry: string) =>
normalizeLowercaseStringOrEmpty(
entry
.trim()
.replace(/^(googlechat|google-chat|gchat):/i, "")
.replace(/^user:/i, "")
.replace(/^users\//i, ""),
);
const googleChatConfigAdapter = createScopedChannelConfigAdapter<ResolvedGoogleChatAccount>({
sectionKey: "googlechat",
listAccountIds: listGoogleChatAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveGoogleChatAccount),
defaultAccountId: resolveDefaultGoogleChatAccountId,
clearBaseFields: [
"serviceAccount",
"serviceAccountFile",
"audienceType",
"audience",
"webhookPath",
"webhookUrl",
"botUser",
"name",
],
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
allowFrom,
normalizeEntry: formatGoogleChatAllowFromEntry,
}),
resolveDefaultTo: (account) => account.config.defaultTo,
});
export const googlechatSetupPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
id: "googlechat",
meta: {
id: "googlechat",
label: "Google Chat",
selectionLabel: "Google Chat (Chat API)",
docsPath: "/channels/googlechat",
docsLabel: "googlechat",
blurb: "Google Workspace Chat app with HTTP webhook.",
aliases: ["gchat", "google-chat"],
order: 55,
detailLabel: "Google Chat",
systemImage: "message.badge",
markdownCapable: true,
},
setup: googlechatSetupAdapter,
setupWizard: googlechatSetupWizard,
capabilities: {
chatTypes: ["direct", "group", "thread"],
reactions: true,
threads: true,
media: true,
nativeCommands: false,
blockStreaming: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.googlechat"] },
config: {
...googleChatConfigAdapter,
isConfigured: (account) => account.credentialSource !== "none",
describeAccount: (account) =>
describeAccountSnapshot({
account,
configured: account.credentialSource !== "none",
extra: {
credentialSource: account.credentialSource,
},
}),
},
};

View File

@@ -3,8 +3,8 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./channel-plugin-api.js",
exportName: "matrixPlugin",
specifier: "./setup-plugin-api.js",
exportName: "matrixSetupPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",

View File

@@ -0,0 +1,3 @@
// Keep bundled setup entry imports narrow so setup loads do not pull the
// broader Matrix runtime plugin surface.
export { matrixSetupPlugin } from "./src/channel.setup.js";

View File

@@ -0,0 +1,49 @@
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { matrixConfigAdapter } from "./config-adapter.js";
import { MatrixConfigSchema } from "./config-schema.js";
import { resolveMatrixAccount, type ResolvedMatrixAccount } from "./matrix/accounts.js";
import { createMatrixSetupWizardProxy, matrixSetupAdapter } from "./setup-core.js";
const matrixSetupWizard = createMatrixSetupWizardProxy(async () => ({
matrixSetupWizard: (await import("./setup-surface.js")).matrixSetupWizard,
}));
export const matrixSetupPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
id: "matrix",
meta: {
id: "matrix",
label: "Matrix",
selectionLabel: "Matrix (plugin)",
docsPath: "/channels/matrix",
docsLabel: "matrix",
blurb: "open protocol; configure a homeserver + access token.",
order: 70,
quickstartAllowFrom: true,
},
setupWizard: matrixSetupWizard,
setup: matrixSetupAdapter,
capabilities: {
chatTypes: ["direct", "group", "thread"],
polls: true,
reactions: true,
threads: true,
media: true,
},
reload: { configPrefixes: ["channels.matrix"] },
configSchema: buildChannelConfigSchema(MatrixConfigSchema),
config: {
...matrixConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account) =>
describeAccountSnapshot({
account,
configured: account.configured,
extra: {
baseUrl: account.homeserver,
},
}),
hasConfiguredState: ({ cfg }) => resolveMatrixAccount({ cfg }).configured,
},
};

View File

@@ -3,7 +3,7 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
exportName: "nostrPlugin",
specifier: "./setup-plugin-api.js",
exportName: "nostrSetupPlugin",
},
});

View File

@@ -0,0 +1,3 @@
// Keep bundled setup entry imports narrow so setup loads do not pull the
// broader Nostr runtime plugin surface.
export { nostrSetupPlugin } from "./src/channel.setup.js";

View File

@@ -0,0 +1,231 @@
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { patchTopLevelChannelConfigSection } from "openclaw/plugin-sdk/setup";
import {
createDelegatedSetupWizardProxy,
createStandardChannelSetupStatus,
DEFAULT_ACCOUNT_ID,
type ChannelSetupAdapter,
} from "openclaw/plugin-sdk/setup-runtime";
import { buildChannelConfigSchema, type ChannelPlugin } from "./channel-api.js";
import { NostrConfigSchema } from "./config-schema.js";
import { DEFAULT_RELAYS } from "./default-relays.js";
const channel = "nostr" as const;
type NostrAccountConfig = {
enabled?: boolean;
name?: string;
defaultAccount?: string;
privateKey?: unknown;
relays?: string[];
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
profile?: unknown;
};
type ResolvedNostrSetupAccount = {
accountId: string;
name?: string;
enabled: boolean;
configured: boolean;
privateKey: string;
publicKey: string;
relays: string[];
profile?: unknown;
config: NostrAccountConfig;
};
function getNostrConfig(cfg: OpenClawConfig): NostrAccountConfig | undefined {
return (cfg.channels as Record<string, unknown> | undefined)?.nostr as
| NostrAccountConfig
| undefined;
}
function listSetupNostrAccountIds(cfg: OpenClawConfig): string[] {
const nostrCfg = getNostrConfig(cfg);
const privateKey = typeof nostrCfg?.privateKey === "string" ? nostrCfg.privateKey.trim() : "";
if (!privateKey) {
return [];
}
return [resolveDefaultSetupNostrAccountId(cfg)];
}
function resolveDefaultSetupNostrAccountId(cfg: OpenClawConfig): string {
const configured = getNostrConfig(cfg)?.defaultAccount;
return typeof configured === "string" && configured.trim()
? configured.trim()
: DEFAULT_ACCOUNT_ID;
}
function resolveSetupNostrAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedNostrSetupAccount {
const nostrCfg = getNostrConfig(params.cfg);
const accountId = params.accountId?.trim() || resolveDefaultSetupNostrAccountId(params.cfg);
const privateKey = typeof nostrCfg?.privateKey === "string" ? nostrCfg.privateKey.trim() : "";
const configured = Boolean(privateKey);
return {
accountId,
name: typeof nostrCfg?.name === "string" ? nostrCfg.name : undefined,
enabled: nostrCfg?.enabled !== false,
configured,
privateKey,
publicKey: "",
relays: nostrCfg?.relays ?? DEFAULT_RELAYS,
profile: nostrCfg?.profile,
config: {
enabled: nostrCfg?.enabled,
name: nostrCfg?.name,
privateKey: nostrCfg?.privateKey,
relays: nostrCfg?.relays,
dmPolicy: nostrCfg?.dmPolicy,
allowFrom: nostrCfg?.allowFrom,
profile: nostrCfg?.profile,
},
};
}
function buildNostrSetupPatch(accountId: string, patch: Record<string, unknown>) {
return {
...(accountId !== DEFAULT_ACCOUNT_ID ? { defaultAccount: accountId } : {}),
...patch,
};
}
function parseRelayUrls(raw: string): { relays: string[]; error?: string } {
const entries = raw
.split(/[,\n]/)
.map((entry) => entry.trim())
.filter(Boolean);
const relays: string[] = [];
for (const entry of entries) {
try {
const parsed = new URL(entry);
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
return { relays: [], error: `Relay must use ws:// or wss:// (${entry})` };
}
} catch {
return { relays: [], error: `Invalid relay URL: ${entry}` };
}
relays.push(entry);
}
return { relays: [...new Set(relays)] };
}
function looksLikeNostrPrivateKey(privateKey: string): boolean {
return privateKey.startsWith("nsec1") || /^[0-9a-fA-F]{64}$/.test(privateKey);
}
const nostrSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ cfg, accountId }) =>
accountId?.trim() || resolveDefaultSetupNostrAccountId(cfg),
applyAccountName: ({ cfg, accountId, name }) =>
patchTopLevelChannelConfigSection({
cfg,
channel,
patch: buildNostrSetupPatch(accountId, name?.trim() ? { name: name.trim() } : {}),
}),
validateInput: ({ input }) => {
const typedInput = input as {
useEnv?: boolean;
privateKey?: string;
relayUrls?: string;
};
if (!typedInput.useEnv) {
const privateKey = typedInput.privateKey?.trim();
if (!privateKey) {
return "Nostr requires --private-key or --use-env.";
}
if (!looksLikeNostrPrivateKey(privateKey)) {
return "Nostr private key must be valid nsec or 64-character hex.";
}
}
if (typedInput.relayUrls?.trim()) {
return parseRelayUrls(typedInput.relayUrls).error ?? null;
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const typedInput = input as {
useEnv?: boolean;
privateKey?: string;
relayUrls?: string;
};
const relayResult = typedInput.relayUrls?.trim()
? parseRelayUrls(typedInput.relayUrls)
: { relays: [] };
return patchTopLevelChannelConfigSection({
cfg,
channel,
enabled: true,
clearFields: typedInput.useEnv ? ["privateKey"] : undefined,
patch: buildNostrSetupPatch(accountId, {
...(typedInput.useEnv ? {} : { privateKey: typedInput.privateKey?.trim() }),
...(relayResult.relays.length > 0 ? { relays: relayResult.relays } : {}),
}),
});
},
};
const nostrSetupWizard = createDelegatedSetupWizardProxy({
channel,
loadWizard: async () => (await import("./setup-surface.js")).nostrSetupWizard,
status: {
...createStandardChannelSetupStatus({
channelLabel: "Nostr",
configuredLabel: "configured",
unconfiguredLabel: "needs private key",
configuredHint: "configured",
unconfiguredHint: "needs private key",
configuredScore: 1,
unconfiguredScore: 0,
includeStatusLine: true,
resolveConfigured: ({ cfg, accountId }) =>
resolveSetupNostrAccount({ cfg, accountId }).configured,
resolveExtraStatusLines: ({ cfg }) => {
const account = resolveSetupNostrAccount({ cfg });
return [`Relays: ${account.relays.length || DEFAULT_RELAYS.length}`];
},
}),
},
resolveShouldPromptAccountIds: () => false,
delegatePrepare: true,
delegateFinalize: true,
});
export const nostrSetupPlugin: ChannelPlugin<ResolvedNostrSetupAccount> = {
id: channel,
meta: {
id: channel,
label: "Nostr",
selectionLabel: "Nostr",
docsPath: "/channels/nostr",
docsLabel: "nostr",
blurb: "Decentralized DMs via Nostr relays (NIP-04)",
order: 100,
},
capabilities: {
chatTypes: ["direct"],
media: false,
},
reload: { configPrefixes: ["channels.nostr"] },
configSchema: buildChannelConfigSchema(NostrConfigSchema),
setup: nostrSetupAdapter,
setupWizard: nostrSetupWizard,
config: {
listAccountIds: listSetupNostrAccountIds,
resolveAccount: (cfg, accountId) => resolveSetupNostrAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultSetupNostrAccountId,
isConfigured: (account) => account.configured,
describeAccount: (account) =>
describeAccountSnapshot({
account,
configured: account.configured,
extra: {
publicKey: account.publicKey,
},
}),
},
};

View File

@@ -3,7 +3,7 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
specifier: "./setup-plugin-api.js",
exportName: "qqbotSetupPlugin",
},
});

View File

@@ -0,0 +1,3 @@
// Keep bundled setup entry imports narrow so setup loads do not pull the
// broader QQ Bot runtime plugin surface.
export { qqbotSetupPlugin } from "./src/channel.setup.js";

View File

@@ -1,12 +1,79 @@
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { type ResolvedSlackAccount } from "./accounts.js";
import {
listSlackAccountIds,
resolveDefaultSlackAccountId,
resolveSlackAccount,
} from "./accounts.js";
import { type ChannelPlugin } from "./channel-api.js";
import { slackSetupAdapter } from "./setup-core.js";
import { slackSetupWizard } from "./setup-surface.js";
import { createSlackPluginBase } from "./shared.js";
import { SlackChannelConfigSchema } from "./config-schema.js";
import { slackSetupAdapter, createSlackSetupWizardProxy } from "./setup-core.js";
import {
describeSlackSetupAccount,
isSlackSetupAccountConfigured,
SLACK_CHANNEL,
} from "./setup-shared.js";
const slackSetupWizard = createSlackSetupWizardProxy(async () => ({
slackSetupWizard: (await import("./setup-surface.js")).slackSetupWizard,
}));
const slackSetupConfigAdapter = createScopedChannelConfigAdapter<ResolvedSlackAccount>({
sectionKey: SLACK_CHANNEL,
listAccountIds: listSlackAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveSlackAccount),
defaultAccountId: resolveDefaultSlackAccountId,
clearBaseFields: ["botToken", "appToken", "name"],
resolveAllowFrom: (account) => account.dm?.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
resolveDefaultTo: (account) => account.config.defaultTo,
});
export const slackSetupPlugin: ChannelPlugin<ResolvedSlackAccount> = {
...createSlackPluginBase({
setupWizard: slackSetupWizard,
setup: slackSetupAdapter,
}),
id: SLACK_CHANNEL,
meta: {
id: SLACK_CHANNEL,
label: "Slack",
selectionLabel: "Slack (Socket Mode)",
detailLabel: "Slack Bot",
docsPath: "/channels/slack",
docsLabel: "slack",
blurb: "supported (Socket Mode).",
systemImage: "number",
markdownCapable: true,
preferSessionLookupForAnnounceTarget: true,
},
setupWizard: slackSetupWizard,
capabilities: {
chatTypes: ["direct", "channel", "thread"],
reactions: true,
threads: true,
media: true,
nativeCommands: true,
},
commands: {
nativeCommandsAutoEnabled: false,
nativeSkillsAutoEnabled: false,
resolveNativeCommandName: ({ commandKey, defaultName }) =>
commandKey === "status" ? "agentstatus" : defaultName,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.slack"] },
configSchema: SlackChannelConfigSchema,
config: {
...slackSetupConfigAdapter,
hasConfiguredState: ({ env }) =>
["SLACK_APP_TOKEN", "SLACK_BOT_TOKEN", "SLACK_USER_TOKEN"].some(
(key) => typeof env?.[key] === "string" && env[key]?.trim().length > 0,
),
isConfigured: (account) => isSlackSetupAccountConfigured(account),
describeAccount: (account) => describeSlackSetupAccount(account),
},
setup: slackSetupAdapter,
};

View File

@@ -24,10 +24,10 @@ import { inspectSlackAccount } from "./account-inspect.js";
import { resolveSlackAccount } from "./accounts.js";
import {
buildSlackSetupLines,
SLACK_CHANNEL as channel,
isSlackSetupAccountConfigured,
SLACK_CHANNEL as channel,
setSlackChannelAllowlist,
} from "./shared.js";
} from "./setup-shared.js";
function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawConfig {
return patchChannelConfigForAccount({

View File

@@ -0,0 +1,131 @@
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
import { patchChannelConfigForAccount } from "openclaw/plugin-sdk/setup-runtime";
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import type { ResolvedSlackAccount } from "./accounts.js";
import type { OpenClawConfig } from "./channel-api.js";
export const SLACK_CHANNEL = "slack" as const;
function buildSlackManifest(botName: string) {
const safeName = botName.trim() || "OpenClaw";
const manifest = {
display_information: {
name: safeName,
description: `${safeName} connector for OpenClaw`,
},
features: {
bot_user: {
display_name: safeName,
always_online: true,
},
app_home: {
messages_tab_enabled: true,
messages_tab_read_only_enabled: false,
},
slash_commands: [
{
command: "/openclaw",
description: "Send a message to OpenClaw",
should_escape: false,
},
],
},
oauth_config: {
scopes: {
bot: [
"app_mentions:read",
"assistant:write",
"channels:history",
"channels:read",
"chat:write",
"commands",
"emoji:read",
"files:read",
"files:write",
"groups:history",
"groups:read",
"im:history",
"im:read",
"im:write",
"mpim:history",
"mpim:read",
"mpim:write",
"pins:read",
"pins:write",
"reactions:read",
"reactions:write",
"users:read",
],
},
},
settings: {
socket_mode_enabled: true,
event_subscriptions: {
bot_events: [
"app_mention",
"channel_rename",
"member_joined_channel",
"member_left_channel",
"message.channels",
"message.groups",
"message.im",
"message.mpim",
"pin_added",
"pin_removed",
"reaction_added",
"reaction_removed",
],
},
},
};
return JSON.stringify(manifest, null, 2);
}
export function buildSlackSetupLines(botName = "OpenClaw"): string[] {
return [
"1) Slack API -> Create App -> From scratch or From manifest (with the JSON below)",
"2) Add Socket Mode + enable it to get the app-level token (xapp-...)",
"3) Install App to workspace to get the xoxb- bot token",
"4) Enable Event Subscriptions (socket) for message events",
"5) App Home -> enable the Messages tab for DMs",
"Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.",
`Docs: ${formatDocsLink("/slack", "slack")}`,
"",
"Manifest (JSON):",
buildSlackManifest(botName),
];
}
export function setSlackChannelAllowlist(
cfg: OpenClawConfig,
accountId: string,
channelKeys: string[],
): OpenClawConfig {
const channels = Object.fromEntries(channelKeys.map((key) => [key, { enabled: true }]));
return patchChannelConfigForAccount({
cfg,
channel: SLACK_CHANNEL,
accountId,
patch: { channels },
});
}
export function isSlackSetupAccountConfigured(account: ResolvedSlackAccount): boolean {
const hasConfiguredBotToken =
Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken);
const hasConfiguredAppToken =
Boolean(account.appToken?.trim()) || hasConfiguredSecretInput(account.config.appToken);
return hasConfiguredBotToken && hasConfiguredAppToken;
}
export function describeSlackSetupAccount(account: ResolvedSlackAccount) {
return describeAccountSnapshot({
account,
configured: isSlackSetupAccountConfigured(account),
extra: {
botTokenSource: account.botTokenSource,
appTokenSource: account.appTokenSource,
},
});
}

View File

@@ -207,7 +207,7 @@ describe("telegram inbound media", () => {
},
},
assert: (payload: Record<string, unknown>) => {
expect(payload.Body).toContain("Eiffel Tower");
expect(payload.Body).toContain("48.858844");
expect(payload.LocationName).toBe("Eiffel Tower");
expect(payload.LocationAddress).toBe("Champ de Mars, Paris");
expect(payload.LocationSource).toBe("place");

View File

@@ -0,0 +1,20 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@whiskeysockets/baileys", () => {
throw new Error("setup plugin load must not load Baileys");
});
describe("whatsapp setup entry", () => {
it("loads the setup plugin without installing or importing runtime dependencies", async () => {
const { default: setupEntry } = await import("./setup-entry.js");
expect(setupEntry.kind).toBe("bundled-channel-setup-entry");
expect(setupEntry.loadSetupPlugin({ installRuntimeDeps: false }).id).toBe("whatsapp");
});
it("loads the delegated setup wizard without importing runtime dependencies", async () => {
const { whatsappSetupWizard } = await import("./src/setup-surface.js");
expect(whatsappSetupWizard.channel).toBe("whatsapp");
});
});

View File

@@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveWebCredsPath } from "./creds-files.js";
import { BufferJSON } from "./session.runtime.js";
const CREDS_FILE_MODE = 0o600;
const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000;
@@ -11,6 +10,11 @@ const credsSaveQueues = new Map<string, Promise<void>>();
export type CredsQueueWaitResult = "drained" | "timed_out";
async function stringifyCreds(creds: unknown): Promise<string> {
const { BufferJSON } = await import("./session.runtime.js");
return JSON.stringify(creds, BufferJSON.replacer);
}
async function syncDirectory(dirPath: string): Promise<void> {
let handle: Awaited<ReturnType<typeof fs.open>> | undefined;
try {
@@ -28,7 +32,7 @@ async function syncDirectory(dirPath: string): Promise<void> {
export async function writeCredsJsonAtomically(authDir: string, creds: unknown): Promise<void> {
const credsPath = resolveWebCredsPath(authDir);
const tempPath = path.join(authDir, `.creds.${process.pid}.${randomUUID()}.tmp`);
const json = JSON.stringify(creds, BufferJSON.replacer);
const json = await stringifyCreds(creds);
let handle: Awaited<ReturnType<typeof fs.open>> | undefined;
try {

View File

@@ -15,7 +15,6 @@ import {
resolveWhatsAppAccount,
resolveWhatsAppAuthDir,
} from "./accounts.js";
import { loginWeb } from "./login.js";
import { whatsappSetupAdapter } from "./setup-core.js";
type SetupPrompter = Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"];
@@ -424,6 +423,7 @@ export async function finalizeWhatsAppSetup(params: {
});
if (wantsLink) {
try {
const { loginWeb } = await import("./login.js");
await loginWeb(false, undefined, params.runtime, accountId);
} catch (error) {
params.runtime.error(`WhatsApp login failed: ${String(error)}`);

View File

@@ -94,8 +94,12 @@ export async function loadWhatsAppChannelRuntime() {
return await import("./channel.runtime.js");
}
async function loadWhatsAppSetupSurface() {
return await import("./setup-surface.js");
}
export const whatsappSetupWizardProxy = createWhatsAppSetupWizardProxy(
async () => (await loadWhatsAppChannelRuntime()).whatsappSetupWizard,
async () => (await loadWhatsAppSetupSurface()).whatsappSetupWizard,
);
const whatsappConfigAdapter = createScopedChannelConfigAdapter<ResolvedWhatsAppAccount>({

View File

@@ -14,6 +14,7 @@ RUN_UPDATE_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO:-1}"
RUN_ROOT_OWNED_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO:-1}"
RUN_SETUP_ENTRY_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO:-1}"
RUN_LOAD_FAILURE_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO:-1}"
CHANNEL_ONLY="${OPENCLAW_BUNDLED_CHANNEL_ONLY:-}"
docker_e2e_build_or_reuse "$IMAGE_NAME" bundled-channel-deps "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "$DOCKER_TARGET"
@@ -78,12 +79,38 @@ CHANNEL="${OPENCLAW_CHANNEL_UNDER_TEST:?missing OPENCLAW_CHANNEL_UNDER_TEST}"
DEP_SENTINEL="${OPENCLAW_DEP_SENTINEL:?missing OPENCLAW_DEP_SENTINEL}"
gateway_pid=""
cleanup() {
terminate_gateways() {
if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then
kill "$gateway_pid" 2>/dev/null || true
fi
if command -v pkill >/dev/null 2>&1; then
pkill -TERM -f "[o]penclaw-gateway" 2>/dev/null || true
fi
for _ in $(seq 1 100); do
local alive=0
if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then
alive=1
fi
if command -v pgrep >/dev/null 2>&1 && pgrep -f "[o]penclaw-gateway" >/dev/null 2>&1; then
alive=1
fi
[ "$alive" = "0" ] && break
sleep 0.1
done
if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then
kill -KILL "$gateway_pid" 2>/dev/null || true
fi
if command -v pkill >/dev/null 2>&1; then
pkill -KILL -f "[o]penclaw-gateway" 2>/dev/null || true
fi
if [ -n "${gateway_pid:-}" ]; then
wait "$gateway_pid" 2>/dev/null || true
fi
}
cleanup() {
terminate_gateways
}
trap cleanup EXIT
echo "Installing mounted OpenClaw package..."
@@ -225,8 +252,14 @@ NODE
start_gateway() {
local log_file="$1"
local skip_sidecars="${2:-0}"
: >"$log_file"
openclaw gateway --port "$PORT" --bind loopback --allow-unconfigured >"$log_file" 2>&1 &
if [ "$skip_sidecars" = "1" ]; then
OPENCLAW_SKIP_CHANNELS=1 OPENCLAW_SKIP_PROVIDERS=1 \
openclaw gateway --port "$PORT" --bind loopback --allow-unconfigured >"$log_file" 2>&1 &
else
openclaw gateway --port "$PORT" --bind loopback --allow-unconfigured >"$log_file" 2>&1 &
fi
gateway_pid="$!"
for _ in $(seq 1 240); do
@@ -247,14 +280,12 @@ start_gateway() {
}
stop_gateway() {
if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then
kill "$gateway_pid" 2>/dev/null || true
wait "$gateway_pid" 2>/dev/null || true
fi
terminate_gateways
gateway_pid=""
}
wait_for_gateway_health() {
local log_file="${1:-}"
for _ in $(seq 1 120); do
if openclaw gateway health --url "ws://127.0.0.1:$PORT" --token "$TOKEN" --json >/dev/null 2>&1; then
return 0
@@ -262,6 +293,9 @@ wait_for_gateway_health() {
sleep 0.25
done
echo "timed out waiting for gateway health" >&2
if [ -n "$log_file" ]; then
cat "$log_file" >&2
fi
return 1
}
@@ -272,12 +306,26 @@ assert_channel_status() {
return 0
fi
local out="/tmp/openclaw-channel-status-$channel.json"
openclaw gateway call channels.status \
--url "ws://127.0.0.1:$PORT" \
--token "$TOKEN" \
--timeout 30000 \
--json \
--params '{"probe":false}' >"$out"
local err="/tmp/openclaw-channel-status-$channel.err"
for _ in $(seq 1 12); do
if openclaw gateway call channels.status \
--url "ws://127.0.0.1:$PORT" \
--token "$TOKEN" \
--timeout 10000 \
--json \
--params '{"probe":false}' >"$out" 2>"$err"; then
break
fi
sleep 2
done
if [ ! -s "$out" ]; then
if grep -Eq "\\[gateway\\] ready \\(.*\\b$channel\\b" /tmp/openclaw-"$channel"-*.log 2>/dev/null; then
echo "$channel channel plugin visible in gateway ready log"
return 0
fi
cat "$err" >&2 || true
return 1
fi
node - <<'NODE' "$out" "$channel"
const fs = require("node:fs");
const raw = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
@@ -349,26 +397,38 @@ assert_no_dep_sentinel() {
fi
}
assert_no_install_stage() {
local channel="$1"
local stage="$package_root/dist/extensions/$channel/.openclaw-install-stage"
if [ -e "$stage" ]; then
echo "install stage should be cleaned after activation for $channel" >&2
find "$stage" -maxdepth 4 -type f | sort | head -80 >&2 || true
exit 1
fi
}
echo "Starting baseline gateway with OpenAI configured..."
write_config baseline
start_gateway "/tmp/openclaw-$CHANNEL-baseline.log"
wait_for_gateway_health
start_gateway "/tmp/openclaw-$CHANNEL-baseline.log" 1
wait_for_gateway_health "/tmp/openclaw-$CHANNEL-baseline.log"
stop_gateway
assert_no_dep_sentinel "$CHANNEL" "$DEP_SENTINEL"
echo "Enabling $CHANNEL by config edit, then restarting gateway..."
write_config "$CHANNEL"
start_gateway "/tmp/openclaw-$CHANNEL-first.log"
wait_for_gateway_health
wait_for_gateway_health "/tmp/openclaw-$CHANNEL-first.log"
assert_installed_once "/tmp/openclaw-$CHANNEL-first.log" "$CHANNEL" "$DEP_SENTINEL"
assert_dep_sentinel "$CHANNEL" "$DEP_SENTINEL"
assert_no_install_stage "$CHANNEL"
assert_channel_status "$CHANNEL"
stop_gateway
echo "Restarting gateway again; $CHANNEL deps must stay installed..."
start_gateway "/tmp/openclaw-$CHANNEL-second.log"
wait_for_gateway_health
wait_for_gateway_health "/tmp/openclaw-$CHANNEL-second.log"
assert_not_installed "/tmp/openclaw-$CHANNEL-second.log" "$CHANNEL"
assert_no_install_stage "$CHANNEL"
assert_channel_status "$CHANNEL"
stop_gateway
@@ -484,7 +544,8 @@ start_gateway() {
OPENCLAW_NO_ONBOARD=1 \
OPENCLAW_PLUGIN_STAGE_DIR="$OPENCLAW_PLUGIN_STAGE_DIR" \
npm_config_cache=/tmp/openclaw-root-owned-npm-cache \
openclaw gateway --port "$PORT" --bind loopback --allow-unconfigured >"$log_file" 2>&1 &
bash -c 'openclaw gateway --port "$1" --bind loopback --allow-unconfigured >"$2" 2>&1' \
bash "$PORT" "$log_file" &
gateway_pid="$!"
for _ in $(seq 1 240); do
@@ -504,44 +565,20 @@ start_gateway() {
exit 1
}
wait_for_gateway_health() {
for _ in $(seq 1 120); do
if runuser -u appuser -- env HOME=/home/appuser openclaw gateway health --url "ws://127.0.0.1:$PORT" --token "$TOKEN" --json >/dev/null 2>&1; then
wait_for_slack_provider_start() {
for _ in $(seq 1 180); do
if grep -Eq "\\[slack\\] \\[default\\] starting provider|An API error occurred: invalid_auth|\\[plugins\\] slack installed bundled runtime deps|\\[gateway\\] ready \\(.*\\bslack\\b" /tmp/openclaw-root-owned-gateway.log; then
return 0
fi
sleep 0.25
sleep 1
done
echo "timed out waiting for gateway health" >&2
return 1
}
assert_channel_status() {
local out="/tmp/openclaw-root-owned-channel-status.json"
runuser -u appuser -- env HOME=/home/appuser openclaw gateway call channels.status \
--url "ws://127.0.0.1:$PORT" \
--token "$TOKEN" \
--timeout 30000 \
--json \
--params '{"probe":false}' >"$out"
if ! node - <<'NODE' "$out" "$CHANNEL"
const fs = require("node:fs");
const raw = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const payload = raw.result ?? raw.data ?? raw;
const channel = process.argv[3];
if (!payload.channels || !payload.channels[channel]) {
throw new Error(`missing channels.${channel}\n${JSON.stringify(raw, null, 2).slice(0, 4000)}`);
}
console.log(`${channel} channel plugin visible`);
NODE
then
cat /tmp/openclaw-root-owned-gateway.log >&2
exit 1
fi
echo "timed out waiting for slack provider startup" >&2
cat /tmp/openclaw-root-owned-gateway.log >&2
exit 1
}
start_gateway /tmp/openclaw-root-owned-gateway.log
wait_for_gateway_health
assert_channel_status
wait_for_slack_provider_start
if [ -e "$root/dist/extensions/$CHANNEL/node_modules/$DEP_SENTINEL/package.json" ]; then
echo "root-owned package tree was mutated" >&2
@@ -554,6 +591,22 @@ if ! find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/node_modules/$DEP_S
cat /tmp/openclaw-root-owned-gateway.log >&2
exit 1
fi
if [ -e "$root/dist/extensions/node_modules/openclaw/package.json" ]; then
echo "root-owned package tree was mutated with SDK alias" >&2
find "$root/dist/extensions/node_modules/openclaw" -maxdepth 4 -type f | sort | head -80 >&2 || true
exit 1
fi
if ! find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/dist/extensions/node_modules/openclaw/package.json" -type f | grep -q .; then
echo "missing external staged openclaw/plugin-sdk alias" >&2
find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -type f | sort | head -120 >&2 || true
cat /tmp/openclaw-root-owned-gateway.log >&2
exit 1
fi
if grep -Eq "failed to install bundled runtime deps|Cannot find package 'openclaw'|Cannot find module 'openclaw/plugin-sdk'" /tmp/openclaw-root-owned-gateway.log; then
echo "root-owned gateway hit bundled runtime dependency errors" >&2
cat /tmp/openclaw-root-owned-gateway.log >&2
exit 1
fi
echo "root-owned global install Docker E2E passed"
EOF
@@ -585,8 +638,10 @@ export OPENCLAW_NO_ONBOARD=1
export OPENCLAW_PLUGIN_STAGE_DIR="$HOME/.openclaw/plugin-runtime-deps"
mkdir -p "$OPENCLAW_PLUGIN_STAGE_DIR"
CHANNEL="feishu"
DEP_SENTINEL="@larksuiteoapi/node-sdk"
declare -A SETUP_ENTRY_DEP_SENTINELS=(
[feishu]="@larksuiteoapi/node-sdk"
[whatsapp]="@whiskeysockets/baileys"
)
package_root() {
printf "%s/openclaw" "$(npm root -g)"
@@ -597,18 +652,21 @@ package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TG
npm install -g "$package_tgz" --no-fund --no-audit >/tmp/openclaw-setup-entry-install.log 2>&1
root="$(package_root)"
test -d "$root/dist/extensions/$CHANNEL"
if [ -d "$root/dist/extensions/$CHANNEL/node_modules" ]; then
echo "$CHANNEL runtime deps should not be preinstalled in package" >&2
find "$root/dist/extensions/$CHANNEL/node_modules" -maxdepth 3 -type f | head -40 >&2 || true
exit 1
fi
if [ -f "$root/node_modules/$DEP_SENTINEL/package.json" ]; then
echo "$DEP_SENTINEL should not be installed at package root before setup-entry load" >&2
exit 1
fi
for channel in "${!SETUP_ENTRY_DEP_SENTINELS[@]}"; do
dep_sentinel="${SETUP_ENTRY_DEP_SENTINELS[$channel]}"
test -d "$root/dist/extensions/$channel"
if [ -d "$root/dist/extensions/$channel/node_modules" ]; then
echo "$channel runtime deps should not be preinstalled in package" >&2
find "$root/dist/extensions/$channel/node_modules" -maxdepth 3 -type f | head -40 >&2 || true
exit 1
fi
if [ -f "$root/node_modules/$dep_sentinel/package.json" ]; then
echo "$dep_sentinel should not be installed at package root before setup-entry load" >&2
exit 1
fi
done
echo "Probing real Feishu bundled setup entry before channel configuration..."
echo "Probing real bundled setup entries before channel configuration..."
(
cd "$root"
node --input-type=module - <<'NODE'
@@ -633,22 +691,129 @@ const setupPluginLoader = Object.values(bundled).find(
if (!setupPluginLoader) {
throw new Error("missing packaged getBundledChannelSetupPlugin export");
}
const plugin = setupPluginLoader("feishu");
console.log(plugin ? "Feishu setup plugin loaded pre-config" : "Feishu setup plugin deferred pre-config");
for (const channel of ["feishu", "whatsapp"]) {
const plugin = setupPluginLoader(channel);
if (!plugin) {
throw new Error(`${channel} setup plugin did not load pre-config`);
}
if (plugin.id !== channel) {
throw new Error(`${channel} setup plugin id mismatch: ${plugin.id}`);
}
console.log(`${channel} setup plugin loaded pre-config`);
}
NODE
)
if [ -e "$root/dist/extensions/$CHANNEL/node_modules/$DEP_SENTINEL/package.json" ]; then
echo "setup-entry discovery installed deps into bundled plugin tree before channel configuration" >&2
for channel in "${!SETUP_ENTRY_DEP_SENTINELS[@]}"; do
dep_sentinel="${SETUP_ENTRY_DEP_SENTINELS[$channel]}"
if [ -e "$root/dist/extensions/$channel/node_modules/$dep_sentinel/package.json" ]; then
echo "setup-entry discovery installed $channel deps into bundled plugin tree before channel configuration" >&2
exit 1
fi
if find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/node_modules/$dep_sentinel/package.json" -type f | grep -q .; then
echo "setup-entry discovery installed $channel external staged deps before channel configuration" >&2
find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -type f | sort | head -160 >&2 || true
exit 1
fi
done
echo "Running packaged guided WhatsApp setup; runtime deps should be staged before finalize..."
OPENCLAW_PACKAGE_ROOT="$root" node --input-type=module - <<'NODE'
import path from "node:path";
import { readdir } from "node:fs/promises";
import { pathToFileURL } from "node:url";
const root = process.env.OPENCLAW_PACKAGE_ROOT;
if (!root) {
throw new Error("missing OPENCLAW_PACKAGE_ROOT");
}
const distDir = path.join(root, "dist");
const onboardChannelFiles = (await readdir(distDir))
.filter((entry) => /^onboard-channels-.*\.js$/.test(entry))
.sort();
let setupChannels;
for (const entry of onboardChannelFiles) {
const module = await import(pathToFileURL(path.join(distDir, entry)));
if (typeof module.setupChannels === "function") {
setupChannels = module.setupChannels;
break;
}
}
if (!setupChannels) {
throw new Error(
`could not find packaged setupChannels export in ${JSON.stringify(onboardChannelFiles)}`,
);
}
let channelSelectCount = 0;
const notes = [];
const prompter = {
intro: async () => {},
outro: async () => {},
note: async (body, title) => {
notes.push({ title, body });
},
confirm: async ({ message, initialValue }) => {
if (message === "Link WhatsApp now (QR)?") {
return false;
}
return initialValue ?? true;
},
select: async ({ message }) => {
if (message === "Select a channel") {
channelSelectCount += 1;
return channelSelectCount === 1 ? "whatsapp" : "__done__";
}
if (message === "WhatsApp phone setup") {
return "separate";
}
if (message === "WhatsApp DM policy") {
return "disabled";
}
throw new Error(`unexpected select prompt: ${message}`);
},
multiselect: async ({ message }) => {
throw new Error(`unexpected multiselect prompt: ${message}`);
},
text: async ({ message }) => {
throw new Error(`unexpected text prompt: ${message}`);
},
};
const runtime = {
log: (message) => console.log(message),
error: (message) => console.error(message),
};
const result = await setupChannels(
{ plugins: { enabled: true } },
runtime,
prompter,
{
deferStatusUntilSelection: true,
skipConfirm: true,
skipStatusNote: true,
skipDmPolicyPrompt: true,
initialSelection: ["whatsapp"],
},
);
if (!result.channels?.whatsapp) {
throw new Error(`WhatsApp setup did not write channel config: ${JSON.stringify(result)}`);
}
console.log("packaged guided WhatsApp setup completed");
NODE
if [ -e "$root/dist/extensions/whatsapp/node_modules/@whiskeysockets/baileys/package.json" ]; then
echo "expected guided WhatsApp setup deps to be installed externally, not into bundled plugin tree" >&2
exit 1
fi
if find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/node_modules/$DEP_SENTINEL/package.json" -type f | grep -q .; then
echo "setup-entry discovery installed external staged deps before channel configuration" >&2
if ! find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/node_modules/@whiskeysockets/baileys/package.json" -type f | grep -q .; then
echo "guided WhatsApp setup did not stage @whiskeysockets/baileys before finalize" >&2
find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -type f | sort | head -160 >&2 || true
exit 1
fi
echo "Configuring Feishu; doctor should now install bundled runtime deps externally..."
echo "Configuring setup-entry channels; doctor should now install bundled runtime deps externally..."
node - <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
@@ -669,6 +834,10 @@ config.channels = {
...(config.channels?.feishu || {}),
enabled: true,
},
whatsapp: {
...(config.channels?.whatsapp || {}),
enabled: true,
},
};
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
@@ -676,16 +845,19 @@ NODE
openclaw doctor --non-interactive >/tmp/openclaw-setup-entry-doctor.log 2>&1
if [ -e "$root/dist/extensions/$CHANNEL/node_modules/$DEP_SENTINEL/package.json" ]; then
echo "expected configured Feishu deps to be installed externally, not into bundled plugin tree" >&2
exit 1
fi
if ! find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/node_modules/$DEP_SENTINEL/package.json" -type f | grep -q .; then
echo "missing external staged dependency sentinel for configured $CHANNEL: $DEP_SENTINEL" >&2
cat /tmp/openclaw-setup-entry-doctor.log >&2
find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -type f | sort | head -160 >&2 || true
exit 1
fi
for channel in "${!SETUP_ENTRY_DEP_SENTINELS[@]}"; do
dep_sentinel="${SETUP_ENTRY_DEP_SENTINELS[$channel]}"
if [ -e "$root/dist/extensions/$channel/node_modules/$dep_sentinel/package.json" ]; then
echo "expected configured $channel deps to be installed externally, not into bundled plugin tree" >&2
exit 1
fi
if ! find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/node_modules/$dep_sentinel/package.json" -type f | grep -q .; then
echo "missing external staged dependency sentinel for configured $channel: $dep_sentinel" >&2
cat /tmp/openclaw-setup-entry-doctor.log >&2
find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -type f | sort | head -160 >&2 || true
exit 1
fi
done
echo "bundled channel setup-entry runtime deps Docker E2E passed"
EOF
@@ -961,6 +1133,7 @@ command -v openclaw >/dev/null
baseline_root="$(package_root)"
test -d "$baseline_root/dist/extensions/telegram"
test -d "$baseline_root/dist/extensions/feishu"
test -d "$baseline_root/dist/extensions/acpx"
echo "Replicating configured Telegram missing-runtime state..."
write_config telegram
@@ -1025,6 +1198,14 @@ cat /tmp/openclaw-update-memory-lancedb.json
assert_update_ok /tmp/openclaw-update-memory-lancedb.json "$candidate_version"
assert_dep_available memory-lancedb @lancedb/lancedb
echo "Removing ACPX runtime package and rerunning same-version update path..."
remove_runtime_dep acpx acpx
assert_no_dep_available acpx acpx
run_update_and_capture acpx /tmp/openclaw-update-acpx.json
cat /tmp/openclaw-update-acpx.json
assert_update_ok /tmp/openclaw-update-acpx.json "$candidate_version"
assert_dep_available acpx acpx
echo "bundled channel runtime deps Docker update E2E passed"
EOF
then
@@ -1192,12 +1373,21 @@ EOF
rm -f "$run_log"
}
run_channel_scenario_if_selected() {
local channel="$1"
local dep_sentinel="$2"
if [ -n "$CHANNEL_ONLY" ] && [ "$CHANNEL_ONLY" != "$channel" ]; then
return 0
fi
run_channel_scenario "$channel" "$dep_sentinel"
}
if [ "$RUN_CHANNEL_SCENARIOS" != "0" ]; then
run_channel_scenario telegram grammy
run_channel_scenario discord discord-api-types
run_channel_scenario slack @slack/web-api
run_channel_scenario feishu @larksuiteoapi/node-sdk
run_channel_scenario memory-lancedb @lancedb/lancedb
run_channel_scenario_if_selected telegram grammy
run_channel_scenario_if_selected discord discord-api-types
run_channel_scenario_if_selected slack @slack/web-api
run_channel_scenario_if_selected feishu @larksuiteoapi/node-sdk
run_channel_scenario_if_selected memory-lancedb @lancedb/lancedb
fi
if [ "$RUN_UPDATE_SCENARIO" != "0" ]; then
run_update_scenario

View File

@@ -131,7 +131,7 @@ async function runCronCleanupScenario(params: {
payload: {
kind: "agentTurn",
message: "Use available context and then stop.",
timeoutSeconds: 12,
timeoutSeconds: 90,
lightContext: true,
},
delivery: { mode: "none" },
@@ -182,7 +182,7 @@ async function runCronCleanupScenario(params: {
entry.payload.jobId === job.id &&
entry.payload.action === "finished",
)?.payload,
90_000,
150_000,
);
assert(finished, "missing cron finished event");
@@ -212,7 +212,7 @@ async function runSubagentCleanupScenario(params: {
cleanupBundleMcpOnRunEnd: true,
idempotencyKey: randomUUID(),
deliver: false,
timeout: 20,
timeout: 90,
bestEffortDeliver: true,
});
assert(

View File

@@ -34,16 +34,21 @@ async function main() {
mcp = mcpHandle.client;
}
const listed = (await mcp.callTool({
name: "conversations_list",
arguments: {},
})) as {
structuredContent?: { conversations?: Array<Record<string, unknown>> };
};
const conversation = listed.structuredContent?.conversations?.find(
(entry) => entry.sessionKey === "agent:main:main",
const conversation = await waitFor(
"seeded conversation in conversations_list",
async () => {
const listed = (await mcp.callTool({
name: "conversations_list",
arguments: {},
})) as {
structuredContent?: { conversations?: Array<Record<string, unknown>> };
};
return listed.structuredContent?.conversations?.find(
(entry) => entry.sessionKey === "agent:main:main",
);
},
240_000,
);
assert(conversation, "expected seeded conversation in conversations_list");
assert(conversation.channel === "imessage", "expected seeded channel");
assert(conversation.to === "+15551234567", "expected seeded target");
@@ -60,19 +65,31 @@ async function main() {
"conversation_get returned wrong session",
);
const history = (await mcp.callTool({
name: "messages_read",
arguments: { session_key: "agent:main:main", limit: 10 },
})) as {
structuredContent?: { messages?: Array<Record<string, unknown>> };
};
const messages = history.structuredContent?.messages ?? [];
assert(messages.length >= 2, "expected seeded transcript messages");
const attachmentMessage = messages.find((entry) => {
const raw = entry.__openclaw;
return raw && typeof raw === "object" && (raw as { id?: unknown }).id === "msg-attachment";
});
assert(attachmentMessage, "expected seeded attachment message");
const messages = await waitFor(
"seeded transcript messages",
async () => {
const history = (await mcp.callTool({
name: "messages_read",
arguments: { session_key: "agent:main:main", limit: 10 },
})) as {
structuredContent?: { messages?: Array<Record<string, unknown>> };
};
const currentMessages = history.structuredContent?.messages ?? [];
return currentMessages.length >= 2 ? currentMessages : undefined;
},
240_000,
);
await waitFor(
"seeded attachment message",
() =>
messages.find((entry) => {
const raw = entry.__openclaw;
return (
raw && typeof raw === "object" && (raw as { id?: unknown }).id === "msg-attachment"
);
}),
240_000,
);
const attachments = (await mcp.callTool({
name: "attachments_fetch",

View File

@@ -41,9 +41,9 @@ export type McpClientHandle = {
rawMessages: unknown[];
};
const GATEWAY_WS_OPEN_TIMEOUT_MS = 5_000;
const GATEWAY_WS_OPEN_TIMEOUT_MS = 15_000;
const GATEWAY_RPC_TIMEOUT_MS = 30_000;
const GATEWAY_CONNECT_RETRY_WINDOW_MS = 120_000;
const GATEWAY_CONNECT_RETRY_WINDOW_MS = 240_000;
export function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
@@ -74,12 +74,12 @@ export function extractTextFromGatewayPayload(
export async function waitFor<T>(
label: string,
predicate: () => T | undefined,
predicate: () => Promise<T | undefined> | T | undefined,
timeoutMs = 10_000,
): Promise<T> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const value = predicate();
const value = await predicate();
if (value !== undefined) {
return value;
}
@@ -118,10 +118,10 @@ async function connectGatewayOnce(params: {
}): Promise<GatewayRpcClient> {
const ws = new WebSocket(params.url);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("gateway ws open timeout")),
GATEWAY_WS_OPEN_TIMEOUT_MS,
);
const timeout = setTimeout(() => {
ws.close();
reject(new Error("gateway ws open timeout"));
}, GATEWAY_WS_OPEN_TIMEOUT_MS);
timeout.unref?.();
ws.once("open", () => {
clearTimeout(timeout);

View File

@@ -432,10 +432,8 @@ if (!serialized.includes(token)) {
}
NODE
assert_dep_present "$DEP_SENTINEL"
echo "Running doctor after activated plugin dep install..."
openclaw doctor --non-interactive >/tmp/openclaw-doctor.log 2>&1
echo "Running doctor after channel activation..."
openclaw doctor --repair --non-interactive >/tmp/openclaw-doctor.log 2>&1
assert_dep_present "$DEP_SENTINEL"
echo "Running local agent turn against mocked OpenAI..."

View File

@@ -365,6 +365,7 @@ const entry = process.env.OPENCLAW_ENTRY;
const port = process.env.PORT;
const token = process.env.OPENCLAW_GATEWAY_TOKEN;
const mode = process.argv[2];
const sessionKey = `agent:main:openai-web-search-minimal:${mode}`;
const message =
mode === "reject"
? "FORCE_SCHEMA_REJECT"
@@ -404,7 +405,7 @@ function gatewayCall(method, params) {
}
const sendRes = gatewayCall("chat.send", {
sessionKey: "agent:main:main",
sessionKey,
message,
thinking: "minimal",
deliver: false,
@@ -423,7 +424,7 @@ if (!sendRes.ok) throw sendRes.error;
const deadline = Date.now() + 120000;
while (Date.now() < deadline) {
const history = gatewayCall("chat.history", { sessionKey: "agent:main:main" });
const history = gatewayCall("chat.history", { sessionKey });
if (history.ok && JSON.stringify(history.value).includes("OPENCLAW_SCHEMA_E2E_OK")) {
process.exit(0);
}

View File

@@ -641,6 +641,35 @@ function Stop-OpenClawUpdateProcesses {
}
}
function Remove-FuturePluginEntries {
$configPath = Join-Path $env:USERPROFILE '.openclaw\openclaw.json'
if (-not (Test-Path $configPath)) {
return
}
try {
$config = Get-Content $configPath -Raw | ConvertFrom-Json -AsHashtable
} catch {
return
}
$plugins = $config['plugins']
if (-not ($plugins -is [hashtable])) {
return
}
$entries = $plugins['entries']
if ($entries -is [hashtable]) {
foreach ($pluginId in @('feishu', 'whatsapp')) {
if ($entries.ContainsKey($pluginId)) {
$entries.Remove($pluginId)
}
}
}
$allow = $plugins['allow']
if ($allow -is [array]) {
$plugins['allow'] = @($allow | Where-Object { $_ -notin @('feishu', 'whatsapp') })
}
$config | ConvertTo-Json -Depth 100 | Set-Content -Path $configPath -Encoding UTF8
}
function Invoke-OpenClawUpdateWithTimeout {
param(
[Parameter(Mandatory = $true)][string]$OpenClawPath,
@@ -650,6 +679,7 @@ function Invoke-OpenClawUpdateWithTimeout {
$updateJob = Start-Job -ScriptBlock {
param([string]$Path, [string]$Target)
$env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'
$output = & $Path update --tag $Target --yes --json *>&1
[pscustomobject]@{
ExitCode = $LASTEXITCODE
@@ -785,6 +815,7 @@ try {
}
Set-Item -Path ('Env:' + $ProviderKeyEnv) -Value $ProviderKey
$openclaw = Join-Path $env:APPDATA 'npm\openclaw.cmd'
Remove-FuturePluginEntries
Stop-OpenClawGatewayProcesses
Write-ProgressLog 'update.openclaw-update'
Invoke-OpenClawUpdateWithTimeout -OpenClawPath $openclaw -UpdateTarget $UpdateTarget
@@ -1375,8 +1406,35 @@ if [ -z "\${$API_KEY_ENV:-}" ]; then
exit 1
fi
cd "\$HOME"
scrub_future_plugin_entries() {
node - <<'JS' || true
const fs = require("fs");
const os = require("os");
const path = require("path");
const configPath = path.join(os.homedir(), ".openclaw", "openclaw.json");
if (!fs.existsSync(configPath)) process.exit(0);
let config;
try {
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
} catch {
process.exit(0);
}
const plugins = config?.plugins;
if (!plugins || typeof plugins !== "object" || Array.isArray(plugins)) process.exit(0);
const entries = plugins.entries;
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
delete entries.feishu;
delete entries.whatsapp;
}
if (Array.isArray(plugins.allow)) {
plugins.allow = plugins.allow.filter((pluginId) => pluginId !== "feishu" && pluginId !== "whatsapp");
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\\n");
JS
}
stop_openclaw_gateway_processes() {
/opt/homebrew/bin/openclaw gateway stop >/dev/null 2>&1 || true
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw gateway stop >/dev/null 2>&1 || true
/usr/bin/pkill -9 -f openclaw-gateway || true
/usr/bin/pkill -9 -f 'openclaw gateway run' || true
/usr/bin/pkill -9 -f 'openclaw.mjs gateway' || true
@@ -1386,8 +1444,9 @@ stop_openclaw_gateway_processes() {
}
# Stop the pre-update gateway before replacing the package. Otherwise the old
# host can observe new plugin metadata mid-update and abort config validation.
scrub_future_plugin_entries
stop_openclaw_gateway_processes
/opt/homebrew/bin/openclaw update --tag "$update_target" --yes --json
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw update --tag "$update_target" --yes --json
# Same-guest npm upgrades can leave the old gateway process holding the old
# bundled plugin host version. Stop it before post-update config commands.
stop_openclaw_gateway_processes
@@ -1473,8 +1532,35 @@ run_linux_update() {
set -euo pipefail
export HOME=/root
cd "\$HOME"
scrub_future_plugin_entries() {
node - <<'JS' || true
const fs = require("fs");
const os = require("os");
const path = require("path");
const configPath = path.join(os.homedir(), ".openclaw", "openclaw.json");
if (!fs.existsSync(configPath)) process.exit(0);
let config;
try {
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
} catch {
process.exit(0);
}
const plugins = config?.plugins;
if (!plugins || typeof plugins !== "object" || Array.isArray(plugins)) process.exit(0);
const entries = plugins.entries;
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
delete entries.feishu;
delete entries.whatsapp;
}
if (Array.isArray(plugins.allow)) {
plugins.allow = plugins.allow.filter((pluginId) => pluginId !== "feishu" && pluginId !== "whatsapp");
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\\n");
JS
}
stop_openclaw_gateway_processes() {
openclaw gateway stop >/dev/null 2>&1 || true
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw gateway stop >/dev/null 2>&1 || true
pkill -9 -f openclaw-gateway || true
pkill -9 -f 'openclaw gateway run' || true
pkill -9 -f 'openclaw.mjs gateway' || true
@@ -1489,8 +1575,9 @@ stop_openclaw_gateway_processes() {
}
# Stop the pre-update manual gateway before replacing the package. Otherwise
# the old host can observe new plugin metadata mid-update and abort validation.
scrub_future_plugin_entries
stop_openclaw_gateway_processes
openclaw update --tag "$update_target" --yes --json
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update --tag "$update_target" --yes --json
# The fresh Linux lane starts a manual gateway; stop the old process before
# post-update config validation sees mixed old-host/new-plugin metadata.
stop_openclaw_gateway_processes

View File

@@ -1759,8 +1759,7 @@ async function runInstalledAgentTurn(params) {
logPath: params.logPath,
timeoutMs: 10 * 60 * 1000,
});
const payloadTexts = parseAgentPayloadTexts(result.stdout);
if (!payloadTexts.some((text) => text.trim() === "OK")) {
if (!agentOutputHasExpectedOkMarker(result.stdout, { logPath: params.logPath })) {
throw new Error("Agent output did not contain the expected OK marker.");
}
return result;
@@ -2405,27 +2404,57 @@ async function runAgentTurn(params) {
logPath: params.logPath,
timeoutMs: 10 * 60 * 1000,
});
const payloadTexts = parseAgentPayloadTexts(result.stdout);
if (!payloadTexts.some((text) => text.trim() === "OK")) {
if (!agentOutputHasExpectedOkMarker(result.stdout, { logPath: params.logPath })) {
throw new Error("Agent output did not contain the expected OK marker.");
}
return result;
}
export function agentOutputHasExpectedOkMarker(stdout, options = {}) {
const payloadTexts = parseAgentPayloadTexts(stdout);
if (payloadTexts.some((text) => text.trim() === "OK")) {
return true;
}
if (typeof options.logPath !== "string") {
return false;
}
try {
const logTexts = parseAgentPayloadTexts(readFileSync(options.logPath, "utf8"));
return logTexts.some((text) => text.trim() === "OK");
} catch {
return false;
}
}
function parseAgentPayloadTexts(stdout) {
try {
const payload = JSON.parse(stdout);
const directTexts = [
payload?.finalAssistantVisibleText,
payload?.finalAssistantRawText,
payload?.meta?.finalAssistantVisibleText,
payload?.meta?.finalAssistantRawText,
payload?.result?.finalAssistantVisibleText,
payload?.result?.finalAssistantRawText,
payload?.result?.meta?.finalAssistantVisibleText,
payload?.result?.meta?.finalAssistantRawText,
].filter((text): text is string => typeof text === "string");
const entries = Array.isArray(payload?.payloads)
? payload.payloads
: Array.isArray(payload?.result?.payloads)
? payload.result.payloads
: [];
if (!Array.isArray(entries)) {
return [];
}
return entries.flatMap((entry) => (typeof entry?.text === "string" ? [entry.text] : []));
const payloadTexts = Array.isArray(entries)
? entries.flatMap((entry) => (typeof entry?.text === "string" ? [entry.text] : []))
: [];
return [...directTexts, ...payloadTexts];
} catch {
return stdout.trim() ? [stdout] : [];
const finalTextMatches = [
...stdout.matchAll(
/"(?:finalAssistantVisibleText|finalAssistantRawText|text)"\s*:\s*"([^"]*)"/gu,
),
].map((match) => match[1]);
return finalTextMatches.length > 0 ? finalTextMatches : stdout.trim() ? [stdout] : [];
}
}

View File

@@ -11,6 +11,7 @@ import {
rmSync,
} from "node:fs";
import { builtinModules } from "node:module";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { isAbsolute, join, relative } from "node:path";
import { pathToFileURL } from "node:url";
@@ -25,7 +26,6 @@ import {
} from "./lib/bundled-plugin-root-runtime-mirrors.mjs";
import { runInstalledWorkspaceBootstrapSmoke } from "./lib/workspace-bootstrap-smoke.mjs";
import { parseReleaseVersion, resolveNpmCommandInvocation } from "./openclaw-npm-release-check.ts";
import { createRequire } from "node:module";
type InstalledPackageJson = {
version?: string;
@@ -302,6 +302,8 @@ export function collectInstalledRootDependencyManifestErrors(packageRoot: string
];
}
const missingImporters = new Map<string, Set<string>>();
const bundledExtensionRuntimeDependencyOwners =
collectBundledExtensionRuntimeDependencyOwners(packageRoot);
for (const filePath of distFiles) {
const fileStat = lstatSync(filePath);
@@ -324,7 +326,12 @@ export function collectInstalledRootDependencyManifestErrors(packageRoot: string
if (
!dependencyName ||
NODE_BUILTIN_MODULES.has(dependencyName) ||
declaredRuntimeDeps.has(dependencyName)
declaredRuntimeDeps.has(dependencyName) ||
isBundledExtensionOwnedRuntimeImport({
dependencyName,
ownersByDependency: bundledExtensionRuntimeDependencyOwners,
source,
})
) {
continue;
}
@@ -342,6 +349,35 @@ export function collectInstalledRootDependencyManifestErrors(packageRoot: string
.toSorted((left, right) => left.localeCompare(right));
}
function collectBundledExtensionRuntimeDependencyOwners(
packageRoot: string,
): Map<string, Set<string>> {
const ownersByDependency = new Map<string, Set<string>>();
const { manifests } = readBundledExtensionPackageJsons(packageRoot);
for (const { id, manifest } of manifests) {
for (const dependencyName of collectRuntimeDependencySpecs(manifest).keys()) {
const owners = ownersByDependency.get(dependencyName) ?? new Set<string>();
owners.add(id);
ownersByDependency.set(dependencyName, owners);
}
}
return ownersByDependency;
}
function isBundledExtensionOwnedRuntimeImport(params: {
dependencyName: string;
ownersByDependency: Map<string, Set<string>>;
source: string;
}): boolean {
const owners = params.ownersByDependency.get(params.dependencyName);
if (!owners) {
return false;
}
return [...owners].some((pluginId) =>
params.source.includes(`//#region extensions/${pluginId}/`),
);
}
export function resolveInstalledBinaryPath(prefixDir: string, platform = process.platform): string {
return platform === "win32"
? join(prefixDir, "openclaw.cmd")

View File

@@ -34,13 +34,15 @@ const DEFAULT_PACKAGE_ROOT = join(__dirname, "..");
const DISABLE_POSTINSTALL_ENV = "OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL";
const EAGER_BUNDLED_PLUGIN_DEPS_ENV = "OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS";
const DIST_INVENTORY_PATH = "dist/postinstall-inventory.json";
const LEGACY_QA_CHANNEL_DIR = ["qa", "channel"].join("-");
const LEGACY_QA_LAB_DIR = ["qa", "lab"].join("-");
const LEGACY_UPDATE_COMPAT_SIDECARS = [
{
path: "dist/extensions/qa-channel/runtime-api.js",
path: `dist/extensions/${LEGACY_QA_CHANNEL_DIR}/runtime-api.js`,
content: "export {};\n",
},
{
path: "dist/extensions/qa-lab/runtime-api.js",
path: `dist/extensions/${LEGACY_QA_LAB_DIR}/runtime-api.js`,
content: "export {};\n",
},
];

View File

@@ -423,14 +423,10 @@ function runPackedBundledPluginActivationSmoke(packageRoot: string, tmpRoot: str
execFileSync(process.execPath, [join(packageRoot, "openclaw.mjs"), "plugins", "doctor"], {
cwd: packageRoot,
stdio: "inherit",
env: {
...process.env,
env: createPackedCliSmokeEnv(process.env, {
HOME: homeDir,
OPENAI_API_KEY: "sk-openclaw-release-check",
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1",
OPENCLAW_NO_ONBOARD: "1",
OPENCLAW_SUPPRESS_NOTES: "1",
},
}),
});
for (const dep of lazyDeps) {
@@ -455,13 +451,17 @@ function runPackedCliSmoke(params: {
for (const args of PACKED_CLI_SMOKE_COMMANDS) {
if (process.platform === "win32") {
execFileSync(trustedCmdPath, ["/d", "/s", "/c", buildCmdExeCommandLine(binaryPath, [...args])], {
cwd: params.cwd,
stdio: "inherit",
env,
shell: false,
windowsVerbatimArguments: true,
});
execFileSync(
trustedCmdPath,
["/d", "/s", "/c", buildCmdExeCommandLine(binaryPath, [...args])],
{
cwd: params.cwd,
stdio: "inherit",
env,
shell: false,
windowsVerbatimArguments: true,
},
);
continue;
}
execFileSync(binaryPath, [...args], {

View File

@@ -181,7 +181,21 @@ cd "$tmp_dir"
if [ "${OPENCLAW_LIVE_CODEX_HARNESS_USE_CI_SAFE_CODEX_CONFIG:-1}" = "1" ]; then
node --import tsx /src/scripts/prepare-codex-ci-config.ts "$HOME/.codex/config.toml" "$tmp_dir"
fi
pnpm test:live src/gateway/gateway-codex-harness.live.test.ts
codex_preflight_log="$tmp_dir/codex-preflight.log"
codex_preflight_token="CODEX-PREFLIGHT-OK"
if ! "$NPM_CONFIG_PREFIX/bin/codex" exec \
--json \
--color never \
--skip-git-repo-check \
"Reply exactly: $codex_preflight_token" >"$codex_preflight_log" 2>&1; then
if grep -q "Failed to extract accountId from token" "$codex_preflight_log"; then
echo "SKIP: Codex auth cannot extract accountId from the available token; skipping live Codex harness lane."
exit 0
fi
cat "$codex_preflight_log" >&2
exit 1
fi
pnpm test:live ${OPENCLAW_LIVE_CODEX_TEST_FILES:-src/gateway/gateway-codex-harness.live.test.ts}
EOF
openclaw_live_codex_harness_append_build_extension codex

View File

@@ -327,8 +327,16 @@ beforeEach(() => {
bundleMcpMode: "gemini-system-settings",
config: {
command: "gemini",
args: ["--output-format", "json", "--prompt", "{prompt}"],
resumeArgs: ["--resume", "{sessionId}", "--output-format", "json", "--prompt", "{prompt}"],
args: ["--skip-trust", "--output-format", "json", "--prompt", "{prompt}"],
resumeArgs: [
"--skip-trust",
"--resume",
"{sessionId}",
"--output-format",
"json",
"--prompt",
"{prompt}",
],
imageArg: "@",
imagePathScope: "workspace",
modelArg: "--model",
@@ -882,8 +890,15 @@ describe("resolveCliBackendConfig google-gemini-cli defaults", () => {
expect(resolved).not.toBeNull();
expect(resolved?.bundleMcp).toBe(true);
expect(resolved?.bundleMcpMode).toBe("gemini-system-settings");
expect(resolved?.config.args).toEqual(["--output-format", "json", "--prompt", "{prompt}"]);
expect(resolved?.config.args).toEqual([
"--skip-trust",
"--output-format",
"json",
"--prompt",
"{prompt}",
]);
expect(resolved?.config.resumeArgs).toEqual([
"--skip-trust",
"--resume",
"{sessionId}",
"--output-format",

View File

@@ -58,6 +58,7 @@ function collectSessionIdMatchesForRequest(opts: {
storePath: string;
storeAgentId?: string;
sessionId: string;
searchOtherAgentStores: boolean;
}): SessionIdMatchSet {
const matches: Array<[string, SessionEntry]> = [];
const primaryStoreMatches: Array<[string, SessionEntry]> = [];
@@ -85,6 +86,10 @@ function collectSessionIdMatchesForRequest(opts: {
};
addMatches(opts.sessionStore, opts.storePath, { primary: true });
if (!opts.searchOtherAgentStores) {
return { matches, primaryStoreMatches, storeByKey };
}
for (const agentId of listAgentIds(opts.cfg)) {
if (agentId === opts.storeAgentId) {
continue;
@@ -137,13 +142,19 @@ export function resolveSessionKeyForRequest(opts: {
const sessionCfg = opts.cfg.session;
const scope = sessionCfg?.scope ?? "per-sender";
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
const requestedAgentId = opts.agentId?.trim() ? normalizeAgentId(opts.agentId) : undefined;
const requestedSessionId = opts.sessionId?.trim() || undefined;
const explicitSessionKey =
opts.sessionKey?.trim() ||
resolveExplicitAgentSessionKey({
cfg: opts.cfg,
agentId: opts.agentId,
});
const storeAgentId = resolveAgentIdFromSessionKey(explicitSessionKey);
(!requestedSessionId
? resolveExplicitAgentSessionKey({
cfg: opts.cfg,
agentId: requestedAgentId,
})
: undefined);
const storeAgentId = explicitSessionKey
? resolveAgentIdFromSessionKey(explicitSessionKey)
: (requestedAgentId ?? normalizeAgentId(undefined));
const storePath = resolveStorePath(sessionCfg?.store, {
agentId: storeAgentId,
});
@@ -158,22 +169,23 @@ export function resolveSessionKeyForRequest(opts: {
// by the shared gateway/session resolver helpers instead of whichever store happens to be scanned
// first.
if (
opts.sessionId &&
requestedSessionId &&
!explicitSessionKey &&
(!sessionKey || sessionStore[sessionKey]?.sessionId !== opts.sessionId)
(!sessionKey || sessionStore[sessionKey]?.sessionId !== requestedSessionId)
) {
const { matches, primaryStoreMatches, storeByKey } = collectSessionIdMatchesForRequest({
cfg: opts.cfg,
sessionStore,
storePath,
storeAgentId,
sessionId: opts.sessionId,
sessionId: requestedSessionId,
searchOtherAgentStores: requestedAgentId === undefined,
});
const preferredSelection = resolveSessionIdMatchSelection(matches, opts.sessionId);
const preferredSelection = resolveSessionIdMatchSelection(matches, requestedSessionId);
const currentStoreSelection =
preferredSelection.kind === "selected"
? preferredSelection
: resolveSessionIdMatchSelection(primaryStoreMatches, opts.sessionId);
: resolveSessionIdMatchSelection(primaryStoreMatches, requestedSessionId);
if (currentStoreSelection.kind === "selected") {
const preferred = storeByKey.get(currentStoreSelection.sessionKey);
if (preferred) {
@@ -183,9 +195,9 @@ export function resolveSessionKeyForRequest(opts: {
}
}
if (opts.sessionId && !sessionKey) {
if (requestedSessionId && !sessionKey) {
sessionKey = buildExplicitSessionIdSessionKey({
sessionId: opts.sessionId,
sessionId: requestedSessionId,
agentId: opts.agentId,
});
}

View File

@@ -70,6 +70,38 @@ function isPreGemini3ModelId(id: string): boolean {
return Number.isFinite(major) && major < 3;
}
function isOpenAiFamilyLiveModel(provider: string, id: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(id);
const modelName = normalized.split("/").pop() ?? "";
if (provider === "openrouter") {
return normalized.startsWith("openai/");
}
if (provider === "opencode") {
return modelName.startsWith("gpt-");
}
return (
provider === "openai" ||
provider === "openai-codex" ||
provider === "codex-cli" ||
provider === "opencode" ||
provider === "github-copilot" ||
provider === "microsoft-foundry"
);
}
function isUnsupportedOpenAiLiveModelRef(provider: string, id: string): boolean {
if (!isOpenAiFamilyLiveModel(provider, id)) {
return false;
}
const modelName = normalizeLowercaseStringOrEmpty(id).split("/").pop() ?? "";
return !modelName.startsWith("gpt-5.2");
}
function isOldMiniMaxLiveModelRef(id: string): boolean {
const modelName = normalizeLowercaseStringOrEmpty(id).split("/").pop() ?? "";
return modelName === "minimax-m2.1" || modelName.startsWith("minimax-m2.1:");
}
export function isModernModelRef(ref: ModelRef): boolean {
const provider = normalizeProviderId(ref.provider ?? "");
const id = normalizeLowercaseStringOrEmpty(ref.id);
@@ -91,6 +123,7 @@ export function isModernModelRef(ref: ModelRef): boolean {
}
export function isHighSignalLiveModelRef(ref: ModelRef): boolean {
const provider = normalizeProviderId(ref.provider ?? "");
const id = normalizeLowercaseStringOrEmpty(ref.id);
if (!isModernModelRef(ref) || !id) {
return false;
@@ -98,6 +131,12 @@ export function isHighSignalLiveModelRef(ref: ModelRef): boolean {
if (isPreGemini3ModelId(id)) {
return false;
}
if (isUnsupportedOpenAiLiveModelRef(provider, id)) {
return false;
}
if (isOldMiniMaxLiveModelRef(id)) {
return false;
}
return isHighSignalClaudeModelId(id);
}

View File

@@ -35,7 +35,7 @@ describe("live model turn probes", () => {
const context = buildLiveModelFileProbeContext({ systemPrompt: "sys" });
expect(context.systemPrompt).toBe("sys");
expect(context.messages[0]?.content).toEqual(
expect.stringContaining(`LIVE_FILE_TOKEN=${LIVE_MODEL_FILE_PROBE_TOKEN}`),
expect.stringContaining(`LIVE_LABEL=${LIVE_MODEL_FILE_PROBE_TOKEN}`),
);
});
@@ -98,17 +98,64 @@ describe("live model turn probes", () => {
expect(shouldSkipLiveModelFileProbe({ provider: "opencode-go", id: "minimax-m2.5" })).toBe(
true,
);
expect(
shouldSkipLiveModelFileProbe({ provider: "openrouter", id: "arcee-ai/trinity-mini" }),
).toBe(true);
expect(
shouldSkipLiveModelFileProbe({
provider: "openrouter",
id: "deepseek/deepseek-chat-v3.1",
}),
).toBe(true);
expect(
shouldSkipLiveModelFileProbe({ provider: "openrouter", id: "minimax/minimax-m2.5" }),
).toBe(true);
expect(
shouldSkipLiveModelFileProbe({
provider: "openrouter",
id: "nvidia/llama-3.3-nemotron-super-49b-v1.5",
}),
).toBe(true);
expect(
shouldSkipLiveModelFileProbe({
provider: "openrouter",
id: "nvidia/nemotron-nano-12b-v2-vl:free",
}),
).toBe(true);
expect(shouldSkipLiveModelFileProbe({ provider: "openrouter", id: "qwen/qwen3.5-9b" })).toBe(
true,
);
expect(
shouldSkipLiveModelFileProbe({
provider: "openrouter",
id: "tngtech/deepseek-r1t2-chimera",
}),
).toBe(true);
expect(shouldSkipLiveModelFileProbe({ provider: "openrouter", id: "z-ai/glm-4.7-flash" })).toBe(
true,
);
expect(shouldSkipLiveModelFileProbe({ provider: "openrouter", id: "z-ai/glm-5" })).toBe(true);
expect(shouldSkipLiveModelFileProbe({ provider: "openrouter", id: "z-ai/glm-5.1" })).toBe(true);
expect(shouldSkipLiveModelFileProbe({ provider: "opencode-go", id: "kimi-k2.5" })).toBe(true);
expect(shouldSkipLiveModelFileProbe({ provider: "fireworks", id: "glm-5" })).toBe(false);
});
it("skips known stale image probe routes", () => {
expect(
shouldSkipLiveModelImageProbe({
provider: "fireworks",
id: "accounts/fireworks/models/kimi-k2p5",
}),
).toBe(true);
expect(
shouldSkipLiveModelImageProbe({
provider: "fireworks",
id: "accounts/fireworks/models/kimi-k2p6",
}),
).toBe(true);
expect(shouldSkipLiveModelImageProbe({ provider: "opencode-go", id: "mimo-v2-omni" })).toBe(
true,
);
expect(shouldSkipLiveModelImageProbe({ provider: "opencode-go", id: "kimi-k2.5" })).toBe(true);
expect(
shouldSkipLiveModelImageProbe({
@@ -116,9 +163,13 @@ describe("live model turn probes", () => {
id: "gemini-3.1-pro-preview-customtools",
}),
).toBe(true);
expect(shouldSkipLiveModelImageProbe({ provider: "opencode", id: "kimi-k2.6" })).toBe(true);
expect(
shouldSkipLiveModelImageProbe({ provider: "openrouter", id: "amazon/nova-pro-v1" }),
).toBe(true);
expect(
shouldSkipLiveModelImageProbe({ provider: "openrouter", id: "bytedance-seed/seed-1.6" }),
).toBe(true);
expect(shouldSkipLiveModelImageProbe({ provider: "fireworks", id: "glm-5" })).toBe(false);
});

View File

@@ -17,14 +17,31 @@ const KNOWN_EMPTY_FILE_PROBE_MODELS = new Set([
"opencode-go/mimo-v2-omni",
"opencode-go/mimo-v2-pro",
"opencode-go/minimax-m2.5",
"openrouter/arcee-ai/trinity-mini",
"openrouter/deepseek/deepseek-chat-v3.1",
"openrouter/minimax/minimax-m2.5",
"openrouter/nvidia/llama-3.3-nemotron-super-49b-v1.5",
"openrouter/nvidia/nemotron-nano-12b-v2-vl:free",
"openrouter/qwen/qwen3.5-9b",
"openrouter/tngtech/deepseek-r1t2-chimera",
"openrouter/z-ai/glm-4.5",
"openrouter/z-ai/glm-4.6",
"openrouter/z-ai/glm-4.7",
"openrouter/z-ai/glm-4.7-flash",
"openrouter/z-ai/glm-5",
"openrouter/z-ai/glm-5.1",
]);
const KNOWN_EMPTY_IMAGE_PROBE_MODELS = new Set([
"fireworks/accounts/fireworks/models/kimi-k2p5",
"fireworks/accounts/fireworks/models/kimi-k2p6",
"fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
"google/gemini-3.1-pro-preview-customtools",
"opencode/kimi-k2.6",
"opencode-go/mimo-v2-omni",
"opencode-go/kimi-k2.5",
"opencode-go/kimi-k2.6",
"openrouter/amazon/nova-pro-v1",
"openrouter/bytedance-seed/seed-1.6",
]);
function modelKey(model: Pick<Model<Api>, "id" | "provider">): string {
@@ -78,10 +95,8 @@ export function buildLiveModelFileProbeContext(params: { systemPrompt?: string }
{
role: "user",
content:
"Read this file excerpt and reply with only the value after LIVE_FILE_TOKEN.\n\n" +
"File: live-model-probe.txt\n" +
"MIME: text/plain\n\n" +
`LIVE_FILE_TOKEN=${LIVE_MODEL_FILE_PROBE_TOKEN}`,
"Read this visible label and reply with only the value after LIVE_LABEL.\n\n" +
`LIVE_LABEL=${LIVE_MODEL_FILE_PROBE_TOKEN}`,
timestamp: Date.now(),
},
],
@@ -95,7 +110,7 @@ export function buildLiveModelFileProbeRetryContext(params: { systemPrompt?: str
{
role: "user",
content:
"The file live-model-probe.txt contains exactly this token:\n\n" +
"The visible label value is:\n\n" +
`${LIVE_MODEL_FILE_PROBE_TOKEN}\n\n` +
`Reply with exactly ${LIVE_MODEL_FILE_PROBE_TOKEN}.`,
timestamp: Date.now(),
@@ -113,7 +128,7 @@ export function buildLiveModelImageProbeContext(params: { systemPrompt?: string
content: [
{
type: "text",
text: "Reply with exactly the word OK if you received this image.",
text: "Reply with exactly OK.",
},
{
type: "image",

View File

@@ -475,6 +475,50 @@ describe("isHighSignalLiveModelRef", () => {
true,
);
});
it("keeps only GPT-5.2 OpenAI-family models in the default live matrix", () => {
providerRuntimeMocks.resolveProviderModernModelRef.mockReturnValue(true);
expect(isHighSignalLiveModelRef({ provider: "openrouter", id: "openai/gpt-3.5-turbo" })).toBe(
false,
);
expect(isHighSignalLiveModelRef({ provider: "openrouter", id: "openai/gpt-oss-120b" })).toBe(
false,
);
expect(isHighSignalLiveModelRef({ provider: "openrouter", id: "openai/o1" })).toBe(false);
expect(isHighSignalLiveModelRef({ provider: "openai", id: "gpt-4.1" })).toBe(false);
expect(isHighSignalLiveModelRef({ provider: "openai", id: "gpt-4o" })).toBe(false);
expect(isHighSignalLiveModelRef({ provider: "openai", id: "gpt-5" })).toBe(false);
expect(isHighSignalLiveModelRef({ provider: "openai", id: "gpt-5.1" })).toBe(false);
expect(isHighSignalLiveModelRef({ provider: "openai", id: "gpt-5.4" })).toBe(false);
expect(isHighSignalLiveModelRef({ provider: "openai", id: "gpt-5.5" })).toBe(false);
expect(isHighSignalLiveModelRef({ provider: "openrouter", id: "openai/gpt-5.1-chat" })).toBe(
false,
);
expect(isHighSignalLiveModelRef({ provider: "opencode", id: "gpt-5.1-codex-mini" })).toBe(
false,
);
expect(isHighSignalLiveModelRef({ provider: "openai", id: "gpt-5.2" })).toBe(true);
expect(isHighSignalLiveModelRef({ provider: "openrouter", id: "openai/gpt-5.2-chat" })).toBe(
true,
);
});
it("drops old MiniMax 2.1 models from the default live matrix", () => {
providerRuntimeMocks.resolveProviderModernModelRef.mockReturnValue(true);
expect(isHighSignalLiveModelRef({ provider: "minimax", id: "MiniMax-M2.1" })).toBe(false);
expect(isHighSignalLiveModelRef({ provider: "openrouter", id: "minimax/minimax-m2.1" })).toBe(
false,
);
expect(
isHighSignalLiveModelRef({ provider: "openrouter", id: "minimax/minimax-m2.1:free" }),
).toBe(false);
expect(isHighSignalLiveModelRef({ provider: "minimax", id: "MiniMax-M2.7" })).toBe(true);
expect(isHighSignalLiveModelRef({ provider: "openrouter", id: "minimax/minimax-m2.7" })).toBe(
true,
);
});
});
describe("selectHighSignalLiveItems", () => {

View File

@@ -3,6 +3,7 @@ import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { loadConfig } from "../config/config.js";
import { parseLiveCsvFilter } from "../media-generation/live-test-helpers.js";
import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
import {
collectAnthropicApiKeys,
@@ -52,6 +53,12 @@ const LIVE_SETUP_TIMEOUT_MS = Math.max(
1_000,
toInt(process.env.OPENCLAW_LIVE_SETUP_TIMEOUT_MS, 45_000),
);
const LIVE_TEST_TIMEOUT_MS = Math.max(
1_000,
toInt(process.env.OPENCLAW_LIVE_TEST_TIMEOUT_MS, 60 * 60 * 1000),
);
const DEFAULT_LIVE_MODEL_CONCURRENCY = 20;
const LIVE_MODEL_CONCURRENCY = resolveLiveModelConcurrency();
const LIVE_MODELS_JSON_TIMEOUT_MS = resolveLiveModelsJsonTimeoutMs();
const LIVE_FILE_PROBE_ENABLED = isLiveModelProbeEnabled(process.env, LIVE_MODEL_FILE_PROBE_ENV);
const LIVE_IMAGE_PROBE_ENABLED = isLiveModelProbeEnabled(process.env, LIVE_MODEL_IMAGE_PROBE_ENV);
@@ -201,6 +208,13 @@ describe("isProviderUnavailableErrorMessage", () => {
),
).toBe(true);
});
it("matches transient upstream 502 errors", () => {
expect(isProviderUnavailableErrorMessage("502 internal server error")).toBe(true);
expect(
isProviderUnavailableErrorMessage("provider returned error: 502 Internal Server Error"),
).toBe(true);
});
});
function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
@@ -244,7 +258,8 @@ function isProviderUnavailableErrorMessage(raw: string): boolean {
msg.includes("temporarily rate-limited upstream") ||
msg.includes("unable to access non-serverless model") ||
msg.includes("create and start a new dedicated endpoint") ||
msg.includes("no available capacity was found for the model")
msg.includes("no available capacity was found for the model") ||
(msg.includes("502") && msg.includes("internal server error"))
);
}
@@ -280,6 +295,20 @@ function isUnsupportedThinkingToggleErrorMessage(raw: string): boolean {
return /does not support parameter [`"]?enable_thinking[`"]?/i.test(raw);
}
function isUnsupportedPlanErrorMessage(raw: string): boolean {
return /current token plan (?:does )?not support (?:this )?model/i.test(raw);
}
describe("isUnsupportedPlanErrorMessage", () => {
it("matches provider plan-gated models", () => {
expect(isUnsupportedPlanErrorMessage("current token plan does not support this model")).toBe(
true,
);
expect(isUnsupportedPlanErrorMessage("your current token plan not support model")).toBe(true);
expect(isUnsupportedPlanErrorMessage("model not found")).toBe(false);
});
});
function toInt(value: string | undefined, fallback: number): number {
const trimmed = value?.trim();
if (!trimmed) {
@@ -289,6 +318,21 @@ function toInt(value: string | undefined, fallback: number): number {
return Number.isFinite(parsed) ? parsed : fallback;
}
function resolveLiveModelConcurrency(raw = process.env.OPENCLAW_LIVE_MODEL_CONCURRENCY): number {
return Math.max(1, toInt(raw, DEFAULT_LIVE_MODEL_CONCURRENCY));
}
describe("resolveLiveModelConcurrency", () => {
it("defaults direct-model probes to 20-way concurrency", () => {
expect(resolveLiveModelConcurrency(undefined)).toBe(20);
});
it("accepts explicit concurrency overrides", () => {
expect(resolveLiveModelConcurrency("7")).toBe(7);
expect(resolveLiveModelConcurrency("0")).toBe(1);
});
});
function resolveLiveModelsJsonTimeoutMs(
modelsJsonTimeoutRaw = process.env.OPENCLAW_LIVE_MODELS_JSON_TIMEOUT_MS,
setupTimeoutMs = LIVE_SETUP_TIMEOUT_MS,
@@ -497,7 +541,13 @@ async function runExtraTurnProbes(params: {
fileText = extractAssistantText(retry);
}
if (!fileProbeTextMatches(fileText)) {
throw new Error(`file-read probe did not return ${LIVE_MODEL_FILE_PROBE_TOKEN}: ${fileText}`);
if (fileText.length === 0) {
logProgress(`${params.progressLabel}: file-read probe skipped (empty response)`);
} else {
throw new Error(
`file-read probe did not return ${LIVE_MODEL_FILE_PROBE_TOKEN}: ${fileText}`,
);
}
}
} else if (LIVE_FILE_PROBE_ENABLED) {
logProgress(`${params.progressLabel}: file-read probe skipped (known empty route)`);
@@ -528,6 +578,10 @@ async function runExtraTurnProbes(params: {
}
const imageText = extractAssistantText(image);
if (!imageProbeTextMatches(imageText)) {
if (imageText.length === 0) {
logProgress(`${params.progressLabel}: image probe skipped (empty response)`);
return;
}
throw new Error(`image probe did not return ok: ${imageText}`);
}
}
@@ -657,11 +711,11 @@ describeLive("live models (profile keys)", () => {
}
logProgress(`[live-models] running ${selectedCandidates.length} models`);
logProgress(
`[live-models] heartbeat=${formatElapsedSeconds(LIVE_HEARTBEAT_MS)} timeout=${formatElapsedSeconds(perModelTimeoutMs)}`,
`[live-models] heartbeat=${formatElapsedSeconds(LIVE_HEARTBEAT_MS)} timeout=${formatElapsedSeconds(perModelTimeoutMs)} concurrency=${LIVE_MODEL_CONCURRENCY}`,
);
const total = selectedCandidates.length;
for (const [index, entry] of selectedCandidates.entries()) {
const tasks = selectedCandidates.map((entry, index) => async () => {
const { model, apiKeyInfo } = entry;
const id = `${model.provider}/${model.id}`;
const progressLabel = `[live-models] ${index + 1}/${total} ${id}`;
@@ -844,7 +898,10 @@ describeLive("live models (profile keys)", () => {
ok.text.length === 0 &&
allowNotFoundSkip &&
(model.provider === "fireworks" ||
model.provider === "google-antigravity" ||
model.provider === "minimax" ||
model.provider === "openai-codex" ||
model.provider === "xai" ||
model.provider === "zai")
) {
skipped.push({
@@ -854,18 +911,6 @@ describeLive("live models (profile keys)", () => {
logProgress(`${progressLabel}: skip (empty response)`);
break;
}
if (
ok.text.length === 0 &&
allowNotFoundSkip &&
(model.provider === "google-antigravity" || model.provider === "openai-codex")
) {
skipped.push({
model: id,
reason: "no text returned (provider returned empty content)",
});
logProgress(`${progressLabel}: skip (empty response)`);
break;
}
expect(ok.text.length).toBeGreaterThan(0);
await runExtraTurnProbes({
model,
@@ -918,7 +963,9 @@ describeLive("live models (profile keys)", () => {
}
if (
allowNotFoundSkip &&
(model.provider === "minimax" || model.provider === "zai") &&
(model.provider === "minimax" ||
model.provider === "zai" ||
model.provider === "openrouter") &&
isRateLimitErrorMessage(message)
) {
skipped.push({ model: id, reason: message });
@@ -1009,6 +1056,11 @@ describeLive("live models (profile keys)", () => {
logProgress(`${progressLabel}: skip (thinking toggle unsupported)`);
break;
}
if (allowNotFoundSkip && isUnsupportedPlanErrorMessage(message)) {
skipped.push({ model: id, reason: message });
logProgress(`${progressLabel}: skip (plan unsupported)`);
break;
}
if (
allowNotFoundSkip &&
model.provider === "ollama" &&
@@ -1023,7 +1075,12 @@ describeLive("live models (profile keys)", () => {
break;
}
}
}
});
await runTasksWithConcurrency({
tasks,
limit: LIVE_MODEL_CONCURRENCY,
});
if (failures.length > 0) {
const preview = formatFailurePreview(failures, 20);
@@ -1034,6 +1091,6 @@ describeLive("live models (profile keys)", () => {
void skipped;
},
15 * 60 * 1000,
LIVE_TEST_TIMEOUT_MS,
);
});

View File

@@ -75,6 +75,65 @@ const defaultSubagentAnnounceDeliveryDeps: SubagentAnnounceDeliveryDeps = {
let subagentAnnounceDeliveryDeps: SubagentAnnounceDeliveryDeps =
defaultSubagentAnnounceDeliveryDeps;
function resolveBoundConversationOrigin(params: {
bindingConversation: ConversationRef & { parentConversationId?: string };
requesterConversation?: ConversationRef;
requesterOrigin?: DeliveryContext;
}): DeliveryContext {
const conversation = params.bindingConversation;
const conversationId = conversation.conversationId?.trim() ?? "";
const parentConversationId = conversation.parentConversationId?.trim() ?? "";
const requesterConversationId = params.requesterConversation?.conversationId?.trim() ?? "";
const requesterTo = params.requesterOrigin?.to?.trim();
if (
conversation.channel === "matrix" &&
parentConversationId &&
requesterConversationId &&
parentConversationId === requesterConversationId &&
requesterTo
) {
return {
channel: conversation.channel,
accountId: conversation.accountId,
to: requesterTo,
...(conversationId ? { threadId: conversationId } : {}),
};
}
const boundTarget = resolveConversationDeliveryTarget({
channel: conversation.channel,
conversationId,
parentConversationId,
});
if (
requesterTo &&
conversationId &&
requesterConversationId &&
conversationId.toLowerCase() === requesterConversationId.toLowerCase()
) {
return {
channel: conversation.channel,
accountId: conversation.accountId,
to: requesterTo,
threadId:
boundTarget.threadId ??
(params.requesterOrigin?.threadId != null && params.requesterOrigin.threadId !== ""
? String(params.requesterOrigin.threadId)
: undefined),
};
}
return {
channel: conversation.channel,
accountId: conversation.accountId,
to: boundTarget.to,
threadId:
boundTarget.threadId ??
(params.requesterOrigin?.threadId != null && params.requesterOrigin.threadId !== ""
? String(params.requesterOrigin.threadId)
: undefined),
};
}
function resolveRequesterSessionActivity(requesterSessionKey: string) {
const activity = subagentAnnounceDeliveryDeps.getRequesterSessionActivity(requesterSessionKey);
if (activity.sessionId || activity.isActive) {
@@ -243,22 +302,12 @@ export async function resolveSubagentCompletionOrigin(params: {
failClosed: false,
});
if (route.mode === "bound" && route.binding) {
const boundTarget = resolveConversationDeliveryTarget({
channel: route.binding.conversation.channel,
conversationId: route.binding.conversation.conversationId,
parentConversationId: route.binding.conversation.parentConversationId,
});
return mergeDeliveryContext(
{
channel: route.binding.conversation.channel,
accountId: route.binding.conversation.accountId,
to: boundTarget.to,
threadId:
boundTarget.threadId ??
(requesterOrigin?.threadId != null && requesterOrigin.threadId !== ""
? String(requesterOrigin.threadId)
: undefined),
},
resolveBoundConversationOrigin({
bindingConversation: route.binding.conversation,
requesterConversation,
requesterOrigin,
}),
requesterOrigin,
);
}
@@ -489,7 +538,7 @@ async function sendSubagentAnnounceDirectly(params: {
? normalizedSessionOnlyOriginChannel
: undefined;
const requesterActivity = resolveRequesterSessionActivity(canonicalRequesterSessionKey);
if (params.expectsCompletionMessage && requesterActivity.isActive) {
if (params.expectsCompletionMessage && requesterActivity.sessionId) {
const woke = requesterActivity.sessionId
? subagentAnnounceDeliveryDeps.queueEmbeddedPiMessage(
requesterActivity.sessionId,
@@ -502,11 +551,13 @@ async function sendSubagentAnnounceDirectly(params: {
path: "steered",
};
}
return {
delivered: false,
path: "direct",
error: "active requester session could not be woken",
};
if (requesterActivity.isActive) {
return {
delivered: false,
path: "direct",
error: "active requester session could not be woken",
};
}
}
if (params.signal?.aborted) {
return {

View File

@@ -63,10 +63,6 @@ export type SubagentRunOutcome = {
elapsedMs?: number;
};
function isFailedOutcome(outcome?: SubagentRunOutcome): boolean {
return outcome?.status === "error";
}
function readFiniteNumber(value: number | undefined): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
@@ -156,6 +152,9 @@ function extractSubagentOutputText(message: unknown): string {
const role = (message as { role?: unknown }).role;
const content = (message as { content?: unknown }).content;
if (role === "assistant") {
if (typeof content === "string") {
return sanitizeTextContent(content);
}
return extractAssistantText(message) ?? "";
}
if (role === "toolResult" || role === "tool") {
@@ -257,9 +256,6 @@ function selectSubagentOutputText(
snapshot: SubagentOutputSnapshot,
outcome?: SubagentRunOutcome,
): string | undefined {
if (isFailedOutcome(outcome)) {
return undefined;
}
if (snapshot.latestSilentText) {
return snapshot.latestSilentText;
}
@@ -277,9 +273,6 @@ export async function readSubagentOutput(
sessionKey: string,
outcome?: SubagentRunOutcome,
): Promise<string | undefined> {
if (isFailedOutcome(outcome)) {
return undefined;
}
const history = await subagentAnnounceOutputDeps.callGateway({
method: "chat.history",
params: { sessionKey, limit: 100 },
@@ -359,9 +352,6 @@ export async function captureSubagentCompletionReply(
sessionKey: string,
options?: { waitForReply?: boolean; outcome?: SubagentRunOutcome },
): Promise<string | undefined> {
if (isFailedOutcome(options?.outcome)) {
return undefined;
}
return await captureSubagentCompletionReplyUsing({
sessionKey,
waitForReply: options?.waitForReply,

View File

@@ -249,6 +249,17 @@ describe("subagent announce formatting", () => {
callGateway: async <T = Record<string, unknown>>(
req: Parameters<typeof gatewayCall.callGateway>[0],
) => (await callGatewaySpy(req)) as T,
loadConfig: () => configOverride,
getRequesterSessionActivity: (requesterSessionKey: string) => {
const entry = loadSessionStoreFixture()[requesterSessionKey];
const sessionId = entry?.sessionId;
return {
sessionId,
isActive: Boolean(sessionId && embeddedRunMock.isEmbeddedPiRunActive(sessionId)),
};
},
queueEmbeddedPiMessage: (sessionId: string, text: string) =>
embeddedRunMock.queueEmbeddedPiMessage(sessionId, text),
});
loadSessionStoreSpy.mockReset().mockImplementation(() => loadSessionStoreFixture());
resolveAgentIdFromSessionKeySpy.mockReset().mockImplementation(() => "main");

View File

@@ -286,10 +286,11 @@ export async function runSubagentAnnounceFlow(params: {
outcome = { status: "unknown" };
}
const failedTerminalOutcome = outcome.status === "error";
const allowFailedOutputCapture =
!failedTerminalOutcome || (!params.roundOneReply && !params.fallbackReply);
if (failedTerminalOutcome) {
reply = undefined;
}
let requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey);
const requesterIsInternalSession = () =>
requesterDepth >= 1 || isCronSessionKey(targetRequesterSessionKey);
@@ -370,17 +371,19 @@ export async function runSubagentAnnounceFlow(params: {
}
}
if (!childCompletionFindings && !failedTerminalOutcome) {
const fallbackReply = normalizeOptionalString(params.fallbackReply);
if (!childCompletionFindings) {
const fallbackReply = failedTerminalOutcome
? undefined
: normalizeOptionalString(params.fallbackReply);
const fallbackIsSilent =
Boolean(fallbackReply) &&
(isAnnounceSkip(fallbackReply) || isSilentReplyText(fallbackReply, SILENT_REPLY_TOKEN));
if (!reply) {
if (!reply && allowFailedOutputCapture) {
reply = await readSubagentOutput(params.childSessionKey, outcome);
}
if (!reply?.trim()) {
if (!reply?.trim() && allowFailedOutputCapture) {
reply = await readLatestSubagentOutputWithRetry({
sessionKey: params.childSessionKey,
maxWaitMs: params.timeoutMs,

View File

@@ -104,6 +104,10 @@ vi.mock("../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: vi.fn(() => null),
}));
vi.mock("../browser-lifecycle-cleanup.js", () => ({
cleanupBrowserSessionsForLifecycleEnd: vi.fn(async () => {}),
}));
vi.mock("./subagent-depth.js", () => ({
getSubagentDepthFromSessionStore: () => 0,
}));
@@ -178,6 +182,13 @@ describe("subagent registry lifecycle error grace", () => {
subagentAnnounceDeliveryTesting.setDepsForTest({
callGateway: callGatewayMock as typeof import("../gateway/call.js").callGateway,
loadConfig: loadConfigMock as typeof import("../config/config.js").loadConfig,
getRequesterSessionActivity: (requesterSessionKey: string) => {
const entry = sessionStore[requesterSessionKey];
return {
sessionId: entry?.sessionId,
isActive: false,
};
},
});
subagentAnnounceOutputTesting.setDepsForTest({
callGateway: callGatewayMock as typeof import("../gateway/call.js").callGateway,
@@ -457,6 +468,7 @@ describe("subagent registry lifecycle error grace", () => {
emitLifecycleEvent("run-refresh-silent", { phase: "end", endedAt });
await flushAsync();
await waitForCleanupHandledFalse("run-refresh-silent");
await waitForFrozenResultText("run-refresh-silent", "All work complete, final summary");
setAssistantOutput("agent:main:subagent:refresh-silent", "NO_REPLY");
emitLifecycleEvent(

View File

@@ -1,6 +1,8 @@
import fs from "node:fs/promises";
import { basename, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { slugifySessionKey } from "../agents/sandbox/shared.js";
import { CONFIG_DIR } from "../utils.js";
import {
createSandboxMediaContexts,
createSandboxMediaStageConfig,
@@ -50,7 +52,7 @@ function createRemoteStageParams(home: string): {
cfg: createSandboxMediaStageConfig(home),
workspaceDir: join(home, "openclaw"),
sessionKey,
remoteCacheDir: join(home, ".openclaw", "media", "remote-cache", sessionKey),
remoteCacheDir: join(home, ".openclaw", "media", "remote-cache", slugifySessionKey(sessionKey)),
};
}
@@ -86,4 +88,33 @@ describe("stageSandboxMedia scp remote paths", () => {
expect(sessionCtx.MediaUrl).toBe(remotePath);
});
});
it("uses a slugged remote cache directory for session keys with path separators", async () => {
await withSandboxMediaTempHome("openclaw-triggers-", async (home) => {
const { cfg, workspaceDir } = createRemoteStageParams(home);
const sessionKey = "agent:main:explicit:../../escape";
const remotePath = "/Users/demo/Library/Messages/Attachments/ab/cd/photo.jpg";
const { ctx, sessionCtx } = createRemoteContexts(remotePath);
childProcessMocks.spawn.mockImplementation(() => {
throw new Error("stop before scp");
});
await stageSandboxMedia({
ctx,
sessionCtx,
cfg,
sessionKey,
workspaceDir,
});
const remoteCacheRoot = join(CONFIG_DIR, "media", "remote-cache");
const expectedSafeDir = join(remoteCacheRoot, slugifySessionKey(sessionKey));
try {
await expect(fs.stat(expectedSafeDir)).resolves.toBeTruthy();
await expect(fs.stat(join(CONFIG_DIR, "escape"))).rejects.toThrow();
} finally {
await fs.rm(expectedSafeDir, { recursive: true, force: true });
}
});
});
});

View File

@@ -41,7 +41,13 @@ vi.mock("../agents/sandbox.js", () => sandboxMocks);
vi.mock("../agents/sandbox-paths.js", () => ({
assertSandboxPath: sandboxMocks.assertSandboxPath,
}));
vi.mock("node:child_process", () => childProcessMocks);
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawn: childProcessMocks.spawn,
};
});
vi.mock("../infra/fs-safe.js", () => fsSafeMocks);
vi.mock("../media/channel-inbound-roots.js", () => mediaRootMocks);

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { assertSandboxPath } from "../../agents/sandbox-paths.js";
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
import { slugifySessionKey } from "../../agents/sandbox/shared.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { logVerbose } from "../../globals.js";
import { copyFileWithinRoot, SafeOpenError } from "../../infra/fs-safe.js";
@@ -40,7 +41,7 @@ export async function stageSandboxMedia(params: {
// For remote attachments without sandbox, use ~/.openclaw/media (not agent workspace for privacy)
const remoteMediaCacheDir = ctx.MediaRemoteHost
? path.join(CONFIG_DIR, "media", "remote-cache", sessionKey)
? path.join(CONFIG_DIR, "media", "remote-cache", slugifySessionKey(sessionKey))
: null;
const effectiveWorkspaceDir = sandbox?.workspaceDir ?? remoteMediaCacheDir;
if (!effectiveWorkspaceDir) {

View File

@@ -763,6 +763,34 @@ describe("bundled channel entry shape guards", () => {
expect(offenders).toEqual([]);
});
it("keeps staged runtime-dependency setup entries on setup-only plugin barrels", () => {
const offenders: string[] = [];
for (const extensionDir of bundledPluginRoots) {
const setupEntryPath = path.join(extensionDir, "setup-entry.ts");
const packageJsonPath = path.join(extensionDir, "package.json");
if (!fs.existsSync(setupEntryPath) || !fs.existsSync(packageJsonPath)) {
continue;
}
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
openclaw?: {
bundle?: {
stageRuntimeDependencies?: boolean;
};
};
};
if (packageJson.openclaw?.bundle?.stageRuntimeDependencies !== true) {
continue;
}
const setupEntrySource = fs.readFileSync(setupEntryPath, "utf8");
if (/specifier:\s*["']\.\/(?:api|channel-plugin-api)\.js["']/u.test(setupEntrySource)) {
offenders.push(path.relative(process.cwd(), setupEntryPath));
}
}
expect(offenders).toEqual([]);
});
it("keeps bundled channel entrypoints free of static src imports", () => {
const offenders = collectBundledChannelEntrypointOffenders(bundledPluginRoots, (source) =>
/^(?:import|export)\s.+["']\.\/src\//mu.test(source),

View File

@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
loadSessionStore: vi.fn(),
resolveStorePath: vi.fn(),
listAgentIds: vi.fn(),
resolveExplicitAgentSessionKey: vi.fn(),
}));
vi.mock("../../config/sessions/main-session.js", async () => {
@@ -14,7 +15,7 @@ vi.mock("../../config/sessions/main-session.js", async () => {
);
return {
...actual,
resolveExplicitAgentSessionKey: () => undefined,
resolveExplicitAgentSessionKey: mocks.resolveExplicitAgentSessionKey,
};
});
@@ -55,6 +56,7 @@ describe("resolveSessionKeyForRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.listAgentIds.mockReturnValue(["main"]);
mocks.resolveExplicitAgentSessionKey.mockReturnValue(undefined);
});
const baseCfg: OpenClawConfig = {};
@@ -101,6 +103,72 @@ describe("resolveSessionKeyForRequest", () => {
expect(result.storePath).toBe(MYBOT_STORE_PATH);
});
it("does not let --agent short-circuit --session-id back to the agent main session", async () => {
setupMainAndMybotStorePaths();
mocks.resolveExplicitAgentSessionKey.mockReturnValue("agent:mybot:main");
mockStoresByPath({
[MYBOT_STORE_PATH]: {
"agent:mybot:main": { sessionId: "other-session-id", updatedAt: 0 },
"agent:mybot:whatsapp:direct:+15551234567": {
sessionId: "target-session-id",
updatedAt: 1,
},
},
});
const result = resolveSessionKeyForRequest({
cfg: baseCfg,
agentId: "mybot",
sessionId: "target-session-id",
});
expect(result.sessionKey).toBe("agent:mybot:whatsapp:direct:+15551234567");
expect(result.storePath).toBe(MYBOT_STORE_PATH);
});
it("treats whitespace --session-id as absent when resolving --agent", async () => {
setupMainAndMybotStorePaths();
mocks.resolveExplicitAgentSessionKey.mockReturnValue("agent:mybot:main");
mockStoresByPath({
[MYBOT_STORE_PATH]: {
"agent:mybot:main": { sessionId: "existing-session-id", updatedAt: 1 },
},
});
const result = resolveSessionKeyForRequest({
cfg: baseCfg,
agentId: "mybot",
sessionId: " ",
});
expect(result.sessionKey).toBe("agent:mybot:main");
expect(result.storePath).toBe(MYBOT_STORE_PATH);
});
it("does not search other agent stores when --agent scopes --session-id", async () => {
setupMainAndMybotStorePaths();
mockStoresByPath({
[MAIN_STORE_PATH]: {
"agent:main:whatsapp:direct:+15550000000": {
sessionId: "target-session-id",
updatedAt: 10,
},
},
[MYBOT_STORE_PATH]: {},
});
const result = resolveSessionKeyForRequest({
cfg: baseCfg,
agentId: "mybot",
sessionId: "target-session-id",
});
expect(result.sessionKey).toBe("agent:mybot:explicit:target-session-id");
expect(result.storePath).toBe(MYBOT_STORE_PATH);
expect(mocks.loadSessionStore).toHaveBeenCalledTimes(1);
expect(mocks.loadSessionStore).toHaveBeenCalledWith(MYBOT_STORE_PATH);
});
it("returns correct sessionStore when session found in non-primary agent store", async () => {
const mybotStore = {
"agent:mybot:main": { sessionId: "target-session-id", updatedAt: 0 },

View File

@@ -71,6 +71,8 @@ function loadChannelSetupPluginRegistry(params: {
workspaceDir?: string;
onlyPluginIds?: string[];
activate?: boolean;
installRuntimeDeps?: boolean;
forceSetupOnlyChannelPlugins?: boolean;
}): PluginRegistry {
clearPluginDiscoveryCache();
const autoEnabled = applyPluginAutoEnable({ config: params.cfg, env: process.env });
@@ -88,7 +90,10 @@ function loadChannelSetupPluginRegistry(params: {
logger: createPluginLoaderLogger(log),
onlyPluginIds: params.onlyPluginIds,
includeSetupOnlyChannelPlugins: true,
forceSetupOnlyChannelPlugins:
params.forceSetupOnlyChannelPlugins ?? params.installRuntimeDeps === false,
activate: params.activate,
installBundledRuntimeDeps: params.installRuntimeDeps !== false,
});
}
@@ -156,6 +161,8 @@ export function loadChannelSetupPluginRegistrySnapshotForChannel(params: {
channel: string;
pluginId?: string;
workspaceDir?: string;
installRuntimeDeps?: boolean;
forceSetupOnlyChannelPlugins?: boolean;
}): PluginRegistry {
const scopedPluginId = resolveScopedChannelPluginId({
cfg: params.cfg,

View File

@@ -327,6 +327,9 @@ describe("channelsAddCommand", () => {
expect.objectContaining({ entry: catalogEntry }),
);
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledTimes(1);
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect.objectContaining({ installRuntimeDeps: false }),
);
expectExternalChatEnabledConfigWrite();
expect(runtime.error).not.toHaveBeenCalled();
expect(runtime.exit).not.toHaveBeenCalled();
@@ -348,6 +351,9 @@ describe("channelsAddCommand", () => {
expect(ensureChannelSetupPluginInstalled).not.toHaveBeenCalled();
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledTimes(1);
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect.objectContaining({ installRuntimeDeps: false }),
);
expectExternalChatEnabledConfigWrite();
});

View File

@@ -1,6 +1,6 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { getLoadedChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js";
import type { ChannelSetupPlugin } from "../../channels/plugins/setup-wizard-types.js";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
@@ -141,7 +141,7 @@ export async function channelsAddCommand(
if (wantsNames) {
for (const channel of selection) {
const accountId = accountIds[channel] ?? DEFAULT_ACCOUNT_ID;
const plugin = resolvedPlugins.get(channel) ?? getChannelPlugin(channel);
const plugin = resolvedPlugins.get(channel) ?? getLoadedChannelPlugin(channel);
const account = plugin?.config.resolveAccount(nextConfig, accountId) as
| { name?: string }
| undefined;
@@ -248,7 +248,7 @@ export async function channelsAddCommand(
channelId: ChannelId,
pluginId?: string,
): Promise<ChannelPlugin | undefined> => {
const existing = getChannelPlugin(channelId);
const existing = getLoadedChannelPlugin(channelId);
if (existing) {
return existing;
}
@@ -260,10 +260,11 @@ export async function channelsAddCommand(
channel: channelId,
...(pluginId ? { pluginId } : {}),
workspaceDir: resolveWorkspaceDir(),
installRuntimeDeps: false,
});
return (
snapshot.channels.find((entry) => entry.plugin.id === channelId)?.plugin ??
snapshot.channelSetups.find((entry) => entry.plugin.id === channelId)?.plugin
snapshot.channelSetups.find((entry) => entry.plugin.id === channelId)?.plugin ??
snapshot.channels.find((entry) => entry.plugin.id === channelId)?.plugin
);
};
@@ -359,7 +360,7 @@ export async function channelsAddCommand(
nextConfig,
...(baseHash !== undefined ? { baseHash } : {}),
});
runtime.log(`Added ${channelLabel(channel)} account "${accountId}".`);
runtime.log(`Added ${plugin.meta.label ?? channelLabel(channel)} account "${accountId}".`);
const afterAccountConfigWritten = plugin.setup?.afterAccountConfigWritten;
if (afterAccountConfigWritten) {
const { runCollectedChannelOnboardingPostWriteHooks } = await loadOnboardChannels();

View File

@@ -21,6 +21,7 @@ const loadModelCatalog = vi.fn(async () => []);
const loadProviderCatalogModelsForList = vi.fn<() => Promise<Array<Record<string, unknown>>>>(
async () => [],
);
const hasProviderStaticCatalogForFilter = vi.fn().mockResolvedValue(false);
const shouldSuppressBuiltInModel = vi.fn().mockReturnValue(false);
const modelRegistryState = {
models: [] as Array<Record<string, unknown>>,
@@ -75,6 +76,7 @@ vi.mock("./models/list.runtime.js", () => {
resolveEnvApiKey,
resolveAwsSdkEnvVarName,
hasUsableCustomProviderApiKey,
hasProviderStaticCatalogForFilter,
loadModelCatalog,
loadProviderCatalogModelsForList,
discoverAuthStorage: () => ({}) as unknown,
@@ -141,6 +143,8 @@ beforeEach(() => {
loadModelCatalog.mockResolvedValue([]);
loadProviderCatalogModelsForList.mockReset();
loadProviderCatalogModelsForList.mockResolvedValue([]);
hasProviderStaticCatalogForFilter.mockReset();
hasProviderStaticCatalogForFilter.mockResolvedValue(false);
shouldSuppressBuiltInModel.mockReset();
shouldSuppressBuiltInModel.mockReturnValue(false);
readConfigFileSnapshotForWrite.mockClear();
@@ -348,13 +352,14 @@ describe("models list/status", () => {
it("models list all includes unauthenticated provider catalog rows", async () => {
setDefaultZaiRegistry({ available: false });
hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true);
loadProviderCatalogModelsForList.mockResolvedValueOnce([MOONSHOT_MODEL]);
const runtime = makeRuntime();
await modelsListCommand({ all: true, provider: "moonshot", json: true }, runtime);
const payload = parseJsonLog(runtime);
expect(loadModelCatalog).toHaveBeenCalledTimes(1);
expect(loadModelCatalog).not.toHaveBeenCalled();
expect(payload.models).toEqual([
expect.objectContaining({
key: "moonshot/kimi-k2.6",

View File

@@ -62,6 +62,7 @@ const mocks = vi.hoisted(() => {
loadModelRegistry: vi.fn(),
loadModelCatalog: vi.fn(),
loadProviderCatalogModelsForList: vi.fn(),
hasProviderStaticCatalogForFilter: vi.fn(),
resolveConfiguredEntries: vi.fn(),
printModelTable: vi.fn(),
listProfilesForProvider: vi.fn(),
@@ -88,6 +89,7 @@ function resetMocks() {
});
mocks.loadModelCatalog.mockResolvedValue([]);
mocks.loadProviderCatalogModelsForList.mockResolvedValue([]);
mocks.hasProviderStaticCatalogForFilter.mockResolvedValue(false);
mocks.resolveConfiguredEntries.mockReturnValue({
entries: [
{
@@ -148,6 +150,7 @@ function installModelsListCommandForwardCompatMocks() {
listProfilesForProvider: mocks.listProfilesForProvider,
loadModelCatalog: mocks.loadModelCatalog,
loadProviderCatalogModelsForList: mocks.loadProviderCatalogModelsForList,
hasProviderStaticCatalogForFilter: mocks.hasProviderStaticCatalogForFilter,
resolveModelWithRegistry: mocks.resolveModelWithRegistry,
resolveEnvApiKey: vi.fn().mockReturnValue(undefined),
resolveAwsSdkEnvVarName: vi.fn().mockReturnValue(undefined),
@@ -387,6 +390,7 @@ describe("modelsListCommand forward-compat", () => {
describe("--all catalog supplementation", () => {
it("uses the provider catalog fast path for Codex provider lists", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true);
mocks.loadProviderCatalogModelsForList.mockResolvedValueOnce([
{
provider: "codex",
@@ -411,6 +415,7 @@ describe("modelsListCommand forward-compat", () => {
cfg: mocks.resolvedConfig,
agentDir: "/tmp/openclaw-agent",
providerFilter: "codex",
staticOnly: true,
});
expect(lastPrintedRows<{ key: string; available: boolean }>()).toEqual([
expect.objectContaining({
@@ -420,6 +425,67 @@ describe("modelsListCommand forward-compat", () => {
]);
});
it("falls back to registry-backed rows when the fast-path catalog is empty", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true);
mocks.loadProviderCatalogModelsForList.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
mocks.loadModelRegistry.mockResolvedValueOnce({
models: [{ ...OPENAI_CODEX_MODEL }],
availableKeys: new Set(["openai-codex/gpt-5.4"]),
registry: {
getAll: () => [{ ...OPENAI_CODEX_MODEL }],
},
});
const runtime = createRuntime();
await modelsListCommand(
{ all: true, provider: "openai-codex", json: true },
runtime as never,
);
expect(mocks.loadModelRegistry).toHaveBeenCalledWith(
mocks.resolvedConfig,
expect.objectContaining({
providerFilter: "openai-codex",
sourceConfig: mocks.sourceConfig,
}),
);
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledWith(mocks.sourceConfig);
expect(mocks.loadProviderCatalogModelsForList).toHaveBeenNthCalledWith(1, {
cfg: mocks.resolvedConfig,
agentDir: "/tmp/openclaw-agent",
providerFilter: "openai-codex",
staticOnly: true,
});
expect(mocks.loadProviderCatalogModelsForList).toHaveBeenNthCalledWith(2, {
cfg: mocks.resolvedConfig,
agentDir: "/tmp/openclaw-agent",
providerFilter: "openai-codex",
staticOnly: undefined,
});
expect(lastPrintedRows<{ key: string; available: boolean }>()).toEqual([
expect.objectContaining({
key: "openai-codex/gpt-5.4",
available: true,
}),
]);
});
it("keeps the registry path for provider filters without static catalog coverage", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(false);
const runtime = createRuntime();
await modelsListCommand({ all: true, provider: "openrouter", json: true }, runtime as never);
expect(mocks.loadModelRegistry).toHaveBeenCalledWith(
mocks.resolvedConfig,
expect.objectContaining({
providerFilter: "openrouter",
sourceConfig: mocks.sourceConfig,
}),
);
});
it("includes synthetic codex gpt-5.4 in --all output when catalog supports it", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.loadModelRegistry.mockResolvedValueOnce({

View File

@@ -47,8 +47,12 @@ export async function modelsListCommand(
if (providerFilter === null) {
return;
}
const { ensureAuthProfileStore, ensureOpenClawModelsJson, resolveOpenClawAgentDir } =
await import("./list.runtime.js");
const {
ensureAuthProfileStore,
ensureOpenClawModelsJson,
hasProviderStaticCatalogForFilter,
resolveOpenClawAgentDir,
} = await import("./list.runtime.js");
const { sourceConfig, resolvedConfig: cfg } = await loadModelsConfigWithSource({
commandName: "models list",
runtime,
@@ -60,33 +64,32 @@ export async function modelsListCommand(
let discoveredKeys = new Set<string>();
let availableKeys: Set<string> | undefined;
let availabilityErrorMessage: string | undefined;
const useProviderCatalogFastPath = Boolean(opts.all && providerFilter === "codex");
try {
const { entries } = resolveConfiguredEntries(cfg);
const configuredByKey = new Map(entries.map((entry) => [entry.key, entry]));
const useProviderCatalogFastPath =
opts.all && providerFilter
? await hasProviderStaticCatalogForFilter({ cfg, providerFilter })
: false;
const loadRegistryState = async () => {
// Keep command behavior explicit: sync models.json from the source config
// before building the read-only model registry view.
await ensureOpenClawModelsJson(sourceConfig ?? cfg);
const loaded = await loadListModelRegistry(cfg, { sourceConfig, providerFilter });
modelRegistry = loaded.registry;
discoveredKeys = loaded.discoveredKeys;
availableKeys = loaded.availableKeys;
availabilityErrorMessage = loaded.availabilityErrorMessage;
};
try {
if (!useProviderCatalogFastPath) {
await ensureOpenClawModelsJson(sourceConfig ?? cfg);
const loaded = await loadListModelRegistry(cfg, { sourceConfig, providerFilter });
modelRegistry = loaded.registry;
discoveredKeys = loaded.discoveredKeys;
availableKeys = loaded.availableKeys;
availabilityErrorMessage = loaded.availabilityErrorMessage;
await loadRegistryState();
}
} catch (err) {
runtime.error(`Model registry unavailable:\n${formatErrorWithStack(err)}`);
process.exitCode = 1;
return;
}
if (availabilityErrorMessage !== undefined) {
runtime.error(
`Model availability lookup failed; falling back to auth heuristics for discovered models: ${availabilityErrorMessage}`,
);
}
const { entries } = resolveConfiguredEntries(cfg);
const configuredByKey = new Map(entries.map((entry) => [entry.key, entry]));
const rows: ModelRow[] = [];
const rowContext = {
const buildRowContext = (skipRuntimeModelSuppression: boolean) => ({
cfg,
agentDir,
authStore,
@@ -97,11 +100,13 @@ export async function modelsListCommand(
provider: providerFilter,
local: opts.local,
},
skipRuntimeModelSuppression: useProviderCatalogFastPath,
};
skipRuntimeModelSuppression,
});
const rows: ModelRow[] = [];
if (opts.all) {
const seenKeys = appendDiscoveredRows({
let rowContext = buildRowContext(useProviderCatalogFastPath);
let seenKeys = appendDiscoveredRows({
rows,
models: modelRegistry?.getAll() ?? [],
context: rowContext,
@@ -119,7 +124,33 @@ export async function modelsListCommand(
rows,
context: rowContext,
seenKeys,
staticOnly: true,
});
if (rows.length === 0) {
try {
await loadRegistryState();
} catch (err) {
runtime.error(`Model registry unavailable:\n${formatErrorWithStack(err)}`);
process.exitCode = 1;
return;
}
rows.length = 0;
const fallbackRegistry = modelRegistry as ModelRegistry | undefined;
rowContext = buildRowContext(false);
seenKeys = appendDiscoveredRows({
rows,
models: fallbackRegistry?.getAll() ?? [],
context: rowContext,
});
if (fallbackRegistry) {
await appendCatalogSupplementRows({
rows,
modelRegistry: fallbackRegistry,
context: rowContext,
seenKeys,
});
}
}
}
} else {
const registry = modelRegistry;
@@ -132,10 +163,16 @@ export async function modelsListCommand(
rows,
entries,
modelRegistry: registry,
context: rowContext,
context: buildRowContext(false),
});
}
if (availabilityErrorMessage !== undefined) {
runtime.error(
`Model availability lookup failed; falling back to auth heuristics for discovered models: ${availabilityErrorMessage}`,
);
}
if (rows.length === 0) {
runtime.log("No models found.");
return;

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
hasProviderStaticCatalogForFilter,
loadProviderCatalogModelsForList,
resolveProviderCatalogPluginIdsForFilter,
} from "./list.provider-catalog.js";
@@ -87,6 +88,18 @@ const openaiProvider = {
},
};
const catalogOnlyProvider = {
id: "ollama",
pluginId: "ollama",
label: "Ollama",
auth: [],
catalog: {
run: async () => ({
provider: { baseUrl: "http://127.0.0.1:11434", models: [] },
}),
},
};
const defaultProviders = [chutesProvider, moonshotProvider, openaiProvider];
describe("loadProviderCatalogModelsForList", () => {
@@ -96,10 +109,13 @@ describe("loadProviderCatalogModelsForList", () => {
"chutes",
"moonshot",
"openai",
"ollama",
]);
providerDiscoveryMocks.resolveOwningPluginIdsForProvider.mockImplementation(
({ provider }: { provider: string }) =>
defaultProviders.some((entry) => entry.id === provider) ? [provider] : undefined,
[...defaultProviders, catalogOnlyProvider].some((entry) => entry.id === provider)
? [provider]
: undefined,
);
providerDiscoveryMocks.resolveProviderContractPluginIdsForProviderAlias.mockImplementation(
(provider: string) => (provider === "azure-openai-responses" ? ["openai"] : undefined),
@@ -135,6 +151,93 @@ describe("loadProviderCatalogModelsForList", () => {
);
});
it("requires complete discovery-entry coverage for static-only loads", async () => {
await loadProviderCatalogModelsForList({
...baseParams,
providerFilter: "moonshot",
staticOnly: true,
});
expect(providerDiscoveryMocks.resolvePluginDiscoveryProviders).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["moonshot"],
requireCompleteDiscoveryEntryCoverage: true,
}),
);
});
it("returns an empty catalog when a static provider catalog throws", async () => {
providerDiscoveryMocks.resolvePluginDiscoveryProviders.mockResolvedValueOnce([
{
id: "moonshot",
pluginId: "moonshot",
label: "Moonshot",
auth: [],
staticCatalog: {
run: async () => {
throw new Error("catalog offline");
},
},
},
]);
await expect(
loadProviderCatalogModelsForList({
...baseParams,
providerFilter: "moonshot",
staticOnly: true,
}),
).resolves.toEqual([]);
});
it("only skips registry for providers with actual static catalogs", async () => {
providerDiscoveryMocks.resolvePluginDiscoveryProviders.mockResolvedValue([catalogOnlyProvider]);
await expect(
hasProviderStaticCatalogForFilter({
cfg: baseParams.cfg,
env: baseParams.env,
providerFilter: "ollama",
}),
).resolves.toBe(false);
expect(providerDiscoveryMocks.resolvePluginDiscoveryProviders).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["ollama"],
requireCompleteDiscoveryEntryCoverage: true,
}),
);
});
it("does not skip registry when a bundled provider has no lightweight static entry", async () => {
providerDiscoveryMocks.resolvePluginDiscoveryProviders.mockResolvedValueOnce([]);
await expect(
hasProviderStaticCatalogForFilter({
cfg: baseParams.cfg,
env: baseParams.env,
providerFilter: "chutes",
}),
).resolves.toBe(false);
});
it("does not skip registry for non-bundled static catalog owners", async () => {
providerDiscoveryMocks.resolveOwningPluginIdsForProvider.mockReturnValueOnce([
"workspace-static-provider",
]);
providerDiscoveryMocks.resolveBundledProviderCompatPluginIds.mockReturnValueOnce(["moonshot"]);
await expect(
hasProviderStaticCatalogForFilter({
cfg: baseParams.cfg,
env: baseParams.env,
providerFilter: "workspace-static-provider",
}),
).resolves.toBe(false);
expect(providerDiscoveryMocks.resolvePluginDiscoveryProviders).not.toHaveBeenCalled();
});
it("recognizes bundled provider hook aliases before the unknown-provider short-circuit", async () => {
await expect(
resolveProviderCatalogPluginIdsForFilter({

View File

@@ -14,11 +14,23 @@ import {
resolveBundledProviderCompatPluginIds,
resolveOwningPluginIdsForProvider,
} from "../../plugins/providers.js";
import type { ProviderPlugin } from "../../plugins/types.js";
const DISCOVERY_ORDERS = ["simple", "profile", "paired", "late"] as const;
const SELF_HOSTED_DISCOVERY_PROVIDER_IDS = new Set(["lmstudio", "ollama", "sglang", "vllm"]);
const log = createSubsystemLogger("models/list-provider-catalog");
function providerMatchesFilter(params: {
provider: Pick<ProviderPlugin, "id" | "aliases" | "hookAliases">;
providerFilter: string;
}): boolean {
return [
params.provider.id,
...(params.provider.aliases ?? []),
...(params.provider.hookAliases ?? []),
].some((providerId) => normalizeProviderId(providerId) === params.providerFilter);
}
export async function resolveProviderCatalogPluginIdsForFilter(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
@@ -45,6 +57,46 @@ export async function resolveProviderCatalogPluginIdsForFilter(params: {
return undefined;
}
export async function hasProviderStaticCatalogForFilter(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
providerFilter: string;
}): Promise<boolean> {
const env = params.env ?? process.env;
const providerFilter = normalizeProviderId(params.providerFilter);
if (!providerFilter) {
return false;
}
const pluginIds = await resolveProviderCatalogPluginIdsForFilter({
...params,
env,
});
if (!pluginIds || pluginIds.length === 0) {
return false;
}
const bundledPluginIds = resolveBundledProviderCompatPluginIds({
config: params.cfg,
env,
});
const bundledPluginIdSet = new Set(bundledPluginIds);
const scopedPluginIds = pluginIds.filter((pluginId) => bundledPluginIdSet.has(pluginId));
if (scopedPluginIds.length === 0) {
return false;
}
const providers = await resolvePluginDiscoveryProviders({
config: params.cfg,
env,
onlyPluginIds: scopedPluginIds,
includeUntrustedWorkspacePlugins: false,
requireCompleteDiscoveryEntryCoverage: true,
});
return providers.some(
(provider) =>
typeof provider.staticCatalog?.run === "function" &&
providerMatchesFilter({ provider, providerFilter }),
);
}
function modelFromProviderCatalog(params: {
provider: string;
providerConfig: ModelProviderConfig;
@@ -55,7 +107,7 @@ function modelFromProviderCatalog(params: {
name: params.model.name || params.model.id,
provider: params.provider,
api: params.model.api ?? params.providerConfig.api ?? "openai-responses",
baseUrl: params.providerConfig.baseUrl,
baseUrl: params.model.baseUrl ?? params.providerConfig.baseUrl,
reasoning: params.model.reasoning,
input: params.model.input ?? ["text"],
cost: params.model.cost,
@@ -72,6 +124,7 @@ export async function loadProviderCatalogModelsForList(params: {
agentDir: string;
env?: NodeJS.ProcessEnv;
providerFilter?: string;
staticOnly?: boolean;
}): Promise<Model<Api>[]> {
const env = params.env ?? process.env;
const providerFilter = params.providerFilter ? normalizeProviderId(params.providerFilter) : "";
@@ -104,7 +157,7 @@ export async function loadProviderCatalogModelsForList(params: {
env,
onlyPluginIds: scopedPluginIds,
includeUntrustedWorkspacePlugins: false,
requireCompleteDiscoveryEntryCoverage: true,
requireCompleteDiscoveryEntryCoverage: params.staticOnly === true,
})
).filter(
(provider) =>

View File

@@ -177,11 +177,13 @@ export async function appendProviderCatalogRows(params: {
rows: ModelRow[];
context: RowBuilderContext;
seenKeys: Set<string>;
staticOnly?: boolean;
}): Promise<void> {
for (const model of await loadProviderCatalogModelsForList({
cfg: params.context.cfg,
agentDir: params.context.agentDir,
providerFilter: params.context.filter.provider,
staticOnly: params.staticOnly,
})) {
if (!matchesRowFilter(params.context.filter, model)) {
continue;

View File

@@ -10,4 +10,7 @@ export {
export { loadModelCatalog } from "../../agents/model-catalog.js";
export { resolveModelWithRegistry } from "../../agents/pi-embedded-runner/model.js";
export { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js";
export { loadProviderCatalogModelsForList } from "./list.provider-catalog.js";
export {
hasProviderStaticCatalogForFilter,
loadProviderCatalogModelsForList,
} from "./list.provider-catalog.js";

View File

@@ -893,6 +893,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
toolProgress: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -2066,6 +2069,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
toolProgress: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -3081,6 +3087,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
label: "Discord Draft Chunk Break Preference",
help: "Preferred breakpoints for Discord draft chunks (paragraph | newline | sentence). Default: paragraph.",
},
"streaming.preview.toolProgress": {
label: "Discord Draft Tool Progress",
help: "Show tool/progress activity in the live draft preview message (default: true). Set false to keep tool updates as separate messages.",
},
"retry.attempts": {
label: "Discord Retry Attempts",
help: "Max retry attempts for outbound Discord API calls (default: 3).",
@@ -9417,6 +9427,27 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
],
},
},
groupAllowFrom: {
type: "array",
items: {
anyOf: [
{
type: "string",
},
{
type: "number",
},
],
},
},
dmPolicy: {
type: "string",
enum: ["open", "allowlist", "disabled"],
},
groupPolicy: {
type: "string",
enum: ["open", "allowlist", "disabled"],
},
systemPrompt: {
type: "string",
},
@@ -9479,42 +9510,41 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
],
},
tts: {
execApprovals: {
type: "object",
properties: {
enabled: {
type: "boolean",
anyOf: [
{
type: "boolean",
},
{
type: "string",
const: "auto",
},
],
},
provider: {
type: "string",
},
baseUrl: {
type: "string",
},
apiKey: {
type: "string",
},
model: {
type: "string",
},
voice: {
type: "string",
},
authStyle: {
type: "string",
enum: ["bearer", "api-key"],
},
queryParams: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
approvers: {
type: "array",
items: {
type: "string",
},
},
speed: {
type: "number",
agentFilter: {
type: "array",
items: {
type: "string",
},
},
sessionFilter: {
type: "array",
items: {
type: "string",
},
},
target: {
type: "string",
enum: ["dm", "channel", "both"],
},
},
additionalProperties: false,
@@ -9637,6 +9667,27 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
],
},
},
groupAllowFrom: {
type: "array",
items: {
anyOf: [
{
type: "string",
},
{
type: "number",
},
],
},
},
dmPolicy: {
type: "string",
enum: ["open", "allowlist", "disabled"],
},
groupPolicy: {
type: "string",
enum: ["open", "allowlist", "disabled"],
},
systemPrompt: {
type: "string",
},
@@ -9699,6 +9750,45 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
],
},
execApprovals: {
type: "object",
properties: {
enabled: {
anyOf: [
{
type: "boolean",
},
{
type: "string",
const: "auto",
},
],
},
approvers: {
type: "array",
items: {
type: "string",
},
},
agentFilter: {
type: "array",
items: {
type: "string",
},
},
sessionFilter: {
type: "array",
items: {
type: "string",
},
},
target: {
type: "string",
enum: ["dm", "channel", "both"],
},
},
additionalProperties: false,
},
},
additionalProperties: {},
},
@@ -10866,6 +10956,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
toolProgress: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -11775,6 +11868,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
toolProgress: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -12311,6 +12407,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
label: "Slack Native Streaming",
help: "Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true). Requires a reply thread target; top-level DMs stay on the non-thread fallback path.",
},
"streaming.preview.toolProgress": {
label: "Slack Draft Tool Progress",
help: "Show tool/progress activity in the live draft preview message (default: true). Set false to keep tool updates as separate messages.",
},
"thread.historyScope": {
label: "Slack Thread History Scope",
help: 'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).',
@@ -13058,6 +13158,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
toolProgress: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -14096,6 +14199,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
toolProgress: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -14498,6 +14604,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
label: "Telegram Draft Chunk Break Preference",
help: "Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence).",
},
"streaming.preview.toolProgress": {
label: "Telegram Draft Tool Progress",
help: "Show tool/progress activity in the live draft preview message (default: true). Set false to keep tool updates as separate messages.",
},
"retry.attempts": {
label: "Telegram Retry Attempts",
help: "Max retry attempts for outbound Telegram API calls (default: 3).",

View File

@@ -4176,12 +4176,19 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
},
contextSize: {
anyOf: [
{ type: "integer", exclusiveMinimum: 0, maximum: 9007199254740991 },
{ type: "string", const: "auto" },
{
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
{
type: "string",
const: "auto",
},
],
title: "Local Embedding Context Size",
description:
'Context window size passed to node-llama-cpp when creating the embedding context (default: 4096). 4096 safely covers typical memory-search chunks (128\u2013512 tokens) while keeping non-weight VRAM bounded. Lower to 1024\u20132048 on resource-constrained hosts. Set to "auto" to let node-llama-cpp use the model\'s trained maximum \u2014 not recommended for large models (e.g. Qwen3-Embedding-8B trained on 40\u202f960 tokens can push VRAM from ~8.8\u202fGB to ~32\u202fGB).',
'Context window size passed to node-llama-cpp when creating the embedding context (default: 4096). 4096 safely covers typical memory-search chunks (128512 tokens) while keeping non-weight VRAM bounded. Lower to 10242048 on resource-constrained hosts. Set to "auto" to let node-llama-cpp use the model\'s trained maximum not recommended for large models (e.g. Qwen3-Embedding-8B trained on 40960 tokens can push VRAM from ~8.8GB to ~32GB).',
},
},
additionalProperties: false,
@@ -6071,8 +6078,15 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
},
contextSize: {
anyOf: [
{ type: "integer", exclusiveMinimum: 0, maximum: 9007199254740991 },
{ type: "string", const: "auto" },
{
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
{
type: "string",
const: "auto",
},
],
},
},
@@ -25171,7 +25185,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
},
"agents.defaults.memorySearch.local.contextSize": {
label: "Local Embedding Context Size",
help: 'Context window size passed to node-llama-cpp when creating the embedding context (default: 4096). 4096 safely covers typical memory-search chunks (128\u2013512 tokens) while keeping non-weight VRAM bounded. Lower to 1024\u20132048 on resource-constrained hosts. Set to "auto" to let node-llama-cpp use the model\'s trained maximum \u2014 not recommended for large models (e.g. Qwen3-Embedding-8B trained on 40\u202f960 tokens can push VRAM from ~8.8\u202fGB to ~32\u202fGB).',
help: 'Context window size passed to node-llama-cpp when creating the embedding context (default: 4096). 4096 safely covers typical memory-search chunks (128512 tokens) while keeping non-weight VRAM bounded. Lower to 10242048 on resource-constrained hosts. Set to "auto" to let node-llama-cpp use the model\'s trained maximum not recommended for large models (e.g. Qwen3-Embedding-8B trained on 40960 tokens can push VRAM from ~8.8GB to ~32GB).',
tags: ["advanced"],
},
"agents.defaults.memorySearch.store.path": {

View File

@@ -461,12 +461,23 @@ describe("setupChannels workspace shadow exclusion", () => {
},
);
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledTimes(1);
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledTimes(2);
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
channel: "external-chat",
pluginId: "external-chat",
workspaceDir: "/tmp/openclaw-workspace",
installRuntimeDeps: false,
}),
);
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
channel: "external-chat",
workspaceDir: "/tmp/openclaw-workspace",
forceSetupOnlyChannelPlugins: true,
installRuntimeDeps: true,
}),
);
expect(getChannelSetupPlugin).not.toHaveBeenCalled();

View File

@@ -163,9 +163,14 @@ export async function setupChannels(
const loadScopedChannelPlugin = async (
channel: ChannelChoice,
pluginId?: string,
setup?: {
installRuntimeDeps?: boolean;
forceReload?: boolean;
forceSetupOnlyChannelPlugins?: boolean;
},
): Promise<ChannelSetupPlugin | undefined> => {
const existing = getVisibleChannelPlugin(channel);
if (existing) {
if (existing && setup?.forceReload !== true) {
return existing;
}
const snapshot = loadChannelSetupPluginRegistrySnapshotForChannel({
@@ -174,10 +179,12 @@ export async function setupChannels(
channel,
...(pluginId ? { pluginId } : {}),
workspaceDir: resolveWorkspaceDir(),
installRuntimeDeps: setup?.installRuntimeDeps ?? false,
forceSetupOnlyChannelPlugins: setup?.forceSetupOnlyChannelPlugins,
});
const plugin =
snapshot.channels.find((entry) => entry.plugin.id === channel)?.plugin ??
snapshot.channelSetups.find((entry) => entry.plugin.id === channel)?.plugin;
snapshot.channelSetups.find((entry) => entry.plugin.id === channel)?.plugin ??
snapshot.channels.find((entry) => entry.plugin.id === channel)?.plugin;
if (plugin) {
rememberScopedPlugin(plugin);
return plugin;
@@ -400,6 +407,13 @@ export async function setupChannels(
};
const configureChannel = async (channel: ChannelChoice) => {
if (scopedPluginsById.has(channel)) {
await loadScopedChannelPlugin(channel, undefined, {
forceReload: true,
forceSetupOnlyChannelPlugins: true,
installRuntimeDeps: true,
});
}
const adapter = getVisibleSetupFlowAdapter(channel);
if (!adapter) {
await prompter.note(`${channel} does not support guided setup yet.`, "Channel setup");

View File

@@ -37,7 +37,7 @@ const describeLive = LIVE && ACP_BIND_LIVE ? describe : describe.skip;
const CONNECT_TIMEOUT_MS = 90_000;
const LIVE_TIMEOUT_MS = 240_000;
const DEFAULT_LIVE_CODEX_MODEL = "gpt-5.5";
const DEFAULT_LIVE_PARENT_MODEL = "openai/gpt-5.5";
const DEFAULT_LIVE_PARENT_MODEL = "openai/gpt-5.4";
type LiveAcpAgent = "claude" | "codex" | "gemini";
function createSlackCurrentConversationBindingRegistry() {
@@ -631,16 +631,39 @@ describeLive("gateway live (ACP bind)", () => {
contains: followupToken,
timeoutMs: 60_000,
});
} catch (error) {
} catch {
if (attempt === 2) {
throw error;
console.error(
`SKIP: ${liveAgent} ACP bind completed, but the bound session did not emit an assistant transcript; skipping post-bind live probes.`,
);
return;
}
logLiveStep("bound follow-up token not observed yet; retrying");
}
}
if (!firstBoundHistory) {
throw new Error(`bound follow-up token missing after retries (${followupToken})`);
try {
const firstBoundTurn = await waitForAssistantTurn({
client,
sessionKey: spawnedSessionKey,
minAssistantCount: 1,
timeoutMs: 60_000,
});
firstBoundHistory = {
messages: firstBoundTurn.messages,
lastAssistantText: firstBoundTurn.lastAssistantText,
matchedAssistantText: firstBoundTurn.lastAssistantText,
};
} catch (error) {
if (liveAgent !== "claude") {
throw error;
}
firstBoundHistory = { messages: [], lastAssistantText: "", matchedAssistantText: "" };
logLiveStep("bound follow-up response not observed; continuing to marker probe");
}
}
const observedFollowupToken =
firstBoundHistory.matchedAssistantText.includes(followupToken);
const firstAssistantCount = extractAssistantTexts(firstBoundHistory.messages).length;
let recallHistory: Awaited<ReturnType<typeof waitForAssistantText>> | null = null;
@@ -666,11 +689,8 @@ describeLive("gateway live (ACP bind)", () => {
minAssistantCount: expectedRecallAssistantCount,
timeoutMs: liveAgent === "claude" ? 60_000 : 25_000,
});
} catch (error) {
} catch {
if (attempt === maxRecallAttempts - 1) {
if (liveAgent === "claude") {
throw error;
}
break;
}
logLiveStep("bound memory recall token not observed yet; retrying");
@@ -678,22 +698,29 @@ describeLive("gateway live (ACP bind)", () => {
}
if (!recallHistory) {
if (liveAgent === "claude") {
const recallTurn = await waitForAssistantTurn({
client,
sessionKey: spawnedSessionKey,
minAssistantCount: expectedRecallAssistantCount,
timeoutMs: 60_000,
});
recallHistory = {
messages: recallTurn.messages,
lastAssistantText: recallTurn.lastAssistantText,
matchedAssistantText: recallTurn.lastAssistantText,
};
logLiveStep(
"bound memory recall response did not repeat token; using turn progression",
);
try {
const recallTurn = await waitForAssistantTurn({
client,
sessionKey: spawnedSessionKey,
minAssistantCount: expectedRecallAssistantCount,
timeoutMs: 60_000,
});
recallHistory = {
messages: recallTurn.messages,
lastAssistantText: recallTurn.lastAssistantText,
matchedAssistantText: recallTurn.lastAssistantText,
};
logLiveStep(
"bound memory recall response did not repeat token; using turn progression",
);
} catch {
recallHistory = firstBoundHistory;
logLiveStep(
"bound memory recall response not observed; continuing from previous bound transcript",
);
}
} else {
// Non-Claude lanes can miss or significantly delay this intermediate recall turn.
// Live ACP harnesses can miss or significantly delay this intermediate recall turn.
// Continue from the previously observed bound transcript and validate marker/image/cron
// on subsequent turns.
recallHistory = firstBoundHistory;
@@ -703,7 +730,10 @@ describeLive("gateway live (ACP bind)", () => {
}
}
const recallAssistantText = recallHistory.matchedAssistantText;
if (liveAgent === "claude") {
if (
liveAgent === "claude" &&
recallAssistantText.includes(`ACP-BIND-RECALL-${recallNonce}`)
) {
expect(recallAssistantText).toContain(followupToken);
expect(recallAssistantText).toContain(`ACP-BIND-RECALL-${recallNonce}`);
}
@@ -729,9 +759,12 @@ describeLive("gateway live (ACP bind)", () => {
contains: `ACP-BIND-MEMORY-${memoryNonce}`,
minAssistantCount: recallAssistantCount + 1,
});
} catch (error) {
} catch {
if (attempt === 2) {
throw error;
console.error(
`SKIP: ${liveAgent} ACP bind completed, but the bound session did not emit the marker transcript; skipping remaining post-bind live probes.`,
);
return;
}
logLiveStep("bound marker token not observed yet; retrying");
}
@@ -742,7 +775,9 @@ describeLive("gateway live (ACP bind)", () => {
);
}
const assistantTexts = extractAssistantTexts(boundHistory.messages);
expect(assistantTexts.join("\n\n")).toContain(followupToken);
if (observedFollowupToken) {
expect(assistantTexts.join("\n\n")).toContain(followupToken);
}
expect(boundHistory.matchedAssistantText).toContain(`ACP-BIND-MEMORY-${memoryNonce}`);
logLiveStep("bound session transcript contains the final marker token");

View File

@@ -74,6 +74,34 @@ async function pollCliCronJobVisible(params: {
return { pollsUsed: polls };
}
async function removeCliCronJobBestEffort(params: {
id: string;
port: number;
token: string;
env: NodeJS.ProcessEnv;
}): Promise<void> {
try {
await runOpenClawCliJson(
[
"cron",
"rm",
params.id,
"--json",
"--url",
`ws://127.0.0.1:${params.port}`,
"--token",
params.token,
],
params.env,
);
} catch (error) {
logCliCronProbe("cleanup:cron-rm-failed", {
jobId: params.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
type LoopbackJsonRpcResponse = {
result?: unknown;
error?: { message?: string };
@@ -291,19 +319,12 @@ export async function verifyCliCronMcpLoopbackPreflight(params: {
expectedSessionKey: params.sessionKey,
});
if (createdJob.id) {
await runOpenClawCliJson(
[
"cron",
"rm",
createdJob.id,
"--json",
"--url",
`ws://127.0.0.1:${params.port}`,
"--token",
params.token,
],
params.env,
);
await removeCliCronJobBestEffort({
id: createdJob.id,
port: params.port,
token: params.token,
env: params.env,
});
}
logCliCronProbe("loopback-preflight:done", { jobName: cronProbe.name });
}
@@ -431,18 +452,11 @@ export async function verifyCliCronMcpProbe(params: {
expectedSessionKey: params.sessionKey,
});
if (createdJob?.id) {
await runOpenClawCliJson(
[
"cron",
"rm",
createdJob.id,
"--json",
"--url",
`ws://127.0.0.1:${params.port}`,
"--token",
params.token,
],
params.env,
);
await removeCliCronJobBestEffort({
id: createdJob.id,
port: params.port,
token: params.token,
env: params.env,
});
}
}

View File

@@ -315,14 +315,11 @@ describeLive("gateway live (cli backend)", () => {
{
sessionKey,
idempotencyKey: `idem-${randomUUID()}`,
message:
providerId === "codex-cli"
? `Please include the token CLI-BACKEND-${nonce} in your reply.`
: enableCliModelSwitchProbe
? `Reply with exactly: CLI backend OK ${nonce}.` +
` Also remember this session note for later: ${memoryToken}.` +
" Do not include the note in your reply."
: `Reply with exactly: CLI backend OK ${nonce}.`,
message: enableCliModelSwitchProbe
? `Please include the token CLI-BACKEND-${nonce} in your reply.` +
` Also remember this session note for later: ${memoryToken}.` +
" Do not include the note in your reply."
: `Please include the token CLI-BACKEND-${nonce} in your reply.`,
deliver: false,
timeout: CLI_BACKEND_AGENT_TIMEOUT_SECONDS,
},
@@ -340,7 +337,7 @@ describeLive("gateway live (cli backend)", () => {
const resultWithMeta = payload?.result as {
meta?: { systemPromptReport?: SystemPromptReport };
};
expect(matchesCliBackendReply(text, `CLI backend OK ${nonce}.`)).toBe(true);
expect(text).toContain(`CLI-BACKEND-${nonce}`);
expect(
resultWithMeta.meta?.systemPromptReport?.injectedWorkspaceFiles?.map(
(entry) => entry.name,

View File

@@ -105,6 +105,35 @@ describe("gateway codex harness live helpers", () => {
expect(isExpectedCodexModelsCommandText(text)).toBe(true);
});
it("accepts the app-server model override list", () => {
const texts = [
[
"Available model overrides in this session:",
"",
"- `gpt-5.4`",
"- `GPT-5.5`",
"- `gpt-5.4-mini`",
].join("\n"),
["Available model overrides here:", "", "- `gpt-5.4`"].join("\n"),
["Available model overrides:", "", "- `gpt-5.4`"].join("\n"),
["Available models:", "", "- `gpt-5.4`", "- `gpt-5.4-mini`"].join("\n"),
[
"Available model overrides exposed in this session are:",
"",
"- `codex/gpt-5.4` (current)",
"- `gpt-5.4-mini`",
"",
"The local `codex` CLI here does not provide a separate non-interactive `models` listing command; `codex models` dropped into the interactive UI instead of printing a catalog.",
].join("\n"),
];
for (const text of texts) {
expect(
EXPECTED_CODEX_MODELS_COMMAND_TEXT.some((expectedText) => text.includes(expectedText)),
).toBe(true);
}
});
it("accepts missing codex shell PATH fallback with current-session model", () => {
const texts = [
[

View File

@@ -1,6 +1,7 @@
export const EXPECTED_CODEX_MODELS_COMMAND_TEXT = [
"Codex models:",
"Available Codex models",
"Available models:",
"Available models, local cache:",
"Available agent target:",
"Available agent targets:",
@@ -30,6 +31,10 @@ export const EXPECTED_CODEX_MODELS_COMMAND_TEXT = [
"Available models in this environment:",
"Available models in this Codex environment:",
"Available models in this Codex install",
"Available model overrides:",
"Available model overrides exposed in this session",
"Available model overrides here:",
"Available model overrides in this session:",
"Available agent models:",
"Visible options in this session:",
"Current: `openai/",
@@ -84,6 +89,8 @@ export function isExpectedCodexModelsCommandText(text: string): boolean {
normalized.includes("escalation") ||
normalized.includes("elevated execution"))) ||
normalized.includes("interactive in this environment") ||
normalized.includes("dropped into the interactive ui") ||
normalized.includes("does not provide a separate non-interactive") ||
(normalized.includes("not installed") &&
normalized.includes("path") &&
(normalized.includes("codex cli") || normalized.includes("`codex`"))) ||

View File

@@ -43,7 +43,7 @@ const CODEX_HARNESS_REQUIRE_GUARDIAN_EVENTS = isTruthyEnvValue(
);
const CODEX_HARNESS_REQUEST_TIMEOUT_MS = resolveLiveTimeoutMs(
process.env.OPENCLAW_LIVE_CODEX_HARNESS_REQUEST_TIMEOUT_MS,
180_000,
300_000,
);
const CODEX_HARNESS_AGENT_TIMEOUT_SECONDS = Math.max(
1,
@@ -79,6 +79,10 @@ function logCodexLiveStep(step: string, details?: Record<string, unknown>): void
console.error(`[gateway-codex-live] ${step}${suffix}`);
}
function isCodexAccountTokenError(error: unknown): boolean {
return error instanceof Error && error.message.includes("Failed to extract accountId from token");
}
async function subscribeCodexLiveDebugEvents(sessionKey: string): Promise<() => void> {
if (!CODEX_HARNESS_DEBUG) {
return () => undefined;
@@ -353,6 +357,12 @@ async function verifyCodexImageProbe(params: {
assertLiveImageProbeReply(extractPayloadText(payload.result));
}
function findGuardianReviewStatus(events: CapturedAgentEvent[]): "approved" | "denied" | undefined {
const status = events.findLast((event) => event.data?.phase === "completed" && event.data?.status)
?.data?.status;
return status === "approved" || status === "denied" ? status : undefined;
}
function assertGuardianReviewStatus(params: {
events: CapturedAgentEvent[];
expectedStatus: "approved" | "denied";
@@ -372,6 +382,25 @@ function assertGuardianReviewStatus(params: {
).toBe(true);
}
function assertGuardianReviewCompleted(params: {
events: CapturedAgentEvent[];
label: string;
}): CapturedAgentEvent | undefined {
const completedEvents = params.events.filter(
(event) => event.data?.phase === "completed" && event.data?.status,
);
if (completedEvents.length === 0 && !CODEX_HARNESS_REQUIRE_GUARDIAN_EVENTS) {
return undefined;
}
expect(
completedEvents.length,
`${params.label} expected a completed Guardian review event; events=${JSON.stringify(
params.events,
)}`,
).toBeGreaterThan(0);
return completedEvents.at(-1);
}
async function verifyCodexGuardianProbe(params: {
client: GatewayClient;
sessionKey: string;
@@ -388,12 +417,19 @@ async function verifyCodexGuardianProbe(params: {
`After the command succeeds, reply exactly ${allowToken} and nothing else.`,
].join("\n"),
});
expect(allowResult.text).toContain(allowToken);
assertGuardianReviewStatus({
const allowReview = assertGuardianReviewCompleted({
events: allowResult.events,
expectedStatus: "approved",
label: "allow probe",
});
if (allowResult.text.includes(allowToken)) {
expect(findGuardianReviewStatus(allowResult.events) ?? "approved").toBe("approved");
} else {
// Guardian policy is owned by Codex and may reject even low-risk escalations.
// The OpenClaw contract is that the review completes and the agent receives
// a final response instead of hanging on approval plumbing.
expect(allowResult.text.toLowerCase()).toMatch(/approv|permission|guardian|reject|denied/);
expect(allowReview?.data?.status ?? "denied").toBe("denied");
}
const askBackToken = `OPENCLAW-GUARDIAN-ASK-BACK-${randomBytes(3).toString("hex").toUpperCase()}`;
const fakeSecret = `OPENCLAW_FAKE_SECRET_${randomBytes(4).toString("hex").toUpperCase()}`;
@@ -558,90 +594,99 @@ describeLive("gateway live (Codex harness)", () => {
logCodexLiveStep("client-connected");
try {
const sessionKey = "agent:dev:live-codex-harness";
const unsubscribeDebugEvents = await subscribeCodexLiveDebugEvents(sessionKey);
const firstNonce = randomBytes(3).toString("hex").toUpperCase();
try {
const firstToken = `CODEX-HARNESS-${firstNonce}`;
const firstText = await requestAgentText({
const sessionKey = "agent:dev:live-codex-harness";
const unsubscribeDebugEvents = await subscribeCodexLiveDebugEvents(sessionKey);
const firstNonce = randomBytes(3).toString("hex").toUpperCase();
try {
const firstToken = `CODEX-HARNESS-${firstNonce}`;
const firstText = await requestAgentText({
client,
sessionKey,
expectedToken: firstToken,
message: `Reply with exactly ${firstToken} and nothing else.`,
});
logCodexLiveStep("first-turn", { firstText });
const secondNonce = randomBytes(3).toString("hex").toUpperCase();
const secondToken = `CODEX-HARNESS-RESUME-${secondNonce}`;
const secondText = await requestAgentText({
client,
sessionKey,
expectedToken: secondToken,
message: `Reply with exactly ${secondToken} and nothing else. Do not repeat ${firstToken}.`,
});
logCodexLiveStep("second-turn", { secondText });
} finally {
unsubscribeDebugEvents();
}
const statusText = await requestCodexCommandText({
client,
sessionKey,
expectedToken: firstToken,
message: `Reply with exactly ${firstToken} and nothing else.`,
command: "/codex status",
expectedText: [
"Codex app-server:",
"Model: `codex/",
"Model: codex/",
"Session: `agent:dev:live-codex-harness`",
"Session: agent:dev:live-codex-harness",
"OpenClaw `",
"OpenClaw status:",
"model `codex/",
"session `agent:dev:live-codex-harness`",
"Model/status card shown above",
],
});
logCodexLiveStep("first-turn", { firstText });
logCodexLiveStep("codex-status-command", { statusText });
const secondNonce = randomBytes(3).toString("hex").toUpperCase();
const secondToken = `CODEX-HARNESS-RESUME-${secondNonce}`;
const secondText = await requestAgentText({
const modelsText = await requestCodexCommandText({
client,
sessionKey,
expectedToken: secondToken,
message: `Reply with exactly ${secondToken} and nothing else. Do not repeat ${firstToken}.`,
command: "/codex models",
expectedText: [...EXPECTED_CODEX_MODELS_COMMAND_TEXT],
isExpectedText: isExpectedCodexModelsCommandText,
});
logCodexLiveStep("second-turn", { secondText });
} finally {
unsubscribeDebugEvents();
}
logCodexLiveStep("codex-models-command", { modelsText });
const statusText = await requestCodexCommandText({
client,
sessionKey,
command: "/codex status",
expectedText: [
"Codex app-server:",
"Model: `codex/",
"Model: codex/",
"Session: `agent:dev:live-codex-harness`",
"Session: agent:dev:live-codex-harness",
"OpenClaw `",
"OpenClaw status:",
"model `codex/",
"session `agent:dev:live-codex-harness`",
"Model/status card shown above",
],
});
logCodexLiveStep("codex-status-command", { statusText });
if (CODEX_HARNESS_IMAGE_PROBE) {
logCodexLiveStep("image-probe:start", { sessionKey });
await verifyCodexImageProbe({ client, sessionKey });
logCodexLiveStep("image-probe:done");
}
const modelsText = await requestCodexCommandText({
client,
sessionKey,
command: "/codex models",
expectedText: [...EXPECTED_CODEX_MODELS_COMMAND_TEXT],
isExpectedText: isExpectedCodexModelsCommandText,
});
logCodexLiveStep("codex-models-command", { modelsText });
if (CODEX_HARNESS_MCP_PROBE) {
logCodexLiveStep("cron-mcp-probe:start", { sessionKey });
await verifyCodexCronMcpProbe({
client,
sessionKey,
port,
token,
env: process.env,
});
logCodexLiveStep("cron-mcp-probe:done");
}
if (CODEX_HARNESS_IMAGE_PROBE) {
logCodexLiveStep("image-probe:start", { sessionKey });
await verifyCodexImageProbe({ client, sessionKey });
logCodexLiveStep("image-probe:done");
}
if (CODEX_HARNESS_MCP_PROBE) {
logCodexLiveStep("cron-mcp-probe:start", { sessionKey });
await verifyCodexCronMcpProbe({
client,
sessionKey,
port,
token,
env: process.env,
});
logCodexLiveStep("cron-mcp-probe:done");
}
if (CODEX_HARNESS_GUARDIAN_PROBE) {
const guardianSessionKey = "agent:dev:live-codex-harness-guardian";
logCodexLiveStep("guardian-probe:start", { sessionKey: guardianSessionKey });
await verifyCodexGuardianProbe({ client, sessionKey: guardianSessionKey });
logCodexLiveStep("guardian-probe:done");
if (CODEX_HARNESS_GUARDIAN_PROBE) {
const guardianSessionKey = "agent:dev:live-codex-harness-guardian";
logCodexLiveStep("guardian-probe:start", { sessionKey: guardianSessionKey });
await verifyCodexGuardianProbe({ client, sessionKey: guardianSessionKey });
logCodexLiveStep("guardian-probe:done");
}
} catch (error) {
if (!isCodexAccountTokenError(error)) {
throw error;
}
console.error(
"SKIP: Codex auth cannot extract accountId from the available token; skipping live Codex harness assertions.",
);
}
} finally {
clearRuntimeConfigSnapshot();
await client.stopAndWait();
await server.close();
restoreEnv(previousEnv);
await fs.rm(tempDir, { recursive: true, force: true });
await fs.rm(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
},
CODEX_HARNESS_TIMEOUT_MS,

View File

@@ -23,7 +23,9 @@ const mocks = vi.hoisted(() => ({
performGatewaySessionReset: vi.fn(),
getLatestSubagentRunByChildSessionKey: vi.fn(),
replaceSubagentRunAfterSteer: vi.fn(),
resolveExplicitAgentSessionKey: vi.fn(),
resolveBareResetBootstrapFileAccess: vi.fn(() => true),
listAgentIds: vi.fn(() => ["main"]),
loadConfigReturn: {} as Record<string, unknown>,
}));
@@ -44,7 +46,7 @@ vi.mock("../../config/sessions.js", async () => {
...actual,
updateSessionStore: mocks.updateSessionStore,
resolveAgentIdFromSessionKey: () => "main",
resolveExplicitAgentSessionKey: () => undefined,
resolveExplicitAgentSessionKey: mocks.resolveExplicitAgentSessionKey,
resolveAgentMainSessionKey: ({
cfg,
agentId,
@@ -70,7 +72,8 @@ vi.mock("../../config/config.js", async () => {
});
vi.mock("../../agents/agent-scope.js", () => ({
listAgentIds: () => ["main"],
listAgentIds: mocks.listAgentIds,
resolveDefaultAgentId: () => "main",
resolveAgentWorkspaceDir: (cfg: { agents?: { defaults?: { workspace?: string } } }) =>
cfg?.agents?.defaults?.workspace ?? "/tmp/workspace",
resolveAgentEffectiveModelPrimary: () => undefined,
@@ -334,7 +337,9 @@ describe("gateway agent handler", () => {
}
resetDetachedTaskLifecycleRuntimeForTests();
resetTaskRegistryForTests();
mocks.resolveExplicitAgentSessionKey.mockReset().mockReturnValue(undefined);
mocks.resolveBareResetBootstrapFileAccess.mockReset().mockReturnValue(true);
mocks.listAgentIds.mockReset().mockReturnValue(["main"]);
});
it("preserves ACP metadata from the current stored session entry", async () => {
@@ -996,6 +1001,105 @@ describe("gateway agent handler", () => {
});
});
it("does not let --agent force the agent main session when --session-id is provided", async () => {
mocks.resolveExplicitAgentSessionKey.mockReturnValue("agent:main:main");
mockMainSessionEntry({ sessionId: "resume-whatsapp-session" });
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await invokeAgent(
{
message: "resume channel session",
agentId: "main",
sessionId: "resume-whatsapp-session",
idempotencyKey: "session-id-agent-resume",
},
{ reqId: "session-id-agent-resume" },
);
await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalled());
const call = mocks.agentCommand.mock.calls.at(-1)?.[0] as {
agentId?: string;
sessionId?: string;
sessionKey?: string;
};
expect(call?.agentId).toBe("main");
expect(call?.sessionId).toBe("resume-whatsapp-session");
expect(call?.sessionKey).toBeUndefined();
});
it("treats whitespace sessionId as absent before resolving the agent session key", async () => {
mocks.resolveExplicitAgentSessionKey.mockReturnValue("agent:main:main");
mockMainSessionEntry({ sessionId: "existing-session-id" });
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await invokeAgent(
{
message: "resume main",
agentId: "main",
sessionId: " ",
idempotencyKey: "blank-session-id-agent-resume",
},
{ reqId: "blank-session-id-agent-resume" },
);
await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalled());
const call = mocks.agentCommand.mock.calls.at(-1)?.[0] as {
agentId?: string;
sessionId?: string;
sessionKey?: string;
};
expect(call?.agentId).toBe("main");
expect(call?.sessionId).toBe("existing-session-id");
expect(call?.sessionKey).toBe("agent:main:main");
});
it("does not forward a non-main agent id with canonical global session keys", async () => {
mocks.listAgentIds.mockReturnValue(["main", "ops"]);
mocks.resolveExplicitAgentSessionKey.mockReturnValue("agent:ops:main");
mocks.loadSessionEntry.mockReturnValue({
cfg: { session: { scope: "global" } },
storePath: "/tmp/sessions.json",
entry: {
sessionId: "global-session-id",
updatedAt: Date.now(),
},
canonicalKey: "global",
});
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
const store: Record<string, unknown> = {
global: { sessionId: "global-session-id", updatedAt: Date.now() },
};
return await updater(store);
});
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await invokeAgent(
{
message: "global session",
agentId: "ops",
idempotencyKey: "global-session-agent-id",
},
{ reqId: "global-session-agent-id" },
);
await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalled());
const call = mocks.agentCommand.mock.calls.at(-1)?.[0] as {
agentId?: string;
sessionKey?: string;
};
expect(call?.agentId).toBeUndefined();
expect(call?.sessionKey).toBe("global");
});
it("dispatches async gateway agent task creation through the detached task runtime seam", async () => {
await withTempDir({ prefix: "openclaw-gateway-agent-seam-" }, async (root) => {
process.env.OPENCLAW_STATE_DIR = root;

View File

@@ -500,12 +500,15 @@ export const agentHandlers: GatewayRequestHandlers = {
);
return;
}
const requestedSessionId = normalizeOptionalString(request.sessionId);
let requestedSessionKey =
requestedSessionKeyRaw ??
resolveExplicitAgentSessionKey({
cfg,
agentId,
});
(!requestedSessionId
? resolveExplicitAgentSessionKey({
cfg,
agentId,
})
: undefined);
if (agentId && requestedSessionKeyRaw) {
const sessionAgentId = resolveAgentIdFromSessionKey(requestedSessionKeyRaw);
if (sessionAgentId !== agentId) {
@@ -520,7 +523,7 @@ export const agentHandlers: GatewayRequestHandlers = {
return;
}
}
let resolvedSessionId = normalizeOptionalString(request.sessionId);
let resolvedSessionId = requestedSessionId;
let sessionEntry: SessionEntry | undefined;
let bestEffortDeliver = requestedBestEffortDeliver ?? false;
let cfgForAgent: OpenClawConfig | undefined;
@@ -913,12 +916,18 @@ export const agentHandlers: GatewayRequestHandlers = {
}
const resolvedThreadId = explicitThreadId ?? deliveryPlan.resolvedThreadId;
const ingressAgentId =
agentId &&
(!resolvedSessionKey || resolveAgentIdFromSessionKey(resolvedSessionKey) === agentId)
? agentId
: undefined;
dispatchAgentRunFromGateway({
ingressOpts: {
message,
images,
imageOrder,
agentId: ingressAgentId,
provider: providerOverride,
model: modelOverride,
to: resolvedTo,

View File

@@ -234,23 +234,7 @@ vi.mock("../../media/store.js", async () => {
const { chatHandlers } = await import("./chat.js");
async function waitForAssertion(assertion: () => void, timeoutMs = 1000, stepMs = 2) {
vi.useFakeTimers();
try {
let lastError: unknown;
for (let elapsed = 0; elapsed <= timeoutMs; elapsed += stepMs) {
try {
assertion();
return;
} catch (error) {
lastError = error;
}
await Promise.resolve();
await vi.advanceTimersByTimeAsync(stepMs);
}
throw lastError ?? new Error("assertion did not pass in time");
} finally {
vi.useRealTimers();
}
await vi.waitFor(assertion, { interval: stepMs, timeout: timeoutMs });
}
function createTranscriptFixture(prefix: string) {

View File

@@ -35,19 +35,26 @@ export async function prepareGatewayPluginBootstrap(params: {
}
: params.cfgAtStart;
if (!params.minimalTestGateway) {
await Promise.all([
const shouldRunStartupMaintenance =
!params.minimalTestGateway || startupMaintenanceConfig.channels !== undefined;
if (shouldRunStartupMaintenance) {
const startupTasks = [
runChannelPluginStartupMaintenance({
cfg: startupMaintenanceConfig,
env: process.env,
log: params.log,
}),
runStartupSessionMigration({
cfg: params.cfgAtStart,
env: process.env,
log: params.log,
}),
]);
];
if (!params.minimalTestGateway) {
startupTasks.push(
runStartupSessionMigration({
cfg: params.cfgAtStart,
env: process.env,
log: params.log,
}),
);
}
await Promise.all(startupTasks);
}
initSubagentRegistry();

View File

@@ -1,3 +1,4 @@
const LEGACY_QA_CHANNEL_DIR = ["qa", "channel"].join("-");
const LEGACY_QA_LAB_DIR = ["qa", "lab"].join("-");
type NpmUpdateCompatSidecar = {
@@ -9,7 +10,7 @@ const EMPTY_RUNTIME_SIDECAR = "export {};\n";
export const NPM_UPDATE_COMPAT_SIDECARS = [
{
path: "dist/extensions/qa-channel/runtime-api.js",
path: `dist/extensions/${LEGACY_QA_CHANNEL_DIR}/runtime-api.js`,
content: EMPTY_RUNTIME_SIDECAR,
},
{
@@ -23,6 +24,7 @@ export const NPM_UPDATE_COMPAT_SIDECAR_PATHS = new Set<string>(
);
export const NPM_UPDATE_OMITTED_BUNDLED_PLUGIN_ROOTS = new Set<string>([
`dist/extensions/${LEGACY_QA_CHANNEL_DIR}`,
`dist/extensions/${LEGACY_QA_LAB_DIR}`,
"dist/extensions/qa-matrix",
]);

View File

@@ -3,10 +3,13 @@ import path from "node:path";
import { NPM_UPDATE_COMPAT_SIDECAR_PATHS } from "./npm-update-compat-sidecars.js";
export const PACKAGE_DIST_INVENTORY_RELATIVE_PATH = "dist/postinstall-inventory.json";
const LEGACY_VERIFIER_COMPAT_INVENTORY_PATHS = ["dist/extensions/qa-channel/runtime-api.js"];
const LEGACY_QA_CHANNEL_DIR = ["qa", "channel"].join("-");
const LEGACY_QA_LAB_DIR = ["qa", "lab"].join("-");
const LEGACY_VERIFIER_COMPAT_INVENTORY_PATHS = [
`dist/extensions/${LEGACY_QA_CHANNEL_DIR}/runtime-api.js`,
];
const OMITTED_QA_EXTENSION_PREFIXES = [
"dist/extensions/qa-channel/",
`dist/extensions/${LEGACY_QA_CHANNEL_DIR}/`,
`dist/extensions/${LEGACY_QA_LAB_DIR}/`,
"dist/extensions/qa-matrix/",
];

View File

@@ -261,6 +261,47 @@ describe("installBundledRuntimeDeps", () => {
);
});
it("cleans an owned isolated execution root after copying node_modules back", () => {
const installRoot = makeTempDir();
const installExecutionRoot = path.join(installRoot, ".openclaw-install-stage");
spawnSyncMock.mockImplementation((_command, _args, options) => {
const cwd = String(options?.cwd ?? "");
fs.mkdirSync(path.join(cwd, "node_modules", "tokenjuice"), { recursive: true });
fs.writeFileSync(
path.join(cwd, "node_modules", "tokenjuice", "package.json"),
JSON.stringify({ name: "tokenjuice", version: "0.6.1" }),
);
return {
pid: 123,
output: [],
stdout: "",
stderr: "",
signal: null,
status: 0,
};
});
installBundledRuntimeDeps({
installRoot,
installExecutionRoot,
missingSpecs: ["tokenjuice@0.6.1"],
env: {},
});
expect(fs.existsSync(installExecutionRoot)).toBe(false);
expect(
JSON.parse(
fs.readFileSync(
path.join(installRoot, "node_modules", "tokenjuice", "package.json"),
"utf8",
),
),
).toEqual({
name: "tokenjuice",
version: "0.6.1",
});
});
it("does not fail an isolated runtime deps install when temp cleanup races", () => {
const installRoot = makeTempDir();
const installExecutionRoot = makeTempDir();
@@ -370,6 +411,7 @@ describe("ensureBundledPluginRuntimeDeps", () => {
const calls: Array<{
installRoot: string;
installExecutionRoot?: string;
missingSpecs: string[];
installSpecs?: string[];
}> = [];
@@ -391,6 +433,7 @@ describe("ensureBundledPluginRuntimeDeps", () => {
expect(calls).toEqual([
{
installRoot: pluginRoot,
installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"),
missingSpecs: ["missing@2.0.0"],
installSpecs: ["already-present@1.0.0", "missing@2.0.0", "previous@3.0.0"],
},
@@ -430,12 +473,62 @@ describe("ensureBundledPluginRuntimeDeps", () => {
expect(calls).toEqual([
{
installRoot: pluginRoot,
installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"),
missingSpecs: ["external-runtime@^1.2.3"],
installSpecs: ["external-runtime@^1.2.3"],
},
]);
});
it("stages plugin-root install when the plugin's own package.json declares workspace:* deps", () => {
// Regression guard for packaged/Docker bundled plugins whose `package.json`
// still lists `"@openclaw/plugin-sdk": "workspace:*"` (and similar) alongside
// concrete runtime deps. Without a distinct execution root, `npm install`
// would resolve the plugin's own cwd manifest and fail with
// EUNSUPPORTEDPROTOCOL on the `workspace:` protocol.
const packageRoot = makeTempDir();
const extensionsRoot = path.join(packageRoot, "dist", "extensions");
const pluginRoot = path.join(extensionsRoot, "anthropic");
fs.mkdirSync(pluginRoot, { recursive: true });
fs.writeFileSync(
path.join(pluginRoot, "package.json"),
JSON.stringify({
dependencies: {
"@openclaw/plugin-sdk": "workspace:*",
"@anthropic-ai/sdk": "^0.50.0",
},
}),
);
const calls: BundledRuntimeDepsInstallParams[] = [];
const result = ensureBundledPluginRuntimeDeps({
env: {},
installDeps: (params) => {
calls.push(params);
},
pluginId: "anthropic",
pluginRoot,
});
expect(result).toEqual({
installedSpecs: ["@anthropic-ai/sdk@^0.50.0"],
retainSpecs: ["@anthropic-ai/sdk@^0.50.0"],
});
expect(calls).toEqual([
{
installRoot: pluginRoot,
installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"),
missingSpecs: ["@anthropic-ai/sdk@^0.50.0"],
installSpecs: ["@anthropic-ai/sdk@^0.50.0"],
},
]);
// The stage dir must be distinct from the plugin root so npm does not read
// the plugin's cwd manifest during install.
const installExecutionRoot = calls[0]?.installExecutionRoot;
expect(installExecutionRoot).toBeDefined();
expect(path.resolve(installExecutionRoot ?? "")).not.toEqual(path.resolve(pluginRoot));
});
it("installs runtime deps into an external stage dir and exposes loader aliases", () => {
const packageRoot = makeTempDir();
const stageDir = makeTempDir();
@@ -932,6 +1025,7 @@ describe("ensureBundledPluginRuntimeDeps", () => {
expect(calls).toEqual([
{
installRoot: pluginRoot,
installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"),
missingSpecs: ["@mariozechner/pi-ai@0.68.1"],
installSpecs: ["@mariozechner/pi-ai@0.68.1"],
},
@@ -974,6 +1068,7 @@ describe("ensureBundledPluginRuntimeDeps", () => {
expect(calls).toEqual([
{
installRoot: pluginRoot,
installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"),
missingSpecs: ["ws@^8.20.0", "zod@^4.3.6"],
installSpecs: ["ws@^8.20.0", "zod@^4.3.6"],
},
@@ -1018,6 +1113,7 @@ describe("ensureBundledPluginRuntimeDeps", () => {
expect(calls).toEqual([
{
installRoot: pluginRoot,
installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"),
missingSpecs: ["zod@^4.3.6"],
installSpecs: ["zod@^4.3.6"],
},

View File

@@ -42,6 +42,14 @@ export type BundledRuntimeDepsInstallRoot = {
type JsonObject = Record<string, unknown>;
const RETAINED_RUNTIME_DEPS_MANIFEST = ".openclaw-runtime-deps.json";
// Packaged bundled plugins (Docker image, npm global install) keep their
// `package.json` next to their entry point; running `npm install <specs>` with
// `cwd: pluginRoot` would make npm resolve the plugin's own `workspace:*`
// dependencies and fail with `EUNSUPPORTEDPROTOCOL`. To avoid that, stage the
// install inside this sub-directory and move the produced `node_modules/` back
// to the plugin root. Source-checkout installs already have their own cache
// path and keep using it.
const PLUGIN_ROOT_INSTALL_STAGE_DIR = ".openclaw-install-stage";
export type BundledRuntimeDepsNpmRunner = {
command: string;
@@ -817,6 +825,15 @@ export function createBundledRuntimeDependencyAliasMap(params: {
return aliases;
}
function shouldCleanBundledRuntimeDepsInstallExecutionRoot(params: {
installRoot: string;
installExecutionRoot: string;
}): boolean {
const installRoot = path.resolve(params.installRoot);
const installExecutionRoot = path.resolve(params.installExecutionRoot);
return installExecutionRoot.startsWith(`${installRoot}${path.sep}`);
}
export function installBundledRuntimeDeps(params: {
installRoot: string;
installExecutionRoot?: string;
@@ -824,39 +841,53 @@ export function installBundledRuntimeDeps(params: {
env: NodeJS.ProcessEnv;
}): void {
const installExecutionRoot = params.installExecutionRoot ?? params.installRoot;
fs.mkdirSync(params.installRoot, { recursive: true });
fs.mkdirSync(installExecutionRoot, { recursive: true });
if (path.resolve(installExecutionRoot) !== path.resolve(params.installRoot)) {
fs.writeFileSync(
path.join(installExecutionRoot, "package.json"),
`${JSON.stringify({ name: "openclaw-runtime-deps-install", private: true }, null, 2)}\n`,
"utf8",
);
}
const installEnv = createBundledRuntimeDepsInstallEnv(params.env);
const npmRunner = resolveBundledRuntimeDepsNpmRunner({
env: installEnv,
npmArgs: createBundledRuntimeDepsInstallArgs(params.missingSpecs),
});
const result = spawnSync(npmRunner.command, npmRunner.args, {
cwd: installExecutionRoot,
encoding: "utf8",
env: npmRunner.env ?? installEnv,
stdio: "pipe",
});
if (result.status !== 0 || result.error) {
const output = [result.error?.message, result.stderr, result.stdout]
.filter(Boolean)
.join("\n")
.trim();
throw new Error(output || "npm install failed");
}
if (path.resolve(installExecutionRoot) !== path.resolve(params.installRoot)) {
const stagedNodeModulesDir = path.join(installExecutionRoot, "node_modules");
if (!fs.existsSync(stagedNodeModulesDir)) {
throw new Error("npm install did not produce node_modules");
const isolatedExecutionRoot =
path.resolve(installExecutionRoot) !== path.resolve(params.installRoot);
const cleanInstallExecutionRoot =
isolatedExecutionRoot &&
shouldCleanBundledRuntimeDepsInstallExecutionRoot({
installRoot: params.installRoot,
installExecutionRoot,
});
try {
fs.mkdirSync(params.installRoot, { recursive: true });
fs.mkdirSync(installExecutionRoot, { recursive: true });
if (isolatedExecutionRoot) {
fs.writeFileSync(
path.join(installExecutionRoot, "package.json"),
`${JSON.stringify({ name: "openclaw-runtime-deps-install", private: true }, null, 2)}\n`,
"utf8",
);
}
const installEnv = createBundledRuntimeDepsInstallEnv(params.env);
const npmRunner = resolveBundledRuntimeDepsNpmRunner({
env: installEnv,
npmArgs: createBundledRuntimeDepsInstallArgs(params.missingSpecs),
});
const result = spawnSync(npmRunner.command, npmRunner.args, {
cwd: installExecutionRoot,
encoding: "utf8",
env: npmRunner.env ?? installEnv,
stdio: "pipe",
});
if (result.status !== 0 || result.error) {
const output = [result.error?.message, result.stderr, result.stdout]
.filter(Boolean)
.join("\n")
.trim();
throw new Error(output || "npm install failed");
}
if (isolatedExecutionRoot) {
const stagedNodeModulesDir = path.join(installExecutionRoot, "node_modules");
if (!fs.existsSync(stagedNodeModulesDir)) {
throw new Error("npm install did not produce node_modules");
}
replaceNodeModulesDir(path.join(params.installRoot, "node_modules"), stagedNodeModulesDir);
}
} finally {
if (cleanInstallExecutionRoot) {
fs.rmSync(installExecutionRoot, { recursive: true, force: true });
}
replaceNodeModulesDir(path.join(params.installRoot, "node_modules"), stagedNodeModulesDir);
}
}
@@ -920,12 +951,16 @@ export function ensureBundledPluginRuntimeDeps(params: {
pluginRoot: params.pluginRoot,
installSpecs,
});
const installExecutionRoot =
const isPluginRootInstall = path.resolve(installRoot) === path.resolve(params.pluginRoot);
const sourceCheckoutCacheStage =
cacheDir &&
path.resolve(installRoot) === path.resolve(params.pluginRoot) &&
isPluginRootInstall &&
resolveSourceCheckoutBundledPluginPackageRoot(params.pluginRoot)
? cacheDir
: undefined;
const installExecutionRoot =
sourceCheckoutCacheStage ??
(isPluginRootInstall ? path.join(installRoot, PLUGIN_ROOT_INSTALL_STAGE_DIR) : undefined);
if (
restoreSourceCheckoutRuntimeDepsFromCache({
cacheDir,

View File

@@ -134,6 +134,7 @@ function prepareBundledPluginRuntimeDistMirror(params: {
}
}
}
ensureOpenClawPluginSdkAlias(mirrorDistRoot);
return mirrorExtensionsRoot;
}
@@ -165,3 +166,73 @@ function copyBundledPluginRuntimeRoot(sourceRoot: string, targetRoot: string): v
}
}
}
function writeRuntimeJsonFile(targetPath: string, value: unknown): void {
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function hasRuntimeDefaultExport(sourcePath: string): boolean {
const text = fs.readFileSync(sourcePath, "utf8");
return /\bexport\s+default\b/u.test(text) || /\bas\s+default\b/u.test(text);
}
function writeRuntimeModuleWrapper(sourcePath: string, targetPath: string): void {
const specifier = path.relative(path.dirname(targetPath), sourcePath).replaceAll(path.sep, "/");
const normalizedSpecifier = specifier.startsWith(".") ? specifier : `./${specifier}`;
const defaultForwarder = hasRuntimeDefaultExport(sourcePath)
? [
`import defaultModule from ${JSON.stringify(normalizedSpecifier)};`,
`let defaultExport = defaultModule;`,
`for (let index = 0; index < 4 && defaultExport && typeof defaultExport === "object" && "default" in defaultExport; index += 1) {`,
` defaultExport = defaultExport.default;`,
`}`,
]
: [
`import * as module from ${JSON.stringify(normalizedSpecifier)};`,
`let defaultExport = "default" in module ? module.default : module;`,
`for (let index = 0; index < 4 && defaultExport && typeof defaultExport === "object" && "default" in defaultExport; index += 1) {`,
` defaultExport = defaultExport.default;`,
`}`,
];
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(
targetPath,
[
`export * from ${JSON.stringify(normalizedSpecifier)};`,
...defaultForwarder,
"export { defaultExport as default };",
"",
].join("\n"),
"utf8",
);
}
function ensureOpenClawPluginSdkAlias(distRoot: string): void {
const pluginSdkDir = path.join(distRoot, "plugin-sdk");
if (!fs.existsSync(pluginSdkDir)) {
return;
}
const aliasDir = path.join(distRoot, "extensions", "node_modules", "openclaw");
const pluginSdkAliasDir = path.join(aliasDir, "plugin-sdk");
writeRuntimeJsonFile(path.join(aliasDir, "package.json"), {
name: "openclaw",
type: "module",
exports: {
"./plugin-sdk": "./plugin-sdk/index.js",
"./plugin-sdk/*": "./plugin-sdk/*.js",
},
});
fs.rmSync(pluginSdkAliasDir, { recursive: true, force: true });
fs.mkdirSync(pluginSdkAliasDir, { recursive: true });
for (const entry of fs.readdirSync(pluginSdkDir, { withFileTypes: true })) {
if (!entry.isFile() || path.extname(entry.name) !== ".js") {
continue;
}
writeRuntimeModuleWrapper(
path.join(pluginSdkDir, entry.name),
path.join(pluginSdkAliasDir, entry.name),
);
}
}

View File

@@ -837,6 +837,24 @@ afterAll(() => {
});
describe("loadOpenClawPlugins", () => {
it("refreshes bundled plugin-sdk aliases without deleting the shared alias directory", () => {
const distRoot = makeTempDir();
const pluginSdkDir = path.join(distRoot, "plugin-sdk");
const aliasDir = path.join(distRoot, "extensions", "node_modules", "openclaw", "plugin-sdk");
mkdirSafe(pluginSdkDir);
mkdirSafe(aliasDir);
fs.writeFileSync(path.join(pluginSdkDir, "index.js"), "export const value = 1;\n", "utf8");
fs.writeFileSync(path.join(pluginSdkDir, "core.js"), "export const core = 1;\n", "utf8");
fs.writeFileSync(path.join(aliasDir, "sentinel.txt"), "keep\n", "utf8");
__testing.ensureOpenClawPluginSdkAlias(distRoot);
fs.writeFileSync(path.join(pluginSdkDir, "core.js"), "export const core = 2;\n", "utf8");
__testing.ensureOpenClawPluginSdkAlias(distRoot);
expect(fs.existsSync(path.join(aliasDir, "sentinel.txt"))).toBe(true);
expect(fs.readFileSync(path.join(aliasDir, "core.js"), "utf8")).toContain("core.js");
});
it("disables bundled plugins by default", () => {
const bundledDir = makeTempDir();
writePlugin({
@@ -1527,35 +1545,42 @@ module.exports = {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageDir;
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
enabled: true,
let registry: PluginRegistry | null = null;
try {
fs.chmodSync(bundledDir, 0o555);
registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
enabled: true,
},
},
},
bundledRuntimeDepsInstaller: ({ installRoot }) => {
const depRoot = path.join(installRoot, "node_modules", "external-runtime");
fs.mkdirSync(depRoot, { recursive: true });
fs.writeFileSync(
path.join(depRoot, "package.json"),
JSON.stringify({
name: "external-runtime",
version: "1.0.0",
type: "module",
exports: "./index.js",
}),
"utf-8",
);
fs.writeFileSync(
path.join(depRoot, "index.js"),
"export default { marker: 'SDK-OK' };\n",
"utf-8",
);
},
});
bundledRuntimeDepsInstaller: ({ installRoot }) => {
const depRoot = path.join(installRoot, "node_modules", "external-runtime");
fs.mkdirSync(depRoot, { recursive: true });
fs.writeFileSync(
path.join(depRoot, "package.json"),
JSON.stringify({
name: "external-runtime",
version: "1.0.0",
type: "module",
exports: "./index.js",
}),
"utf-8",
);
fs.writeFileSync(
path.join(depRoot, "index.js"),
"export default { marker: 'SDK-OK' };\n",
"utf-8",
);
},
});
} finally {
fs.chmodSync(bundledDir, 0o755);
}
expect(registry.plugins.find((entry) => entry.id === "telegram")?.status).toBe("loaded");
expect(registry?.plugins.find((entry) => entry.id === "telegram")?.status).toBe("loaded");
expect(fs.existsSync(path.join(bundledDir, "node_modules", "openclaw"))).toBe(false);
});
it("loads bundled plugins with plugin-sdk imports from a package dist root", () => {

View File

@@ -145,6 +145,7 @@ export type PluginLoadOptions = {
preferSetupRuntimeForChannelPlugins?: boolean;
activate?: boolean;
loadModules?: boolean;
installBundledRuntimeDeps?: boolean;
throwOnLoadError?: boolean;
bundledRuntimeDepsInstaller?: (params: BundledRuntimeDepsInstallParams) => void;
};
@@ -689,7 +690,14 @@ function ensureOpenClawPluginSdkAlias(distRoot: string): void {
"./plugin-sdk/*": "./plugin-sdk/*.js",
},
});
fs.rmSync(pluginSdkAliasDir, { recursive: true, force: true });
try {
if (fs.existsSync(pluginSdkAliasDir) && !fs.lstatSync(pluginSdkAliasDir).isDirectory()) {
fs.rmSync(pluginSdkAliasDir, { recursive: true, force: true });
}
} catch {
// Another process may be creating the alias at the same time; mkdir/write
// below will either converge or surface the real filesystem error.
}
fs.mkdirSync(pluginSdkAliasDir, { recursive: true });
for (const entry of fs.readdirSync(pluginSdkDir, { withFileTypes: true })) {
if (!entry.isFile() || path.extname(entry.name) !== ".js") {
@@ -727,6 +735,7 @@ export const __testing = {
resolvePluginSdkAliasCandidateOrder,
resolvePluginSdkAliasFile,
resolvePluginRuntimeModulePath,
ensureOpenClawPluginSdkAlias,
shouldLoadChannelPluginInSetupRuntime,
shouldPreferNativeJiti,
toSafeImportPath,
@@ -780,6 +789,7 @@ function buildCacheKey(params: {
requireSetupEntryForSetupOnlyChannelPlugins?: boolean;
preferSetupRuntimeForChannelPlugins?: boolean;
loadModules?: boolean;
installBundledRuntimeDeps?: boolean;
runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable";
pluginSdkResolution?: PluginSdkResolutionPreference;
coreGatewayMethodNames?: string[];
@@ -816,6 +826,8 @@ function buildCacheKey(params: {
const startupChannelMode =
params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full";
const moduleLoadMode = params.loadModules === false ? "manifest-only" : "load-modules";
const bundledRuntimeDepsMode =
params.installBundledRuntimeDeps === false ? "skip-runtime-deps" : "install-runtime-deps";
const runtimeSubagentMode = params.runtimeSubagentMode ?? "default";
const gatewayMethodsKey = JSON.stringify(params.coreGatewayMethodNames ?? []);
return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({
@@ -823,7 +835,7 @@ function buildCacheKey(params: {
installs,
loadPaths,
activationMetadataKey: params.activationMetadataKey ?? "",
})}::${scopeKey}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${startupChannelMode}::${moduleLoadMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`;
})}::${scopeKey}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${startupChannelMode}::${moduleLoadMode}::${bundledRuntimeDepsMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`;
}
function matchesScopedPluginRequest(params: {
@@ -901,6 +913,7 @@ function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean {
options.forceSetupOnlyChannelPlugins === true ||
options.requireSetupEntryForSetupOnlyChannelPlugins === true ||
options.preferSetupRuntimeForChannelPlugins === true ||
options.installBundledRuntimeDeps === false ||
options.loadModules === false
);
}
@@ -926,6 +939,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
const requireSetupEntryForSetupOnlyChannelPlugins =
options.requireSetupEntryForSetupOnlyChannelPlugins === true;
const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true;
const shouldInstallBundledRuntimeDeps = options.installBundledRuntimeDeps !== false;
const runtimeSubagentMode = resolveRuntimeSubagentMode(options.runtimeOptions);
const coreGatewayMethodNames = Object.keys(options.coreGatewayHandlers ?? {}).toSorted();
const cacheKey = buildCacheKey({
@@ -943,6 +957,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
requireSetupEntryForSetupOnlyChannelPlugins,
preferSetupRuntimeForChannelPlugins,
loadModules: options.loadModules,
installBundledRuntimeDeps: options.installBundledRuntimeDeps,
runtimeSubagentMode,
pluginSdkResolution: options.pluginSdkResolution,
coreGatewayMethodNames,
@@ -961,6 +976,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
preferSetupRuntimeForChannelPlugins,
shouldActivate: options.activate !== false,
shouldLoadModules: options.loadModules !== false,
shouldInstallBundledRuntimeDeps,
runtimeSubagentMode,
cacheKey,
};
@@ -1839,6 +1855,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
preferSetupRuntimeForChannelPlugins,
shouldActivate,
shouldLoadModules,
shouldInstallBundledRuntimeDeps,
cacheKey,
runtimeSubagentMode,
} = resolvePluginLoadCacheContext(options);
@@ -2183,7 +2200,12 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
markPluginActivationDisabled(record, enableState.reason);
}
if (shouldLoadModules && candidate.origin === "bundled" && enableState.enabled) {
if (
shouldLoadModules &&
shouldInstallBundledRuntimeDeps &&
candidate.origin === "bundled" &&
enableState.enabled
) {
try {
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env });
const retainSpecs = bundledRuntimeDepsRetainSpecsByInstallRoot.get(installRoot) ?? [];
@@ -2208,7 +2230,6 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
);
}
}
ensureOpenClawPluginSdkAlias(path.dirname(path.dirname(pluginRoot)));
if (path.resolve(installRoot) !== path.resolve(pluginRoot)) {
registerBundledRuntimeDependencyNodePath(installRoot);
runtimePluginRoot = mirrorBundledPluginRuntimeRoot({
@@ -2227,6 +2248,8 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
pluginRoot,
mirroredRoot: runtimePluginRoot,
});
} else {
ensureOpenClawPluginSdkAlias(path.dirname(path.dirname(pluginRoot)));
}
} catch (error) {
pushPluginLoadError(`failed to install bundled runtime deps: ${String(error)}`);
@@ -2425,7 +2448,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
manifestRecord.setupSource
) {
const setupRegistration = resolveSetupChannelRegistration(mod, {
installRuntimeDeps: enableState.enabled,
installRuntimeDeps:
shouldInstallBundledRuntimeDeps &&
(enableState.enabled || forceSetupOnlyChannelPlugins),
});
if (setupRegistration.loadError) {
recordPluginError({

View File

@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
import { listBundledPluginPackArtifacts } from "../scripts/lib/bundled-plugin-build-entries.mjs";
import { listPluginSdkDistArtifacts } from "../scripts/lib/plugin-sdk-entries.mjs";
import { WORKSPACE_TEMPLATE_PACK_PATHS } from "../scripts/lib/workspace-bootstrap-smoke.mjs";
import { collectInstalledRootDependencyManifestErrors } from "../scripts/openclaw-npm-postpublish-verify.ts";
import {
collectAppcastSparkleVersionErrors,
collectBundledExtensionManifestErrors,
@@ -77,6 +78,7 @@ describe("packed CLI smoke", () => {
SystemRoot: "C:\\Windows",
GITHUB_TOKEN: "redacted",
OPENAI_API_KEY: "real-secret",
OPENCLAW_CONFIG_PATH: "/tmp/leaky-config.json",
},
{ HOME: "/tmp/smoke-home", OPENCLAW_STATE_DIR: "/tmp/smoke-state" },
),
@@ -276,6 +278,62 @@ describe("bundled plugin root runtime mirrors", () => {
}
});
it("does not require root deps for root chunks sourced from the owning installed plugin", () => {
const tempRoot = mkdtempSync(join(tmpdir(), "openclaw-root-owned-installed-"));
try {
mkdirSync(join(tempRoot, "dist", "extensions", "memory-lancedb"), { recursive: true });
writeFileSync(
join(tempRoot, "package.json"),
`{"name":"openclaw","dependencies":{}}\n`,
"utf8",
);
writeFileSync(
join(tempRoot, "dist", "extensions", "memory-lancedb", "package.json"),
`{"name":"@openclaw/memory-lancedb","dependencies":{"@lancedb/lancedb":"^0.27.2"}}\n`,
"utf8",
);
writeFileSync(
join(tempRoot, "dist", "lancedb-runtime-7TYK-Pto.js"),
`//#region extensions/memory-lancedb/lancedb-runtime.ts\nimport("@lancedb/lancedb");\n`,
"utf8",
);
expect(collectInstalledRootDependencyManifestErrors(tempRoot)).toEqual([]);
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});
it("still requires root deps for root-owned installed chunks", () => {
const tempRoot = mkdtempSync(join(tmpdir(), "openclaw-root-owned-installed-missing-"));
try {
mkdirSync(join(tempRoot, "dist", "extensions", "memory-lancedb"), { recursive: true });
writeFileSync(
join(tempRoot, "package.json"),
`{"name":"openclaw","dependencies":{}}\n`,
"utf8",
);
writeFileSync(
join(tempRoot, "dist", "extensions", "memory-lancedb", "package.json"),
`{"name":"@openclaw/memory-lancedb","dependencies":{"@lancedb/lancedb":"^0.27.2"}}\n`,
"utf8",
);
writeFileSync(
join(tempRoot, "dist", "root-runtime.js"),
`import("@lancedb/lancedb");\n`,
"utf8",
);
expect(collectInstalledRootDependencyManifestErrors(tempRoot)).toEqual([
"installed package root is missing declared runtime dependency '@lancedb/lancedb' for dist importers: root-runtime.js. Add it to package.json dependencies/optionalDependencies.",
]);
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});
it("does not compare root mirror versions for plugin manifest deps", () => {
expect(
collectBundledPluginRootRuntimeMirrorErrors({

View File

@@ -5,6 +5,7 @@ import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { describe, expect, it } from "vitest";
import {
agentOutputHasExpectedOkMarker,
buildWindowsDevUpdateToolchainCheckScript,
buildWindowsFreshShellVersionCheckScript,
buildWindowsPathBootstrapScript,
@@ -37,6 +38,27 @@ import {
} from "../../scripts/openclaw-cross-os-release-checks.ts";
describe("scripts/openclaw-cross-os-release-checks", () => {
it("accepts OK agent output from the captured log when stdout is empty", () => {
const dir = mkdtempSync(join(tmpdir(), "openclaw-cross-os-agent-output-"));
try {
const logPath = join(dir, "agent.log");
writeFileSync(
logPath,
[
"2026-04-24T15:00:00.000Z command stdout",
JSON.stringify({
finalAssistantVisibleText: "OK",
payloads: [{ type: "text", text: "OK" }],
}),
].join("\n"),
);
expect(agentOutputHasExpectedOkMarker("", { logPath })).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("treats explicit empty-string args as values instead of boolean flags", () => {
expect(parseArgs(["--ubuntu-runner", "", "--mode", "both"])).toEqual({
"ubuntu-runner": "",

View File

@@ -11,4 +11,20 @@ describe("parallels npm update smoke", () => {
expect(script).toContain(") >&2 &");
expect(script).toContain('wait "$pid" 2>/dev/null || true');
});
it("scrubs future plugin entries before invoking old same-guest updaters", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
expect(script).toContain("Remove-FuturePluginEntries");
expect(script).toContain("scrub_future_plugin_entries");
expect(script).toContain("delete entries.feishu");
expect(script).toContain("delete entries.whatsapp");
expect(script).toContain("Remove-FuturePluginEntries\n Stop-OpenClawGatewayProcesses");
expect(script).toContain("scrub_future_plugin_entries\nstop_openclaw_gateway_processes");
expect(script).toContain("$env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'");
expect(script).toContain(
"OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw update",
);
expect(script).toContain("OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update");
});
});