Compare commits

...

36 Commits

Author SHA1 Message Date
Peter Steinberger ccfeecb688 test: harden parallels macos dashboard smoke
Docker Release / validate_manual_backfill (push) Has been skipped
Docker Release / approve_manual_backfill (push) Has been skipped
Docker Release / build-amd64 (push) Failing after 39s
Docker Release / create-manifest (push) Has been cancelled
Docker Release / build-arm64 (push) Has been cancelled
2026-03-23 16:13:20 -07:00
Peter Steinberger a921b5bdff test: fix update-cli default path assertion 2026-03-23 23:05:25 +00:00
Peter Steinberger 725a2cc2ca test: expand gemini live transcript stripping 2026-03-23 23:01:22 +00:00
Peter Steinberger 67dbb1ad42 test: update command coverage 2026-03-23 23:01:22 +00:00
Peter Steinberger d67efbfbd3 test: stabilize test isolation 2026-03-23 23:01:22 +00:00
Sally O'Malley ae336d1602 Doctor: prune stale plugin allowlist and entry refs (#53187)
Signed-off-by: sallyom <somalley@redhat.com>
2026-03-23 18:58:54 -04:00
Vincent Koc 03231c0633 fix(auth): prevent stale auth store reverts (#53211) 2026-03-23 15:56:46 -07:00
Peter Steinberger 47bdc36831 test: make update-cli checkout path assertion platform-safe 2026-03-23 22:54:32 +00:00
Vincent Koc 19295994f3 fix(ci): stabilize whatsapp extension checks 2026-03-23 15:50:19 -07:00
Vincent Koc 6f5df14308 test(whatsapp): preserve harness session exports 2026-03-23 15:50:19 -07:00
Vincent Koc 7b03502175 test(whatsapp): preserve media test module exports 2026-03-23 15:50:19 -07:00
Vincent Koc e87a64f8d0 test(whatsapp): preserve session exports in login coverage 2026-03-23 15:50:19 -07:00
Vincent Koc dad6018230 test(whatsapp): stabilize login coverage in shared workers 2026-03-23 15:50:19 -07:00
Peter Steinberger e808f8d6ef docs(changelog): reorder release highlights 2026-03-23 15:44:18 -07:00
Onur 6ab6e7a493 Release: document manual macOS asset upload (#53178)
* Release: document manual macOS asset upload

* Release: document macOS smoke-test mode
2026-03-23 23:19:52 +01:00
Robin Waslander fb6588cb99 fix(diagnostics): redact credentials from cache-trace diagnostic output
Refs #53103
2026-03-23 22:59:09 +01:00
Onur 6e8d5cd578 Release: privatize macOS publish flow (#53166) 2026-03-23 22:35:51 +01:00
George Zhang ade0182ae0 feat(modelstudio): add standard (pay-as-you-go) DashScope endpoints for Qwen (#43878)
Add Standard API Key auth methods for China (dashscope.aliyuncs.com)
and Global/Intl (dashscope-intl.aliyuncs.com) pay-as-you-go endpoints
alongside the existing Coding Plan (subscription) endpoints.

Also updates group label to 'Qwen (Alibaba Cloud Model Studio)' and
fixes glm-4.7 -> glm-5 in Coding Plan note messages.

Co-authored-by: wenmeng zhou <wenmengzhou@users.noreply.github.com>
2026-03-23 14:35:19 -07:00
Ayush Ojha 64f4df1886 fix: generalize api_error detection for fallback model triggering (#49611)
Co-authored-by: Ayush Ojha <7945279+ayushozha@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
2026-03-24 00:34:27 +03:00
Vincent Koc 3814f956d1 Infra: tighten shell-wrapper positional-argv allowlist matching (#53133)
* Infra: tighten shell carrier allowlist matching

* fix(security): tighten shell carrier allowlist matcher
2026-03-23 14:11:38 -07:00
theo674 dbe7da7684 fix: prevent delivery-mirror re-delivery and raise Slack chunk limit (#45489)
Merged via squash.

Prepared head SHA: c7664c7b6e026b0d197fc9b53065d13bef05176a
Co-authored-by: theo674 <261068216+theo674@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-24 00:11:19 +03:00
Altay a53715e9d0 test(auth): align device scope expectations (#53151) 2026-03-23 23:56:02 +03:00
Vincent Koc c1131ba7e0 fix(plugins): accept clawhub uninstall specs 2026-03-23 13:52:27 -07:00
betoblair 1c7e98de16 docs(feishu): replace botName with name in config examples (#52753)
Merged via squash.

Prepared head SHA: 5237726c9cc329bc3795b5caf8227420f1f09285
Co-authored-by: haroldfabla2-hue <229189334+haroldfabla2-hue@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-23 23:48:33 +03:00
Vincent Koc 34c57487b4 fix(subagents): recheck timed-out announce waits (#53127)
Recheck timed-out subagent announce waits against the latest runtime snapshot before announcing timeout, and keep that recheck best-effort so transient gateway failures do not suppress the announcement.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-03-23 15:36:49 -05:00
Onur Solmaz bc95436a43 release: upload macos preflight artifacts (#53105)
* release: upload macos preflight artifacts

* release: speed up macos preflight

* release: use xlarge macos runner

* release: skip dmg path in macos preflight
2026-03-23 21:34:51 +01:00
Peter Steinberger f698774324 build: finalize 2026.3.23 release 2026-03-23 13:08:33 -07:00
Nimrod Gutman b98f3634c4 fix(memory): bootstrap lancedb runtime on demand (#53111)
Bootstrap LanceDB into plugin runtime state on first use for packaged/global installs, keep @lancedb/lancedb plugin-local, and add regression coverage for bundled, cached, retry, and Nix fail-fast runtime paths.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-03-23 15:07:24 -05:00
Vincent Koc fd5496d1d3 docs(changelog): note skill secretref runtime fix 2026-03-23 13:05:32 -07:00
Vincent Koc 13e81870bb fix(agents): prefer runtime snapshot for skill secrets 2026-03-23 13:04:12 -07:00
Peter Steinberger 6c58277577 build: prepare 2026.3.23 2026-03-23 12:58:00 -07:00
Val Alexander 3e2b3bd2c5 Fix Control UI operator.read scope handling (#53110)
Preserve Control UI scopes through the device-auth bypass path, normalize implied operator device-auth scopes, ignore cached under-scoped operator tokens, and degrade read-backed main pages gracefully when a connection truly lacks operator.read.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-03-23 14:57:21 -05:00
Tak Hoffman 99c84294f3 ci: shard bun test lane 2026-03-23 14:51:47 -05:00
Onur Solmaz 01c1fc797f release: harden preflight workflows (#53087)
* release: harden preflight-only workflows

* release: require main for publish runs

* release: select xcode for macos workflow

* release: retry flaky macos preflight steps
2026-03-23 20:30:46 +01:00
Tak Hoffman 8670f2cead fix(openai-codex): bootstrap proxy on oauth refresh (openclaw#53078)
Verified:
- pnpm install --frozen-lockfile
- pnpm exec vitest run extensions/openai/openai-codex-provider.runtime.test.ts extensions/openai/openai-provider.test.ts
2026-03-23 14:08:04 -05:00
Vincent Koc cdd797f943 docs(changelog): credit web search runtime fix 2026-03-23 12:01:17 -07:00
111 changed files with 3142 additions and 576 deletions
@@ -32,6 +32,7 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Preferred entrypoint: `pnpm test:parallels:macos`
- Default to the snapshot closest to `macOS 26.3.1 latest`.
- On Peter's Tahoe VM, `fresh-latest-march-2026` can hang in `prlctl snapshot-switch`; if restore times out there, rerun with `--snapshot-hint 'macOS 26.3.1 latest'` before blaming auth or the harness.
- The macOS smoke should include a dashboard load phase after gateway health: resolve the tokenized URL with `openclaw dashboard --no-open`, verify the served HTML contains the Control UI title/root shell, then open Safari and require an established localhost TCP connection from Safari to the gateway port.
- `prlctl exec` is fine for deterministic repo commands, but use the guest Terminal or `prlctl enter` when installer parity or shell-sensitive behavior matters.
- Multi-word `openclaw agent --message ...` checks should go through a guest shell wrapper (`guest_current_user_sh` / `guest_current_user_cli` or `/bin/sh -lc ...`), not raw `prlctl exec ... node openclaw.mjs ...`, or the message can be split into extra argv tokens and Commander reports `too many arguments for 'agent'`.
- On the fresh Tahoe snapshot, `brew` exists but `node` may be missing from PATH in noninteractive exec. Use `/opt/homebrew/bin/node` when needed.
@@ -85,8 +85,9 @@ OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
- `pnpm release:check`
- `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke`
- Check all release-related build surfaces touched by the release, not only the npm package.
- Include mac release readiness in preflight by running or inspecting the mac
packaging, notarization, and appcast flow for every release.
- Include mac release readiness in preflight by running the public validation
workflow in `openclaw/openclaw` and the real mac preflight in
`openclaw/releases-private` for every release.
- Treat the `appcast.xml` update on `main` as part of mac release readiness, not an optional follow-up.
- The workflows remain tag-based. The agent is responsible for making sure
preflight runs complete successfully before any publish run starts.
@@ -104,22 +105,51 @@ OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
- OpenClaw publish uses GitHub trusted publishing.
- The publish run must be started manually with `workflow_dispatch`.
- Both release workflows accept `preflight_only=true` to run CI
validation/build steps without entering the gated publish job.
- npm preflight and macOS preflight must both pass before any publish run
starts.
- The npm workflow and the private mac publish workflow accept
`preflight_only=true` to run validation/build/package steps without uploading
public release assets.
- The private mac workflow also accepts `smoke_test_only=true` for branch-safe
workflow smoke tests that use ad-hoc signing, skip notarization, skip shared
appcast generation, and do not prove release readiness.
- `preflight_only=true` on the npm workflow is also the right way to validate an
existing tag after publish; it should keep running the build checks even when
the npm version is already published.
- Validation-only runs may be dispatched from a branch when you are testing a
workflow change before merge.
- `.github/workflows/macos-release.yml` in `openclaw/openclaw` is now a
public validation-only handoff. It validates the tag/release state and points
operators to the private repo; it does not build or publish macOS artifacts.
- Real mac preflight and real mac publish both use
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`.
- The private mac workflow runs on GitHub's xlarge macOS runner and uses a
SwiftPM cache because the Swift build/test/package path is CPU-heavy.
- Private mac preflight uploads notarized build artifacts as workflow artifacts
instead of uploading public GitHub release assets.
- Private smoke-test runs upload ad-hoc, non-notarized build artifacts as
workflow artifacts and intentionally skip stable `appcast.xml` generation.
- npm preflight, public mac validation, and private mac preflight must all pass
before any real publish run starts.
- Real publish runs must be dispatched from `main`; branch-dispatched publish
attempts should fail before the protected environment is reached.
- The release workflows stay tag-based; rely on the documented release sequence
rather than workflow-level SHA pinning.
- The `npm-release` environment must be approved by `@openclaw/openclaw-release-managers` before publish continues.
- Mac publish uses `.github/workflows/macos-release.yml` for build, signing,
notarization, stable-feed `appcast.xml` artifact generation, and release-asset
upload.
- The agent must download the signed `appcast.xml` artifact from a successful
stable mac workflow and then update `appcast.xml` on `main`.
- Mac publish uses
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml` for
build, signing, notarization, packaged mac artifact generation, and
stable-feed `appcast.xml` artifact generation.
- After a successful real private mac publish, the agent must download
`macos-release-<tag>` from that run and upload the packaged mac assets to the
existing GitHub release in `openclaw/openclaw`.
- For stable releases, the agent must also download the signed
`macos-appcast-<tag>` artifact from the successful private mac workflow and
then update `appcast.xml` on `main`.
- For beta mac releases, do not update the shared production `appcast.xml`
unless a separate beta Sparkle feed exists.
- `.github/workflows/macos-release.yml` still requires the `mac-release`
environment approval.
- The private repo targets a dedicated `mac-release` environment. If the GitHub
plan does not yet support required reviewers there, do not assume the
environment alone is the approval boundary; rely on private repo access and
CODEOWNERS until those settings can be enabled.
- Do not use `NPM_TOKEN` or the plugin OTP flow for OpenClaw releases.
- `@openclaw/*` plugin publishes use a separate maintainer-only flow.
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
@@ -153,25 +183,30 @@ OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
8. Create or refresh the matching GitHub release.
9. Start `.github/workflows/openclaw-npm-release.yml` with `preflight_only=true`
and wait for it to pass.
10. Start `.github/workflows/macos-release.yml` with `preflight_only=true` and
wait for it to pass.
11. If either preflight fails, fix the issue on a new commit, delete the tag
and matching GitHub release, recreate them from the fixed commit, and rerun
both preflights from scratch before continuing. Never reuse old preflight
results after the commit changes.
12. Start `.github/workflows/openclaw-npm-release.yml` with the same tag for
10. Start `.github/workflows/macos-release.yml` in `openclaw/openclaw` and wait
for the public validation-only run to pass.
11. Start
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
with `preflight_only=true` and wait for it to pass.
12. If any preflight or validation run fails, fix the issue on a new commit,
delete the tag and matching GitHub release, recreate them from the fixed
commit, and rerun all relevant preflights from scratch before continuing.
Never reuse old preflight results after the commit changes.
13. Start `.github/workflows/openclaw-npm-release.yml` with the same tag for
the real publish.
13. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
14. Start `.github/workflows/macos-release.yml` for the real publish and wait
for `mac-release` approval and success.
15. For stable releases, let the mac workflow generate the signed
`appcast.xml` artifact before it uploads the public mac assets, then
download that artifact from the successful run, update `appcast.xml` on
`main`, and verify the feed.
16. For beta releases, publish the mac assets but expect no shared production
14. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
15. Start
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
for the real publish and wait for success.
16. Download `macos-release-<tag>` from the successful private mac run and
upload the `.zip`, `.dmg`, and `.dSYM.zip` artifacts to the existing
GitHub release in `openclaw/openclaw`.
17. For stable releases, download `macos-appcast-<tag>` from the successful
private mac run, update `appcast.xml` on `main`, and verify the feed.
18. For beta releases, publish the mac assets but expect no shared production
`appcast.xml` artifact and do not update the shared production feed unless a
separate beta feed exists.
17. After publish, verify npm and any attached release artifacts.
19. After publish, verify npm and the attached release artifacts.
## GHSA advisory work
+8 -1
View File
@@ -257,7 +257,14 @@ jobs:
command: pnpm protocol:check
- runtime: bun
task: test
command: pnpm canvas:a2ui:bundle && OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts
shard_index: 1
shard_count: 2
command: pnpm canvas:a2ui:bundle && OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts --shard 1/2
- runtime: bun
task: test
shard_index: 2
shard_count: 2
command: pnpm canvas:a2ui:bundle && OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts --shard 2/2
- runtime: node
task: compat-node22
node_version: "22.x"
+17 -237
View File
@@ -4,13 +4,13 @@ on:
workflow_dispatch:
inputs:
tag:
description: Existing release tag to build macOS artifacts for (for example v2026.3.22 or v2026.3.22-beta.1)
description: Existing release tag to validate for macOS release handoff (for example v2026.3.22 or v2026.3.22-beta.1)
required: true
type: string
preflight_only:
description: Run validation/build only and skip the gated publish job
description: Retained for operator compatibility; this public workflow is validation-only
required: true
default: false
default: true
type: boolean
concurrency:
@@ -21,11 +21,10 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
PNPM_VERSION: "10.23.0"
SPARKLE_FEED_URL: https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
jobs:
preflight_macos_release:
runs-on: macos-latest
validate_macos_release_request:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
@@ -70,237 +69,18 @@ jobs:
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
pnpm release:openclaw:npm:check
- name: Resolve package version
id: package_version
run: echo "value=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT"
- name: Check
run: pnpm check
- name: Build
run: pnpm build
- name: Build Control UI
run: node scripts/ui.js build
- name: Verify release contents
run: pnpm release:check
- name: Swift build
run: swift build --package-path apps/macos --configuration release
- name: Swift test
run: swift test --package-path apps/macos --parallel
- name: Package macOS release with ad-hoc signing
env:
APP_VERSION: ${{ steps.package_version.outputs.value }}
BUNDLE_ID: ai.openclaw.mac
BUILD_CONFIG: release
CODESIGN_TIMESTAMP: "off"
SIGN_IDENTITY: "-"
SKIP_NOTARIZE: "1"
SKIP_PNPM_INSTALL: "1"
SKIP_TSC: "1"
SKIP_UI_BUILD: "1"
SPARKLE_FEED_URL: ${{ env.SPARKLE_FEED_URL }}
run: scripts/package-mac-dist.sh
publish_macos_release:
needs: [preflight_macos_release]
if: ${{ !inputs.preflight_only }}
runs-on: macos-latest
environment: mac-release
concurrency:
# Stable releases all derive the same shared appcast.xml; serialize those
# runs so each artifact starts from the latest stable feed snapshot.
group: macos-release-publish-${{ contains(inputs.tag, '-beta.') && inputs.tag || 'stable-feed' }}
cancel-in-progress: false
permissions:
contents: write
steps:
- name: Validate tag input format
- name: Summarize next step
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
echo "Invalid release tag format: ${RELEASE_TAG}"
exit 1
fi
- name: Checkout selected tag
uses: actions/checkout@v6
with:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "false"
use-sticky-disk: "false"
- name: Ensure matching GitHub release exists
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
run: gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
- name: Resolve package version
id: package_version
run: echo "value=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT"
- name: Determine release channel
id: release_channel
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ "$RELEASE_TAG" == *-beta.* ]]; then
echo "is_beta=true" >> "$GITHUB_OUTPUT"
else
echo "is_beta=false" >> "$GITHUB_OUTPUT"
fi
- name: Import Developer ID certificate
env:
MACOS_DEVELOPER_ID_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_P12_BASE64 }}
MACOS_DEVELOPER_ID_P12_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_P12_PASSWORD }}
run: |
set -euo pipefail
CERT_PATH="$RUNNER_TEMP/openclaw-macos-release.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/openclaw-release.keychain-db"
KEYCHAIN_PASSWORD="$(openssl rand -hex 32)"
echo "::add-mask::$KEYCHAIN_PASSWORD"
export CERT_PATH MACOS_DEVELOPER_ID_P12_BASE64
python3 - <<'PY'
import base64
import os
from pathlib import Path
Path(os.environ["CERT_PATH"]).write_bytes(
base64.b64decode(os.environ["MACOS_DEVELOPER_ID_P12_BASE64"])
)
PY
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" \
-k "$KEYCHAIN_PATH" \
-P "$MACOS_DEVELOPER_ID_P12_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/security
EXISTING_KEYCHAINS="$(security list-keychains -d user | tr -d '"')"
security list-keychains -d user -s "$KEYCHAIN_PATH" $EXISTING_KEYCHAINS
security default-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Resolve signing identity
run: |
set -euo pipefail
SIGN_IDENTITY="$(security find-identity -p codesigning -v "$KEYCHAIN_PATH" 2>/dev/null | awk -F'\"' '/Developer ID Application/ { print $2; exit }')"
if [[ -z "${SIGN_IDENTITY}" ]]; then
echo "Developer ID Application identity not found in imported keychain." >&2
exit 1
fi
echo "SIGN_IDENTITY=$SIGN_IDENTITY" >> "$GITHUB_ENV"
- name: Write notary and Sparkle key files
env:
APP_STORE_CONNECT_API_KEY_P8: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: |
set -euo pipefail
NOTARYTOOL_KEY_PATH="$RUNNER_TEMP/openclaw-notary.p8"
SPARKLE_PRIVATE_KEY_PATH="$RUNNER_TEMP/openclaw-sparkle-ed25519.pem"
export NOTARYTOOL_KEY_PATH SPARKLE_PRIVATE_KEY_PATH
python3 - <<'PY'
import os
from pathlib import Path
def write_secret(path_env: str, value_env: str) -> None:
value = os.environ[value_env].replace("\\n", "\n")
Path(os.environ[path_env]).write_text(value, encoding="utf-8")
write_secret("NOTARYTOOL_KEY_PATH", "APP_STORE_CONNECT_API_KEY_P8")
write_secret("SPARKLE_PRIVATE_KEY_PATH", "SPARKLE_PRIVATE_KEY")
PY
echo "NOTARYTOOL_KEY=$NOTARYTOOL_KEY_PATH" >> "$GITHUB_ENV"
echo "NOTARYTOOL_KEY_ID=$APP_STORE_CONNECT_KEY_ID" >> "$GITHUB_ENV"
echo "NOTARYTOOL_ISSUER=$APP_STORE_CONNECT_ISSUER_ID" >> "$GITHUB_ENV"
echo "SPARKLE_PRIVATE_KEY_FILE=$SPARKLE_PRIVATE_KEY_PATH" >> "$GITHUB_ENV"
- name: Build, sign, notarize, and package macOS release
env:
APP_VERSION: ${{ steps.package_version.outputs.value }}
BUNDLE_ID: ai.openclaw.mac
BUILD_CONFIG: release
SIGN_IDENTITY: ${{ env.SIGN_IDENTITY }}
SKIP_PNPM_INSTALL: "1"
SPARKLE_FEED_URL: ${{ env.SPARKLE_FEED_URL }}
run: scripts/package-mac-dist.sh
- name: Checkout main branch for appcast seed
if: ${{ steps.release_channel.outputs.is_beta != 'true' }}
uses: actions/checkout@v6
with:
path: openclaw-main
ref: main
fetch-depth: 0
- name: Seed appcast from main
if: ${{ steps.release_channel.outputs.is_beta != 'true' }}
run: |
set -euo pipefail
APPCAST_SOURCE="openclaw-main/appcast.xml"
if [[ -f "$APPCAST_SOURCE" ]]; then
cp "$APPCAST_SOURCE" appcast.xml
else
echo "No existing appcast at $APPCAST_SOURCE; generating a fresh feed."
fi
- name: Generate signed appcast artifact
if: ${{ steps.release_channel.outputs.is_beta != 'true' }}
env:
SPARKLE_DOWNLOAD_URL_PREFIX: https://github.com/openclaw/openclaw/releases/download/${{ inputs.tag }}/
SPARKLE_RELEASE_VERSION: ${{ steps.package_version.outputs.value }}
run: scripts/make_appcast.sh "dist/OpenClaw-${{ steps.package_version.outputs.value }}.zip" "${{ env.SPARKLE_FEED_URL }}"
- name: Upload stable appcast artifact
if: ${{ steps.release_channel.outputs.is_beta != 'true' }}
uses: actions/upload-artifact@v7
with:
name: macos-appcast-${{ inputs.tag }}
path: appcast.xml
if-no-files-found: error
- name: Skip shared appcast for beta releases
if: ${{ steps.release_channel.outputs.is_beta == 'true' }}
run: echo "Beta release detected; skip shared production appcast artifact generation."
- name: Upload macOS assets to GitHub release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
VERSION: ${{ steps.package_version.outputs.value }}
run: |
set -euo pipefail
gh release upload "$RELEASE_TAG" \
"dist/OpenClaw-$VERSION.zip" \
"dist/OpenClaw-$VERSION.dmg" \
"dist/OpenClaw-$VERSION.dSYM.zip" \
--clobber \
--repo "$GITHUB_REPOSITORY"
- name: Clean up signing keychain
if: always()
run: |
if [[ -n "${KEYCHAIN_PATH:-}" ]]; then
security delete-keychain "$KEYCHAIN_PATH" >/dev/null 2>&1 || true
fi
{
echo "## Public macOS validation only"
echo
echo "This workflow no longer builds, signs, notarizes, or uploads macOS assets."
echo
echo "Next step:"
echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml\` with tag \`${RELEASE_TAG}\`."
echo "- Use \`preflight_only=true\` there for the full private mac preflight."
echo "- For the real publish path, the private run uploads \`macos-release-${RELEASE_TAG}\` as a workflow artifact. Download it and upload the packaged assets to the existing GitHub release in \`openclaw/openclaw\`."
echo "- For stable releases, also download \`macos-appcast-${RELEASE_TAG}\` from the successful private run and commit \`appcast.xml\` back to \`main\` in \`openclaw/openclaw\`."
} >> "$GITHUB_STEP_SUMMARY"
+23 -1
View File
@@ -66,11 +66,17 @@ jobs:
pnpm release:openclaw:npm:check
- name: Ensure version is not already published
env:
PREFLIGHT_ONLY: ${{ inputs.preflight_only }}
run: |
set -euo pipefail
PACKAGE_VERSION=$(node -p "require('./package.json').version")
if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then
echo "openclaw@${PACKAGE_VERSION} is already published on npm; continuing because preflight_only=true."
exit 0
fi
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
exit 1
fi
@@ -86,9 +92,25 @@ jobs:
- name: Verify release contents
run: pnpm release:check
validate_publish_dispatch_ref:
if: ${{ !inputs.preflight_only }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Require main workflow ref for publish
env:
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
echo "Real publish runs must be dispatched from main. Use preflight_only=true for branch validation."
exit 1
fi
publish_openclaw_npm:
# npm trusted publishing + provenance requires a GitHub-hosted runner.
needs: [preflight_openclaw_npm]
needs: [preflight_openclaw_npm, validate_publish_dispatch_ref]
if: ${{ !inputs.preflight_only }}
runs-on: ubuntu-latest
environment: npm-release
+37 -13
View File
@@ -8,28 +8,49 @@ Docs: https://docs.openclaw.ai
### Changes
- ModelStudio/Qwen: add standard (pay-as-you-go) DashScope endpoints for China and global Qwen API keys alongside the existing Coding Plan endpoints, and relabel the provider group to `Qwen (Alibaba Cloud Model Studio)`. (#43878)
### Fixes
- Control UI/auth: preserve operator scopes through the device-auth bypass path, ignore cached under-scoped operator tokens, and show a clear `operator.read` fallback message when a connection really lacks read scope, so operator sessions stop failing or blanking on read-backed pages. (#53110) Thanks @BunsDev.
- Plugins/uninstall: accept installed `clawhub:` specs and versionless ClawHub package names as uninstall targets, so `openclaw plugins uninstall clawhub:<package>` works again even when the recorded install was pinned to a version.
- Auth/OpenAI tokens: stop live gateway auth-profile writes from reverting freshly saved credentials back to stale in-memory values, and make `models auth paste-token` write to the resolved agent store, so Configure, Onboard, and token-paste flows stop snapping back to expired OpenAI tokens. Fixes #53207. Related to #45516.
- Agents/failover: classify generic `api_error` payloads as retryable only when they include transient failure signals, so MiniMax-style backend failures still trigger model fallback without misclassifying billing, auth, or format/context errors. (#49611) Thanks @ayushozha.
- Diagnostics/cache trace: strip credential fields from cache-trace JSONL output while preserving non-sensitive diagnostic fields and image redaction metadata.
- Docs/Feishu: replace `botName` with `name` in the channel config examples so the docs match the strict account schema for per-account display names. (#52753) Thanks @haroldfabla2-hue.
## 2026.3.23
### Breaking
### Changes
### Fixes
- Plugins/message tool: make Discord `components` and Slack `blocks` optional again, and route Feishu `message(..., media=...)` sends through the outbound media path, so pin/unpin/react flows stop failing schema validation and Feishu file/image attachments actually send. Fixes #52970 and #52962. Thanks @vincentkoc.
- Gateway/model pricing: stop `openrouter/auto` pricing refresh from recursing indefinitely during bootstrap, so OpenRouter auto routes can populate cached pricing and `usage.cost` again. Fixes #53035. Thanks @vincentkoc.
- Browser/Chrome MCP: wait for existing-session browser tabs to become usable after attach instead of treating the initial Chrome MCP handshake as ready, which reduces user-profile timeouts and repeated consent churn on macOS Chrome attach flows. Fixes #52930. Thanks @vincentkoc.
- Browser/CDP: reuse an already-running loopback browser after a short initial reachability miss instead of immediately falling back to relaunch detection, which fixes second-run browser start/open regressions on slower headless Linux setups. Fixes #53004. Thanks @vincentkoc.
- ClawHub/skills: resolve the local ClawHub auth token for gateway skill browsing and switch browse-all requests to search so ClawControl stops falling into unauthenticated 429s and empty authenticated skill lists. Fixes #52949. Thanks @vincentkoc.
- ClawHub/macOS auth: honor macOS auth config and XDG auth paths for saved ClawHub credentials, so `openclaw skills ...` and gateway skill browsing keep using the signed-in auth state instead of silently falling back to unauthenticated mode. Fixes #53034.
- Agents/web_search: use the active runtime `web_search` provider instead of stale/default selection, so agent turns keep hitting the provider you actually configured. Fixes #53020.
- Channels/catalog: let external channel catalogs override shipped fallback metadata and honor overridden npm specs during channel setup, so custom channel catalogs no longer fall back to bundled packages when a channel id matches. (#52988)
- Gateway/auth: require auth for canvas routes and admin scope for agent session reset, so anonymous canvas access and non-admin reset requests fail closed.
- Gateway/probe: stop successful gateway handshakes from timing out as unreachable while post-connect detail RPCs are still loading, so slow devices report a reachable RPC failure instead of a false negative dead gateway. Fixes #52927. Thanks @vincentkoc.
- Gateway/supervision: stop lock conflicts from crash-looping under launchd and systemd by keeping the duplicate process in a retry wait instead of exiting as a failure while another healthy gateway still owns the lock. Fixes #52922. Thanks @vincentkoc.
- ClawHub/macOS: read the local ClawHub login from the macOS Application Support path and still honor XDG config on macOS, so skill browsing uses the logged-in token on both default and XDG-style setups. Fixes #52949. Thanks @scoootscooob.
- ClawHub/skills: resolve the local ClawHub auth token for gateway skill browsing and switch browse-all requests to search so ClawControl stops falling into unauthenticated 429s and empty authenticated skill lists. Fixes #52949. Thanks @vincentkoc.
- Plugins/message tool: make Discord `components` and Slack `blocks` optional again, and route Feishu `message(..., media=...)` sends through the outbound media path, so pin/unpin/react flows stop failing schema validation and Feishu file/image attachments actually send. Fixes #52970 and #52962. Thanks @vincentkoc.
- Gateway/model pricing: stop `openrouter/auto` pricing refresh from recursing indefinitely during bootstrap, so OpenRouter auto routes can populate cached pricing and `usage.cost` again. Fixes #53035. Thanks @vincentkoc.
- Mistral/models: lower bundled Mistral max-token defaults to safe output budgets and teach `openclaw doctor --fix` to repair old persisted Mistral provider configs that still carry context-sized output limits, avoiding deterministic Mistral 422 rejects on fresh and existing setups. Fixes #52599. Thanks @vincentkoc.
- Agents/web_search: use the active runtime `web_search` provider instead of stale/default selection, so agent turns keep hitting the provider you actually configured. Fixes #53020. Thanks @jzakirov.
- Models/OpenAI Codex OAuth: bootstrap the env-configured HTTP/HTTPS proxy dispatcher on the stored-credential refresh path before token renewal runs, so expired Codex OAuth profiles can refresh successfully in proxy-required environments instead of locking users out after the first token expiry.
- Plugins/memory-lancedb: bootstrap LanceDB into plugin runtime state on first use when the bundled npm install does not already have it, so `plugins.slots.memory="memory-lancedb"` works again after global npm installs without moving LanceDB into OpenClaw core dependencies. Fixes #26100.
- Config/plugins: treat stale unknown `plugins.allow` ids as warnings instead of fatal config errors, so recovery commands like `plugins install`, `doctor --fix`, and `status` still run when a plugin is missing locally. Fixes #52992. Thanks @vincentkoc.
- Doctor/WhatsApp: stop auto-enable from appending built-in channel ids like `whatsapp` to `plugins.allow`, so `openclaw doctor --fix` no longer writes schema-invalid plugin allowlist entries when repairing built-in channels. Fixes #52931. Thanks @vincentkoc.
- Agents/Anthropic: preserve latest assistant thinking and redacted-thinking block ordering during transcript image sanitization so follow-up turns do not trip Anthropic's unmodified-thinking validation. (#52961) Thanks @vincentkoc.
- Voice-call/Plivo: stabilize Plivo v2 replay keys so webhook retries and replay protection stop colliding on valid follow-up deliveries.
- Release/install: keep previously released bundled plugins and Control UI assets in published openclaw npm installs, and fail release checks when those shipped artifacts are missing. Thanks @vincentkoc.
- Mistral/models: lower bundled Mistral max-token defaults to safe output budgets and teach `openclaw doctor --fix` to repair old persisted Mistral provider configs that still carry context-sized output limits, avoiding deterministic Mistral 422 rejects on fresh and existing setups. Fixes #52599. Thanks @vincentkoc.
- Telegram/auto-reply: preserve same-chat inbound debounce order without stranding stale busy-session followups, and keep same-key overflow turns ordered when tracked debounce keys are saturated. (#52998) Thanks @osolmaz.
- ClawHub/macOS: read the local ClawHub login from the macOS Application Support path and still honor XDG config on macOS, so skill browsing uses the logged-in token on both default and XDG-style setups. Fixes #52949. Thanks @scoootscooob.
- Discord/commands: return an explicit unauthorized reply for privileged native slash commands instead of falling through to Discord's misleading generic completion when auth gates reject the sender. Fixes #53041. Thanks @scoootscooob.
- Channels/catalog: let external channel catalogs override shipped fallback metadata and honor overridden npm specs during channel setup, so custom channel catalogs no longer fall back to bundled packages when a channel id matches. (#52988)
- Voice-call/Plivo: stabilize Plivo v2 replay keys so webhook retries and replay protection stop colliding on valid follow-up deliveries.
- Agents/skills: prefer the active resolved runtime snapshot for embedded skill config and env injection, so `skills.entries.<skill>.apiKey` SecretRefs resolve correctly during embedded startup instead of failing on raw source config. Fixes #53098. Thanks @vincentkoc.
- Agents/subagents: recheck timed-out worker waits against the latest runtime snapshot before sending completion events, so fast-finishing workers stop being reported as timed out when they actually succeeded. Fixes #53106. Thanks @vincentkoc.
- Agents/Anthropic: preserve latest assistant thinking and redacted-thinking block ordering during transcript image sanitization so follow-up turns do not trip Anthropic's unmodified-thinking validation. (#52961) Thanks @vincentkoc.
- Gateway/probe: stop successful gateway handshakes from timing out as unreachable while post-connect detail RPCs are still loading, so slow devices report a reachable RPC failure instead of a false negative dead gateway. Fixes #52927. Thanks @vincentkoc.
- Gateway/supervision: stop lock conflicts from crash-looping under launchd and systemd by keeping the duplicate process in a retry wait instead of exiting as a failure while another healthy gateway still owns the lock. Fixes #52922. Thanks @vincentkoc.
- Gateway/auth: require auth for canvas routes and admin scope for agent session reset, so anonymous canvas access and non-admin reset requests fail closed.
- Release/install: keep previously released bundled plugins and Control UI assets in published openclaw npm installs, and fail release checks when those shipped artifacts are missing. Thanks @vincentkoc.
## 2026.3.22
@@ -135,6 +156,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Security/exec approvals: keep shell-wrapper positional-argv allowlist matching on real direct carriers only by rejecting single-quoted `$0`/`$n` tokens, disallowing newline-separated `exec`, and still accepting `exec --` carrier forms. Thanks @vincentkoc.
- Models/OpenAI Codex OAuth and Plugins/MiniMax OAuth: ensure env-configured HTTP/HTTPS proxy dispatchers are initialized before OAuth preflight and token exchange requests so proxy-required environments can complete MiniMax and OpenAI Codex sign-in flows again. (#52228; fixes #51619, #51569) Thanks @openperf.
- Plugins/DeepSeek: refactor the bundled DeepSeek provider onto the shared single-provider plugin entry, move its coverage into the extension test lane, and keep bundled auth env-var metadata on the generated manifest path. (#48762) Thanks @07akioni.
- Web tools/search provider lists: keep onboarding, configure, and docs provider lists alphabetical while preserving the separate runtime auto-detect precedence used for credential-based provider selection.
@@ -357,6 +379,8 @@ Docs: https://docs.openclaw.ai
- Memory/core tools: register `memory_search` and `memory_get` independently so one unavailable memory tool no longer suppresses the other in new sessions. (#50198) Thanks @artwalker.
- Telegram/Mattermost message tool: keep plugin button schemas optional in isolated and cron sessions so plain sends do not fail validation when no current channel is active. (#52589) Thanks @tylerliu612.
- Release/npm publish: fail the npm release check when `dist/control-ui/index.html` is missing from the packed tarball, so broken Control UI asset releases are blocked before publish. Fixes #52808. (#52852) Thanks @kevinheinrichs.
- Slack/embedded delivery: suppress transcript-only `delivery-mirror` assistant messages before embedded re-delivery and raise the default Slack chunk fallback so messages just over 4000 characters stay in a single post. (#45489) Thanks @theo674.
- Slack/embedded delivery: suppress transcript-only `delivery-mirror` assistant messages before embedded re-delivery and raise the default Slack chunk fallback so messages just over 4000 characters stay in a single post. (#45489) Thanks @theo674.
## 2026.3.13
+1 -1
View File
@@ -1,7 +1,7 @@
// Shared iOS version defaults.
// Generated overrides live in build/Version.xcconfig (git-ignored).
OPENCLAW_GATEWAY_VERSION = 2026.3.23-beta.1
OPENCLAW_GATEWAY_VERSION = 2026.3.23
OPENCLAW_MARKETING_VERSION = 2026.3.23
OPENCLAW_BUILD_VERSION = 202603230
+1 -1
View File
@@ -99,7 +99,7 @@ def normalize_release_version(raw_value)
version = raw_value.to_s.strip.sub(/\Av/, "")
UI.user_error!("Missing root package.json version.") unless env_present?(version)
unless version.match?(/\A\d+\.\d+\.\d+(?:[.-]?beta[.-]\d+)?\z/i)
UI.user_error!("Invalid package.json version '#{raw_value}'. Expected 2026.3.22 or 2026.3.22-beta.1.")
UI.user_error!("Invalid package.json version '#{raw_value}'. Expected YYYY.M.D or YYYY.M.D-beta.N.")
end
version
+3 -3
View File
@@ -185,7 +185,7 @@ Edit `~/.openclaw/openclaw.json`:
main: {
appId: "cli_xxx",
appSecret: "xxx",
botName: "My AI assistant",
name: "My AI assistant",
},
},
},
@@ -494,12 +494,12 @@ openclaw pairing list feishu
main: {
appId: "cli_xxx",
appSecret: "xxx",
botName: "Primary bot",
name: "Primary bot",
},
backup: {
appId: "cli_yyy",
appSecret: "yyy",
botName: "Backup bot",
name: "Backup bot",
enabled: false,
},
},
+3 -3
View File
@@ -190,7 +190,7 @@ openclaw channels add
main: {
appId: "cli_xxx",
appSecret: "xxx",
botName: "My AI assistant",
name: "My AI assistant",
},
},
},
@@ -499,12 +499,12 @@ openclaw pairing list feishu
main: {
appId: "cli_xxx",
appSecret: "xxx",
botName: "Primary bot",
name: "Primary bot",
},
backup: {
appId: "cli_yyy",
appSecret: "yyy",
botName: "Backup bot",
name: "Backup bot",
enabled: false,
},
},
+13 -9
View File
@@ -148,14 +148,7 @@ describe("memory plugin e2e", () => {
const toArray = vi.fn(async () => []);
const limit = vi.fn(() => ({ toArray }));
const vectorSearch = vi.fn(() => ({ limit }));
vi.resetModules();
vi.doMock("openai", () => ({
default: class MockOpenAI {
embeddings = { create: embeddingsCreate };
},
}));
vi.doMock("@lancedb/lancedb", () => ({
const loadLanceDbModule = vi.fn(async () => ({
connect: vi.fn(async () => ({
tableNames: vi.fn(async () => ["memories"]),
openTable: vi.fn(async () => ({
@@ -167,6 +160,16 @@ describe("memory plugin e2e", () => {
})),
}));
vi.resetModules();
vi.doMock("openai", () => ({
default: class MockOpenAI {
embeddings = { create: embeddingsCreate };
},
}));
vi.doMock("./lancedb-runtime.js", () => ({
loadLanceDbModule,
}));
try {
const { default: memoryPlugin } = await import("./index.js");
// oxlint-disable-next-line typescript/no-explicit-any
@@ -214,6 +217,7 @@ describe("memory plugin e2e", () => {
}
await recallTool.execute("test-call-dims", { query: "hello dimensions" });
expect(loadLanceDbModule).toHaveBeenCalledTimes(1);
expect(embeddingsCreate).toHaveBeenCalledWith({
model: "text-embedding-3-small",
input: "hello dimensions",
@@ -221,7 +225,7 @@ describe("memory plugin e2e", () => {
});
} finally {
vi.doUnmock("openai");
vi.doUnmock("@lancedb/lancedb");
vi.doUnmock("./lancedb-runtime.js");
vi.resetModules();
}
});
+2 -14
View File
@@ -18,24 +18,12 @@ import {
memoryConfigSchema,
vectorDimsForModel,
} from "./config.js";
import { loadLanceDbModule } from "./lancedb-runtime.js";
// ============================================================================
// Types
// ============================================================================
let lancedbImportPromise: Promise<typeof import("@lancedb/lancedb")> | null = null;
const loadLanceDB = async (): Promise<typeof import("@lancedb/lancedb")> => {
if (!lancedbImportPromise) {
lancedbImportPromise = import("@lancedb/lancedb");
}
try {
return await lancedbImportPromise;
} catch (err) {
// Common on macOS today: upstream package may not ship darwin native bindings.
throw new Error(`memory-lancedb: failed to load LanceDB. ${String(err)}`, { cause: err });
}
};
type MemoryEntry = {
id: string;
text: string;
@@ -79,7 +67,7 @@ class MemoryDB {
}
private async doInitialize(): Promise<void> {
const lancedb = await loadLanceDB();
const lancedb = await loadLanceDbModule();
this.db = await lancedb.connect(this.dbPath);
const tables = await this.db.tableNames();
@@ -0,0 +1,176 @@
import { describe, expect, it, vi } from "vitest";
import { createLanceDbRuntimeLoader, type LanceDbRuntimeLogger } from "./lancedb-runtime.js";
const TEST_RUNTIME_MANIFEST = {
name: "openclaw-memory-lancedb-runtime",
private: true as const,
type: "module" as const,
dependencies: {
"@lancedb/lancedb": "^0.27.1",
},
};
type LanceDbModule = typeof import("@lancedb/lancedb");
type RuntimeManifest = {
name: string;
private: true;
type: "module";
dependencies: Record<string, string>;
};
function createMockModule(): LanceDbModule {
return {
connect: vi.fn(),
} as unknown as LanceDbModule;
}
function createLoader(
overrides: {
env?: NodeJS.ProcessEnv;
importBundled?: () => Promise<LanceDbModule>;
importResolved?: (resolvedPath: string) => Promise<LanceDbModule>;
resolveRuntimeEntry?: (params: {
runtimeDir: string;
manifest: RuntimeManifest;
}) => string | null;
installRuntime?: (params: {
runtimeDir: string;
manifest: RuntimeManifest;
env: NodeJS.ProcessEnv;
logger?: LanceDbRuntimeLogger;
}) => Promise<string>;
} = {},
) {
return createLanceDbRuntimeLoader({
env: overrides.env ?? ({} as NodeJS.ProcessEnv),
resolveStateDir: () => "/tmp/openclaw-state",
runtimeManifest: TEST_RUNTIME_MANIFEST,
importBundled:
overrides.importBundled ??
(async () => {
throw new Error("Cannot find package '@lancedb/lancedb'");
}),
importResolved: overrides.importResolved ?? (async () => createMockModule()),
resolveRuntimeEntry: overrides.resolveRuntimeEntry ?? (() => null),
installRuntime:
overrides.installRuntime ??
(async ({ runtimeDir }: { runtimeDir: string }) =>
`${runtimeDir}/node_modules/@lancedb/lancedb/index.js`),
});
}
describe("lancedb runtime loader", () => {
it("uses the bundled module when it is already available", async () => {
const bundledModule = createMockModule();
const importBundled = vi.fn(async () => bundledModule);
const importResolved = vi.fn(async () => createMockModule());
const resolveRuntimeEntry = vi.fn(() => null);
const installRuntime = vi.fn(async () => "/tmp/openclaw-state/plugin-runtimes/lancedb.js");
const loader = createLoader({
importBundled,
importResolved,
resolveRuntimeEntry,
installRuntime,
});
await expect(loader.load()).resolves.toBe(bundledModule);
expect(resolveRuntimeEntry).not.toHaveBeenCalled();
expect(installRuntime).not.toHaveBeenCalled();
expect(importResolved).not.toHaveBeenCalled();
});
it("reuses an existing user runtime install before attempting a reinstall", async () => {
const runtimeModule = createMockModule();
const importResolved = vi.fn(async () => runtimeModule);
const resolveRuntimeEntry = vi.fn(
() => "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/runtime-entry.js",
);
const installRuntime = vi.fn(
async () => "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/runtime-entry.js",
);
const loader = createLoader({
importResolved,
resolveRuntimeEntry,
installRuntime,
});
await expect(loader.load()).resolves.toBe(runtimeModule);
expect(resolveRuntimeEntry).toHaveBeenCalledWith(
expect.objectContaining({
runtimeDir: "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb",
}),
);
expect(installRuntime).not.toHaveBeenCalled();
});
it("installs LanceDB into user state when the bundled runtime is unavailable", async () => {
const runtimeModule = createMockModule();
const logger: LanceDbRuntimeLogger = {
warn: vi.fn(),
info: vi.fn(),
};
const importResolved = vi.fn(async () => runtimeModule);
const resolveRuntimeEntry = vi.fn(() => null);
const installRuntime = vi.fn(
async ({ runtimeDir }: { runtimeDir: string }) =>
`${runtimeDir}/node_modules/@lancedb/lancedb/index.js`,
);
const loader = createLoader({
importResolved,
resolveRuntimeEntry,
installRuntime,
});
await expect(loader.load(logger)).resolves.toBe(runtimeModule);
expect(installRuntime).toHaveBeenCalledWith(
expect.objectContaining({
runtimeDir: "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb",
manifest: TEST_RUNTIME_MANIFEST,
}),
);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
"installing runtime deps under /tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb",
),
);
});
it("fails fast in nix mode instead of attempting auto-install", async () => {
const installRuntime = vi.fn(
async ({ runtimeDir }: { runtimeDir: string }) =>
`${runtimeDir}/node_modules/@lancedb/lancedb/index.js`,
);
const loader = createLoader({
env: { OPENCLAW_NIX_MODE: "1" } as NodeJS.ProcessEnv,
installRuntime,
});
await expect(loader.load()).rejects.toThrow(
"memory-lancedb: failed to load LanceDB and Nix mode disables auto-install.",
);
expect(installRuntime).not.toHaveBeenCalled();
});
it("clears the cached failure so later calls can retry the install", async () => {
const runtimeModule = createMockModule();
const installRuntime = vi
.fn()
.mockRejectedValueOnce(new Error("network down"))
.mockResolvedValueOnce(
"/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb/node_modules/@lancedb/lancedb/index.js",
);
const importResolved = vi.fn(async () => runtimeModule);
const loader = createLoader({
installRuntime,
importResolved,
});
await expect(loader.load()).rejects.toThrow("network down");
await expect(loader.load()).resolves.toBe(runtimeModule);
expect(installRuntime).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,266 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { resolveStateDir } from "./api.js";
type LanceDbModule = typeof import("@lancedb/lancedb");
export type LanceDbRuntimeLogger = {
info?: (message: string) => void;
warn?: (message: string) => void;
};
type RuntimeManifest = {
name: string;
private: true;
type: "module";
dependencies: Record<string, string>;
};
type LanceDbRuntimeLoaderDeps = {
env: NodeJS.ProcessEnv;
resolveStateDir: (env?: NodeJS.ProcessEnv, homedir?: () => string) => string;
runtimeManifest: RuntimeManifest;
importBundled: () => Promise<LanceDbModule>;
importResolved: (resolvedPath: string) => Promise<LanceDbModule>;
resolveRuntimeEntry: (params: { runtimeDir: string; manifest: RuntimeManifest }) => string | null;
installRuntime: (params: {
runtimeDir: string;
manifest: RuntimeManifest;
env: NodeJS.ProcessEnv;
logger?: LanceDbRuntimeLogger;
}) => Promise<string>;
};
const MEMORY_LANCEDB_RUNTIME_MANIFEST: RuntimeManifest = (() => {
const packageJson = JSON.parse(
fs.readFileSync(new URL("./package.json", import.meta.url), "utf8"),
) as {
dependencies?: Record<string, string>;
};
const lanceDbSpec = packageJson.dependencies?.["@lancedb/lancedb"];
if (!lanceDbSpec) {
throw new Error('memory-lancedb package.json is missing "@lancedb/lancedb"');
}
return {
name: "openclaw-memory-lancedb-runtime",
private: true,
type: "module",
dependencies: {
"@lancedb/lancedb": lanceDbSpec,
},
};
})();
function resolveRuntimeDir(stateDir: string): string {
return path.join(stateDir, "plugin-runtimes", "memory-lancedb", "lancedb");
}
function readRuntimeManifest(filePath: string): RuntimeManifest | null {
try {
return JSON.parse(fs.readFileSync(filePath, "utf8")) as RuntimeManifest;
} catch {
return null;
}
}
function manifestsMatch(actual: RuntimeManifest | null, expected: RuntimeManifest): boolean {
if (!actual) {
return false;
}
return JSON.stringify(actual) === JSON.stringify(expected);
}
function defaultResolveRuntimeEntry(params: {
runtimeDir: string;
manifest: RuntimeManifest;
}): string | null {
const runtimePackagePath = path.join(params.runtimeDir, "package.json");
if (!manifestsMatch(readRuntimeManifest(runtimePackagePath), params.manifest)) {
return null;
}
try {
const runtimeRequire = createRequire(runtimePackagePath);
return runtimeRequire.resolve("@lancedb/lancedb");
} catch {
return null;
}
}
function collectSpawnOutput(params: {
command: string;
args: string[];
cwd: string;
env: NodeJS.ProcessEnv;
}): Promise<{ code: number | null; stdout: string; stderr: string; error?: Error }> {
return new Promise((resolve) => {
const child = spawn(params.command, params.args, {
cwd: params.cwd,
env: params.env,
shell: process.platform === "win32",
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer | string) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer | string) => {
stderr += chunk.toString();
});
child.on("error", (error) => {
resolve({ code: null, stdout, stderr, error });
});
child.on("close", (code) => {
resolve({ code, stdout, stderr });
});
});
}
async function defaultInstallRuntime(params: {
runtimeDir: string;
manifest: RuntimeManifest;
env: NodeJS.ProcessEnv;
logger?: LanceDbRuntimeLogger;
}): Promise<string> {
const runtimePackagePath = path.join(params.runtimeDir, "package.json");
const currentManifest = readRuntimeManifest(runtimePackagePath);
if (!manifestsMatch(currentManifest, params.manifest)) {
await fs.promises.rm(path.join(params.runtimeDir, "node_modules"), {
recursive: true,
force: true,
});
await fs.promises.rm(path.join(params.runtimeDir, "package-lock.json"), { force: true });
}
await fs.promises.mkdir(params.runtimeDir, { recursive: true });
await fs.promises.writeFile(
runtimePackagePath,
`${JSON.stringify(params.manifest, null, 2)}\n`,
"utf8",
);
const install = await collectSpawnOutput({
command: "npm",
args: ["install", "--omit=dev", "--silent", "--ignore-scripts", "--package-lock=false"],
cwd: params.runtimeDir,
env: params.env,
});
if (install.error) {
const spawnError = install.error as NodeJS.ErrnoException;
throw new Error(
spawnError.code === "ENOENT"
? "npm is required to install the LanceDB runtime but was not found on PATH"
: install.error.message,
);
}
if ((install.code ?? 0) !== 0) {
const detail = install.stderr.trim() || install.stdout.trim();
throw new Error(detail || `npm exited with code ${install.code ?? "unknown"}`);
}
const resolved = defaultResolveRuntimeEntry({
runtimeDir: params.runtimeDir,
manifest: params.manifest,
});
if (!resolved) {
throw new Error("installed LanceDB runtime is missing the @lancedb/lancedb entry");
}
params.logger?.info?.(`memory-lancedb: installed LanceDB runtime under ${params.runtimeDir}`);
return resolved;
}
function defaultImportResolved(resolvedPath: string): Promise<LanceDbModule> {
return import(pathToFileURL(resolvedPath).href);
}
function buildLoadFailureMessage(prefix: string, error: unknown): string {
return `memory-lancedb: ${prefix}. ${String(error)}`;
}
export function createLanceDbRuntimeLoader(overrides: Partial<LanceDbRuntimeLoaderDeps> = {}): {
load: (logger?: LanceDbRuntimeLogger) => Promise<LanceDbModule>;
} {
const deps: LanceDbRuntimeLoaderDeps = {
env: overrides.env ?? process.env,
resolveStateDir: overrides.resolveStateDir ?? resolveStateDir,
runtimeManifest: overrides.runtimeManifest ?? MEMORY_LANCEDB_RUNTIME_MANIFEST,
importBundled: overrides.importBundled ?? (() => import("@lancedb/lancedb")),
importResolved: overrides.importResolved ?? defaultImportResolved,
resolveRuntimeEntry: overrides.resolveRuntimeEntry ?? defaultResolveRuntimeEntry,
installRuntime: overrides.installRuntime ?? defaultInstallRuntime,
};
let loadPromise: Promise<LanceDbModule> | null = null;
return {
async load(logger?: LanceDbRuntimeLogger): Promise<LanceDbModule> {
if (!loadPromise) {
loadPromise = (async () => {
try {
return await deps.importBundled();
} catch (bundledError) {
const runtimeDir = resolveRuntimeDir(
deps.resolveStateDir(deps.env, () =>
deps.env.HOME?.trim() ? deps.env.HOME : os.homedir(),
),
);
const existingRuntime = deps.resolveRuntimeEntry({
runtimeDir,
manifest: deps.runtimeManifest,
});
if (existingRuntime) {
try {
return await deps.importResolved(existingRuntime);
} catch {
// Reinstall below when the cached runtime is incomplete or stale.
}
}
if (deps.env.OPENCLAW_NIX_MODE === "1") {
throw new Error(
buildLoadFailureMessage(
"failed to load LanceDB and Nix mode disables auto-install",
bundledError,
),
{ cause: bundledError },
);
}
logger?.warn?.(
`memory-lancedb: bundled LanceDB runtime unavailable (${String(bundledError)}); installing runtime deps under ${runtimeDir}`,
);
const installedEntry = await deps.installRuntime({
runtimeDir,
manifest: deps.runtimeManifest,
env: deps.env,
logger,
});
try {
return await deps.importResolved(installedEntry);
} catch (runtimeError) {
throw new Error(
buildLoadFailureMessage(
"failed to load LanceDB after installing runtime deps",
runtimeError,
),
{ cause: runtimeError },
);
}
}
})().catch((error) => {
loadPromise = null;
throw error;
});
}
return await loadPromise;
},
};
}
const defaultLoader = createLanceDbRuntimeLoader();
export async function loadLanceDbModule(logger?: LanceDbRuntimeLogger): Promise<LanceDbModule> {
return await defaultLoader.load(logger);
}
+52 -6
View File
@@ -2,6 +2,8 @@ import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-en
import {
applyModelStudioConfig,
applyModelStudioConfigCn,
applyModelStudioStandardConfig,
applyModelStudioStandardConfigCn,
MODELSTUDIO_DEFAULT_MODEL_REF,
} from "./onboard.js";
import { buildModelStudioProvider } from "./provider-catalog.js";
@@ -16,6 +18,50 @@ export default defineSingleProviderPluginEntry({
label: "Model Studio",
docsPath: "/providers/models",
auth: [
{
methodId: "standard-api-key-cn",
label: "Standard API Key for China (pay-as-you-go)",
hint: "Endpoint: dashscope.aliyuncs.com",
optionKey: "modelstudioStandardApiKeyCn",
flagName: "--modelstudio-standard-api-key-cn",
envVar: "MODELSTUDIO_API_KEY",
promptMessage: "Enter Alibaba Cloud Model Studio API key (China)",
defaultModel: MODELSTUDIO_DEFAULT_MODEL_REF,
applyConfig: (cfg) => applyModelStudioStandardConfigCn(cfg),
noteMessage: [
"Get your API key at: https://bailian.console.aliyun.com/",
"Endpoint: dashscope.aliyuncs.com/compatible-mode/v1",
"Models: qwen3.5-plus, qwen3-coder-plus, qwen3-coder-next, etc.",
].join("\n"),
noteTitle: "Alibaba Cloud Model Studio Standard (China)",
wizard: {
choiceHint: "Endpoint: dashscope.aliyuncs.com",
groupLabel: "Qwen (Alibaba Cloud Model Studio)",
groupHint: "Standard / Coding Plan (CN / Global)",
},
},
{
methodId: "standard-api-key",
label: "Standard API Key for Global/Intl (pay-as-you-go)",
hint: "Endpoint: dashscope-intl.aliyuncs.com",
optionKey: "modelstudioStandardApiKey",
flagName: "--modelstudio-standard-api-key",
envVar: "MODELSTUDIO_API_KEY",
promptMessage: "Enter Alibaba Cloud Model Studio API key (Global/Intl)",
defaultModel: MODELSTUDIO_DEFAULT_MODEL_REF,
applyConfig: (cfg) => applyModelStudioStandardConfig(cfg),
noteMessage: [
"Get your API key at: https://modelstudio.console.alibabacloud.com/",
"Endpoint: dashscope-intl.aliyuncs.com/compatible-mode/v1",
"Models: qwen3.5-plus, qwen3-coder-plus, qwen3-coder-next, etc.",
].join("\n"),
noteTitle: "Alibaba Cloud Model Studio Standard (Global/Intl)",
wizard: {
choiceHint: "Endpoint: dashscope-intl.aliyuncs.com",
groupLabel: "Qwen (Alibaba Cloud Model Studio)",
groupHint: "Standard / Coding Plan (CN / Global)",
},
},
{
methodId: "api-key-cn",
label: "Coding Plan API Key for China (subscription)",
@@ -29,13 +75,13 @@ export default defineSingleProviderPluginEntry({
noteMessage: [
"Get your API key at: https://bailian.console.aliyun.com/",
"Endpoint: coding.dashscope.aliyuncs.com",
"Models: qwen3.5-plus, glm-4.7, kimi-k2.5, MiniMax-M2.5, etc.",
"Models: qwen3.5-plus, glm-5, kimi-k2.5, MiniMax-M2.5, etc.",
].join("\n"),
noteTitle: "Alibaba Cloud Model Studio Coding Plan (China)",
wizard: {
choiceHint: "Endpoint: coding.dashscope.aliyuncs.com",
groupLabel: "Alibaba Cloud Model Studio",
groupHint: "Coding Plan API key (CN / Global)",
groupLabel: "Qwen (Alibaba Cloud Model Studio)",
groupHint: "Standard / Coding Plan (CN / Global)",
},
},
{
@@ -51,13 +97,13 @@ export default defineSingleProviderPluginEntry({
noteMessage: [
"Get your API key at: https://bailian.console.aliyun.com/",
"Endpoint: coding-intl.dashscope.aliyuncs.com",
"Models: qwen3.5-plus, glm-4.7, kimi-k2.5, MiniMax-M2.5, etc.",
"Models: qwen3.5-plus, glm-5, kimi-k2.5, MiniMax-M2.5, etc.",
].join("\n"),
noteTitle: "Alibaba Cloud Model Studio Coding Plan (Global/Intl)",
wizard: {
choiceHint: "Endpoint: coding-intl.dashscope.aliyuncs.com",
groupLabel: "Alibaba Cloud Model Studio",
groupHint: "Coding Plan API key (CN / Global)",
groupLabel: "Qwen (Alibaba Cloud Model Studio)",
groupHint: "Standard / Coding Plan (CN / Global)",
},
},
],
@@ -2,6 +2,9 @@ import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-models"
export const MODELSTUDIO_CN_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1";
export const MODELSTUDIO_GLOBAL_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1";
export const MODELSTUDIO_STANDARD_CN_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
export const MODELSTUDIO_STANDARD_GLOBAL_BASE_URL =
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
export const MODELSTUDIO_DEFAULT_MODEL_ID = "qwen3.5-plus";
export const MODELSTUDIO_DEFAULT_MODEL_REF = `modelstudio/${MODELSTUDIO_DEFAULT_MODEL_ID}`;
export const MODELSTUDIO_DEFAULT_COST = {
+25 -1
View File
@@ -6,10 +6,18 @@ import {
MODELSTUDIO_CN_BASE_URL,
MODELSTUDIO_DEFAULT_MODEL_REF,
MODELSTUDIO_GLOBAL_BASE_URL,
MODELSTUDIO_STANDARD_CN_BASE_URL,
MODELSTUDIO_STANDARD_GLOBAL_BASE_URL,
} from "./model-definitions.js";
import { buildModelStudioProvider } from "./provider-catalog.js";
export { MODELSTUDIO_CN_BASE_URL, MODELSTUDIO_DEFAULT_MODEL_REF, MODELSTUDIO_GLOBAL_BASE_URL };
export {
MODELSTUDIO_CN_BASE_URL,
MODELSTUDIO_DEFAULT_MODEL_REF,
MODELSTUDIO_GLOBAL_BASE_URL,
MODELSTUDIO_STANDARD_CN_BASE_URL,
MODELSTUDIO_STANDARD_GLOBAL_BASE_URL,
};
const modelStudioPresetAppliers = createModelCatalogPresetAppliers<[string]>({
primaryModelRef: MODELSTUDIO_DEFAULT_MODEL_REF,
@@ -43,3 +51,19 @@ export function applyModelStudioConfig(cfg: OpenClawConfig): OpenClawConfig {
export function applyModelStudioConfigCn(cfg: OpenClawConfig): OpenClawConfig {
return modelStudioPresetAppliers.applyConfig(cfg, MODELSTUDIO_CN_BASE_URL);
}
export function applyModelStudioStandardProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return modelStudioPresetAppliers.applyProviderConfig(cfg, MODELSTUDIO_STANDARD_GLOBAL_BASE_URL);
}
export function applyModelStudioStandardProviderConfigCn(cfg: OpenClawConfig): OpenClawConfig {
return modelStudioPresetAppliers.applyProviderConfig(cfg, MODELSTUDIO_STANDARD_CN_BASE_URL);
}
export function applyModelStudioStandardConfig(cfg: OpenClawConfig): OpenClawConfig {
return modelStudioPresetAppliers.applyConfig(cfg, MODELSTUDIO_STANDARD_GLOBAL_BASE_URL);
}
export function applyModelStudioStandardConfigCn(cfg: OpenClawConfig): OpenClawConfig {
return modelStudioPresetAppliers.applyConfig(cfg, MODELSTUDIO_STANDARD_CN_BASE_URL);
}
+32 -4
View File
@@ -5,6 +5,34 @@
"modelstudio": ["MODELSTUDIO_API_KEY"]
},
"providerAuthChoices": [
{
"provider": "modelstudio",
"method": "standard-api-key-cn",
"choiceId": "modelstudio-standard-api-key-cn",
"choiceLabel": "Standard API Key for China (pay-as-you-go)",
"choiceHint": "Endpoint: dashscope.aliyuncs.com",
"groupId": "modelstudio",
"groupLabel": "Qwen (Alibaba Cloud Model Studio)",
"groupHint": "Standard / Coding Plan (CN / Global)",
"optionKey": "modelstudioStandardApiKeyCn",
"cliFlag": "--modelstudio-standard-api-key-cn",
"cliOption": "--modelstudio-standard-api-key-cn <key>",
"cliDescription": "Alibaba Cloud Model Studio Standard API key (China)"
},
{
"provider": "modelstudio",
"method": "standard-api-key",
"choiceId": "modelstudio-standard-api-key",
"choiceLabel": "Standard API Key for Global/Intl (pay-as-you-go)",
"choiceHint": "Endpoint: dashscope-intl.aliyuncs.com",
"groupId": "modelstudio",
"groupLabel": "Qwen (Alibaba Cloud Model Studio)",
"groupHint": "Standard / Coding Plan (CN / Global)",
"optionKey": "modelstudioStandardApiKey",
"cliFlag": "--modelstudio-standard-api-key",
"cliOption": "--modelstudio-standard-api-key <key>",
"cliDescription": "Alibaba Cloud Model Studio Standard API key (Global/Intl)"
},
{
"provider": "modelstudio",
"method": "api-key-cn",
@@ -12,8 +40,8 @@
"choiceLabel": "Coding Plan API Key for China (subscription)",
"choiceHint": "Endpoint: coding.dashscope.aliyuncs.com",
"groupId": "modelstudio",
"groupLabel": "Alibaba Cloud Model Studio",
"groupHint": "Coding Plan API key (CN / Global)",
"groupLabel": "Qwen (Alibaba Cloud Model Studio)",
"groupHint": "Standard / Coding Plan (CN / Global)",
"optionKey": "modelstudioApiKeyCn",
"cliFlag": "--modelstudio-api-key-cn",
"cliOption": "--modelstudio-api-key-cn <key>",
@@ -26,8 +54,8 @@
"choiceLabel": "Coding Plan API Key for Global/Intl (subscription)",
"choiceHint": "Endpoint: coding-intl.dashscope.aliyuncs.com",
"groupId": "modelstudio",
"groupLabel": "Alibaba Cloud Model Studio",
"groupHint": "Coding Plan API key (CN / Global)",
"groupLabel": "Qwen (Alibaba Cloud Model Studio)",
"groupHint": "Standard / Coding Plan (CN / Global)",
"optionKey": "modelstudioApiKey",
"cliFlag": "--modelstudio-api-key",
"cliOption": "--modelstudio-api-key <key>",
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
ensureGlobalUndiciEnvProxyDispatcher: vi.fn(),
getOAuthApiKey: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/infra-runtime", () => ({
ensureGlobalUndiciEnvProxyDispatcher: mocks.ensureGlobalUndiciEnvProxyDispatcher,
}));
vi.mock("@mariozechner/pi-ai/oauth", () => ({
getOAuthApiKey: mocks.getOAuthApiKey,
}));
import { getOAuthApiKey } from "./openai-codex-provider.runtime.js";
describe("openai-codex-provider.runtime", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("bootstraps the env proxy dispatcher before refreshing oauth credentials", async () => {
const refreshed = {
newCredentials: {
access: "next-access",
refresh: "next-refresh",
expires: Date.now() + 60_000,
},
};
mocks.getOAuthApiKey.mockResolvedValue(refreshed);
await expect(
getOAuthApiKey("openai-codex", {
"openai-codex": {
provider: "openai-codex",
type: "oauth",
access: "access-token",
refresh: "refresh-token",
expires: Date.now(),
},
}),
).resolves.toBe(refreshed);
expect(mocks.ensureGlobalUndiciEnvProxyDispatcher).toHaveBeenCalledOnce();
expect(mocks.getOAuthApiKey).toHaveBeenCalledOnce();
expect(mocks.ensureGlobalUndiciEnvProxyDispatcher.mock.invocationCallOrder[0]).toBeLessThan(
mocks.getOAuthApiKey.mock.invocationCallOrder[0],
);
});
});
@@ -1 +1,9 @@
export { getOAuthApiKey } from "@mariozechner/pi-ai/oauth";
import { getOAuthApiKey as getOAuthApiKeyFromPi } from "@mariozechner/pi-ai/oauth";
import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/infra-runtime";
export async function getOAuthApiKey(
...args: Parameters<typeof getOAuthApiKeyFromPi>
): Promise<Awaited<ReturnType<typeof getOAuthApiKeyFromPi>>> {
ensureGlobalUndiciEnvProxyDispatcher();
return await getOAuthApiKeyFromPi(...args);
}
+5
View File
@@ -192,6 +192,11 @@ describe("slackPlugin outbound", () => {
},
};
it("advertises the 8000-character Slack default chunk limit", () => {
expect(slackOutbound.textChunkLimit).toBe(8000);
expect(slackPlugin.outbound?.textChunkLimit).toBe(8000);
});
it("uses threadId as threadTs fallback for sendText", async () => {
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-text" });
const sendText = requireSlackSendText();
+2 -1
View File
@@ -46,6 +46,7 @@ import {
} from "./directory-config.js";
import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js";
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import { normalizeAllowListLower } from "./monitor/allow-list.js";
import type { SlackProbe } from "./probe.js";
import { resolveSlackUserAllowlist } from "./resolve-users.js";
@@ -602,7 +603,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
base: {
deliveryMode: "direct",
chunker: null,
textChunkLimit: 4000,
textChunkLimit: SLACK_TEXT_LIMIT,
},
attachedResults: {
channel: "slack",
+18
View File
@@ -98,6 +98,24 @@ describe("createSlackDraftStream", () => {
expect(warn).toHaveBeenCalledTimes(1);
});
it("allows a 4205-character preview with the default max chars", async () => {
const { stream, send, warn } = createDraftStreamHarness();
const text = "a".repeat(4205);
stream.update(text);
await stream.flush();
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith(
"channel:C123",
text,
expect.objectContaining({
token: "xoxb-test",
}),
);
expect(warn).not.toHaveBeenCalled();
});
it("clear removes preview message when one exists", async () => {
const { stream, remove } = createDraftStreamHarness();
+2 -2
View File
@@ -1,8 +1,8 @@
import { createDraftStreamLoop } from "openclaw/plugin-sdk/channel-lifecycle";
import { deleteSlackMessage, editSlackMessage } from "./actions.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import { sendMessageSlack } from "./send.js";
const SLACK_STREAM_MAX_CHARS = 4000;
const DEFAULT_THROTTLE_MS = 1000;
export type SlackDraftStream = {
@@ -29,7 +29,7 @@ export function createSlackDraftStream(params: {
edit?: typeof editSlackMessage;
remove?: typeof deleteSlackMessage;
}): SlackDraftStream {
const maxChars = Math.min(params.maxChars ?? SLACK_STREAM_MAX_CHARS, SLACK_STREAM_MAX_CHARS);
const maxChars = Math.min(params.maxChars ?? SLACK_TEXT_LIMIT, SLACK_TEXT_LIMIT);
const throttleMs = Math.max(250, params.throttleMs ?? DEFAULT_THROTTLE_MS);
const send = params.send ?? sendMessageSlack;
const edit = params.edit ?? editSlackMessage;
+1
View File
@@ -0,0 +1 @@
export const SLACK_TEXT_LIMIT = 8000;
@@ -17,6 +17,7 @@ import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/secur
import { editSlackMessage, reactSlackMessage, removeSlackReaction } from "../../actions.js";
import { createSlackDraftStream } from "../../draft-stream.js";
import { normalizeSlackOutboundText } from "../../format.js";
import { SLACK_TEXT_LIMIT } from "../../limits.js";
import { recordSlackThreadParticipation } from "../../sent-thread-cache.js";
import {
applyAppendOnlyStreamUpdate,
@@ -375,7 +376,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
target: prepared.replyTarget,
token: ctx.botToken,
accountId: account.accountId,
maxChars: Math.min(ctx.textLimit, 4000),
maxChars: Math.min(ctx.textLimit, SLACK_TEXT_LIMIT),
resolveThreadTs: () => {
const ts = replyPlan.nextThreadTs();
if (ts) {
+4 -1
View File
@@ -28,6 +28,7 @@ import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime";
import { resolveSlackAccount } from "../accounts.js";
import { resolveSlackWebClientOptions } from "../client.js";
import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js";
import { SLACK_TEXT_LIMIT } from "../limits.js";
import { resolveSlackChannelAllowlist } from "../resolve-channels.js";
import { resolveSlackUserAllowlist } from "../resolve-users.js";
import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js";
@@ -242,7 +243,9 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const threadHistoryScope = slackCfg.thread?.historyScope ?? "thread";
const threadInheritParent = slackCfg.thread?.inheritParent ?? false;
const slashCommand = resolveSlackSlashCommandConfig(opts.slashCommand ?? slackCfg.slashCommand);
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId, {
fallbackLimit: SLACK_TEXT_LIMIT,
});
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const typingReaction = slackCfg.typingReaction?.trim() ?? "";
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
@@ -6,6 +6,7 @@ vi.mock("../send.js", () => ({
}));
let deliverReplies: typeof import("./replies.js").deliverReplies;
import { deliverSlackSlashReplies } from "./replies.js";
function baseParams(overrides?: Record<string, unknown>) {
return {
@@ -97,3 +98,23 @@ describe("deliverReplies identity passthrough", () => {
);
});
});
describe("deliverSlackSlashReplies chunking", () => {
it("keeps a 4205-character reply in a single slash response by default", async () => {
const respond = vi.fn(async () => undefined);
const text = "a".repeat(4205);
await deliverSlackSlashReplies({
replies: [{ text }],
respond,
ephemeral: true,
textLimit: 8000,
});
expect(respond).toHaveBeenCalledTimes(1);
expect(respond).toHaveBeenCalledWith({
text,
response_type: "ephemeral",
});
});
});
+2 -1
View File
@@ -11,6 +11,7 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { parseSlackBlocksInput } from "../blocks-input.js";
import { markdownToSlackMrkdwnChunks } from "../format.js";
import { SLACK_TEXT_LIMIT } from "../limits.js";
import { sendMessageSlack, type SlackSendIdentity } from "../send.js";
export function readSlackReplyBlocks(payload: ReplyPayload) {
@@ -188,7 +189,7 @@ export async function deliverSlackSlashReplies(params: {
chunkMode?: ChunkMode;
}) {
const messages: string[] = [];
const chunkLimit = Math.min(params.textLimit, 4000);
const chunkLimit = Math.min(params.textLimit, SLACK_TEXT_LIMIT);
for (const payload of params.replies) {
const reply = resolveSendableOutboundReplyParts(payload);
const text =
+2 -1
View File
@@ -19,6 +19,7 @@ import {
} from "openclaw/plugin-sdk/reply-payload";
import { parseSlackBlocksInput } from "./blocks-input.js";
import { buildSlackInteractiveBlocks, type SlackBlock } from "./blocks-render.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import { sendMessageSlack, type SlackSendIdentity } from "./send.js";
const SLACK_MAX_BLOCKS = 50;
@@ -149,7 +150,7 @@ function resolveSlackBlocks(payload: {
export const slackOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: null,
textChunkLimit: 4000,
textChunkLimit: SLACK_TEXT_LIMIT,
sendPayload: async (ctx) => {
const payload = {
...ctx.payload,
+20
View File
@@ -50,6 +50,26 @@ describe("sendMessageSlack NO_REPLY guard", () => {
});
});
describe("sendMessageSlack chunking", () => {
it("keeps 4205-character text in a single Slack post by default", async () => {
const client = createSlackSendTestClient();
const message = "a".repeat(4205);
await sendMessageSlack("channel:C123", message, {
token: "xoxb-test",
client,
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(1);
expect(client.chat.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
channel: "C123",
text: message,
}),
);
});
});
describe("sendMessageSlack blocks", () => {
it("posts blocks with fallback text when message is empty", async () => {
const client = createSlackSendTestClient();
+4 -3
View File
@@ -20,10 +20,9 @@ import { buildSlackBlocksFallbackText } from "./blocks-fallback.js";
import { validateSlackBlocksArray } from "./blocks-input.js";
import { createSlackWebClient } from "./client.js";
import { markdownToSlackMrkdwnChunks } from "./format.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import { parseSlackTarget } from "./targets.js";
import { resolveSlackBotToken } from "./token.js";
const SLACK_TEXT_LIMIT = 4000;
const SLACK_UPLOAD_SSRF_POLICY = {
allowedHostnames: ["*.slack.com", "*.slack-edge.com", "*.slack-files.com"],
allowRfc2544BenchmarkRange: true,
@@ -296,7 +295,9 @@ export async function sendMessageSlack(
channelId,
};
}
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId, {
fallbackLimit: SLACK_TEXT_LIMIT,
});
const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT);
const tableMode = resolveMarkdownTableMode({
cfg,
+20 -4
View File
@@ -3,6 +3,14 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
mockExtractMessageContent,
mockGetContentType,
mockIsJidGroup,
mockNormalizeMessageContent,
} from "../../../test/mocks/baileys.js";
type MockMessageInput = Parameters<typeof mockNormalizeMessageContent>[0];
const readAllowFromStoreMock = vi.fn().mockResolvedValue([]);
const upsertPairingRequestMock = vi.fn().mockResolvedValue({ code: "PAIRCODE", created: true });
@@ -55,9 +63,8 @@ vi.mock("openclaw/plugin-sdk/media-runtime", async () => {
const HOME = path.join(os.tmpdir(), `openclaw-inbound-media-${crypto.randomUUID()}`);
process.env.HOME = HOME;
vi.mock("@whiskeysockets/baileys", async () => {
const actual =
await vi.importActual<typeof import("@whiskeysockets/baileys")>("@whiskeysockets/baileys");
vi.mock("@whiskeysockets/baileys", async (importOriginal) => {
const actual = await importOriginal<typeof import("@whiskeysockets/baileys")>();
const jpegBuffer = Buffer.from([
0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x03, 0x02, 0x02, 0x02, 0x02, 0x02, 0x03, 0x02, 0x02,
0x02, 0x03, 0x03, 0x03, 0x03, 0x04, 0x06, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08, 0x06, 0x06, 0x05,
@@ -72,11 +79,19 @@ vi.mock("@whiskeysockets/baileys", async () => {
]);
return {
...actual,
DisconnectReason: actual.DisconnectReason ?? { loggedOut: 401 },
downloadMediaMessage: vi.fn().mockResolvedValue(jpegBuffer),
extractMessageContent: vi.fn((message: MockMessageInput) => mockExtractMessageContent(message)),
getContentType: vi.fn((message: MockMessageInput) => mockGetContentType(message)),
isJidGroup: vi.fn((jid: string | undefined | null) => mockIsJidGroup(jid)),
normalizeMessageContent: vi.fn((message: MockMessageInput) =>
mockNormalizeMessageContent(message),
),
};
});
vi.mock("./session.js", () => {
vi.mock("./session.js", async () => {
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
const { EventEmitter } = require("node:events");
const ev = new EventEmitter();
const sock = {
@@ -90,6 +105,7 @@ vi.mock("./session.js", () => {
user: { id: "me@s.whatsapp.net" },
};
return {
...actual,
createWaSocket: vi.fn().mockResolvedValue(sock),
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
getStatusCode: vi.fn(() => 200),
+103 -5
View File
@@ -9,8 +9,106 @@ import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { jidToE164 } from "openclaw/plugin-sdk/text-runtime";
import { parseVcard } from "../vcard.js";
const MESSAGE_WRAPPER_KEYS = [
"ephemeralMessage",
"viewOnceMessage",
"viewOnceMessageV2",
"viewOnceMessageV2Extension",
"documentWithCaptionMessage",
] as const;
const MESSAGE_CONTENT_KEYS = [
"conversation",
"extendedTextMessage",
"imageMessage",
"videoMessage",
"audioMessage",
"documentMessage",
"stickerMessage",
"locationMessage",
"liveLocationMessage",
"contactMessage",
"contactsArrayMessage",
"buttonsResponseMessage",
"listResponseMessage",
"templateButtonReplyMessage",
"interactiveResponseMessage",
"buttonsMessage",
"listMessage",
] as const;
function fallbackNormalizeMessageContent(
message: proto.IMessage | undefined,
): proto.IMessage | undefined {
let current = message as unknown;
while (current && typeof current === "object") {
let unwrapped = false;
for (const key of MESSAGE_WRAPPER_KEYS) {
const candidate = (current as Record<string, unknown>)[key];
if (
candidate &&
typeof candidate === "object" &&
"message" in (candidate as Record<string, unknown>) &&
(candidate as { message?: unknown }).message
) {
current = (candidate as { message: unknown }).message;
unwrapped = true;
break;
}
}
if (!unwrapped) {
break;
}
}
return current as proto.IMessage | undefined;
}
function normalizeMessage(message: proto.IMessage | undefined): proto.IMessage | undefined {
if (typeof normalizeMessageContent === "function") {
return normalizeMessageContent(message);
}
return fallbackNormalizeMessageContent(message);
}
function fallbackGetContentType(
message: proto.IMessage | undefined,
): keyof proto.IMessage | undefined {
const normalized = fallbackNormalizeMessageContent(message);
if (!normalized || typeof normalized !== "object") {
return undefined;
}
for (const key of MESSAGE_CONTENT_KEYS) {
if ((normalized as Record<string, unknown>)[key] != null) {
return key as keyof proto.IMessage;
}
}
return undefined;
}
function getMessageContentType(
message: proto.IMessage | undefined,
): keyof proto.IMessage | undefined {
if (typeof getContentType === "function") {
return getContentType(message);
}
return fallbackGetContentType(message);
}
function extractMessage(message: proto.IMessage | undefined): proto.IMessage | undefined {
if (typeof extractMessageContent === "function") {
return extractMessageContent(message) as proto.IMessage | undefined;
}
const normalized = fallbackNormalizeMessageContent(message);
const contentType = fallbackGetContentType(normalized);
if (!normalized || !contentType || contentType === "conversation") {
return normalized;
}
const candidate = (normalized as Record<string, unknown>)[contentType];
return candidate && typeof candidate === "object" ? (candidate as proto.IMessage) : normalized;
}
function unwrapMessage(message: proto.IMessage | undefined): proto.IMessage | undefined {
const normalized = normalizeMessageContent(message);
const normalized = normalizeMessage(message);
return normalized;
}
@@ -18,7 +116,7 @@ function extractContextInfo(message: proto.IMessage | undefined): proto.IContext
if (!message) {
return undefined;
}
const contentType = getContentType(message);
const contentType = getMessageContentType(message);
const candidate = contentType ? (message as Record<string, unknown>)[contentType] : undefined;
const contextInfo =
candidate && typeof candidate === "object" && "contextInfo" in candidate
@@ -89,7 +187,7 @@ export function extractText(rawMessage: proto.IMessage | undefined): string | un
if (!message) {
return undefined;
}
const extracted = extractMessageContent(message);
const extracted = extractMessage(message);
const candidates = [message, extracted && extracted !== message ? extracted : undefined];
for (const candidate of candidates) {
if (!candidate) {
@@ -300,7 +398,7 @@ export function describeReplyContext(rawMessage: proto.IMessage | undefined): {
return null;
}
const contextInfo = extractContextInfo(message);
const quoted = normalizeMessageContent(contextInfo?.quotedMessage as proto.IMessage | undefined);
const quoted = normalizeMessage(contextInfo?.quotedMessage as proto.IMessage | undefined);
if (!quoted) {
return null;
}
@@ -312,7 +410,7 @@ export function describeReplyContext(rawMessage: proto.IMessage | undefined): {
body = extractMediaPlaceholder(quoted);
}
if (!body) {
const quotedType = quoted ? getContentType(quoted) : undefined;
const quotedType = quoted ? getMessageContentType(quoted) : undefined;
logVerbose(
`Quoted message missing extractable body${quotedType ? ` (type ${quotedType})` : ""}`,
);
@@ -1,14 +1,30 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
mockExtractMessageContent,
mockGetContentType,
mockIsJidGroup,
mockNormalizeMessageContent,
} from "../../../../test/mocks/baileys.js";
type MockMessageInput = Parameters<typeof mockNormalizeMessageContent>[0];
const { normalizeMessageContent, downloadMediaMessage } = vi.hoisted(() => ({
normalizeMessageContent: vi.fn((msg: unknown) => msg),
normalizeMessageContent: vi.fn((msg: MockMessageInput) => mockNormalizeMessageContent(msg)),
downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from("fake-media-data")),
}));
vi.mock("@whiskeysockets/baileys", () => ({
normalizeMessageContent,
downloadMediaMessage,
}));
vi.mock("@whiskeysockets/baileys", async (importOriginal) => {
const actual = await importOriginal<typeof import("@whiskeysockets/baileys")>();
return {
...actual,
DisconnectReason: actual.DisconnectReason ?? { loggedOut: 401 },
extractMessageContent: vi.fn((message: MockMessageInput) => mockExtractMessageContent(message)),
getContentType: vi.fn((message: MockMessageInput) => mockGetContentType(message)),
isJidGroup: vi.fn((jid: string | undefined | null) => mockIsJidGroup(jid)),
normalizeMessageContent,
downloadMediaMessage,
};
});
let downloadInboundMedia: typeof import("./media.js").downloadInboundMedia;
+8 -2
View File
@@ -22,6 +22,12 @@ import { downloadInboundMedia } from "./media.js";
import { createWebSendApi } from "./send-api.js";
import type { WebInboundMessage, WebListenerCloseReason } from "./types.js";
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
function isGroupJid(jid: string): boolean {
return (typeof isJidGroup === "function" ? isJidGroup(jid) : jid.endsWith("@g.us")) === true;
}
export async function monitorWebInbox(options: {
verbose: boolean;
accountId: string;
@@ -176,7 +182,7 @@ export async function monitorWebInbox(options: {
return null;
}
const group = isJidGroup(remoteJid) === true;
const group = isGroupJid(remoteJid);
if (id) {
const dedupeKey = `${options.accountId}:${remoteJid}:${id}`;
if (isRecentInboundMessage(dedupeKey)) {
@@ -438,7 +444,7 @@ export async function monitorWebInbox(options: {
const status = getStatusCode(update.lastDisconnect?.error);
resolveClose({
status,
isLoggedOut: status === DisconnectReason.loggedOut,
isLoggedOut: status === LOGGED_OUT_STATUS,
error: update.lastDisconnect?.error,
});
}
+3 -1
View File
@@ -7,7 +7,8 @@ import {
waitForWaConnection,
} from "./session.js";
vi.mock("./session.js", () => {
vi.mock("./session.js", async () => {
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
const createWaSocket = vi.fn(
async (_printQr: boolean, _verbose: boolean, opts?: { onQr?: (qr: string) => void }) => {
const sock = { ws: { close: vi.fn() } };
@@ -30,6 +31,7 @@ vi.mock("./session.js", () => {
const logoutWeb = vi.fn(async () => true);
const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {});
return {
...actual,
createWaSocket,
waitForWaConnection,
formatError,
+3 -1
View File
@@ -17,6 +17,8 @@ import {
webAuthExists,
} from "./session.js";
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
type WaSocket = Awaited<ReturnType<typeof createWaSocket>>;
type ActiveLogin = {
@@ -261,7 +263,7 @@ export async function waitForWebLogin(
}
if (login.error) {
if (login.errorStatus === DisconnectReason.loggedOut) {
if (login.errorStatus === LOGGED_OUT_STATUS) {
await logoutWeb({
authDir: login.authDir,
isLegacyAuthDir: login.isLegacyAuthDir,
+63 -60
View File
@@ -1,37 +1,23 @@
import { rmSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DisconnectReason } from "@whiskeysockets/baileys";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loginWeb } from "./login.js";
import {
createWaSocket,
formatError,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
} from "./session.js";
const rmMock = vi.spyOn(fs, "rm");
const sessionMocks = vi.hoisted(() => {
const sockA = { ws: { close: vi.fn() } };
const sockB = { ws: { close: vi.fn() } };
const createWaSocket = vi.fn(async () => (createWaSocket.mock.calls.length <= 1 ? sockA : sockB));
return {
sockA,
sockB,
createWaSocket,
waitForWaConnection: vi.fn(),
formatError: vi.fn((err: unknown) => `formatted:${String(err)}`),
getStatusCode: vi.fn(
(err: unknown) =>
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
(err as { status?: number })?.status ??
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode,
),
waitForCredsSaveQueueWithTimeout: vi.fn(async () => {}),
};
});
let loginWeb: typeof import("./login.js").loginWeb;
const testState = vi.hoisted(() => ({
authDir: `${(process.env.TMPDIR ?? "/tmp").replace(/\/+$/, "")}/openclaw-wa-creds-${process.pid}-${Math.random().toString(16).slice(2)}`,
}));
function resolveTestAuthDir() {
return path.join(os.tmpdir(), "wa-creds");
return testState.authDir;
}
const authDir = resolveTestAuthDir();
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
"openclaw/plugin-sdk/config-runtime",
@@ -51,14 +37,28 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
};
});
vi.mock("./session.js", () => {
vi.mock("./session.js", async () => {
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
const authDir = resolveTestAuthDir();
const sockA = { ws: { close: vi.fn() } };
const sockB = { ws: { close: vi.fn() } };
const createWaSocket = vi.fn(async () => (createWaSocket.mock.calls.length <= 1 ? sockA : sockB));
const waitForWaConnection = vi.fn();
const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`);
const getStatusCode = vi.fn(
(err: unknown) =>
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
(err as { status?: number })?.status ??
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode,
);
const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {});
return {
createWaSocket: sessionMocks.createWaSocket,
waitForWaConnection: sessionMocks.waitForWaConnection,
formatError: sessionMocks.formatError,
getStatusCode: sessionMocks.getStatusCode,
waitForCredsSaveQueueWithTimeout: sessionMocks.waitForCredsSaveQueueWithTimeout,
...actual,
createWaSocket,
waitForWaConnection,
formatError,
getStatusCode,
waitForCredsSaveQueueWithTimeout,
WA_WEB_AUTH_DIR: authDir,
logoutWeb: vi.fn(async (params: { authDir?: string }) => {
await fs.rm(params.authDir ?? authDir, {
@@ -70,79 +70,82 @@ vi.mock("./session.js", () => {
};
});
const createWaSocketMock = vi.mocked(createWaSocket);
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout);
const formatErrorMock = vi.mocked(formatError);
async function flushTasks() {
await Promise.resolve();
await Promise.resolve();
}
describe("loginWeb coverage", () => {
beforeEach(async () => {
vi.resetModules();
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
({ loginWeb } = await import("./login.js"));
sessionMocks.sockA.ws.close.mockClear();
sessionMocks.sockB.ws.close.mockClear();
sessionMocks.createWaSocket.mockClear();
sessionMocks.waitForWaConnection.mockReset().mockResolvedValue(undefined);
sessionMocks.waitForCredsSaveQueueWithTimeout.mockReset().mockResolvedValue(undefined);
sessionMocks.formatError
.mockReset()
.mockImplementation((err: unknown) => `formatted:${String(err)}`);
createWaSocketMock.mockClear();
waitForWaConnectionMock.mockReset().mockResolvedValue(undefined);
waitForCredsSaveQueueWithTimeoutMock.mockReset().mockResolvedValue(undefined);
formatErrorMock.mockReset().mockImplementation((err: unknown) => `formatted:${String(err)}`);
rmMock.mockClear();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
afterAll(() => {
rmSync(testState.authDir, { recursive: true, force: true });
});
it("restarts once when WhatsApp requests code 515", async () => {
let releaseCredsFlush: (() => void) | undefined;
const credsFlushGate = new Promise<void>((resolve) => {
releaseCredsFlush = resolve;
});
sessionMocks.waitForWaConnection
waitForWaConnectionMock
.mockRejectedValueOnce({ error: { output: { statusCode: 515 } } })
.mockResolvedValueOnce(undefined);
sessionMocks.waitForCredsSaveQueueWithTimeout.mockReturnValueOnce(credsFlushGate);
waitForCredsSaveQueueWithTimeoutMock.mockReturnValueOnce(credsFlushGate);
const runtime = { log: vi.fn(), error: vi.fn() } as never;
const pendingLogin = loginWeb(false, sessionMocks.waitForWaConnection as never, runtime);
const pendingLogin = loginWeb(false, waitForWaConnectionMock as never, runtime);
await flushTasks();
expect(sessionMocks.createWaSocket).toHaveBeenCalledTimes(1);
expect(sessionMocks.waitForCredsSaveQueueWithTimeout).toHaveBeenCalledOnce();
expect(sessionMocks.waitForCredsSaveQueueWithTimeout).toHaveBeenCalledWith(authDir);
expect(createWaSocketMock).toHaveBeenCalledTimes(1);
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledOnce();
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith(testState.authDir);
releaseCredsFlush?.();
await pendingLogin;
expect(sessionMocks.createWaSocket).toHaveBeenCalledTimes(2);
const firstSock = await sessionMocks.createWaSocket.mock.results[0]?.value;
expect(createWaSocketMock).toHaveBeenCalledTimes(2);
const firstSock = await createWaSocketMock.mock.results[0]?.value;
expect(firstSock.ws.close).toHaveBeenCalled();
vi.runAllTimers();
const secondSock = await sessionMocks.createWaSocket.mock.results[1]?.value;
const secondSock = await createWaSocketMock.mock.results[1]?.value;
expect(secondSock.ws.close).toHaveBeenCalled();
});
it("clears creds and throws when logged out", async () => {
sessionMocks.waitForWaConnection.mockRejectedValueOnce({
output: { statusCode: DisconnectReason.loggedOut },
waitForWaConnectionMock.mockRejectedValueOnce({
output: { statusCode: 401 },
});
await expect(loginWeb(false, sessionMocks.waitForWaConnection as never)).rejects.toThrow(
await expect(loginWeb(false, waitForWaConnectionMock as never)).rejects.toThrow(
/cache cleared/i,
);
expect(rmMock).toHaveBeenCalledWith(authDir, {
expect(rmMock).toHaveBeenCalledWith(testState.authDir, {
recursive: true,
force: true,
});
});
it("formats and rethrows generic errors", async () => {
sessionMocks.waitForWaConnection.mockRejectedValueOnce(new Error("boom"));
await expect(loginWeb(false, sessionMocks.waitForWaConnection as never)).rejects.toThrow(
waitForWaConnectionMock.mockRejectedValueOnce(new Error("boom"));
await expect(loginWeb(false, waitForWaConnectionMock as never)).rejects.toThrow(
"formatted:Error: boom",
);
expect(sessionMocks.formatError).toHaveBeenCalled();
expect(formatErrorMock).toHaveBeenCalled();
});
});
+3 -1
View File
@@ -5,7 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resetLogger, setLoggerOverride } from "../../../src/logging.js";
import { renderQrPngBase64 } from "./qr-image.js";
vi.mock("./session.js", () => {
vi.mock("./session.js", async () => {
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
const ev = new EventEmitter();
const sock = {
ev,
@@ -14,6 +15,7 @@ vi.mock("./session.js", () => {
sendMessage: vi.fn(),
};
return {
...actual,
createWaSocket: vi.fn().mockResolvedValue(sock),
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
};
+3 -1
View File
@@ -14,6 +14,8 @@ import {
waitForWaConnection,
} from "./session.js";
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
export async function loginWeb(
verbose: boolean,
waitForConnection?: typeof waitForWaConnection,
@@ -53,7 +55,7 @@ export async function loginWeb(
setTimeout(() => retry.ws?.close(), 500);
}
}
if (code === DisconnectReason.loggedOut) {
if (code === LOGGED_OUT_STATUS) {
await logoutWeb({
authDir: account.authDir,
isLegacyAuthDir: account.isLegacyAuthDir,
@@ -118,16 +118,20 @@ vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
};
});
vi.mock("./session.js", () => ({
createWaSocket: vi.fn().mockImplementation(async () => {
if (!sessionState.sock) {
throw new Error("mock WhatsApp socket not initialized");
}
return sessionState.sock;
}),
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
getStatusCode: vi.fn(() => 500),
}));
vi.mock("./session.js", async () => {
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
return {
...actual,
createWaSocket: vi.fn().mockImplementation(async () => {
if (!sessionState.sock) {
throw new Error("mock WhatsApp socket not initialized");
}
return sessionState.sock;
}),
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
getStatusCode: vi.fn(() => 500),
};
});
export function getSock(): MockSock {
if (!sessionState.sock) {
+3 -1
View File
@@ -33,6 +33,8 @@ export {
webAuthExists,
} from "./auth-store.js";
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
// Per-authDir queues so multi-account creds saves don't block each other.
const credsSaveQueues = new Map<string, Promise<void>>();
const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000;
@@ -142,7 +144,7 @@ export async function createWaSocket(
}
if (connection === "close") {
const status = getStatusCode(lastDisconnect?.error);
if (status === DisconnectReason.loggedOut) {
if (status === LOGGED_OUT_STATUS) {
console.error(
danger(
`WhatsApp session logged out. Run: ${formatCliCommand("openclaw channels login")}`,
+6 -2
View File
@@ -168,11 +168,15 @@ vi.mock("openclaw/plugin-sdk/state-paths", async (importOriginal) => {
};
});
vi.mock("@whiskeysockets/baileys", () => {
vi.mock("@whiskeysockets/baileys", async (importOriginal) => {
const actual = await importOriginal<typeof import("@whiskeysockets/baileys")>();
const created = createMockBaileys();
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw:lastSocket")] =
created.lastSocket;
return created.mod;
return {
...actual,
...created.mod,
};
});
vi.mock("qrcode-terminal", () => ({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.3.23-beta.1",
"version": "2026.3.23",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",
+106 -3
View File
@@ -31,6 +31,7 @@ GUEST_NPM_BIN="/opt/homebrew/bin/npm"
MAIN_TGZ_DIR="$(mktemp -d)"
MAIN_TGZ_PATH=""
PACKED_MAIN_COMMIT_SHORT=""
SERVER_PID=""
RUN_DIR="$(mktemp -d /tmp/openclaw-parallels-smoke.XXXXXX)"
BUILD_LOCK_DIR="${TMPDIR:-/tmp}/openclaw-parallels-build.lock"
@@ -41,6 +42,7 @@ TIMEOUT_ONBOARD_S=180
TIMEOUT_GATEWAY_S=60
TIMEOUT_AGENT_S=120
TIMEOUT_PERMISSION_S=60
TIMEOUT_DASHBOARD_S=60
TIMEOUT_SNAPSHOT_S=180
TIMEOUT_DISCORD_S=180
@@ -51,6 +53,8 @@ FRESH_GATEWAY_STATUS="skip"
UPGRADE_GATEWAY_STATUS="skip"
FRESH_AGENT_STATUS="skip"
UPGRADE_AGENT_STATUS="skip"
FRESH_DASHBOARD_STATUS="skip"
UPGRADE_DASHBOARD_STATUS="skip"
FRESH_DISCORD_STATUS="skip"
UPGRADE_DISCORD_STATUS="skip"
@@ -562,8 +566,12 @@ extract_package_version_from_tgz() {
tar -xOf "$1" package/package.json | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])'
}
extract_package_build_commit_from_tgz() {
tar -xOf "$1" package/dist/build-info.json | python3 -c 'import json, sys; print(json.load(sys.stdin).get("commit", ""))'
}
pack_main_tgz() {
local short_head pkg
local short_head pkg packed_commit
if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then
say "Pack target package tgz: $TARGET_PACKAGE_SPEC"
pkg="$(
@@ -578,6 +586,7 @@ pack_main_tgz() {
fi
say "Pack current main tgz"
ensure_current_build
stage_pack_runtime_deps
short_head="$(git rev-parse --short HEAD)"
pkg="$(
npm pack --ignore-scripts --json --pack-destination "$MAIN_TGZ_DIR" \
@@ -585,6 +594,9 @@ pack_main_tgz() {
)"
MAIN_TGZ_PATH="$MAIN_TGZ_DIR/openclaw-main-$short_head.tgz"
cp "$MAIN_TGZ_DIR/$pkg" "$MAIN_TGZ_PATH"
packed_commit="$(extract_package_build_commit_from_tgz "$MAIN_TGZ_PATH")"
[[ -n "$packed_commit" ]] || die "failed to read packed build commit from $MAIN_TGZ_PATH"
PACKED_MAIN_COMMIT_SHORT="${packed_commit:0:7}"
say "Packed $MAIN_TGZ_PATH"
tar -xOf "$MAIN_TGZ_PATH" package/dist/build-info.json
}
@@ -594,7 +606,8 @@ verify_target_version() {
verify_version_contains "$TARGET_EXPECT_VERSION"
return
fi
verify_version_contains "$(git rev-parse --short=7 HEAD)"
[[ -n "$PACKED_MAIN_COMMIT_SHORT" ]] || die "packed main commit not captured"
verify_version_contains "$PACKED_MAIN_COMMIT_SHORT"
}
current_build_commit() {
@@ -610,6 +623,10 @@ else:
PY
}
current_control_ui_ready() {
[[ -f "dist/control-ui/index.html" ]]
}
acquire_build_lock() {
local owner_pid=""
while ! mkdir "$BUILD_LOCK_DIR" 2>/dev/null; do
@@ -637,15 +654,22 @@ ensure_current_build() {
acquire_build_lock
head="$(git rev-parse HEAD)"
build_commit="$(current_build_commit)"
if [[ "$build_commit" == "$head" ]]; then
if [[ "$build_commit" == "$head" ]] && current_control_ui_ready; then
release_build_lock
return
fi
say "Build dist for current head"
pnpm build
say "Build Control UI for current head"
pnpm ui:build
build_commit="$(current_build_commit)"
release_build_lock
[[ "$build_commit" == "$head" ]] || die "dist/build-info.json still does not match HEAD after build"
current_control_ui_ready || die "dist/control-ui/index.html missing after ui build"
}
stage_pack_runtime_deps() {
node scripts/stage-bundled-plugin-runtime-deps.mjs
}
start_server() {
@@ -719,6 +743,77 @@ verify_turn() {
--json
}
resolve_dashboard_url() {
local dashboard_url
dashboard_url="$(
guest_current_user_cli "$GUEST_OPENCLAW_BIN" dashboard --no-open \
| awk '/^Dashboard URL: / { sub(/^Dashboard URL: /, ""); print; exit }'
)"
dashboard_url="${dashboard_url//$'\r'/}"
dashboard_url="${dashboard_url//$'\n'/}"
[[ -n "$dashboard_url" ]] || {
echo "failed to resolve dashboard URL from openclaw dashboard --no-open" >&2
return 1
}
printf '%s\n' "$dashboard_url"
}
verify_dashboard_load() {
local dashboard_url dashboard_http_url dashboard_url_q dashboard_http_url_q cmd
dashboard_url="$(resolve_dashboard_url)"
dashboard_http_url="${dashboard_url%%#*}"
dashboard_url_q="$(shell_quote "$dashboard_url")"
dashboard_http_url_q="$(shell_quote "$dashboard_http_url")"
cmd="$(cat <<EOF
set -eu
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin:\${PATH:-}"
if [ -z "\${HOME:-}" ]; then export HOME="/Users/\$(id -un)"; fi
cd "\$HOME"
dashboard_url=$dashboard_url_q
dashboard_http_url=$dashboard_http_url_q
dashboard_port=\$(printf '%s\n' "\$dashboard_http_url" | sed -E 's#^https?://[^:/]+:([0-9]+).*\$#\1#')
if [ -z "\$dashboard_port" ] || [ "\$dashboard_port" = "\$dashboard_http_url" ]; then
echo "failed to parse dashboard port from \$dashboard_http_url" >&2
exit 1
fi
deadline=\$((SECONDS + 30))
dashboard_ready=0
while [ \$SECONDS -lt \$deadline ]; do
if curl -fsSL "\$dashboard_http_url" >/tmp/openclaw-dashboard-smoke.html 2>/dev/null; then
if grep -F '<title>OpenClaw Control</title>' /tmp/openclaw-dashboard-smoke.html >/dev/null; then
if grep -F '<openclaw-app></openclaw-app>' /tmp/openclaw-dashboard-smoke.html >/dev/null; then
dashboard_ready=1
break
fi
fi
fi
sleep 1
done
[ "\$dashboard_ready" = "1" ] || {
echo "dashboard HTML did not become ready at \$dashboard_http_url" >&2
exit 1
}
grep -F '<title>OpenClaw Control</title>' /tmp/openclaw-dashboard-smoke.html >/dev/null
grep -F '<openclaw-app></openclaw-app>' /tmp/openclaw-dashboard-smoke.html >/dev/null
pkill -x Safari >/dev/null 2>&1 || true
open -a Safari "\$dashboard_url"
deadline=\$((SECONDS + 20))
while [ \$SECONDS -lt \$deadline ]; do
if pgrep -x Safari >/dev/null 2>&1; then
if lsof -nPiTCP:"\$dashboard_port" -sTCP:ESTABLISHED 2>/dev/null \
| awk 'NR > 1 && \$1 != "node" { found = 1 } END { exit found ? 0 : 1 }'; then
exit 0
fi
fi
sleep 1
done
echo "Safari did not establish a dashboard client connection on port \$dashboard_port" >&2
exit 1
EOF
)"
guest_current_user_exec /bin/sh -lc "$cmd"
}
configure_discord_smoke() {
local guilds_json script
guilds_json="$(
@@ -996,6 +1091,7 @@ summary = {
"version": os.environ["SUMMARY_FRESH_MAIN_VERSION"],
"gateway": os.environ["SUMMARY_FRESH_GATEWAY_STATUS"],
"agent": os.environ["SUMMARY_FRESH_AGENT_STATUS"],
"dashboard": os.environ["SUMMARY_FRESH_DASHBOARD_STATUS"],
"discord": os.environ["SUMMARY_FRESH_DISCORD_STATUS"],
},
"upgrade": {
@@ -1005,6 +1101,7 @@ summary = {
"mainVersion": os.environ["SUMMARY_UPGRADE_MAIN_VERSION"],
"gateway": os.environ["SUMMARY_UPGRADE_GATEWAY_STATUS"],
"agent": os.environ["SUMMARY_UPGRADE_AGENT_STATUS"],
"dashboard": os.environ["SUMMARY_UPGRADE_DASHBOARD_STATUS"],
"discord": os.environ["SUMMARY_UPGRADE_DISCORD_STATUS"],
},
}
@@ -1041,6 +1138,8 @@ run_fresh_main_lane() {
phase_run "fresh.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard
phase_run "fresh.gateway-status" "$TIMEOUT_GATEWAY_S" verify_gateway
FRESH_GATEWAY_STATUS="pass"
phase_run "fresh.dashboard-load" "$TIMEOUT_DASHBOARD_S" verify_dashboard_load
FRESH_DASHBOARD_STATUS="pass"
phase_run "fresh.first-agent-turn" "$TIMEOUT_AGENT_S" verify_turn
FRESH_AGENT_STATUS="pass"
if discord_smoke_enabled; then
@@ -1074,6 +1173,8 @@ run_upgrade_lane() {
phase_run "upgrade.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard
phase_run "upgrade.gateway-status" "$TIMEOUT_GATEWAY_S" verify_gateway
UPGRADE_GATEWAY_STATUS="pass"
phase_run "upgrade.dashboard-load" "$TIMEOUT_DASHBOARD_S" verify_dashboard_load
UPGRADE_DASHBOARD_STATUS="pass"
phase_run "upgrade.first-agent-turn" "$TIMEOUT_AGENT_S" verify_turn
UPGRADE_AGENT_STATUS="pass"
if discord_smoke_enabled; then
@@ -1153,6 +1254,7 @@ SUMMARY_JSON_PATH="$(
SUMMARY_FRESH_MAIN_VERSION="$FRESH_MAIN_VERSION" \
SUMMARY_FRESH_GATEWAY_STATUS="$FRESH_GATEWAY_STATUS" \
SUMMARY_FRESH_AGENT_STATUS="$FRESH_AGENT_STATUS" \
SUMMARY_FRESH_DASHBOARD_STATUS="$FRESH_DASHBOARD_STATUS" \
SUMMARY_FRESH_DISCORD_STATUS="$FRESH_DISCORD_STATUS" \
SUMMARY_UPGRADE_PRECHECK_STATUS="$UPGRADE_PRECHECK_STATUS" \
SUMMARY_UPGRADE_STATUS="$UPGRADE_STATUS" \
@@ -1160,6 +1262,7 @@ SUMMARY_JSON_PATH="$(
SUMMARY_UPGRADE_MAIN_VERSION="$UPGRADE_MAIN_VERSION" \
SUMMARY_UPGRADE_GATEWAY_STATUS="$UPGRADE_GATEWAY_STATUS" \
SUMMARY_UPGRADE_AGENT_STATUS="$UPGRADE_AGENT_STATUS" \
SUMMARY_UPGRADE_DASHBOARD_STATUS="$UPGRADE_DASHBOARD_STATUS" \
SUMMARY_UPGRADE_DISCORD_STATUS="$UPGRADE_DISCORD_STATUS" \
write_summary_json
)"
+2
View File
@@ -42,6 +42,7 @@ const EXPECTED_REPOSITORY_URL = "https://github.com/openclaw/openclaw";
const MAX_CALVER_DISTANCE_DAYS = 2;
const REQUIRED_PACKED_PATHS = ["dist/control-ui/index.html"];
const CONTROL_UI_ASSET_PREFIX = "dist/control-ui/assets/";
const NPM_PACK_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
function normalizeRepoUrl(value: unknown): string {
if (typeof value !== "string") {
@@ -315,6 +316,7 @@ function runNpmCommand(args: string[]): string {
const invocation = resolveNpmCommandInvocation();
return execFileSync(invocation.command, [...invocation.args, ...args], {
encoding: "utf8",
maxBuffer: NPM_PACK_MAX_BUFFER_BYTES,
stdio: ["ignore", "pipe", "pipe"],
});
}
+12 -7
View File
@@ -36,6 +36,7 @@ DSYM_ZIP="$ROOT_DIR/dist/OpenClaw-$VERSION.dSYM.zip"
SKIP_NOTARIZE="${SKIP_NOTARIZE:-0}"
NOTARIZE=1
SKIP_DSYM="${SKIP_DSYM:-0}"
SKIP_DMG="${SKIP_DMG:-0}"
if [[ "$SKIP_NOTARIZE" == "1" ]]; then
NOTARIZE=0
@@ -53,15 +54,19 @@ echo "📦 Zip: $ZIP"
rm -f "$ZIP"
ditto -c -k --sequesterRsrc --keepParent "$APP" "$ZIP"
echo "💿 DMG: $DMG"
"$ROOT_DIR/scripts/create-dmg.sh" "$APP" "$DMG"
if [[ "$SKIP_DMG" != "1" ]]; then
echo "💿 DMG: $DMG"
"$ROOT_DIR/scripts/create-dmg.sh" "$APP" "$DMG"
if [[ "$NOTARIZE" == "1" ]]; then
if [[ -n "${SIGN_IDENTITY:-}" ]]; then
echo "🔏 Signing DMG: $DMG"
/usr/bin/codesign --force --sign "$SIGN_IDENTITY" --timestamp "$DMG"
if [[ "$NOTARIZE" == "1" ]]; then
if [[ -n "${SIGN_IDENTITY:-}" ]]; then
echo "🔏 Signing DMG: $DMG"
/usr/bin/codesign --force --sign "$SIGN_IDENTITY" --timestamp "$DMG"
fi
"$ROOT_DIR/scripts/notarize-mac-artifact.sh" "$DMG"
fi
"$ROOT_DIR/scripts/notarize-mac-artifact.sh" "$DMG"
else
echo "💿 Skipping DMG (SKIP_DMG=1)"
fi
if [[ "$SKIP_DSYM" != "1" ]]; then
@@ -3,9 +3,11 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
clearRuntimeAuthProfileStoreSnapshots,
calculateAuthProfileCooldownMs,
ensureAuthProfileStore,
markAuthProfileFailure,
replaceRuntimeAuthProfileStoreSnapshots,
} from "./auth-profiles.js";
type AuthProfileStore = ReturnType<typeof ensureAuthProfileStore>;
@@ -48,6 +50,73 @@ function expectCooldownInRange(remainingMs: number, minMs: number, maxMs: number
}
describe("markAuthProfileFailure", () => {
it("does not overwrite fresher on-disk credentials with a stale runtime snapshot", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
try {
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-expired-old",
},
},
}),
);
replaceRuntimeAuthProfileStoreSnapshots([
{
agentDir,
store: ensureAuthProfileStore(agentDir),
},
]);
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-fresh-new",
},
},
}),
);
const staleRuntimeStore = ensureAuthProfileStore(agentDir);
const staleCredential = staleRuntimeStore.profiles["openai:default"];
expect(staleCredential?.type).toBe("api_key");
expect(staleCredential && "key" in staleCredential ? staleCredential.key : undefined).toBe(
"sk-expired-old",
);
await markAuthProfileFailure({
store: staleRuntimeStore,
profileId: "openai:default",
reason: "rate_limit",
agentDir,
});
clearRuntimeAuthProfileStoreSnapshots();
const reloaded = ensureAuthProfileStore(agentDir);
const reloadedCredential = reloaded.profiles["openai:default"];
expect(reloadedCredential?.type).toBe("api_key");
expect(
reloadedCredential && "key" in reloadedCredential ? reloadedCredential.key : undefined,
).toBe("sk-fresh-new");
expect(typeof reloaded.usageStats?.["openai:default"]?.cooldownUntil).toBe("number");
} finally {
clearRuntimeAuthProfileStoreSnapshots();
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("disables billing failures for ~5 hours by default", async () => {
await withAuthProfileStore(async ({ agentDir, store }) => {
const startedAt = Date.now();
+4 -1
View File
@@ -130,7 +130,10 @@ export async function updateAuthProfileStoreWithLock(params: {
try {
return await withFileLock(authPath, AUTH_STORE_LOCK_OPTIONS, async () => {
const store = ensureAuthProfileStore(params.agentDir);
// Locked writers must reload from disk, not from any runtime snapshot.
// Otherwise a live gateway can overwrite fresher CLI/config-auth writes
// with stale in-memory auth state during usage/cooldown updates.
const store = loadAuthProfileStoreForAgent(params.agentDir);
const shouldSave = params.updater(store);
if (shouldSave) {
saveAuthProfileStore(store, params.agentDir);
@@ -29,6 +29,13 @@ let callGatewayTool: typeof import("./tools/gateway.js").callGatewayTool;
let createExecTool: typeof import("./bash-tools.exec.js").createExecTool;
let detectCommandObfuscation: typeof import("../infra/exec-obfuscation-detect.js").detectCommandObfuscation;
async function loadExecApprovalModules() {
vi.resetModules();
({ callGatewayTool } = await import("./tools/gateway.js"));
({ createExecTool } = await import("./bash-tools.exec.js"));
({ detectCommandObfuscation } = await import("../infra/exec-obfuscation-detect.js"));
}
function buildPreparedSystemRunPayload(rawInvokeParams: unknown) {
const invoke = (rawInvokeParams ?? {}) as {
params?: {
@@ -210,10 +217,7 @@ describe("exec approvals", () => {
process.env.HOME = tempDir;
// Windows uses USERPROFILE for os.homedir()
process.env.USERPROFILE = tempDir;
vi.resetModules();
({ callGatewayTool } = await import("./tools/gateway.js"));
({ createExecTool } = await import("./bash-tools.exec.js"));
({ detectCommandObfuscation } = await import("../infra/exec-obfuscation-detect.js"));
await loadExecApprovalModules();
});
afterEach(() => {
+53 -1
View File
@@ -91,7 +91,7 @@ describe("createCacheTrace", () => {
expect(trace).toBeNull();
});
it("redacts image data from options and messages before writing", () => {
it("sanitizes cache-trace payloads before writing", () => {
const lines: string[] = [];
const trace = createCacheTrace({
cfg: {
@@ -109,12 +109,31 @@ describe("createCacheTrace", () => {
});
trace?.recordStage("stream:context", {
system: {
provider: { apiKey: "sk-system-secret", baseUrl: "https://api.example.com" },
},
model: {
id: "test-model",
apiKey: "sk-model-secret",
tokenCount: 8192,
},
options: {
apiKey: "sk-options-secret",
nested: {
password: "super-secret-password",
safe: "keep-me",
tokenCount: 42,
},
images: [{ type: "image", mimeType: "image/png", data: "QUJDRA==" }],
},
messages: [
{
role: "user",
token: "message-secret-token",
metadata: {
secretKey: "message-secret-key",
label: "preserve-me",
},
content: [
{
type: "image",
@@ -126,6 +145,31 @@ describe("createCacheTrace", () => {
});
const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record<string, unknown>;
expect(event.system).toEqual({
provider: {
baseUrl: "https://api.example.com",
},
});
expect(event.model).toEqual({
id: "test-model",
tokenCount: 8192,
});
expect(event.options).toEqual({
nested: {
safe: "keep-me",
tokenCount: 42,
},
images: [
{
type: "image",
mimeType: "image/png",
data: "<redacted>",
bytes: 4,
sha256: crypto.createHash("sha256").update("QUJDRA==").digest("hex"),
},
],
});
const optionsImages = (
((event.options as { images?: unknown[] } | undefined)?.images ?? []) as Array<
Record<string, unknown>
@@ -138,6 +182,14 @@ describe("createCacheTrace", () => {
);
const firstMessage = ((event.messages as Array<Record<string, unknown>> | undefined) ?? [])[0];
expect(firstMessage).not.toHaveProperty("token");
expect(firstMessage).not.toHaveProperty("metadata.secretKey");
expect(firstMessage).toMatchObject({
role: "user",
metadata: {
label: "preserve-me",
},
});
const source = (((firstMessage?.content as Array<Record<string, unknown>> | undefined) ?? [])[0]
?.source ?? {}) as Record<string, unknown>;
expect(source.data).toBe("<redacted>");
+5 -5
View File
@@ -6,7 +6,7 @@ import { resolveStateDir } from "../config/paths.js";
import { resolveUserPath } from "../utils.js";
import { parseBooleanValue } from "../utils/boolean.js";
import { safeJsonStringify } from "../utils/safe-json.js";
import { redactImageDataForDiagnostics } from "./payload-redaction.js";
import { sanitizeDiagnosticPayload } from "./payload-redaction.js";
import { getQueuedFileWriter, type QueuedFileWriter } from "./queued-file-writer.js";
import { buildAgentTraceBase } from "./trace-base.js";
@@ -198,14 +198,14 @@ export function createCacheTrace(params: CacheTraceInit): CacheTrace | null {
event.prompt = payload.prompt;
}
if (payload.system !== undefined && cfg.includeSystem) {
event.system = payload.system;
event.system = sanitizeDiagnosticPayload(payload.system);
event.systemDigest = digest(payload.system);
}
if (payload.options) {
event.options = redactImageDataForDiagnostics(payload.options) as Record<string, unknown>;
event.options = sanitizeDiagnosticPayload(payload.options) as Record<string, unknown>;
}
if (payload.model) {
event.model = payload.model;
event.model = sanitizeDiagnosticPayload(payload.model) as Record<string, unknown>;
}
const messages = payload.messages;
@@ -216,7 +216,7 @@ export function createCacheTrace(params: CacheTraceInit): CacheTrace | null {
event.messageFingerprints = summary.messageFingerprints;
event.messagesDigest = summary.messagesDigest;
if (cfg.includeMessages) {
event.messages = redactImageDataForDiagnostics(messages) as AgentMessage[];
event.messages = sanitizeDiagnosticPayload(messages) as AgentMessage[];
}
}
+46 -17
View File
@@ -2,7 +2,6 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { registerLogTransport, resetLogger, setLoggerOverride } from "../logging/logger.js";
import type { AuthProfileStore } from "./auth-profiles.js";
import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js";
@@ -15,24 +14,53 @@ vi.mock("./auth-profiles.js", () => ({
resolveAuthProfileOrder: vi.fn(),
}));
import {
ensureAuthProfileStore,
getSoonestCooldownExpiry,
isProfileInCooldown,
resolveProfilesUnavailableReason,
resolveAuthProfileOrder,
} from "./auth-profiles.js";
import { _probeThrottleInternals, runWithModelFallback } from "./model-fallback.js";
type AuthProfilesModule = typeof import("./auth-profiles.js");
type ModelFallbackModule = typeof import("./model-fallback.js");
type LoggerModule = typeof import("../logging/logger.js");
const mockedEnsureAuthProfileStore = vi.mocked(ensureAuthProfileStore);
const mockedGetSoonestCooldownExpiry = vi.mocked(getSoonestCooldownExpiry);
const mockedIsProfileInCooldown = vi.mocked(isProfileInCooldown);
const mockedResolveProfilesUnavailableReason = vi.mocked(resolveProfilesUnavailableReason);
const mockedResolveAuthProfileOrder = vi.mocked(resolveAuthProfileOrder);
let mockedEnsureAuthProfileStore: ReturnType<
typeof vi.mocked<AuthProfilesModule["ensureAuthProfileStore"]>
>;
let mockedGetSoonestCooldownExpiry: ReturnType<
typeof vi.mocked<AuthProfilesModule["getSoonestCooldownExpiry"]>
>;
let mockedIsProfileInCooldown: ReturnType<
typeof vi.mocked<AuthProfilesModule["isProfileInCooldown"]>
>;
let mockedResolveProfilesUnavailableReason: ReturnType<
typeof vi.mocked<AuthProfilesModule["resolveProfilesUnavailableReason"]>
>;
let mockedResolveAuthProfileOrder: ReturnType<
typeof vi.mocked<AuthProfilesModule["resolveAuthProfileOrder"]>
>;
let runWithModelFallback: ModelFallbackModule["runWithModelFallback"];
let _probeThrottleInternals: ModelFallbackModule["_probeThrottleInternals"];
let registerLogTransport: LoggerModule["registerLogTransport"];
let resetLogger: LoggerModule["resetLogger"];
let setLoggerOverride: LoggerModule["setLoggerOverride"];
const makeCfg = makeModelFallbackCfg;
let unregisterLogTransport: (() => void) | undefined;
async function loadModelFallbackProbeModules() {
vi.resetModules();
const authProfilesModule = await import("./auth-profiles.js");
const loggerModule = await import("../logging/logger.js");
const modelFallbackModule = await import("./model-fallback.js");
mockedEnsureAuthProfileStore = vi.mocked(authProfilesModule.ensureAuthProfileStore);
mockedGetSoonestCooldownExpiry = vi.mocked(authProfilesModule.getSoonestCooldownExpiry);
mockedIsProfileInCooldown = vi.mocked(authProfilesModule.isProfileInCooldown);
mockedResolveProfilesUnavailableReason = vi.mocked(
authProfilesModule.resolveProfilesUnavailableReason,
);
mockedResolveAuthProfileOrder = vi.mocked(authProfilesModule.resolveAuthProfileOrder);
runWithModelFallback = modelFallbackModule.runWithModelFallback;
_probeThrottleInternals = modelFallbackModule._probeThrottleInternals;
registerLogTransport = loggerModule.registerLogTransport;
resetLogger = loggerModule.resetLogger;
setLoggerOverride = loggerModule.setLoggerOverride;
}
function expectFallbackUsed(
result: { result: unknown; attempts: Array<{ reason?: string }> },
run: {
@@ -131,7 +159,8 @@ describe("runWithModelFallback probe logic", () => {
run,
});
beforeEach(() => {
beforeEach(async () => {
await loadModelFallbackProbeModules();
realDateNow = Date.now;
Date.now = vi.fn(() => NOW);
@@ -159,7 +188,7 @@ describe("runWithModelFallback probe logic", () => {
return [];
});
// Default: only openai profiles are in cooldown; fallback providers are available
mockedIsProfileInCooldown.mockImplementation((_store, profileId: string) => {
mockedIsProfileInCooldown.mockImplementation((_store: AuthProfileStore, profileId: string) => {
return profileId.startsWith("openai");
});
mockedResolveProfilesUnavailableReason.mockReturnValue("rate_limit");
@@ -355,7 +384,7 @@ describe("runWithModelFallback probe logic", () => {
}
return [];
});
mockedIsProfileInCooldown.mockImplementation((_store, profileId: string) =>
mockedIsProfileInCooldown.mockImplementation((_store: AuthProfileStore, profileId: string) =>
profileId.startsWith("google"),
);
mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 30 * 1000);
+2
View File
@@ -65,6 +65,8 @@ const MOONSHOT_NATIVE_BASE_URLS = new Set([
const MODELSTUDIO_NATIVE_BASE_URLS = new Set([
"https://coding-intl.dashscope.aliyuncs.com/v1",
"https://coding.dashscope.aliyuncs.com/v1",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
]);
const ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
+71
View File
@@ -3,10 +3,42 @@ import { estimateBase64DecodedBytes } from "../media/base64.js";
export const REDACTED_IMAGE_DATA = "<redacted>";
const NON_CREDENTIAL_FIELD_NAMES = new Set([
"passwordfile",
"tokenbudget",
"tokencount",
"tokenfield",
"tokenlimit",
"tokens",
]);
function toLowerTrimmed(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function normalizeFieldName(value: string): string {
return value.replaceAll(/[^a-z0-9]/gi, "").toLowerCase();
}
function isCredentialFieldName(key: string): boolean {
const normalized = normalizeFieldName(key);
if (!normalized || NON_CREDENTIAL_FIELD_NAMES.has(normalized)) {
return false;
}
if (normalized === "authorization" || normalized === "proxyauthorization") {
return true;
}
return (
normalized.endsWith("apikey") ||
normalized.endsWith("password") ||
normalized.endsWith("passwd") ||
normalized.endsWith("passphrase") ||
normalized.endsWith("secret") ||
normalized.endsWith("secretkey") ||
normalized.endsWith("token")
);
}
function hasImageMime(record: Record<string, unknown>): boolean {
const candidates = [
toLowerTrimmed(record.mimeType),
@@ -62,3 +94,42 @@ export function redactImageDataForDiagnostics(value: unknown): unknown {
return visit(value);
}
/**
* Removes credential-like fields and image/base64 payload data from diagnostic
* objects before persistence.
*/
export function sanitizeDiagnosticPayload(value: unknown): unknown {
const seen = new WeakSet<object>();
const visit = (input: unknown): unknown => {
if (Array.isArray(input)) {
return input.map((entry) => visit(entry));
}
if (!input || typeof input !== "object") {
return input;
}
if (seen.has(input)) {
return "[Circular]";
}
seen.add(input);
const record = input as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const [key, val] of Object.entries(record)) {
if (isCredentialFieldName(key)) {
continue;
}
out[key] = visit(val);
}
if (shouldRedactImageData(record)) {
out.data = REDACTED_IMAGE_DATA;
out.bytes = estimateBase64DecodedBytes(record.data);
out.sha256 = digestBase64Payload(record.data);
}
return out;
};
return visit(value);
}
@@ -853,11 +853,72 @@ describe("classifyFailoverReason", () => {
expect(classifyFailoverReason("key has been disabled")).toBe("auth_permanent");
expect(classifyFailoverReason("account has been deactivated")).toBe("auth_permanent");
});
it("classifies JSON api_error internal server failures as timeout", () => {
it("classifies JSON api_error with transient signal as timeout", () => {
expect(
classifyFailoverReason(
'{"type":"error","error":{"type":"api_error","message":"Internal server error"}}',
),
).toBe("timeout");
// MiniMax non-standard message
expect(
classifyFailoverReason('{"type":"api_error","message":"unknown error, 520 (1000)"}'),
).toBe("timeout");
// Overloaded variant
expect(
classifyFailoverReason(
'{"type":"error","error":{"type":"api_error","message":"Service temporarily unavailable"}}',
),
).toBe("timeout");
});
it("does not classify non-transient api_error payloads as timeout", () => {
// Context overflow - not transient
expect(
classifyFailoverReason(
'{"type":"error","error":{"type":"api_error","message":"Request size exceeds model context window"}}',
),
).not.toBe("timeout");
// Schema/validation error - not transient
expect(
classifyFailoverReason(
'{"type":"error","error":{"type":"api_error","message":"messages.1.content.1.tool_use.id should match pattern"}}',
),
).not.toBe("timeout");
// Generic unknown api_error without transient wording - should not be retried
expect(
classifyFailoverReason(
'{"type":"error","error":{"type":"api_error","message":"invalid input format"}}',
),
).not.toBe("timeout");
});
it("does not shadow billing errors that carry api_error type", () => {
// A provider may wrap a billing error in a JSON payload with "type":"api_error".
// The billing classifier must win over the broad api_error transient match.
expect(
classifyFailoverReason(
'{"type":"error","error":{"type":"api_error","message":"insufficient credits"}}',
),
).toBe("billing");
expect(
classifyFailoverReason(
'{"type":"error","error":{"type":"api_error","message":"Payment required"}}',
),
).toBe("billing");
});
it("does not shadow auth errors that carry api_error type", () => {
expect(
classifyFailoverReason(
'{"type":"error","error":{"type":"api_error","message":"invalid api key"}}',
),
).toBe("auth");
expect(
classifyFailoverReason(
'{"type":"error","error":{"type":"api_error","message":"unauthorized"}}',
),
).toBe("auth");
expect(
classifyFailoverReason(
'{"type":"error","error":{"type":"api_error","message":"permission_error"}}',
),
).toBe("auth_permanent");
});
});
+34 -11
View File
@@ -730,14 +730,34 @@ export function isBillingAssistantError(msg: AssistantMessage | undefined): bool
return isBillingErrorMessage(msg.errorMessage ?? "");
}
// Transient signal patterns for api_error payloads. Only treat an api_error as
// retryable when the message text itself indicates a transient server issue.
// Non-transient api_error payloads (context overflow, validation/schema errors)
// must NOT be classified as timeout.
const API_ERROR_TRANSIENT_SIGNALS_RE =
/internal server error|overload|temporarily unavailable|service unavailable|unknown error|server error|bad gateway|gateway timeout|upstream error|backend error|try again later|temporarily.+unable/i;
function isJsonApiInternalServerError(raw: string): boolean {
if (!raw) {
return false;
}
const value = raw.toLowerCase();
// Anthropic often wraps transient 500s in JSON payloads like:
// Providers wrap transient 5xx errors in JSON payloads like:
// {"type":"error","error":{"type":"api_error","message":"Internal server error"}}
return value.includes('"type":"api_error"') && value.includes("internal server error");
// Non-standard providers (e.g. MiniMax) may use different message text:
// {"type":"api_error","message":"unknown error, 520 (1000)"}
if (!value.includes('"type":"api_error"')) {
return false;
}
// Billing and auth errors can also carry "type":"api_error". Exclude them so
// the more specific classifiers further down the chain handle them correctly.
if (isBillingErrorMessage(raw) || isAuthErrorMessage(raw) || isAuthPermanentErrorMessage(raw)) {
return false;
}
// Only match when the message contains a transient signal. api_error payloads
// with non-transient messages (e.g. context overflow, schema validation) should
// fall through to more specific classifiers or remain unclassified.
return API_ERROR_TRANSIENT_SIGNALS_RE.test(raw);
}
export function parseImageDimensionError(raw: string): {
@@ -890,24 +910,27 @@ export function classifyFailoverReason(raw: string): FailoverReason | null {
// Treat remaining transient 5xx provider failures as retryable transport issues.
return "timeout";
}
if (isJsonApiInternalServerError(raw)) {
return "timeout";
}
if (isCloudCodeAssistFormatError(raw)) {
return "format";
}
// Billing and auth classifiers run before the broad isJsonApiInternalServerError
// check so that provider errors like {"type":"api_error","message":"insufficient
// balance"} are correctly classified as "billing"/"auth" rather than "timeout".
if (isBillingErrorMessage(raw)) {
return "billing";
}
if (isTimeoutErrorMessage(raw)) {
return "timeout";
}
if (isAuthPermanentErrorMessage(raw)) {
return "auth_permanent";
}
if (isAuthErrorMessage(raw)) {
return "auth";
}
if (isJsonApiInternalServerError(raw)) {
return "timeout";
}
if (isCloudCodeAssistFormatError(raw)) {
return "format";
}
if (isTimeoutErrorMessage(raw)) {
return "timeout";
}
return null;
}
@@ -1,28 +1,21 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
type OpenClawConfig,
} from "../../config/config.js";
import * as skillsModule from "../skills.js";
import type { SkillSnapshot } from "../skills.js";
const hoisted = vi.hoisted(() => ({
loadWorkspaceSkillEntries: vi.fn(
(_workspaceDir: string, _options?: { config?: OpenClawConfig }) => [],
),
}));
vi.mock("../skills.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../skills.js")>();
return {
...actual,
loadWorkspaceSkillEntries: (workspaceDir: string, options?: { config?: OpenClawConfig }) =>
hoisted.loadWorkspaceSkillEntries(workspaceDir, options),
};
});
const { resolveEmbeddedRunSkillEntries } = await import("./skills-runtime.js");
describe("resolveEmbeddedRunSkillEntries", () => {
const loadWorkspaceSkillEntriesSpy = vi.spyOn(skillsModule, "loadWorkspaceSkillEntries");
beforeEach(() => {
hoisted.loadWorkspaceSkillEntries.mockReset();
hoisted.loadWorkspaceSkillEntries.mockReturnValue([]);
clearRuntimeConfigSnapshot();
loadWorkspaceSkillEntriesSpy.mockReset();
loadWorkspaceSkillEntriesSpy.mockReturnValue([]);
});
it("loads skill entries with config when no resolved snapshot skills exist", () => {
@@ -44,8 +37,47 @@ describe("resolveEmbeddedRunSkillEntries", () => {
});
expect(result.shouldLoadSkillEntries).toBe(true);
expect(hoisted.loadWorkspaceSkillEntries).toHaveBeenCalledTimes(1);
expect(hoisted.loadWorkspaceSkillEntries).toHaveBeenCalledWith("/tmp/workspace", { config });
expect(loadWorkspaceSkillEntriesSpy).toHaveBeenCalledTimes(1);
expect(loadWorkspaceSkillEntriesSpy).toHaveBeenCalledWith("/tmp/workspace", { config });
});
it("prefers the active runtime snapshot when caller config still contains SecretRefs", () => {
const sourceConfig: OpenClawConfig = {
skills: {
entries: {
diffs: {
apiKey: {
source: "file",
provider: "default",
id: "/skills/entries/diffs/apiKey",
},
},
},
},
};
const runtimeConfig: OpenClawConfig = {
skills: {
entries: {
diffs: {
apiKey: "resolved-key",
},
},
},
};
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
resolveEmbeddedRunSkillEntries({
workspaceDir: "/tmp/workspace",
config: sourceConfig,
skillsSnapshot: {
prompt: "skills prompt",
skills: [],
},
});
expect(loadWorkspaceSkillEntriesSpy).toHaveBeenCalledWith("/tmp/workspace", {
config: runtimeConfig,
});
});
it("skips skill entry loading when resolved snapshot skills are present", () => {
@@ -65,6 +97,6 @@ describe("resolveEmbeddedRunSkillEntries", () => {
shouldLoadSkillEntries: false,
skillEntries: [],
});
expect(hoisted.loadWorkspaceSkillEntries).not.toHaveBeenCalled();
expect(loadWorkspaceSkillEntriesSpy).not.toHaveBeenCalled();
});
});
@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../../config/config.js";
import { loadWorkspaceSkillEntries, type SkillEntry, type SkillSnapshot } from "../skills.js";
import { resolveSkillRuntimeConfig } from "../skills/runtime-config.js";
export function resolveEmbeddedRunSkillEntries(params: {
workspaceDir: string;
@@ -10,10 +11,11 @@ export function resolveEmbeddedRunSkillEntries(params: {
skillEntries: SkillEntry[];
} {
const shouldLoadSkillEntries = !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
const config = resolveSkillRuntimeConfig(params.config);
return {
shouldLoadSkillEntries,
skillEntries: shouldLoadSkillEntries
? loadWorkspaceSkillEntries(params.workspaceDir, { config: params.config })
? loadWorkspaceSkillEntries(params.workspaceDir, { config })
: [],
};
}
@@ -38,6 +38,15 @@ const stripTrailingDirective = (text: string): string => {
return text.slice(0, openIndex);
};
function isTranscriptOnlyOpenClawAssistantMessage(message: AgentMessage | undefined): boolean {
if (!message || message.role !== "assistant") {
return false;
}
const provider = typeof message.provider === "string" ? message.provider.trim() : "";
const model = typeof message.model === "string" ? message.model.trim() : "";
return provider === "openclaw" && (model === "delivery-mirror" || model === "gateway-injected");
}
function emitReasoningEnd(ctx: EmbeddedPiSubscribeContext) {
if (!ctx.state.reasoningStreamOpen) {
return;
@@ -134,7 +143,7 @@ export function handleMessageStart(
evt: AgentEvent & { message: AgentMessage },
) {
const msg = evt.message;
if (msg?.role !== "assistant") {
if (msg?.role !== "assistant" || isTranscriptOnlyOpenClawAssistantMessage(msg)) {
return;
}
@@ -153,7 +162,7 @@ export function handleMessageUpdate(
evt: AgentEvent & { message: AgentMessage; assistantMessageEvent?: unknown },
) {
const msg = evt.message;
if (msg?.role !== "assistant") {
if (msg?.role !== "assistant" || isTranscriptOnlyOpenClawAssistantMessage(msg)) {
return;
}
@@ -323,7 +332,7 @@ export function handleMessageEnd(
evt: AgentEvent & { message: AgentMessage },
) {
const msg = evt.message;
if (msg?.role !== "assistant") {
if (msg?.role !== "assistant" || isTranscriptOnlyOpenClawAssistantMessage(msg)) {
return;
}
@@ -42,10 +42,15 @@ async function emitMessageToolLifecycle(params: {
});
}
function emitAssistantMessageEnd(emit: (evt: unknown) => void, text: string) {
function emitAssistantMessageEnd(
emit: (evt: unknown) => void,
text: string,
overrides?: Partial<AssistantMessage>,
) {
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text }],
...overrides,
} as AssistantMessage;
emit({ type: "message_end", message: assistantMessage });
}
@@ -68,6 +73,7 @@ describe("subscribeEmbeddedPiSession", () => {
result: "ok",
});
emitAssistantMessageEnd(emit, messageText);
await Promise.resolve();
expect(onBlockReply).not.toHaveBeenCalled();
});
@@ -82,16 +88,44 @@ describe("subscribeEmbeddedPiSession", () => {
result: { details: { status: "error" } },
});
emitAssistantMessageEnd(emit, messageText);
await Promise.resolve();
expect(onBlockReply).toHaveBeenCalledTimes(1);
});
it("clears block reply state on message_start", () => {
it("ignores delivery-mirror assistant messages", async () => {
const { emit, onBlockReply } = createBlockReplyHarness("message_end");
emitAssistantMessageEnd(emit, "Mirrored transcript text", {
provider: "openclaw",
model: "delivery-mirror",
});
await Promise.resolve();
expect(onBlockReply).not.toHaveBeenCalled();
});
it("ignores gateway-injected assistant messages", async () => {
const { emit, onBlockReply } = createBlockReplyHarness("message_end");
emitAssistantMessageEnd(emit, "Injected transcript text", {
provider: "openclaw",
model: "gateway-injected",
});
await Promise.resolve();
expect(onBlockReply).not.toHaveBeenCalled();
});
it("clears block reply state on message_start", async () => {
const { emit, onBlockReply } = createBlockReplyHarness("text_end");
emitAssistantTextEndBlock(emit, "OK");
await Promise.resolve();
expect(onBlockReply).toHaveBeenCalledTimes(1);
// New assistant message with identical output should still emit.
emitAssistantTextEndBlock(emit, "OK");
await Promise.resolve();
expect(onBlockReply).toHaveBeenCalledTimes(2);
});
});
+54 -1
View File
@@ -1,6 +1,11 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
type OpenClawConfig,
} from "../config/config.js";
import { createFixtureSuite } from "../test-utils/fixture-suite.js";
import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js";
import { writeSkill } from "./skills.e2e-test-helpers.js";
@@ -75,6 +80,10 @@ afterAll(async () => {
await fixtureSuite.cleanup();
});
afterEach(() => {
clearRuntimeConfigSnapshot();
});
describe("buildWorkspaceSkillCommandSpecs", () => {
it("sanitizes and de-duplicates command names", async () => {
const workspaceDir = await makeWorkspace();
@@ -370,6 +379,50 @@ describe("applySkillEnvOverrides", () => {
});
});
it("prefers the active runtime snapshot over raw SecretRef skill config", async () => {
const workspaceDir = await makeWorkspace();
await writeEnvSkill(workspaceDir);
const entries = loadWorkspaceSkillEntries(workspaceDir, resolveTestSkillDirs(workspaceDir));
const sourceConfig: OpenClawConfig = {
skills: {
entries: {
"env-skill": {
apiKey: {
source: "file",
provider: "default",
id: "/skills/entries/env-skill/apiKey",
},
},
},
},
};
const runtimeConfig: OpenClawConfig = {
skills: {
entries: {
"env-skill": {
apiKey: "resolved-key",
},
},
},
};
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
withClearedEnv(["ENV_KEY"], () => {
const restore = applySkillEnvOverrides({
skills: entries,
config: sourceConfig,
});
try {
expect(process.env.ENV_KEY).toBe("resolved-key");
} finally {
restore();
expect(process.env.ENV_KEY).toBeUndefined();
}
});
});
it("blocks unsafe env overrides but allows declared secrets", async () => {
const workspaceDir = await makeWorkspace();
const skillDir = path.join(workspaceDir, "skills", "unsafe-env-skill");
+5 -2
View File
@@ -5,6 +5,7 @@ import { createSubsystemLogger } from "../../logging/subsystem.js";
import { sanitizeEnvVars, validateEnvVarValue } from "../sandbox/sanitize-env-vars.js";
import { resolveSkillConfig } from "./config.js";
import { resolveSkillKey } from "./frontmatter.js";
import { resolveSkillRuntimeConfig } from "./runtime-config.js";
import type { SkillEntry, SkillSnapshot } from "./types.js";
const log = createSubsystemLogger("env-overrides");
@@ -211,7 +212,8 @@ function createEnvReverter(updates: EnvUpdate[]) {
}
export function applySkillEnvOverrides(params: { skills: SkillEntry[]; config?: OpenClawConfig }) {
const { skills, config } = params;
const { skills } = params;
const config = resolveSkillRuntimeConfig(params.config);
const updates: EnvUpdate[] = [];
for (const entry of skills) {
@@ -237,7 +239,8 @@ export function applySkillEnvOverridesFromSnapshot(params: {
snapshot?: SkillSnapshot;
config?: OpenClawConfig;
}) {
const { snapshot, config } = params;
const { snapshot } = params;
const config = resolveSkillRuntimeConfig(params.config);
if (!snapshot) {
return () => {};
}
+5
View File
@@ -0,0 +1,5 @@
import { getRuntimeConfigSnapshot, type OpenClawConfig } from "../../config/config.js";
export function resolveSkillRuntimeConfig(config?: OpenClawConfig): OpenClawConfig | undefined {
return getRuntimeConfigSnapshot() ?? config;
}
@@ -383,6 +383,63 @@ describe("subagent announce formatting", () => {
expect(msg).toContain("completed successfully");
});
it("rechecks timed-out waits before announcing timeout when the run finishes immediately after", async () => {
const waitStatuses = [
{ status: "timeout", startedAt: 10, endedAt: 20 },
{ status: "ok", startedAt: 10, endedAt: 30 },
];
callGatewaySpy.mockImplementation(async (req: unknown) => {
const typed = req as { method?: string; params?: { sessionKey?: string } };
if (typed.method === "agent") {
return await agentSpy(typed);
}
if (typed.method === "send") {
return await sendSpy(typed);
}
if (typed.method === "agent.wait") {
return waitStatuses.shift() ?? { status: "ok", startedAt: 10, endedAt: 30 };
}
if (typed.method === "chat.history") {
return {
messages: [
{
role: "assistant",
content: [{ type: "text", text: "Worker executed successfully" }],
},
],
};
}
if (typed.method === "sessions.patch" || typed.method === "sessions.delete") {
return {};
}
return {};
});
readLatestAssistantReplyMock.mockResolvedValue("Worker executed successfully");
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-timeout-race",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: true,
startedAt: 10,
endedAt: 20,
});
const call = agentSpy.mock.calls[0]?.[0] as {
params?: {
message?: string;
internalEvents?: Array<{ status?: string; statusLabel?: string; result?: string }>;
};
};
expect(call?.params?.internalEvents?.[0]?.status).toBe("ok");
expect(call?.params?.internalEvents?.[0]?.statusLabel).toBe("completed successfully");
expect(call?.params?.internalEvents?.[0]?.result).toContain("Worker executed successfully");
});
it("uses child-run announce identity for direct idempotency", async () => {
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:worker",
+85 -27
View File
@@ -81,6 +81,13 @@ type SubagentOutputSnapshot = {
toolCallCount: number;
};
type AgentWaitResult = {
status?: string;
startedAt?: number;
endedAt?: number;
error?: string;
};
function resolveSubagentAnnounceTimeoutMs(cfg: ReturnType<typeof loadConfig>): number {
const configured = cfg.agents?.defaults?.subagents?.announceTimeoutMs;
if (typeof configured !== "number" || !Number.isFinite(configured)) {
@@ -418,6 +425,49 @@ async function readLatestSubagentOutputWithRetry(params: {
return result;
}
async function waitForSubagentRunOutcome(
runId: string,
timeoutMs: number,
): Promise<AgentWaitResult> {
const waitMs = Math.max(0, Math.floor(timeoutMs));
return await callGateway<AgentWaitResult>({
method: "agent.wait",
params: {
runId,
timeoutMs: waitMs,
},
timeoutMs: waitMs + 2000,
});
}
function applySubagentWaitOutcome(params: {
wait: AgentWaitResult | undefined;
outcome: SubagentRunOutcome | undefined;
startedAt?: number;
endedAt?: number;
}) {
const next = {
outcome: params.outcome,
startedAt: params.startedAt,
endedAt: params.endedAt,
};
const waitError = typeof params.wait?.error === "string" ? params.wait.error : undefined;
if (params.wait?.status === "timeout") {
next.outcome = { status: "timeout" };
} else if (params.wait?.status === "error") {
next.outcome = { status: "error", error: waitError };
} else if (params.wait?.status === "ok") {
next.outcome = { status: "ok" };
}
if (typeof params.wait?.startedAt === "number" && !next.startedAt) {
next.startedAt = params.wait.startedAt;
}
if (typeof params.wait?.endedAt === "number" && !next.endedAt) {
next.endedAt = params.wait.endedAt;
}
return next;
}
export async function captureSubagentCompletionReply(
sessionKey: string,
): Promise<string | undefined> {
@@ -1294,34 +1344,16 @@ export async function runSubagentAnnounceFlow(params: {
}
if (!reply && params.waitForCompletion !== false) {
const waitMs = settleTimeoutMs;
const wait = await callGateway<{
status?: string;
startedAt?: number;
endedAt?: number;
error?: string;
}>({
method: "agent.wait",
params: {
runId: params.childRunId,
timeoutMs: waitMs,
},
timeoutMs: waitMs + 2000,
const wait = await waitForSubagentRunOutcome(params.childRunId, settleTimeoutMs);
const applied = applySubagentWaitOutcome({
wait,
outcome,
startedAt: params.startedAt,
endedAt: params.endedAt,
});
const waitError = typeof wait?.error === "string" ? wait.error : undefined;
if (wait?.status === "timeout") {
outcome = { status: "timeout" };
} else if (wait?.status === "error") {
outcome = { status: "error", error: waitError };
} else if (wait?.status === "ok") {
outcome = { status: "ok" };
}
if (typeof wait?.startedAt === "number" && !params.startedAt) {
params.startedAt = wait.startedAt;
}
if (typeof wait?.endedAt === "number" && !params.endedAt) {
params.endedAt = wait.endedAt;
}
outcome = applied.outcome;
params.startedAt = applied.startedAt;
params.endedAt = applied.endedAt;
}
if (!outcome) {
@@ -1422,6 +1454,28 @@ export async function runSubagentAnnounceFlow(params: {
reply = fallbackReply;
}
// A worker can finish just after the first wait request timed out.
// If we already have real completion content, do one cached recheck so
// the final completion event prefers the authoritative terminal state.
// This is best-effort; if the recheck fails, keep the known timeout
// outcome instead of dropping the announcement entirely.
if (outcome?.status === "timeout" && reply?.trim() && params.waitForCompletion !== false) {
try {
const rechecked = await waitForSubagentRunOutcome(params.childRunId, 0);
const applied = applySubagentWaitOutcome({
wait: rechecked,
outcome,
startedAt: params.startedAt,
endedAt: params.endedAt,
});
outcome = applied.outcome;
params.startedAt = applied.startedAt;
params.endedAt = applied.endedAt;
} catch {
// Best-effort recheck; keep the existing timeout outcome on failure.
}
}
if (isAnnounceSkip(reply) || isSilentReplyText(reply, SILENT_REPLY_TOKEN)) {
if (fallbackReply && !fallbackIsSilent) {
reply = fallbackReply;
@@ -1431,6 +1485,10 @@ export async function runSubagentAnnounceFlow(params: {
}
}
if (!outcome) {
outcome = { status: "unknown" };
}
// Build status label
const statusLabel =
outcome.status === "ok"
+80 -3
View File
@@ -11,12 +11,35 @@ import type {
} from "../../plugin-sdk/media-understanding.js";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import { minimaxUnderstandImage } from "../minimax-vlm.js";
import { createOpenClawCodingTools } from "../pi-tools.js";
import type { SandboxFsBridge } from "../sandbox/fs-bridge.js";
import { createHostSandboxFsBridge } from "../test-helpers/host-sandbox-fs-bridge.js";
import { createUnsafeMountedSandbox } from "../test-helpers/unsafe-mounted-sandbox.js";
import { makeZeroUsageSnapshot } from "../usage.js";
import { __testing, createImageTool, resolveImageModelConfigForTool } from "./image-tool.js";
type PiToolsModule = typeof import("../pi-tools.js");
type CreateOpenClawCodingToolsArgs = Parameters<PiToolsModule["createOpenClawCodingTools"]>[0];
type MockOpenClawToolsOptions = {
config?: OpenClawConfig;
agentDir?: string;
workspaceDir?: string;
sandboxRoot?: string;
sandboxFsBridge?: SandboxFsBridge;
fsPolicy?: NonNullable<Parameters<typeof createImageTool>[0]>["fsPolicy"];
modelHasVision?: boolean;
};
const piToolsHarness = vi.hoisted(() => ({
createStubTool(name: string) {
return {
name,
description: `${name} stub`,
parameters: { type: "object", properties: {} },
execute: vi.fn(),
};
},
}));
const imageProviderHarness = vi.hoisted(() => {
let providers = new Map<string, MediaUnderstandingProvider>();
return {
@@ -63,6 +86,50 @@ vi.mock("../../media-understanding/provider-registry.js", async (importOriginal)
};
});
vi.mock("../bash-tools.js", () => ({
createExecTool: vi.fn(() => piToolsHarness.createStubTool("exec")),
createProcessTool: vi.fn(() => piToolsHarness.createStubTool("process")),
}));
vi.mock("../channel-tools.js", () => ({
listChannelAgentTools: vi.fn(() => []),
}));
vi.mock("../apply-patch.js", () => ({
createApplyPatchTool: vi.fn(() => piToolsHarness.createStubTool("apply_patch")),
}));
vi.mock("../pi-tools.before-tool-call.js", () => ({
wrapToolWithBeforeToolCallHook: vi.fn((tool) => tool),
}));
vi.mock("../pi-tools.abort.js", () => ({
wrapToolWithAbortSignal: vi.fn((tool) => tool),
}));
vi.mock("../openclaw-tools.js", async () => {
const { createImageTool } = await import("./image-tool.js");
return {
createOpenClawTools: vi.fn((options?: MockOpenClawToolsOptions) => {
const imageTool = createImageTool({
config: options?.config,
agentDir: options?.agentDir,
workspaceDir: options?.workspaceDir,
sandbox:
options?.sandboxRoot && options?.sandboxFsBridge
? {
root: options.sandboxRoot,
bridge: options.sandboxFsBridge,
}
: undefined,
fsPolicy: options?.fsPolicy,
modelHasVision: options?.modelHasVision,
});
return imageTool ? [imageTool] : [];
}),
};
});
async function writeAuthProfiles(agentDir: string, profiles: unknown) {
await fs.mkdir(agentDir, { recursive: true });
await fs.writeFile(
@@ -72,6 +139,12 @@ async function writeAuthProfiles(agentDir: string, profiles: unknown) {
);
}
async function createOpenClawCodingToolsWithFreshModules(options?: CreateOpenClawCodingToolsArgs) {
vi.resetModules();
const { createOpenClawCodingTools } = await import("../pi-tools.js");
return createOpenClawCodingTools(options);
}
async function withTempAgentDir<T>(run: (agentDir: string) => Promise<T>): Promise<T> {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-"));
try {
@@ -732,7 +805,11 @@ describe("image tool implicit imageModel config", () => {
await withTempAgentDir(async (agentDir) => {
const cfg = createMinimaxImageConfig();
const tools = createOpenClawCodingTools({ config: cfg, agentDir, workspaceDir });
const tools = await createOpenClawCodingToolsWithFreshModules({
config: cfg,
agentDir,
workspaceDir,
});
const tool = requireImageTool(tools.find((candidate) => candidate.name === "image"));
await expectImageToolExecOk(tool, imagePath);
@@ -776,7 +853,7 @@ describe("image tool implicit imageModel config", () => {
tools: { fs: { workspaceOnly: true } },
};
const tools = createOpenClawCodingTools({
const tools = await createOpenClawCodingToolsWithFreshModules({
config: cfg,
agentDir,
sandbox,
+44 -2
View File
@@ -5,6 +5,7 @@ import type { OpenClawConfig } from "../config/config.js";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { parseClawHubPluginSpec } from "../infra/clawhub.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import { listMarketplacePlugins } from "../plugins/marketplace.js";
import type { PluginRecord } from "../plugins/registry.js";
@@ -59,6 +60,44 @@ export type PluginUninstallOptions = {
dryRun?: boolean;
};
function resolvePluginUninstallId(params: {
rawId: string;
config: OpenClawConfig;
plugins: PluginRecord[];
}): { pluginId: string; plugin?: PluginRecord } {
const rawId = params.rawId.trim();
const plugin = params.plugins.find((entry) => entry.id === rawId || entry.name === rawId);
if (plugin) {
return { pluginId: plugin.id, plugin };
}
for (const [pluginId, install] of Object.entries(params.config.plugins?.installs ?? {})) {
if (
install.spec === rawId ||
install.resolvedSpec === rawId ||
install.resolvedName === rawId ||
install.marketplacePlugin === rawId
) {
return { pluginId };
}
}
const requestedClawHub = parseClawHubPluginSpec(rawId);
if (requestedClawHub) {
for (const [pluginId, install] of Object.entries(params.config.plugins?.installs ?? {})) {
const installedClawHubName =
install.clawhubPackage ??
parseClawHubPluginSpec(install.spec ?? "")?.name ??
parseClawHubPluginSpec(install.resolvedSpec ?? "")?.name;
if (installedClawHubName === requestedClawHub.name) {
return { pluginId };
}
}
}
return { pluginId: rawId };
}
function formatPluginLine(plugin: PluginRecord, verbose = false): string {
const status =
plugin.status === "loaded"
@@ -546,8 +585,11 @@ export function registerPluginsCli(program: Command) {
defaultRuntime.log(theme.warn("`--keep-config` is deprecated, use `--keep-files`."));
}
const plugin = report.plugins.find((p) => p.id === id || p.name === id);
const pluginId = plugin?.id ?? id;
const { plugin, pluginId } = resolvePluginUninstallId({
rawId: id,
config: cfg,
plugins: report.plugins,
});
const hasEntry = pluginId in (cfg.plugins?.entries ?? {});
const hasInstall = pluginId in (cfg.plugins?.installs ?? {});
+70
View File
@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/config.js";
import {
buildPluginStatusReport,
loadConfig,
parseClawHubPluginSpec,
promptYesNo,
resetPluginsCliTestState,
runPluginsCommand,
@@ -118,4 +119,73 @@ describe("plugins cli uninstall", () => {
expect(runtimeErrors.at(-1)).toContain("is not managed by plugins config/install records");
expect(uninstallPlugin).not.toHaveBeenCalled();
});
it("accepts the recorded ClawHub spec as an uninstall target", async () => {
loadConfig.mockReturnValue({
plugins: {
entries: {
"linkmind-context": { enabled: true },
},
installs: {
"linkmind-context": {
source: "npm",
spec: "clawhub:linkmind-context",
clawhubPackage: "linkmind-context",
},
},
},
} as OpenClawConfig);
buildPluginStatusReport.mockReturnValue({
plugins: [{ id: "linkmind-context", name: "linkmind-context" }],
diagnostics: [],
});
parseClawHubPluginSpec.mockImplementation((raw: string) =>
raw === "clawhub:linkmind-context" ? { name: "linkmind-context" } : null,
);
await runPluginsCommand(["plugins", "uninstall", "clawhub:linkmind-context", "--force"]);
expect(uninstallPlugin).toHaveBeenCalledWith(
expect.objectContaining({
pluginId: "linkmind-context",
}),
);
});
it("accepts a versionless ClawHub spec when the install was pinned", async () => {
loadConfig.mockReturnValue({
plugins: {
entries: {
"linkmind-context": { enabled: true },
},
installs: {
"linkmind-context": {
source: "npm",
spec: "clawhub:linkmind-context@1.2.3",
},
},
},
} as OpenClawConfig);
buildPluginStatusReport.mockReturnValue({
plugins: [{ id: "linkmind-context", name: "linkmind-context" }],
diagnostics: [],
});
parseClawHubPluginSpec.mockImplementation((raw: string) => {
if (raw === "clawhub:linkmind-context") {
return { name: "linkmind-context" };
}
if (raw === "clawhub:linkmind-context@1.2.3") {
return { name: "linkmind-context", version: "1.2.3" };
}
return null;
});
await runPluginsCommand(["plugins", "uninstall", "clawhub:linkmind-context", "--force"]);
expect(uninstallPlugin).toHaveBeenCalledWith(
expect.objectContaining({
pluginId: "linkmind-context",
}),
);
});
});
+1 -1
View File
@@ -1132,7 +1132,7 @@ describe("update-cli", () => {
it("uses ~/openclaw as the default dev checkout directory", async () => {
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue("/tmp/oc-home");
await withEnvAsync({ OPENCLAW_GIT_DIR: undefined }, async () => {
expect(resolveGitInstallDir()).toBe("/tmp/oc-home/openclaw");
expect(resolveGitInstallDir()).toBe(path.posix.join("/tmp/oc-home", "openclaw"));
});
homedirSpy.mockRestore();
});
+2 -3
View File
@@ -72,6 +72,7 @@ describe("dashboardCommand", () => {
formatControlUiSshHintMock.mockClear();
copyToClipboardMock.mockClear();
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.CUSTOM_GATEWAY_TOKEN;
});
it("opens and copies the dashboard link by default", async () => {
@@ -174,12 +175,10 @@ describe("dashboardCommand", () => {
it("resolves env-template gateway.auth.token before building dashboard URL", async () => {
mockSnapshot("${CUSTOM_GATEWAY_TOKEN}");
process.env.CUSTOM_GATEWAY_TOKEN = "resolved-secret-token";
copyToClipboardMock.mockResolvedValue(true);
detectBrowserOpenSupportMock.mockResolvedValue({ ok: true });
openUrlMock.mockResolvedValue(true);
resolveSecretRefValuesMock.mockResolvedValue(
new Map([["env:default:CUSTOM_GATEWAY_TOKEN", "resolved-secret-token"]]),
);
await dashboardCommand(runtime);
+2
View File
@@ -13,6 +13,7 @@ import { scanEmptyAllowlistPolicyWarnings } from "./shared/empty-allowlist-scan.
import { maybeRepairExecSafeBinProfiles } from "./shared/exec-safe-bins.js";
import { maybeRepairLegacyToolsBySenderKeys } from "./shared/legacy-tools-by-sender.js";
import { maybeRepairOpenPolicyAllowFrom } from "./shared/open-policy-allowfrom.js";
import { maybeRepairStalePluginConfig } from "./shared/stale-plugin-config.js";
export async function runDoctorRepairSequence(params: {
state: DoctorConfigMutationState;
@@ -48,6 +49,7 @@ export async function runDoctorRepairSequence(params: {
applyMutation(await maybeRepairTelegramAllowFromUsernames(state.candidate));
applyMutation(maybeRepairDiscordNumericIds(state.candidate));
applyMutation(maybeRepairOpenPolicyAllowFrom(state.candidate));
applyMutation(maybeRepairStalePluginConfig(state.candidate, process.env));
applyMutation(await maybeRepairAllowlistPolicyAllowFrom(state.candidate));
const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(state.candidate, {
@@ -1,7 +1,34 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginManifestRecord } from "../../../plugins/manifest-registry.js";
import * as manifestRegistry from "../../../plugins/manifest-registry.js";
import { collectDoctorPreviewWarnings } from "./preview-warnings.js";
function manifest(id: string): PluginManifestRecord {
return {
id,
channels: [],
providers: [],
skills: [],
hooks: [],
origin: "bundled",
rootDir: `/plugins/${id}`,
source: `/plugins/${id}`,
manifestPath: `/plugins/${id}/openclaw.plugin.json`,
};
}
describe("doctor preview warnings", () => {
beforeEach(() => {
vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({
plugins: [manifest("discord")],
diagnostics: [],
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("collects provider and shared preview warnings", () => {
const warnings = collectDoctorPreviewWarnings({
cfg: {
@@ -45,4 +72,52 @@ describe("doctor preview warnings", () => {
expect(warnings[0]).not.toContain("\u001B");
expect(warnings[0]).not.toContain("\r");
});
it("includes stale plugin config warnings", () => {
const warnings = collectDoctorPreviewWarnings({
cfg: {
plugins: {
allow: ["acpx"],
entries: {
acpx: { enabled: true },
},
},
},
doctorFixCommand: "openclaw doctor --fix",
});
expect(warnings).toEqual([
expect.stringContaining('plugins.allow: stale plugin reference "acpx"'),
]);
expect(warnings[0]).toContain("plugins.entries.acpx");
expect(warnings[0]).toContain('Run "openclaw doctor --fix"');
expect(warnings[0]).not.toContain("Auto-removal is paused");
});
it("warns but skips auto-removal when plugin discovery has errors", () => {
vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({
plugins: [],
diagnostics: [
{ level: "error", message: "plugin path not found: /missing", source: "/missing" },
],
});
const warnings = collectDoctorPreviewWarnings({
cfg: {
plugins: {
allow: ["acpx"],
entries: {
acpx: { enabled: true },
},
},
},
doctorFixCommand: "openclaw doctor --fix",
});
expect(warnings).toEqual([
expect.stringContaining('plugins.allow: stale plugin reference "acpx"'),
]);
expect(warnings[0]).toContain("Auto-removal is paused");
expect(warnings[0]).toContain('rerun "openclaw doctor --fix"');
});
});
@@ -24,6 +24,11 @@ import {
collectOpenPolicyAllowFromWarnings,
maybeRepairOpenPolicyAllowFrom,
} from "./open-policy-allowfrom.js";
import {
collectStalePluginConfigWarnings,
isStalePluginAutoRepairBlocked,
scanStalePluginConfig,
} from "./stale-plugin-config.js";
export function collectDoctorPreviewWarnings(params: {
cfg: OpenClawConfig;
@@ -61,6 +66,17 @@ export function collectDoctorPreviewWarnings(params: {
);
}
const stalePluginHits = scanStalePluginConfig(params.cfg, process.env);
if (stalePluginHits.length > 0) {
warnings.push(
collectStalePluginConfigWarnings({
hits: stalePluginHits,
doctorFixCommand: params.doctorFixCommand,
autoRepairBlocked: isStalePluginAutoRepairBlocked(params.cfg, process.env),
}).join("\n"),
);
}
const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(params.cfg, {
doctorFixCommand: params.doctorFixCommand,
extraWarningsForAccount: collectTelegramEmptyAllowlistExtraWarnings,
@@ -0,0 +1,174 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import type { PluginManifestRecord } from "../../../plugins/manifest-registry.js";
import * as manifestRegistry from "../../../plugins/manifest-registry.js";
import {
collectStalePluginConfigWarnings,
maybeRepairStalePluginConfig,
scanStalePluginConfig,
} from "./stale-plugin-config.js";
function manifest(id: string): PluginManifestRecord {
return {
id,
channels: [],
providers: [],
skills: [],
hooks: [],
origin: "bundled",
rootDir: `/plugins/${id}`,
source: `/plugins/${id}`,
manifestPath: `/plugins/${id}/openclaw.plugin.json`,
};
}
describe("doctor stale plugin config helpers", () => {
beforeEach(() => {
vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({
plugins: [manifest("discord"), manifest("voice-call"), manifest("openai")],
diagnostics: [],
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("finds stale plugins.allow and plugins.entries refs", () => {
const hits = scanStalePluginConfig({
plugins: {
allow: ["discord", "acpx"],
entries: {
"voice-call": { enabled: true },
acpx: { enabled: true },
},
},
} as OpenClawConfig);
expect(hits).toEqual([
{
pluginId: "acpx",
pathLabel: "plugins.allow",
surface: "allow",
},
{
pluginId: "acpx",
pathLabel: "plugins.entries.acpx",
surface: "entries",
},
]);
});
it("removes stale plugin ids from allow and entries without changing valid refs", () => {
const result = maybeRepairStalePluginConfig({
plugins: {
allow: ["discord", "acpx", "voice-call"],
entries: {
"voice-call": { enabled: true },
acpx: { enabled: true },
},
},
} as OpenClawConfig);
expect(result.changes).toEqual([
"- plugins.allow: removed 1 stale plugin id (acpx)",
"- plugins.entries: removed 1 stale plugin entry (acpx)",
]);
expect(result.config.plugins?.allow).toEqual(["discord", "voice-call"]);
expect(result.config.plugins?.entries).toEqual({
"voice-call": { enabled: true },
});
});
it("formats stale plugin warnings with a doctor hint", () => {
const warnings = collectStalePluginConfigWarnings({
hits: [
{
pluginId: "acpx",
pathLabel: "plugins.allow",
surface: "allow",
},
],
doctorFixCommand: "openclaw doctor --fix",
});
expect(warnings).toEqual([
expect.stringContaining('plugins.allow: stale plugin reference "acpx"'),
expect.stringContaining('Run "openclaw doctor --fix"'),
]);
});
it("does not auto-repair stale refs while plugin discovery has errors", () => {
vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({
plugins: [],
diagnostics: [
{ level: "error", message: "plugin path not found: /missing", source: "/missing" },
],
});
const cfg = {
plugins: {
allow: ["acpx"],
entries: {
acpx: { enabled: true },
},
},
} as OpenClawConfig;
const hits = scanStalePluginConfig(cfg);
expect(hits).toEqual([
{
pluginId: "acpx",
pathLabel: "plugins.allow",
surface: "allow",
},
{
pluginId: "acpx",
pathLabel: "plugins.entries.acpx",
surface: "entries",
},
]);
const result = maybeRepairStalePluginConfig(cfg);
expect(result.changes).toEqual([]);
expect(result.config).toEqual(cfg);
const warnings = collectStalePluginConfigWarnings({
hits,
doctorFixCommand: "openclaw doctor --fix",
autoRepairBlocked: true,
});
expect(warnings[2]).toContain("Auto-removal is paused");
});
it("treats legacy plugin aliases as valid ids during scan and repair", () => {
const cfg = {
plugins: {
allow: ["openai-codex", "acpx"],
entries: {
"openai-codex": { enabled: true },
acpx: { enabled: true },
},
},
} as OpenClawConfig;
expect(scanStalePluginConfig(cfg)).toEqual([
{
pluginId: "acpx",
pathLabel: "plugins.allow",
surface: "allow",
},
{
pluginId: "acpx",
pathLabel: "plugins.entries.acpx",
surface: "entries",
},
]);
const result = maybeRepairStalePluginConfig(cfg);
expect(result.config.plugins?.allow).toEqual(["openai-codex"]);
expect(result.config.plugins?.entries).toEqual({
"openai-codex": { enabled: true },
});
});
});
@@ -0,0 +1,169 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../../agents/agent-scope.js";
import type { OpenClawConfig } from "../../../config/config.js";
import { normalizePluginId } from "../../../plugins/config-state.js";
import { loadPluginManifestRegistry } from "../../../plugins/manifest-registry.js";
import { sanitizeForLog } from "../../../terminal/ansi.js";
import { asObjectRecord } from "./object.js";
type StalePluginSurface = "allow" | "entries";
type StalePluginConfigHit = {
pluginId: string;
pathLabel: string;
surface: StalePluginSurface;
};
type StalePluginRegistryState = {
knownIds: Set<string>;
hasDiscoveryErrors: boolean;
};
function collectPluginRegistryState(
cfg: OpenClawConfig,
env?: NodeJS.ProcessEnv,
): StalePluginRegistryState {
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const registry = loadPluginManifestRegistry({
config: cfg,
workspaceDir: workspaceDir ?? undefined,
env,
});
return {
knownIds: new Set(registry.plugins.map((plugin) => plugin.id)),
hasDiscoveryErrors: registry.diagnostics.some((diag) => diag.level === "error"),
};
}
export function isStalePluginAutoRepairBlocked(
cfg: OpenClawConfig,
env?: NodeJS.ProcessEnv,
): boolean {
return collectPluginRegistryState(cfg, env).hasDiscoveryErrors;
}
export function scanStalePluginConfig(
cfg: OpenClawConfig,
env?: NodeJS.ProcessEnv,
): StalePluginConfigHit[] {
const plugins = asObjectRecord(cfg.plugins);
if (!plugins) {
return [];
}
const { knownIds } = collectPluginRegistryState(cfg, env);
const hits: StalePluginConfigHit[] = [];
const allow = Array.isArray(plugins.allow) ? plugins.allow : [];
for (const rawPluginId of allow) {
if (typeof rawPluginId !== "string") {
continue;
}
const pluginId = normalizePluginId(rawPluginId);
if (!pluginId || knownIds.has(pluginId)) {
continue;
}
hits.push({
pluginId: rawPluginId,
pathLabel: "plugins.allow",
surface: "allow",
});
}
const entries = asObjectRecord(plugins.entries);
if (!entries) {
return hits;
}
for (const rawPluginId of Object.keys(entries)) {
if (knownIds.has(normalizePluginId(rawPluginId))) {
continue;
}
hits.push({
pluginId: rawPluginId,
pathLabel: `plugins.entries.${rawPluginId}`,
surface: "entries",
});
}
return hits;
}
export function collectStalePluginConfigWarnings(params: {
hits: StalePluginConfigHit[];
doctorFixCommand: string;
autoRepairBlocked?: boolean;
}): string[] {
if (params.hits.length === 0) {
return [];
}
const lines = params.hits.map(
(hit) => `- ${hit.pathLabel}: stale plugin reference "${hit.pluginId}" was found.`,
);
if (params.autoRepairBlocked) {
lines.push(
`- Auto-removal is paused because plugin discovery currently has errors. Fix plugin discovery first, then rerun "${params.doctorFixCommand}".`,
);
} else {
lines.push(
`- Run "${params.doctorFixCommand}" to remove stale plugins.allow and plugins.entries ids.`,
);
}
return lines.map((line) => sanitizeForLog(line));
}
export function maybeRepairStalePluginConfig(
cfg: OpenClawConfig,
env?: NodeJS.ProcessEnv,
): {
config: OpenClawConfig;
changes: string[];
} {
if (isStalePluginAutoRepairBlocked(cfg, env)) {
return { config: cfg, changes: [] };
}
const hits = scanStalePluginConfig(cfg, env);
if (hits.length === 0) {
return { config: cfg, changes: [] };
}
const next = structuredClone(cfg);
const nextPlugins = asObjectRecord(next.plugins);
if (!nextPlugins) {
return { config: cfg, changes: [] };
}
const allowIds = hits.filter((hit) => hit.surface === "allow").map((hit) => hit.pluginId);
if (allowIds.length > 0 && Array.isArray(nextPlugins.allow)) {
const staleAllowIds = new Set(allowIds.map((pluginId) => normalizePluginId(pluginId)));
nextPlugins.allow = nextPlugins.allow.filter(
(pluginId) => typeof pluginId !== "string" || !staleAllowIds.has(normalizePluginId(pluginId)),
);
}
const entryIds = hits.filter((hit) => hit.surface === "entries").map((hit) => hit.pluginId);
if (entryIds.length > 0) {
const entries = asObjectRecord(nextPlugins.entries);
if (entries) {
const staleEntryIds = new Set(entryIds.map((pluginId) => normalizePluginId(pluginId)));
for (const pluginId of Object.keys(entries)) {
if (staleEntryIds.has(normalizePluginId(pluginId))) {
delete entries[pluginId];
}
}
}
}
const changes: string[] = [];
if (allowIds.length > 0) {
changes.push(
`- plugins.allow: removed ${allowIds.length} stale plugin id${allowIds.length === 1 ? "" : "s"} (${allowIds.join(", ")})`,
);
}
if (entryIds.length > 0) {
changes.push(
`- plugins.entries: removed ${entryIds.length} stale plugin entr${entryIds.length === 1 ? "y" : "ies"} (${entryIds.join(", ")})`,
);
}
return { config: next, changes };
}
+17
View File
@@ -315,6 +315,23 @@ describe("modelsAuthLoginCommand", () => {
}
});
it("writes pasted tokens to the resolved agent store", async () => {
const runtime = createRuntime();
mocks.clackText.mockResolvedValue("tok-fresh");
await modelsAuthPasteTokenCommand({ provider: "openai" }, runtime);
expect(mocks.upsertAuthProfile).toHaveBeenCalledWith({
profileId: "openai:manual",
credential: {
type: "token",
provider: "openai",
token: "tok-fresh",
},
agentDir: "/tmp/openclaw/agents/main",
});
});
it("runs token auth for any token-capable provider plugin", async () => {
const runtime = createRuntime();
const runTokenAuth = vi.fn().mockResolvedValue({
+2
View File
@@ -359,6 +359,7 @@ export async function modelsAuthPasteTokenCommand(
},
runtime: RuntimeEnv,
) {
const { agentDir } = await resolveModelsAuthContext();
const rawProvider = opts.provider?.trim();
if (!rawProvider) {
throw new Error("Missing --provider.");
@@ -385,6 +386,7 @@ export async function modelsAuthPasteTokenCommand(
token,
...(expires ? { expires } : {}),
},
agentDir,
});
await updateConfig((cfg) => applyAuthProfileConfig(cfg, { profileId, provider, mode: "token" }));
+4
View File
@@ -49,6 +49,8 @@ export type BuiltInAuthChoice =
| "volcengine-api-key"
| "byteplus-api-key"
| "qianfan-api-key"
| "modelstudio-standard-api-key-cn"
| "modelstudio-standard-api-key"
| "modelstudio-api-key-cn"
| "modelstudio-api-key"
| "custom-api-key"
@@ -143,6 +145,8 @@ export type OnboardOptions = {
volcengineApiKey?: string;
byteplusApiKey?: string;
qianfanApiKey?: string;
modelstudioStandardApiKeyCn?: string;
modelstudioStandardApiKey?: string;
modelstudioApiKeyCn?: string;
modelstudioApiKey?: string;
customBaseUrl?: string;
+12
View File
@@ -154,6 +154,7 @@ async function withEnvVar<T>(key: string, value: string, run: () => Promise<T>):
}
const mocks = vi.hoisted(() => ({
hasPotentialConfiguredChannels: vi.fn(() => true),
loadConfig: vi.fn().mockReturnValue({ session: {} }),
loadSessionStore: vi.fn().mockReturnValue({
"+1000": createDefaultSessionStoreEntry(),
@@ -208,6 +209,14 @@ const mocks = vi.hoisted(() => ({
buildPluginCompatibilityNotices: vi.fn((): PluginCompatibilityNotice[] => []),
}));
vi.mock("../channels/config-presence.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../channels/config-presence.js")>();
return {
...actual,
hasPotentialConfiguredChannels: mocks.hasPotentialConfiguredChannels,
};
});
vi.mock("../memory/index.js", () => ({
getMemorySearchManager: vi.fn(async ({ agentId }: { agentId: string }) => ({
manager: {
@@ -417,6 +426,8 @@ const runtimeLogMock = runtime.log as Mock<(...args: unknown[]) => void>;
describe("statusCommand", () => {
afterEach(() => {
mocks.hasPotentialConfiguredChannels.mockReset();
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
mocks.loadConfig.mockReset();
mocks.loadConfig.mockReturnValue({ session: {} });
mocks.loadSessionStore.mockReset();
@@ -477,6 +488,7 @@ describe("statusCommand", () => {
});
it("prints JSON when requested", async () => {
mocks.hasPotentialConfiguredChannels.mockReturnValue(false);
mocks.buildPluginCompatibilityNotices.mockReturnValue([
{
pluginId: "legacy-plugin",
+1 -1
View File
@@ -16286,6 +16286,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
tags: ["security", "auth"],
},
},
version: "2026.3.23-beta.1",
version: "2026.3.23",
generatedAt: "2026-03-22T21:17:33.302Z",
} as const satisfies BaseConfigSchemaResponse;
+37
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { upsertAcpSessionMeta } from "../../acp/runtime/session-meta.js";
import * as jsonFiles from "../../infra/json-files.js";
import * as transcriptEvents from "../../sessions/transcript-events.js";
import type { OpenClawConfig } from "../config.js";
import {
clearSessionStoreCacheForTest,
@@ -429,6 +430,42 @@ describe("appendAssistantMessageToSessionTranscript", () => {
}
});
it("emits transcript update events for delivery mirrors", async () => {
const sessionId = "test-session-id";
const sessionKey = "test-session";
const store = {
[sessionKey]: {
sessionId,
chatType: "direct",
channel: "discord",
},
};
fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8");
const emitSpy = vi.spyOn(transcriptEvents, "emitSessionTranscriptUpdate");
await appendAssistantMessageToSessionTranscript({
sessionKey,
text: "Hello from delivery mirror!",
storePath: fixture.storePath(),
});
const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir());
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionFile,
sessionKey,
messageId: expect.any(String),
message: expect.objectContaining({
role: "assistant",
provider: "openclaw",
model: "delivery-mirror",
content: [{ type: "text", text: "Hello from delivery mirror!" }],
}),
}),
);
emitSpy.mockRestore();
});
it("does not append a duplicate delivery mirror for the same idempotency key", async () => {
writeTranscriptStore();
+13 -2
View File
@@ -110,8 +110,15 @@ vi.mock("../logger.js", async (importOriginal) => {
};
});
const { GatewayClient } = await import("./client.js");
type GatewayClientInstance = InstanceType<typeof GatewayClient>;
type GatewayClientModule = typeof import("./client.js");
type GatewayClientInstance = InstanceType<GatewayClientModule["GatewayClient"]>;
let GatewayClient: GatewayClientModule["GatewayClient"];
async function loadGatewayClientModule() {
vi.resetModules();
({ GatewayClient } = await import("./client.js"));
}
function getLatestWs(): MockWebSocket {
const ws = wsInstances.at(-1);
@@ -153,6 +160,10 @@ function expectSecurityConnectError(
}
}
beforeEach(async () => {
await loadGatewayClientModule();
});
describe("GatewayClient security checks", () => {
const envSnapshot = captureEnv(["OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"]);
@@ -64,6 +64,9 @@ const GATEWAY_LIVE_HEARTBEAT_MS = Math.max(
const GATEWAY_LIVE_STRIP_SCAFFOLDING_MODEL_KEYS = new Set([
"google/gemini-3-flash-preview",
"google/gemini-3-pro-preview",
"google/gemini-3.1-flash-lite-preview",
"google/gemini-3.1-pro-preview",
"google/gemini-3.1-pro-preview-customtools",
]);
const GATEWAY_LIVE_MAX_MODELS = resolveGatewayLiveMaxModels();
const GATEWAY_LIVE_SUITE_TIMEOUT_MS = resolveGatewayLiveSuiteTimeoutMs(GATEWAY_LIVE_MAX_MODELS);
@@ -297,7 +300,7 @@ function maybeStripAssistantScaffoldingForLiveModel(text: string, modelKey?: str
}
describe("maybeStripAssistantScaffoldingForLiveModel", () => {
it("strips scaffolding for the gemini 3.1 flash alias and targeted live models", () => {
it("strips scaffolding for Gemini preview models with known transcript wrappers", () => {
expect(
maybeStripAssistantScaffoldingForLiveModel(
"<think>hidden</think>Visible",
@@ -307,7 +310,19 @@ describe("maybeStripAssistantScaffoldingForLiveModel", () => {
expect(
maybeStripAssistantScaffoldingForLiveModel(
"<think>hidden</think>Visible",
"google/gemini-3-pro-preview",
"google/gemini-3.1-flash-lite-preview",
),
).toBe("Visible");
expect(
maybeStripAssistantScaffoldingForLiveModel(
"<think>hidden</think>Visible",
"google/gemini-3.1-pro-preview",
),
).toBe("Visible");
expect(
maybeStripAssistantScaffoldingForLiveModel(
"<think>hidden</think>Visible",
"google/gemini-3.1-pro-preview-customtools",
),
).toBe("Visible");
expect(
+7 -3
View File
@@ -25,9 +25,13 @@ vi.mock("../plugins/channel-plugin-ids.js", () => ({
resolveGatewayStartupPluginIds,
}));
vi.mock("../channels/plugins/binding-registry.js", () => ({
primeConfiguredBindingRegistry,
}));
vi.mock("../channels/plugins/binding-registry.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../channels/plugins/binding-registry.js")>();
return {
...actual,
primeConfiguredBindingRegistry,
};
});
vi.mock("./server-methods.js", () => ({
handleGatewayRequest,
@@ -409,6 +409,35 @@ export function registerControlUiAndPairingSuite(): void {
}
});
test("preserves requested control ui scopes when dangerouslyDisableDeviceAuth bypasses device identity", async () => {
testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true };
testState.gatewayAuth = { mode: "token", token: "secret" };
const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_TOKEN = "secret";
try {
await withGatewayServer(async ({ port }) => {
const ws = await openWs(port, { origin: originForPort(port) });
const res = await connectReq(ws, {
token: "secret",
scopes: ["operator.read"],
client: {
...CONTROL_UI_CLIENT,
},
});
expect(res.ok).toBe(true);
const health = await rpcReq(ws, "health");
expect(health.ok).toBe(true);
const talk = await rpcReq(ws, "chat.history", { sessionKey: "main", limit: 1 });
expect(talk.ok).toBe(true);
ws.close();
});
} finally {
restoreGatewayToken(prevToken);
}
});
test("device token auth matrix", async () => {
const { server, ws, port, prevToken } = await startServerWithClient("secret");
const { deviceToken, deviceIdentityPath } = await ensurePairedDeviceTokenForCurrentIdentity(ws);
@@ -542,7 +542,8 @@ export function attachGatewayWsMessageHandler(params: {
if (
!device &&
(decision.kind !== "allow" ||
(!preserveInsecureLocalControlUiScopes &&
(!controlUiAuthPolicy.allowBypass &&
!preserveInsecureLocalControlUiScopes &&
(authMethod === "token" || authMethod === "password" || trustedProxyAuthOk)))
) {
clearUnboundScopes();
+5 -1
View File
@@ -294,7 +294,11 @@ describe("device pairing tokens", () => {
const paired = await getPairedDevice("device-1", baseDir);
expect(paired?.scopes).toEqual(["operator.admin"]);
expect(paired?.approvedScopes).toEqual(["operator.admin"]);
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.admin"]);
expect(paired?.tokens?.operator?.scopes).toEqual([
"operator.admin",
"operator.read",
"operator.write",
]);
});
test("rejects scope escalation when rotating a token and leaves state unchanged", async () => {
@@ -249,6 +249,123 @@ describe("resolveAllowAlwaysPatterns", () => {
expect(second.allowlistSatisfied).toBe(true);
});
it("persists carried executables for exec -- positional argv carriers", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const touch = makeExecutable(dir, "touch");
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
const safeBins = resolveSafeBins(undefined);
const { persisted } = resolvePersistedPatterns({
command: `sh -lc 'exec -- "$0" "$1"' touch ${path.join(dir, "marker")}`,
dir,
env,
safeBins,
});
expect(persisted).toEqual([touch]);
const second = evaluateShellAllowlist({
command: `sh -lc 'exec -- "$0" "$1"' touch ${path.join(dir, "second-marker")}`,
allowlist: [{ pattern: touch }],
safeBins,
cwd: dir,
env,
platform: process.platform,
});
expect(second.allowlistSatisfied).toBe(true);
});
it("rejects positional argv carriers when $0 is single-quoted", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const touch = makeExecutable(dir, "touch");
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
const safeBins = resolveSafeBins(undefined);
const marker = path.join(dir, "marker");
const { persisted } = resolvePersistedPatterns({
command: `sh -lc "'$0' "$1"" touch ${marker}`,
dir,
env,
safeBins,
});
expect(persisted).not.toContain(touch);
const second = evaluateShellAllowlist({
command: `sh -lc "'$0' "$1"" touch ${marker}`,
allowlist: [{ pattern: touch }],
safeBins,
cwd: dir,
env,
platform: process.platform,
});
expect(second.allowlistSatisfied).toBe(false);
});
it("rejects positional argv carriers when exec is separated from $0 by a newline", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const touch = makeExecutable(dir, "touch");
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
const safeBins = resolveSafeBins(undefined);
const marker = path.join(dir, "marker");
const { persisted } = resolvePersistedPatterns({
command: `sh -lc "exec
$0 \\"$1\\"" touch ${marker}`,
dir,
env,
safeBins,
});
expect(persisted).not.toContain(touch);
const second = evaluateShellAllowlist({
command: `sh -lc "exec
$0 \\"$1\\"" touch ${marker}`,
allowlist: [{ pattern: touch }],
safeBins,
cwd: dir,
env,
platform: process.platform,
});
expect(second.allowlistSatisfied).toBe(false);
});
it("rejects positional argv carriers when inline command contains extra shell operations", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const touch = makeExecutable(dir, "touch");
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
const safeBins = resolveSafeBins(undefined);
const marker = path.join(dir, "marker");
const { persisted } = resolvePersistedPatterns({
command: `sh -lc 'echo blocked; $0 "$1"' touch ${marker}`,
dir,
env,
safeBins,
});
expect(persisted).not.toContain(touch);
const second = evaluateShellAllowlist({
command: `sh -lc 'echo blocked; $0 "$1"' touch ${marker}`,
allowlist: [{ pattern: touch }],
safeBins,
cwd: dir,
env,
platform: process.platform,
});
expect(second.allowlistSatisfied).toBe(false);
});
it("does not treat inline shell commands as persisted script paths", () => {
if (process.platform === "win32") {
return;
+18 -1
View File
@@ -449,7 +449,7 @@ function resolveShellWrapperPositionalArgvCandidatePath(params: {
if (inlineMatch.valueTokenIndex === null || !inlineMatch.command) {
return undefined;
}
if (!/(?:^|[^\\$])\$(?:0|\{0\})/.test(inlineMatch.command)) {
if (!isDirectShellPositionalCarrierInvocation(inlineMatch.command)) {
return undefined;
}
@@ -465,6 +465,23 @@ function resolveShellWrapperPositionalArgvCandidatePath(params: {
return resolveAllowlistCandidatePath(resolution, params.cwd);
}
function isDirectShellPositionalCarrierInvocation(command: string): boolean {
const trimmed = command.trim();
if (trimmed.length === 0) {
return false;
}
// Keep carrier matching strict: only allow direct `$0` execution with positional arguments.
// This prevents payloads like `echo blocked; $0 "$1"` from satisfying allowlist checks.
const shellWhitespace = String.raw`[^\S\r\n]+`;
const positionalZero = String.raw`(?:\$(?:0|\{0\})|"\$(?:0|\{0\})")`;
const positionalArg = String.raw`(?:\$(?:[@*]|[1-9]|\{[@*1-9]\})|"\$(?:[@*]|[1-9]|\{[@*1-9]\})")`;
return new RegExp(
`^(?:exec${shellWhitespace}(?:--${shellWhitespace})?)?${positionalZero}(?:${shellWhitespace}${positionalArg})*$`,
"u",
).test(trimmed);
}
function collectAllowAlwaysPatterns(params: {
segment: ExecCommandSegment;
cwd?: string;
+4 -3
View File
@@ -1,10 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const resolveProviderUsageAuthWithPluginMock = vi.fn();
const resolveProviderUsageAuthWithPluginMock = vi.fn(
async (..._args: unknown[]): Promise<unknown> => null,
);
vi.mock("../plugins/provider-runtime.js", () => ({
resolveProviderUsageAuthWithPlugin: (...args: unknown[]) =>
resolveProviderUsageAuthWithPluginMock(...args),
resolveProviderUsageAuthWithPlugin: resolveProviderUsageAuthWithPluginMock,
}));
let resolveProviderAuths: typeof import("./provider-usage.auth.js").resolveProviderAuths;
+1
View File
@@ -2,4 +2,5 @@
// Keep this list additive and scoped to symbols used under extensions/memory-lancedb.
export { definePluginEntry } from "./plugin-entry.js";
export { resolveStateDir } from "./state-paths.js";
export type { OpenClawPluginApi } from "../plugins/types.js";
@@ -1927,6 +1927,34 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [
modelstudio: ["MODELSTUDIO_API_KEY"],
},
providerAuthChoices: [
{
provider: "modelstudio",
method: "standard-api-key-cn",
choiceId: "modelstudio-standard-api-key-cn",
choiceLabel: "Standard API Key for China (pay-as-you-go)",
choiceHint: "Endpoint: dashscope.aliyuncs.com",
groupId: "modelstudio",
groupLabel: "Qwen (Alibaba Cloud Model Studio)",
groupHint: "Standard / Coding Plan (CN / Global)",
optionKey: "modelstudioStandardApiKeyCn",
cliFlag: "--modelstudio-standard-api-key-cn",
cliOption: "--modelstudio-standard-api-key-cn <key>",
cliDescription: "Alibaba Cloud Model Studio Standard API key (China)",
},
{
provider: "modelstudio",
method: "standard-api-key",
choiceId: "modelstudio-standard-api-key",
choiceLabel: "Standard API Key for Global/Intl (pay-as-you-go)",
choiceHint: "Endpoint: dashscope-intl.aliyuncs.com",
groupId: "modelstudio",
groupLabel: "Qwen (Alibaba Cloud Model Studio)",
groupHint: "Standard / Coding Plan (CN / Global)",
optionKey: "modelstudioStandardApiKey",
cliFlag: "--modelstudio-standard-api-key",
cliOption: "--modelstudio-standard-api-key <key>",
cliDescription: "Alibaba Cloud Model Studio Standard API key (Global/Intl)",
},
{
provider: "modelstudio",
method: "api-key-cn",
@@ -1934,8 +1962,8 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [
choiceLabel: "Coding Plan API Key for China (subscription)",
choiceHint: "Endpoint: coding.dashscope.aliyuncs.com",
groupId: "modelstudio",
groupLabel: "Alibaba Cloud Model Studio",
groupHint: "Coding Plan API key (CN / Global)",
groupLabel: "Qwen (Alibaba Cloud Model Studio)",
groupHint: "Standard / Coding Plan (CN / Global)",
optionKey: "modelstudioApiKeyCn",
cliFlag: "--modelstudio-api-key-cn",
cliOption: "--modelstudio-api-key-cn <key>",
@@ -1948,8 +1976,8 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [
choiceLabel: "Coding Plan API Key for Global/Intl (subscription)",
choiceHint: "Endpoint: coding-intl.dashscope.aliyuncs.com",
groupId: "modelstudio",
groupLabel: "Alibaba Cloud Model Studio",
groupHint: "Coding Plan API key (CN / Global)",
groupLabel: "Qwen (Alibaba Cloud Model Studio)",
groupHint: "Standard / Coding Plan (CN / Global)",
optionKey: "modelstudioApiKey",
cliFlag: "--modelstudio-api-key",
cliOption: "--modelstudio-api-key <key>",
+1 -1
View File
@@ -72,7 +72,7 @@ const PLUGIN_ID_ALIASES: Readonly<Record<string, string>> = {
"minimax-portal-auth": "minimax",
};
function normalizePluginId(id: string): string {
export function normalizePluginId(id: string): string {
const trimmed = id.trim();
return PLUGIN_ID_ALIASES[trimmed] ?? trimmed;
}
+1 -1
View File
@@ -186,7 +186,7 @@ describe("device-auth-store", () => {
expect(entry).toEqual({
token: "new-token",
role: "operator",
scopes: ["operator.write"],
scopes: ["operator.read", "operator.write"],
updatedAtMs: 2222,
});
expect(readStore()).toEqual({
+12
View File
@@ -21,4 +21,16 @@ describe("shared/device-auth", () => {
"z.scope",
]);
});
it("expands implied operator scopes for stored device auth", () => {
expect(normalizeDeviceAuthScopes(["operator.write"])).toEqual([
"operator.read",
"operator.write",
]);
expect(normalizeDeviceAuthScopes(["operator.admin"])).toEqual([
"operator.admin",
"operator.read",
"operator.write",
]);
});
});
+6
View File
@@ -26,5 +26,11 @@ export function normalizeDeviceAuthScopes(scopes: string[] | undefined): string[
out.add(trimmed);
}
}
if (out.has("operator.admin")) {
out.add("operator.read");
out.add("operator.write");
} else if (out.has("operator.write")) {
out.add("operator.read");
}
return [...out].toSorted();
}
+105
View File
@@ -7,6 +7,13 @@ type MakeCacheableSignalKeyStoreFn = BaileysExports["makeCacheableSignalKeyStore
type MakeWASocketFn = BaileysExports["makeWASocket"];
type UseMultiFileAuthStateFn = BaileysExports["useMultiFileAuthState"];
type DownloadMediaMessageFn = BaileysExports["downloadMediaMessage"];
type ExtractMessageContentFn = BaileysExports["extractMessageContent"];
type GetContentTypeFn = BaileysExports["getContentType"];
type NormalizeMessageContentFn = BaileysExports["normalizeMessageContent"];
type IsJidGroupFn = BaileysExports["isJidGroup"];
type MessageContentInput = Parameters<NormalizeMessageContentFn>[0];
type MessageContentOutput = ReturnType<NormalizeMessageContentFn>;
type MessageContentType = ReturnType<GetContentTypeFn>;
export type MockBaileysSocket = {
ev: EventEmitter;
@@ -19,15 +26,105 @@ export type MockBaileysSocket = {
export type MockBaileysModule = {
DisconnectReason: { loggedOut: number };
extractMessageContent: ReturnType<typeof vi.fn<ExtractMessageContentFn>>;
fetchLatestBaileysVersion: ReturnType<typeof vi.fn<FetchLatestBaileysVersionFn>>;
getContentType: ReturnType<typeof vi.fn<GetContentTypeFn>>;
isJidGroup: ReturnType<typeof vi.fn<IsJidGroupFn>>;
makeCacheableSignalKeyStore: ReturnType<typeof vi.fn<MakeCacheableSignalKeyStoreFn>>;
makeWASocket: ReturnType<typeof vi.fn<MakeWASocketFn>>;
normalizeMessageContent: ReturnType<typeof vi.fn<NormalizeMessageContentFn>>;
useMultiFileAuthState: ReturnType<typeof vi.fn<UseMultiFileAuthStateFn>>;
jidToE164?: (jid: string) => string | null;
proto?: unknown;
downloadMediaMessage?: ReturnType<typeof vi.fn<DownloadMediaMessageFn>>;
};
const MESSAGE_WRAPPER_KEYS = [
"ephemeralMessage",
"viewOnceMessage",
"viewOnceMessageV2",
"viewOnceMessageV2Extension",
"documentWithCaptionMessage",
] as const;
const MESSAGE_CONTENT_KEYS = [
"conversation",
"extendedTextMessage",
"imageMessage",
"videoMessage",
"audioMessage",
"documentMessage",
"stickerMessage",
"locationMessage",
"liveLocationMessage",
"contactMessage",
"contactsArrayMessage",
"buttonsResponseMessage",
"listResponseMessage",
"templateButtonReplyMessage",
"interactiveResponseMessage",
"buttonsMessage",
"listMessage",
] as const;
type MessageLike = Record<string, unknown>;
export function mockNormalizeMessageContent(message: MessageContentInput): MessageContentOutput {
let current = message as unknown;
while (current && typeof current === "object") {
let unwrapped = false;
for (const key of MESSAGE_WRAPPER_KEYS) {
const candidate = (current as MessageLike)[key];
if (
candidate &&
typeof candidate === "object" &&
"message" in (candidate as MessageLike) &&
(candidate as { message?: unknown }).message
) {
current = (candidate as { message: unknown }).message;
unwrapped = true;
break;
}
}
if (!unwrapped) {
break;
}
}
return current as MessageContentOutput;
}
export function mockGetContentType(message: MessageContentInput): MessageContentType {
const normalized = mockNormalizeMessageContent(message);
if (!normalized || typeof normalized !== "object") {
return undefined;
}
for (const key of MESSAGE_CONTENT_KEYS) {
if ((normalized as MessageLike)[key] != null) {
return key as MessageContentType;
}
}
return undefined;
}
export function mockExtractMessageContent(message: MessageContentInput): MessageContentOutput {
const normalized = mockNormalizeMessageContent(message);
if (!normalized || typeof normalized !== "object") {
return normalized;
}
const contentType = mockGetContentType(normalized);
if (!contentType || contentType === "conversation") {
return normalized;
}
const candidate = (normalized as MessageLike)[contentType];
return (
candidate && typeof candidate === "object" ? candidate : normalized
) as MessageContentOutput;
}
export function mockIsJidGroup(jid: string | undefined | null): boolean {
return typeof jid === "string" && jid.endsWith("@g.us");
}
export function createMockBaileys(): {
mod: MockBaileysModule;
lastSocket: () => MockBaileysSocket;
@@ -50,11 +147,19 @@ export function createMockBaileys(): {
const mod: MockBaileysModule = {
DisconnectReason: { loggedOut: 401 },
extractMessageContent: vi.fn<ExtractMessageContentFn>((message) =>
mockExtractMessageContent(message),
),
fetchLatestBaileysVersion: vi
.fn<FetchLatestBaileysVersionFn>()
.mockResolvedValue({ version: [1, 2, 3], isLatest: true }),
getContentType: vi.fn<GetContentTypeFn>((message) => mockGetContentType(message)),
isJidGroup: vi.fn<IsJidGroupFn>((jid) => mockIsJidGroup(jid)),
makeCacheableSignalKeyStore: vi.fn<MakeCacheableSignalKeyStoreFn>((keys) => keys),
makeWASocket,
normalizeMessageContent: vi.fn<NormalizeMessageContentFn>((message) =>
mockNormalizeMessageContent(message),
),
useMultiFileAuthState: vi.fn<UseMultiFileAuthStateFn>(async () => ({
state: { creds: {}, keys: {} } as Awaited<ReturnType<UseMultiFileAuthStateFn>>["state"],
saveCreds: vi.fn(),
+13 -2
View File
@@ -2,6 +2,10 @@ import type { GatewayBrowserClient } from "../gateway.ts";
import type { AgentsListResult, ToolsCatalogResult } from "../types.ts";
import { saveConfig } from "./config.ts";
import type { ConfigState } from "./config.ts";
import {
formatMissingOperatorReadScopeMessage,
isMissingOperatorReadScopeError,
} from "./scope-errors.ts";
export type AgentsState = {
client: GatewayBrowserClient | null;
@@ -38,7 +42,12 @@ export async function loadAgents(state: AgentsState) {
}
}
} catch (err) {
state.agentsError = String(err);
if (isMissingOperatorReadScopeError(err)) {
state.agentsList = null;
state.agentsError = formatMissingOperatorReadScopeMessage("agent list");
} else {
state.agentsError = String(err);
}
} finally {
state.agentsLoading = false;
}
@@ -76,7 +85,9 @@ export async function loadToolsCatalog(state: AgentsState, agentId: string) {
return;
}
state.toolsCatalogResult = null;
state.toolsCatalogError = String(err);
state.toolsCatalogError = isMissingOperatorReadScopeError(err)
? formatMissingOperatorReadScopeMessage("tools catalog")
: String(err);
} finally {
if (state.toolsCatalogLoadingAgentId === resolvedAgentId) {
state.toolsCatalogLoadingAgentId = null;

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