Compare commits

...

94 Commits

Author SHA1 Message Date
Peter Steinberger
15901513a3 fix(browser): declare zod runtime dependency 2026-04-29 21:12:43 +01:00
Peter Steinberger
b2a4d3d99a test: remove Parallels future config scrub 2026-04-29 20:36:03 +01:00
Peter Steinberger
50826a70c7 test: stop Parallels update config scrubbing 2026-04-29 20:25:37 +01:00
Peter Steinberger
2d52abc011 test: drop redundant gateway telegram e2e 2026-04-29 20:19:25 +01:00
Peter Steinberger
c8749c4f8f test: stop Parallels smoke plugin allowlist rewrites 2026-04-29 19:40:10 +01:00
Peter Steinberger
6076794339 test: relax live web search timeouts 2026-04-29 19:13:39 +01:00
Peter Steinberger
375f45054d fix(ci): stabilize bundled runtime dependency staging 2026-04-29 19:02:47 +01:00
Peter Steinberger
728cbd7068 fix(models): satisfy params merge lint 2026-04-29 16:41:30 +01:00
Peter Steinberger
1a2a707297 test: stabilize release live e2e probes 2026-04-29 16:32:22 +01:00
Peter Steinberger
7fde52d1e5 fix(models): merge legacy openrouter params 2026-04-29 16:32:22 +01:00
Peter Steinberger
5370add03b test(release): align package artifact reuse assertions 2026-04-29 16:13:32 +01:00
Peter Steinberger
56cb9506c0 ci: use same-run release package artifacts 2026-04-29 16:06:35 +01:00
Peter Steinberger
2046212eaa ci: speed up package tarball validation 2026-04-29 15:56:44 +01:00
Peter Steinberger
b0a4d59401 fix(ci): speed up release tarball integrity check 2026-04-29 15:43:35 +01:00
Peter Steinberger
18fa754699 test(parallels): quote Windows npm update allowlist batch 2026-04-29 15:17:20 +01:00
Peter Steinberger
04101e42eb ci: bound release package tarball checks 2026-04-29 15:12:22 +01:00
Peter Steinberger
6339b66ae1 test(release): dedupe doctor config write guard 2026-04-29 14:51:13 +01:00
Peter Steinberger
d7fc1c0ad5 test: tolerate transient google tts and openrouter tool probes 2026-04-29 14:46:16 +01:00
Peter Steinberger
f6913c8269 test: enable doctor config writes in release test 2026-04-29 14:45:48 +01:00
Peter Steinberger
b8dd69a100 test(ci): harden release failure paths
(cherry picked from commit 5580d8951c)
2026-04-29 14:43:07 +01:00
Peter Steinberger
3f1ac9dc10 test: tolerate opencl live stt transcript variant
(cherry picked from commit d8b25506bb)
2026-04-29 14:37:44 +01:00
Peter Steinberger
68ae346547 test: make doctor migration assertion order independent
(cherry picked from commit 5605b31375)
2026-04-29 14:25:05 +01:00
Peter Steinberger
d9a3f18db6 test(browser): isolate pw-session mocks 2026-04-29 14:14:15 +01:00
Peter Steinberger
525a187d96 test(release): isolate no-isolate module mocks 2026-04-29 14:05:20 +01:00
Peter Steinberger
47b1f19add test(release): refresh release validation baselines 2026-04-29 13:53:53 +01:00
Vincent Koc
d93ff21fea fix(test): stabilize core runtime infra shard
(cherry picked from commit 8e5fcfff50)
2026-04-29 13:43:54 +01:00
Peter Steinberger
d8d6e0e5b6 test(agent): allow catalog fallback scope lookup 2026-04-29 13:33:42 +01:00
Peter Steinberger
c18d636030 fix(release): satisfy scoped context lint 2026-04-29 13:29:37 +01:00
Peter Steinberger
9f7d44ecc0 fix(release): scope package validation runtime discovery 2026-04-29 13:27:03 +01:00
Ayaan Zaidi
bad9f9de3a fix: prevent Telegram gateway stalls (#74210) 2026-04-29 10:31:06 +01:00
Ayaan Zaidi
becd348278 fix(telegram): bound outbound request timeouts 2026-04-29 10:30:28 +01:00
Peter Steinberger
d2fc7b39fc test(release): narrow Parallels agent smoke plugins 2026-04-29 10:25:52 +01:00
Peter Steinberger
9aa980bf8e test(release): use batch file for Windows smoke allowlist 2026-04-29 09:37:56 +01:00
Peter Steinberger
17d6fa87c8 test(release): preserve Windows smoke JSON config 2026-04-29 09:35:18 +01:00
Peter Steinberger
5402f7166c test(release): focus Parallels smoke plugins 2026-04-29 09:05:38 +01:00
Peter Steinberger
49c42b9e92 test: trim release smoke memory startup
(cherry picked from commit 7662a17b08)
2026-04-29 08:30:15 +01:00
Peter Steinberger
09a13fd55a fix(memory): isolate qmd boot refresh
(cherry picked from commit afc4f06ca3)
2026-04-29 08:29:51 +01:00
Peter Steinberger
8d02646129 test(release): steady Parallels smoke agent turns 2026-04-29 08:29:09 +01:00
Peter Steinberger
af43f5dabc test(ci): tolerate slow live provider cleanup
(cherry picked from commit 32c2337095)
2026-04-29 07:27:55 +01:00
Peter Steinberger
e246a0e87f test: align zalo fixtures with open dm policy
(cherry picked from commit ad761975de)
2026-04-29 07:26:58 +01:00
Peter Steinberger
b771d99b6b test(zalo): align open dm lifecycle fixtures
(cherry picked from commit 2da2d506b5)
2026-04-29 07:26:58 +01:00
Peter Steinberger
9bc264cbed test(ci): avoid timer-sensitive trace context assertion 2026-04-29 06:57:52 +01:00
Peter Steinberger
2c0797dfb9 test(ci): tolerate live STT brand drift 2026-04-29 06:42:31 +01:00
Peter Steinberger
bbefe55d74 fix(docker): copy postinstall helper imports 2026-04-29 06:41:49 +01:00
Peter Steinberger
2dbafe9d47 fix(test): wait for Windows gateway recovery 2026-04-29 06:35:00 +01:00
Peter Steinberger
4046183e95 fix(update): type legacy doctor env fallback 2026-04-29 05:49:48 +01:00
Peter Steinberger
4629752801 fix(update): skip legacy parent doctor config writes 2026-04-29 05:36:51 +01:00
Peter Steinberger
e20b7334c6 fix(parallels): fail dev update on unrepaired errors 2026-04-29 05:17:58 +01:00
Peter Steinberger
b2eb387681 fix(update): prune stale compile cache on install 2026-04-29 05:14:43 +01:00
Peter Steinberger
be9f00735f fix(update): preserve doctor repair writes in legacy handoff 2026-04-29 04:42:33 +01:00
Peter Steinberger
8fe647be7d fix(parallels): default OpenAI smokes to gpt-5.5 2026-04-29 04:38:09 +01:00
Peter Steinberger
ba4f62fad3 fix(update): tolerate legacy doctor metadata handoff 2026-04-29 04:15:23 +01:00
Vincent Koc
3d809b01a8 fix(update): skip disabled plugins during post-update sync (#73970)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-04-29 03:40:47 +01:00
Peter Steinberger
74530bc0cd fix(update): disable compile cache for post-update commands 2026-04-29 03:40:06 +01:00
Peter Steinberger
d429bf4a08 fix(update): resume git post-update in updated process 2026-04-29 03:38:50 +01:00
Peter Steinberger
1f945cf95f test(parallels): tolerate old updater stale chunk recovery 2026-04-29 03:10:32 +01:00
Peter Steinberger
7b74b1b17e fix(release): ship dist import helper 2026-04-29 02:22:32 +01:00
Peter Steinberger
02e6866df2 fix(ci): preserve imported dist chunks after install 2026-04-29 02:16:37 +01:00
Peter Steinberger
7eae05e55e test(parallels): reset macos state after restore 2026-04-29 02:03:17 +01:00
Peter Steinberger
a38a33512d test(parallels): prefer arm64 mingit downloads 2026-04-29 01:24:50 +01:00
Peter Steinberger
3e4b50a3e9 fix(cli): skip plugin preload for json agent runs 2026-04-29 01:17:33 +01:00
Peter Steinberger
27599d319e fix(bonjour): suppress ciao internal cancellations 2026-04-29 00:59:40 +01:00
Peter Steinberger
bd11678122 perf(plugins): cache runtime mirror file decisions
(cherry picked from commit 75df09b9ec)
2026-04-29 00:54:28 +01:00
Peter Steinberger
c34ba97262 fix(bonjour): recover from ciao cancellation 2026-04-29 00:47:58 +01:00
Peter Steinberger
b8d15f8219 test(parallels): retry macOS staged runtime mirror race 2026-04-29 00:37:55 +01:00
Peter Steinberger
03195583fe test(parallels): extend cold Windows and macOS gateway proof 2026-04-29 00:18:29 +01:00
Peter Steinberger
18f65c2d58 test(release): retry staged runtime mirror races 2026-04-29 00:01:06 +01:00
Peter Steinberger
78853d9adc ci(release): harden Docker release checks 2026-04-28 23:48:54 +01:00
Peter Steinberger
f51008a8f6 test(parallels): harden fresh gateway smoke 2026-04-28 23:35:52 +01:00
Peter Steinberger
ab17e06057 test(release): extend slow live release timeouts 2026-04-28 23:12:11 +01:00
Peter Steinberger
3978d44fa8 fix(qa): restore release channel reply checks
(cherry picked from commit 96a21e2553)
2026-04-28 22:49:26 +01:00
Peter Steinberger
46eaa5171d test(release): harden qa live canaries 2026-04-28 22:25:56 +01:00
Peter Steinberger
dcc8190933 test(release): widen codex harness subagent probe 2026-04-28 21:39:07 +01:00
Peter Steinberger
93ecd917ec test(gateway): harden device auth e2e helper 2026-04-28 21:27:01 +01:00
Peter Steinberger
55cdac2ab7 test(release): harden repo e2e release checks 2026-04-28 20:11:23 +01:00
Peter Steinberger
2ea5f19ec0 ci(release): pin qa live model 2026-04-28 19:36:37 +01:00
Peter Steinberger
579e40269b test(release): extend cli backend live timeout 2026-04-28 19:35:54 +01:00
Peter Steinberger
462abf326a test(release): harden live validation wording 2026-04-28 19:35:01 +01:00
Peter Steinberger
3a3859b484 fix(release): harden package candidate checks 2026-04-28 16:34:06 +01:00
Peter Steinberger
3afc597287 fix(release): complete qa lab harness runtime 2026-04-28 16:31:51 +01:00
Peter Steinberger
b7a30fc201 fix(release): keep legacy memory chunk stub 2026-04-28 16:00:42 +01:00
Peter Steinberger
3fc8277eb2 test(release): retry post-update version probes 2026-04-28 15:38:08 +01:00
Peter Steinberger
13327dbaef fix(release): scope launcher compile cache 2026-04-28 15:17:41 +01:00
Peter Steinberger
e09ddbd8b6 fix(release): isolate packaged compile cache 2026-04-28 14:53:27 +01:00
Peter Steinberger
f55220d6b9 test(release): avoid legacy updater restart in parallels 2026-04-28 14:29:19 +01:00
Peter Steinberger
2e8f91c36e fix(release): verify package entrypoint imports 2026-04-28 14:06:46 +01:00
Peter Steinberger
727bff8133 test(release): stabilize windows parallels agent check 2026-04-28 13:38:02 +01:00
Peter Steinberger
3ead5926b7 test(release): relax macos parallels gateway timeout 2026-04-28 13:18:50 +01:00
Peter Steinberger
7e22aa0a0e test(release): relax macos parallels agent timeout 2026-04-28 13:09:14 +01:00
Peter Steinberger
f5cd467d93 fix(release): typecheck qa channel mentions 2026-04-28 12:54:55 +01:00
Peter Steinberger
7e42e2c087 fix(release): stabilize beta validation 2026-04-28 12:50:49 +01:00
Peter Steinberger
99e69a232b fix(release): restore private qa release checks 2026-04-28 12:17:07 +01:00
Peter Steinberger
90c3d4ae1d chore(release): refresh plugin sdk api baseline 2026-04-28 12:04:17 +01:00
Peter Steinberger
df76659019 chore(release): prepare 2026.4.27 beta 1 2026-04-28 11:53:57 +01:00
152 changed files with 4612 additions and 887 deletions

View File

@@ -349,7 +349,7 @@ jobs:
needs: [preflight]
if: needs.preflight.outputs.run_fast_install_smoke == 'true' || needs.preflight.outputs.run_full_install_smoke == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 8
timeout-minutes: 12
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
@@ -372,4 +372,4 @@ jobs:
env:
OPENCLAW_BUNDLED_CHANNEL_DEPS_E2E_IMAGE: openclaw-bundled-channel-fast:local
OPENCLAW_BUNDLED_CHANNEL_DOCKER_RUN_TIMEOUT: 90s
run: timeout 240s pnpm test:docker:bundled-channel-deps:fast
run: timeout 480s pnpm test:docker:bundled-channel-deps:fast

View File

@@ -333,6 +333,9 @@ jobs:
cache: pnpm
cache-dependency-path: ${{ inputs.candidate_artifact_name == '' && 'source/pnpm-lock.yaml' || 'workflow/pnpm-lock.yaml' }}
- name: Ensure pnpm cache path exists
run: mkdir -p "$(pnpm store path --silent)"
- name: Build candidate artifact once
if: inputs.candidate_artifact_name == ''
env:
@@ -343,12 +346,19 @@ jobs:
--source-dir source \
--output-dir "${OUTPUT_DIR}"
- name: Download provided candidate artifact
if: inputs.candidate_artifact_name != ''
- name: Download current-run candidate artifact
if: inputs.candidate_artifact_name != '' && inputs.candidate_artifact_run_id == ''
uses: actions/download-artifact@v8
with:
name: ${{ inputs.candidate_artifact_name }}
run-id: ${{ inputs.candidate_artifact_run_id || github.run_id }}
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package
- name: Download previous-run candidate artifact
if: inputs.candidate_artifact_name != '' && inputs.candidate_artifact_run_id != ''
uses: actions/download-artifact@v8
with:
name: ${{ inputs.candidate_artifact_name }}
run-id: ${{ inputs.candidate_artifact_run_id }}
github-token: ${{ github.token }}
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package

View File

@@ -1875,18 +1875,20 @@ jobs:
case "${{ matrix.suite_id }}" in
live-cli-backend-docker)
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.5" >> "$GITHUB_ENV"
# The CLI backend Docker lane should exercise the same staged
# Codex auth path Peter uses locally so MCP cron creation and
# multimodal probes stay covered in CI. Replace the staged
# config.toml with a minimal CI-safe config so the repo stays
# trusted for MCP/tool use without inheriting maintainer-local
# provider/profile overrides that do not exist inside CI.
# Keep the release-blocking CI lane on Codex API-key auth. The
# staged auth-file path remains supported for local maintainer
# reruns, but it can hang on stale subscription/session state in
# an otherwise healthy release run.
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
# Replace the staged config.toml with a minimal CI-safe config so
# the repo stays trusted for MCP/tool use without inheriting
# maintainer-local provider/profile overrides that do not exist
# inside CI.
# Codex's workspace-write sandbox relies on user namespaces that
# this Docker lane does not provide, so run Codex unsandboxed
# inside the already-isolated container to keep MCP cron/tool
# execution representative instead of failing on nested sandbox
# setup.
echo 'OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV=["OPENAI_API_KEY","OPENAI_BASE_URL"]' >> "$GITHUB_ENV"
echo 'OPENCLAW_LIVE_CLI_BACKEND_ARGS=["exec","--json","--color","never","--sandbox","danger-full-access","--skip-git-repo-check"]' >> "$GITHUB_ENV"
echo 'OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS=["exec","resume","{sessionId}","-c","sandbox_mode=\"danger-full-access\"","--skip-git-repo-check"]' >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"

View File

@@ -63,6 +63,7 @@ env:
NODE_VERSION: "24.x"
PNPM_VERSION: "10.33.0"
OPENCLAW_CI_OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL }}
OPENCLAW_QA_LIVE_OPENAI_MODEL: openai/gpt-5.4
jobs:
resolve_target:
@@ -314,7 +315,6 @@ jobs:
provider: ${{ needs.resolve_target.outputs.provider }}
mode: ${{ needs.resolve_target.outputs.mode }}
candidate_artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
candidate_artifact_run_id: ${{ github.run_id }}
candidate_file_name: openclaw-current.tgz
candidate_version: ${{ needs.prepare_release_package.outputs.package_version }}
candidate_source_sha: ${{ needs.prepare_release_package.outputs.source_sha }}
@@ -343,7 +343,6 @@ jobs:
include_live_suites: true
release_test_profile: ${{ needs.resolve_target.outputs.release_profile }}
package_artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
package_artifact_run_id: ${{ github.run_id }}
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -404,7 +403,6 @@ jobs:
with:
workflow_ref: ${{ github.ref_name }}
source: artifact
artifact_run_id: ${{ github.run_id }}
artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
package_sha256: ${{ needs.prepare_release_package.outputs.package_sha256 }}
suite_profile: custom
@@ -649,6 +647,7 @@ jobs:
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS: "90000"
OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS: "3000"
run: |
set -euo pipefail
@@ -660,8 +659,8 @@ jobs:
--repo-root . \
--output-dir "${output_dir}" \
--provider-mode live-frontier \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
--model "${OPENCLAW_QA_LIVE_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_QA_LIVE_OPENAI_MODEL}" \
--profile fast \
--fast
)
@@ -751,8 +750,8 @@ jobs:
--repo-root . \
--output-dir "${output_dir}" \
--provider-mode live-frontier \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
--model "${OPENCLAW_QA_LIVE_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_QA_LIVE_OPENAI_MODEL}" \
--fast \
--credential-source convex \
--credential-role ci

View File

@@ -281,8 +281,15 @@ jobs:
install-bun: ${{ inputs.source == 'ref' && 'true' || 'false' }}
install-deps: "false"
- name: Download package artifact input
if: inputs.source == 'artifact'
- name: Download current-run package artifact input
if: inputs.source == 'artifact' && inputs.artifact_run_id == ''
uses: actions/download-artifact@v8
with:
name: ${{ inputs.artifact_name }}
path: .artifacts/package-candidate-input
- name: Download previous-run package artifact input
if: inputs.source == 'artifact' && inputs.artifact_run_id != ''
env:
GH_TOKEN: ${{ github.token }}
ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }}
@@ -290,10 +297,6 @@ jobs:
shell: bash
run: |
set -euo pipefail
if [[ -z "${ARTIFACT_RUN_ID// }" ]]; then
echo "artifact_run_id is required when source=artifact." >&2
exit 1
fi
if [[ -z "${ARTIFACT_NAME// }" ]]; then
echo "artifact_name is required when source=artifact." >&2
exit 1

View File

@@ -4,10 +4,6 @@ Docs: https://docs.openclaw.ai
## Unreleased
### Changes
- Channels: add Yuanbao channel docs entrance so the Tencent Yuanbao bot appears in the channel listing and sidebar navigation. (#73443) Thanks @loongfay.
## 2026.4.27
### Changes
@@ -29,6 +25,7 @@ Docs: https://docs.openclaw.ai
- Plugin SDK/models: move BytePlus and Volcano Engine standard and plan-provider catalogs into plugin manifest `modelCatalog` rows and remove the now-unused Volcengine-family shared catalog SDK subpath. Thanks @shakkernerd.
- CLI/models: move Fireworks and Together AI fixed provider catalogs into plugin manifest `modelCatalog` rows so provider-filtered listing can use manifest-backed static rows. Thanks @shakkernerd.
- Channels/Yuanbao: register the Tencent Yuanbao external channel plugin (`openclaw-plugin-yuanbao`) in the official channel catalog, contract suites, and community plugin docs, with a new `docs/channels/yuanbao.md` quick-start guide for WebSocket bot DMs and group chats. (#72756) Thanks @loongfay.
- Channels/Yuanbao: add a channel docs entrance so the Tencent Yuanbao bot appears in the channel listing and sidebar navigation. (#73443) Thanks @loongfay.
- Channels/QQBot: add full group chat support (history tracking, @-mention gating, activation modes, per-group config, FIFO message queue with deliver debounce), C2C `stream_messages` streaming with a `StreamingController` lifecycle manager, unified `sendMedia` with chunked upload for large files, and refactor the engine into pipeline stages, focused outbound submodules, builtin slash-command modules, and explicit DI ports via `createEngineAdapters()`. (#70624) Thanks @cxyhhhhh.
- Plugins/startup: migrate bundled plugin manifests to explicit `activation.onStartup` declarations so Gateway startup imports only the bundled plugins that intentionally register startup-time runtime surfaces. Thanks @shakkernerd.
- Plugins/startup: add an opt-in future-mode gate for disabling deprecated implicit startup sidecar loading while preserving explicit startup and narrower activation triggers. Thanks @shakkernerd.
@@ -62,17 +59,20 @@ Docs: https://docs.openclaw.ai
- Gateway/auth: clear reused stale device tokens and stop reconnecting on device-token mismatch in the Control UI and Node gateway clients, avoiding rate-limit loops after scope-upgrade or token-rotation handoffs. Fixes #71609. Thanks @ricksayhi.
- Gateway/approvals: treat duplicate same-decision approval resolves as idempotent during the resolved-entry grace window, including consumed `allow-once` approvals, while returning an explicit already-resolved error for conflicting repeats. Fixes #59162; refs #58479 and #65486. Thanks @wikithoughts, @sajazuniga7-coder, and @mjmai20682068-create.
- Channels/Telegram: honor `approvals.exec/plugin.targets[].accountId` when routing native approvals across multi-bot Telegram accounts while preserving unscoped Telegram targets for any account. Fixes #69916. Thanks @joerod26.
- Telegram/gateway: bound outbound Bot API calls and cache bundled plugin alias lookup so slow Telegram sends or WSL2 filesystem scans no longer wedge gateway replies. (#74210) Thanks @obviyus.
- Agents/exec: omit the internal session-resume fallback preface from successful async exec completion messages sent directly back to chat. Fixes #67181. Thanks @raistlin88.
- Agents/media: register detached `video_generate` and `music_generate` tool run contexts until terminal status, so Discord-backed provider jobs stay live in `/tasks` instead of becoming `lost` when the parent chat run context disappears. Thanks @vincentkoc.
- Agents/media: prefer OpenAI image and video providers when the default model uses the OpenAI Codex auth alias, so auto media generation no longer falls through to Fal before GPT Image or Sora. Thanks @vincentkoc.
- Tasks/media: infer agent ownership for session-scoped task records so `/tasks` agent-local fallback includes session-backed `video_generate` and other async media jobs even when the current chat session has no linked rows. Thanks @vincentkoc.
- Agents/media: keep long-running `video_generate` and `music_generate` tasks fresh while provider jobs are still pending, so task maintenance does not mark active Discord media renders lost before completion. Thanks @vincentkoc.
- CLI/status: treat scope-limited gateway probes as reachable-but-degraded in shared status scans, so `openclaw status --all` no longer reports a live gateway as unreachable after `missing scope: operator.read`. Fixes #49180; supersedes #47981. Thanks @openjay.
- CLI/update: skip tracked plugins disabled in config during post-update plugin sync before npm, ClawHub, or marketplace update checks, preserving their install records without failing the update. Fixes #73880. Thanks @islandpreneur007.
- Slack/Socket Mode: use a 15s Slack SDK pong timeout by default and add `channels.slack.socketMode.clientPingTimeout`, `serverPingTimeout`, and `pingPongLoggingEnabled` overrides so stale-websocket handling no longer depends on app-event health heuristics. Fixes #14248; refs #58519, #64009, and #63488. Thanks @shivasymbl and @freerk.
- Slack/media: bound private file and forwarded attachment downloads with idle and total timeouts while preserving placeholder fallback, so stalled Slack `file_share` media no longer wedges inbound message handling. Fixes #61850. Thanks @bassboy2k.
- Plugins/inspector: keep bundled plugin runtime capture quiet and config-tolerant for Codex, memory-lancedb, Feishu, Mattermost, QQBot, and Tlon so plugin-inspector JSON checks can validate the full bundled set. Thanks @vincentkoc.
- Slack/auto-reply: keep fully consumed text reset triggers such as `new session` out of `BodyForAgent` after directive cleanup, so configured Slack reset phrases do not leak into the fresh model turn. Fixes #73137. Thanks @neeravmakwana.
- Plugins/runtime deps: prune stale retained bundled runtime deps and keep doctor/secret channel contract scans on lightweight artifacts, so disabled bundled channels stop preserving old dependency trees or importing heavy plugin surfaces. Thanks @SymbolStar and @vincentkoc.
- Plugins/runtime deps: cache unchanged bundled runtime mirror dist-file materialization decisions and close file-lock handles on owner-write failures, reducing repeated startup chunk scans and avoiding FileHandle-GC recovery stalls. Refs #73532. Thanks @oadiazp and @bstanbury.
- Auto-reply: bound the post-run pending tool-result delivery drain with a progress-aware idle timeout, so a never-settling tool-result task no longer leaves the session active forever while slow healthy deliveries can keep draining. Fixes #53889; supersedes #64733 and #73434. Thanks @zijunl and @wujiaming88.
- Gateway/startup: start chat channels without waiting for primary model prewarm, keeping model warmup bounded in the background so Slack and other channels come online promptly when provider discovery is slow. Supersedes #73420. Thanks @dorukardahan.
- Gateway/install: carry env-backed config SecretRefs such as `channels.discord.token` into generated service environments when they are present only in the installing shell, while keeping gateway auth SecretRefs non-persisted. Fixes #67817; supersedes #73426. Thanks @wdimaculangan and @ztexydt-cqh.
@@ -307,6 +307,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Active Memory/QMD: run QMD boot refresh through a one-shot subprocess path, preserve interactive file watching, and align watcher dependency/build ignores with QMD's scanner so gateway startup avoids arming long-lived QMD watchers. Thanks @codexGW.
- Gateway/device tokens: stop echoing rotated bearer tokens from shared/admin `device.token.rotate` responses while preserving the same-device token handoff needed by token-only clients before reconnect. (#66773) Thanks @MoerAI.
- Agents/sessions_spawn: resolve configured bare model aliases for spawn model overrides using the target agent runtime default provider, carrying forward the alias-specific #69029 review fixes from #59681 without the unrelated active-session pruning path. Fixes #59681. Thanks @HowdyDooToYou.
- Control UI/Talk: keep Google Live browser sessions on the WebSocket transport instead of falling back to WebRTC, validate browser Google Live WebSocket endpoints, cap Gateway relay sessions per browser connection, and remove stale browser-native voice buttons that did not use the configured Talk/TTS provider. Thanks @BunsDev.

View File

@@ -63,6 +63,7 @@ COPY openclaw.mjs ./
COPY ui/package.json ./ui/package.json
COPY patches ./patches
COPY scripts/postinstall-bundled-plugins.mjs scripts/preinstall-package-manager-warning.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
COPY scripts/lib/package-dist-imports.mjs ./scripts/lib/package-dist-imports.mjs
COPY --from=ext-deps /out/ ./${OPENCLAW_BUNDLED_PLUGIN_DIR}/

View File

@@ -1,2 +1,2 @@
9a688c953f0108f85f58c173e79c28363d846a592130abec04cafbcabbb22dcc plugin-sdk-api-baseline.json
010252e56202abde0816787588239c41b4bfb710b930a5454848a5ae76ad6dae plugin-sdk-api-baseline.jsonl
eb01888d55022db197e445659ebd24233486e1c0b6d6799b9adb5a9fe8a754b2 plugin-sdk-api-baseline.json
98c1132dd42ae9c21dec9b9f8bb3c9717961553151e8f15909aaa95ac6af9812 plugin-sdk-api-baseline.jsonl

View File

@@ -52,9 +52,15 @@ present.
- OpenClaw creates collections from your workspace memory files and any
configured `memory.qmd.paths`, then runs `qmd update` on boot and
periodically (default every 5 minutes). Semantic modes also run `qmd embed`.
periodically (default every 5 minutes). These refreshes run through QMD
subprocesses, not an in-process filesystem crawl. Semantic modes also run
`qmd embed`.
- The default workspace collection tracks `MEMORY.md` plus the `memory/`
tree. Lowercase `memory.md` is not indexed as a root memory file.
- QMD's own scanner ignores hidden paths and common dependency/build
directories such as `.git`, `.cache`, `node_modules`, `vendor`, `dist`, and
`build`. Boot refreshes use a one-shot QMD subprocess path instead of
creating the full long-lived in-process watcher during gateway startup.
- Boot refresh runs in the background so chat startup is not blocked.
- Searches use the configured `searchMode` (default: `search`; also supports
`vsearch` and `query`). `search` is BM25-only, so OpenClaw skips semantic

View File

@@ -99,6 +99,7 @@ Pass `--scenario <id>` (repeatable) to run a hand-picked set; combine with `--pr
| Variable | Default | Effect |
| --------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OPENCLAW_QA_MATRIX_TIMEOUT_MS` | `1800000` (30 min) | Hard upper bound on the entire run. |
| `OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS` | `45000` | Bound for the initial canary reply. Release CI raises this on shared runners so a slow first gateway turn does not fail before scenario coverage starts. |
| `OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS` | `8000` | Quiet window for negative no-reply assertions. Clamped to `≤` the run timeout. |
| `OPENCLAW_QA_MATRIX_CLEANUP_TIMEOUT_MS` | `90000` | Bound for Docker teardown. Failure surfaces include the recovery `docker compose ... down --remove-orphans` command. |
| `OPENCLAW_QA_MATRIX_TUWUNEL_IMAGE` | `ghcr.io/matrix-construct/tuwunel:v1.5.1` | Override the homeserver image when validating against a different Tuwunel version. |

View File

@@ -137,6 +137,7 @@ The Gateway writes a rolling log file (printed on startup as
`gateway log file: ...`). Look for `bonjour:` lines, especially:
- `bonjour: advertise failed ...`
- `bonjour: suppressing ciao cancellation ...`
- `bonjour: ... name conflict resolved` / `hostname conflict resolved`
- `bonjour: watchdog detected non-announced service ...`
- `bonjour: disabling advertiser after ... failed restarts ...`

View File

@@ -497,7 +497,7 @@ QMD model overrides stay on the QMD side, not OpenClaw config. If you need to ov
| ------------------------- | --------- | ------- | ------------------------------------- |
| `update.interval` | `string` | `5m` | Refresh interval |
| `update.debounceMs` | `number` | `15000` | Debounce file changes |
| `update.onBoot` | `boolean` | `true` | Refresh on startup |
| `update.onBoot` | `boolean` | `true` | Refresh on startup in a QMD subprocess |
| `update.waitForBootSync` | `boolean` | `false` | Block startup until refresh completes |
| `update.embedInterval` | `string` | -- | Separate embed cadence |
| `update.commandTimeoutMs` | `number` | -- | Timeout for QMD commands |
@@ -545,6 +545,8 @@ QMD model overrides stay on the QMD side, not OpenClaw config. If you need to ov
</Accordion>
</AccordionGroup>
QMD boot refreshes use a one-shot subprocess path during gateway startup. The long-lived QMD manager still owns the regular file watcher and interval timers when memory search is opened for interactive use.
### Full QMD example
```json5

View File

@@ -315,7 +315,7 @@ describe("gateway bonjour advertiser", () => {
await started.stop();
});
it("does not install process-level ciao handlers by default", async () => {
it("installs only the scoped ciao unhandled-rejection listener by default", async () => {
enableAdvertiserUnitMode();
const destroy = vi.fn().mockResolvedValue(undefined);
@@ -331,7 +331,7 @@ describe("gateway bonjour advertiser", () => {
{ logger },
);
expect(processOn).not.toHaveBeenCalledWith("unhandledRejection", expect.any(Function));
expect(processOn).toHaveBeenCalledWith("unhandledRejection", expect.any(Function));
expect(processOn).not.toHaveBeenCalledWith("uncaughtException", expect.any(Function));
await started.stop();
@@ -393,11 +393,11 @@ describe("gateway bonjour advertiser", () => {
expect(exceptionHandler).toBeTypeOf("function");
expect(handler?.(new Error("CIAO PROBING CANCELLED"))).toBe(true);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining("ignoring unhandled ciao rejection"),
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("suppressing ciao cancellation"),
);
logger.debug.mockClear();
logger.warn.mockClear();
expect(
handler?.(new Error("Reached illegal state! IPV4 address change from defined to undefined!")),
).toBe(true);
@@ -423,6 +423,37 @@ describe("gateway bonjour advertiser", () => {
await started.stop();
});
it("recovers when ciao cancellation escapes the advertiser", async () => {
enableAdvertiserUnitMode();
const destroy = vi.fn().mockResolvedValue(undefined);
const advertise = vi.fn().mockResolvedValue(undefined);
mockCiaoService({ advertise, destroy });
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const handler = registerUnhandledRejectionHandler.mock.calls[0]?.[0] as
| ((reason: unknown) => boolean)
| undefined;
expect(handler?.(new Error("CIAO ANNOUNCEMENT CANCELLED"))).toBe(true);
await vi.waitFor(() => {
expect(createService).toHaveBeenCalledTimes(2);
});
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("suppressing ciao cancellation"),
);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("restarting advertiser"));
expect(destroy).toHaveBeenCalledTimes(1);
expect(advertise).toHaveBeenCalledTimes(2);
await started.stop();
});
it("logs advertise failures and retries via watchdog", async () => {
enableAdvertiserUnitMode();
vi.useFakeTimers();

View File

@@ -69,6 +69,7 @@ type ServiceStateTracker = {
type ConsoleLogFn = (...args: unknown[]) => void;
type UncaughtExceptionHandler = (error: unknown) => boolean;
type UnhandledRejectionHandler = (reason: unknown) => boolean;
type ProcessUnhandledRejectionListener = (reason: unknown, promise: Promise<unknown>) => void;
type ExecBridge = (command: string, options?: unknown, callback?: unknown) => ChildProcess;
type ExecOptionsRecord = Record<string, unknown> & { windowsHide?: boolean };
@@ -324,6 +325,25 @@ function installCiaoWindowsExecHidePatch(): () => void {
};
}
function installCiaoUnhandledRejectionListener(handler: UnhandledRejectionHandler): () => void {
const hadOtherListeners = process.listenerCount("unhandledRejection") > 0;
const listener: ProcessUnhandledRejectionListener = (reason) => {
if (handler(reason)) {
return;
}
if (hadOtherListeners) {
return;
}
queueMicrotask(() => {
throw reason instanceof Error ? reason : new Error(String(reason));
});
};
process.on("unhandledRejection", listener);
return () => {
process.off("unhandledRejection", listener);
};
}
export async function startGatewayBonjourAdvertiser(
opts: GatewayBonjourAdvertiseOpts,
deps: BonjourAdvertiserDeps = {},
@@ -341,6 +361,7 @@ export async function startGatewayBonjourAdvertiser(
let restoreConsoleLog: () => void = () => {};
let requestCiaoRecovery: ((classification: CiaoProcessErrorClassification) => void) | undefined;
let cleanupUnhandledRejection: (() => void) | undefined;
let cleanupDirectUnhandledRejection: (() => void) | undefined;
let cleanupUncaughtException: (() => void) | undefined;
let processHandlersCleaned = false;
@@ -349,6 +370,7 @@ export async function startGatewayBonjourAdvertiser(
return;
}
processHandlersCleaned = true;
cleanupDirectUnhandledRejection?.();
cleanupUncaughtException?.();
cleanupUnhandledRejection?.();
}
@@ -363,7 +385,8 @@ export async function startGatewayBonjourAdvertiser(
}
if (classification.kind === "cancellation") {
logger.debug(`bonjour: ignoring unhandled ciao rejection: ${classification.formatted}`);
logger.warn(`bonjour: suppressing ciao cancellation: ${classification.formatted}`);
requestCiaoRecovery?.(classification);
} else if (classification.kind === "interface-enumeration-failure") {
// Restricted sandboxes can refuse os.networkInterfaces(); mDNS cannot
// function without it, so surface a single warning and skip recovery.
@@ -379,6 +402,7 @@ export async function startGatewayBonjourAdvertiser(
}
return true;
};
cleanupDirectUnhandledRejection = installCiaoUnhandledRejectionListener(handleCiaoProcessError);
cleanupUnhandledRejection = deps.registerUnhandledRejectionHandler?.(handleCiaoProcessError);
cleanupUncaughtException = deps.registerUncaughtExceptionHandler?.(handleCiaoProcessError);
@@ -490,6 +514,28 @@ export async function startGatewayBonjourAdvertiser(
}
}
function handleAdvertiseFailure(
label: string,
svc: BonjourService,
err: unknown,
action: "failed" | "threw",
) {
const classification = classifyCiaoProcessError(err);
if (classification) {
logger.warn(
`bonjour: advertise ${action} with ciao ${classification.kind} (${serviceSummary(
label,
svc,
)}): ${classification.formatted}`,
);
requestCiaoRecovery?.(classification);
return;
}
logger.warn(
`bonjour: advertise ${action} (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
);
}
function startAdvertising(services: Array<{ label: string; svc: BonjourService }>) {
for (const { label, svc } of services) {
try {
@@ -499,14 +545,10 @@ export async function startGatewayBonjourAdvertiser(
logger.info(`bonjour: advertised ${serviceSummary(label, svc)}`);
})
.catch((err) => {
logger.warn(
`bonjour: advertise failed (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
);
handleAdvertiseFailure(label, svc, err, "failed");
});
} catch (err) {
logger.warn(
`bonjour: advertise threw (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
);
handleAdvertiseFailure(label, svc, err, "threw");
}
}
}
@@ -523,8 +565,6 @@ export async function startGatewayBonjourAdvertiser(
let consecutiveRestarts = 0;
let cycle: BonjourCycle | null = createCycle();
const stateTracker = new Map<string, ServiceStateTracker>();
attachConflictListeners(cycle.services);
startAdvertising(cycle.services);
const updateStateTrackers = (services: Array<{ label: string; svc: BonjourService }>) => {
const now = Date.now();
@@ -578,6 +618,8 @@ export async function startGatewayBonjourAdvertiser(
requestCiaoRecovery = (classification) => {
void recreateAdvertiser(`ciao ${classification.kind}: ${classification.formatted}`);
};
attachConflictListeners(cycle.services);
startAdvertising(cycle.services);
const lastRepairAttempt = new Map<string, number>();
const watchdog = setInterval(() => {

View File

@@ -11,7 +11,8 @@
"playwright-core": "1.59.1",
"typebox": "1.1.33",
"undici": "8.1.0",
"ws": "^8.20.0"
"ws": "^8.20.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -1,15 +1,10 @@
import { chromium } from "playwright-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as chromeModule from "./chrome.js";
import {
closePlaywrightBrowserConnection,
createPageViaPlaywright,
getPageForTargetId,
listPagesViaPlaywright,
} from "./pw-session.js";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { connectOverCdpMock, getChromeWebSocketUrlMock } from "./pw-session.mock-setup.js";
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
let closePlaywrightBrowserConnection: typeof import("./pw-session.js").closePlaywrightBrowserConnection;
let createPageViaPlaywright: typeof import("./pw-session.js").createPageViaPlaywright;
let getPageForTargetId: typeof import("./pw-session.js").getPageForTargetId;
let listPagesViaPlaywright: typeof import("./pw-session.js").listPagesViaPlaywright;
type BrowserMockBundle = {
browser: import("playwright-core").Browser;
@@ -121,9 +116,18 @@ function makeMutatingDisconnectBrowser(): BrowserMockBundle & {
return { browser, browserClose, newPage };
}
beforeAll(async () => {
({
closePlaywrightBrowserConnection,
createPageViaPlaywright,
getPageForTargetId,
listPagesViaPlaywright,
} = await import("./pw-session.js"));
});
afterEach(async () => {
connectOverCdpSpy.mockReset();
getChromeWebSocketUrlSpy.mockReset();
connectOverCdpMock.mockReset();
getChromeWebSocketUrlMock.mockReset();
await closePlaywrightBrowserConnection().catch(() => {});
});
@@ -133,7 +137,7 @@ describe("pw-session connection scoping", () => {
const browserB = makeBrowser("B", "https://b.example");
let resolveA: ((value: import("playwright-core").Browser) => void) | undefined;
connectOverCdpSpy.mockImplementation((async (...args: unknown[]) => {
connectOverCdpMock.mockImplementation((async (...args: unknown[]) => {
const endpointText = String(args[0]);
if (endpointText === "http://127.0.0.1:9222") {
return await new Promise<import("playwright-core").Browser>((resolve) => {
@@ -145,21 +149,21 @@ describe("pw-session connection scoping", () => {
}
throw new Error(`unexpected endpoint: ${endpointText}`);
}) as never);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketUrlMock.mockResolvedValue(null);
const pendingA = listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" });
await Promise.resolve();
const pendingB = listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9333" });
await vi.waitFor(() => {
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
expect(connectOverCdpMock).toHaveBeenCalledTimes(2);
});
expect(connectOverCdpSpy).toHaveBeenNthCalledWith(
expect(connectOverCdpMock).toHaveBeenNthCalledWith(
1,
"http://127.0.0.1:9222",
expect.any(Object),
);
expect(connectOverCdpSpy).toHaveBeenNthCalledWith(
expect(connectOverCdpMock).toHaveBeenNthCalledWith(
2,
"http://127.0.0.1:9333",
expect.any(Object),
@@ -175,7 +179,7 @@ describe("pw-session connection scoping", () => {
const browserA = makeBrowser("A", "https://a.example");
const browserB = makeBrowser("B", "https://b.example");
connectOverCdpSpy.mockImplementation((async (...args: unknown[]) => {
connectOverCdpMock.mockImplementation((async (...args: unknown[]) => {
const endpointText = String(args[0]);
if (endpointText === "http://127.0.0.1:9222") {
return browserA.browser;
@@ -185,7 +189,7 @@ describe("pw-session connection scoping", () => {
}
throw new Error(`unexpected endpoint: ${endpointText}`);
}) as never);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketUrlMock.mockResolvedValue(null);
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" });
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9333" });
@@ -202,7 +206,7 @@ describe("pw-session connection scoping", () => {
const browserB = makeBrowser("B", "https://b.example");
let callsForA = 0;
connectOverCdpSpy.mockImplementation((async (...args: unknown[]) => {
connectOverCdpMock.mockImplementation((async (...args: unknown[]) => {
const endpointText = String(args[0]);
if (endpointText === "http://127.0.0.1:9222") {
callsForA += 1;
@@ -213,7 +217,7 @@ describe("pw-session connection scoping", () => {
}
throw new Error(`unexpected endpoint: ${endpointText}`);
}) as never);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketUrlMock.mockResolvedValue(null);
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" });
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9333" });
@@ -226,7 +230,7 @@ describe("pw-session connection scoping", () => {
expect(staleA.browserClose).toHaveBeenCalledTimes(1);
expect(refreshedA.browserClose).not.toHaveBeenCalled();
expect(browserB.browserClose).not.toHaveBeenCalled();
expect(connectOverCdpSpy).toHaveBeenCalledTimes(3);
expect(connectOverCdpMock).toHaveBeenCalledTimes(3);
});
it("reconnects listPagesViaPlaywright once after a cached transport disconnect", async () => {
@@ -234,7 +238,7 @@ describe("pw-session connection scoping", () => {
const refreshed = makeBrowser("A", "https://a.example/recovered");
let connectCalls = 0;
connectOverCdpSpy.mockImplementation((async (...args: unknown[]) => {
connectOverCdpMock.mockImplementation((async (...args: unknown[]) => {
const endpointText = String(args[0]);
if (endpointText !== "http://127.0.0.1:9222") {
throw new Error(`unexpected endpoint: ${endpointText}`);
@@ -242,12 +246,12 @@ describe("pw-session connection scoping", () => {
connectCalls += 1;
return connectCalls === 1 ? stale.browser : refreshed.browser;
}) as never);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketUrlMock.mockResolvedValue(null);
const pages = await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" });
expect(pages.map((page) => page.targetId)).toEqual(["A"]);
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
expect(connectOverCdpMock).toHaveBeenCalledTimes(2);
await vi.waitFor(() => expect(stale.browserClose).toHaveBeenCalledTimes(1));
expect(refreshed.browserClose).not.toHaveBeenCalled();
});
@@ -257,7 +261,7 @@ describe("pw-session connection scoping", () => {
const refreshed = makeBrowser("A", "https://a.example/recovered");
let connectCalls = 0;
connectOverCdpSpy.mockImplementation((async (...args: unknown[]) => {
connectOverCdpMock.mockImplementation((async (...args: unknown[]) => {
const endpointText = String(args[0]);
if (endpointText !== "http://127.0.0.1:9222") {
throw new Error(`unexpected endpoint: ${endpointText}`);
@@ -265,7 +269,7 @@ describe("pw-session connection scoping", () => {
connectCalls += 1;
return connectCalls === 1 ? stale.browser : refreshed.browser;
}) as never);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketUrlMock.mockResolvedValue(null);
await expect(
createPageViaPlaywright({
@@ -275,6 +279,6 @@ describe("pw-session connection scoping", () => {
).rejects.toThrow(/browser has been closed/);
expect(stale.newPage).toHaveBeenCalledTimes(1);
expect(connectOverCdpSpy).toHaveBeenCalledTimes(1);
expect(connectOverCdpMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,23 +1,18 @@
import { chromium } from "playwright-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import "../test-support/browser-security.mock.js";
import * as chromeModule from "./chrome.js";
import { BrowserTabNotFoundError } from "./errors.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
import * as navigationGuardModule from "./navigation-guard.js";
import {
BlockedBrowserTargetError,
closePlaywrightBrowserConnection,
createPageViaPlaywright,
forceDisconnectPlaywrightForTarget,
getPageForTargetId,
gotoPageWithNavigationGuard,
listPagesViaPlaywright,
} from "./pw-session.js";
import { connectOverCdpMock, getChromeWebSocketUrlMock } from "./pw-session.mock-setup.js";
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
let BlockedBrowserTargetError: typeof import("./pw-session.js").BlockedBrowserTargetError;
let closePlaywrightBrowserConnection: typeof import("./pw-session.js").closePlaywrightBrowserConnection;
let createPageViaPlaywright: typeof import("./pw-session.js").createPageViaPlaywright;
let forceDisconnectPlaywrightForTarget: typeof import("./pw-session.js").forceDisconnectPlaywrightForTarget;
let getPageForTargetId: typeof import("./pw-session.js").getPageForTargetId;
let gotoPageWithNavigationGuard: typeof import("./pw-session.js").gotoPageWithNavigationGuard;
let listPagesViaPlaywright: typeof import("./pw-session.js").listPagesViaPlaywright;
const PROXY_ENV_KEYS = [
"ALL_PROXY",
@@ -101,8 +96,8 @@ function installBrowserMocks() {
close: browserClose,
} as unknown as import("playwright-core").Browser;
connectOverCdpSpy.mockResolvedValue(browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
connectOverCdpMock.mockResolvedValue(browser);
getChromeWebSocketUrlMock.mockResolvedValue(null);
const getBrowserDisconnectedHandler = () =>
browserOn.mock.calls.find((call) => call[0] === "disconnected")?.[1] as
@@ -186,10 +181,22 @@ beforeEach(() => {
}
});
beforeAll(async () => {
({
BlockedBrowserTargetError,
closePlaywrightBrowserConnection,
createPageViaPlaywright,
forceDisconnectPlaywrightForTarget,
getPageForTargetId,
gotoPageWithNavigationGuard,
listPagesViaPlaywright,
} = await import("./pw-session.js"));
});
afterEach(async () => {
vi.unstubAllEnvs();
connectOverCdpSpy.mockClear();
getChromeWebSocketUrlSpy.mockClear();
connectOverCdpMock.mockClear();
getChromeWebSocketUrlMock.mockClear();
await closePlaywrightBrowserConnection().catch(() => {});
});

View File

@@ -1,14 +1,9 @@
import { chromium } from "playwright-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as chromeModule from "./chrome.js";
import {
closePlaywrightBrowserConnection,
getPageForTargetId,
listPagesViaPlaywright,
} from "./pw-session.js";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { connectOverCdpMock, getChromeWebSocketUrlMock } from "./pw-session.mock-setup.js";
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
let closePlaywrightBrowserConnection: typeof import("./pw-session.js").closePlaywrightBrowserConnection;
let getPageForTargetId: typeof import("./pw-session.js").getPageForTargetId;
let listPagesViaPlaywright: typeof import("./pw-session.js").listPagesViaPlaywright;
type MockPageSpec = {
targetId?: string;
@@ -61,9 +56,14 @@ function makeBrowser(pages: MockPageSpec[]): BrowserMockBundle {
return { browser, browserClose, pages: pageObjects };
}
beforeAll(async () => {
({ closePlaywrightBrowserConnection, getPageForTargetId, listPagesViaPlaywright } =
await import("./pw-session.js"));
});
afterEach(async () => {
connectOverCdpSpy.mockReset();
getChromeWebSocketUrlSpy.mockReset();
connectOverCdpMock.mockReset();
getChromeWebSocketUrlMock.mockReset();
await closePlaywrightBrowserConnection().catch(() => {});
});
@@ -100,8 +100,8 @@ function createExtensionFallbackBrowserHarness(options?: {
close: browserClose,
} as unknown as import("playwright-core").Browser;
connectOverCdpSpy.mockResolvedValue(browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
connectOverCdpMock.mockResolvedValue(browser);
getChromeWebSocketUrlMock.mockResolvedValue(null);
return { browserClose, newCDPSession, pages };
}
@@ -179,15 +179,15 @@ describe("pw-session getPageForTargetId", () => {
const stale = makeBrowser([]);
const fresh = makeBrowser([{ targetId: "TARGET_OK", url: "https://fresh.example" }]);
connectOverCdpSpy.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
connectOverCdpMock.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser);
getChromeWebSocketUrlMock.mockResolvedValue(null);
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" });
const resolved = await getPageForTargetId({ cdpUrl: "http://127.0.0.1:9222" });
expect(resolved).toBe(fresh.pages[0]);
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
expect(connectOverCdpMock).toHaveBeenCalledTimes(2);
expect(stale.browserClose).toHaveBeenCalledTimes(1);
});
@@ -201,8 +201,8 @@ describe("pw-session getPageForTargetId", () => {
{ targetId: "TARGET_B", url: "https://beta.example" },
]);
connectOverCdpSpy.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
connectOverCdpMock.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser);
getChromeWebSocketUrlMock.mockResolvedValue(null);
await getPageForTargetId({ cdpUrl: "http://127.0.0.1:9333" });
@@ -212,7 +212,7 @@ describe("pw-session getPageForTargetId", () => {
});
expect(resolved).toBe(fresh.pages[1]);
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
expect(connectOverCdpMock).toHaveBeenCalledTimes(2);
expect(stale.browserClose).toHaveBeenCalledTimes(1);
});
@@ -220,27 +220,27 @@ describe("pw-session getPageForTargetId", () => {
const stale = makeBrowser([]);
const stillBroken = makeBrowser([]);
connectOverCdpSpy
connectOverCdpMock
.mockResolvedValueOnce(stale.browser)
.mockResolvedValueOnce(stillBroken.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketUrlMock.mockResolvedValue(null);
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9444" });
await expect(getPageForTargetId({ cdpUrl: "http://127.0.0.1:9444" })).rejects.toThrow(
"No pages available in the connected browser.",
);
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
expect(connectOverCdpMock).toHaveBeenCalledTimes(2);
expect(stale.browserClose).toHaveBeenCalledTimes(1);
});
it("does not add an extra top-level retry for non-recoverable connect failures", async () => {
connectOverCdpSpy.mockRejectedValue(new Error("connectOverCDP exploded"));
getChromeWebSocketUrlSpy.mockResolvedValue(null);
connectOverCdpMock.mockRejectedValue(new Error("connectOverCDP exploded"));
getChromeWebSocketUrlMock.mockResolvedValue(null);
await expect(getPageForTargetId({ cdpUrl: "http://127.0.0.1:9555" })).rejects.toThrow(
"connectOverCDP exploded",
);
expect(connectOverCdpSpy).toHaveBeenCalledTimes(3);
expect(connectOverCdpMock).toHaveBeenCalledTimes(3);
});
});

View File

@@ -277,6 +277,54 @@ describe("Google speech provider", () => {
expect(result.audioBuffer.subarray(44)).toEqual(pcm);
});
it("retries once when Gemini TTS fetch aborts", async () => {
const pcm = Buffer.from([7, 0, 8, 0]);
const abortError = new Error("This operation was aborted");
abortError.name = "AbortError";
const requestSequence = vi
.fn()
.mockRejectedValueOnce(abortError)
.mockResolvedValueOnce({
response: googleTtsResponse(pcm),
release: vi.fn(async () => {}),
});
postJsonRequestMock.mockImplementation(requestSequence);
const provider = buildGoogleSpeechProvider();
const result = await provider.synthesize({
text: "Retry aborted fetch.",
cfg: {},
providerConfig: {
apiKey: "google-test-key",
},
target: "audio-file",
timeoutMs: 5_000,
});
expect(requestSequence).toHaveBeenCalledTimes(2);
expect(result.audioBuffer.subarray(44)).toEqual(pcm);
});
it("does not retry non-transient Gemini TTS request failures", async () => {
const requestSequence = vi.fn().mockRejectedValueOnce(new Error("invalid request"));
postJsonRequestMock.mockImplementation(requestSequence);
const provider = buildGoogleSpeechProvider();
await expect(
provider.synthesize({
text: "Do not retry this.",
cfg: {},
providerConfig: {
apiKey: "google-test-key",
},
target: "audio-file",
timeoutMs: 5_000,
}),
).rejects.toThrow("invalid request");
expect(requestSequence).toHaveBeenCalledTimes(1);
});
it("falls back to GEMINI_API_KEY and configured Google API base URL", async () => {
vi.stubEnv("GEMINI_API_KEY", "env-google-key");
const requestMock = installGoogleTtsRequestMock();

View File

@@ -107,6 +107,25 @@ class GoogleTtsRetryableError extends Error {
}
}
function isGoogleTtsRetryableError(err: unknown): boolean {
if (err instanceof GoogleTtsRetryableError) {
return true;
}
if (!(err instanceof Error)) {
return false;
}
if (err.name === "AbortError") {
return true;
}
const message = err.message.toLowerCase();
return (
message.includes("aborted") ||
message.includes("timeout") ||
message.includes("fetch failed") ||
message.includes("network")
);
}
function normalizeGoogleTtsModel(model: unknown): string {
const trimmed = normalizeOptionalString(model);
if (!trimmed) {
@@ -509,7 +528,7 @@ async function synthesizeGoogleTtsPcm(params: {
return await synthesizeGoogleTtsPcmOnce(params);
} catch (err) {
lastError = err;
if (!(err instanceof GoogleTtsRetryableError) || attempt > 0) {
if (!isGoogleTtsRetryableError(err) || attempt > 0) {
throw err;
}
}

View File

@@ -432,6 +432,18 @@ describe("QmdMemoryManager", () => {
};
const initialUpdateCalls = spawnMock.mock.calls.filter((call) => call[1]?.[0] === "update");
expect(initialUpdateCalls).toHaveLength(0);
const [, watchOptions] = watchMock.mock.calls[0] as unknown as [
string[],
{ ignored?: (watchPath: string) => boolean },
];
expect(watchOptions.ignored?.(path.join(workspaceDir, "node_modules", "pkg", "note.md"))).toBe(
true,
);
expect(watchOptions.ignored?.(path.join(workspaceDir, ".cache", "qmd", "note.md"))).toBe(true);
expect(watchOptions.ignored?.(path.join(workspaceDir, "vendor", "pkg", "note.md"))).toBe(true);
expect(watchOptions.ignored?.(path.join(workspaceDir, "dist", "note.md"))).toBe(true);
expect(watchOptions.ignored?.(path.join(workspaceDir, "build", "note.md"))).toBe(true);
expect(watchOptions.ignored?.(path.join(workspaceDir, "notes.md"))).toBe(false);
watcher.emit("change", path.join(workspaceDir, "notes.md"));
expect(manager.status().dirty).toBe(true);

View File

@@ -79,7 +79,11 @@ const QMD_EMBED_QUEUE_KEY = Symbol.for("openclaw.qmdEmbedQueueTail");
const QMD_UPDATE_QUEUE_KEY = Symbol.for("openclaw.qmdUpdateQueueState");
const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([
".git",
".cache",
"node_modules",
"vendor",
"dist",
"build",
".pnpm-store",
".venv",
"venv",
@@ -402,6 +406,7 @@ export class QmdMemoryManager implements MemorySearchManager {
}
private async initialize(mode: QmdManagerMode): Promise<void> {
const startTime = Date.now();
this.bootstrapCollections();
if (mode === "status") {
return;
@@ -424,10 +429,16 @@ export class QmdMemoryManager implements MemorySearchManager {
await this.ensureCollections();
if (mode === "cli") {
log.info(
`qmd manager initialized for agent "${this.agentId}" mode=cli collections=${this.qmd.collections.length} durationMs=${Date.now() - startTime}`,
);
return;
}
this.ensureWatcher();
log.info(
`qmd manager initialized for agent "${this.agentId}" mode=full collections=${this.qmd.collections.length} durationMs=${Date.now() - startTime}`,
);
if (this.qmd.update.onBoot) {
const bootRun = this.runUpdate("boot", true);
@@ -1459,6 +1470,10 @@ export class QmdMemoryManager implements MemorySearchManager {
return;
}
const run = async () => {
const startTime = Date.now();
log.debug(
`qmd sync started for agent "${this.agentId}" reason=${reason} force=${force === true}`,
);
await this.withQmdUpdateQueue(async () => {
if (this.closed) {
return;
@@ -1492,6 +1507,9 @@ export class QmdMemoryManager implements MemorySearchManager {
}
this.lastUpdateAt = Date.now();
this.docPathCache.clear();
log.info(
`qmd sync completed for agent "${this.agentId}" reason=${reason} durationMs=${Date.now() - startTime}`,
);
};
this.pendingUpdate = run().finally(() => {
this.pendingUpdate = null;
@@ -1513,7 +1531,10 @@ export class QmdMemoryManager implements MemorySearchManager {
if (watchPaths.size === 0) {
return;
}
this.watcher = chokidar.watch(Array.from(watchPaths), {
const watchPathList = Array.from(watchPaths);
const startTime = Date.now();
log.info(`qmd watcher starting for agent "${this.agentId}" paths=${watchPathList.length}`);
this.watcher = chokidar.watch(watchPathList, {
ignoreInitial: true,
ignored: (watchPath) => shouldIgnoreMemoryWatchPath(watchPath),
awaitWriteFinish: {
@@ -1528,6 +1549,11 @@ export class QmdMemoryManager implements MemorySearchManager {
this.watcher.on("add", markDirty);
this.watcher.on("change", markDirty);
this.watcher.on("unlink", markDirty);
this.watcher.once("ready", () => {
log.info(
`qmd watcher ready for agent "${this.agentId}" paths=${watchPathList.length} durationMs=${Date.now() - startTime}`,
);
});
}
private resolveCollectionWatchPath(collection: ManagedCollection): string {

View File

@@ -5,6 +5,7 @@ import { createKimiWebSearchProvider } from "./src/kimi-web-search-provider.js";
const KIMI_SEARCH_KEY =
process.env.KIMI_API_KEY?.trim() || process.env.MOONSHOT_API_KEY?.trim() || "";
const describeLive = isLiveTestEnabled() && KIMI_SEARCH_KEY.length > 0 ? describe : describe.skip;
const KIMI_LIVE_SEARCH_TIMEOUT_SECONDS = 60;
function isTransientKimiSearchError(error: unknown): boolean {
if (!(error instanceof Error)) {
@@ -22,7 +23,11 @@ describeLive("moonshot plugin live", () => {
const provider = createKimiWebSearchProvider();
const tool = provider.createTool?.({
config: {},
searchConfig: { kimi: { apiKey: KIMI_SEARCH_KEY }, cacheTtlMinutes: 0, timeoutSeconds: 90 },
searchConfig: {
kimi: { apiKey: KIMI_SEARCH_KEY },
cacheTtlMinutes: 0,
timeoutSeconds: KIMI_LIVE_SEARCH_TIMEOUT_SECONDS,
},
} as never);
let result: { provider?: string; content?: unknown; citations?: unknown } | undefined;
@@ -47,5 +52,5 @@ describeLive("moonshot plugin live", () => {
expect(typeof result?.content).toBe("string");
expect((result?.content as string).length).toBeGreaterThan(20);
expect(Array.isArray(result?.citations)).toBe(true);
}, 120_000);
}, 180_000);
});

View File

@@ -13,6 +13,11 @@ import {
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { runRealtimeSttLiveTest } from "openclaw/plugin-sdk/provider-test-contracts";
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
import {
isOverloadedErrorMessage,
isServerErrorMessage,
isTimeoutErrorMessage,
} from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
@@ -99,6 +104,21 @@ function createReferencePng(): Buffer {
return encodePngRgba(buf, width, height);
}
function formatLiveOpenAIError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function resolveLiveOpenAISkipReason(error: unknown): string | null {
const message = formatLiveOpenAIError(error);
if (isTimeoutErrorMessage(message) || /timed out|operation was aborted/i.test(message)) {
return "provider timeout";
}
if (isOverloadedErrorMessage(message) || isServerErrorMessage(message)) {
return "provider outage";
}
return null;
}
function createLiveConfig(): OpenClawConfig {
const cfg = getRuntimeConfig();
return {
@@ -151,6 +171,10 @@ async function createTempAgentDir(): Promise<string> {
return await fs.mkdtemp(path.join(os.tmpdir(), "openai-plugin-live-"));
}
async function removeTempAgentDir(agentDir: string): Promise<void> {
await fs.rm(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
function normalizeTranscriptForMatch(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
@@ -387,7 +411,7 @@ describeLive("openai plugin live", () => {
expect(generated.images[0]?.mimeType).toBe("image/png");
expect(generated.images[0]?.buffer.byteLength).toBeGreaterThan(1_000);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await removeTempAgentDir(agentDir);
}
}, 240_000);
@@ -424,7 +448,7 @@ describeLive("openai plugin live", () => {
expect(edited.images[0]?.mimeType).toBe("image/png");
expect(edited.images[0]?.buffer.byteLength).toBeGreaterThan(1_000);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await removeTempAgentDir(agentDir);
}
}, 240_000);
@@ -436,22 +460,36 @@ describeLive("openai plugin live", () => {
const agentDir = await createTempAgentDir();
try {
const description = await mediaProvider.describeImage?.({
buffer: createReferencePng(),
fileName: "reference.png",
mime: "image/png",
prompt: "Reply with one lowercase word for the dominant center color.",
timeoutMs: 120_000,
agentDir,
cfg,
authStore: EMPTY_AUTH_STORE,
model: LIVE_VISION_MODEL,
provider: "openai",
});
let description:
| Awaited<ReturnType<NonNullable<typeof mediaProvider.describeImage>>>
| undefined;
try {
description = await mediaProvider.describeImage?.({
buffer: createReferencePng(),
fileName: "reference.png",
mime: "image/png",
prompt: "Reply with one lowercase word for the dominant center color.",
timeoutMs: 45_000,
agentDir,
cfg,
authStore: EMPTY_AUTH_STORE,
model: LIVE_VISION_MODEL,
provider: "openai",
});
} catch (err) {
const skipReason = resolveLiveOpenAISkipReason(err);
if (skipReason) {
console.warn(
`[live:openai] image description skipped: ${skipReason}: ${formatLiveOpenAIError(err)}`,
);
return;
}
throw err;
}
expect((description?.text ?? "").toLowerCase()).toContain("orange");
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await removeTempAgentDir(agentDir);
}
}, 180_000);
}, 240_000);
});

View File

@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import setupEntry from "./setup-entry.js";
describe("qa-channel setup entry", () => {
it("exposes the bundled setup-entry contract", () => {
expect(setupEntry.kind).toBe("bundled-channel-setup-entry");
expect(setupEntry.loadSetupPlugin().id).toBe("qa-channel");
});
});

View File

@@ -1,4 +1,13 @@
import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core";
import { qaChannelPlugin } from "./src/channel.js";
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineSetupPluginEntry(qaChannelPlugin);
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./channel-plugin-api.js",
exportName: "qaChannelPlugin",
},
runtime: {
specifier: "./api.js",
exportName: "setQaChannelRuntime",
},
});

View File

@@ -1,3 +1,4 @@
import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers";
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import {
@@ -56,6 +57,30 @@ function createMockQaRuntime(params?: {
sessionUpdatedAt.set(sessionKey, Date.now());
},
},
text: {
hasControlCommand() {
return false;
},
},
mentions: {
buildMentionRegexes() {
return [/\b@?openclaw\b/i];
},
matchesMentionPatterns(text: string, regexes: RegExp[]) {
return regexes.some((regex) => regex.test(text));
},
resolveInboundMentionDecision,
},
groups: {
resolveRequireMention() {
return true;
},
},
commands: {
shouldHandleTextCommands() {
return true;
},
},
reply: {
resolveEnvelopeFormatOptions() {
return {};
@@ -197,6 +222,78 @@ describe("qa-channel plugin", () => {
}
});
it("marks mentioned threaded group traffic before dispatch", { timeout: 20_000 }, async () => {
let dispatchedCtx: Record<string, unknown> | null = null;
const harness = await startQaChannelTestHarness({
allowFrom: ["*"],
runtime: createMockQaRuntime({
onDispatch: (ctx) => {
dispatchedCtx = ctx;
},
}),
});
try {
harness.state.addInboundMessage({
conversation: { id: "qa-room", kind: "channel", title: "QA Room" },
senderId: "alice",
senderName: "Alice",
text: "@openclaw thread memory check",
threadId: "thread-1",
threadTitle: "Thread 1",
});
const outbound = await harness.state.waitFor({
kind: "message-text",
textIncludes: "qa-echo: @openclaw thread memory check",
direction: "outbound",
timeoutMs: 15_000,
});
expect("threadId" in outbound && outbound.threadId).toBe("thread-1");
expect(dispatchedCtx).toMatchObject({
ChatType: "group",
WasMentioned: true,
MessageThreadId: "thread-1",
});
} finally {
await harness.stop();
}
});
it("drops unmentioned group traffic when mention is required", { timeout: 20_000 }, async () => {
let didDispatch = false;
const harness = await startQaChannelTestHarness({
allowFrom: ["*"],
runtime: createMockQaRuntime({
onDispatch: () => {
didDispatch = true;
},
}),
});
try {
harness.state.addInboundMessage({
conversation: { id: "qa-room", kind: "channel", title: "QA Room" },
senderId: "alice",
senderName: "Alice",
text: "thread memory check",
threadId: "thread-1",
});
await expect(
harness.state.waitFor({
kind: "message-text",
textIncludes: "qa-echo:",
direction: "outbound",
timeoutMs: 750,
}),
).rejects.toThrow(/wait timeout/i);
expect(didDispatch).toBe(false);
} finally {
await harness.stop();
}
});
it("stages inbound image attachments into agent media payload", { timeout: 20_000 }, async () => {
let dispatchedCtx: Record<string, unknown> | null = null;
const harness = await startQaChannelTestHarness({

View File

@@ -1,5 +1,17 @@
import { describe, expect, it } from "vitest";
import { isHttpMediaUrl } from "./inbound.js";
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setQaChannelRuntime } from "../api.js";
import { handleQaInbound, isHttpMediaUrl } from "./inbound.js";
const dispatchInboundReplyWithBaseMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/inbound-reply-dispatch", () => ({
dispatchInboundReplyWithBase: dispatchInboundReplyWithBaseMock,
}));
beforeEach(() => {
dispatchInboundReplyWithBaseMock.mockReset();
});
describe("isHttpMediaUrl", () => {
it("accepts only http and https urls", () => {
@@ -10,3 +22,45 @@ describe("isHttpMediaUrl", () => {
expect(isHttpMediaUrl("data:text/plain;base64,SGVsbG8=")).toBe(false);
});
});
describe("handleQaInbound", () => {
it("marks group messages that match configured mention patterns", async () => {
const runtime = createPluginRuntimeMock();
vi.mocked(runtime.channel.mentions.buildMentionRegexes).mockReturnValue([/\b@?openclaw\b/i]);
setQaChannelRuntime(runtime);
await handleQaInbound({
channelId: "qa-channel",
channelLabel: "QA Channel",
account: {
accountId: "default",
enabled: true,
configured: true,
baseUrl: "http://127.0.0.1:43123",
botUserId: "openclaw",
botDisplayName: "OpenClaw QA",
pollTimeoutMs: 250,
config: {},
},
config: {},
message: {
id: "msg-1",
accountId: "default",
direction: "inbound",
conversation: {
kind: "channel",
id: "qa-room",
title: "QA Room",
},
senderId: "alice",
senderName: "Alice",
text: "@openclaw ping",
timestamp: 1_777_000_000_000,
reactions: [],
},
});
expect(dispatchInboundReplyWithBaseMock).toHaveBeenCalledTimes(1);
expect(dispatchInboundReplyWithBaseMock.mock.calls[0]?.[0].ctxPayload.WasMentioned).toBe(true);
});
});

View File

@@ -81,6 +81,48 @@ export async function handleQaInbound(params: {
id: target,
},
});
const isGroup = inbound.conversation.kind !== "direct";
const mentionRegexes = isGroup
? runtime.channel.mentions.buildMentionRegexes(params.config as OpenClawConfig, route.agentId)
: [];
const wasMentioned =
isGroup && mentionRegexes.length > 0
? runtime.channel.mentions.matchesMentionPatterns(inbound.text, mentionRegexes)
: false;
const allowTextCommands = runtime.channel.commands.shouldHandleTextCommands({
cfg: params.config as OpenClawConfig,
surface: params.channelId,
});
const hasControlCommand = runtime.channel.text.hasControlCommand(
inbound.text,
params.config as OpenClawConfig,
);
const commandAuthorized = true;
const requireMention = isGroup
? runtime.channel.groups.resolveRequireMention({
cfg: params.config as OpenClawConfig,
channel: params.channelId,
groupId: inbound.conversation.id,
accountId: params.account.accountId,
})
: false;
const mentionDecision = runtime.channel.mentions.resolveInboundMentionDecision({
facts: {
canDetectMention: mentionRegexes.length > 0,
wasMentioned,
hasAnyMention: wasMentioned,
},
policy: {
isGroup,
requireMention,
allowTextCommands,
hasControlCommand,
commandAuthorized,
},
});
if (isGroup && mentionDecision.shouldSkip) {
return;
}
const storePath = runtime.channel.session.resolveStorePath(params.config.session?.store, {
agentId: route.agentId,
});
@@ -110,7 +152,7 @@ export async function handleQaInbound(params: {
To: target,
SessionKey: route.sessionKey,
AccountId: route.accountId ?? params.account.accountId,
ChatType: inbound.conversation.kind === "direct" ? "direct" : "group",
ChatType: isGroup ? "group" : "direct",
ConversationLabel:
inbound.threadTitle ||
inbound.conversation.title ||
@@ -135,7 +177,8 @@ export async function handleQaInbound(params: {
Timestamp: inbound.timestamp,
OriginatingChannel: params.channelId,
OriginatingTo: target,
CommandAuthorized: true,
WasMentioned: isGroup ? mentionDecision.effectiveWasMentioned : undefined,
CommandAuthorized: commandAuthorized,
...mediaPayload,
});

View File

@@ -1,3 +1,4 @@
import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
type SessionRecord = {
@@ -48,6 +49,30 @@ export function createQaRunnerRuntime(): PluginRuntime {
});
},
},
text: {
hasControlCommand() {
return false;
},
},
mentions: {
buildMentionRegexes() {
return [/\b@?openclaw\b/i];
},
matchesMentionPatterns(text: string, regexes: RegExp[]) {
return regexes.some((regex) => regex.test(text));
},
resolveInboundMentionDecision,
},
groups: {
resolveRequireMention() {
return false;
},
},
commands: {
shouldHandleTextCommands() {
return true;
},
},
reply: {
resolveEnvelopeFormatOptions() {
return {};

View File

@@ -134,6 +134,7 @@ describe("discord live qa runtime", () => {
expect(next.plugins?.allow).toContain("discord");
expect(next.plugins?.entries?.discord).toEqual({ enabled: true });
expect(next.messages?.groupChat?.visibleReplies).toBe("automatic");
expect(next.channels?.discord).toEqual({
enabled: true,
defaultAccount: "sut",

View File

@@ -287,6 +287,13 @@ function buildDiscordQaConfig(
allow: pluginAllow,
entries: pluginEntries,
},
messages: {
...baseCfg.messages,
groupChat: {
...baseCfg.messages?.groupChat,
visibleReplies: "automatic",
},
},
channels: {
...baseCfg.channels,
discord: {

View File

@@ -94,6 +94,62 @@ describe("startQaLiveLaneGateway", () => {
expect(mockStop).toHaveBeenCalledTimes(1);
});
it("disables memory search for transport-only live lanes", async () => {
await startQaLiveLaneGateway({
repoRoot: "/tmp/openclaw-repo",
transport: createStubTransport(),
transportBaseUrl: "http://127.0.0.1:43123",
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.5",
alternateModel: "mock-openai/gpt-5.5-alt",
controlUiEnabled: false,
});
const [{ mutateConfig }] = startQaGatewayChild.mock.calls[0] ?? [];
expect(typeof mutateConfig).toBe("function");
const cfg = mutateConfig?.({
plugins: {
allow: ["acpx", "memory-core", "qa-channel"],
entries: {
acpx: { enabled: true },
"memory-core": { enabled: true },
"qa-channel": { enabled: true },
},
slots: {
memory: "memory-core",
contextEngine: "qmd",
},
},
agents: {
defaults: {
memorySearch: {
enabled: true,
sync: {
onSearch: true,
onSessionStart: true,
watch: true,
},
},
},
},
});
expect(cfg?.plugins?.allow).toEqual(["acpx", "qa-channel"]);
expect(cfg?.plugins?.entries).not.toHaveProperty("memory-core");
expect(cfg?.plugins?.slots).toMatchObject({
memory: "none",
contextEngine: "qmd",
});
expect(cfg?.agents?.defaults?.memorySearch).toMatchObject({
enabled: false,
sync: {
onSearch: false,
onSessionStart: false,
watch: false,
},
});
});
it("forwards gateway stop options to the child harness", async () => {
const harness = await startQaLiveLaneGateway({
repoRoot: "/tmp/openclaw-repo",

View File

@@ -34,6 +34,52 @@ async function stopQaLiveLaneResources(
}
}
function omitMemoryCoreEntry<T extends Record<string, unknown> | undefined>(entries: T): T {
if (!entries || !Object.prototype.hasOwnProperty.call(entries, "memory-core")) {
return entries;
}
const { "memory-core": _memoryCore, ...rest } = entries;
return rest as T;
}
function prepareLiveTransportGatewayConfig(cfg: OpenClawConfig): OpenClawConfig {
const defaults = cfg.agents?.defaults ?? {};
return {
...cfg,
plugins: cfg.plugins
? {
...cfg.plugins,
allow: cfg.plugins.allow?.filter((pluginId) => pluginId !== "memory-core"),
entries: omitMemoryCoreEntry(cfg.plugins.entries),
slots: {
...cfg.plugins.slots,
memory: "none",
},
}
: {
slots: {
memory: "none",
},
},
agents: {
...cfg.agents,
defaults: {
...defaults,
memorySearch: {
...defaults.memorySearch,
enabled: false,
sync: {
...defaults.memorySearch?.sync,
onSearch: false,
onSessionStart: false,
watch: false,
},
},
},
},
};
}
export async function startQaLiveLaneGateway(params: {
repoRoot: string;
command?: QaGatewayChildCommand;
@@ -70,7 +116,8 @@ export async function startQaLiveLaneGateway(params: {
thinkingDefault: params.thinkingDefault,
claudeCliAuthMode: params.claudeCliAuthMode,
controlUiEnabled: params.controlUiEnabled,
mutateConfig: params.mutateConfig,
mutateConfig: (cfg) =>
prepareLiveTransportGatewayConfig(params.mutateConfig ? params.mutateConfig(cfg) : cfg),
});
return {
gateway,

View File

@@ -28,6 +28,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
describe("telegram live qa runtime", () => {
afterEach(() => {
vi.useRealTimers();
fetchWithSsrFGuardMock.mockClear();
vi.restoreAllMocks();
vi.unstubAllGlobals();
@@ -100,6 +101,46 @@ describe("telegram live qa runtime", () => {
).toBe(true);
});
it("normalizes the Telegram canary timeout env", () => {
expect(
__testing.resolveTelegramQaCanaryTimeoutMs({
OPENCLAW_QA_TELEGRAM_CANARY_TIMEOUT_MS: "12345",
}),
).toBe(12345);
expect(
__testing.resolveTelegramQaCanaryTimeoutMs({
OPENCLAW_QA_TELEGRAM_CANARY_TIMEOUT_MS: "nope",
}),
).toBe(60_000);
});
it("waits for Telegram polling connectivity before treating the account as ready", async () => {
vi.useFakeTimers();
const gateway = {
call: vi
.fn()
.mockResolvedValueOnce({
channelAccounts: {
telegram: [{ accountId: "sut", connected: false, running: true }],
},
})
.mockResolvedValueOnce({
channelAccounts: {
telegram: [{ accountId: "sut", connected: true, running: true }],
},
}),
};
const ready = __testing.waitForTelegramChannelRunning(gateway as never, "sut", {
pollIntervalMs: 10,
timeoutMs: 1_000,
});
await vi.advanceTimersByTimeAsync(10);
await expect(ready).resolves.toBeUndefined();
expect(gateway.call).toHaveBeenCalledTimes(2);
});
it("sanitizes and truncates Telegram live progress details", () => {
expect(__testing.sanitizeTelegramQaProgressValue("scenario\nid\tvalue")).toBe(
"scenario id value",
@@ -165,6 +206,7 @@ describe("telegram live qa runtime", () => {
expect(next.agents?.defaults?.skipBootstrap).toBe(true);
expect(next.plugins?.allow).toContain("telegram");
expect(next.plugins?.entries?.telegram).toEqual({ enabled: true });
expect(next.messages?.groupChat?.visibleReplies).toBe("automatic");
expect(next.channels?.telegram).toEqual({
enabled: true,
defaultAccount: "sut",

View File

@@ -302,6 +302,7 @@ const TELEGRAM_QA_ENV_KEYS = [
"OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN",
"OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN",
] as const;
const DEFAULT_TELEGRAM_QA_CANARY_TIMEOUT_MS = 60_000;
const TELEGRAM_QA_CAPTURE_CONTENT_ENV = "OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT";
const QA_REDACT_PUBLIC_METADATA_ENV = "OPENCLAW_QA_REDACT_PUBLIC_METADATA";
const QA_SUITE_PROGRESS_ENV = "OPENCLAW_QA_SUITE_PROGRESS";
@@ -342,6 +343,26 @@ function parseTelegramQaProgressBooleanEnv(value: string | undefined): boolean |
return undefined;
}
function parsePositiveTelegramQaEnvMs(env: NodeJS.ProcessEnv, name: string, fallback: number) {
const raw = env[name];
if (raw === undefined) {
return fallback;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 1) {
return fallback;
}
return Math.floor(parsed);
}
function resolveTelegramQaCanaryTimeoutMs(env: NodeJS.ProcessEnv = process.env) {
return parsePositiveTelegramQaEnvMs(
env,
"OPENCLAW_QA_TELEGRAM_CANARY_TIMEOUT_MS",
DEFAULT_TELEGRAM_QA_CANARY_TIMEOUT_MS,
);
}
function shouldLogTelegramQaLiveProgress(env: NodeJS.ProcessEnv = process.env) {
const override = parseTelegramQaProgressBooleanEnv(env[QA_SUITE_PROGRESS_ENV]);
if (override !== undefined) {
@@ -486,6 +507,13 @@ function buildTelegramQaConfig(
allow: pluginAllow,
entries: pluginEntries,
},
messages: {
...baseCfg.messages,
groupChat: {
...baseCfg.messages?.groupChat,
visibleReplies: "automatic",
},
},
channels: {
...baseCfg.channels,
telegram: {
@@ -633,9 +661,15 @@ async function waitForObservedMessage(params: {
async function waitForTelegramChannelRunning(
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>,
accountId: string,
opts: {
pollIntervalMs?: number;
timeoutMs?: number;
} = {},
) {
const startedAt = Date.now();
while (Date.now() - startedAt < 45_000) {
const timeoutMs = opts.timeoutMs ?? 90_000;
const pollIntervalMs = opts.pollIntervalMs ?? 500;
while (Date.now() - startedAt < timeoutMs) {
try {
const payload = (await gateway.call(
"channels.status",
@@ -644,20 +678,25 @@ async function waitForTelegramChannelRunning(
)) as {
channelAccounts?: Record<
string,
Array<{ accountId?: string; running?: boolean; restartPending?: boolean }>
Array<{
accountId?: string;
connected?: boolean;
running?: boolean;
restartPending?: boolean;
}>
>;
};
const accounts = payload.channelAccounts?.telegram ?? [];
const match = accounts.find((entry) => entry.accountId === accountId);
if (match?.running && match.restartPending !== true) {
if (match?.running && match.connected === true && match.restartPending !== true) {
return;
}
} catch {
// retry
}
await new Promise((resolve) => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error(`telegram account "${accountId}" did not become ready`);
throw new Error(`telegram account "${accountId}" did not become ready and connected`);
}
function renderTelegramQaMarkdown(params: {
@@ -831,6 +870,7 @@ async function runCanary(params: {
params.groupId,
`/help@${params.sutUsername}`,
);
const canaryTimeoutMs = resolveTelegramQaCanaryTimeoutMs();
const requestStartedAt = new Date(requestStartedAtMs).toISOString();
let firstUnthreadedReply:
| Pick<TelegramObservedMessage, "messageId" | "replyToMessageId" | "text">
@@ -840,7 +880,7 @@ async function runCanary(params: {
sutObserved = await waitForObservedMessage({
token: params.driverToken,
initialOffset: offset,
timeoutMs: 30_000,
timeoutMs: canaryTimeoutMs,
observedMessages: params.observedMessages,
observationScenarioId: "telegram-canary",
observationScenarioTitle: "Telegram canary",
@@ -881,7 +921,7 @@ async function runCanary(params: {
}
throw new TelegramQaCanaryError(
"sut_reply_timeout",
"SUT bot did not send any group reply after the canary command within 30s.",
`SUT bot did not send any group reply after the canary command within ${Math.round(canaryTimeoutMs / 1000)}s.`,
{
groupId: params.groupId,
sutBotId: params.sutBotId,
@@ -1439,10 +1479,12 @@ export const __testing = {
matchesTelegramScenarioReply,
normalizeTelegramObservedMessage,
parseTelegramQaProgressBooleanEnv,
resolveTelegramQaCanaryTimeoutMs,
parseTelegramQaCredentialPayload,
resolveTelegramQaRuntimeEnv,
sanitizeTelegramQaProgressValue,
shouldLogTelegramQaLiveProgress,
waitForTelegramChannelRunning,
formatTelegramQaProgressDetails,
renderTelegramQaMarkdown,
};

View File

@@ -24,6 +24,7 @@ describe("qa channel transport", () => {
messages: {
groupChat: {
mentionPatterns: ["\\b@?openclaw\\b"],
visibleReplies: "automatic",
},
},
});

View File

@@ -90,6 +90,7 @@ export function createQaChannelGatewayConfig(params: {
messages: {
groupChat: {
mentionPatterns: ["\\b@?openclaw\\b"],
visibleReplies: "automatic",
},
},
};

View File

@@ -23,6 +23,7 @@ function createQaChannelTransportParams(baseUrl = "http://127.0.0.1:43124") {
messages: {
groupChat: {
mentionPatterns: ["\\b@?openclaw\\b"],
visibleReplies: "automatic",
},
},
} satisfies QaTransportGatewayConfig,
@@ -77,7 +78,10 @@ describe("buildQaGatewayConfig", () => {
baseUrl: "http://127.0.0.1:43124",
pollTimeoutMs: 250,
});
expect(cfg.messages?.groupChat?.mentionPatterns).toEqual(["\\b@?openclaw\\b"]);
expect(cfg.messages?.groupChat).toMatchObject({
mentionPatterns: ["\\b@?openclaw\\b"],
visibleReplies: "automatic",
});
});
it("maps provider-qualified openai and anthropic refs through the mock provider lane", () => {

View File

@@ -106,6 +106,22 @@ describe("matrix live qa runtime", () => {
}
});
it("normalizes the Matrix QA canary timeout env", () => {
const previous = process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS;
try {
process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS = "12345";
expect(liveTesting.resolveMatrixQaCanaryTimeoutMs()).toBe(12345);
process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS = "nope";
expect(liveTesting.resolveMatrixQaCanaryTimeoutMs()).toBe(90_000);
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS;
} else {
process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS = previous;
}
}
});
it("injects a temporary Matrix account into the QA gateway config", () => {
const baseCfg: OpenClawConfig = {
plugins: {
@@ -148,6 +164,7 @@ describe("matrix live qa runtime", () => {
expect(next.plugins?.allow).toContain("matrix");
expect(next.plugins?.entries?.matrix).toEqual({ enabled: true });
expect(next.messages?.groupChat?.visibleReplies).toBe("automatic");
expect(next.channels?.matrix).toEqual({
enabled: true,
defaultAccount: "sut",

View File

@@ -50,6 +50,7 @@ type MatrixQaGatewayChild = {
};
const DEFAULT_MATRIX_QA_RUN_TIMEOUT_MS = 30 * 60_000;
const DEFAULT_MATRIX_QA_CANARY_TIMEOUT_MS = 90_000;
const DEFAULT_MATRIX_QA_CLEANUP_TIMEOUT_MS = 90_000;
type MatrixQaLiveLaneGatewayHarness = {
@@ -192,6 +193,13 @@ function createMatrixQaRunDeadline() {
};
}
function resolveMatrixQaCanaryTimeoutMs() {
return parsePositiveMatrixQaEnvMs(
"OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS",
DEFAULT_MATRIX_QA_CANARY_TIMEOUT_MS,
);
}
function remainingMatrixQaRunMs(deadline: { deadlineMs: number }) {
return Math.max(1, deadline.deadlineMs - Date.now());
}
@@ -720,7 +728,7 @@ export async function runMatrixQaLive(params: {
syncState,
syncStreams,
sutUserId: provisioning.sut.userId,
timeoutMs: 45_000,
timeoutMs: resolveMatrixQaCanaryTimeoutMs(),
}),
),
);
@@ -935,9 +943,8 @@ export async function runMatrixQaLive(params: {
} finally {
if (gatewayHarness) {
try {
const shouldPreserveGatewayDebugArtifacts = scenarioResults.some(
(scenario) => scenario?.status === "fail",
);
const shouldPreserveGatewayDebugArtifacts =
scenarioResults.some((scenario) => scenario?.status === "fail") || canaryFailed;
preservedGatewayDebugDirPath = shouldPreserveGatewayDebugArtifacts
? path.join(outputDir, "gateway-debug")
: undefined;
@@ -1128,6 +1135,7 @@ export const __testing = {
findMatrixQaScenarios,
isMatrixAccountReady,
patchMatrixQaGatewayConfig,
resolveMatrixQaCanaryTimeoutMs,
resolveMatrixQaModels,
shouldWriteMatrixQaProgress,
summarizeMatrixQaConfigSnapshot,

View File

@@ -76,6 +76,7 @@ describe("matrix qa config", () => {
replyToMode: "off",
threadReplies: "inbound",
});
expect(next.messages?.groupChat?.visibleReplies).toBe("automatic");
});
it("applies room-keyed Matrix QA config overrides", () => {

View File

@@ -640,6 +640,13 @@ export function buildMatrixQaConfig(
matrix: { enabled: true },
},
},
messages: {
...baseCfg.messages,
groupChat: {
...baseCfg.messages?.groupChat,
visibleReplies: "automatic",
},
},
channels: {
...baseCfg.channels,
matrix: {

View File

@@ -12,8 +12,15 @@ describe("resolveTelegramRequestTimeoutMs", () => {
expect(resolveTelegramRequestTimeoutMs("getupdates")).toBe(45_000);
});
it("bounds outbound delivery methods", () => {
expect(resolveTelegramRequestTimeoutMs("sendmessage")).toBe(20_000);
expect(resolveTelegramRequestTimeoutMs("sendchataction")).toBe(10_000);
expect(resolveTelegramRequestTimeoutMs("editmessagetext")).toBe(15_000);
expect(resolveTelegramRequestTimeoutMs("sendphoto")).toBe(30_000);
});
it("does not assign hard timeouts to unrelated Telegram methods", () => {
expect(resolveTelegramRequestTimeoutMs("sendmessage")).toBeUndefined();
expect(resolveTelegramRequestTimeoutMs("answercallbackquery")).toBeUndefined();
expect(resolveTelegramRequestTimeoutMs(null)).toBeUndefined();
});
});

View File

@@ -2,8 +2,24 @@ const TELEGRAM_REQUEST_TIMEOUTS_MS = {
// Bound startup/control-plane calls so the gateway cannot report Telegram as
// healthy while provider startup is still hung on Bot API setup.
deletewebhook: 15_000,
deletemessage: 15_000,
editforumtopic: 15_000,
editmessagetext: 15_000,
getchat: 15_000,
getfile: 30_000,
getme: 15_000,
getupdates: 45_000,
pinchatmessage: 15_000,
sendanimation: 30_000,
sendaudio: 30_000,
sendchataction: 10_000,
senddocument: 30_000,
sendmessage: 20_000,
sendmessagedraft: 20_000,
sendphoto: 30_000,
sendvideo: 30_000,
sendvoice: 30_000,
setmessagereaction: 10_000,
setwebhook: 15_000,
} as const;

View File

@@ -7,7 +7,10 @@ import {
registerProviderPlugin,
requireRegisteredProvider,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { runRealtimeSttLiveTest } from "openclaw/plugin-sdk/provider-test-contracts";
import {
expectOpenClawLiveTranscriptMarker,
runRealtimeSttLiveTest,
} from "openclaw/plugin-sdk/provider-test-contracts";
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
@@ -68,10 +71,6 @@ const registerXaiPlugin = () =>
name: "xAI Provider",
});
function normalizeTranscriptForMatch(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
describeLive("xai plugin live", () => {
it("synthesizes TTS through the registered speech provider", async () => {
const { speechProviders } = await registerXaiPlugin();
@@ -146,9 +145,8 @@ describeLive("xai plugin live", () => {
});
const normalized = transcript?.text.toLowerCase() ?? "";
const compact = normalizeTranscriptForMatch(normalized);
expect(transcript?.model).toBe(XAI_DEFAULT_STT_MODEL);
expect(compact).toContain("openclaw");
expectOpenClawLiveTranscriptMarker(normalized);
expect(normalized).toContain("speech");
expect(normalized).toContain("text");
expect(normalized).toContain("integration");
@@ -222,8 +220,7 @@ describeLive("xai plugin live", () => {
});
const normalized = transcripts.join(" ").toLowerCase();
const compact = normalizeTranscriptForMatch(normalized);
expect(compact).toContain("openclaw");
expectOpenClawLiveTranscriptMarker(normalized);
expect(normalized).toContain("transcription");
expect(partials.length + transcripts.length).toBeGreaterThan(0);
}, 180_000);

View File

@@ -559,6 +559,7 @@ describe("handleZaloWebhookRequest", () => {
...DEFAULT_ACCOUNT,
config: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
});

View File

@@ -3,6 +3,13 @@ import { expect, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
import type { ResolvedZaloAccount } from "../types.js";
function resolveLifecycleAllowFrom(params: {
dmPolicy: "open" | "pairing";
allowFrom?: string[];
}): string[] | undefined {
return params.allowFrom ?? (params.dmPolicy === "open" ? ["*"] : undefined);
}
export function createLifecycleConfig(params: {
accountId: string;
dmPolicy: "open" | "pairing";
@@ -12,6 +19,7 @@ export function createLifecycleConfig(params: {
}): OpenClawConfig {
const webhookUrl = params.webhookUrl ?? "https://example.com/hooks/zalo";
const webhookSecret = params.webhookSecret ?? "supersecret";
const allowFrom = resolveLifecycleAllowFrom(params);
return {
channels: {
zalo: {
@@ -22,7 +30,7 @@ export function createLifecycleConfig(params: {
webhookUrl,
webhookSecret, // pragma: allowlist secret
dmPolicy: params.dmPolicy,
...(params.allowFrom ? { allowFrom: params.allowFrom } : {}),
...(allowFrom ? { allowFrom } : {}),
},
},
},
@@ -39,6 +47,7 @@ export function createLifecycleAccount(params: {
}): ResolvedZaloAccount {
const webhookUrl = params.webhookUrl ?? "https://example.com/hooks/zalo";
const webhookSecret = params.webhookSecret ?? "supersecret";
const allowFrom = resolveLifecycleAllowFrom(params);
return {
accountId: params.accountId,
enabled: true,
@@ -48,7 +57,7 @@ export function createLifecycleAccount(params: {
webhookUrl,
webhookSecret, // pragma: allowlist secret
dmPolicy: params.dmPolicy,
...(params.allowFrom ? { allowFrom: params.allowFrom } : {}),
...(allowFrom ? { allowFrom } : {}),
},
} as ResolvedZaloAccount;
}

View File

@@ -1,9 +1,11 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync, statSync } from "node:fs";
import { access } from "node:fs/promises";
import module from "node:module";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const MIN_NODE_MAJOR = 22;
@@ -46,6 +48,41 @@ const isSourceCheckoutLauncher = () =>
const isNodeCompileCacheDisabled = () => process.env.NODE_DISABLE_COMPILE_CACHE !== undefined;
const isNodeCompileCacheRequested = () =>
process.env.NODE_COMPILE_CACHE !== undefined && !isNodeCompileCacheDisabled();
const sanitizeCompileCachePathSegment = (value) => {
const normalized = value.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
return normalized.length > 0 ? normalized : "unknown";
};
const readPackageVersion = () => {
try {
const parsed = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
if (typeof parsed?.version === "string" && parsed.version.trim().length > 0) {
return parsed.version;
}
} catch {
// Fall through to an install-metadata-only cache key.
}
return "unknown";
};
const resolvePackagedCompileCacheDirectory = () => {
const packageJsonUrl = new URL("./package.json", import.meta.url);
const version = sanitizeCompileCachePathSegment(readPackageVersion());
let installMarker = "no-package-json";
try {
const stat = statSync(packageJsonUrl);
installMarker = `${Math.trunc(stat.mtimeMs)}-${stat.size}`;
} catch {
// Package archives should always have package.json, but keep startup best-effort.
}
const baseDirectory = isNodeCompileCacheRequested()
? process.env.NODE_COMPILE_CACHE
: path.join(os.tmpdir(), "node-compile-cache");
return path.join(
baseDirectory,
"openclaw",
version,
sanitizeCompileCachePathSegment(installMarker),
);
};
const respawnWithoutCompileCacheIfNeeded = () => {
if (!isSourceCheckoutLauncher()) {
@@ -79,10 +116,46 @@ const respawnWithoutCompileCacheIfNeeded = () => {
respawnWithoutCompileCacheIfNeeded();
const respawnWithPackagedCompileCacheIfNeeded = () => {
if (isSourceCheckoutLauncher() || isNodeCompileCacheDisabled()) {
return false;
}
if (process.env.OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED === "1") {
return false;
}
const currentDirectory = module.getCompileCacheDir?.();
if (!currentDirectory) {
return false;
}
const desiredDirectory = resolvePackagedCompileCacheDirectory();
if (path.resolve(currentDirectory) === path.resolve(desiredDirectory)) {
return false;
}
const env = {
...process.env,
NODE_COMPILE_CACHE: desiredDirectory,
OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED: "1",
};
const result = spawnSync(
process.execPath,
[...process.execArgv, fileURLToPath(import.meta.url), ...process.argv.slice(2)],
{
stdio: "inherit",
env,
},
);
if (result.error) {
throw result.error;
}
process.exit(result.status ?? 1);
};
respawnWithPackagedCompileCacheIfNeeded();
// https://nodejs.org/api/module.html#module-compile-cache
if (module.enableCompileCache && !isNodeCompileCacheDisabled() && !isSourceCheckoutLauncher()) {
try {
module.enableCompileCache();
module.enableCompileCache(resolvePackagedCompileCacheDirectory());
} catch {
// Ignore errors
}

View File

@@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.4.27",
"version": "2026.4.27-beta.1",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",
@@ -57,6 +57,7 @@
"skills/",
"scripts/npm-runner.mjs",
"scripts/preinstall-package-manager-warning.mjs",
"scripts/lib/package-dist-imports.mjs",
"scripts/postinstall-bundled-plugins.mjs",
"scripts/windows-cmd-helpers.mjs"
],

3
pnpm-lock.yaml generated
View File

@@ -362,6 +362,9 @@ importers:
ws:
specifier: ^8.20.0
version: 8.20.0
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*

View File

@@ -4,13 +4,26 @@
// prebuilt package artifact with dist inventory, not a source checkout.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { LOCAL_BUILD_METADATA_DIST_PATHS } from "./lib/local-build-metadata-paths.mjs";
import { expandPackageDistImportClosure } from "./lib/package-dist-imports.mjs";
let extractDir = "";
function usage() {
return "Usage: node scripts/check-openclaw-package-tarball.mjs <openclaw.tgz>";
}
function cleanupExtractDir() {
if (extractDir) {
fs.rmSync(extractDir, { recursive: true, force: true });
extractDir = "";
}
}
function fail(message) {
cleanupExtractDir();
console.error(message);
process.exit(1);
}
@@ -31,6 +44,20 @@ if (list.status !== 0) {
fail(`tar -tf failed for ${tarball}: ${list.stderr || list.status}`);
}
extractDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-package-tarball-"));
try {
const extract = spawnSync("tar", ["-xf", tarball, "-C", extractDir], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (extract.status !== 0) {
fail(`tar -xf failed for ${tarball}: ${extract.stderr || extract.status}`);
}
} catch (error) {
cleanupExtractDir();
throw error;
}
const entries = list.stdout
.split(/\r?\n/u)
.map((entry) => entry.trim())
@@ -39,9 +66,22 @@ const normalized = entries.map((entry) => entry.replace(/^package\//u, ""));
const entrySet = new Set(normalized);
const errors = [];
const warnings = [];
const unsafeEntries = normalized.filter(
(entry) => entry.startsWith("/") || entry.split("/").includes(".."),
);
const DIST_JS_IMPORT_SPECIFIER_PATTERN =
/\b(?:import|export)\s+(?:(?:[^'"()]*?\s+from\s+)|)["'](?<staticSpecifier>[^"']+)["']|\bimport\s*\(\s*["'](?<dynamicSpecifier>[^"']+)["']\s*\)/gu;
const DIST_IMPORT_REFERENCE_ENTRYPOINTS = [
"dist/entry.js",
"dist/cli/run-main.js",
"dist/index.js",
"dist/index.mjs",
];
const LEGACY_PACKAGE_ACCEPTANCE_COMPAT_MAX = { year: 2026, month: 4, day: 25 };
const LEGACY_LOCAL_BUILD_METADATA_COMPAT_MAX = { year: 2026, month: 4, day: 26 };
const FORBIDDEN_LOCAL_BUILD_METADATA_FILES = new Set(LOCAL_BUILD_METADATA_DIST_PATHS);
const REQUIRED_PACKAGE_ENTRIES = ["dist/control-ui/index.html", "dist/postinstall-inventory.json"];
const REQUIRED_PACKAGE_PREFIXES = ["dist/control-ui/assets/"];
const LEGACY_OMITTED_PRIVATE_QA_INVENTORY_PREFIXES = [
"dist/extensions/qa-channel/",
@@ -104,23 +144,85 @@ function isLegacyLocalBuildMetadataCompatVersion(version) {
}
function readTarEntry(entryPath) {
const candidates = [entryPath, `package/${entryPath}`];
const candidates = [
path.join(extractDir, entryPath),
path.join(extractDir, "package", entryPath),
];
for (const candidate of candidates) {
const result = spawnSync("tar", ["-xOf", tarball, candidate], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status === 0) {
return result.stdout;
if (fs.existsSync(candidate)) {
return fs.readFileSync(candidate, "utf8");
}
}
return "";
}
for (const entry of normalized) {
if (entry.startsWith("/") || entry.split("/").includes("..")) {
errors.push(`unsafe tar entry: ${entry}`);
function isRelativeModuleSpecifier(value) {
return value.startsWith("./") || value.startsWith("../");
}
function normalizeModuleSpecifierTarget(value) {
return value.split(/[?#]/u, 1)[0] ?? value;
}
function normalizeTarPath(value) {
return value.replace(/\\/gu, "/");
}
function resolveTarImportTarget(importer, specifier) {
const normalizedSpecifier = normalizeModuleSpecifierTarget(specifier);
const base = normalizeTarPath(
new URL(normalizedSpecifier, `file:///${importer}`).pathname.replace(/^\//u, ""),
);
const candidates = [
base,
`${base}.js`,
`${base}.mjs`,
`${base}.cjs`,
`${base}/index.js`,
`${base}/index.mjs`,
`${base}/index.cjs`,
];
return candidates.find((candidate) => entrySet.has(candidate)) ?? null;
}
function collectTarImportReferenceErrors() {
if (unsafeEntries.length > 0) {
return [];
}
const extractRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-package-check-"));
const importErrors = [];
try {
const extract = spawnSync("tar", ["-xzf", tarball, "-C", extractRoot], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (extract.status !== 0) {
return [
`tar extraction failed for import reference check: ${extract.stderr || extract.status}`,
];
}
for (const entry of DIST_IMPORT_REFERENCE_ENTRYPOINTS.filter((entry) => entrySet.has(entry))) {
const source = fs.readFileSync(path.join(extractRoot, "package", entry), "utf8");
for (const match of source.matchAll(DIST_JS_IMPORT_SPECIFIER_PATTERN)) {
const specifier = match.groups?.staticSpecifier ?? match.groups?.dynamicSpecifier ?? "";
if (!isRelativeModuleSpecifier(specifier)) {
continue;
}
if (resolveTarImportTarget(entry, specifier)) {
continue;
}
importErrors.push(`missing packaged dist import target ${specifier} from ${entry}`);
}
}
} finally {
fs.rmSync(extractRoot, { recursive: true, force: true });
}
return importErrors.toSorted((left, right) => left.localeCompare(right));
}
for (const entry of unsafeEntries) {
errors.push(`unsafe tar entry: ${entry}`);
}
if (!entrySet.has("package.json")) {
@@ -147,8 +249,15 @@ for (const forbiddenEntry of FORBIDDEN_LOCAL_BUILD_METADATA_FILES) {
errors.push(`forbidden local build metadata tar entry ${forbiddenEntry}`);
}
}
if (!entrySet.has("dist/postinstall-inventory.json")) {
errors.push("missing dist/postinstall-inventory.json");
for (const requiredEntry of REQUIRED_PACKAGE_ENTRIES) {
if (!entrySet.has(requiredEntry)) {
errors.push(`missing required package tar entry ${requiredEntry}`);
}
}
for (const requiredPrefix of REQUIRED_PACKAGE_PREFIXES) {
if (!normalized.some((entry) => entry.startsWith(requiredPrefix))) {
errors.push(`missing required package tar entries under ${requiredPrefix}`);
}
}
if (entrySet.has("dist/postinstall-inventory.json")) {
try {
@@ -158,6 +267,8 @@ if (entrySet.has("dist/postinstall-inventory.json")) {
if (!Array.isArray(inventory) || inventory.some((entry) => typeof entry !== "string")) {
errors.push("invalid dist/postinstall-inventory.json");
} else {
const normalizedInventory = inventory.map((entry) => entry.replace(/\\/gu, "/"));
const normalizedInventorySet = new Set(normalizedInventory);
for (const inventoryEntry of inventory) {
const normalizedEntry = inventoryEntry.replace(/\\/gu, "/");
if (!entrySet.has(normalizedEntry)) {
@@ -173,6 +284,16 @@ if (entrySet.has("dist/postinstall-inventory.json")) {
errors.push(`inventory references missing tar entry ${normalizedEntry}`);
}
}
const expandedInventory = expandPackageDistImportClosure({
files: normalized,
seedFiles: normalizedInventory,
readText: readTarEntry,
});
for (const importedEntry of expandedInventory) {
if (!normalizedInventorySet.has(importedEntry)) {
errors.push(`inventory omits imported dist file ${importedEntry}`);
}
}
}
} catch (error) {
errors.push(
@@ -182,6 +303,7 @@ if (entrySet.has("dist/postinstall-inventory.json")) {
);
}
}
errors.push(...collectTarImportReferenceErrors());
if (errors.length > 0) {
fail(`OpenClaw package tarball integrity failed:\n${errors.join("\n")}`);
@@ -190,4 +312,5 @@ if (errors.length > 0) {
for (const warning of warnings) {
console.warn(`OpenClaw package tarball integrity warning: ${warning}`);
}
cleanupExtractDir();
console.log("OpenClaw package tarball integrity passed.");

View File

@@ -20,6 +20,7 @@ COPY packages ./packages
COPY extensions ./extensions
COPY patches ./patches
COPY scripts/postinstall-bundled-plugins.mjs scripts/preinstall-package-manager-warning.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
COPY scripts/lib/package-dist-imports.mjs ./scripts/lib/package-dist-imports.mjs
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
corepack enable \
&& if ! pnpm install --frozen-lockfile >/tmp/openclaw-cleanup-pnpm-install.log 2>&1; then \

View File

@@ -23,6 +23,7 @@ GW_NAME="openclaw-openwebui-gateway-$$"
OW_NAME="openclaw-openwebui-$$"
DOCKER_COMMAND_TIMEOUT="${OPENCLAW_OPENWEBUI_DOCKER_COMMAND_TIMEOUT:-600s}"
DOCKER_PULL_TIMEOUT="${OPENCLAW_OPENWEBUI_DOCKER_PULL_TIMEOUT:-600s}"
OPENWEBUI_READY_ATTEMPTS="${OPENCLAW_OPENWEBUI_READY_ATTEMPTS:-420}"
docker_cmd() {
timeout "$DOCKER_COMMAND_TIMEOUT" "$@"
@@ -177,7 +178,7 @@ docker_cmd docker run -d \
echo "Waiting for Open WebUI..."
ow_ready=0
for _ in $(seq 1 240); do
for _ in $(seq 1 "$OPENWEBUI_READY_ATTEMPTS"); do
if [ "$(docker_cmd docker inspect -f '{{.State.Running}}' "$OW_NAME" 2>/dev/null || echo false)" != "true" ]; then
break
fi

View File

@@ -106,7 +106,7 @@ Options:
--provider <openai|anthropic|minimax>
Provider auth/model lane. Default: openai
--model <provider/model> Override the model used for the agent-turn smoke.
Default: openai/gpt-5.5 for the OpenAI lane
Default: openai/gpt-5.4-mini for the OpenAI lane
--api-key-env <var> Host env var name for provider API key.
Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
@@ -209,7 +209,7 @@ case "$PROVIDER" in
openai)
AUTH_CHOICE="openai-api-key"
AUTH_KEY_FLAG="openai-api-key"
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}"
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.4-mini}"
[[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY"
;;
anthropic)
@@ -231,6 +231,7 @@ esac
API_KEY_VALUE="${!API_KEY_ENV:-}"
[[ -n "$API_KEY_VALUE" ]] || die "$API_KEY_ENV is required"
case "${OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR:-}" in
1|true|TRUE|yes|YES|on|ON)
DISABLE_BONJOUR_FOR_GATEWAY=1

View File

@@ -49,11 +49,11 @@ BUILD_LOCK_DIR="${TMPDIR:-/tmp}/openclaw-parallels-build.lock"
TIMEOUT_INSTALL_SITE_S=420
TIMEOUT_INSTALL_TGZ_S=420
TIMEOUT_INSTALL_REGISTRY_S=420
TIMEOUT_UPDATE_DEV_S="${OPENCLAW_PARALLELS_MACOS_UPDATE_DEV_TIMEOUT_S:-1200}"
TIMEOUT_UPDATE_DEV_S="${OPENCLAW_PARALLELS_MACOS_UPDATE_DEV_TIMEOUT_S:-1800}"
TIMEOUT_VERIFY_S=60
TIMEOUT_ONBOARD_S=180
TIMEOUT_GATEWAY_S=180
TIMEOUT_AGENT_S="${OPENCLAW_PARALLELS_MACOS_AGENT_TIMEOUT_S:-240}"
TIMEOUT_GATEWAY_S=420
TIMEOUT_AGENT_S="${OPENCLAW_PARALLELS_MACOS_AGENT_TIMEOUT_S:-900}"
TIMEOUT_PERMISSION_S=60
TIMEOUT_DASHBOARD_S=180
TIMEOUT_SNAPSHOT_S=360
@@ -144,7 +144,7 @@ Options:
--provider <openai|anthropic|minimax>
Provider auth/model lane. Default: openai
--model <provider/model> Override the model used for the agent-turn smoke.
Default: openai/gpt-5.5 for the OpenAI lane
Default: openai/gpt-5.4-mini for the OpenAI lane
--api-key-env <var> Host env var name for provider API key.
Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
@@ -266,7 +266,7 @@ case "$PROVIDER" in
openai)
AUTH_CHOICE="openai-api-key"
AUTH_KEY_FLAG="openai-api-key"
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}"
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.4-mini}"
[[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY"
;;
anthropic)
@@ -1109,6 +1109,7 @@ ensure_guest_pnpm_for_dev_update() {
repair_legacy_dev_source_checkout_if_needed() {
local bootstrap_bin update_root update_entry
LEGACY_DEV_SOURCE_REPAIR_APPLIED=0
bootstrap_bin="/tmp/openclaw-smoke-pnpm-bootstrap/node_modules/.bin"
update_root="$(resolve_guest_current_user_home)/openclaw"
update_entry="$update_root/openclaw.mjs"
@@ -1121,6 +1122,7 @@ repair_legacy_dev_source_checkout_if_needed() {
if ! guest_current_user_exec /bin/test -f "$update_root/src/entry.ts"; then
return 0
fi
LEGACY_DEV_SOURCE_REPAIR_APPLIED=1
warn "repairing legacy dev source archive into git checkout"
ensure_guest_pnpm_for_dev_update
guest_current_user_exec /bin/rm -rf "$update_root"
@@ -1160,6 +1162,9 @@ EOF
guest_current_user_tail_file "$update_log" 120 >&2 || true
fi
repair_legacy_dev_source_checkout_if_needed
if (( update_rc != 0 && LEGACY_DEV_SOURCE_REPAIR_APPLIED == 0 )); then
return "$update_rc"
fi
printf 'update-dev: git-version\n'
guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" --version
printf 'update-dev: git-status\n'
@@ -1393,6 +1398,17 @@ EOF
guest_current_user_exec /bin/bash -lc "$cmd"
}
reset_openclaw_user_state() {
guest_current_user_sh "$(cat <<'EOF'
/usr/bin/pkill -f 'openclaw.*gateway run' >/dev/null 2>&1 || true
/usr/bin/pkill -f 'openclaw-gateway' >/dev/null 2>&1 || true
/usr/bin/pkill -f 'openclaw.mjs gateway' >/dev/null 2>&1 || true
rm -rf "$HOME/.openclaw"
rm -f /tmp/openclaw-parallels-macos-gateway.log
EOF
)"
}
run_ref_onboard() {
local daemon_args=("--install-daemon")
if headless_guest_fallback; then
@@ -1460,15 +1476,36 @@ EOF
}
verify_gateway() {
local attempt
for attempt in 1 2 3 4 5 6 7 8; do
if guest_current_user_exec "$GUEST_OPENCLAW_BIN" gateway status --deep --require-rpc --timeout 15000; then
local attempt deadline probe_json
attempt=1
deadline=$((SECONDS + TIMEOUT_GATEWAY_S))
while (( SECONDS < deadline )); do
if probe_json="$(
guest_current_user_exec "$GUEST_OPENCLAW_BIN" gateway probe \
--url ws://127.0.0.1:18789 \
--timeout 30000 \
--json
)"; then
printf '%s\n' "$probe_json"
if PROBE_JSON="$probe_json" python3 - <<'PY'
import json
import os
payload = json.loads(os.environ["PROBE_JSON"])
raise SystemExit(0 if payload.get("ok") else 1)
PY
then
return 0
fi
elif ! guest_current_user_exec "$GUEST_OPENCLAW_BIN" gateway probe --help >/dev/null 2>&1 &&
guest_current_user_exec "$GUEST_OPENCLAW_BIN" gateway status --deep --require-rpc --timeout 30000; then
return 0
fi
if (( attempt < 8 )); then
printf 'gateway-status retry %s\n' "$attempt" >&2
if (( SECONDS < deadline )); then
printf 'gateway-probe retry %s\n' "$attempt" >&2
sleep 5
fi
attempt=$((attempt + 1))
done
return 1
}
@@ -1482,9 +1519,15 @@ show_gateway_status_compat() {
}
verify_turn() {
local agent_log agent_done agent_runner attempt rc
agent_log="/tmp/openclaw-parallels-agent-turn.log"
agent_done="/tmp/openclaw-parallels-agent-turn.done"
agent_runner="/tmp/openclaw-parallels-agent-turn.sh"
guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" models set "$MODEL_ID"
guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" config set agents.defaults.skipBootstrap true --strict-json
guest_current_user_sh "$(cat <<EOF
for attempt in 1 2; do
set +e
run_logged_guest_current_user_sh "$(cat <<EOF
export PATH=$(shell_quote "$GUEST_EXEC_PATH")
workspace="\${OPENCLAW_WORKSPACE_DIR:-\$HOME/.openclaw/workspace}"
mkdir -p "\$workspace/.openclaw"
@@ -1508,7 +1551,19 @@ exec /usr/bin/env $(shell_quote "$API_KEY_ENV=$API_KEY_VALUE") \
--message $(shell_quote "Reply with exact ASCII text OK only.") \
--json
EOF
)"
)" "$agent_log" "$agent_done" "$TIMEOUT_AGENT_S" "$agent_runner"
rc=$?
set -e
if [[ $rc -eq 0 ]]; then
return 0
fi
if (( attempt < 2 )) &&
guest_current_user_exec /usr/bin/grep -Eq 'plugin load failed: .*ENOENT: .*plugin-runtime-deps.*/dist/' "$agent_log"; then
warn "retrying macOS agent turn after staged runtime mirror race"
continue
fi
return "$rc"
done
}
resolve_dashboard_url() {
@@ -1944,6 +1999,7 @@ run_fresh_main_lane() {
local snapshot_id="$1"
local host_ip="$2"
phase_run "fresh.restore-snapshot" "$TIMEOUT_SNAPSHOT_S" restore_snapshot "$snapshot_id"
phase_run "fresh.reset-state" "$TIMEOUT_ONBOARD_S" reset_openclaw_user_state
phase_run "fresh.install-main" "$(install_main_timeout)" install_main_tgz "$host_ip" "openclaw-main-fresh.tgz"
FRESH_MAIN_VERSION="$(extract_last_version "$(phase_log_path fresh.install-main)")"
phase_run "fresh.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version
@@ -1971,6 +2027,7 @@ run_upgrade_lane() {
local snapshot_id="$1"
local host_ip="$2"
phase_run "upgrade.restore-snapshot" "$TIMEOUT_SNAPSHOT_S" restore_snapshot "$snapshot_id"
phase_run "upgrade.reset-state" "$TIMEOUT_ONBOARD_S" reset_openclaw_user_state
phase_run "upgrade.install-latest" "$TIMEOUT_INSTALL_SITE_S" install_latest_release
LATEST_INSTALLED_VERSION="$(extract_last_version "$(phase_log_path upgrade.install-latest)")"
phase_run "upgrade.verify-latest-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$INSTALL_VERSION"

View File

@@ -122,7 +122,7 @@ Options:
--provider <openai|anthropic|minimax>
Provider auth/model lane. Default: openai
--model <provider/model> Override the model used for agent-turn smoke checks.
Default: openai/gpt-5.5 for the OpenAI lane
Default: openai/gpt-5.4-mini for the OpenAI lane
--api-key-env <var> Host env var name for provider API key.
Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
@@ -214,7 +214,7 @@ case "$PROVIDER" in
openai)
AUTH_CHOICE="openai-api-key"
AUTH_KEY_FLAG="openai-api-key"
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}"
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.4-mini}"
[[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY"
;;
anthropic)
@@ -236,6 +236,7 @@ esac
API_KEY_VALUE="${!API_KEY_ENV:-}"
[[ -n "$API_KEY_VALUE" ]] || die "$API_KEY_ENV is required"
resolve_python_bin
resolve_linux_vm_name() {
@@ -701,35 +702,6 @@ 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,
@@ -742,7 +714,7 @@ function Invoke-OpenClawUpdateWithTimeout {
$previousDisableBundledPlugins = $env:OPENCLAW_DISABLE_BUNDLED_PLUGINS
$env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'
try {
$output = & $Path update --tag $Target --yes --json *>&1
$output = & $Path update --tag $Target --yes --no-restart --json *>&1
} finally {
if ($null -eq $previousDisableBundledPlugins) {
Remove-Item Env:OPENCLAW_DISABLE_BUNDLED_PLUGINS -ErrorAction SilentlyContinue
@@ -835,6 +807,33 @@ function Invoke-OpenClawAgentWithTimeout {
throw "openclaw agent timed out after ${TimeoutSeconds}s"
}
function Invoke-OpenClawVersionWithRetry {
param(
[Parameter(Mandatory = $true)][string]$OpenClawPath,
[Parameter(Mandatory = $false)][string]$ExpectedNeedle,
[int]$Attempts = 12,
[int]$SleepSeconds = 2
)
$lastError = ''
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
Write-ProgressLog "update.verify-version.attempt-$attempt"
try {
$version = Invoke-CaptureLogged 'openclaw --version' { & $OpenClawPath --version }
if (-not $ExpectedNeedle -or $version -match [regex]::Escape($ExpectedNeedle)) {
return $version
}
$lastError = "version mismatch: expected substring $ExpectedNeedle"
} catch {
$lastError = $_ | Out-String
}
if ($attempt -lt $Attempts) {
Start-Sleep -Seconds $SleepSeconds
}
}
throw $lastError
}
function Start-GatewayRunFallback {
param(
[Parameter(Mandatory = $true)][string]$OpenClawPath
@@ -943,15 +942,11 @@ 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
Write-ProgressLog 'update.verify-version'
$version = Invoke-CaptureLogged 'openclaw --version' { & $openclaw --version }
if ($ExpectedNeedle -and $version -notmatch [regex]::Escape($ExpectedNeedle)) {
throw "version mismatch: expected substring $ExpectedNeedle"
}
$version = Invoke-OpenClawVersionWithRetry -OpenClawPath $openclaw -ExpectedNeedle $ExpectedNeedle
Write-ProgressLog $version
Write-ProgressLog 'update.status'
Invoke-Logged 'openclaw update status' { & $openclaw update status --json }
@@ -1054,11 +1049,20 @@ gateway_listener_ready() {
gateway_log_ready() {
latest="\$(/bin/ls -t /tmp/openclaw/openclaw-*.log 2>/dev/null | /usr/bin/head -n 1 || true)"
[ -n "\$latest" ] || return 1
/usr/bin/tail -n 160 "\$latest" | /usr/bin/grep -q 'ready ('
/usr/bin/tail -n 160 "\$latest" | /usr/bin/grep -Eq 'ready( \(|[[:space:]]*\$)'
}
gateway_smoke_ready() {
gateway_listener_ready && gateway_log_ready
}
stop_openclaw_gateway_processes() {
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
for pid in \$(/usr/sbin/lsof -tiTCP:18789 -sTCP:LISTEN 2>/dev/null || true); do
/bin/kill -9 "\$pid" 2>/dev/null || true
done
}
if [ -n "\$busy" ]; then
printf 'update still has active npm/pnpm/openclaw processes\n%s\n' "\$busy" >&2
exit 1
@@ -1094,6 +1098,18 @@ if [ "\$gateway_ready" != "1" ]; then
done
fi
if [ "\$gateway_ready" != "1" ]; then
stop_openclaw_gateway_processes
/opt/homebrew/bin/openclaw gateway run --bind loopback --port 18789 --force >/tmp/openclaw-parallels-npm-update-macos-recover-gateway.log 2>&1 </dev/null &
for _ in 1 2 3 4 5 6 7 8 9 10; do
if gateway_smoke_ready; then
gateway_ready=1
break
fi
sleep 2
done
fi
if [ "\$gateway_ready" != "1" ]; then
tail -n 120 /tmp/openclaw-parallels-npm-update-macos-recover-gateway.log 2>/dev/null || true
echo "gateway did not become ready after transport recovery" >&2
exit 1
fi
@@ -1489,7 +1505,7 @@ run_windows_script_via_log() {
local provider_key="$7"
local runner_name log_name done_name done_status launcher_state guest_log
local start_seconds poll_deadline startup_checked poll_rc state_rc log_rc
local log_state_path provider_key_b64
local log_state_path provider_key_b64 plugin_allow_b64
runner_name="openclaw-update-$RANDOM-$RANDOM.ps1"
log_name="openclaw-update-$RANDOM-$RANDOM.log"
done_name="openclaw-update-$RANDOM-$RANDOM.done"
@@ -1637,38 +1653,11 @@ gateway_listener_ready() {
gateway_log_ready() {
latest="\$(/bin/ls -t /tmp/openclaw/openclaw-*.log 2>/dev/null | /usr/bin/head -n 1 || true)"
[ -n "\$latest" ] || return 1
/usr/bin/tail -n 160 "\$latest" | /usr/bin/grep -q 'ready ('
/usr/bin/tail -n 160 "\$latest" | /usr/bin/grep -Eq 'ready( \(|[[:space:]]*\$)'
}
gateway_smoke_ready() {
gateway_listener_ready && gateway_log_ready
}
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_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw gateway stop >/dev/null 2>&1 || true
/usr/bin/pkill -9 -f openclaw-gateway || true
@@ -1678,29 +1667,43 @@ stop_openclaw_gateway_processes() {
/bin/kill -9 "\$pid" 2>/dev/null || true
done
}
openclaw_version_with_retry() {
bin="\$1"
expected="\$2"
version=""
last_output=""
for attempt in 1 2 3 4 5 6 7 8 9 10 11 12; do
set +e
version="\$("\$bin" --version 2>&1)"
status=\$?
set -e
if [ "\$status" -eq 0 ]; then
printf '%s\n' "\$version"
if [ -z "\$expected" ] || [[ "\$version" == *"\$expected"* ]]; then
return 0
fi
last_output="version mismatch: expected substring \$expected"
else
last_output="\$version"
printf '%s\n' "\$last_output" >&2
fi
sleep 2
done
printf '%s\n' "\$last_output" >&2
return 1
}
# 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
# The baseline updater process may run its post-install doctor through the old
# host while new bundled plugin metadata is already on disk. Keep this
# same-guest update hop focused on core/package migration; post-update smoke
# below starts the fresh gateway with bundled plugins enabled.
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw update --tag "$update_target" --yes --json
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw update --tag "$update_target" --yes --no-restart --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
version="\$(/opt/homebrew/bin/openclaw --version)"
printf '%s\n' "\$version"
if [ -n "$expected_needle" ]; then
case "\$version" in
*"$expected_needle"*) ;;
*)
echo "version mismatch: expected substring $expected_needle" >&2
exit 1
;;
esac
fi
version="\$(openclaw_version_with_retry /opt/homebrew/bin/openclaw "$expected_needle")"
/opt/homebrew/bin/openclaw update status --json
/opt/homebrew/bin/openclaw models set "$MODEL_ID"
/opt/homebrew/bin/openclaw config set agents.defaults.skipBootstrap true --strict-json
@@ -1775,33 +1778,6 @@ 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_DISABLE_BUNDLED_PLUGINS=1 openclaw gateway stop >/dev/null 2>&1 || true
pkill -9 -f openclaw-gateway || true
@@ -1816,25 +1792,39 @@ stop_openclaw_gateway_processes() {
done
fi
}
openclaw_version_with_retry() {
bin="\$1"
expected="\$2"
version=""
last_output=""
for attempt in 1 2 3 4 5 6 7 8 9 10 11 12; do
set +e
version="\$("\$bin" --version 2>&1)"
status=\$?
set -e
if [ "\$status" -eq 0 ]; then
printf '%s\n' "\$version"
if [ -z "\$expected" ] || [[ "\$version" == *"\$expected"* ]]; then
return 0
fi
last_output="version mismatch: expected substring \$expected"
else
last_output="\$version"
printf '%s\n' "\$last_output" >&2
fi
sleep 2
done
printf '%s\n' "\$last_output" >&2
return 1
}
# 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_DISABLE_BUNDLED_PLUGINS=1 openclaw update --tag "$update_target" --yes --json
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update --tag "$update_target" --yes --no-restart --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
version="\$(openclaw --version)"
printf '%s\n' "\$version"
if [ -n "$expected_needle" ]; then
case "\$version" in
*"$expected_needle"*) ;;
*)
echo "version mismatch: expected substring $expected_needle" >&2
exit 1
;;
esac
fi
version="\$(openclaw_version_with_retry openclaw "$expected_needle")"
openclaw update status --json
openclaw models set "$MODEL_ID"
openclaw config set agents.defaults.skipBootstrap true --strict-json

View File

@@ -50,9 +50,9 @@ TIMEOUT_UPDATE_POLL_GRACE_S=60
TIMEOUT_VERIFY_S=120
TIMEOUT_ONBOARD_S=600
TIMEOUT_ONBOARD_PHASE_S=$((TIMEOUT_ONBOARD_S + 120))
# verify_gateway_reachable runs six 30s probes plus short retry sleeps.
TIMEOUT_GATEWAY_S=420
TIMEOUT_AGENT_S="${OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S:-900}"
GATEWAY_RECOVERY_AFTER_S="${OPENCLAW_PARALLELS_WINDOWS_GATEWAY_RECOVERY_AFTER_S:-180}"
TIMEOUT_AGENT_S="${OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S:-1500}"
PHASE_STALE_WARN_S=60
FRESH_MAIN_STATUS="skip"
@@ -140,7 +140,7 @@ Options:
--provider <openai|anthropic|minimax>
Provider auth/model lane. Default: openai
--model <provider/model> Override the model used for the agent-turn smoke.
Default: openai/gpt-5.5 for the OpenAI lane
Default: openai/gpt-5.4-mini for the OpenAI lane
--api-key-env <var> Host env var name for provider API key.
Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
@@ -257,7 +257,7 @@ case "$PROVIDER" in
openai)
AUTH_CHOICE="openai-api-key"
AUTH_KEY_FLAG="openai-api-key"
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}"
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.4-mini}"
[[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY"
;;
anthropic)
@@ -616,7 +616,7 @@ EOF
guest_run_agent_turn_process() {
local env_name_q env_value_q runner_basename runner_script_path runner_url runner_url_q
local runner_name stdout_name stderr_name done_name
local runner_name stdout_name stderr_name done_name ok_name
local start_seconds poll_deadline startup_checked state_rc log_rc done_rc
local agent_combined done_status launcher_state
env_name_q="$(ps_single_quote "$API_KEY_ENV")"
@@ -629,6 +629,7 @@ guest_run_agent_turn_process() {
stdout_name="openclaw-parallels-agent-$RANDOM-$RANDOM.out.log"
stderr_name="openclaw-parallels-agent-$RANDOM-$RANDOM.err.log"
done_name="openclaw-parallels-agent-$RANDOM-$RANDOM.done"
ok_name="openclaw-parallels-agent-$RANDOM-$RANDOM.ok"
start_seconds="$SECONDS"
poll_deadline=$((SECONDS + TIMEOUT_AGENT_S + 60))
startup_checked=0
@@ -637,21 +638,33 @@ guest_run_agent_turn_process() {
param(
[string]$StdoutPath,
[string]$StderrPath,
[string]$OkPath,
[string]$DonePath,
[string]$EnvName,
[string]$EnvValue
[string]$EnvName
)
$ErrorActionPreference = 'Continue'
try {
if ($EnvName -ne '') {
Set-Item -Path ('Env:' + $EnvName) -Value $EnvValue
}
$node = Join-Path $env:ProgramFiles 'nodejs\node.exe'
if (-not (Test-Path $node)) {
$node = 'node'
}
$entry = Join-Path $env:APPDATA 'npm\node_modules\openclaw\openclaw.mjs'
& $node $entry agent --local --agent main --session-id 'parallels-windows-smoke' --message 'Reply with exact ASCII text OK only.' --json > $StdoutPath 2> $StderrPath
$ok = '0'
try {
$raw = Get-Content -Path $StdoutPath -Raw -ErrorAction Stop
$json = $raw | ConvertFrom-Json -ErrorAction Stop
foreach ($payload in @($json.payloads)) {
if ($payload.text -eq 'OK') {
$ok = '1'
}
}
if ($json.meta.finalAssistantVisibleText -eq 'OK' -or $json.meta.finalAssistantRawText -eq 'OK') {
$ok = '1'
}
} catch {
}
Set-Content -Path $OkPath -Value $ok
Set-Content -Path $DonePath -Value ([string]$LASTEXITCODE)
exit $LASTEXITCODE
} catch {
@@ -666,8 +679,12 @@ EOF
\$stdout = Join-Path \$env:TEMP '$stdout_name'
\$stderr = Join-Path \$env:TEMP '$stderr_name'
\$done = Join-Path \$env:TEMP '$done_name'
Remove-Item \$runner, \$stdout, \$stderr, \$done -Force -ErrorAction SilentlyContinue
\$ok = Join-Path \$env:TEMP '$ok_name'
Remove-Item \$runner, \$stdout, \$stderr, \$done, \$ok -Force -ErrorAction SilentlyContinue
curl.exe -fsSL '${runner_url_q}' -o \$runner
if ('${env_name_q}' -ne '') {
Set-Item -Path ('Env:' + '${env_name_q}') -Value '${env_value_q}'
}
Start-Process powershell.exe -ArgumentList @(
'-NoProfile',
'-ExecutionPolicy',
@@ -678,12 +695,12 @@ Start-Process powershell.exe -ArgumentList @(
\$stdout,
'-StderrPath',
\$stderr,
'-OkPath',
\$ok,
'-DonePath',
\$done,
'-EnvName',
'${env_name_q}',
'-EnvValue',
'${env_value_q}'
'${env_name_q}'
) -WindowStyle Hidden | Out-Null
EOF
)"
@@ -697,7 +714,7 @@ EOF
set -e
if [[ $log_rc -eq 0 ]] && printf '%s\n' "$agent_combined" | grep -Eq '"finalAssistant(Raw|Visible)Text":[[:space:]]*"OK"'; then
printf '%s\n' "$agent_combined"
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; Remove-Item \$runner, \$done -Force -ErrorAction SilentlyContinue"
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; \$ok = Join-Path \$env:TEMP '$ok_name'; Remove-Item \$runner, \$done, \$ok -Force -ErrorAction SilentlyContinue"
return 0
fi
@@ -708,10 +725,19 @@ EOF
done_rc=$?
set -e
if [[ $done_rc -eq 0 && -n "$done_status" ]]; then
ok_status="$(guest_powershell_poll 20 "\$ok = Join-Path \$env:TEMP '$ok_name'; if (Test-Path \$ok) { (Get-Content \$ok -Raw).Trim() }" 2>/dev/null || true)"
ok_status="${ok_status//$'\r'/}"
if [[ "$ok_status" == "1" ]]; then
if [[ $log_rc -eq 0 && -n "$agent_combined" ]]; then
printf '%s\n' "$agent_combined"
fi
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; \$ok = Join-Path \$env:TEMP '$ok_name'; Remove-Item \$runner, \$done, \$ok -Force -ErrorAction SilentlyContinue"
return 0
fi
if [[ $log_rc -eq 0 && -n "$agent_combined" ]]; then
printf '%s\n' "$agent_combined"
fi
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; Remove-Item \$runner, \$done -Force -ErrorAction SilentlyContinue"
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; \$ok = Join-Path \$env:TEMP '$ok_name'; Remove-Item \$runner, \$done, \$ok -Force -ErrorAction SilentlyContinue"
warn "openclaw agent finished without OK response (exit $done_status)"
return 1
fi
@@ -719,7 +745,7 @@ EOF
if [[ "$startup_checked" -eq 0 && $((SECONDS - start_seconds)) -ge 20 ]]; then
set +e
launcher_state="$(
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$stdout = Join-Path \$env:TEMP '$stdout_name'; \$stderr = Join-Path \$env:TEMP '$stderr_name'; \$done = Join-Path \$env:TEMP '$done_name'; \$currentPid = \$PID; \$process = Get-CimInstance Win32_Process | Where-Object { \$_.ProcessId -ne \$currentPid -and ((\$_.CommandLine -like '*$runner_name*') -or (\$_.CommandLine -like '*openclaw.mjs*agent*parallels-windows-smoke*')) } | Select-Object -First 1; 'runner=' + (Test-Path \$runner) + ' stdout=' + (Test-Path \$stdout) + ' stderr=' + (Test-Path \$stderr) + ' done=' + (Test-Path \$done) + ' process=' + [bool]\$process"
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$stdout = Join-Path \$env:TEMP '$stdout_name'; \$stderr = Join-Path \$env:TEMP '$stderr_name'; \$done = Join-Path \$env:TEMP '$done_name'; \$ok = Join-Path \$env:TEMP '$ok_name'; \$currentPid = \$PID; \$process = Get-CimInstance Win32_Process | Where-Object { \$_.ProcessId -ne \$currentPid -and ((\$_.CommandLine -like '*$runner_name*') -or (\$_.CommandLine -like '*openclaw.mjs*agent*parallels-windows-smoke*')) } | Select-Object -First 1; 'runner=' + (Test-Path \$runner) + ' stdout=' + (Test-Path \$stdout) + ' stderr=' + (Test-Path \$stderr) + ' done=' + (Test-Path \$done) + ' ok=' + (Test-Path \$ok) + ' process=' + [bool]\$process"
)"
state_rc=$?
set -e
@@ -735,7 +761,7 @@ EOF
if [[ $log_rc -eq 0 && -n "$agent_combined" ]]; then
printf '%s\n' "$agent_combined"
fi
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; Remove-Item \$runner, \$done -Force -ErrorAction SilentlyContinue"
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; \$ok = Join-Path \$env:TEMP '$ok_name'; Remove-Item \$runner, \$done, \$ok -Force -ErrorAction SilentlyContinue"
warn "openclaw agent timed out after $TIMEOUT_AGENT_S seconds"
return 1
fi
@@ -1029,11 +1055,24 @@ for wanted in preferred_names:
break
if best is None:
candidates = []
for asset in assets:
name = asset.get("name", "")
if name.startswith("MinGit-") and name.endswith(".zip") and "busybox" not in name:
best = asset
break
if not (name.startswith("MinGit-") and name.endswith(".zip")):
continue
if "busybox" in name:
continue
if "-arm64." in name:
rank = 0
elif "-64-bit." in name:
rank = 1
elif "-32-bit." in name:
rank = 2
else:
rank = 3
candidates.append((rank, name, asset))
if candidates:
best = sorted(candidates, key=lambda item: (item[0], item[1]))[0][2]
if best is None:
raise SystemExit("no MinGit asset found")
@@ -1137,7 +1176,7 @@ ensure_mingit_zip() {
MINGIT_ZIP_PATH="$MAIN_TGZ_DIR/$mingit_name"
if [[ ! -f "$MINGIT_ZIP_PATH" ]]; then
say "Download $MINGIT_ZIP_NAME"
curl -fsSL "$mingit_url" -o "$MINGIT_ZIP_PATH"
curl --retry 5 --retry-delay 3 --retry-all-errors -fsSL "$mingit_url" -o "$MINGIT_ZIP_PATH"
fi
}
@@ -2382,8 +2421,13 @@ verify_gateway() {
}
verify_gateway_reachable() {
local probe_json attempt
for attempt in 1 2 3 4 5 6; do
local probe_json attempt start_seconds deadline recovery_tried
start_seconds="$SECONDS"
deadline=$((SECONDS + TIMEOUT_GATEWAY_S))
recovery_tried=0
attempt=1
while (( SECONDS < deadline )); do
probe_json="$(
guest_run_openclaw "" "" gateway probe --url ws://127.0.0.1:18789 --timeout 30000 --json
)"
@@ -2398,11 +2442,25 @@ PY
then
return 0
fi
if (( attempt < 6 )); then
printf 'gateway-reachable retry %s\n' "$attempt" >&2
sleep 3
if [[ "$recovery_tried" -eq 0 && $((SECONDS - start_seconds)) -ge "$GATEWAY_RECOVERY_AFTER_S" ]]; then
printf 'gateway-reachable recovery: gateway start after %ss\n' "$((SECONDS - start_seconds))" >&2
if ! run_gateway_daemon_action start; then
printf 'gateway-reachable recovery start failed; continuing probes\n' >&2
fi
recovery_tried=1
fi
printf 'gateway-reachable retry %s elapsed=%ss\n' "$attempt" "$((SECONDS - start_seconds))" >&2
attempt=$((attempt + 1))
sleep 5
done
if [[ "$recovery_tried" -eq 0 ]]; then
printf 'gateway-reachable recovery: gateway start after timeout\n' >&2
run_gateway_daemon_action start || true
fi
return 1
}

View File

@@ -0,0 +1,186 @@
import path from "node:path";
const JS_DIST_FILE_RE = /^dist\/.*\.(?:cjs|js|mjs)$/u;
function normalizePackagePath(value) {
return value.replace(/\\/gu, "/").replace(/^package\//u, "");
}
function stripSpecifierSuffix(value) {
return value.replace(/[?#].*$/u, "");
}
function resolveDistImportPath(importerPath, specifier) {
if (!specifier.startsWith(".")) {
return null;
}
const stripped = stripSpecifierSuffix(specifier);
if (!stripped) {
return null;
}
return path.posix.normalize(path.posix.join(path.posix.dirname(importerPath), stripped));
}
function findStatementStart(source, index) {
return (
Math.max(
source.lastIndexOf(";", index),
source.lastIndexOf("{", index),
source.lastIndexOf("}", index),
source.lastIndexOf("\n", index),
source.lastIndexOf("\r", index),
) + 1
);
}
function isImportSpecifierContext(source, index) {
const dynamicPrefix = source.slice(Math.max(0, index - 32), index);
if (/\bimport\s*\(\s*$/u.test(dynamicPrefix)) {
return true;
}
const statementPrefix = source.slice(findStatementStart(source, index), index).trimStart();
return (
/^(?:import|export)\b[\s\S]*\bfrom\s*$/u.test(statementPrefix) ||
/^import\s*$/u.test(statementPrefix)
);
}
function collectImportSpecifiers(source) {
const specifiers = [];
let inBlockComment = false;
let inLineComment = false;
for (let index = 0; index < source.length; index += 1) {
if (inBlockComment) {
if (source[index] === "*" && source[index + 1] === "/") {
inBlockComment = false;
index += 1;
}
continue;
}
if (inLineComment) {
if (source[index] === "\n" || source[index] === "\r") {
inLineComment = false;
}
continue;
}
if (source[index] === "/" && source[index + 1] === "*") {
inBlockComment = true;
index += 1;
continue;
}
if (source[index] === "/" && source[index + 1] === "/") {
inLineComment = true;
index += 1;
continue;
}
const quote = source[index];
if (quote !== '"' && quote !== "'") {
continue;
}
let cursor = index + 1;
let value = "";
while (cursor < source.length) {
const char = source[cursor];
if (char === "\\") {
value += source.slice(cursor, cursor + 2);
cursor += 2;
continue;
}
if (char === quote) {
break;
}
value += char;
cursor += 1;
}
if (cursor >= source.length) {
break;
}
if (value.startsWith(".")) {
if (isImportSpecifierContext(source, index)) {
specifiers.push(value);
}
}
index = cursor;
}
return specifiers;
}
export function collectPackageDistImportErrors(params) {
const files = [...new Set(params.files.map(normalizePackagePath))];
const fileSet = new Set(files);
const errors = [];
const imports = params.imports ?? collectPackageDistImports({ files, readText: params.readText });
for (const { importerPath, importedPath } of imports) {
if (!fileSet.has(importedPath)) {
errors.push(`${importerPath} imports missing ${importedPath}`);
}
}
return errors;
}
export function collectPackageDistImports(params) {
const files = [...new Set(params.files.map(normalizePackagePath))];
const imports = [];
for (const importerPath of files.toSorted((left, right) => left.localeCompare(right))) {
if (!JS_DIST_FILE_RE.test(importerPath) || importerPath.includes("/node_modules/")) {
continue;
}
const source = params.readText(importerPath);
for (const specifier of collectImportSpecifiers(source)) {
const importedPath = resolveDistImportPath(importerPath, specifier);
if (!importedPath) {
continue;
}
imports.push({ importerPath, importedPath });
}
}
return imports;
}
export function expandPackageDistImportClosure(params) {
const files = [...new Set(params.files.map(normalizePackagePath))];
const fileSet = new Set(files);
const expectedSet = new Set(params.seedFiles.map(normalizePackagePath));
const importsByImporter = new Map();
if (params.imports) {
for (const { importerPath, importedPath } of params.imports) {
const normalizedImporterPath = normalizePackagePath(importerPath);
const importerImports = importsByImporter.get(normalizedImporterPath) ?? [];
importerImports.push(normalizePackagePath(importedPath));
importsByImporter.set(normalizedImporterPath, importerImports);
}
}
const queue = [...expectedSet].filter((file) => fileSet.has(file));
for (let index = 0; index < queue.length; index += 1) {
const importerPath = queue[index];
let importedPaths = importsByImporter.get(importerPath);
if (!params.imports) {
importedPaths = [];
if (JS_DIST_FILE_RE.test(importerPath) && !importerPath.includes("/node_modules/")) {
const source = params.readText(importerPath);
for (const specifier of collectImportSpecifiers(source)) {
const importedPath = resolveDistImportPath(importerPath, specifier);
if (importedPath) {
importedPaths.push(importedPath);
}
}
}
}
for (const importedPath of importedPaths ?? []) {
if (fileSet.has(importedPath) && !expectedSet.has(importedPath)) {
expectedSet.add(importedPath);
queue.push(importedPath);
}
}
}
return [...expectedSet].toSorted((left, right) => left.localeCompare(right));
}

View File

@@ -1,7 +1,7 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
export const WORKSPACE_TEMPLATE_PACK_PATHS = [
"docs/reference/templates/AGENTS.md",
@@ -23,6 +23,47 @@ const REQUIRED_BOOTSTRAP_WORKSPACE_FILES = [
"BOOTSTRAP.md",
];
export const WORKSPACE_BOOTSTRAP_SMOKE_TIMEOUT_MS = 15_000;
const SAFE_UNIX_SMOKE_PATH = "/usr/bin:/bin";
export function createWorkspaceBootstrapSmokeEnv(env, homeDir, overrides = {}) {
const allowlistedEnvEntries = [
"TMPDIR",
"TMP",
"TEMP",
"SystemRoot",
"ComSpec",
"PATHEXT",
"WINDIR",
];
const windowsRoot = env.SystemRoot ?? env.WINDIR ?? "C:\\Windows";
const nodeBinDir = dirname(process.execPath);
const safePath =
process.platform === "win32"
? `${nodeBinDir};${windowsRoot}\\System32;${windowsRoot}`
: `${nodeBinDir}:${SAFE_UNIX_SMOKE_PATH}`;
return {
...Object.fromEntries(
allowlistedEnvEntries.flatMap((key) => {
const value = env[key];
return typeof value === "string" && value.length > 0 ? [[key, value]] : [];
}),
),
PATH: safePath,
HOME: homeDir,
USERPROFILE: homeDir,
OPENCLAW_HOME: homeDir,
OPENCLAW_NO_ONBOARD: "1",
OPENCLAW_SUPPRESS_NOTES: "1",
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1",
AWS_EC2_METADATA_DISABLED: "true",
AWS_SHARED_CREDENTIALS_FILE: join(homeDir, ".aws", "credentials"),
AWS_CONFIG_FILE: join(homeDir, ".aws", "config"),
...overrides,
};
}
function collectMissingBootstrapWorkspaceFiles(workspaceDir) {
return REQUIRED_BOOTSTRAP_WORKSPACE_FILES.filter(
(filename) => !existsSync(join(workspaceDir, filename)),
@@ -77,12 +118,8 @@ export function runInstalledWorkspaceBootstrapSmoke(params) {
encoding: "utf8",
maxBuffer: 1024 * 1024 * 16,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
HOME: homeDir,
OPENCLAW_HOME: homeDir,
OPENCLAW_SUPPRESS_NOTES: "1",
},
timeout: WORKSPACE_BOOTSTRAP_SMOKE_TIMEOUT_MS,
env: createWorkspaceBootstrapSmokeEnv(process.env, homeDir),
},
);
} catch (error) {

View File

@@ -10,6 +10,7 @@ import {
existsSync,
mkdirSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
@@ -38,7 +39,7 @@ const providerConfig = {
extensionId: "openai",
secretEnv: "OPENAI_API_KEY",
authChoice: "openai-api-key",
model: "openai/gpt-5.5",
model: "openai/gpt-5.4-mini",
},
anthropic: {
extensionId: "anthropic",
@@ -54,6 +55,19 @@ const providerConfig = {
},
};
const RELEASE_SMOKE_PLUGIN_ALLOWLIST_BASE = [
"acpx",
"bonjour",
"browser",
"device-pair",
"phone-control",
"talk-voice",
];
export function buildCrossOsReleaseSmokePluginAllowlist(providerMeta) {
return [...new Set([providerMeta.extensionId, ...RELEASE_SMOKE_PLUGIN_ALLOWLIST_BASE])];
}
const PACKAGE_DIST_INVENTORY_RELATIVE_PATH = "dist/postinstall-inventory.json";
const OMITTED_QA_EXTENSION_PREFIXES = [
"dist/extensions/qa-channel/",
@@ -691,18 +705,22 @@ async function runUpgradeLane(params) {
"--timeout",
String(updateStepTimeoutSeconds()),
];
await runOpenClaw({
const updateResult = await runOpenClaw({
lane,
env: updateEnv,
args: updateArgs,
logPath: join(params.logsDir, "upgrade-update.log"),
timeoutMs: updateTimeoutMs(),
check: false,
});
verifyPackagedUpgradeUpdateResult(updateResult, {
candidateVersion: params.build.candidateVersion,
});
logLanePhase(lane, "update-status");
await runOpenClaw({
lane,
env,
env: updateEnv,
args: ["update", "status", "--json"],
logPath: join(params.logsDir, "upgrade-update-status.log"),
timeoutMs: 2 * 60 * 1000,
@@ -1218,11 +1236,55 @@ export function shouldSkipInstallerDaemonHealthCheck(platform = process.platform
}
export function buildRealUpdateEnv(env) {
const updateEnv = { ...env };
const updateEnv = {
...env,
NODE_DISABLE_COMPILE_CACHE: "1",
};
delete updateEnv.OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL;
delete updateEnv.NODE_COMPILE_CACHE;
return updateEnv;
}
export function verifyPackagedUpgradeUpdateResult(result, options) {
if (result.exitCode === 0) {
return;
}
let payload = null;
try {
payload = JSON.parse(result.stdout);
} catch {
payload = null;
}
const steps = Array.isArray(payload?.steps) ? payload.steps : [];
const allStepsSucceeded = steps.every((step) => step?.exitCode === 0);
const afterVersion = typeof payload?.after?.version === "string" ? payload.after.version : "";
if (
payload?.status === "ok" &&
afterVersion === options.candidateVersion &&
allStepsSucceeded &&
isSelfSwappedPackageProcessExit(result.stderr)
) {
return;
}
throw new Error(
`Packaged upgrade failed (${result.exitCode}): ${trimForSummary(
`${result.stdout}\n${result.stderr}`,
)}`,
);
}
function isSelfSwappedPackageProcessExit(stderr) {
return (
typeof stderr === "string" &&
stderr.includes("[openclaw] Failed to start CLI:") &&
stderr.includes("ERR_MODULE_NOT_FOUND") &&
/[\\/]node_modules[\\/]openclaw[\\/]dist[\\/]/u.test(stderr)
);
}
export function resolveExplicitBaselineVersion(baselineSpec) {
const trimmed = baselineSpec.trim();
if (!trimmed || trimmed === "openclaw@latest") {
@@ -1302,7 +1364,9 @@ export function resolveInstalledPrefixDirFromCliPath(cliPath, platform = process
}
function readInstalledMetadataFromCliPath(cliPath, platform = process.platform) {
return readInstalledMetadata(resolveInstalledPrefixDirFromCliPath(cliPath, platform));
return readInstalledMetadataFromPackageRoot(
resolveInstalledPackageRootFromCliPath(cliPath, platform),
);
}
function resolveInstalledCliInvocation(cliPath, platform = process.platform) {
@@ -1793,6 +1857,20 @@ async function runInstalledModelsSet(params) {
logPath: params.logPath,
timeoutMs: 2 * 60 * 1000,
});
await runInstalledCli({
cliPath: params.cliPath,
args: [
"config",
"set",
"plugins.allow",
JSON.stringify(buildCrossOsReleaseSmokePluginAllowlist(params.providerConfig)),
"--strict-json",
],
cwd: params.cwd,
env: params.env,
logPath: params.logPath,
timeoutMs: 2 * 60 * 1000,
});
await runInstalledCli({
cliPath: params.cliPath,
args: ["config", "set", "agents.defaults.skipBootstrap", "true", "--strict-json"],
@@ -1815,6 +1893,8 @@ async function runInstalledAgentTurn(params) {
sessionId,
"--message",
"Reply with exact ASCII text OK only.",
"--thinking",
"minimal",
"--json",
],
cwd: params.cwd,
@@ -2547,6 +2627,19 @@ async function runModelsSet(params) {
logPath: params.logPath,
timeoutMs: 2 * 60 * 1000,
});
await runOpenClaw({
lane: params.lane,
env: params.env,
args: [
"config",
"set",
"plugins.allow",
JSON.stringify(buildCrossOsReleaseSmokePluginAllowlist(params.providerConfig)),
"--strict-json",
],
logPath: params.logPath,
timeoutMs: 2 * 60 * 1000,
});
await runOpenClaw({
lane: params.lane,
env: params.env,
@@ -2572,6 +2665,8 @@ async function runAgentTurn(params) {
sessionId,
"--message",
"Reply with exact ASCII text OK only.",
"--thinking",
"minimal",
"--json",
],
logPath: params.logPath,
@@ -2781,6 +2876,10 @@ async function runOpenClaw(params) {
function readInstalledPackageManifest(prefixDir) {
const packageRoot = installedPackageRoot(prefixDir);
return readInstalledPackageManifestFromPackageRoot(packageRoot);
}
function readInstalledPackageManifestFromPackageRoot(packageRoot) {
const packageJsonPath = join(packageRoot, "package.json");
if (!existsSync(packageJsonPath)) {
throw new Error(`Installed package manifest missing: ${packageJsonPath}`);
@@ -2798,6 +2897,15 @@ export function readInstalledVersion(prefixDir) {
function readInstalledMetadata(prefixDir) {
const { packageJson, packageRoot } = readInstalledPackageManifest(prefixDir);
return readInstalledMetadataFromManifest(packageJson, packageRoot);
}
function readInstalledMetadataFromPackageRoot(packageRoot) {
const { packageJson } = readInstalledPackageManifestFromPackageRoot(packageRoot);
return readInstalledMetadataFromManifest(packageJson, packageRoot);
}
function readInstalledMetadataFromManifest(packageJson, packageRoot) {
const buildInfoPath = join(packageRoot, "dist", "build-info.json");
if (!existsSync(buildInfoPath)) {
throw new Error(`Installed build info missing: ${buildInfoPath}`);
@@ -2824,8 +2932,55 @@ function verifyInstalledCandidate(installed, build) {
}
}
function installedPackageRoot(prefixDir) {
return process.platform === "win32"
export function resolveInstalledPackageRootFromCliPath(
cliPath,
platform = process.platform,
env = process.env,
) {
const prefixDir = resolveInstalledPrefixDirFromCliPath(cliPath, platform);
const candidates = [installedPackageRoot(prefixDir, platform)];
if (platform !== "win32") {
const resolvedCliPath = String(cliPath ?? "").trim();
if (resolvedCliPath) {
try {
const realCliPath = realpathSync(resolvedCliPath);
candidates.push(dirname(realCliPath));
candidates.push(dirname(dirname(realCliPath)));
} catch {
// Some installer shims are shell wrappers, not symlinks. Fall through to
// common user-local npm prefixes below.
}
}
for (const prefix of [
env.NPM_CONFIG_PREFIX,
env.npm_config_prefix,
env.HOME && join(env.HOME, ".npm-global"),
env.HOME && join(env.HOME, ".local"),
]) {
if (typeof prefix === "string" && prefix.trim()) {
candidates.push(installedPackageRoot(prefix, platform));
}
}
}
const checked: string[] = [];
for (const candidate of candidates) {
if (!candidate || checked.includes(candidate)) {
continue;
}
checked.push(candidate);
if (existsSync(join(candidate, "package.json"))) {
return candidate;
}
}
throw new Error(`Installed package manifest missing. Checked: ${checked.join(", ")}`);
}
function installedPackageRoot(prefixDir, platform = process.platform) {
return platform === "win32"
? join(prefixDir, "node_modules", "openclaw")
: join(prefixDir, "lib", "node_modules", "openclaw");
}

View File

@@ -39,16 +39,33 @@ function parseArgs(argv) {
return options;
}
function run(command, args, cwd) {
function run(command, args, cwd, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
let timedOut = false;
const timeout =
options.timeoutMs === undefined
? undefined
: setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => child.kill("SIGKILL"), 5_000).unref?.();
}, options.timeoutMs);
timeout?.unref?.();
child.stdout.pipe(process.stderr, { end: false });
child.stderr.pipe(process.stderr, { end: false });
child.on("error", reject);
child.on("close", (status, signal) => {
if (timeout) {
clearTimeout(timeout);
}
if (timedOut) {
reject(new Error(`${command} ${args.join(" ")} timed out after ${options.timeoutMs}ms`));
return;
}
if (status === 0) {
resolve();
return;
@@ -115,6 +132,8 @@ async function main() {
if (!options.skipBuild) {
console.error("==> Building OpenClaw package artifacts");
await run("pnpm", ["build"], sourceDir);
console.error("==> Building OpenClaw Control UI assets");
await run("pnpm", ["ui:build"], sourceDir);
}
console.error("==> Writing OpenClaw package inventory");
@@ -148,10 +167,15 @@ async function main() {
}
console.error("==> Checking OpenClaw package tarball");
const checkStartedAt = Date.now();
await run(
"node",
[path.join(ROOT_DIR, "scripts/check-openclaw-package-tarball.mjs"), tarball],
sourceDir,
{ timeoutMs: 5 * 60 * 1000 },
);
console.error(
`==> OpenClaw package tarball check finished in ${Math.round((Date.now() - checkStartedAt) / 1000)}s`,
);
process.stdout.write(`${tarball}\n`);

View File

@@ -21,8 +21,10 @@ import {
unlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, isAbsolute, join, relative } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { expandPackageDistImportClosure } from "./lib/package-dist-imports.mjs";
import { resolveNpmRunner } from "./npm-runner.mjs";
export const BUNDLED_PLUGIN_INSTALL_TARGETS = [];
@@ -292,6 +294,16 @@ export function pruneInstalledPackageDist(params = {}) {
}
}
const installedFiles = listInstalledDistFiles(params);
const readFile = params.readFileSync ?? readFileSync;
expectedFiles = new Set(
expandPackageDistImportClosure({
files: installedFiles,
seedFiles: [...expectedFiles],
readText(relativePath) {
return readFile(join(packageRoot, relativePath), "utf8");
},
}),
);
const removed = [];
for (const relativePath of installedFiles) {
@@ -716,6 +728,29 @@ function shouldRunBundledPluginPostinstall(params) {
return true;
}
export function pruneOpenClawCompileCache(params = {}) {
const env = params.env ?? process.env;
const pathExists = params.existsSync ?? existsSync;
const remove = params.rmSync ?? rmSync;
const log = params.log ?? console;
const baseDirs = [
env.NODE_DISABLE_COMPILE_CACHE ? "" : env.NODE_COMPILE_CACHE,
join(tmpdir(), "node-compile-cache"),
].filter((value, index, values) => value && values.indexOf(value) === index);
for (const baseDir of baseDirs) {
const cacheRoot = join(baseDir, "openclaw");
if (!pathExists(cacheRoot)) {
continue;
}
try {
remove(cacheRoot, { recursive: true, force: true, maxRetries: 2, retryDelay: 100 });
} catch (error) {
log.warn?.(`[postinstall] could not prune OpenClaw compile cache: ${String(error)}`);
}
}
}
export function runBundledPluginPostinstall(params = {}) {
const env = params.env ?? process.env;
const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT;
@@ -726,6 +761,12 @@ export function runBundledPluginPostinstall(params = {}) {
if (env?.[DISABLE_POSTINSTALL_ENV]?.trim()) {
return;
}
pruneOpenClawCompileCache({
env,
existsSync: pathExists,
rmSync: params.rmSync,
log,
});
if (isSourceCheckoutRoot({ packageRoot, existsSync: pathExists })) {
try {
pruneBundledPluginSourceNodeModules({

View File

@@ -14,6 +14,7 @@ import {
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { COMPLETION_SKIP_PLUGIN_COMMANDS_ENV } from "../src/cli/completion-runtime.ts";
import {
isBundledRuntimeDepsInstallStagePath,
LOCAL_BUILD_METADATA_DIST_PATHS,
@@ -69,6 +70,7 @@ const requiredPathGroups = [
...WORKSPACE_TEMPLATE_PACK_PATHS,
"scripts/npm-runner.mjs",
"scripts/preinstall-package-manager-warning.mjs",
"scripts/lib/package-dist-imports.mjs",
"scripts/postinstall-bundled-plugins.mjs",
"dist/plugin-sdk/compat.js",
"dist/plugin-sdk/root-alias.cjs",
@@ -120,6 +122,12 @@ export const PACKED_CLI_SMOKE_COMMANDS = [
["config", "schema"],
["models", "list", "--provider", "amazon-bedrock"],
] as const;
export const PACKED_COMPLETION_SMOKE_ARGS = [
"completion",
"--write-state",
"--shell",
"zsh",
] as const;
function collectBundledExtensions(): BundledExtension[] {
const extensionsDir = resolve("extensions");
@@ -298,6 +306,19 @@ export function createPackedCliSmokeEnv(
};
}
export function createPackedCompletionSmokeEnv(
env: NodeJS.ProcessEnv,
overrides: NodeJS.ProcessEnv = {},
): NodeJS.ProcessEnv {
return {
...env,
...overrides,
OPENCLAW_SUPPRESS_NOTES: "1",
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1",
[COMPLETION_SKIP_PLUGIN_COMMANDS_ENV]: "1",
};
}
function runPackedBundledPluginPostinstall(packageRoot: string): void {
execFileSync(process.execPath, [join(packageRoot, "scripts/postinstall-bundled-plugins.mjs")], {
cwd: packageRoot,
@@ -482,20 +503,25 @@ function runPackedTaskRegistryControlRuntimeSmoke(packageRoot: string): void {
if (!existsSync(runtimePath)) {
throw new Error("release-check: packed task-registry control runtime is missing.");
}
const source = `
const runtime = await import(${JSON.stringify(pathToFileURL(runtimePath).href)});
if (typeof runtime.getAcpSessionManager !== "function") {
throw new Error("missing getAcpSessionManager export");
}
if (typeof runtime.killSubagentRunAdmin !== "function") {
throw new Error("missing killSubagentRunAdmin export");
}
`;
execFileSync(process.execPath, ["--input-type=module", "--eval", source], {
cwd: packageRoot,
stdio: "inherit",
env: createPackedCliSmokeEnv(process.env),
});
const source = [
'const dynamicImport = Function("specifier", "return im" + "port(specifier)");',
"const runtime = await dynamicImport(process.argv[1]);",
'if (typeof runtime.getAcpSessionManager !== "function") {',
' throw new Error("missing getAcpSessionManager export");',
"}",
'if (typeof runtime.killSubagentRunAdmin !== "function") {',
' throw new Error("missing killSubagentRunAdmin export");',
"}",
].join("\n");
execFileSync(
process.execPath,
["--input-type=module", "--eval", source, pathToFileURL(runtimePath).href],
{
cwd: packageRoot,
stdio: "inherit",
env: createPackedCliSmokeEnv(process.env),
},
);
}
function runPackedCliSmoke(params: {
@@ -579,17 +605,14 @@ function runPackedBundledChannelEntrySmoke(): void {
execFileSync(
process.execPath,
[join(packageRoot, "openclaw.mjs"), "completion", "--write-state"],
[join(packageRoot, "openclaw.mjs"), ...PACKED_COMPLETION_SMOKE_ARGS],
{
cwd: packageRoot,
stdio: "inherit",
env: {
...process.env,
env: createPackedCompletionSmokeEnv(process.env, {
HOME: homeDir,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_SUPPRESS_NOTES: "1",
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1",
},
}),
},
);

View File

@@ -93,6 +93,16 @@ function run(command, args, options = {}) {
cwd: options.cwd ?? ROOT_DIR,
stdio: options.capture ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
});
let timedOut = false;
const timeout =
options.timeoutMs === undefined
? undefined
: setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => child.kill("SIGKILL"), 5_000).unref?.();
}, options.timeoutMs);
timeout?.unref?.();
let stdout = "";
let stderr = "";
if (options.capture) {
@@ -105,6 +115,13 @@ function run(command, args, options = {}) {
}
child.on("error", reject);
child.on("close", (status, signal) => {
if (timeout) {
clearTimeout(timeout);
}
if (timedOut) {
reject(new Error(`${command} ${args.join(" ")} timed out after ${options.timeoutMs}ms`));
return;
}
if (status === 0) {
resolve(stdout);
return;
@@ -406,7 +423,14 @@ async function resolveCandidate(options) {
}
const digest = await assertExpectedSha256(target, options.packageSha256);
await run("node", ["scripts/check-openclaw-package-tarball.mjs", target]);
console.error(`Checking OpenClaw package tarball: ${target}`);
const checkStartedAt = Date.now();
await run("node", ["scripts/check-openclaw-package-tarball.mjs", target], {
timeoutMs: 5 * 60 * 1000,
});
console.error(
`OpenClaw package tarball check finished in ${Math.round((Date.now() - checkStartedAt) / 1000)}s`,
);
const pkg = await readPackageJson(target);
const metadata = {
name: pkg.name,

View File

@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { performance } from "node:perf_hooks";
import { fileURLToPath, pathToFileURL } from "node:url";
import { copyBundledPluginMetadata } from "./copy-bundled-plugin-metadata.mjs";
import { copyPluginSdkRootAlias } from "./copy-plugin-sdk-root-alias.mjs";
@@ -10,6 +11,12 @@ import { writeOfficialChannelCatalog } from "./write-official-channel-catalog.mj
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const ROOT_RUNTIME_ALIAS_PATTERN = /^(?<base>.+\.(?:runtime|contract))-[A-Za-z0-9_-]+\.js$/u;
export const LEGACY_CLI_EXIT_COMPAT_CHUNKS = [
{
dest: "dist/memory-state-CcqRgDZU.js",
contents: "export function hasMemoryRuntime() {\n return false;\n}\n",
},
];
/**
* Copy static (non-transpiled) runtime assets that are referenced by their
@@ -25,6 +32,14 @@ export const STATIC_EXTENSION_ASSETS = [
src: "extensions/acpx/src/runtime-internals/mcp-proxy.mjs",
dest: "dist/extensions/acpx/mcp-proxy.mjs",
},
{
src: "extensions/acpx/src/runtime-internals/error-format.mjs",
dest: "dist/extensions/acpx/error-format.mjs",
},
{
src: "extensions/acpx/src/runtime-internals/mcp-command-line.mjs",
dest: "dist/extensions/acpx/mcp-command-line.mjs",
},
// diffs viewer runtime bundle — co-deployed inside the plugin package so the
// built bundle can resolve `./assets/viewer-runtime.js` from dist.
{
@@ -81,14 +96,35 @@ export function writeStableRootRuntimeAliases(params = {}) {
}
}
export function writeLegacyCliExitCompatChunks(params = {}) {
const rootDir = params.rootDir ?? ROOT;
const chunks = params.chunks ?? LEGACY_CLI_EXIT_COMPAT_CHUNKS;
for (const { dest, contents } of chunks) {
writeTextFileIfChanged(path.join(rootDir, dest), contents);
}
}
export function runRuntimePostBuild(params = {}) {
copyPluginSdkRootAlias(params);
copyBundledPluginMetadata(params);
writeOfficialChannelCatalog(params);
stageBundledPluginRuntimeDeps(params);
stageBundledPluginRuntime(params);
writeStableRootRuntimeAliases(params);
copyStaticExtensionAssets(params);
const timingsEnabled = params.timings ?? process.env.OPENCLAW_RUNTIME_POSTBUILD_TIMINGS !== "0";
const runPhase = (label, action) => {
const startedAt = performance.now();
try {
return action();
} finally {
if (timingsEnabled) {
const durationMs = Math.round(performance.now() - startedAt);
console.error(`runtime-postbuild: ${label} completed in ${durationMs}ms`);
}
}
};
runPhase("plugin SDK root alias", () => copyPluginSdkRootAlias(params));
runPhase("bundled plugin metadata", () => copyBundledPluginMetadata(params));
runPhase("official channel catalog", () => writeOfficialChannelCatalog(params));
runPhase("bundled plugin runtime deps", () => stageBundledPluginRuntimeDeps(params));
runPhase("bundled plugin runtime overlay", () => stageBundledPluginRuntime(params));
runPhase("stable root runtime aliases", () => writeStableRootRuntimeAliases(params));
runPhase("legacy CLI exit compat chunks", () => writeLegacyCliExitCompatChunks(params));
runPhase("static extension assets", () => copyStaticExtensionAssets(params));
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {

View File

@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { performance } from "node:perf_hooks";
import { pathToFileURL } from "node:url";
import semverSatisfies from "semver/functions/satisfies.js";
import { resolveNpmRunner } from "./npm-runner.mjs";
@@ -499,7 +500,7 @@ function createInstalledRuntimeClosureFingerprint(rootNodeModulesDir, dependency
if (depRoot === null || !fs.existsSync(depRoot)) {
return null;
}
hash.update(`package:${depName}\n`);
hash.update(`package:${depName}:${fs.realpathSync(depRoot)}\n`);
appendDirectoryFingerprint(hash, depRoot);
}
return hash.digest("hex");
@@ -1222,69 +1223,102 @@ export function stageBundledPluginRuntimeDeps(params = {}) {
params.installPluginRuntimeDepsImpl ?? installPluginRuntimeDeps;
const installAttempts = params.installAttempts ?? 3;
const pruneConfig = resolveRuntimeDepPruneConfig(params);
const timingsEnabled =
params.timings ?? process.env.OPENCLAW_RUNTIME_DEPS_STAGING_TIMINGS === "1";
const runPluginPhase = (pluginId, label, action) => {
const startedAt = performance.now();
try {
return action();
} finally {
if (timingsEnabled) {
const durationMs = Math.round(performance.now() - startedAt);
console.error(
`stage-bundled-plugin-runtime-deps: ${pluginId} ${label} completed in ${durationMs}ms`,
);
}
}
};
for (const pluginDir of listBundledPluginRuntimeDirs(repoRoot)) {
const pluginId = path.basename(pluginDir);
const sourcePluginRoot = resolveInstalledWorkspacePluginRoot(repoRoot, pluginId);
const directDependencyPackageRoot = fs.existsSync(path.join(sourcePluginRoot, "package.json"))
? sourcePluginRoot
: null;
const packageJson = sanitizeBundledManifestForRuntimeInstall(pluginDir);
const packageJson = runPluginPhase(pluginId, "sanitize manifest", () =>
sanitizeBundledManifestForRuntimeInstall(pluginDir),
);
const nodeModulesDir = path.join(pluginDir, "node_modules");
const stampPath = resolveRuntimeDepsStampPath(repoRoot, pluginId);
const legacyStampPath = resolveLegacyRuntimeDepsStampPath(pluginDir);
removePathIfExists(legacyStampPath);
removeStaleRuntimeDepsTempDirs(pluginDir);
runPluginPhase(pluginId, "cleanup stale runtime dirs", () => {
removePathIfExists(legacyStampPath);
removeStaleRuntimeDepsTempDirs(pluginDir);
});
if (!hasRuntimeDeps(packageJson) || !shouldStageRuntimeDeps(packageJson)) {
removePathIfExists(nodeModulesDir);
removePathIfExists(stampPath);
runPluginPhase(pluginId, "remove unstaged runtime deps", () => {
removePathIfExists(nodeModulesDir);
removePathIfExists(stampPath);
});
continue;
}
const cheapFingerprint = createRuntimeDepsCheapFingerprint(packageJson, pruneConfig, {
repoRoot,
});
const cheapFingerprint = runPluginPhase(pluginId, "cheap fingerprint", () =>
createRuntimeDepsCheapFingerprint(packageJson, pruneConfig, {
repoRoot,
}),
);
const stamp = readRuntimeDepsStamp(stampPath);
const rootInstalledRuntimeFingerprint = resolveInstalledRuntimeClosureFingerprint({
directDependencyPackageRoot,
packageJson,
rootNodeModulesDir: path.join(repoRoot, "node_modules"),
});
const rootInstalledRuntimeFingerprint = runPluginPhase(
pluginId,
"installed runtime fingerprint",
() =>
resolveInstalledRuntimeClosureFingerprint({
directDependencyPackageRoot,
packageJson,
rootNodeModulesDir: path.join(repoRoot, "node_modules"),
}),
);
const fingerprint = createRuntimeDepsFingerprint(packageJson, pruneConfig, {
repoRoot,
rootInstalledRuntimeFingerprint,
});
if (fs.existsSync(nodeModulesDir) && stamp?.fingerprint === fingerprint) {
runPluginPhase(pluginId, "reuse staged runtime deps", () => {});
continue;
}
if (
stageInstalledRootRuntimeDeps({
directDependencyPackageRoot,
fingerprint,
cheapFingerprint,
packageJson,
pluginDir,
pruneConfig,
repoRoot,
stampPath,
})
) {
continue;
}
try {
installPluginRuntimeDepsWithRetries({
attempts: installAttempts,
install: installPluginRuntimeDepsImpl,
installParams: {
runPluginPhase(pluginId, "stage installed root runtime deps", () =>
stageInstalledRootRuntimeDeps({
directDependencyPackageRoot,
fingerprint,
cheapFingerprint,
packageJson,
pluginDir,
pluginId,
pruneConfig,
repoRoot,
stampPath,
},
});
}),
)
) {
continue;
}
try {
runPluginPhase(pluginId, "fallback install runtime deps", () =>
installPluginRuntimeDepsWithRetries({
attempts: installAttempts,
install: installPluginRuntimeDepsImpl,
installParams: {
directDependencyPackageRoot,
fingerprint,
cheapFingerprint,
packageJson,
pluginDir,
pluginId,
pruneConfig,
repoRoot,
stampPath,
},
}),
);
} catch (error) {
throw createRootRuntimeStagingError({ packageJson, pluginId, cause: error });
}

View File

@@ -430,11 +430,12 @@ async function runBasicAgentCommand() {
}
function expectFallbackOverrideCalls(first: boolean, second: boolean) {
expect(state.resolveEffectiveModelFallbacksMock).toHaveBeenCalledTimes(2);
expect(state.resolveEffectiveModelFallbacksMock.mock.calls[0][0]).toMatchObject({
const calls = state.resolveEffectiveModelFallbacksMock.mock.calls.slice(-2);
expect(calls).toHaveLength(2);
expect(calls[0]?.[0]).toMatchObject({
hasSessionModelOverride: first,
});
expect(state.resolveEffectiveModelFallbacksMock.mock.calls[1][0]).toMatchObject({
expect(calls[1]?.[0]).toMatchObject({
hasSessionModelOverride: second,
});
}

View File

@@ -9,6 +9,7 @@ import {
import { formatCliCommand } from "../cli/command-format.js";
import type { CliDeps } from "../cli/deps.types.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
clearAgentRunContext,
emitAgentEvent,
@@ -251,6 +252,50 @@ function normalizeExplicitOverrideInput(raw: string, kind: "provider" | "model")
return trimmed;
}
function resolveModelCatalogProviderScope(params: {
cfg: OpenClawConfig;
agentId: string;
defaultProvider: string;
defaultModel: string;
hasStoredOverride: boolean;
storedModelOverrideSource?: "auto" | "user";
explicitProviderOverride?: string;
explicitModelOverride?: string;
}): string[] {
const providers = new Set<string>();
const addProvider = (provider: string | undefined) => {
const normalized = provider?.trim();
if (normalized) {
providers.add(normalized);
}
};
const addModelRef = (raw: string | undefined, defaultProvider: string) => {
const parsed = raw ? parseModelRef(raw, defaultProvider) : null;
addProvider(parsed?.provider);
};
addProvider(params.defaultProvider);
addModelRef(params.defaultModel, params.defaultProvider);
addProvider(params.explicitProviderOverride);
addModelRef(
params.explicitModelOverride,
params.explicitProviderOverride ?? params.defaultProvider,
);
for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) {
addModelRef(raw, params.defaultProvider);
}
for (const raw of resolveEffectiveModelFallbacks({
cfg: params.cfg,
agentId: params.agentId,
hasSessionModelOverride: params.hasStoredOverride,
modelOverrideSource: params.storedModelOverrideSource,
}) ?? []) {
addModelRef(raw, params.defaultProvider);
}
return [...providers].toSorted((left, right) => left.localeCompare(right));
}
async function prepareAgentCommandExecution(
opts: AgentCommandOpts & { senderIsOwner: boolean },
runtime: RuntimeEnv,
@@ -723,7 +768,19 @@ async function agentCommandInternal(
let allowAnyModel = !hasAllowlist;
if (needsModelCatalog) {
modelCatalog = await loadModelCatalog({ config: cfg });
modelCatalog = await loadModelCatalog({
config: cfg,
providerDiscoveryProviderIds: resolveModelCatalogProviderScope({
cfg,
agentId: sessionAgentId,
defaultProvider,
defaultModel,
hasStoredOverride,
storedModelOverrideSource,
explicitProviderOverride,
explicitModelOverride,
}),
});
const allowed = buildAllowedModelSet({
cfg,
catalog: modelCatalog,

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { captureEnv } from "../test-utils/env.js";
import {
@@ -10,6 +10,7 @@ import {
writeFakeClaudeCli,
writeFakeClaudeLiveCli,
} from "./bundle-mcp.test-harness.js";
import { __testing as cliBackendsTesting } from "./cli-backends.js";
vi.mock("./cli-runner/helpers.js", async () => {
const original =
@@ -28,6 +29,43 @@ vi.mock("./cli-runner/helpers.js", async () => {
// also reaches this test after several gateway/plugin restart exercises.
const E2E_TIMEOUT_MS = 90_000;
function installTestClaudeBackend(params: { commandPath: string; liveSession?: "claude-stdio" }) {
cliBackendsTesting.setDepsForTest({
resolveRuntimeCliBackends: () => [],
resolvePluginSetupCliBackend: ({ backend }) =>
backend === "claude-cli"
? {
pluginId: "anthropic",
backend: {
id: "claude-cli",
bundleMcp: true,
bundleMcpMode: "claude-config-file",
config: {
command: "node",
args: [params.commandPath],
input: "stdin",
output: "jsonl",
clearEnv: [],
...(params.liveSession ? { liveSession: params.liveSession } : {}),
},
},
}
: undefined,
});
}
async function resetBundleMcpPluginState() {
const { resetPluginLoaderTestStateForTest } = await import("../plugins/loader.test-fixtures.js");
const { clearPluginSetupRegistryCache } = await import("../plugins/setup-registry.js");
resetPluginLoaderTestStateForTest();
clearPluginSetupRegistryCache();
}
afterEach(async () => {
cliBackendsTesting.resetDepsForTest();
await resetBundleMcpPluginState();
});
describe("runCliAgent bundle MCP e2e", () => {
it(
"routes enabled bundle MCP config into the claude-cli backend and executes the tool",
@@ -35,9 +73,20 @@ describe("runCliAgent bundle MCP e2e", () => {
async () => {
const { runCliAgent } = await import("./cli-runner.js");
const { resetGlobalHookRunner } = await import("../plugins/hook-runner-global.js");
const envSnapshot = captureEnv(["HOME"]);
await resetBundleMcpPluginState();
const envSnapshot = captureEnv([
"HOME",
"USERPROFILE",
"OPENCLAW_HOME",
"OPENCLAW_STATE_DIR",
"OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY",
]);
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-bundle-mcp-"));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
delete process.env.OPENCLAW_HOME;
delete process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY = "1";
resetGlobalHookRunner();
const workspaceDir = path.join(tempHome, "workspace");
@@ -50,21 +99,16 @@ describe("runCliAgent bundle MCP e2e", () => {
await writeBundleProbeMcpServer(serverScriptPath);
await writeFakeClaudeCli(fakeClaudePath);
await writeClaudeBundle({ pluginRoot, serverScriptPath });
installTestClaudeBackend({ commandPath: fakeClaudePath });
const config: OpenClawConfig = {
agents: {
defaults: {
workspace: workspaceDir,
cliBackends: {
"claude-cli": {
command: "node",
args: [fakeClaudePath],
clearEnv: [],
},
},
},
},
plugins: {
load: { paths: [pluginRoot] },
entries: {
"bundle-probe": { enabled: true },
},
@@ -102,9 +146,20 @@ describe("runCliAgent bundle MCP e2e", () => {
const { closeMcpLoopbackServer, getActiveMcpLoopbackRuntime } =
await import("../gateway/mcp-http.js");
const { resetGlobalHookRunner } = await import("../plugins/hook-runner-global.js");
const envSnapshot = captureEnv(["HOME"]);
await resetBundleMcpPluginState();
const envSnapshot = captureEnv([
"HOME",
"USERPROFILE",
"OPENCLAW_HOME",
"OPENCLAW_STATE_DIR",
"OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY",
]);
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-live-cleanup-"));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
delete process.env.OPENCLAW_HOME;
delete process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY = "1";
resetGlobalHookRunner();
await closeMcpLoopbackServer();
@@ -119,22 +174,16 @@ describe("runCliAgent bundle MCP e2e", () => {
await writeBundleProbeMcpServer(serverScriptPath);
await writeFakeClaudeLiveCli({ filePath: fakeClaudePath, pidPath: fakeClaudePidPath });
await writeClaudeBundle({ pluginRoot, serverScriptPath });
installTestClaudeBackend({ commandPath: fakeClaudePath, liveSession: "claude-stdio" });
const config: OpenClawConfig = {
agents: {
defaults: {
workspace: workspaceDir,
cliBackends: {
"claude-cli": {
command: "node",
args: [fakeClaudePath],
clearEnv: [],
liveSession: "claude-stdio",
},
},
},
},
plugins: {
load: { paths: [pluginRoot] },
entries: {
"bundle-probe": { enabled: true },
},

View File

@@ -5,6 +5,7 @@ const CONTEXT_WINDOW_RUNTIME_STATE_KEY = Symbol.for("openclaw.contextWindowRunti
type ContextWindowRuntimeState = {
loadPromise: Promise<void> | null;
loadPromises: Map<string, Promise<void>>;
configuredConfig: OpenClawConfig | undefined;
configLoadFailures: number;
nextConfigLoadAttemptAtMs: number;
@@ -18,6 +19,7 @@ export const CONTEXT_WINDOW_RUNTIME_STATE = (() => {
if (!globalState[CONTEXT_WINDOW_RUNTIME_STATE_KEY]) {
globalState[CONTEXT_WINDOW_RUNTIME_STATE_KEY] = {
loadPromise: null,
loadPromises: new Map(),
configuredConfig: undefined,
configLoadFailures: 0,
nextConfigLoadAttemptAtMs: 0,
@@ -29,6 +31,7 @@ export const CONTEXT_WINDOW_RUNTIME_STATE = (() => {
export function resetContextWindowCacheForTest(): void {
CONTEXT_WINDOW_RUNTIME_STATE.loadPromise = null;
CONTEXT_WINDOW_RUNTIME_STATE.loadPromises.clear();
CONTEXT_WINDOW_RUNTIME_STATE.configuredConfig = undefined;
CONTEXT_WINDOW_RUNTIME_STATE.configLoadFailures = 0;
CONTEXT_WINDOW_RUNTIME_STATE.nextConfigLoadAttemptAtMs = 0;

View File

@@ -1,4 +1,5 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
type DiscoveredModel = { id: string; contextWindow?: number; contextTokens?: number };
type ContextModule = typeof import("./context.js");
@@ -165,6 +166,34 @@ describe("lookupContextTokens", () => {
expect(lookupContextTokens("gpt-5.4", { allowAsyncLoad: false })).toBe(272_000);
});
it("scopes async discovery warmup to the explicit provider", async () => {
const cfg: OpenClawConfig = {
models: {
providers: {
openai: {
baseUrl: "https://example.com",
models: [],
},
},
},
};
const { ensureOpenClawModelsJson } = mockContextModuleDeps(() => cfg);
const { resolveContextTokensForModel } = await importContextModule();
expect(
resolveContextTokensForModel({
cfg,
provider: "openai",
model: "gpt-5.4",
}),
).toBeUndefined();
await flushAsyncWarmup();
expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(cfg, undefined, {
providerDiscoveryProviderIds: ["openai"],
});
});
it("rehydrates config-backed cache entries after module reload when runtime config survives", async () => {
const firstLoadConfigMock = vi.fn(() => ({
models: {

View File

@@ -215,9 +215,21 @@ function primeConfiguredContextWindows(): OpenClawConfig | undefined {
}
}
function ensureContextWindowCacheLoaded(): Promise<void> {
if (CONTEXT_WINDOW_RUNTIME_STATE.loadPromise) {
return CONTEXT_WINDOW_RUNTIME_STATE.loadPromise;
function resolveContextWindowLoadScopeKey(providerIds: readonly string[] | undefined): string {
const scoped = providerIds
?.map((value) => normalizeProviderId(value))
.filter(Boolean)
.toSorted((left, right) => left.localeCompare(right));
return scoped?.length ? scoped.join(",") : "*";
}
function ensureContextWindowCacheLoaded(options?: {
providerDiscoveryProviderIds?: readonly string[];
}): Promise<void> {
const scopeKey = resolveContextWindowLoadScopeKey(options?.providerDiscoveryProviderIds);
const existing = CONTEXT_WINDOW_RUNTIME_STATE.loadPromises.get(scopeKey);
if (existing) {
return existing;
}
const cfg = primeConfiguredContextWindows();
@@ -225,9 +237,17 @@ function ensureContextWindowCacheLoaded(): Promise<void> {
return Promise.resolve();
}
CONTEXT_WINDOW_RUNTIME_STATE.loadPromise = (async () => {
const pending = (async () => {
try {
await (await loadModelsConfigRuntime()).ensureOpenClawModelsJson(cfg);
await (
await loadModelsConfigRuntime()
).ensureOpenClawModelsJson(
cfg,
undefined,
options?.providerDiscoveryProviderIds
? { providerDiscoveryProviderIds: options.providerDiscoveryProviderIds }
: undefined,
);
} catch {
// Continue with best-effort discovery/overrides.
}
@@ -257,12 +277,16 @@ function ensureContextWindowCacheLoaded(): Promise<void> {
})().catch(() => {
// Keep lookup best-effort.
});
return CONTEXT_WINDOW_RUNTIME_STATE.loadPromise;
CONTEXT_WINDOW_RUNTIME_STATE.loadPromises.set(scopeKey, pending);
if (scopeKey === "*") {
CONTEXT_WINDOW_RUNTIME_STATE.loadPromise = pending;
}
return pending;
}
export function lookupContextTokens(
modelId?: string,
options?: { allowAsyncLoad?: boolean },
options?: { allowAsyncLoad?: boolean; providerDiscoveryProviderIds?: readonly string[] },
): number | undefined {
if (!modelId) {
return undefined;
@@ -273,7 +297,11 @@ export function lookupContextTokens(
primeConfiguredContextWindows();
} else {
// Best-effort: kick off loading on demand, but don't block lookups.
void ensureContextWindowCacheLoaded();
void ensureContextWindowCacheLoaded(
options?.providerDiscoveryProviderIds
? { providerDiscoveryProviderIds: options.providerDiscoveryProviderIds }
: undefined,
);
}
return lookupCachedContextTokens(modelId);
}
@@ -459,6 +487,7 @@ export function resolveContextTokensForModel(params: {
model: params.model,
});
const explicitProvider = params.provider?.trim();
const discoveryProviderIds = explicitProvider && ref ? ([ref.provider] as const) : undefined;
if (ref) {
const modelParams = resolveConfiguredModelParams(params.cfg, ref.provider, ref.model);
if (modelParams?.context1m === true && isAnthropic1MModel(ref.provider, ref.model)) {
@@ -502,7 +531,10 @@ export function resolveContextTokensForModel(params: {
if (params.provider && ref && !ref.model.includes("/")) {
const qualifiedResult = lookupContextTokens(
`${normalizeProviderId(ref.provider)}/${ref.model}`,
{ allowAsyncLoad: params.allowAsyncLoad },
{
allowAsyncLoad: params.allowAsyncLoad,
...(discoveryProviderIds ? { providerDiscoveryProviderIds: discoveryProviderIds } : {}),
},
);
if (qualifiedResult !== undefined) {
return qualifiedResult;
@@ -513,6 +545,7 @@ export function resolveContextTokensForModel(params: {
// (e.g. "google/gemini-2.5-pro") this IS the raw discovery cache key.
const bareResult = lookupContextTokens(params.model, {
allowAsyncLoad: params.allowAsyncLoad,
...(discoveryProviderIds ? { providerDiscoveryProviderIds: discoveryProviderIds } : {}),
});
if (bareResult !== undefined) {
return bareResult;

View File

@@ -3,10 +3,12 @@ import os from "node:os";
import path from "node:path";
import { getShellEnvAppliedKeys } from "../infra/shell-env.js";
import { resolvePluginSetupProvider } from "../plugins/setup-registry.js";
import { CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "../secrets/provider-env-vars.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
import { resolveProviderEnvApiKeyCandidates } from "./model-auth-env-vars.js";
import { GCP_VERTEX_CREDENTIALS_MARKER } from "./model-auth-markers.js";
import { resolveProviderIdForAuth } from "./provider-auth-aliases.js";
import { normalizeProviderId } from "./provider-id.js";
export type EnvApiKeyResult = {
apiKey: string;
@@ -40,8 +42,7 @@ export function resolveEnvApiKey(
provider: string,
env: NodeJS.ProcessEnv = process.env,
): EnvApiKeyResult | null {
const normalized = resolveProviderIdForAuth(provider, { env });
const candidateMap = resolveProviderEnvApiKeyCandidates({ env });
const rawProvider = normalizeProviderId(provider);
const applied = new Set(getShellEnvAppliedKeys());
const pick = (envVar: string): EnvApiKeyResult | null => {
const value = normalizeOptionalSecretInput(env[envVar]);
@@ -52,6 +53,33 @@ export function resolveEnvApiKey(
return { apiKey: value, source };
};
const coreCandidates = Object.hasOwn(CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES, rawProvider)
? CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES[
rawProvider as keyof typeof CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES
]
: undefined;
if (Array.isArray(coreCandidates)) {
for (const envVar of coreCandidates) {
const resolved = pick(envVar);
if (resolved) {
return resolved;
}
}
}
if (rawProvider === "google-vertex") {
const envKey = resolveGoogleVertexEnvApiKey(env);
if (envKey) {
return { apiKey: envKey, source: "gcloud adc" };
}
}
const candidateMap = resolveProviderEnvApiKeyCandidates({ env });
const normalized = resolveProviderIdForAuth(rawProvider, { env });
if (normalized === rawProvider && coreCandidates) {
return null;
}
const candidates = Object.hasOwn(candidateMap, normalized) ? candidateMap[normalized] : undefined;
if (Array.isArray(candidates)) {
for (const envVar of candidates) {

View File

@@ -175,6 +175,20 @@ describe("loadModelCatalog", () => {
expect(discoverAuthStorage).toHaveBeenCalledWith("/tmp/openclaw", { readOnly: true });
});
it("scopes models.json preparation when catalog provider scope is supplied", async () => {
mockSingleOpenAiCatalogModel();
const cfg = {} as OpenClawConfig;
await loadModelCatalog({
config: cfg,
providerDiscoveryProviderIds: ["openai"],
});
expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledWith(cfg, undefined, {
providerDiscoveryProviderIds: ["openai"],
});
});
it("does not synthesize stale openai-codex/gpt-5.3-codex-spark entries from gpt-5.4", async () => {
mockPiDiscoveryModels([
{

View File

@@ -108,12 +108,19 @@ export async function loadModelCatalog(params?: {
config?: OpenClawConfig;
useCache?: boolean;
readOnly?: boolean;
providerDiscoveryProviderIds?: readonly string[];
}): Promise<ModelCatalogEntry[]> {
const readOnly = params?.readOnly === true;
const providerDiscoveryProviderIds = params?.providerDiscoveryProviderIds
?.map((value) => value.trim())
.filter(Boolean);
const scopedCatalog =
providerDiscoveryProviderIds !== undefined && providerDiscoveryProviderIds.length > 0;
const useSharedCache = !readOnly && !scopedCatalog;
if (!readOnly && params?.useCache === false) {
modelCatalogPromise = null;
}
if (!readOnly && modelCatalogPromise) {
if (useSharedCache && modelCatalogPromise) {
return modelCatalogPromise;
}
@@ -139,7 +146,11 @@ export async function loadModelCatalog(params?: {
try {
const cfg = params?.config ?? getRuntimeConfig();
if (!readOnly) {
await ensureOpenClawModelsJson(cfg);
await ensureOpenClawModelsJson(
cfg,
undefined,
scopedCatalog ? { providerDiscoveryProviderIds } : undefined,
);
logStage("models-json-ready");
}
// IMPORTANT: keep the dynamic import *inside* the try/catch.
@@ -188,6 +199,7 @@ export async function loadModelCatalog(params?: {
const supplemental = await augmentModelCatalogWithProviderPlugins({
config: cfg,
env: process.env,
...(scopedCatalog ? { providerDiscoveryProviderIds } : {}),
context: {
config: cfg,
agentDir,
@@ -208,7 +220,7 @@ export async function loadModelCatalog(params?: {
if (models.length === 0) {
// If we found nothing, don't cache this result so we can try again.
if (!readOnly) {
if (useSharedCache) {
modelCatalogPromise = null;
}
}
@@ -222,7 +234,7 @@ export async function loadModelCatalog(params?: {
log.warn(`Failed to load model catalog: ${String(error)}`);
}
// Don't poison the cache on transient dependency/filesystem issues.
if (!readOnly) {
if (useSharedCache) {
modelCatalogPromise = null;
}
if (models.length > 0) {
@@ -232,7 +244,7 @@ export async function loadModelCatalog(params?: {
}
};
if (readOnly) {
if (!useSharedCache) {
return loadCatalog();
}

View File

@@ -361,12 +361,12 @@ function resolveLiveModelsJsonTimeoutMs(
modelsJsonTimeoutRaw?: string,
setupTimeoutMs = LIVE_SETUP_TIMEOUT_MS,
): number {
return Math.max(setupTimeoutMs, toInt(modelsJsonTimeoutRaw, 120_000));
return Math.max(setupTimeoutMs, toInt(modelsJsonTimeoutRaw, 180_000));
}
describe("resolveLiveModelsJsonTimeoutMs", () => {
it("defaults models.json preparation to a longer setup timeout", () => {
expect(resolveLiveModelsJsonTimeoutMs(undefined, 45_000)).toBe(120_000);
expect(resolveLiveModelsJsonTimeoutMs(undefined, 45_000)).toBe(180_000);
});
it("never goes below the shared live setup timeout", () => {

View File

@@ -18,6 +18,14 @@ import {
installEmbeddedRunnerFastRunE2eMocks,
} from "./test-helpers/pi-embedded-runner-e2e-mocks.js";
type ResolveModelAsyncResult = Awaited<
ReturnType<typeof import("./pi-embedded-runner/model.js").resolveModelAsync>
>;
function asResolveModelAsyncResult(value: unknown): ResolveModelAsyncResult {
return value as ResolveModelAsyncResult;
}
const runEmbeddedAttemptMock = vi.fn();
const disposeSessionMcpRuntimeMock = vi.fn<(sessionId: string) => Promise<void>>(async () => {
return undefined;
@@ -25,7 +33,7 @@ const disposeSessionMcpRuntimeMock = vi.fn<(sessionId: string) => Promise<void>>
const resolveSessionKeyForRequestMock = vi.fn();
const resolveStoredSessionKeyForSessionIdMock = vi.fn();
const resolveModelAsyncMock = vi.fn(async (provider: string, modelId: string) =>
createResolvedEmbeddedRunnerModel(provider, modelId),
asResolveModelAsyncResult(createResolvedEmbeddedRunnerModel(provider, modelId)),
);
const ensureOpenClawModelsJsonMock = vi.fn(async () => ({ wrote: false }));
const loggerWarnMock = vi.fn();
@@ -183,7 +191,7 @@ beforeEach(() => {
resolveStoredSessionKeyForSessionIdMock.mockReset();
resolveModelAsyncMock.mockReset();
resolveModelAsyncMock.mockImplementation(async (provider: string, modelId: string) =>
createResolvedEmbeddedRunnerModel(provider, modelId),
asResolveModelAsyncResult(createResolvedEmbeddedRunnerModel(provider, modelId)),
);
ensureOpenClawModelsJsonMock.mockReset();
ensureOpenClawModelsJsonMock.mockResolvedValue({ wrote: false });
@@ -326,6 +334,49 @@ describe("runEmbeddedPiAgent", () => {
expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled();
});
it("scopes models.json fallback discovery to the requested provider", async () => {
const sessionFile = nextSessionFile();
const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]);
resolveModelAsyncMock
.mockResolvedValueOnce({
model: undefined,
error: "Unknown model: openai/mock-1",
authStorage: {
setRuntimeApiKey: () => undefined,
},
modelRegistry: {},
} as unknown as ResolveModelAsyncResult)
.mockResolvedValueOnce(
asResolveModelAsyncResult(createResolvedEmbeddedRunnerModel("openai", "mock-1")),
);
runEmbeddedAttemptMock.mockResolvedValueOnce(
makeEmbeddedRunnerAttempt({
assistantTexts: ["ok"],
lastAssistant: buildEmbeddedRunnerAssistant({
content: [{ type: "text", text: "ok" }],
}),
}),
);
await runEmbeddedPiAgent({
sessionId: "scoped-model-discovery",
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
runId: nextRunId("scoped-model-discovery"),
enqueue: immediateEnqueue,
});
expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledWith(cfg, agentDir, {
providerDiscoveryProviderIds: ["openai"],
});
});
it("backfills a trimmed session key from sessionId when the embedded run omits it", async () => {
const sessionFile = nextSessionFile();
const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]);

View File

@@ -431,7 +431,9 @@ export async function runEmbeddedPiAgent(
dynamicModelResolution.model || pluginHarnessOwnsTransport
? dynamicModelResolution
: await (async () => {
await ensureOpenClawModelsJson(params.config, agentDir);
await ensureOpenClawModelsJson(params.config, agentDir, {
providerDiscoveryProviderIds: [provider],
});
return await resolveModelAsync(provider, modelId, agentDir, params.config);
})();
const { model, error, authStorage, modelRegistry } = modelResolution;

View File

@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from "vitest";
import { buildAgentRuntimeAuthPlan } from "./auth.js";
const resolveProviderIdForAuth = vi.hoisted(() => vi.fn((provider: string) => provider));
vi.mock("../provider-auth-aliases.js", () => ({
resolveProviderIdForAuth,
}));
describe("buildAgentRuntimeAuthPlan", () => {
it("does not load plugin auth aliases when no profile can be forwarded", () => {
const plan = buildAgentRuntimeAuthPlan({
provider: "OpenAI",
config: {},
workspaceDir: "/tmp/openclaw-runtime-plan",
});
expect(plan).toEqual({
providerForAuth: "openai",
authProfileProviderForAuth: "openai",
});
expect(resolveProviderIdForAuth).not.toHaveBeenCalled();
});
it("uses plugin auth aliases when profile providers differ", () => {
resolveProviderIdForAuth.mockImplementation((provider: string) =>
provider === "codex-cli" ? "openai-codex" : provider,
);
const plan = buildAgentRuntimeAuthPlan({
provider: "openai",
authProfileProvider: "codex-cli",
sessionAuthProfileId: "codex-cli:default",
config: {},
workspaceDir: "/tmp/openclaw-runtime-plan",
});
expect(plan).toMatchObject({
providerForAuth: "openai",
authProfileProviderForAuth: "openai-codex",
});
expect(resolveProviderIdForAuth).toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,7 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { normalizeEmbeddedAgentRuntime } from "../pi-embedded-runner/runtime.js";
import { resolveProviderIdForAuth } from "../provider-auth-aliases.js";
import { normalizeProviderId } from "../provider-id.js";
import type { AgentRuntimeAuthPlan } from "./types.js";
const CODEX_HARNESS_AUTH_PROVIDER = "openai-codex";
@@ -24,16 +25,29 @@ export function buildAgentRuntimeAuthPlan(params: {
harnessRuntime?: string;
allowHarnessAuthProfileForwarding?: boolean;
}): AgentRuntimeAuthPlan {
const normalizedProvider = normalizeProviderId(params.provider);
const normalizedAuthProfileProvider = normalizeProviderId(
params.authProfileProvider ?? params.provider,
);
const aliasLookupParams = {
config: params.config,
workspaceDir: params.workspaceDir,
};
const providerForAuth = resolveProviderIdForAuth(params.provider, aliasLookupParams);
const authProfileProviderForAuth = resolveProviderIdForAuth(
params.authProfileProvider ?? params.provider,
aliasLookupParams,
);
const harnessAuthProvider = resolveHarnessAuthProvider(params);
if (!params.sessionAuthProfileId && !harnessAuthProvider) {
return {
providerForAuth: normalizedProvider,
authProfileProviderForAuth: normalizedAuthProfileProvider,
};
}
const providerForAuth =
normalizedProvider === normalizedAuthProfileProvider
? normalizedProvider
: resolveProviderIdForAuth(params.provider, aliasLookupParams);
const authProfileProviderForAuth =
normalizedProvider === normalizedAuthProfileProvider
? normalizedAuthProfileProvider
: resolveProviderIdForAuth(params.authProfileProvider ?? params.provider, aliasLookupParams);
const harnessProviderForAuth = harnessAuthProvider
? resolveProviderIdForAuth(harnessAuthProvider, aliasLookupParams)
: undefined;

View File

@@ -1,84 +1,75 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const hoisted = vi.hoisted(() => ({
resolveRuntimePluginRegistry: vi.fn(),
getActivePluginRuntimeSubagentMode: vi.fn<() => "default" | "explicit" | "gateway-bindable">(
() => "default",
),
const mocks = vi.hoisted(() => ({
getActivePluginRuntimeSubagentMode: vi.fn(() => "default"),
resolveGatewayStartupPluginIds: vi.fn((_params: unknown) => ["active-memory", "telegram"]),
resolveRuntimePluginRegistry: vi.fn((_params: unknown) => undefined),
}));
vi.mock("../plugins/loader.js", () => ({
resolveRuntimePluginRegistry: hoisted.resolveRuntimePluginRegistry,
resolveRuntimePluginRegistry: (params: unknown) => mocks.resolveRuntimePluginRegistry(params),
}));
vi.mock("../plugins/gateway-startup-plugin-ids.js", () => ({
resolveGatewayStartupPluginIds: (params: unknown) => mocks.resolveGatewayStartupPluginIds(params),
}));
vi.mock("../plugins/runtime.js", () => ({
getActivePluginRuntimeSubagentMode: hoisted.getActivePluginRuntimeSubagentMode,
getActivePluginRuntimeSubagentMode: () => mocks.getActivePluginRuntimeSubagentMode(),
}));
import { ensureRuntimePluginsLoaded } from "./runtime-plugins.js";
describe("ensureRuntimePluginsLoaded", () => {
let ensureRuntimePluginsLoaded: typeof import("./runtime-plugins.js").ensureRuntimePluginsLoaded;
beforeEach(async () => {
hoisted.resolveRuntimePluginRegistry.mockReset();
hoisted.resolveRuntimePluginRegistry.mockReturnValue(undefined);
hoisted.getActivePluginRuntimeSubagentMode.mockReset();
hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("default");
vi.resetModules();
({ ensureRuntimePluginsLoaded } = await import("./runtime-plugins.js"));
beforeEach(() => {
vi.clearAllMocks();
mocks.getActivePluginRuntimeSubagentMode.mockReturnValue("default");
mocks.resolveGatewayStartupPluginIds.mockReturnValue(["active-memory", "telegram"]);
});
it("does not reactivate plugins when a process already has an active registry", async () => {
hoisted.resolveRuntimePluginRegistry.mockReturnValue({});
it("loads only startup-scoped plugins for configured local agent runs", () => {
const config = { plugins: { allow: ["telegram"] } };
ensureRuntimePluginsLoaded({
config: {} as never,
workspaceDir: "/tmp/workspace",
allowGatewaySubagentBinding: true,
config,
workspaceDir: "/workspace",
});
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledTimes(1);
});
it("resolves runtime plugins through the shared runtime helper", async () => {
ensureRuntimePluginsLoaded({
config: {} as never,
workspaceDir: "/tmp/workspace",
allowGatewaySubagentBinding: true,
expect(mocks.resolveGatewayStartupPluginIds).toHaveBeenCalledWith({
config,
workspaceDir: "/workspace",
env: process.env,
});
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: {} as never,
workspaceDir: "/tmp/workspace",
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
});
});
it("does not enable gateway subagent binding for normal runtime loads", async () => {
ensureRuntimePluginsLoaded({
config: {} as never,
workspaceDir: "/tmp/workspace",
});
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: {} as never,
workspaceDir: "/tmp/workspace",
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config,
workspaceDir: "/workspace",
onlyPluginIds: ["active-memory", "telegram"],
runtimeOptions: undefined,
});
});
it("inherits gateway-bindable mode from an active gateway registry", async () => {
hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("gateway-bindable");
it("preserves unscoped loading for callers without config", () => {
ensureRuntimePluginsLoaded({});
expect(mocks.resolveGatewayStartupPluginIds).not.toHaveBeenCalled();
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: undefined,
workspaceDir: undefined,
runtimeOptions: undefined,
});
});
it("keeps gateway subagent binding on scoped loads", () => {
ensureRuntimePluginsLoaded({
config: {} as never,
workspaceDir: "/tmp/workspace",
config: {},
workspaceDir: "/workspace",
allowGatewaySubagentBinding: true,
});
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: {} as never,
workspaceDir: "/tmp/workspace",
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: {},
workspaceDir: "/workspace",
onlyPluginIds: ["active-memory", "telegram"],
runtimeOptions: {
allowGatewaySubagentBinding: true,
},

View File

@@ -1,8 +1,23 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveGatewayStartupPluginIds } from "../plugins/gateway-startup-plugin-ids.js";
import { resolveRuntimePluginRegistry } from "../plugins/loader.js";
import { getActivePluginRuntimeSubagentMode } from "../plugins/runtime.js";
import { resolveUserPath } from "../utils.js";
function resolveRuntimePluginIds(params: {
config?: OpenClawConfig;
workspaceDir?: string;
}): readonly string[] | undefined {
if (!params.config) {
return undefined;
}
return resolveGatewayStartupPluginIds({
config: params.config,
workspaceDir: params.workspaceDir,
env: process.env,
});
}
export function ensureRuntimePluginsLoaded(params: {
config?: OpenClawConfig;
workspaceDir?: string | null;
@@ -18,6 +33,16 @@ export function ensureRuntimePluginsLoaded(params: {
const loadOptions = {
config: params.config,
workspaceDir,
...(params.config
? {
onlyPluginIds: [
...(resolveRuntimePluginIds({
config: params.config,
workspaceDir,
}) ?? []),
],
}
: {}),
runtimeOptions: allowGatewaySubagentBinding
? {
allowGatewaySubagentBinding: true,

View File

@@ -377,7 +377,7 @@ describeLive("tool replay repair live", () => {
expect(response.stopReason).not.toBe("error");
if (text.length > 0) {
expect(text).toMatch(/^transport replay ok\.?$/i);
expect(text).toMatch(/^transport replay(?: ok)?\.?$/i);
}
},
3 * 60 * 1000,

View File

@@ -11,6 +11,7 @@ import { createWebSearchTool } from "./tools/web-search.js";
const XAI_KEY = process.env.XAI_API_KEY ?? "";
const LIVE = isLiveTestEnabled(["XAI_LIVE_TEST"]);
const XAI_WEB_SEARCH_LIVE_TIMEOUT_SECONDS = 60;
const describeLive = LIVE && XAI_KEY ? describe : describe.skip;
@@ -133,6 +134,7 @@ describeLive("xai live", () => {
web: {
search: {
provider: "grok",
timeoutSeconds: XAI_WEB_SEARCH_LIVE_TIMEOUT_SECONDS,
grok: {
model: "grok-4-1-fast",
},
@@ -165,5 +167,5 @@ describeLive("xai live", () => {
(Array.isArray(details.citations) ? details.citations.length : 0) +
(Array.isArray(details.inlineCitations) ? details.inlineCitations.length : 0);
expect(citationCount).toBeGreaterThan(0);
}, 45_000);
}, 90_000);
});

View File

@@ -48,7 +48,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
{
commandPath: ["agent"],
policy: {
loadPlugins: "always",
loadPlugins: "text-only",
networkProxy: ({ argv }) => (hasFlag(argv, "--local") ? "default" : "bypass"),
},
},

View File

@@ -67,6 +67,15 @@ describe("command-path-policy", () => {
});
it("keeps config-only agent commands on config-only startup", () => {
expect(resolveCliCommandPathPolicy(["agent"])).toEqual({
bypassConfigGuard: false,
routeConfigGuard: "never",
loadPlugins: "text-only",
hideBanner: false,
ensureCliPath: true,
networkProxy: expect.any(Function),
});
for (const commandPath of [
["agents", "bind"],
["agents", "bindings"],

View File

@@ -145,6 +145,10 @@ describe("registerPreActionHooks", () => {
program.command("onboard").action(() => {});
const channels = program.command("channels");
channels.command("add").action(() => {});
channels
.command("send")
.option("--json")
.action(() => {});
program
.command("plugins")
.command("install")
@@ -229,7 +233,7 @@ describe("registerPreActionHooks", () => {
processTitleSetSpy.mockRestore();
});
it("loads plugins for local agent runs", async () => {
it("loads plugins for text local agent runs", async () => {
await runPreAction({
parseArgv: ["agent"],
processArgv: ["node", "openclaw", "agent", "--local", "--message", "hi"],
@@ -242,6 +246,20 @@ describe("registerPreActionHooks", () => {
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "all" });
});
it("skips broad plugin preload for json local agent runs", async () => {
await runPreAction({
parseArgv: ["agent"],
processArgv: ["node", "openclaw", "agent", "--local", "--message", "hi", "--json"],
});
expect(ensureConfigReadyMock).toHaveBeenCalledWith({
runtime: runtimeMock,
commandPath: ["agent", "hi"],
suppressDoctorStdout: true,
});
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("keeps setup alias and channels add manifest-first", async () => {
await runPreAction({
parseArgv: ["onboard"],
@@ -394,8 +412,8 @@ describe("registerPreActionHooks", () => {
it("routes logs to stderr in --json mode so stdout stays clean", async () => {
await runPreAction({
parseArgv: ["agent"],
processArgv: ["node", "openclaw", "agent", "--message", "hi", "--json"],
parseArgv: ["channels", "send"],
processArgv: ["node", "openclaw", "channels", "send", "--json"],
});
expect(routeLogsToStderrMock).toHaveBeenCalledOnce();
@@ -474,8 +492,8 @@ describe("registerPreActionHooks", () => {
});
await runPreAction({
parseArgv: ["agent"],
processArgv: ["node", "openclaw", "agent", "--message", "hi", "--json"],
parseArgv: ["channels", "send"],
processArgv: ["node", "openclaw", "channels", "send", "--json"],
});
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalled();

View File

@@ -591,6 +591,7 @@ describe("update-cli", () => {
expect.objectContaining({
stdio: "inherit",
env: expect.objectContaining({
NODE_DISABLE_COMPILE_CACHE: "1",
OPENCLAW_UPDATE_POST_CORE: "1",
OPENCLAW_UPDATE_POST_CORE_CHANNEL: "dev",
}),
@@ -601,6 +602,36 @@ describe("update-cli", () => {
expect(runDaemonRestart).not.toHaveBeenCalled();
});
it("respawns into the updated git root before requested channel persistence", async () => {
const { entrypoints } = setupUpdatedRootRefresh({
gatewayUpdateImpl: async (root) =>
makeOkUpdateResult({
mode: "git",
root,
before: { sha: "old-sha", version: "2026.4.26" },
after: { sha: "new-sha", version: "2026.4.27" },
}),
});
await updateCommand({ channel: "dev", yes: true, restart: false });
expect(spawn).toHaveBeenCalledWith(
expect.stringMatching(/node/),
[entrypoints[0], "update", "--no-restart", "--yes"],
expect.objectContaining({
stdio: "inherit",
env: expect.objectContaining({
OPENCLAW_UPDATE_POST_CORE: "1",
OPENCLAW_UPDATE_POST_CORE_CHANNEL: "dev",
OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL: "dev",
}),
}),
);
expect(replaceConfigFile).not.toHaveBeenCalled();
expect(syncPluginsForUpdateChannel).not.toHaveBeenCalled();
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
});
it("keeps downgrade post-update work in the current process", async () => {
const downgradedRoot = createCaseDir("openclaw-downgraded-root");
setupUpdatedRootRefresh({
@@ -685,6 +716,47 @@ describe("update-cli", () => {
expect(spawn).not.toHaveBeenCalled();
});
it("post-core resume mode persists the requested update channel with the updated process", async () => {
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
...baseSnapshot,
parsed: { update: { channel: "stable" } },
resolved: { update: { channel: "stable" } } as OpenClawConfig,
sourceConfig: { update: { channel: "stable" } } as OpenClawConfig,
runtimeConfig: { update: { channel: "stable" } } as OpenClawConfig,
config: { update: { channel: "stable" } } as OpenClawConfig,
hash: "stable-hash",
});
await withEnvAsync(
{
OPENCLAW_UPDATE_POST_CORE: "1",
OPENCLAW_UPDATE_POST_CORE_CHANNEL: "dev",
OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL: "dev",
},
async () => {
await updateCommand({ restart: false });
},
);
expect(runGatewayUpdate).not.toHaveBeenCalled();
expect(replaceConfigFile).toHaveBeenCalledWith({
nextConfig: {
update: {
channel: "dev",
},
},
baseHash: "stable-hash",
});
expect(syncPluginsForUpdateChannel).toHaveBeenCalledWith(
expect.objectContaining({
channel: "dev",
config: expect.objectContaining({
update: expect.objectContaining({ channel: "dev" }),
}),
}),
);
});
it("passes the update timeout budget into post-core plugin updates", async () => {
await withEnvAsync(
{
@@ -1537,7 +1609,18 @@ describe("update-cli", () => {
[expect.stringMatching(/node/), entryPath, "doctor", "--non-interactive", "--fix"],
expect.any(Object),
);
expect(updateNpmInstalledPlugins).toHaveBeenCalled();
expect(spawn).toHaveBeenCalledWith(
expect.stringMatching(/node/),
[entryPath, "update", "--no-restart", "--yes"],
expect.objectContaining({
stdio: "inherit",
env: expect.objectContaining({
OPENCLAW_UPDATE_POST_CORE: "1",
OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
}),
}),
);
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
expect(
vi
.mocked(defaultRuntime.log)
@@ -1934,10 +2017,14 @@ describe("update-cli", () => {
const syncConfig = vi.mocked(syncPluginsForUpdateChannel).mock.calls[0]?.[0]?.config as
| OpenClawConfig
| undefined;
const updateCall = vi.mocked(updateNpmInstalledPlugins).mock.calls[0]?.[0] as
| { skipDisabledPlugins?: boolean }
| undefined;
expect(syncConfig?.plugins?.installs).toEqual(pluginInstallRecords);
expect(syncConfig?.update?.channel).toBe("beta");
expect(syncConfig?.gateway?.auth).toBeUndefined();
expect(syncConfig?.plugins?.entries).toBeUndefined();
expect(updateCall?.skipDisabledPlugins).toBe(true);
});
it("persists channel and runs post-update work after switching from package to git", async () => {

View File

@@ -54,7 +54,6 @@ import { defaultRuntime } from "../../runtime.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { stylePromptMessage } from "../../terminal/prompt-style.js";
import { theme } from "../../terminal/theme.js";
import { pathExists } from "../../utils.js";
import { replaceCliName, resolveCliName } from "../cli-name.js";
import { formatCliCommand } from "../command-format.js";
import { installCompletion } from "../completion-runtime.js";
@@ -93,7 +92,10 @@ const SERVICE_REFRESH_TIMEOUT_MS = 60_000;
const DEFAULT_UPDATE_STEP_TIMEOUT_MS = 30 * 60_000;
const POST_CORE_UPDATE_ENV = "OPENCLAW_UPDATE_POST_CORE";
const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL";
const POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL";
const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH";
const UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV =
"OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE";
const SERVICE_REFRESH_PATH_ENV_KEYS = [
"OPENCLAW_HOME",
"OPENCLAW_STATE_DIR",
@@ -302,6 +304,20 @@ function resolveServiceRefreshEnv(
return resolvedEnv;
}
function disableUpdatedPackageCompileCacheEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return {
...env,
NODE_DISABLE_COMPILE_CACHE: "1",
};
}
function resolveUpdatedInstallCommandEnv(
env: NodeJS.ProcessEnv,
invocationCwd?: string,
): NodeJS.ProcessEnv {
return disableUpdatedPackageCompileCacheEnv(resolveServiceRefreshEnv(env, invocationCwd));
}
type UpdateDryRunPreview = {
dryRun: true;
root: string;
@@ -376,7 +392,7 @@ async function refreshGatewayServiceEnv(params: {
if (entrypoint) {
const res = await runCommandWithTimeout([resolveNodeRunner(), entrypoint, ...args], {
cwd: params.result.root,
env: resolveServiceRefreshEnv(params.env ?? process.env, params.invocationCwd),
env: resolveUpdatedInstallCommandEnv(params.env ?? process.env, params.invocationCwd),
timeoutMs: SERVICE_REFRESH_TIMEOUT_MS,
});
if (res.code === 0) {
@@ -415,7 +431,7 @@ async function runUpdatedInstallGatewayRestart(params: {
}
const res = await runCommandWithTimeout([resolveNodeRunner(), entrypoint, ...args], {
cwd: params.result.root,
env: resolveServiceRefreshEnv(params.env ?? process.env, params.invocationCwd),
env: resolveUpdatedInstallCommandEnv(params.env ?? process.env, params.invocationCwd),
timeoutMs: SERVICE_REFRESH_TIMEOUT_MS,
});
if (res.code === 0) {
@@ -553,8 +569,9 @@ async function runPackageInstallUpdate(params: {
name: `${CLI_NAME} doctor`,
argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive", "--fix"],
env: {
...process.env,
...disableUpdatedPackageCompileCacheEnv(process.env),
OPENCLAW_UPDATE_IN_PROGRESS: "1",
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
},
timeoutMs: params.timeoutMs,
progress: params.progress,
@@ -728,6 +745,7 @@ async function updatePluginsAfterCoreUpdate(params: {
config: pluginConfig,
timeoutMs: params.timeoutMs,
skipIds: new Set(syncResult.summary.switchedToNpm),
skipDisabledPlugins: true,
logger: pluginLogger,
onIntegrityDrift: async (drift) => {
integrityDrifts.push({
@@ -1015,6 +1033,7 @@ async function maybeRestartService(params: {
defaultRuntime.log(theme.success("Daemon restarted successfully."));
defaultRuntime.log("");
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] = "1";
try {
const interactiveDoctor =
process.stdin.isTTY && !params.opts.json && params.opts.yes !== true;
@@ -1025,6 +1044,7 @@ async function maybeRestartService(params: {
defaultRuntime.log(theme.warn(`Doctor failed: ${String(err)}`));
} finally {
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
delete process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV];
}
}
} catch (err) {
@@ -1078,6 +1098,40 @@ async function runPostCorePluginUpdate(params: {
});
}
async function persistRequestedUpdateChannel(params: {
configSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
requestedChannel: "stable" | "beta" | "dev" | null;
}): Promise<Awaited<ReturnType<typeof readConfigFileSnapshot>>> {
if (!params.requestedChannel || !params.configSnapshot.valid) {
return params.configSnapshot;
}
const storedChannel = normalizeUpdateChannel(params.configSnapshot.config.update?.channel);
if (params.requestedChannel === storedChannel) {
return params.configSnapshot;
}
const next = {
...params.configSnapshot.sourceConfig,
update: {
...params.configSnapshot.sourceConfig.update,
channel: params.requestedChannel,
},
};
await replaceConfigFile({
nextConfig: next,
baseHash: params.configSnapshot.hash,
});
return {
...params.configSnapshot,
hash: undefined,
parsed: next,
sourceConfig: asResolvedSourceConfig(next),
resolved: asResolvedSourceConfig(next),
runtimeConfig: asRuntimeConfig(next),
config: asRuntimeConfig(next),
};
}
async function writePostCorePluginUpdateResultFile(
filePath: string | undefined,
result: PostCorePluginUpdateResult,
@@ -1110,10 +1164,11 @@ async function readPostCorePluginUpdateResultFile(
async function continuePostCoreUpdateInFreshProcess(params: {
root: string;
channel: "stable" | "beta" | "dev";
requestedChannel: "stable" | "beta" | "dev" | null;
opts: UpdateCommandOptions;
}): Promise<{ resumed: boolean; pluginUpdate?: PostCorePluginUpdateResult }> {
const entryPath = path.join(params.root, "dist", "entry.js");
if (!(await pathExists(entryPath))) {
const entryPath = await resolveGatewayInstallEntrypoint(params.root);
if (!entryPath) {
return { resumed: false };
}
@@ -1140,9 +1195,12 @@ async function continuePostCoreUpdateInFreshProcess(params: {
const child = spawn(resolveNodeRunner(), argv, {
stdio: "inherit",
env: {
...process.env,
...disableUpdatedPackageCompileCacheEnv(process.env),
[POST_CORE_UPDATE_ENV]: "1",
[POST_CORE_UPDATE_CHANNEL_ENV]: params.channel,
...(params.requestedChannel
? { [POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]: params.requestedChannel }
: {}),
...(resultPath ? { [POST_CORE_UPDATE_RESULT_PATH_ENV]: resultPath } : {}),
},
});
@@ -1180,7 +1238,23 @@ function shouldResumePostCoreUpdateInFreshProcess(params: {
result: UpdateRunResult;
downgradeRisk: boolean;
}): boolean {
return isPackageManagerUpdateMode(params.result.mode) && !params.downgradeRisk;
if (params.downgradeRisk) {
return false;
}
if (isPackageManagerUpdateMode(params.result.mode)) {
return true;
}
if (params.result.mode !== "git") {
return false;
}
const beforeSha = normalizeOptionalString(params.result.before?.sha);
const afterSha = normalizeOptionalString(params.result.after?.sha);
if (beforeSha && afterSha && beforeSha !== afterSha) {
return true;
}
const beforeVersion = normalizeOptionalString(params.result.before?.version);
const afterVersion = normalizeOptionalString(params.result.after?.version);
return Boolean(beforeVersion && afterVersion && beforeVersion !== afterVersion);
}
export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
@@ -1188,6 +1262,8 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
const invocationCwd = tryResolveInvocationCwd();
const postCoreUpdateResume = process.env[POST_CORE_UPDATE_ENV] === "1";
const postCoreUpdateChannel = process.env[POST_CORE_UPDATE_CHANNEL_ENV]?.trim();
const postCoreRequestedChannelInput =
process.env[POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]?.trim() ?? "";
const timeoutMs = parseTimeoutMsOrExit(opts.timeout);
const shouldRestart = opts.restart !== false;
@@ -1208,10 +1284,24 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
return;
}
const postCoreRequestedChannel = postCoreRequestedChannelInput
? normalizeUpdateChannel(postCoreRequestedChannelInput)
: null;
if (postCoreRequestedChannelInput && !postCoreRequestedChannel) {
defaultRuntime.error("Invalid post-core requested update channel context.");
defaultRuntime.exit(1);
return;
}
const postCoreConfigSnapshot = await persistRequestedUpdateChannel({
configSnapshot: await readConfigFileSnapshot(),
requestedChannel: postCoreRequestedChannel,
});
const pluginUpdate = await runPostCorePluginUpdate({
root,
channel: postCoreUpdateChannel,
configSnapshot: await readConfigFileSnapshot(),
configSnapshot: postCoreConfigSnapshot,
opts,
timeoutMs: updateStepTimeoutMs,
});
@@ -1552,46 +1642,45 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
return;
}
const shouldResumePostCoreInFreshProcess = shouldResumePostCoreUpdateInFreshProcess({
result,
downgradeRisk,
});
let postUpdateConfigSnapshot = configSnapshot;
if (requestedChannel && configSnapshot.valid && requestedChannel !== storedChannel) {
const next = {
...configSnapshot.sourceConfig,
update: {
...configSnapshot.sourceConfig.update,
channel: requestedChannel,
},
};
await replaceConfigFile({
nextConfig: next,
baseHash: configSnapshot.hash,
if (!shouldResumePostCoreInFreshProcess) {
postUpdateConfigSnapshot = await persistRequestedUpdateChannel({
configSnapshot,
requestedChannel,
});
postUpdateConfigSnapshot = {
...configSnapshot,
hash: undefined,
parsed: next,
sourceConfig: asResolvedSourceConfig(next),
resolved: asResolvedSourceConfig(next),
runtimeConfig: asRuntimeConfig(next),
config: asRuntimeConfig(next),
};
if (!opts.json) {
defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`));
}
}
if (
requestedChannel &&
configSnapshot.valid &&
requestedChannel !== storedChannel &&
!shouldResumePostCoreInFreshProcess &&
!opts.json
) {
defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`));
} else if (
requestedChannel &&
configSnapshot.valid &&
requestedChannel !== storedChannel &&
shouldResumePostCoreInFreshProcess &&
!opts.json
) {
defaultRuntime.log(theme.muted(`Update channel will be set to ${requestedChannel}.`));
}
const postUpdateRoot = result.root ?? root;
let postCorePluginUpdate: PostCorePluginUpdateResult | undefined;
let pluginsUpdatedInFreshProcess = false;
if (
shouldResumePostCoreUpdateInFreshProcess({
result,
downgradeRisk,
})
) {
if (shouldResumePostCoreInFreshProcess) {
const freshProcessResult = await continuePostCoreUpdateInFreshProcess({
root: postUpdateRoot,
channel,
requestedChannel,
opts,
});
pluginsUpdatedInFreshProcess = freshProcessResult.resumed;
@@ -1599,6 +1688,12 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
}
if (!pluginsUpdatedInFreshProcess) {
if (shouldResumePostCoreInFreshProcess) {
postUpdateConfigSnapshot = await persistRequestedUpdateChannel({
configSnapshot,
requestedChannel,
});
}
postCorePluginUpdate = await runPostCorePluginUpdate({
root: postUpdateRoot,
channel,

View File

@@ -613,6 +613,11 @@ describe("agentCommand", () => {
{ provider: "anthropic", model: "claude-opus-4-6" },
{ provider: "openai", model: "gpt-5.4" },
]);
expect(loadModelCatalog).toHaveBeenCalledWith(
expect.objectContaining({
providerDiscoveryProviderIds: ["anthropic", "openai"],
}),
);
});
});

View File

@@ -595,12 +595,16 @@ export async function noteStateIntegrity(
cfg: OpenClawConfig,
prompter: DoctorPrompterLike,
configPath?: string,
params: {
env?: NodeJS.ProcessEnv;
homedir?: () => string;
} = {},
) {
const warnings: string[] = [];
const changes: string[] = [];
const noteFn = prompter.note ?? note;
const env = process.env;
const homedir = () => resolveRequiredHomeDir(env, os.homedir);
const env = params.env ?? process.env;
const homedir = () => resolveRequiredHomeDir(env, params.homedir ?? os.homedir);
const stateDir = resolveStateDir(env, homedir);
const defaultStateDir = path.join(homedir(), ".openclaw");
const oauthDir = resolveOAuthDir(env, stateDir);

View File

@@ -112,9 +112,29 @@ describe("doctor command", () => {
},
]);
await doctorCommand(createDoctorRuntime(), { yes: true });
const previousConfigWriteSupport =
process.env.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE;
process.env.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE = "1";
try {
await doctorCommand(createDoctorRuntime(), { yes: true });
} finally {
if (previousConfigWriteSupport === undefined) {
delete process.env.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE;
} else {
process.env.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE =
previousConfigWriteSupport;
}
}
const written = writeConfigFile.mock.calls.at(-1)?.[0] as Record<string, unknown>;
const written = writeConfigFile.mock.calls
.map((call) => call[0] as Record<string, unknown>)
.find((candidate) => {
const auth = candidate.auth as { profiles?: unknown } | undefined;
return Boolean(auth?.profiles);
});
if (!written) {
throw new Error("Expected doctor to write migrated auth profiles");
}
const profiles = (written.auth as { profiles: Record<string, unknown> }).profiles;
expect(profiles["anthropic:me@example.com"]).toBeTruthy();
expect(profiles["anthropic:default"]).toBeUndefined();

View File

@@ -94,6 +94,9 @@ describe("doctor command", () => {
const missingDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-missing-state-"));
fs.rmSync(missingDir, { recursive: true, force: true });
process.env.OPENCLAW_STATE_DIR = missingDir;
doctorCommand = await loadDoctorCommandForTest({
unmockModules: ["../flows/doctor-health-contributions.js", "./doctor-state-integrity.js"],
});
await doctorCommand(createDoctorRuntime(), {
nonInteractive: true,
workspaceSuggestions: false,

View File

@@ -153,12 +153,17 @@ export function upsertCanonicalModelConfigEntry(
) {
const key = modelKey(params.provider, params.model);
const legacyKey = legacyModelKey(params.provider, params.model);
if (!models[key]) {
if (legacyKey && models[legacyKey]) {
models[key] = models[legacyKey];
} else {
models[key] = {};
}
if (legacyKey && models[legacyKey]) {
models[key] = {
...models[legacyKey],
...models[key],
params: {
...models[legacyKey].params,
...models[key]?.params,
},
};
} else if (!models[key]) {
models[key] = {};
}
if (legacyKey) {
delete models[legacyKey];

View File

@@ -28868,6 +28868,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
tags: ["advanced", "url-secret"],
},
},
version: "2026.4.27",
version: "2026.4.27-beta.1",
generatedAt: "2026-03-22T21:17:33.302Z",
};

View File

@@ -74,6 +74,20 @@ describe("Dockerfile", () => {
);
});
it("copies postinstall helper imports before pnpm install", async () => {
const dockerfile = await readFile(dockerfilePath, "utf8");
const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile");
const postinstallIndex = dockerfile.indexOf("COPY scripts/postinstall-bundled-plugins.mjs");
const distImportHelperIndex = dockerfile.indexOf(
"COPY scripts/lib/package-dist-imports.mjs ./scripts/lib/package-dist-imports.mjs",
);
expect(postinstallIndex).toBeGreaterThan(-1);
expect(distImportHelperIndex).toBeGreaterThan(-1);
expect(postinstallIndex).toBeLessThan(installIndex);
expect(distImportHelperIndex).toBeLessThan(installIndex);
});
it("prunes runtime dependencies after the build stage", async () => {
const dockerfile = await readFile(dockerfilePath, "utf8");
expect(dockerfile).toContain("FROM build AS runtime-assets");

View File

@@ -5,6 +5,7 @@ import { cleanupTempDirs, makeTempDir } from "../test/helpers/temp-dir.js";
import {
buildOpenClawCompileCacheRespawnPlan,
isSourceCheckoutInstallRoot,
resolveOpenClawCompileCacheDirectory,
resolveEntryInstallRoot,
shouldEnableOpenClawCompileCache,
} from "./entry.compile-cache.js";
@@ -54,6 +55,21 @@ describe("entry compile cache", () => {
).toBe(false);
});
it("scopes packaged compile cache by package install metadata", async () => {
const root = makeTempDir(tempDirs, "openclaw-compile-cache-package-key-");
const packageJsonPath = path.join(root, "package.json");
await fs.writeFile(packageJsonPath, '{"version":"2026.4.27-beta.1"}\n', "utf8");
const directory = resolveOpenClawCompileCacheDirectory({
env: { NODE_COMPILE_CACHE: path.join(root, ".node-cache") },
installRoot: root,
});
expect(directory).toContain(path.join(".node-cache", "openclaw"));
expect(directory).toContain("2026.4.27-beta.1");
expect(path.basename(directory)).toMatch(/^\d+-\d+$/);
});
it("builds a one-shot no-cache respawn plan when source checkout inherits NODE_COMPILE_CACHE", async () => {
const root = makeTempDir(tempDirs, "openclaw-compile-cache-respawn-");
await fs.mkdir(path.join(root, "src"), { recursive: true });

View File

@@ -1,6 +1,7 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { existsSync, readFileSync, statSync } from "node:fs";
import { enableCompileCache, getCompileCacheDir } from "node:module";
import os from "node:os";
import path from "node:path";
export function resolveEntryInstallRoot(entryFile: string): string {
@@ -34,6 +35,55 @@ export function shouldEnableOpenClawCompileCache(params: {
return !isSourceCheckoutInstallRoot(params.installRoot);
}
function sanitizeCompileCachePathSegment(value: string): string {
const normalized = value.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
return normalized.length > 0 ? normalized : "unknown";
}
function readPackageVersion(packageJsonPath: string): string {
try {
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as unknown;
if (
parsed &&
typeof parsed === "object" &&
"version" in parsed &&
typeof parsed.version === "string" &&
parsed.version.trim().length > 0
) {
return parsed.version;
}
} catch {
// Fall through to an install-metadata-only cache key.
}
return "unknown";
}
export function resolveOpenClawCompileCacheDirectory(params: {
env?: NodeJS.ProcessEnv;
installRoot: string;
}): string {
const env = params.env ?? process.env;
const packageJsonPath = path.join(params.installRoot, "package.json");
const version = sanitizeCompileCachePathSegment(readPackageVersion(packageJsonPath));
let installMarker = "no-package-json";
try {
const stat = statSync(packageJsonPath);
installMarker = `${Math.trunc(stat.mtimeMs)}-${stat.size}`;
} catch {
// Package archives should always have package.json, but keep startup best-effort.
}
const baseDirectory =
env.NODE_COMPILE_CACHE && !isNodeCompileCacheDisabled(env)
? env.NODE_COMPILE_CACHE
: path.join(os.tmpdir(), "node-compile-cache");
return path.join(
baseDirectory,
"openclaw",
version,
sanitizeCompileCachePathSegment(installMarker),
);
}
export type OpenClawCompileCacheRespawnPlan = {
command: string;
args: string[];
@@ -107,7 +157,7 @@ export function enableOpenClawCompileCache(params: {
return;
}
try {
enableCompileCache();
enableCompileCache(resolveOpenClawCompileCacheDirectory(params));
} catch {
// Best-effort only; never block startup.
}

View File

@@ -7,26 +7,19 @@ import {
resolveCliRespawnCommand,
} from "./entry.respawn.js";
const shouldSkipRespawnForArgvMock = vi.hoisted(() => vi.fn(() => false));
const isTruthyEnvValueMock = vi.hoisted(() =>
vi.fn((value: string | undefined) => value === "1" || value === "true"),
);
vi.mock("./cli/respawn-policy.js", () => ({
shouldSkipRespawnForArgv: shouldSkipRespawnForArgvMock,
}));
vi.mock("./infra/env.js", () => ({
isTruthyEnvValue: isTruthyEnvValueMock,
}));
describe("buildCliRespawnPlan", () => {
it("returns null when respawn policy skips the argv", () => {
shouldSkipRespawnForArgvMock.mockReturnValueOnce(true);
expect(
buildCliRespawnPlan({
argv: ["node", "openclaw", "status"],
argv: ["node", "openclaw", "--help"],
env: {},
execArgv: [],
autoNodeExtraCaCerts: "/etc/ssl/certs/ca-certificates.crt",

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