Compare commits

...

23 Commits

Author SHA1 Message Date
Peter Steinberger
325df3efef chore(release): bump to 2026.5.4 2026-05-05 08:37:19 +01:00
Peter Steinberger
2fc80754cf ci: parallelize release publish workflows 2026-05-05 07:35:28 +01:00
Peter Steinberger
41f028e2ea fix(diagnostics): drop stale session recovery event cases 2026-05-05 06:06:02 +01:00
Peter Steinberger
303ff716d4 chore(release): refresh plugin SDK API baseline 2026-05-05 05:56:41 +01:00
Peter Steinberger
5fcdeae80c chore(release): bump to 2026.5.4-beta.3 2026-05-05 05:51:46 +01:00
6607changchun
b73317c217 fix(sandbox): support Windows drive-letter bind sources
Accept drive-absolute Windows sandbox Docker bind sources in config and runtime validation while keeping blocked-path and allowed-root comparisons case-insensitive for Windows drive paths.

Also remove a stale WhatsApp setup import that blocked extension lint after the rebase.

Co-authored-by: 6607changchun <84566142+6607changchun@users.noreply.github.com>
Co-authored-by: Brad Groux <3053586+BradGroux@users.noreply.github.com>
(cherry picked from commit d02fbc6116)
2026-05-05 05:45:56 +01:00
兰之
8f6bf65162 fix(agents): enforce exact skill path from <available_skills> [AI-assisted] (#74161)
Summary:
- The PR updates agents skill prompt guidance to require exact `<location>` paths for single- and multi-skill selection, adds prompt assertions, and records the fix in the changelog.
- Reproducibility: yes. Static source reproduction is enough: current main lacks the exact-`<location>` guard  ... illsSection()`, while the PR diff adds it to both selection branches and asserts the resulting prompt text.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix: enforce exact skill paths for all skill matches

Validation:
- ClawSweeper review passed for head 743c9840c1.
- Required merge gates passed before the squash merge.

Prepared head SHA: 743c9840c1
Review: https://github.com/openclaw/openclaw/pull/74161#issuecomment-4341488109

Co-authored-by: tianguicheng <tianguicheng@xiaomi.com>
Co-authored-by: sallyom <somalley@redhat.com>
(cherry picked from commit c739088d62)
2026-05-05 05:45:56 +01:00
saram ali
8017dc4c3b fix(gateway): skip IPv6 loopback binding on Windows (#69701)
Bind the default loopback gateway listener only to `127.0.0.1` on Windows so libuv dual-stack `::1` behavior cannot wedge localhost HTTP requests.

Also keeps non-Windows dual-loopback behavior covered, replaces the redundant Windows passthrough test with guard coverage, and adds the required changelog entry.

Fixes #69674.

Tests:
- pnpm exec oxfmt --check --threads=1 CHANGELOG.md src/gateway/net.ts src/gateway/net.test.ts
- pnpm test src/gateway/net.test.ts
- pnpm check:changed
- GitHub required checks: green

Thanks @SARAMALI15792.

Co-authored-by: saram ali <140950904+SARAMALI15792@users.noreply.github.com>
Co-authored-by: Brad Groux <3053586+BradGroux@users.noreply.github.com>
(cherry picked from commit 978bc53e80)
2026-05-05 05:45:56 +01:00
Peter Steinberger
578d9072cf test: align beta plugin repair expectations 2026-05-05 05:40:52 +01:00
Vincent Koc
30b73bbf41 fix(plugins): honor beta channel for auto installs
(cherry picked from commit b0f841ef37)
2026-05-05 05:37:52 +01:00
Vincent Koc
ade922ba98 fix(telegram): reuse preview for long text finals (#77658)
* fix(telegram): reuse preview for long text finals

* test(qa): cover long telegram finals

* fix(qa): satisfy extension lint

* fix(qa): keep telegram long final fixture to two chunks

* test(telegram): cover three chunk finals

* fix(telegram): force long final preview boundary

(cherry picked from commit e03fe1e289)
2026-05-05 05:37:52 +01:00
Vincent Koc
997f8af734 fix(whatsapp): normalize onboarding allowlist numbers
Normalize WhatsApp onboarding allowlist entries to digit-only WhatsApp IDs and reject invalid owner-phone inputs during prompt validation.

(cherry picked from commit 68a500c465)
2026-05-05 05:37:52 +01:00
Vincent Koc
6204a6fecc fix(update): authenticate restart health probes
(cherry picked from commit b546aa91e1)
2026-05-05 05:37:25 +01:00
Peter Steinberger
9f15c29397 fix: explain missing git during plugin install
(cherry picked from commit a91c17c426)
2026-05-05 05:23:01 +01:00
Bek
cac973972c fix: slack mention-gating thread participation
(cherry picked from commit cf3ce08b91)
2026-05-05 05:14:29 +01:00
Peter Steinberger
f8f18d53fc fix: start configured generation providers
(cherry picked from commit 0eb06caae3)
2026-05-05 05:10:02 +01:00
pickaxe
696f639cf6 docs: note plugin peer-link update repair
(cherry picked from commit 712aa96a8f)
2026-05-05 05:06:31 +01:00
pickaxe
079b937b46 fix(plugins): repair missing openclaw peer links on update
(cherry picked from commit 2e8761c5c1)
2026-05-05 05:06:31 +01:00
Kelaw - Keshav's Agent
32e36d355d fix: recover missing Codex bound threads
(cherry picked from commit a373468d82)
2026-05-05 04:58:18 +01:00
Peter Steinberger
12e1c67f22 fix(build): route externalized plugin entry chunks 2026-05-05 04:31:46 +01:00
Peter Steinberger
766d02ff3b fix(build): route externalized plugin chunks 2026-05-05 04:23:24 +01:00
Peter Steinberger
e9ebb6ce6c fix(release): prune externalized plugin chunks 2026-05-05 04:15:20 +01:00
Peter Steinberger
e0002c4b5b chore(release): prepare 2026.5.4 beta 2 2026-05-05 02:42:03 +01:00
62 changed files with 2441 additions and 259 deletions

View File

@@ -33,7 +33,7 @@ on:
required: false
type: string
publish_openclaw_npm:
description: Publish the OpenClaw npm package after plugin npm and ClawHub publish complete
description: Publish the OpenClaw npm package after plugin npm succeeds; ClawHub may still run
required: true
default: true
type: boolean
@@ -169,15 +169,15 @@ jobs:
run: |
set -euo pipefail
dispatch_and_wait() {
dispatch_workflow() {
local workflow="$1"
shift
local before_json dispatch_output run_id status conclusion url
local before_json dispatch_output run_id
before_json="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
printf '%s\n' "$dispatch_output"
printf '%s\n' "$dispatch_output" >&2
run_id="$(
printf '%s\n' "$dispatch_output" |
sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' |
@@ -202,15 +202,14 @@ jobs:
exit 1
fi
echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" >&2
printf '%s\n' "${run_id}"
}
cancel_child() {
if [[ -n "${run_id:-}" ]]; then
echo "Cancelling child workflow ${workflow}: ${run_id}" >&2
gh run cancel --repo "$GITHUB_REPOSITORY" "$run_id" >/dev/null 2>&1 || true
fi
}
trap cancel_child EXIT INT TERM
wait_for_run() {
local workflow="$1"
local run_id="$2"
local status conclusion url
while true; do
status="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status --jq '.status')"
@@ -219,7 +218,6 @@ jobs:
fi
sleep 30
done
trap - EXIT INT TERM
conclusion="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json conclusion --jq '.conclusion')"
url="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json url --jq '.url')"
@@ -229,16 +227,36 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
if [[ "$conclusion" != "success" ]]; then
gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
exit 1
return 1
fi
}
wait_for_run_background() {
local workflow="$1"
local run_id="$2"
local result_file="$3"
(
if wait_for_run "${workflow}" "${run_id}"; then
printf 'success\n' > "${result_file}"
else
printf 'failure\n' > "${result_file}"
fi
) &
wait_run_pid="$!"
}
{
echo "### Publish sequence"
echo
echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`"
echo "- Release tag: \`${RELEASE_TAG}\`"
echo "- Release SHA: \`${TARGET_SHA}\`"
echo "- Plugin npm and ClawHub publish: dispatched in parallel"
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
echo "- OpenClaw npm publish: starts after plugin npm succeeds; ClawHub may still be running"
else
echo "- OpenClaw npm publish: skipped by input"
fi
} >> "$GITHUB_STEP_SUMMARY"
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}")
@@ -248,15 +266,53 @@ jobs:
clawhub_args+=(-f plugins="${PLUGINS}")
fi
dispatch_and_wait plugin-npm-release.yml "${npm_args[@]}"
dispatch_and_wait plugin-clawhub-release.yml "${clawhub_args[@]}"
plugin_npm_run_id="$(dispatch_workflow plugin-npm-release.yml "${npm_args[@]}")"
plugin_clawhub_run_id="$(dispatch_workflow plugin-clawhub-release.yml "${clawhub_args[@]}")"
if ! wait_for_run plugin-npm-release.yml "${plugin_npm_run_id}"; then
echo "Plugin npm publish failed; cancelling ClawHub publish child ${plugin_clawhub_run_id}." >&2
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
exit 1
fi
openclaw_npm_run_id=""
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
dispatch_and_wait openclaw-npm-release.yml \
openclaw_npm_run_id="$(dispatch_workflow openclaw-npm-release.yml \
-f tag="${RELEASE_TAG}" \
-f preflight_only=false \
-f preflight_run_id="${PREFLIGHT_RUN_ID}" \
-f npm_dist_tag="${RELEASE_NPM_DIST_TAG}"
-f npm_dist_tag="${RELEASE_NPM_DIST_TAG}")"
else
echo "- OpenClaw npm publish: skipped by input" >> "$GITHUB_STEP_SUMMARY"
fi
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
wait_run_pid=""
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
clawhub_pid="${wait_run_pid}"
openclaw_result=""
openclaw_pid=""
if [[ -n "${openclaw_npm_run_id}" ]]; then
openclaw_result="$RUNNER_TEMP/openclaw-npm-result.txt"
wait_run_pid=""
wait_for_run_background openclaw-npm-release.yml "${openclaw_npm_run_id}" "${openclaw_result}"
openclaw_pid="${wait_run_pid}"
fi
failed=0
if ! wait "${clawhub_pid}"; then
failed=1
fi
if [[ -n "${openclaw_pid}" ]] && ! wait "${openclaw_pid}"; then
failed=1
fi
if [[ -f "${clawhub_result}" && "$(cat "${clawhub_result}")" != "success" ]]; then
failed=1
fi
if [[ -n "${openclaw_result}" && -f "${openclaw_result}" && "$(cat "${openclaw_result}")" != "success" ]]; then
failed=1
fi
if [[ "${failed}" != "0" ]]; then
exit 1
fi

View File

@@ -182,7 +182,7 @@ jobs:
contents: read
strategy:
fail-fast: false
max-parallel: 6
max-parallel: 12
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
steps:
@@ -263,7 +263,7 @@ jobs:
id-token: write
strategy:
fail-fast: false
max-parallel: 6
max-parallel: 12
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
steps:

View File

@@ -2,7 +2,7 @@
Docs: https://docs.openclaw.ai
## Unreleased
## 2026.5.4
### Highlights
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Gateway/Windows: bind the default loopback gateway listener only to `127.0.0.1` on Windows so libuv's dual-stack `::1` behavior cannot wedge localhost HTTP requests. (#69701, fixes #69674) Thanks @SARAMALI15792.
- Plugins/migration: emit catalog-backed install hints when `plugins.entries` or `plugins.allow` references an official external plugin that is not installed, so upgraded configs point operators to `openclaw plugins install <spec>` instead of telling them to remove valid plugin config. (#77483) Thanks @hclsys.
- OpenAI/Codex media: advertise Codex audio transcription in runtime and manifest metadata and route active Codex chat models to the OpenAI transcription default instead of sending chat model ids to audio transcription. Thanks @vincentkoc.
- Dependencies: refresh runtime and provider packages including Pi 0.73.0, ACPX adapters, OpenAI, Anthropic, Slack, and TypeScript native preview, while keeping the Bedrock runtime installer override pinned below the Windows ARM Node 24 npm resolver failure.
@@ -59,22 +60,32 @@ Docs: https://docs.openclaw.ai
- Plugins/ClawHub: annotate 429 errors from ClawHub with the reset window from `RateLimit-Reset`/`Retry-After` and append a `Sign in for higher rate limits.` hint when the request was unauthenticated, so users can see when downloads will recover and how to lift the cap. Thanks @romneyda.
- Plugins/runtime state: add `registerIfAbsent` for atomic keyed-store dedupe claims that return whether a plugin successfully claimed a key without overwriting an existing live value. Thanks @amknight.
- Plugin SDK: add plugin-owned `SessionEntry` slot projection and scoped trusted-policy session extension reads. (#75609; replaces part of #73384/#74483) Thanks @100yenadmin.
- Sandbox/Windows: accept drive-absolute Docker bind sources while keeping sandbox blocked-path and allowed-root policy comparisons Windows-case-insensitive. (#42174) Thanks @6607changchun.
### Fixes
- Plugins/install: honor the beta update channel for onboarding and doctor-managed plugin installs by requesting floating npm and ClawHub specs with `@beta` while keeping persistent install records on the catalog default. Thanks @vincentkoc.
- WhatsApp/onboarding: canonicalize setup and pairing allowlist entries to WhatsApp's digit-only phone ids while still accepting E.164, JID, and `whatsapp:` inputs, so personal-phone allowlists match WhatsApp Web sender ids after setup. Thanks @vincentkoc.
- Gateway/startup: load provider plugins that own explicitly configured image, video, or music generation defaults so generation tools become live after gateway restart instead of remaining catalog-only. Fixes #77244. Thanks @buyuangtampan, @Nikoxx99, and @vincentkoc.
- Slack/subagents: keep resumed parent `message.send` calls in the originating Slack thread when ambient session thread context is present, and suppress successful silent child completion rows from follow-up findings. Thanks @bek91.
- Slack/mentions: record thread participation for successful visible threaded Slack sends, including message-tool and media delivery paths, so unmentioned replies in bot-participated threads can bypass mention gating as documented. Fixes #77648. Thanks @bek91.
- Infra/Windows: skip the POSIX `/tmp/openclaw` preferred path on Windows in `resolvePreferredOpenClawTmpDir` so log files, TTS temp files, and other writes land in `%TEMP%\openclaw-<uid>` instead of `C:\tmp\openclaw`. Fixes #60713. Thanks @juan-flores077.
- Media/Windows: open saved attachment temp files read/write before fsync so Windows WebChat and `chat.send` media offloads no longer fail with EPERM during durability flush. (#76593) Thanks @qq230849622-a11y.
- Agents/tools: honor narrow runtime tool allowlists when constructing embedded-runner tool families and bundled MCP/LSP runtimes, so cron/subagent runs that request tools such as `update_plan`, `browser`, `x_search`, channel login tools, or `group:plugins` no longer start with missing tools or unrelated bootstrap work. (#77519, #77532)
- Codex plugin: mirror the experimental upstream app-server protocol and format generated TypeScript before drift checks, keeping OpenClaw's `experimentalApi` bridge compatible with latest Codex while preserving formatter gates.
- Telegram/media: derive no-caption inbound media placeholders from saved MIME metadata instead of the Telegram `photo` shape, so non-image and mixed attachments no longer reach the model as `<media:image>`. Fixes #69793. Thanks @aspalagin.
- Telegram/streaming: reuse the active preview as the first chunk for long text finals, so multi-chunk replies no longer create a transient extra bubble that appears and then disappears. Thanks @vincentkoc.
- Agents/cache: keep per-turn runtime context out of ordinary chat system prompts while still delivering hidden current-turn context, restoring prompt-cache reuse on chat continuations. Fixes #77431. Thanks @Udjin79.
- Gateway/startup: include resolved thinking and fast-mode defaults in the `agent model` startup log line, defaulting unset startup thinking to `medium` without mixing in reasoning visibility.
- Gateway/update: resolve local gateway probe auth from the installed config during post-update restart verification, so token/device-authenticated VPS gateways are not misreported as unhealthy port conflicts after a package swap. Thanks @vincentkoc.
- Agents/Tools: add post-compaction loop guard in `pi-embedded-runner` that arms after auto-compaction-retry and aborts the run with `compaction_loop_persisted` when the agent emits the same `(tool, args, result)` triple `windowSize` times (default 3) within that window. Disable via existing `tools.loopDetection.enabled`; tune via `tools.loopDetection.postCompactionGuard.windowSize`. Targets the failure mode where context-overflow + compaction does not break a tool-call loop. Refs #77474; carries forward #21597. Thanks @efpiva.
- Gateway/watch: suppress sync-I/O trace output during `pnpm gateway:watch --benchmark` unless explicitly requested, so CPU profiling no longer floods the terminal with stack traces.
- Gateway/watch: when benchmark sync-I/O tracing is explicitly enabled, tee trace blocks to the benchmark output log and filter them from the terminal pane while keeping normal Gateway logs visible.
- Plugins/runtime-deps: include `json5` in the memory-core plugin runtime dependency set so packaged `memory_search` sandboxes can resolve generated OpenClaw runtime chunks that parse JSON5 config. Fixes #77461.
- Plugins/Windows: show a Git install hint when npm plugin installation fails with `spawn git ENOENT`, and document the WhatsApp plugin's Git-on-PATH requirement for Baileys/libsignal installs.
- Codex harness: preserve app-server usage-limit reset details and deliver OpenClaw-owned runtime failure notices through tool-only source-reply mode, so Telegram and other chat channels tell users when Codex subscription limits or API failures block a turn instead of going silent. (#77557) Thanks @pashpashpash.
- Agents/OpenAI: default direct OpenAI Responses models to the SSE transport instead of WebSocket auto-selection, preventing pi runtime chat turns from hanging on servers where the WebSocket path stalls while the OpenAI HTTP stream works. Thanks @vincentkoc.
- Plugins/update: repair missing plugin-local `openclaw` peer links before skipping unchanged npm plugin updates, so current external Codex installs can recover `openclaw/plugin-sdk/*` resolution during OTA repair. (#77544) Thanks @ProspectOre.
- Discord/replies: treat failed final reply delivery as a failed turn instead of counting it as a delivered automatic visible reply, so guild/channel turns no longer show done when the final message was dropped. Fixes #77520. Thanks @Patrick-Erichsen.
- Discord: prefer IPv4 for Discord REST and gateway WebSocket startup paths so IPv4-only networks no longer stall before Gateway READY and inbound message dispatch. Fixes #77398; refs #77526. Thanks @Beandon13.
- Channels/plugins: key bundled package-state probes, env/config presence, and read-only command defaults by channel id instead of manifest plugin id, preserving setup and native-command detection for channel plugins whose package id differs from the channel alias. Thanks @vincentkoc.
@@ -216,6 +227,7 @@ Docs: https://docs.openclaw.ai
- Google Meet: make Twilio setup status require an enabled `voice-call` plugin entry instead of treating a missing entry as ready. Thanks @vincentkoc.
- Telegram: render shared interactive reply buttons in reply delivery so plugin approval messages show inline keyboards. (#76238) Thanks @keshavbotagent.
- Cron/sessions: keep cron metadata rows without an on-disk transcript non-resumable until a transcript exists, so doctor and `sessions cleanup --fix-missing` no longer report or prune pre-transcript cron rows as broken sessions. Refs #77011.
- OpenAI Codex: recreate missing bound app-server threads once when a stale `/codex bind` sidecar survives a restart, preserving the selected auth profile and turn overrides before retrying the inbound turn. (#76936) Thanks @keshavbotagent.
- Agents/cli-runner: drop a saved `claude-cli` resume sessionId at preparation time when its on-disk transcript no longer exists in `~/.claude/projects/`, so a stale binding from a half-installed `update.run` cannot trap follow-up runs (auto-reply / Telegram direct) in a `claude --resume` timeout loop; the run starts fresh and the new sessionId is written back through the existing post-run flow. (#77030; refs #77011) Thanks @openperf.
- Release validation: install the cross-OS TypeScript harness through Windows-safe Node/npm shims so native Windows package checks reach the OpenClaw smoke suites instead of exiting before artifact capture. Thanks @vincentkoc.
- Release validation: let Windows packaged-upgrade checks continue after the shipped 2026.5.2 updater hits its native-module swap cleanup fallback, verifying the fallback-installed candidate through package metadata and downstream smoke instead of crashing on the immediate update-status probe. Thanks @vincentkoc.
@@ -1396,6 +1408,7 @@ Docs: https://docs.openclaw.ai
- Gateway/plugins: enable the native `require()` fast path on Windows for bundled plugin modules so plugin loading uses `require()` instead of Jiti's transform pipeline, reducing startup from ~39s to ~2s on typical 6-plugin setups. Fixes #68656. (#74173) Thanks @galiniliev.
- macOS app: detect stale Gateway TLS certificate pins, automatically repair trusted Tailscale Serve rotations, and surface paired-but-disconnected Mac companion nodes so partial Gateway connections no longer look healthy. Thanks @guti.
- Feishu: recreate WebSocket clients with monitor-owned backoff only after SDK reconnect exhaustion, preserving heartbeat defaults and shutdown cleanup without treating recoverable SDK callback errors as terminal, so persistent connections recover without manual gateway restart. Fixes #52618; duplicate evidence #59753; related #55532, #68766, #72411, and #73739. Thanks @vincentkoc, @schumilin, @alex-xuweilong, @120106835, @sirfengyu, and @tianhaocui.
- Agents/skills: require exact `<location>` skill paths for both single-skill and multi-skill prompt selection, so agents do not guess or hard-code skill file paths. (#74161) Thanks @lanzhi-lee.
## 2026.4.27

View File

@@ -1,2 +1,2 @@
43c6f668cd8301f485c64e6a663dc1b19d38c146ce2572943e2dc961973e0c6f plugin-sdk-api-baseline.json
1d877d94bebb634d90d929fe0581ba4bccf4d12d8342d179ae9bf1053e68c013 plugin-sdk-api-baseline.jsonl
50bd395c818460886af1fd545da4e3ace0fc7f7c36a43abd58f0a8cf76659f09 plugin-sdk-api-baseline.json
bc609d44abbd58515f69ec88c947531c679f7f7910208c0761f52fda4481fa6e plugin-sdk-api-baseline.jsonl

View File

@@ -344,6 +344,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
For text-only replies:
- short DM/group/topic previews: OpenClaw keeps the same preview message and performs a final edit in place, unless a visible non-preview message was sent after the preview appeared
- long text finals that split into multiple Telegram messages reuse the existing preview as the first final chunk when possible, then send only the remaining chunks
- previews followed by visible non-preview output: OpenClaw sends the completed reply as a fresh final message and cleans up the older preview, so the final answer appears after intermediate output
- previews older than about one minute: OpenClaw sends the completed reply as a fresh final message and then cleans up the preview, so Telegram's visible timestamp reflects completion time instead of the preview creation time

View File

@@ -26,6 +26,16 @@ openclaw plugins install @openclaw/whatsapp
Use the bare package to follow the current official release tag. Pin an exact
version only when you need a reproducible install.
On Windows, the WhatsApp plugin needs Git on `PATH` during npm install because
one of its Baileys/libsignal dependencies is fetched from a git URL. Install
Git for Windows, then restart the shell and rerun the install:
```powershell
winget install --id Git.Git -e
```
Portable Git also works if its `bin` directory is on `PATH`.
<CardGroup cols={3}>
<Card title="Pairing" icon="link" href="/channels/pairing">
Default DM policy is pairing for unknown senders.

View File

@@ -232,6 +232,8 @@ Scenarios (`extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime
- `telegram-tools-compact-command`
- `telegram-whoami-command`
- `telegram-context-command`
- `telegram-long-final-reuses-preview`
- `telegram-long-final-three-chunks`
Output artifacts:

View File

@@ -18,6 +18,16 @@ Adds the WhatsApp channel surface for sending and receiving OpenClaw messages.
channels: whatsapp
## Windows install note
On Windows, the WhatsApp plugin needs Git on `PATH` during npm install because one of its Baileys/libsignal dependencies is fetched from a git URL. Install Git for Windows, then restart the shell and rerun the install:
```powershell
winget install --id Git.Git -e
```
Portable Git also works if its `bin` directory is on `PATH`.
## Related docs
- [whatsapp](/channels/whatsapp)

View File

@@ -77,10 +77,13 @@ the maintainer-only release runbook.
prior evidence stale.
9. For beta, tag `vYYYY.M.D-beta.N`, then run `OpenClaw Release Publish` from
the matching `release/YYYY.M.D` branch. It verifies `pnpm plugins:sync:check`,
publishes all publishable plugin packages to npm first, publishes the same
set to ClawHub second as ClawPack npm-pack tarballs, and then promotes the
prepared OpenClaw npm preflight artifact with the matching dist-tag. After
publish, run post-publish package
dispatches all publishable plugin packages to npm and the same set to
ClawHub in parallel, and then promotes the prepared OpenClaw npm preflight
artifact with the matching dist-tag as soon as plugin npm publish succeeds.
ClawHub publishing may still be running while OpenClaw npm publishes, but the
release publish workflow does not finish until both plugin publish paths and
the OpenClaw npm publish path have completed successfully. After publish, run
the post-publish package
acceptance against the published `openclaw@YYYY.M.D-beta.N` or
`openclaw@beta` package. If a pushed or published prerelease needs a fix,
cut the next matching prerelease number; do not delete or rewrite the old

View File

@@ -48,7 +48,10 @@ describe("codex conversation binding", () => {
});
beforeEach(() => {
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} });
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({
version: 1,
profiles: {},
});
agentRuntimeMocks.resolveAuthProfileOrder.mockReturnValue([]);
agentRuntimeMocks.resolveOpenClawAgentDir.mockReturnValue("/agent");
agentRuntimeMocks.resolveProviderIdForAuth.mockImplementation((provider: string) => provider);
@@ -56,7 +59,9 @@ describe("codex conversation binding", () => {
it("uses the default Codex auth profile and omits the public OpenAI provider for new binds", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const config = { auth: { order: { "openai-codex": ["openai-codex:default"] } } };
const config = {
auth: { order: { "openai-codex": ["openai-codex:default"] } },
};
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({
version: 1,
@@ -220,6 +225,142 @@ describe("codex conversation binding", () => {
expect(result).toEqual({ handled: true });
});
it("recreates a missing bound thread and preserves auth plus turn overrides", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({
version: 1,
profiles: {
work: {
type: "oauth",
provider: "openai-codex",
access: "access-token",
},
},
});
await fs.writeFile(
`${sessionFile}.codex-app-server.json`,
JSON.stringify({
schemaVersion: 1,
threadId: "thread-old",
cwd: tempDir,
authProfileId: "work",
model: "gpt-5.4-mini",
modelProvider: "openai",
approvalPolicy: "on-request",
sandbox: "workspace-write",
serviceTier: "fast",
}),
);
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
const notificationHandlers: Array<(notification: Record<string, unknown>) => void> = [];
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
requests.push({ method, params: requestParams });
if (method === "turn/start" && requestParams.threadId === "thread-old") {
throw new Error("thread not found: thread-old");
}
if (method === "thread/start") {
return {
thread: { id: "thread-new", cwd: tempDir },
model: "gpt-5.4-mini",
};
}
if (method === "turn/start" && requestParams.threadId === "thread-new") {
setImmediate(() => {
for (const handler of notificationHandlers) {
handler({
method: "turn/completed",
params: {
threadId: "thread-new",
turn: {
id: "turn-new",
status: "completed",
items: [
{
id: "assistant-1",
type: "agentMessage",
text: "Recovered",
},
],
},
},
});
}
});
return { turn: { id: "turn-new" } };
}
throw new Error(`unexpected method: ${method}`);
}),
addNotificationHandler: vi.fn((handler) => {
notificationHandlers.push(handler);
return () => undefined;
}),
addRequestHandler: vi.fn(() => () => undefined),
});
const result = await handleCodexConversationInboundClaim(
{
content: "hi again",
bodyForAgent: "hi again",
channel: "telegram",
isGroup: false,
commandAuthorized: true,
},
{
channelId: "telegram",
pluginBinding: {
bindingId: "binding-1",
pluginId: "codex",
pluginRoot: tempDir,
channel: "telegram",
accountId: "default",
conversationId: "5185575566",
boundAt: Date.now(),
data: {
kind: "codex-app-server-session",
version: 1,
sessionFile,
workspaceDir: tempDir,
},
},
},
{ timeoutMs: 500 },
);
expect(result).toEqual({ handled: true, reply: { text: "Recovered" } });
expect(requests.map((request) => request.method)).toEqual([
"turn/start",
"thread/start",
"turn/start",
]);
expect(sharedClientMocks.getSharedCodexAppServerClient).toHaveBeenCalledWith(
expect.objectContaining({ authProfileId: "work" }),
);
expect(requests[1]?.params).toMatchObject({
model: "gpt-5.4-mini",
approvalPolicy: "on-request",
sandbox: "workspace-write",
serviceTier: "fast",
});
expect(requests[1]?.params).not.toHaveProperty("modelProvider");
expect(requests[2]?.params).toMatchObject({
threadId: "thread-new",
approvalPolicy: "on-request",
serviceTier: "fast",
});
const savedBinding = JSON.parse(
await fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8"),
);
expect(savedBinding).toMatchObject({
threadId: "thread-new",
authProfileId: "work",
approvalPolicy: "on-request",
sandbox: "workspace-write",
serviceTier: "fast",
});
expect(savedBinding).not.toHaveProperty("modelProvider");
});
it("returns a clean failure reply when app-server turn start rejects", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
await fs.writeFile(

View File

@@ -10,8 +10,11 @@ import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
import {
codexSandboxPolicyForTurn,
resolveCodexAppServerRuntimeOptions,
type CodexAppServerApprovalPolicy,
type CodexAppServerSandboxMode,
} from "./app-server/config.js";
import {
type CodexServiceTier,
type CodexThreadResumeResponse,
type CodexThreadStartResponse,
type CodexTurnStartResponse,
@@ -59,6 +62,9 @@ type CodexConversationStartParams = {
model?: string;
modelProvider?: string;
authProfileId?: string;
approvalPolicy?: CodexAppServerApprovalPolicy;
sandbox?: CodexAppServerSandboxMode;
serviceTier?: CodexServiceTier;
};
type BoundTurnResult = {
@@ -100,6 +106,9 @@ export async function startCodexConversationThread(
model: params.model,
modelProvider: params.modelProvider,
authProfileId,
approvalPolicy: params.approvalPolicy,
sandbox: params.sandbox,
serviceTier: params.serviceTier,
config: params.config,
});
} else {
@@ -110,6 +119,9 @@ export async function startCodexConversationThread(
model: params.model,
modelProvider: params.modelProvider,
authProfileId,
approvalPolicy: params.approvalPolicy,
sandbox: params.sandbox,
serviceTier: params.serviceTier,
config: params.config,
});
}
@@ -137,7 +149,7 @@ export async function handleCodexConversationInboundClaim(
}
try {
const result = await enqueueBoundTurn(data.sessionFile, () =>
runBoundTurn({
runBoundTurnWithMissingThreadRecovery({
data,
prompt,
event,
@@ -177,9 +189,14 @@ async function attachExistingThread(params: {
model?: string;
modelProvider?: string;
authProfileId?: string;
approvalPolicy?: CodexAppServerApprovalPolicy;
sandbox?: CodexAppServerSandboxMode;
serviceTier?: CodexServiceTier;
config?: CodexAppServerAuthProfileLookup["config"];
}): Promise<void> {
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
const runtime = resolveCodexAppServerRuntimeOptions({
pluginConfig: params.pluginConfig,
});
const modelProvider = resolveThreadRequestModelProvider({
authProfileId: params.authProfileId,
modelProvider: params.modelProvider,
@@ -196,10 +213,12 @@ async function attachExistingThread(params: {
threadId: params.threadId,
...(params.model ? { model: params.model } : {}),
...(modelProvider ? { modelProvider } : {}),
approvalPolicy: runtime.approvalPolicy,
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
approvalsReviewer: runtime.approvalsReviewer,
sandbox: runtime.sandbox,
...(runtime.serviceTier ? { serviceTier: runtime.serviceTier } : {}),
sandbox: params.sandbox ?? runtime.sandbox,
...((params.serviceTier ?? runtime.serviceTier)
? { serviceTier: params.serviceTier ?? runtime.serviceTier }
: {}),
persistExtendedHistory: true,
},
{ timeoutMs: runtime.requestTimeoutMs },
@@ -217,9 +236,9 @@ async function attachExistingThread(params: {
authProfileId: params.authProfileId,
modelProvider: response.modelProvider ?? params.modelProvider,
}),
approvalPolicy: runtime.approvalPolicy,
sandbox: runtime.sandbox,
serviceTier: runtime.serviceTier,
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
sandbox: params.sandbox ?? runtime.sandbox,
serviceTier: params.serviceTier ?? runtime.serviceTier,
},
{
config: params.config,
@@ -234,9 +253,14 @@ async function createThread(params: {
model?: string;
modelProvider?: string;
authProfileId?: string;
approvalPolicy?: CodexAppServerApprovalPolicy;
sandbox?: CodexAppServerSandboxMode;
serviceTier?: CodexServiceTier;
config?: CodexAppServerAuthProfileLookup["config"];
}): Promise<void> {
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
const runtime = resolveCodexAppServerRuntimeOptions({
pluginConfig: params.pluginConfig,
});
const modelProvider = resolveThreadRequestModelProvider({
authProfileId: params.authProfileId,
modelProvider: params.modelProvider,
@@ -253,10 +277,12 @@ async function createThread(params: {
cwd: params.workspaceDir,
...(params.model ? { model: params.model } : {}),
...(modelProvider ? { modelProvider } : {}),
approvalPolicy: runtime.approvalPolicy,
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
approvalsReviewer: runtime.approvalsReviewer,
sandbox: runtime.sandbox,
...(runtime.serviceTier ? { serviceTier: runtime.serviceTier } : {}),
sandbox: params.sandbox ?? runtime.sandbox,
...((params.serviceTier ?? runtime.serviceTier)
? { serviceTier: params.serviceTier ?? runtime.serviceTier }
: {}),
developerInstructions:
"This Codex thread is bound to an OpenClaw conversation. Answer normally; OpenClaw will deliver your final response back to the conversation.",
experimentalRawEvents: true,
@@ -276,9 +302,9 @@ async function createThread(params: {
authProfileId: params.authProfileId,
modelProvider: response.modelProvider ?? params.modelProvider,
}),
approvalPolicy: runtime.approvalPolicy,
sandbox: runtime.sandbox,
serviceTier: runtime.serviceTier,
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
sandbox: params.sandbox ?? runtime.sandbox,
serviceTier: params.serviceTier ?? runtime.serviceTier,
},
{
config: params.config,
@@ -293,7 +319,9 @@ async function runBoundTurn(params: {
pluginConfig?: unknown;
timeoutMs?: number;
}): Promise<BoundTurnResult> {
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
const runtime = resolveCodexAppServerRuntimeOptions({
pluginConfig: params.pluginConfig,
});
const binding = await readCodexAppServerBinding(params.data.sessionFile);
const threadId = binding?.threadId;
if (!threadId) {
@@ -350,7 +378,10 @@ async function runBoundTurn(params: {
"turn/start",
{
threadId,
input: buildCodexConversationTurnInput({ prompt: params.prompt, event: params.event }),
input: buildCodexConversationTurnInput({
prompt: params.prompt,
event: params.event,
}),
cwd: binding.cwd || params.data.workspaceDir,
approvalPolicy: binding.approvalPolicy ?? runtime.approvalPolicy,
approvalsReviewer: runtime.approvalsReviewer,
@@ -389,6 +420,39 @@ async function runBoundTurn(params: {
}
}
async function runBoundTurnWithMissingThreadRecovery(params: {
data: CodexConversationBindingData;
prompt: string;
event: PluginHookInboundClaimEvent;
pluginConfig?: unknown;
timeoutMs?: number;
}): Promise<BoundTurnResult> {
try {
return await runBoundTurn(params);
} catch (error) {
if (!isCodexThreadNotFoundError(error)) {
throw error;
}
const binding = await readCodexAppServerBinding(params.data.sessionFile);
await startCodexConversationThread({
pluginConfig: params.pluginConfig,
sessionFile: params.data.sessionFile,
workspaceDir: binding?.cwd || params.data.workspaceDir,
model: binding?.model,
modelProvider: binding?.modelProvider,
authProfileId: binding?.authProfileId,
approvalPolicy: binding?.approvalPolicy,
sandbox: binding?.sandbox,
serviceTier: binding?.serviceTier,
});
return await runBoundTurn(params);
}
}
function isCodexThreadNotFoundError(error: unknown): boolean {
return /\bthread not found:/iu.test(formatErrorMessage(error));
}
function enqueueBoundTurn<T>(key: string, run: () => Promise<T>): Promise<T> {
const state = getGlobalState();
const previous = state.queues.get(key) ?? Promise.resolve();

View File

@@ -333,6 +333,8 @@ describe("telegram live qa runtime", () => {
"telegram-context-command",
"telegram-current-session-status-tool",
"telegram-mentioned-message-reply",
"telegram-long-final-reuses-preview",
"telegram-long-final-three-chunks",
"telegram-mention-gating",
]);
expect(scenarios.map((scenario) => scenario.id)).toEqual([
@@ -343,6 +345,8 @@ describe("telegram live qa runtime", () => {
"telegram-context-command",
"telegram-current-session-status-tool",
"telegram-mentioned-message-reply",
"telegram-long-final-reuses-preview",
"telegram-long-final-three-chunks",
"telegram-mention-gating",
]);
expect(
@@ -355,6 +359,25 @@ describe("telegram live qa runtime", () => {
.find((scenario) => scenario.id === "telegram-mentioned-message-reply")
?.buildRun("sut_bot").replyToLatestSutMessage,
).toBe(true);
expect(
scenarios
.find((scenario) => scenario.id === "telegram-long-final-reuses-preview")
?.buildRun("sut_bot"),
).toMatchObject({
expectedJoinedSutTextIncludes: ["TELEGRAM-LONG-FINAL-BEGIN", "TELEGRAM-LONG-FINAL-END"],
expectedSutMessageCount: 2,
});
expect(
scenarios
.find((scenario) => scenario.id === "telegram-long-final-three-chunks")
?.buildRun("sut_bot"),
).toMatchObject({
expectedJoinedSutTextIncludes: [
"TELEGRAM-LONG-FINAL-3CHUNK-BEGIN",
"TELEGRAM-LONG-FINAL-3CHUNK-END",
],
expectedSutMessageCount: 3,
});
});
it("keeps bot-to-bot plain mentions out of the default Telegram live set", () => {
@@ -382,6 +405,160 @@ describe("telegram live qa runtime", () => {
).toEqual(["allowlist-block", "top-level-reply-shape", "restart-resume"]);
});
it("asserts long Telegram final replies reuse the streamed preview message", () => {
expect(() =>
__testing.assertTelegramScenarioMessageSet({
expectedJoinedSutTextIncludes: ["TELEGRAM-LONG-FINAL-BEGIN", "TELEGRAM-LONG-FINAL-END"],
expectedSutMessageCount: 2,
groupId: "-100123",
scenarioId: "telegram-long-final-reuses-preview",
sutBotId: 99,
observedMessages: [
{
updateId: 1,
messageId: 10,
chatId: -100123,
senderId: 99,
senderIsBot: true,
scenarioId: "telegram-long-final-reuses-preview",
scenarioTitle: "Telegram long final reuses the preview message",
matchedScenario: true,
text: "TELEGRAM-LONG-FINAL-BEGIN part one ",
timestamp: 1_700_000_000_000,
inlineButtons: [],
mediaKinds: [],
},
{
updateId: 2,
messageId: 11,
chatId: -100123,
senderId: 99,
senderIsBot: true,
scenarioId: "telegram-long-final-reuses-preview",
scenarioTitle: "Telegram long final reuses the preview message",
matchedScenario: true,
text: "part two TELEGRAM-LONG-FINAL-END",
timestamp: 1_700_000_001_000,
inlineButtons: [],
mediaKinds: [],
},
],
}),
).not.toThrow();
expect(() =>
__testing.assertTelegramScenarioMessageSet({
expectedSutMessageCount: 2,
groupId: "-100123",
scenarioId: "telegram-long-final-reuses-preview",
sutBotId: 99,
observedMessages: [
{
updateId: 1,
messageId: 10,
chatId: -100123,
senderId: 99,
senderIsBot: true,
scenarioId: "telegram-long-final-reuses-preview",
scenarioTitle: "Telegram long final reuses the preview message",
matchedScenario: true,
text: "preview",
timestamp: 1_700_000_000_000,
inlineButtons: [],
mediaKinds: [],
},
{
updateId: 2,
messageId: 11,
chatId: -100123,
senderId: 99,
senderIsBot: true,
scenarioId: "telegram-long-final-reuses-preview",
scenarioTitle: "Telegram long final reuses the preview message",
matchedScenario: true,
text: "final chunk one",
timestamp: 1_700_000_001_000,
inlineButtons: [],
mediaKinds: [],
},
{
updateId: 3,
messageId: 12,
chatId: -100123,
senderId: 99,
senderIsBot: true,
scenarioId: "telegram-long-final-reuses-preview",
scenarioTitle: "Telegram long final reuses the preview message",
matchedScenario: true,
text: "final chunk two",
timestamp: 1_700_000_002_000,
inlineButtons: [],
mediaKinds: [],
},
],
}),
).toThrow("expected 2 SUT message(s), observed 3");
});
it("accepts legitimate three-chunk Telegram final replies", () => {
expect(() =>
__testing.assertTelegramScenarioMessageSet({
expectedJoinedSutTextIncludes: [
"TELEGRAM-LONG-FINAL-3CHUNK-BEGIN",
"TELEGRAM-LONG-FINAL-3CHUNK-END",
],
expectedSutMessageCount: 3,
groupId: "-100123",
scenarioId: "telegram-long-final-three-chunks",
sutBotId: 99,
observedMessages: [
{
updateId: 1,
messageId: 10,
chatId: -100123,
senderId: 99,
senderIsBot: true,
scenarioId: "telegram-long-final-three-chunks",
scenarioTitle: "Telegram three-chunk final keeps only final chunks",
matchedScenario: true,
text: "TELEGRAM-LONG-FINAL-3CHUNK-BEGIN part one ",
timestamp: 1_700_000_000_000,
inlineButtons: [],
mediaKinds: [],
},
{
updateId: 2,
messageId: 11,
chatId: -100123,
senderId: 99,
senderIsBot: true,
scenarioId: "telegram-long-final-three-chunks",
scenarioTitle: "Telegram three-chunk final keeps only final chunks",
matchedScenario: true,
text: "part two ",
timestamp: 1_700_000_001_000,
inlineButtons: [],
mediaKinds: [],
},
{
updateId: 3,
messageId: 12,
chatId: -100123,
senderId: 99,
senderIsBot: true,
scenarioId: "telegram-long-final-three-chunks",
scenarioTitle: "Telegram three-chunk final keeps only final chunks",
matchedScenario: true,
text: "part three TELEGRAM-LONG-FINAL-3CHUNK-END",
timestamp: 1_700_000_002_000,
inlineButtons: [],
mediaKinds: [],
},
],
}),
).not.toThrow();
});
it("matches scenario replies by thread or exact marker", () => {
expect(
__testing.matchesTelegramScenarioReply({

View File

@@ -48,6 +48,8 @@ type TelegramQaScenarioId =
| "telegram-whoami-command"
| "telegram-context-command"
| "telegram-current-session-status-tool"
| "telegram-long-final-three-chunks"
| "telegram-long-final-reuses-preview"
| "telegram-mentioned-message-reply"
| "telegram-mention-gating";
@@ -56,8 +58,11 @@ type TelegramQaScenarioRun = {
expectReply: boolean;
input: string;
expectedTextIncludes?: string[];
expectedJoinedSutTextIncludes?: string[];
expectedSutMessageCount?: number;
matchText?: string;
replyToLatestSutMessage?: boolean;
settleMs?: number;
};
type TelegramQaScenarioDefinition = LiveTransportScenarioDefinition<TelegramQaScenarioId> & {
@@ -295,6 +300,39 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [
replyToLatestSutMessage: true,
}),
},
{
id: "telegram-long-final-reuses-preview",
title: "Telegram long final reuses the preview message",
defaultEnabled: false,
timeoutMs: 60_000,
buildRun: (sutUsername) => ({
allowAnySutReply: true,
expectReply: true,
input: `@${sutUsername} Telegram long final QA check. Use the scripted long final response.`,
expectedTextIncludes: ["TELEGRAM-LONG-FINAL-BEGIN"],
expectedJoinedSutTextIncludes: ["TELEGRAM-LONG-FINAL-BEGIN", "TELEGRAM-LONG-FINAL-END"],
expectedSutMessageCount: 2,
settleMs: 4_000,
}),
},
{
id: "telegram-long-final-three-chunks",
title: "Telegram three-chunk final keeps only final chunks",
defaultEnabled: false,
timeoutMs: 60_000,
buildRun: (sutUsername) => ({
allowAnySutReply: true,
expectReply: true,
input: `@${sutUsername} Telegram long final three chunk QA check. Use the scripted three chunk final response.`,
expectedTextIncludes: ["TELEGRAM-LONG-FINAL-3CHUNK-BEGIN"],
expectedJoinedSutTextIncludes: [
"TELEGRAM-LONG-FINAL-3CHUNK-BEGIN",
"TELEGRAM-LONG-FINAL-3CHUNK-END",
],
expectedSutMessageCount: 3,
settleMs: 4_000,
}),
},
{
id: "telegram-mention-gating",
standardId: "mention-gating",
@@ -744,6 +782,102 @@ async function waitForObservedMessage(params: {
throw new Error(timeoutMessage);
}
async function collectObservedMessages(params: {
token: string;
initialOffset: number;
settleMs: number;
predicate: (message: TelegramObservedMessage) => boolean;
observedMessages: TelegramObservedMessage[];
observationScenarioId: string;
observationScenarioTitle: string;
}) {
const startedAt = Date.now();
let offset = params.initialOffset;
while (Date.now() - startedAt < params.settleMs) {
const remainingMs = Math.max(1, params.settleMs - (Date.now() - startedAt));
const timeoutSeconds = Math.max(1, Math.min(2, Math.ceil(remainingMs / 1000)));
let updates: TelegramUpdate[];
try {
updates = await callTelegramApi<TelegramUpdate[]>(
params.token,
"getUpdates",
{
offset,
timeout: timeoutSeconds,
allowed_updates: ["message", "edited_message"],
},
timeoutSeconds * 1000 + 5_000,
);
} catch (error) {
if (!isRecoverableTelegramQaPollError(error)) {
throw error;
}
await waitForTelegramPollRetryDelay(params.settleMs - (Date.now() - startedAt));
continue;
}
if (updates.length === 0) {
continue;
}
offset = (updates.at(-1)?.update_id ?? offset) + 1;
for (const update of updates) {
const normalized = normalizeTelegramObservedMessage(update);
if (!normalized) {
continue;
}
params.observedMessages.push({
...normalized,
scenarioId: params.observationScenarioId,
scenarioTitle: params.observationScenarioTitle,
matchedScenario: params.predicate(normalized),
});
}
}
return offset;
}
function assertTelegramScenarioMessageSet(params: {
expectedJoinedSutTextIncludes?: string[];
expectedSutMessageCount?: number;
groupId: string;
observedMessages: TelegramObservedMessage[];
scenarioId: string;
sutBotId: number;
}) {
if (
params.expectedSutMessageCount === undefined &&
(params.expectedJoinedSutTextIncludes ?? []).length === 0
) {
return;
}
const byMessageId = new Map<number, TelegramObservedMessage>();
for (const message of params.observedMessages) {
if (
message.scenarioId === params.scenarioId &&
message.chatId === Number(params.groupId) &&
message.senderId === params.sutBotId
) {
byMessageId.set(message.messageId, message);
}
}
const messages = [...byMessageId.values()].toSorted((a, b) => a.messageId - b.messageId);
if (
params.expectedSutMessageCount !== undefined &&
messages.length !== params.expectedSutMessageCount
) {
throw new Error(
`expected ${params.expectedSutMessageCount} SUT message(s), observed ${messages.length}: ${messages
.map((message) => message.messageId)
.join(", ")}`,
);
}
const joinedText = messages.map((message) => message.text).join("");
for (const expected of params.expectedJoinedSutTextIncludes ?? []) {
if (!joinedText.includes(expected)) {
throw new Error(`joined SUT reply text missing expected text: ${expected}`);
}
}
}
async function waitForTelegramChannelRunning(
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>,
accountId: string,
@@ -1374,6 +1508,25 @@ export async function runTelegramQaLive(params: {
}),
});
driverOffset = matched.nextOffset;
if (scenarioRun.settleMs !== undefined) {
driverOffset = await collectObservedMessages({
token: runtimeEnv.driverToken,
initialOffset: driverOffset,
settleMs: scenarioRun.settleMs,
observedMessages,
observationScenarioId: scenario.id,
observationScenarioTitle: scenario.title,
predicate: (message) =>
matchesTelegramScenarioReply({
allowAnySutReply: scenarioRun.allowAnySutReply,
groupId: runtimeEnv.groupId,
matchText: scenarioRun.matchText,
message,
sentMessageId: sent.message_id,
sutBotId: sutIdentity.id,
}),
});
}
if (!scenarioRun.expectReply) {
throw new Error(`unexpected reply message ${matched.message.messageId} matched`);
}
@@ -1381,14 +1534,26 @@ export async function runTelegramQaLive(params: {
expectedTextIncludes: scenarioRun.expectedTextIncludes,
message: matched.message,
});
assertTelegramScenarioMessageSet({
expectedJoinedSutTextIncludes: scenarioRun.expectedJoinedSutTextIncludes,
expectedSutMessageCount: scenarioRun.expectedSutMessageCount,
groupId: runtimeEnv.groupId,
observedMessages,
scenarioId: scenario.id,
sutBotId: sutIdentity.id,
});
const rttMs = matched.observedAtMs - requestStartedAtMs;
const suffix =
scenarioRun.expectedSutMessageCount === undefined
? ""
: `; observed ${scenarioRun.expectedSutMessageCount} SUT message(s)`;
const result = {
id: scenario.id,
title: scenario.title,
status: "pass",
details: redactPublicMetadata
? `reply matched in ${rttMs}ms`
: `reply message ${matched.message.messageId} matched in ${rttMs}ms`,
? `reply matched in ${rttMs}ms${suffix}`
: `reply message ${matched.message.messageId} matched in ${rttMs}ms${suffix}`,
rttMs,
requestStartedAt,
responseObservedAt: new Date(matched.observedAtMs).toISOString(),
@@ -1565,6 +1730,7 @@ export const __testing = {
buildObservedMessagesArtifact,
canaryFailureMessage,
callTelegramApi,
assertTelegramScenarioMessageSet,
isRecoverableTelegramQaPollError,
assertTelegramScenarioReply,
classifyCanaryReply,

View File

@@ -221,6 +221,48 @@ describe("qa mock openai server", () => {
expect(partialBody).toContain('"type":"response.output_text.delta"');
expect(partialBody).toContain("QA_PARTIAL_OK");
const telegramLongResponse = await fetch(`${server.baseUrl}/v1/responses`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
stream: true,
input: [
makeUserInput("Telegram long final QA check. Use the scripted long final response."),
],
}),
});
expect(telegramLongResponse.status).toBe(200);
const telegramLongBody = await telegramLongResponse.text();
expect(telegramLongBody).toContain('"type":"response.output_text.delta"');
expect(telegramLongBody).toContain('"phase":"final_answer"');
expect(telegramLongBody).toContain("TELEGRAM-LONG-FINAL-BEGIN");
expect(telegramLongBody).toContain("TELEGRAM-LONG-FINAL-END");
expect(telegramLongBody.length).toBeGreaterThan(4_500);
const telegramThreeChunkLongResponse = await fetch(`${server.baseUrl}/v1/responses`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
stream: true,
input: [
makeUserInput(
"Telegram long final three chunk QA check. Use the scripted three chunk final response.",
),
],
}),
});
expect(telegramThreeChunkLongResponse.status).toBe(200);
const telegramThreeChunkLongBody = await telegramThreeChunkLongResponse.text();
expect(telegramThreeChunkLongBody).toContain('"type":"response.output_text.delta"');
expect(telegramThreeChunkLongBody).toContain('"phase":"final_answer"');
expect(telegramThreeChunkLongBody).toContain("TELEGRAM-LONG-FINAL-3CHUNK-BEGIN");
expect(telegramThreeChunkLongBody).toContain("TELEGRAM-LONG-FINAL-3CHUNK-END");
expect(telegramThreeChunkLongBody.length).toBeGreaterThan(8_000);
const blockResponse = await fetch(`${server.baseUrl}/v1/responses`, {
method: "POST",
headers: {

View File

@@ -153,6 +153,8 @@ const QA_GROUP_VISIBLE_REPLY_TOOL_PROMPT_RE = /qa group visible reply tool check
const QA_GROUP_MESSAGE_UNAVAILABLE_FALLBACK_PROMPT_RE =
/qa group message unavailable fallback check/i;
const QA_TELEGRAM_CURRENT_SESSION_STATUS_PROMPT_RE = /telegram current session_status qa check/i;
const QA_TELEGRAM_LONG_FINAL_THREE_CHUNK_PROMPT_RE = /telegram long final three chunk qa check/i;
const QA_TELEGRAM_LONG_FINAL_PROMPT_RE = /telegram long final qa check/i;
const QA_SUBAGENT_DIRECT_FALLBACK_PROMPT_RE = /subagent direct fallback qa check/i;
const QA_SUBAGENT_DIRECT_FALLBACK_WORKER_RE = /subagent direct fallback worker/i;
const QA_SUBAGENT_DIRECT_FALLBACK_MARKER = "QA-SUBAGENT-DIRECT-FALLBACK-OK";
@@ -1034,6 +1036,23 @@ function splitMockStreamingText(text: string, parts = 3) {
return chunks.length > 1 ? chunks : [text.slice(0, 1), text.slice(1)];
}
function buildTelegramLongFinalText({
endMarker = "TELEGRAM-LONG-FINAL-END",
segmentCount = 54,
startMarker = "TELEGRAM-LONG-FINAL-BEGIN",
}: {
endMarker?: string;
segmentCount?: number;
startMarker?: string;
} = {}) {
const body = Array.from(
{ length: segmentCount },
(_, index) =>
`telegram-long-final-segment-${String(index + 1).padStart(3, "0")} ${"x".repeat(54)}`,
).join("\n");
return `${startMarker}\n${body}\n${endMarker}`;
}
function buildAssistantOutputItem(spec: MockAssistantMessageSpec) {
return {
type: "message",
@@ -1310,6 +1329,32 @@ async function buildResponsesPayload(
}
return buildAssistantEvents("");
}
if (QA_TELEGRAM_LONG_FINAL_THREE_CHUNK_PROMPT_RE.test(allInputText)) {
const text = buildTelegramLongFinalText({
endMarker: "TELEGRAM-LONG-FINAL-3CHUNK-END",
segmentCount: 96,
startMarker: "TELEGRAM-LONG-FINAL-3CHUNK-BEGIN",
});
return buildAssistantEvents([
{
id: "msg_mock_telegram_long_final_three_chunk",
phase: "final_answer",
streamDeltas: splitMockStreamingText(text),
text,
},
]);
}
if (QA_TELEGRAM_LONG_FINAL_PROMPT_RE.test(allInputText)) {
const text = buildTelegramLongFinalText();
return buildAssistantEvents([
{
id: "msg_mock_telegram_long_final",
phase: "final_answer",
streamDeltas: splitMockStreamingText(text),
text,
},
]);
}
if (QA_STREAMING_PROMPT_RE.test(allInputText) && exactReplyDirective) {
return buildAssistantEvents([
{

View File

@@ -16,7 +16,6 @@ const reactSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
const readSlackMessages = vi.fn(async (..._args: unknown[]) => ({}));
const removeOwnSlackReactions = vi.fn(async (..._args: unknown[]) => ["thumbsup"]);
const removeSlackReaction = vi.fn(async (..._args: unknown[]) => ({}));
const recordSlackThreadParticipation = vi.fn();
const sendSlackMessage = vi.fn(async (..._args: unknown[]) => ({ channelId: "C123" }));
const unpinSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
@@ -103,7 +102,6 @@ describe("handleSlackAction", () => {
pinSlackMessage,
reactSlackMessage,
readSlackMessages,
recordSlackThreadParticipation,
removeOwnSlackReactions,
removeSlackReaction,
sendSlackMessage,

View File

@@ -12,7 +12,6 @@ import {
type OpenClawConfig,
withNormalizedTimestamp,
} from "./runtime-api.js";
import { recordSlackThreadParticipation } from "./sent-thread-cache.js";
import { parseSlackTarget, resolveSlackChannelId } from "./targets.js";
const messagingActions = new Set([
@@ -78,7 +77,6 @@ export const slackActionRuntime = {
pinSlackMessage: createLazySlackAction("pinSlackMessage"),
reactSlackMessage: createLazySlackAction("reactSlackMessage"),
readSlackMessages: createLazySlackAction("readSlackMessages"),
recordSlackThreadParticipation,
removeOwnSlackReactions: createLazySlackAction("removeOwnSlackReactions"),
removeSlackReaction: createLazySlackAction("removeSlackReaction"),
sendSlackMessage: createLazySlackAction("sendSlackMessage"),
@@ -273,14 +271,6 @@ export async function handleSlackAction(
blocks,
});
if (threadTs && result.channelId && account.accountId) {
slackActionRuntime.recordSlackThreadParticipation(
account.accountId,
result.channelId,
threadTs,
);
}
// Keep "first" mode consistent even when the agent explicitly provided
// threadTs: once we send a message to the current channel, consider the
// first reply "used" so later tool calls don't auto-thread again.
@@ -318,14 +308,6 @@ export async function handleSlackAction(
...(title ? { uploadTitle: title } : {}),
});
if (threadTs && result.channelId && account.accountId) {
slackActionRuntime.recordSlackThreadParticipation(
account.accountId,
result.channelId,
threadTs,
);
}
if (context?.hasRepliedRef && context.currentChannelId) {
if (sameSlackChannelTarget(to, context.currentChannelId)) {
context.hasRepliedRef.value = true;

View File

@@ -92,6 +92,25 @@ describe("slack outbound shared hook wiring", () => {
expect(sendMessageSlackMock).toHaveBeenCalledTimes(1);
});
it("passes replyToId as Slack threadTs for threaded outbound delivery", async () => {
await deliverOutboundPayloads({
cfg,
channel: "slack",
to: "C123",
payloads: [{ text: "hello" }],
accountId: "default",
replyToId: "1712000000.000001",
});
expect(sendMessageSlackMock).toHaveBeenCalledWith(
"C123",
"hello",
expect.objectContaining({
threadTs: "1712000000.000001",
}),
);
});
it("respects cancel from the shared hook without a second adapter pass", async () => {
const hookRegistry = createEmptyPluginRegistry();
const handler = vi.fn().mockResolvedValue({ cancel: true });

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { createSlackSendTestClient, installSlackBlockTestMocks } from "./blocks.test-helpers.js";
import {
clearSlackThreadParticipationCache,
hasSlackThreadParticipation,
} from "./sent-thread-cache.js";
installSlackBlockTestMocks();
const { sendMessageSlack } = await import("./send.js");
@@ -67,6 +71,49 @@ describe("sendMessageSlack NO_REPLY guard", () => {
});
});
describe("sendMessageSlack thread participation", () => {
it("records participation after a successful threaded send", async () => {
clearSlackThreadParticipationCache();
const client = createSlackSendTestClient();
await sendMessageSlack("channel:C123", "hello thread", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
threadTs: "1712345678.123456",
});
expect(hasSlackThreadParticipation("default", "C123", "1712345678.123456")).toBe(true);
});
it("does not record participation for unthreaded sends", async () => {
clearSlackThreadParticipationCache();
const client = createSlackSendTestClient();
await sendMessageSlack("channel:C123", "hello channel", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
});
expect(hasSlackThreadParticipation("default", "C123", "1712345678.123456")).toBe(false);
});
it("does not record participation for invalid thread ids", async () => {
clearSlackThreadParticipationCache();
const client = createSlackSendTestClient();
await sendMessageSlack("channel:C123", "hello invalid thread", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
threadTs: "not-a-slack-thread",
});
expect(hasSlackThreadParticipation("default", "C123", "not-a-slack-thread")).toBe(false);
});
});
describe("sendMessageSlack chunking", () => {
it("keeps 4205-character text in a single Slack post by default", async () => {
const client = createSlackSendTestClient();

View File

@@ -24,7 +24,9 @@ import { createSlackTokenCacheKey, getSlackWriteClient } from "./client.js";
import { markdownToSlackMrkdwnChunks } from "./format.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import { loadOutboundMediaFromUrl } from "./runtime-api.js";
import { recordSlackThreadParticipation } from "./sent-thread-cache.js";
import { parseSlackTarget } from "./targets.js";
import { normalizeSlackThreadTsCandidate } from "./thread-ts.js";
import { resolveSlackBotToken } from "./token.js";
import { truncateSlackText } from "./truncate.js";
const SLACK_UPLOAD_SSRF_POLICY = {
@@ -535,7 +537,7 @@ export async function sendMessageSlack(
recipient,
threadTs: opts.threadTs,
});
return await runQueuedSlackSend(queueKey, () =>
const result = await runQueuedSlackSend(queueKey, () =>
sendMessageSlackQueued({
trimmedMessage,
opts,
@@ -546,6 +548,11 @@ export async function sendMessageSlack(
blocks,
}),
);
const threadTs = normalizeSlackThreadTsCandidate(opts.threadTs);
if (threadTs && result.channelId && account.accountId) {
recordSlackThreadParticipation(account.accountId, result.channelId, threadTs);
}
return result;
}
async function sendMessageSlackQueued(params: {

View File

@@ -1,6 +1,10 @@
import type { WebClient } from "@slack/web-api";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installSlackBlockTestMocks } from "./blocks.test-helpers.js";
import {
clearSlackThreadParticipationCache,
hasSlackThreadParticipation,
} from "./sent-thread-cache.js";
// --- Module mocks (must precede dynamic import) ---
installSlackBlockTestMocks();
@@ -96,6 +100,7 @@ describe("sendMessageSlack file upload with user IDs", () => {
loadOutboundMediaFromUrlMock.mockClear();
clearSlackDmChannelCache();
clearSlackSendQueuesForTest();
clearSlackThreadParticipationCache();
});
afterEach(() => {
@@ -297,6 +302,7 @@ describe("sendMessageSlack file upload with user IDs", () => {
thread_ts: "171.222",
}),
);
expect(hasSlackThreadParticipation("default", "C123CHAN", "171.222")).toBe(true);
});
it("uses explicit upload filename and title overrides when provided", async () => {

View File

@@ -373,6 +373,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramDeps?: TelegramBotDeps;
bot?: Bot;
replyToMode?: Parameters<typeof dispatchTelegramMessage>[0]["replyToMode"];
textLimit?: number;
}) {
const bot = params.bot ?? createBot();
await dispatchTelegramMessage({
@@ -382,7 +383,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
runtime: createRuntime(),
replyToMode: params.replyToMode ?? "first",
streamMode: params.streamMode ?? "partial",
textLimit: 4096,
textLimit: params.textLimit ?? 4096,
telegramCfg: params.telegramCfg ?? {},
telegramDeps: params.telegramDeps ?? telegramDepsForTest,
opts: { token: "token" },
@@ -1576,6 +1577,89 @@ describe("dispatchTelegramMessage draft streaming", () => {
);
});
it("uses the active preview as the first chunk for long text finals", async () => {
const answerDraftStream = createSequencedDraftStream(1001);
const reasoningDraftStream = createDraftStream();
createTelegramDraftStream
.mockImplementationOnce(() => answerDraftStream)
.mockImplementationOnce(() => reasoningDraftStream);
const finalText = `${"A".repeat(70)}${"B".repeat(70)}`;
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "Working preview" });
await dispatcherOptions.deliver({ text: finalText, replyToId: "456" }, { kind: "final" });
return { queuedFinal: true };
},
);
deliverReplies.mockResolvedValue({ delivered: true });
editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" });
await dispatchWithContext({
context: createContext(),
streamMode: "partial",
textLimit: 80,
});
const editedText = editMessageTelegram.mock.calls[0]?.[2] as string;
const followUpText =
(deliverReplies.mock.calls[0]?.[0] as { replies?: Array<{ text?: string }> })?.replies?.[0]
?.text ?? "";
expect(editMessageTelegram).toHaveBeenCalledTimes(1);
expect(editedText.length).toBeLessThanOrEqual(80);
expect(followUpText.length).toBeGreaterThan(0);
expect(`${editedText}${followUpText}`).toBe(finalText);
expect(deliverReplies).toHaveBeenCalledTimes(1);
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.not.objectContaining({ replyToId: expect.any(String) })],
}),
);
expect(answerDraftStream.clear).not.toHaveBeenCalled();
});
it("uses the active preview as the first chunk for three-chunk long text finals", async () => {
const answerDraftStream = createSequencedDraftStream(1001);
const reasoningDraftStream = createDraftStream();
createTelegramDraftStream
.mockImplementationOnce(() => answerDraftStream)
.mockImplementationOnce(() => reasoningDraftStream);
const finalText = `${"A".repeat(70)}${"B".repeat(70)}${"C".repeat(70)}`;
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "Working preview" });
await dispatcherOptions.deliver({ text: finalText, replyToId: "456" }, { kind: "final" });
return { queuedFinal: true };
},
);
deliverReplies.mockResolvedValue({ delivered: true });
editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" });
await dispatchWithContext({
context: createContext(),
streamMode: "partial",
textLimit: 80,
});
const editedText = editMessageTelegram.mock.calls[0]?.[2] as string;
const followUpReplies =
(deliverReplies.mock.calls[0]?.[0] as { replies?: Array<{ text?: string }> })?.replies ?? [];
const followUpText = followUpReplies.map((reply) => reply.text ?? "").join("");
expect(editMessageTelegram).toHaveBeenCalledTimes(1);
expect(editedText.length).toBeLessThanOrEqual(80);
expect(followUpReplies).toHaveLength(1);
expect(followUpText.length).toBeGreaterThan(80);
expect(`${editedText}${followUpText}`).toBe(finalText);
expect(deliverReplies).toHaveBeenCalledTimes(1);
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.not.objectContaining({ replyToId: expect.any(String) })],
}),
);
expect(answerDraftStream.clear).not.toHaveBeenCalled();
});
it("does not force new message on first assistant message start", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);

View File

@@ -28,6 +28,7 @@ import {
createOutboundPayloadPlan,
projectOutboundPayloadPlanForDelivery,
} from "openclaw/plugin-sdk/outbound-runtime";
import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-chunking";
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
@@ -75,7 +76,7 @@ import {
shouldSuppressTelegramError,
} from "./error-policy.js";
import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js";
import { renderTelegramHtmlText } from "./format.js";
import { markdownToTelegramChunks, renderTelegramHtmlText } from "./format.js";
import {
type ArchivedPreview,
createLaneDeliveryStateTracker,
@@ -784,6 +785,27 @@ export const dispatchTelegramMessage = async ({
}
return { ...payload, text };
};
const applyTextToFollowUpPayload = (payload: ReplyPayload, text: string): ReplyPayload => {
const next = applyTextToPayload(payload, text);
const {
replyToId: _replyToId,
replyToCurrent: _replyToCurrent,
replyToTag: _replyToTag,
...followUp
} = next;
return followUp;
};
const splitFinalTextForPreview = (text: string): string[] => {
const markdownChunks =
chunkMode === "newline"
? chunkMarkdownTextWithMode(text, draftMaxChars, chunkMode)
: [text];
return markdownChunks.flatMap((chunk) =>
markdownToTelegramChunks(chunk, draftMaxChars, { tableMode }).map(
(telegramChunk) => telegramChunk.text,
),
);
};
const applyQuoteReplyTarget = (payload: ReplyPayload): ReplyPayload => {
if (
!implicitQuoteReplyTargetId ||
@@ -836,6 +858,8 @@ export const dispatchTelegramMessage = async ({
retainPreviewOnCleanupByLane,
draftMaxChars,
applyTextToPayload,
applyTextToFollowUpPayload,
splitFinalTextForPreview,
sendPayload,
flushDraftLane,
stopDraftLane: async (lane) => {

View File

@@ -81,6 +81,8 @@ type CreateLaneTextDelivererParams = {
retainPreviewOnCleanupByLane: Record<LaneName, boolean>;
draftMaxChars: number;
applyTextToPayload: (payload: ReplyPayload, text: string) => ReplyPayload;
applyTextToFollowUpPayload?: (payload: ReplyPayload, text: string) => ReplyPayload;
splitFinalTextForPreview?: (text: string) => readonly string[];
sendPayload: (payload: ReplyPayload) => Promise<boolean>;
flushDraftLane: (lane: DraftLaneState) => Promise<void>;
stopDraftLane: (lane: DraftLaneState) => Promise<void>;
@@ -117,7 +119,7 @@ type TryUpdatePreviewParams = {
previewButtons?: TelegramInlineButtons;
stopBeforeEdit?: boolean;
updateLaneSnapshot?: boolean;
skipRegressive: "always" | "existingOnly";
skipRegressive: RegressiveSkipMode;
context: "final" | "update";
previewMessageId?: number;
previewTextSnapshot?: string;
@@ -134,7 +136,7 @@ type ConsumeArchivedAnswerPreviewParams = {
};
type PreviewUpdateContext = "final" | "update";
type RegressiveSkipMode = "always" | "existingOnly";
type RegressiveSkipMode = "always" | "existingOnly" | "never";
type ResolvePreviewTargetParams = {
lane: DraftLaneState;
@@ -169,6 +171,9 @@ function shouldSkipRegressivePreviewUpdate(args: {
if (currentPreviewText === undefined) {
return false;
}
if (args.skipRegressive === "never") {
return false;
}
return (
currentPreviewText.startsWith(args.text) &&
args.text.length < currentPreviewText.length &&
@@ -184,6 +189,26 @@ function isLongLivedPreview(visibleSinceMs: number | undefined, nowMs: number):
);
}
function compactPreviewFinalChunks(chunks: readonly string[]): string[] {
const result: string[] = [];
let pendingWhitespace = "";
for (const chunk of chunks) {
if (!chunk) {
continue;
}
if (chunk.trim().length === 0) {
pendingWhitespace += chunk;
continue;
}
result.push(`${pendingWhitespace}${chunk}`);
pendingWhitespace = "";
}
if (pendingWhitespace && result.length > 0) {
result[result.length - 1] = `${result[result.length - 1]}${pendingWhitespace}`;
}
return result;
}
function resolvePreviewTarget(params: ResolvePreviewTargetParams): PreviewTargetResolution {
const lanePreviewMessageId = params.lane.stream?.messageId();
const previewMessageId =
@@ -227,6 +252,10 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
const shouldUseFreshFinalForPreview = (lane: DraftLaneState, visibleSinceMs?: number) =>
isMessagePreviewLane(lane) &&
(isLongLivedPreview(visibleSinceMs, readNow()) || wasVisiblyOverwrittenSince(visibleSinceMs));
const buildFollowUpPayload = (payload: ReplyPayload, text: string) =>
params.applyTextToFollowUpPayload
? params.applyTextToFollowUpPayload(payload, text)
: params.applyTextToPayload(payload, text);
const clearActivePreviewAfterFreshFinal = async (lane: DraftLaneState, laneName: LaneName) => {
try {
await lane.stream?.clear();
@@ -330,6 +359,56 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
return "fallback";
}
};
const tryDeliverLongFinalThroughPreview = async (args: {
lane: DraftLaneState;
laneName: LaneName;
text: string;
payload: ReplyPayload;
previewButtons?: TelegramInlineButtons;
}): Promise<LaneDeliveryResult | undefined> => {
if (
!args.lane.stream ||
args.previewButtons !== undefined ||
params.activePreviewLifecycleByLane[args.laneName] !== "transient"
) {
return undefined;
}
const chunks = compactPreviewFinalChunks(params.splitFinalTextForPreview?.(args.text) ?? []);
const [firstChunk, ...remainingChunks] = chunks;
if (!firstChunk || remainingChunks.length === 0 || firstChunk.length > params.draftMaxChars) {
return undefined;
}
await params.flushDraftLane(args.lane);
const previewMessageId = args.lane.stream.messageId();
if (typeof previewMessageId !== "number") {
return undefined;
}
const finalized = await tryUpdatePreviewForLane({
lane: args.lane,
laneName: args.laneName,
text: firstChunk,
stopBeforeEdit: true,
updateLaneSnapshot: true,
skipRegressive: "never",
context: "final",
});
if (finalized === "fallback") {
return undefined;
}
if (finalized === "retained") {
markActivePreviewComplete(args.laneName);
return result("preview-retained");
}
markActivePreviewComplete(args.laneName);
const remainingText = remainingChunks.join("");
if (remainingText.trim().length > 0) {
await params.sendPayload(buildFollowUpPayload(args.payload, remainingText));
}
return result("preview-finalized", {
content: args.text,
messageId: previewMessageId,
});
};
const tryUpdatePreviewForLane = async ({
lane,
@@ -596,6 +675,16 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
return result("preview-retained");
}
} else if (!hasMedia && !payload.isError && text.length > params.draftMaxChars) {
const longFinalResult = await tryDeliverLongFinalThroughPreview({
lane,
laneName,
text,
payload,
previewButtons,
});
if (longFinalResult) {
return longFinalResult;
}
params.log(
`telegram: preview final too long for edit (${text.length} > ${params.draftMaxChars}); falling back to standard send`,
);

View File

@@ -22,6 +22,7 @@ function createHarness(params?: {
answerHasStreamedMessage?: boolean;
answerLastPartialText?: string;
answerPreviewVisibleSinceMs?: number;
splitFinalTextForPreview?: (text: string) => readonly string[];
nowMs?: number;
}) {
const answer =
@@ -70,6 +71,7 @@ function createHarness(params?: {
retainPreviewOnCleanupByLane: { ...retainPreviewOnCleanupByLane },
draftMaxChars: params?.draftMaxChars ?? 4_096,
applyTextToPayload: (payload: ReplyPayload, text: string) => ({ ...payload, text }),
splitFinalTextForPreview: params?.splitFinalTextForPreview,
sendPayload,
flushDraftLane,
stopDraftLane,
@@ -383,6 +385,36 @@ describe("createLaneTextDeliverer", () => {
expect(harness.log).toHaveBeenCalledWith(expect.stringContaining("preview final too long"));
});
it("forces a long final preview back to the first chunk before sending the rest", async () => {
const firstChunk = "First chunk boundary.";
const remainingText = " Follow-up body after the boundary.";
const finalText = `${firstChunk}${remainingText}`;
const harness = createHarness({
answerMessageId: 999,
answerHasStreamedMessage: true,
answerLastPartialText: `${firstChunk} overlap already visible`,
draftMaxChars: 24,
splitFinalTextForPreview: () => [firstChunk, remainingText],
});
const result = await deliverFinalAnswer(harness, finalText);
expect(expectPreviewFinalized(result)).toEqual({
content: finalText,
messageId: 999,
});
expect(harness.editPreview).toHaveBeenCalledWith(
expect.objectContaining({
messageId: 999,
text: firstChunk,
}),
);
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: remainingText }),
);
expect(harness.lanes.answer.lastPartialText).toBe(firstChunk);
});
it("sends a fresh final when a message preview is long lived", async () => {
const visibleSinceMs = 10_000;
const harness = createHarness({

View File

@@ -172,6 +172,23 @@ describe("whatsapp setup wizard", () => {
expectWhatsAppOwnerAllowlistSetup(result.cfg, harness);
});
it("rejects invalid owner numbers during prompt validation", async () => {
const harness = createWhatsAppOwnerAllowlistHarness(createQueuedWizardPrompter);
await runConfigureWithHarness({
harness,
forceAllowFrom: true,
});
const prompt = harness.text.mock.calls[0]?.[0] as
| { validate?: (value: string) => string | undefined }
| undefined;
expect(prompt?.validate).toEqual(expect.any(Function));
expect(prompt?.validate?.("abc")).toBe("Invalid number: abc");
expect(prompt?.validate?.("whatsapp:")).toBe("Invalid number: whatsapp:");
expect(prompt?.validate?.("+1 (555) 555-0123")).toBeUndefined();
});
it("supports disabled DM policy for separate-phone setup", async () => {
const { harness, result } = await runSeparatePhoneFlow({
selectValues: ["separate", "disabled"],

View File

@@ -30,6 +30,7 @@ import {
isWhatsAppGroupJid,
isWhatsAppNewsletterJid,
looksLikeWhatsAppTargetId,
normalizeWhatsAppAllowFromEntry,
normalizeWhatsAppMessagingTarget,
normalizeWhatsAppTarget,
} from "./normalize.js";
@@ -69,6 +70,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
createChatChannelPlugin<ResolvedWhatsAppAccount>({
pairing: {
idLabel: "whatsappSenderId",
normalizeAllowEntry: (entry) => normalizeWhatsAppAllowFromEntry(entry) ?? "",
},
outbound: whatsappChannelOutbound,
threading: {

View File

@@ -29,6 +29,6 @@ describe("whatsapp config accessors", () => {
it("normalizes allowFrom entries like the channel plugin", () => {
expect(
formatWhatsAppConfigAllowFromEntries([" whatsapp:+49123 ", "*", "49124@s.whatsapp.net"]),
).toEqual(["+49123", "*", "+49124"]);
).toEqual(["49123", "*", "49124"]);
});
});

View File

@@ -101,11 +101,30 @@ export function normalizeWhatsAppMessagingTarget(raw: string): string | undefine
}
export function normalizeWhatsAppAllowFromEntries(allowFrom: Array<string | number>): string[] {
return allowFrom
const seen = new Set<string>();
const normalized = allowFrom
.map((entry) => String(entry).trim())
.filter((entry): entry is string => Boolean(entry))
.map((entry) => (entry === "*" ? entry : normalizeWhatsAppTarget(entry)))
.map(normalizeWhatsAppAllowFromEntry)
.filter((entry): entry is string => Boolean(entry));
return normalized.filter((entry) => {
if (seen.has(entry)) {
return false;
}
seen.add(entry);
return true;
});
}
export function normalizeWhatsAppAllowFromEntry(entry: string): string | null {
if (entry === "*") {
return entry;
}
const normalized = normalizeWhatsAppTarget(entry);
if (!normalized) {
return null;
}
return normalized.startsWith("+") ? normalized.slice(1) : normalized;
}
export function looksLikeWhatsAppTargetId(raw: string): boolean {

View File

@@ -1,5 +1,6 @@
export {
looksLikeWhatsAppTargetId,
normalizeWhatsAppAllowFromEntry,
normalizeWhatsAppMessagingTarget,
isWhatsAppGroupJid,
isWhatsAppNewsletterJid,

View File

@@ -1,8 +1,6 @@
import path from "node:path";
import {
DEFAULT_ACCOUNT_ID,
normalizeAllowFromEntries,
normalizeE164,
pathExists,
splitSetupEntries,
type DmPolicy,
@@ -15,6 +13,10 @@ import {
resolveWhatsAppAccount,
resolveWhatsAppAuthDir,
} from "./accounts.js";
import {
normalizeWhatsAppAllowFromEntries,
normalizeWhatsAppAllowFromEntry,
} from "./normalize-target.js";
import { whatsappSetupAdapter } from "./setup-core.js";
type SetupPrompter = Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"];
@@ -177,7 +179,7 @@ async function promptWhatsAppOwnerAllowFrom(params: {
if (!raw) {
return "Required";
}
const normalized = normalizeE164(raw);
const normalized = normalizeWhatsAppAllowFromEntry(raw);
if (!normalized) {
return `Invalid number: ${raw}`;
}
@@ -185,14 +187,14 @@ async function promptWhatsAppOwnerAllowFrom(params: {
},
});
const normalized = normalizeE164(trimPromptText(entry));
const normalized = normalizeWhatsAppAllowFromEntry(trimPromptText(entry));
if (!normalized) {
throw new Error("Invalid WhatsApp owner number (expected E.164 after validation).");
}
const allowFrom = normalizeAllowFromEntries(
[...existingAllowFrom.filter((item) => item !== "*"), normalized],
normalizeE164,
);
const allowFrom = normalizeWhatsAppAllowFromEntries([
...existingAllowFrom.filter((item) => item !== "*"),
normalized,
]);
return { normalized, allowFrom };
}
@@ -229,13 +231,13 @@ function parseWhatsAppAllowFromEntries(raw: string): { entries: string[]; invali
entries.push("*");
continue;
}
const normalized = normalizeE164(part);
const normalized = normalizeWhatsAppAllowFromEntry(part);
if (!normalized) {
return { entries: [], invalidEntry: part };
}
entries.push(normalized);
}
return { entries: normalizeAllowFromEntries(entries, normalizeE164) };
return { entries: normalizeWhatsAppAllowFromEntries(entries) };
}
async function promptWhatsAppDmAccess(params: {
@@ -313,7 +315,7 @@ async function promptWhatsAppDmAccess(params: {
let next = setWhatsAppSelfChatMode(params.cfg, accountId, false);
next = setWhatsAppDmPolicy(next, accountId, policy);
if (policy === "open") {
const allowFrom = normalizeAllowFromEntries(["*", ...existingAllowFrom], normalizeE164);
const allowFrom = normalizeWhatsAppAllowFromEntries(["*", ...existingAllowFrom]);
next = setWhatsAppAllowFrom(next, accountId, allowFrom.length > 0 ? allowFrom : ["*"]);
return next;
}

View File

@@ -24,9 +24,10 @@ type QueuedWizardPrompterFactory<T extends WizardPromptHarness> = (params: {
}) => T;
const WHATSAPP_OWNER_NUMBER_INPUT = "+1 (555) 555-0123";
const WHATSAPP_OWNER_NUMBER = "+15555550123";
const WHATSAPP_OWNER_NUMBER_E164 = "+15555550123";
const WHATSAPP_OWNER_NUMBER = "15555550123";
const WHATSAPP_PERSONAL_NUMBER_INPUT = "+1 (555) 111-2222";
const WHATSAPP_PERSONAL_NUMBER = "+15551112222";
const WHATSAPP_PERSONAL_NUMBER = "15551112222";
const WHATSAPP_ACCESS_NOTE_TITLE = "WhatsApp DM access";
const WHATSAPP_LOGIN_NOTE_TITLE = "WhatsApp";
@@ -34,7 +35,7 @@ export function createWhatsAppRootAllowFromConfig(): WhatsAppSetupConfig {
return {
channels: {
whatsapp: {
allowFrom: [WHATSAPP_OWNER_NUMBER],
allowFrom: [WHATSAPP_OWNER_NUMBER_E164],
},
},
};
@@ -78,7 +79,7 @@ export function createWhatsAppWorkAccountConfig(
whatsapp: {
...(params.defaultAccount ? { defaultAccount: params.defaultAccount } : {}),
dmPolicy: "disabled",
allowFrom: [WHATSAPP_OWNER_NUMBER],
allowFrom: [WHATSAPP_OWNER_NUMBER_E164],
accounts: {
work: {
authDir: "/tmp/work",
@@ -118,7 +119,7 @@ function expectWhatsAppDmAccess(
export function expectWhatsAppWorkAccountOpenAccess(cfg: WhatsAppSetupConfig): void {
expect(cfg.channels?.whatsapp?.dmPolicy).toBe("disabled");
expect(cfg.channels?.whatsapp?.allowFrom).toEqual([WHATSAPP_OWNER_NUMBER]);
expect(cfg.channels?.whatsapp?.allowFrom).toEqual([WHATSAPP_OWNER_NUMBER_E164]);
expect(cfg.channels?.whatsapp?.accounts?.work?.dmPolicy).toBe("open");
expect(cfg.channels?.whatsapp?.accounts?.work?.allowFrom).toEqual(["*", WHATSAPP_OWNER_NUMBER]);
}

View File

@@ -28,6 +28,20 @@ const PLUGIN_DOC_ALIASES = new Map([
["tavily", "/tools/tavily"],
["tokenjuice", "/tools/tokenjuice"],
]);
const PLUGIN_REFERENCE_EXTRA_SECTIONS = new Map([
[
"whatsapp",
`## Windows install note
On Windows, the WhatsApp plugin needs Git on \`PATH\` during npm install because one of its Baileys/libsignal dependencies is fetched from a git URL. Install Git for Windows, then restart the shell and rerun the install:
\`\`\`powershell
winget install --id Git.Git -e
\`\`\`
Portable Git also works if its \`bin\` directory is on \`PATH\`.`,
],
]);
function readJson(relativePath) {
return JSON.parse(fs.readFileSync(path.join(ROOT, relativePath), "utf8"));
@@ -376,6 +390,7 @@ ${record.docs.map((link) => `- ${docLink(link)}`).join("\n")}`;
function renderReferencePage(record) {
const relatedDocs = renderRelatedDocs(record);
const extraSections = PLUGIN_REFERENCE_EXTRA_SECTIONS.get(record.id);
return `---
summary: "${record.description.replaceAll('"', '\\"')}"
read_when:
@@ -394,7 +409,7 @@ ${record.description}
## Surface
${record.surface}${relatedDocs ? `\n\n${relatedDocs}` : ""}
${record.surface}${extraSections ? `\n\n${extraSections}` : ""}${relatedDocs ? `\n\n${relatedDocs}` : ""}
`;
}

View File

@@ -3,6 +3,8 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
getSandboxHostPathPolicyKey,
isSandboxHostPathAbsolute,
normalizeSandboxHostPath,
resolveSandboxHostPathViaExistingAncestor,
} from "./host-paths.js";
@@ -11,6 +13,33 @@ describe("normalizeSandboxHostPath", () => {
it("normalizes dot segments and strips trailing slash", () => {
expect(normalizeSandboxHostPath("/tmp/a/../b//")).toBe("/tmp/b");
});
it("normalizes Windows drive-letter paths without losing the drive root", () => {
expect(normalizeSandboxHostPath("c:\\Users\\Kai\\..\\Project\\")).toBe("C:/Users/Project");
expect(normalizeSandboxHostPath("d:/")).toBe("D:/");
});
});
describe("isSandboxHostPathAbsolute", () => {
it("accepts POSIX and drive-absolute Windows paths", () => {
expect(isSandboxHostPathAbsolute("/tmp/project")).toBe(true);
expect(isSandboxHostPathAbsolute("C:/Users/kai/project")).toBe(true);
expect(isSandboxHostPathAbsolute("C:\\Users\\kai\\project")).toBe(true);
});
it("rejects relative paths, named volumes, and drive-relative Windows paths", () => {
expect(isSandboxHostPathAbsolute("relative/path")).toBe(false);
expect(isSandboxHostPathAbsolute("my-volume")).toBe(false);
expect(isSandboxHostPathAbsolute("C:relative\\path")).toBe(false);
});
});
describe("getSandboxHostPathPolicyKey", () => {
it("compares Windows drive-letter paths case-insensitively", () => {
expect(getSandboxHostPathPolicyKey("c:\\Users\\Kai\\.SSH\\config")).toBe(
"c:/users/kai/.ssh/config",
);
});
});
describe("resolveSandboxHostPathViaExistingAncestor", () => {
@@ -18,6 +47,16 @@ describe("resolveSandboxHostPathViaExistingAncestor", () => {
expect(resolveSandboxHostPathViaExistingAncestor("relative/path")).toBe("relative/path");
});
it("normalizes Windows paths without resolving them through POSIX cwd on non-Windows hosts", () => {
if (process.platform === "win32") {
return;
}
expect(resolveSandboxHostPathViaExistingAncestor("C:/Users/kai/project")).toBe(
"C:/Users/kai/project",
);
});
it("resolves symlink parents when the final leaf does not exist", () => {
if (process.platform === "win32") {
return;

View File

@@ -19,16 +19,42 @@ function stripWindowsNamespacePrefix(input: string): string {
return input;
}
export function isWindowsDriveAbsolutePath(raw: string): boolean {
return /^[A-Za-z]:[\\/]/.test(stripWindowsNamespacePrefix(raw.trim()));
}
export function isSandboxHostPathAbsolute(raw: string): boolean {
const trimmed = stripWindowsNamespacePrefix(raw.trim());
return trimmed.startsWith("/") || isWindowsDriveAbsolutePath(trimmed);
}
/**
* Normalize a POSIX host path: resolve `.`, `..`, collapse `//`, strip trailing `/`.
* Normalize a host path: resolve `.`, `..`, collapse `//`, strip trailing `/`.
* Windows drive-letter paths preserve the drive root and uppercase the drive letter.
*/
export function normalizeSandboxHostPath(raw: string): string {
const trimmed = stripWindowsNamespacePrefix(raw.trim());
if (!trimmed) {
return "/";
}
const normalized = posix.normalize(trimmed.replaceAll("\\", "/"));
return normalized.replace(/\/+$/, "") || "/";
let normalTrimmed = trimmed.replaceAll("\\", "/");
if (isWindowsDriveAbsolutePath(normalTrimmed)) {
normalTrimmed = normalTrimmed.charAt(0).toUpperCase() + normalTrimmed.slice(1);
}
const normalized = posix.normalize(normalTrimmed);
const withoutTrailingSlash = normalized.replace(/\/+$/, "") || "/";
if (/^[A-Z]:$/.test(withoutTrailingSlash)) {
return `${withoutTrailingSlash}/`;
}
return withoutTrailingSlash;
}
export function getSandboxHostPathPolicyKey(raw: string): string {
const normalized = normalizeSandboxHostPath(raw);
if (isWindowsDriveAbsolutePath(normalized)) {
return normalized.toLowerCase();
}
return normalized;
}
/**
@@ -36,8 +62,11 @@ export function normalizeSandboxHostPath(raw: string): string {
* even when the final source leaf does not exist yet.
*/
export function resolveSandboxHostPathViaExistingAncestor(sourcePath: string): string {
if (!sourcePath.startsWith("/")) {
if (!isSandboxHostPathAbsolute(sourcePath)) {
return sourcePath;
}
if (isWindowsDriveAbsolutePath(sourcePath) && process.platform !== "win32") {
return normalizeSandboxHostPath(sourcePath);
}
return normalizeSandboxHostPath(resolvePathViaExistingAncestorSync(sourcePath));
}

View File

@@ -174,6 +174,25 @@ describe("validateBindMounts", () => {
expect(() => validateBindMounts(["/home/tester/.netrc:/mnt/netrc:ro"])).toThrow(/blocked path/);
});
it("allows drive-absolute Windows bind sources", () => {
expect(() => validateBindMounts(["D:/data/openclaw/src:/src:ro"])).not.toThrow();
expect(() => validateBindMounts(["D:\\data\\openclaw\\output:/output:rw"])).not.toThrow();
});
it("compares Windows allowed roots case-insensitively", () => {
expect(() =>
validateBindMounts(["d:/DATA/OpenClaw/src:/src:ro"], {
allowedSourceRoots: ["D:/data/openclaw"],
}),
).not.toThrow();
expect(() =>
validateBindMounts(["D:/other/project:/src:ro"], {
allowedSourceRoots: ["d:/data/openclaw"],
}),
).toThrow(/outside allowed roots/);
});
it("blocks credential binds through canonical home aliases", () => {
if (process.platform === "win32") {
return;
@@ -193,14 +212,7 @@ describe("validateBindMounts", () => {
it("blocks symlink escapes into blocked directories", () => {
if (process.platform === "win32") {
// Symlinks to non-existent targets like /etc require
// SeCreateSymbolicLinkPrivilege on Windows. The Windows branch of this
// test does not need a real symlink — it only asserts that Windows source
// paths are rejected as non-POSIX.
const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-"));
const fakePath = join(dir, "etc-link", "passwd");
const run = () => validateBindMounts([`${fakePath}:/mnt/passwd:ro`]);
expect(run).toThrow(/non-absolute source path/);
// Symlink setup for blocked POSIX targets like /etc is POSIX-only.
return;
}
@@ -213,7 +225,7 @@ describe("validateBindMounts", () => {
it("blocks symlink-parent escapes with non-existent leaf outside allowed roots", () => {
if (process.platform === "win32") {
// Windows source paths (e.g. C:\\...) are intentionally rejected as non-POSIX.
// Windows symlink semantics differ; POSIX symlink escape coverage runs on POSIX hosts.
return;
}
const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-"));
@@ -233,7 +245,7 @@ describe("validateBindMounts", () => {
it("blocks symlink-parent escapes into blocked paths when leaf does not exist", () => {
if (process.platform === "win32") {
// Windows source paths (e.g. C:\\...) are intentionally rejected as non-POSIX.
// Symlink setup for blocked POSIX targets like /var/run is POSIX-only.
return;
}
const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-"));

View File

@@ -12,6 +12,8 @@ import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"
import { splitSandboxBindSpec } from "./bind-spec.js";
import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
import {
getSandboxHostPathPolicyKey,
isSandboxHostPathAbsolute,
normalizeSandboxHostPath,
resolveSandboxHostPathViaExistingAncestor,
} from "./host-paths.js";
@@ -101,6 +103,7 @@ function parseBindTargetPath(bind: string): string {
/**
* Normalize a POSIX path: resolve `.`, `..`, collapse `//`, strip trailing `/`.
* If it starts with the drive letter, convert it to the upper case.
*/
function normalizeHostPath(raw: string): string {
return normalizeSandboxHostPath(raw);
@@ -115,10 +118,9 @@ function normalizeHostPath(raw: string): string {
*/
export function getBlockedBindReason(bind: string): BlockedBindReason | null {
const sourceRaw = parseBindSourcePath(bind);
if (!sourceRaw.startsWith("/")) {
if (!isSandboxHostPathAbsolute(sourceRaw)) {
return { kind: "non_absolute", sourcePath: sourceRaw };
}
const normalized = normalizeHostPath(sourceRaw);
const blockedHostPaths = getBlockedHostPaths();
const directReason = getBlockedReasonForSourcePath(normalized, blockedHostPaths);
@@ -141,8 +143,10 @@ function getBlockedReasonForSourcePath(
if (sourceNormalized === "/") {
return { kind: "covers", blockedPath: "/" };
}
const sourceKey = getSandboxHostPathPolicyKey(sourceNormalized);
for (const blocked of blockedHostPaths) {
if (sourceNormalized === blocked || sourceNormalized.startsWith(blocked + "/")) {
const blockedKey = getSandboxHostPathPolicyKey(blocked);
if (sourceKey === blockedKey || sourceKey.startsWith(`${blockedKey}/`)) {
return { kind: "targets", blockedPath: blocked };
}
}
@@ -193,7 +197,7 @@ function normalizeAllowedRoots(roots: string[] | undefined): string[] {
}
const normalized = roots
.map((entry) => entry.trim())
.filter((entry) => entry.startsWith("/"))
.filter(isSandboxHostPathAbsolute)
.map(normalizeHostPath);
const expanded = new Set<string>();
for (const root of normalized) {
@@ -210,7 +214,9 @@ function isPathInsidePosix(root: string, target: string): boolean {
if (root === "/") {
return true;
}
return target === root || target.startsWith(`${root}/`);
const rootKey = getSandboxHostPathPolicyKey(root);
const targetKey = getSandboxHostPathPolicyKey(target);
return targetKey === rootKey || targetKey.startsWith(`${rootKey}/`);
}
function getOutsideAllowedRootsReason(
@@ -274,7 +280,7 @@ function formatBindBlockedError(params: { bind: string; reason: BlockedBindReaso
if (params.reason.kind === "non_absolute") {
return new Error(
`Sandbox security: bind mount "${params.bind}" uses a non-absolute source path ` +
`"${params.reason.sourcePath}". Only absolute POSIX paths are supported for sandbox binds.`,
`"${params.reason.sourcePath}". Only absolute POSIX or Windows drive-letter paths are supported for sandbox binds.`,
);
}
if (params.reason.kind === "outside_allowed_roots") {

View File

@@ -458,7 +458,10 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("- Read: Read file contents");
expect(prompt).toContain("- Exec: Run shell commands");
expect(prompt).toContain(
"- If exactly one skill clearly applies: read its SKILL.md at <location> with `Read`, then follow it.",
"- If exactly one skill clearly applies: read its SKILL.md at <location> with `Read`, then follow it. You MUST use the exact <location> value from <available_skills>; never guess, fabricate, or hard-code a skill file path.",
);
expect(prompt).toContain(
"- If multiple could apply: choose the most specific one, read its SKILL.md at <location> with `Read`, then follow it. You MUST use the exact <location> value from <available_skills>; never guess, fabricate, or hard-code a skill file path.",
);
expect(prompt).toContain("OpenClaw docs: /tmp/openclaw/docs");
expect(prompt).toContain(
@@ -644,7 +647,10 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("## Skills");
expect(prompt).toContain(
"- If exactly one skill clearly applies: read its SKILL.md at <location> with `read`, then follow it.",
"- If exactly one skill clearly applies: read its SKILL.md at <location> with `read`, then follow it. You MUST use the exact <location> value from <available_skills>; never guess, fabricate, or hard-code a skill file path.",
);
expect(prompt).toContain(
"- If multiple could apply: choose the most specific one, read its SKILL.md at <location> with `read`, then follow it. You MUST use the exact <location> value from <available_skills>; never guess, fabricate, or hard-code a skill file path.",
);
});

View File

@@ -202,8 +202,8 @@ function buildSkillsSection(params: { skillsPrompt?: string; readToolName: strin
return [
"## Skills (mandatory)",
"Before replying: scan <available_skills> <description> entries.",
`- If exactly one skill clearly applies: read its SKILL.md at <location> with \`${params.readToolName}\`, then follow it.`,
"- If multiple could apply: choose the most specific one, then read/follow it.",
`- If exactly one skill clearly applies: read its SKILL.md at <location> with \`${params.readToolName}\`, then follow it. You MUST use the exact <location> value from <available_skills>; never guess, fabricate, or hard-code a skill file path.`,
`- If multiple could apply: choose the most specific one, read its SKILL.md at <location> with \`${params.readToolName}\`, then follow it. You MUST use the exact <location> value from <available_skills>; never guess, fabricate, or hard-code a skill file path.`,
"- If none clearly apply: do not read any SKILL.md.",
"Constraints: never read more than one skill up front; only read after selecting.",
"- When a skill drives external API writes, assume rate limits: prefer fewer larger writes, avoid tight one-item loops, serialize bursts when possible, and respect 429/Retry-After.",

View File

@@ -8,6 +8,12 @@ const classifyPortListener = vi.hoisted(() =>
vi.fn<(_listener: unknown, _port: number) => PortListenerKind>(() => "gateway"),
);
const probeGateway = vi.hoisted(() => vi.fn());
const readBestEffortConfig = vi.hoisted(() => vi.fn(async () => ({})));
const resolveGatewayProbeAuthSafeWithSecretInputs = vi.hoisted(() =>
vi.fn<(_opts: unknown) => Promise<{ auth: { token?: string; password?: string } }>>(async () => ({
auth: {},
})),
);
vi.mock("../../infra/ports.js", () => ({
classifyPortListener: (listener: unknown, port: number) => classifyPortListener(listener, port),
@@ -19,6 +25,17 @@ vi.mock("../../gateway/probe.js", () => ({
probeGateway: (opts: unknown) => probeGateway(opts),
}));
vi.mock("../../config/io.js", () => ({
createConfigIO: () => ({
readBestEffortConfig: () => readBestEffortConfig(),
}),
}));
vi.mock("../../gateway/probe-auth.js", () => ({
resolveGatewayProbeAuthSafeWithSecretInputs: (opts: unknown) =>
resolveGatewayProbeAuthSafeWithSecretInputs(opts),
}));
vi.mock("../../utils.js", async () => {
const actual = await vi.importActual<typeof import("../../utils.js")>("../../utils.js");
return {
@@ -112,6 +129,10 @@ async function waitForStoppedFreeGatewayRestart() {
describe("inspectGatewayRestart", () => {
beforeEach(() => {
inspectPortUsage.mockReset();
readBestEffortConfig.mockReset();
readBestEffortConfig.mockResolvedValue({});
resolveGatewayProbeAuthSafeWithSecretInputs.mockReset();
resolveGatewayProbeAuthSafeWithSecretInputs.mockResolvedValue({ auth: {} });
inspectPortUsage.mockResolvedValue({
port: 0,
status: "free",
@@ -380,6 +401,52 @@ describe("inspectGatewayRestart", () => {
expect(snapshot.versionMismatch).toBeUndefined();
});
it("uses configured local probe auth while waiting for a matching-version restart", async () => {
readBestEffortConfig.mockResolvedValue({
gateway: { auth: { mode: "token", token: "probe-token" } },
});
resolveGatewayProbeAuthSafeWithSecretInputs.mockResolvedValue({
auth: { token: "probe-token" },
});
probeGateway.mockResolvedValue({
ok: true,
close: null,
server: { version: "2026.4.24", connId: "new" },
});
const service = makeGatewayService({ status: "running", pid: 8000 });
inspectPortUsage.mockResolvedValue({
port: 18789,
status: "busy",
listeners: [{ pid: 8000, commandLine: "openclaw-gateway" }],
hints: [],
});
const { waitForGatewayHealthyRestart } = await import("./restart-health.js");
const snapshot = await waitForGatewayHealthyRestart({
service,
port: 18789,
expectedVersion: "2026.4.24",
attempts: 1,
});
expect(snapshot).toMatchObject({
healthy: true,
gatewayVersion: "2026.4.24",
expectedVersion: "2026.4.24",
});
expect(resolveGatewayProbeAuthSafeWithSecretInputs).toHaveBeenCalledWith(
expect.objectContaining({
cfg: { gateway: { auth: { mode: "token", token: "probe-token" } } },
mode: "local",
}),
);
expect(probeGateway).toHaveBeenCalledWith(
expect.objectContaining({
auth: { token: "probe-token", password: undefined },
}),
);
});
it("stops waiting once the restarted gateway reports the wrong version", async () => {
probeGateway.mockResolvedValue({
ok: true,

View File

@@ -1,6 +1,9 @@
import type { PluginHealthErrorSummary } from "../../commands/health.types.js";
import { createConfigIO } from "../../config/io.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js";
import type { GatewayService } from "../../daemon/service.js";
import { resolveGatewayProbeAuthSafeWithSecretInputs } from "../../gateway/probe-auth.js";
import { probeGateway } from "../../gateway/probe.js";
import {
classifyPortListener,
@@ -61,6 +64,11 @@ type GatewayReachability = {
channelProbeErrors: Array<{ id: string; error: string }>;
};
type GatewayRestartProbeAuth = {
token?: string;
password?: string;
};
function hasListenerAttributionGap(portUsage: PortUsage): boolean {
if (portUsage.status !== "busy" || portUsage.listeners.length > 0) {
return false;
@@ -228,9 +236,12 @@ function applyChannelProbeErrors(snapshot: GatewayRestartSnapshot): GatewayResta
async function confirmGatewayReachable(params: {
port: number;
includeHealthDetails?: boolean;
auth?: GatewayRestartProbeAuth;
}): Promise<GatewayReachability> {
const token = normalizeOptionalString(process.env.OPENCLAW_GATEWAY_TOKEN);
const password = normalizeOptionalString(process.env.OPENCLAW_GATEWAY_PASSWORD);
const token = normalizeOptionalString(params.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN);
const password = normalizeOptionalString(
params.auth?.password ?? process.env.OPENCLAW_GATEWAY_PASSWORD,
);
const probe = await probeGateway({
url: `ws://127.0.0.1:${params.port}`,
auth: token || password ? { token, password } : undefined,
@@ -251,13 +262,37 @@ async function confirmGatewayReachable(params: {
};
}
async function inspectGatewayPortHealth(port: number): Promise<GatewayPortHealthSnapshot> {
async function resolveGatewayRestartProbeAuth(
env: NodeJS.ProcessEnv | undefined,
): Promise<GatewayRestartProbeAuth | undefined> {
const mergedEnv = {
...(process.env as Record<string, string | undefined>),
...(env ?? undefined),
} as NodeJS.ProcessEnv;
const cfg = await createConfigIO({
env: mergedEnv,
pluginValidation: "skip",
})
.readBestEffortConfig()
.catch((): OpenClawConfig => ({}));
const resolved = await resolveGatewayProbeAuthSafeWithSecretInputs({
cfg,
mode: "local",
env: mergedEnv,
});
return resolved.auth;
}
async function inspectGatewayPortHealth(params: {
port: number;
auth?: GatewayRestartProbeAuth;
}): Promise<GatewayPortHealthSnapshot> {
let portUsage: PortUsage;
try {
portUsage = await inspectPortUsage(port);
portUsage = await inspectPortUsage(params.port);
} catch (err) {
portUsage = {
port,
port: params.port,
status: "unknown",
listeners: [],
hints: [],
@@ -268,7 +303,12 @@ async function inspectGatewayPortHealth(port: number): Promise<GatewayPortHealth
let healthy = false;
if (portUsage.status === "busy") {
try {
healthy = (await confirmGatewayReachable({ port })).reachable;
healthy = (
await confirmGatewayReachable({
port: params.port,
auth: params.auth,
})
).reachable;
} catch {
// best-effort probe
}
@@ -283,6 +323,7 @@ export async function inspectGatewayRestart(params: {
env?: NodeJS.ProcessEnv;
expectedVersion?: string | null;
includeUnknownListenersAsStale?: boolean;
probeAuth?: GatewayRestartProbeAuth;
}): Promise<GatewayRestartSnapshot> {
const env = params.env ?? process.env;
const expectedVersion = normalizeOptionalString(params.expectedVersion);
@@ -294,6 +335,7 @@ export async function inspectGatewayRestart(params: {
reachability = await confirmGatewayReachable({
port: params.port,
includeHealthDetails: Boolean(expectedVersion),
auth: params.probeAuth,
});
activatedPluginErrors = reachability.activatedPluginErrors;
channelProbeErrors = reachability.channelProbeErrors;
@@ -477,12 +519,14 @@ export async function waitForGatewayHealthyRestart(params: {
const attempts = params.attempts ?? DEFAULT_RESTART_HEALTH_ATTEMPTS;
const delayMs = params.delayMs ?? DEFAULT_RESTART_HEALTH_DELAY_MS;
const probeAuth = await resolveGatewayRestartProbeAuth(params.env).catch(() => undefined);
let snapshot = await inspectGatewayRestart({
service: params.service,
port: params.port,
env: params.env,
expectedVersion: params.expectedVersion,
includeUnknownListenersAsStale: params.includeUnknownListenersAsStale,
probeAuth,
});
let consecutiveStoppedFreeCount = 0;
@@ -523,6 +567,7 @@ export async function waitForGatewayHealthyRestart(params: {
env: params.env,
expectedVersion: params.expectedVersion,
includeUnknownListenersAsStale: params.includeUnknownListenersAsStale,
probeAuth,
});
}
@@ -537,14 +582,21 @@ export async function waitForGatewayHealthyListener(params: {
const attempts = params.attempts ?? DEFAULT_RESTART_HEALTH_ATTEMPTS;
const delayMs = params.delayMs ?? DEFAULT_RESTART_HEALTH_DELAY_MS;
let snapshot = await inspectGatewayPortHealth(params.port);
const probeAuth = await resolveGatewayRestartProbeAuth(undefined).catch(() => undefined);
let snapshot = await inspectGatewayPortHealth({
port: params.port,
auth: probeAuth,
});
for (let attempt = 0; attempt < attempts; attempt += 1) {
if (snapshot.healthy) {
return snapshot;
}
await sleep(delayMs);
snapshot = await inspectGatewayPortHealth(params.port);
snapshot = await inspectGatewayPortHealth({
port: params.port,
auth: probeAuth,
});
}
return snapshot;

View File

@@ -1096,6 +1096,34 @@ describe("plugins cli install", () => {
expect(runtimeErrors.at(-1)).toContain("npm install failed");
});
it("adds a Git PATH hint when npm plugin dependency install cannot spawn git", async () => {
loadConfig.mockReturnValue({} as OpenClawConfig);
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: [
"npm install failed:",
"npm error code ENOENT",
"npm error syscall spawn git",
"npm error path git",
].join("\n"),
});
installHooksFromNpmSpec.mockResolvedValue({
ok: false,
error: "package.json missing openclaw.hooks",
});
await expect(
runPluginsCommand(["plugins", "install", "npm:@openclaw/whatsapp"]),
).rejects.toThrow("__exit__:1");
expect(installPluginFromClawHub).not.toHaveBeenCalled();
expect(runtimeErrors.at(-1)).toContain(
"one of this plugin's npm dependencies is fetched from a git URL",
);
expect(runtimeErrors.at(-1)).toContain("winget install --id Git.Git -e");
expect(runtimeErrors.at(-1)).toContain("Also not a valid hook pack");
});
it("does not resolve npm: prefixed bundled plugin ids through bundled installs", async () => {
loadConfig.mockReturnValue({ plugins: { load: { paths: [] } } } as OpenClawConfig);
installPluginFromNpmSpec.mockResolvedValue({

View File

@@ -176,16 +176,36 @@ export function formatPluginInstallWithHookFallbackError(
pluginError: string,
hookError: string,
): string {
const formattedPluginError = formatPluginInstallAttemptError(pluginError);
const formattedHookError = formatPluginInstallAttemptError(hookError);
if (/plugin already exists: .+ \(delete it first\)/.test(pluginError)) {
return `${pluginError}\nUse \`openclaw plugins update <id-or-npm-spec>\` to upgrade the tracked plugin, or rerun install with \`--force\` to replace it.`;
return `${formattedPluginError}\nUse \`openclaw plugins update <id-or-npm-spec>\` to upgrade the tracked plugin, or rerun install with \`--force\` to replace it.`;
}
if (
pluginError.startsWith("Invalid extensions directory:") ||
pluginError === "Invalid path: must stay within extensions directory"
) {
return pluginError;
return formattedPluginError;
}
return `${pluginError}\nAlso not a valid hook pack: ${hookError}`;
return `${formattedPluginError}\nAlso not a valid hook pack: ${formattedHookError}`;
}
const MISSING_GIT_FOR_NPM_DEPENDENCY_HINT =
"Git is required because one of this plugin's npm dependencies is fetched from a git URL, but `git` was not found on PATH. Install Git and rerun the install. On Windows, use `winget install --id Git.Git -e` or add a portable Git `bin` directory to PATH.";
function formatPluginInstallAttemptError(error: string): string {
if (!isMissingGitForNpmDependencyError(error)) {
return error;
}
if (error.includes(MISSING_GIT_FOR_NPM_DEPENDENCY_HINT)) {
return error;
}
return `${error}\n\n${MISSING_GIT_FOR_NPM_DEPENDENCY_HINT}`;
}
function isMissingGitForNpmDependencyError(error: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(error);
return /\bspawn\s+git\b/u.test(normalized) && /\benoent\b/u.test(normalized);
}
export function logHookPackRestartHint(runtime: RuntimeEnv = defaultRuntime) {

View File

@@ -437,6 +437,54 @@ describe("ensureChannelSetupPluginInstalled", () => {
expect(await runInitialValueForChannel("beta")).toBe("npm");
});
it("installs npm beta on the beta channel without persisting the beta tag", async () => {
const runtime = makeRuntime();
const { prompter, select } = makeSkipInstallPrompter();
const cfg: OpenClawConfig = { update: { channel: "beta" } };
vi.mocked(fs.existsSync).mockReturnValue(false);
installPluginFromNpmSpec.mockResolvedValue({
ok: true,
pluginId: "wecom-openclaw-plugin",
targetDir: "/tmp/wecom-openclaw-plugin",
version: "2026.5.4-beta.1",
npmResolution: {
name: "@openclaw/wecom",
version: "2026.5.4-beta.1",
resolvedSpec: "@openclaw/wecom@2026.5.4-beta.1",
},
});
const result = await ensureChannelSetupPluginInstalled({
cfg,
entry: {
id: "wecom",
pluginId: "wecom-openclaw-plugin",
meta: {
id: "wecom",
label: "WeCom",
selectionLabel: "WeCom",
docsPath: "/channels/wecom",
blurb: "WeCom channel",
},
install: {
npmSpec: "@openclaw/wecom",
},
},
prompter,
runtime,
promptInstall: false,
});
expect(select).not.toHaveBeenCalled();
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/wecom@beta",
expectedPluginId: "wecom-openclaw-plugin",
}),
);
expect(result.cfg.plugins?.installs?.["wecom-openclaw-plugin"]?.spec).toBe("@openclaw/wecom");
});
it("defaults to bundled local path on beta channel when available", async () => {
const runtime = makeRuntime();
const { prompter, select } = makeSkipInstallPrompter();

View File

@@ -111,6 +111,21 @@ describe("doctor command", () => {
throw new Error("missing browser doctor facade");
});
vi.doMock("../plugin-sdk/facade-loader.js", () => ({
createLazyFacadeArrayValue: <T extends readonly unknown[]>(load: () => T): T =>
new Proxy([], {
get(_target, property, receiver) {
return Reflect.get(load(), property, receiver);
},
}) as unknown as T,
createLazyFacadeObjectValue: <T extends object>(load: () => T): T =>
new Proxy(
{},
{
get(_target, property, receiver) {
return Reflect.get(load(), property, receiver);
},
},
) as T,
loadBundledPluginPublicSurfaceModuleSync,
}));
doctorCommand = await loadDoctorCommandForTest({

View File

@@ -1,4 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { VERSION } from "../../../version.js";
function expectedDefaultNpmInstallSpec(spec: string): string {
return /(?:^|[.-])beta(?:[.-]|$)/i.test(VERSION) ? `${spec}@beta` : spec;
}
function expectedMissingInstallChange(pluginId: string, spec: string): string {
return `Installed missing configured plugin "${pluginId}" from ${expectedDefaultNpmInstallSpec(spec)}.`;
}
const mocks = vi.hoisted(() => ({
installPluginFromClawHub: vi.fn(),
@@ -354,14 +363,12 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/twitch",
spec: expectedDefaultNpmInstallSpec("@openclaw/twitch"),
expectedPluginId: "twitch",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(result.changes).toEqual([
'Installed missing configured plugin "twitch" from @openclaw/twitch.',
]);
expect(result.changes).toEqual([expectedMissingInstallChange("twitch", "@openclaw/twitch")]);
});
it("installs missing configured non-channel plugins from the official external catalog", async () => {
@@ -406,12 +413,12 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/diagnostics-otel",
spec: expectedDefaultNpmInstallSpec("@openclaw/diagnostics-otel"),
expectedPluginId: "diagnostics-otel",
}),
);
expect(result.changes).toEqual([
'Installed missing configured plugin "diagnostics-otel" from @openclaw/diagnostics-otel.',
expectedMissingInstallChange("diagnostics-otel", "@openclaw/diagnostics-otel"),
]);
});
@@ -453,14 +460,12 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/acpx",
spec: expectedDefaultNpmInstallSpec("@openclaw/acpx"),
expectedPluginId: "acpx",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(result.changes).toEqual([
'Installed missing configured plugin "acpx" from @openclaw/acpx.',
]);
expect(result.changes).toEqual([expectedMissingInstallChange("acpx", "@openclaw/acpx")]);
});
it("does not install disabled configured plugin entries", async () => {
@@ -1176,7 +1181,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(mocks.resolveProviderInstallCatalogEntries).toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/codex",
spec: expectedDefaultNpmInstallSpec("@openclaw/codex"),
expectedPluginId: "codex",
trustedSourceLinkedOfficialInstall: true,
}),
@@ -1192,9 +1197,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
}),
{ env: {} },
);
expect(result.changes).toEqual([
'Installed missing configured plugin "codex" from @openclaw/codex.',
]);
expect(result.changes).toEqual([expectedMissingInstallChange("codex", "@openclaw/codex")]);
expect(result.warnings).toEqual([]);
});
@@ -1254,7 +1257,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/codex",
spec: expectedDefaultNpmInstallSpec("@openclaw/codex"),
expectedPluginId: "codex",
trustedSourceLinkedOfficialInstall: true,
}),
@@ -1271,7 +1274,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
{ env },
);
expect(result).toEqual({
changes: ['Installed missing configured plugin "codex" from @openclaw/codex.'],
changes: [expectedMissingInstallChange("codex", "@openclaw/codex")],
warnings: [],
});
});
@@ -1427,14 +1430,12 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/feishu",
spec: expectedDefaultNpmInstallSpec("@openclaw/feishu"),
expectedPluginId: "feishu",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(result.changes).toEqual([
'Installed missing configured plugin "feishu" from @openclaw/feishu.',
]);
expect(result.changes).toEqual([expectedMissingInstallChange("feishu", "@openclaw/feishu")]);
});
it("still installs a channel catalog plugin when that plugin is explicitly configured", async () => {
@@ -1505,14 +1506,12 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/feishu",
spec: expectedDefaultNpmInstallSpec("@openclaw/feishu"),
expectedPluginId: "feishu",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(result.changes).toEqual([
'Installed missing configured plugin "feishu" from @openclaw/feishu.',
]);
expect(result.changes).toEqual([expectedMissingInstallChange("feishu", "@openclaw/feishu")]);
});
it("reinstalls a missing configured plugin from its persisted install record", async () => {
@@ -1660,7 +1659,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
);
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/discord",
spec: expectedDefaultNpmInstallSpec("@openclaw/discord"),
expectedPluginId: "discord",
trustedSourceLinkedOfficialInstall: true,
}),
@@ -1671,9 +1670,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
}),
{ env: {} },
);
expect(result.changes).toEqual([
'Installed missing configured plugin "discord" from @openclaw/discord.',
]);
expect(result.changes).toEqual([expectedMissingInstallChange("discord", "@openclaw/discord")]);
});
it("updates a known configured plugin when its installed manifest path still exists", async () => {
@@ -2005,13 +2002,100 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/brave-plugin",
spec: expectedDefaultNpmInstallSpec("@openclaw/brave-plugin"),
expectedPluginId: "brave",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(result.changes).toEqual([
'Installed missing configured plugin "brave" from @openclaw/brave-plugin.',
expectedMissingInstallChange("brave", "@openclaw/brave-plugin"),
]);
});
it("installs configured external web search plugins from beta on the beta channel", async () => {
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
{
id: "brave",
label: "Brave",
install: {
npmSpec: "@openclaw/brave-plugin",
defaultChoice: "npm",
},
openclaw: {
plugin: { id: "brave", label: "Brave" },
webSearchProviders: [
{
id: "brave",
label: "Brave Search",
hint: "Brave Search",
envVars: ["BRAVE_API_KEY"],
placeholder: "BSA...",
signupUrl: "https://example.test/brave",
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
},
],
install: {
npmSpec: "@openclaw/brave-plugin",
defaultChoice: "npm",
},
},
},
]);
mocks.resolveOfficialExternalPluginId.mockImplementation(
(entry: { id?: string; openclaw?: { plugin?: { id?: string } } }) =>
entry.openclaw?.plugin?.id ?? entry.id,
);
mocks.resolveOfficialExternalPluginInstall.mockImplementation(
(entry: { install?: unknown; openclaw?: { install?: unknown } }) =>
entry.openclaw?.install ?? entry.install ?? null,
);
mocks.resolveOfficialExternalPluginLabel.mockImplementation(
(entry: { label?: string; openclaw?: { plugin?: { label?: string } } }) =>
entry.openclaw?.plugin?.label ?? entry.label ?? "plugin",
);
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
ok: true,
pluginId: "brave",
targetDir: "/tmp/openclaw-plugins/brave",
version: "2026.5.4-beta.1",
npmResolution: {
name: "@openclaw/brave-plugin",
version: "2026.5.4-beta.1",
resolvedSpec: "@openclaw/brave-plugin@2026.5.4-beta.1",
},
});
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
update: { channel: "beta" },
tools: {
web: {
search: {
provider: "brave",
},
},
},
},
env: {},
});
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/brave-plugin@beta",
expectedPluginId: "brave",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
expect.objectContaining({
brave: expect.objectContaining({ spec: "@openclaw/brave-plugin" }),
}),
{ env: {} },
);
expect(result.changes).toEqual([
'Installed missing configured plugin "brave" from @openclaw/brave-plugin@beta.',
]);
});

View File

@@ -9,9 +9,18 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../../config/types.plugins.js";
import { parseClawHubPluginSpec } from "../../../infra/clawhub-spec.js";
import { parseRegistryNpmSpec } from "../../../infra/npm-registry-spec.js";
import {
normalizeUpdateChannel,
resolveRegistryUpdateChannel,
type UpdateChannel,
} from "../../../infra/update-channels.js";
import { resolveConfiguredChannelPresencePolicy } from "../../../plugins/channel-plugin-ids.js";
import { buildClawHubPluginInstallRecordFields } from "../../../plugins/clawhub-install-records.js";
import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "../../../plugins/clawhub.js";
import {
resolveClawHubInstallSpecsForUpdateChannel,
resolveNpmInstallSpecsForUpdateChannel,
} from "../../../plugins/install-channel-specs.js";
import { resolveDefaultPluginExtensionsDir } from "../../../plugins/install-paths.js";
import { installPluginFromNpmSpec } from "../../../plugins/install.js";
import { loadInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js";
@@ -32,6 +41,7 @@ import { updateNpmInstalledPlugins } from "../../../plugins/update.js";
import { resolveWebSearchInstallCatalogEntry } from "../../../plugins/web-search-install-catalog.js";
import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js";
import { resolveUserPath } from "../../../utils.js";
import { VERSION } from "../../../version.js";
import { asObjectRecord } from "./object.js";
type DownloadableInstallCandidate = {
@@ -457,6 +467,7 @@ function recordClawHubPackageName(value: string | undefined): string | undefined
async function installCandidate(params: {
candidate: DownloadableInstallCandidate;
records: Record<string, PluginInstallRecord>;
updateChannel?: UpdateChannel;
}): Promise<{
records: Record<string, PluginInstallRecord>;
changes: string[];
@@ -465,9 +476,23 @@ async function installCandidate(params: {
const { candidate } = params;
const extensionsDir = resolveDefaultPluginExtensionsDir();
const changes: string[] = [];
if (candidate.clawhubSpec && candidate.defaultChoice !== "npm") {
const clawhubSpecs = candidate.clawhubSpec
? resolveClawHubInstallSpecsForUpdateChannel({
spec: candidate.clawhubSpec,
updateChannel: params.updateChannel,
})
: null;
const npmSpecs = candidate.npmSpec
? resolveNpmInstallSpecsForUpdateChannel({
spec: candidate.npmSpec,
updateChannel: params.updateChannel,
})
: null;
const clawhubInstallSpec = clawhubSpecs?.installSpec ?? candidate.clawhubSpec;
const npmInstallSpec = npmSpecs?.installSpec ?? candidate.npmSpec;
if (clawhubInstallSpec && candidate.defaultChoice !== "npm") {
const clawhubResult = await installPluginFromClawHub({
spec: candidate.clawhubSpec,
spec: clawhubInstallSpec,
extensionsDir,
expectedPluginId: candidate.pluginId,
mode: "install",
@@ -479,31 +504,29 @@ async function installCandidate(params: {
...params.records,
[pluginId]: {
...buildClawHubPluginInstallRecordFields(clawhubResult.clawhub),
spec: candidate.clawhubSpec,
spec: clawhubSpecs?.recordSpec ?? clawhubInstallSpec,
installPath: clawhubResult.targetDir,
installedAt: new Date().toISOString(),
},
},
changes: [
`Installed missing configured plugin "${pluginId}" from ${candidate.clawhubSpec}.`,
],
changes: [`Installed missing configured plugin "${pluginId}" from ${clawhubInstallSpec}.`],
warnings: [],
};
}
if (!candidate.npmSpec || !shouldFallbackClawHubToNpm(clawhubResult)) {
if (!npmInstallSpec || !shouldFallbackClawHubToNpm(clawhubResult)) {
return {
records: params.records,
changes: [],
warnings: [
`Failed to install missing configured plugin "${candidate.pluginId}" from ${candidate.clawhubSpec}: ${clawhubResult.error}`,
`Failed to install missing configured plugin "${candidate.pluginId}" from ${clawhubInstallSpec}: ${clawhubResult.error}`,
],
};
}
changes.push(
`ClawHub ${candidate.clawhubSpec} unavailable for "${candidate.pluginId}"; falling back to npm ${candidate.npmSpec}.`,
`ClawHub ${clawhubInstallSpec} unavailable for "${candidate.pluginId}"; falling back to npm ${npmInstallSpec}.`,
);
}
if (!candidate.npmSpec) {
if (!npmInstallSpec) {
return {
records: params.records,
changes: [],
@@ -513,7 +536,7 @@ async function installCandidate(params: {
};
}
const result = await installPluginFromNpmSpec({
spec: candidate.npmSpec,
spec: npmInstallSpec,
extensionsDir,
expectedPluginId: candidate.pluginId,
expectedIntegrity: candidate.expectedIntegrity,
@@ -527,7 +550,7 @@ async function installCandidate(params: {
records: params.records,
changes: [],
warnings: [
`Failed to install missing configured plugin "${candidate.pluginId}" from ${candidate.npmSpec}: ${result.error}`,
`Failed to install missing configured plugin "${candidate.pluginId}" from ${npmInstallSpec}: ${result.error}`,
],
};
}
@@ -537,7 +560,7 @@ async function installCandidate(params: {
...params.records,
[pluginId]: {
source: "npm",
spec: candidate.npmSpec,
spec: npmSpecs?.recordSpec ?? npmInstallSpec,
installPath: result.targetDir,
version: result.version,
installedAt: new Date().toISOString(),
@@ -546,7 +569,7 @@ async function installCandidate(params: {
},
changes: [
...changes,
`Installed missing configured plugin "${pluginId}" from ${candidate.npmSpec}.`,
`Installed missing configured plugin "${pluginId}" from ${npmInstallSpec}.`,
],
warnings: [],
};
@@ -642,6 +665,10 @@ async function repairMissingPluginInstalls(params: {
const changes: string[] = [];
const warnings: string[] = [];
const deferredPluginIds = new Set<string>();
const updateChannel = resolveRegistryUpdateChannel({
configChannel: normalizeUpdateChannel(params.cfg.update?.channel),
currentVersion: VERSION,
});
let nextRecords = records;
for (const [pluginId, record] of Object.entries(records)) {
@@ -700,7 +727,7 @@ async function repairMissingPluginInstalls(params: {
},
},
pluginIds: missingRecordedPluginIds,
updateChannel: params.cfg.update?.channel,
updateChannel,
logger: {
warn: (message) => warnings.push(message),
error: (message) => warnings.push(message),
@@ -754,7 +781,7 @@ async function repairMissingPluginInstalls(params: {
if (hasUsableRecord) {
continue;
}
const installed = await installCandidate({ candidate, records: nextRecords });
const installed = await installCandidate({ candidate, records: nextRecords, updateChannel });
nextRecords = installed.records;
changes.push(...installed.changes);
warnings.push(...installed.warnings);

View File

@@ -4,6 +4,7 @@ import { resolveBundledInstallPlanForCatalogEntry } from "../cli/plugin-install-
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import { normalizeUpdateChannel, resolveRegistryUpdateChannel } from "../infra/update-channels.js";
import {
findBundledPluginSourceInMap,
resolveBundledPluginSources,
@@ -11,6 +12,10 @@ import {
import { buildClawHubPluginInstallRecordFields } from "../plugins/clawhub-install-records.js";
import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub.js";
import { enablePluginInConfig, type PluginEnableResult } from "../plugins/enable.js";
import {
resolveClawHubInstallSpecsForUpdateChannel,
resolveNpmInstallSpecsForUpdateChannel,
} from "../plugins/install-channel-specs.js";
import { resolveDefaultPluginExtensionsDir } from "../plugins/install-paths.js";
import { installPluginFromNpmSpec } from "../plugins/install.js";
import { buildNpmResolutionInstallFields, recordPluginInstall } from "../plugins/installs.js";
@@ -18,6 +23,7 @@ import type { PluginPackageInstall } from "../plugins/manifest.js";
import type { RuntimeEnv } from "../runtime.js";
import { sanitizeTerminalText } from "../terminal/safe-text.js";
import { withTimeout } from "../utils/with-timeout.js";
import { VERSION } from "../version.js";
import type { WizardPrompter } from "../wizard/prompts.js";
type InstallChoice = "clawhub" | "npm" | "local" | "skip";
@@ -325,6 +331,8 @@ async function promptInstallChoice(params: {
* to that source. Useful when the caller already knows the user's intent
* (e.g. they just picked the channel in a previous menu). */
autoConfirmSingleSource?: boolean;
effectiveNpmSpec?: string | null;
effectiveClawHubSpec?: string | null;
}): Promise<InstallChoice> {
const rawClawHubSpec = resolveClawHubSpecForOnboarding(params.entry.install);
const rawNpmSpec = resolveNpmSpecForOnboarding(params.entry.install);
@@ -336,8 +344,10 @@ async function promptInstallChoice(params: {
// case is misleading; those catalog specs only exist as fallback metadata for
// non-bundled builds. Hide them so bundled channels like Tlon look identical
// to Twitch / Slack in the menu.
const clawhubSpec = params.bundledLocalPath ? null : rawClawHubSpec;
const npmSpec = params.bundledLocalPath ? null : rawNpmSpec;
const clawhubSpec = params.bundledLocalPath
? null
: (params.effectiveClawHubSpec ?? rawClawHubSpec);
const npmSpec = params.bundledLocalPath ? null : (params.effectiveNpmSpec ?? rawNpmSpec);
const safeLabel = sanitizeTerminalText(params.entry.label);
const safeClawHubSpec = clawhubSpec ? sanitizeTerminalText(clawhubSpec) : null;
const safeNpmSpec = npmSpec ? sanitizeTerminalText(npmSpec) : null;
@@ -729,6 +739,24 @@ export async function ensureOnboardingPluginInstalled(params: {
});
const clawhubSpec = resolveClawHubSpecForOnboarding(entry.install);
const npmSpec = resolveNpmSpecForOnboarding(entry.install);
const updateChannel = resolveRegistryUpdateChannel({
configChannel: normalizeUpdateChannel(next.update?.channel),
currentVersion: VERSION,
});
const clawhubSpecs = clawhubSpec
? resolveClawHubInstallSpecsForUpdateChannel({
spec: clawhubSpec,
updateChannel,
})
: null;
const npmSpecs = npmSpec
? resolveNpmInstallSpecsForUpdateChannel({
spec: npmSpec,
updateChannel,
})
: null;
const clawhubInstallSpec = clawhubSpecs?.installSpec ?? clawhubSpec;
const npmInstallSpec = npmSpecs?.installSpec ?? npmSpec;
const defaultChoice = resolveInstallDefaultChoice({
cfg: next,
entry,
@@ -747,6 +775,8 @@ export async function ensureOnboardingPluginInstalled(params: {
defaultChoice,
prompter,
autoConfirmSingleSource: params.autoConfirmSingleSource,
effectiveClawHubSpec: clawhubInstallSpec,
effectiveNpmSpec: npmInstallSpec,
});
if (choice === "skip") {
@@ -793,10 +823,10 @@ export async function ensureOnboardingPluginInstalled(params: {
}
let shouldTryNpm = choice === "npm";
if (choice === "clawhub" && clawhubSpec) {
if (choice === "clawhub" && clawhubInstallSpec) {
const installOutcome = await installPluginFromClawHubSpecWithProgress({
entry,
clawhubSpec,
clawhubSpec: clawhubInstallSpec,
prompter,
runtime,
});
@@ -804,13 +834,13 @@ export async function ensureOnboardingPluginInstalled(params: {
if (installOutcome.status === "timed_out") {
await prompter.note(
[
`Installing ${sanitizeTerminalText(clawhubSpec)} timed out after ${formatDurationLabel(ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS)}.`,
`Installing ${sanitizeTerminalText(clawhubInstallSpec)} timed out after ${formatDurationLabel(ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS)}.`,
"Returning to selection.",
].join("\n"),
"Plugin install",
);
runtime.error?.(
`Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(clawhubSpec)}`,
`Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(clawhubInstallSpec)}`,
);
return {
cfg: next,
@@ -841,7 +871,7 @@ export async function ensureOnboardingPluginInstalled(params: {
next = recordPluginInstall(next, {
pluginId: result.pluginId,
...buildClawHubPluginInstallRecordFields(result.clawhub),
spec: clawhubSpec,
spec: clawhubSpecs?.recordSpec ?? clawhubInstallSpec,
installPath: result.targetDir,
});
return {
@@ -854,13 +884,13 @@ export async function ensureOnboardingPluginInstalled(params: {
await prompter.note(
[
`Failed to install ${sanitizeTerminalText(clawhubSpec)}: ${summarizeInstallError(result.error)}`,
`Failed to install ${sanitizeTerminalText(clawhubInstallSpec)}: ${summarizeInstallError(result.error)}`,
"Returning to selection.",
].join("\n"),
"Plugin install",
);
if (!npmSpec || !shouldFallbackClawHubToNpm(result)) {
if (!npmInstallSpec || !shouldFallbackClawHubToNpm(result)) {
runtime.error?.(`Plugin install failed: ${sanitizeTerminalText(result.error)}`);
return {
cfg: next,
@@ -871,7 +901,7 @@ export async function ensureOnboardingPluginInstalled(params: {
}
shouldTryNpm = await prompter.confirm({
message: `Use npm package instead? (${sanitizeTerminalText(npmSpec)})`,
message: `Use npm package instead? (${sanitizeTerminalText(npmInstallSpec)})`,
initialValue: true,
});
if (!shouldTryNpm) {
@@ -885,7 +915,7 @@ export async function ensureOnboardingPluginInstalled(params: {
}
}
if (!shouldTryNpm || !npmSpec) {
if (!shouldTryNpm || !npmInstallSpec) {
await prompter.note(
`No remote install source is available for ${sanitizeTerminalText(entry.label)}. Returning to selection.`,
"Plugin install",
@@ -903,7 +933,7 @@ export async function ensureOnboardingPluginInstalled(params: {
const installOutcome = await installPluginFromNpmSpecWithProgress({
entry,
npmSpec,
npmSpec: npmInstallSpec,
prompter,
runtime,
});
@@ -911,13 +941,13 @@ export async function ensureOnboardingPluginInstalled(params: {
if (installOutcome.status === "timed_out") {
await prompter.note(
[
`Installing ${sanitizeTerminalText(npmSpec)} timed out after ${formatDurationLabel(ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS)}.`,
`Installing ${sanitizeTerminalText(npmInstallSpec)} timed out after ${formatDurationLabel(ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS)}.`,
"Returning to selection.",
].join("\n"),
"Plugin install",
);
runtime.error?.(
`Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(npmSpec)}`,
`Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(npmInstallSpec)}`,
);
return {
cfg: next,
@@ -949,7 +979,7 @@ export async function ensureOnboardingPluginInstalled(params: {
const install = {
pluginId: result.pluginId,
source: "npm",
spec: npmSpec,
spec: npmSpecs?.recordSpec ?? npmInstallSpec,
installPath: result.targetDir,
version: result.version,
...buildNpmResolutionInstallFields(result.npmResolution),
@@ -965,7 +995,7 @@ export async function ensureOnboardingPluginInstalled(params: {
await prompter.note(
[
`Failed to install ${sanitizeTerminalText(npmSpec)}: ${summarizeInstallError(result.error)}`,
`Failed to install ${sanitizeTerminalText(npmInstallSpec)}: ${summarizeInstallError(result.error)}`,
"Returning to selection.",
].join("\n"),
"Plugin install",

View File

@@ -62,6 +62,42 @@ describe("sandbox docker config", () => {
}
});
it("accepts Windows drive-letter binds in sandbox.docker config", () => {
const res = validateConfigObject({
agents: {
defaults: {
sandbox: {
docker: {
binds: ["D:/data/openclaw/src:/src:ro", "D:\\data\\openclaw\\output:/output:rw"],
},
},
},
},
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.agents?.defaults?.sandbox?.docker?.binds).toEqual([
"D:/data/openclaw/src:/src:ro",
"D:\\data\\openclaw\\output:/output:rw",
]);
}
});
it("rejects drive-relative Windows binds in sandbox.docker config", () => {
const res = validateConfigObject({
agents: {
defaults: {
sandbox: {
docker: {
binds: ["D:relative\\path:/src:ro"],
},
},
},
},
});
expect(res.ok).toBe(false);
});
it("accepts non-empty Docker GPU passthrough config", () => {
const res = validateConfigObject({
agents: {

View File

@@ -1,4 +1,6 @@
import { z } from "zod";
import { splitSandboxBindSpec } from "../agents/sandbox/bind-spec.js";
import { isSandboxHostPathAbsolute } from "../agents/sandbox/host-paths.js";
import { getBlockedNetworkModeReason } from "../agents/sandbox/network-mode.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import {
@@ -158,15 +160,16 @@ const SandboxDockerSchema = z
});
continue;
}
const firstColon = bind.indexOf(":");
const source = (firstColon <= 0 ? bind : bind.slice(0, firstColon)).trim();
if (!source.startsWith("/")) {
const parsed = splitSandboxBindSpec(bind);
const source = (parsed ? parsed.host : bind).trim();
if (!isSandboxHostPathAbsolute(source)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["binds", i],
message:
`Sandbox security: bind mount "${bind}" uses a non-absolute source path "${source}". ` +
"Only absolute POSIX paths are supported for sandbox binds.",
"Only absolute POSIX or Windows drive-letter paths are supported for sandbox binds.",
});
}
}

View File

@@ -542,7 +542,7 @@ describe("resolveGatewayLiveSuiteTimeoutMs", () => {
});
it("scales model-capped sweeps for multi-probe retries", () => {
expect(resolveGatewayLiveSuiteTimeoutMs(2)).toBeGreaterThan(GATEWAY_LIVE_DEFAULT_TIMEOUT_MS);
expect(resolveGatewayLiveSuiteTimeoutMs(4)).toBeGreaterThan(GATEWAY_LIVE_DEFAULT_TIMEOUT_MS);
});
it("caps very large model sweeps", () => {

View File

@@ -290,6 +290,10 @@ describe("resolveClientIp", () => {
});
describe("resolveGatewayListenHosts", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it.each([
{
name: "non-loopback host passthrough",
@@ -312,11 +316,28 @@ describe("resolveGatewayListenHosts", () => {
expected: ["127.0.0.1"],
},
] as const)("resolves listen hosts: $name", async ({ host, canBindToHost, expected }) => {
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
const hosts = await resolveGatewayListenHosts(host, {
canBindToHost,
});
expect(hosts).toEqual(expected);
});
it("skips ::1 on Windows even when IPv6 is bindable", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const canBindToHost = vi.fn().mockResolvedValue(true);
const hosts = await resolveGatewayListenHosts("127.0.0.1", { canBindToHost });
expect(hosts).toEqual(["127.0.0.1"]);
expect(canBindToHost).not.toHaveBeenCalled();
});
it("still includes ::1 on non-Windows when IPv6 is bindable", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
const canBindToHost = vi.fn().mockResolvedValue(true);
const hosts = await resolveGatewayListenHosts("127.0.0.1", { canBindToHost });
expect(hosts).toEqual(["127.0.0.1", "::1"]);
expect(canBindToHost).toHaveBeenCalledWith("::1");
});
});
describe("pickPrimaryLanIPv4", () => {

View File

@@ -330,6 +330,12 @@ export async function resolveGatewayListenHosts(
if (bindHost !== "127.0.0.1") {
return [bindHost];
}
// Windows: uv_tcp_bind6 creates a dual-stack socket (no UV_TCP_IPV6ONLY), which
// also accepts ::ffff:127.0.0.1 connections. Binding both ::1 and 127.0.0.1 on
// the same port causes non-deterministic TCP routing → HTTP requests hang silently.
if (process.platform === "win32") {
return [bindHost];
}
const canBind = opts?.canBindToHost ?? canBindToHost;
if (await canBind("::1")) {
return [bindHost, "::1"];

View File

@@ -24,7 +24,11 @@ export function expectedIntegrityForUpdate(
return integrity;
}
export async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readInstalledPackageManifest(dir: string): Record<string, unknown> | undefined {
const manifestPath = path.join(dir, "package.json");
const opened = openBoundaryFileSync({
absolutePath: manifestPath,
@@ -35,12 +39,32 @@ export async function readInstalledPackageVersion(dir: string): Promise<string |
return undefined;
}
try {
const raw = fsSync.readFileSync(opened.fd, "utf-8");
const parsed = JSON.parse(raw) as { version?: unknown };
return typeof parsed.version === "string" ? parsed.version : undefined;
const parsed = JSON.parse(fsSync.readFileSync(opened.fd, "utf-8")) as unknown;
return isRecord(parsed) ? parsed : undefined;
} catch {
return undefined;
} finally {
fsSync.closeSync(opened.fd);
}
}
export async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
const manifest = readInstalledPackageManifest(dir);
return typeof manifest?.version === "string" ? manifest.version : undefined;
}
export function installedPackageNeedsOpenClawPeerLinkRepair(dir: string): boolean {
const manifest = readInstalledPackageManifest(dir);
const peerDependencies = isRecord(manifest?.peerDependencies) ? manifest.peerDependencies : {};
if (!Object.hasOwn(peerDependencies, "openclaw")) {
return false;
}
try {
fsSync.statSync(path.join(dir, "node_modules", "openclaw"));
return false;
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
return code === "ENOENT" || code === "ENOTDIR";
}
}

View File

@@ -9,6 +9,7 @@ type TsdownConfigEntry = {
};
entry?: Record<string, string> | string[];
inputOptions?: TsdownInputOptions;
outputOptions?: TsdownOutputOptions;
outDir?: string;
};
@@ -39,6 +40,24 @@ type TsdownExternalFunction = (
isResolved: boolean,
) => boolean | null | undefined;
type TsdownOutputOptions = (
options: {
entryFileNames?:
| string
| ((chunkInfo: { facadeModuleId?: string; moduleIds: string[]; name?: string }) => string);
chunkFileNames?: string | ((chunkInfo: { moduleIds: string[] }) => string);
},
format?: unknown,
context?: unknown,
) =>
| {
entryFileNames?:
| string
| ((chunkInfo: { facadeModuleId?: string; moduleIds: string[]; name?: string }) => string);
chunkFileNames?: string | ((chunkInfo: { moduleIds: string[] }) => string);
}
| undefined;
function asConfigArray(config: unknown): TsdownConfigEntry[] {
return Array.isArray(config) ? (config as TsdownConfigEntry[]) : [config as TsdownConfigEntry];
}
@@ -178,6 +197,67 @@ describe("tsdown config", () => {
expect(externalize("qrcode-terminal/lib/main.js", undefined, false)).toBe(true);
});
it("routes externalized bundled plugin chunks under their excluded dist subtree", () => {
const configured = unifiedDistGraph()?.outputOptions?.({
entryFileNames: "[name].js",
chunkFileNames: "[name]-[hash].js",
});
const entryFileNames = configured?.entryFileNames;
const chunkFileNames = configured?.chunkFileNames;
expect(typeof entryFileNames).toBe("function");
expect(typeof chunkFileNames).toBe("function");
expect(
(
entryFileNames as (chunkInfo: {
facadeModuleId?: string;
moduleIds: string[];
name?: string;
}) => string
)({
facadeModuleId: "/repo/extensions/zalouser/src/setup-surface.ts",
moduleIds: [],
name: "setup-surface",
}),
).toBe("extensions/zalouser/[name].js");
expect(
(
entryFileNames as (chunkInfo: {
facadeModuleId?: string;
moduleIds: string[];
name?: string;
}) => string
)({
facadeModuleId: "/repo/extensions/zalouser/index.ts",
moduleIds: [],
name: "extensions/zalouser/index",
}),
).toBe("[name].js");
expect(
(chunkFileNames as (chunkInfo: { moduleIds: string[] }) => string)({
moduleIds: ["/repo/extensions/feishu/src/client.ts"],
}),
).toBe("extensions/feishu/[name]-[hash].js");
expect(
(chunkFileNames as (chunkInfo: { moduleIds: string[] }) => string)({
moduleIds: ["/repo/extensions/telegram/src/api.ts"],
}),
).toBe("[name]-[hash].js");
expect(
(chunkFileNames as (chunkInfo: { moduleIds: string[] }) => string)({
moduleIds: ["/repo/extensions/feishu/src/client.ts", "/repo/src/shared/string.ts"],
}),
).toBe("extensions/feishu/[name]-[hash].js");
expect(
(chunkFileNames as (chunkInfo: { moduleIds: string[] }) => string)({
moduleIds: [
"/repo/extensions/feishu/src/client.ts",
"/repo/extensions/telegram/src/api.ts",
],
}),
).toBe("[name]-[hash].js");
});
it("suppresses unresolved imports from extension source", () => {
const configured = unifiedDistGraph()?.inputOptions?.({})?.onLog;
const handled: TsdownLog[] = [];

View File

@@ -164,6 +164,10 @@ function createManifestRegistryFixture(): PluginManifestRegistry {
enabledByDefault: true,
providers: ["openai", "openai-codex"],
cliBackends: ["codex-cli"],
contracts: {
imageGenerationProviders: ["openai"],
videoGenerationProviders: ["openai"],
},
},
{
id: "google",
@@ -172,6 +176,11 @@ function createManifestRegistryFixture(): PluginManifestRegistry {
enabledByDefault: true,
providers: ["google", "google-gemini-cli"],
cliBackends: ["google-gemini-cli"],
contracts: {
imageGenerationProviders: ["google"],
videoGenerationProviders: ["google"],
musicGenerationProviders: ["google"],
},
},
{
id: "codex",
@@ -754,6 +763,53 @@ describe("resolveGatewayStartupPluginIds", () => {
} as OpenClawConfig,
["browser", "memory-core"],
],
[
"includes bundled generation providers configured by media defaults at startup",
{
channels: {},
agents: {
defaults: {
imageGenerationModel: {
primary: "openai/gpt-image-2",
fallbacks: ["google/gemini-3-pro-image-preview"],
},
videoGenerationModel: {
primary: "google/veo-3.1-fast-generate-preview",
},
musicGenerationModel: {
primary: "google/lyria-3-clip-preview",
},
},
},
} as OpenClawConfig,
["browser", "openai", "google", "memory-core"],
],
[
"honors explicit plugin disablement for configured generation providers",
{
channels: {},
agents: {
defaults: {
imageGenerationModel: { primary: "google/gemini-3-pro-image-preview" },
},
},
plugins: { entries: { google: { enabled: false } } },
} as OpenClawConfig,
["browser", "memory-core"],
],
[
"keeps configured generation providers behind restrictive allowlists",
{
channels: {},
agents: {
defaults: {
imageGenerationModel: { primary: "google/gemini-3-pro-image-preview" },
},
},
plugins: { allow: ["browser"] },
} as OpenClawConfig,
["browser"],
],
[
"includes explicitly enabled non-channel sidecars in startup scope",
createStartupConfig({

View File

@@ -39,6 +39,11 @@ export type GatewayStartupPluginPlan = {
};
type NormalizedPluginsConfig = ReturnType<typeof normalizePluginsConfigWithRegistry>;
type GenerationProviderContractKey =
| "imageGenerationProviders"
| "videoGenerationProviders"
| "musicGenerationProviders";
type ConfiguredGenerationProviderIds = Record<GenerationProviderContractKey, ReadonlySet<string>>;
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
@@ -209,6 +214,123 @@ function manifestOwnsConfiguredSpeechProvider(params: {
});
}
function listModelProviderRefs(value: unknown): string[] {
if (typeof value === "string") {
return [value];
}
if (!isRecord(value)) {
return [];
}
const refs: string[] = [];
if (typeof value.primary === "string") {
refs.push(value.primary);
}
if (Array.isArray(value.fallbacks)) {
for (const fallback of value.fallbacks) {
if (typeof fallback === "string") {
refs.push(fallback);
}
}
}
return refs;
}
function collectModelProviderIds(value: unknown): ReadonlySet<string> {
return new Set(
listModelProviderRefs(value)
.map((ref) => {
const slashIndex = ref.indexOf("/");
return slashIndex > 0 ? normalizeOptionalLowercaseString(ref.slice(0, slashIndex)) : "";
})
.filter((providerId): providerId is string => Boolean(providerId)),
);
}
function collectConfiguredGenerationProviderIds(
config: OpenClawConfig,
): ConfiguredGenerationProviderIds {
const defaults = config.agents?.defaults;
return {
imageGenerationProviders: collectModelProviderIds(defaults?.imageGenerationModel),
videoGenerationProviders: collectModelProviderIds(defaults?.videoGenerationModel),
musicGenerationProviders: collectModelProviderIds(defaults?.musicGenerationModel),
};
}
function manifestOwnsConfiguredGenerationProvider(params: {
manifest: PluginManifestRecord | undefined;
configuredGenerationProviderIds: ConfiguredGenerationProviderIds;
}): boolean {
for (const contractKey of [
"imageGenerationProviders",
"videoGenerationProviders",
"musicGenerationProviders",
] as const) {
const configuredProviderIds = params.configuredGenerationProviderIds[contractKey];
if (configuredProviderIds.size === 0) {
continue;
}
if (
(params.manifest?.contracts?.[contractKey] ?? []).some((providerId) => {
const normalized = normalizeOptionalLowercaseString(providerId);
return normalized ? configuredProviderIds.has(normalized) : false;
})
) {
return true;
}
}
return false;
}
function canStartConfiguredGenerationProviderPlugin(params: {
plugin: InstalledPluginIndexRecord;
manifest: PluginManifestRecord | undefined;
config: OpenClawConfig;
pluginsConfig: ReturnType<typeof normalizePluginsConfigWithRegistry>;
activationSource: {
plugins: ReturnType<typeof normalizePluginsConfigWithRegistry>;
rootConfig?: OpenClawConfig;
};
configuredGenerationProviderIds: ConfiguredGenerationProviderIds;
platform?: NodeJS.Platform;
}): boolean {
if (
!manifestOwnsConfiguredGenerationProvider({
manifest: params.manifest,
configuredGenerationProviderIds: params.configuredGenerationProviderIds,
})
) {
return false;
}
if (!params.pluginsConfig.enabled || !params.activationSource.plugins.enabled) {
return false;
}
if (
params.pluginsConfig.deny.includes(params.plugin.pluginId) ||
params.activationSource.plugins.deny.includes(params.plugin.pluginId)
) {
return false;
}
if (
params.pluginsConfig.entries[params.plugin.pluginId]?.enabled === false ||
params.activationSource.plugins.entries[params.plugin.pluginId]?.enabled === false
) {
return false;
}
const activationState = resolveEffectivePluginActivationState({
id: params.plugin.pluginId,
origin: params.plugin.origin,
config: params.pluginsConfig,
rootConfig: params.config,
enabledByDefault: isPluginEnabledByDefaultForPlatform(params.plugin, params.platform),
activationSource: params.activationSource,
});
return (
activationState.enabled &&
(params.plugin.origin === "bundled" || activationState.explicitlyEnabled)
);
}
function canStartConfiguredSpeechProviderPlugin(params: {
plugin: InstalledPluginIndexRecord;
manifest: PluginManifestRecord | undefined;
@@ -512,6 +634,8 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: {
const startupDreamingPluginIds = resolveGatewayStartupDreamingPluginIds(params.config);
const manifestLookup = createManifestRegistryLookup(params.manifestRegistry);
const configuredSpeechProviderIds = collectConfiguredSpeechProviderIds(activationSourceConfig);
const configuredGenerationProviderIds =
collectConfiguredGenerationProviderIds(activationSourceConfig);
const normalizePluginId = createPluginRegistryIdNormalizer(params.index, {
manifestRegistry: params.manifestRegistry,
});
@@ -581,6 +705,19 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: {
) {
return true;
}
if (
canStartConfiguredGenerationProviderPlugin({
plugin,
manifest,
config: params.config,
pluginsConfig,
activationSource,
configuredGenerationProviderIds,
platform: params.platform,
})
) {
return true;
}
if (
canStartExplicitHookPlugin({
plugin,

View File

@@ -0,0 +1,87 @@
import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import type { UpdateChannel } from "../infra/update-channels.js";
export type ChannelInstallSpecs = {
installSpec: string;
recordSpec: string;
fallbackSpec?: string;
fallbackLabel?: string;
};
function isDefaultNpmSpecForBetaChannel(spec: string): { name: string } | null {
const parsed = parseRegistryNpmSpec(spec);
if (!parsed) {
return null;
}
if (parsed.selectorKind === "none") {
return { name: parsed.name };
}
if (parsed.selectorKind === "tag" && parsed.selector?.toLowerCase() === "latest") {
return { name: parsed.name };
}
return null;
}
function isDefaultClawHubSpecForBetaChannel(spec: string): { name: string } | null {
const parsed = parseClawHubPluginSpec(spec);
if (!parsed) {
return null;
}
if (!parsed.version || parsed.version.toLowerCase() === "latest") {
return { name: parsed.name };
}
return null;
}
export function resolveNpmInstallSpecsForUpdateChannel(params: {
spec: string;
updateChannel?: UpdateChannel;
}): ChannelInstallSpecs {
if (params.updateChannel !== "beta") {
return {
installSpec: params.spec,
recordSpec: params.spec,
};
}
const betaTarget = isDefaultNpmSpecForBetaChannel(params.spec);
if (!betaTarget) {
return {
installSpec: params.spec,
recordSpec: params.spec,
};
}
const betaSpec = `${betaTarget.name}@beta`;
return {
installSpec: betaSpec,
recordSpec: params.spec,
fallbackSpec: params.spec,
fallbackLabel: betaSpec,
};
}
export function resolveClawHubInstallSpecsForUpdateChannel(params: {
spec: string;
updateChannel?: UpdateChannel;
}): ChannelInstallSpecs {
if (params.updateChannel !== "beta") {
return {
installSpec: params.spec,
recordSpec: params.spec,
};
}
const betaTarget = isDefaultClawHubSpecForBetaChannel(params.spec);
if (!betaTarget) {
return {
installSpec: params.spec,
recordSpec: params.spec,
};
}
const betaSpec = `clawhub:${betaTarget.name}@beta`;
return {
installSpec: betaSpec,
recordSpec: params.spec,
fallbackSpec: params.spec,
fallbackLabel: betaSpec,
};
}

View File

@@ -250,12 +250,24 @@ function createCodexAppServerInstallConfig(params: {
};
}
function createInstalledPackageDir(params: { name?: string; version: string }): string {
function createInstalledPackageDir(params: {
name?: string;
version: string;
peerDependencies?: Record<string, string>;
}): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-test-"));
tempDirs.push(dir);
fs.writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({ name: params.name ?? "test-plugin", version: params.version }, null, 2),
JSON.stringify(
{
name: params.name ?? "test-plugin",
version: params.version,
...(params.peerDependencies ? { peerDependencies: params.peerDependencies } : {}),
},
null,
2,
),
);
return dir;
}
@@ -708,6 +720,119 @@ describe("updateNpmInstalledPlugins", () => {
]);
});
it("repairs missing openclaw peer links before skipping unchanged npm plugins", async () => {
const installPath = createInstalledPackageDir({
name: "@openclaw/codex",
version: "2026.5.3",
peerDependencies: { openclaw: ">=2026.5.3" },
});
mockNpmViewMetadata({
name: "@openclaw/codex",
version: "2026.5.3",
integrity: "sha512-same",
shasum: "same",
});
installPluginFromNpmSpecMock.mockResolvedValue(
createSuccessfulNpmUpdateResult({
pluginId: "codex",
targetDir: installPath,
version: "2026.5.3",
npmResolution: {
name: "@openclaw/codex",
version: "2026.5.3",
resolvedSpec: "@openclaw/codex@2026.5.3",
},
}),
);
const config: OpenClawConfig = {
plugins: {
installs: {
codex: {
source: "npm",
spec: "@openclaw/codex",
installPath,
resolvedName: "@openclaw/codex",
resolvedVersion: "2026.5.3",
resolvedSpec: "@openclaw/codex@2026.5.3",
integrity: "sha512-same",
shasum: "same",
},
},
},
};
const result = await updateNpmInstalledPlugins({
config,
pluginIds: ["codex"],
});
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/codex",
mode: "update",
expectedPluginId: "codex",
}),
);
expect(result.changed).toBe(true);
expect(result.outcomes).toEqual([
{
pluginId: "codex",
status: "unchanged",
currentVersion: "2026.5.3",
nextVersion: "2026.5.3",
message: "codex already at 2026.5.3.",
},
]);
});
it("skips unchanged npm plugins when the openclaw peer link already resolves", async () => {
const installPath = createInstalledPackageDir({
name: "@openclaw/codex",
version: "2026.5.3",
peerDependencies: { openclaw: ">=2026.5.3" },
});
fs.mkdirSync(path.join(installPath, "node_modules", "openclaw"), { recursive: true });
mockNpmViewMetadata({
name: "@openclaw/codex",
version: "2026.5.3",
integrity: "sha512-same",
shasum: "same",
});
installPluginFromNpmSpecMock.mockRejectedValue(new Error("installer should not run"));
const result = await updateNpmInstalledPlugins({
config: {
plugins: {
installs: {
codex: {
source: "npm",
spec: "@openclaw/codex",
installPath,
resolvedName: "@openclaw/codex",
resolvedVersion: "2026.5.3",
resolvedSpec: "@openclaw/codex@2026.5.3",
integrity: "sha512-same",
shasum: "same",
},
},
},
},
pluginIds: ["codex"],
});
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
expect(result.changed).toBe(false);
expect(result.outcomes).toEqual([
{
pluginId: "codex",
status: "unchanged",
currentVersion: "2026.5.3",
nextVersion: "2026.5.3",
message: "codex is up to date (2026.5.3).",
},
]);
});
it("refreshes legacy npm install records before skipping unchanged artifacts", async () => {
const installPath = createInstalledPackageDir({
name: "@martian-engineering/lossless-claw",

View File

@@ -11,6 +11,7 @@ import {
} from "../infra/npm-registry-spec.js";
import {
expectedIntegrityForUpdate,
installedPackageNeedsOpenClawPeerLinkRepair,
readInstalledPackageVersion,
} from "../infra/package-update-utils.js";
import { compareComparableSemver, parseComparableSemver } from "../infra/semver-compare.js";
@@ -30,6 +31,10 @@ import {
type ExternalizedBundledPluginBridge,
} from "./externalized-bundled-plugins.js";
import { installPluginFromGitSpec } from "./git-install.js";
import {
resolveClawHubInstallSpecsForUpdateChannel,
resolveNpmInstallSpecsForUpdateChannel,
} from "./install-channel-specs.js";
import {
installPluginFromNpmSpec,
PLUGIN_INSTALL_ERROR_CODE,
@@ -458,20 +463,6 @@ function npmUpdateFailureSpec(params: {
return params.effectiveSpec ?? params.fallbackSpec ?? "unknown";
}
function isDefaultNpmSpecForBetaUpdate(spec: string): { name: string } | null {
const parsed = parseRegistryNpmSpec(spec);
if (!parsed) {
return null;
}
if (parsed.selectorKind === "none") {
return { name: parsed.name };
}
if (parsed.selectorKind === "tag" && parsed.selector?.toLowerCase() === "latest") {
return { name: parsed.name };
}
return null;
}
function resolveNpmSpecPackageName(spec: string | undefined): string | undefined {
return spec ? parseRegistryNpmSpec(spec)?.name : undefined;
}
@@ -562,36 +553,16 @@ function resolveNpmUpdateSpecs(params: {
if (!recordSpec) {
return {};
}
if (params.specOverride || params.updateChannel !== "beta") {
if (params.specOverride) {
return {
installSpec: recordSpec,
recordSpec,
};
}
const betaTarget = isDefaultNpmSpecForBetaUpdate(recordSpec);
if (!betaTarget) {
return {
installSpec: recordSpec,
recordSpec,
};
}
return {
installSpec: `${betaTarget.name}@beta`,
recordSpec,
fallbackSpec: recordSpec,
fallbackLabel: `${betaTarget.name}@beta`,
};
}
function isDefaultClawHubSpecForBetaUpdate(spec: string): { name: string } | null {
const parsed = parseClawHubPluginSpec(spec);
if (!parsed) {
return null;
}
if (!parsed.version || parsed.version.toLowerCase() === "latest") {
return { name: parsed.name };
}
return null;
return resolveNpmInstallSpecsForUpdateChannel({
spec: recordSpec,
updateChannel: params.updateChannel,
});
}
function resolveClawHubUpdateSpecs(params: {
@@ -607,25 +578,10 @@ function resolveClawHubUpdateSpecs(params: {
return {};
}
const recordSpec = params.record.spec ?? `clawhub:${params.record.clawhubPackage}`;
if (params.updateChannel !== "beta") {
return {
installSpec: recordSpec,
recordSpec,
};
}
const betaTarget = isDefaultClawHubSpecForBetaUpdate(recordSpec);
if (!betaTarget) {
return {
installSpec: recordSpec,
recordSpec,
};
}
return {
installSpec: `clawhub:${betaTarget.name}@beta`,
recordSpec,
fallbackSpec: recordSpec,
fallbackLabel: `clawhub:${betaTarget.name}@beta`,
};
return resolveClawHubInstallSpecsForUpdateChannel({
spec: recordSpec,
updateChannel: params.updateChannel,
});
}
function isBridgeAlreadyInstalledFromPreferredSource(params: {
@@ -989,6 +945,7 @@ export async function updateNpmInstalledPlugins(params: {
spec: effectiveSpec!,
trustedSourceLinkedOfficialInstall,
}) &&
!installedPackageNeedsOpenClawPeerLinkRepair(installPath) &&
shouldSkipUnchangedNpmInstall({
currentVersion,
record,

View File

@@ -126,6 +126,23 @@ describe("security audit sandbox docker config", () => {
},
],
},
{
name: "Windows drive-letter bind is absolute",
cfg: {
agents: {
defaults: {
sandbox: {
mode: "all",
docker: {
binds: ["D:/data/openclaw/src:/src:ro"],
},
},
},
},
} as OpenClawConfig,
expectedFindings: [],
expectedAbsent: ["sandbox.bind_mount_non_absolute"],
},
{
name: "container namespace join network mode",
cfg: {

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { defineConfig, type UserConfig } from "tsdown";
import {
collectBundledPluginBuildEntries,
collectRootPackageExcludedExtensionDirs,
NON_PACKAGED_BUNDLED_PLUGIN_DIRS,
} from "./scripts/lib/bundled-plugin-build-entries.mjs";
import { buildPluginSdkEntrySources } from "./scripts/lib/plugin-sdk-entries.mjs";
@@ -23,6 +24,29 @@ type InputOptionsReturn = InputOptionsFactory extends (
? Return
: never;
type OnLogFunction = InputOptionsArg extends { onLog?: infer OnLog } ? NonNullable<OnLog> : never;
type OutputOptionsFactory = Extract<NonNullable<UserConfig["outputOptions"]>, Function>;
type OutputOptionsArg = OutputOptionsFactory extends (
options: infer Options,
format: infer _Format,
context: infer _Context,
) => infer _Return
? Options
: never;
type OutputOptionsReturn = OutputOptionsFactory extends (
options: infer _Options,
format: infer _Format,
context: infer _Context,
) => infer Return
? Return
: never;
type EntryFileNamesFunction = OutputOptionsArg extends { entryFileNames?: infer EntryFileNames }
? Extract<NonNullable<EntryFileNames>, Function>
: never;
type ChunkFileNamesFunction = OutputOptionsArg extends { chunkFileNames?: infer ChunkFileNames }
? Extract<NonNullable<ChunkFileNames>, Function>
: never;
type ChunkFileNameFunction = EntryFileNamesFunction | ChunkFileNamesFunction;
type ChunkInfo = Parameters<ChunkFileNameFunction>[0];
type ExternalOptionFunction = (
id: string,
parentId: string | undefined,
@@ -116,6 +140,76 @@ function buildInputOptions(options: InputOptionsArg): InputOptionsReturn {
};
}
const rootPackageExcludedExtensionDirs = collectRootPackageExcludedExtensionDirs();
function normalizeModuleId(moduleId: string): string {
return moduleId.replaceAll("\\", "/");
}
function resolveExternalizedBundledPluginChunkId(chunkInfo: ChunkInfo): string | null {
let pluginId: string | null = null;
const moduleIds = [
...(chunkInfo.facadeModuleId ? [chunkInfo.facadeModuleId] : []),
...(chunkInfo.moduleIds ?? []),
];
for (const moduleId of moduleIds) {
const normalized = normalizeModuleId(moduleId);
const match = /(?:^|\/)extensions\/([^/]+)\//u.exec(normalized);
if (!match?.[1]) {
continue;
}
if (!rootPackageExcludedExtensionDirs.has(match[1])) {
return null;
}
if (pluginId && pluginId !== match[1]) {
return null;
}
pluginId = match[1];
}
return pluginId;
}
function externalizedBundledPluginFileNamePattern(
pluginId: string,
chunkInfo: ChunkInfo,
fallback: string,
): string {
return chunkInfo.name?.startsWith(`extensions/${pluginId}/`)
? fallback
: `extensions/${pluginId}/${fallback}`;
}
function buildOutputOptions(options: OutputOptionsArg): OutputOptionsReturn {
const previousEntryFileNames = options.entryFileNames;
const previousChunkFileNames = options.chunkFileNames;
return {
...options,
entryFileNames(chunkInfo: ChunkInfo) {
const fallback =
typeof previousEntryFileNames === "function"
? previousEntryFileNames(chunkInfo)
: (previousEntryFileNames ?? "[name].js");
const externalizedPluginId = resolveExternalizedBundledPluginChunkId(chunkInfo);
if (externalizedPluginId) {
return externalizedBundledPluginFileNamePattern(externalizedPluginId, chunkInfo, fallback);
}
return fallback;
},
chunkFileNames(chunkInfo: ChunkInfo) {
const fallback =
typeof previousChunkFileNames === "function"
? previousChunkFileNames(chunkInfo)
: (previousChunkFileNames ?? "[name]-[hash].js");
const externalizedPluginId = resolveExternalizedBundledPluginChunkId(chunkInfo);
if (externalizedPluginId) {
return externalizedBundledPluginFileNamePattern(externalizedPluginId, chunkInfo, fallback);
}
return fallback;
},
};
}
function nodeBuildConfig(config: UserConfig): UserConfig {
return {
...config,
@@ -123,6 +217,7 @@ function nodeBuildConfig(config: UserConfig): UserConfig {
fixedExtension: false,
platform: "node",
inputOptions: buildInputOptions,
outputOptions: buildOutputOptions,
};
}