Compare commits

..

74 Commits

Author SHA1 Message Date
Mason Huang
33985ee935 refactor: rename hourlyMessageCounts to utcQuarterHourMessageCounts 2026-04-29 14:53:25 +08:00
konanok
5a104e6f3b fix: use precise hourly message counts for Peak Error Hours
The Peak Error Hours widget displayed identical error rates for all hours
because the legacy algorithm distributed errors proportionally across
session time spans. This produced misleading data (e.g., every hour
showing 9.89%) instead of highlighting actual error-heavy periods.

Changes:
- Add SessionHourlyMessageCounts type with quarterIndex field (0-95)
  representing UTC-based 15-minute buckets for timezone-accurate
  hour conversion — covers all global UTC offsets (±15/30/45 min)
- Collect per-quarter-hour message/error counts in loadSessionCostSummary
  using getUTCHours()/getUTCMinutes() with UTC day keys (formatUtcDayKey)
- Prefer precise quarterly data in buildPeakErrorHours for both local
  and UTC views, falling back to proportional allocation for sessions
  without quarterly data
- Fix DST bug: replace single getTimezoneOffset() with
  new Date(Date.UTC(...)).getHours() for DST-aware local hour mapping
- Extract accumulateMessageCounts helper to deduplicate daily/hourly
  message count aggregation logic
- Guard hourlyMessageCounts with .length check before including in
  summary (consistent with dailyLatency/dailyModelUsage pattern)
2026-04-29 10:58:15 +08:00
Ayaan Zaidi
e12eb9acdd docs(changelog): note Ollama configure picker fix 2026-04-29 08:23:57 +05:30
Ayaan Zaidi
d8c4d7c3c1 fix(configure): show provider model picker after setup 2026-04-29 08:23:57 +05:30
Ayaan Zaidi
2613692298 fix(ollama): avoid cloud model metadata fanout 2026-04-29 08:23:57 +05:30
Peter Steinberger
7a5b419843 refactor(plugins): simplify plugin cache boundaries 2026-04-29 03:52:22 +01:00
Vincent Koc
86c5f378d6 fix(github): skip clownfish active PR label 2026-04-28 19:51:26 -07:00
Peter Steinberger
9bf50450de docs: document ClawSweeper commit reruns 2026-04-29 03:51:00 +01:00
Peter Steinberger
ba0f2e948f ci: preinstall ffmpeg for live media checks 2026-04-29 03:48:33 +01:00
Peter Steinberger
1f055d23fd refactor(test): share onboarding e2e helpers 2026-04-29 03:44:36 +01:00
Ehsan
18237bc015 docs(install): fix gog/goplaces release URLs in docker-vm-runtime example (#72154)
Merged via squash.

Prepared head SHA: 7f06b724af
Co-authored-by: Ehsan <22658149+ebarkhordar@users.noreply.github.com>
Co-authored-by: Sally O'Malley <11166065+sallyom@users.noreply.github.com>
Reviewed-by: @sallyom
2026-04-28 22:42:37 -04:00
Sliverp
e0008268ad fix(onboarding): Improve the dynamic import UX. (#73419)
* fix(onboarding): skip redundant install prompt when only one source exists

When the channel-setup flow asks 'Install <plugin>?' after the user has
already picked the channel in the previous menu, and the only real
install source available is npm (or local), the prompt degenerates into
'<that source> vs Skip'. The user already expressed intent by picking
the channel, so re-confirming adds friction without offering a
meaningful choice.

Resolve directly to the available source in that case. Keep the prompt
when both npm and local sources exist so the user can still pick which
to use, and keep it when no real source exists (the prompt then only
offers Skip, which is informative).

* fix ci

* fix ci

* fix(channel-setup): skip redundant install prompt when only one source exists

Add autoConfirmSingleSource opt-in parameter to promptInstallChoice /
ensureOnboardingPluginInstalled / ensureChannelSetupPluginInstalled.
When set and only one real install source (npm or local, not both)
exists, the 'Install <plugin>? / Skip' prompt is skipped and the
single source is used directly.

Only channel-setup.ts passes autoConfirmSingleSource: true — the user
already expressed intent by picking the channel in the previous menu,
so re-confirming adds friction without a meaningful choice. The
onboarding and quickstart entry points keep the existing prompt
behavior unchanged.

Also fix findBundledPluginSourceInMap mock type in
onboarding-plugin-install.test.ts to avoid TS2345.

* fix(tests): revert auto-confirm test expectations and fix mock leak

- Revert 'offers registry npm specs' test to expect the prompt
  (autoConfirmSingleSource not passed)
- Revert channel-setup 'does not default to bundled local path' test
  to expect the prompt
- Reset findBundledPluginSourceInMap and
  resolveBundledInstallPlanForCatalogEntry mocks after the bundled
  prompt test to prevent cross-test leakage

* fix ci

* docs(changelog): add #73419
2026-04-29 10:41:42 +08:00
Peter Steinberger
180033eeae fix(update): resume git post-update in updated process 2026-04-29 03:39:09 +01:00
Vincent Koc
43da089790 fix(update): skip disabled plugins during post-update sync (#73970)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-04-28 19:36:11 -07:00
Vincent Koc
c65ec4d68c fix(github): exempt clownfish PRs from active limit closure 2026-04-28 19:34:40 -07:00
Peter Steinberger
c2e3b6e6f8 fix(openai): skip malformed empty SSE frames 2026-04-29 03:28:46 +01:00
Vincent Koc
09e2cf1103 ci: right-size codeql quality runners
Run CodeQL Critical Quality on 4 vCPU Blacksmith runners.
2026-04-28 19:26:45 -07:00
Peter Steinberger
13fdeec2cc ci: disable ClawSweeper commit checks by default 2026-04-29 03:25:20 +01:00
Peter Steinberger
38e56972cd docs: document Clownfish comment commands 2026-04-29 03:24:01 +01:00
Peter Steinberger
f4c9e71e4e fix(models): guard provider policy model shape 2026-04-29 03:16:35 +01:00
Peter Steinberger
b5a90b066d refactor: reuse docker gateway e2e helpers 2026-04-29 03:15:29 +01:00
ethanclaw
492e2a3060 fix(logs): find active log file across date boundaries (#42904)
* fix(logs): find active log file across date boundaries

Fixes #42875

When gateway runs across midnight, openclaw channels logs was looking
for today's log file instead of the active one. This change makes
the CLI find the most recently modified log file as a fallback.

(cherry picked from commit fba6b88e8644365360f82802cbe25039a091409d)

* fix(channels): resolve active log file for channel logs

(cherry picked from commit ee87397a4323f04fdd37a2fc136de02e648a92d5)

---------

Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
2026-04-28 19:11:14 -07:00
Peter Steinberger
a5790946f5 test(parallels): tolerate old updater stale chunk recovery 2026-04-29 03:10:44 +01:00
Vincent Koc
1e1fe80ae0 docs(changelog): note plugin lifecycle fixes 2026-04-28 19:02:45 -07:00
Bek
d6c2280aab fix(slack): normalize action thread targets (#73931) 2026-04-28 22:02:18 -04:00
Eden
bb6a15da04 fix(gateway): improve shutdown error visibility and add close timeout
Adds structured warning collection to gateway shutdown, preserves lifecycle timeout handling, and covers HTTP/WebSocket/subsystem warning paths.

Co-authored-by: Eden <146086744+edenfunf@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
2026-04-28 19:01:11 -07:00
Vincent Koc
df9d26eb43 fix(active-memory): narrow prompt hook timeout 2026-04-28 19:00:14 -07:00
Andrew Barnes
79159f11f6 fix: use LRU eviction for cron schedule cache
Fixes #39679
2026-04-28 18:56:19 -07:00
Peter Steinberger
610e575844 chore: add ClawSweeper agent skill 2026-04-29 02:48:40 +01:00
Vincent Koc
1f41b8b44b fix(gateway): bound default restart deferral 2026-04-28 18:42:49 -07:00
openclaw-clownfish[bot]
7e5c3753f6 fix(security): include dangerous commands in audit known commands (#73915)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-04-28 18:34:55 -07:00
Vincent Koc
7a88117f42 fix(qa): retry transient Telegram polling failures 2026-04-28 18:26:37 -07:00
Peter Steinberger
51119f2ef1 fix(release): ship dist import helper 2026-04-29 02:22:56 +01:00
Jari Mustonen
d8a600f2ad context-engine: pass runtime context to ContextEngineFactory (#67243)
Merged via squash.

Prepared head SHA: 9aca6a5af1
Co-authored-by: jarimustonen <1272053+jarimustonen@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-28 18:21:14 -07:00
Josh Lehman
12c52963ea fix: allow cron self-removal in isolated runs (#73028) 2026-04-28 18:16:31 -07:00
hcl
46783d41e9 fix(whatsapp): gate pairing access-control on extractable inbound user content (#73797) (#73823)
Merged via squash.

Prepared head SHA: 61506e1439
Co-authored-by: hclsys <7755017+hclsys@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-04-28 22:09:23 -03:00
Peter Steinberger
381c2e1d1a fix(security): tighten telegram dm audit coverage 2026-04-29 02:04:20 +01:00
Peter Steinberger
a968f4f437 test(parallels): reset macos state after restore 2026-04-29 02:03:26 +01:00
Peter Steinberger
a5824b9d01 fix(ci): stabilize full release validation 2026-04-29 02:00:30 +01:00
Peter Steinberger
28ff82dcda chore: add Clownfish cloud PR skill 2026-04-29 01:40:04 +01:00
Vincent Koc
b96e7739a9 docs(install/docker): document compose config dir default fallback
For 054b2e1b7e: docs/install/docker.md "Storage and persistence" now
records that the bundled docker-compose.yml falls back to
${HOME}/.openclaw (and ${HOME}/.openclaw/workspace for the workspace
mount), or /tmp/.openclaw when HOME is also unset, when
OPENCLAW_CONFIG_DIR / OPENCLAW_WORKSPACE_DIR are not provided. That
matches the new default expressions in the compose file and prevents an
empty-source volume spec on bare environments.
2026-04-28 17:38:25 -07:00
Vincent Koc
293348b429 fix(plugins): prebuild private qa for gauntlet 2026-04-28 17:34:18 -07:00
Vincent Koc
8e5fcfff50 fix(test): stabilize core runtime infra shard 2026-04-28 17:31:35 -07:00
Peter Steinberger
7229ec5e04 fix(ci): pin release validation child ref 2026-04-29 01:30:53 +01:00
Peter Steinberger
ceeb3a7398 ci: dispatch commit reviews on main pushes 2026-04-29 01:27:45 +01:00
pashpashpash
4aa8da3756 Route sensitive group commands to the owner privately (#73872)
* fix(commands): route sensitive group approvals privately

* fix(commands): require owner private routes

* test(commands): cover owner-derived Telegram diagnostics routing
2026-04-29 09:27:18 +09:00
Peter Steinberger
e94e9347a4 fix(cli): load plugins for local JSON agent runs 2026-04-29 01:25:50 +01:00
Peter Steinberger
c24c8bab13 test(parallels): prefer arm64 mingit downloads 2026-04-29 01:25:08 +01:00
Peter Steinberger
a820a307df fix(ci): keep postinstall script self-contained 2026-04-29 01:20:37 +01:00
Peter Steinberger
45f3074ee6 fix(cli): skip plugin preload for json agent runs 2026-04-29 01:19:05 +01:00
Peter Steinberger
3286e99bc2 refactor: share docker e2e instance helpers 2026-04-29 01:17:19 +01:00
Peter Steinberger
6249c32826 ci: raise Blacksmith Docker cache caps 2026-04-29 01:14:39 +01:00
Peter Steinberger
03b1731d0f fix(ci): preserve imported dist chunks after install 2026-04-29 01:13:03 +01:00
Joe LaPenna
054b2e1b7e fix(docker): add config dir defaults to compose mounts (#64485)
Merged via squash.

Prepared head SHA: 2c5b954a2c
Reviewed-by: @sallyom
2026-04-28 20:12:24 -04:00
Vincent Koc
fd2625a162 fix(plugins): resolve ClawHub tags in prerelease CI 2026-04-28 17:04:01 -07:00
Vincent Koc
2eac4bacee test(ci): add kitchen-sink npm plugin prerelease lane 2026-04-28 17:04:01 -07:00
Peter Steinberger
0487cc59f0 fix(bonjour): suppress ciao internal cancellations
(cherry picked from commit 27599d319e)
2026-04-29 00:59:56 +01:00
Peter Steinberger
212a32648f fix(ci): speed up tarball checks and avoid CLI bootstrap 2026-04-29 00:52:45 +01:00
Peter Steinberger
5a0702ecf8 test: add reusable OpenClaw test instance helper 2026-04-29 00:50:55 +01:00
Peter Steinberger
8f4cbbbe66 perf(prompt): stabilize channel prompt suffix 2026-04-29 00:49:57 +01:00
Peter Steinberger
d3683a61c5 fix(bonjour): recover from ciao cancellation
(cherry picked from commit c34ba97262)
2026-04-29 00:49:41 +01:00
AARON AGENT
7dc0041ca9 fix(sandbox): add once option to Docker abort listener (#58277)
Merged via squash.

Prepared head SHA: 692dd15014
Reviewed-by: @sallyom
2026-04-28 19:47:52 -04:00
Patrick Erichsen
2a7ba582cb Use kitchen sink for ClawHub plugin E2E (#73821) 2026-04-28 16:44:38 -07:00
Vincent Koc
8cca1598d9 fix(plugins): satisfy gauntlet script lint 2026-04-28 16:44:13 -07:00
Vincent Koc
e4cb0f6683 fix(plugins): run gauntlet qa through source cli 2026-04-28 16:44:12 -07:00
Vincent Koc
de3f8af48e fix(plugins): allow bundled gauntlet install scan 2026-04-28 16:44:12 -07:00
Vincent Koc
dd31a27e71 fix(plugins): run gauntlet against built cli 2026-04-28 16:44:11 -07:00
Vincent Koc
8c9cac244d fix(plugins): print gauntlet failure details 2026-04-28 16:44:11 -07:00
Vincent Koc
516a91243f fix(plugins): link gauntlet lifecycle installs 2026-04-28 16:44:11 -07:00
Vincent Koc
a6dfaaeb4e test(plugins): add gateway gauntlet 2026-04-28 16:44:10 -07:00
Peter Steinberger
ef58307f84 fix(ci): keep video live helper within plugin boundary 2026-04-29 00:40:42 +01:00
Peter Steinberger
b04c9380ed fix(ci): harden full release live checks 2026-04-29 00:36:49 +01:00
pashpashpash
43fa40a35d fix(telegram): use owners for exec approvals (#73852) 2026-04-29 08:34:46 +09:00
Patrick Erichsen
a235a487d0 docs: add clawhub rescan recovery guidance (#73414)
* docs: add clawhub rescan recovery guidance

* docs: clarify clawhub rescan wording
2026-04-28 16:34:00 -07:00
319 changed files with 9818 additions and 4986 deletions

View File

@@ -0,0 +1,138 @@
---
name: clawsweeper
description: Inspect ClawSweeper commit-review and issue/PR-sweeper reports for OpenClaw, including recent per-commit reports, finding summaries, GitHub Checks, Actions monitoring, manual backfills, and report links.
---
# ClawSweeper
ClawSweeper lives at `~/Projects/clawsweeper`. Use this skill when Peter asks
about ClawSweeper reports, commit-review checks, recent findings, historic
backfills, or whether the sweeper/dispatch lane is healthy.
## Start
```bash
cd ~/Projects/clawsweeper
git status --short
git pull --ff-only
pnpm run build
```
Do not overwrite unrelated local edits. If the tree is dirty, inspect status
and keep report-reading commands read-only unless Peter asked to commit.
## Recent Commit Reports
Canonical reports are flat:
```text
records/<repo-slug>/commits/<40-char-sha>.md
```
Use the lister instead of browsing date folders:
```bash
pnpm commit-reports -- --since 6h
pnpm commit-reports -- --since "24 hours ago" --findings
pnpm commit-reports -- --since 7d --non-clean
pnpm commit-reports -- --repo openclaw/openclaw --author steipete --since 7d
pnpm commit-reports -- --since 24h --json
```
One report per commit. Reruns overwrite the same SHA-named file. Results:
`nothing_found`, `findings`, `inconclusive`, `failed`, `skipped_non_code`.
## Monitor Actions
Receiver lane in `openclaw/clawsweeper`:
```bash
gh run list --repo openclaw/clawsweeper --workflow "ClawSweeper Commit Review" \
--limit 12 --json databaseId,displayTitle,event,status,conclusion,createdAt,updatedAt,url
gh run list --repo openclaw/clawsweeper --workflow "ClawSweeper Commit Review" \
--status in_progress --limit 20 --json databaseId,displayTitle,event,status,createdAt,url
```
Target dispatcher in `openclaw/openclaw`:
```bash
gh run list --repo openclaw/openclaw --workflow "ClawSweeper Dispatch" \
--event push --limit 8 --json databaseId,displayTitle,event,status,conclusion,headSha,url
git ls-remote https://github.com/openclaw/openclaw.git refs/heads/main
```
Check the target commit's published report check:
```bash
gh api "repos/openclaw/openclaw/commits/<sha>/check-runs?per_page=100" \
--jq '.check_runs[] | select(.name=="ClawSweeper Commit Review") | [.status,.conclusion,.details_url] | @tsv'
```
## Manual Commit Rerun / Backfill
Use the receiver workflow when Peter asks to rerun a specific commit report,
review a specific commit, or backfill a historic range. Reruns overwrite the
same canonical report file:
`records/<repo-slug>/commits/<40-char-sha>.md`.
Single-commit rerun:
```bash
gh workflow run commit-review.yml --repo openclaw/clawsweeper \
-f target_repo=openclaw/openclaw \
-f commit_sha=<sha> \
-f before_sha=<parent-sha> \
-f create_checks=false \
-f enabled=true
```
Historic range backfill:
```bash
gh workflow run commit-review.yml --repo openclaw/clawsweeper \
-f target_repo=openclaw/openclaw \
-f commit_sha=<end-sha> \
-f before_sha=<start-sha> \
-f create_checks=false \
-f enabled=true
```
Use `create_checks=true` only when Peter explicitly wants target commit check
runs. Checks are opt-in; markdown reports are the primary surface.
For a targeted rerun with extra instructions, add `additional_prompt`:
```bash
-f additional_prompt="Review this commit with focus on <topic>."
```
After dispatch, monitor and then pull the regenerated report:
```bash
gh run list --repo openclaw/clawsweeper --workflow "ClawSweeper Commit Review" \
--limit 5 --json databaseId,displayTitle,status,conclusion,url
gh run watch <run-id> --repo openclaw/clawsweeper --interval 30 --exit-status
git pull --ff-only
sed -n '1,180p' records/openclaw-openclaw/commits/<sha>.md
```
## Report Reading
Lead with counts and useful findings:
```bash
pnpm commit-reports -- --since 24h
pnpm commit-reports -- --since 24h --findings
```
If findings exist, open the markdown report and summarize:
- SHA and author/co-authors
- result, confidence, severity, check conclusion
- concrete finding and affected file
- whether the report includes tests/live checks
- GitHub report URL:
`https://github.com/openclaw/clawsweeper/blob/main/<report-path>`
Do not post GitHub comments from this lane. Commit Sweeper's public surfaces are
markdown reports and the `ClawSweeper Commit Review` check.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "ClawSweeper"
short_description: "Inspect ClawSweeper commit review reports and Actions runs."
default_prompt: "Review recent ClawSweeper commit reports and summarize findings."

View File

@@ -0,0 +1,89 @@
---
name: clownfish-cloud-pr
description: Use when launching Clownfish in GitHub Actions to create or update one guarded GitHub implementation PR from issue/PR refs, a ClawSweeper report, or a custom maintainer prompt.
---
# Clownfish Cloud PR
Use this skill when the user wants Codex to ask Clownfish to create a PR in the
cloud from issue/PR refs plus a custom prompt.
## Create One Job
```bash
cd ~/Projects/clownfish
git status --short --branch
npm run create-job -- \
--repo openclaw/openclaw \
--refs 123,456 \
--prompt-file /tmp/clownfish-prompt.md
```
From a ClawSweeper report:
```bash
npm run create-job -- \
--from-report ../clawsweeper/records/openclaw-openclaw/items/123.md
```
The script checks for an existing open PR/body match and remote branch named
`clownfish/<cluster-id>` before writing a duplicate job. Use `--dry-run` to
inspect the exact job body.
## Validate And Dispatch
```bash
npm run validate:job -- jobs/openclaw/inbox/clawsweeper-openclaw-openclaw-123.md
npm run render -- jobs/openclaw/inbox/clawsweeper-openclaw-openclaw-123.md --mode autonomous >/tmp/clownfish-rendered-prompt.md
git add jobs/openclaw/inbox/clawsweeper-openclaw-openclaw-123.md
git commit -m "chore: add ClawSweeper promoted job"
git push origin main
npm run dispatch -- jobs/openclaw/inbox/clawsweeper-openclaw-openclaw-123.md \
--mode autonomous \
--runner blacksmith-4vcpu-ubuntu-2404 \
--execution-runner blacksmith-16vcpu-ubuntu-2404 \
--model gpt-5.5
```
Do not use `--dispatch` until the job is committed and pushed; the workflow
reads the job path from GitHub. Keep `CLOWNFISH_ALLOW_MERGE=0` unless Peter
explicitly opens the merge gate.
## Maintainer Comment Commands
Clownfish can also be asked from target repo comments, but only by maintainers.
Use `/clownfish ...` or `@openclaw-clownfish ...`; do not use `@clownfish`
because that is a separate GitHub user.
Supported commands:
```text
/clownfish status
/clownfish fix ci
/clownfish address review
/clownfish rebase
/clownfish explain
/clownfish stop
@openclaw-clownfish fix ci
```
The router accepts `OWNER`, `MEMBER`, and `COLLABORATOR` comments by default.
Contributor comments are ignored without a reply. Repair commands dispatch
`cluster-worker.yml` only for existing Clownfish PRs with the `clownfish` label
or `clownfish/*` branch.
```bash
npm run comment-router -- --repo openclaw/openclaw --lookback-minutes 180
npm run comment-router -- --repo openclaw/openclaw --execute --wait-for-capacity
```
Scheduled routing stays dry until `CLOWNFISH_COMMENT_ROUTER_EXECUTE=1` is set in
`openclaw/clownfish` repo variables.
## Guardrails
- One cluster, one branch, one PR: `clownfish/<cluster-id>`.
- No security-sensitive work.
- Do not close duplicates before the fix PR path exists, lands, or is proven
unnecessary.
- Codex workers do not get GitHub tokens; deterministic scripts own writes.

View File

@@ -222,6 +222,13 @@ When `Full Release Validation` dispatches release checks, it passes the requeste
branch/tag plus an `expected_sha` so branch/tag refs resolve through the fast
remote-ref path while the package and QA jobs still validate the exact SHA.
The full-profile native live media shards use the prebuilt
`ghcr.io/openclaw/openclaw-live-media-runner:ubuntu-24.04` container so
`ffmpeg`/`ffprobe` are already present. If those jobs suddenly spend minutes in
dependency setup again, first check the `Live Media Runner Image` workflow and
the `Verify preinstalled live media dependencies` step before assuming the media
tests themselves slowed down.
The release Docker path intentionally shards the plugin/runtime tail. The
workflow uses `plugins-runtime-plugins`, `plugins-runtime-services`, and
`plugins-runtime-install-a` through `plugins-runtime-install-d`; aggregate

View File

@@ -4,6 +4,7 @@
self-hosted-runner:
labels:
# Blacksmith CI runners
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-8vcpu-ubuntu-2404
- blacksmith-8vcpu-windows-2025
- blacksmith-16vcpu-ubuntu-2404

View File

@@ -0,0 +1,16 @@
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
ffmpeg \
git \
openssh-client \
unzip \
xz-utils \
zstd \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -3,6 +3,8 @@ name: ClawSweeper Dispatch
on:
issues:
types: [opened, reopened, edited, labeled, unlabeled]
push:
branches: [main]
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned external dispatch; no checkout or untrusted PR code execution
types: [opened, reopened, synchronize, ready_for_review, edited, labeled, unlabeled]
@@ -37,6 +39,7 @@ jobs:
repositories: clawsweeper
- name: Dispatch exact ClawSweeper review
if: ${{ github.event_name != 'push' }}
env:
GH_TOKEN: ${{ steps.token.outputs.token || secrets.OPENCLAW_GH_TOKEN }}
TARGET_REPO: ${{ github.repository }}
@@ -64,3 +67,36 @@ jobs:
else
echo "::warning::Skipping ClawSweeper dispatch because the configured credential could not dispatch to openclaw/clawsweeper."
fi
- name: Dispatch ClawSweeper commit review
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.event.deleted != true }}
env:
GH_TOKEN: ${{ steps.token.outputs.token || secrets.OPENCLAW_GH_TOKEN }}
TARGET_REPO: ${{ github.repository }}
BEFORE_SHA: ${{ github.event.before }}
AFTER_SHA: ${{ github.sha }}
SOURCE_REF: ${{ github.ref }}
CREATE_CHECKS: ${{ vars.CLAWSWEEPER_COMMIT_REVIEW_CREATE_CHECKS || 'false' }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::notice::Skipping ClawSweeper commit dispatch because no dispatch credential is configured."
exit 0
fi
case "$CREATE_CHECKS" in
true|TRUE|1|yes|YES|on|ON) create_checks=true ;;
*) create_checks=false ;;
esac
payload="$(jq -nc \
--arg target_repo "$TARGET_REPO" \
--arg before_sha "$BEFORE_SHA" \
--arg after_sha "$AFTER_SHA" \
--arg ref "$SOURCE_REF" \
--argjson create_checks "$create_checks" \
'{event_type:"clawsweeper_commit_review",client_payload:{target_repo:$target_repo,before_sha:$before_sha,after_sha:$after_sha,ref:$ref,enabled:true,create_checks:$create_checks}}')"
if gh api repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"; then
echo "Dispatched ClawSweeper commit review."
else
echo "::warning::Skipping ClawSweeper commit dispatch because the configured credential could not dispatch to openclaw/clawsweeper."
fi

View File

@@ -20,7 +20,7 @@ permissions:
jobs:
javascript-typescript:
name: Critical Quality (javascript-typescript)
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
@@ -41,7 +41,7 @@ jobs:
config-boundary:
name: Critical Quality (config-boundary)
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
@@ -62,7 +62,7 @@ jobs:
gateway-runtime-boundary:
name: Critical Quality (gateway-runtime-boundary)
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
@@ -83,7 +83,7 @@ jobs:
channel-runtime-boundary:
name: Critical Quality (channel-runtime-boundary)
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
@@ -104,7 +104,7 @@ jobs:
agent-runtime-boundary:
name: Critical Quality (agent-runtime-boundary)
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
@@ -125,7 +125,7 @@ jobs:
plugin-boundary:
name: Critical Quality (plugin-boundary)
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout

View File

@@ -375,7 +375,7 @@ jobs:
fi
dispatch_and_wait openclaw-release-checks.yml \
-f ref="$TARGET_REF" \
-f ref="$TARGET_SHA" \
-f expected_sha="$TARGET_SHA" \
-f provider="$PROVIDER" \
-f mode="$MODE" \

View File

@@ -104,6 +104,8 @@ jobs:
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
with:
max-cache-size-mb: 800000
# Blacksmith's builder owns the Docker layer cache; keep smoke builds off
# explicit gha cache directives so local tags still load cleanly.
@@ -211,6 +213,8 @@ jobs:
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
with:
max-cache-size-mb: 800000
# Blacksmith's builder owns the Docker layer cache; keep smoke builds off
# explicit gha cache directives so local tags still load cleanly.
@@ -361,6 +365,8 @@ jobs:
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
with:
max-cache-size-mb: 800000
- name: Setup Node environment for package smoke
uses: ./.github/actions/setup-node-env

View File

@@ -278,6 +278,7 @@ jobs:
const labelColor = "B60205";
const labelDescription = `Author has more than ${activePrLimit} active PRs in this repo`;
const authorLogin = pullRequest.user?.login;
const headRefName = pullRequest.head?.ref ?? "";
if (!authorLogin) {
return;
}
@@ -374,7 +375,10 @@ jobs:
return false;
};
if (await isPrivilegedAuthor()) {
const isClownfishPullRequest =
typeof headRefName === "string" && headRefName.startsWith("clownfish/");
if ((await isPrivilegedAuthor()) || isClownfishPullRequest) {
if (labelNames.has(activePrLimitLabel)) {
try {
await github.rest.issues.removeLabel({

View File

@@ -0,0 +1,54 @@
name: Live Media Runner Image
on:
workflow_dispatch:
push:
branches: [main]
paths:
- ".github/images/live-media-runner/Dockerfile"
- ".github/workflows/live-media-runner-image.yml"
permissions:
contents: read
packages: write
concurrency:
group: live-media-runner-image-${{ github.ref }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
build:
name: Build live media runner image
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Login to GHCR
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
with:
max-cache-size-mb: 800000
- name: Build and push live media runner image
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
with:
context: .github/images/live-media-runner
file: .github/images/live-media-runner/Dockerfile
platforms: linux/amd64
tags: |
ghcr.io/openclaw/openclaw-live-media-runner:ubuntu-24.04
ghcr.io/openclaw/openclaw-live-media-runner:${{ github.sha }}
sbom: true
provenance: mode=max
push: true

View File

@@ -1229,6 +1229,8 @@ jobs:
- name: Setup Docker builder
if: steps.image_exists.outputs.needs_build == '1'
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
with:
max-cache-size-mb: 800000
- name: Build and push bare Docker E2E image
if: steps.plan.outputs.needs_bare_image == '1' && steps.image_exists.outputs.bare_exists != '1'
@@ -1576,196 +1578,138 @@ jobs:
label: Native live agents
command: node .release-harness/scripts/test-live-shard.mjs native-live-src-agents
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: native-live-src-gateway-core
label: Native live gateway core
command: node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-core
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: minimum stable full
- suite_id: native-live-src-gateway-profiles-anthropic
label: Native live gateway profiles Anthropic
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: native-live-src-gateway-profiles-google
label: Native live gateway profiles Google
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview,google/gemini-3-flash-preview node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: native-live-src-gateway-profiles-minimax
label: Native live gateway profiles MiniMax
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: native-live-src-gateway-profiles-openai
label: Native live gateway profiles OpenAI
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: minimum stable full
- suite_id: native-live-src-gateway-profiles-fireworks
label: Native live gateway profiles Fireworks
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=fireworks node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-src-gateway-profiles-deepseek
label: Native live gateway profiles DeepSeek
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=deepseek node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-src-gateway-profiles-opencode-go
label: Native live gateway profiles OpenCode Go deep
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=opencode-go node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-src-gateway-profiles-opencode-go-smoke
label: Native live gateway profiles OpenCode Go smoke
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=opencode-go OPENCLAW_LIVE_GATEWAY_SMOKE=1 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 45
needs_ffmpeg: false
profile_env_only: false
profiles: stable
- suite_id: native-live-src-gateway-profiles-openrouter
label: Native live gateway profiles OpenRouter
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=openrouter node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-src-gateway-profiles-xai
label: Native live gateway profiles xAI
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=xai node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-src-gateway-profiles-zai
label: Native live gateway profiles Z.ai
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=zai node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-src-gateway-backends
label: Native live gateway backends
command: node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-backends
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: native-live-test
label: Native live test harnesses
command: node .release-harness/scripts/test-live-shard.mjs native-live-test
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: native-live-extensions-a-k
label: Native live plugins A-K
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-a-k
timeout_minutes: 90
needs_ffmpeg: true
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-l-n
label: Native live plugins L-N
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-l-n
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-openai
label: Native live OpenAI plugin
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-openai
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: minimum stable full
- suite_id: native-live-extensions-o-z-other
label: Native live plugins O-Z other
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-o-z-other
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-xai
label: Native live xAI plugin
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-xai
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-media-audio
label: Native live media audio plugins
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-media-audio
timeout_minutes: 90
needs_ffmpeg: true
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-media-music-google
label: Native live media music Google
command: OPENCLAW_LIVE_MUSIC_GENERATION_PROVIDERS=google node .release-harness/scripts/test-live-shard.mjs native-live-extensions-media-music-google
timeout_minutes: 90
needs_ffmpeg: true
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-media-music-minimax
label: Native live media music MiniMax
command: OPENCLAW_LIVE_MUSIC_GENERATION_PROVIDERS=minimax node .release-harness/scripts/test-live-shard.mjs native-live-extensions-media-music-minimax
timeout_minutes: 90
needs_ffmpeg: true
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-media-video
label: Native live media video plugins
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-media-video
timeout_minutes: 90
needs_ffmpeg: true
profile_env_only: false
profiles: full
- suite_id: live-gateway-docker
label: Docker live gateway
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 120
needs_ffmpeg: false
profile_env_only: false
profiles: minimum stable full
- suite_id: live-cli-backend-docker
label: Docker live CLI backend
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-cli-backend-docker.sh
timeout_minutes: 120
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: live-acp-bind-docker
label: Docker live ACP bind
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-acp-bind-docker.sh
timeout_minutes: 120
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: live-codex-harness-docker
label: Docker live Codex harness
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-codex-harness-docker.sh
timeout_minutes: 120
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
env:
@@ -1845,25 +1789,6 @@ jobs:
if: contains(matrix.profiles, inputs.release_test_profile)
run: bash scripts/ci-hydrate-live-auth.sh
- name: Install live media dependencies
if: matrix.needs_ffmpeg && contains(matrix.profiles, inputs.release_test_profile)
shell: bash
run: |
set -euo pipefail
if ! command -v ffmpeg >/dev/null 2>&1; then
for attempt in 1 2 3; do
if sudo apt-get update -o Acquire::Retries=3; then
break
fi
if [[ "${attempt}" == "3" ]]; then
exit 1
fi
sleep $((attempt * 5))
done
sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ffmpeg
fi
ffmpeg -version | head -1
- name: Configure suite-specific env
if: contains(matrix.profiles, inputs.release_test_profile)
shell: bash
@@ -1920,3 +1845,146 @@ jobs:
- name: Run ${{ matrix.label }}
if: contains(matrix.profiles, inputs.release_test_profile)
run: ${{ matrix.command }}
validate_live_media_provider_suites:
name: Live media suites (${{ matrix.label }})
needs: validate_selected_ref
if: inputs.include_live_suites && !inputs.live_models_only
runs-on: blacksmith-32vcpu-ubuntu-2404
container:
image: ghcr.io/openclaw/openclaw-live-media-runner:ubuntu-24.04
credentials:
username: ${{ github.actor }}
password: ${{ github.token }}
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
matrix:
include:
- suite_id: native-live-extensions-a-k
label: Native live plugins A-K
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-a-k
timeout_minutes: 90
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-media-audio
label: Native live media audio plugins
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-media-audio
timeout_minutes: 90
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-media-music-google
label: Native live media music Google
command: OPENCLAW_LIVE_MUSIC_GENERATION_PROVIDERS=google node .release-harness/scripts/test-live-shard.mjs native-live-extensions-media-music-google
timeout_minutes: 90
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-media-music-minimax
label: Native live media music MiniMax
command: OPENCLAW_LIVE_MUSIC_GENERATION_PROVIDERS=minimax node .release-harness/scripts/test-live-shard.mjs native-live-extensions-media-music-minimax
timeout_minutes: 90
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-media-video
label: Native live media video plugins
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-media-video
timeout_minutes: 90
profile_env_only: false
profiles: full
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
FAL_KEY: ${{ secrets.FAL_KEY }}
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
OPENCLAW_LIVE_VIDEO_GENERATION_SKIP_PROVIDERS: ""
OPENCLAW_LIVE_VYDRA_VIDEO: "1"
OPENCLAW_VITEST_MAX_WORKERS: "2"
steps:
- name: Checkout selected ref
if: contains(matrix.profiles, inputs.release_test_profile)
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Checkout trusted live shard harness
if: contains(matrix.profiles, inputs.release_test_profile)
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
path: .release-harness
- name: Verify preinstalled live media dependencies
if: contains(matrix.profiles, inputs.release_test_profile)
shell: bash
run: |
set -euo pipefail
ffmpeg -version | head -1
ffprobe -version | head -1
- name: Setup Node environment
if: contains(matrix.profiles, inputs.release_test_profile)
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Hydrate live auth/profile inputs
if: contains(matrix.profiles, inputs.release_test_profile)
run: bash scripts/ci-hydrate-live-auth.sh
- name: Configure suite-specific env
if: contains(matrix.profiles, inputs.release_test_profile)
shell: bash
run: |
set -euo pipefail
if [[ "${{ matrix.profile_env_only }}" == "true" ]]; then
echo "OPENCLAW_DOCKER_PROFILE_ENV_ONLY=1" >> "$GITHUB_ENV"
fi
- name: Run ${{ matrix.label }}
if: contains(matrix.profiles, inputs.release_test_profile)
run: ${{ matrix.command }}

View File

@@ -13,6 +13,15 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/shutdown: report structured shutdown warnings and HTTP close timeout warnings through `ShutdownResult` while preserving lifecycle hook hardening. Carries forward #41296. Thanks @edenfunf.
- Plugins/QA: prebuild the private QA channel runtime before plugin gauntlet source runs so wrapper CPU/RSS measurements are not polluted by private QA dist rebuild work. Thanks @vincentkoc.
- Gateway/reload: bound default restart deferral and SIGUSR1 restart drain to five minutes while preserving explicit `deferralTimeoutMs: 0` indefinite waits, so stale active work accounting cannot block config reloads forever. Thanks @vincentkoc.
- Active Memory: register the prompt-build hook with the configured recall timeout plus setup grace instead of the 150s maximum budget, so default memory recall cannot delay turn startup for multiple minutes. Thanks @vincentkoc.
- CLI/channels logs: reuse the rolling log-file resolver so `openclaw channels logs` falls back to the active dated log across date boundaries without reading unrelated custom log files. Fixes #42875; carries forward #42904 and #43043. Thanks @ethanclaw and @wdskuki.
- CLI/update: skip tracked plugins disabled in config during post-update plugin sync before npm, ClawHub, or marketplace update checks, preserving their install records without failing the update. Fixes #73880. Thanks @islandpreneur007.
- Security/audit: recognize dangerous node command IDs as valid `gateway.nodes.denyCommands` entries, so audit only warns on real typos or unsupported patterns. (#56923) Thanks @chziyue.
- Telegram/exec approvals: stop treating general Telegram chat allowlists and `defaultTo` routes as native exec approvers; Telegram now uses explicit `execApprovals.approvers` or owner identity from `commands.ownerAllowFrom`, matching the first-pairing owner bootstrap path. Thanks @pashpashpash.
- Chat commands: route sensitive group `/diagnostics` and `/export-trajectory` approvals and results to a private owner route, preferring same-surface DMs before falling back to the first configured owner route, so Discord group invocations can land in Telegram when that is the primary owner interface. Thanks @pashpashpash.
- Plugin SDK/Discord: restore a deprecated `openclaw/plugin-sdk/discord` compatibility facade and the legacy compat group-policy warning export for the published `@openclaw/discord@2026.3.13` package, covering its config, account, directory, status, and thread-binding imports while keeping new plugins on generic SDK subpaths. Fixes #73685; supersedes #73703. Thanks @rderickson9 and @SymbolStar.
- Channels/Discord: suppress duplicate gateway monitors when multiple enabled accounts resolve to the same bot token, preferring config tokens over default env fallback and reporting skipped duplicates as disabled. Supersedes #73608. Thanks @kagura-agent.
- Control UI/Talk: decode Google Live binary WebSocket JSON frames and stop queued browser audio on interruption or shutdown, so browser Talk leaves `Connecting Talk...` and barge-in no longer plays stale audio. Fixes #73601 and #73460; supersedes #73466. Thanks @Spolen23 and @WadydX.
@@ -38,6 +47,8 @@ Docs: https://docs.openclaw.ai
- Channels/Discord: let text-only configs drop the `GuildVoiceStates` gateway intent and expose a bounded `/gateway/bot` metadata timeout with rate-limited fallback logs, reducing idle CPU and warning floods. Fixes #73709 and #73585. Thanks @sanchezm86 and @trac3r00.
- Agents/sessions: mark same-turn `sessions_send` and A2A reply prompts with an inter-session `isUser=false` envelope before they reach the model, so foreign session output no longer lands as bare active user text. Fixes #73702; refs #73698, #73609, #73595, and #73622. Thanks @alvelda.
- Outbound/security: strip known internal runtime scaffolding such as `<system-reminder>` and `<previous_response>` at the final channel delivery boundary and keep Discord output on targeted tag stripping, so degraded harness replies cannot leak those tags to users. Fixes #73595. Thanks @gabrielexito-stack and @martingarramon.
- Security/Telegram: load Telegram security adapters in read-only audit/doctor, audit malformed Telegram DM `allowFrom` entries even when groups are disabled, and keep allowlist DM audits from counting stale pairing-store senders, so public/shared-DM risk checks stay accurate. Refs #73698. Thanks @xace1825.
- Plugins: remove hidden manifest, provider-owner, bootstrap, and channel metadata caches so plugin installs, manifest edits, and bundled-root changes are visible on the next metadata read while keeping runtime/module loader caches for actual plugin code. Thanks @shakkernerd.
- CLI/plugins: use plugin metadata snapshots for install slot selection and add opt-in plugin lifecycle timing traces, so plugin install avoids runtime-loading the plugin registry for metadata-only decisions. Thanks @shakkernerd.
- fix(plugins): restrict bundled plugin dir resolution to trusted package roots. (#73275) Thanks @pgondhi987.
- fix(security): prevent workspace PATH injection via service env and trash helpers. (#73264) Thanks @pgondhi987.
@@ -61,6 +72,9 @@ Docs: https://docs.openclaw.ai
- CLI/model probes: request trusted operator scope for `infer model run --gateway --model <provider/model>` so Gateway raw model smokes can use one-off provider/model overrides instead of being rejected before provider auth resolution. Fixes #73759. Thanks @chrislro.
- CLI/image describe: pass `--prompt` and `--timeout-ms` through `infer image describe` and `describe-many`, so custom vision instructions and slow local model budgets reach media-understanding providers such as Ollama, OpenAI, Google, and OpenRouter. Refs #63700. Thanks @cedricjanssens.
- Model selection: include the rejected provider/model ref and allowlist recovery hint when a stored session override is cleared, so local model selections such as Gemma GGUF variants do not fall back to the default with a generic message. Refs #71069. Thanks @CyberRaccoonTeam.
- OpenAI-compatible providers: drop malformed event-only or blank-data SSE frames before the OpenAI SDK stream parser sees them, so proxies that split `event:` from `data:` no longer crash streaming runs with `Unexpected end of JSON input`. Fixes #52802. Thanks @LyHug.
- Local model prompt caching: keep stable Project Context above volatile channel/session prompt guidance and stop embedding current channel names in the message tool description, so Ollama, MLX, llama.cpp, and other prefix-cache backends avoid avoidable full prompt reprocessing across channel turns. Fixes #40256; supersedes #40296. Thanks @rhclaw and @sriram369.
- Gateway/OpenAI-compatible API: guard provider policy lookup against runtime providers with non-array `models` values, so `/v1/chat/completions` no longer fails with `provider?.models?.some is not a function`. Fixes #66744; carries forward #66761. Thanks @MightyMoud, @MukundaKatta.
- WhatsApp/Web: pass explicit Baileys socket timings into every WhatsApp Web socket and expose `web.whatsapp.*` keepalive, connect, and query timeout settings so unstable networks can avoid repeated 408 disconnect and opening-handshake timeout loops. Fixes #56365. (#73580) Thanks @velvet-shark.
- Channels/Telegram: persist native command metadata on target sessions so topic, helper, and ACP-bound slash commands keep their session metadata attached to the routed conversation. (#57548) Thanks @GaosCode.
- Channels/native commands: keep validated native slash command replies visible in group chats while preserving explicit owner allowlists for command authorization. (#73672) Thanks @obviyus.
@@ -68,6 +82,10 @@ Docs: https://docs.openclaw.ai
- Telegram/exec: infer native exec approvers from `commands.ownerAllowFrom` and auto-enable the Telegram approval client when an owner is resolvable, so owner-only commands such as `/diagnostics` can be approved in Telegram without duplicate per-channel approver config. Thanks @pashpashpash.
- Auto-reply/session: carry the tail of user/assistant turns into the freshly-rotated transcript on silent in-reply session resets (compaction failure, role-ordering conflict) so direct-chat continuity survives the rebind. Fixes #70853. (#70898) Thanks @neeravmakwana.
- Config: skip malformed non-string `env.vars` entries before env-reference checks, so config loading no longer crashes on JSON values like numbers or booleans. (#42402) Thanks @MiltonHeYan.
- Docker Compose: default missing config and workspace bind mounts to `${HOME:-/tmp}/.openclaw` so manual compose runs do not create invalid empty-source volume specs. (#64485) Thanks @jlapenna.
- Agents/context engines: preserve the child agent's configured `agentDir` when subagent cleanup re-resolves a context engine, so `onSubagentEnded` hooks keep operating on the correct per-agent state. (#67243) Thanks @jarimustonen.
- Channels/WhatsApp: restrict pairing verification replies to real inbound user content, preventing unsolicited prompts from receipts, typing indicators, presence updates, and other non-message Baileys upserts. Fixes #73797. (#73823) Thanks @hclsys.
- Configure/Ollama: show the configured Ollama model allowlist after Cloud only or Cloud + Local setup and skip slow per-model cloud metadata fetches. (#73995) Thanks @obviyus.
## 2026.4.27
@@ -120,6 +138,8 @@ Docs: https://docs.openclaw.ai
### Fixes
- Control UI: fix Peak Error Hours showing incorrect hourly rates when the browser's timezone observes DST, by storing hourly message counts with UTC date keys and using DST-aware `Date.getHours()` for local conversion. Also extract `accumulateMessageCounts` helper to reduce duplicated daily/hourly aggregation logic. (#49396) Thanks @konanok.
- CLI/channel-setup: auto-skip the redundant "Install \<plugin\>?" confirmation when only one install source (npm or local) exists, show `download from <npm-spec>` hints for installable catalog channels in the picker, and suppress misleading npm hints for already-bundled channels. Fixes #73419. Thanks @sliverp.
- BlueBubbles: tighten DM-vs-group routing across the outbound session route (`chat_guid:iMessage;-;...` DMs no longer classified as groups), reaction handling (drop group reactions that arrive without any chat identifier instead of synthesizing a `"group"` literal peerId), inbound `chatGuid` fallback (no longer fall back to the sender's DM chatGuid when resolving a group whose webhook omits chatGuid+chatId+chatIdentifier), and short message id resolution (carry caller chat context so a numeric short id reused after a long group conversation cannot silently resolve to a message in a different chat, with the same cross-chat guard applied to full GUIDs so retries cannot bypass it). Thanks @zqchris.
- Agents/approvals: fail restart-interrupted sessions whose transcript tail is still `approval-pending` instead of replaying stale exec approval IDs into the new Gateway process after restart. Fixes #65486. Thanks @mjmai20682068-create.
- CLI/Gateway: use method-specific least-privilege scopes for classified CLI Gateway calls while preserving legacy broad scopes for unclassified plugin methods, so read-only commands no longer create admin/write/pairing scope-upgrade prompts. Fixes #68634. Thanks @nightmusher.
@@ -351,6 +371,7 @@ Docs: https://docs.openclaw.ai
- Discord/gateway: count failed health-monitor restart attempts toward cooldown and hourly caps, and evict stale account lifecycle state during channel reloads so repeated Discord gateway recovery cannot loop on old status. Fixes #38596. (#40413) Thanks @jellyAI-dev and @vashquez.
- TTS/BlueBubbles: pre-transcode synthesized MP3 audio to opus-in-CAF (mono, 24 kHz — validated against macOS 15.x Messages.app's native voice-memo CAF descriptor) on macOS hosts before handing the file to BlueBubbles, so iMessage renders the result as a native voice-memo bubble with proper duration and waveform UI instead of a plain file attachment. Adds an opt-in `tts.voice.preferAudioFileFormat` channel capability and a magic-byte sniff for the CAF container so the host-local-media validator (which uses `file-type` and didn't recognize CAF natively) can verify the pre-transcoded buffer. Channels that don't opt in are unaffected. (#72586) Fixes #72506. Thanks @omarshahine.
- Feishu: retry WebSocket startup failures with monitor-owned backoff while preserving SDK-local heartbeat defaults, so persistent-connection startup failures no longer leave the monitor hung. Fixes #68766; related #42354 and #55532. Thanks @alex-xuweilong, @120106835, @sirfengyu, and @tianhaocui.
- Cron: normalize isolated job tool allowlists before granting the narrow self-removal cron tool path, keeping scheduled jobs aligned with shared tool policy normalization. (#73028) Thanks @jalehman.
## 2026.4.26

View File

@@ -130,7 +130,8 @@ RUN printf 'packages:\n - .\n - ui\n' > /tmp/pnpm-workspace.runtime.yaml && \
cp /tmp/pnpm-workspace.runtime.yaml pnpm-workspace.yaml && \
CI=true NPM_CONFIG_FROZEN_LOCKFILE=false pnpm prune --prod && \
node scripts/postinstall-bundled-plugins.mjs && \
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \
node scripts/check-package-dist-imports.mjs /app
# ── Runtime base image ──────────────────────────────────────────
FROM ${OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE} AS base-runtime

View File

@@ -26,8 +26,8 @@ services:
OPENCLAW_PLUGIN_STAGE_DIR: /var/lib/openclaw/plugin-runtime-deps
TZ: ${OPENCLAW_TZ:-UTC}
volumes:
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
- ${OPENCLAW_CONFIG_DIR:-${HOME:-/tmp}/.openclaw}:/home/node/.openclaw
- ${OPENCLAW_WORKSPACE_DIR:-${HOME:-/tmp}/.openclaw/workspace}:/home/node/.openclaw/workspace
- openclaw-plugin-runtime-deps:/var/lib/openclaw/plugin-runtime-deps
## Uncomment the lines below to enable sandbox isolation
## (agents.defaults.sandbox). Requires Docker CLI in the image
@@ -90,8 +90,8 @@ services:
OPENCLAW_PLUGIN_STAGE_DIR: /var/lib/openclaw/plugin-runtime-deps
TZ: ${OPENCLAW_TZ:-UTC}
volumes:
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
- ${OPENCLAW_CONFIG_DIR:-${HOME:-/tmp}/.openclaw}:/home/node/.openclaw
- ${OPENCLAW_WORKSPACE_DIR:-${HOME:-/tmp}/.openclaw/workspace}:/home/node/.openclaw/workspace
- openclaw-plugin-runtime-deps:/var/lib/openclaw/plugin-runtime-deps
stdin_open: true
tty: true

View File

@@ -1,4 +1,4 @@
d4c98bce7b547349b9cbbe08ec1018eafce9900502d7794df993d07fdec0e2e0 config-baseline.json
6ce74b2ab3544e5375009a435a2360a3095e6bd759bb7dd8114293fb8a0e2b25 config-baseline.core.json
0e38bad86bdc96c38573f6d51ac9e6fc5306cc20fb4a454399c57c105a61ba87 config-baseline.channel.json
7f9815a297504c75022c4db2df250ce4cc9ff5c3f69250c67ca253b89148b9f3 config-baseline.json
8bc9fda7c1096472beaa416a61043ce51d691d4dcad9ed3e0be46e68bb70b0ce config-baseline.core.json
45162ff84813be8a1fe561ed8d6245a248d5c6288ef9e9af51bdf4ec05ef65ad config-baseline.channel.json
0dd6583fafae6c9134e46c4cf9bddee9822d6436436dcb1a6dcba6d012962e51 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
46476e7b4fee105ca27aed9c769c507f70f02b8ce8586c135feb18e751db0de1 plugin-sdk-api-baseline.json
4bc1c0dc66d910c80694fa1a6b7ba3ab488bf737b3566e53b8a5857c16d2e0b1 plugin-sdk-api-baseline.jsonl
e7d03a0d5aed4f1afb5c7d5e235a166e1e248090632248eaa92b0016531e7f3b plugin-sdk-api-baseline.json
b9bbf8e444b358485cb33c634d3f6f6588004a5c32482c1a473167957269ae58 plugin-sdk-api-baseline.jsonl

View File

@@ -904,6 +904,8 @@ Default slash command settings:
Discord auto-enables native exec approvals when `enabled` is unset or `"auto"` and at least one approver can be resolved, either from `execApprovals.approvers` or from `commands.ownerAllowFrom`. Discord does not infer exec approvers from channel `allowFrom`, legacy `dm.allowFrom`, or direct-message `defaultTo`. Set `enabled: false` to disable Discord as a native approval client explicitly.
For sensitive owner-only group commands such as `/diagnostics` and `/export-trajectory`, OpenClaw sends approval prompts and final results privately. It tries Discord DM first when the invoking owner has a Discord owner route; if that is not available, it falls back to the first available owner route from `commands.ownerAllowFrom`, such as Telegram.
When `target` is `channel` or `both`, the approval prompt is visible in the channel. Only resolved approvers can use the buttons; other users receive an ephemeral denial. Approval prompts include the command text, so only enable channel delivery in trusted channels. If the channel ID cannot be derived from the session key, OpenClaw falls back to DM delivery.
Discord also renders the shared approval buttons used by other chat channels. The native Discord adapter mainly adds approver DM routing and channel fanout.

View File

@@ -778,10 +778,12 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \
Config path:
- `channels.telegram.execApprovals.enabled` (auto-enables when at least one approver is resolvable)
- `channels.telegram.execApprovals.approvers` (falls back to numeric owner IDs from `commands.ownerAllowFrom`, `allowFrom`, or `defaultTo`)
- `channels.telegram.execApprovals.approvers` (falls back to numeric owner IDs from `commands.ownerAllowFrom`)
- `channels.telegram.execApprovals.target`: `dm` (default) | `channel` | `both`
- `agentFilter`, `sessionFilter`
`channels.telegram.allowFrom`, `groupAllowFrom`, and `defaultTo` control who can talk to the bot and where it sends normal replies. They do not make someone an exec approver. The first approved DM pairing bootstraps `commands.ownerAllowFrom` when no command owner exists yet, so the one-owner setup still works without duplicating IDs under `execApprovals.approvers`.
Channel delivery shows the command text in the chat; only enable `channel` or `both` in trusted groups/topics. When the prompt lands in a forum topic, OpenClaw preserves the topic for the approval prompt and the follow-up. Exec approvals expire after 30 minutes by default.
Inline approval buttons also require `channels.telegram.capabilities.inlineButtons` to allow the target surface (`dm`, `group`, or `all`). Approval IDs prefixed with `plugin:` resolve through plugin approvals; others resolve through exec approvals first.

View File

@@ -45,6 +45,13 @@ provider failures easier to rerun and diagnose. The aggregate
`native-live-extensions-media-music` shard names remain valid for manual
one-shot reruns.
The native live media shards run in
`ghcr.io/openclaw/openclaw-live-media-runner:ubuntu-24.04`, built by the
`Live Media Runner Image` workflow. That image preinstalls `ffmpeg` and
`ffprobe`; media jobs only verify the binaries before setup. Keep Docker-backed
live suites on normal Blacksmith runners, because container jobs are the wrong
place to launch nested Docker tests.
`OpenClaw Release Checks` uses the trusted workflow ref to resolve the selected
ref once into a `release-package-under-test` tarball, then passes that artifact
to both the live/E2E release-path Docker workflow and the package acceptance
@@ -250,8 +257,9 @@ default workflow because the macOS build dominates runtime even when clean.
The `CodeQL Critical Quality` workflow is the matching non-security shard. It
runs only error-severity, non-security JavaScript/TypeScript quality queries
over narrow high-value surfaces. Its baseline job scans the same auth, secrets,
sandbox, cron, and gateway surface as the security workflow. The config-boundary
over narrow high-value surfaces on the smaller Blacksmith Linux runner. Its
baseline job scans the same auth, secrets, sandbox, cron, and gateway surface
as the security workflow. The config-boundary
job scans config schema, migration, normalization, and IO contracts under the
separate `/codeql-critical-quality/config-boundary` category. The
gateway-runtime-boundary job scans gateway protocol schemas and server method
@@ -393,6 +401,7 @@ The automatic CI concurrency key is versioned (`CI-v7-*`) so a GitHub-side zombi
| Runner | Jobs |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ubuntu-24.04` | `preflight`, fast security jobs and aggregates (`security-scm-fast`, `security-dependency-audit`, `security-fast`), fast protocol/contract/bundled checks, sharded channel contract checks, `check` shards except lint, `check-additional` shards and aggregates, Node test aggregate verifiers, docs checks, Python skills, workflow-sanity, labeler, auto-response; install-smoke preflight also uses GitHub-hosted Ubuntu so the Blacksmith matrix can queue earlier |
| `blacksmith-4vcpu-ubuntu-2404` | `CodeQL Critical Quality`, lower-weight extension shards, `checks-fast-core`, `checks-node-compat-node22`, `check-prod-types`, and `check-test-types` |
| `blacksmith-8vcpu-ubuntu-2404` | `build-artifacts`, build-smoke, Linux Node test shards, bundled plugin test shards, `android` |
| `blacksmith-16vcpu-ubuntu-2404` | `check-lint`, which remains CPU-sensitive enough that 8 vCPU cost more than it saved; install-smoke Docker builds, where 32-vCPU queue time cost more than it saved |
| `blacksmith-16vcpu-windows-2025` | `checks-windows` |

View File

@@ -100,6 +100,8 @@ Bare package names are checked against ClawHub first, then npm. Treat plugin ins
This CLI flag applies to plugin install/update flows. Gateway-backed skill dependency installs use the matching `dangerouslyForceUnsafeInstall` request override, while `openclaw skills install` remains a separate ClawHub skill download/install flow.
If a plugin you published on ClawHub is blocked by a registry scan, use the publisher steps in [ClawHub](/tools/clawhub).
</Accordion>
<Accordion title="Hook packs and npm specs">
`plugins install` is also the install surface for hook packs that expose `openclaw.hooks` in `package.json`. Use `openclaw hooks` for filtered hook visibility and per-hook enablement, not package installation.

View File

@@ -122,7 +122,7 @@ A plugin can register a context engine using the plugin API:
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
export default function register(api) {
api.registerContextEngine("my-engine", () => ({
api.registerContextEngine("my-engine", (ctx) => ({
info: {
id: "my-engine",
name: "My Context Engine",
@@ -154,6 +154,10 @@ export default function register(api) {
}
```
The factory `ctx` includes optional `config`, `agentDir`, and `workspaceDir`
values so plugins can initialize per-agent or per-workspace state before the
first lifecycle hook runs.
Then enable it in config:
```json5

View File

@@ -53,6 +53,14 @@ The prompt is intentionally compact and uses fixed sections:
- **Runtime**: host, OS, node, model, repo root (when detected), thinking level (one line).
- **Reasoning**: current visibility level + /reasoning toggle hint.
OpenClaw keeps large stable content, including **Project Context**, above the
internal prompt cache boundary. Volatile channel/session sections such as
Control UI embed guidance, **Messaging**, **Voice**, **Group Chat Context**,
**Reactions**, **Heartbeats**, and **Runtime** are appended below that boundary
so local backends with prefix caches can reuse the stable workspace prefix
across channel turns. Tool descriptions should likewise avoid embedding current
channel names when the accepted schema already carries that runtime detail.
The Tooling section also includes runtime guidance for long-running work:
- use cron for future follow-up (`check back later`, reminders, recurring work)

View File

@@ -137,6 +137,7 @@ The Gateway writes a rolling log file (printed on startup as
`gateway log file: ...`). Look for `bonjour:` lines, especially:
- `bonjour: advertise failed ...`
- `bonjour: suppressing ciao cancellation ...`
- `bonjour: ... name conflict resolved` / `hostname conflict resolved`
- `bonjour: watchdog detected non-announced service ...`
- `bonjour: disabling advertiser after ... failed restarts ...`

View File

@@ -515,7 +515,7 @@ See [Multiple Gateways](/gateway/multiple-gateways).
reload: {
mode: "hybrid", // off | restart | hot | hybrid
debounceMs: 500,
deferralTimeoutMs: 0,
deferralTimeoutMs: 300000,
},
},
}
@@ -527,7 +527,7 @@ See [Multiple Gateways](/gateway/multiple-gateways).
- `"hot"`: apply changes in-process without restarting.
- `"hybrid"` (default): try hot reload first; fall back to restart if required.
- `debounceMs`: debounce window in ms before config changes are applied (non-negative integer).
- `deferralTimeoutMs`: optional maximum time in ms to wait for in-flight operations before forcing a restart. Omit it or set `0` to wait indefinitely and log periodic still-pending warnings.
- `deferralTimeoutMs`: optional maximum time in ms to wait for in-flight operations before forcing a restart. Omit it to use the default bounded wait (`300000`); set `0` to wait indefinitely and log periodic still-pending warnings.
---

View File

@@ -613,8 +613,8 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or
- MCP channel bridge (seeded Gateway + stdio bridge + raw Claude notification-frame smoke): `pnpm test:docker:mcp-channels` (script: `scripts/e2e/mcp-channels-docker.sh`)
- Pi bundle MCP tools (real stdio MCP server + embedded Pi profile allow/deny smoke): `pnpm test:docker:pi-bundle-mcp-tools` (script: `scripts/e2e/pi-bundle-mcp-tools-docker.sh`)
- Cron/subagent MCP cleanup (real Gateway + stdio MCP child teardown after isolated cron and one-shot subagent runs): `pnpm test:docker:cron-mcp-cleanup` (script: `scripts/e2e/cron-mcp-cleanup-docker.sh`)
- Plugins (install smoke, ClawHub install/uninstall, marketplace updates, and Claude-bundle enable/inspect): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`)
Set `OPENCLAW_PLUGINS_E2E_CLAWHUB=0` to skip the live ClawHub block, or override the default package with `OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC` and `OPENCLAW_PLUGINS_E2E_CLAWHUB_ID`.
- Plugins (install smoke, ClawHub kitchen-sink install/uninstall, marketplace updates, and Claude-bundle enable/inspect): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`)
Set `OPENCLAW_PLUGINS_E2E_CLAWHUB=0` to skip the ClawHub block, or override the default kitchen-sink package/runtime pair with `OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC` and `OPENCLAW_PLUGINS_E2E_CLAWHUB_ID`. Without `OPENCLAW_CLAWHUB_URL`/`CLAWHUB_URL`, the test uses a hermetic local ClawHub fixture server.
- Plugin update unchanged smoke: `pnpm test:docker:plugin-update` (script: `scripts/e2e/plugin-update-unchanged-docker.sh`)
- Config reload metadata smoke: `pnpm test:docker:config-reload` (script: `scripts/e2e/config-reload-source-docker.sh`)
- Bundled plugin runtime deps: `pnpm test:docker:bundled-channel-deps` builds a small Docker runner image by default, builds and packs OpenClaw once on the host, then mounts that tarball into each Linux install scenario. Reuse the image with `OPENCLAW_SKIP_DOCKER_BUILD=1`, skip the host rebuild after a fresh local build with `OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD=0`, or point at an existing tarball with `OPENCLAW_CURRENT_PACKAGE_TGZ=/path/to/openclaw-*.tgz`. The full Docker aggregate and release-path bundled-channel chunks pre-pack this tarball once, then shard bundled channel checks into independent lanes, including separate update lanes for Telegram, Discord, Slack, Feishu, memory-lancedb, and ACPX. Release chunks split channel smokes, update targets, and setup/runtime contracts into `bundled-channels-core`, `bundled-channels-update-a`, `bundled-channels-update-b`, and `bundled-channels-contracts`; the aggregate `bundled-channels` chunk remains available for manual reruns. The release workflow also splits provider installer chunks and bundled plugin install/uninstall chunks; legacy `package-update`, `plugins-runtime`, and `plugins-integrations` chunks remain aggregate aliases for manual reruns. Use `OPENCLAW_BUNDLED_CHANNELS=telegram,slack` to narrow the channel matrix when running the bundled lane directly, or `OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=telegram,acpx` to narrow the update scenario. The lane also verifies that `channels.<id>.enabled=false` and `plugins.entries.<id>.enabled=false` suppress doctor/runtime-dependency repair.

View File

@@ -17,7 +17,7 @@ All external binaries required by skills must be installed at image build time.
The examples below show three common binaries only:
- `gog` for Gmail access
- `gog` (from `gogcli`) for Gmail access
- `goplaces` for Google Places
- `wacli` for WhatsApp
@@ -37,17 +37,23 @@ FROM node:24-bookworm
RUN apt-get update && apt-get install -y socat && rm -rf /var/lib/apt/lists/*
# Example binary 1: Gmail CLI
RUN curl -L https://github.com/steipete/gog/releases/latest/download/gog_Linux_x86_64.tar.gz \
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/gog
# Example binary 1: Gmail CLI (gogcli — installs as `gog`)
# Copy the current Linux asset URL from https://github.com/steipete/gogcli/releases
RUN curl -L https://github.com/steipete/gogcli/releases/latest/download/gogcli_linux_amd64.tar.gz \
| tar -xzO gog > /usr/local/bin/gog; \
chmod +x /usr/local/bin/gog
# Example binary 2: Google Places CLI
RUN curl -L https://github.com/steipete/goplaces/releases/latest/download/goplaces_Linux_x86_64.tar.gz \
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/goplaces
# Copy the current Linux asset URL from https://github.com/steipete/goplaces/releases
RUN curl -L https://github.com/steipete/goplaces/releases/latest/download/goplaces_linux_amd64.tar.gz \
| tar -xzO goplaces > /usr/local/bin/goplaces; \
chmod +x /usr/local/bin/goplaces
# Example binary 3: WhatsApp CLI
RUN curl -L https://github.com/steipete/wacli/releases/latest/download/wacli_Linux_x86_64.tar.gz \
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/wacli
# Copy the current Linux asset URL from https://github.com/steipete/wacli/releases
RUN curl -L https://github.com/steipete/wacli/releases/latest/download/wacli-linux-amd64.tar.gz \
| tar -xzO wacli > /usr/local/bin/wacli; \
chmod +x /usr/local/bin/wacli
# Add more binaries below using the same pattern
@@ -70,7 +76,7 @@ CMD ["node","dist/index.js"]
```
<Note>
The download URLs above are for x86_64 (amd64). For ARM-based VMs (e.g. Hetzner ARM, GCP Tau T2A), replace the download URLs with the appropriate ARM64 variants from each tool's release page.
The URLs above are examples. For ARM-based VMs, choose the `arm64` assets. For reproducible builds, pin versioned release URLs.
</Note>
## Build and launch

View File

@@ -261,7 +261,11 @@ For gotchas and troubleshooting, see [Bonjour discovery](/gateway/bonjour).
Docker Compose bind-mounts `OPENCLAW_CONFIG_DIR` to `/home/node/.openclaw` and
`OPENCLAW_WORKSPACE_DIR` to `/home/node/.openclaw/workspace`, so those paths
survive container replacement.
survive container replacement. When either variable is unset, the bundled
`docker-compose.yml` falls back to `${HOME}/.openclaw` (and
`${HOME}/.openclaw/workspace` for the workspace mount), or `/tmp/.openclaw`
when `HOME` itself is also missing. That keeps `docker compose up` from
emitting an empty-source volume spec on bare environments.
That mounted config directory is where OpenClaw keeps:

View File

@@ -87,35 +87,51 @@ discovery order. When setup runtime does execute, registry diagnostics report
drift between `setup.providers` / `setup.cliBackends` and the providers or CLI
backends registered by setup-api without blocking legacy plugins.
### What the loader caches
### Plugin cache boundary
OpenClaw keeps short in-process caches for:
OpenClaw does not cache plugin discovery results or direct manifest registry
data behind wall-clock windows. Installs, manifest edits, and load-path changes
must become visible on the next explicit metadata read or snapshot rebuild.
The safe metadata fast path is explicit object ownership, not a hidden cache.
Gateway startup hot paths should pass the current `PluginMetadataSnapshot`, the
derived `PluginLookUpTable`, or an explicit manifest registry through the call
chain. Config validation, startup auto-enable, plugin bootstrap, setup lookup,
and provider selection can reuse those objects while they represent the current
config and plugin inventory. When that input changes, rebuild and replace the
snapshot instead of mutating it or keeping historical copies.
Views over the active plugin registry and bundled channel bootstrap helpers
should be recomputed from the current registry/root. Short-lived maps are fine
inside one call to dedupe work or guard reentry; they must not become process
metadata caches.
For plugin loading, the persistent cache layer is runtime loading. It may reuse
loader state when code or installed artifacts are actually loaded, such as:
- `PluginLoaderCacheState` and compatible active runtime registries
- jiti/module caches and public-surface loader caches used to avoid importing
the same runtime surface repeatedly
- runtime dependency mirrors and filesystem caches for installed plugin
artifacts
- short-lived per-call maps for path normalization or duplicate resolution
Those caches are data-plane implementation details. They must not answer
control-plane questions such as "which plugin owns this provider?" unless the
caller deliberately asked for runtime loading.
Do not add persistent or wall-clock caches for:
- discovery results
- manifest registry data
- loaded plugin registries
- direct manifest registries
- manifest registries reconstructed from the installed plugin index
- provider owner lookup, model suppression, provider policy, or public-artifact
metadata
- any other manifest-derived answer where a changed manifest, installed index,
or load path should be visible on the next metadata read
These caches reduce bursty startup and repeated command overhead. They are safe
to think of as short-lived performance caches, not persistence.
Gateway startup hot paths should prefer the current `PluginMetadataSnapshot`,
the derived `PluginLookUpTable`, or an explicit manifest registry passed through
the call chain. Config validation, startup auto-enable, and plugin bootstrap use
the same snapshot when available. For callers that still rebuild manifest
metadata from the persisted installed plugin index, OpenClaw also keeps a small
bounded fallback cache keyed by the installed index, request shape, config
policy, runtime roots, and manifest/package file signatures. That cache is only a
fallback for repeated installed-index reconstruction; it is not a mutable runtime
plugin registry.
Performance note:
- Set `OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE=1` or
`OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE=1` to disable these caches.
- Set `OPENCLAW_DISABLE_INSTALLED_PLUGIN_MANIFEST_REGISTRY_CACHE=1` to disable
only the installed-index manifest-registry fallback cache.
- Tune cache windows with `OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS` and
`OPENCLAW_PLUGIN_MANIFEST_CACHE_MS`.
Callers that rebuild manifest metadata from the persisted installed plugin
index reconstruct that registry on demand. The installed index is durable
source-plane state; it is not a hidden in-process metadata cache.
## Registry model
@@ -944,7 +960,7 @@ source-plane diagnostics without adding a second raw filesystem-path disclosure
surface. The persisted `plugins/installs.json` plugin index is the install
source of truth and can be refreshed without loading plugin runtime modules.
Its `installRecords` map is durable even when a plugin manifest is missing or
invalid; its `plugins` array is a rebuildable manifest/cache view.
invalid; its `plugins` array is a rebuildable manifest view.
## Context engine plugins
@@ -960,7 +976,7 @@ pipeline rather than just add memory search or hooks.
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
export default function (api) {
api.registerContextEngine("lossless-claw", () => ({
api.registerContextEngine("lossless-claw", (ctx) => ({
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
async ingest() {
return { ingested: true };
@@ -982,6 +998,9 @@ export default function (api) {
}
```
The factory `ctx` exposes optional `config`, `agentDir`, and `workspaceDir`
values for construction-time initialization.
If your engine does **not** own the compaction algorithm, keep `compact()`
implemented and delegate it explicitly:
@@ -992,7 +1011,7 @@ import {
} from "openclaw/plugin-sdk/core";
export default function (api) {
api.registerContextEngine("my-memory-engine", () => ({
api.registerContextEngine("my-memory-engine", (ctx) => ({
info: {
id: "my-memory-engine",
name: "My Memory Engine",

View File

@@ -165,7 +165,9 @@ The snapshot and lookup table keep repeated startup decisions on the fast path:
The safety boundary is snapshot replacement, not mutation. Rebuild the snapshot when config, plugin inventory, install records, or persisted index policy changes. Do not treat it as a broad mutable global registry, and do not keep unbounded historical snapshots. Runtime plugin loading remains separate from metadata snapshots so stale runtime state cannot be hidden behind a metadata cache.
Some cold-path callers still reconstruct manifest registries directly from the persisted installed plugin index instead of receiving a Gateway `PluginLookUpTable`. That fallback path keeps a small bounded in-memory cache keyed by the installed index, request shape, config policy, runtime roots, and manifest/package file signatures. It is a fallback safety net for repeated index reconstruction, not the preferred Gateway hot path. Prefer passing the current lookup table or an explicit manifest registry through runtime flows when a caller already has one.
The cache rule is documented in [Plugin architecture internals](/plugins/architecture-internals#plugin-cache-boundary): manifest and discovery metadata are fresh unless a caller holds an explicit snapshot, lookup table, or manifest registry for the current flow. Hidden metadata caches and wall-clock TTLs are not part of plugin loading. Only runtime loader, module, and dependency-artifact caches may persist after code or installed artifacts are actually loaded.
Some cold-path callers still reconstruct manifest registries directly from the persisted installed plugin index instead of receiving a Gateway `PluginLookUpTable`. That path now reconstructs the registry on demand; prefer passing the current lookup table or an explicit manifest registry through runtime flows when a caller already has one.
### Activation planning

View File

@@ -6,7 +6,7 @@ read_when:
- You are deciding between Codex Computer Use, PeekabooBridge, and direct cua-driver MCP
- You are deciding between Codex Computer Use and a direct cua-driver MCP setup
- You are configuring computerUse for the bundled Codex plugin
- You are troubleshooting /codex computer-use status, install, or setup
- You are troubleshooting /codex computer-use status or install
---
Computer Use is a Codex-native MCP plugin for local desktop control. OpenClaw
@@ -115,15 +115,6 @@ register the bundled Codex marketplace from
fails. If setup still cannot make the MCP server available, the turn fails
before the thread starts.
During interactive onboarding, if you choose Codex login and opt into the native
Codex runtime on macOS, OpenClaw offers to set up Codex Computer Use immediately.
That setup installs or re-enables Computer Use if needed and invokes a read-only
Computer Use tool so native first-run permissions can appear while you are
present.
You can also run `/codex computer-use setup` later from an OpenClaw chat
surface. It uses the same install and read-only probe path.
Existing sessions keep their runtime and Codex thread binding. After changing
`agentRuntime` or Computer Use config, use `/new` or `/reset` in the affected
chat before testing.
@@ -137,7 +128,6 @@ not `openclaw codex ...` CLI subcommands:
```text
/codex computer-use status
/codex computer-use install
/codex computer-use setup
/codex computer-use install --source <marketplace-source>
/codex computer-use install --marketplace-path <path>
/codex computer-use install --marketplace <name>
@@ -150,10 +140,6 @@ enable Codex plugin support.
marketplace source, installs or re-enables the configured plugin through Codex
app-server, reloads MCP servers, and verifies that the MCP server exposes tools.
`setup` runs `install`, starts a temporary Codex thread, and calls the read-only
`list_apps` Computer Use MCP tool. This deliberately starts the native Computer
Use path before an agent needs it for real work.
## Marketplace choices
OpenClaw uses the same app-server API that Codex itself exposes. The
@@ -255,15 +241,6 @@ status for chat:
The chat output includes the plugin state, MCP server state, marketplace, tools
when available, and the specific message for the failing setup step.
The `setup` command also reports a setup probe result:
| Probe state | Meaning |
| --------------------- | ------------------------------------------------------------------- |
| `completed` | The read-only Computer Use probe returned normally. |
| `permissions pending` | The native permission flow opened and still needs user action. |
| `failed` | The setup probe returned an error or app-server request failed. |
| `skipped` | Computer Use is ready, but the read-only setup tool is unavailable. |
## macOS permissions
Computer Use is macOS-specific. The Codex-owned MCP server may need local OS
@@ -278,16 +255,6 @@ Use setup first:
- macOS has granted the required permissions for the desktop-control app.
- The current host session can access the desktop being controlled.
On macOS, onboarding and `/codex computer-use setup` can surface the native
Computer Use permissions flow before a normal agent turn needs it. If a Codex
Computer Use window or macOS System Settings opens, finish the prompts and rerun
setup or status.
On Windows or Linux, Codex Computer Use is not expected to become available
through this path. OpenClaw reports the missing plugin, MCP server, or tools
instead of silently running a Codex-mode turn without the required desktop
control path.
OpenClaw intentionally fails closed when `computerUse.enabled` is true. A
Codex-mode turn should not silently proceed without the native desktop tools
that the config required.
@@ -300,9 +267,6 @@ marketplace is not discovered, pass `--source` or `--marketplace-path`.
**Status says installed but disabled.** Run `/codex computer-use install` again.
Codex app-server install writes the plugin config back to enabled.
**Setup says permissions are pending.** Finish the Codex Computer Use and macOS
System Settings prompts, then rerun `/codex computer-use setup`.
**Status says remote install is unsupported.** Use a local marketplace source or
path. Remote-only catalog entries can be inspected but not installed through the
current app-server API.

View File

@@ -633,7 +633,6 @@ The setup can be checked or installed from the command surface:
- `/codex computer-use status`
- `/codex computer-use install`
- `/codex computer-use setup`
- `/codex computer-use install --source <marketplace-source>`
- `/codex computer-use install --marketplace-path <path>`
@@ -644,11 +643,6 @@ silently running without the native Computer Use tools. See
[Codex Computer Use](/plugins/codex-computer-use) for marketplace choices,
remote catalog limits, status reasons, and troubleshooting.
Interactive onboarding also offers this setup path when a user chooses Codex
login, opts into the native Codex runtime, and is running on macOS. Windows and
Linux onboarding skip the Computer Use prompt because this Codex desktop-control
path is macOS-specific.
When `computerUse.autoInstall` is true, OpenClaw can register the standard
bundled Codex Desktop marketplace from
`/Applications/Codex.app/Contents/Resources/plugins/openai-bundled` if Codex
@@ -761,7 +755,6 @@ Common forms:
- `/codex diagnostics [note]` asks before sending Codex diagnostics feedback for the attached thread.
- `/codex computer-use status` checks the configured Computer Use plugin and MCP server.
- `/codex computer-use install` installs the configured Computer Use plugin and reloads MCP servers.
- `/codex computer-use setup` installs Computer Use if needed and opens the first-run native setup path.
- `/codex account` shows account and rate-limit status.
- `/codex mcp` lists Codex app-server MCP server status.
- `/codex skills` lists Codex app-server skills.

View File

@@ -155,7 +155,7 @@ Examples of non-Plan consumers:
| Approval workflow | Session extension, command continuation, next-turn injection, UI descriptor |
| Budget/workspace policy gate | Trusted tool policy, tool metadata, session projection |
| Background lifecycle monitor | Runtime lifecycle cleanup, agent event subscription, session scheduler ownership/cleanup, heartbeat prompt contribution, UI descriptor |
| Setup or onboarding wizard | Setup entry, onboarding hook, session extension, scoped commands, Control UI descriptor |
| Setup or onboarding wizard | Session extension, scoped commands, Control UI descriptor |
<Note>
Reserved core admin namespaces (`config.*`, `exec.approvals.*`, `wizard.*`,

View File

@@ -306,8 +306,6 @@ Bundled workspace channels that keep setup-safe exports in sidecar modules can u
- The channel plugin object (via `defineSetupPluginEntry`).
- Any HTTP routes required before gateway listen.
- Any gateway methods needed during startup.
- Optional onboarding hooks via `api.registerOnboardingHook(...)` when the
plugin needs an interactive setup step after core onboarding choices.
Those startup gateway methods should still avoid reserved core admin namespaces such as `config.*` or `update.*`.

View File

@@ -16,7 +16,9 @@ title: "Tests"
- `pnpm check:changed`: runs the smart changed check gate for the diff against `origin/main`. It runs typecheck, lint, and guard commands for the affected architectural lanes, but does not run Vitest tests. Use `pnpm test:changed` or explicit `pnpm test <target>` for test proof.
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs use fixed shard groups and expand to leaf configs for local parallel execution; the extension group always expands to the per-extension shard configs instead of one giant root-project process.
- Test wrapper runs end with a short `[test] passed|failed|skipped ... in ...` summary. Vitest's own duration line stays the per-shard detail.
- Shared OpenClaw test state: use `src/test-utils/openclaw-test-state.ts` from Vitest when a test needs an isolated `HOME`, `OPENCLAW_STATE_DIR`, `OPENCLAW_CONFIG_PATH`, config fixture, workspace, agent dir, or auth-profile store. Docker/Bash E2E lanes that source `scripts/lib/docker-e2e-image.sh` can pass `docker_e2e_test_state_shell_b64 <label> <scenario>` into the container and `eval` the decoded snippet there; multi-home scripts can pass `docker_e2e_test_state_function_b64` and call `openclaw_test_state_create <label> <scenario>` in each flow. Lower-level callers can use `scripts/lib/openclaw-test-state.mjs shell --label <name> --scenario <name>` for an in-container shell snippet, or `node scripts/lib/openclaw-test-state.mjs -- create --label <name> --scenario <name> --env-file <path> --json` for a sourceable host env file. The `--` before `create` keeps newer Node runtimes from treating `--env-file` as a Node flag.
- Shared OpenClaw test state: use `src/test-utils/openclaw-test-state.ts` from Vitest when a test needs an isolated `HOME`, `OPENCLAW_STATE_DIR`, `OPENCLAW_CONFIG_PATH`, config fixture, workspace, agent dir, or auth-profile store.
- Process E2E helpers: use `test/helpers/openclaw-test-instance.ts` when a Vitest process-level E2E test needs a running Gateway, CLI env, log capture, and cleanup in one place.
- Docker/Bash E2E helpers: lanes that source `scripts/lib/docker-e2e-image.sh` can pass `docker_e2e_test_state_shell_b64 <label> <scenario>` into the container and decode it with `scripts/lib/openclaw-e2e-instance.sh`; multi-home scripts can pass `docker_e2e_test_state_function_b64` and call `openclaw_test_state_create <label> <scenario>` in each flow. Lower-level callers can use `scripts/lib/openclaw-test-state.mjs shell --label <name> --scenario <name>` for an in-container shell snippet, or `node scripts/lib/openclaw-test-state.mjs -- create --label <name> --scenario <name> --env-file <path> --json` for a sourceable host env file. The `--` before `create` keeps newer Node runtimes from treating `--env-file` as a Node flag. Docker/Bash lanes that launch a Gateway can source `scripts/lib/openclaw-e2e-instance.sh` inside the container for entrypoint resolution, mock OpenAI startup, Gateway foreground/background launch, readiness probes, state env export, log dumps, and process cleanup.
- Full, extension, and include-pattern shard runs update local timing data in `.artifacts/vitest-shard-timings.json`; later whole-config runs use those timings to balance slow and fast shards. Include-pattern CI shards append the shard name to the timing key, which keeps filtered shard timings visible without replacing whole-config timing data. Set `OPENCLAW_TEST_PROJECTS_TIMINGS=0` to ignore the local timing artifact.
- Selected `plugin-sdk` and `commands` test files now route through dedicated light lanes that keep only `test/setup.ts`, leaving runtime-heavy cases on their existing lanes.
- Source files with sibling tests map to that sibling before falling back to wider directory globs. Helper edits under `src/channels/plugins/contracts/test-helpers`, `src/plugin-sdk/test-helpers`, and `src/plugins/contracts` use a local import graph to run importing tests instead of broad-running every shard when the dependency path is precise.

View File

@@ -128,15 +128,19 @@ shared, and gated, see [Skills](/tools/skills).
## Service features
| Feature | Notes |
| ------------------ | ---------------------------------------------------------- |
| Public browsing | Skills and their `SKILL.md` content are publicly viewable. |
| Search | Embedding-powered (vector search), not just keywords. |
| Versioning | Semver, changelogs, and tags (including `latest`). |
| Downloads | Zip per version. |
| Stars and comments | Community feedback. |
| Moderation | Approvals and audits. |
| CLI-friendly API | Suitable for automation and scripting. |
| Feature | Notes |
| ------------------------ | ------------------------------------------------------------------- |
| Public browsing | Skills and their `SKILL.md` content are publicly viewable. |
| Search | Embedding-powered (vector search), not just keywords. |
| Versioning | Semver, changelogs, and tags (including `latest`). |
| Downloads | Zip per version. |
| Stars and comments | Community feedback. |
| Security scan summaries | Detail pages show the latest scan state before install or download. |
| Scanner detail pages | VirusTotal, ClawScan, and static-analysis results have deep links. |
| Owner recovery dashboard | Publishers can see scan-held owned content from `/dashboard`. |
| Owner-requested rescans | Owners can request limited rescans for false-positive recovery. |
| Moderation | Approvals and audits. |
| CLI-friendly API | Suitable for automation and scripting. |
## Security and moderation
@@ -145,6 +149,16 @@ account must be **at least one week old** to publish. This slows down
abuse without blocking legitimate contributors.
<AccordionGroup>
<Accordion title="Security scans">
ClawHub runs automated security checks on published skills and plugin
releases. Public detail pages summarize the current result, and scanner
rows link to dedicated detail pages for VirusTotal, ClawScan, and static
analysis.
Scan-held or blocked releases may be unavailable on public catalog and
install surfaces while still visible to their owner in `/dashboard`.
</Accordion>
<Accordion title="Reporting">
- Any signed-in user can report a skill.
- Report reasons are required and recorded.
@@ -276,6 +290,23 @@ publish/sync.
- `--json` — emit machine-readable output for CI.
- `--source-repo`, `--source-commit`, `--source-ref` — optional overrides when auto-detection is not enough.
</Accordion>
<Accordion title="Request rescans">
```bash
clawhub skill rescan <slug>
clawhub skill rescan <slug> --yes --json
clawhub package rescan <name>
clawhub package rescan <name> --yes --json
```
Rescan commands require a logged-in owner token and target the latest
published skill version or plugin release. In non-interactive runs, pass
`--yes`.
JSON responses include the target kind, name, version, rescan status, and
remaining/max request counts for that version or release.
</Accordion>
<Accordion title="Delete / undelete (owner or admin)">
```bash

View File

@@ -271,8 +271,8 @@ Generic model:
Native approval clients auto-enable DM-first delivery when all of these are true:
- the channel supports native approval delivery
- approvers can be resolved from explicit `execApprovals.approvers` or that
channel's documented fallback sources
- approvers can be resolved from explicit `execApprovals.approvers` or owner
identity such as `commands.ownerAllowFrom`
- `channels.<channel>.execApprovals.enabled` is unset or `"auto"`
Set `enabled: false` to disable a native approval client explicitly. Set `enabled: true` to force
@@ -295,7 +295,7 @@ Shared behavior:
- when a native approval client auto-enables, the default native delivery target is approver DMs
- for Discord and Telegram, only resolved approvers can approve or deny
- Discord approvers can be explicit (`execApprovals.approvers`) or inferred from `commands.ownerAllowFrom`
- Telegram approvers can be explicit (`execApprovals.approvers`) or inferred from existing owner config (`allowFrom`, plus direct-message `defaultTo` where supported)
- Telegram approvers can be explicit (`execApprovals.approvers`) or inferred from `commands.ownerAllowFrom`
- Slack approvers can be explicit (`execApprovals.approvers`) or inferred from `commands.ownerAllowFrom`
- Slack native buttons preserve approval id kind, so `plugin:` ids can resolve plugin approvals
without a second Slack-local fallback layer
@@ -313,6 +313,13 @@ Shared behavior:
- pending exec approvals expire after 30 minutes by default
- if no operator UI or configured approval client can accept the request, the prompt falls back to `askFallback`
Sensitive owner-only group commands such as `/diagnostics` and `/export-trajectory` use private
owner routing for approval prompts and final results. OpenClaw first tries a private route on the
same surface where the owner ran the command. If that surface has no private owner route, it falls
back to the first available owner route from `commands.ownerAllowFrom`, so a Discord group command
can still send the approval and result to the owner's Telegram DM when Telegram is the configured
primary private interface. The group chat only gets a short acknowledgement.
Telegram defaults to approver DMs (`target: "dm"`). You can switch to `channel` or `both` when you
want approval prompts to appear in the originating Telegram chat/topic as well. For Telegram forum
topics, OpenClaw preserves the topic for the approval prompt and the post-approval follow-up.

View File

@@ -434,6 +434,12 @@ dependency installs use the matching `dangerouslyForceUnsafeInstall` request
override instead, while `openclaw skills install` remains the separate ClawHub
skill download/install flow.
If a plugin you published on ClawHub is hidden or blocked by a scan, open the
ClawHub dashboard or run `clawhub package rescan <name>` to ask ClawHub to check
it again. `--dangerously-force-unsafe-install` only affects installs on your own
machine; it does not ask ClawHub to rescan the plugin or make a blocked release
public.
Compatible bundles participate in the same plugin list/inspect/enable/disable
flow. Current runtime support includes bundle skills, Claude command-skills,
Claude `settings.json` defaults, Claude `.lsp.json` and manifest-declared

View File

@@ -131,6 +131,12 @@ Native `openclaw skills install` installs into the active workspace
configured OpenClaw workspace). OpenClaw picks that up as
`<workspace>/skills` on the next session.
ClawHub skill pages expose the latest security scan state before install,
with scanner detail pages for VirusTotal, ClawScan, and static analysis.
`openclaw skills install <slug>` remains only the install path; publishers
recover false positives through the ClawHub dashboard or
`clawhub skill rescan <slug>`.
## Security
<Warning>

View File

@@ -185,9 +185,19 @@ describe("active-memory plugin", () => {
it("registers a before_prompt_build hook", () => {
expect(api.on).toHaveBeenCalledWith("before_prompt_build", expect.any(Function), {
timeoutMs: 150_000,
timeoutMs: 45_000,
});
expect(hookOptions.before_prompt_build?.timeoutMs).toBe(150_000);
expect(hookOptions.before_prompt_build?.timeoutMs).toBe(45_000);
});
it("registers before_prompt_build with the configured recall timeout plus setup grace", () => {
api.pluginConfig = {
agents: ["main"],
timeoutMs: 90_000,
};
plugin.register(api as unknown as OpenClawPluginApi);
expect(hookOptions.before_prompt_build?.timeoutMs).toBe(120_000);
});
it("runs recall without recording shared auth-profile failures", async () => {

View File

@@ -2431,7 +2431,7 @@ export default definePluginEntry({
},
});
const beforePromptBuildTimeoutMs = 120_000 + setupGraceTimeoutMs;
const beforePromptBuildTimeoutMs = config.timeoutMs + setupGraceTimeoutMs;
api.on(
"before_prompt_build",
async (event, ctx) => {

View File

@@ -315,7 +315,7 @@ describe("gateway bonjour advertiser", () => {
await started.stop();
});
it("does not install process-level ciao handlers by default", async () => {
it("installs only the scoped ciao unhandled-rejection listener by default", async () => {
enableAdvertiserUnitMode();
const destroy = vi.fn().mockResolvedValue(undefined);
@@ -331,7 +331,7 @@ describe("gateway bonjour advertiser", () => {
{ logger },
);
expect(processOn).not.toHaveBeenCalledWith("unhandledRejection", expect.any(Function));
expect(processOn).toHaveBeenCalledWith("unhandledRejection", expect.any(Function));
expect(processOn).not.toHaveBeenCalledWith("uncaughtException", expect.any(Function));
await started.stop();
@@ -393,11 +393,11 @@ describe("gateway bonjour advertiser", () => {
expect(exceptionHandler).toBeTypeOf("function");
expect(handler?.(new Error("CIAO PROBING CANCELLED"))).toBe(true);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining("ignoring unhandled ciao rejection"),
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("suppressing ciao cancellation"),
);
logger.debug.mockClear();
logger.warn.mockClear();
expect(
handler?.(new Error("Reached illegal state! IPV4 address change from defined to undefined!")),
).toBe(true);
@@ -423,6 +423,37 @@ describe("gateway bonjour advertiser", () => {
await started.stop();
});
it("recovers when ciao cancellation escapes the advertiser", async () => {
enableAdvertiserUnitMode();
const destroy = vi.fn().mockResolvedValue(undefined);
const advertise = vi.fn().mockResolvedValue(undefined);
mockCiaoService({ advertise, destroy });
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const handler = registerUnhandledRejectionHandler.mock.calls[0]?.[0] as
| ((reason: unknown) => boolean)
| undefined;
expect(handler?.(new Error("CIAO ANNOUNCEMENT CANCELLED"))).toBe(true);
await vi.waitFor(() => {
expect(createService).toHaveBeenCalledTimes(2);
});
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("suppressing ciao cancellation"),
);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("restarting advertiser"));
expect(destroy).toHaveBeenCalledTimes(1);
expect(advertise).toHaveBeenCalledTimes(2);
await started.stop();
});
it("logs advertise failures and retries via watchdog", async () => {
enableAdvertiserUnitMode();
vi.useFakeTimers();

View File

@@ -69,6 +69,7 @@ type ServiceStateTracker = {
type ConsoleLogFn = (...args: unknown[]) => void;
type UncaughtExceptionHandler = (error: unknown) => boolean;
type UnhandledRejectionHandler = (reason: unknown) => boolean;
type ProcessUnhandledRejectionListener = (reason: unknown, promise: Promise<unknown>) => void;
type ExecBridge = (command: string, options?: unknown, callback?: unknown) => ChildProcess;
type ExecOptionsRecord = Record<string, unknown> & { windowsHide?: boolean };
@@ -324,6 +325,25 @@ function installCiaoWindowsExecHidePatch(): () => void {
};
}
function installCiaoUnhandledRejectionListener(handler: UnhandledRejectionHandler): () => void {
const hadOtherListeners = process.listenerCount("unhandledRejection") > 0;
const listener: ProcessUnhandledRejectionListener = (reason) => {
if (handler(reason)) {
return;
}
if (hadOtherListeners) {
return;
}
queueMicrotask(() => {
throw reason instanceof Error ? reason : new Error(String(reason));
});
};
process.on("unhandledRejection", listener);
return () => {
process.off("unhandledRejection", listener);
};
}
export async function startGatewayBonjourAdvertiser(
opts: GatewayBonjourAdvertiseOpts,
deps: BonjourAdvertiserDeps = {},
@@ -341,6 +361,7 @@ export async function startGatewayBonjourAdvertiser(
let restoreConsoleLog: () => void = () => {};
let requestCiaoRecovery: ((classification: CiaoProcessErrorClassification) => void) | undefined;
let cleanupUnhandledRejection: (() => void) | undefined;
let cleanupDirectUnhandledRejection: (() => void) | undefined;
let cleanupUncaughtException: (() => void) | undefined;
let processHandlersCleaned = false;
@@ -349,6 +370,7 @@ export async function startGatewayBonjourAdvertiser(
return;
}
processHandlersCleaned = true;
cleanupDirectUnhandledRejection?.();
cleanupUncaughtException?.();
cleanupUnhandledRejection?.();
}
@@ -363,7 +385,8 @@ export async function startGatewayBonjourAdvertiser(
}
if (classification.kind === "cancellation") {
logger.debug(`bonjour: ignoring unhandled ciao rejection: ${classification.formatted}`);
logger.warn(`bonjour: suppressing ciao cancellation: ${classification.formatted}`);
requestCiaoRecovery?.(classification);
} else if (classification.kind === "interface-enumeration-failure") {
// Restricted sandboxes can refuse os.networkInterfaces(); mDNS cannot
// function without it, so surface a single warning and skip recovery.
@@ -379,6 +402,7 @@ export async function startGatewayBonjourAdvertiser(
}
return true;
};
cleanupDirectUnhandledRejection = installCiaoUnhandledRejectionListener(handleCiaoProcessError);
cleanupUnhandledRejection = deps.registerUnhandledRejectionHandler?.(handleCiaoProcessError);
cleanupUncaughtException = deps.registerUncaughtExceptionHandler?.(handleCiaoProcessError);
@@ -490,6 +514,28 @@ export async function startGatewayBonjourAdvertiser(
}
}
function handleAdvertiseFailure(
label: string,
svc: BonjourService,
err: unknown,
action: "failed" | "threw",
) {
const classification = classifyCiaoProcessError(err);
if (classification) {
logger.warn(
`bonjour: advertise ${action} with ciao ${classification.kind} (${serviceSummary(
label,
svc,
)}): ${classification.formatted}`,
);
requestCiaoRecovery?.(classification);
return;
}
logger.warn(
`bonjour: advertise ${action} (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
);
}
function startAdvertising(services: Array<{ label: string; svc: BonjourService }>) {
for (const { label, svc } of services) {
try {
@@ -499,14 +545,10 @@ export async function startGatewayBonjourAdvertiser(
logger.info(`bonjour: advertised ${serviceSummary(label, svc)}`);
})
.catch((err) => {
logger.warn(
`bonjour: advertise failed (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
);
handleAdvertiseFailure(label, svc, err, "failed");
});
} catch (err) {
logger.warn(
`bonjour: advertise threw (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
);
handleAdvertiseFailure(label, svc, err, "threw");
}
}
}
@@ -523,8 +565,6 @@ export async function startGatewayBonjourAdvertiser(
let consecutiveRestarts = 0;
let cycle: BonjourCycle | null = createCycle();
const stateTracker = new Map<string, ServiceStateTracker>();
attachConflictListeners(cycle.services);
startAdvertising(cycle.services);
const updateStateTrackers = (services: Array<{ label: string; svc: BonjourService }>) => {
const now = Date.now();
@@ -578,6 +618,8 @@ export async function startGatewayBonjourAdvertiser(
requestCiaoRecovery = (classification) => {
void recreateAdvertiser(`ciao ${classification.kind}: ${classification.formatted}`);
};
attachConflictListeners(cycle.services);
startAdvertising(cycle.services);
const lastRepairAttempt = new Map<string, number>();
const watchdog = setInterval(() => {

View File

@@ -17,7 +17,6 @@
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-api.ts",
"bundle": {
"stageRuntimeDependencies": true
}

View File

@@ -1,165 +0,0 @@
import type { OpenClawConfig, PluginOnboardingContext } from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it, vi } from "vitest";
import { __testing } from "./setup-api.js";
function createContext(params: {
config: OpenClawConfig;
confirms?: boolean[];
}): PluginOnboardingContext & {
notes: Array<{ message: string; title?: string }>;
} {
const notes: Array<{ message: string; title?: string }> = [];
const confirms = [...(params.confirms ?? [])];
return {
config: params.config,
env: {},
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
workspaceDir: "/tmp/openclaw-workspace",
notes,
prompter: {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async (message, title) => {
notes.push({ message, title });
}),
select: vi.fn(async () => {
throw new Error("select should not be called");
}),
multiselect: vi.fn(async () => {
throw new Error("multiselect should not be called");
}),
text: vi.fn(async () => {
throw new Error("text should not be called");
}),
confirm: vi.fn(async () => confirms.shift() ?? false),
progress: vi.fn(() => ({
update: vi.fn(),
stop: vi.fn(),
})),
},
};
}
function createReadyComputerUseResult() {
return {
status: {
enabled: true,
ready: true,
reason: "ready",
installed: true,
pluginEnabled: true,
mcpServerAvailable: true,
pluginName: "computer-use",
mcpServerName: "computer-use",
tools: ["list_apps"],
message: "Computer Use is ready.",
},
probe: {
attempted: true,
state: "completed",
toolName: "list_apps",
message: "Computer Use setup probe completed.",
},
} as const;
}
describe("Codex setup onboarding hook", () => {
it("offers native Codex runtime after OpenAI Codex login without forcing Computer Use", async () => {
const ctx = createContext({
config: {
agents: {
defaults: {
model: { primary: "openai-codex/gpt-5.5" },
},
},
},
confirms: [true, false],
});
const next = await __testing.runCodexOnboardingHook(ctx, { platform: "darwin" });
expect(next.agents?.defaults?.model).toMatchObject({ primary: "openai/gpt-5.5" });
expect(next.agents?.defaults?.models).toMatchObject({ "openai/gpt-5.5": {} });
expect(next.agents?.defaults?.agentRuntime).toMatchObject({
id: "codex",
fallback: "none",
});
expect(next.plugins?.entries?.codex).toMatchObject({ enabled: true });
expect(
(next.plugins?.entries?.codex as { config?: { computerUse?: unknown } } | undefined)?.config
?.computerUse,
).toBeUndefined();
});
it("sets up Computer Use on macOS when Codex runtime is configured", async () => {
const setupCodexComputerUsePermissions = vi.fn(async () => createReadyComputerUseResult());
const ctx = createContext({
config: {
agents: {
defaults: {
model: { primary: "openai/gpt-5.5" },
agentRuntime: { id: "codex" },
},
},
plugins: {
entries: {
codex: { enabled: true },
},
},
},
confirms: [true],
});
const next = await __testing.runCodexOnboardingHook(ctx, {
platform: "darwin",
setupCodexComputerUsePermissions,
});
expect(setupCodexComputerUsePermissions).toHaveBeenCalledWith({
cwd: "/tmp/openclaw-workspace",
pluginConfig: {
computerUse: {
enabled: true,
autoInstall: true,
},
},
});
expect(next.plugins?.entries?.codex).toMatchObject({
enabled: true,
config: {
computerUse: {
enabled: true,
autoInstall: true,
},
},
});
expect(ctx.notes.some((note) => note.message.includes("Setup probe: completed"))).toBe(true);
});
it("does not show Computer Use setup on non-macOS platforms", async () => {
const setupCodexComputerUsePermissions = vi.fn(async () => createReadyComputerUseResult());
const ctx = createContext({
config: {
agents: {
defaults: {
model: { primary: "openai/gpt-5.5" },
agentRuntime: { id: "codex" },
},
},
},
confirms: [true],
});
const next = await __testing.runCodexOnboardingHook(ctx, {
platform: "win32",
setupCodexComputerUsePermissions,
});
expect(setupCodexComputerUsePermissions).not.toHaveBeenCalled();
expect(next).toBe(ctx.config);
});
});

View File

@@ -1,285 +0,0 @@
import type { OpenClawConfig, PluginOnboardingContext } from "openclaw/plugin-sdk/plugin-entry";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { formatComputerUseSetupResult } from "./src/command-formatters.js";
type CodexComputerUseSetupPermissions =
typeof import("./src/app-server/computer-use.js").setupCodexComputerUsePermissions;
type CodexOnboardingDeps = {
platform?: NodeJS.Platform;
setupCodexComputerUsePermissions?: CodexComputerUseSetupPermissions;
};
const CODEX_PLUGIN_ID = "codex";
const CODEX_RUNTIME_ID = "codex";
const OPENAI_PROVIDER_PREFIX = "openai/";
const OPENAI_CODEX_PROVIDER_PREFIX = "openai-codex/";
const LEGACY_CODEX_PROVIDER_PREFIX = "codex/";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function readPrimaryModel(config: OpenClawConfig): string {
const model = config.agents?.defaults?.model;
if (typeof model === "string") {
return model.trim();
}
return isRecord(model) ? normalizeString(model.primary) : "";
}
function hasCodexRuntime(config: OpenClawConfig): boolean {
const defaultsRuntime = config.agents?.defaults?.agentRuntime;
if (normalizeString(defaultsRuntime?.id).toLowerCase() === CODEX_RUNTIME_ID) {
return true;
}
const agents = config.agents?.list;
return Array.isArray(agents)
? agents.some(
(agent) =>
isRecord(agent) &&
isRecord(agent.agentRuntime) &&
normalizeString(agent.agentRuntime.id).toLowerCase() === CODEX_RUNTIME_ID,
)
: false;
}
function resolveNativeCodexModelRef(primaryModel: string): string | null {
if (primaryModel.startsWith(OPENAI_CODEX_PROVIDER_PREFIX)) {
const modelId = primaryModel.slice(OPENAI_CODEX_PROVIDER_PREFIX.length).trim();
return modelId ? `${OPENAI_PROVIDER_PREFIX}${modelId}` : null;
}
if (primaryModel.startsWith(LEGACY_CODEX_PROVIDER_PREFIX)) {
const modelId = primaryModel.slice(LEGACY_CODEX_PROVIDER_PREFIX.length).trim();
return modelId ? `${OPENAI_PROVIDER_PREFIX}${modelId}` : null;
}
return null;
}
function withPrimaryModel(config: OpenClawConfig, primaryModel: string): OpenClawConfig {
const defaults = config.agents?.defaults ?? {};
const existingModel = defaults.model;
const existingModels = defaults.models ?? {};
const model = isRecord(existingModel)
? {
...existingModel,
primary: primaryModel,
}
: {
primary: primaryModel,
};
return {
...config,
agents: {
...config.agents,
defaults: {
...defaults,
models: {
...existingModels,
[primaryModel]: existingModels[primaryModel] ?? {},
},
model,
},
},
};
}
function withCodexRuntime(config: OpenClawConfig): OpenClawConfig {
const defaults = config.agents?.defaults ?? {};
return {
...config,
agents: {
...config.agents,
defaults: {
...defaults,
agentRuntime: {
...defaults.agentRuntime,
id: CODEX_RUNTIME_ID,
fallback: defaults.agentRuntime?.fallback ?? "none",
},
},
},
};
}
function readCodexPluginEntry(config: OpenClawConfig): Record<string, unknown> {
const entry = config.plugins?.entries?.[CODEX_PLUGIN_ID];
return isRecord(entry) ? entry : {};
}
function readCodexPluginConfig(config: OpenClawConfig): Record<string, unknown> {
const pluginConfig = readCodexPluginEntry(config).config;
return isRecord(pluginConfig) ? pluginConfig : {};
}
function withCodexPluginEnabled(config: OpenClawConfig): OpenClawConfig {
const entry = readCodexPluginEntry(config);
return {
...config,
plugins: {
...config.plugins,
entries: {
...config.plugins?.entries,
[CODEX_PLUGIN_ID]: {
...entry,
enabled: true,
config: readCodexPluginConfig(config),
},
},
},
};
}
function withComputerUseConfig(config: OpenClawConfig): OpenClawConfig {
const withPlugin = withCodexPluginEnabled(config);
const entry = readCodexPluginEntry(withPlugin);
const pluginConfig = readCodexPluginConfig(withPlugin);
const computerUse = isRecord(pluginConfig.computerUse) ? pluginConfig.computerUse : {};
return {
...withPlugin,
plugins: {
...withPlugin.plugins,
entries: {
...withPlugin.plugins?.entries,
[CODEX_PLUGIN_ID]: {
...entry,
enabled: true,
config: {
...pluginConfig,
computerUse: {
...computerUse,
enabled: true,
autoInstall: true,
},
},
},
},
},
};
}
function isComputerUseExplicitlyDisabled(config: OpenClawConfig): boolean {
const computerUse = readCodexPluginConfig(config).computerUse;
return isRecord(computerUse) && computerUse.enabled === false;
}
function hasComputerUseConfig(config: OpenClawConfig): boolean {
return isRecord(readCodexPluginConfig(config).computerUse);
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function loadComputerUseSetup(): Promise<CodexComputerUseSetupPermissions> {
const { setupCodexComputerUsePermissions } = await import("./src/app-server/computer-use.js");
return setupCodexComputerUsePermissions;
}
async function maybeConfigureNativeCodexRuntime(
ctx: PluginOnboardingContext,
config: OpenClawConfig,
): Promise<OpenClawConfig> {
if (hasCodexRuntime(config)) {
return config;
}
const nativeModel = resolveNativeCodexModelRef(readPrimaryModel(config));
if (!nativeModel) {
return config;
}
await ctx.prompter.note(
[
"OpenAI Codex login can use the normal OpenClaw runner, or it can run agent turns through the native Codex app-server runtime.",
"Native Codex runtime is required for Codex Computer Use.",
].join("\n"),
"Codex runtime",
);
const useNativeRuntime = await ctx.prompter.confirm({
message: "Use native Codex runtime for this agent?",
initialValue: true,
});
if (!useNativeRuntime) {
return config;
}
return withCodexPluginEnabled(withCodexRuntime(withPrimaryModel(config, nativeModel)));
}
async function maybeSetupComputerUse(
ctx: PluginOnboardingContext,
config: OpenClawConfig,
deps: CodexOnboardingDeps,
): Promise<OpenClawConfig> {
const platform = deps.platform ?? process.platform;
if (
platform !== "darwin" ||
!hasCodexRuntime(config) ||
isComputerUseExplicitlyDisabled(config)
) {
return config;
}
await ctx.prompter.note(
[
"Codex Computer Use lets native Codex-mode agents control this Mac through Codex's Computer Use plugin.",
"Setup installs or re-enables the plugin, then starts the macOS permission flow while you are here.",
].join("\n"),
"Codex Computer Use",
);
const shouldSetup = await ctx.prompter.confirm({
message: "Set up Codex Computer Use now?",
initialValue: !hasComputerUseConfig(config),
});
if (!shouldSetup) {
return config;
}
const candidate = withComputerUseConfig(config);
const setupCodexComputerUsePermissions =
deps.setupCodexComputerUsePermissions ?? (await loadComputerUseSetup());
try {
const result = await setupCodexComputerUsePermissions({
cwd: ctx.workspaceDir,
pluginConfig: readCodexPluginConfig(candidate),
});
await ctx.prompter.note(formatComputerUseSetupResult(result), "Codex Computer Use");
return candidate;
} catch (error) {
await ctx.prompter.note(
[
`Computer Use setup did not finish: ${formatError(error)}`,
"You can rerun setup later from chat with /codex computer-use setup.",
].join("\n"),
"Codex Computer Use",
);
return config;
}
}
export async function runCodexOnboardingHook(
ctx: PluginOnboardingContext,
deps: CodexOnboardingDeps = {},
): Promise<OpenClawConfig> {
const nativeConfig = await maybeConfigureNativeCodexRuntime(ctx, ctx.config);
return await maybeSetupComputerUse(ctx, nativeConfig, deps);
}
export const __testing = {
runCodexOnboardingHook,
withComputerUseConfig,
withCodexRuntime,
withPrimaryModel,
};
export default definePluginEntry({
id: CODEX_PLUGIN_ID,
name: "Codex Setup",
description: "Lightweight Codex setup hooks",
register(api) {
api.registerOnboardingHook((ctx) => runCodexOnboardingHook(ctx));
},
});

View File

@@ -6,7 +6,6 @@ import {
ensureCodexComputerUse,
installCodexComputerUse,
readCodexComputerUseStatus,
setupCodexComputerUsePermissions,
type CodexComputerUseRequest,
} from "./computer-use.js";
@@ -443,110 +442,16 @@ describe("Codex Computer Use setup", () => {
pluginName: "computer-use",
});
});
it("runs the Computer Use setup probe through app-server MCP", async () => {
const request = createComputerUseRequest({ installed: false });
await expect(
setupCodexComputerUsePermissions({
pluginConfig: { computerUse: { enabled: true, autoInstall: true } },
request,
cwd: "/repo",
}),
).resolves.toEqual({
status: expect.objectContaining({
ready: true,
reason: "ready",
installed: true,
pluginEnabled: true,
tools: ["list_apps"],
}),
probe: {
attempted: true,
state: "completed",
toolName: "list_apps",
threadId: "thread-computer-use-setup",
message:
"Computer Use setup probe completed. If a Codex Computer Use permissions window appeared, follow it to finish macOS setup.",
},
});
expect(request).toHaveBeenCalledWith(
"thread/start",
expect.objectContaining({
cwd: "/repo",
ephemeral: true,
experimentalRawEvents: false,
persistExtendedHistory: false,
}),
);
expect(request).toHaveBeenCalledWith("mcpServer/tool/call", {
threadId: "thread-computer-use-setup",
server: "computer-use",
tool: "list_apps",
arguments: {},
});
});
it("reports pending native permissions from the setup probe", async () => {
const request = createComputerUseRequest({
installed: true,
toolCallText: "Computer Use permissions are still pending.",
toolCallIsError: true,
});
await expect(
setupCodexComputerUsePermissions({
pluginConfig: { computerUse: { enabled: true } },
request,
}),
).resolves.toEqual(
expect.objectContaining({
probe: expect.objectContaining({
attempted: true,
state: "permissions_pending",
message:
"Computer Use opened its permission flow. Finish the Codex Computer Use window and macOS System Settings, then run /codex computer-use setup again.",
}),
}),
);
});
it("skips the setup probe when the read-only setup tool is unavailable", async () => {
const request = createComputerUseRequest({ installed: true, tools: ["get_app_state"] });
await expect(
setupCodexComputerUsePermissions({
pluginConfig: { computerUse: { enabled: true } },
request,
}),
).resolves.toEqual(
expect.objectContaining({
probe: {
attempted: false,
state: "skipped",
toolName: "list_apps",
message:
"Computer Use is ready, but setup did not run because the list_apps MCP tool is unavailable.",
},
}),
);
expect(request).not.toHaveBeenCalledWith("thread/start", expect.anything());
expect(request).not.toHaveBeenCalledWith("mcpServer/tool/call", expect.anything());
});
});
function createComputerUseRequest(params: {
installed: boolean;
enabled?: boolean;
marketplaceAvailableAfterListCalls?: number;
toolCallIsError?: boolean;
toolCallText?: string;
tools?: string[];
}): CodexComputerUseRequest {
let installed = params.installed;
let enabled = params.enabled ?? installed;
let pluginListCalls = 0;
const tools = params.tools ?? ["list_apps"];
return vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "experimentalFeature/enablement/set") {
return { enablement: { plugins: true } };
@@ -610,9 +515,12 @@ function createComputerUseRequest(params: {
? [
{
name: "computer-use",
tools: Object.fromEntries(
tools.map((tool) => [tool, { name: tool, inputSchema: { type: "object" } }]),
),
tools: {
list_apps: {
name: "list_apps",
inputSchema: { type: "object" },
},
},
resources: [],
resourceTemplates: [],
authStatus: "unsupported",
@@ -622,27 +530,6 @@ function createComputerUseRequest(params: {
nextCursor: null,
};
}
if (method === "thread/start") {
return {
thread: { id: "thread-computer-use-setup", cwd: "/repo" },
model: "gpt-5.5",
modelProvider: "openai",
serviceTier: null,
cwd: "/repo",
instructionSources: [],
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: { type: "dangerFullAccess" },
permissionProfile: null,
reasoningEffort: null,
};
}
if (method === "mcpServer/tool/call") {
return {
content: [{ type: "text", text: params.toolCallText ?? "[]" }],
isError: params.toolCallIsError ?? false,
};
}
throw new Error(`unexpected request ${method}`);
}) as CodexComputerUseRequest;
}

View File

@@ -8,7 +8,7 @@ import {
type ResolvedCodexComputerUseConfig,
} from "./config.js";
import type { v2 } from "./protocol-generated/typescript/index.js";
import { isJsonObject, type JsonValue } from "./protocol.js";
import type { JsonValue } from "./protocol.js";
import { requestCodexAppServerJson } from "./request.js";
export type CodexComputerUseRequest = <T = JsonValue | undefined>(
@@ -42,25 +42,6 @@ export type CodexComputerUseStatus = {
message: string;
};
export type CodexComputerUseSetupProbeState =
| "completed"
| "failed"
| "permissions_pending"
| "skipped";
export type CodexComputerUseSetupProbe = {
attempted: boolean;
state: CodexComputerUseSetupProbeState;
toolName: string;
message: string;
threadId?: string;
};
export type CodexComputerUseSetupResult = {
status: CodexComputerUseStatus;
probe: CodexComputerUseSetupProbe;
};
export class CodexComputerUseSetupError extends Error {
readonly status: CodexComputerUseStatus;
@@ -82,11 +63,6 @@ export type CodexComputerUseSetupParams = {
defaultBundledMarketplacePath?: string;
};
export type CodexComputerUsePermissionSetupParams = CodexComputerUseSetupParams & {
cwd?: string;
setupToolName?: string;
};
type MarketplaceRef =
| {
kind: "local";
@@ -118,10 +94,6 @@ const CURATED_MARKETPLACE_POLL_INTERVAL_MS = 2_000;
const COMPUTER_USE_MARKETPLACE_NAME_PRIORITY = ["openai-bundled", "openai-curated", "local"];
const DEFAULT_CODEX_BUNDLED_MARKETPLACE_PATH =
"/Applications/Codex.app/Contents/Resources/plugins/openai-bundled";
const DEFAULT_COMPUTER_USE_SETUP_TOOL_NAME = "list_apps";
const COMPUTER_USE_PERMISSION_PENDING_RE =
/Computer Use permissions are (?:still pending|not granted)/i;
const COMPUTER_USE_SETUP_ERROR_MAX_CHARS = 300;
export async function readCodexComputerUseStatus(
params: CodexComputerUseSetupParams = {},
@@ -200,62 +172,6 @@ export async function installCodexComputerUse(
return status;
}
export async function setupCodexComputerUsePermissions(
params: CodexComputerUsePermissionSetupParams = {},
): Promise<CodexComputerUseSetupResult> {
const status = await installCodexComputerUse(params);
const toolName = params.setupToolName ?? DEFAULT_COMPUTER_USE_SETUP_TOOL_NAME;
if (!status.tools.includes(toolName)) {
return {
status,
probe: {
attempted: false,
state: "skipped",
toolName,
message: `Computer Use is ready, but setup did not run because the ${toolName} MCP tool is unavailable.`,
},
};
}
const request = createComputerUseRequest(params);
try {
const thread = await request<v2.ThreadStartResponse>("thread/start", {
cwd: params.cwd ?? process.cwd(),
developerInstructions:
"This temporary thread checks whether Computer Use can start. Do not perform user work in this thread.",
ephemeral: true,
experimentalRawEvents: false,
persistExtendedHistory: false,
} satisfies v2.ThreadStartParams);
const result = await request<v2.McpServerToolCallResponse>("mcpServer/tool/call", {
threadId: thread.thread.id,
server: status.mcpServerName,
tool: toolName,
arguments: {},
} satisfies v2.McpServerToolCallParams);
return {
status,
probe: {
attempted: true,
state: computerUseSetupProbeState(result),
toolName,
threadId: thread.thread.id,
message: computerUseSetupProbeMessage(result),
},
};
} catch (error) {
return {
status,
probe: {
attempted: true,
state: "failed",
toolName,
message: `Computer Use setup probe failed: ${describeControlFailure(error)}`,
},
};
}
}
async function inspectCodexComputerUse(params: {
pluginConfig?: unknown;
request?: CodexComputerUseRequest;
@@ -601,45 +517,6 @@ async function readComputerUsePlugin(
return response.plugin;
}
function computerUseSetupProbeState(
result: v2.McpServerToolCallResponse,
): CodexComputerUseSetupProbeState {
const text = readToolCallText(result);
if (COMPUTER_USE_PERMISSION_PENDING_RE.test(text)) {
return "permissions_pending";
}
return result.isError ? "failed" : "completed";
}
function computerUseSetupProbeMessage(result: v2.McpServerToolCallResponse): string {
const text = readToolCallText(result);
if (COMPUTER_USE_PERMISSION_PENDING_RE.test(text)) {
return "Computer Use opened its permission flow. Finish the Codex Computer Use window and macOS System Settings, then run /codex computer-use setup again.";
}
if (result.isError) {
return `Computer Use setup probe returned an error: ${truncateSetupMessage(text || "unknown error")}`;
}
return "Computer Use setup probe completed. If a Codex Computer Use permissions window appeared, follow it to finish macOS setup.";
}
function readToolCallText(result: v2.McpServerToolCallResponse): string {
return result.content.map(readTextContent).filter(Boolean).join("\n").trim();
}
function readTextContent(value: JsonValue): string {
if (isJsonObject(value)) {
const text = value.text;
return typeof text === "string" ? text : "";
}
return "";
}
function truncateSetupMessage(value: string): string {
return value.length > COMPUTER_USE_SETUP_ERROR_MAX_CHARS
? `${value.slice(0, COMPUTER_USE_SETUP_ERROR_MAX_CHARS)}...`
: value;
}
async function readMcpServerStatus(
request: CodexComputerUseRequest,
serverName: string,

View File

@@ -1,8 +1,4 @@
import type {
CodexComputerUseSetupProbeState,
CodexComputerUseSetupResult,
CodexComputerUseStatus,
} from "./app-server/computer-use.js";
import type { CodexComputerUseStatus } from "./app-server/computer-use.js";
import type { CodexAppServerModelListResult } from "./app-server/models.js";
import { isJsonObject, type JsonObject, type JsonValue } from "./app-server/protocol.js";
import type { SafeValue } from "./command-rpc.js";
@@ -114,21 +110,6 @@ export function formatComputerUseStatus(status: CodexComputerUseStatus): string
return lines.join("\n");
}
export function formatComputerUseSetupResult(result: CodexComputerUseSetupResult): string {
return [
formatComputerUseStatus(result.status),
`Setup probe: ${formatComputerUseProbeState(result.probe.state)}`,
result.probe.message,
].join("\n");
}
function formatComputerUseProbeState(state: CodexComputerUseSetupProbeState): string {
if (state === "permissions_pending") {
return "permissions pending";
}
return state;
}
function computerUsePluginState(status: CodexComputerUseStatus): string {
if (!status.installed) {
return "not installed";
@@ -168,7 +149,7 @@ export function buildHelp(): string {
"- /codex compact",
"- /codex review",
"- /codex diagnostics [note]",
"- /codex computer-use [status|install|setup]",
"- /codex computer-use [status|install]",
"- /codex account",
"- /codex mcp",
"- /codex skills",

View File

@@ -4,7 +4,6 @@ import { CODEX_CONTROL_METHODS, type CodexControlMethod } from "./app-server/cap
import {
installCodexComputerUse,
readCodexComputerUseStatus,
setupCodexComputerUsePermissions,
type CodexComputerUseSetupParams,
} from "./app-server/computer-use.js";
import type { CodexComputerUseConfig } from "./app-server/config.js";
@@ -18,7 +17,6 @@ import {
import {
buildHelp,
formatAccount,
formatComputerUseSetupResult,
formatComputerUseStatus,
formatCodexStatus,
formatList,
@@ -61,7 +59,6 @@ export type CodexCommandDeps = {
clearCodexAppServerBinding: typeof clearCodexAppServerBinding;
readCodexComputerUseStatus: typeof readCodexComputerUseStatus;
installCodexComputerUse: typeof installCodexComputerUse;
setupCodexComputerUsePermissions: typeof setupCodexComputerUsePermissions;
resolveCodexDefaultWorkspaceDir: typeof resolveCodexDefaultWorkspaceDir;
startCodexConversationThread: typeof startCodexConversationThread;
readCodexConversationActiveTurn: typeof readCodexConversationActiveTurn;
@@ -95,7 +92,6 @@ const defaultCodexCommandDeps: CodexCommandDeps = {
clearCodexAppServerBinding,
readCodexComputerUseStatus,
installCodexComputerUse,
setupCodexComputerUsePermissions,
resolveCodexDefaultWorkspaceDir,
startCodexConversationThread,
readCodexConversationActiveTurn,
@@ -115,7 +111,7 @@ type ParsedBindArgs = {
};
type ParsedComputerUseArgs = {
action: "status" | "install" | "setup";
action: "status" | "install";
overrides: Partial<CodexComputerUseConfig>;
hasOverrides: boolean;
help?: boolean;
@@ -306,26 +302,18 @@ async function handleComputerUseCommand(
const parsed = parseComputerUseArgs(args);
if (parsed.help) {
return [
"Usage: /codex computer-use [status|install|setup] [--source <marketplace-source>] [--marketplace-path <path>] [--marketplace <name>]",
"Checks, installs, or opens first-run setup for the configured Codex Computer Use plugin through app-server.",
"Usage: /codex computer-use [status|install] [--source <marketplace-source>] [--marketplace-path <path>] [--marketplace <name>]",
"Checks or installs the configured Codex Computer Use plugin through app-server.",
].join("\n");
}
const params: CodexComputerUseSetupParams = {
pluginConfig,
forceEnable: parsed.action !== "status" || parsed.hasOverrides,
forceEnable: parsed.action === "install" || parsed.hasOverrides,
...(Object.keys(parsed.overrides).length > 0 ? { overrides: parsed.overrides } : {}),
};
if (parsed.action === "install") {
return formatComputerUseStatus(await deps.installCodexComputerUse(params));
}
if (parsed.action === "setup") {
return formatComputerUseSetupResult(
await deps.setupCodexComputerUsePermissions({
...params,
cwd: deps.resolveCodexDefaultWorkspaceDir(pluginConfig),
}),
);
}
return formatComputerUseStatus(await deps.readCodexComputerUseStatus(params));
}
@@ -1469,7 +1457,7 @@ function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs {
parsed.help = true;
continue;
}
if (arg === "status" || arg === "install" || arg === "setup") {
if (arg === "status" || arg === "install") {
parsed.action = arg;
continue;
}

View File

@@ -348,45 +348,6 @@ describe("codex command", () => {
});
});
it("runs Codex Computer Use first-run setup from the command surface", async () => {
const setupCodexComputerUsePermissions = vi.fn(async () => ({
status: computerUseReadyStatus(),
probe: {
attempted: true,
state: "completed" as const,
toolName: "list_apps",
message:
"Computer Use setup probe completed. If a Codex Computer Use permissions window appeared, follow it to finish macOS setup.",
},
}));
const resolveCodexDefaultWorkspaceDir = vi.fn(() => "/repo");
await expect(
handleCodexCommand(createContext("computer-use setup"), {
deps: createDeps({
setupCodexComputerUsePermissions,
resolveCodexDefaultWorkspaceDir,
}),
}),
).resolves.toEqual({
text: [
"Computer Use: ready",
"Plugin: computer-use (installed)",
"MCP server: computer-use (1 tools)",
"Marketplace: desktop-tools",
"Tools: list_apps",
"Computer Use is ready.",
"Setup probe: completed",
"Computer Use setup probe completed. If a Codex Computer Use permissions window appeared, follow it to finish macOS setup.",
].join("\n"),
});
expect(setupCodexComputerUsePermissions).toHaveBeenCalledWith({
pluginConfig: undefined,
forceEnable: true,
cwd: "/repo",
});
});
it("shows help when Computer Use option values are missing", async () => {
const installCodexComputerUse = vi.fn(async () => computerUseReadyStatus());

View File

@@ -2,7 +2,6 @@ import crypto from "node:crypto";
import {
buildEmbeddingBatchGroupOptions,
runEmbeddingBatchGroups,
type EmbeddingBatchExecutionParams,
buildBatchHeaders,
debugEmbeddingsLog,
normalizeBatchBaseUrl,
@@ -12,6 +11,14 @@ import {
import { createProviderHttpError } from "openclaw/plugin-sdk/provider-http";
import type { GeminiEmbeddingClient, GeminiTextEmbeddingRequest } from "./embedding-provider.js";
type EmbeddingBatchExecutionParams = {
wait: boolean;
pollIntervalMs: number;
timeoutMs: number;
concurrency: number;
debug?: (message: string, data?: Record<string, unknown>) => void;
};
export type GeminiBatchRequest = {
custom_id: string;
request: GeminiTextEmbeddingRequest;

View File

@@ -7,14 +7,12 @@ import {
type MemoryEmbeddingProviderRuntime,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { createSubsystemLogger } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
import { type SessionFileEntry } from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
import {
buildMultimodalChunkForIndexing,
chunkMarkdown,
hashText,
remapChunkLines,
type MemoryChunk,
type MemoryFileEntry,
type MemorySource,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import {
@@ -55,6 +53,17 @@ const EMBEDDING_BATCH_TIMEOUT_LOCAL_MS = 10 * 60_000;
const log = createSubsystemLogger("memory");
type MemoryIndexEntry = {
path: string;
absPath: string;
mtimeMs: number;
size: number;
hash: string;
kind?: "markdown" | "multimodal";
contentText?: string;
lineMap?: number[];
};
export function resolveEmbeddingTimeoutMs(params: {
kind: "query" | "batch";
providerId?: string;
@@ -207,7 +216,7 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
private async embedChunksWithBatch(
chunks: MemoryChunk[],
_entry: MemoryFileEntry | SessionFileEntry,
_entry: MemoryIndexEntry,
source: MemorySource,
): Promise<number[][]> {
const provider = this.provider;
@@ -544,7 +553,7 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
this.db.prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`).run(pathname, source);
}
private upsertFileRecord(entry: MemoryFileEntry | SessionFileEntry, source: MemorySource): void {
private upsertFileRecord(entry: MemoryIndexEntry, source: MemorySource): void {
this.db
.prepare(
`INSERT INTO files (path, source, hash, mtime, size) VALUES (?, ?, ?, ?, ?)
@@ -567,7 +576,7 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
* Pass an empty embeddings array to skip vector writes (FTS-only mode).
*/
private writeChunks(
entry: MemoryFileEntry | SessionFileEntry,
entry: MemoryIndexEntry,
source: MemorySource,
model: string,
chunks: MemoryChunk[],
@@ -634,7 +643,7 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
}
protected async indexFile(
entry: MemoryFileEntry | SessionFileEntry,
entry: MemoryIndexEntry,
options: { source: MemorySource; content?: string },
) {
// FTS-only mode: no embedding provider, but we can still build a FTS index

View File

@@ -19,7 +19,6 @@ import {
buildSessionEntry,
listSessionFilesForAgent,
sessionPathForFile,
type SessionFileEntry,
} from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
import {
buildFileEntry,
@@ -29,7 +28,6 @@ import {
loadSqliteVecExtension,
normalizeExtraMemoryPaths,
runWithConcurrency,
type MemoryFileEntry,
type MemorySource,
type MemorySyncProgressUpdate,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
@@ -67,6 +65,15 @@ type MemorySyncProgressState = {
report: (update: MemorySyncProgressUpdate) => void;
};
type MemoryIndexEntry = {
path: string;
absPath: string;
mtimeMs: number;
size: number;
hash: string;
content?: string;
};
const META_KEY = "memory_index_meta_v1";
const VECTOR_TABLE = "chunks_vec";
const FTS_TABLE = "chunks_fts";
@@ -197,7 +204,7 @@ export abstract class MemoryManagerSyncOps {
protected abstract getIndexConcurrency(): number;
protected abstract pruneEmbeddingCacheIfNeeded(): void;
protected abstract indexFile(
entry: MemoryFileEntry | SessionFileEntry,
entry: MemoryIndexEntry,
options: { source: MemorySource; content?: string },
): Promise<void>;
@@ -712,7 +719,7 @@ export abstract class MemoryManagerSyncOps {
),
this.getIndexConcurrency(),
)
).filter((entry): entry is MemoryFileEntry => entry !== null);
).filter((entry): entry is MemoryIndexEntry => entry !== null);
log.debug("memory sync: indexing memory files", {
files: fileEntries.length,
needsFullReindex: params.needsFullReindex,

View File

@@ -431,7 +431,9 @@ describe("ollama setup", () => {
"qwen3-coder:480b-cloud",
"gpt-oss:120b-cloud",
]);
expect(models?.find((m) => m.id === "qwen3-coder:480b-cloud")?.contextWindow).toBe(262144);
expect(fetchMock.mock.calls.some((call) => requestUrl(call[0]).endsWith("/api/show"))).toBe(
false,
);
expect(
fetchMock.mock.calls.some((call) => requestUrl(call[0]) === "https://ollama.com/api/tags"),
).toBe(true);

View File

@@ -601,14 +601,6 @@ export async function promptAndConfigureOllama(params: {
const { reachable, models: rawDiscoveredModels } =
await fetchOllamaModels(OLLAMA_CLOUD_BASE_URL);
const discoveredModels = rawDiscoveredModels.slice(0, OLLAMA_CLOUD_MAX_DISCOVERED_MODELS);
const enrichedModels =
reachable && discoveredModels.length > 0
? await enrichOllamaModelsWithContext(
OLLAMA_CLOUD_BASE_URL,
discoveredModels.slice(0, OLLAMA_CONTEXT_ENRICH_LIMIT),
)
: [];
const discoveredModelsByName = new Map(enrichedModels.map((model) => [model.name, model]));
const discoveredModelNames = discoveredModels.map((model) => model.name);
const modelNames =
discoveredModelNames.length > 0
@@ -621,7 +613,7 @@ export async function promptAndConfigureOllama(params: {
params.cfg,
OLLAMA_CLOUD_BASE_URL,
modelNames,
discoveredModelsByName,
undefined,
credential,
),
};

View File

@@ -11,7 +11,6 @@ import {
resolveCompletedBatchResult,
runEmbeddingBatchGroups,
throwIfBatchTerminalFailure,
type EmbeddingBatchExecutionParams,
type EmbeddingBatchStatus,
type BatchCompletionResult,
type ProviderBatchOutputLine,
@@ -20,6 +19,14 @@ import {
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import type { OpenAiEmbeddingClient } from "./embedding-provider.js";
type EmbeddingBatchExecutionParams = {
wait: boolean;
pollIntervalMs: number;
timeoutMs: number;
concurrency: number;
debug?: (message: string, data?: Record<string, unknown>) => void;
};
export type OpenAiBatchRequest = {
custom_id: string;
method: "POST";

View File

@@ -206,6 +206,27 @@ describe("qa cli runtime", () => {
});
});
it("passes explicit suite plugin enablements into the host gateway run", async () => {
await runQaSuiteCommand({
repoRoot: "/tmp/openclaw-repo",
providerMode: "mock-openai",
scenarioIds: ["channel-chat-baseline"],
enabledPluginIds: ["browser", "memory-core"],
});
expect(runQaSuiteFromRuntime).toHaveBeenCalledWith({
repoRoot: path.resolve("/tmp/openclaw-repo"),
outputDir: undefined,
transportId: "qa-channel",
providerMode: "mock-openai",
primaryModel: undefined,
alternateModel: undefined,
fastMode: undefined,
scenarioIds: ["channel-chat-baseline"],
enabledPluginIds: ["browser", "memory-core"],
});
});
it("drops blank suite model refs so provider defaults apply", async () => {
await runQaSuiteCommand({
repoRoot: "/tmp/openclaw-repo",

View File

@@ -474,6 +474,7 @@ export async function runQaSuiteCommand(opts: {
scenarioIds?: string[];
concurrency?: number;
allowFailures?: boolean;
enabledPluginIds?: string[];
image?: string;
cpus?: number;
memory?: string;
@@ -567,6 +568,7 @@ export async function runQaSuiteCommand(opts: {
...(thinkingDefault ? { thinkingDefault } : {}),
...(claudeCliAuthMode ? { claudeCliAuthMode } : {}),
scenarioIds,
...(opts.enabledPluginIds !== undefined ? { enabledPluginIds: opts.enabledPluginIds } : {}),
...(opts.concurrency !== undefined
? { concurrency: parseQaPositiveIntegerOption("--concurrency", opts.concurrency) }
: {}),

View File

@@ -37,6 +37,7 @@ async function runQaSuite(opts: {
fastMode?: boolean;
thinking?: string;
allowFailures?: boolean;
enabledPluginIds?: string[];
cliAuthMode?: string;
parityPack?: string;
scenarioIds?: string[];
@@ -248,6 +249,12 @@ export function registerQaLabCli(program: Command) {
)
.option("--parity-pack <name>", 'Preset scenario pack; currently only "agentic" is supported')
.option("--scenario <id>", "Run only the named QA scenario (repeatable)", collectString, [])
.option(
"--enable-plugin <id>",
"Enable an extra bundled plugin in the QA gateway config (repeatable)",
collectString,
[],
)
.option("--concurrency <count>", "Scenario worker concurrency", (value: string) =>
Number(value),
)
@@ -278,6 +285,7 @@ export function registerQaLabCli(program: Command) {
cliAuthMode?: string;
parityPack?: string;
scenario?: string[];
enablePlugin?: string[];
concurrency?: number;
allowFailures?: boolean;
fast?: boolean;
@@ -301,6 +309,7 @@ export function registerQaLabCli(program: Command) {
cliAuthMode: opts.cliAuthMode,
parityPack: opts.parityPack,
scenarioIds: opts.scenario,
enabledPluginIds: opts.enablePlugin,
concurrency: opts.concurrency,
allowFailures: opts.allowFailures,
image: opts.image,

View File

@@ -466,6 +466,77 @@ describe("telegram live qa runtime", () => {
expect(signal?.aborted).toBe(true);
});
it("treats transient Telegram getUpdates network errors as recoverable", () => {
expect(__testing.isRecoverableTelegramQaPollError(new TypeError("fetch failed"))).toBe(true);
expect(__testing.isRecoverableTelegramQaPollError(new Error("socket hang up"))).toBe(true);
expect(
__testing.isRecoverableTelegramQaPollError(new Error("Bad Request: chat not found")),
).toBe(false);
});
it("retries transient Telegram polling fetch failures while waiting for scenario replies", async () => {
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new TypeError("fetch failed"))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
ok: true,
result: [
{
update_id: 10,
message: {
message_id: 99,
chat: { id: -100123 },
from: { id: 88, is_bot: true, username: "sut_bot" },
text: "Identity\nChannel: telegram",
date: 1_700_000_000,
reply_to_message: { message_id: 55 },
},
},
],
}),
{
status: 200,
headers: {
"content-type": "application/json",
},
},
),
);
vi.stubGlobal("fetch", fetchMock);
const observedMessages: Parameters<
typeof __testing.waitForObservedMessage
>[0]["observedMessages"] = [];
const result = await __testing.waitForObservedMessage({
token: "token",
initialOffset: 7,
timeoutMs: 5_000,
observedMessages,
observationScenarioId: "telegram-whoami-command",
observationScenarioTitle: "Telegram whoami reply",
predicate: (message) =>
__testing.matchesTelegramScenarioReply({
groupId: "-100123",
message,
sentMessageId: 55,
sutBotId: 88,
}),
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(result.message.messageId).toBe(99);
expect(result.nextOffset).toBe(11);
expect(observedMessages).toEqual([
expect.objectContaining({
matchedScenario: true,
messageId: 99,
scenarioId: "telegram-whoami-command",
}),
]);
});
it("redacts observed message content by default in artifacts", () => {
expect(
__testing.buildObservedMessagesArtifact({

View File

@@ -550,6 +550,17 @@ async function callTelegramApi<T>(
}
}
function isRecoverableTelegramQaPollError(error: unknown): boolean {
const message = formatErrorMessage(error).toLowerCase();
return (
message.includes("fetch failed") ||
message.includes("econnreset") ||
message.includes("etimedout") ||
message.includes("socket hang up") ||
message.includes("terminated")
);
}
async function getBotIdentity(token: string) {
return await callTelegramApi<TelegramBotIdentity>(token, "getMe");
}
@@ -584,6 +595,10 @@ async function sendGroupMessage(token: string, groupId: string, text: string) {
});
}
async function waitForTelegramPollRetryDelay(remainingMs: number) {
await new Promise((resolve) => setTimeout(resolve, Math.min(250, Math.max(100, remainingMs))));
}
async function waitForObservedMessage(params: {
token: string;
initialOffset: number;
@@ -595,22 +610,34 @@ async function waitForObservedMessage(params: {
}) {
const startedAt = Date.now();
let offset = params.initialOffset;
let lastPollingError: unknown;
while (Date.now() - startedAt < params.timeoutMs) {
const remainingMs = Math.max(
1_000,
Math.min(10_000, params.timeoutMs - (Date.now() - startedAt)),
);
const timeoutSeconds = Math.max(1, Math.min(10, Math.floor(remainingMs / 1000)));
const updates = await callTelegramApi<TelegramUpdate[]>(
params.token,
"getUpdates",
{
offset,
timeout: timeoutSeconds,
allowed_updates: ["message"],
},
timeoutSeconds * 1000 + 5_000,
);
let updates: TelegramUpdate[];
try {
updates = await callTelegramApi<TelegramUpdate[]>(
params.token,
"getUpdates",
{
offset,
timeout: timeoutSeconds,
allowed_updates: ["message"],
},
timeoutSeconds * 1000 + 5_000,
);
lastPollingError = undefined;
} catch (error) {
if (!isRecoverableTelegramQaPollError(error)) {
throw error;
}
lastPollingError = error;
await waitForTelegramPollRetryDelay(params.timeoutMs - (Date.now() - startedAt));
continue;
}
const batchObservedAtMs = Date.now();
if (updates.length === 0) {
continue;
@@ -634,7 +661,13 @@ async function waitForObservedMessage(params: {
}
}
}
throw new Error(`timed out after ${params.timeoutMs}ms waiting for Telegram message`);
const timeoutMessage = `timed out after ${params.timeoutMs}ms waiting for Telegram message`;
if (lastPollingError) {
throw new Error(
`${timeoutMessage}; last polling error: ${formatErrorMessage(lastPollingError)}`,
);
}
throw new Error(timeoutMessage);
}
async function waitForTelegramChannelRunning(
@@ -1440,6 +1473,7 @@ export const __testing = {
buildObservedMessagesArtifact,
canaryFailureMessage,
callTelegramApi,
isRecoverableTelegramQaPollError,
assertTelegramScenarioReply,
classifyCanaryReply,
findScenario,
@@ -1452,4 +1486,5 @@ export const __testing = {
shouldLogTelegramQaLiveProgress,
formatTelegramQaProgressDetails,
renderTelegramQaMarkdown,
waitForObservedMessage,
};

View File

@@ -83,6 +83,7 @@ export type QaSuiteRunParams = {
lab?: QaLabServerHandle;
startLab?: QaSuiteStartLabFn;
concurrency?: number;
enabledPluginIds?: string[];
controlUiEnabled?: boolean;
transportReadyTimeoutMs?: number;
};
@@ -433,7 +434,12 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResu
primaryModel,
claudeCliAuthMode: params?.claudeCliAuthMode,
});
const enabledPluginIds = collectQaSuitePluginIds(selectedCatalogScenarios);
const enabledPluginIds = [
...new Set([
...collectQaSuitePluginIds(selectedCatalogScenarios),
...(params?.enabledPluginIds ?? []).map((pluginId) => pluginId.trim()).filter(Boolean),
]),
];
const gatewayConfigPatch = collectQaSuiteGatewayConfigPatch(selectedCatalogScenarios);
const gatewayRuntimeOptions = collectQaSuiteGatewayRuntimeOptions(selectedCatalogScenarios);
const concurrency = normalizeQaSuiteConcurrency(
@@ -553,6 +559,7 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResu
thinkingDefault: params?.thinkingDefault,
claudeCliAuthMode: params?.claudeCliAuthMode,
scenarioIds: [scenario.id],
enabledPluginIds: params?.enabledPluginIds,
concurrency: 1,
startLab,
// Most isolated workers do not need their own Control UI proxy.

View File

@@ -515,6 +515,24 @@ describe("handleSlackAction", () => {
await sendSecondMessageAndExpectNoThread({ cfg, context });
});
it("replyToMode=first normalizes channel target when accounting explicit threadTs", async () => {
const { cfg, context, hasRepliedRef } = createReplyToFirstScenario();
await handleSlackAction(
{
action: "sendMessage",
to: "#c123",
content: "Explicit",
threadTs: "9999999999.999999",
},
cfg,
context,
);
expect(hasRepliedRef.value).toBe(true);
await sendSecondMessageAndExpectNoThread({ cfg, context });
});
it("replyToMode=first marks hasRepliedRef even when threadTs is explicit", async () => {
const { cfg, context, hasRepliedRef } = createReplyToFirstScenario();

View File

@@ -1,5 +1,6 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { parseSlackBlocksInput } from "./blocks-input.js";
import {
createActionGate,
@@ -26,6 +27,19 @@ const messagingActions = new Set([
const reactionsActions = new Set(["react", "reactions"]);
const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
function sameSlackChannelTarget(targetChannel: string, currentChannelId: string): boolean {
const parsedTarget = parseSlackTarget(targetChannel, {
defaultKind: "channel",
});
if (!parsedTarget || parsedTarget.kind !== "channel") {
return false;
}
return (
normalizeLowercaseStringOrEmpty(parsedTarget.id) ===
normalizeLowercaseStringOrEmpty(currentChannelId)
);
}
type SlackActionsRuntimeModule = typeof import("./actions.runtime.js");
type SlackAccountsRuntimeModule = typeof import("./accounts.runtime.js");
@@ -105,16 +119,8 @@ function resolveThreadTsFromContext(
return undefined;
}
const parsedTarget = parseSlackTarget(targetChannel, {
defaultKind: "channel",
});
if (!parsedTarget || parsedTarget.kind !== "channel") {
return undefined;
}
const normalizedTarget = parsedTarget.id;
// Different channel - don't inject
if (normalizedTarget !== context.currentChannelId) {
if (!sameSlackChannelTarget(targetChannel, context.currentChannelId)) {
return undefined;
}
@@ -267,8 +273,7 @@ export async function handleSlackAction(
// threadTs: once we send a message to the current channel, consider the
// first reply "used" so later tool calls don't auto-thread again.
if (context?.hasRepliedRef && context.currentChannelId) {
const parsedTarget = parseSlackTarget(to, { defaultKind: "channel" });
if (parsedTarget?.kind === "channel" && parsedTarget.id === context.currentChannelId) {
if (sameSlackChannelTarget(to, context.currentChannelId)) {
context.hasRepliedRef.value = true;
}
}
@@ -310,8 +315,7 @@ export async function handleSlackAction(
}
if (context?.hasRepliedRef && context.currentChannelId) {
const parsedTarget = parseSlackTarget(to, { defaultKind: "channel" });
if (parsedTarget?.kind === "channel" && parsedTarget.id === context.currentChannelId) {
if (sameSlackChannelTarget(to, context.currentChannelId)) {
context.hasRepliedRef.value = true;
}
}

View File

@@ -41,6 +41,21 @@ describe("resolveSlackAutoThreadId", () => {
).toBeUndefined();
});
it("threads first matching prefixed channel target with bare current channel", () => {
const hasRepliedRef = { value: false };
expect(
resolveSlackAutoThreadId({
to: "channel:C123",
toolContext: createToolContext({
replyToMode: "first",
hasRepliedRef,
}),
}),
).toBe("thread-1");
expect(hasRepliedRef.value).toBe(false);
});
it("skips auto-threading when reply mode or thread context blocks it", () => {
expect(
resolveSlackAutoThreadId({

View File

@@ -40,8 +40,8 @@ describe("telegram native approval adapter", () => {
expect(text).toContain("`channels.telegram.execApprovals.approvers`");
expect(text).toContain("`commands.ownerAllowFrom`");
expect(text).toContain("`channels.telegram.allowFrom`");
expect(text).toContain("`channels.telegram.defaultTo`");
expect(text).not.toContain("`channels.telegram.allowFrom`");
expect(text).not.toContain("`channels.telegram.defaultTo`");
expect(text).not.toContain("`channels.telegram.dm.allowFrom`");
});
@@ -54,8 +54,8 @@ describe("telegram native approval adapter", () => {
expect(text).toContain("`channels.telegram.accounts.work.execApprovals.approvers`");
expect(text).toContain("`commands.ownerAllowFrom`");
expect(text).toContain("`channels.telegram.accounts.work.allowFrom`");
expect(text).toContain("`channels.telegram.accounts.work.defaultTo`");
expect(text).not.toContain("`channels.telegram.accounts.work.allowFrom`");
expect(text).not.toContain("`channels.telegram.accounts.work.defaultTo`");
expect(text).not.toContain("`channels.telegram.allowFrom`");
});

View File

@@ -92,7 +92,7 @@ const telegramNativeApprovalCapability = createApproverRestrictedNativeApprovalC
accountId && accountId !== "default"
? `channels.telegram.accounts.${accountId}`
: "channels.telegram";
return `Approve it from the Web UI or terminal UI for now. Telegram supports native exec approvals for this account. Configure \`${prefix}.execApprovals.approvers\`; if you leave it unset, OpenClaw can infer numeric owner IDs from \`commands.ownerAllowFrom\`, \`${prefix}.allowFrom\`, or direct-message \`${prefix}.defaultTo\` when possible. Leave \`${prefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
return `Approve it from the Web UI or terminal UI for now. Telegram supports native exec approvals for this account. Configure \`${prefix}.execApprovals.approvers\` or \`commands.ownerAllowFrom\`; leave \`${prefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
},
listAccountIds: listTelegramAccountIds,
hasApprovers: ({ cfg, accountId }) =>

View File

@@ -135,7 +135,7 @@ export const telegramChannelConfigUiHints = {
},
"execApprovals.approvers": {
label: "Telegram Exec Approval Approvers",
help: "Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from commands.ownerAllowFrom, channels.telegram.allowFrom, and direct-message defaultTo when possible.",
help: "Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from commands.ownerAllowFrom when possible.",
},
"execApprovals.agentFilter": {
label: "Telegram Exec Approval Agent Filter",

View File

@@ -121,7 +121,12 @@ describe("telegram exec approvals", () => {
isTelegramExecApprovalClientEnabled({
cfg: buildConfig(undefined, { allowFrom: ["123"] }),
}),
).toBe(true);
).toBe(false);
expect(
isTelegramExecApprovalClientEnabled({
cfg: buildConfig(undefined, { defaultTo: 123 }),
}),
).toBe(false);
expect(
isTelegramExecApprovalClientEnabled({
cfg: buildConfig({ approvers: ["123"] }),
@@ -160,7 +165,26 @@ describe("telegram exec approvals", () => {
expect(isTelegramExecApprovalApprover({ cfg, senderId: "67890" })).toBe(true);
});
it("infers approvers from allowFrom and direct defaultTo", () => {
it("does not require explicit Telegram exec approvers when command owner identifies the Telegram operator", () => {
const cfg = {
...buildConfig(),
commands: {
ownerAllowFrom: ["telegram:12345"],
},
} as OpenClawConfig;
expect(cfg.channels?.telegram?.execApprovals?.approvers).toBeUndefined();
expect(getTelegramExecApprovalApprovers({ cfg })).toEqual(["12345"]);
expect(isTelegramExecApprovalClientEnabled({ cfg })).toBe(true);
expect(
shouldHandleTelegramExecApprovalRequest({
cfg,
request: makeForeignChannelApprovalRequest({ id: "discord-diagnostics" }),
}),
).toBe(true);
});
it("does not infer approvers from Telegram chat allowlists", () => {
const cfg = buildConfig(
{ enabled: true },
{
@@ -169,9 +193,10 @@ describe("telegram exec approvals", () => {
},
);
expect(getTelegramExecApprovalApprovers({ cfg })).toEqual(["12345", "67890"]);
expect(isTelegramExecApprovalApprover({ cfg, senderId: "12345" })).toBe(true);
expect(isTelegramExecApprovalApprover({ cfg, senderId: "67890" })).toBe(true);
expect(getTelegramExecApprovalApprovers({ cfg })).toEqual([]);
expect(isTelegramExecApprovalClientEnabled({ cfg })).toBe(false);
expect(isTelegramExecApprovalApprover({ cfg, senderId: "12345" })).toBe(false);
expect(isTelegramExecApprovalApprover({ cfg, senderId: "67890" })).toBe(false);
});
it("defaults target to dm", () => {

View File

@@ -58,12 +58,9 @@ export function getTelegramExecApprovalApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
const account = resolveTelegramAccount(params).config;
return resolveApprovalApprovers({
explicit: resolveTelegramExecApprovalConfig(params)?.approvers,
allowFrom: account.allowFrom,
extraAllowFrom: resolveTelegramOwnerApprovers(params.cfg),
defaultTo: account.defaultTo ? String(account.defaultTo) : null,
allowFrom: resolveTelegramOwnerApprovers(params.cfg),
normalizeApprover: normalizeTelegramDirectApproverId,
});
}

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import type { ResolvedTelegramAccount } from "./accounts.js";
import { collectTelegramSecurityAuditFindings } from "./security-audit.js";
@@ -32,6 +32,11 @@ function getTelegramConfig(cfg: OpenClawConfig) {
}
describe("Telegram security audit findings", () => {
beforeEach(() => {
readChannelAllowFromStoreMock.mockReset();
readChannelAllowFromStoreMock.mockResolvedValue([]);
});
it("flags group commands without a sender allowlist", async () => {
const cfg: OpenClawConfig = {
channels: {
@@ -44,7 +49,6 @@ describe("Telegram security audit findings", () => {
},
};
readChannelAllowFromStoreMock.mockResolvedValue([]);
const findings = await collectTelegramSecurityAuditFindings({
cfg,
account: createTelegramAccount(getTelegramConfig(cfg)),
@@ -74,7 +78,6 @@ describe("Telegram security audit findings", () => {
},
};
readChannelAllowFromStoreMock.mockResolvedValue([]);
const findings = await collectTelegramSecurityAuditFindings({
cfg,
account: createTelegramAccount(getTelegramConfig(cfg)),
@@ -90,4 +93,61 @@ describe("Telegram security audit findings", () => {
]),
);
});
it("warns about invalid DM allowFrom entries even when groups are not enabled", async () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
enabled: true,
botToken: "t",
dmPolicy: "allowlist",
allowFrom: ["@TrustedOperator"],
groupPolicy: "allowlist",
},
},
};
const findings = await collectTelegramSecurityAuditFindings({
cfg,
account: createTelegramAccount(getTelegramConfig(cfg)),
accountId: "default",
});
expect(findings).toEqual([
expect.objectContaining({
checkId: "channels.telegram.allowFrom.invalid_entries",
severity: "warn",
}),
]);
expect(readChannelAllowFromStoreMock).not.toHaveBeenCalled();
});
it("warns about invalid DM allowFrom entries when text commands are disabled", async () => {
const cfg: OpenClawConfig = {
commands: { text: false },
channels: {
telegram: {
enabled: true,
botToken: "t",
dmPolicy: "allowlist",
allowFrom: ["@TrustedOperator"],
groupPolicy: "allowlist",
},
},
};
const findings = await collectTelegramSecurityAuditFindings({
cfg,
account: createTelegramAccount(getTelegramConfig(cfg)),
accountId: "default",
});
expect(findings).toEqual([
expect.objectContaining({
checkId: "channels.telegram.allowFrom.invalid_entries",
severity: "warn",
}),
]);
expect(readChannelAllowFromStoreMock).not.toHaveBeenCalled();
});
});

View File

@@ -24,6 +24,36 @@ function collectInvalidTelegramAllowFromEntries(params: { entries: unknown; targ
}
}
function appendInvalidTelegramAllowFromFinding(
findings: Array<{
checkId: string;
severity: "info" | "warn" | "critical";
title: string;
detail: string;
remediation?: string;
}>,
invalidTelegramAllowFromEntries: Set<string>,
) {
if (invalidTelegramAllowFromEntries.size === 0) {
return;
}
const examples = Array.from(invalidTelegramAllowFromEntries).slice(0, 5);
const more =
invalidTelegramAllowFromEntries.size > examples.length
? ` (+${invalidTelegramAllowFromEntries.size - examples.length} more)`
: "";
findings.push({
checkId: "channels.telegram.allowFrom.invalid_entries",
severity: "warn",
title: "Telegram allowlist contains non-numeric entries",
detail:
"Telegram sender authorization requires numeric Telegram user IDs. " +
`Found non-numeric allowFrom entries: ${examples.join(", ")}${more}.`,
remediation:
"Replace @username entries with numeric Telegram user IDs (use setup to resolve), then re-run the audit.",
});
}
export async function collectTelegramSecurityAuditFindings(params: {
cfg: OpenClawConfig;
accountId?: string | null;
@@ -36,13 +66,20 @@ export async function collectTelegramSecurityAuditFindings(params: {
detail: string;
remediation?: string;
}> = [];
if (params.cfg.commands?.text === false) {
return findings;
}
const telegramCfg = params.account.config ?? {};
const accountId =
normalizeOptionalString(params.accountId) ?? params.account.accountId ?? "default";
const invalidTelegramAllowFromEntries = new Set<string>();
collectInvalidTelegramAllowFromEntries({
entries: Array.isArray(telegramCfg.allowFrom) ? telegramCfg.allowFrom : [],
target: invalidTelegramAllowFromEntries,
});
if (params.cfg.commands?.text === false) {
appendInvalidTelegramAllowFromFinding(findings, invalidTelegramAllowFromEntries);
return findings;
}
const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy;
const groupPolicy =
(telegramCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist";
@@ -51,6 +88,7 @@ export async function collectTelegramSecurityAuditFindings(params: {
const groupAccessPossible =
groupPolicy === "open" || (groupPolicy === "allowlist" && groupsConfigured);
if (!groupAccessPossible) {
appendInvalidTelegramAllowFromFinding(findings, invalidTelegramAllowFromEntries);
return findings;
}
@@ -60,7 +98,6 @@ export async function collectTelegramSecurityAuditFindings(params: {
const storeHasWildcard = storeAllowFrom.some(
(value) => (normalizeOptionalString(value) ?? "") === "*",
);
const invalidTelegramAllowFromEntries = new Set<string>();
collectInvalidTelegramAllowFromEntries({
entries: storeAllowFrom,
target: invalidTelegramAllowFromEntries,
@@ -75,10 +112,6 @@ export async function collectTelegramSecurityAuditFindings(params: {
entries: groupAllowFrom,
target: invalidTelegramAllowFromEntries,
});
collectInvalidTelegramAllowFromEntries({
entries: Array.isArray(telegramCfg.allowFrom) ? telegramCfg.allowFrom : [],
target: invalidTelegramAllowFromEntries,
});
let anyGroupOverride = false;
if (groups) {
@@ -119,23 +152,7 @@ export async function collectTelegramSecurityAuditFindings(params: {
const hasAnySenderAllowlist =
storeAllowFrom.length > 0 || groupAllowFrom.length > 0 || anyGroupOverride;
if (invalidTelegramAllowFromEntries.size > 0) {
const examples = Array.from(invalidTelegramAllowFromEntries).slice(0, 5);
const more =
invalidTelegramAllowFromEntries.size > examples.length
? ` (+${invalidTelegramAllowFromEntries.size - examples.length} more)`
: "";
findings.push({
checkId: "channels.telegram.allowFrom.invalid_entries",
severity: "warn",
title: "Telegram allowlist contains non-numeric entries",
detail:
"Telegram sender authorization requires numeric Telegram user IDs. " +
`Found non-numeric allowFrom entries: ${examples.join(", ")}${more}.`,
remediation:
"Replace @username entries with numeric Telegram user IDs (use setup to resolve), then re-run the audit.",
});
}
appendInvalidTelegramAllowFromFinding(findings, invalidTelegramAllowFromEntries);
if (storeHasWildcard || groupAllowFromHasWildcard) {
findings.push({

View File

@@ -16,15 +16,15 @@ import {
encodePngRgba,
fillPixel,
getShellEnvAppliedKeys,
isAuthErrorMessage,
isBillingErrorMessage,
isLiveProfileKeyModeEnabled,
isLiveTestEnabled,
isModelNotFoundErrorMessage,
isTruthyEnvValue,
isAuthErrorMessage,
isBillingErrorMessage,
isOverloadedErrorMessage,
isServerErrorMessage,
isTimeoutErrorMessage,
isTruthyEnvValue,
normalizeVideoGenerationDuration,
parseCsvFilter,
parseProviderModelMap,
@@ -77,7 +77,7 @@ const LIVE_VIDEO_OPERATION_TIMEOUT_MS = readPositiveIntegerEnv(
const LIVE_VIDEO_TEST_TIMEOUT_MS =
(RUN_FULL_VIDEO_MODES ? 3 : 1) * LIVE_VIDEO_OPERATION_TIMEOUT_MS + 30_000;
const LIVE_VIDEO_SMOKE_PROMPT =
"A one-second low-motion video of a lobster walking across wet sand, no text.";
"A one-second low-motion video of a blue cube sliding across a clean studio floor.";
type LiveProviderCase = {
plugin: Parameters<typeof registerProviderPlugin>[0]["plugin"];
@@ -260,6 +260,9 @@ function resolveLiveVideoSkipReason(message: string): string | null {
if (/access denied|not authorized|not enabled|permission denied/i.test(message)) {
return "provider/model drift";
}
if (/blocked by (?:our )?moderation system|content policy|policy violation/i.test(message)) {
return "provider policy drift";
}
return null;
}

View File

@@ -1,6 +1,6 @@
import type { proto } from "@whiskeysockets/baileys";
import { describe, expect, it } from "vitest";
import { extractMentionedJids } from "./extract.js";
import { extractMentionedJids, hasInboundUserContent } from "./extract.js";
describe("extractMentionedJids", () => {
const botJid = "5511999999999@s.whatsapp.net";
@@ -101,3 +101,182 @@ describe("extractMentionedJids", () => {
expect(extractMentionedJids(message)).toEqual([botJid]);
});
});
describe("hasInboundUserContent", () => {
it("returns true for plain text conversation", () => {
expect(hasInboundUserContent({ conversation: "hello" })).toBe(true);
});
it("returns true for extendedTextMessage", () => {
expect(
hasInboundUserContent({ extendedTextMessage: { text: "hello" } } as proto.IMessage),
).toBe(true);
});
it("returns true for image message", () => {
expect(
hasInboundUserContent({ imageMessage: { mimetype: "image/png" } } as proto.IMessage),
).toBe(true);
});
it("returns true for video message", () => {
expect(
hasInboundUserContent({ videoMessage: { mimetype: "video/mp4" } } as proto.IMessage),
).toBe(true);
});
it("returns true for audio message", () => {
expect(
hasInboundUserContent({ audioMessage: { mimetype: "audio/ogg" } } as proto.IMessage),
).toBe(true);
});
it("returns true for document message", () => {
expect(
hasInboundUserContent({
documentMessage: { fileName: "x.pdf" },
} as proto.IMessage),
).toBe(true);
});
it("returns true for sticker message", () => {
expect(
hasInboundUserContent({ stickerMessage: { mimetype: "image/webp" } } as proto.IMessage),
).toBe(true);
});
it("returns true for location message with valid coords", () => {
expect(
hasInboundUserContent({
locationMessage: { degreesLatitude: 1, degreesLongitude: 2 },
} as proto.IMessage),
).toBe(true);
});
it("returns true for live location message with valid coords", () => {
expect(
hasInboundUserContent({
liveLocationMessage: { degreesLatitude: 1, degreesLongitude: 2 },
} as proto.IMessage),
).toBe(true);
});
it("returns true for contact message", () => {
expect(
hasInboundUserContent({
contactMessage: { displayName: "Alice", vcard: "BEGIN:VCARD\nEND:VCARD" },
} as proto.IMessage),
).toBe(true);
});
it("returns true for contactsArrayMessage via contact placeholder extraction", () => {
expect(
hasInboundUserContent({
contactsArrayMessage: {
contacts: [{ displayName: "Alice", vcard: "BEGIN:VCARD\nEND:VCARD" }],
},
} as proto.IMessage),
).toBe(true);
});
it("returns true for buttons response (user button click)", () => {
expect(
hasInboundUserContent({
buttonsResponseMessage: {
selectedButtonId: "yes",
selectedDisplayText: "Yes",
},
} as proto.IMessage),
).toBe(true);
});
it("returns true for list response (user list selection)", () => {
expect(
hasInboundUserContent({
listResponseMessage: {
title: "Option A",
singleSelectReply: { selectedRowId: "a" },
} as unknown as proto.Message.IListResponseMessage,
} as proto.IMessage),
).toBe(true);
});
it("returns true for template button reply", () => {
expect(
hasInboundUserContent({
templateButtonReplyMessage: {
selectedId: "btn-1",
selectedDisplayText: "Click",
} as unknown as proto.Message.ITemplateButtonReplyMessage,
} as proto.IMessage),
).toBe(true);
});
it("returns true for interactive response", () => {
expect(
hasInboundUserContent({
interactiveResponseMessage: {
body: { text: "x" },
nativeFlowResponseMessage: { name: "n", paramsJson: "{}" },
} as unknown as proto.Message.IInteractiveResponseMessage,
} as proto.IMessage),
).toBe(true);
});
it("returns true for buttons response wrapped in ephemeralMessage (regression for #73797 + greptile review)", () => {
expect(
hasInboundUserContent({
ephemeralMessage: {
message: {
buttonsResponseMessage: {
selectedButtonId: "ok",
selectedDisplayText: "OK",
},
},
},
} as proto.IMessage),
).toBe(true);
});
it("returns false for undefined message (regression for #73797)", () => {
expect(hasInboundUserContent(undefined)).toBe(false);
});
it("returns false for empty message object (no content keys)", () => {
expect(hasInboundUserContent({} as proto.IMessage)).toBe(false);
});
it("returns false for protocol message envelope without inner content (regression for #73797)", () => {
expect(
hasInboundUserContent({
protocolMessage: {
type: 0,
} as unknown as proto.Message.IProtocolMessage,
} as proto.IMessage),
).toBe(false);
});
it("returns false for receipt-style senderKeyDistribution-only payload (regression for #73797)", () => {
expect(
hasInboundUserContent({
senderKeyDistributionMessage: {
groupId: "g@example",
} as unknown as proto.Message.ISenderKeyDistributionMessage,
} as proto.IMessage),
).toBe(false);
});
it("returns false when location coords are missing (incomplete event, regression for #73797)", () => {
expect(
hasInboundUserContent({
locationMessage: { name: "no coords" },
} as proto.IMessage),
).toBe(false);
});
it("returns false when extendedTextMessage has only empty text", () => {
expect(hasInboundUserContent({ extendedTextMessage: { text: " " } } as proto.IMessage)).toBe(
false,
);
});
});

View File

@@ -438,3 +438,49 @@ export function describeReplyContext(
sender,
};
}
function hasInteractiveResponseContent(message: proto.IMessage | undefined): boolean {
if (!message) {
return false;
}
// Button/list/template/interactive selections that the existing four
// extractors do not cover. Treat any presence of these keys as user
// content — Baileys never delivers these as receipts or protocol
// envelopes, only as explicit user choices.
return Boolean(
message.buttonsResponseMessage ||
message.listResponseMessage ||
message.templateButtonReplyMessage ||
message.interactiveResponseMessage,
);
}
/**
* Fast check that a Baileys message carries user-visible inbound content
* (text, media, contact, location, button/list selection). Returns false for
* protocol/receipt/typing notifications that arrive on the same
* `messages.upsert` stream as real messages but should not trigger pairing
* access-control side effects.
*/
export function hasInboundUserContent(rawMessage: proto.IMessage | undefined): boolean {
if (!rawMessage) {
return false;
}
if (extractText(rawMessage)) {
return true;
}
if (extractMediaPlaceholder(rawMessage)) {
return true;
}
if (extractLocationData(rawMessage)) {
return true;
}
// Walk wrappers (ephemeral, viewOnce, etc.) — interactive responses
// can arrive nested.
for (const candidate of buildMessageChain(rawMessage)) {
if (hasInteractiveResponseContent(candidate)) {
return true;
}
}
return false;
}

View File

@@ -35,6 +35,7 @@ import {
extractMediaPlaceholder,
extractMentionedJids,
extractText,
hasInboundUserContent,
} from "./extract.js";
import { attachEmitterListener, closeInboundMonitorSocket } from "./lifecycle.js";
import { downloadInboundMedia } from "./media.js";
@@ -381,6 +382,18 @@ export async function attachWebInboxToSocket(
);
return null;
}
// Gate pairing access-control on extractable inbound user content. Baileys
// delivers receipts, typing indicators, presence updates, and protocol
// messages on the same `messages.upsert` stream as real messages; without
// this gate, `checkInboundAccessControl` can send an unsolicited pairing
// verification reply to a `dmPolicy: pairing` peer who never typed
// anything (e.g. when Master sends an outbound message to a new JID and
// the receipt round-trip arrives before the recipient ever replies).
// Echoes of our own outbound messages are already handled above.
if (!hasInboundUserContent(msg.message ?? undefined)) {
return null;
}
const participantJid = msg.key?.participant ?? undefined;
const from = group ? remoteJid : await resolveInboundJid(remoteJid);
if (!from) {

View File

@@ -57,6 +57,7 @@
"skills/",
"scripts/npm-runner.mjs",
"scripts/preinstall-package-manager-warning.mjs",
"scripts/lib/package-dist-imports.mjs",
"scripts/postinstall-bundled-plugins.mjs",
"scripts/windows-cmd-helpers.mjs"
],
@@ -1456,6 +1457,7 @@
"test:docker:doctor-switch": "bash scripts/e2e/doctor-install-switch-docker.sh",
"test:docker:e2e-build": "bash scripts/e2e/build-image.sh",
"test:docker:gateway-network": "bash scripts/e2e/gateway-network-docker.sh",
"test:docker:kitchen-sink-plugin": "bash scripts/e2e/kitchen-sink-plugin-docker.sh",
"test:docker:live-acp-bind": "bash scripts/test-live-acp-bind-docker.sh",
"test:docker:live-acp-bind:claude": "OPENCLAW_LIVE_ACP_BIND_AGENT=claude bash scripts/test-live-acp-bind-docker.sh",
"test:docker:live-acp-bind:codex": "OPENCLAW_LIVE_ACP_BIND_AGENT=codex bash scripts/test-live-acp-bind-docker.sh",
@@ -1544,6 +1546,7 @@
"test:perf:imports:changed": "OPENCLAW_VITEST_IMPORT_DURATIONS=1 OPENCLAW_VITEST_PRINT_IMPORT_BREAKDOWN=1 node scripts/test-projects.mjs --changed origin/main",
"test:perf:profile:main": "node scripts/run-vitest-profile.mjs main",
"test:perf:profile:runner": "node scripts/run-vitest-profile.mjs runner",
"test:plugins:gateway-gauntlet": "node scripts/check-plugin-gateway-gauntlet.mjs",
"test:sectriage": "OPENCLAW_GATEWAY_PROJECT_SHARDS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts && node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts",
"test:serial": "OPENCLAW_TEST_PROJECTS_SERIAL=1 OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/test-projects.mjs",
"test:stability:gateway": "OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts src/gateway/gateway-stability.test.ts && OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.logging.config.ts src/logging/diagnostic-stability-bundle.test.ts && OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.infra.config.ts src/infra/fatal-error-hooks.test.ts",

View File

@@ -163,7 +163,7 @@ function collectObservations(params) {
const observations = [];
for (const result of params.startup?.results ?? []) {
const cpuCoreMax = result.summary?.cpuCoreRatio?.max;
const wallMax = result.summary?.readyz?.max ?? result.summary?.healthz?.max;
const wallMax = result.summary?.readyzMs?.max ?? result.summary?.healthzMs?.max;
if (
typeof cpuCoreMax === "number" &&
typeof wallMax === "number" &&

View File

@@ -4,7 +4,13 @@
// prebuilt package artifact with dist inventory, not a source checkout.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { LOCAL_BUILD_METADATA_DIST_PATHS } from "./lib/local-build-metadata-paths.mjs";
import {
collectPackageDistImportErrors,
expandPackageDistImportClosure,
} from "./lib/package-dist-imports.mjs";
function usage() {
return "Usage: node scripts/check-openclaw-package-tarball.mjs <openclaw.tgz>";
@@ -31,6 +37,20 @@ if (list.status !== 0) {
fail(`tar -tf failed for ${tarball}: ${list.stderr || list.status}`);
}
const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-package-tarball-"));
try {
const extract = spawnSync("tar", ["-xf", tarball, "-C", extractDir], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (extract.status !== 0) {
fail(`tar -xf failed for ${tarball}: ${extract.stderr || extract.status}`);
}
} catch (error) {
fs.rmSync(extractDir, { recursive: true, force: true });
throw error;
}
const entries = list.stdout
.split(/\r?\n/u)
.map((entry) => entry.trim())
@@ -106,14 +126,13 @@ function isLegacyLocalBuildMetadataCompatVersion(version) {
}
function readTarEntry(entryPath) {
const candidates = [entryPath, `package/${entryPath}`];
const candidates = [
path.join(extractDir, entryPath),
path.join(extractDir, "package", entryPath),
];
for (const candidate of candidates) {
const result = spawnSync("tar", ["-xOf", tarball, candidate], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status === 0) {
return result.stdout;
if (fs.existsSync(candidate)) {
return fs.readFileSync(candidate, "utf8");
}
}
return "";
@@ -170,6 +189,8 @@ if (entrySet.has("dist/postinstall-inventory.json")) {
if (!Array.isArray(inventory) || inventory.some((entry) => typeof entry !== "string")) {
errors.push("invalid dist/postinstall-inventory.json");
} else {
const normalizedInventory = inventory.map((entry) => entry.replace(/\\/gu, "/"));
const normalizedInventorySet = new Set(normalizedInventory);
for (const inventoryEntry of inventory) {
const normalizedEntry = inventoryEntry.replace(/\\/gu, "/");
if (!entrySet.has(normalizedEntry)) {
@@ -185,6 +206,16 @@ if (entrySet.has("dist/postinstall-inventory.json")) {
errors.push(`inventory references missing tar entry ${normalizedEntry}`);
}
}
const expandedInventory = expandPackageDistImportClosure({
files: normalized,
seedFiles: normalizedInventory,
readText: readTarEntry,
});
for (const importedEntry of expandedInventory) {
if (!normalizedInventorySet.has(importedEntry)) {
errors.push(`inventory omits imported dist file ${importedEntry}`);
}
}
}
} catch (error) {
errors.push(
@@ -195,11 +226,20 @@ if (entrySet.has("dist/postinstall-inventory.json")) {
}
}
errors.push(
...collectPackageDistImportErrors({
files: normalized,
readText: readTarEntry,
}),
);
if (errors.length > 0) {
fs.rmSync(extractDir, { recursive: true, force: true });
fail(`OpenClaw package tarball integrity failed:\n${errors.join("\n")}`);
}
for (const warning of warnings) {
console.warn(`OpenClaw package tarball integrity warning: ${warning}`);
}
fs.rmSync(extractDir, { recursive: true, force: true });
console.log("OpenClaw package tarball integrity passed.");

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { collectPackageDistImportErrors } from "./lib/package-dist-imports.mjs";
function usage() {
return "Usage: node scripts/check-package-dist-imports.mjs [package-root]";
}
function fail(message) {
console.error(message);
process.exit(1);
}
const packageRoot = path.resolve(process.argv[2] ?? process.cwd());
if (process.argv.length > 3) {
fail(usage());
}
const distRoot = path.join(packageRoot, "dist");
if (!fs.existsSync(distRoot)) {
fail(`missing dist directory: ${distRoot}`);
}
function collectFiles(rootDir) {
const pending = [rootDir];
const files = [];
while (pending.length > 0) {
const dir = pending.pop();
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
pending.push(entryPath);
continue;
}
if (entry.isFile()) {
files.push(path.relative(packageRoot, entryPath).replace(/\\/gu, "/"));
}
}
}
return files;
}
const errors = collectPackageDistImportErrors({
files: collectFiles(distRoot),
readText(relativePath) {
return fs.readFileSync(path.join(packageRoot, relativePath), "utf8");
},
});
if (errors.length > 0) {
fail(`OpenClaw package dist import closure failed:\n${errors.join("\n")}`);
}
console.log("OpenClaw package dist import closure passed.");

View File

@@ -0,0 +1,636 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import {
buildGauntletPrebuildEnv,
collectGatewayCpuObservations,
collectMetricObservations,
collectQaBaselineRegressionObservations,
discoverBundledPluginManifests,
selectPluginEntries,
} from "./lib/plugin-gateway-gauntlet.mjs";
const DEFAULT_QA_SCENARIOS = [
"channel-chat-baseline",
"memory-failure-fallback",
"gateway-restart-inflight-run",
];
const DEFAULT_CPU_CORE_WARN = 0.9;
const DEFAULT_HOT_WALL_WARN_MS = 30_000;
const DEFAULT_MAX_RSS_WARN_MB = 1536;
const DEFAULT_QA_PLUGIN_CHUNK_SIZE = 12;
const ANSI_PATTERN = new RegExp(String.raw`\u001B\[[0-9;]*m`, "gu");
function parseArgs(argv) {
const options = {
repoRoot: process.cwd(),
outputDir: path.join(
process.cwd(),
".artifacts",
"plugin-gateway-gauntlet",
new Date().toISOString().replace(/[:.]/g, "-"),
),
pluginIds: [],
shardTotal: readOptionalPositiveIntEnv("OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_TOTAL") ?? 1,
shardIndex: readOptionalNonNegativeIntEnv("OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_INDEX") ?? 0,
limit: undefined,
skipPrebuild: false,
skipLifecycle: false,
skipQa: false,
qaBaseline: false,
skipSlashHelp: false,
qaScenarios: [],
qaPluginChunkSize: DEFAULT_QA_PLUGIN_CHUNK_SIZE,
cpuCoreWarn: DEFAULT_CPU_CORE_WARN,
hotWallWarnMs: DEFAULT_HOT_WALL_WARN_MS,
maxRssWarnMb: DEFAULT_MAX_RSS_WARN_MB,
wallAnomalyMultiplier: 3,
rssAnomalyMultiplier: 2.5,
qaCpuRegressionMultiplier: 2,
qaWallRegressionMultiplier: 2,
commandTimeoutMs: 120_000,
buildTimeoutMs: 600_000,
qaTimeoutMs: 900_000,
};
const envIds = normalizeCsv(process.env.OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_IDS);
options.pluginIds.push(...envIds);
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const readValue = () => {
const value = argv[index + 1];
if (!value) {
throw new Error(`Missing value for ${arg}`);
}
index += 1;
return value;
};
switch (arg) {
case "--":
break;
case "--repo-root":
options.repoRoot = path.resolve(readValue());
break;
case "--output-dir":
options.outputDir = path.resolve(readValue());
break;
case "--plugin":
options.pluginIds.push(readValue());
break;
case "--shard-total":
options.shardTotal = parsePositiveInt(readValue(), "--shard-total");
break;
case "--shard-index":
options.shardIndex = parseNonNegativeInt(readValue(), "--shard-index");
break;
case "--limit":
options.limit = parsePositiveInt(readValue(), "--limit");
break;
case "--qa-scenario":
options.qaScenarios.push(readValue());
break;
case "--qa-plugin-chunk-size":
options.qaPluginChunkSize = parsePositiveInt(readValue(), "--qa-plugin-chunk-size");
break;
case "--qa-baseline":
options.qaBaseline = true;
break;
case "--cpu-core-warn":
options.cpuCoreWarn = parsePositiveNumber(readValue(), "--cpu-core-warn");
break;
case "--hot-wall-warn-ms":
options.hotWallWarnMs = parsePositiveInt(readValue(), "--hot-wall-warn-ms");
break;
case "--max-rss-warn-mb":
options.maxRssWarnMb = parsePositiveNumber(readValue(), "--max-rss-warn-mb");
break;
case "--wall-anomaly-multiplier":
options.wallAnomalyMultiplier = parsePositiveNumber(
readValue(),
"--wall-anomaly-multiplier",
);
break;
case "--rss-anomaly-multiplier":
options.rssAnomalyMultiplier = parsePositiveNumber(readValue(), "--rss-anomaly-multiplier");
break;
case "--qa-cpu-regression-multiplier":
options.qaCpuRegressionMultiplier = parsePositiveNumber(
readValue(),
"--qa-cpu-regression-multiplier",
);
break;
case "--qa-wall-regression-multiplier":
options.qaWallRegressionMultiplier = parsePositiveNumber(
readValue(),
"--qa-wall-regression-multiplier",
);
break;
case "--command-timeout-ms":
options.commandTimeoutMs = parsePositiveInt(readValue(), "--command-timeout-ms");
break;
case "--build-timeout-ms":
options.buildTimeoutMs = parsePositiveInt(readValue(), "--build-timeout-ms");
break;
case "--qa-timeout-ms":
options.qaTimeoutMs = parsePositiveInt(readValue(), "--qa-timeout-ms");
break;
case "--skip-prebuild":
options.skipPrebuild = true;
break;
case "--skip-lifecycle":
options.skipLifecycle = true;
break;
case "--skip-qa":
options.skipQa = true;
break;
case "--skip-slash-help":
options.skipSlashHelp = true;
break;
case "--help":
printHelp();
process.exit(0);
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
if (options.qaScenarios.length === 0) {
options.qaScenarios = [...DEFAULT_QA_SCENARIOS];
}
return options;
}
function printHelp() {
console.log(`Usage: pnpm test:plugins:gateway-gauntlet [options]
Runs a shardable bundled-plugin lifecycle, slash inventory, and QA gateway perf gauntlet.
Options:
--plugin <id> Plugin id to include, repeatable
--shard-total <count> Total plugin shards (default: env or 1)
--shard-index <index> Zero-based shard index (default: env or 0)
--limit <count> Limit selected plugins after sharding
--output-dir <path> Artifact directory
--qa-scenario <id> QA Lab scenario id, repeatable
--qa-plugin-chunk-size <count> Plugins enabled per QA run (default: 12)
--qa-baseline Run a no-extra-plugin QA baseline before plugin chunks
--cpu-core-warn <ratio> Hot CPU threshold (default: 0.9)
--hot-wall-warn-ms <ms> Minimum wall time for hot CPU observations (default: 30000)
--max-rss-warn-mb <mb> Maximum RSS warning threshold (default: 1536)
--skip-prebuild Skip the upfront build used to avoid per-command rebuild noise
--skip-lifecycle Skip plugin install/inspect/disable/enable/doctor/uninstall
--skip-qa Skip QA Lab RPC conversation runs
--skip-slash-help Skip CLI help probes for plugin-declared command aliases
`);
}
function normalizeCsv(raw) {
return raw
? raw
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
: [];
}
function readOptionalPositiveIntEnv(name) {
const raw = process.env[name];
return raw ? parsePositiveInt(raw, name) : undefined;
}
function readOptionalNonNegativeIntEnv(name) {
const raw = process.env[name];
return raw ? parseNonNegativeInt(raw, name) : undefined;
}
function parsePositiveInt(raw, label) {
const value = Number(raw);
if (!Number.isInteger(value) || value < 1) {
throw new Error(`${label} must be a positive integer`);
}
return value;
}
function parseNonNegativeInt(raw, label) {
const value = Number(raw);
if (!Number.isInteger(value) || value < 0) {
throw new Error(`${label} must be a non-negative integer`);
}
return value;
}
function parsePositiveNumber(raw, label) {
const value = Number(raw);
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`${label} must be a positive number`);
}
return value;
}
function pnpmCommand() {
return process.platform === "win32" ? "pnpm.cmd" : "pnpm";
}
function openclawCommand(repoRoot, args) {
return {
command: process.execPath,
args: [path.join(repoRoot, "dist", "entry.js"), ...args],
};
}
function sourceOpenclawCommand(repoRoot, args) {
return {
command: process.execPath,
args: [path.join(repoRoot, "scripts", "run-node.mjs"), ...args],
};
}
function chunkArray(values, chunkSize) {
const chunks = [];
for (let index = 0; index < values.length; index += chunkSize) {
chunks.push(values.slice(index, index + chunkSize));
}
return chunks;
}
function toRepoRelativePath(repoRoot, absolutePath) {
const relativePath = path.relative(repoRoot, absolutePath);
if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
throw new Error(`Output path must stay inside repo root: ${absolutePath}`);
}
return relativePath;
}
function createIsolatedEnv(repoRoot, runRoot) {
const home = path.join(runRoot, "home");
const stateDir = path.join(runRoot, "state");
fs.mkdirSync(home, { recursive: true });
fs.mkdirSync(stateDir, { recursive: true });
return {
...process.env,
HOME: home,
XDG_CONFIG_HOME: path.join(home, ".config"),
XDG_CACHE_HOME: path.join(home, ".cache"),
XDG_DATA_HOME: path.join(home, ".local", "share"),
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
OPENCLAW_LOG_DIR: path.join(runRoot, "logs"),
OPENCLAW_QA_SUITE_PROGRESS: process.env.OPENCLAW_QA_SUITE_PROGRESS ?? "1",
PATH: process.env.PATH,
PWD: repoRoot,
};
}
function hasUsrBinTime() {
return fs.existsSync("/usr/bin/time");
}
function timeWrapperArgs(command, args) {
if (!hasUsrBinTime()) {
return { command, args, mode: "none" };
}
if (process.platform === "darwin") {
return { command: "/usr/bin/time", args: ["-l", command, ...args], mode: "bsd" };
}
return { command: "/usr/bin/time", args: ["-v", command, ...args], mode: "gnu" };
}
function parseTimedMetrics(stderr, wallMs, mode) {
let userSeconds = null;
let systemSeconds = null;
let maxRssMb = null;
if (mode === "gnu") {
userSeconds = parseFirstFloat(stderr, /User time \(seconds\):\s*([0-9.]+)/u);
systemSeconds = parseFirstFloat(stderr, /System time \(seconds\):\s*([0-9.]+)/u);
const maxRssKb = parseFirstFloat(stderr, /Maximum resident set size \(kbytes\):\s*([0-9.]+)/u);
maxRssMb = maxRssKb == null ? null : maxRssKb / 1024;
} else if (mode === "bsd") {
userSeconds = parseFirstFloat(stderr, /[0-9.]+\s+real\s+([0-9.]+)\s+user/u);
systemSeconds = parseFirstFloat(stderr, /([0-9.]+)\s+sys/u);
const maxRssBytes = parseFirstFloat(stderr, /([0-9]+)\s+maximum resident set size/u);
maxRssMb = maxRssBytes == null ? null : maxRssBytes / 1024 / 1024;
}
const cpuMs =
userSeconds == null && systemSeconds == null
? null
: ((userSeconds ?? 0) + (systemSeconds ?? 0)) * 1000;
return {
wallMs,
cpuMs,
cpuCoreRatio: cpuMs == null || wallMs <= 0 ? null : cpuMs / wallMs,
maxRssMb,
};
}
function parseFirstFloat(value, pattern) {
const match = value.match(pattern);
if (!match) {
return null;
}
const parsed = Number(match[1]);
return Number.isFinite(parsed) ? parsed : null;
}
function stripAnsi(value) {
return value.replace(ANSI_PATTERN, "");
}
function writeCommandLog(params) {
const { logDir, label, stdout, stderr } = params;
fs.mkdirSync(logDir, { recursive: true });
const safeLabel = label.replace(/[^a-zA-Z0-9_.-]+/gu, "_");
const logPath = path.join(logDir, `${safeLabel}.log`);
fs.writeFileSync(
logPath,
[`$ ${params.command.join(" ")}`, "", stripAnsi(stdout), stripAnsi(stderr)].join("\n"),
"utf8",
);
return logPath;
}
function runMeasuredCommand(params) {
const { command, args, mode } = timeWrapperArgs(params.command, params.args);
const started = performance.now();
const result = spawnSync(command, args, {
cwd: params.cwd,
env: params.env,
encoding: "utf8",
timeout: params.timeoutMs,
maxBuffer: 16 * 1024 * 1024,
});
const wallMs = performance.now() - started;
const status = result.status ?? (result.signal ? 1 : 0);
const stdout = result.stdout ?? "";
const stderr = result.stderr ?? "";
const logPath = writeCommandLog({
logDir: params.logDir,
label: params.label,
command: [params.command, ...params.args],
stdout,
stderr,
});
return {
label: params.label,
phase: params.phase,
pluginId: params.pluginId ?? null,
status,
signal: result.signal ?? null,
timedOut: result.error?.code === "ETIMEDOUT",
logPath,
...parseTimedMetrics(stderr, wallMs, mode),
};
}
function runPluginLifecycle(params) {
for (const plugin of params.plugins) {
const commands = [
{
phase: "install",
args: ["install", plugin.dir, "--link", "--dangerously-force-unsafe-install"],
},
{ phase: "inspect", args: ["inspect", plugin.id, "--json"] },
{ phase: "disable", args: ["disable", plugin.id] },
{ phase: "enable", args: ["enable", plugin.id] },
{ phase: "doctor", args: ["doctor"] },
{ phase: "uninstall", args: ["uninstall", plugin.id, "--force"] },
];
for (const { phase, args } of commands) {
process.stderr.write(`[plugin-gauntlet] ${plugin.id} ${phase}\n`);
params.rows.push(
runMeasuredCommand({
cwd: params.repoRoot,
env: params.env,
logDir: path.join(params.outputDir, "logs", "lifecycle"),
...openclawCommand(params.repoRoot, ["plugins", ...args]),
label: `${plugin.id}-${phase}`,
phase: `lifecycle:${phase}`,
pluginId: plugin.id,
timeoutMs: params.commandTimeoutMs,
}),
);
}
}
}
function runSlashHelpProbes(params) {
for (const plugin of params.plugins) {
for (const alias of plugin.cliCommandAliases) {
const command = alias.cliCommand ?? alias.name;
process.stderr.write(`[plugin-gauntlet] ${plugin.id} slash-help /${alias.name}\n`);
params.rows.push(
runMeasuredCommand({
cwd: params.repoRoot,
env: params.env,
logDir: path.join(params.outputDir, "logs", "slash-help"),
...openclawCommand(params.repoRoot, [command, "--help"]),
label: `${plugin.id}-slash-${alias.name}`,
phase: "slash:help",
pluginId: plugin.id,
timeoutMs: params.commandTimeoutMs,
}),
);
}
}
}
function runQaChunks(params) {
const chunks = [
...(params.qaBaseline ? [{ label: "baseline", plugins: [] }] : []),
...chunkArray(params.plugins, params.qaPluginChunkSize).map((plugins, index) => ({
label: `chunk-${String(index).padStart(2, "0")}`,
plugins,
})),
];
const summaries = [];
for (let index = 0; index < chunks.length; index += 1) {
const chunk = chunks[index];
const outputDir = path.join(params.outputDir, "qa-suite", chunk.label);
const outputArg = toRepoRelativePath(params.repoRoot, outputDir);
const pluginIds = chunk.plugins.map((plugin) => plugin.id);
const pluginIdLabel = pluginIds.length > 0 ? pluginIds.join(",") : "<baseline>";
process.stderr.write(
`[plugin-gauntlet] qa chunk ${index + 1}/${chunks.length}: ${pluginIdLabel}\n`,
);
const row = runMeasuredCommand({
cwd: params.repoRoot,
env: params.env,
logDir: path.join(params.outputDir, "logs", "qa-suite"),
...sourceOpenclawCommand(params.repoRoot, [
"qa",
"suite",
"--provider-mode",
"mock-openai",
"--concurrency",
"1",
"--output-dir",
outputArg,
...params.qaScenarios.flatMap((scenario) => ["--scenario", scenario]),
...pluginIds.flatMap((pluginId) => ["--enable-plugin", pluginId]),
]),
label: `qa-${chunk.label}`,
phase: "qa:rpc",
timeoutMs: params.qaTimeoutMs,
});
const summaryPath = path.join(outputDir, "qa-suite-summary.json");
const qaSummary = fs.existsSync(summaryPath)
? JSON.parse(fs.readFileSync(summaryPath, "utf8"))
: null;
params.rows.push({
...row,
pluginId: pluginIdLabel,
...(qaSummary?.metrics ? { qaMetrics: qaSummary.metrics } : {}),
});
if (fs.existsSync(summaryPath)) {
summaries.push(qaSummary);
}
}
return summaries;
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const repoRoot = path.resolve(options.repoRoot);
fs.mkdirSync(options.outputDir, { recursive: true });
const runRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-gauntlet-"));
const env = createIsolatedEnv(repoRoot, runRoot);
const matrix = discoverBundledPluginManifests(repoRoot);
const selectedPlugins = selectPluginEntries(matrix, {
ids: options.pluginIds,
shardTotal: options.shardTotal,
shardIndex: options.shardIndex,
limit: options.limit,
});
const rows = [];
if (!options.skipPrebuild && (selectedPlugins.length > 0 || !options.skipQa)) {
process.stderr.write("[plugin-gauntlet] prebuild\n");
rows.push(
runMeasuredCommand({
cwd: repoRoot,
env: buildGauntletPrebuildEnv(env, { includePrivateQa: !options.skipQa }),
logDir: path.join(options.outputDir, "logs", "prebuild"),
command: pnpmCommand(),
args: ["build"],
label: "prebuild",
phase: "prebuild",
timeoutMs: options.buildTimeoutMs,
}),
);
}
const prebuildFailed = rows.some(
(row) => row.phase === "prebuild" && (row.status !== 0 || row.timedOut),
);
if (!prebuildFailed && !options.skipLifecycle) {
runPluginLifecycle({
repoRoot,
outputDir: options.outputDir,
env,
plugins: selectedPlugins,
rows,
commandTimeoutMs: options.commandTimeoutMs,
});
}
if (!prebuildFailed && !options.skipSlashHelp) {
runSlashHelpProbes({
repoRoot,
outputDir: options.outputDir,
env,
plugins: selectedPlugins,
rows,
commandTimeoutMs: options.commandTimeoutMs,
});
}
const qaSummaries =
options.skipQa || prebuildFailed
? []
: runQaChunks({
repoRoot,
outputDir: options.outputDir,
env,
plugins: selectedPlugins,
qaBaseline: options.qaBaseline,
rows,
qaScenarios: options.qaScenarios,
qaPluginChunkSize: options.qaPluginChunkSize,
qaTimeoutMs: options.qaTimeoutMs,
});
const metricObservations = collectMetricObservations(rows, {
cpuCoreWarn: options.cpuCoreWarn,
hotWallWarnMs: options.hotWallWarnMs,
maxRssWarnMb: options.maxRssWarnMb,
wallAnomalyMultiplier: options.wallAnomalyMultiplier,
rssAnomalyMultiplier: options.rssAnomalyMultiplier,
});
const qaBaselineObservations = collectQaBaselineRegressionObservations(rows, {
cpuRegressionMultiplier: options.qaCpuRegressionMultiplier,
wallRegressionMultiplier: options.qaWallRegressionMultiplier,
});
const gatewayObservations = qaSummaries.flatMap((qa) =>
collectGatewayCpuObservations({
startup: null,
qa,
cpuCoreWarn: options.cpuCoreWarn,
hotWallWarnMs: options.hotWallWarnMs,
}),
);
const failures = rows.filter((row) => row.status !== 0 || row.timedOut);
const summary = {
generatedAt: new Date().toISOString(),
repoRoot,
outputDir: options.outputDir,
isolatedRunRoot: runRoot,
selectedPluginCount: selectedPlugins.length,
totalPluginCount: matrix.length,
options: {
pluginIds: options.pluginIds,
shardTotal: options.shardTotal,
shardIndex: options.shardIndex,
limit: options.limit ?? null,
qaScenarios: options.qaScenarios,
qaPluginChunkSize: options.qaPluginChunkSize,
qaBaseline: options.qaBaseline,
skipLifecycle: options.skipLifecycle,
skipQa: options.skipQa,
skipSlashHelp: options.skipSlashHelp,
skipPrebuild: options.skipPrebuild,
thresholds: {
cpuCoreWarn: options.cpuCoreWarn,
hotWallWarnMs: options.hotWallWarnMs,
maxRssWarnMb: options.maxRssWarnMb,
wallAnomalyMultiplier: options.wallAnomalyMultiplier,
rssAnomalyMultiplier: options.rssAnomalyMultiplier,
qaCpuRegressionMultiplier: options.qaCpuRegressionMultiplier,
qaWallRegressionMultiplier: options.qaWallRegressionMultiplier,
},
},
matrix,
selectedPlugins,
rows,
observations: [...metricObservations, ...qaBaselineObservations, ...gatewayObservations],
failures,
};
const summaryPath = path.join(options.outputDir, "plugin-gateway-gauntlet-summary.json");
fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
process.stdout.write(`[plugin-gauntlet] summary: ${summaryPath}\n`);
process.stdout.write(
`[plugin-gauntlet] plugins=${selectedPlugins.length}/${matrix.length} rows=${rows.length} failures=${failures.length} observations=${summary.observations.length}\n`,
);
for (const failure of failures) {
process.stdout.write(
`[plugin-gauntlet] failure phase=${failure.phase} plugin=${failure.pluginId ?? "<none>"} status=${failure.status} timedOut=${failure.timedOut} wallMs=${Math.round(failure.wallMs)} log=${failure.logPath}\n`,
);
}
for (const observation of summary.observations.slice(0, 20)) {
process.stdout.write(`[plugin-gauntlet] observation ${JSON.stringify(observation)}\n`);
}
if (failures.length > 0) {
process.exitCode = 1;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});

View File

@@ -20,6 +20,7 @@ COPY packages ./packages
COPY extensions ./extensions
COPY patches ./patches
COPY scripts/postinstall-bundled-plugins.mjs scripts/preinstall-package-manager-warning.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
COPY scripts/lib/package-dist-imports.mjs ./scripts/lib/package-dist-imports.mjs
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
corepack enable \
&& if ! pnpm install --frozen-lockfile >/tmp/openclaw-cleanup-pnpm-install.log 2>&1; then \

View File

@@ -109,6 +109,13 @@ run_with_heartbeat() {
return "$status"
}
is_self_swapped_package_process_exit() {
local stderr="$1"
[[ "$stderr" == *"[openclaw] Failed to start CLI:"* ]] &&
[[ "$stderr" == *"ERR_MODULE_NOT_FOUND"* ]] &&
[[ "$stderr" == *"/node_modules/openclaw/dist/"* ]]
}
npm_install_global() {
local label="$1"
shift
@@ -262,8 +269,12 @@ run_update_smoke() {
printf "%s\n" "$update_stderr" >&2
fi
if [[ "$update_status" -ne 0 ]]; then
echo "ERROR: openclaw update failed with exit code $update_status" >&2
return "$update_status"
if is_self_swapped_package_process_exit "$update_stderr"; then
echo "WARN: legacy updater process exited after self-swap; validating update JSON and installed CLI" >&2
else
echo "ERROR: openclaw update failed with exit code $update_status" >&2
return "$update_status"
fi
fi
UPDATE_JSON="$UPDATE_JSON" \

View File

@@ -41,6 +41,7 @@ EOF
echo "Building Docker image: $IMAGE_NAME"
docker_build_run browser-cdp-snapshot-build -t "$IMAGE_NAME" -f "$build_dir/Dockerfile" "$build_dir"
fi
docker_e2e_harness_mount_args
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 browser-cdp-snapshot empty)"
echo "Starting browser CDP snapshot container..."
@@ -55,19 +56,13 @@ docker_cmd docker run -d \
-e OPENCLAW_SKIP_CRON=1 \
-e OPENCLAW_SKIP_CANVAS_HOST=1 \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
"$IMAGE_NAME" \
bash -lc "set -euo pipefail
eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\"
{
printf 'export HOME=%q\n' \"\$HOME\"
printf 'export OPENCLAW_HOME=%q\n' \"\$OPENCLAW_HOME\"
printf 'export OPENCLAW_STATE_DIR=%q\n' \"\$OPENCLAW_STATE_DIR\"
printf 'export OPENCLAW_CONFIG_PATH=%q\n' \"\$OPENCLAW_CONFIG_PATH\"
printf 'export OPENCLAW_AGENT_DIR=%q\n' \"\${OPENCLAW_AGENT_DIR-}\"
printf 'export PI_CODING_AGENT_DIR=%q\n' \"\${PI_CODING_AGENT_DIR-}\"
} >/tmp/openclaw-test-state-env
entry=dist/index.mjs
[ -f \"\$entry\" ] || entry=dist/index.js
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\"
openclaw_e2e_write_state_env
entry=\"\$(openclaw_e2e_resolve_entrypoint)\"
mkdir -p /tmp/openclaw-browser-cdp/chrome
find dist -maxdepth 1 -type f -name 'pw-ai-*.js' ! -name 'pw-ai-state-*' -exec mv {} /tmp/openclaw-browser-cdp/ \;
cat > \"\$OPENCLAW_CONFIG_PATH\" <<'JSON'
@@ -120,7 +115,7 @@ http
})
.listen($FIXTURE_PORT, '127.0.0.1');
NODE
node \"\$entry\" gateway --port $PORT --bind loopback --allow-unconfigured >/tmp/browser-cdp-gateway.log 2>&1" >/dev/null
openclaw_e2e_exec_gateway \"\$entry\" $PORT loopback /tmp/browser-cdp-gateway.log" >/dev/null
echo "Waiting for Chromium and Gateway..."
ready=0
@@ -129,14 +124,9 @@ for _ in $(seq 1 180); do
break
fi
if docker_cmd docker exec "$CONTAINER_NAME" bash -lc "
node --input-type=module -e 'const res = await fetch(\"http://127.0.0.1:$CDP_PORT/json/version\"); if (!res.ok) process.exit(1);' >/dev/null &&
node --input-type=module -e '
import net from \"node:net\";
const socket = net.createConnection({ host: \"127.0.0.1\", port: $PORT });
const timeout = setTimeout(() => { socket.destroy(); process.exit(1); }, 400);
socket.on(\"connect\", () => { clearTimeout(timeout); socket.end(); process.exit(0); });
socket.on(\"error\", () => { clearTimeout(timeout); process.exit(1); });
' >/dev/null
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_probe_http_status http://127.0.0.1:$CDP_PORT/json/version
openclaw_e2e_probe_tcp 127.0.0.1 $PORT
" >/dev/null 2>&1; then
ready=1
break
@@ -155,8 +145,8 @@ echo "Running browser CDP snapshot smoke..."
docker_cmd docker exec "$CONTAINER_NAME" bash -lc "
set -euo pipefail
source /tmp/openclaw-test-state-env
entry=dist/index.mjs
[ -f \"\$entry\" ] || entry=dist/index.js
source scripts/lib/openclaw-e2e-instance.sh
entry=\"\$(openclaw_e2e_resolve_entrypoint)\"
base_args=(--url ws://127.0.0.1:$PORT --token '$TOKEN')
node \"\$entry\" browser \"\${base_args[@]}\" --browser-profile docker-cdp doctor --deep >/tmp/browser-cdp-doctor.txt
grep -q 'OK live-snapshot' /tmp/browser-cdp-doctor.txt

View File

@@ -16,6 +16,7 @@ cleanup() {
trap cleanup EXIT
docker_e2e_build_or_reuse "$IMAGE_NAME" config-reload "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD"
docker_e2e_harness_mount_args
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 config-reload empty)"
echo "Starting gateway container..."
@@ -29,19 +30,13 @@ docker run -d \
-e OPENCLAW_SKIP_CRON=1 \
-e OPENCLAW_SKIP_CANVAS_HOST=1 \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
"$IMAGE_NAME" \
bash -lc "set -euo pipefail
eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\"
{
printf 'export HOME=%q\n' \"\$HOME\"
printf 'export OPENCLAW_HOME=%q\n' \"\$OPENCLAW_HOME\"
printf 'export OPENCLAW_STATE_DIR=%q\n' \"\$OPENCLAW_STATE_DIR\"
printf 'export OPENCLAW_CONFIG_PATH=%q\n' \"\$OPENCLAW_CONFIG_PATH\"
printf 'export OPENCLAW_AGENT_DIR=%q\n' \"\${OPENCLAW_AGENT_DIR-}\"
printf 'export PI_CODING_AGENT_DIR=%q\n' \"\${PI_CODING_AGENT_DIR-}\"
} >/tmp/openclaw-test-state-env
entry=dist/index.mjs
[ -f \"\$entry\" ] || entry=dist/index.js
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\"
openclaw_e2e_write_state_env
entry=\"\$(openclaw_e2e_resolve_entrypoint)\"
cat > \"\$OPENCLAW_CONFIG_PATH\" <<'JSON'
{
\"gateway\": {
@@ -65,7 +60,7 @@ cat > \"\$OPENCLAW_CONFIG_PATH\" <<'JSON'
}
}
JSON
node \"\$entry\" gateway --port $PORT --bind loopback --allow-unconfigured > /tmp/config-reload-e2e.log 2>&1" >/dev/null
openclaw_e2e_exec_gateway \"\$entry\" $PORT loopback /tmp/config-reload-e2e.log" >/dev/null
echo "Waiting for gateway..."
ready=0
@@ -73,23 +68,7 @@ for _ in $(seq 1 180); do
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || echo false)" != "true" ]; then
break
fi
if docker exec "$CONTAINER_NAME" bash -lc "node --input-type=module -e '
import net from \"node:net\";
const socket = net.createConnection({ host: \"127.0.0.1\", port: $PORT });
const timeout = setTimeout(() => {
socket.destroy();
process.exit(1);
}, 400);
socket.on(\"connect\", () => {
clearTimeout(timeout);
socket.end();
process.exit(0);
});
socket.on(\"error\", () => {
clearTimeout(timeout);
process.exit(1);
});
' >/dev/null 2>&1"; then
if docker exec "$CONTAINER_NAME" bash -lc "source scripts/lib/openclaw-e2e-instance.sh; openclaw_e2e_probe_tcp 127.0.0.1 $PORT" >/dev/null 2>&1; then
ready=1
break
fi
@@ -106,8 +85,8 @@ fi
echo "Checking initial RPC status..."
docker exec "$CONTAINER_NAME" bash -lc "
source /tmp/openclaw-test-state-env
entry=dist/index.mjs
[ -f \"\$entry\" ] || entry=dist/index.js
source scripts/lib/openclaw-e2e-instance.sh
entry=\"\$(openclaw_e2e_resolve_entrypoint)\"
node \"\$entry\" gateway status --url ws://127.0.0.1:$PORT --token '$TOKEN' --require-rpc --timeout 30000 >/tmp/config-reload-status-before.log
"
@@ -133,8 +112,8 @@ fi
echo "Checking post-write RPC status..."
docker exec "$CONTAINER_NAME" bash -lc "
source /tmp/openclaw-test-state-env
entry=dist/index.mjs
[ -f \"\$entry\" ] || entry=dist/index.js
source scripts/lib/openclaw-e2e-instance.sh
entry=\"\$(openclaw_e2e_resolve_entrypoint)\"
node \"\$entry\" gateway status --url ws://127.0.0.1:$PORT --token '$TOKEN' --require-rpc --timeout 30000 >/tmp/config-reload-status-after.log
"

View File

@@ -40,68 +40,37 @@ docker run --rm \
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
"$IMAGE_NAME" \
bash -lc "set -euo pipefail
eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\"
entry=dist/index.mjs
[ -f \"\$entry\" ] || entry=dist/index.js
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\"
entry=\"\$(openclaw_e2e_resolve_entrypoint)\"
export MOCK_PORT=44081
export SUCCESS_MARKER=OPENCLAW_CRON_MCP_CLEANUP_OK
export MOCK_REQUEST_LOG=/tmp/openclaw-cron-mock-openai-requests.jsonl
export OPENCLAW_DOCKER_OPENAI_BASE_URL=\"http://127.0.0.1:\$MOCK_PORT/v1\"
node scripts/e2e/mock-openai-server.mjs >/tmp/cron-mcp-cleanup-mock-openai.log 2>&1 &
mock_pid=\$!
tsx scripts/e2e/cron-mcp-cleanup-seed.ts >/tmp/cron-mcp-cleanup-seed.log
node \"\$entry\" gateway --port $PORT --bind loopback --allow-unconfigured >/tmp/cron-mcp-cleanup-gateway.log 2>&1 &
gateway_pid=\$!
stop_process() {
pid=\"\$1\"
kill \"\$pid\" >/dev/null 2>&1 || true
for _ in \$(seq 1 40); do
if ! kill -0 \"\$pid\" >/dev/null 2>&1; then
wait \"\$pid\" >/dev/null 2>&1 || true
return
fi
sleep 0.25
done
kill -9 \"\$pid\" >/dev/null 2>&1 || true
wait \"\$pid\" >/dev/null 2>&1 || true
}
mock_pid=\"\$(openclaw_e2e_start_mock_openai \"\$MOCK_PORT\" /tmp/cron-mcp-cleanup-mock-openai.log)\"
gateway_pid=
cleanup_inner() {
stop_process \"\$mock_pid\"
stop_process \"\$gateway_pid\"
openclaw_e2e_stop_process \"\${gateway_pid:-}\"
openclaw_e2e_stop_process \"\${mock_pid:-}\"
}
dump_gateway_log_on_error() {
status=\$?
if [ \"\$status\" -ne 0 ]; then
tail -n 80 /tmp/cron-mcp-cleanup-gateway.log 2>/dev/null || true
cat /tmp/cron-mcp-cleanup-seed.log 2>/dev/null || true
cat /tmp/cron-mcp-cleanup-mock-openai.log 2>/dev/null || true
cat \"\$MOCK_REQUEST_LOG\" 2>/dev/null || true
openclaw_e2e_dump_logs \
/tmp/cron-mcp-cleanup-gateway.log \
/tmp/cron-mcp-cleanup-seed.log \
/tmp/cron-mcp-cleanup-mock-openai.log \
\"\$MOCK_REQUEST_LOG\"
fi
cleanup_inner
exit \"\$status\"
}
trap cleanup_inner EXIT
trap dump_gateway_log_on_error ERR
for _ in \$(seq 1 80); do
if node -e \"fetch('http://127.0.0.1:' + process.env.MOCK_PORT + '/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"; then
break
fi
sleep 0.1
done
node -e \"fetch('http://127.0.0.1:' + process.env.MOCK_PORT + '/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"
gateway_ready=0
for _ in \$(seq 1 300); do
if grep -q '\[gateway\] ready' /tmp/cron-mcp-cleanup-gateway.log 2>/dev/null; then
gateway_ready=1
break
fi
sleep 0.25
done
if [ \"\$gateway_ready\" -ne 1 ]; then
echo \"Gateway did not become ready\"
tail -n 120 /tmp/cron-mcp-cleanup-gateway.log 2>/dev/null || true
exit 1
fi
openclaw_e2e_wait_mock_openai \"\$MOCK_PORT\"
tsx scripts/e2e/cron-mcp-cleanup-seed.ts >/tmp/cron-mcp-cleanup-seed.log
gateway_pid=\"\$(openclaw_e2e_start_gateway \"\$entry\" $PORT /tmp/cron-mcp-cleanup-gateway.log)\"
openclaw_e2e_wait_gateway_ready \"\$gateway_pid\" /tmp/cron-mcp-cleanup-gateway.log 300
tsx scripts/e2e/cron-mcp-cleanup-docker-client.ts
" >"$CLIENT_LOG" 2>&1
status=${PIPESTATUS[0]}

View File

@@ -24,6 +24,7 @@ cleanup() {
trap cleanup EXIT
docker_e2e_build_or_reuse "$IMAGE_NAME" gateway-network "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD"
docker_e2e_harness_mount_args
echo "Creating Docker network..."
docker_cmd docker network create "$NET_NAME" >/dev/null
@@ -37,8 +38,9 @@ docker_cmd docker run -d \
-e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \
-e "OPENCLAW_SKIP_CRON=1" \
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
"$IMAGE_NAME" \
bash -lc "set -euo pipefail; entry=dist/index.mjs; [ -f \"\$entry\" ] || entry=dist/index.js; node \"\$entry\" config set gateway.controlUi.enabled false >/dev/null; node \"\$entry\" gateway --port $PORT --bind lan --allow-unconfigured > /tmp/gateway-net-e2e.log 2>&1" >/dev/null
bash -lc "set -euo pipefail; source scripts/lib/openclaw-e2e-instance.sh; entry=\"\$(openclaw_e2e_resolve_entrypoint)\"; node \"\$entry\" config set gateway.controlUi.enabled false >/dev/null; openclaw_e2e_exec_gateway \"\$entry\" $PORT lan /tmp/gateway-net-e2e.log" >/dev/null
echo "Waiting for gateway to come up..."
ready=0
@@ -46,27 +48,7 @@ for _ in $(seq 1 180); do
if [ "$(docker_cmd docker inspect -f '{{.State.Running}}' "$GW_NAME" 2>/dev/null || echo false)" != "true" ]; then
break
fi
if docker_cmd docker exec "$GW_NAME" bash -lc "node --input-type=module -e '
import net from \"node:net\";
const socket = net.createConnection({ host: \"127.0.0.1\", port: $PORT });
const timeout = setTimeout(() => {
socket.destroy();
process.exit(1);
}, 400);
socket.on(\"connect\", () => {
clearTimeout(timeout);
socket.end();
process.exit(0);
});
socket.on(\"error\", () => {
clearTimeout(timeout);
process.exit(1);
});
' >/dev/null 2>&1"; then
ready=1
break
fi
if docker_cmd docker exec "$GW_NAME" bash -lc "grep -q \"listening on ws://\" /tmp/gateway-net-e2e.log 2>/dev/null"; then
if docker_cmd docker exec "$GW_NAME" bash -lc "source scripts/lib/openclaw-e2e-instance.sh; openclaw_e2e_probe_tcp 127.0.0.1 $PORT || grep -q \"listening on ws://\" /tmp/gateway-net-e2e.log 2>/dev/null"; then
ready=1
break
fi

View File

@@ -0,0 +1,771 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-kitchen-sink-plugin-e2e" OPENCLAW_KITCHEN_SINK_PLUGIN_E2E_IMAGE)"
docker_e2e_build_or_reuse "$IMAGE_NAME" kitchen-sink-plugin
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 kitchen-sink-plugin empty)"
DEFAULT_KITCHEN_SINK_SCENARIOS="$(cat <<'SCENARIOS'
npm-latest|npm:@openclaw/kitchen-sink@latest|openclaw-kitchen-sink-fixture|npm|success|full
npm-beta|npm:@openclaw/kitchen-sink@beta|openclaw-kitchen-sink-fixture|npm|failure|none
clawhub-latest|clawhub:openclaw-kitchen-sink@latest|openclaw-kitchen-sink-fixture|clawhub|success|basic
clawhub-beta|clawhub:openclaw-kitchen-sink@beta|openclaw-kitchen-sink-fixture|clawhub|failure|none
SCENARIOS
)"
KITCHEN_SINK_SCENARIOS="${OPENCLAW_KITCHEN_SINK_PLUGIN_SCENARIOS:-$DEFAULT_KITCHEN_SINK_SCENARIOS}"
MAX_MEMORY_MIB="${OPENCLAW_KITCHEN_SINK_MAX_MEMORY_MIB:-2048}"
MAX_CPU_PERCENT="${OPENCLAW_KITCHEN_SINK_MAX_CPU_PERCENT:-1200}"
CONTAINER_NAME="openclaw-kitchen-sink-plugin-e2e-$$"
RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-kitchen-sink-plugin.XXXXXX")"
STATS_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-kitchen-sink-plugin-stats.XXXXXX")"
SCRIPT_FILE="$(mktemp "${TMPDIR:-/tmp}/openclaw-kitchen-sink-plugin-script.XXXXXX")"
cleanup() {
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
rm -f "$SCRIPT_FILE"
}
trap cleanup EXIT
cat > "$SCRIPT_FILE" <<'EOF'
set -euo pipefail
if [ -f dist/index.mjs ]; then
OPENCLAW_ENTRY="dist/index.mjs"
elif [ -f dist/index.js ]; then
OPENCLAW_ENTRY="dist/index.js"
else
echo "Missing dist/index.(m)js (build output):"
ls -la dist || true
exit 1
fi
export OPENCLAW_ENTRY
eval "$(printf "%s" "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" | base64 -d)"
run_logged() {
local label="$1"
shift
local log_file="/tmp/openclaw-kitchen-sink-${label}.log"
if ! "$@" >"$log_file" 2>&1; then
cat "$log_file"
exit 1
fi
cat "$log_file"
}
run_expect_failure() {
local label="$1"
shift
local output_file="/tmp/kitchen-sink-expected-failure-${label}.txt"
set +e
"$@" >"$output_file" 2>&1
local status="$?"
set -e
cat "$output_file"
if [ "$status" -eq 0 ]; then
echo "Expected ${label} to fail, but it succeeded." >&2
exit 1
fi
node - "$output_file" <<'NODE'
const fs = require("node:fs");
const output = fs.readFileSync(process.argv[2], "utf8");
const source = process.env.KITCHEN_SINK_SOURCE;
const spec = process.env.KITCHEN_SINK_SPEC;
const displayedSpec = source === "npm" ? spec.replace(/^npm:/u, "") : spec;
const expected =
source === "clawhub"
? /Version not found on ClawHub|ClawHub .* failed \(404\)|version.*not found/iu
: /No matching version|ETARGET|notarget|npm (?:error|ERR!)/iu;
if (!output.includes(displayedSpec)) {
throw new Error(`expected failure output to mention ${displayedSpec}`);
}
if (!expected.test(output)) {
throw new Error(`unexpected ${source} beta failure output:\n${output}`);
}
console.log("ok");
NODE
}
start_kitchen_sink_clawhub_fixture_server() {
local fixture_dir="$1"
local server_log="$fixture_dir/clawhub-fixture.log"
local server_port_file="$fixture_dir/clawhub-fixture-port"
local server_pid_file="$fixture_dir/clawhub-fixture-pid"
node - <<'NODE' "$server_port_file" >"$server_log" 2>&1 &
const crypto = require("node:crypto");
const http = require("node:http");
const path = require("node:path");
const { createRequire } = require("node:module");
const portFile = process.argv[2];
const requireFromApp = createRequire(path.join(process.cwd(), "package.json"));
const JSZip = requireFromApp("jszip");
const packageName = "openclaw-kitchen-sink";
const pluginId = "openclaw-kitchen-sink-fixture";
const version = "0.1.3";
async function main() {
const zip = new JSZip();
zip.file(
"package/package.json",
`${JSON.stringify(
{
name: packageName,
version,
openclaw: { extensions: ["./index.js"] },
},
null,
2,
)}\n`,
{ date: new Date(0) },
);
zip.file(
"package/index.js",
`module.exports = {
id: "${pluginId}",
name: "OpenClaw Kitchen Sink",
register(api) {
api.registerProvider({
id: "kitchen-sink-provider",
label: "Kitchen Sink Provider",
docsPath: "/providers/kitchen-sink",
auth: [],
});
api.registerChannel({
plugin: {
id: "kitchen-sink-channel",
meta: {
id: "kitchen-sink-channel",
label: "Kitchen Sink Channel",
selectionLabel: "Kitchen Sink",
docsPath: "/channels/kitchen-sink",
blurb: "Kitchen sink ClawHub fixture channel",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({ accountId: "default" }),
},
outbound: { deliveryMode: "direct" },
},
});
},
};
`,
{ date: new Date(0) },
);
zip.file(
"package/openclaw.plugin.json",
`${JSON.stringify(
{
id: pluginId,
name: "OpenClaw Kitchen Sink",
channels: ["kitchen-sink-channel"],
providers: ["kitchen-sink-provider"],
configSchema: {
type: "object",
properties: {},
},
},
null,
2,
)}\n`,
{ date: new Date(0) },
);
const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
const sha256hash = crypto.createHash("sha256").update(archive).digest("hex");
const packageDetail = {
package: {
name: packageName,
displayName: "OpenClaw Kitchen Sink",
family: "code-plugin",
runtimeId: pluginId,
channel: "official",
isOfficial: true,
summary: "Kitchen sink plugin fixture for prerelease CI.",
ownerHandle: "openclaw",
createdAt: 0,
updatedAt: 0,
latestVersion: version,
tags: { latest: version },
capabilityTags: ["test-fixture"],
executesCode: true,
compatibility: {
pluginApiRange: ">=2026.4.11",
minGatewayVersion: "2026.4.11",
},
capabilities: {
executesCode: true,
runtimeId: pluginId,
capabilityTags: ["test-fixture"],
channels: ["kitchen-sink-channel"],
providers: ["kitchen-sink-provider"],
},
verification: {
tier: "source-linked",
sourceRepo: "https://github.com/openclaw/kitchen-sink",
hasProvenance: false,
scanStatus: "passed",
},
},
};
const versionDetail = {
package: {
name: packageName,
displayName: "OpenClaw Kitchen Sink",
family: "code-plugin",
},
version: {
version,
createdAt: 0,
changelog: "Fixture package for kitchen-sink plugin prerelease CI.",
distTags: ["latest"],
sha256hash,
compatibility: packageDetail.package.compatibility,
capabilities: packageDetail.package.capabilities,
verification: packageDetail.package.verification,
},
};
const json = (response, value, status = 200) => {
response.writeHead(status, { "content-type": "application/json" });
response.end(`${JSON.stringify(value)}\n`);
};
const server = http.createServer((request, response) => {
const url = new URL(request.url, "http://127.0.0.1");
if (request.method !== "GET") {
response.writeHead(405);
response.end("method not allowed");
return;
}
if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}`) {
json(response, packageDetail);
return;
}
if (
url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/${version}`
) {
json(response, versionDetail);
return;
}
if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/beta`) {
json(response, { error: "version not found" }, 404);
return;
}
if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/download`) {
response.writeHead(200, {
"content-type": "application/zip",
"content-length": String(archive.length),
});
response.end(archive);
return;
}
response.writeHead(404, { "content-type": "text/plain" });
response.end(`not found: ${url.pathname}`);
});
server.listen(0, "127.0.0.1", () => {
require("node:fs").writeFileSync(portFile, String(server.address().port));
});
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
NODE
local server_pid="$!"
echo "$server_pid" > "$server_pid_file"
for _ in $(seq 1 100); do
if [[ -s "$server_port_file" ]]; then
export OPENCLAW_CLAWHUB_URL="http://127.0.0.1:$(cat "$server_port_file")"
trap 'if [[ -f "'"$server_pid_file"'" ]]; then kill "$(cat "'"$server_pid_file"'")" 2>/dev/null || true; fi' EXIT
return 0
fi
if ! kill -0 "$server_pid" 2>/dev/null; then
cat "$server_log"
return 1
fi
sleep 0.1
done
cat "$server_log"
echo "Timed out waiting for kitchen-sink ClawHub fixture server." >&2
return 1
}
scan_logs_for_unexpected_errors() {
node - <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const roots = ["/tmp", path.join(process.env.HOME, ".openclaw")];
const files = [];
const visit = (entry) => {
if (!fs.existsSync(entry)) {
return;
}
const stat = fs.statSync(entry);
if (stat.isDirectory()) {
for (const child of fs.readdirSync(entry)) {
visit(path.join(entry, child));
}
return;
}
if (/\.(?:log|jsonl)$/u.test(entry) || /openclaw-kitchen-sink-/u.test(path.basename(entry))) {
if (entry.includes("/.npm/_logs/")) {
return;
}
files.push(entry);
}
};
for (const root of roots) {
visit(root);
}
const deny = [
/\buncaught exception\b/iu,
/\bunhandled rejection\b/iu,
/\bfatal\b/iu,
/\bpanic\b/iu,
/\blevel["']?\s*:\s*["']error["']/iu,
/\[(?:error|ERROR)\]/u,
];
const allow = [
/0 errors?/iu,
/expected no diagnostics errors?/iu,
/diagnostics errors?:\s*$/iu,
];
const findings = [];
for (const file of files) {
const text = fs.readFileSync(file, "utf8");
const lines = text.split(/\r?\n/u);
lines.forEach((line, index) => {
if (allow.some((pattern) => pattern.test(line))) {
return;
}
if (deny.some((pattern) => pattern.test(line))) {
findings.push(`${file}:${index + 1}: ${line}`);
}
});
}
if (findings.length > 0) {
throw new Error(`unexpected error-like log lines:\n${findings.join("\n")}`);
}
console.log(`log scan passed (${files.length} file(s))`);
NODE
}
configure_kitchen_sink_runtime() {
node - <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const pluginId = process.env.KITCHEN_SINK_ID;
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {};
config.plugins = config.plugins || {};
config.plugins.entries = config.plugins.entries || {};
config.plugins.entries[pluginId] = {
...(config.plugins.entries[pluginId] || {}),
hooks: {
...(config.plugins.entries[pluginId]?.hooks || {}),
allowConversationAccess: true,
},
};
config.channels = {
...(config.channels || {}),
"kitchen-sink-channel": { enabled: true, token: "kitchen-sink-ci" },
};
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
NODE
}
remove_kitchen_sink_channel_config() {
node - <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
delete config.channels?.["kitchen-sink-channel"];
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
}
NODE
}
assert_kitchen_sink_installed() {
node - <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const pluginId = process.env.KITCHEN_SINK_ID;
const spec = process.env.KITCHEN_SINK_SPEC;
const source = process.env.KITCHEN_SINK_SOURCE;
const surfaceMode = process.env.KITCHEN_SINK_SURFACE_MODE;
const label = process.env.KITCHEN_SINK_LABEL;
const list = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-plugins.json`, "utf8"));
const inspect = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-inspect.json`, "utf8"));
const allInspect = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-inspect-all.json`, "utf8"));
const plugin = (list.plugins || []).find((entry) => entry.id === pluginId);
if (!plugin) throw new Error(`kitchen-sink plugin not found after install: ${pluginId}`);
if (plugin.status !== "loaded") {
throw new Error(`unexpected kitchen-sink status after enable: ${plugin.status}`);
}
if (inspect.plugin?.id !== pluginId) {
throw new Error(`unexpected inspected kitchen-sink plugin id: ${inspect.plugin?.id}`);
}
if (inspect.plugin?.enabled !== true || inspect.plugin?.status !== "loaded") {
throw new Error(
`expected enabled loaded kitchen-sink plugin, got enabled=${inspect.plugin?.enabled} status=${inspect.plugin?.status}`,
);
}
const expectIncludes = (listValue, expected, field) => {
if (!Array.isArray(listValue) || !listValue.includes(expected)) {
throw new Error(`${field} missing ${expected}: ${JSON.stringify(listValue)}`);
}
};
expectIncludes(inspect.plugin?.channelIds, "kitchen-sink-channel", "channels");
expectIncludes(inspect.plugin?.providerIds, "kitchen-sink-provider", "providers");
const diagnostics = [
...(list.diagnostics || []),
...(inspect.diagnostics || []),
...(allInspect.diagnostics || []),
];
const errorMessages = new Set(
diagnostics
.filter((diag) => diag?.level === "error")
.map((diag) => String(diag.message || "")),
);
if (surfaceMode === "full") {
const toolNames = Array.isArray(inspect.tools)
? inspect.tools.flatMap((entry) => (Array.isArray(entry?.names) ? entry.names : []))
: [];
expectIncludes(inspect.plugin?.speechProviderIds, "kitchen-sink-speech-provider", "speech providers");
expectIncludes(
inspect.plugin?.realtimeTranscriptionProviderIds,
"kitchen-sink-realtime-transcription-provider",
"realtime transcription providers",
);
expectIncludes(
inspect.plugin?.realtimeVoiceProviderIds,
"kitchen-sink-realtime-voice-provider",
"realtime voice providers",
);
expectIncludes(
inspect.plugin?.mediaUnderstandingProviderIds,
"kitchen-sink-media-understanding-provider",
"media understanding providers",
);
expectIncludes(
inspect.plugin?.imageGenerationProviderIds,
"kitchen-sink-image-generation-provider",
"image generation providers",
);
expectIncludes(
inspect.plugin?.videoGenerationProviderIds,
"kitchen-sink-video-generation-provider",
"video generation providers",
);
expectIncludes(
inspect.plugin?.musicGenerationProviderIds,
"kitchen-sink-music-generation-provider",
"music generation providers",
);
expectIncludes(inspect.plugin?.webFetchProviderIds, "kitchen-sink-web-fetch-provider", "web fetch providers");
expectIncludes(inspect.plugin?.webSearchProviderIds, "kitchen-sink-web-search-provider", "web search providers");
expectIncludes(inspect.plugin?.migrationProviderIds, "kitchen-sink-migration-provider", "migration providers");
expectIncludes(inspect.plugin?.agentHarnessIds, "kitchen-sink-agent-harness", "agent harnesses");
expectIncludes(inspect.services, "kitchen-sink-service", "services");
expectIncludes(inspect.commands, "kitchen-sink-command", "commands");
expectIncludes(toolNames, "kitchen-sink-tool", "tools");
if ((inspect.plugin?.hookCount || 0) < 30 || !Array.isArray(inspect.typedHooks) || inspect.typedHooks.length < 30) {
throw new Error(
`expected kitchen-sink typed hooks to load, got hookCount=${inspect.plugin?.hookCount} typedHooks=${inspect.typedHooks?.length}`,
);
}
const expectedErrorMessages = new Set([
"only bundled plugins can register agent tool result middleware",
"cli registration missing explicit commands metadata",
"only bundled plugins can register Codex app-server extension factories",
"http route registration missing or invalid auth: /kitchen-sink/http-route",
"plugin must own memory slot or declare contracts.memoryEmbeddingProviders for adapter: kitchen-sink-memory-embedding-provider",
]);
for (const message of errorMessages) {
if (!expectedErrorMessages.has(message)) {
throw new Error(`unexpected kitchen-sink diagnostic error: ${message}`);
}
}
for (const message of expectedErrorMessages) {
if (!errorMessages.has(message)) {
throw new Error(`missing expected kitchen-sink diagnostic error: ${message}`);
}
}
} else if (errorMessages.size > 0) {
throw new Error(`unexpected kitchen-sink diagnostic errors: ${[...errorMessages].join(", ")}`);
}
const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json");
const index = JSON.parse(fs.readFileSync(indexPath, "utf8"));
const record = (index.installRecords ?? index.records ?? {})[pluginId];
if (!record) throw new Error(`missing kitchen-sink install record for ${pluginId}`);
if (record.source !== source) {
throw new Error(`expected kitchen-sink install source=${source}, got ${record.source}`);
}
if (source === "npm") {
const expectedSpec = spec.replace(/^npm:/u, "");
if (record.spec !== expectedSpec) {
throw new Error(`expected kitchen-sink npm spec ${expectedSpec}, got ${record.spec}`);
}
if (!record.resolvedVersion || !record.resolvedSpec) {
throw new Error(`missing npm resolution metadata: ${JSON.stringify(record)}`);
}
} else if (source === "clawhub") {
const value = spec.slice("clawhub:".length).trim();
const slashIndex = value.lastIndexOf("/");
const atIndex = value.lastIndexOf("@");
const packageName = atIndex > 0 && atIndex > slashIndex ? value.slice(0, atIndex) : value;
if (record.spec !== spec) {
throw new Error(`expected kitchen-sink ClawHub spec ${spec}, got ${record.spec}`);
}
if (record.clawhubPackage !== packageName) {
throw new Error(`expected ClawHub package ${packageName}, got ${record.clawhubPackage}`);
}
if (record.clawhubFamily !== "code-plugin" && record.clawhubFamily !== "bundle-plugin") {
throw new Error(`unexpected ClawHub family: ${record.clawhubFamily}`);
}
if (!record.version || !record.integrity || !record.resolvedAt) {
throw new Error(`missing ClawHub resolution metadata: ${JSON.stringify(record)}`);
}
}
if (typeof record.installPath !== "string" || record.installPath.length === 0) {
throw new Error("missing kitchen-sink install path");
}
const installPath = record.installPath.replace(/^~(?=$|\/)/u, process.env.HOME);
if (!fs.existsSync(installPath)) {
throw new Error(`kitchen-sink install path missing: ${record.installPath}`);
}
fs.writeFileSync(`/tmp/kitchen-sink-${label}-install-path.txt`, installPath, "utf8");
console.log("ok");
NODE
}
assert_kitchen_sink_removed() {
node - <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const pluginId = process.env.KITCHEN_SINK_ID;
const label = process.env.KITCHEN_SINK_LABEL;
const list = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-uninstalled.json`, "utf8"));
if ((list.plugins || []).some((entry) => entry.id === pluginId)) {
throw new Error(`kitchen-sink plugin still listed after uninstall: ${pluginId}`);
}
const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json");
const index = fs.existsSync(indexPath) ? JSON.parse(fs.readFileSync(indexPath, "utf8")) : {};
const records = index.installRecords ?? index.records ?? {};
if (records[pluginId]) {
throw new Error(`kitchen-sink install record still present after uninstall: ${pluginId}`);
}
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {};
if (config.plugins?.entries?.[pluginId]) {
throw new Error(`kitchen-sink config entry still present after uninstall: ${pluginId}`);
}
if ((config.plugins?.allow || []).includes(pluginId)) {
throw new Error(`kitchen-sink allowlist still contains ${pluginId}`);
}
if ((config.plugins?.deny || []).includes(pluginId)) {
throw new Error(`kitchen-sink denylist still contains ${pluginId}`);
}
if (config.channels?.["kitchen-sink-channel"]) {
throw new Error("kitchen-sink channel config still present after uninstall");
}
const installPathFile = `/tmp/kitchen-sink-${label}-install-path.txt`;
if (fs.existsSync(installPathFile)) {
const installPath = fs.readFileSync(installPathFile, "utf8").trim();
if (installPath && fs.existsSync(installPath)) {
throw new Error(`kitchen-sink managed install directory still exists: ${installPath}`);
}
}
console.log("ok");
NODE
}
run_success_scenario() {
echo "Testing ${KITCHEN_SINK_LABEL} install from ${KITCHEN_SINK_SPEC}..."
run_logged "install-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins install "$KITCHEN_SINK_SPEC"
run_logged "enable-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins enable "$KITCHEN_SINK_ID"
configure_kitchen_sink_runtime
node "$OPENCLAW_ENTRY" plugins list --json > "/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-plugins.json"
node "$OPENCLAW_ENTRY" plugins inspect "$KITCHEN_SINK_ID" --json > "/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-inspect.json"
node "$OPENCLAW_ENTRY" plugins inspect --all --json > "/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-inspect-all.json"
assert_kitchen_sink_installed
if [ "$KITCHEN_SINK_SOURCE" = "clawhub" ]; then
run_logged "uninstall-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins uninstall "$KITCHEN_SINK_SPEC" --force
else
run_logged "uninstall-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins uninstall "$KITCHEN_SINK_ID" --force
fi
remove_kitchen_sink_channel_config
node "$OPENCLAW_ENTRY" plugins list --json > "/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-uninstalled.json"
assert_kitchen_sink_removed
}
run_failure_scenario() {
echo "Testing expected ${KITCHEN_SINK_LABEL} install failure from ${KITCHEN_SINK_SPEC}..."
run_expect_failure "install-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins install "$KITCHEN_SINK_SPEC"
remove_kitchen_sink_channel_config
node "$OPENCLAW_ENTRY" plugins list --json > "/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-uninstalled.json"
assert_kitchen_sink_removed
}
if [[ "$KITCHEN_SINK_SCENARIOS" == *"clawhub:"* ]] &&
[[ "${OPENCLAW_KITCHEN_SINK_LIVE_CLAWHUB:-0}" != "1" ]] &&
[[ -z "${OPENCLAW_CLAWHUB_URL:-}" && -z "${CLAWHUB_URL:-}" ]]; then
clawhub_fixture_dir="$(mktemp -d "/tmp/openclaw-kitchen-sink-clawhub.XXXXXX")"
start_kitchen_sink_clawhub_fixture_server "$clawhub_fixture_dir"
fi
scenario_count=0
while IFS='|' read -r label spec plugin_id source expectation surface_mode; do
if [ -z "${label:-}" ] || [[ "$label" == \#* ]]; then
continue
fi
scenario_count=$((scenario_count + 1))
export KITCHEN_SINK_LABEL="$label"
export KITCHEN_SINK_SPEC="$spec"
export KITCHEN_SINK_ID="$plugin_id"
export KITCHEN_SINK_SOURCE="$source"
export KITCHEN_SINK_SURFACE_MODE="$surface_mode"
case "$expectation" in
success)
run_success_scenario
;;
failure)
run_failure_scenario
;;
*)
echo "Unknown kitchen-sink expectation for ${label}: ${expectation}" >&2
exit 1
;;
esac
done <<< "$KITCHEN_SINK_SCENARIOS"
if [ "$scenario_count" -eq 0 ]; then
echo "No kitchen-sink plugin scenarios configured." >&2
exit 1
fi
scan_logs_for_unexpected_errors
echo "kitchen-sink plugin Docker E2E passed (${scenario_count} scenario(s))"
EOF
DOCKER_ENV_ARGS=(
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64"
-e "KITCHEN_SINK_SCENARIOS=$KITCHEN_SINK_SCENARIOS"
)
for env_name in \
OPENCLAW_KITCHEN_SINK_LIVE_CLAWHUB \
OPENCLAW_CLAWHUB_URL \
CLAWHUB_URL \
OPENCLAW_CLAWHUB_TOKEN \
CLAWHUB_TOKEN \
CLAWHUB_AUTH_TOKEN; do
env_value="${!env_name:-}"
if [[ -n "$env_value" && "$env_value" != "undefined" && "$env_value" != "null" ]]; then
DOCKER_ENV_ARGS+=(-e "$env_name")
fi
done
echo "Running kitchen-sink plugin Docker E2E..."
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker run --name "$CONTAINER_NAME" "${DOCKER_ENV_ARGS[@]}" -i "$IMAGE_NAME" bash -s \
>"$RUN_LOG" 2>&1 < "$SCRIPT_FILE" &
docker_pid="$!"
while kill -0 "$docker_pid" 2>/dev/null; do
if docker inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
docker stats --no-stream --format '{{json .}}' "$CONTAINER_NAME" >>"$STATS_LOG" 2>/dev/null || true
fi
sleep 2
done
set +e
wait "$docker_pid"
run_status="$?"
set -e
cat "$RUN_LOG"
node - "$STATS_LOG" "$MAX_MEMORY_MIB" "$MAX_CPU_PERCENT" <<'NODE'
const fs = require("node:fs");
const [statsFile, maxMemoryRaw, maxCpuRaw] = process.argv.slice(2);
const maxMemoryMiB = Number(maxMemoryRaw);
const maxCpuPercent = Number(maxCpuRaw);
const parseMemoryMiB = (raw) => {
const value = String(raw || "").split("/")[0]?.trim() || "";
const match = /^([0-9.]+)\s*([KMGT]?i?B)$/iu.exec(value);
if (!match) return 0;
const amount = Number(match[1]);
const unit = match[2].toLowerCase();
if (unit === "kb" || unit === "kib") return amount / 1024;
if (unit === "mb" || unit === "mib") return amount;
if (unit === "gb" || unit === "gib") return amount * 1024;
if (unit === "tb" || unit === "tib") return amount * 1024 * 1024;
return 0;
};
const lines = fs.existsSync(statsFile)
? fs.readFileSync(statsFile, "utf8").split(/\r?\n/u).filter(Boolean)
: [];
let maxObservedMemoryMiB = 0;
let maxObservedCpuPercent = 0;
for (const line of lines) {
let parsed;
try {
parsed = JSON.parse(line);
} catch {
continue;
}
maxObservedMemoryMiB = Math.max(maxObservedMemoryMiB, parseMemoryMiB(parsed.MemUsage));
maxObservedCpuPercent = Math.max(
maxObservedCpuPercent,
Number(String(parsed.CPUPerc || "0").replace(/%$/u, "")) || 0,
);
}
console.log(
`kitchen-sink resource peak: memory=${maxObservedMemoryMiB.toFixed(1)}MiB cpu=${maxObservedCpuPercent.toFixed(1)}% samples=${lines.length}`,
);
if (lines.length === 0) {
throw new Error("no docker stats samples captured for kitchen-sink plugin lane");
}
if (maxObservedMemoryMiB > maxMemoryMiB) {
throw new Error(
`kitchen-sink memory peak ${maxObservedMemoryMiB.toFixed(1)}MiB exceeded ${maxMemoryMiB}MiB`,
);
}
if (maxObservedCpuPercent > maxCpuPercent) {
throw new Error(
`kitchen-sink CPU peak ${maxObservedCpuPercent.toFixed(1)}% exceeded ${maxCpuPercent}%`,
);
}
NODE
rm -f "$RUN_LOG" "$STATS_LOG"
exit "$run_status"

View File

@@ -40,69 +40,34 @@ docker run --rm \
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
"$IMAGE_NAME" \
bash -lc "set -euo pipefail
eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\"
entry=dist/index.mjs
[ -f \"\$entry\" ] || entry=dist/index.js
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\"
entry=\"\$(openclaw_e2e_resolve_entrypoint)\"
mock_port=44081
export OPENCLAW_DOCKER_OPENAI_BASE_URL=\"http://127.0.0.1:\$mock_port/v1\"
MOCK_PORT=\"\$mock_port\" node scripts/e2e/mock-openai-server.mjs >/tmp/mcp-channels-mock-openai.log 2>&1 &
mock_pid=\$!
for _ in \$(seq 1 80); do
if node -e \"fetch('http://127.0.0.1:' + process.argv[1] + '/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\" \"\$mock_port\"; then
break
fi
sleep 0.1
done
node -e \"fetch('http://127.0.0.1:' + process.argv[1] + '/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\" \"\$mock_port\"
tsx scripts/e2e/mcp-channels-seed.ts >/tmp/mcp-channels-seed.log
node \"\$entry\" gateway --port $PORT --bind loopback --allow-unconfigured >/tmp/mcp-channels-gateway.log 2>&1 &
gateway_pid=\$!
stop_process() {
pid=\"\$1\"
kill \"\$pid\" >/dev/null 2>&1 || true
for _ in \$(seq 1 40); do
if ! kill -0 \"\$pid\" >/dev/null 2>&1; then
wait \"\$pid\" >/dev/null 2>&1 || true
return
fi
sleep 0.25
done
kill -9 \"\$pid\" >/dev/null 2>&1 || true
wait \"\$pid\" >/dev/null 2>&1 || true
}
mock_pid=\"\$(openclaw_e2e_start_mock_openai \"\$mock_port\" /tmp/mcp-channels-mock-openai.log)\"
gateway_pid=
cleanup_inner() {
stop_process \"\$gateway_pid\"
stop_process \"\$mock_pid\"
openclaw_e2e_stop_process \"\${gateway_pid:-}\"
openclaw_e2e_stop_process \"\${mock_pid:-}\"
}
dump_gateway_log_on_error() {
status=\$?
if [ \"\$status\" -ne 0 ]; then
tail -n 80 /tmp/mcp-channels-gateway.log 2>/dev/null || true
openclaw_e2e_dump_logs \
/tmp/mcp-channels-gateway.log \
/tmp/mcp-channels-seed.log \
/tmp/mcp-channels-mock-openai.log
fi
cleanup_inner
exit \"\$status\"
}
trap cleanup_inner EXIT
trap dump_gateway_log_on_error ERR
gateway_ready=0
for _ in \$(seq 1 480); do
if ! kill -0 \"\$gateway_pid\" >/dev/null 2>&1; then
echo \"Gateway exited before becoming ready\"
wait \"\$gateway_pid\" || true
tail -n 120 /tmp/mcp-channels-gateway.log 2>/dev/null || true
exit 1
fi
if grep -q '\[gateway\] ready' /tmp/mcp-channels-gateway.log 2>/dev/null; then
gateway_ready=1
break
fi
sleep 0.25
done
if [ \"\$gateway_ready\" -ne 1 ]; then
echo \"Gateway did not become ready\"
tail -n 120 /tmp/mcp-channels-gateway.log 2>/dev/null || true
exit 1
fi
openclaw_e2e_wait_mock_openai \"\$mock_port\"
tsx scripts/e2e/mcp-channels-seed.ts >/tmp/mcp-channels-seed.log
gateway_pid=\"\$(openclaw_e2e_start_gateway \"\$entry\" $PORT /tmp/mcp-channels-gateway.log)\"
openclaw_e2e_wait_gateway_ready \"\$gateway_pid\" /tmp/mcp-channels-gateway.log 480
tsx scripts/e2e/mcp-channels-docker-client.ts
" >"$CLIENT_LOG" 2>&1
status=${PIPESTATUS[0]}

View File

@@ -52,7 +52,8 @@ if ! docker run --rm \
-i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF'
set -euo pipefail
eval "$(printf "%s" "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" | base64 -d)"
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
export NPM_CONFIG_PREFIX="$HOME/.npm-global"
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
export OPENAI_API_KEY="sk-openclaw-npm-onboard-e2e"
@@ -63,6 +64,7 @@ PORT="18789"
MOCK_PORT="44080"
SUCCESS_MARKER="OPENCLAW_AGENT_E2E_OK_ASSISTANT"
MOCK_REQUEST_LOG="/tmp/openclaw-mock-openai-requests.jsonl"
export SUCCESS_MARKER MOCK_REQUEST_LOG
mock_pid=""
case "$CHANNEL" in
@@ -81,17 +83,14 @@ case "$CHANNEL" in
esac
cleanup() {
if [ -n "${mock_pid:-}" ] && kill -0 "$mock_pid" 2>/dev/null; then
kill "$mock_pid" 2>/dev/null || true
wait "$mock_pid" 2>/dev/null || true
fi
openclaw_e2e_stop_process "${mock_pid:-}"
}
trap cleanup EXIT
dump_debug_logs() {
local status="$1"
echo "npm onboard/channel/agent scenario failed with exit code $status" >&2
for file in \
openclaw_e2e_dump_logs \
/tmp/openclaw-install.log \
/tmp/openclaw-onboard.json \
/tmp/openclaw-channel-add.log \
@@ -100,12 +99,7 @@ dump_debug_logs() {
/tmp/openclaw-agent.err \
/tmp/openclaw-agent.json \
/tmp/openclaw-mock-openai.log \
"$MOCK_REQUEST_LOG"; do
if [ -f "$file" ]; then
echo "--- $file ---" >&2
sed -n '1,220p' "$file" >&2 || true
fi
done
"$MOCK_REQUEST_LOG"
}
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
@@ -136,15 +130,8 @@ assert_dep_present() {
fi
}
MOCK_PORT="$MOCK_PORT" SUCCESS_MARKER="$SUCCESS_MARKER" MOCK_REQUEST_LOG="$MOCK_REQUEST_LOG" node scripts/e2e/mock-openai-server.mjs >/tmp/openclaw-mock-openai.log 2>&1 &
mock_pid="$!"
for _ in $(seq 1 80); do
if node -e "fetch('http://127.0.0.1:${MOCK_PORT}/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"; then
break
fi
sleep 0.1
done
node -e "fetch('http://127.0.0.1:${MOCK_PORT}/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" /tmp/openclaw-mock-openai.log)"
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
echo "Running non-interactive onboarding..."
openclaw onboard --non-interactive --accept-risk \

View File

@@ -4,54 +4,27 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-onboard-e2e" OPENCLAW_ONBOARD_E2E_IMAGE)"
OPENCLAW_TEST_STATE_FUNCTION_B64="$(
node "$ROOT_DIR/scripts/lib/openclaw-test-state.mjs" shell-function \
| base64 \
| tr -d '\n'
)"
OPENCLAW_TEST_STATE_FUNCTION_B64="$(docker_e2e_test_state_function_b64)"
docker_e2e_build_or_reuse "$IMAGE_NAME" onboard
docker_e2e_harness_mount_args
echo "Running onboarding E2E..."
docker run --rm -t \
-e "OPENCLAW_TEST_STATE_FUNCTION_B64=$OPENCLAW_TEST_STATE_FUNCTION_B64" \
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
"$IMAGE_NAME" bash -lc '
set -euo pipefail
trap "" PIPE
export TERM=xterm-256color
source scripts/lib/openclaw-e2e-instance.sh
eval "$(printf "%s" "${OPENCLAW_TEST_STATE_FUNCTION_B64:?missing OPENCLAW_TEST_STATE_FUNCTION_B64}" | base64 -d)"
ONBOARD_FLAGS="--flow quickstart --auth-choice skip --skip-channels --skip-skills --skip-daemon --skip-ui"
# tsdown may emit dist/index.js or dist/index.mjs depending on runtime/bundler.
if [ -f dist/index.mjs ]; then
OPENCLAW_ENTRY="dist/index.mjs"
elif [ -f dist/index.js ]; then
OPENCLAW_ENTRY="dist/index.js"
else
echo "Missing dist/index.(m)js (build output):"
ls -la dist || true
exit 1
fi
OPENCLAW_ENTRY="$(openclaw_e2e_resolve_entrypoint)"
export OPENCLAW_ENTRY
# Provide a minimal trash shim to avoid noisy "missing trash" logs in containers.
export PATH="/tmp/openclaw-bin:$PATH"
mkdir -p /tmp/openclaw-bin
cat > /tmp/openclaw-bin/trash <<'"'"'TRASH'"'"'
#!/usr/bin/env bash
set -euo pipefail
trash_dir="$HOME/.Trash"
mkdir -p "$trash_dir"
for target in "$@"; do
[ -e "$target" ] || continue
base="$(basename "$target")"
dest="$trash_dir/$base"
if [ -e "$dest" ]; then
dest="$trash_dir/${base}-$(date +%s)-$$"
fi
mv "$target" "$dest"
done
TRASH
chmod +x /tmp/openclaw-bin/trash
openclaw_e2e_install_trash_shim
send() {
local payload="$1"
@@ -120,29 +93,12 @@ TRASH
}
start_gateway() {
node "$OPENCLAW_ENTRY" gateway --port 18789 --bind loopback --allow-unconfigured > /tmp/gateway-e2e.log 2>&1 &
GATEWAY_PID="$!"
GATEWAY_PID="$(openclaw_e2e_start_gateway "$OPENCLAW_ENTRY" 18789 /tmp/gateway-e2e.log)"
}
wait_for_gateway() {
for _ in $(seq 1 20); do
if node --input-type=module -e "
import net from 'node:net';
const socket = net.createConnection({ host: '127.0.0.1', port: 18789 });
const timeout = setTimeout(() => {
socket.destroy();
process.exit(1);
}, 500);
socket.on('connect', () => {
clearTimeout(timeout);
socket.end();
process.exit(0);
});
socket.on('error', () => {
clearTimeout(timeout);
process.exit(1);
});
" >/dev/null 2>&1; then
if openclaw_e2e_probe_tcp 127.0.0.1 18789 500 >/dev/null 2>&1; then
return 0
fi
if [ -f /tmp/gateway-e2e.log ] && grep -E -q "listening on ws://[^ ]+:18789" /tmp/gateway-e2e.log; then
@@ -158,11 +114,7 @@ TRASH
}
stop_gateway() {
local gw_pid="$1"
if [ -n "$gw_pid" ]; then
kill "$gw_pid" 2>/dev/null || true
wait "$gw_pid" || true
fi
openclaw_e2e_stop_process "$1"
}
run_wizard_cmd() {
@@ -229,32 +181,6 @@ TRASH
openclaw_test_state_create "$state_ref" empty
}
assert_file() {
local file_path="$1"
if [ ! -f "$file_path" ]; then
echo "Missing file: $file_path"
exit 1
fi
}
assert_dir() {
local dir_path="$1"
if [ ! -d "$dir_path" ]; then
echo "Missing dir: $dir_path"
exit 1
fi
}
run_case_logged() {
local label="$1"
shift
local log_path="/tmp/openclaw-onboard-${label}.log"
if ! "$@" >"$log_path" 2>&1; then
cat "$log_path"
exit 1
fi
}
select_skip_hooks() {
# Hooks multiselect: pick "Skip for now".
wait_for_log "Enable hooks?" 60
@@ -306,7 +232,7 @@ TRASH
run_case_local_basic() {
set_isolated_openclaw_env local-basic
run_case_logged local-basic node "$OPENCLAW_ENTRY" onboard \
openclaw_e2e_run_logged local-basic node "$OPENCLAW_ENTRY" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
@@ -322,10 +248,10 @@ TRASH
config_path="$OPENCLAW_CONFIG_PATH"
sessions_dir="$OPENCLAW_STATE_DIR/agents/main/sessions"
assert_file "$config_path"
assert_dir "$sessions_dir"
openclaw_e2e_assert_file "$config_path"
openclaw_e2e_assert_dir "$sessions_dir"
for file in AGENTS.md BOOTSTRAP.md IDENTITY.md SOUL.md TOOLS.md USER.md; do
assert_file "$workspace_dir/$file"
openclaw_e2e_assert_file "$workspace_dir/$file"
done
CONFIG_PATH="$config_path" WORKSPACE_DIR="$workspace_dir" node --input-type=module - <<'"'"'NODE'"'"'
@@ -380,7 +306,7 @@ NODE
run_case_remote_non_interactive() {
set_isolated_openclaw_env remote-non-interactive
# Smoke test non-interactive remote config write.
run_case_logged remote-non-interactive node "$OPENCLAW_ENTRY" onboard --non-interactive --accept-risk \
openclaw_e2e_run_logged remote-non-interactive node "$OPENCLAW_ENTRY" onboard --non-interactive --accept-risk \
--mode remote \
--remote-url ws://gateway.local:18789 \
--remote-token remote-token \
@@ -388,7 +314,7 @@ NODE
--skip-health
config_path="$OPENCLAW_CONFIG_PATH"
assert_file "$config_path"
openclaw_e2e_assert_file "$config_path"
CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"'
import fs from "node:fs";
@@ -431,7 +357,7 @@ NODE
}
JSON
run_case_logged reset-config node "$OPENCLAW_ENTRY" onboard \
openclaw_e2e_run_logged reset-config node "$OPENCLAW_ENTRY" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
@@ -444,7 +370,7 @@ JSON
--skip-health
config_path="$OPENCLAW_CONFIG_PATH"
assert_file "$config_path"
openclaw_e2e_assert_file "$config_path"
CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"'
import fs from "node:fs";
@@ -475,7 +401,7 @@ NODE
run_wizard_cmd channels channels "node \"$OPENCLAW_ENTRY\" configure --section channels" send_channels_flow
config_path="$OPENCLAW_CONFIG_PATH"
assert_file "$config_path"
openclaw_e2e_assert_file "$config_path"
CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"'
import fs from "node:fs";
@@ -526,7 +452,7 @@ JSON
run_wizard_cmd skills "$home_dir" "node \"$OPENCLAW_ENTRY\" configure --section skills" send_skills_flow
config_path="$OPENCLAW_CONFIG_PATH"
assert_file "$config_path"
openclaw_e2e_assert_file "$config_path"
CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"'
import fs from "node:fs";
@@ -552,18 +478,9 @@ if (errors.length > 0) {
NODE
}
assert_log_not_contains() {
local file_path="$1"
local needle="$2"
if grep -q "$needle" "$file_path"; then
echo "Unexpected log output: $needle"
exit 1
fi
}
validate_local_basic_log() {
local log_path="$1"
assert_log_not_contains "$log_path" "systemctl --user unavailable"
openclaw_e2e_assert_log_not_contains "$log_path" "systemctl --user unavailable"
}
run_case_local_basic

View File

@@ -74,8 +74,8 @@ docker_cmd docker run -d \
"$IMAGE_NAME" \
bash -lc '
set -euo pipefail
entry=dist/index.mjs
[ -f "$entry" ] || entry=dist/index.js
source scripts/lib/openclaw-e2e-instance.sh
entry="$(openclaw_e2e_resolve_entrypoint)"
openai_api_key="${OPENAI_API_KEY:?OPENAI_API_KEY required}"
batch_file="$(mktemp /tmp/openclaw-openwebui-config.XXXXXX.json)"
@@ -120,7 +120,7 @@ EOF
EOF
rm -f "$workspace/BOOTSTRAP.md"
exec node "$entry" gateway --port '"$PORT"' --bind lan --allow-unconfigured > /tmp/openwebui-gateway.log 2>&1
openclaw_e2e_exec_gateway "$entry" '"$PORT"' lan /tmp/openwebui-gateway.log
' >/dev/null
echo "Waiting for gateway HTTP surface..."

View File

@@ -1393,6 +1393,17 @@ EOF
guest_current_user_exec /bin/bash -lc "$cmd"
}
reset_openclaw_user_state() {
guest_current_user_sh "$(cat <<'EOF'
/usr/bin/pkill -f 'openclaw.*gateway run' >/dev/null 2>&1 || true
/usr/bin/pkill -f 'openclaw-gateway' >/dev/null 2>&1 || true
/usr/bin/pkill -f 'openclaw.mjs gateway' >/dev/null 2>&1 || true
rm -rf "$HOME/.openclaw"
rm -f /tmp/openclaw-parallels-macos-gateway.log
EOF
)"
}
run_ref_onboard() {
local daemon_args=("--install-daemon")
if headless_guest_fallback; then
@@ -1944,6 +1955,7 @@ run_fresh_main_lane() {
local snapshot_id="$1"
local host_ip="$2"
phase_run "fresh.restore-snapshot" "$TIMEOUT_SNAPSHOT_S" restore_snapshot "$snapshot_id"
phase_run "fresh.reset-state" "$TIMEOUT_ONBOARD_S" reset_openclaw_user_state
phase_run "fresh.install-main" "$(install_main_timeout)" install_main_tgz "$host_ip" "openclaw-main-fresh.tgz"
FRESH_MAIN_VERSION="$(extract_last_version "$(phase_log_path fresh.install-main)")"
phase_run "fresh.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version
@@ -1971,6 +1983,7 @@ run_upgrade_lane() {
local snapshot_id="$1"
local host_ip="$2"
phase_run "upgrade.restore-snapshot" "$TIMEOUT_SNAPSHOT_S" restore_snapshot "$snapshot_id"
phase_run "upgrade.reset-state" "$TIMEOUT_ONBOARD_S" reset_openclaw_user_state
phase_run "upgrade.install-latest" "$TIMEOUT_INSTALL_SITE_S" install_latest_release
LATEST_INSTALLED_VERSION="$(extract_last_version "$(phase_log_path upgrade.install-latest)")"
phase_run "upgrade.verify-latest-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$INSTALL_VERSION"

View File

@@ -1029,11 +1029,24 @@ for wanted in preferred_names:
break
if best is None:
candidates = []
for asset in assets:
name = asset.get("name", "")
if name.startswith("MinGit-") and name.endswith(".zip") and "busybox" not in name:
best = asset
break
if not (name.startswith("MinGit-") and name.endswith(".zip")):
continue
if "busybox" in name:
continue
if "-arm64." in name:
rank = 0
elif "-64-bit." in name:
rank = 1
elif "-32-bit." in name:
rank = 2
else:
rank = 3
candidates.append((rank, name, asset))
if candidates:
best = sorted(candidates, key=lambda item: (item[0], item[1]))[0][2]
if best is None:
raise SystemExit("no MinGit asset found")
@@ -1137,7 +1150,7 @@ ensure_mingit_zip() {
MINGIT_ZIP_PATH="$MAIN_TGZ_DIR/$mingit_name"
if [[ ! -f "$MINGIT_ZIP_PATH" ]]; then
say "Download $MINGIT_ZIP_NAME"
curl -fsSL "$mingit_url" -o "$MINGIT_ZIP_PATH"
curl --retry 5 --retry-delay 3 --retry-all-errors -fsSL "$mingit_url" -o "$MINGIT_ZIP_PATH"
fi
}
@@ -2083,6 +2096,13 @@ PY
warn "windows dev update helper log drain failed after completion"
fi
rm -f "$log_state_path"
if [[ "$done_status" != "0" ]] &&
[[ "$guest_log" == *"ERR_MODULE_NOT_FOUND"* ]] &&
[[ "$guest_log" == *"dist\\cli\\run-main.js"* ]] &&
verify_windows_dev_update_after_transport_loss; then
warn "windows dev update old updater hit stale dist chunk after install; product verification passed"
return 0
fi
[[ "$done_status" == "0" ]]
return $?
fi

View File

@@ -649,9 +649,9 @@ NODE
if [ "${OPENCLAW_PLUGINS_E2E_CLAWHUB:-1}" = "0" ]; then
echo "Skipping ClawHub plugin install and uninstall (OPENCLAW_PLUGINS_E2E_CLAWHUB=0)."
else
echo "Testing ClawHub plugin install and uninstall..."
CLAWHUB_PLUGIN_SPEC="${OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC:-clawhub:openclaw-now4real}"
CLAWHUB_PLUGIN_ID="${OPENCLAW_PLUGINS_E2E_CLAWHUB_ID:-now4real}"
echo "Testing ClawHub kitchen-sink plugin install and uninstall..."
CLAWHUB_PLUGIN_SPEC="${OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC:-clawhub:openclaw-kitchen-sink}"
CLAWHUB_PLUGIN_ID="${OPENCLAW_PLUGINS_E2E_CLAWHUB_ID:-openclaw-kitchen-sink-fixture}"
export CLAWHUB_PLUGIN_SPEC CLAWHUB_PLUGIN_ID
start_clawhub_fixture_server() {
@@ -669,9 +669,9 @@ const { createRequire } = require("node:module");
const portFile = process.argv[2];
const requireFromApp = createRequire(path.join(process.cwd(), "package.json"));
const JSZip = requireFromApp("jszip");
const packageName = "openclaw-now4real";
const pluginId = "now4real";
const version = "0.1.2";
const packageName = "openclaw-kitchen-sink";
const pluginId = "openclaw-kitchen-sink-fixture";
const version = "0.1.0";
async function main() {
const zip = new JSZip();
@@ -692,9 +692,18 @@ async function main() {
"package/index.js",
`module.exports = {
id: "${pluginId}",
name: "Now 4 Real",
name: "OpenClaw Kitchen Sink",
description: "Docker E2E kitchen-sink plugin fixture",
register(api) {
api.registerGatewayMethod("now4real.ping", async () => ({ ok: true }));
api.on("before_agent_start", async (event, context) => ({
kitchenSink: true,
observedEventKeys: Object.keys(event || {}),
observedContextKeys: Object.keys(context || {}),
}));
api.registerTool(() => null, { name: "kitchen_sink_tool" });
api.registerGatewayMethod("kitchen-sink.ping", async () => ({ ok: true }));
api.registerCli(() => {}, { commands: ["kitchen-sink"] });
api.registerService({ id: "kitchen-sink-service", start: () => {} });
},
};
`,
@@ -735,7 +744,7 @@ async function main() {
json(response, {
package: {
name: packageName,
displayName: "Now 4 Real",
displayName: "OpenClaw Kitchen Sink",
family: "code-plugin",
channel: "official",
isOfficial: true,
@@ -744,8 +753,8 @@ async function main() {
createdAt: 0,
updatedAt: 0,
compatibility: {
pluginApiRange: ">=2026.4.11",
minGatewayVersion: "2026.4.11",
pluginApiRange: ">=2026.4.26",
minGatewayVersion: "2026.4.26",
},
},
});
@@ -758,11 +767,11 @@ async function main() {
version: {
version,
createdAt: 0,
changelog: "Fixture package for Docker plugin E2E.",
changelog: "Kitchen-sink fixture package for Docker plugin E2E.",
sha256hash,
compatibility: {
pluginApiRange: ">=2026.4.11",
minGatewayVersion: "2026.4.11",
pluginApiRange: ">=2026.4.26",
minGatewayVersion: "2026.4.26",
},
},
});

View File

@@ -238,6 +238,11 @@ const candidateActionRules = [
const normalizeLogin = (login) => login.toLowerCase();
export function isClownfishPullRequest(pullRequest) {
const headRefName = pullRequest.headRefName ?? pullRequest.head?.ref ?? "";
return typeof headRefName === "string" && headRefName.startsWith("clownfish/");
}
export function extractIssueFormValue(body, field) {
if (!body) {
return "";
@@ -1026,6 +1031,9 @@ export async function runBarnacleAutoResponse({ github, context, core = console
if (pullRequest && labelSet.has(activePrLimitOverrideLabel)) {
labelSet.delete(activePrLimitLabel);
}
if (pullRequest && isClownfishPullRequest(pullRequest)) {
await removeLabels(github, context, pullRequest.number, [activePrLimitLabel], labelSet);
}
const rule = rules.find((item) => labelSet.has(item.label));
if (!rule) {

View File

@@ -60,5 +60,5 @@ docker_e2e_package_mount_args() {
}
docker_e2e_harness_mount_args() {
DOCKER_E2E_HARNESS_ARGS=(-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro")
DOCKER_E2E_HARNESS_ARGS=(-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" -v "$ROOT_DIR/scripts/lib:/app/scripts/lib:ro")
}

View File

@@ -283,6 +283,11 @@ export const mainLanes = [
stateScenario: "empty",
weight: 6,
}),
lane("kitchen-sink-plugin", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:kitchen-sink-plugin", {
resources: ["npm"],
stateScenario: "empty",
weight: 3,
}),
...bundledPluginInstallUninstallLanes,
lane(
"plugins-offline",

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