Compare commits
409 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cff6dc94e3 | |||
| 97a7e93db4 | |||
| e2e9f979ca | |||
| 7cc86e9685 | |||
| c2a2edb329 | |||
| a0b9dc0078 | |||
| bd4237c16c | |||
| edb5123f26 | |||
| e9ac2860c1 | |||
| da60aff17a | |||
| ee714f5a42 | |||
| ea08f2eb8c | |||
| 3c3fd8c386 | |||
| 436aa838fe | |||
| 284084672a | |||
| 66c88b4c77 | |||
| c92002e1de | |||
| 39ad51426c | |||
| 9e95125f06 | |||
| b19cc399b6 | |||
| 3b6d980c52 | |||
| cdba1e6771 | |||
| d874f3970a | |||
| 7c2790cec4 | |||
| c3d1dbc696 | |||
| d363af8c13 | |||
| f3fe019e3d | |||
| 770a5ee5b1 | |||
| 93594a1440 | |||
| ff25407861 | |||
| 52bec1612c | |||
| 12082f47bd | |||
| b7f2b0d7b9 | |||
| 524004ff32 | |||
| 3de04bdd6d | |||
| fc49258c12 | |||
| 9873ef0e39 | |||
| 94041f06b4 | |||
| b497f3cda0 | |||
| 15776091a8 | |||
| a10d587b41 | |||
| 765182dcc6 | |||
| ee0dcaa7b0 | |||
| 3e2e9bc238 | |||
| 419824729a | |||
| 717ff0d667 | |||
| 0295271f97 | |||
| 2fe38b0201 | |||
| 55dc6a8bb2 | |||
| 2a40612058 | |||
| e1cd90db6e | |||
| 4140100807 | |||
| c7f021f70f | |||
| b9857a2b79 | |||
| 8b80690a1a | |||
| fac0a172e5 | |||
| 2566d6b300 | |||
| a322059efa | |||
| 69195f7e9d | |||
| 89b7fee352 | |||
| 1c82b06645 | |||
| e53809035e | |||
| b99b521a92 | |||
| f5408d82d2 | |||
| 258a214bcb | |||
| 5dec3dddc4 | |||
| 773427470a | |||
| b6e70a5cdd | |||
| abec3ed645 | |||
| 57e2223eec | |||
| 6c3e767289 | |||
| efafbece17 | |||
| 717ee2fa59 | |||
| db35f30005 | |||
| d2248534d8 | |||
| 7467f304a7 | |||
| e8e45a4936 | |||
| c22f3c514b | |||
| 149c4683a3 | |||
| 9ab226d275 | |||
| 731016472c | |||
| 247f82119c | |||
| 0bdb8ac7ad | |||
| 33d31e2b0d | |||
| bc8622c659 | |||
| 6f137fff76 | |||
| 793b36c5d2 | |||
| 1a815e323c | |||
| 09a4453026 | |||
| 2c3cf4f387 | |||
| 46d3617d25 | |||
| 10161c2d79 | |||
| 5a5c5d4cde | |||
| d43dda465d | |||
| 61dd61e917 | |||
| 94425764a8 | |||
| 30e80fb947 | |||
| 8a463e7aa9 | |||
| fe84148724 | |||
| 6e050808ef | |||
| e5d0d810e1 | |||
| 1c9f62fad3 | |||
| 23a4932997 | |||
| c00372e559 | |||
| 039e87c942 | |||
| 762fed1f90 | |||
| 2aaea9f99e | |||
| 03dc287a29 | |||
| 5eb6fdca6f | |||
| ef5e554def | |||
| fae4492d92 | |||
| 61d866838f | |||
| 3a4c860798 | |||
| 4d41b8664c | |||
| dc85235bf0 | |||
| 43058c021e | |||
| cb76ba2406 | |||
| ed9646516d | |||
| 410c2dba65 | |||
| 6c04ce3092 | |||
| b91374eb0d | |||
| f48571bec6 | |||
| 40f820ff7f | |||
| 93656da672 | |||
| f3eb620824 | |||
| 0c35ac4423 | |||
| 64432f8e46 | |||
| 3c46e0307a | |||
| 7a7e4cd4c4 | |||
| df58b4f5fb | |||
| 9c7823350b | |||
| fb04801ed7 | |||
| 2c1d16e261 | |||
| 6651511e90 | |||
| 57fd0a9b23 | |||
| 154e14f18f | |||
| 5799322d9e | |||
| 2069e124a9 | |||
| d10669629d | |||
| e1d16ba42e | |||
| 8d87e85705 | |||
| 1b5b23d2b1 | |||
| 475983a364 | |||
| ad818bda84 | |||
| 6eaff70b55 | |||
| 16d2e68610 | |||
| f7de5c3b83 | |||
| 83591fabfb | |||
| 3a1b517581 | |||
| e6db1dde45 | |||
| f6205de73a | |||
| 5cdb50abe6 | |||
| 56eeec4099 | |||
| 561acd1675 | |||
| 639706f298 | |||
| 3664c2ce46 | |||
| b9f48707dc | |||
| d4fda79ff7 | |||
| ca578a9183 | |||
| eaad4ad1be | |||
| 0709224ce3 | |||
| ac7ca52090 | |||
| b665749e9f | |||
| e48a0b80a8 | |||
| 33e9e485b8 | |||
| 1ba436b372 | |||
| 1a7914521b | |||
| c9f4dd3c1b | |||
| 63b0036248 | |||
| e10ea53ea1 | |||
| d21ecd7642 | |||
| 3ce09bd071 | |||
| 81be4b45a6 | |||
| dbb806d257 | |||
| 6f6468027a | |||
| 369119b6b5 | |||
| 1d7cb6fc03 | |||
| 907b5254f6 | |||
| 1fd684329d | |||
| a03bbca4df | |||
| b6031a98e7 | |||
| fee9d4cf37 | |||
| 2c5c5acb1b | |||
| c90ae1ee7f | |||
| b8a0258618 | |||
| 40ab7aca3d | |||
| d282667321 | |||
| 3dc139b0c0 | |||
| e28b516fb5 | |||
| 47dc7fe816 | |||
| c541cde0f6 | |||
| e24704d5eb | |||
| eb40f0b961 | |||
| ac8a5a614b | |||
| a18e156316 | |||
| 14e3c2de5f | |||
| e5173af77e | |||
| 3622569853 | |||
| d648aebf4d | |||
| 23a4ae4759 | |||
| 9f4f997472 | |||
| a4ccd75ff3 | |||
| 51e59983a1 | |||
| cf96fa67af | |||
| 69d6e95c2a | |||
| 3031f061fc | |||
| 68b36cd9de | |||
| bcd61f0a38 | |||
| ebe18c0379 | |||
| 0d2315ed15 | |||
| 6bf90a1d68 | |||
| eda1ef7b1a | |||
| ddf65a995a | |||
| e2acfcf527 | |||
| caa718a554 | |||
| e99c270684 | |||
| 7d6d112656 | |||
| f6a0cdc25a | |||
| aaf2d6359e | |||
| 7330e2ce23 | |||
| db0f957aba | |||
| c2fb7f1948 | |||
| 1beda4aff1 | |||
| 231d62582f | |||
| 4029ce738c | |||
| 698c02e775 | |||
| 87919dec2c | |||
| 805bff6e7e | |||
| 91b1e41132 | |||
| ec23552b58 | |||
| a4327ad544 | |||
| d60112287f | |||
| 870c52aac7 | |||
| 40315556d0 | |||
| 627ab895e2 | |||
| 7101ddc5d3 | |||
| 783cbd1e9d | |||
| a9da52da50 | |||
| f6b3377af2 | |||
| 2383107711 | |||
| a97188ceb3 | |||
| e4ce1d9a0e | |||
| 0cdd4db6e9 | |||
| 0caafa587f | |||
| d0002c5e1e | |||
| 3a4cc89c53 | |||
| 6bef8deda9 | |||
| f41bdf3c54 | |||
| 6451beddb2 | |||
| e16f0cf908 | |||
| 7fab2c2897 | |||
| 03ed0bccf1 | |||
| a395c757ab | |||
| 19093112ce | |||
| 44e27c6092 | |||
| 01d3442246 | |||
| fc60ced03c | |||
| f163759167 | |||
| 8633c7fa73 | |||
| 9acb4c8fbc | |||
| d25b4a2943 | |||
| 7daaefdb08 | |||
| 3b03ff11fc | |||
| 548c2019f1 | |||
| 6e9591c4ce | |||
| 217cb0ac58 | |||
| e7ae7d921a | |||
| 7ab46301a9 | |||
| 488ad4ac70 | |||
| 86de8b65b1 | |||
| a088109327 | |||
| fbe5f45340 | |||
| 240479abef | |||
| d58d90074f | |||
| 822563d1ab | |||
| 69a0a6c847 | |||
| 7b8142997f | |||
| d2e0cfc09f | |||
| a8bf75f03e | |||
| 435e2c5967 | |||
| a37ed72829 | |||
| f2475a7f70 | |||
| 398d58fb8a | |||
| a1c91bdb75 | |||
| f47549c5f6 | |||
| cc9d1103d9 | |||
| 6e20c26397 | |||
| 4518f6e820 | |||
| b11f4835e2 | |||
| 0d4b47a14e | |||
| f52752889b | |||
| 14f1b65c70 | |||
| 2990446b21 | |||
| 44d5e6d672 | |||
| 7eefddd0ed | |||
| ba95d43e3c | |||
| 8e9e2d2f4e | |||
| 27448c3113 | |||
| 9f47892bef | |||
| 129b1b5037 | |||
| bbe6f7fdd9 | |||
| 559b3a5fd4 | |||
| e727ad6898 | |||
| 72300e8fd0 | |||
| 700ec2f25d | |||
| 2f238b5d7d | |||
| a1cb302c20 | |||
| ada703a7b4 | |||
| 79ef86c305 | |||
| 49e3f2db06 | |||
| 27b92f8335 | |||
| 332d2ebfe8 | |||
| 5edba12f79 | |||
| f0761b4914 | |||
| 3e9ff16645 | |||
| 49ae71fa62 | |||
| 86921b624c | |||
| a29b9f2c20 | |||
| 1d4db9920d | |||
| 781295c14b | |||
| 66e954858b | |||
| aa91000a5d | |||
| 3f99a30163 | |||
| 0bda670d9a | |||
| d884676dd2 | |||
| 83bb647238 | |||
| db4572b459 | |||
| 88f49c27a0 | |||
| df2f900677 | |||
| 075ece3dac | |||
| 938f8f4d83 | |||
| 35de467b1a | |||
| 8754d8e330 | |||
| 91adc5e718 | |||
| dd11bdd003 | |||
| 807daf54fe | |||
| d7e48d4883 | |||
| f56a79f838 | |||
| e6e2407cee | |||
| b72d0c8459 | |||
| 0a04ef494d | |||
| ac07d8814a | |||
| c84c630b4c | |||
| 922f4e66ea | |||
| 60cd98a841 | |||
| b1b162fcdb | |||
| 43131dcc08 | |||
| e7817ad12a | |||
| 2833b27f52 | |||
| d41b92fff2 | |||
| b61a875d56 | |||
| cb58e45130 | |||
| a710366e9e | |||
| ecb3aa7fe0 | |||
| ff2e9a52ff | |||
| cc8ed8d25b | |||
| 5e9ea804d4 | |||
| 5dc42dfb17 | |||
| fd0fa97952 | |||
| c3744fbfc4 | |||
| a2d3b9f317 | |||
| ab8c834aab | |||
| 0fc27409c0 | |||
| 687ce31f88 | |||
| da10b6026a | |||
| 3689a82494 | |||
| 0f84aac487 | |||
| 0b54b64fe7 | |||
| 9082795b10 | |||
| 013385e5c2 | |||
| 535b792808 | |||
| ec7f564566 | |||
| 25d52b56a0 | |||
| ef6821a4ca | |||
| a8beb55b74 | |||
| cbb11b3662 | |||
| 489797ceaf | |||
| 3ae100a8d7 | |||
| 5c9e4cd30a | |||
| e8ba55adbb | |||
| 7464330a08 | |||
| 2e1c88b728 | |||
| cd90130877 | |||
| ea62655e19 | |||
| 9f863140d5 | |||
| 9d21be4193 | |||
| da5b7ff0af | |||
| be20eebc21 | |||
| 5ab3782215 | |||
| 67c7f98c32 | |||
| d8aada9d45 | |||
| 3359dcfdcf | |||
| 6e970010f7 | |||
| 9f5d286caf | |||
| 8c89d0e7cd | |||
| e864421d83 | |||
| e28e520379 | |||
| 1bfef17825 | |||
| 26365f7daf | |||
| 0857447a5d | |||
| 69a317995d | |||
| 3a36fffc15 | |||
| dc4d2ca263 | |||
| ce49d8bca9 | |||
| 50d996a6ec | |||
| 6142fc1d94 | |||
| 483dc90f05 | |||
| dd2361a4c4 | |||
| 17c1ee7716 |
@@ -23,10 +23,15 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
|
||||
- Preferred entrypoint: `pnpm test:parallels:npm-update`
|
||||
- Flow: fresh snapshot -> install npm package baseline -> smoke -> install current main tgz on the same guest -> smoke again.
|
||||
- Same-guest update verification should set the default model explicitly to `openai/gpt-5.4` before the agent turn and use a fresh explicit `--session-id` so old session model state does not leak into the check.
|
||||
- Keep the aggregate npm-update Linux VM name aligned with the default Linux smoke VM (`Ubuntu 24.04.3 ARM64` on Peter's host today). Do not hardcode a different Linux guest in the wrapper unless the per-OS Linux smoke default changed too.
|
||||
- The aggregate npm-update wrapper must resolve the Linux VM with the same Ubuntu fallback policy as `parallels-linux-smoke.sh` before both fresh and update lanes. On Peter's current host, missing `Ubuntu 24.04.3 ARM64` should fall back to `Ubuntu 25.10`.
|
||||
- On Windows same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; in-place global npm updates can otherwise leave stale hashed `dist/*` module imports alive in the running service.
|
||||
- For Windows same-guest update checks, prefer the done-file/log-drain PowerShell runner pattern over one long-lived `prlctl exec ... powershell -EncodedCommand ...` transport. The guest can finish successfully while the outer `prlctl exec` still hangs.
|
||||
- Linux same-guest update verification should also export `HOME=/root`, pass `OPENAI_API_KEY` via `prlctl exec ... /usr/bin/env`, and use `openclaw agent --local`; the fresh Linux baseline does not rely on persisted gateway credentials.
|
||||
|
||||
## CLI invocation footgun
|
||||
|
||||
- The Parallels smoke shell scripts should tolerate a literal bare `--` arg so `pnpm test:parallels:* -- --json` and similar forwarded invocations work without needing to call `bash scripts/e2e/...` directly.
|
||||
|
||||
## macOS flow
|
||||
|
||||
- Preferred entrypoint: `pnpm test:parallels:macos`
|
||||
|
||||
@@ -72,9 +72,20 @@ pnpm test:install:smoke
|
||||
For a non-root smoke path:
|
||||
|
||||
```bash
|
||||
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
|
||||
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
|
||||
```
|
||||
|
||||
After npm publish, run:
|
||||
|
||||
```bash
|
||||
node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
```
|
||||
|
||||
- This verifies the published registry install path in a fresh temp prefix.
|
||||
- For stable correction releases like `YYYY.M.D-N`, it also verifies the
|
||||
upgrade path from `YYYY.M.D` to `YYYY.M.D-N` so a correction publish cannot
|
||||
silently leave existing global installs on the old base stable payload.
|
||||
|
||||
## Check all relevant release builds
|
||||
|
||||
- Always validate the OpenClaw npm release path before creating the tag.
|
||||
@@ -138,9 +149,10 @@ OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
|
||||
`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`.
|
||||
- Real private mac publish uploads the packaged `.zip`, `.dmg`, and
|
||||
`.dSYM.zip` assets to the existing GitHub release in `openclaw/openclaw`
|
||||
automatically when `OPENCLAW_PUBLIC_REPO_RELEASE_TOKEN` is present in the
|
||||
private repo `mac-release` environment.
|
||||
- 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`.
|
||||
@@ -210,9 +222,9 @@ OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
|
||||
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`.
|
||||
16. Verify the successful real private mac run uploaded 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
|
||||
|
||||
@@ -23,6 +23,16 @@ runs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! [[ "$BASE_SHA" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
|
||||
echo "::error title=ensure-base-commit invalid base sha::Refusing invalid base SHA: $BASE_SHA"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! git check-ref-format --branch "$FETCH_REF" >/dev/null 2>&1; then
|
||||
echo "::error title=ensure-base-commit invalid fetch ref::Refusing invalid fetch ref: $FETCH_REF"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
|
||||
echo "Base commit already present: $BASE_SHA"
|
||||
exit 0
|
||||
@@ -30,7 +40,7 @@ runs:
|
||||
|
||||
for deepen_by in 25 100 300; do
|
||||
echo "Base commit missing; deepening $FETCH_REF by $deepen_by."
|
||||
if ! git fetch --no-tags --deepen="$deepen_by" origin "$FETCH_REF"; then
|
||||
if ! git fetch --no-tags --deepen="$deepen_by" origin -- "$FETCH_REF"; then
|
||||
echo "::warning title=ensure-base-commit fetch failed::Failed to deepen $FETCH_REF by $deepen_by while looking for $BASE_SHA"
|
||||
fi
|
||||
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
|
||||
@@ -40,7 +50,7 @@ runs:
|
||||
done
|
||||
|
||||
echo "Base commit still missing; fetching full history for $FETCH_REF."
|
||||
if ! git fetch --no-tags origin "$FETCH_REF"; then
|
||||
if ! git fetch --no-tags origin -- "$FETCH_REF"; then
|
||||
echo "::warning title=ensure-base-commit fetch failed::Failed to fetch full history for $FETCH_REF while looking for $BASE_SHA"
|
||||
fi
|
||||
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
|
||||
|
||||
@@ -11,6 +11,10 @@ on:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -5,8 +5,8 @@ on:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-bun-push-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
group: ci-bun-push-${{ github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
+156
-77
@@ -6,22 +6,24 @@ on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'pull_request' && format('ci-pr-{0}', github.event.pull_request.number) || format('ci-push-{0}', github.ref_name) }}
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
# Preflight: establish the fast global truth for this revision before the
|
||||
# expensive platform and test lanes fan out.
|
||||
# Scope: establish the fast global truth for this revision before the
|
||||
# expensive platform and platform-specific lanes fan out.
|
||||
# Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
|
||||
# Run scope detection, changed-extension detection, and fast security checks in
|
||||
# one visible job so operators have a single preflight box to inspect and rerun.
|
||||
# Keep this job focused on routing decisions so the rest of CI can fan out sooner.
|
||||
# Fail-safe: if detection steps are skipped, downstream outputs fall back to
|
||||
# conservative defaults that keep heavy lanes enabled.
|
||||
preflight:
|
||||
scope:
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
@@ -41,6 +43,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Ensure preflight base commit
|
||||
@@ -101,6 +104,46 @@ jobs:
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8");
|
||||
EOF
|
||||
|
||||
# Run the fast security/SCM checks in parallel with scope detection so the
|
||||
# main Node jobs do not have to wait for Python/pre-commit setup.
|
||||
security-fast:
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
PRE_COMMIT_CACHE_KEY_SUFFIX: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Ensure security base commit
|
||||
uses: ./.github/actions/ensure-base-commit
|
||||
with:
|
||||
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
|
||||
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
|
||||
|
||||
- name: Prepare trusted pre-commit config
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
trusted_config="$RUNNER_TEMP/pre-commit-base.yaml"
|
||||
git show "${BASE_SHA}:.pre-commit-config.yaml" > "$trusted_config"
|
||||
echo "PRE_COMMIT_CONFIG_PATH=$trusted_config" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
install-deps: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Setup Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v6
|
||||
@@ -116,15 +159,17 @@ jobs:
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
key: pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
key: pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}-${{ env.PRE_COMMIT_CACHE_KEY_SUFFIX }}
|
||||
restore-keys: |
|
||||
pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}-
|
||||
|
||||
- name: Install pre-commit
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install pre-commit
|
||||
python -m pip install pre-commit==4.2.0
|
||||
|
||||
- name: Detect committed private keys
|
||||
run: pre-commit run --all-files detect-private-key
|
||||
run: pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" --all-files detect-private-key
|
||||
|
||||
- name: Audit changed GitHub workflows with zizmor
|
||||
env:
|
||||
@@ -151,24 +196,24 @@ jobs:
|
||||
fi
|
||||
|
||||
printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}"
|
||||
pre-commit run zizmor --files "${workflow_files[@]}"
|
||||
pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
|
||||
|
||||
- name: Audit production dependencies
|
||||
if: steps.docs_scope.outputs.docs_only != 'true'
|
||||
run: pre-commit run --all-files pnpm-audit-prod
|
||||
run: pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" --all-files pnpm-audit-prod
|
||||
|
||||
# Fanout: downstream lanes branch from preflight outputs instead of waiting
|
||||
# on unrelated Linux checks.
|
||||
# Build dist once for Node-relevant changes and share it with downstream jobs.
|
||||
build-artifacts:
|
||||
needs: [preflight]
|
||||
if: github.event_name == 'push' && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true'
|
||||
needs: [scope]
|
||||
if: needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Ensure secrets base commit (PR fast path)
|
||||
@@ -207,14 +252,15 @@ jobs:
|
||||
|
||||
# Validate npm pack contents after build (only on push to main, not PRs).
|
||||
release-check:
|
||||
needs: [preflight, build-artifacts]
|
||||
if: github.event_name == 'push' && needs.preflight.outputs.docs_only != 'true'
|
||||
needs: [scope, build-artifacts]
|
||||
if: github.event_name == 'push' && needs.scope.outputs.docs_only != 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -232,9 +278,42 @@ jobs:
|
||||
- name: Check release contents
|
||||
run: pnpm release:check
|
||||
|
||||
checks-fast:
|
||||
needs: [scope]
|
||||
if: always() && needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runtime: node
|
||||
task: extensions
|
||||
command: pnpm test:extensions
|
||||
- runtime: node
|
||||
task: contracts-protocol
|
||||
command: |
|
||||
pnpm test:contracts
|
||||
pnpm protocol:check
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
checks:
|
||||
needs: [preflight, build-artifacts]
|
||||
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
|
||||
needs: [scope, build-artifacts]
|
||||
if: always() && needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_node == 'true' && needs.build-artifacts.result == 'success'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
@@ -244,24 +323,28 @@ jobs:
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 1
|
||||
shard_count: 2
|
||||
shard_count: 4
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 2
|
||||
shard_count: 2
|
||||
shard_count: 4
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: extensions
|
||||
command: pnpm test:extensions
|
||||
task: test
|
||||
shard_index: 3
|
||||
shard_count: 4
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 4
|
||||
shard_count: 4
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: channels
|
||||
shard_index: 1
|
||||
shard_count: 3
|
||||
command: pnpm test:channels
|
||||
- runtime: node
|
||||
task: contracts
|
||||
command: pnpm test:contracts
|
||||
- runtime: node
|
||||
task: channels
|
||||
shard_index: 2
|
||||
@@ -272,9 +355,6 @@ jobs:
|
||||
shard_index: 3
|
||||
shard_count: 3
|
||||
command: pnpm test:channels
|
||||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
- runtime: node
|
||||
task: compat-node22
|
||||
node_version: "22.x"
|
||||
@@ -296,6 +376,7 @@ jobs:
|
||||
if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -327,44 +408,37 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Download dist artifact
|
||||
if: github.event_name == 'push' && matrix.task == 'test'
|
||||
if: matrix.task == 'test'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
|
||||
- name: Download A2UI bundle artifact
|
||||
if: github.event_name == 'push' && (matrix.task == 'test' || matrix.task == 'channels')
|
||||
if: matrix.task == 'test' || matrix.task == 'channels'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: canvas-a2ui-bundle
|
||||
path: src/canvas-host/a2ui/
|
||||
|
||||
- name: Build A2UI bundle
|
||||
if: github.event_name != 'push' && (matrix.task == 'test' || matrix.task == 'channels')
|
||||
run: pnpm canvas:a2ui:bundle
|
||||
|
||||
- name: Build dist
|
||||
if: github.event_name != 'push' && matrix.task == 'test' && matrix.runtime == 'node'
|
||||
run: pnpm build
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
extension-fast:
|
||||
name: "extension-fast"
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && needs.preflight.outputs.has_changed_extensions == 'true'
|
||||
needs: [scope]
|
||||
if: needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_node == 'true' && needs.scope.outputs.has_changed_extensions == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.changed_extensions_matrix) }}
|
||||
matrix: ${{ fromJson(needs.scope.outputs.changed_extensions_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -381,14 +455,15 @@ jobs:
|
||||
# Types, lint, and format check.
|
||||
check:
|
||||
name: "check"
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true'
|
||||
needs: [scope]
|
||||
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && needs.scope.outputs.docs_only != 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -405,14 +480,15 @@ jobs:
|
||||
|
||||
check-additional:
|
||||
name: "check-additional"
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true'
|
||||
needs: [scope]
|
||||
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && needs.scope.outputs.docs_only != 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -503,14 +579,15 @@ jobs:
|
||||
|
||||
build-smoke:
|
||||
name: "build-smoke"
|
||||
needs: [preflight, build-artifacts]
|
||||
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
|
||||
needs: [scope, build-artifacts]
|
||||
if: always() && needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_node == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -544,14 +621,15 @@ jobs:
|
||||
|
||||
# Validate docs (format, lint, broken links) only when docs files changed.
|
||||
check-docs:
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.docs_changed == 'true'
|
||||
needs: [scope]
|
||||
if: needs.scope.outputs.docs_changed == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -564,14 +642,15 @@ jobs:
|
||||
run: pnpm check:docs
|
||||
|
||||
skills-python:
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.preflight.outputs.run_skills_python == 'true')
|
||||
needs: [scope]
|
||||
if: needs.scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.scope.outputs.run_skills_python == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Python
|
||||
@@ -591,8 +670,8 @@ jobs:
|
||||
run: python -m pytest -q skills
|
||||
|
||||
checks-windows:
|
||||
needs: [preflight, build-artifacts]
|
||||
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_windows == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
|
||||
needs: [scope, build-artifacts]
|
||||
if: always() && needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_windows == 'true' && needs.build-artifacts.result == 'success'
|
||||
runs-on: blacksmith-32vcpu-windows-2025
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
@@ -610,47 +689,53 @@ jobs:
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 1
|
||||
shard_count: 8
|
||||
shard_count: 9
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 2
|
||||
shard_count: 8
|
||||
shard_count: 9
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 3
|
||||
shard_count: 8
|
||||
shard_count: 9
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 4
|
||||
shard_count: 8
|
||||
shard_count: 9
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 5
|
||||
shard_count: 8
|
||||
shard_count: 9
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 6
|
||||
shard_count: 8
|
||||
shard_count: 9
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 7
|
||||
shard_count: 8
|
||||
shard_count: 9
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 8
|
||||
shard_count: 8
|
||||
shard_count: 9
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 9
|
||||
shard_count: 9
|
||||
command: pnpm test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Try to exclude workspace from Windows Defender (best-effort)
|
||||
@@ -719,27 +804,19 @@ jobs:
|
||||
echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download dist artifact
|
||||
if: github.event_name == 'push' && matrix.task == 'test'
|
||||
if: matrix.task == 'test'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
|
||||
- name: Download A2UI bundle artifact
|
||||
if: github.event_name == 'push' && matrix.task == 'test'
|
||||
if: matrix.task == 'test'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: canvas-a2ui-bundle
|
||||
path: src/canvas-host/a2ui/
|
||||
|
||||
- name: Build A2UI bundle (Windows)
|
||||
if: github.event_name != 'push' && matrix.task == 'test'
|
||||
run: pnpm canvas:a2ui:bundle
|
||||
|
||||
- name: Build dist (Windows)
|
||||
if: github.event_name != 'push' && matrix.task == 'test'
|
||||
run: pnpm build
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
@@ -748,14 +825,15 @@ jobs:
|
||||
# running 4 separate jobs per PR (as before) starved the queue. One job
|
||||
# per PR allows 5 PRs to run macOS checks simultaneously.
|
||||
macos:
|
||||
needs: [preflight]
|
||||
if: github.event_name == 'pull_request' && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_macos == 'true'
|
||||
needs: [scope]
|
||||
if: github.event_name == 'pull_request' && needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_macos == 'true'
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -825,8 +903,8 @@ jobs:
|
||||
exit 1
|
||||
|
||||
android:
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_android == 'true'
|
||||
needs: [scope]
|
||||
if: needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_android == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
@@ -845,6 +923,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Java
|
||||
@@ -874,7 +953,7 @@ jobs:
|
||||
echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5
|
||||
with:
|
||||
gradle-version: 8.11.1
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ on:
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: docker-release-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
|
||||
group: ${{ github.event_name == 'workflow_dispatch' && format('docker-release-manual-{0}', inputs.tag) || format('docker-release-push-{0}', github.run_id) }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'pull_request' && format('install-smoke-pr-{0}', github.event.pull_request.number) || format('install-smoke-push-{0}', github.run_id) }}
|
||||
group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
|
||||
@@ -19,6 +19,10 @@ on:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -81,6 +81,6 @@ jobs:
|
||||
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 the real publish path, the private run uploads the packaged \`.zip\`, \`.dmg\`, and \`.dSYM.zip\` files to the existing GitHub release in \`openclaw/openclaw\` automatically."
|
||||
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"
|
||||
|
||||
@@ -15,7 +15,7 @@ on:
|
||||
- scripts/sandbox-common-setup.sh
|
||||
|
||||
concurrency:
|
||||
group: sandbox-common-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
|
||||
+136
@@ -8,8 +8,138 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- MiniMax: add image generation provider for `image-01` model, supporting generate and image-to-image editing with aspect ratio control. (#54487) Thanks @liyuan97.
|
||||
- MiniMax: trim model catalog to M2.7 only, removing legacy M2, M2.1, M2.5, and VL-01 models. (#54487) Thanks @liyuan97.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents/sandbox: honor `tools.sandbox.tools.alsoAllow`, let explicit sandbox re-allows remove matching built-in default-deny tools, and keep sandbox explain/error guidance aligned with the effective sandbox tool policy. (#54492) Thanks @ngutman.
|
||||
- Feishu: close WebSocket connections on monitor stop/abort so ghost connections no longer persist, preventing duplicate event processing and resource leaks across restart cycles. (#52844) Thanks @schumilin.
|
||||
- Feishu: use the original message `create_time` instead of `Date.now()` for inbound timestamps so offline-retried messages carry the correct authoring time, preventing mis-targeted agent actions on stale instructions. (#52809) Thanks @schumilin.
|
||||
- Plugins/SDK: thread `moduleUrl` through plugin-sdk alias resolution so user-installed plugins outside the openclaw directory (e.g. `~/.openclaw/extensions/`) correctly resolve `openclaw/plugin-sdk/*` subpath imports, and gate `plugin-sdk:check-exports` in `release:check`. (#54283) Thanks @xieyongliang.
|
||||
|
||||
## 2026.3.24
|
||||
|
||||
### Breaking
|
||||
|
||||
### Changes
|
||||
|
||||
- Gateway/OpenAI compatibility: add `/v1/models` and `/v1/embeddings`, and forward explicit model overrides through `/v1/chat/completions` and `/v1/responses` for broader client and RAG compatibility. Thanks @vincentkoc.
|
||||
- Agents/tools: make `/tools` show the tools the current agent can actually use right now, add a compact default view with an optional detailed mode, and add a live "Available Right Now" section in the Control UI so it is easier to see what will work before you ask.
|
||||
- Microsoft Teams: migrate to the official Teams SDK and add AI-agent UX best practices including streaming 1:1 replies, welcome cards with prompt starters, feedback/reflection, informative status updates, typing indicators, and native AI labeling. (#51808)
|
||||
- Microsoft Teams: add message edit and delete support for sent messages, including in-thread fallbacks when no explicit target is provided. (#49925)
|
||||
- Skills/install metadata: add one-click install recipes to bundled skills (coding-agent, gh-issues, openai-whisper-api, session-logs, tmux, trello, weather) so the CLI and Control UI can offer dependency installation when requirements are missing. (#53411) Thanks @BunsDev.
|
||||
- Control UI/skills: add status-filter tabs (All / Ready / Needs Setup / Disabled) with counts, replace inline skill cards with a click-to-detail dialog showing requirements, toggle switch, install action, API key entry, source metadata, and homepage link. (#53411) Thanks @BunsDev.
|
||||
- Slack/interactive replies: restore rich reply parity for direct deliveries, auto-render simple trailing `Options:` lines as buttons/selects, improve Slack interactive setup defaults, and isolate reply controls from plugin interactive handlers. (#53389) Thanks @vincentkoc.
|
||||
- CLI/containers: add `--container` and `OPENCLAW_CONTAINER` to run `openclaw` commands inside a running Docker or Podman OpenClaw container. (#52651) Thanks @sallyom.
|
||||
- Discord/auto threads: add optional `autoThreadName: "generated"` naming so new auto-created threads can be renamed asynchronously with concise LLM-generated titles while keeping the existing message-based naming as the default. (#43366) Thanks @davidguttman.
|
||||
- Plugins/hooks: add `before_dispatch` with canonical inbound metadata and route handled replies through the normal final-delivery path, preserving TTS and routed delivery semantics. (#50444) Thanks @gfzhx.
|
||||
- Control UI/agents: convert agent workspace file rows to expandable `<details>` with lazy-loaded inline markdown preview, and add comprehensive `.sidebar-markdown` styles for headings, lists, code blocks, tables, blockquotes, and details/summary elements. (#53411) Thanks @BunsDev.
|
||||
- Control UI/markdown preview: restyle the agent workspace file preview dialog with a frosted backdrop, sized panel, and styled header, and integrate `@create-markdown/preview` v2 system theme for rich markdown rendering (headings, tables, code blocks, callouts, blockquotes) that auto-adapts to the app's light/dark design tokens. (#53411) Thanks @BunsDev.
|
||||
- macOS app/config: replace horizontal pill-based subsection navigation with a collapsible tree sidebar using disclosure chevrons and indented subsection rows. (#53411) Thanks @BunsDev.
|
||||
- CLI/skills: soften missing-requirements label from "missing" to "needs setup" and surface API key setup guidance (where to get a key, CLI save command, storage path) in `openclaw skills info` output. (#53411) Thanks @BunsDev.
|
||||
- macOS app/skills: add "Get your key" homepage link and storage-path hint to the API key editor dialog, and show the config path in save confirmation messages. (#53411) Thanks @BunsDev.
|
||||
- Control UI/agents: add a "Not set" placeholder to the default agent model selector dropdown. (#53411) Thanks @BunsDev.
|
||||
- Runtime/install: lower the supported Node 22 floor to `22.14+` while continuing to recommend Node 24, so npm installs and self-updates do not strand Node 22.14 users on older releases.
|
||||
- CLI/update: preflight the target npm package `engines.node` before `openclaw update` runs a global package install, so outdated Node runtimes fail with a clear upgrade message instead of attempting an unsupported latest release.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Outbound media/local files: align outbound media access with the configured fs policy so host-local files and inbound-media paths keep sending when `workspaceOnly` is off, while strict workspace-only agents remain sandboxed.
|
||||
- Security/sandbox media dispatch: close the `mediaUrl`/`fileUrl` alias bypass so outbound tool and message actions cannot escape media-root restrictions. (#54034)
|
||||
- Gateway/restart sentinel: wake the interrupted agent session via heartbeat after restart instead of only sending a best-effort restart note, retry outbound delivery once on transient failure, and preserve explicit thread/topic routing through the wake path so replies land in the correct Telegram topic or Slack thread. (#53940) Thanks @VACInc.
|
||||
- Docker/setup: avoid the pre-start `openclaw-cli` shared-network namespace loop by routing setup-time onboard/config writes through `openclaw-gateway`, so fresh Docker installs stop failing before the gateway comes up. (#53385) Thanks @amsminn.
|
||||
- Gateway/channels: keep channel startup sequential while isolating per-channel boot failures, so one broken channel no longer blocks later channels from starting. (#54215) Thanks @JonathanJing.
|
||||
- Embedded runs/secrets: stop unresolved `SecretRef` config from crashing embedded agent runs by falling back to the resolved runtime snapshot when needed. Fixes #45838.
|
||||
- WhatsApp/groups: track recent gateway-sent message IDs and suppress only matching group echoes, preserving owner `/status`, `/new`, and `/activation` commands from linked-account `fromMe` traffic. (#53624) Thanks @w-sss.
|
||||
- WhatsApp/reply-to-bot detection: restore implicit group reply detection by unwrapping `botInvokeMessage` payloads and reading `selfLid` from `creds.json`, so reply-based mentions reach the bot again in linked-account group chats.
|
||||
- Telegram/forum topics: recover `#General` topic `1` routing when Telegram omits forum metadata, including native commands, interactive callbacks, inbound message context, and fallback error replies. (#53699) thanks @huntharo
|
||||
- Discord/gateway supervision: centralize gateway error handling behind a lifetime-owned supervisor so early, active, and late-teardown Carbon gateway errors stay classified consistently and stop surfacing as process-killing teardown crashes.
|
||||
- Discord/timeouts: send a visible timeout reply when the inbound Discord worker times out before a final reply starts, including created auto-thread targets and queued-run ordering. (#53823) Thanks @Kimbo7870.
|
||||
- ACP/direct chats: always deliver a terminal ACP result when final TTS does not yield audio, even if block text already streamed earlier, and skip redundant empty-text final synthesis. (#53692) Thanks @w-sss.
|
||||
- Telegram/outbound errors: preserve actionable 403 membership/block/kick details and treat `bot not a member` as a permanent delivery failure so Telegram sends stop retrying doomed chats. (#53635) Thanks @w-sss.
|
||||
- Telegram/photos: preflight Telegram photo dimension and aspect-ratio rules, and fall back to document sends when image metadata is invalid or unavailable so photo uploads stop failing with `PHOTO_INVALID_DIMENSIONS`. (#52545) Thanks @hnshah.
|
||||
- Slack/runtime defaults: trim Slack DM reply overhead, restore Codex auto transport, and tighten Slack/web-search runtime defaults around DM preview threading, cache scoping, warning dedupe, and explicit web-search opt-in. (#53957) Thanks @vincentkoc.
|
||||
|
||||
## 2026.3.24-beta.2
|
||||
|
||||
### Breaking
|
||||
|
||||
### Changes
|
||||
|
||||
### Fixes
|
||||
|
||||
- Outbound media/local files: align outbound media access with the configured fs policy so host-local files and inbound-media paths keep sending when `workspaceOnly` is off, while strict workspace-only agents remain sandboxed.
|
||||
- Runtime/install: lower the supported Node 22 floor to `22.14+` while continuing to recommend Node 24, so npm installs and self-updates do not strand Node 22.14 users on older releases.
|
||||
- CLI/update: preflight the target npm package `engines.node` before `openclaw update` runs a global package install, so outdated Node runtimes fail with a clear upgrade message instead of attempting an unsupported latest release.
|
||||
- Tests/security audit: isolate audit-test home and personal skill resolution so local `~/.agents/skills` installs no longer make maintainer prep runs fail nondeterministically. (#54473) thanks @huntharo
|
||||
|
||||
## 2026.3.24-beta.1
|
||||
|
||||
### Breaking
|
||||
|
||||
### Changes
|
||||
|
||||
- Gateway/OpenAI compatibility: add `/v1/models` and `/v1/embeddings`, and forward explicit model overrides through `/v1/chat/completions` and `/v1/responses` for broader client and RAG compatibility. Thanks @vincentkoc.
|
||||
- Agents/tools: make `/tools` show the tools the current agent can actually use right now, add a compact default view with an optional detailed mode, and add a live “Available Right Now” section in the Control UI so it is easier to see what will work before you ask.
|
||||
- Microsoft Teams: migrate to the official Teams SDK and add AI-agent UX best practices including streaming 1:1 replies, welcome cards with prompt starters, feedback/reflection, informative status updates, typing indicators, and native AI labeling. (#51808)
|
||||
- Microsoft Teams: add message edit and delete support for sent messages, including in-thread fallbacks when no explicit target is provided. (#49925)
|
||||
- Skills/install metadata: add one-click install recipes to bundled skills (coding-agent, gh-issues, openai-whisper-api, session-logs, tmux, trello, weather) so the CLI and Control UI can offer dependency installation when requirements are missing. (#53411) Thanks @BunsDev.
|
||||
- Control UI/skills: add status-filter tabs (All / Ready / Needs Setup / Disabled) with counts, replace inline skill cards with a click-to-detail dialog showing requirements, toggle switch, install action, API key entry, source metadata, and homepage link. (#53411) Thanks @BunsDev.
|
||||
- Slack/interactive replies: restore rich reply parity for direct deliveries, auto-render simple trailing `Options:` lines as buttons/selects, improve Slack interactive setup defaults, and isolate reply controls from plugin interactive handlers. (#53389) Thanks @vincentkoc.
|
||||
- CLI/containers: add `--container` and `OPENCLAW_CONTAINER` to run `openclaw` commands inside a running Docker or Podman OpenClaw container. (#52651) Thanks @sallyom.
|
||||
- Discord/auto threads: add optional `autoThreadName: "generated"` naming so new auto-created threads can be renamed asynchronously with concise LLM-generated titles while keeping the existing message-based naming as the default. (#43366) Thanks @davidguttman.
|
||||
- Plugins/hooks: add `before_dispatch` with canonical inbound metadata and route handled replies through the normal final-delivery path, preserving TTS and routed delivery semantics. (#50444) Thanks @gfzhx.
|
||||
- Control UI/agents: convert agent workspace file rows to expandable `<details>` with lazy-loaded inline markdown preview, and add comprehensive `.sidebar-markdown` styles for headings, lists, code blocks, tables, blockquotes, and details/summary elements. (#53411) Thanks @BunsDev.
|
||||
- Control UI/markdown preview: restyle the agent workspace file preview dialog with a frosted backdrop, sized panel, and styled header, and integrate `@create-markdown/preview` v2 system theme for rich markdown rendering (headings, tables, code blocks, callouts, blockquotes) that auto-adapts to the app's light/dark design tokens. (#53411) Thanks @BunsDev.
|
||||
- macOS app/config: replace horizontal pill-based subsection navigation with a collapsible tree sidebar using disclosure chevrons and indented subsection rows. (#53411) Thanks @BunsDev.
|
||||
- CLI/skills: soften missing-requirements label from "missing" to "needs setup" and surface API key setup guidance (where to get a key, CLI save command, storage path) in `openclaw skills info` output. (#53411) Thanks @BunsDev.
|
||||
- macOS app/skills: add "Get your key" homepage link and storage-path hint to the API key editor dialog, and show the config path in save confirmation messages. (#53411) Thanks @BunsDev.
|
||||
- Control UI/agents: add a "Not set" placeholder to the default agent model selector dropdown. (#53411) Thanks @BunsDev.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security/sandbox media dispatch: close the `mediaUrl`/`fileUrl` alias bypass so outbound tool and message actions cannot escape media-root restrictions. (#54034)
|
||||
- Gateway/restart sentinel: wake the interrupted agent session via heartbeat after restart instead of only sending a best-effort restart note, retry outbound delivery once on transient failure, and preserve explicit thread/topic routing through the wake path so replies land in the correct Telegram topic or Slack thread. (#53940) Thanks @VACInc.
|
||||
- Docker/setup: avoid the pre-start `openclaw-cli` shared-network namespace loop by routing setup-time onboard/config writes through `openclaw-gateway`, so fresh Docker installs stop failing before the gateway comes up. (#53385) Thanks @amsminn.
|
||||
- Gateway/channels: keep channel startup sequential while isolating per-channel boot failures, so one broken channel no longer blocks later channels from starting. (#54215) Thanks @JonathanJing.
|
||||
- Embedded runs/secrets: stop unresolved `SecretRef` config from crashing embedded agent runs by falling back to the resolved runtime snapshot when needed. Fixes #45838.
|
||||
- WhatsApp/groups: track recent gateway-sent message IDs and suppress only matching group echoes, preserving owner `/status`, `/new`, and `/activation` commands from linked-account `fromMe` traffic. (#53624) Thanks @w-sss.
|
||||
- WhatsApp/reply-to-bot detection: restore implicit group reply detection by unwrapping `botInvokeMessage` payloads and reading `selfLid` from `creds.json`, so reply-based mentions reach the bot again in linked-account group chats.
|
||||
- Telegram/forum topics: recover `#General` topic `1` routing when Telegram omits forum metadata, including native commands, interactive callbacks, inbound message context, and fallback error replies. (#53699) thanks @huntharo
|
||||
- Discord/gateway supervision: centralize gateway error handling behind a lifetime-owned supervisor so early, active, and late-teardown Carbon gateway errors stay classified consistently and stop surfacing as process-killing teardown crashes.
|
||||
- Discord/timeouts: send a visible timeout reply when the inbound Discord worker times out before a final reply starts, including created auto-thread targets and queued-run ordering. (#53823) Thanks @Kimbo7870.
|
||||
- ACP/direct chats: always deliver a terminal ACP result when final TTS does not yield audio, even if block text already streamed earlier, and skip redundant empty-text final synthesis. (#53692) Thanks @w-sss.
|
||||
- Telegram/outbound errors: preserve actionable 403 membership/block/kick details and treat `bot not a member` as a permanent delivery failure so Telegram sends stop retrying doomed chats. (#53635) Thanks @w-sss.
|
||||
- Telegram/photos: preflight Telegram photo dimension and aspect-ratio rules, and fall back to document sends when image metadata is invalid or unavailable so photo uploads stop failing with `PHOTO_INVALID_DIMENSIONS`. (#52545) Thanks @hnshah.
|
||||
- Slack/runtime defaults: trim Slack DM reply overhead, restore Codex auto transport, and tighten Slack/web-search runtime defaults around DM preview threading, cache scoping, warning dedupe, and explicit web-search opt-in. (#53957) Thanks @vincentkoc.
|
||||
- Doctor/image generation: seed migrated legacy Nano Banana Google provider config with the `/v1beta` API root and an empty model list so `openclaw doctor --fix` completes and the migrated native Google image path keeps hitting the correct endpoint. (#53757) Thanks @mahopan.
|
||||
- Models/google: normalize bare Google Generative AI API roots for custom provider names, and keep built-in Google model-id rewrites working when `api` is declared only on individual models, so custom Google lanes and older configs stop missing `/v1beta` or preview-id normalization. (#44969) Thanks @Kathie-yu.
|
||||
- Feishu/startup: treat unresolved `SecretRef` app credentials as not configured during account resolution so CLI startup and read-only Feishu config surfaces stop crashing before runtime-backed secret resolution is available. (#53675) Thanks @hpt.
|
||||
- Feishu/groups: when `groupPolicy` is `open`, stop implicitly requiring @mentions for unset `requireMention`, so image, file, audio, and other non-text group messages reach the bot unless operators explicitly keep mention gating on. (#54058) Thanks @byungsker.
|
||||
- Feishu/startup: keep `requireMention` enforcement strict when bot identity startup probes fail, raise the startup bot-info timeout to 30s, and add cancellable background identity recovery so mention-gated groups recover without noisy fallback. (#43788) Thanks @lefarcen.
|
||||
- Feishu/MSTeams message tool: keep provider-native `card` payloads optional in merged tool schemas so media-only sends stop failing validation before channel runtime dispatch. (#53715) Thanks @lndyzwdxhs.
|
||||
- Feishu/docx block ordering: preserve the document tree order from `docx.document.convert` when inserting blocks, fixing heading/paragraph/list misordering in newly written Feishu documents. (#40524) Thanks @TaoXieSZ.
|
||||
- Telegram/native commands: run native slash-command execution against the resolved runtime snapshot so DM commands still reply when fresh config reads surface unresolved SecretRefs. (#53179) Thanks @nimbleenigma.
|
||||
- Gateway/ports: parse Docker Compose-style `OPENCLAW_GATEWAY_PORT` host publish values correctly without reviving the legacy `CLAWDBOT_GATEWAY_PORT` override. (#44083) Thanks @bebule.
|
||||
- Plugins/memory-lancedb: bootstrap the env-configured HTTP/HTTPS proxy dispatcher before OpenAI embeddings requests so memory capture and recall work in proxy-required environments again. (#54119) Thanks @neeravmakwana.
|
||||
- Runtime/build: stabilize long-lived lazy `dist` runtime entry paths and harden bundled plugin npm staging so local rebuilds stop breaking on missing hashed chunks or broken shell `npm` shims. (#53855) Thanks @vincentkoc.
|
||||
- Security/skills: validate skill installer metadata against strict regex allowlists per package manager, sanitize skill metadata for terminal output, add URL protocol allowlisting in markdown preview and skill homepage links, warn on non-bundled skill install sources, and remove unsafe `file://` workspace links. (#53471) Thanks @BunsDev.
|
||||
- Memory/builtin sqlite: cut redundant sync and status query churn by snapshotting file state once per source, reusing sync statements, and consolidating status aggregation reads, which reduces builtin memory overhead on sync/status/doctor-style paths. Thanks @vincentkoc.
|
||||
- TUI/chat: preserve pending user messages when a slow local run emits an empty final event, but still defer and flush the needed history reload after the newer active run finishes so silent/tool-only runs do not stay incomplete. (#53130) Thanks @joelnishanth.
|
||||
- DeepSeek/pricing: replace the zero-cost DeepSeek catalog rates with the current DeepSeek V3.2 pricing so usage totals stop showing `$0.00` for DeepSeek sessions. (#54143) Thanks @arkyu2077.
|
||||
- CLI/logging: make pretty log timestamps always include an explicit timezone offset in default UTC and `--local-time` modes, so incident triage no longer mixes ambiguous clock displays. (#38904) Thanks @sahilsatralkar.
|
||||
- Browser/default detection: recognize macOS LaunchServices Edge bundle ids so default Chromium detection stops falling back to Chrome when Edge is the system default. (#48561) Thanks @zoherghadyali.
|
||||
- CLI/Telegram topics: route `message thread create` through Telegram `topic-create` with the required topic `name` field so Telegram forum topic creation works from the CLI again. (#54336) Thanks @andyliu.
|
||||
- Telegram/pairing: render pairing codes and approval commands as Telegram-only code blocks while keeping shared pairing replies plain text for other channels. (#52784) Thanks @sumukhj1219.
|
||||
- Agents/cron: suppress the default heartbeat system prompt for cron-triggered embedded runs even when they target non-cron session keys, so cron tasks stop reading `HEARTBEAT.md` and polluting unrelated threads. (#53152) Thanks @Protocol-zero-0.
|
||||
- Agents/cron: mark best-effort announce runs as not delivered when any payload fails, and log those partial delivery failures instead of silently reporting success. (#42535) Thanks @MoerAI.
|
||||
- Plugins: enforce terminal hook decision semantics for tool/message guards (#54241) Thanks @joshavant.
|
||||
- Marketplace/agents: correct the ClawHub skill URL in agent docs and stream marketplace archive downloads to disk so installs avoid excess memory use and fail cleanly on empty responses. (#54160) Thanks @QuinnH496.
|
||||
- Discord/config types: add missing `autoArchiveDuration` to `DiscordGuildChannelConfig` so TypeScript config definitions match the existing schema and runtime support. (#43427) Thanks @davidguttman.
|
||||
- Docs/IRC: fix five `json55` code-fence typos in the IRC channel examples so Mintlify applies JSON5 syntax highlighting correctly. (#50842) Thanks @Hollychou924.
|
||||
- Discord/commands: trim overlong slash-command descriptions to Discord's 100-character limit and map rejected deploy indexes from Discord validation payloads back to command names/descriptions, so deploys stop failing on long descriptions and startup logs identify the rejected commands. (#54118) thanks @huntharo
|
||||
|
||||
## 2026.3.23
|
||||
|
||||
### Breaking
|
||||
@@ -69,6 +199,8 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
- WhatsApp/outbound sends: keep the active Web listener on a direct process-global symbol so split runtime chunks keep sharing the connected Baileys session and `openclaw message send --channel whatsapp` stops failing after connect. Fixes #52574. Thanks @MonkeyLeeT.
|
||||
- Agents/process: fail loud when `send-keys` tries cursor-sensitive keys before a background PTY reports its cursor mode, so startup races no longer silently send the wrong arrow/Home/End sequences. (#51490) Thanks @liuy.
|
||||
|
||||
## 2026.3.22
|
||||
|
||||
@@ -393,6 +525,10 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents/edit tool: accept common path/text alias spellings, show current file contents on exact-match failures, and avoid false edit failures after successful writes. (#52516) thanks @mbelinky.
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
### Changes
|
||||
|
||||
+2
-4
@@ -24,7 +24,7 @@ Welcome to the lobster tank! 🦞
|
||||
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
|
||||
|
||||
- **Ayaan Zaidi** - Telegram subsystem, Android app
|
||||
- GitHub: [@obviyus](https://github.com/obviyus) · X: [@0bviyus](https://x.com/0bviyus)
|
||||
- GitHub: [@obviyus](https://github.com/obviyus) · X: [@obviyus](https://x.com/obviyus)
|
||||
|
||||
- **Tyler Yust** - Agents/subagents, cron, BlueBubbles, macOS app
|
||||
- GitHub: [@tyler6204](https://github.com/tyler6204) · X: [@tyleryust](https://x.com/tyleryust)
|
||||
@@ -58,6 +58,7 @@ Welcome to the lobster tank! 🦞
|
||||
|
||||
- **Jonathan Taylor** - ACP subsystem, Gateway features/bugs, Gog/Mog/Sog CLI's, SEDMAT
|
||||
- GitHub [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik)
|
||||
|
||||
- **Josh Lehman** - Compaction, Tlon/Urbit subsystem
|
||||
- GitHub [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_)
|
||||
|
||||
@@ -76,9 +77,6 @@ Welcome to the lobster tank! 🦞
|
||||
- **Tengji (George) Zhang** - Chinese model APIs, cloud, pi
|
||||
- GitHub: [@odysseus0](https://github.com/odysseus0) · X: [@odysseus0z](https://x.com/odysseus0z)
|
||||
|
||||
- **Andrew (Bubbles) Demczuk** - Agents/Gateway/TTS/VTT
|
||||
- GitHub: [@ademczuk](https://github.com/ademczuk) · X: [@ademczuk](https://x.com/ademczuk)
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Bugs & small fixes** → Open a PR!
|
||||
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026032300
|
||||
versionName = "2026.3.23"
|
||||
versionCode = 2026032400
|
||||
versionName = "2026.3.24"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Shared iOS version defaults.
|
||||
// Generated overrides live in build/Version.xcconfig (git-ignored).
|
||||
|
||||
OPENCLAW_GATEWAY_VERSION = 2026.3.23
|
||||
OPENCLAW_MARKETING_VERSION = 2026.3.23
|
||||
OPENCLAW_BUILD_VERSION = 202603230
|
||||
OPENCLAW_GATEWAY_VERSION = 2026.3.24
|
||||
OPENCLAW_MARKETING_VERSION = 2026.3.24
|
||||
OPENCLAW_BUILD_VERSION = 2026032490
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -46,6 +46,13 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
||||
_ = try await self.gateway.request(method: "sessions.reset", paramsJSON: json, timeoutSeconds: 10)
|
||||
}
|
||||
|
||||
func compactSession(sessionKey: String) async throws {
|
||||
struct Params: Codable { var key: String }
|
||||
let data = try JSONEncoder().encode(Params(key: sessionKey))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
_ = try await self.gateway.request(method: "sessions.compact", paramsJSON: json, timeoutSeconds: 10)
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
struct Params: Codable { var sessionKey: String }
|
||||
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
|
||||
|
||||
@@ -73,7 +73,7 @@ extension ConfigSettings {
|
||||
|
||||
private var sidebar: some View {
|
||||
SettingsSidebarScroll {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
LazyVStack(alignment: .leading, spacing: 4) {
|
||||
if self.sections.isEmpty {
|
||||
Text("No config sections available.")
|
||||
.font(.caption)
|
||||
@@ -82,7 +82,7 @@ extension ConfigSettings {
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
ForEach(self.sections) { section in
|
||||
self.sidebarRow(section)
|
||||
self.sidebarSection(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,6 @@ extension ConfigSettings {
|
||||
}
|
||||
self.actionRow
|
||||
self.sectionHeader(section)
|
||||
self.subsectionNav(section)
|
||||
self.sectionForm(section)
|
||||
if self.store.configDirty, !self.isNixMode {
|
||||
Text("Unsaved changes")
|
||||
@@ -182,78 +181,76 @@ extension ConfigSettings {
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
private func sidebarRow(_ section: ConfigSection) -> some View {
|
||||
let isSelected = self.activeSectionKey == section.key
|
||||
return Button {
|
||||
self.selectSection(section)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(section.label)
|
||||
if let help = section.help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
private func sidebarSection(_ section: ConfigSection) -> some View {
|
||||
let isExpanded = self.activeSectionKey == section.key
|
||||
let subsections = isExpanded ? self.resolveSubsections(for: section) : []
|
||||
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
Button {
|
||||
self.selectSection(section)
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||
Text(section.label)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(isExpanded && subsections.isEmpty
|
||||
? Color.accentColor.opacity(0.18)
|
||||
: Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.background(Color.clear)
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Rectangle())
|
||||
|
||||
if isExpanded, !subsections.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
self.sidebarSubRow(title: "All", key: nil, sectionKey: section.key)
|
||||
ForEach(subsections) { sub in
|
||||
self.sidebarSubRow(title: sub.label, key: sub.key, sectionKey: section.key)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 20)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: isExpanded)
|
||||
}
|
||||
|
||||
private func sidebarSubRow(title: String, key: String?, sectionKey: String) -> some View {
|
||||
let isSelected: Bool = {
|
||||
guard self.activeSectionKey == sectionKey else { return false }
|
||||
if let key { return self.activeSubsection == .key(key) }
|
||||
return self.activeSubsection == .all
|
||||
}()
|
||||
|
||||
return Button {
|
||||
if let key {
|
||||
self.activeSubsection = .key(key)
|
||||
} else {
|
||||
self.activeSubsection = .all
|
||||
}
|
||||
} label: {
|
||||
Text(title)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func subsectionNav(_ section: ConfigSection) -> some View {
|
||||
let subsections = self.resolveSubsections(for: section)
|
||||
if subsections.isEmpty {
|
||||
EmptyView()
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
self.subsectionButton(
|
||||
title: "All",
|
||||
isSelected: self.activeSubsection == .all)
|
||||
{
|
||||
self.activeSubsection = .all
|
||||
}
|
||||
ForEach(subsections) { subsection in
|
||||
self.subsectionButton(
|
||||
title: subsection.label,
|
||||
isSelected: self.activeSubsection == .key(subsection.key))
|
||||
{
|
||||
self.activeSubsection = .key(subsection.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func subsectionButton(
|
||||
title: String,
|
||||
isSelected: Bool,
|
||||
action: @escaping () -> Void) -> some View
|
||||
{
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(isSelected ? Color.accentColor : .primary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func sectionForm(_ section: ConfigSection) -> some View {
|
||||
let subsection = self.activeSubsection
|
||||
let defaultPath: ConfigPath = [.key(section.key)]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
|
||||
enum OpenClawConfigFile {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "config")
|
||||
private static let configAuditFileName = "config-audit.jsonl"
|
||||
private static let configHealthFileName = "config-health.json"
|
||||
|
||||
static func url() -> URL {
|
||||
OpenClawPaths.configURL
|
||||
@@ -23,9 +25,11 @@ enum OpenClawConfigFile {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
guard let root = self.parseConfigData(data) else {
|
||||
self.observeConfigRead(data: data, root: nil, configURL: url, valid: false)
|
||||
self.logger.warning("config JSON root invalid")
|
||||
return [:]
|
||||
}
|
||||
self.observeConfigRead(data: data, root: root, configURL: url, valid: true)
|
||||
return root
|
||||
} catch {
|
||||
self.logger.warning("config read failed: \(error.localizedDescription)")
|
||||
@@ -76,6 +80,7 @@ enum OpenClawConfigFile {
|
||||
"gatewayModeAfter": gatewayModeAfter ?? NSNull(),
|
||||
"suspicious": suspicious,
|
||||
])
|
||||
self.observeConfigRead(data: data, root: output, configURL: url, valid: true)
|
||||
} catch {
|
||||
self.logger.error("config save failed: \(error.localizedDescription)")
|
||||
self.appendConfigWriteAudit([
|
||||
@@ -316,6 +321,211 @@ enum OpenClawConfigFile {
|
||||
.appendingPathComponent(self.configAuditFileName, isDirectory: false)
|
||||
}
|
||||
|
||||
private static func configHealthStateURL() -> URL {
|
||||
self.stateDirURL()
|
||||
.appendingPathComponent("logs", isDirectory: true)
|
||||
.appendingPathComponent(self.configHealthFileName, isDirectory: false)
|
||||
}
|
||||
|
||||
private static func readConfigHealthState() -> [String: Any] {
|
||||
let url = self.configHealthStateURL()
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else {
|
||||
return [:]
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
private static func writeConfigHealthState(_ root: [String: Any]) {
|
||||
guard JSONSerialization.isValidJSONObject(root),
|
||||
let data = try? JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
||||
else {
|
||||
return
|
||||
}
|
||||
let url = self.configHealthStateURL()
|
||||
do {
|
||||
try FileManager().createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
private static func configHealthEntry(state: [String: Any], configPath: String) -> [String: Any] {
|
||||
let entries = state["entries"] as? [String: Any]
|
||||
return entries?[configPath] as? [String: Any] ?? [:]
|
||||
}
|
||||
|
||||
private static func setConfigHealthEntry(
|
||||
state: [String: Any],
|
||||
configPath: String,
|
||||
entry: [String: Any]) -> [String: Any]
|
||||
{
|
||||
var next = state
|
||||
var entries = next["entries"] as? [String: Any] ?? [:]
|
||||
entries[configPath] = entry
|
||||
next["entries"] = entries
|
||||
return next
|
||||
}
|
||||
|
||||
private static func isUpdateChannelOnlyRoot(_ root: [String: Any]) -> Bool {
|
||||
let keys = Array(root.keys)
|
||||
guard keys.count == 1, keys.first == "update" else { return false }
|
||||
guard let update = root["update"] as? [String: Any] else { return false }
|
||||
let updateKeys = Array(update.keys)
|
||||
return updateKeys.count == 1 && update["channel"] is String
|
||||
}
|
||||
|
||||
private static func fileTimestampMs(_ value: Any?) -> Double? {
|
||||
guard let date = value as? Date else { return nil }
|
||||
return date.timeIntervalSince1970 * 1000
|
||||
}
|
||||
|
||||
private static func configFingerprint(
|
||||
data: Data,
|
||||
root: [String: Any]?,
|
||||
configURL: URL,
|
||||
observedAt: String) -> [String: Any]
|
||||
{
|
||||
let attributes = try? FileManager().attributesOfItem(atPath: configURL.path)
|
||||
return [
|
||||
"hash": SHA256.hash(data: data).compactMap { String(format: "%02x", $0) }.joined(),
|
||||
"bytes": data.count,
|
||||
"mtimeMs": self.fileTimestampMs(attributes?[.modificationDate]) ?? NSNull(),
|
||||
"ctimeMs": self.fileTimestampMs(attributes?[.creationDate]) ?? NSNull(),
|
||||
"hasMeta": self.hasMeta(root),
|
||||
"gatewayMode": self.gatewayMode(root) ?? NSNull(),
|
||||
"observedAt": observedAt,
|
||||
]
|
||||
}
|
||||
|
||||
private static func sameFingerprint(_ left: [String: Any]?, _ right: [String: Any]) -> Bool {
|
||||
guard let left else { return false }
|
||||
return (left["hash"] as? String) == (right["hash"] as? String) &&
|
||||
(left["bytes"] as? Int) == (right["bytes"] as? Int) &&
|
||||
(left["mtimeMs"] as? Double) == (right["mtimeMs"] as? Double) &&
|
||||
(left["ctimeMs"] as? Double) == (right["ctimeMs"] as? Double) &&
|
||||
(left["hasMeta"] as? Bool) == (right["hasMeta"] as? Bool) &&
|
||||
(left["gatewayMode"] as? String) == (right["gatewayMode"] as? String)
|
||||
}
|
||||
|
||||
private static func observeSuspiciousReasons(
|
||||
root: [String: Any]?,
|
||||
bytes: Int,
|
||||
lastKnownGood: [String: Any]?) -> [String]
|
||||
{
|
||||
guard let lastKnownGood else { return [] }
|
||||
var reasons: [String] = []
|
||||
if let previousBytes = lastKnownGood["bytes"] as? Int,
|
||||
previousBytes >= 512,
|
||||
bytes < max(1, previousBytes / 2)
|
||||
{
|
||||
reasons.append("size-drop-vs-last-good:\(previousBytes)->\(bytes)")
|
||||
}
|
||||
if (lastKnownGood["hasMeta"] as? Bool) == true, !self.hasMeta(root) {
|
||||
reasons.append("missing-meta-vs-last-good")
|
||||
}
|
||||
if (lastKnownGood["gatewayMode"] as? String) != nil, self.gatewayMode(root) == nil {
|
||||
reasons.append("gateway-mode-missing-vs-last-good")
|
||||
}
|
||||
if let root, (lastKnownGood["gatewayMode"] as? String) != nil, self.isUpdateChannelOnlyRoot(root) {
|
||||
reasons.append("update-channel-only-root")
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
|
||||
private static func readConfigFingerprint(at url: URL) -> [String: Any]? {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
let root = self.parseConfigData(data)
|
||||
return self.configFingerprint(
|
||||
data: data,
|
||||
root: root,
|
||||
configURL: url,
|
||||
observedAt: ISO8601DateFormatter().string(from: Date()))
|
||||
}
|
||||
|
||||
private static func configTimestampToken(_ timestamp: String) -> String {
|
||||
timestamp.replacingOccurrences(of: ":", with: "-")
|
||||
.replacingOccurrences(of: ".", with: "-")
|
||||
}
|
||||
|
||||
private static func persistClobberedSnapshot(data: Data, configURL: URL, observedAt: String) -> String? {
|
||||
let url = configURL.deletingLastPathComponent()
|
||||
.appendingPathComponent("\(configURL.lastPathComponent).clobbered.\(self.configTimestampToken(observedAt))")
|
||||
guard !FileManager().fileExists(atPath: url.path) else { return url.path }
|
||||
do {
|
||||
try data.write(to: url, options: [])
|
||||
return url.path
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func observeConfigRead(data: Data, root: [String: Any]?, configURL: URL, valid: Bool) {
|
||||
let observedAt = ISO8601DateFormatter().string(from: Date())
|
||||
let current = self.configFingerprint(data: data, root: root, configURL: configURL, observedAt: observedAt)
|
||||
var state = self.readConfigHealthState()
|
||||
let entry = self.configHealthEntry(state: state, configPath: configURL.path)
|
||||
let lastKnownGood = entry["lastKnownGood"] as? [String: Any]
|
||||
let suspicious = self.observeSuspiciousReasons(
|
||||
root: root,
|
||||
bytes: current["bytes"] as? Int ?? 0,
|
||||
lastKnownGood: lastKnownGood)
|
||||
|
||||
if suspicious.isEmpty {
|
||||
guard valid else { return }
|
||||
let nextEntry: [String: Any] = [
|
||||
"lastKnownGood": current,
|
||||
"lastObservedSuspiciousSignature": NSNull(),
|
||||
]
|
||||
if !self.sameFingerprint(lastKnownGood, current) || entry["lastObservedSuspiciousSignature"] != nil {
|
||||
state = self.setConfigHealthEntry(state: state, configPath: configURL.path, entry: nextEntry)
|
||||
self.writeConfigHealthState(state)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let signature = "\((current["hash"] as? String) ?? ""):\(suspicious.joined(separator: ","))"
|
||||
if (entry["lastObservedSuspiciousSignature"] as? String) == signature {
|
||||
return
|
||||
}
|
||||
|
||||
let backup = self.readConfigFingerprint(at: configURL.deletingLastPathComponent().appendingPathComponent("\(configURL.lastPathComponent).bak"))
|
||||
let clobberedPath = self.persistClobberedSnapshot(data: data, configURL: configURL, observedAt: observedAt)
|
||||
self.logger.warning("config observe anomaly (\(suspicious.joined(separator: ", "))) at \(configURL.path)")
|
||||
self.appendConfigObserveAudit([
|
||||
"phase": "read",
|
||||
"configPath": configURL.path,
|
||||
"exists": true,
|
||||
"valid": valid,
|
||||
"hash": current["hash"] ?? NSNull(),
|
||||
"bytes": current["bytes"] ?? NSNull(),
|
||||
"mtimeMs": current["mtimeMs"] ?? NSNull(),
|
||||
"ctimeMs": current["ctimeMs"] ?? NSNull(),
|
||||
"hasMeta": current["hasMeta"] ?? false,
|
||||
"gatewayMode": current["gatewayMode"] ?? NSNull(),
|
||||
"suspicious": suspicious,
|
||||
"lastKnownGoodHash": lastKnownGood?["hash"] ?? NSNull(),
|
||||
"lastKnownGoodBytes": lastKnownGood?["bytes"] ?? NSNull(),
|
||||
"lastKnownGoodMtimeMs": lastKnownGood?["mtimeMs"] ?? NSNull(),
|
||||
"lastKnownGoodCtimeMs": lastKnownGood?["ctimeMs"] ?? NSNull(),
|
||||
"lastKnownGoodGatewayMode": lastKnownGood?["gatewayMode"] ?? NSNull(),
|
||||
"backupHash": backup?["hash"] ?? NSNull(),
|
||||
"backupBytes": backup?["bytes"] ?? NSNull(),
|
||||
"backupMtimeMs": backup?["mtimeMs"] ?? NSNull(),
|
||||
"backupCtimeMs": backup?["ctimeMs"] ?? NSNull(),
|
||||
"backupGatewayMode": backup?["gatewayMode"] ?? NSNull(),
|
||||
"clobberedPath": clobberedPath ?? NSNull(),
|
||||
])
|
||||
var nextEntry = entry
|
||||
nextEntry["lastObservedSuspiciousSignature"] = signature
|
||||
state = self.setConfigHealthEntry(state: state, configPath: configURL.path, entry: nextEntry)
|
||||
self.writeConfigHealthState(state)
|
||||
}
|
||||
|
||||
private static func appendConfigWriteAudit(_ fields: [String: Any]) {
|
||||
var record: [String: Any] = [
|
||||
"ts": ISO8601DateFormatter().string(from: Date()),
|
||||
@@ -351,4 +561,40 @@ enum OpenClawConfigFile {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
private static func appendConfigObserveAudit(_ fields: [String: Any]) {
|
||||
var record: [String: Any] = [
|
||||
"ts": ISO8601DateFormatter().string(from: Date()),
|
||||
"source": "macos-openclaw-config-file",
|
||||
"event": "config.observe",
|
||||
"pid": ProcessInfo.processInfo.processIdentifier,
|
||||
"argv": Array(ProcessInfo.processInfo.arguments.prefix(8)),
|
||||
]
|
||||
for (key, value) in fields {
|
||||
record[key] = value is NSNull ? NSNull() : value
|
||||
}
|
||||
guard JSONSerialization.isValidJSONObject(record),
|
||||
let data = try? JSONSerialization.data(withJSONObject: record)
|
||||
else {
|
||||
return
|
||||
}
|
||||
var line = Data()
|
||||
line.append(data)
|
||||
line.append(0x0A)
|
||||
let logURL = self.configAuditLogURL()
|
||||
do {
|
||||
try FileManager().createDirectory(
|
||||
at: logURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
if !FileManager().fileExists(atPath: logURL.path) {
|
||||
FileManager().createFile(atPath: logURL.path, contents: nil)
|
||||
}
|
||||
let handle = try FileHandle(forWritingTo: logURL)
|
||||
defer { try? handle.close() }
|
||||
try handle.seekToEnd()
|
||||
try handle.write(contentsOf: line)
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.23</string>
|
||||
<string>2026.3.24</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202603230</string>
|
||||
<string>2026032490</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -95,7 +95,8 @@ struct SkillsSettings: View {
|
||||
skillKey: skill.skillKey,
|
||||
skillName: skill.name,
|
||||
envKey: envKey,
|
||||
isPrimary: isPrimary)
|
||||
isPrimary: isPrimary,
|
||||
homepage: skill.homepage)
|
||||
})
|
||||
}
|
||||
if !self.model.skills.isEmpty, self.filteredSkills.isEmpty {
|
||||
@@ -258,8 +259,12 @@ private struct SkillRow: View {
|
||||
guard let raw = self.skill.homepage?.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
||||
return nil
|
||||
}
|
||||
guard !raw.isEmpty else { return nil }
|
||||
return URL(string: raw)
|
||||
guard !raw.isEmpty, let url = URL(string: raw),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https" else {
|
||||
return nil
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private var enabledBinding: Binding<Bool> {
|
||||
@@ -428,6 +433,7 @@ private struct EnvEditorState: Identifiable {
|
||||
let skillName: String
|
||||
let envKey: String
|
||||
let isPrimary: Bool
|
||||
let homepage: String?
|
||||
|
||||
var id: String {
|
||||
"\(self.skillKey)::\(self.envKey)"
|
||||
@@ -447,8 +453,15 @@ private struct EnvEditorView: View {
|
||||
Text(self.subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
if let homepageUrl = self.homepageUrl {
|
||||
Link("Get your key →", destination: homepageUrl)
|
||||
.font(.caption)
|
||||
}
|
||||
SecureField(self.editor.envKey, text: self.$value)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Text("Saved to openclaw.json under skills.entries.\(self.editor.skillKey)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
HStack {
|
||||
Button("Cancel") { self.dismiss() }
|
||||
Spacer()
|
||||
@@ -464,6 +477,18 @@ private struct EnvEditorView: View {
|
||||
.frame(width: 420)
|
||||
}
|
||||
|
||||
private var homepageUrl: URL? {
|
||||
guard let raw = self.editor.homepage?.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
||||
return nil
|
||||
}
|
||||
guard !raw.isEmpty, let url = URL(string: raw),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https" else {
|
||||
return nil
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
self.editor.isPrimary ? "Set API Key" : "Set Environment Variable"
|
||||
}
|
||||
@@ -539,12 +564,12 @@ final class SkillsSettingsModel {
|
||||
_ = try await GatewayConnection.shared.skillsUpdate(
|
||||
skillKey: skillKey,
|
||||
apiKey: value)
|
||||
self.statusMessage = "Saved API key"
|
||||
self.statusMessage = "Saved API key — stored in openclaw.json (skills.entries.\(skillKey))"
|
||||
} else {
|
||||
_ = try await GatewayConnection.shared.skillsUpdate(
|
||||
skillKey: skillKey,
|
||||
env: [envKey: value])
|
||||
self.statusMessage = "Saved \(envKey)"
|
||||
self.statusMessage = "Saved \(envKey) — stored in openclaw.json (skills.entries.\(skillKey).env)"
|
||||
}
|
||||
} catch {
|
||||
self.statusMessage = error.localizedDescription
|
||||
@@ -608,7 +633,8 @@ extension SkillsSettings {
|
||||
skillKey: "test",
|
||||
skillName: "Test Skill",
|
||||
envKey: "API_KEY",
|
||||
isPrimary: true),
|
||||
isPrimary: true,
|
||||
homepage: "https://example.com"),
|
||||
onSave: { _ in })
|
||||
_ = editor.body
|
||||
}
|
||||
|
||||
@@ -126,6 +126,13 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
timeoutMs: 10000)
|
||||
}
|
||||
|
||||
func compactSession(sessionKey: String) async throws {
|
||||
_ = try await GatewayConnection.shared.request(
|
||||
method: "sessions.compact",
|
||||
params: ["key": AnyCodable(sessionKey)],
|
||||
timeoutMs: 10000)
|
||||
}
|
||||
|
||||
func events() -> AsyncStream<OpenClawChatTransportEvent> {
|
||||
AsyncStream { continuation in
|
||||
let task = Task {
|
||||
|
||||
@@ -2764,6 +2764,110 @@ public struct ToolsCatalogResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ToolsEffectiveParams: Codable, Sendable {
|
||||
public let agentid: String?
|
||||
public let sessionkey: String
|
||||
|
||||
public init(
|
||||
agentid: String?,
|
||||
sessionkey: String)
|
||||
{
|
||||
self.agentid = agentid
|
||||
self.sessionkey = sessionkey
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
case sessionkey = "sessionKey"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ToolsEffectiveEntry: Codable, Sendable {
|
||||
public let id: String
|
||||
public let label: String
|
||||
public let description: String
|
||||
public let rawdescription: String
|
||||
public let source: AnyCodable
|
||||
public let pluginid: String?
|
||||
public let channelid: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
label: String,
|
||||
description: String,
|
||||
rawdescription: String,
|
||||
source: AnyCodable,
|
||||
pluginid: String?,
|
||||
channelid: String?)
|
||||
{
|
||||
self.id = id
|
||||
self.label = label
|
||||
self.description = description
|
||||
self.rawdescription = rawdescription
|
||||
self.source = source
|
||||
self.pluginid = pluginid
|
||||
self.channelid = channelid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case label
|
||||
case description
|
||||
case rawdescription = "rawDescription"
|
||||
case source
|
||||
case pluginid = "pluginId"
|
||||
case channelid = "channelId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ToolsEffectiveGroup: Codable, Sendable {
|
||||
public let id: AnyCodable
|
||||
public let label: String
|
||||
public let source: AnyCodable
|
||||
public let tools: [ToolsEffectiveEntry]
|
||||
|
||||
public init(
|
||||
id: AnyCodable,
|
||||
label: String,
|
||||
source: AnyCodable,
|
||||
tools: [ToolsEffectiveEntry])
|
||||
{
|
||||
self.id = id
|
||||
self.label = label
|
||||
self.source = source
|
||||
self.tools = tools
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case label
|
||||
case source
|
||||
case tools
|
||||
}
|
||||
}
|
||||
|
||||
public struct ToolsEffectiveResult: Codable, Sendable {
|
||||
public let agentid: String
|
||||
public let profile: String
|
||||
public let groups: [ToolsEffectiveGroup]
|
||||
|
||||
public init(
|
||||
agentid: String,
|
||||
profile: String,
|
||||
groups: [ToolsEffectiveGroup])
|
||||
{
|
||||
self.agentid = agentid
|
||||
self.profile = profile
|
||||
self.groups = groups
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
case profile
|
||||
case groups
|
||||
}
|
||||
}
|
||||
|
||||
public struct SkillsBinsParams: Codable, Sendable {}
|
||||
|
||||
public struct SkillsBinsResult: Codable, Sendable {
|
||||
|
||||
@@ -135,4 +135,69 @@ struct OpenClawConfigFileTests {
|
||||
#expect(auditRoot?["configPath"] as? String == configPath.path)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `load dict audits suspicious out-of-band clobbers`() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
try await TestIsolation.withEnvValues([
|
||||
"OPENCLAW_STATE_DIR": stateDir.path,
|
||||
"OPENCLAW_CONFIG_PATH": configPath.path,
|
||||
]) {
|
||||
OpenClawConfigFile.saveDict([
|
||||
"update": ["channel": "beta"],
|
||||
"browser": ["enabled": true],
|
||||
"gateway": ["mode": "local"],
|
||||
"channels": [
|
||||
"discord": [
|
||||
"enabled": true,
|
||||
"dmPolicy": "pairing",
|
||||
],
|
||||
],
|
||||
])
|
||||
_ = OpenClawConfigFile.loadDict()
|
||||
|
||||
let clobbered = """
|
||||
{
|
||||
"update": {
|
||||
"channel": "beta"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try clobbered.write(to: configPath, atomically: true, encoding: .utf8)
|
||||
|
||||
let loaded = OpenClawConfigFile.loadDict()
|
||||
#expect((loaded["gateway"] as? [String: Any]) == nil)
|
||||
|
||||
let rawAudit = try String(contentsOf: auditPath, encoding: .utf8)
|
||||
let lines = rawAudit
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map(String.init)
|
||||
let observeLine = lines.reversed().first { $0.contains("\"event\":\"config.observe\"") }
|
||||
#expect(observeLine != nil)
|
||||
guard let observeLine else {
|
||||
Issue.record("Missing config.observe audit line")
|
||||
return
|
||||
}
|
||||
let auditRoot = try JSONSerialization.jsonObject(with: Data(observeLine.utf8)) as? [String: Any]
|
||||
#expect(auditRoot?["source"] as? String == "macos-openclaw-config-file")
|
||||
#expect(auditRoot?["configPath"] as? String == configPath.path)
|
||||
let suspicious = auditRoot?["suspicious"] as? [String] ?? []
|
||||
#expect(suspicious.contains("gateway-mode-missing-vs-last-good"))
|
||||
#expect(suspicious.contains("update-channel-only-root"))
|
||||
|
||||
let clobberedPath = auditRoot?["clobberedPath"] as? String
|
||||
#expect(clobberedPath != nil)
|
||||
if let clobberedPath {
|
||||
let preserved = try String(contentsOfFile: clobberedPath, encoding: .utf8)
|
||||
#expect(preserved == clobbered)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ public protocol OpenClawChatTransport: Sendable {
|
||||
|
||||
func setActiveSessionKey(_ sessionKey: String) async throws
|
||||
func resetSession(sessionKey: String) async throws
|
||||
func compactSession(sessionKey: String) async throws
|
||||
}
|
||||
|
||||
extension OpenClawChatTransport {
|
||||
@@ -40,6 +41,13 @@ extension OpenClawChatTransport {
|
||||
userInfo: [NSLocalizedDescriptionKey: "sessions.reset not supported by this transport"])
|
||||
}
|
||||
|
||||
public func compactSession(sessionKey _: String) async throws {
|
||||
throw NSError(
|
||||
domain: "OpenClawChatTransport",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "sessions.compact not supported by this transport"])
|
||||
}
|
||||
|
||||
public func abortRun(sessionKey _: String, runId _: String) async throws {
|
||||
throw NSError(
|
||||
domain: "OpenClawChatTransport",
|
||||
|
||||
@@ -60,6 +60,9 @@ public final class OpenClawChatViewModel {
|
||||
private var nextThinkingSelectionRequestID: UInt64 = 0
|
||||
private var latestThinkingSelectionRequestIDsBySession: [String: UInt64] = [:]
|
||||
private var latestThinkingLevelsBySession: [String: String] = [:]
|
||||
private var isCompacting = false
|
||||
private var lastCompactAt: Date?
|
||||
private let compactCooldown: TimeInterval = 60
|
||||
|
||||
private var pendingToolCallsById: [String: OpenClawChatPendingToolCall] = [:] {
|
||||
didSet {
|
||||
@@ -465,6 +468,7 @@ public final class OpenClawChatViewModel {
|
||||
}
|
||||
|
||||
private static let resetTriggers: Set<String> = ["/new", "/reset", "/clear"]
|
||||
private static let compactTriggers: Set<String> = ["/compact"]
|
||||
|
||||
private func performSend() async {
|
||||
guard !self.isSending else { return }
|
||||
@@ -476,6 +480,11 @@ public final class OpenClawChatViewModel {
|
||||
await self.performReset()
|
||||
return
|
||||
}
|
||||
if Self.compactTriggers.contains(trimmed.lowercased()) {
|
||||
self.input = ""
|
||||
await self.performCompact()
|
||||
return
|
||||
}
|
||||
|
||||
let sessionKey = self.sessionKey
|
||||
|
||||
@@ -623,6 +632,42 @@ public final class OpenClawChatViewModel {
|
||||
await self.bootstrap()
|
||||
}
|
||||
|
||||
private func performCompact() async {
|
||||
guard !self.isCompacting else { return }
|
||||
guard !self.isSending, self.pendingRuns.isEmpty, !self.isAborting else {
|
||||
self.errorText = "Wait for the current response before compacting the session."
|
||||
return
|
||||
}
|
||||
if let lastCompactAt,
|
||||
Date().timeIntervalSince(lastCompactAt) < self.compactCooldown
|
||||
{
|
||||
self.errorText = "Please wait before compacting this session again."
|
||||
return
|
||||
}
|
||||
|
||||
self.isCompacting = true
|
||||
self.isLoading = true
|
||||
self.errorText = nil
|
||||
defer {
|
||||
self.isLoading = false
|
||||
self.isCompacting = false
|
||||
}
|
||||
|
||||
do {
|
||||
try await self.transport.compactSession(sessionKey: self.sessionKey)
|
||||
} catch {
|
||||
self.errorText = "Unable to compact the session. Please try again."
|
||||
let nsError = error as NSError
|
||||
chatUILogger.error(
|
||||
"session compact failed domain=\(nsError.domain, privacy: .public) code=\(nsError.code, privacy: .public) details=\(String(describing: error), privacy: .private)"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
self.lastCompactAt = Date()
|
||||
await self.bootstrap()
|
||||
}
|
||||
|
||||
private func performSelectThinkingLevel(_ level: String) async {
|
||||
let next = Self.normalizedThinkingLevel(level) ?? "off"
|
||||
guard next != self.thinkingLevel else { return }
|
||||
|
||||
@@ -2764,6 +2764,110 @@ public struct ToolsCatalogResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ToolsEffectiveParams: Codable, Sendable {
|
||||
public let agentid: String?
|
||||
public let sessionkey: String
|
||||
|
||||
public init(
|
||||
agentid: String?,
|
||||
sessionkey: String)
|
||||
{
|
||||
self.agentid = agentid
|
||||
self.sessionkey = sessionkey
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
case sessionkey = "sessionKey"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ToolsEffectiveEntry: Codable, Sendable {
|
||||
public let id: String
|
||||
public let label: String
|
||||
public let description: String
|
||||
public let rawdescription: String
|
||||
public let source: AnyCodable
|
||||
public let pluginid: String?
|
||||
public let channelid: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
label: String,
|
||||
description: String,
|
||||
rawdescription: String,
|
||||
source: AnyCodable,
|
||||
pluginid: String?,
|
||||
channelid: String?)
|
||||
{
|
||||
self.id = id
|
||||
self.label = label
|
||||
self.description = description
|
||||
self.rawdescription = rawdescription
|
||||
self.source = source
|
||||
self.pluginid = pluginid
|
||||
self.channelid = channelid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case label
|
||||
case description
|
||||
case rawdescription = "rawDescription"
|
||||
case source
|
||||
case pluginid = "pluginId"
|
||||
case channelid = "channelId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ToolsEffectiveGroup: Codable, Sendable {
|
||||
public let id: AnyCodable
|
||||
public let label: String
|
||||
public let source: AnyCodable
|
||||
public let tools: [ToolsEffectiveEntry]
|
||||
|
||||
public init(
|
||||
id: AnyCodable,
|
||||
label: String,
|
||||
source: AnyCodable,
|
||||
tools: [ToolsEffectiveEntry])
|
||||
{
|
||||
self.id = id
|
||||
self.label = label
|
||||
self.source = source
|
||||
self.tools = tools
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case label
|
||||
case source
|
||||
case tools
|
||||
}
|
||||
}
|
||||
|
||||
public struct ToolsEffectiveResult: Codable, Sendable {
|
||||
public let agentid: String
|
||||
public let profile: String
|
||||
public let groups: [ToolsEffectiveGroup]
|
||||
|
||||
public init(
|
||||
agentid: String,
|
||||
profile: String,
|
||||
groups: [ToolsEffectiveGroup])
|
||||
{
|
||||
self.agentid = agentid
|
||||
self.profile = profile
|
||||
self.groups = groups
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
case profile
|
||||
case groups
|
||||
}
|
||||
}
|
||||
|
||||
public struct SkillsBinsParams: Codable, Sendable {}
|
||||
|
||||
public struct SkillsBinsResult: Codable, Sendable {
|
||||
|
||||
@@ -84,6 +84,7 @@ private func makeViewModel(
|
||||
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
|
||||
modelResponses: [[OpenClawChatModelChoice]] = [],
|
||||
resetSessionHook: (@Sendable (String) async throws -> Void)? = nil,
|
||||
compactSessionHook: (@Sendable (String) async throws -> Void)? = nil,
|
||||
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
|
||||
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil,
|
||||
initialThinkingLevel: String? = nil,
|
||||
@@ -95,6 +96,7 @@ private func makeViewModel(
|
||||
sessionsResponses: sessionsResponses,
|
||||
modelResponses: modelResponses,
|
||||
resetSessionHook: resetSessionHook,
|
||||
compactSessionHook: compactSessionHook,
|
||||
setSessionModelHook: setSessionModelHook,
|
||||
setSessionThinkingHook: setSessionThinkingHook)
|
||||
let vm = await MainActor.run {
|
||||
@@ -219,11 +221,25 @@ private actor AsyncGate {
|
||||
}
|
||||
}
|
||||
|
||||
private actor AsyncCounter {
|
||||
private var value: Int
|
||||
|
||||
init(_ initialValue: Int = 0) {
|
||||
self.value = initialValue
|
||||
}
|
||||
|
||||
func increment() -> Int {
|
||||
self.value += 1
|
||||
return self.value
|
||||
}
|
||||
}
|
||||
|
||||
private actor TestChatTransportState {
|
||||
var historyCallCount: Int = 0
|
||||
var sessionsCallCount: Int = 0
|
||||
var modelsCallCount: Int = 0
|
||||
var resetSessionKeys: [String] = []
|
||||
var compactSessionKeys: [String] = []
|
||||
var sentRunIds: [String] = []
|
||||
var sentThinkingLevels: [String] = []
|
||||
var abortedRunIds: [String] = []
|
||||
@@ -237,6 +253,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
||||
private let sessionsResponses: [OpenClawChatSessionsListResponse]
|
||||
private let modelResponses: [[OpenClawChatModelChoice]]
|
||||
private let resetSessionHook: (@Sendable (String) async throws -> Void)?
|
||||
private let compactSessionHook: (@Sendable (String) async throws -> Void)?
|
||||
private let setSessionModelHook: (@Sendable (String?) async throws -> Void)?
|
||||
private let setSessionThinkingHook: (@Sendable (String) async throws -> Void)?
|
||||
|
||||
@@ -248,6 +265,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
||||
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
|
||||
modelResponses: [[OpenClawChatModelChoice]] = [],
|
||||
resetSessionHook: (@Sendable (String) async throws -> Void)? = nil,
|
||||
compactSessionHook: (@Sendable (String) async throws -> Void)? = nil,
|
||||
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
|
||||
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil)
|
||||
{
|
||||
@@ -255,6 +273,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
||||
self.sessionsResponses = sessionsResponses
|
||||
self.modelResponses = modelResponses
|
||||
self.resetSessionHook = resetSessionHook
|
||||
self.compactSessionHook = compactSessionHook
|
||||
self.setSessionModelHook = setSessionModelHook
|
||||
self.setSessionThinkingHook = setSessionThinkingHook
|
||||
var cont: AsyncStream<OpenClawChatTransportEvent>.Continuation!
|
||||
@@ -336,6 +355,13 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
||||
}
|
||||
}
|
||||
|
||||
func compactSession(sessionKey: String) async throws {
|
||||
await self.state.compactSessionKeysAppend(sessionKey)
|
||||
if let compactSessionHook = self.compactSessionHook {
|
||||
try await compactSessionHook(sessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
func setSessionThinking(sessionKey _: String, thinkingLevel: String) async throws {
|
||||
await self.state.patchedThinkingLevelsAppend(thinkingLevel)
|
||||
if let setSessionThinkingHook = self.setSessionThinkingHook {
|
||||
@@ -375,6 +401,10 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
||||
func resetSessionKeys() async -> [String] {
|
||||
await self.state.resetSessionKeys
|
||||
}
|
||||
|
||||
func compactSessionKeys() async -> [String] {
|
||||
await self.state.compactSessionKeys
|
||||
}
|
||||
}
|
||||
|
||||
extension TestChatTransportState {
|
||||
@@ -413,6 +443,10 @@ extension TestChatTransportState {
|
||||
fileprivate func resetSessionKeysAppend(_ v: String) {
|
||||
self.resetSessionKeys.append(v)
|
||||
}
|
||||
|
||||
fileprivate func compactSessionKeysAppend(_ v: String) {
|
||||
self.compactSessionKeys.append(v)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct ChatViewModelTests {
|
||||
@@ -915,6 +949,140 @@ extension TestChatTransportState {
|
||||
#expect(await transport.lastSentRunId() == nil)
|
||||
}
|
||||
|
||||
@Test func compactTriggerCompactsSessionAndReloadsHistory() async throws {
|
||||
let before = historyPayload(
|
||||
messages: [
|
||||
chatTextMessage(role: "assistant", text: "before compact", timestamp: 1),
|
||||
])
|
||||
let after = historyPayload(
|
||||
messages: [
|
||||
chatTextMessage(role: "assistant", text: "after compact", timestamp: 2),
|
||||
])
|
||||
|
||||
let (transport, vm) = await makeViewModel(historyResponses: [before, after])
|
||||
try await loadAndWaitBootstrap(vm: vm)
|
||||
try await waitUntil("initial history loaded") {
|
||||
await MainActor.run { vm.messages.first?.content.first?.text == "before compact" }
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
vm.input = "/compact"
|
||||
vm.send()
|
||||
}
|
||||
|
||||
try await waitUntil("compact called") {
|
||||
await transport.compactSessionKeys() == ["main"]
|
||||
}
|
||||
try await waitUntil("history reloaded") {
|
||||
await MainActor.run { vm.messages.first?.content.first?.text == "after compact" }
|
||||
}
|
||||
#expect(await transport.lastSentRunId() == nil)
|
||||
}
|
||||
|
||||
@Test func compactTriggerShowsGenericErrorMessageOnFailure() async throws {
|
||||
let history = historyPayload()
|
||||
let (transport, vm) = await makeViewModel(
|
||||
historyResponses: [history],
|
||||
compactSessionHook: { _ in
|
||||
throw NSError(
|
||||
domain: "TestCompact",
|
||||
code: 42,
|
||||
userInfo: [NSLocalizedDescriptionKey: "backend details should not leak"])
|
||||
})
|
||||
try await loadAndWaitBootstrap(vm: vm)
|
||||
|
||||
await MainActor.run {
|
||||
vm.input = "/compact"
|
||||
vm.send()
|
||||
}
|
||||
|
||||
try await waitUntil("compact attempted") {
|
||||
await transport.compactSessionKeys() == ["main"]
|
||||
}
|
||||
#expect(await MainActor.run { vm.errorText } == "Unable to compact the session. Please try again.")
|
||||
}
|
||||
|
||||
@Test func compactTriggerIgnoresConcurrentAndImmediateRepeatRequests() async throws {
|
||||
let before = historyPayload(
|
||||
messages: [
|
||||
chatTextMessage(role: "assistant", text: "before compact", timestamp: 1),
|
||||
])
|
||||
let after = historyPayload(
|
||||
messages: [
|
||||
chatTextMessage(role: "assistant", text: "after compact", timestamp: 2),
|
||||
])
|
||||
let gate = AsyncGate()
|
||||
let (transport, vm) = await makeViewModel(
|
||||
historyResponses: [before, after],
|
||||
compactSessionHook: { _ in
|
||||
await gate.wait()
|
||||
})
|
||||
try await loadAndWaitBootstrap(vm: vm)
|
||||
|
||||
await MainActor.run {
|
||||
vm.input = "/compact"
|
||||
vm.send()
|
||||
vm.input = "/compact"
|
||||
vm.send()
|
||||
}
|
||||
|
||||
try await waitUntil("single compact request issued") {
|
||||
await transport.compactSessionKeys() == ["main"]
|
||||
}
|
||||
#expect(await MainActor.run { vm.errorText } == nil)
|
||||
|
||||
await gate.open()
|
||||
try await waitUntil("history reloaded after compact") {
|
||||
await MainActor.run { vm.messages.first?.content.first?.text == "after compact" }
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
vm.input = "/compact"
|
||||
vm.send()
|
||||
}
|
||||
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
#expect(await transport.compactSessionKeys() == ["main"])
|
||||
#expect(await MainActor.run { vm.errorText } == "Please wait before compacting this session again.")
|
||||
}
|
||||
|
||||
@Test func compactTriggerAllowsImmediateRetryAfterFailure() async throws {
|
||||
let history = historyPayload()
|
||||
let attemptCount = AsyncCounter()
|
||||
let (transport, vm) = await makeViewModel(
|
||||
historyResponses: [history],
|
||||
compactSessionHook: { _ in
|
||||
let next = await attemptCount.increment()
|
||||
if next == 1 {
|
||||
throw NSError(
|
||||
domain: "TestCompact",
|
||||
code: 42,
|
||||
userInfo: [NSLocalizedDescriptionKey: "temporary failure"])
|
||||
}
|
||||
})
|
||||
try await loadAndWaitBootstrap(vm: vm)
|
||||
|
||||
await MainActor.run {
|
||||
vm.input = "/compact"
|
||||
vm.send()
|
||||
}
|
||||
|
||||
try await waitUntil("first compact attempted") {
|
||||
await transport.compactSessionKeys() == ["main"]
|
||||
}
|
||||
#expect(await MainActor.run { vm.errorText } == "Unable to compact the session. Please try again.")
|
||||
|
||||
await MainActor.run {
|
||||
vm.input = "/compact"
|
||||
vm.send()
|
||||
}
|
||||
|
||||
try await waitUntil("second compact attempted") {
|
||||
await transport.compactSessionKeys() == ["main", "main"]
|
||||
}
|
||||
#expect(await MainActor.run { vm.errorText } == nil)
|
||||
}
|
||||
|
||||
@Test func bootstrapsModelSelectionFromSessionAndDefaults() async throws {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let history = historyPayload()
|
||||
|
||||
@@ -7530,6 +7530,16 @@
|
||||
"tags": [],
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "auth.profiles.*.displayName",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "auth.profiles.*.email",
|
||||
"kind": "core",
|
||||
@@ -10382,6 +10392,20 @@
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.discord.accounts.*.guilds.*.channels.*.autoThreadName",
|
||||
"kind": "channel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"enumValues": [
|
||||
"message",
|
||||
"generated"
|
||||
],
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.discord.accounts.*.guilds.*.channels.*.enabled",
|
||||
"kind": "channel",
|
||||
@@ -13314,6 +13338,20 @@
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.discord.guilds.*.channels.*.autoThreadName",
|
||||
"kind": "channel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"enumValues": [
|
||||
"message",
|
||||
"generated"
|
||||
],
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.discord.guilds.*.channels.*.enabled",
|
||||
"kind": "channel",
|
||||
@@ -17079,8 +17117,7 @@
|
||||
"path": "channels.feishu.requireMention",
|
||||
"kind": "channel",
|
||||
"type": "boolean",
|
||||
"required": true,
|
||||
"defaultValue": true,
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
@@ -24324,7 +24361,7 @@
|
||||
"network"
|
||||
],
|
||||
"label": "Microsoft Teams",
|
||||
"help": "Bot Framework; enterprise support.",
|
||||
"help": "Teams SDK; enterprise support.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5628}
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5631}
|
||||
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -669,6 +669,7 @@
|
||||
{"recordType":"path","path":"auth.order.*.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"auth.profiles","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["access","auth","storage"],"label":"Auth Profiles","help":"Named auth profiles (provider + mode + optional email).","hasChildren":true}
|
||||
{"recordType":"path","path":"auth.profiles.*","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"auth.profiles.*.displayName","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"auth.profiles.*.email","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"auth.profiles.*.mode","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"auth.profiles.*.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -916,6 +917,7 @@
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.autoArchiveDuration","kind":"channel","type":["number","string"],"required":false,"enumValues":["60","1440","4320","10080"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.autoThread","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.autoThreadName","kind":"channel","type":"string","required":false,"enumValues":["message","generated"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.ignoreOtherMentions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.includeThreadStarter","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1182,6 +1184,7 @@
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.autoArchiveDuration","kind":"channel","type":["number","string"],"required":false,"enumValues":["60","1440","4320","10080"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.autoThread","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.autoThreadName","kind":"channel","type":"string","required":false,"enumValues":["message","generated"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.ignoreOtherMentions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.includeThreadStarter","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1516,7 +1519,7 @@
|
||||
{"recordType":"path","path":"channels.feishu.reactionNotifications","kind":"channel","type":"string","required":true,"enumValues":["off","own","all"],"defaultValue":"own","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.renderMode","kind":"channel","type":"string","required":false,"enumValues":["auto","raw","card"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.requireMention","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.resolveSenderNames","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.streaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -2181,7 +2184,7 @@
|
||||
{"recordType":"path","path":"channels.mattermost.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Mattermost Require Mention","help":"Require @mention in channels before responding (default: true).","hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.msteams","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Microsoft Teams","help":"Bot Framework; enterprise support.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.msteams","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Microsoft Teams","help":"Teams SDK; enterprise support.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.msteams.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.msteams.allowFrom.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.msteams.appId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"exportName": "buildGoogleImageGenerationProvider",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 95,
|
||||
"line": 98,
|
||||
"path": "extensions/google/image-generation-provider.ts"
|
||||
}
|
||||
},
|
||||
@@ -2359,7 +2359,7 @@
|
||||
"exportName": "buildCommandsMessage",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 961,
|
||||
"line": 1049,
|
||||
"path": "src/auto-reply/status.ts"
|
||||
}
|
||||
},
|
||||
@@ -2368,7 +2368,7 @@
|
||||
"exportName": "buildCommandsMessagePaginated",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 970,
|
||||
"line": 1058,
|
||||
"path": "src/auto-reply/status.ts"
|
||||
}
|
||||
},
|
||||
@@ -2377,7 +2377,7 @@
|
||||
"exportName": "buildCommandsPaginationKeyboard",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 89,
|
||||
"line": 196,
|
||||
"path": "src/auto-reply/reply/commands-info.ts"
|
||||
}
|
||||
},
|
||||
@@ -2404,7 +2404,7 @@
|
||||
"exportName": "buildHelpMessage",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 841,
|
||||
"line": 844,
|
||||
"path": "src/auto-reply/status.ts"
|
||||
}
|
||||
},
|
||||
@@ -2620,7 +2620,7 @@
|
||||
"exportName": "resolveCommandAuthorization",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 440,
|
||||
"line": 461,
|
||||
"path": "src/auto-reply/command-auth.ts"
|
||||
}
|
||||
},
|
||||
@@ -3009,7 +3009,7 @@
|
||||
"exportName": "buildChannelOutboundSessionRoute",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 162,
|
||||
"line": 163,
|
||||
"path": "src/plugin-sdk/core.ts"
|
||||
}
|
||||
},
|
||||
@@ -3045,7 +3045,7 @@
|
||||
"exportName": "createChannelPluginBase",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 436,
|
||||
"line": 437,
|
||||
"path": "src/plugin-sdk/core.ts"
|
||||
}
|
||||
},
|
||||
@@ -3054,7 +3054,7 @@
|
||||
"exportName": "createChatChannelPlugin",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 413,
|
||||
"line": 414,
|
||||
"path": "src/plugin-sdk/core.ts"
|
||||
}
|
||||
},
|
||||
@@ -3063,7 +3063,7 @@
|
||||
"exportName": "defineChannelPluginEntry",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 245,
|
||||
"line": 246,
|
||||
"path": "src/plugin-sdk/core.ts"
|
||||
}
|
||||
},
|
||||
@@ -3081,7 +3081,7 @@
|
||||
"exportName": "defineSetupPluginEntry",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 276,
|
||||
"line": 277,
|
||||
"path": "src/plugin-sdk/core.ts"
|
||||
}
|
||||
},
|
||||
@@ -3135,7 +3135,7 @@
|
||||
"exportName": "getChatChannelMeta",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 158,
|
||||
"line": 155,
|
||||
"path": "src/channels/registry.ts"
|
||||
}
|
||||
},
|
||||
@@ -3229,6 +3229,15 @@
|
||||
"path": "src/shared/gateway-bind-url.ts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"declaration": "export function resolveGatewayPort(cfg?: OpenClawConfig | undefined, env?: ProcessEnv): number;",
|
||||
"exportName": "resolveGatewayPort",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 285,
|
||||
"path": "src/config/paths.ts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"declaration": "export function resolveTailnetHostWithRunner(runCommandWithTimeout?: TailscaleStatusCommandRunner | undefined): Promise<string | null>;",
|
||||
"exportName": "resolveTailnetHostWithRunner",
|
||||
@@ -3270,7 +3279,7 @@
|
||||
"exportName": "stripChannelTargetPrefix",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 142,
|
||||
"line": 143,
|
||||
"path": "src/plugin-sdk/core.ts"
|
||||
}
|
||||
},
|
||||
@@ -3279,7 +3288,7 @@
|
||||
"exportName": "stripTargetKindPrefix",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 154,
|
||||
"line": 155,
|
||||
"path": "src/plugin-sdk/core.ts"
|
||||
}
|
||||
},
|
||||
@@ -3351,7 +3360,7 @@
|
||||
"exportName": "ChannelOutboundSessionRouteParams",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 137,
|
||||
"line": 138,
|
||||
"path": "src/plugin-sdk/core.ts"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{"category":"legacy","entrypoint":"index","importSpecifier":"openclaw/plugin-sdk","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/index.ts"}
|
||||
{"declaration":"export function buildFalImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildFalImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":190,"sourcePath":"extensions/fal/image-generation-provider.ts"}
|
||||
{"declaration":"export function buildGoogleImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildGoogleImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":95,"sourcePath":"extensions/google/image-generation-provider.ts"}
|
||||
{"declaration":"export function buildGoogleImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildGoogleImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":98,"sourcePath":"extensions/google/image-generation-provider.ts"}
|
||||
{"declaration":"export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildOpenAIImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":22,"sourcePath":"extensions/openai/image-generation-provider.ts"}
|
||||
{"declaration":"export function delegateCompactionToRuntime(params: { sessionId: string; sessionKey?: string | undefined; sessionFile: string; tokenBudget?: number | undefined; force?: boolean | undefined; currentTokenCount?: number | undefined; compactionTarget?: \"budget\" | ... 1 more ... | undefined; customInstructions?: string | undefined; runtimeContext?: ContextEngineRuntimeContext | undefined; }): Promise<...>;","entrypoint":"index","exportName":"delegateCompactionToRuntime","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":16,"sourcePath":"src/context-engine/delegate.ts"}
|
||||
{"declaration":"export function emptyPluginConfigSchema(): OpenClawPluginConfigSchema;","entrypoint":"index","exportName":"emptyPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":13,"sourcePath":"src/plugins/config-schema.ts"}
|
||||
@@ -258,12 +258,12 @@
|
||||
{"declaration":"export type ChannelSetupWizard = ChannelSetupWizard;","entrypoint":"channel-setup","exportName":"ChannelSetupWizard","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":247,"sourcePath":"src/channels/plugins/setup-wizard.ts"}
|
||||
{"declaration":"export type OptionalChannelSetupSurface = OptionalChannelSetupSurface;","entrypoint":"channel-setup","exportName":"OptionalChannelSetupSurface","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":29,"sourcePath":"src/plugin-sdk/channel-setup.ts"}
|
||||
{"category":"channel","entrypoint":"command-auth","importSpecifier":"openclaw/plugin-sdk/command-auth","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/command-auth.ts"}
|
||||
{"declaration":"export function buildCommandsMessage(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandsMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":961,"sourcePath":"src/auto-reply/status.ts"}
|
||||
{"declaration":"export function buildCommandsMessagePaginated(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): CommandsMessageResult;","entrypoint":"command-auth","exportName":"buildCommandsMessagePaginated","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":970,"sourcePath":"src/auto-reply/status.ts"}
|
||||
{"declaration":"export function buildCommandsPaginationKeyboard(currentPage: number, totalPages: number, agentId?: string | undefined): { text: string; callback_data: string; }[][];","entrypoint":"command-auth","exportName":"buildCommandsPaginationKeyboard","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":89,"sourcePath":"src/auto-reply/reply/commands-info.ts"}
|
||||
{"declaration":"export function buildCommandsMessage(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandsMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":1049,"sourcePath":"src/auto-reply/status.ts"}
|
||||
{"declaration":"export function buildCommandsMessagePaginated(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): CommandsMessageResult;","entrypoint":"command-auth","exportName":"buildCommandsMessagePaginated","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":1058,"sourcePath":"src/auto-reply/status.ts"}
|
||||
{"declaration":"export function buildCommandsPaginationKeyboard(currentPage: number, totalPages: number, agentId?: string | undefined): { text: string; callback_data: string; }[][];","entrypoint":"command-auth","exportName":"buildCommandsPaginationKeyboard","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":196,"sourcePath":"src/auto-reply/reply/commands-info.ts"}
|
||||
{"declaration":"export function buildCommandText(commandName: string, args?: string | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandText","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":199,"sourcePath":"src/auto-reply/commands-registry.ts"}
|
||||
{"declaration":"export function buildCommandTextFromArgs(command: ChatCommandDefinition, args?: CommandArgs | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandTextFromArgs","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":291,"sourcePath":"src/auto-reply/commands-registry.ts"}
|
||||
{"declaration":"export function buildHelpMessage(cfg?: OpenClawConfig | undefined): string;","entrypoint":"command-auth","exportName":"buildHelpMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":841,"sourcePath":"src/auto-reply/status.ts"}
|
||||
{"declaration":"export function buildHelpMessage(cfg?: OpenClawConfig | undefined): string;","entrypoint":"command-auth","exportName":"buildHelpMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":844,"sourcePath":"src/auto-reply/status.ts"}
|
||||
{"declaration":"export function buildModelsProviderData(cfg: OpenClawConfig, agentId?: string | undefined): Promise<ModelsProviderData>;","entrypoint":"command-auth","exportName":"buildModelsProviderData","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":37,"sourcePath":"src/auto-reply/reply/commands-models.ts"}
|
||||
{"declaration":"export function createPreCryptoDirectDmAuthorizer(params: { resolveAccess: (senderId: string) => Promise<ResolvedInboundDirectDmAccess | Pick<ResolvedInboundDirectDmAccess, \"access\">>; issuePairingChallenge?: ((params: { ...; }) => Promise<...>) | undefined; onBlocked?: ((params: { ...; }) => void) | undefined; }): (input: { ...; }) => Promise<...>;","entrypoint":"command-auth","exportName":"createPreCryptoDirectDmAuthorizer","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":105,"sourcePath":"src/plugin-sdk/direct-dm.ts"}
|
||||
{"declaration":"export function findCommandByNativeName(name: string, provider?: string | undefined): ChatCommandDefinition | undefined;","entrypoint":"command-auth","exportName":"findCommandByNativeName","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":187,"sourcePath":"src/auto-reply/commands-registry.ts"}
|
||||
@@ -287,7 +287,7 @@
|
||||
{"declaration":"export function parseCommandArgs(command: ChatCommandDefinition, raw?: string | undefined): CommandArgs | undefined;","entrypoint":"command-auth","exportName":"parseCommandArgs","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":254,"sourcePath":"src/auto-reply/commands-registry.ts"}
|
||||
{"declaration":"export function resolveCommandArgChoices(params: { command: ChatCommandDefinition; arg: CommandArgDefinition; cfg?: OpenClawConfig | undefined; provider?: string | undefined; model?: string | undefined; }): ResolvedCommandArgChoice[];","entrypoint":"command-auth","exportName":"resolveCommandArgChoices","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":316,"sourcePath":"src/auto-reply/commands-registry.ts"}
|
||||
{"declaration":"export function resolveCommandArgMenu(params: { command: ChatCommandDefinition; args?: CommandArgs | undefined; cfg?: OpenClawConfig | undefined; }): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string | undefined; } | null;","entrypoint":"command-auth","exportName":"resolveCommandArgMenu","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":346,"sourcePath":"src/auto-reply/commands-registry.ts"}
|
||||
{"declaration":"export function resolveCommandAuthorization(params: { ctx: MsgContext; cfg: OpenClawConfig; commandAuthorized: boolean; }): CommandAuthorization;","entrypoint":"command-auth","exportName":"resolveCommandAuthorization","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":440,"sourcePath":"src/auto-reply/command-auth.ts"}
|
||||
{"declaration":"export function resolveCommandAuthorization(params: { ctx: MsgContext; cfg: OpenClawConfig; commandAuthorized: boolean; }): CommandAuthorization;","entrypoint":"command-auth","exportName":"resolveCommandAuthorization","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":461,"sourcePath":"src/auto-reply/command-auth.ts"}
|
||||
{"declaration":"export function resolveCommandAuthorizedFromAuthorizers(params: { useAccessGroups: boolean; authorizers: CommandAuthorizer[]; modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff | undefined; }): boolean;","entrypoint":"command-auth","exportName":"resolveCommandAuthorizedFromAuthorizers","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":8,"sourcePath":"src/channels/command-gating.ts"}
|
||||
{"declaration":"export function resolveControlCommandGate(params: { useAccessGroups: boolean; authorizers: CommandAuthorizer[]; allowTextCommands: boolean; hasControlCommand: boolean; modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff | undefined; }): { ...; };","entrypoint":"command-auth","exportName":"resolveControlCommandGate","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":31,"sourcePath":"src/channels/command-gating.ts"}
|
||||
{"declaration":"export function resolveDirectDmAuthorizationOutcome(params: { isGroup: boolean; dmPolicy: string; senderAllowedForCommands: boolean; }): \"disabled\" | \"unauthorized\" | \"allowed\";","entrypoint":"command-auth","exportName":"resolveDirectDmAuthorizationOutcome","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":120,"sourcePath":"src/plugin-sdk/command-auth.ts"}
|
||||
@@ -330,21 +330,21 @@
|
||||
{"declaration":"export function applyAccountNameToChannelSection(params: { cfg: OpenClawConfig; channelKey: string; accountId: string; name?: string | undefined; alwaysUseAccounts?: boolean | undefined; }): OpenClawConfig;","entrypoint":"core","exportName":"applyAccountNameToChannelSection","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":33,"sourcePath":"src/channels/plugins/setup-helpers.ts"}
|
||||
{"declaration":"export function buildAgentSessionKey(params: { agentId: string; channel: string; accountId?: string | null | undefined; peer?: RoutePeer | null | undefined; dmScope?: \"main\" | \"per-peer\" | \"per-channel-peer\" | \"per-account-channel-peer\" | undefined; identityLinks?: Record<...> | undefined; }): string;","entrypoint":"core","exportName":"buildAgentSessionKey","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":91,"sourcePath":"src/routing/resolve-route.ts"}
|
||||
{"declaration":"export function buildChannelConfigSchema(schema: ZodType<unknown, unknown, $ZodTypeInternals<unknown, unknown>>): ChannelConfigSchema;","entrypoint":"core","exportName":"buildChannelConfigSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":35,"sourcePath":"src/channels/plugins/config-schema.ts"}
|
||||
{"declaration":"export function buildChannelOutboundSessionRoute(params: { cfg: OpenClawConfig; agentId: string; channel: string; accountId?: string | null | undefined; peer: { kind: \"direct\" | \"group\" | \"channel\"; id: string; }; chatType: \"direct\" | \"group\" | \"channel\"; from: string; to: string; threadId?: string | ... 1 more ... | undefined; }): ChannelOutboundSessionRoute;","entrypoint":"core","exportName":"buildChannelOutboundSessionRoute","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":162,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function buildChannelOutboundSessionRoute(params: { cfg: OpenClawConfig; agentId: string; channel: string; accountId?: string | null | undefined; peer: { kind: \"direct\" | \"group\" | \"channel\"; id: string; }; chatType: \"direct\" | \"group\" | \"channel\"; from: string; to: string; threadId?: string | ... 1 more ... | undefined; }): ChannelOutboundSessionRoute;","entrypoint":"core","exportName":"buildChannelOutboundSessionRoute","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":163,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function channelTargetSchema(options?: { description?: string | undefined; } | undefined): TString;","entrypoint":"core","exportName":"channelTargetSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":38,"sourcePath":"src/agents/schema/typebox.ts"}
|
||||
{"declaration":"export function channelTargetsSchema(options?: { description?: string | undefined; } | undefined): TArray<TString>;","entrypoint":"core","exportName":"channelTargetsSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":44,"sourcePath":"src/agents/schema/typebox.ts"}
|
||||
{"declaration":"export function clearAccountEntryFields<TAccountEntry extends object>(params: { accounts?: Record<string, TAccountEntry> | undefined; accountId: string; fields: string[]; isValueSet?: ((value: unknown) => boolean) | undefined; markClearedOnFieldPresence?: boolean | undefined; }): { ...; };","entrypoint":"core","exportName":"clearAccountEntryFields","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":122,"sourcePath":"src/channels/plugins/config-helpers.ts"}
|
||||
{"declaration":"export function createChannelPluginBase<TResolvedAccount>(params: CreateChannelPluginBaseOptions<TResolvedAccount>): CreatedChannelPluginBase<TResolvedAccount>;","entrypoint":"core","exportName":"createChannelPluginBase","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":436,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function createChatChannelPlugin<TResolvedAccount extends { accountId?: string | null; }, Probe = unknown, Audit = unknown>(params: { base: ChatChannelPluginBase<TResolvedAccount, Probe, Audit>; security?: ChannelSecurityAdapter<TResolvedAccount> | ChatChannelSecurityOptions<...> | undefined; pairing?: ChannelPairingAdapter | ... 1 more ... | undefined; threading?: ChannelThreadingAdapter | ... 1 more ... | undefined; outbound?: ChannelOutboundAdapter | ... 1 more ... | undefined; }): ChannelPlugin<...>;","entrypoint":"core","exportName":"createChatChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":413,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function defineChannelPluginEntry<TPlugin>({ id, name, description, plugin, configSchema, setRuntime, registerFull, }: DefineChannelPluginEntryOptions<TPlugin>): DefinedPluginEntry;","entrypoint":"core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":245,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function createChannelPluginBase<TResolvedAccount>(params: CreateChannelPluginBaseOptions<TResolvedAccount>): CreatedChannelPluginBase<TResolvedAccount>;","entrypoint":"core","exportName":"createChannelPluginBase","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":437,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function createChatChannelPlugin<TResolvedAccount extends { accountId?: string | null; }, Probe = unknown, Audit = unknown>(params: { base: ChatChannelPluginBase<TResolvedAccount, Probe, Audit>; security?: ChannelSecurityAdapter<TResolvedAccount> | ChatChannelSecurityOptions<...> | undefined; pairing?: ChannelPairingAdapter | ... 1 more ... | undefined; threading?: ChannelThreadingAdapter | ... 1 more ... | undefined; outbound?: ChannelOutboundAdapter | ... 1 more ... | undefined; }): ChannelPlugin<...>;","entrypoint":"core","exportName":"createChatChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":414,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function defineChannelPluginEntry<TPlugin>({ id, name, description, plugin, configSchema, setRuntime, registerFull, }: DefineChannelPluginEntryOptions<TPlugin>): DefinedPluginEntry;","entrypoint":"core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":246,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"core","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":88,"sourcePath":"src/plugin-sdk/plugin-entry.ts"}
|
||||
{"declaration":"export function defineSetupPluginEntry<TPlugin>(plugin: TPlugin): { plugin: TPlugin; };","entrypoint":"core","exportName":"defineSetupPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":276,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function defineSetupPluginEntry<TPlugin>(plugin: TPlugin): { plugin: TPlugin; };","entrypoint":"core","exportName":"defineSetupPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":277,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function delegateCompactionToRuntime(params: { sessionId: string; sessionKey?: string | undefined; sessionFile: string; tokenBudget?: number | undefined; force?: boolean | undefined; currentTokenCount?: number | undefined; compactionTarget?: \"budget\" | ... 1 more ... | undefined; customInstructions?: string | undefined; runtimeContext?: ContextEngineRuntimeContext | undefined; }): Promise<...>;","entrypoint":"core","exportName":"delegateCompactionToRuntime","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":16,"sourcePath":"src/context-engine/delegate.ts"}
|
||||
{"declaration":"export function deleteAccountFromConfigSection(params: { cfg: OpenClawConfig; sectionKey: string; accountId: string; clearBaseFields?: string[] | undefined; }): OpenClawConfig;","entrypoint":"core","exportName":"deleteAccountFromConfigSection","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":60,"sourcePath":"src/channels/plugins/config-helpers.ts"}
|
||||
{"declaration":"export function emptyPluginConfigSchema(): OpenClawPluginConfigSchema;","entrypoint":"core","exportName":"emptyPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":13,"sourcePath":"src/plugins/config-schema.ts"}
|
||||
{"declaration":"export function enqueueKeyedTask<T>(params: { tails: Map<string, Promise<void>>; key: string; task: () => Promise<T>; hooks?: KeyedAsyncQueueHooks | undefined; }): Promise<...>;","entrypoint":"core","exportName":"enqueueKeyedTask","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":7,"sourcePath":"src/plugin-sdk/keyed-async-queue.ts"}
|
||||
{"declaration":"export function formatPairingApproveHint(channelId: string): string;","entrypoint":"core","exportName":"formatPairingApproveHint","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":17,"sourcePath":"src/channels/plugins/helpers.ts"}
|
||||
{"declaration":"export function getChatChannelMeta(id: \"telegram\" | \"whatsapp\" | \"discord\" | \"irc\" | \"googlechat\" | \"slack\" | \"signal\" | \"imessage\" | \"line\"): ChannelMeta;","entrypoint":"core","exportName":"getChatChannelMeta","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":158,"sourcePath":"src/channels/registry.ts"}
|
||||
{"declaration":"export function getChatChannelMeta(id: \"telegram\" | \"whatsapp\" | \"discord\" | \"irc\" | \"googlechat\" | \"slack\" | \"signal\" | \"imessage\" | \"line\"): ChannelMeta;","entrypoint":"core","exportName":"getChatChannelMeta","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":155,"sourcePath":"src/channels/registry.ts"}
|
||||
{"declaration":"export function isSecretRef(value: unknown): value is SecretRef;","entrypoint":"core","exportName":"isSecretRef","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":34,"sourcePath":"src/config/types.secrets.ts"}
|
||||
{"declaration":"export function loadSecretFileSync(filePath: string, label: string, options?: SecretFileReadOptions): SecretFileReadResult;","entrypoint":"core","exportName":"loadSecretFileSync","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":29,"sourcePath":"src/infra/secret-file.ts"}
|
||||
{"declaration":"export function migrateBaseNameToDefaultAccount(params: { cfg: OpenClawConfig; channelKey: string; alwaysUseAccounts?: boolean | undefined; }): OpenClawConfig;","entrypoint":"core","exportName":"migrateBaseNameToDefaultAccount","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":92,"sourcePath":"src/channels/plugins/setup-helpers.ts"}
|
||||
@@ -355,12 +355,13 @@
|
||||
{"declaration":"export function parseOptionalDelimitedEntries(value?: string | undefined): string[] | undefined;","entrypoint":"core","exportName":"parseOptionalDelimitedEntries","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":23,"sourcePath":"src/channels/plugins/helpers.ts"}
|
||||
{"declaration":"export function readSecretFileSync(filePath: string, label: string, options?: SecretFileReadOptions): string;","entrypoint":"core","exportName":"readSecretFileSync","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":118,"sourcePath":"src/infra/secret-file.ts"}
|
||||
{"declaration":"export function resolveGatewayBindUrl(params: { bind?: string | undefined; customBindHost?: string | undefined; scheme: \"ws\" | \"wss\"; port: number; pickTailnetHost: () => string | null; pickLanHost: () => string | null; }): GatewayBindUrlResult;","entrypoint":"core","exportName":"resolveGatewayBindUrl","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":11,"sourcePath":"src/shared/gateway-bind-url.ts"}
|
||||
{"declaration":"export function resolveGatewayPort(cfg?: OpenClawConfig | undefined, env?: ProcessEnv): number;","entrypoint":"core","exportName":"resolveGatewayPort","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":285,"sourcePath":"src/config/paths.ts"}
|
||||
{"declaration":"export function resolveTailnetHostWithRunner(runCommandWithTimeout?: TailscaleStatusCommandRunner | undefined): Promise<string | null>;","entrypoint":"core","exportName":"resolveTailnetHostWithRunner","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":43,"sourcePath":"src/shared/tailscale-status.ts"}
|
||||
{"declaration":"export function resolveThreadSessionKeys(params: { baseSessionKey: string; threadId?: string | null | undefined; parentSessionKey?: string | undefined; useSuffix?: boolean | undefined; normalizeThreadId?: ((threadId: string) => string) | undefined; }): { ...; };","entrypoint":"core","exportName":"resolveThreadSessionKeys","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":234,"sourcePath":"src/routing/session-key.ts"}
|
||||
{"declaration":"export function setAccountEnabledInConfigSection(params: { cfg: OpenClawConfig; sectionKey: string; accountId: string; enabled: boolean; allowTopLevel?: boolean | undefined; }): OpenClawConfig;","entrypoint":"core","exportName":"setAccountEnabledInConfigSection","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":16,"sourcePath":"src/channels/plugins/config-helpers.ts"}
|
||||
{"declaration":"export function stringEnum<T extends readonly string[]>(values: T, options?: StringEnumOptions<T>): TUnsafe<T[number]>;","entrypoint":"core","exportName":"stringEnum","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":15,"sourcePath":"src/agents/schema/typebox.ts"}
|
||||
{"declaration":"export function stripChannelTargetPrefix(raw: string, ...providers: string[]): string;","entrypoint":"core","exportName":"stripChannelTargetPrefix","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":142,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function stripTargetKindPrefix(raw: string): string;","entrypoint":"core","exportName":"stripTargetKindPrefix","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":154,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function stripChannelTargetPrefix(raw: string, ...providers: string[]): string;","entrypoint":"core","exportName":"stripChannelTargetPrefix","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":143,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function stripTargetKindPrefix(raw: string): string;","entrypoint":"core","exportName":"stripTargetKindPrefix","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":155,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function tryReadSecretFileSync(filePath: string | undefined, label: string, options?: SecretFileReadOptions): string | undefined;","entrypoint":"core","exportName":"tryReadSecretFileSync","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":130,"sourcePath":"src/infra/secret-file.ts"}
|
||||
{"declaration":"export const DEFAULT_ACCOUNT_ID: \"default\";","entrypoint":"core","exportName":"DEFAULT_ACCOUNT_ID","importSpecifier":"openclaw/plugin-sdk/core","kind":"const","recordType":"export","sourceLine":3,"sourcePath":"src/routing/account-id.ts"}
|
||||
{"declaration":"export const DEFAULT_SECRET_FILE_MAX_BYTES: number;","entrypoint":"core","exportName":"DEFAULT_SECRET_FILE_MAX_BYTES","importSpecifier":"openclaw/plugin-sdk/core","kind":"const","recordType":"export","sourceLine":5,"sourcePath":"src/infra/secret-file.ts"}
|
||||
@@ -368,7 +369,7 @@
|
||||
{"declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"core","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":482,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelMessagingAdapter = ChannelMessagingAdapter;","entrypoint":"core","exportName":"ChannelMessagingAdapter","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":395,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelOutboundSessionRoute = ChannelOutboundSessionRoute;","entrypoint":"core","exportName":"ChannelOutboundSessionRoute","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":309,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelOutboundSessionRouteParams = { cfg: OpenClawConfig; agentId: string; accountId?: string | null; target: string; resolvedTarget?: { to: string; kind: import(\"src/channels/plugins/types.core\").ChannelDirectoryEntryKind | \"channel\"; display?: string; source: \"normalized\" | \"directory\"; }; replyToId?: string | null; threadId?: string | number | null;};","entrypoint":"core","exportName":"ChannelOutboundSessionRouteParams","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":137,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export type ChannelOutboundSessionRouteParams = { cfg: OpenClawConfig; agentId: string; accountId?: string | null; target: string; resolvedTarget?: { to: string; kind: import(\"src/channels/plugins/types.core\").ChannelDirectoryEntryKind | \"channel\"; display?: string; source: \"normalized\" | \"directory\"; }; replyToId?: string | null; threadId?: string | number | null;};","entrypoint":"core","exportName":"ChannelOutboundSessionRouteParams","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":138,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export type ChannelPlugin = ChannelPlugin<ResolvedAccount, Probe, Audit>;","entrypoint":"core","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":55,"sourcePath":"src/channels/plugins/types.plugin.ts"}
|
||||
{"declaration":"export type GatewayBindUrlResult = GatewayBindUrlResult;","entrypoint":"core","exportName":"GatewayBindUrlResult","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1,"sourcePath":"src/shared/gateway-bind-url.ts"}
|
||||
{"declaration":"export type GatewayRequestHandlerOptions = GatewayRequestHandlerOptions;","entrypoint":"core","exportName":"GatewayRequestHandlerOptions","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":112,"sourcePath":"src/gateway/server-methods/types.ts"}
|
||||
|
||||
@@ -275,6 +275,68 @@ Triggered when the gateway starts:
|
||||
|
||||
- **`gateway:startup`**: After channels start and hooks are loaded
|
||||
|
||||
### Session Patch Events
|
||||
|
||||
Triggered when session properties are modified:
|
||||
|
||||
- **`session:patch`**: When a session is updated
|
||||
|
||||
#### Session Event Context
|
||||
|
||||
Session events include rich context about the session and changes:
|
||||
|
||||
```typescript
|
||||
{
|
||||
sessionEntry: SessionEntry, // The complete updated session entry
|
||||
patch: { // The patch object (only changed fields)
|
||||
// Session identity & labeling
|
||||
label?: string | null, // Human-readable session label
|
||||
|
||||
// AI model configuration
|
||||
model?: string | null, // Model override (e.g., "claude-opus-4-5")
|
||||
thinkingLevel?: string | null, // Thinking level ("off"|"low"|"med"|"high")
|
||||
verboseLevel?: string | null, // Verbose output level
|
||||
reasoningLevel?: string | null, // Reasoning mode override
|
||||
elevatedLevel?: string | null, // Elevated mode override
|
||||
responseUsage?: "off" | "tokens" | "full" | null, // Usage display mode
|
||||
|
||||
// Tool execution settings
|
||||
execHost?: string | null, // Exec host (sandbox|gateway|node)
|
||||
execSecurity?: string | null, // Security mode (deny|allowlist|full)
|
||||
execAsk?: string | null, // Approval mode (off|on-miss|always)
|
||||
execNode?: string | null, // Node ID for host=node
|
||||
|
||||
// Subagent coordination
|
||||
spawnedBy?: string | null, // Parent session key (for subagents)
|
||||
spawnDepth?: number | null, // Nesting depth (0 = root)
|
||||
|
||||
// Communication policies
|
||||
sendPolicy?: "allow" | "deny" | null, // Message send policy
|
||||
groupActivation?: "mention" | "always" | null, // Group chat activation
|
||||
},
|
||||
cfg: OpenClawConfig // Current gateway config
|
||||
}
|
||||
```
|
||||
|
||||
**Security note:** Only privileged clients (including the Control UI) can trigger `session:patch` events. Standard WebChat clients are blocked from patching sessions (see PR #20800), so the hook will not fire from those connections.
|
||||
|
||||
See `SessionsPatchParamsSchema` in `src/gateway/protocol/schema/sessions.ts` for the complete type definition.
|
||||
|
||||
#### Example: Session Patch Logger Hook
|
||||
|
||||
```typescript
|
||||
const handler = async (event) => {
|
||||
if (event.type !== "session" || event.action !== "patch") {
|
||||
return;
|
||||
}
|
||||
const { patch } = event.context;
|
||||
console.log(`[session-patch] Session updated: ${event.sessionKey}`);
|
||||
console.log(`[session-patch] Changes:`, patch);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
```
|
||||
|
||||
### Message Events
|
||||
|
||||
Triggered when messages are received or sent:
|
||||
|
||||
+15
-12
@@ -316,41 +316,43 @@ After approval, you can chat normally.
|
||||
|
||||
**1. Group policy** (`channels.feishu.groupPolicy`):
|
||||
|
||||
- `"open"` = allow everyone in groups (default)
|
||||
- `"open"` = allow everyone in groups
|
||||
- `"allowlist"` = only allow `groupAllowFrom`
|
||||
- `"disabled"` = disable group messages
|
||||
|
||||
**2. Mention requirement** (`channels.feishu.groups.<chat_id>.requireMention`):
|
||||
Default: `allowlist`
|
||||
|
||||
- `true` = require @mention (default)
|
||||
- `false` = respond without mentions
|
||||
**2. Mention requirement** (`channels.feishu.requireMention`, overridable via `channels.feishu.groups.<chat_id>.requireMention`):
|
||||
|
||||
- explicit `true` = require @mention
|
||||
- explicit `false` = respond without mentions
|
||||
- when unset and `groupPolicy: "open"` = default to `false`
|
||||
- when unset and `groupPolicy` is not `"open"` = default to `true`
|
||||
|
||||
---
|
||||
|
||||
## Group configuration examples
|
||||
|
||||
### Allow all groups, require @mention (default)
|
||||
### Allow all groups, no @mention required (default for open groups)
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
groupPolicy: "open",
|
||||
// Default requireMention: true
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Allow all groups, no @mention required
|
||||
### Allow all groups, but still require @mention
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
groups: {
|
||||
oc_xxx: { requireMention: false },
|
||||
},
|
||||
groupPolicy: "open",
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -680,9 +682,10 @@ Key options:
|
||||
| `channels.feishu.accounts.<id>.domain` | Per-account API domain override | `feishu` |
|
||||
| `channels.feishu.dmPolicy` | DM policy | `pairing` |
|
||||
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | - |
|
||||
| `channels.feishu.groupPolicy` | Group policy | `open` |
|
||||
| `channels.feishu.groupPolicy` | Group policy | `allowlist` |
|
||||
| `channels.feishu.groupAllowFrom` | Group allowlist | - |
|
||||
| `channels.feishu.groups.<chat_id>.requireMention` | Require @mention | `true` |
|
||||
| `channels.feishu.requireMention` | Default require @mention | conditional |
|
||||
| `channels.feishu.groups.<chat_id>.requireMention` | Per-group require @mention override | inherited |
|
||||
| `channels.feishu.groups.<chat_id>.enabled` | Enable group | `true` |
|
||||
| `channels.feishu.textChunkLimit` | Message chunk size | `2000` |
|
||||
| `channels.feishu.mediaMaxMb` | Media size limit | `30` |
|
||||
|
||||
@@ -74,7 +74,7 @@ If you see logs like:
|
||||
|
||||
Example (allow anyone in `#tuirc-dev` to talk to the bot):
|
||||
|
||||
```json55
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
irc: {
|
||||
@@ -95,7 +95,7 @@ That means you may see logs like `drop channel … (missing-mention)` unless the
|
||||
|
||||
To make the bot reply in an IRC channel **without needing a mention**, disable mention gating for that channel:
|
||||
|
||||
```json55
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
irc: {
|
||||
@@ -113,7 +113,7 @@ To make the bot reply in an IRC channel **without needing a mention**, disable m
|
||||
|
||||
Or to allow **all** IRC channels (no per-channel allowlist) and still reply without mentions:
|
||||
|
||||
```json55
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
irc: {
|
||||
@@ -133,7 +133,7 @@ To reduce risk, restrict tools for that channel.
|
||||
|
||||
### Same tools for everyone in the channel
|
||||
|
||||
```json55
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
irc: {
|
||||
@@ -154,7 +154,7 @@ To reduce risk, restrict tools for that channel.
|
||||
|
||||
Use `toolsBySender` to apply a stricter policy to `"*"` and a looser one to your nick:
|
||||
|
||||
```json55
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
irc: {
|
||||
|
||||
@@ -92,6 +92,13 @@ These run inside the agent loop or gateway pipeline:
|
||||
- **`session_start` / `session_end`**: session lifecycle boundaries.
|
||||
- **`gateway_start` / `gateway_stop`**: gateway lifecycle events.
|
||||
|
||||
Hook decision rules for outbound/tool guards:
|
||||
|
||||
- `before_tool_call`: `{ block: true }` is terminal and stops lower-priority handlers.
|
||||
- `before_tool_call`: `{ block: false }` is a no-op and does not clear a prior block.
|
||||
- `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers.
|
||||
- `message_sending`: `{ cancel: false }` is a no-op and does not clear a prior cancel.
|
||||
|
||||
See [Plugin hooks](/plugins/architecture#provider-runtime-hooks) for the hook API and registration details.
|
||||
|
||||
## Streaming + partial replies
|
||||
|
||||
+25
-1
@@ -70,11 +70,35 @@ Default mode is `gateway.reload.mode="hybrid"`.
|
||||
- One always-on process for routing, control plane, and channel connections.
|
||||
- Single multiplexed port for:
|
||||
- WebSocket control/RPC
|
||||
- HTTP APIs (OpenAI-compatible, Responses, tools invoke)
|
||||
- HTTP APIs, OpenAI compatible (`/v1/models`, `/v1/embeddings`, `/v1/chat/completions`, `/v1/responses`, `/tools/invoke`)
|
||||
- Control UI and hooks
|
||||
- Default bind mode: `loopback`.
|
||||
- Auth is required by default (`gateway.auth.token` / `gateway.auth.password`, or `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`).
|
||||
|
||||
## OpenAI-compatible endpoints
|
||||
|
||||
OpenClaw’s highest-leverage compatibility surface is now:
|
||||
|
||||
- `GET /v1/models`
|
||||
- `GET /v1/models/{id}`
|
||||
- `POST /v1/embeddings`
|
||||
- `POST /v1/chat/completions`
|
||||
- `POST /v1/responses`
|
||||
|
||||
Why this set matters:
|
||||
|
||||
- Most Open WebUI, LobeChat, and LibreChat integrations probe `/v1/models` first.
|
||||
- Many RAG and memory pipelines expect `/v1/embeddings`.
|
||||
- Agent-native clients increasingly prefer `/v1/responses`.
|
||||
|
||||
Planning note:
|
||||
|
||||
- `/v1/models` is agent-first: it returns `openclaw`, `openclaw/default`, and `openclaw/<agentId>`.
|
||||
- `openclaw/default` is the stable alias that always maps to the configured default agent.
|
||||
- Use `x-openclaw-model` when you want a backend provider/model override; otherwise the selected agent's normal model and embedding setup stays in control.
|
||||
|
||||
All of these run on the main Gateway port and use the same trusted operator auth boundary as the rest of the Gateway HTTP API.
|
||||
|
||||
### Port and bind precedence
|
||||
|
||||
| Setting | Resolution order |
|
||||
|
||||
+132
-12
@@ -14,6 +14,13 @@ This endpoint is **disabled by default**. Enable it in config first.
|
||||
- `POST /v1/chat/completions`
|
||||
- Same port as the Gateway (WS + HTTP multiplex): `http://<gateway-host>:<port>/v1/chat/completions`
|
||||
|
||||
When the Gateway’s OpenAI-compatible HTTP surface is enabled, it also serves:
|
||||
|
||||
- `GET /v1/models`
|
||||
- `GET /v1/models/{id}`
|
||||
- `POST /v1/embeddings`
|
||||
- `POST /v1/responses`
|
||||
|
||||
Under the hood, requests are executed as a normal Gateway agent run (same codepath as `openclaw agent`), so routing/permissions/config match your Gateway.
|
||||
|
||||
## Authentication
|
||||
@@ -41,20 +48,25 @@ Treat this endpoint as a **full operator-access** surface for the gateway instan
|
||||
|
||||
See [Security](/gateway/security) and [Remote access](/gateway/remote).
|
||||
|
||||
## Choosing an agent
|
||||
## Agent-first model contract
|
||||
|
||||
No custom headers required: encode the agent id in the OpenAI `model` field:
|
||||
OpenClaw treats the OpenAI `model` field as an **agent target**, not a raw provider model id.
|
||||
|
||||
- `model: "openclaw:<agentId>"` (example: `"openclaw:main"`, `"openclaw:beta"`)
|
||||
- `model: "agent:<agentId>"` (alias)
|
||||
- `model: "openclaw"` routes to the configured default agent.
|
||||
- `model: "openclaw/default"` also routes to the configured default agent.
|
||||
- `model: "openclaw/<agentId>"` routes to a specific agent.
|
||||
|
||||
Or target a specific OpenClaw agent by header:
|
||||
Optional request headers:
|
||||
|
||||
- `x-openclaw-agent-id: <agentId>` (default: `main`)
|
||||
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent.
|
||||
- `x-openclaw-agent-id: <agentId>` remains supported as a compatibility override.
|
||||
- `x-openclaw-session-key: <sessionKey>` fully controls session routing.
|
||||
- `x-openclaw-message-channel: <channel>` sets the synthetic ingress channel context for channel-aware prompts and policies.
|
||||
|
||||
Advanced:
|
||||
Compatibility aliases still accepted:
|
||||
|
||||
- `x-openclaw-session-key: <sessionKey>` to fully control session routing.
|
||||
- `model: "openclaw:<agentId>"`
|
||||
- `model: "agent:<agentId>"`
|
||||
|
||||
## Enabling the endpoint
|
||||
|
||||
@@ -94,6 +106,57 @@ By default the endpoint is **stateless per request** (a new session key is gener
|
||||
|
||||
If the request includes an OpenAI `user` string, the Gateway derives a stable session key from it, so repeated calls can share an agent session.
|
||||
|
||||
## Why this surface matters
|
||||
|
||||
This is the highest-leverage compatibility set for self-hosted frontends and tooling:
|
||||
|
||||
- Most Open WebUI, LobeChat, and LibreChat setups expect `/v1/models`.
|
||||
- Many RAG systems expect `/v1/embeddings`.
|
||||
- Existing OpenAI chat clients can usually start with `/v1/chat/completions`.
|
||||
- More agent-native clients increasingly prefer `/v1/responses`.
|
||||
|
||||
## Model list and agent routing
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="What does `/v1/models` return?">
|
||||
An OpenClaw agent-target list.
|
||||
|
||||
The returned ids are `openclaw`, `openclaw/default`, and `openclaw/<agentId>` entries.
|
||||
Use them directly as OpenAI `model` values.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Does `/v1/models` list agents or sub-agents?">
|
||||
It lists top-level agent targets, not backend provider models and not sub-agents.
|
||||
|
||||
Sub-agents remain internal execution topology. They do not appear as pseudo-models.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Why is `openclaw/default` included?">
|
||||
`openclaw/default` is the stable alias for the configured default agent.
|
||||
|
||||
That means clients can keep using one predictable id even if the real default agent id changes between environments.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="How do I override the backend model?">
|
||||
Use `x-openclaw-model`.
|
||||
|
||||
Examples:
|
||||
`x-openclaw-model: openai/gpt-5.4`
|
||||
`x-openclaw-model: gpt-5.4`
|
||||
|
||||
If you omit it, the selected agent runs with its normal configured model choice.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="How do embeddings fit this contract?">
|
||||
`/v1/embeddings` uses the same agent-target `model` ids.
|
||||
|
||||
Use `model: "openclaw/default"` or `model: "openclaw/<agentId>"`.
|
||||
When you need a specific embedding model, send it in `x-openclaw-model`.
|
||||
Without that header, the request passes through to the selected agent's normal embedding setup.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Streaming (SSE)
|
||||
|
||||
Set `stream: true` to receive Server-Sent Events (SSE):
|
||||
@@ -102,6 +165,30 @@ Set `stream: true` to receive Server-Sent Events (SSE):
|
||||
- Each event line is `data: <json>`
|
||||
- Stream ends with `data: [DONE]`
|
||||
|
||||
## Open WebUI quick setup
|
||||
|
||||
For a basic Open WebUI connection:
|
||||
|
||||
- Base URL: `http://127.0.0.1:18789/v1`
|
||||
- Docker on macOS base URL: `http://host.docker.internal:18789/v1`
|
||||
- API key: your Gateway bearer token
|
||||
- Model: `openclaw/default`
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- `GET /v1/models` should list `openclaw/default`
|
||||
- Open WebUI should use `openclaw/default` as the chat model id
|
||||
- If you want a specific backend provider/model for that agent, set the agent's normal default model or send `x-openclaw-model`
|
||||
|
||||
Quick smoke:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:18789/v1/models \
|
||||
-H 'Authorization: Bearer YOUR_TOKEN'
|
||||
```
|
||||
|
||||
If that returns `openclaw/default`, most Open WebUI setups can connect with the same base URL and token.
|
||||
|
||||
## Examples
|
||||
|
||||
Non-streaming:
|
||||
@@ -110,9 +197,8 @@ Non-streaming:
|
||||
curl -sS http://127.0.0.1:18789/v1/chat/completions \
|
||||
-H 'Authorization: Bearer YOUR_TOKEN' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'x-openclaw-agent-id: main' \
|
||||
-d '{
|
||||
"model": "openclaw",
|
||||
"model": "openclaw/default",
|
||||
"messages": [{"role":"user","content":"hi"}]
|
||||
}'
|
||||
```
|
||||
@@ -123,10 +209,44 @@ Streaming:
|
||||
curl -N http://127.0.0.1:18789/v1/chat/completions \
|
||||
-H 'Authorization: Bearer YOUR_TOKEN' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'x-openclaw-agent-id: main' \
|
||||
-H 'x-openclaw-model: openai/gpt-5.4' \
|
||||
-d '{
|
||||
"model": "openclaw",
|
||||
"model": "openclaw/research",
|
||||
"stream": true,
|
||||
"messages": [{"role":"user","content":"hi"}]
|
||||
}'
|
||||
```
|
||||
|
||||
List models:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:18789/v1/models \
|
||||
-H 'Authorization: Bearer YOUR_TOKEN'
|
||||
```
|
||||
|
||||
Fetch one model:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:18789/v1/models/openclaw%2Fdefault \
|
||||
-H 'Authorization: Bearer YOUR_TOKEN'
|
||||
```
|
||||
|
||||
Create embeddings:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:18789/v1/embeddings \
|
||||
-H 'Authorization: Bearer YOUR_TOKEN' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'x-openclaw-model: openai/text-embedding-3-small' \
|
||||
-d '{
|
||||
"model": "openclaw/default",
|
||||
"input": ["alpha", "beta"]
|
||||
}'
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `/v1/models` returns OpenClaw agent targets, not raw provider catalogs.
|
||||
- `openclaw/default` is always present so one stable id works across environments.
|
||||
- Backend provider/model overrides belong in `x-openclaw-model`, not the OpenAI `model` field.
|
||||
- `/v1/embeddings` supports `input` as a string or array of strings.
|
||||
|
||||
@@ -24,11 +24,22 @@ Operational behavior matches [OpenAI Chat Completions](/gateway/openai-http-api)
|
||||
|
||||
- use `Authorization: Bearer <token>` with the normal Gateway auth config
|
||||
- treat the endpoint as full operator access for the gateway instance
|
||||
- select agents with `model: "openclaw:<agentId>"`, `model: "agent:<agentId>"`, or `x-openclaw-agent-id`
|
||||
- select agents with `model: "openclaw"`, `model: "openclaw/default"`, `model: "openclaw/<agentId>"`, or `x-openclaw-agent-id`
|
||||
- use `x-openclaw-model` when you want to override the selected agent's backend model
|
||||
- use `x-openclaw-session-key` for explicit session routing
|
||||
- use `x-openclaw-message-channel` when you want a non-default synthetic ingress channel context
|
||||
|
||||
Enable or disable this endpoint with `gateway.http.endpoints.responses.enabled`.
|
||||
|
||||
The same compatibility surface also includes:
|
||||
|
||||
- `GET /v1/models`
|
||||
- `GET /v1/models/{id}`
|
||||
- `POST /v1/embeddings`
|
||||
- `POST /v1/chat/completions`
|
||||
|
||||
For the canonical explanation of how agent-target models, `openclaw/default`, embeddings pass-through, and backend model overrides fit together, see [OpenAI Chat Completions](/gateway/openai-http-api#agent-first-model-contract) and [Model list and agent routing](/gateway/openai-http-api#model-list-and-agent-routing).
|
||||
|
||||
## Session behavior
|
||||
|
||||
By default the endpoint is **stateless per request** (a new session key is generated each call).
|
||||
@@ -54,9 +65,12 @@ Accepted but **currently ignored**:
|
||||
- `reasoning`
|
||||
- `metadata`
|
||||
- `store`
|
||||
- `previous_response_id`
|
||||
- `truncation`
|
||||
|
||||
Supported:
|
||||
|
||||
- `previous_response_id`: OpenClaw reuses the earlier response session when the request stays within the same agent/user/requested-session scope.
|
||||
|
||||
## Items (input)
|
||||
|
||||
### `message`
|
||||
|
||||
@@ -181,6 +181,13 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
||||
- `source`: `core` or `plugin`
|
||||
- `pluginId`: plugin owner when `source="plugin"`
|
||||
- `optional`: whether a plugin tool is optional
|
||||
- Operators may call `tools.effective` (`operator.read`) to fetch the runtime-effective tool
|
||||
inventory for a session.
|
||||
- `sessionKey` is required.
|
||||
- The gateway derives trusted runtime context from the session server-side instead of accepting
|
||||
caller-supplied auth or delivery context.
|
||||
- The response is session-scoped and reflects what the active conversation can use right now,
|
||||
including core, plugin, and channel tools.
|
||||
|
||||
## Exec approvals
|
||||
|
||||
|
||||
+2
-3
@@ -2153,9 +2153,8 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
1. Upgrade to a current OpenClaw release (or run from source `main`), then restart the gateway.
|
||||
2. Make sure MiniMax is configured (wizard or JSON), or that a MiniMax API key
|
||||
exists in env/auth profiles so the provider can be injected.
|
||||
3. Use the exact model id (case-sensitive): `minimax/MiniMax-M2.7`,
|
||||
`minimax/MiniMax-M2.7-highspeed`, `minimax/MiniMax-M2.5`, or
|
||||
`minimax/MiniMax-M2.5-highspeed`.
|
||||
3. Use the exact model id (case-sensitive): `minimax/MiniMax-M2.7` or
|
||||
`minimax/MiniMax-M2.7-highspeed`.
|
||||
4. Run:
|
||||
|
||||
```bash
|
||||
|
||||
+21
-1
@@ -424,10 +424,16 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
|
||||
|
||||
## Docker runners (optional "works in Linux" checks)
|
||||
|
||||
These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted). They also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store:
|
||||
These Docker runners split into two buckets:
|
||||
|
||||
- Live-model runners: `test:docker:live-models` and `test:docker:live-gateway` run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted).
|
||||
- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:gateway-network`, and `test:docker:plugins` boot one or more real containers and verify higher-level integration paths.
|
||||
|
||||
The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store:
|
||||
|
||||
- Direct models: `pnpm test:docker:live-models` (script: `scripts/test-live-models-docker.sh`)
|
||||
- Gateway + dev agent: `pnpm test:docker:live-gateway` (script: `scripts/test-live-gateway-models-docker.sh`)
|
||||
- Open WebUI live smoke: `pnpm test:docker:openwebui` (script: `scripts/e2e/openwebui-docker.sh`)
|
||||
- Onboarding wizard (TTY, full scaffolding): `pnpm test:docker:onboard` (script: `scripts/e2e/onboard-docker.sh`)
|
||||
- Gateway networking (two containers, WS auth + health): `pnpm test:docker:gateway-network` (script: `scripts/e2e/gateway-network-docker.sh`)
|
||||
- Plugins (install smoke + `/plugin` alias + Claude-bundle restart semantics): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`)
|
||||
@@ -440,6 +446,17 @@ real Telegram/Discord/etc. channel workers inside the container.
|
||||
`test:docker:live-models` still runs `pnpm test:live`, so pass through
|
||||
`OPENCLAW_LIVE_GATEWAY_*` as well when you need to narrow or exclude gateway
|
||||
live coverage from that Docker lane.
|
||||
`test:docker:openwebui` is a higher-level compatibility smoke: it starts an
|
||||
OpenClaw gateway container with the OpenAI-compatible HTTP endpoints enabled,
|
||||
starts a pinned Open WebUI container against that gateway, signs in through
|
||||
Open WebUI, verifies `/api/models` exposes `openclaw/default`, then sends a
|
||||
real chat request through Open WebUI's `/api/chat/completions` proxy.
|
||||
The first run can be noticeably slower because Docker may need to pull the
|
||||
Open WebUI image and Open WebUI may need to finish its own cold-start setup.
|
||||
This lane expects a usable live model key, and `OPENCLAW_PROFILE_FILE`
|
||||
(`~/.profile` by default) is the primary way to provide it in Dockerized runs.
|
||||
Successful runs print a small JSON payload like `{ "ok": true, "model":
|
||||
"openclaw/default", ... }`.
|
||||
|
||||
Manual ACP plain-language thread smoke (not CI):
|
||||
|
||||
@@ -458,6 +475,9 @@ Useful env vars:
|
||||
- `OPENCLAW_LIVE_GATEWAY_MODELS=...` / `OPENCLAW_LIVE_MODELS=...` to narrow the run
|
||||
- `OPENCLAW_LIVE_GATEWAY_PROVIDERS=...` / `OPENCLAW_LIVE_PROVIDERS=...` to filter providers in-container
|
||||
- `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to ensure creds come from the profile store (not env)
|
||||
- `OPENCLAW_OPENWEBUI_MODEL=...` to choose the model exposed by the gateway for the Open WebUI smoke
|
||||
- `OPENCLAW_OPENWEBUI_PROMPT=...` to override the nonce-check prompt used by the Open WebUI smoke
|
||||
- `OPENWEBUI_IMAGE=...` to override the pinned Open WebUI image tag
|
||||
|
||||
## Docs sanity
|
||||
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ OpenClaw is a **self-hosted gateway** that connects your favorite chat apps —
|
||||
- **Agent-native**: built for coding agents with tool use, sessions, memory, and multi-agent routing
|
||||
- **Open source**: MIT licensed, community-driven
|
||||
|
||||
**What do you need?** Node 24 (recommended), or Node 22 LTS (`22.16+`) for compatibility, an API key from your chosen provider, and 5 minutes. For best quality and security, use the strongest latest-generation model available.
|
||||
**What do you need?** Node 24 (recommended), or Node 22 LTS (`22.14+`) for compatibility, an API key from your chosen provider, and 5 minutes. For best quality and security, use the strongest latest-generation model available.
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ The Ansible playbook installs and configures:
|
||||
1. **Tailscale** -- mesh VPN for secure remote access
|
||||
2. **UFW firewall** -- SSH + Tailscale ports only
|
||||
3. **Docker CE + Compose V2** -- for agent sandboxes
|
||||
4. **Node.js 24 + pnpm** -- runtime dependencies (Node 22 LTS, currently `22.16+`, remains supported)
|
||||
4. **Node.js 24 + pnpm** -- runtime dependencies (Node 22 LTS, currently `22.14+`, remains supported)
|
||||
5. **OpenClaw** -- host-based, not containerized
|
||||
6. **Systemd service** -- auto-start with security hardening
|
||||
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ Bun is an optional local runtime for running TypeScript directly (`bun run ...`,
|
||||
|
||||
Bun blocks dependency lifecycle scripts unless explicitly trusted. For this repo, the commonly blocked scripts are not required:
|
||||
|
||||
- `@whiskeysockets/baileys` `preinstall` -- checks Node major >= 20 (OpenClaw defaults to Node 24 and still supports Node 22 LTS, currently `22.16+`)
|
||||
- `@whiskeysockets/baileys` `preinstall` -- checks Node major >= 20 (OpenClaw defaults to Node 24 and still supports Node 22 LTS, currently `22.14+`)
|
||||
- `protobufjs` `postinstall` -- emits warnings about incompatible version schemes (no build artifacts)
|
||||
|
||||
If you hit a runtime issue that requires these scripts, trust them explicitly:
|
||||
|
||||
+20
-1
@@ -55,6 +55,10 @@ Docker is **optional**. Use it only if you want a containerized gateway or to va
|
||||
- generate a gateway token and write it to `.env`
|
||||
- start the gateway via Docker Compose
|
||||
|
||||
During setup, pre-start onboarding and config writes run through
|
||||
`openclaw-gateway` directly. `openclaw-cli` is for commands you run after
|
||||
the gateway container already exists.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Open the Control UI">
|
||||
@@ -94,7 +98,15 @@ If you prefer to run each step yourself instead of using the setup script:
|
||||
|
||||
```bash
|
||||
docker build -t openclaw:local -f Dockerfile .
|
||||
docker compose run --rm openclaw-cli onboard
|
||||
docker compose run --rm --no-deps --entrypoint node openclaw-gateway \
|
||||
dist/index.js onboard --mode local --no-install-daemon
|
||||
docker compose run --rm --no-deps --entrypoint node openclaw-gateway \
|
||||
dist/index.js config set gateway.mode local
|
||||
docker compose run --rm --no-deps --entrypoint node openclaw-gateway \
|
||||
dist/index.js config set gateway.bind lan
|
||||
docker compose run --rm --no-deps --entrypoint node openclaw-gateway \
|
||||
dist/index.js config set gateway.controlUi.allowedOrigins \
|
||||
'["http://localhost:18789","http://127.0.0.1:18789"]' --strict-json
|
||||
docker compose up -d openclaw-gateway
|
||||
```
|
||||
|
||||
@@ -104,6 +116,13 @@ or `OPENCLAW_HOME_VOLUME`, the setup script writes `docker-compose.extra.yml`;
|
||||
include it with `-f docker-compose.yml -f docker-compose.extra.yml`.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
Because `openclaw-cli` shares `openclaw-gateway`'s network namespace, it is a
|
||||
post-start tool. Before `docker compose up -d openclaw-gateway`, run onboarding
|
||||
and setup-time config writes through `openclaw-gateway` with
|
||||
`--no-deps --entrypoint node`.
|
||||
</Note>
|
||||
|
||||
### Environment variables
|
||||
|
||||
The setup script accepts these optional environment variables:
|
||||
|
||||
@@ -45,7 +45,7 @@ For all flags and CI/automation options, see [Installer internals](/install/inst
|
||||
|
||||
## System requirements
|
||||
|
||||
- **Node 24** (recommended) or Node 22.16+ — the installer script handles this automatically
|
||||
- **Node 24** (recommended) or Node 22.14+ — the installer script handles this automatically
|
||||
- **macOS, Linux, or Windows** — both native Windows and WSL2 are supported; WSL2 is more stable. See [Windows](/platforms/windows).
|
||||
- `pnpm` is only needed if you build from source
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ Recommended for most interactive installs on macOS/Linux/WSL.
|
||||
Supports macOS and Linux (including WSL). If macOS is detected, installs Homebrew if missing.
|
||||
</Step>
|
||||
<Step title="Ensure Node.js 24 by default">
|
||||
Checks Node version and installs Node 24 if needed (Homebrew on macOS, NodeSource setup scripts on Linux apt/dnf/yum). OpenClaw still supports Node 22 LTS, currently `22.16+`, for compatibility.
|
||||
Checks Node version and installs Node 24 if needed (Homebrew on macOS, NodeSource setup scripts on Linux apt/dnf/yum). OpenClaw still supports Node 22 LTS, currently `22.14+`, for compatibility.
|
||||
</Step>
|
||||
<Step title="Ensure Git">
|
||||
Installs Git if missing.
|
||||
@@ -257,7 +257,7 @@ Designed for environments where you want everything under a local prefix (defaul
|
||||
Requires PowerShell 5+.
|
||||
</Step>
|
||||
<Step title="Ensure Node.js 24 by default">
|
||||
If missing, attempts install via winget, then Chocolatey, then Scoop. Node 22 LTS, currently `22.16+`, remains supported for compatibility.
|
||||
If missing, attempts install via winget, then Chocolatey, then Scoop. Node 22 LTS, currently `22.14+`, remains supported for compatibility.
|
||||
</Step>
|
||||
<Step title="Install OpenClaw">
|
||||
- `npm` method (default): global npm install using selected `-Tag`
|
||||
|
||||
@@ -9,7 +9,7 @@ read_when:
|
||||
|
||||
# Node.js
|
||||
|
||||
OpenClaw requires **Node 22.16 or newer**. **Node 24 is the default and recommended runtime** for installs, CI, and release workflows. Node 22 remains supported via the active LTS line. The [installer script](/install#alternative-install-methods) will detect and install Node automatically — this page is for when you want to set up Node yourself and make sure everything is wired up correctly (versions, PATH, global installs).
|
||||
OpenClaw requires **Node 22.14 or newer**. **Node 24 is the default and recommended runtime** for installs, CI, and release workflows. Node 22 remains supported via the active LTS line. The [installer script](/install#alternative-install-methods) will detect and install Node automatically — this page is for when you want to set up Node yourself and make sure everything is wired up correctly (versions, PATH, global installs).
|
||||
|
||||
## Check your version
|
||||
|
||||
@@ -17,7 +17,7 @@ OpenClaw requires **Node 22.16 or newer**. **Node 24 is the default and recommen
|
||||
node -v
|
||||
```
|
||||
|
||||
If this prints `v24.x.x` or higher, you're on the recommended default. If it prints `v22.16.x` or higher, you're on the supported Node 22 LTS path, but we still recommend upgrading to Node 24 when convenient. If Node isn't installed or the version is too old, pick an install method below.
|
||||
If this prints `v24.x.x` or higher, you're on the recommended default. If it prints `v22.14.x` or higher, you're on the supported Node 22 LTS path, but we still recommend upgrading to Node 24 when convenient. If Node isn't installed or the version is too old, pick an install method below.
|
||||
|
||||
## Install Node
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Native Linux companion apps are planned. Contributions are welcome if you want t
|
||||
|
||||
## Beginner quick path (VPS)
|
||||
|
||||
1. Install Node 24 (recommended; Node 22 LTS, currently `22.16+`, still works for compatibility)
|
||||
1. Install Node 24 (recommended; Node 22 LTS, currently `22.14+`, still works for compatibility)
|
||||
2. `npm i -g openclaw@latest`
|
||||
3. `openclaw onboard --install-daemon`
|
||||
4. From your laptop: `ssh -N -L 18789:127.0.0.1:18789 <user>@<host>`
|
||||
|
||||
@@ -16,7 +16,7 @@ running (or attaches to an existing local Gateway if one is already running).
|
||||
|
||||
## Install the CLI (required for local mode)
|
||||
|
||||
Node 24 is the default runtime on the Mac. Node 22 LTS, currently `22.16+`, still works for compatibility. Then install `openclaw` globally:
|
||||
Node 24 is the default runtime on the Mac. Node 22 LTS, currently `22.14+`, still works for compatibility. Then install `openclaw` globally:
|
||||
|
||||
```bash
|
||||
npm install -g openclaw@<version>
|
||||
|
||||
@@ -14,7 +14,7 @@ This guide covers the necessary steps to build and run the OpenClaw macOS applic
|
||||
Before building the app, ensure you have the following installed:
|
||||
|
||||
1. **Xcode 26.2+**: Required for Swift development.
|
||||
2. **Node.js 24 & pnpm**: Recommended for the gateway, CLI, and packaging scripts. Node 22 LTS, currently `22.16+`, remains supported for compatibility.
|
||||
2. **Node.js 24 & pnpm**: Recommended for the gateway, CLI, and packaging scripts. Node 22 LTS, currently `22.14+`, remains supported for compatibility.
|
||||
|
||||
## 1. Install Dependencies
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ This app is usually built from [`scripts/package-mac-app.sh`](https://github.com
|
||||
- calls [`scripts/codesign-mac-app.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/codesign-mac-app.sh) to sign the main binary and app bundle so macOS treats each rebuild as the same signed bundle and keeps TCC permissions (notifications, accessibility, screen recording, mic, speech). For stable permissions, use a real signing identity; ad-hoc is opt-in and fragile (see [macOS permissions](/platforms/mac/permissions)).
|
||||
- uses `CODESIGN_TIMESTAMP=auto` by default; it enables trusted timestamps for Developer ID signatures. Set `CODESIGN_TIMESTAMP=off` to skip timestamping (offline debug builds).
|
||||
- inject build metadata into Info.plist: `OpenClawBuildTimestamp` (UTC) and `OpenClawGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
|
||||
- **Packaging defaults to Node 24**: the script runs TS builds and the Control UI build. Node 22 LTS, currently `22.16+`, remains supported for compatibility.
|
||||
- **Packaging defaults to Node 24**: the script runs TS builds and the Control UI build. Node 22 LTS, currently `22.14+`, remains supported for compatibility.
|
||||
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
|
||||
- runs a Team ID audit after signing and fails if any Mach-O inside the app bundle is signed by a different Team ID. Set `SKIP_TEAM_ID_CHECK=1` to bypass.
|
||||
|
||||
|
||||
@@ -144,6 +144,15 @@ A single plugin can register any number of capabilities via the `api` object:
|
||||
|
||||
For the full registration API, see [SDK Overview](/plugins/sdk-overview#registration-api).
|
||||
|
||||
Hook guard semantics to keep in mind:
|
||||
|
||||
- `before_tool_call`: `{ block: true }` is terminal and stops lower-priority handlers.
|
||||
- `before_tool_call`: `{ block: false }` is treated as no decision.
|
||||
- `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers.
|
||||
- `message_sending`: `{ cancel: false }` is treated as no decision.
|
||||
|
||||
See [SDK Overview hook decision semantics](/plugins/sdk-overview#hook-decision-semantics) for details.
|
||||
|
||||
## Registering agent tools
|
||||
|
||||
Tools are typed functions the LLM can call. They can be required (always
|
||||
|
||||
@@ -152,6 +152,13 @@ methods:
|
||||
| `api.on(hookName, handler, opts?)` | Typed lifecycle hook |
|
||||
| `api.onConversationBindingResolved(handler)` | Conversation binding callback |
|
||||
|
||||
### Hook decision semantics
|
||||
|
||||
- `before_tool_call`: returning `{ block: true }` is terminal. Once any handler sets it, lower-priority handlers are skipped.
|
||||
- `before_tool_call`: returning `{ block: false }` is treated as no decision (same as omitting `block`), not as an override.
|
||||
- `message_sending`: returning `{ cancel: true }` is terminal. Once any handler sets it, lower-priority handlers are skipped.
|
||||
- `message_sending`: returning `{ cancel: false }` is treated as no decision (same as omitting `cancel`), not as an override.
|
||||
|
||||
### API object fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|
||||
@@ -8,16 +8,12 @@ title: "MiniMax"
|
||||
|
||||
# MiniMax
|
||||
|
||||
OpenClaw's MiniMax provider defaults to **MiniMax M2.7** and keeps
|
||||
**MiniMax M2.5** in the catalog for compatibility.
|
||||
OpenClaw's MiniMax provider defaults to **MiniMax M2.7**.
|
||||
|
||||
## Model lineup
|
||||
|
||||
- `MiniMax-M2.7`: default hosted text model.
|
||||
- `MiniMax-M2.7-highspeed`: faster M2.7 text tier.
|
||||
- `MiniMax-M2.5`: previous text model, still available in the MiniMax catalog.
|
||||
- `MiniMax-M2.5-highspeed`: faster M2.5 text tier.
|
||||
- `MiniMax-VL-01`: vision model for text + image inputs.
|
||||
|
||||
## Choose a setup
|
||||
|
||||
@@ -80,24 +76,6 @@ Configure via CLI:
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.12 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "MiniMax-M2.5-highspeed",
|
||||
name: "MiniMax M2.5 Highspeed",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.12 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -128,46 +106,6 @@ Example below uses Opus as a concrete primary; swap to your preferred latest-gen
|
||||
}
|
||||
```
|
||||
|
||||
### Optional: Local via LM Studio (manual)
|
||||
|
||||
**Best for:** local inference with LM Studio.
|
||||
We have seen strong results with MiniMax M2.5 on powerful hardware (e.g. a
|
||||
desktop/server) using LM Studio's local server.
|
||||
|
||||
Configure manually via `openclaw.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "lmstudio/minimax-m2.5-gs32" },
|
||||
models: { "lmstudio/minimax-m2.5-gs32": { alias: "Minimax" } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
lmstudio: {
|
||||
baseUrl: "http://127.0.0.1:1234/v1",
|
||||
apiKey: "lmstudio",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "minimax-m2.5-gs32",
|
||||
name: "MiniMax M2.5 GS32",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 196608,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Configure via `openclaw configure`
|
||||
|
||||
Use the interactive config wizard to set MiniMax without editing JSON:
|
||||
@@ -190,7 +128,7 @@ Use the interactive config wizard to set MiniMax without editing JSON:
|
||||
|
||||
- Model refs are `minimax/<model>`.
|
||||
- Default text model: `MiniMax-M2.7`.
|
||||
- Alternate text models: `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`.
|
||||
- Alternate text model: `MiniMax-M2.7-highspeed`.
|
||||
- Coding Plan usage API: `https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains` (requires a coding plan key).
|
||||
- Update pricing values in `models.json` if you need exact cost tracking.
|
||||
- Referral link for MiniMax Coding Plan (10% off): [https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link](https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link)
|
||||
@@ -214,8 +152,6 @@ Make sure the model id is **case‑sensitive**:
|
||||
|
||||
- `minimax/MiniMax-M2.7`
|
||||
- `minimax/MiniMax-M2.7-highspeed`
|
||||
- `minimax/MiniMax-M2.5`
|
||||
- `minimax/MiniMax-M2.5-highspeed`
|
||||
|
||||
Then recheck with:
|
||||
|
||||
|
||||
@@ -42,6 +42,14 @@ OpenClaw has three public release lanes:
|
||||
- Run `pnpm release:check` before every tagged release
|
||||
- Run `RELEASE_TAG=vYYYY.M.D node --import tsx scripts/openclaw-npm-release-check.ts`
|
||||
(or the matching beta/correction tag) before approval
|
||||
- After npm publish, run
|
||||
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts YYYY.M.D`
|
||||
(or the matching beta/correction version) to verify the published registry
|
||||
install path in a fresh temp prefix
|
||||
- For stable correction releases like `YYYY.M.D-N`, the post-publish verifier
|
||||
also checks the same temp-prefix upgrade path from `YYYY.M.D` to `YYYY.M.D-N`
|
||||
so release corrections cannot silently leave older global installs on the
|
||||
base stable payload
|
||||
- npm release preflight fails closed unless the tarball includes both
|
||||
`dist/control-ui/index.html` and a non-empty `dist/control-ui/assets/` payload
|
||||
so we do not ship an empty browser dashboard again
|
||||
|
||||
@@ -26,6 +26,7 @@ title: "Tests"
|
||||
- Gateway integration: opt-in via `OPENCLAW_TEST_INCLUDE_GATEWAY=1 pnpm test` or `pnpm test:gateway`.
|
||||
- `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `forks` + adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=<n>` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs.
|
||||
- `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip.
|
||||
- `pnpm test:docker:openwebui`: Starts Dockerized OpenClaw + Open WebUI, signs in through Open WebUI, checks `/api/models`, then runs a real proxied chat through `/api/chat/completions`. Requires a usable live model key (for example OpenAI in `~/.profile`), pulls an external Open WebUI image, and is not expected to be CI-stable like the normal unit/e2e suites.
|
||||
|
||||
## Local PR gate
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ and a working chat session.
|
||||
|
||||
## What you need
|
||||
|
||||
- **Node.js** — Node 24 recommended (Node 22.16+ also supported)
|
||||
- **Node.js** — Node 24 recommended (Node 22.14+ also supported)
|
||||
- **An API key** from a model provider (Anthropic, OpenAI, Google, etc.) — onboarding will prompt you
|
||||
|
||||
<Tip>
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ For onboarding details, see [Onboarding (CLI)](/start/wizard).
|
||||
|
||||
## Prereqs (from source)
|
||||
|
||||
- Node 24 recommended (Node 22 LTS, currently `22.16+`, still supported)
|
||||
- Node 24 recommended (Node 22 LTS, currently `22.14+`, still supported)
|
||||
- `pnpm`
|
||||
- Docker (optional; only for containerized setup/e2e — see [Docker](/install/docker))
|
||||
|
||||
|
||||
@@ -258,6 +258,15 @@ Common registration methods:
|
||||
| `registerContextEngine` | Context engine |
|
||||
| `registerService` | Background service |
|
||||
|
||||
Hook guard behavior for typed lifecycle hooks:
|
||||
|
||||
- `before_tool_call`: `{ block: true }` is terminal; lower-priority handlers are skipped.
|
||||
- `before_tool_call`: `{ block: false }` is a no-op and does not clear an earlier block.
|
||||
- `message_sending`: `{ cancel: true }` is terminal; lower-priority handlers are skipped.
|
||||
- `message_sending`: `{ cancel: false }` is a no-op and does not clear an earlier cancel.
|
||||
|
||||
For full typed hook behavior, see [SDK Overview](/plugins/sdk-overview#hook-decision-semantics).
|
||||
|
||||
## Related
|
||||
|
||||
- [Building Plugins](/plugins/building-plugins) — create your own plugin
|
||||
|
||||
@@ -75,6 +75,7 @@ Text + native (when enabled):
|
||||
|
||||
- `/help`
|
||||
- `/commands`
|
||||
- `/tools [compact|verbose]` (show what the current agent can use right now; `verbose` adds descriptions)
|
||||
- `/skill <name> [input]` (run a skill by name)
|
||||
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
||||
- `/allowlist` (list/add/remove allowlist entries)
|
||||
@@ -157,6 +158,22 @@ Notes:
|
||||
- Example: `/prose` (OpenProse plugin) — see [OpenProse](/prose).
|
||||
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.
|
||||
|
||||
## `/tools`
|
||||
|
||||
`/tools` answers a runtime question, not a config question: **what this agent can use right now in
|
||||
this conversation**.
|
||||
|
||||
- Default `/tools` is compact and optimized for quick scanning.
|
||||
- `/tools verbose` adds short descriptions.
|
||||
- Native-command surfaces that support arguments expose the same mode switch as `compact|verbose`.
|
||||
- Results are session-scoped, so changing agent, channel, thread, sender authorization, or model can
|
||||
change the output.
|
||||
- `/tools` includes tools that are actually reachable at runtime, including core tools, connected
|
||||
plugin tools, and channel-owned tools.
|
||||
|
||||
For profile and override editing, use the Control UI Tools panel or config/catalog surfaces instead
|
||||
of treating `/tools` as a static catalog.
|
||||
|
||||
## Usage surfaces (what shows where)
|
||||
|
||||
- **Provider usage/quota** (example: “Claude 80% left”) shows up in `/status` for the current model provider when usage tracking is enabled.
|
||||
|
||||
+9
-9
@@ -10,7 +10,7 @@ title: "Text-to-Speech"
|
||||
# Text-to-speech (TTS)
|
||||
|
||||
OpenClaw can convert outbound replies into audio using ElevenLabs, Microsoft, or OpenAI.
|
||||
It works anywhere OpenClaw can send audio; Telegram gets a round voice-note bubble.
|
||||
It works anywhere OpenClaw can send audio.
|
||||
|
||||
## Supported services
|
||||
|
||||
@@ -170,7 +170,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
}
|
||||
```
|
||||
|
||||
### Only reply with audio after an inbound voice note
|
||||
### Only reply with audio after an inbound voice message
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -203,7 +203,7 @@ Then run:
|
||||
### Notes on fields
|
||||
|
||||
- `auto`: auto‑TTS mode (`off`, `always`, `inbound`, `tagged`).
|
||||
- `inbound` only sends audio after an inbound voice note.
|
||||
- `inbound` only sends audio after an inbound voice message.
|
||||
- `tagged` only sends audio when the reply includes `[[tts]]` tags.
|
||||
- `enabled`: legacy toggle (doctor migrates this to `auto`).
|
||||
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
|
||||
@@ -319,18 +319,18 @@ These override `messages.tts.*` for that host.
|
||||
|
||||
## Output formats (fixed)
|
||||
|
||||
- **Telegram**: Opus voice note (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
|
||||
- 48kHz / 64kbps is a good voice-note tradeoff and required for the round bubble.
|
||||
- **Feishu / Matrix / Telegram / WhatsApp**: Opus voice message (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
|
||||
- 48kHz / 64kbps is a good voice message tradeoff.
|
||||
- **Other channels**: MP3 (`mp3_44100_128` from ElevenLabs, `mp3` from OpenAI).
|
||||
- 44.1kHz / 128kbps is the default balance for speech clarity.
|
||||
- **Microsoft**: uses `microsoft.outputFormat` (default `audio-24khz-48kbitrate-mono-mp3`).
|
||||
- The bundled transport accepts an `outputFormat`, but not all formats are available from the service.
|
||||
- Output format values follow Microsoft Speech output formats (including Ogg/WebM Opus).
|
||||
- Telegram `sendVoice` accepts OGG/MP3/M4A; use OpenAI/ElevenLabs if you need
|
||||
guaranteed Opus voice notes. citeturn1search1
|
||||
guaranteed Opus voice messages.
|
||||
- If the configured Microsoft output format fails, OpenClaw retries with MP3.
|
||||
|
||||
OpenAI/ElevenLabs formats are fixed; Telegram expects Opus for voice-note UX.
|
||||
OpenAI/ElevenLabs output formats are fixed per channel (see above).
|
||||
|
||||
## Auto-TTS behavior
|
||||
|
||||
@@ -391,8 +391,8 @@ Notes:
|
||||
## Agent tool
|
||||
|
||||
The `tts` tool converts text to speech and returns an audio attachment for
|
||||
reply delivery. When the result is Telegram-compatible, OpenClaw marks it for
|
||||
voice-bubble delivery.
|
||||
reply delivery. When the channel is Feishu, Matrix, Telegram, or WhatsApp,
|
||||
the audio is delivered as a voice message rather than a file attachment.
|
||||
|
||||
## Gateway RPC
|
||||
|
||||
|
||||
+9
-9
@@ -10,7 +10,7 @@ title: "Text-to-Speech (legacy path)"
|
||||
# Text-to-speech (TTS)
|
||||
|
||||
OpenClaw can convert outbound replies into audio using ElevenLabs, Microsoft, or OpenAI.
|
||||
It works anywhere OpenClaw can send audio; Telegram gets a round voice-note bubble.
|
||||
It works anywhere OpenClaw can send audio.
|
||||
|
||||
## Supported services
|
||||
|
||||
@@ -170,7 +170,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
}
|
||||
```
|
||||
|
||||
### Only reply with audio after an inbound voice note
|
||||
### Only reply with audio after an inbound voice message
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -203,7 +203,7 @@ Then run:
|
||||
### Notes on fields
|
||||
|
||||
- `auto`: auto‑TTS mode (`off`, `always`, `inbound`, `tagged`).
|
||||
- `inbound` only sends audio after an inbound voice note.
|
||||
- `inbound` only sends audio after an inbound voice message.
|
||||
- `tagged` only sends audio when the reply includes `[[tts]]` tags.
|
||||
- `enabled`: legacy toggle (doctor migrates this to `auto`).
|
||||
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
|
||||
@@ -319,18 +319,18 @@ These override `messages.tts.*` for that host.
|
||||
|
||||
## Output formats (fixed)
|
||||
|
||||
- **Telegram**: Opus voice note (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
|
||||
- 48kHz / 64kbps is a good voice-note tradeoff and required for the round bubble.
|
||||
- **Feishu / Matrix / Telegram / WhatsApp**: Opus voice message (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
|
||||
- 48kHz / 64kbps is a good voice message tradeoff.
|
||||
- **Other channels**: MP3 (`mp3_44100_128` from ElevenLabs, `mp3` from OpenAI).
|
||||
- 44.1kHz / 128kbps is the default balance for speech clarity.
|
||||
- **Microsoft**: uses `microsoft.outputFormat` (default `audio-24khz-48kbitrate-mono-mp3`).
|
||||
- The bundled transport accepts an `outputFormat`, but not all formats are available from the service.
|
||||
- Output format values follow Microsoft Speech output formats (including Ogg/WebM Opus).
|
||||
- Telegram `sendVoice` accepts OGG/MP3/M4A; use OpenAI/ElevenLabs if you need
|
||||
guaranteed Opus voice notes. citeturn1search1
|
||||
guaranteed Opus voice messages.
|
||||
- If the configured Microsoft output format fails, OpenClaw retries with MP3.
|
||||
|
||||
OpenAI/ElevenLabs formats are fixed; Telegram expects Opus for voice-note UX.
|
||||
OpenAI/ElevenLabs output formats are fixed per channel (see above).
|
||||
|
||||
## Auto-TTS behavior
|
||||
|
||||
@@ -391,8 +391,8 @@ Notes:
|
||||
## Agent tool
|
||||
|
||||
The `tts` tool converts text to speech and returns an audio attachment for
|
||||
reply delivery. When the result is Telegram-compatible, OpenClaw marks it for
|
||||
voice-bubble delivery.
|
||||
reply delivery. When the channel is Feishu, Matrix, Telegram, or WhatsApp,
|
||||
the audio is delivered as a voice message rather than a file attachment.
|
||||
|
||||
## Gateway RPC
|
||||
|
||||
|
||||
+8
-4
@@ -33,10 +33,14 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
|
||||
|
||||
## Control UI agents tools panel
|
||||
|
||||
- The Control UI `/agents` Tools panel fetches a runtime catalog via `tools.catalog` and labels each
|
||||
tool as `core` or `plugin:<id>` (plus `optional` for optional plugin tools).
|
||||
- If `tools.catalog` is unavailable, the panel falls back to a built-in static list.
|
||||
- The panel edits profile and override config, but effective runtime access still follows policy
|
||||
- The Control UI `/agents` Tools panel has two separate views:
|
||||
- **Available Right Now** uses `tools.effective(sessionKey=...)` and shows what the current
|
||||
session can actually use at runtime, including core, plugin, and channel-owned tools.
|
||||
- **Tool Configuration** uses `tools.catalog` and stays focused on profiles, overrides, and
|
||||
catalog semantics.
|
||||
- Runtime availability is session-scoped. Switching sessions on the same agent can change the
|
||||
**Available Right Now** list.
|
||||
- The config editor does not imply runtime availability; effective access still follows policy
|
||||
precedence (`allow`/`deny`, per-agent and provider/channel overrides).
|
||||
|
||||
## Remote use
|
||||
|
||||
@@ -38,6 +38,53 @@ afterAll(async () => {
|
||||
await cleanupMockRuntimeFixtures();
|
||||
});
|
||||
|
||||
async function expectSessionEnsureFallback(params: {
|
||||
sessionKey: string;
|
||||
env?: Record<string, string>;
|
||||
expectNewAfterStatus: boolean;
|
||||
expectedRecordId?: string;
|
||||
}) {
|
||||
const previousEnv = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(params.env ?? {})) {
|
||||
previousEnv.set(key, process.env[key]);
|
||||
process.env[key] = value;
|
||||
}
|
||||
|
||||
try {
|
||||
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: params.sessionKey,
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
expect(handle.backend).toBe("acpx");
|
||||
if (params.expectedRecordId) {
|
||||
expect(handle.acpxRecordId).toBe(params.expectedRecordId);
|
||||
}
|
||||
|
||||
const logs = await readMockRuntimeLogEntries(logPath);
|
||||
const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure");
|
||||
const statusIndex = logs.findIndex((entry) => entry.kind === "status");
|
||||
const newIndex = logs.findIndex((entry) => entry.kind === "new");
|
||||
expect(ensureIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(statusIndex).toBeGreaterThan(ensureIndex);
|
||||
if (params.expectNewAfterStatus) {
|
||||
expect(newIndex).toBeGreaterThan(statusIndex);
|
||||
} else {
|
||||
expect(newIndex).toBe(-1);
|
||||
}
|
||||
} finally {
|
||||
for (const [key, value] of previousEnv.entries()) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("AcpxRuntime", () => {
|
||||
it("passes the shared ACP adapter contract suite", async () => {
|
||||
const fixture = await createMockRuntimeFixture();
|
||||
@@ -155,87 +202,38 @@ describe("AcpxRuntime", () => {
|
||||
});
|
||||
|
||||
it("replaces dead named sessions returned by sessions ensure", async () => {
|
||||
process.env.MOCK_ACPX_STATUS_STATUS = "dead";
|
||||
process.env.MOCK_ACPX_STATUS_SUMMARY = "queue owner unavailable";
|
||||
try {
|
||||
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||
const sessionKey = "agent:codex:acp:dead-session";
|
||||
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey,
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
expect(handle.backend).toBe("acpx");
|
||||
const logs = await readMockRuntimeLogEntries(logPath);
|
||||
const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure");
|
||||
const statusIndex = logs.findIndex((entry) => entry.kind === "status");
|
||||
const newIndex = logs.findIndex((entry) => entry.kind === "new");
|
||||
expect(ensureIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(statusIndex).toBeGreaterThan(ensureIndex);
|
||||
expect(newIndex).toBeGreaterThan(statusIndex);
|
||||
} finally {
|
||||
delete process.env.MOCK_ACPX_STATUS_STATUS;
|
||||
delete process.env.MOCK_ACPX_STATUS_SUMMARY;
|
||||
}
|
||||
await expectSessionEnsureFallback({
|
||||
sessionKey: "agent:codex:acp:dead-session",
|
||||
env: {
|
||||
MOCK_ACPX_STATUS_STATUS: "dead",
|
||||
MOCK_ACPX_STATUS_SUMMARY: "queue owner unavailable",
|
||||
},
|
||||
expectNewAfterStatus: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses a live named session when sessions ensure exits before returning identifiers", async () => {
|
||||
process.env.MOCK_ACPX_ENSURE_EXIT_1 = "1";
|
||||
process.env.MOCK_ACPX_STATUS_STATUS = "alive";
|
||||
try {
|
||||
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||
const sessionKey = "agent:codex:acp:ensure-fallback-alive";
|
||||
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey,
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
expect(handle.backend).toBe("acpx");
|
||||
expect(handle.acpxRecordId).toBe("rec-" + sessionKey);
|
||||
const logs = await readMockRuntimeLogEntries(logPath);
|
||||
const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure");
|
||||
const statusIndex = logs.findIndex((entry) => entry.kind === "status");
|
||||
const newIndex = logs.findIndex((entry) => entry.kind === "new");
|
||||
expect(ensureIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(statusIndex).toBeGreaterThan(ensureIndex);
|
||||
expect(newIndex).toBe(-1);
|
||||
} finally {
|
||||
delete process.env.MOCK_ACPX_ENSURE_EXIT_1;
|
||||
delete process.env.MOCK_ACPX_STATUS_STATUS;
|
||||
}
|
||||
await expectSessionEnsureFallback({
|
||||
sessionKey: "agent:codex:acp:ensure-fallback-alive",
|
||||
env: {
|
||||
MOCK_ACPX_ENSURE_EXIT_1: "1",
|
||||
MOCK_ACPX_STATUS_STATUS: "alive",
|
||||
},
|
||||
expectNewAfterStatus: false,
|
||||
expectedRecordId: "rec-agent:codex:acp:ensure-fallback-alive",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a fresh named session when sessions ensure exits and status is dead", async () => {
|
||||
process.env.MOCK_ACPX_ENSURE_EXIT_1 = "1";
|
||||
process.env.MOCK_ACPX_STATUS_STATUS = "dead";
|
||||
process.env.MOCK_ACPX_STATUS_SUMMARY = "queue owner unavailable";
|
||||
try {
|
||||
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||
const sessionKey = "agent:codex:acp:ensure-fallback-dead";
|
||||
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey,
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
expect(handle.backend).toBe("acpx");
|
||||
const logs = await readMockRuntimeLogEntries(logPath);
|
||||
const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure");
|
||||
const statusIndex = logs.findIndex((entry) => entry.kind === "status");
|
||||
const newIndex = logs.findIndex((entry) => entry.kind === "new");
|
||||
expect(ensureIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(statusIndex).toBeGreaterThan(ensureIndex);
|
||||
expect(newIndex).toBeGreaterThan(statusIndex);
|
||||
} finally {
|
||||
delete process.env.MOCK_ACPX_ENSURE_EXIT_1;
|
||||
delete process.env.MOCK_ACPX_STATUS_STATUS;
|
||||
delete process.env.MOCK_ACPX_STATUS_SUMMARY;
|
||||
}
|
||||
await expectSessionEnsureFallback({
|
||||
sessionKey: "agent:codex:acp:ensure-fallback-dead",
|
||||
env: {
|
||||
MOCK_ACPX_ENSURE_EXIT_1: "1",
|
||||
MOCK_ACPX_STATUS_STATUS: "dead",
|
||||
MOCK_ACPX_STATUS_SUMMARY: "queue owner unavailable",
|
||||
},
|
||||
expectNewAfterStatus: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes text plus image attachments into ACP prompt blocks", async () => {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
|
||||
describe("resolveBlueBubblesAccount", () => {
|
||||
it("treats SecretRef passwords as configured when serverUrl exists", () => {
|
||||
const resolved = resolveBlueBubblesAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "BLUEBUBBLES_PASSWORD",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.configured).toBe(true);
|
||||
expect(resolved.baseUrl).toBe("http://localhost:1234");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
||||
import {
|
||||
adaptScopedAccountAccessor,
|
||||
createScopedChannelConfigAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import {
|
||||
listBlueBubblesAccountIds,
|
||||
type ResolvedBlueBubblesAccount,
|
||||
resolveBlueBubblesAccount,
|
||||
resolveDefaultBlueBubblesAccountId,
|
||||
} from "./accounts.js";
|
||||
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||
import type { ChannelPlugin } from "./runtime-api.js";
|
||||
import { normalizeBlueBubblesHandle } from "./targets.js";
|
||||
|
||||
export const bluebubblesMeta = {
|
||||
id: "bluebubbles",
|
||||
label: "BlueBubbles",
|
||||
selectionLabel: "BlueBubbles (macOS app)",
|
||||
detailLabel: "BlueBubbles",
|
||||
docsPath: "/channels/bluebubbles",
|
||||
docsLabel: "bluebubbles",
|
||||
blurb: "iMessage via the BlueBubbles mac app + REST API.",
|
||||
systemImage: "bubble.left.and.text.bubble.right",
|
||||
aliases: ["bb"],
|
||||
order: 75,
|
||||
preferOver: ["imessage"],
|
||||
};
|
||||
|
||||
export const bluebubblesCapabilities: ChannelPlugin<ResolvedBlueBubblesAccount>["capabilities"] = {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: true,
|
||||
reactions: true,
|
||||
edit: true,
|
||||
unsend: true,
|
||||
reply: true,
|
||||
effects: true,
|
||||
groupManagement: true,
|
||||
};
|
||||
|
||||
export const bluebubblesReload = { configPrefixes: ["channels.bluebubbles"] };
|
||||
export const bluebubblesConfigSchema = buildChannelConfigSchema(BlueBubblesConfigSchema);
|
||||
|
||||
export const bluebubblesConfigAdapter =
|
||||
createScopedChannelConfigAdapter<ResolvedBlueBubblesAccount>({
|
||||
sectionKey: "bluebubbles",
|
||||
listAccountIds: listBlueBubblesAccountIds,
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveBlueBubblesAccount),
|
||||
defaultAccountId: resolveDefaultBlueBubblesAccountId,
|
||||
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
|
||||
resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom,
|
||||
formatAllowFrom: (allowFrom) =>
|
||||
formatNormalizedAllowFromEntries({
|
||||
allowFrom,
|
||||
normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
|
||||
}),
|
||||
});
|
||||
|
||||
export function describeBlueBubblesAccount(account: ResolvedBlueBubblesAccount) {
|
||||
return describeAccountSnapshot({
|
||||
account,
|
||||
configured: account.configured,
|
||||
extra: {
|
||||
baseUrl: account.baseUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,81 +1,31 @@
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
||||
import {
|
||||
adaptScopedAccountAccessor,
|
||||
createScopedChannelConfigAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import { type ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
import {
|
||||
listBlueBubblesAccountIds,
|
||||
type ResolvedBlueBubblesAccount,
|
||||
resolveBlueBubblesAccount,
|
||||
resolveDefaultBlueBubblesAccountId,
|
||||
} from "./accounts.js";
|
||||
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||
bluebubblesCapabilities,
|
||||
bluebubblesConfigAdapter,
|
||||
bluebubblesConfigSchema,
|
||||
bluebubblesMeta,
|
||||
bluebubblesReload,
|
||||
describeBlueBubblesAccount,
|
||||
} from "./channel-shared.js";
|
||||
import { blueBubblesSetupAdapter } from "./setup-core.js";
|
||||
import { blueBubblesSetupWizard } from "./setup-surface.js";
|
||||
import { normalizeBlueBubblesHandle } from "./targets.js";
|
||||
|
||||
const meta = {
|
||||
id: "bluebubbles",
|
||||
label: "BlueBubbles",
|
||||
selectionLabel: "BlueBubbles (macOS app)",
|
||||
detailLabel: "BlueBubbles",
|
||||
docsPath: "/channels/bluebubbles",
|
||||
docsLabel: "bluebubbles",
|
||||
blurb: "iMessage via the BlueBubbles mac app + REST API.",
|
||||
systemImage: "bubble.left.and.text.bubble.right",
|
||||
aliases: ["bb"],
|
||||
order: 75,
|
||||
preferOver: ["imessage"],
|
||||
} as const;
|
||||
|
||||
const bluebubblesConfigAdapter = createScopedChannelConfigAdapter<ResolvedBlueBubblesAccount>({
|
||||
sectionKey: "bluebubbles",
|
||||
listAccountIds: listBlueBubblesAccountIds,
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveBlueBubblesAccount),
|
||||
defaultAccountId: resolveDefaultBlueBubblesAccountId,
|
||||
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
|
||||
resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom,
|
||||
formatAllowFrom: (allowFrom) =>
|
||||
formatNormalizedAllowFromEntries({
|
||||
allowFrom,
|
||||
normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
|
||||
}),
|
||||
});
|
||||
|
||||
export const bluebubblesSetupPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
id: "bluebubbles",
|
||||
meta: {
|
||||
...meta,
|
||||
aliases: [...meta.aliases],
|
||||
preferOver: [...meta.preferOver],
|
||||
...bluebubblesMeta,
|
||||
aliases: [...bluebubblesMeta.aliases],
|
||||
preferOver: [...bluebubblesMeta.preferOver],
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: true,
|
||||
reactions: true,
|
||||
edit: true,
|
||||
unsend: true,
|
||||
reply: true,
|
||||
effects: true,
|
||||
groupManagement: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.bluebubbles"] },
|
||||
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
|
||||
capabilities: bluebubblesCapabilities,
|
||||
reload: bluebubblesReload,
|
||||
configSchema: bluebubblesConfigSchema,
|
||||
setupWizard: blueBubblesSetupWizard,
|
||||
config: {
|
||||
...bluebubblesConfigAdapter,
|
||||
isConfigured: (account) => account.configured,
|
||||
describeAccount: (account) =>
|
||||
describeAccountSnapshot({
|
||||
account,
|
||||
configured: account.configured,
|
||||
extra: {
|
||||
baseUrl: account.baseUrl,
|
||||
},
|
||||
}),
|
||||
describeAccount: (account) => describeBlueBubblesAccount(account),
|
||||
},
|
||||
setup: blueBubblesSetupAdapter,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
||||
import {
|
||||
adaptScopedAccountAccessor,
|
||||
createScopedChannelConfigAdapter,
|
||||
createScopedDmSecurityResolver,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import {
|
||||
@@ -25,15 +19,21 @@ import {
|
||||
resolveDefaultBlueBubblesAccountId,
|
||||
} from "./accounts.js";
|
||||
import { bluebubblesMessageActions } from "./actions.js";
|
||||
import {
|
||||
bluebubblesCapabilities,
|
||||
bluebubblesConfigAdapter,
|
||||
bluebubblesConfigSchema,
|
||||
bluebubblesMeta as meta,
|
||||
bluebubblesReload,
|
||||
describeBlueBubblesAccount,
|
||||
} from "./channel-shared.js";
|
||||
import type { BlueBubblesProbe } from "./channel.runtime.js";
|
||||
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
resolveBlueBubblesGroupRequireMention,
|
||||
resolveBlueBubblesGroupToolPolicy,
|
||||
} from "./group-policy.js";
|
||||
import type { ChannelAccountSnapshot, ChannelPlugin } from "./runtime-api.js";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
buildProbeChannelStatusSummary,
|
||||
collectBlueBubblesStatusIssues,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
@@ -57,20 +57,6 @@ const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport(
|
||||
"blueBubblesChannelRuntime",
|
||||
);
|
||||
|
||||
const bluebubblesConfigAdapter = createScopedChannelConfigAdapter<ResolvedBlueBubblesAccount>({
|
||||
sectionKey: "bluebubbles",
|
||||
listAccountIds: listBlueBubblesAccountIds,
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveBlueBubblesAccount),
|
||||
defaultAccountId: resolveDefaultBlueBubblesAccountId,
|
||||
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
|
||||
resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom,
|
||||
formatAllowFrom: (allowFrom) =>
|
||||
formatNormalizedAllowFromEntries({
|
||||
allowFrom,
|
||||
normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
|
||||
}),
|
||||
});
|
||||
|
||||
const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver<ResolvedBlueBubblesAccount>({
|
||||
channelKey: "bluebubbles",
|
||||
resolvePolicy: (account) => account.config.dmPolicy,
|
||||
@@ -90,53 +76,23 @@ const collectBlueBubblesSecurityWarnings =
|
||||
mentionGated: false,
|
||||
});
|
||||
|
||||
const meta = {
|
||||
id: "bluebubbles",
|
||||
label: "BlueBubbles",
|
||||
selectionLabel: "BlueBubbles (macOS app)",
|
||||
detailLabel: "BlueBubbles",
|
||||
docsPath: "/channels/bluebubbles",
|
||||
docsLabel: "bluebubbles",
|
||||
blurb: "iMessage via the BlueBubbles mac app + REST API.",
|
||||
systemImage: "bubble.left.and.text.bubble.right",
|
||||
aliases: ["bb"],
|
||||
order: 75,
|
||||
preferOver: ["imessage"],
|
||||
};
|
||||
|
||||
export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBubblesProbe> =
|
||||
createChatChannelPlugin<ResolvedBlueBubblesAccount, BlueBubblesProbe>({
|
||||
base: {
|
||||
id: "bluebubbles",
|
||||
meta,
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: true,
|
||||
reactions: true,
|
||||
edit: true,
|
||||
unsend: true,
|
||||
reply: true,
|
||||
effects: true,
|
||||
groupManagement: true,
|
||||
},
|
||||
capabilities: bluebubblesCapabilities,
|
||||
groups: {
|
||||
resolveRequireMention: resolveBlueBubblesGroupRequireMention,
|
||||
resolveToolPolicy: resolveBlueBubblesGroupToolPolicy,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.bluebubbles"] },
|
||||
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
|
||||
reload: bluebubblesReload,
|
||||
configSchema: bluebubblesConfigSchema,
|
||||
setupWizard: blueBubblesSetupWizard,
|
||||
config: {
|
||||
...bluebubblesConfigAdapter,
|
||||
isConfigured: (account) => account.configured,
|
||||
describeAccount: (account): ChannelAccountSnapshot =>
|
||||
describeAccountSnapshot({
|
||||
account,
|
||||
configured: account.configured,
|
||||
extra: {
|
||||
baseUrl: account.baseUrl,
|
||||
},
|
||||
}),
|
||||
describeAccount: (account): ChannelAccountSnapshot => describeBlueBubblesAccount(account),
|
||||
},
|
||||
actions: bluebubblesMessageActions,
|
||||
messaging: {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||
|
||||
describe("BlueBubblesConfigSchema", () => {
|
||||
it("accepts account config when serverUrl and password are both set", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts SecretRef password when serverUrl is set", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "BLUEBUBBLES_PASSWORD",
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires password when top-level serverUrl is configured", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
serverUrl: "http://localhost:1234",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) {
|
||||
return;
|
||||
}
|
||||
expect(parsed.error.issues[0]?.path).toEqual(["password"]);
|
||||
expect(parsed.error.issues[0]?.message).toBe(
|
||||
"password is required when serverUrl is configured",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires password when account serverUrl is configured", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
accounts: {
|
||||
work: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) {
|
||||
return;
|
||||
}
|
||||
expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "password"]);
|
||||
expect(parsed.error.issues[0]?.message).toBe(
|
||||
"password is required when serverUrl is configured",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows password omission when serverUrl is not configured", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
accounts: {
|
||||
work: {
|
||||
name: "Work iMessage",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveBlueBubblesGroupRequireMention,
|
||||
resolveBlueBubblesGroupToolPolicy,
|
||||
} from "./group-policy.js";
|
||||
|
||||
describe("bluebubbles group policy", () => {
|
||||
it("uses generic channel group policy helpers", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
groups: {
|
||||
"chat:primary": {
|
||||
requireMention: false,
|
||||
tools: { deny: ["exec"] },
|
||||
},
|
||||
"*": {
|
||||
requireMention: true,
|
||||
tools: { allow: ["message.send"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:primary" })).toBe(false);
|
||||
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true);
|
||||
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:primary" })).toEqual({
|
||||
deny: ["exec"],
|
||||
});
|
||||
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({
|
||||
allow: ["message.send"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,20 @@ import {
|
||||
type WizardPrompter,
|
||||
} from "../../../test/helpers/extensions/setup-wizard.js";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
resolveBlueBubblesGroupRequireMention,
|
||||
resolveBlueBubblesGroupToolPolicy,
|
||||
} from "./group-policy.js";
|
||||
import {
|
||||
inferBlueBubblesTargetChatType,
|
||||
isAllowedBlueBubblesSender,
|
||||
looksLikeBlueBubblesExplicitTargetId,
|
||||
looksLikeBlueBubblesTargetId,
|
||||
normalizeBlueBubblesMessagingTarget,
|
||||
parseBlueBubblesAllowTarget,
|
||||
parseBlueBubblesTarget,
|
||||
} from "./targets.js";
|
||||
import { DEFAULT_WEBHOOK_PATH } from "./webhook-shared.js";
|
||||
|
||||
async function createBlueBubblesConfigureAdapter() {
|
||||
@@ -138,3 +152,337 @@ describe("bluebubbles setup surface", () => {
|
||||
expect(next?.channels?.bluebubbles?.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBlueBubblesAccount", () => {
|
||||
it("treats SecretRef passwords as configured when serverUrl exists", () => {
|
||||
const resolved = resolveBlueBubblesAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "BLUEBUBBLES_PASSWORD",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.configured).toBe(true);
|
||||
expect(resolved.baseUrl).toBe("http://localhost:1234");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BlueBubblesConfigSchema", () => {
|
||||
it("accepts account config when serverUrl and password are both set", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts SecretRef password when serverUrl is set", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "BLUEBUBBLES_PASSWORD",
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires password when top-level serverUrl is configured", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
serverUrl: "http://localhost:1234",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) {
|
||||
return;
|
||||
}
|
||||
expect(parsed.error.issues[0]?.path).toEqual(["password"]);
|
||||
expect(parsed.error.issues[0]?.message).toBe(
|
||||
"password is required when serverUrl is configured",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires password when account serverUrl is configured", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
accounts: {
|
||||
work: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) {
|
||||
return;
|
||||
}
|
||||
expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "password"]);
|
||||
expect(parsed.error.issues[0]?.message).toBe(
|
||||
"password is required when serverUrl is configured",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows password omission when serverUrl is not configured", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
accounts: {
|
||||
work: {
|
||||
name: "Work iMessage",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bluebubbles group policy", () => {
|
||||
it("uses generic channel group policy helpers", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
groups: {
|
||||
"chat:primary": {
|
||||
requireMention: false,
|
||||
tools: { deny: ["exec"] },
|
||||
},
|
||||
"*": {
|
||||
requireMention: true,
|
||||
tools: { allow: ["message.send"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:primary" })).toBe(false);
|
||||
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true);
|
||||
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:primary" })).toEqual({
|
||||
deny: ["exec"],
|
||||
});
|
||||
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({
|
||||
allow: ["message.send"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeBlueBubblesMessagingTarget", () => {
|
||||
it("normalizes chat_guid targets", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123");
|
||||
});
|
||||
|
||||
it("normalizes group numeric targets to chat_id", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123");
|
||||
});
|
||||
|
||||
it("strips provider prefix and normalizes handles", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe(
|
||||
"imessage:user@example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts handle from DM chat_guid for cross-context matching", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429")).toBe(
|
||||
"+19257864429",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567")).toBe(
|
||||
"+15551234567",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;user@example.com")).toBe(
|
||||
"user@example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves group chat_guid format", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789")).toBe(
|
||||
"chat_guid:iMessage;+;chat123456789",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes raw chat_guid values", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe(
|
||||
"chat_guid:iMessage;+;chat660250192681427962",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429");
|
||||
});
|
||||
|
||||
it("normalizes chat<digits> pattern to chat_identifier format", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe(
|
||||
"chat_identifier:chat660250192681427962",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_identifier:chat123");
|
||||
expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_identifier:Chat456789");
|
||||
});
|
||||
|
||||
it("normalizes UUID/hex chat identifiers", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(
|
||||
"chat_identifier:8b9c1a10536d4d86a336ea03ab7151cc",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(
|
||||
"chat_identifier:1C2D3E4F-1234-5678-9ABC-DEF012345678",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeBlueBubblesTargetId", () => {
|
||||
it("accepts chat targets", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("chat_guid:ABC-123")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts email handles", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("user@example.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts phone numbers with punctuation", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts raw chat_guid values", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts chat<digits> pattern as chat_id", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true);
|
||||
expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true);
|
||||
expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts UUID/hex chat identifiers", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(true);
|
||||
expect(looksLikeBlueBubblesTargetId("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects display names", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeBlueBubblesExplicitTargetId", () => {
|
||||
it("treats explicit chat targets as immediate ids", () => {
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("chat_guid:ABC-123")).toBe(true);
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("imessage:+15551234567")).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers directory fallback for bare handles and phone numbers", () => {
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("+1 (555) 123-4567")).toBe(false);
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("user@example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferBlueBubblesTargetChatType", () => {
|
||||
it("infers direct chat for handles and dm chat_guids", () => {
|
||||
expect(inferBlueBubblesTargetChatType("+15551234567")).toBe("direct");
|
||||
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;-;+15551234567")).toBe("direct");
|
||||
});
|
||||
|
||||
it("infers group chat for explicit group targets", () => {
|
||||
expect(inferBlueBubblesTargetChatType("chat_id:123")).toBe("group");
|
||||
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;+;chat123")).toBe("group");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBlueBubblesTarget", () => {
|
||||
it("parses chat<digits> pattern as chat_identifier", () => {
|
||||
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat660250192681427962",
|
||||
});
|
||||
expect(parseBlueBubblesTarget("chat123")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat123",
|
||||
});
|
||||
expect(parseBlueBubblesTarget("Chat456789")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "Chat456789",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses UUID/hex chat identifiers as chat_identifier", () => {
|
||||
expect(parseBlueBubblesTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
|
||||
});
|
||||
expect(parseBlueBubblesTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses explicit chat_id: prefix", () => {
|
||||
expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 });
|
||||
});
|
||||
|
||||
it("parses phone numbers as handles", () => {
|
||||
expect(parseBlueBubblesTarget("+19257864429")).toEqual({
|
||||
kind: "handle",
|
||||
to: "+19257864429",
|
||||
service: "auto",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses raw chat_guid format", () => {
|
||||
expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({
|
||||
kind: "chat_guid",
|
||||
chatGuid: "iMessage;+;chat660250192681427962",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBlueBubblesAllowTarget", () => {
|
||||
it("parses chat<digits> pattern as chat_identifier", () => {
|
||||
expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat660250192681427962",
|
||||
});
|
||||
expect(parseBlueBubblesAllowTarget("chat123")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat123",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses UUID/hex chat identifiers as chat_identifier", () => {
|
||||
expect(parseBlueBubblesAllowTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
|
||||
});
|
||||
expect(parseBlueBubblesAllowTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses explicit chat_id: prefix", () => {
|
||||
expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 });
|
||||
});
|
||||
|
||||
it("parses phone numbers as handles", () => {
|
||||
expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({
|
||||
kind: "handle",
|
||||
handle: "+19257864429",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAllowedBlueBubblesSender", () => {
|
||||
it("denies when allowFrom is empty", () => {
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
allowFrom: [],
|
||||
sender: "+15551234567",
|
||||
});
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("allows wildcard entries", () => {
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
allowFrom: ["*"],
|
||||
sender: "+15551234567",
|
||||
});
|
||||
expect(allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
inferBlueBubblesTargetChatType,
|
||||
looksLikeBlueBubblesExplicitTargetId,
|
||||
isAllowedBlueBubblesSender,
|
||||
looksLikeBlueBubblesTargetId,
|
||||
normalizeBlueBubblesMessagingTarget,
|
||||
parseBlueBubblesTarget,
|
||||
parseBlueBubblesAllowTarget,
|
||||
} from "./targets.js";
|
||||
|
||||
describe("normalizeBlueBubblesMessagingTarget", () => {
|
||||
it("normalizes chat_guid targets", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123");
|
||||
});
|
||||
|
||||
it("normalizes group numeric targets to chat_id", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123");
|
||||
});
|
||||
|
||||
it("strips provider prefix and normalizes handles", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe(
|
||||
"imessage:user@example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts handle from DM chat_guid for cross-context matching", () => {
|
||||
// DM format: service;-;handle
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429")).toBe(
|
||||
"+19257864429",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567")).toBe(
|
||||
"+15551234567",
|
||||
);
|
||||
// Email handles
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;user@example.com")).toBe(
|
||||
"user@example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves group chat_guid format", () => {
|
||||
// Group format: service;+;groupId
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789")).toBe(
|
||||
"chat_guid:iMessage;+;chat123456789",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes raw chat_guid values", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe(
|
||||
"chat_guid:iMessage;+;chat660250192681427962",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429");
|
||||
});
|
||||
|
||||
it("normalizes chat<digits> pattern to chat_identifier format", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe(
|
||||
"chat_identifier:chat660250192681427962",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_identifier:chat123");
|
||||
expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_identifier:Chat456789");
|
||||
});
|
||||
|
||||
it("normalizes UUID/hex chat identifiers", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(
|
||||
"chat_identifier:8b9c1a10536d4d86a336ea03ab7151cc",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(
|
||||
"chat_identifier:1C2D3E4F-1234-5678-9ABC-DEF012345678",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeBlueBubblesTargetId", () => {
|
||||
it("accepts chat targets", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("chat_guid:ABC-123")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts email handles", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("user@example.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts phone numbers with punctuation", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts raw chat_guid values", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts chat<digits> pattern as chat_id", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true);
|
||||
expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true);
|
||||
expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts UUID/hex chat identifiers", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(true);
|
||||
expect(looksLikeBlueBubblesTargetId("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects display names", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeBlueBubblesExplicitTargetId", () => {
|
||||
it("treats explicit chat targets as immediate ids", () => {
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("chat_guid:ABC-123")).toBe(true);
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("imessage:+15551234567")).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers directory fallback for bare handles and phone numbers", () => {
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("+1 (555) 123-4567")).toBe(false);
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("user@example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferBlueBubblesTargetChatType", () => {
|
||||
it("infers direct chat for handles and dm chat_guids", () => {
|
||||
expect(inferBlueBubblesTargetChatType("+15551234567")).toBe("direct");
|
||||
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;-;+15551234567")).toBe("direct");
|
||||
});
|
||||
|
||||
it("infers group chat for explicit group targets", () => {
|
||||
expect(inferBlueBubblesTargetChatType("chat_id:123")).toBe("group");
|
||||
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;+;chat123")).toBe("group");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBlueBubblesTarget", () => {
|
||||
it("parses chat<digits> pattern as chat_identifier", () => {
|
||||
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat660250192681427962",
|
||||
});
|
||||
expect(parseBlueBubblesTarget("chat123")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat123",
|
||||
});
|
||||
expect(parseBlueBubblesTarget("Chat456789")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "Chat456789",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses UUID/hex chat identifiers as chat_identifier", () => {
|
||||
expect(parseBlueBubblesTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
|
||||
});
|
||||
expect(parseBlueBubblesTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses explicit chat_id: prefix", () => {
|
||||
expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 });
|
||||
});
|
||||
|
||||
it("parses phone numbers as handles", () => {
|
||||
expect(parseBlueBubblesTarget("+19257864429")).toEqual({
|
||||
kind: "handle",
|
||||
to: "+19257864429",
|
||||
service: "auto",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses raw chat_guid format", () => {
|
||||
expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({
|
||||
kind: "chat_guid",
|
||||
chatGuid: "iMessage;+;chat660250192681427962",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBlueBubblesAllowTarget", () => {
|
||||
it("parses chat<digits> pattern as chat_identifier", () => {
|
||||
expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat660250192681427962",
|
||||
});
|
||||
expect(parseBlueBubblesAllowTarget("chat123")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat123",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses UUID/hex chat identifiers as chat_identifier", () => {
|
||||
expect(parseBlueBubblesAllowTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
|
||||
});
|
||||
expect(parseBlueBubblesAllowTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses explicit chat_id: prefix", () => {
|
||||
expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 });
|
||||
});
|
||||
|
||||
it("parses phone numbers as handles", () => {
|
||||
expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({
|
||||
kind: "handle",
|
||||
handle: "+19257864429",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAllowedBlueBubblesSender", () => {
|
||||
it("denies when allowFrom is empty", () => {
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
allowFrom: [],
|
||||
sender: "+15551234567",
|
||||
});
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("allows wildcard entries", () => {
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
allowFrom: ["*"],
|
||||
sender: "+15551234567",
|
||||
});
|
||||
expect(allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __testing } from "./brave-web-search-provider.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { __testing, createBraveWebSearchProvider } from "./brave-web-search-provider.js";
|
||||
|
||||
describe("brave web search provider", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("normalizes brave language parameters and swaps reversed ui/search inputs", () => {
|
||||
expect(
|
||||
__testing.normalizeBraveLanguageParams({
|
||||
@@ -49,4 +53,29 @@ describe("brave web search provider", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns validation errors for invalid date ranges", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: {
|
||||
apiKey: "BSA...",
|
||||
brave: { apiKey: "BSA..." },
|
||||
},
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
const result = await tool.execute({
|
||||
query: "latest gpu news",
|
||||
date_after: "2026-03-20",
|
||||
date_before: "2026-03-01",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
error: "invalid_date_range",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
formatCliCommand,
|
||||
mergeScopedSearchConfig,
|
||||
normalizeFreshness,
|
||||
normalizeToIsoDate,
|
||||
parseIsoDateRange,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readNumberParam,
|
||||
@@ -478,29 +478,17 @@ function createBraveToolDefinition(
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined;
|
||||
if (rawDateAfter && !dateAfter) {
|
||||
return {
|
||||
error: "invalid_date",
|
||||
message: "date_after must be YYYY-MM-DD format.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined;
|
||||
if (rawDateBefore && !dateBefore) {
|
||||
return {
|
||||
error: "invalid_date",
|
||||
message: "date_before must be YYYY-MM-DD format.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (dateAfter && dateBefore && dateAfter > dateBefore) {
|
||||
return {
|
||||
error: "invalid_date_range",
|
||||
message: "date_after must be before date_before.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
const parsedDateRange = parseIsoDateRange({
|
||||
rawDateAfter,
|
||||
rawDateBefore,
|
||||
invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.",
|
||||
invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.",
|
||||
invalidDateRangeMessage: "date_after must be before date_before.",
|
||||
});
|
||||
if ("error" in parsedDateRange) {
|
||||
return parsedDateRange;
|
||||
}
|
||||
const { dateAfter, dateBefore } = parsedDateRange;
|
||||
|
||||
const cacheKey = buildSearchCacheKey([
|
||||
"brave",
|
||||
|
||||
@@ -8,7 +8,11 @@ export {
|
||||
type DeviceBootstrapProfile,
|
||||
} from "openclaw/plugin-sdk/device-bootstrap";
|
||||
export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
export { resolveGatewayBindUrl, resolveTailnetHostWithRunner } from "openclaw/plugin-sdk/core";
|
||||
export {
|
||||
resolveGatewayBindUrl,
|
||||
resolveGatewayPort,
|
||||
resolveTailnetHostWithRunner,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export {
|
||||
resolvePreferredOpenClawTmpDir,
|
||||
runPluginCommandWithTimeout,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
|
||||
import type { OpenClawPluginApi } from "./api.js";
|
||||
import type { PendingPairingRequest } from "./notify.ts";
|
||||
|
||||
const pluginApiMocks = vi.hoisted(() => ({
|
||||
clearDeviceBootstrapTokens: vi.fn(async () => ({ removed: 2 })),
|
||||
@@ -17,6 +18,7 @@ const pluginApiMocks = vi.hoisted(() => ({
|
||||
})),
|
||||
revokeDeviceBootstrapToken: vi.fn(async () => ({ removed: true })),
|
||||
renderQrPngBase64: vi.fn(async () => "ZmFrZXBuZw=="),
|
||||
resolveGatewayPort: vi.fn(() => 18789),
|
||||
resolvePreferredOpenClawTmpDir: vi.fn(() => path.join(os.tmpdir(), "openclaw-device-pair-tests")),
|
||||
}));
|
||||
|
||||
@@ -35,6 +37,7 @@ vi.mock("./api.js", () => {
|
||||
revokeDeviceBootstrapToken: pluginApiMocks.revokeDeviceBootstrapToken,
|
||||
resolvePreferredOpenClawTmpDir: pluginApiMocks.resolvePreferredOpenClawTmpDir,
|
||||
resolveGatewayBindUrl: vi.fn(),
|
||||
resolveGatewayPort: pluginApiMocks.resolveGatewayPort,
|
||||
resolveTailnetHostWithRunner: vi.fn(),
|
||||
runPluginCommandWithTimeout: vi.fn(),
|
||||
};
|
||||
@@ -383,6 +386,49 @@ describe("device-pair /pair qr", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("device-pair notify pending formatting", () => {
|
||||
it("includes role and scopes for pending requests", async () => {
|
||||
const { formatPendingRequests } =
|
||||
await vi.importActual<typeof import("./notify.ts")>("./notify.ts");
|
||||
const pending: PendingPairingRequest[] = [
|
||||
{
|
||||
requestId: "req-1",
|
||||
deviceId: "device-1",
|
||||
displayName: "dev one",
|
||||
platform: "ios",
|
||||
role: "operator",
|
||||
scopes: ["operator.admin", "operator.read"],
|
||||
remoteIp: "198.51.100.2",
|
||||
},
|
||||
];
|
||||
|
||||
const text = formatPendingRequests(pending);
|
||||
expect(text).toContain("Pending device pairing requests:");
|
||||
expect(text).toContain("name=dev one");
|
||||
expect(text).toContain("platform=ios");
|
||||
expect(text).toContain("role=operator");
|
||||
expect(text).toContain("scopes=operator.admin, operator.read");
|
||||
expect(text).toContain("ip=198.51.100.2");
|
||||
});
|
||||
|
||||
it("falls back to roles list and no scopes when role/scopes are absent", async () => {
|
||||
const { formatPendingRequests } =
|
||||
await vi.importActual<typeof import("./notify.ts")>("./notify.ts");
|
||||
const pending: PendingPairingRequest[] = [
|
||||
{
|
||||
requestId: "req-2",
|
||||
deviceId: "device-2",
|
||||
roles: ["node", "operator"],
|
||||
scopes: [],
|
||||
},
|
||||
];
|
||||
|
||||
const text = formatPendingRequests(pending);
|
||||
expect(text).toContain("role=node, operator");
|
||||
expect(text).toContain("scopes=none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("device-pair /pair approve", () => {
|
||||
it("rejects internal gateway callers without operator.pairing", async () => {
|
||||
vi.mocked(listDevicePairing).mockResolvedValueOnce({
|
||||
|
||||
@@ -11,9 +11,10 @@ import {
|
||||
renderQrPngBase64,
|
||||
revokeDeviceBootstrapToken,
|
||||
resolveGatewayBindUrl,
|
||||
resolveGatewayPort,
|
||||
resolvePreferredOpenClawTmpDir,
|
||||
resolveTailnetHostWithRunner,
|
||||
runPluginCommandWithTimeout,
|
||||
resolveTailnetHostWithRunner,
|
||||
type OpenClawPluginApi,
|
||||
} from "./api.js";
|
||||
import {
|
||||
@@ -43,8 +44,6 @@ function formatDurationMinutes(expiresAtMs: number): string {
|
||||
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
const DEFAULT_GATEWAY_PORT = 18789;
|
||||
|
||||
type DevicePairPluginConfig = {
|
||||
publicUrl?: string;
|
||||
};
|
||||
@@ -175,26 +174,6 @@ function parseNormalizedGatewayUrl(raw: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function parsePositiveInteger(raw: string | undefined): number | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function resolveGatewayPort(cfg: OpenClawPluginApi["config"]): number {
|
||||
const envPort = parsePositiveInteger(process.env.OPENCLAW_GATEWAY_PORT?.trim());
|
||||
if (envPort) {
|
||||
return envPort;
|
||||
}
|
||||
const configPort = cfg.gateway?.port;
|
||||
if (typeof configPort === "number" && Number.isFinite(configPort) && configPort > 0) {
|
||||
return configPort;
|
||||
}
|
||||
return DEFAULT_GATEWAY_PORT;
|
||||
}
|
||||
|
||||
function resolveScheme(
|
||||
cfg: OpenClawPluginApi["config"],
|
||||
opts?: { forceSecure?: boolean },
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatPendingRequests, type PendingPairingRequest } from "./notify.ts";
|
||||
|
||||
describe("device-pair notify pending formatting", () => {
|
||||
it("includes role and scopes for pending requests", () => {
|
||||
const pending: PendingPairingRequest[] = [
|
||||
{
|
||||
requestId: "req-1",
|
||||
deviceId: "device-1",
|
||||
displayName: "dev one",
|
||||
platform: "ios",
|
||||
role: "operator",
|
||||
scopes: ["operator.admin", "operator.read"],
|
||||
remoteIp: "198.51.100.2",
|
||||
},
|
||||
];
|
||||
|
||||
const text = formatPendingRequests(pending);
|
||||
expect(text).toContain("Pending device pairing requests:");
|
||||
expect(text).toContain("name=dev one");
|
||||
expect(text).toContain("platform=ios");
|
||||
expect(text).toContain("role=operator");
|
||||
expect(text).toContain("scopes=operator.admin, operator.read");
|
||||
expect(text).toContain("ip=198.51.100.2");
|
||||
});
|
||||
|
||||
it("falls back to roles list and no scopes when role/scopes are absent", () => {
|
||||
const pending: PendingPairingRequest[] = [
|
||||
{
|
||||
requestId: "req-2",
|
||||
deviceId: "device-2",
|
||||
roles: ["node", "operator"],
|
||||
scopes: [],
|
||||
},
|
||||
];
|
||||
|
||||
const text = formatPendingRequests(pending);
|
||||
expect(text).toContain("role=node, operator");
|
||||
expect(text).toContain("scopes=none");
|
||||
});
|
||||
});
|
||||
@@ -1,54 +1 @@
|
||||
import { encodePngRgba, fillPixel } from "openclaw/plugin-sdk/media-runtime";
|
||||
import QRCodeModule from "qrcode-terminal/vendor/QRCode/index.js";
|
||||
import QRErrorCorrectLevelModule from "qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js";
|
||||
|
||||
type QRCodeConstructor = new (
|
||||
typeNumber: number,
|
||||
errorCorrectLevel: unknown,
|
||||
) => {
|
||||
addData: (data: string) => void;
|
||||
make: () => void;
|
||||
getModuleCount: () => number;
|
||||
isDark: (row: number, col: number) => boolean;
|
||||
};
|
||||
|
||||
const QRCode = QRCodeModule as QRCodeConstructor;
|
||||
const QRErrorCorrectLevel = QRErrorCorrectLevelModule;
|
||||
|
||||
function createQrMatrix(input: string) {
|
||||
const qr = new QRCode(-1, QRErrorCorrectLevel.L);
|
||||
qr.addData(input);
|
||||
qr.make();
|
||||
return qr;
|
||||
}
|
||||
|
||||
export async function renderQrPngBase64(
|
||||
input: string,
|
||||
opts: { scale?: number; marginModules?: number } = {},
|
||||
): Promise<string> {
|
||||
const { scale = 6, marginModules = 4 } = opts;
|
||||
const qr = createQrMatrix(input);
|
||||
const modules = qr.getModuleCount();
|
||||
const size = (modules + marginModules * 2) * scale;
|
||||
|
||||
const buf = Buffer.alloc(size * size * 4, 255);
|
||||
for (let row = 0; row < modules; row += 1) {
|
||||
for (let col = 0; col < modules; col += 1) {
|
||||
if (!qr.isDark(row, col)) {
|
||||
continue;
|
||||
}
|
||||
const startX = (col + marginModules) * scale;
|
||||
const startY = (row + marginModules) * scale;
|
||||
for (let y = 0; y < scale; y += 1) {
|
||||
const pixelY = startY + y;
|
||||
for (let x = 0; x < scale; x += 1) {
|
||||
const pixelX = startX + x;
|
||||
fillPixel(buf, pixelX, pixelY, size, 0, 0, 0, 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const png = encodePngRgba(buf, size, size);
|
||||
return png.toString("base64");
|
||||
}
|
||||
export { renderQrPngBase64 } from "openclaw/plugin-sdk/media-runtime";
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMockServerResponse } from "../../test/helpers/extensions/mock-http-response.js";
|
||||
import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
|
||||
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "./api.js";
|
||||
import plugin from "./index.js";
|
||||
|
||||
describe("diffs plugin registration", () => {
|
||||
it("registers the tool, http route, and system-prompt guidance hook", async () => {
|
||||
const registerTool = vi.fn();
|
||||
const registerHttpRoute = vi.fn();
|
||||
const on = vi.fn();
|
||||
|
||||
plugin.register?.(
|
||||
createTestPluginApi({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Diffs",
|
||||
source: "test",
|
||||
config: {},
|
||||
runtime: {} as never,
|
||||
registerTool,
|
||||
registerHttpRoute,
|
||||
on,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(registerTool).toHaveBeenCalledTimes(1);
|
||||
expect(registerHttpRoute).toHaveBeenCalledTimes(1);
|
||||
expect(registerHttpRoute.mock.calls[0]?.[0]).toMatchObject({
|
||||
path: "/plugins/diffs",
|
||||
auth: "plugin",
|
||||
match: "prefix",
|
||||
});
|
||||
expect(on).toHaveBeenCalledTimes(1);
|
||||
expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
|
||||
const beforePromptBuild = on.mock.calls[0]?.[1];
|
||||
const result = await beforePromptBuild?.({}, {});
|
||||
expect(result).toMatchObject({
|
||||
prependSystemContext: expect.stringContaining("prefer the `diffs` tool"),
|
||||
});
|
||||
expect(result?.prependContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies plugin-config defaults through registered tool and viewer handler", async () => {
|
||||
type RegisteredTool = {
|
||||
execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
|
||||
|
||||
let registeredToolFactory:
|
||||
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
|
||||
| undefined;
|
||||
let registeredHttpRouteHandler: RegisteredHttpRouteParams["handler"] | undefined;
|
||||
|
||||
const api = createTestPluginApi({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Diffs",
|
||||
source: "test",
|
||||
config: {
|
||||
gateway: {
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
},
|
||||
},
|
||||
pluginConfig: {
|
||||
defaults: {
|
||||
mode: "view",
|
||||
theme: "light",
|
||||
background: false,
|
||||
layout: "split",
|
||||
showLineNumbers: false,
|
||||
diffIndicators: "classic",
|
||||
lineSpacing: 2,
|
||||
},
|
||||
},
|
||||
runtime: {} as never,
|
||||
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
|
||||
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
|
||||
},
|
||||
registerHttpRoute(params: RegisteredHttpRouteParams) {
|
||||
registeredHttpRouteHandler = params.handler;
|
||||
},
|
||||
});
|
||||
|
||||
plugin.register?.(api as unknown as OpenClawPluginApi);
|
||||
|
||||
const registeredTool = registeredToolFactory?.({
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
messageChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
}) as RegisteredTool | undefined;
|
||||
const result = await registeredTool?.execute?.("tool-1", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
});
|
||||
const viewerPath = String(
|
||||
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
|
||||
);
|
||||
const res = createMockServerResponse();
|
||||
const handled = await registeredHttpRouteHandler?.(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: viewerPath,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain('body data-theme="light"');
|
||||
expect(String(res.body)).toContain('"backgroundEnabled":false');
|
||||
expect(String(res.body)).toContain('"diffStyle":"split"');
|
||||
expect(String(res.body)).toContain('"disableLineNumbers":true');
|
||||
expect(String(res.body)).toContain('"diffIndicators":"classic"');
|
||||
expect(String(res.body)).toContain("--diffs-line-height: 30px;");
|
||||
expect((result as { details?: Record<string, unknown> } | undefined)?.details?.context).toEqual(
|
||||
{
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
messageChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function localReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: IncomingMessage["headers"];
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js";
|
||||
import { createTestPluginApi } from "../../../test/helpers/extensions/plugin-api.js";
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js";
|
||||
import plugin from "../index.js";
|
||||
import { createTempDiffRoot } from "./test-helpers.js";
|
||||
|
||||
const { launchMock } = vi.hoisted(() => ({
|
||||
launchMock: vi.fn(),
|
||||
}));
|
||||
|
||||
let PlaywrightDiffScreenshotter: typeof import("./browser.js").PlaywrightDiffScreenshotter;
|
||||
let resetSharedBrowserStateForTests: typeof import("./browser.js").resetSharedBrowserStateForTests;
|
||||
|
||||
vi.mock("playwright-core", () => ({
|
||||
chromium: {
|
||||
launch: launchMock,
|
||||
@@ -19,18 +27,22 @@ describe("PlaywrightDiffScreenshotter", () => {
|
||||
let outputPath: string;
|
||||
let cleanupRootDir: () => Promise<void>;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
({ PlaywrightDiffScreenshotter, resetSharedBrowserStateForTests } =
|
||||
await import("./browser.js"));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
({ rootDir, cleanup: cleanupRootDir } = await createTempDiffRoot("openclaw-diffs-browser-"));
|
||||
outputPath = path.join(rootDir, "preview.png");
|
||||
launchMock.mockReset();
|
||||
const browserModule = await import("./browser.js");
|
||||
await browserModule.resetSharedBrowserStateForTests();
|
||||
await resetSharedBrowserStateForTests();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const browserModule = await import("./browser.js");
|
||||
await browserModule.resetSharedBrowserStateForTests();
|
||||
await resetSharedBrowserStateForTests();
|
||||
vi.useRealTimers();
|
||||
await cleanupRootDir();
|
||||
});
|
||||
@@ -131,8 +143,6 @@ describe("PlaywrightDiffScreenshotter", () => {
|
||||
boundingBox: { x: 40, y: 40, width: 960, height: 60_000 },
|
||||
});
|
||||
launchMock.mockResolvedValue(browser);
|
||||
const { PlaywrightDiffScreenshotter } = await import("./browser.js");
|
||||
|
||||
const screenshotter = new PlaywrightDiffScreenshotter({
|
||||
config: createConfig(),
|
||||
browserIdleMs: 1_000,
|
||||
@@ -182,6 +192,128 @@ describe("PlaywrightDiffScreenshotter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffs plugin registration", () => {
|
||||
it("registers the tool, http route, and system-prompt guidance hook", async () => {
|
||||
const registerTool = vi.fn();
|
||||
const registerHttpRoute = vi.fn();
|
||||
const on = vi.fn();
|
||||
|
||||
plugin.register?.(
|
||||
createTestPluginApi({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Diffs",
|
||||
source: "test",
|
||||
config: {},
|
||||
runtime: {} as never,
|
||||
registerTool,
|
||||
registerHttpRoute,
|
||||
on,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(registerTool).toHaveBeenCalledTimes(1);
|
||||
expect(registerHttpRoute).toHaveBeenCalledTimes(1);
|
||||
expect(registerHttpRoute.mock.calls[0]?.[0]).toMatchObject({
|
||||
path: "/plugins/diffs",
|
||||
auth: "plugin",
|
||||
match: "prefix",
|
||||
});
|
||||
expect(on).toHaveBeenCalledTimes(1);
|
||||
expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
|
||||
const beforePromptBuild = on.mock.calls[0]?.[1];
|
||||
const result = await beforePromptBuild?.({}, {});
|
||||
expect(result).toMatchObject({
|
||||
prependSystemContext: expect.stringContaining("prefer the `diffs` tool"),
|
||||
});
|
||||
expect(result?.prependContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies plugin-config defaults through registered tool and viewer handler", async () => {
|
||||
type RegisteredTool = {
|
||||
execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
|
||||
|
||||
let registeredToolFactory:
|
||||
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
|
||||
| undefined;
|
||||
let registeredHttpRouteHandler: RegisteredHttpRouteParams["handler"] | undefined;
|
||||
|
||||
const api = createTestPluginApi({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Diffs",
|
||||
source: "test",
|
||||
config: {
|
||||
gateway: {
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
},
|
||||
},
|
||||
pluginConfig: {
|
||||
defaults: {
|
||||
mode: "view",
|
||||
theme: "light",
|
||||
background: false,
|
||||
layout: "split",
|
||||
showLineNumbers: false,
|
||||
diffIndicators: "classic",
|
||||
lineSpacing: 2,
|
||||
},
|
||||
},
|
||||
runtime: {} as never,
|
||||
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
|
||||
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
|
||||
},
|
||||
registerHttpRoute(params: RegisteredHttpRouteParams) {
|
||||
registeredHttpRouteHandler = params.handler;
|
||||
},
|
||||
});
|
||||
|
||||
plugin.register?.(api as unknown as OpenClawPluginApi);
|
||||
|
||||
const registeredTool = registeredToolFactory?.({
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
messageChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
}) as RegisteredTool | undefined;
|
||||
const result = await registeredTool?.execute?.("tool-1", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
});
|
||||
const viewerPath = String(
|
||||
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
|
||||
);
|
||||
const res = createMockServerResponse();
|
||||
const handled = await registeredHttpRouteHandler?.(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: viewerPath,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain('body data-theme="light"');
|
||||
expect(String(res.body)).toContain('"backgroundEnabled":false');
|
||||
expect(String(res.body)).toContain('"diffStyle":"split"');
|
||||
expect(String(res.body)).toContain('"disableLineNumbers":true');
|
||||
expect(String(res.body)).toContain('"diffIndicators":"classic"');
|
||||
expect(String(res.body)).toContain("--diffs-line-height: 30px;");
|
||||
expect((result as { details?: Record<string, unknown> } | undefined)?.details?.context).toEqual(
|
||||
{
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
messageChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createConfig(): OpenClawConfig {
|
||||
return {
|
||||
browser: {
|
||||
@@ -190,6 +322,18 @@ function createConfig(): OpenClawConfig {
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function localReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: IncomingMessage["headers"];
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
async function createScreenshotterHarness(options?: {
|
||||
boundingBox?: { x: number; y: number; width: number; height: number };
|
||||
}) {
|
||||
@@ -200,7 +344,6 @@ async function createScreenshotterHarness(options?: {
|
||||
}> = [];
|
||||
const browser = createMockBrowser(pages, options);
|
||||
launchMock.mockResolvedValue(browser);
|
||||
const { PlaywrightDiffScreenshotter } = await import("./browser.js");
|
||||
const screenshotter = new PlaywrightDiffScreenshotter({
|
||||
config: createConfig(),
|
||||
browserIdleMs: 1_000,
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
resolveDiffsPluginDefaults,
|
||||
resolveDiffsPluginSecurity,
|
||||
} from "./config.js";
|
||||
import { renderDiffDocument } from "./render.js";
|
||||
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
|
||||
import { getServedViewerAsset, VIEWER_LOADER_PATH, VIEWER_RUNTIME_PATH } from "./viewer-assets.js";
|
||||
import { parseViewerPayloadJson } from "./viewer-payload.js";
|
||||
|
||||
const FULL_DEFAULTS = {
|
||||
fontFamily: "JetBrains Mono",
|
||||
@@ -177,3 +181,232 @@ describe("diffs plugin schema surfaces", () => {
|
||||
expect(diffsPluginConfigSchema.jsonSchema).toEqual(manifest.configSchema);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffs viewer URL helpers", () => {
|
||||
it("defaults to loopback for lan/tailnet bind modes", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: { gateway: { bind: "lan", port: 18789 } },
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("http://127.0.0.1:18789/plugins/diffs/view/id/token");
|
||||
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: { gateway: { bind: "tailnet", port: 24444 } },
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("http://127.0.0.1:24444/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("uses custom bind host when provided", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: {
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.example.com",
|
||||
port: 443,
|
||||
tls: { enabled: true },
|
||||
},
|
||||
},
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("https://gateway.example.com/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("joins viewer path under baseUrl pathname", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: {},
|
||||
baseUrl: "https://example.com/openclaw",
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("rejects base URLs with query/hash", () => {
|
||||
expect(() => normalizeViewerBaseUrl("https://example.com?a=1")).toThrow(
|
||||
"baseUrl must not include query/hash",
|
||||
);
|
||||
expect(() => normalizeViewerBaseUrl("https://example.com#frag")).toThrow(
|
||||
"baseUrl must not include query/hash",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderDiffDocument", () => {
|
||||
it("renders before/after input into a complete viewer document", async () => {
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "before_after",
|
||||
before: "const value = 1;\n",
|
||||
after: "const value = 2;\n",
|
||||
path: "src/example.ts",
|
||||
},
|
||||
{
|
||||
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.title).toBe("src/example.ts");
|
||||
expect(rendered.fileCount).toBe(1);
|
||||
expect(rendered.html).toContain("data-openclaw-diff-root");
|
||||
expect(rendered.html).toContain("src/example.ts");
|
||||
expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js");
|
||||
expect(rendered.imageHtml).toContain("/plugins/diffs/assets/viewer.js");
|
||||
expect(rendered.imageHtml).toContain("max-width: 960px;");
|
||||
expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;");
|
||||
expect(rendered.html).toContain("min-height: 100vh;");
|
||||
expect(rendered.html).toContain('"diffIndicators":"bars"');
|
||||
expect(rendered.html).toContain('"disableLineNumbers":false');
|
||||
expect(rendered.html).toContain("--diffs-line-height: 24px;");
|
||||
expect(rendered.html).toContain("--diffs-font-size: 15px;");
|
||||
expect(rendered.html).not.toContain("fonts.googleapis.com");
|
||||
});
|
||||
|
||||
it("renders multi-file patch input", async () => {
|
||||
const patch = [
|
||||
"diff --git a/a.ts b/a.ts",
|
||||
"--- a/a.ts",
|
||||
"+++ b/a.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-const a = 1;",
|
||||
"+const a = 2;",
|
||||
"diff --git a/b.ts b/b.ts",
|
||||
"--- a/b.ts",
|
||||
"+++ b/b.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-const b = 1;",
|
||||
"+const b = 2;",
|
||||
].join("\n");
|
||||
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "patch",
|
||||
patch,
|
||||
title: "Workspace patch",
|
||||
},
|
||||
{
|
||||
presentation: {
|
||||
...DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
layout: "split",
|
||||
theme: "dark",
|
||||
},
|
||||
image: resolveDiffImageRenderOptions({
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
fileQuality: "hq",
|
||||
fileMaxWidth: 1180,
|
||||
}),
|
||||
expandUnchanged: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.title).toBe("Workspace patch");
|
||||
expect(rendered.fileCount).toBe(2);
|
||||
expect(rendered.html).toContain("Workspace patch");
|
||||
expect(rendered.imageHtml).toContain("max-width: 1180px;");
|
||||
});
|
||||
|
||||
it("rejects patches that exceed file-count limits", async () => {
|
||||
const patch = Array.from({ length: 129 }, (_, i) => {
|
||||
return [
|
||||
`diff --git a/f${i}.ts b/f${i}.ts`,
|
||||
`--- a/f${i}.ts`,
|
||||
`+++ b/f${i}.ts`,
|
||||
"@@ -1 +1 @@",
|
||||
"-const x = 1;",
|
||||
"+const x = 2;",
|
||||
].join("\n");
|
||||
}).join("\n");
|
||||
|
||||
await expect(
|
||||
renderDiffDocument(
|
||||
{
|
||||
kind: "patch",
|
||||
patch,
|
||||
},
|
||||
{
|
||||
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("too many files");
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewer assets", () => {
|
||||
it("serves a stable loader that points at the current runtime bundle", async () => {
|
||||
const loader = await getServedViewerAsset(VIEWER_LOADER_PATH);
|
||||
|
||||
expect(loader?.contentType).toBe("text/javascript; charset=utf-8");
|
||||
expect(String(loader?.body)).toContain(`${VIEWER_RUNTIME_PATH}?v=`);
|
||||
});
|
||||
|
||||
it("serves the runtime bundle body", async () => {
|
||||
const runtime = await getServedViewerAsset(VIEWER_RUNTIME_PATH);
|
||||
|
||||
expect(runtime?.contentType).toBe("text/javascript; charset=utf-8");
|
||||
expect(String(runtime?.body)).toContain("openclawDiffsReady");
|
||||
});
|
||||
|
||||
it("returns null for unknown asset paths", async () => {
|
||||
await expect(getServedViewerAsset("/plugins/diffs/assets/not-real.js")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseViewerPayloadJson", () => {
|
||||
function buildValidPayload(): Record<string, unknown> {
|
||||
return {
|
||||
prerenderedHTML: "<div>ok</div>",
|
||||
langs: ["text"],
|
||||
oldFile: {
|
||||
name: "README.md",
|
||||
contents: "before",
|
||||
},
|
||||
newFile: {
|
||||
name: "README.md",
|
||||
contents: "after",
|
||||
},
|
||||
options: {
|
||||
theme: {
|
||||
light: "pierre-light",
|
||||
dark: "pierre-dark",
|
||||
},
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "dark",
|
||||
backgroundEnabled: true,
|
||||
overflow: "wrap",
|
||||
unsafeCSS: ":host{}",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("accepts valid payload JSON", () => {
|
||||
const parsed = parseViewerPayloadJson(JSON.stringify(buildValidPayload()));
|
||||
expect(parsed.options.diffStyle).toBe("unified");
|
||||
expect(parsed.options.diffIndicators).toBe("bars");
|
||||
});
|
||||
|
||||
it("rejects payloads with invalid shape", () => {
|
||||
const broken = buildValidPayload();
|
||||
broken.options = {
|
||||
...(broken.options as Record<string, unknown>),
|
||||
diffIndicators: "invalid",
|
||||
};
|
||||
|
||||
expect(() => parseViewerPayloadJson(JSON.stringify(broken))).toThrow(
|
||||
"Diff payload has invalid shape.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid JSON", () => {
|
||||
expect(() => parseViewerPayloadJson("{not-json")).toThrow("Diff payload is not valid JSON.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js";
|
||||
import { createDiffsHttpHandler } from "./http.js";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
import { createDiffStoreHarness } from "./test-helpers.js";
|
||||
|
||||
describe("createDiffsHttpHandler", () => {
|
||||
let store: DiffArtifactStore;
|
||||
let cleanupRootDir: () => Promise<void>;
|
||||
|
||||
async function handleLocalGet(url: string) {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
return { handled, res };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupRootDir();
|
||||
});
|
||||
|
||||
it("serves a stored diff document", async () => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
const { handled, res } = await handleLocalGet(artifact.viewerPath);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
expect(res.getHeader("content-security-policy")).toContain("default-src 'none'");
|
||||
});
|
||||
|
||||
it("rejects invalid tokens", async () => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
const { handled, res } = await handleLocalGet(
|
||||
artifact.viewerPath.replace(artifact.token, "bad-token"),
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects malformed artifact ids before reading from disk", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/not-a-real-id/not-a-real-token",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("serves the shared viewer asset", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer.js",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain("/plugins/diffs/assets/viewer-runtime.js?v=");
|
||||
});
|
||||
|
||||
it("serves the shared viewer runtime asset", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer-runtime.js",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain("openclawDiffsReady");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "blocks non-loopback viewer access by default",
|
||||
request: remoteReq,
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "blocks loopback requests that carry proxy forwarding headers by default",
|
||||
request: localReq,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "allows remote access when allowRemoteViewer is enabled",
|
||||
request: remoteReq,
|
||||
allowRemoteViewer: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
{
|
||||
name: "allows proxied loopback requests when allowRemoteViewer is enabled",
|
||||
request: localReq,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
allowRemoteViewer: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
])("$name", async ({ request, headers, allowRemoteViewer, expectedStatusCode }) => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
|
||||
const handler = createDiffsHttpHandler({ store, allowRemoteViewer });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
request({
|
||||
method: "GET",
|
||||
url: artifact.viewerPath,
|
||||
headers,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(expectedStatusCode);
|
||||
if (expectedStatusCode === 200) {
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
}
|
||||
});
|
||||
|
||||
it("rate-limits repeated remote misses", async () => {
|
||||
const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true });
|
||||
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const miss = createMockServerResponse();
|
||||
await handler(
|
||||
remoteReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
}),
|
||||
miss,
|
||||
);
|
||||
expect(miss.statusCode).toBe(404);
|
||||
}
|
||||
|
||||
const limited = createMockServerResponse();
|
||||
await handler(
|
||||
remoteReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
}),
|
||||
limited,
|
||||
);
|
||||
expect(limited.statusCode).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
async function createViewerArtifact(store: DiffArtifactStore) {
|
||||
return await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function localReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
function remoteReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "203.0.113.10" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffImageRenderOptions } from "./config.js";
|
||||
import { renderDiffDocument } from "./render.js";
|
||||
|
||||
describe("renderDiffDocument", () => {
|
||||
it("renders before/after input into a complete viewer document", async () => {
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "before_after",
|
||||
before: "const value = 1;\n",
|
||||
after: "const value = 2;\n",
|
||||
path: "src/example.ts",
|
||||
},
|
||||
{
|
||||
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.title).toBe("src/example.ts");
|
||||
expect(rendered.fileCount).toBe(1);
|
||||
expect(rendered.html).toContain("data-openclaw-diff-root");
|
||||
expect(rendered.html).toContain("src/example.ts");
|
||||
expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js");
|
||||
expect(rendered.imageHtml).toContain("/plugins/diffs/assets/viewer.js");
|
||||
expect(rendered.imageHtml).toContain("max-width: 960px;");
|
||||
expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;");
|
||||
expect(rendered.html).toContain("min-height: 100vh;");
|
||||
expect(rendered.html).toContain('"diffIndicators":"bars"');
|
||||
expect(rendered.html).toContain('"disableLineNumbers":false');
|
||||
expect(rendered.html).toContain("--diffs-line-height: 24px;");
|
||||
expect(rendered.html).toContain("--diffs-font-size: 15px;");
|
||||
expect(rendered.html).not.toContain("fonts.googleapis.com");
|
||||
});
|
||||
|
||||
it("renders multi-file patch input", async () => {
|
||||
const patch = [
|
||||
"diff --git a/a.ts b/a.ts",
|
||||
"--- a/a.ts",
|
||||
"+++ b/a.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-const a = 1;",
|
||||
"+const a = 2;",
|
||||
"diff --git a/b.ts b/b.ts",
|
||||
"--- a/b.ts",
|
||||
"+++ b/b.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-const b = 1;",
|
||||
"+const b = 2;",
|
||||
].join("\n");
|
||||
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "patch",
|
||||
patch,
|
||||
title: "Workspace patch",
|
||||
},
|
||||
{
|
||||
presentation: {
|
||||
...DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
layout: "split",
|
||||
theme: "dark",
|
||||
},
|
||||
image: resolveDiffImageRenderOptions({
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
fileQuality: "hq",
|
||||
fileMaxWidth: 1180,
|
||||
}),
|
||||
expandUnchanged: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.title).toBe("Workspace patch");
|
||||
expect(rendered.fileCount).toBe(2);
|
||||
expect(rendered.html).toContain("Workspace patch");
|
||||
expect(rendered.imageHtml).toContain("max-width: 1180px;");
|
||||
});
|
||||
|
||||
it("rejects patches that exceed file-count limits", async () => {
|
||||
const patch = Array.from({ length: 129 }, (_, i) => {
|
||||
return [
|
||||
`diff --git a/f${i}.ts b/f${i}.ts`,
|
||||
`--- a/f${i}.ts`,
|
||||
`+++ b/f${i}.ts`,
|
||||
"@@ -1 +1 @@",
|
||||
"-const x = 1;",
|
||||
"+const x = 2;",
|
||||
].join("\n");
|
||||
}).join("\n");
|
||||
|
||||
await expect(
|
||||
renderDiffDocument(
|
||||
{
|
||||
kind: "patch",
|
||||
patch,
|
||||
},
|
||||
{
|
||||
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("too many files");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js";
|
||||
import { createDiffsHttpHandler } from "./http.js";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
import { createDiffStoreHarness } from "./test-helpers.js";
|
||||
|
||||
@@ -211,3 +214,203 @@ describe("DiffArtifactStore", () => {
|
||||
expect(cleanupSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDiffsHttpHandler", () => {
|
||||
let store: DiffArtifactStore;
|
||||
let cleanupRootDir: () => Promise<void>;
|
||||
|
||||
async function handleLocalGet(url: string) {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
return { handled, res };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupRootDir();
|
||||
});
|
||||
|
||||
it("serves a stored diff document", async () => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
const { handled, res } = await handleLocalGet(artifact.viewerPath);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
expect(res.getHeader("content-security-policy")).toContain("default-src 'none'");
|
||||
});
|
||||
|
||||
it("rejects invalid tokens", async () => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
const { handled, res } = await handleLocalGet(
|
||||
artifact.viewerPath.replace(artifact.token, "bad-token"),
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects malformed artifact ids before reading from disk", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/not-a-real-id/not-a-real-token",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("serves the shared viewer asset", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer.js",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain("/plugins/diffs/assets/viewer-runtime.js?v=");
|
||||
});
|
||||
|
||||
it("serves the shared viewer runtime asset", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer-runtime.js",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain("openclawDiffsReady");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "blocks non-loopback viewer access by default",
|
||||
request: remoteReq,
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "blocks loopback requests that carry proxy forwarding headers by default",
|
||||
request: localReq,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "allows remote access when allowRemoteViewer is enabled",
|
||||
request: remoteReq,
|
||||
allowRemoteViewer: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
{
|
||||
name: "allows proxied loopback requests when allowRemoteViewer is enabled",
|
||||
request: localReq,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
allowRemoteViewer: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
])("$name", async ({ request, headers, allowRemoteViewer, expectedStatusCode }) => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
|
||||
const handler = createDiffsHttpHandler({ store, allowRemoteViewer });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
request({
|
||||
method: "GET",
|
||||
url: artifact.viewerPath,
|
||||
headers,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(expectedStatusCode);
|
||||
if (expectedStatusCode === 200) {
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
}
|
||||
});
|
||||
|
||||
it("rate-limits repeated remote misses", async () => {
|
||||
const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true });
|
||||
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const miss = createMockServerResponse();
|
||||
await handler(
|
||||
remoteReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
}),
|
||||
miss,
|
||||
);
|
||||
expect(miss.statusCode).toBe(404);
|
||||
}
|
||||
|
||||
const limited = createMockServerResponse();
|
||||
await handler(
|
||||
remoteReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
}),
|
||||
limited,
|
||||
);
|
||||
expect(limited.statusCode).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
async function createViewerArtifact(store: DiffArtifactStore) {
|
||||
return await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function localReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
function remoteReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "203.0.113.10" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
|
||||
|
||||
describe("diffs viewer URL helpers", () => {
|
||||
it("defaults to loopback for lan/tailnet bind modes", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: { gateway: { bind: "lan", port: 18789 } },
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("http://127.0.0.1:18789/plugins/diffs/view/id/token");
|
||||
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: { gateway: { bind: "tailnet", port: 24444 } },
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("http://127.0.0.1:24444/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("uses custom bind host when provided", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: {
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.example.com",
|
||||
port: 443,
|
||||
tls: { enabled: true },
|
||||
},
|
||||
},
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("https://gateway.example.com/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("joins viewer path under baseUrl pathname", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: {},
|
||||
baseUrl: "https://example.com/openclaw",
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("rejects base URLs with query/hash", () => {
|
||||
expect(() => normalizeViewerBaseUrl("https://example.com?a=1")).toThrow(
|
||||
"baseUrl must not include query/hash",
|
||||
);
|
||||
expect(() => normalizeViewerBaseUrl("https://example.com#frag")).toThrow(
|
||||
"baseUrl must not include query/hash",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getServedViewerAsset, VIEWER_LOADER_PATH, VIEWER_RUNTIME_PATH } from "./viewer-assets.js";
|
||||
|
||||
describe("viewer assets", () => {
|
||||
it("serves a stable loader that points at the current runtime bundle", async () => {
|
||||
const loader = await getServedViewerAsset(VIEWER_LOADER_PATH);
|
||||
|
||||
expect(loader?.contentType).toBe("text/javascript; charset=utf-8");
|
||||
expect(String(loader?.body)).toContain(`${VIEWER_RUNTIME_PATH}?v=`);
|
||||
});
|
||||
|
||||
it("serves the runtime bundle body", async () => {
|
||||
const runtime = await getServedViewerAsset(VIEWER_RUNTIME_PATH);
|
||||
|
||||
expect(runtime?.contentType).toBe("text/javascript; charset=utf-8");
|
||||
expect(String(runtime?.body)).toContain("openclawDiffsReady");
|
||||
});
|
||||
|
||||
it("returns null for unknown asset paths", async () => {
|
||||
await expect(getServedViewerAsset("/plugins/diffs/assets/not-real.js")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseViewerPayloadJson } from "./viewer-payload.js";
|
||||
|
||||
function buildValidPayload(): Record<string, unknown> {
|
||||
return {
|
||||
prerenderedHTML: "<div>ok</div>",
|
||||
langs: ["text"],
|
||||
oldFile: {
|
||||
name: "README.md",
|
||||
contents: "before",
|
||||
},
|
||||
newFile: {
|
||||
name: "README.md",
|
||||
contents: "after",
|
||||
},
|
||||
options: {
|
||||
theme: {
|
||||
light: "pierre-light",
|
||||
dark: "pierre-dark",
|
||||
},
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "dark",
|
||||
backgroundEnabled: true,
|
||||
overflow: "wrap",
|
||||
unsafeCSS: ":host{}",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseViewerPayloadJson", () => {
|
||||
it("accepts valid payload JSON", () => {
|
||||
const parsed = parseViewerPayloadJson(JSON.stringify(buildValidPayload()));
|
||||
expect(parsed.options.diffStyle).toBe("unified");
|
||||
expect(parsed.options.diffIndicators).toBe("bars");
|
||||
});
|
||||
|
||||
it("rejects payloads with invalid shape", () => {
|
||||
const broken = buildValidPayload();
|
||||
broken.options = {
|
||||
...(broken.options as Record<string, unknown>),
|
||||
diffIndicators: "invalid",
|
||||
};
|
||||
|
||||
expect(() => parseViewerPayloadJson(JSON.stringify(broken))).toThrow(
|
||||
"Diff payload has invalid shape.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid JSON", () => {
|
||||
expect(() => parseViewerPayloadJson("{not-json")).toThrow("Diff payload is not valid JSON.");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
|
||||
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
|
||||
import type { ResolvedDiscordAccount } from "./accounts.js";
|
||||
@@ -77,7 +77,10 @@ afterEach(() => {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.resetModules();
|
||||
installDiscordRuntime({});
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
({ discordPlugin } = await import("./channel.js"));
|
||||
({ setDiscordRuntime } = await import("./runtime.js"));
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MessageFlags } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let clearDiscordComponentEntries: typeof import("./components-registry.js").clearDiscordComponentEntries;
|
||||
let registerDiscordComponentEntries: typeof import("./components-registry.js").registerDiscordComponentEntries;
|
||||
@@ -9,8 +9,7 @@ let buildDiscordComponentMessage: typeof import("./components.js").buildDiscordC
|
||||
let buildDiscordComponentMessageFlags: typeof import("./components.js").buildDiscordComponentMessageFlags;
|
||||
let readDiscordComponentSpec: typeof import("./components.js").readDiscordComponentSpec;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
beforeAll(async () => {
|
||||
({
|
||||
clearDiscordComponentEntries,
|
||||
registerDiscordComponentEntries,
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveDiscordGroupRequireMention,
|
||||
resolveDiscordGroupToolPolicy,
|
||||
} from "./group-policy.js";
|
||||
|
||||
describe("discord group policy", () => {
|
||||
it("prefers channel policy, then guild policy, with sender-specific overrides", () => {
|
||||
const discordCfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
token: "discord-test",
|
||||
guilds: {
|
||||
guild1: {
|
||||
requireMention: false,
|
||||
tools: { allow: ["message.guild"] },
|
||||
toolsBySender: {
|
||||
"id:user:guild-admin": { allow: ["sessions.list"] },
|
||||
},
|
||||
channels: {
|
||||
"123": {
|
||||
requireMention: true,
|
||||
tools: { allow: ["message.channel"] },
|
||||
toolsBySender: {
|
||||
"id:user:channel-admin": { deny: ["exec"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
expect(
|
||||
resolveDiscordGroupRequireMention({ cfg: discordCfg, groupSpace: "guild1", groupId: "123" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveDiscordGroupRequireMention({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "missing",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveDiscordGroupToolPolicy({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "123",
|
||||
senderId: "user:channel-admin",
|
||||
}),
|
||||
).toEqual({ deny: ["exec"] });
|
||||
expect(
|
||||
resolveDiscordGroupToolPolicy({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "123",
|
||||
senderId: "user:someone",
|
||||
}),
|
||||
).toEqual({ allow: ["message.channel"] });
|
||||
expect(
|
||||
resolveDiscordGroupToolPolicy({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "missing",
|
||||
senderId: "user:guild-admin",
|
||||
}),
|
||||
).toEqual({ allow: ["sessions.list"] });
|
||||
expect(
|
||||
resolveDiscordGroupToolPolicy({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "missing",
|
||||
senderId: "user:someone",
|
||||
}),
|
||||
).toEqual({ allow: ["message.guild"] });
|
||||
});
|
||||
});
|
||||
@@ -1,87 +1,114 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { waitForDiscordGatewayStop } from "./monitor.gateway.js";
|
||||
import type { DiscordGatewayEvent } from "./monitor/gateway-supervisor.js";
|
||||
|
||||
function createGatewayEvent(
|
||||
type: DiscordGatewayEvent["type"],
|
||||
message: string,
|
||||
): DiscordGatewayEvent {
|
||||
const err = new Error(message);
|
||||
return {
|
||||
type,
|
||||
err,
|
||||
message: String(err),
|
||||
shouldStopLifecycle: type !== "other",
|
||||
};
|
||||
}
|
||||
|
||||
function createGatewayWaitHarness() {
|
||||
const emitter = new EventEmitter();
|
||||
let lifecycleHandler: ((event: DiscordGatewayEvent) => void) | undefined;
|
||||
const disconnect = vi.fn();
|
||||
const abort = new AbortController();
|
||||
return { emitter, disconnect, abort };
|
||||
const attachLifecycle = vi.fn((handler: (event: DiscordGatewayEvent) => void) => {
|
||||
lifecycleHandler = handler;
|
||||
});
|
||||
const detachLifecycle = vi.fn(() => {
|
||||
lifecycleHandler = undefined;
|
||||
});
|
||||
return {
|
||||
abort,
|
||||
attachLifecycle,
|
||||
detachLifecycle,
|
||||
disconnect,
|
||||
emitGatewayEvent: (event: DiscordGatewayEvent) => {
|
||||
lifecycleHandler?.(event);
|
||||
},
|
||||
gatewaySupervisor: {
|
||||
attachLifecycle,
|
||||
detachLifecycle,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function startGatewayWait(params?: {
|
||||
onGatewayError?: (error: unknown) => void;
|
||||
shouldStopOnError?: (error: unknown) => boolean;
|
||||
disconnect?: () => void;
|
||||
onGatewayEvent?: (event: DiscordGatewayEvent) => "continue" | "stop";
|
||||
registerForceStop?: (fn: (error: unknown) => void) => void;
|
||||
}) {
|
||||
const harness = createGatewayWaitHarness();
|
||||
if (params?.disconnect) {
|
||||
harness.disconnect.mockImplementation(params.disconnect);
|
||||
}
|
||||
const promise = waitForDiscordGatewayStop({
|
||||
gateway: { emitter: harness.emitter, disconnect: harness.disconnect },
|
||||
gateway: { disconnect: harness.disconnect },
|
||||
abortSignal: harness.abort.signal,
|
||||
...(params?.onGatewayError ? { onGatewayError: params.onGatewayError } : {}),
|
||||
...(params?.shouldStopOnError ? { shouldStopOnError: params.shouldStopOnError } : {}),
|
||||
gatewaySupervisor: harness.gatewaySupervisor,
|
||||
...(params?.onGatewayEvent ? { onGatewayEvent: params.onGatewayEvent } : {}),
|
||||
...(params?.registerForceStop ? { registerForceStop: params.registerForceStop } : {}),
|
||||
});
|
||||
return { ...harness, promise };
|
||||
}
|
||||
|
||||
async function expectAbortToResolve(params: {
|
||||
emitter: EventEmitter;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
abort: AbortController;
|
||||
attachLifecycle: ReturnType<typeof vi.fn>;
|
||||
detachLifecycle: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
promise: Promise<void>;
|
||||
expectedDisconnectBeforeAbort?: number;
|
||||
}) {
|
||||
if (params.expectedDisconnectBeforeAbort !== undefined) {
|
||||
expect(params.disconnect).toHaveBeenCalledTimes(params.expectedDisconnectBeforeAbort);
|
||||
}
|
||||
expect(params.emitter.listenerCount("error")).toBe(1);
|
||||
expect(params.attachLifecycle).toHaveBeenCalledTimes(1);
|
||||
params.abort.abort();
|
||||
await expect(params.promise).resolves.toBeUndefined();
|
||||
expect(params.disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(params.emitter.listenerCount("error")).toBe(0);
|
||||
expect(params.detachLifecycle).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
|
||||
describe("waitForDiscordGatewayStop", () => {
|
||||
it("resolves on abort and disconnects gateway", async () => {
|
||||
const { emitter, disconnect, abort, promise } = startGatewayWait();
|
||||
await expectAbortToResolve({ emitter, disconnect, abort, promise });
|
||||
const { abort, attachLifecycle, detachLifecycle, disconnect, promise } = startGatewayWait();
|
||||
await expectAbortToResolve({ abort, attachLifecycle, detachLifecycle, disconnect, promise });
|
||||
});
|
||||
|
||||
it("rejects on gateway error and disconnects", async () => {
|
||||
const onGatewayError = vi.fn();
|
||||
const err = new Error("boom");
|
||||
it("rejects on lifecycle stop events and disconnects", async () => {
|
||||
const fatalEvent = createGatewayEvent("fatal", "boom");
|
||||
const { detachLifecycle, disconnect, emitGatewayEvent, promise } = startGatewayWait();
|
||||
|
||||
const { emitter, disconnect, abort, promise } = startGatewayWait({
|
||||
onGatewayError,
|
||||
});
|
||||
|
||||
emitter.emit("error", err);
|
||||
emitGatewayEvent(fatalEvent);
|
||||
|
||||
await expect(promise).rejects.toThrow("boom");
|
||||
expect(onGatewayError).toHaveBeenCalledWith(err);
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(emitter.listenerCount("error")).toBe(0);
|
||||
|
||||
abort.abort();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(detachLifecycle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores gateway errors when instructed", async () => {
|
||||
const onGatewayError = vi.fn();
|
||||
const err = new Error("transient");
|
||||
it("ignores transient gateway events when instructed", async () => {
|
||||
const transientEvent = createGatewayEvent("other", "transient");
|
||||
const onGatewayEvent = vi.fn(() => "continue" as const);
|
||||
const { abort, attachLifecycle, detachLifecycle, disconnect, emitGatewayEvent, promise } =
|
||||
startGatewayWait({
|
||||
onGatewayEvent,
|
||||
});
|
||||
|
||||
const { emitter, disconnect, abort, promise } = startGatewayWait({
|
||||
onGatewayError,
|
||||
shouldStopOnError: () => false,
|
||||
});
|
||||
|
||||
emitter.emit("error", err);
|
||||
expect(onGatewayError).toHaveBeenCalledWith(err);
|
||||
emitGatewayEvent(transientEvent);
|
||||
expect(onGatewayEvent).toHaveBeenCalledWith(transientEvent);
|
||||
await expectAbortToResolve({
|
||||
emitter,
|
||||
disconnect,
|
||||
abort,
|
||||
attachLifecycle,
|
||||
detachLifecycle,
|
||||
disconnect,
|
||||
promise,
|
||||
expectedDisconnectBeforeAbort: 0,
|
||||
});
|
||||
@@ -89,7 +116,6 @@ describe("waitForDiscordGatewayStop", () => {
|
||||
|
||||
it("resolves on abort without a gateway", async () => {
|
||||
const abort = new AbortController();
|
||||
|
||||
const promise = waitForDiscordGatewayStop({
|
||||
abortSignal: abort.signal,
|
||||
});
|
||||
@@ -102,7 +128,7 @@ describe("waitForDiscordGatewayStop", () => {
|
||||
it("rejects via registerForceStop and disconnects gateway", async () => {
|
||||
let forceStop: ((err: unknown) => void) | undefined;
|
||||
|
||||
const { emitter, disconnect, promise } = startGatewayWait({
|
||||
const { detachLifecycle, disconnect, promise } = startGatewayWait({
|
||||
registerForceStop: (fn) => {
|
||||
forceStop = fn;
|
||||
},
|
||||
@@ -115,7 +141,7 @@ describe("waitForDiscordGatewayStop", () => {
|
||||
|
||||
await expect(promise).rejects.toThrow("reconnect watchdog timeout");
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(emitter.listenerCount("error")).toBe(0);
|
||||
expect(detachLifecycle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores forceStop after promise already settled", async () => {
|
||||
@@ -133,4 +159,49 @@ describe("waitForDiscordGatewayStop", () => {
|
||||
forceStop?.(new Error("too late"));
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the lifecycle handler active until disconnect returns on abort", async () => {
|
||||
const onGatewayEvent = vi.fn(() => "stop" as const);
|
||||
const fatalEvent = createGatewayEvent("fatal", "disconnect emitted error");
|
||||
let emitFromDisconnect: ((event: DiscordGatewayEvent) => void) | undefined;
|
||||
const { abort, detachLifecycle, disconnect, emitGatewayEvent, promise } = startGatewayWait({
|
||||
onGatewayEvent,
|
||||
disconnect: () => {
|
||||
emitFromDisconnect?.(fatalEvent);
|
||||
},
|
||||
});
|
||||
emitFromDisconnect = emitGatewayEvent;
|
||||
|
||||
abort.abort();
|
||||
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
expect(onGatewayEvent).toHaveBeenCalledWith(fatalEvent);
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(detachLifecycle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the original rejection when disconnect emits another stop event", async () => {
|
||||
const firstEvent = createGatewayEvent("fatal", "first failure");
|
||||
const secondEvent = createGatewayEvent("fatal", "second failure");
|
||||
const seenEvents: DiscordGatewayEvent[] = [];
|
||||
let emitFromDisconnect: ((event: DiscordGatewayEvent) => void) | undefined;
|
||||
const { emitGatewayEvent, promise } = startGatewayWait({
|
||||
onGatewayEvent: (event) => {
|
||||
seenEvents.push(event);
|
||||
return "stop";
|
||||
},
|
||||
disconnect: () => {
|
||||
emitFromDisconnect?.(secondEvent);
|
||||
},
|
||||
});
|
||||
emitFromDisconnect = emitGatewayEvent;
|
||||
|
||||
emitGatewayEvent(firstEvent);
|
||||
|
||||
await expect(promise).rejects.toThrow("first failure");
|
||||
expect(seenEvents.map((event) => event.message)).toEqual([
|
||||
firstEvent.message,
|
||||
secondEvent.message,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import type { EventEmitter } from "node:events";
|
||||
import type {
|
||||
DiscordGatewayEvent,
|
||||
DiscordGatewaySupervisor,
|
||||
} from "./monitor/gateway-supervisor.js";
|
||||
|
||||
export type DiscordGatewayHandle = {
|
||||
emitter?: Pick<EventEmitter, "on" | "removeListener">;
|
||||
disconnect?: () => void;
|
||||
};
|
||||
|
||||
export type WaitForDiscordGatewayStopParams = {
|
||||
gateway?: DiscordGatewayHandle;
|
||||
abortSignal?: AbortSignal;
|
||||
onGatewayError?: (err: unknown) => void;
|
||||
shouldStopOnError?: (err: unknown) => boolean;
|
||||
gatewaySupervisor?: Pick<DiscordGatewaySupervisor, "attachLifecycle" | "detachLifecycle">;
|
||||
onGatewayEvent?: (event: DiscordGatewayEvent) => "continue" | "stop";
|
||||
registerForceStop?: (forceStop: (err: unknown) => void) => void;
|
||||
};
|
||||
|
||||
@@ -20,23 +23,24 @@ export function getDiscordGatewayEmitter(gateway?: unknown): EventEmitter | unde
|
||||
export async function waitForDiscordGatewayStop(
|
||||
params: WaitForDiscordGatewayStopParams,
|
||||
): Promise<void> {
|
||||
const { gateway, abortSignal, onGatewayError, shouldStopOnError } = params;
|
||||
const emitter = gateway?.emitter;
|
||||
const { gateway, abortSignal } = params;
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
abortSignal?.removeEventListener("abort", onAbort);
|
||||
emitter?.removeListener("error", onGatewayErrorEvent);
|
||||
params.gatewaySupervisor?.detachLifecycle();
|
||||
};
|
||||
const finishResolve = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
try {
|
||||
gateway?.disconnect?.();
|
||||
} finally {
|
||||
// remove listeners after disconnect so late "error" events emitted
|
||||
// during disconnect are still handled instead of becoming uncaught
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
@@ -45,21 +49,20 @@ export async function waitForDiscordGatewayStop(
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
try {
|
||||
gateway?.disconnect?.();
|
||||
} finally {
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
const onAbort = () => {
|
||||
finishResolve();
|
||||
};
|
||||
const onGatewayErrorEvent = (err: unknown) => {
|
||||
onGatewayError?.(err);
|
||||
const shouldStop = shouldStopOnError?.(err) ?? true;
|
||||
const onGatewayEvent = (event: DiscordGatewayEvent) => {
|
||||
const shouldStop = (params.onGatewayEvent?.(event) ?? "stop") === "stop";
|
||||
if (shouldStop) {
|
||||
finishReject(err);
|
||||
finishReject(event.err);
|
||||
}
|
||||
};
|
||||
const onForceStop = (err: unknown) => {
|
||||
@@ -72,7 +75,7 @@ export async function waitForDiscordGatewayStop(
|
||||
}
|
||||
|
||||
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
emitter?.on("error", onGatewayErrorEvent);
|
||||
params.gatewaySupervisor?.attachLifecycle(onGatewayEvent);
|
||||
params.registerForceStop?.(onForceStop);
|
||||
});
|
||||
}
|
||||
|
||||
+11
-5
@@ -1,6 +1,7 @@
|
||||
import type { Client } from "@buape/carbon";
|
||||
import { MessageType } from "@buape/carbon";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { expectPairingReplyText } from "../../../test/helpers/pairing-reply.js";
|
||||
import {
|
||||
dispatchMock,
|
||||
loadConfigMock,
|
||||
@@ -16,12 +17,14 @@ let createDiscordMessageHandler: typeof import("./monitor/message-handler.js").c
|
||||
let __resetDiscordChannelInfoCacheForTest: typeof import("./monitor/message-utils.js").__resetDiscordChannelInfoCacheForTest;
|
||||
let createNoopThreadBindingManager: typeof import("./monitor/thread-bindings.js").createNoopThreadBindingManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
beforeAll(async () => {
|
||||
({ ChannelType } = await import("@buape/carbon"));
|
||||
({ createDiscordMessageHandler } = await import("./monitor/message-handler.js"));
|
||||
({ __resetDiscordChannelInfoCacheForTest } = await import("./monitor/message-utils.js"));
|
||||
({ createNoopThreadBindingManager } = await import("./monitor/thread-bindings.js"));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
__resetDiscordChannelInfoCacheForTest();
|
||||
sendMock.mockClear().mockResolvedValue(undefined);
|
||||
updateLastRouteMock.mockClear();
|
||||
@@ -234,7 +237,10 @@ describe("discord tool result dispatch", () => {
|
||||
expect(dispatchMock).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalled();
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain("Your Discord user id: u2");
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain("Pairing code: PAIRCODE");
|
||||
expectPairingReplyText(String(sendMock.mock.calls[0]?.[1] ?? ""), {
|
||||
channel: "discord",
|
||||
idLine: "Your Discord user id: u2",
|
||||
code: "PAIRCODE",
|
||||
});
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let buildDiscordComponentCustomId: typeof import("../components.js").buildDiscordComponentCustomId;
|
||||
let buildDiscordModalCustomId: typeof import("../components.js").buildDiscordModalCustomId;
|
||||
@@ -10,8 +10,7 @@ let createDiscordComponentRoleSelect: typeof import("./agent-components.js").cre
|
||||
let createDiscordComponentStringSelect: typeof import("./agent-components.js").createDiscordComponentStringSelect;
|
||||
let createDiscordComponentUserSelect: typeof import("./agent-components.js").createDiscordComponentUserSelect;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
beforeAll(async () => {
|
||||
({ buildDiscordComponentCustomId, buildDiscordModalCustomId } = await import("../components.js"));
|
||||
({
|
||||
createDiscordComponentButton,
|
||||
|
||||
@@ -29,6 +29,7 @@ type DiscordChannelOverrideConfig = {
|
||||
systemPrompt?: string;
|
||||
includeThreadStarter?: boolean;
|
||||
autoThread?: boolean;
|
||||
autoThreadName?: "message" | "generated";
|
||||
autoArchiveDuration?: "60" | "1440" | "4320" | "10080" | 60 | 1440 | 4320 | 10080;
|
||||
};
|
||||
|
||||
@@ -403,6 +404,7 @@ function resolveDiscordChannelConfigEntry(
|
||||
systemPrompt: entry.systemPrompt,
|
||||
includeThreadStarter: entry.includeThreadStarter,
|
||||
autoThread: entry.autoThread,
|
||||
autoThreadName: entry.autoThreadName,
|
||||
autoArchiveDuration: entry.autoArchiveDuration,
|
||||
};
|
||||
return resolved;
|
||||
|
||||
@@ -3,7 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ButtonInteraction, ComponentData } from "@buape/carbon";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearSessionStoreCacheForTest } from "../../../../src/config/sessions.js";
|
||||
import type { DiscordExecApprovalConfig } from "../../../../src/config/types.discord.js";
|
||||
|
||||
@@ -273,8 +273,7 @@ beforeEach(() => {
|
||||
mockCreateOperatorApprovalsGatewayClient.mockReset();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
beforeAll(async () => {
|
||||
({
|
||||
buildExecApprovalCustomId,
|
||||
extractDiscordChannelId,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { attachEarlyGatewayErrorGuard } from "./gateway-error-guard.js";
|
||||
|
||||
describe("attachEarlyGatewayErrorGuard", () => {
|
||||
it("captures gateway errors until released", () => {
|
||||
const emitter = new EventEmitter();
|
||||
const fallbackErrorListener = vi.fn();
|
||||
emitter.on("error", fallbackErrorListener);
|
||||
const client = {
|
||||
getPlugin: vi.fn(() => ({ emitter })),
|
||||
};
|
||||
|
||||
const guard = attachEarlyGatewayErrorGuard(client as never);
|
||||
emitter.emit("error", new Error("Fatal Gateway error: 4014"));
|
||||
expect(guard.pendingErrors).toHaveLength(1);
|
||||
|
||||
guard.release();
|
||||
emitter.emit("error", new Error("Fatal Gateway error: 4000"));
|
||||
expect(guard.pendingErrors).toHaveLength(1);
|
||||
expect(fallbackErrorListener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns noop guard when gateway emitter is unavailable", () => {
|
||||
const client = {
|
||||
getPlugin: vi.fn(() => undefined),
|
||||
};
|
||||
|
||||
const guard = attachEarlyGatewayErrorGuard(client as never);
|
||||
expect(guard.pendingErrors).toEqual([]);
|
||||
expect(() => guard.release()).not.toThrow();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user