mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
23 Commits
codex/runt
...
v2026.5.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
325df3efef | ||
|
|
2fc80754cf | ||
|
|
41f028e2ea | ||
|
|
303ff716d4 | ||
|
|
5fcdeae80c | ||
|
|
b73317c217 | ||
|
|
8f6bf65162 | ||
|
|
8017dc4c3b | ||
|
|
578d9072cf | ||
|
|
30b73bbf41 | ||
|
|
ade922ba98 | ||
|
|
997f8af734 | ||
|
|
6204a6fecc | ||
|
|
9f15c29397 | ||
|
|
cac973972c | ||
|
|
f8f18d53fc | ||
|
|
696f639cf6 | ||
|
|
079b937b46 | ||
|
|
32e36d355d | ||
|
|
12e1c67f22 | ||
|
|
766d02ff3b | ||
|
|
e9ebb6ce6c | ||
|
|
e0002c4b5b |
92
.github/workflows/openclaw-release-publish.yml
vendored
92
.github/workflows/openclaw-release-publish.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/plugin-clawhub-release.yml
vendored
4
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -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:
|
||||
|
||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
looksLikeWhatsAppTargetId,
|
||||
normalizeWhatsAppAllowFromEntry,
|
||||
normalizeWhatsAppMessagingTarget,
|
||||
isWhatsAppGroupJid,
|
||||
isWhatsAppNewsletterJid,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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}` : ""}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
87
src/plugins/install-channel-specs.ts
Normal file
87
src/plugins/install-channel-specs.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user