Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccfeecb688 | |||
| a921b5bdff | |||
| 725a2cc2ca | |||
| 67dbb1ad42 | |||
| d67efbfbd3 | |||
| ae336d1602 | |||
| 03231c0633 | |||
| 47bdc36831 | |||
| 19295994f3 | |||
| 6f5df14308 | |||
| 7b03502175 | |||
| e87a64f8d0 | |||
| dad6018230 | |||
| e808f8d6ef | |||
| 6ab6e7a493 | |||
| fb6588cb99 | |||
| 6e8d5cd578 | |||
| ade0182ae0 | |||
| 64f4df1886 | |||
| 3814f956d1 | |||
| dbe7da7684 | |||
| a53715e9d0 | |||
| c1131ba7e0 | |||
| 1c7e98de16 | |||
| 34c57487b4 | |||
| bc95436a43 | |||
| f698774324 | |||
| b98f3634c4 | |||
| fd5496d1d3 | |||
| 13e81870bb | |||
| 6c58277577 | |||
| 3e2b3bd2c5 | |||
| 99c84294f3 | |||
| 01c1fc797f | |||
| 8670f2cead | |||
| cdd797f943 |
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")}`,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)"
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>");
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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_]*$/;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+36
-2
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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 () => {};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 ?? {});
|
||||
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user