Compare commits

...

102 Commits

Author SHA1 Message Date
Peter Steinberger
aa36ee670b fix(gateway): stage startup plugin deps before load 2026-04-27 12:41:39 +01:00
Peter Steinberger
5ec987c64c chore(release): bump stable 2026.4.25 2026-04-27 12:33:43 +01:00
Peter Steinberger
f4c7d4c942 test: cover startup runtime dependency staging 2026-04-27 09:45:06 +01:00
Peter Steinberger
e6d642d510 test(docker): allow heavyweight lanes at low parallelism 2026-04-27 09:37:12 +01:00
Peter Steinberger
94c1e10643 chore(release): refresh beta 11 schema 2026-04-27 08:20:46 +01:00
Peter Steinberger
52c4f5a0a1 chore(release): bump beta 11 2026-04-27 08:14:24 +01:00
Peter Steinberger
009216941e fix: preserve bundled runtime mirror chunks 2026-04-27 07:33:55 +01:00
Peter Steinberger
9048032a76 fix: materialize staged plugin chunks 2026-04-27 07:14:27 +01:00
Peter Steinberger
ad2db902db test(docker): backport packaged harness fixes 2026-04-27 06:56:04 +01:00
Peter Steinberger
25a65b731d ci: replace telegram package dependency links 2026-04-27 06:16:33 +01:00
Peter Steinberger
d85778ace5 ci: fix telegram package runner parse 2026-04-27 06:06:00 +01:00
Peter Steinberger
2c1c51fa4b ci: backport package acceptance workflow 2026-04-27 05:56:25 +01:00
Peter Steinberger
81fd54696f test: keep release docker helper assertions scoped 2026-04-27 05:01:04 +01:00
Vincent Koc
1b113d80f7 fix(cli): skip plugin preload for plugin updates 2026-04-27 05:00:34 +01:00
Tak Hoffman
8fa3c94653 Fail package update on unhealthy restart (#72422) 2026-04-27 05:00:34 +01:00
Peter Steinberger
f934ecaa12 test(docker): keep web search smoke on one gateway connection 2026-04-27 05:00:34 +01:00
Peter Steinberger
75bb5c6077 fix: close session locks synchronously on exit 2026-04-27 04:51:29 +01:00
Peter Steinberger
dedad1c00d fix: support qr docker build without extra args 2026-04-27 04:51:26 +01:00
Peter Steinberger
324915c15c test: harden load-sensitive release lanes 2026-04-27 04:51:11 +01:00
Peter Steinberger
13d269f792 fix: keep package inventory aligned with npm tarball 2026-04-27 04:49:02 +01:00
Peter Steinberger
9d77d75b27 ci: validate release tarball before npm publish 2026-04-27 00:37:37 +01:00
Peter Steinberger
684b60cbff fix(bonjour): auto-disable advertising in containers
(cherry picked from commit 989cfd1e33)
2026-04-27 00:01:35 +01:00
Peter Steinberger
fa95a607f2 fix: restart package updates through updated install
(cherry picked from commit 6077941d0b)
2026-04-27 00:01:33 +01:00
Peter Steinberger
b02fdb8264 test(qa): drop brittle telegram workflow assertions 2026-04-26 23:51:51 +01:00
Peter Steinberger
5e04b0f97a ci(qa): remove telegram beta approval gate 2026-04-26 23:30:33 +01:00
Peter Steinberger
7677b4ca24 ci(docker): pass beta env to installer e2e 2026-04-26 23:21:56 +01:00
Peter Steinberger
d8c4dcb6a4 ci(docker): test release installer against beta 2026-04-26 23:09:05 +01:00
Peter Steinberger
61a539a1b7 ci(docker): use resolved pnpm for scheduled lanes 2026-04-26 22:54:17 +01:00
Peter Steinberger
2e8a089836 ci(docker): preserve pnpm path in scheduler lanes 2026-04-26 22:45:11 +01:00
Peter Steinberger
7109251318 test(qa): relax telegram mention reply assertion 2026-04-26 22:43:54 +01:00
Peter Steinberger
abf0ef9cd3 ci(release): trust release branch docker checks 2026-04-26 22:35:45 +01:00
Peter Steinberger
f950503b77 ci: add targeted docker lane reruns 2026-04-26 22:33:18 +01:00
Peter Steinberger
306cfe42b5 ci: centralize docker build wrapper 2026-04-26 22:33:18 +01:00
Peter Steinberger
11c46893f4 ci: enable docker image attestations 2026-04-26 22:33:18 +01:00
Peter Steinberger
c02a556faf ci: fix ACPX Docker update repair target 2026-04-26 22:33:18 +01:00
Peter Steinberger
d8e62793bb ci: run release Docker chunks through scheduler 2026-04-26 22:33:18 +01:00
Peter Steinberger
b07811b01d ci: chunk release Docker e2e jobs 2026-04-26 22:33:18 +01:00
Peter Steinberger
53f8e9de13 ci(release): allow npm telegram e2e from release branch 2026-04-26 22:33:05 +01:00
Peter Steinberger
218bceaa14 fix(release): harden beta validation lanes 2026-04-26 22:19:43 +01:00
Peter Steinberger
a410f05a09 chore(release): bump 2026.4.25 beta 10 2026-04-26 20:48:56 +01:00
Vincent Koc
55d1a2e0e0 fix(logging): redact persisted transcript text
(cherry picked from commit 406ae72fd2)
2026-04-26 20:48:48 +01:00
Peter Steinberger
c8972376cb docs(changelog): remove codex credits 2026-04-26 20:20:03 +01:00
Peter Steinberger
377041cd75 chore(release): bump 2026.4.25 beta 9 2026-04-26 20:06:06 +01:00
Peter Steinberger
d32a7916bd docs(changelog): note beta 9 backports 2026-04-26 20:05:50 +01:00
Vincent Koc
2c625f9368 fix: repair skills and memory watcher refresh paths
(cherry picked from commit e53c068d78)
2026-04-26 20:05:32 +01:00
Peter Steinberger
5ea41fe40c test(gateway): classify stream fallback as empty live response
(cherry picked from commit 4e181d30fa)
2026-04-26 20:05:23 +01:00
Peter Steinberger
cec1d46b30 test(gateway): harden acp bind docker smoke
(cherry picked from commit e60cc50dff)
2026-04-26 20:05:23 +01:00
Peter Steinberger
a8ba87ee90 fix(agents): keep responses web search reasoning compatible
(cherry picked from commit f2dab9b334)
2026-04-26 20:05:10 +01:00
Peter Steinberger
3f821a8888 fix(agents): honor bundle mcp tool allowlist
(cherry picked from commit fc6cfbd418)
2026-04-26 20:05:10 +01:00
Vincent Koc
1a3c480155 fix: shortcut live session model redirects during fallback
(cherry picked from commit 480a3f66c9)
2026-04-26 20:05:10 +01:00
Vincent Koc
683437fe61 fix(discord): escalate repeated health-monitor restarts
(cherry picked from commit b4cdd55f62)
2026-04-26 20:04:53 +01:00
Peter Steinberger
095e1a90f5 docs(release): allow retagging unpublished betas 2026-04-26 19:39:52 +01:00
Peter Steinberger
227a07558b docs(changelog): place auto-reply backport in 2026.4.25 2026-04-26 19:33:19 +01:00
Vincent Koc
773e302179 fix(auto-reply): poison inbound dedupe after partial turn failure
* fix(auto-reply): poison inbound dedupe after replay-unsafe failures

* fix(clownfish): address review for ghcrawl-165980-agentic-merge (1)
2026-04-26 19:10:34 +01:00
Peter Steinberger
ec71b01f71 chore(release): bump 2026.4.25 beta 8 2026-04-26 18:56:52 +01:00
Peter Steinberger
ca9fb36d53 docs(changelog): place WhatsApp backport in 2026.4.25 2026-04-26 18:50:48 +01:00
Vincent Koc
1f194f1d55 fix(whatsapp): stop reconnecting quiet sockets
Fixes #70678.\n\nKeeps quiet but healthy WhatsApp linked-device sessions connected by tracking WhatsApp Web transport activity, while retaining a longer app-silence cap so frame activity cannot mask a stuck session forever. Also cleans up transport activity listeners on failed connection-open paths.\n\nCarries forward the focused #71466 approach and keeps #63939 as related configurable-timeout follow-up. Thanks @vincentkoc and @oromeis.\n\nValidation:\n- pnpm test:serial extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts extensions/whatsapp/src/connection-controller.test.ts\n- pnpm check:changed\n- codex review --base origin/main
2026-04-26 18:37:01 +01:00
Peter Steinberger
a188d486dd chore(release): bump 2026.4.25 beta 7 2026-04-26 18:17:26 +01:00
Peter Steinberger
a4266be808 test(release): stabilize release validation waits 2026-04-26 18:11:12 +01:00
Peter Steinberger
90c40e9f90 chore(release): bump 2026.4.25 beta 6 2026-04-26 17:20:37 +01:00
Peter Steinberger
b77514b6d9 fix: avoid PowerShell error variable collision 2026-04-26 17:19:53 +01:00
Peter Steinberger
a813219b6b chore(release): bump 2026.4.25 beta 5 2026-04-26 16:48:34 +01:00
Peter Steinberger
4ac1406644 Merge remote-tracking branch 'origin/main' into release/2026.4.25
# Conflicts:
#	CHANGELOG.md
2026-04-26 16:24:13 +01:00
Peter Steinberger
4d0e1470df fix(release): stabilize beta validation lanes 2026-04-26 16:21:56 +01:00
Peter Steinberger
6ecae22943 chore(release): bump 2026.4.25 beta 4 2026-04-26 14:24:00 +01:00
Peter Steinberger
2c5ac5c0e2 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 14:17:08 +01:00
Peter Steinberger
8c309aa3de chore(release): bump 2026.4.25 beta 3 2026-04-26 13:54:23 +01:00
Peter Steinberger
3c89b16fb0 test(release): wait longer for dashboard smoke 2026-04-26 13:53:50 +01:00
Peter Steinberger
ef447c43c7 test(qa): allow slower gateway rpc startup retries 2026-04-26 13:51:24 +01:00
Peter Steinberger
ddb66a71af Merge remote-tracking branch 'origin/main' into release/2026.4.25
# Conflicts:
#	CHANGELOG.md
2026-04-26 13:49:27 +01:00
Peter Steinberger
9b1583112a test(extensions): restore transformed dynamic imports 2026-04-26 13:15:46 +01:00
Peter Steinberger
865fde8f72 chore(release): bump 2026.4.25 beta 2 2026-04-26 13:00:00 +01:00
Peter Steinberger
ccc8d71461 fix(cli): keep channel add plugin install noninteractive 2026-04-26 12:58:33 +01:00
Peter Steinberger
a947464403 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 12:46:42 +01:00
Peter Steinberger
63803d78f4 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 12:15:22 +01:00
Peter Steinberger
dcad0256b2 docs(plugin-sdk): refresh api baseline after main sync 2026-04-26 12:08:12 +01:00
Peter Steinberger
12b1a63b84 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 12:07:37 +01:00
Peter Steinberger
6ea3f30b9b Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 12:03:01 +01:00
Peter Steinberger
660dcf2c94 docs(plugin-sdk): refresh api baseline after main sync 2026-04-26 11:52:40 +01:00
Peter Steinberger
26ab654da2 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 11:52:07 +01:00
Peter Steinberger
5bc728d480 docs(release): refine beta validation guidance 2026-04-26 11:51:06 +01:00
Peter Steinberger
3779853ef9 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 11:45:31 +01:00
Peter Steinberger
b4ff947206 fix(ui): remove ineffective dynamic imports 2026-04-26 11:45:22 +01:00
Peter Steinberger
1e464867e7 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 11:41:22 +01:00
Peter Steinberger
ea9da71f03 test: type setup provider mocks 2026-04-26 11:41:08 +01:00
Peter Steinberger
1dbc246e29 Merge remote-tracking branch 'origin/main' into release/2026.4.25
# Conflicts:
#	CHANGELOG.md
2026-04-26 11:39:46 +01:00
Peter Steinberger
41c7256420 Merge remote-tracking branch 'origin/main' into release/2026.4.25
# Conflicts:
#	.agents/skills/openclaw-release-maintainer/SKILL.md
#	CHANGELOG.md
#	package.json
#	src/config/schema.base.generated.ts
2026-04-26 10:00:50 +01:00
Peter Steinberger
b7733c48c0 docs(release): codify beta train backport scan 2026-04-26 09:58:34 +01:00
Peter Steinberger
50565b05aa docs(changelog): add 2026.4.25 release highlights 2026-04-26 09:40:56 +01:00
Vincent Koc
2e10d87919 docs(changelog): flatten 27 multi-line bullets into single lines per AGENTS.md rule
(cherry picked from commit eb6b35671a)
2026-04-26 09:40:42 +01:00
Peter Steinberger
0ca3fae91a fix: hide raw agent failures in group chats
(cherry picked from commit 1969452c3f)
2026-04-26 09:40:40 +01:00
Peter Steinberger
308ba59151 test: update npm telegram workflow expectations
(cherry picked from commit 4ad8b613c9)
2026-04-26 09:40:38 +01:00
Vincent Koc
6ca5907692 fix(runtime): harden dependency install surfaces (#71997)
* fix(runtime): harden dependency surfaces

* fix(runtime): harden dependency install surfaces

* fix(runtime): address dependency surface review

* fix(runtime): address dependency surface review

* fix(channels): avoid read-only plugin loader cycle

* fix(channels): allow optional read-only loader workspace

* test(commands): refresh current main checks

* test(commands): keep provider metadata mock unique

* test(commands): keep doctor security read-only mock unique

(cherry picked from commit abd5ec98ab)
2026-04-26 09:40:19 +01:00
Peter Steinberger
b9758bf44a docs(plugin-sdk): refresh beta api baseline after main sync 2026-04-26 09:23:31 +01:00
Peter Steinberger
b923421129 Merge remote-tracking branch 'origin/main' into release/2026.4.25
# Conflicts:
#	src/plugin-sdk/command-auth.ts
#	src/plugins/command-registration.ts
#	src/plugins/command-registry-state.ts
#	src/plugins/command-specs.ts
#	src/plugins/commands.ts
2026-04-26 09:18:34 +01:00
Peter Steinberger
c6276d6b19 docs(plugin-sdk): refresh api baseline 2026-04-26 08:58:39 +01:00
Peter Steinberger
399b41bbdb docs(config): refresh channel config baseline 2026-04-26 08:57:20 +01:00
Peter Steinberger
1ce1713139 chore(config): refresh bundled channel metadata 2026-04-26 08:56:20 +01:00
Peter Steinberger
1768995c37 chore(release): sync beta config schema 2026-04-26 08:54:43 +01:00
Peter Steinberger
ced0e96cf2 fix: break plugin command spec import cycle 2026-04-26 08:46:02 +01:00
Peter Steinberger
dd13141903 fix: satisfy traceparent header lint 2026-04-26 08:43:26 +01:00
Peter Steinberger
072a5ae4b0 chore(release): prepare 2026.4.25 beta 1 2026-04-26 08:41:57 +01:00
117 changed files with 5822 additions and 644 deletions

View File

@@ -41,20 +41,31 @@ Use this skill for release and publish-time workflow. Keep ordinary development
recommended replacement can shift as plugin ownership, externalization, and
config footprint move, so do not blindly copy stale replacement annotations
into release notes.
- Do not delete or rewrite beta tags after they leave the machine. If a
published or pushed beta needs a fix, commit the fix on the release branch and
- Do not delete or rewrite any beta tag after the matching npm package has been
published, or after a GitHub release/prerelease was created from that tag. If
an npm-published beta needs a fix, commit the fix on the release branch and
increment to the next `-beta.N`.
- For a beta release train, run the fast local preflight first, publish the
beta to npm `beta`, then run the expensive published-package roster focused
on install/update/Docker/Parallels/NPM Telegram. If anything fails, fix it on
the release branch, commit/push/pull, increment beta number, and repeat. Run
the full expensive roster at least once before stable/latest promotion; for
later beta attempts, rerun only lanes whose evidence changed unless the fix
touches broad release, install/update, plugin, Docker, Parallels, or live QA
behavior. After each beta is published, scan current `main` once for critical
fixes that landed after the release branch cut and backport only important
low-risk fixes. Operators may authorize up to 4 autonomous beta attempts;
after 4 failed beta attempts, stop and report.
- Beta-only Git tags that were pushed for preflight but never published to npm
may be moved or replaced when the operator explicitly approves it. Before
retagging, verify `npm view openclaw@YYYY.M.D-beta.N version` is unpublished
and no GitHub release/prerelease exists for the tag; after retagging, push the
updated tag intentionally and rerun npm preflight because older preflight
artifacts are tied to the previous tag SHA.
- For a beta release train, run the fast local preflight first, then create a
local/preflight npm tarball and run the expensive release roster against that
exact tarball before publishing anything to npm. Focus the roster on
install/update/Docker/Parallels/NPM Telegram. If anything fails before npm
publish, fix it on the release branch, commit/push/pull, and rerun preflight;
beta-only tags may be moved only when the operator explicitly approves and
the matching npm version is still unpublished. Publish the beta only after
the tarball proof is good enough. Run the full expensive roster at least once
before stable/latest promotion; for later attempts, rerun only lanes whose
evidence changed unless the fix touches broad release, install/update,
plugin, Docker, Parallels, or live QA behavior. After each beta is published,
scan current `main` once for critical fixes that landed after the release
branch cut and backport only important low-risk fixes. Operators may
authorize up to 4 autonomous beta attempts; after 4 failed attempts, stop and
report.
- Use `/changelog` before version/tag preparation so the top changelog section
is deduped and ordered by user impact.
- Do not create beta-specific `CHANGELOG.md` headings. Beta releases use the
@@ -325,9 +336,11 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- Docker install/update coverage that exercises the published beta package
- published npm Telegram proof: dispatch Actions > `NPM Telegram Beta E2E`
from `main` with `package_spec=openclaw@<beta-version>` and
`provider_mode=mock-openai`, approve `npm-release`, and require success.
This is the default button path for installed-package onboarding,
Telegram setup, and real Telegram E2E against the published npm package.
`provider_mode=mock-openai`, and require success. This workflow is
maintainer-dispatched and intentionally has no `npm-release` approval gate;
`qa-live-shared` only supplies the shared QA secrets. This is the default
button path for installed-package onboarding, Telegram setup, and real
Telegram E2E against the published npm package.
Use the local `pnpm test:docker:npm-telegram-live` lane with the matching
`OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC` and Convex CI env only as a fallback
or debugging path.

View File

@@ -163,7 +163,8 @@ jobs:
OPENCLAW_EXTENSIONS=diagnostics-otel
tags: ${{ steps.tags.outputs.value }}
labels: ${{ steps.labels.outputs.value }}
provenance: false
sbom: true
provenance: mode=max
push: true
- name: Build and push amd64 slim image
@@ -180,7 +181,8 @@ jobs:
OPENCLAW_VARIANT=slim
tags: ${{ steps.tags.outputs.slim }}
labels: ${{ steps.labels.outputs.value }}
provenance: false
sbom: true
provenance: mode=max
push: true
# Build arm64 images (default + slim share the build stage cache)
@@ -283,7 +285,8 @@ jobs:
OPENCLAW_EXTENSIONS=diagnostics-otel
tags: ${{ steps.tags.outputs.value }}
labels: ${{ steps.labels.outputs.value }}
provenance: false
sbom: true
provenance: mode=max
push: true
- name: Build and push arm64 slim image
@@ -300,7 +303,8 @@ jobs:
OPENCLAW_VARIANT=slim
tags: ${{ steps.tags.outputs.slim }}
labels: ${{ steps.labels.outputs.value }}
provenance: false
sbom: true
provenance: mode=max
push: true
# Create multi-platform manifests

View File

@@ -4,10 +4,20 @@ on:
workflow_dispatch:
inputs:
package_spec:
description: Published OpenClaw package spec to test
description: Published OpenClaw package spec to test when no artifact is supplied
required: true
default: openclaw@beta
type: string
package_label:
description: Optional display label for an artifact-backed package candidate
required: false
default: ""
type: string
package_artifact_name:
description: Advanced package-under-test artifact name; leave blank for registry install
required: false
default: ""
type: string
provider_mode:
description: QA provider mode
required: true
@@ -20,8 +30,42 @@ on:
description: Optional comma-separated Telegram scenario ids
required: false
type: string
workflow_call:
inputs:
package_spec:
description: Published OpenClaw package spec to test when no artifact is supplied
required: true
type: string
package_artifact_name:
description: Optional package-under-test artifact from the current workflow run
required: false
default: ""
type: string
package_label:
description: Optional display label for an artifact-backed package candidate
required: false
default: ""
type: string
provider_mode:
description: QA provider mode
required: false
default: mock-openai
type: string
scenario:
description: Optional comma-separated Telegram scenario ids
required: false
default: ""
type: string
secrets:
OPENAI_API_KEY:
required: false
OPENCLAW_QA_CONVEX_SITE_URL:
required: false
OPENCLAW_QA_CONVEX_SECRET_CI:
required: false
permissions:
actions: read
contents: read
concurrency:
@@ -34,44 +78,19 @@ env:
PNPM_VERSION: "10.33.0"
jobs:
validate_dispatch_ref:
name: Validate dispatch ref
runs-on: blacksmith-8vcpu-ubuntu-2404
steps:
- name: Require main workflow ref
env:
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
echo "NPM Telegram beta E2E must be dispatched from main so workflow logic stays controlled." >&2
exit 1
fi
approve_release_manager:
name: Approve npm Telegram beta E2E
needs: validate_dispatch_ref
runs-on: ubuntu-latest
environment: npm-release
steps:
- name: Record approval
env:
PACKAGE_SPEC: ${{ inputs.package_spec }}
run: echo "Approved npm Telegram beta E2E for ${PACKAGE_SPEC}"
run_npm_telegram_beta_e2e:
name: Run published npm Telegram E2E
needs: approve_release_manager
run_package_telegram_e2e:
name: Run package Telegram E2E
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
permissions:
actions: read
contents: read
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
steps:
- name: Checkout main
- name: Checkout dispatch ref
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
@@ -79,6 +98,8 @@ jobs:
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
with:
max-cache-size-mb: 800000
- name: Build Docker E2E image
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
@@ -102,6 +123,7 @@ jobs:
- name: Validate inputs and secrets
env:
PACKAGE_SPEC: ${{ inputs.package_spec }}
PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || '' }}
PROVIDER_MODE: ${{ inputs.provider_mode }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
@@ -110,10 +132,19 @@ jobs:
run: |
set -euo pipefail
if [[ ! "${PACKAGE_SPEC}" =~ ^openclaw@(beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-beta\.[1-9][0-9]*)?)$ ]]; then
echo "package_spec must be openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: ${PACKAGE_SPEC}" >&2
exit 1
if [[ -z "${PACKAGE_ARTIFACT_NAME// }" ]]; then
if [[ ! "${PACKAGE_SPEC}" =~ ^openclaw@(beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-beta\.[1-9][0-9]*)?)$ ]]; then
echo "package_spec must be openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: ${PACKAGE_SPEC}" >&2
exit 1
fi
fi
case "${PROVIDER_MODE}" in
mock-openai | live-frontier) ;;
*)
echo "provider_mode must be mock-openai or live-frontier; got: ${PROVIDER_MODE}" >&2
exit 1
;;
esac
require_var() {
local key="$1"
@@ -129,7 +160,14 @@ jobs:
require_var OPENAI_API_KEY
fi
- name: Run npm Telegram beta E2E
- name: Download package-under-test artifact
if: inputs.package_artifact_name != ''
uses: actions/download-artifact@v8
with:
name: ${{ inputs.package_artifact_name }}
path: .artifacts/telegram-package-under-test
- name: Run package Telegram E2E
id: run_lane
shell: bash
env:
@@ -137,13 +175,16 @@ jobs:
OPENCLAW_SKIP_DOCKER_BUILD: "1"
OPENCLAW_DOCKER_E2E_IMAGE: openclaw-docker-e2e:local
OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC: ${{ inputs.package_spec }}
OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL: ${{ inputs.package_label }}
OPENCLAW_NPM_TELEGRAM_PROVIDER_MODE: ${{ inputs.provider_mode }}
OPENCLAW_NPM_TELEGRAM_CREDENTIAL_SOURCE: convex
OPENCLAW_NPM_TELEGRAM_CREDENTIAL_ROLE: ci
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
INPUT_SCENARIO: ${{ inputs.scenario }}
PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || '' }}
run: |
set -euo pipefail
@@ -151,6 +192,20 @@ jobs:
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
export OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR="${output_dir}"
if [[ -n "${PACKAGE_ARTIFACT_NAME// }" ]]; then
mapfile -t package_tgzs < <(find .artifacts/telegram-package-under-test -type f -name "*.tgz" | sort)
if [[ "${#package_tgzs[@]}" -ne 1 ]]; then
echo "package artifact ${PACKAGE_ARTIFACT_NAME} must contain exactly one .tgz; found ${#package_tgzs[@]}" >&2
exit 1
fi
export OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ="${package_tgzs[0]}"
if [[ -z "${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL// }" ]]; then
export OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="$(basename "${package_tgzs[0]}")"
fi
elif [[ -z "${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL// }" ]]; then
export OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="${OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC}"
fi
if [[ -n "${INPUT_SCENARIO// }" ]]; then
export OPENCLAW_NPM_TELEGRAM_SCENARIOS="${INPUT_SCENARIO}"
fi

View File

@@ -23,6 +23,16 @@ on:
required: false
default: true
type: boolean
docker_lanes:
description: Comma/space separated Docker scheduler lane names to run against the prepared image
required: false
default: ""
type: string
package_artifact_name:
description: Existing workflow artifact containing openclaw-current.tgz; blank lets lanes pack the selected ref
required: false
default: ""
type: string
include_live_suites:
description: Whether to run live-provider coverage
required: false
@@ -54,6 +64,16 @@ on:
required: false
default: true
type: boolean
docker_lanes:
description: Comma/space separated Docker scheduler lane names to run against the prepared image
required: false
default: ""
type: string
package_artifact_name:
description: Existing workflow artifact containing openclaw-current.tgz; blank lets lanes pack the selected ref
required: false
default: ""
type: string
include_live_suites:
description: Whether to run live-provider coverage
required: false
@@ -182,6 +202,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
INPUT_REF: ${{ inputs.ref }}
WORKFLOW_REF_NAME: ${{ github.ref_name }}
shell: bash
run: |
set -euo pipefail
@@ -189,9 +210,15 @@ jobs:
trusted_reason=""
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
if [[ "${WORKFLOW_REF_NAME}" =~ ^release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]]; then
git fetch --no-tags origin "+refs/heads/${WORKFLOW_REF_NAME}:refs/remotes/origin/${WORKFLOW_REF_NAME}"
fi
if git merge-base --is-ancestor "$selected_sha" refs/remotes/origin/main; then
trusted_reason="main-ancestor"
elif [[ "${WORKFLOW_REF_NAME}" =~ ^release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] &&
[[ "$selected_sha" == "$(git rev-parse "refs/remotes/origin/${WORKFLOW_REF_NAME}")" ]]; then
trusted_reason="release-branch-head"
elif git tag --points-at "$selected_sha" | grep -Eq '^v'; then
trusted_reason="release-tag"
else
@@ -208,7 +235,7 @@ jobs:
if [[ -z "$trusted_reason" ]]; then
echo "Ref '${INPUT_REF}' resolved to $selected_sha, which is not trusted for secret-bearing live/E2E checks." >&2
echo "Allowed refs must be on main, point to a release tag, or match an open PR head in ${GITHUB_REPOSITORY}." >&2
echo "Allowed refs must be on main, match the current release branch head, point to a release tag, or match an open PR head in ${GITHUB_REPOSITORY}." >&2
exit 1
fi
@@ -303,7 +330,7 @@ jobs:
requires_live_suites: false
- suite_id: openai-ws-stream-live-e2e
label: OpenAI WebSocket live E2E
command: pnpm test:e2e -- src/agents/openai-ws-stream.e2e.test.ts
command: pnpm test:e2e src/agents/openai-ws-stream.e2e.test.ts
timeout_minutes: 90
requires_repo_e2e: false
requires_live_suites: true
@@ -363,93 +390,23 @@ jobs:
validate_docker_e2e:
needs: [validate_selected_ref, prepare_docker_e2e_image]
if: inputs.include_release_path_suites
if: inputs.include_release_path_suites && inputs.docker_lanes == ''
name: Docker E2E (${{ matrix.label }})
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
matrix:
include:
- suite_id: docker-onboard
label: Onboarding Docker E2E
command: pnpm test:docker:onboard
timeout_minutes: 60
release_path: true
- suite_id: docker-npm-onboard-channel-agent
label: Npm Onboard Channel Agent Docker E2E
command: pnpm test:docker:npm-onboard-channel-agent
timeout_minutes: 90
release_path: true
- suite_id: docker-gateway-network
label: Gateway Network Docker E2E
command: pnpm test:docker:gateway-network
timeout_minutes: 60
release_path: true
- suite_id: docker-openai-web-search-minimal
label: OpenAI Web Search Minimal Docker E2E
command: pnpm test:docker:openai-web-search-minimal
timeout_minutes: 60
release_path: true
- suite_id: docker-mcp-channels
label: MCP Channels Docker E2E
command: pnpm test:docker:mcp-channels
timeout_minutes: 60
release_path: true
- suite_id: docker-pi-bundle-mcp-tools
label: Pi Bundle MCP Tools Docker E2E
command: pnpm test:docker:pi-bundle-mcp-tools
timeout_minutes: 60
release_path: true
- suite_id: docker-cron-mcp-cleanup
label: Cron MCP Cleanup Docker E2E
command: pnpm test:docker:cron-mcp-cleanup
timeout_minutes: 60
release_path: true
- suite_id: docker-plugins
label: Plugins Docker E2E
command: pnpm test:docker:plugins
timeout_minutes: 75
release_path: true
- suite_id: docker-plugin-update
label: Plugin Update Docker E2E
command: pnpm test:docker:plugin-update
timeout_minutes: 60
release_path: true
- suite_id: docker-config-reload
label: Config Reload Docker E2E
command: pnpm test:docker:config-reload
timeout_minutes: 60
release_path: true
- suite_id: docker-bundled-channel-deps
label: Bundled Channel Runtime Deps Docker E2E
command: pnpm test:docker:bundled-channel-deps
timeout_minutes: 75
release_path: true
- suite_id: docker-doctor-switch
label: Doctor Install Switch Docker E2E
command: pnpm test:docker:doctor-switch
timeout_minutes: 60
release_path: true
- suite_id: docker-update-channel-switch
label: Update Channel Switch Docker E2E
command: pnpm test:docker:update-channel-switch
timeout_minutes: 60
release_path: true
- suite_id: docker-session-runtime-context
label: Session Runtime Context Docker E2E
command: pnpm test:docker:session-runtime-context
timeout_minutes: 60
release_path: true
- suite_id: docker-qr
label: QR Import Docker E2E
command: pnpm test:docker:qr
timeout_minutes: 60
release_path: true
- suite_id: docker-install-e2e
label: Installer Docker E2E
command: pnpm test:install:e2e
- chunk_id: core
label: core
timeout_minutes: 120
release_path: true
- chunk_id: package-update
label: package/update
timeout_minutes: 180
- chunk_id: plugins-integrations
label: plugins/integrations
timeout_minutes: 180
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -496,7 +453,12 @@ jobs:
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
OPENCLAW_DOCKER_E2E_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.image }}
OPENCLAW_CURRENT_PACKAGE_TGZ: ${{ inputs.package_artifact_name != '' && '.artifacts/docker-e2e-package/openclaw-current.tgz' || '' }}
OPENCLAW_BUNDLED_CHANNEL_PACKAGE_TGZ: ${{ inputs.package_artifact_name != '' && '.artifacts/docker-e2e-package/openclaw-current.tgz' || '' }}
OPENCLAW_NPM_ONBOARD_PACKAGE_TGZ: ${{ inputs.package_artifact_name != '' && '.artifacts/docker-e2e-package/openclaw-current.tgz' || '' }}
OPENCLAW_SKIP_DOCKER_BUILD: "1"
INCLUDE_OPENWEBUI: ${{ inputs.include_openwebui }}
DOCKER_E2E_CHUNK: ${{ matrix.chunk_id }}
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
@@ -521,22 +483,41 @@ jobs:
- name: Hydrate live auth/profile inputs
run: bash scripts/ci-hydrate-live-auth.sh
- name: Configure suite-specific env
- name: Pull shared Docker E2E image
shell: bash
run: |
set -euo pipefail
case "${{ matrix.suite_id }}" in
docker-install-e2e)
echo "OPENCLAW_E2E_MODELS=both" >> "$GITHUB_ENV"
;;
esac
docker pull "${OPENCLAW_DOCKER_E2E_IMAGE}"
- name: Validate suite credentials
- name: Download package-under-test artifact
if: inputs.package_artifact_name != ''
uses: actions/download-artifact@v8
with:
name: ${{ inputs.package_artifact_name }}
path: .artifacts/docker-e2e-package
- name: Normalize package-under-test artifact
if: inputs.package_artifact_name != ''
shell: bash
run: |
set -euo pipefail
case "${{ matrix.suite_id }}" in
docker-install-e2e)
target=".artifacts/docker-e2e-package/openclaw-current.tgz"
if [[ ! -f "$target" ]]; then
mapfile -t tgzs < <(find .artifacts/docker-e2e-package -type f -name '*.tgz' | sort)
if [[ "${#tgzs[@]}" -ne 1 ]]; then
echo "Expected exactly one package tarball in .artifacts/docker-e2e-package; found ${#tgzs[@]}." >&2
printf '%s\n' "${tgzs[@]}" >&2
exit 1
fi
cp "${tgzs[0]}" "$target"
fi
- name: Validate chunk credentials
shell: bash
run: |
set -euo pipefail
case "${DOCKER_E2E_CHUNK}" in
package-update)
[[ -n "${OPENAI_API_KEY:-}" ]] || {
echo "OPENAI_API_KEY is required for installer Docker E2E." >&2
exit 1
@@ -546,14 +527,258 @@ jobs:
exit 1
fi
;;
plugins-integrations)
if [[ "${INCLUDE_OPENWEBUI}" == "true" ]]; then
[[ -n "${OPENAI_API_KEY:-}" ]] || {
echo "OPENAI_API_KEY is required for the Open WebUI Docker smoke." >&2
exit 1
}
fi
;;
esac
- name: Run ${{ matrix.label }}
run: ${{ matrix.command }}
- name: Run Docker E2E chunk
shell: bash
run: |
set -euo pipefail
export OPENCLAW_DOCKER_ALL_PROFILE=release-path
export OPENCLAW_DOCKER_ALL_CHUNK="${DOCKER_E2E_CHUNK}"
export OPENCLAW_DOCKER_ALL_BUILD=0
export OPENCLAW_DOCKER_ALL_PREFLIGHT=0
export OPENCLAW_DOCKER_ALL_FAIL_FAST=0
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI="${INCLUDE_OPENWEBUI}"
export OPENCLAW_DOCKER_ALL_LOG_DIR=".artifacts/docker-tests/release-${DOCKER_E2E_CHUNK}"
export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/release-${DOCKER_E2E_CHUNK}-timings.json"
export OPENCLAW_DOCKER_ALL_PNPM_COMMAND="$(command -v pnpm)"
pnpm test:docker:all
- name: Summarize Docker E2E chunk
if: always()
shell: bash
run: |
set -euo pipefail
summary=".artifacts/docker-tests/release-${DOCKER_E2E_CHUNK}/summary.json"
if [[ ! -f "$summary" ]]; then
echo "Docker chunk summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
node --input-type=module - "$summary" <<'NODE' >> "$GITHUB_STEP_SUMMARY"
import fs from "node:fs";
const summary = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const lanes = Array.isArray(summary.lanes) ? summary.lanes : [];
console.log(`### Docker E2E chunk: ${summary.chunk ?? "unknown"}`);
console.log("");
console.log(`Status: \`${summary.status}\``);
console.log("");
console.log("| Lane | Status | Seconds | Timed out | Rerun |");
console.log("| --- | ---: | ---: | --- | --- |");
for (const lane of lanes) {
const status = lane.status === 0 ? "pass" : `fail ${lane.status}`;
const rerun = String(lane.rerunCommand ?? "").replaceAll("`", "\\`");
console.log(`| \`${lane.name}\` | ${status} | ${lane.elapsedSeconds ?? ""} | ${lane.timedOut ? "yes" : "no"} | \`${rerun}\` |`);
}
NODE
- name: Upload Docker E2E chunk artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: docker-e2e-${{ matrix.chunk_id }}
path: .artifacts/docker-tests/
if-no-files-found: ignore
validate_docker_lanes:
needs: [validate_selected_ref, prepare_docker_e2e_image]
if: inputs.docker_lanes != ''
name: Docker E2E targeted lanes
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 180
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 }}
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_DOCKER_E2E_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.image }}
OPENCLAW_CURRENT_PACKAGE_TGZ: ${{ inputs.package_artifact_name != '' && '.artifacts/docker-e2e-package/openclaw-current.tgz' || '' }}
OPENCLAW_BUNDLED_CHANNEL_PACKAGE_TGZ: ${{ inputs.package_artifact_name != '' && '.artifacts/docker-e2e-package/openclaw-current.tgz' || '' }}
OPENCLAW_NPM_ONBOARD_PACKAGE_TGZ: ${{ inputs.package_artifact_name != '' && '.artifacts/docker-e2e-package/openclaw-current.tgz' || '' }}
OPENCLAW_SKIP_DOCKER_BUILD: "1"
INCLUDE_OPENWEBUI: ${{ inputs.include_openwebui }}
DOCKER_E2E_LANES: ${{ inputs.docker_lanes }}
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Log in to GHCR for shared Docker E2E image
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Setup Node environment
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
run: bash scripts/ci-hydrate-live-auth.sh
- name: Pull shared Docker E2E image
shell: bash
run: |
set -euo pipefail
docker pull "${OPENCLAW_DOCKER_E2E_IMAGE}"
- name: Download package-under-test artifact
if: inputs.package_artifact_name != ''
uses: actions/download-artifact@v8
with:
name: ${{ inputs.package_artifact_name }}
path: .artifacts/docker-e2e-package
- name: Normalize package-under-test artifact
if: inputs.package_artifact_name != ''
shell: bash
run: |
set -euo pipefail
target=".artifacts/docker-e2e-package/openclaw-current.tgz"
if [[ ! -f "$target" ]]; then
mapfile -t tgzs < <(find .artifacts/docker-e2e-package -type f -name '*.tgz' | sort)
if [[ "${#tgzs[@]}" -ne 1 ]]; then
echo "Expected exactly one package tarball in .artifacts/docker-e2e-package; found ${#tgzs[@]}." >&2
printf '%s\n' "${tgzs[@]}" >&2
exit 1
fi
cp "${tgzs[0]}" "$target"
fi
- name: Validate targeted lane credentials
shell: bash
run: |
set -euo pipefail
lanes=" ${DOCKER_E2E_LANES//,/ } "
if [[ "$lanes" == *" install-e2e "* ]]; then
[[ -n "${OPENAI_API_KEY:-}" ]] || {
echo "OPENAI_API_KEY is required for installer Docker E2E." >&2
exit 1
}
if [[ -z "${ANTHROPIC_API_TOKEN:-}" && -z "${ANTHROPIC_API_KEY:-}" ]]; then
echo "ANTHROPIC_API_TOKEN or ANTHROPIC_API_KEY is required for installer Docker E2E." >&2
exit 1
fi
fi
if [[ "$lanes" == *" openwebui "* || "$lanes" == *" openai-web-search-minimal "* ]]; then
[[ -n "${OPENAI_API_KEY:-}" ]] || {
echo "OPENAI_API_KEY is required for selected OpenAI Docker lanes." >&2
exit 1
}
fi
- name: Run targeted Docker E2E lanes
shell: bash
run: |
set -euo pipefail
lanes=" ${DOCKER_E2E_LANES//,/ } "
export OPENCLAW_DOCKER_ALL_LANES="${DOCKER_E2E_LANES}"
export OPENCLAW_DOCKER_ALL_PREFLIGHT=0
export OPENCLAW_DOCKER_ALL_FAIL_FAST=0
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI="${INCLUDE_OPENWEBUI}"
export OPENCLAW_DOCKER_ALL_LOG_DIR=".artifacts/docker-tests/targeted"
export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/targeted-timings.json"
export OPENCLAW_DOCKER_ALL_PNPM_COMMAND="$(command -v pnpm)"
if [[ "$lanes" == *" live-"* ]]; then
export OPENCLAW_DOCKER_ALL_BUILD=1
else
export OPENCLAW_DOCKER_ALL_BUILD=0
fi
pnpm test:docker:all
- name: Summarize targeted Docker E2E lanes
if: always()
shell: bash
run: |
set -euo pipefail
summary=".artifacts/docker-tests/targeted/summary.json"
if [[ ! -f "$summary" ]]; then
echo "Docker targeted summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
node --input-type=module - "$summary" <<'NODE' >> "$GITHUB_STEP_SUMMARY"
import fs from "node:fs";
const summary = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const lanes = Array.isArray(summary.lanes) ? summary.lanes : [];
console.log("### Docker E2E targeted lanes");
console.log("");
console.log(`Status: \`${summary.status}\``);
console.log("");
console.log("| Lane | Status | Seconds | Timed out | Rerun |");
console.log("| --- | ---: | ---: | --- | --- |");
for (const lane of lanes) {
const status = lane.status === 0 ? "pass" : `fail ${lane.status}`;
const rerun = String(lane.rerunCommand ?? "").replaceAll("`", "\\`");
console.log(`| \`${lane.name}\` | ${status} | ${lane.elapsedSeconds ?? ""} | ${lane.timedOut ? "yes" : "no"} | \`${rerun}\` |`);
}
NODE
- name: Upload targeted Docker E2E artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: docker-e2e-targeted
path: .artifacts/docker-tests/
if-no-files-found: ignore
validate_docker_openwebui:
needs: [validate_selected_ref, prepare_docker_e2e_image]
if: inputs.include_openwebui
if: inputs.include_openwebui && !inputs.include_release_path_suites && inputs.docker_lanes == ''
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 75
env:
@@ -596,7 +821,7 @@ jobs:
prepare_docker_e2e_image:
needs: validate_selected_ref
if: inputs.include_release_path_suites || inputs.include_openwebui
if: inputs.include_release_path_suites || inputs.include_openwebui || inputs.docker_lanes != ''
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 90
permissions:
@@ -646,7 +871,8 @@ jobs:
cache-from: type=gha,scope=docker-e2e
cache-to: type=gha,mode=max,scope=docker-e2e
tags: ${{ steps.image.outputs.image }}
provenance: false
sbom: true
provenance: mode=max
push: true
validate_live_models_docker:

517
.github/workflows/package-acceptance.yml vendored Normal file
View File

@@ -0,0 +1,517 @@
name: Package Acceptance
on:
workflow_dispatch:
inputs:
workflow_ref:
description: Trusted repo ref for workflow scripts and Docker E2E harness
required: true
default: release/2026.4.25
type: string
source:
description: Package candidate source
required: true
default: npm
type: choice
options:
- npm
- ref
- url
- artifact
package_ref:
description: Trusted package source ref when source=ref
required: true
default: release/2026.4.25
type: string
package_spec:
description: Published package spec when source=npm
required: false
default: openclaw@beta
type: string
package_url:
description: HTTPS .tgz URL when source=url
required: false
default: ""
type: string
package_sha256:
description: Expected package SHA-256; required for source=url
required: false
default: ""
type: string
artifact_run_id:
description: GitHub Actions run id when source=artifact
required: false
default: ""
type: string
artifact_name:
description: Artifact name containing one .tgz when source=artifact
required: false
default: package-under-test
type: string
suite_profile:
description: Acceptance profile
required: true
default: package
type: choice
options:
- smoke
- package
- product
- full
- custom
docker_lanes:
description: Comma/space separated Docker lanes when suite_profile=custom
required: false
default: ""
type: string
telegram_mode:
description: Optional Telegram QA lane for the resolved package candidate
required: true
default: none
type: choice
options:
- none
- mock-openai
- live-frontier
workflow_call:
inputs:
workflow_ref:
description: Trusted repo ref for workflow scripts and Docker E2E harness
required: false
default: release/2026.4.25
type: string
source:
description: "Package candidate source: npm, ref, url, or artifact"
required: true
type: string
package_ref:
description: Trusted package source ref when source=ref
required: false
default: release/2026.4.25
type: string
package_spec:
description: Published package spec when source=npm
required: false
default: openclaw@beta
type: string
package_url:
description: HTTPS .tgz URL when source=url
required: false
default: ""
type: string
package_sha256:
description: Expected package SHA-256; required for source=url
required: false
default: ""
type: string
artifact_run_id:
description: GitHub Actions run id when source=artifact
required: false
default: ""
type: string
artifact_name:
description: Artifact name containing one .tgz when source=artifact
required: false
default: package-under-test
type: string
suite_profile:
description: "Acceptance profile: smoke, package, product, full, or custom"
required: false
default: package
type: string
docker_lanes:
description: Comma/space separated Docker lanes when suite_profile=custom
required: false
default: ""
type: string
telegram_mode:
description: Optional Telegram QA lane for the resolved package candidate
required: false
default: none
type: string
secrets:
OPENAI_API_KEY:
required: false
OPENAI_BASE_URL:
required: false
ANTHROPIC_API_KEY:
required: false
ANTHROPIC_API_KEY_OLD:
required: false
ANTHROPIC_API_TOKEN:
required: false
BYTEPLUS_API_KEY:
required: false
CEREBRAS_API_KEY:
required: false
DASHSCOPE_API_KEY:
required: false
GROQ_API_KEY:
required: false
KIMI_API_KEY:
required: false
MODELSTUDIO_API_KEY:
required: false
MOONSHOT_API_KEY:
required: false
MISTRAL_API_KEY:
required: false
MINIMAX_API_KEY:
required: false
OPENCODE_API_KEY:
required: false
OPENCODE_ZEN_API_KEY:
required: false
OPENCLAW_LIVE_BROWSER_CDP_URL:
required: false
OPENCLAW_LIVE_SETUP_TOKEN:
required: false
OPENCLAW_LIVE_SETUP_TOKEN_MODEL:
required: false
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE:
required: false
OPENCLAW_LIVE_SETUP_TOKEN_VALUE:
required: false
GEMINI_API_KEY:
required: false
GOOGLE_API_KEY:
required: false
OPENROUTER_API_KEY:
required: false
QWEN_API_KEY:
required: false
FAL_KEY:
required: false
RUNWAY_API_KEY:
required: false
DEEPGRAM_API_KEY:
required: false
TOGETHER_API_KEY:
required: false
VYDRA_API_KEY:
required: false
XAI_API_KEY:
required: false
ZAI_API_KEY:
required: false
Z_AI_API_KEY:
required: false
BYTEPLUS_ACCESS_KEY_ID:
required: false
BYTEPLUS_SECRET_ACCESS_KEY:
required: false
CLAUDE_CODE_OAUTH_TOKEN:
required: false
OPENCLAW_CODEX_AUTH_JSON:
required: false
OPENCLAW_CODEX_CONFIG_TOML:
required: false
OPENCLAW_CLAUDE_JSON:
required: false
OPENCLAW_CLAUDE_CREDENTIALS_JSON:
required: false
OPENCLAW_CLAUDE_SETTINGS_JSON:
required: false
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON:
required: false
OPENCLAW_GEMINI_SETTINGS_JSON:
required: false
FIREWORKS_API_KEY:
required: false
OPENCLAW_QA_CONVEX_SITE_URL:
required: false
OPENCLAW_QA_CONVEX_SECRET_CI:
required: false
permissions:
actions: read
contents: read
packages: write
pull-requests: read
concurrency:
group: package-acceptance-${{ github.run_id }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
PNPM_VERSION: "10.33.0"
PACKAGE_ARTIFACT_NAME: package-under-test
jobs:
resolve_package:
name: Resolve package candidate
runs-on: ubuntu-24.04
timeout-minutes: 60
outputs:
docker_lanes: ${{ steps.profile.outputs.docker_lanes }}
include_live_suites: ${{ steps.profile.outputs.include_live_suites }}
include_openwebui: ${{ steps.profile.outputs.include_openwebui }}
include_release_path_suites: ${{ steps.profile.outputs.include_release_path_suites }}
package_artifact_name: ${{ steps.profile.outputs.package_artifact_name }}
package_sha256: ${{ steps.resolve.outputs.sha256 }}
package_version: ${{ steps.resolve.outputs.package_version }}
telegram_enabled: ${{ steps.profile.outputs.telegram_enabled }}
telegram_mode: ${{ steps.profile.outputs.telegram_mode }}
steps:
- name: Checkout package workflow ref
uses: actions/checkout@v6
with:
ref: ${{ inputs.workflow_ref }}
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: ${{ inputs.source == 'ref' && 'true' || 'false' }}
install-deps: "false"
- name: Download package artifact input
if: inputs.source == 'artifact'
env:
GH_TOKEN: ${{ github.token }}
ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }}
ARTIFACT_NAME: ${{ inputs.artifact_name }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${ARTIFACT_RUN_ID// }" ]]; then
echo "artifact_run_id is required when source=artifact." >&2
exit 1
fi
if [[ -z "${ARTIFACT_NAME// }" ]]; then
echo "artifact_name is required when source=artifact." >&2
exit 1
fi
mkdir -p .artifacts/package-candidate-input
gh run download "$ARTIFACT_RUN_ID" -n "$ARTIFACT_NAME" -D .artifacts/package-candidate-input
- name: Resolve package candidate
id: resolve
env:
SOURCE: ${{ inputs.source }}
PACKAGE_REF: ${{ inputs.package_ref }}
PACKAGE_SPEC: ${{ inputs.package_spec }}
PACKAGE_URL: ${{ inputs.package_url }}
PACKAGE_SHA256: ${{ inputs.package_sha256 }}
shell: bash
run: |
set -euo pipefail
artifact_dir=""
if [[ "$SOURCE" == "artifact" ]]; then
artifact_dir=".artifacts/package-candidate-input"
fi
node scripts/resolve-openclaw-package-candidate.mjs \
--source "$SOURCE" \
--package-ref "$PACKAGE_REF" \
--package-spec "$PACKAGE_SPEC" \
--package-url "$PACKAGE_URL" \
--package-sha256 "$PACKAGE_SHA256" \
--artifact-dir "${artifact_dir:-.}" \
--output-dir .artifacts/docker-e2e-package \
--output-name openclaw-current.tgz \
--metadata .artifacts/docker-e2e-package/package-candidate.json \
--github-output "$GITHUB_OUTPUT"
- name: Select acceptance profile
id: profile
env:
SOURCE: ${{ inputs.source }}
SUITE_PROFILE: ${{ inputs.suite_profile }}
CUSTOM_DOCKER_LANES: ${{ inputs.docker_lanes }}
TELEGRAM_MODE: ${{ inputs.telegram_mode }}
shell: bash
run: |
set -euo pipefail
include_release_path_suites=false
include_openwebui=false
include_live_suites=false
docker_lanes=""
case "$SUITE_PROFILE" in
smoke)
docker_lanes="npm-onboard-channel-agent gateway-network config-reload"
;;
package)
docker_lanes="install-e2e npm-onboard-channel-agent doctor-switch update-channel-switch bundled-channel-deps plugins plugin-update"
;;
product)
docker_lanes="install-e2e npm-onboard-channel-agent doctor-switch update-channel-switch bundled-channel-deps plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui"
include_openwebui=true
;;
full)
include_release_path_suites=true
include_openwebui=true
;;
custom)
docker_lanes="$CUSTOM_DOCKER_LANES"
if [[ -z "${docker_lanes// }" ]]; then
echo "docker_lanes is required when suite_profile=custom." >&2
exit 1
fi
if [[ "$docker_lanes" == *"openwebui"* ]]; then
include_openwebui=true
fi
;;
*)
echo "Unknown suite_profile: $SUITE_PROFILE" >&2
exit 1
;;
esac
telegram_enabled=false
if [[ "$TELEGRAM_MODE" != "none" ]]; then
telegram_enabled=true
fi
{
echo "docker_lanes=$docker_lanes"
echo "include_release_path_suites=$include_release_path_suites"
echo "include_openwebui=$include_openwebui"
echo "include_live_suites=$include_live_suites"
echo "telegram_enabled=$telegram_enabled"
echo "telegram_mode=$TELEGRAM_MODE"
echo "package_artifact_name=${PACKAGE_ARTIFACT_NAME}"
} >> "$GITHUB_OUTPUT"
- name: Upload package-under-test artifact
uses: actions/upload-artifact@v7
with:
name: ${{ env.PACKAGE_ARTIFACT_NAME }}
path: |
.artifacts/docker-e2e-package/openclaw-current.tgz
.artifacts/docker-e2e-package/package-candidate.json
retention-days: 14
if-no-files-found: error
- name: Summarize package candidate
env:
PACKAGE_SHA256: ${{ steps.resolve.outputs.sha256 }}
PACKAGE_VERSION: ${{ steps.resolve.outputs.package_version }}
PACKAGE_REF: ${{ inputs.package_ref }}
SOURCE: ${{ inputs.source }}
SUITE_PROFILE: ${{ inputs.suite_profile }}
WORKFLOW_REF: ${{ inputs.workflow_ref }}
shell: bash
run: |
{
echo "## Package acceptance"
echo
echo "- Source: \`${SOURCE}\`"
echo "- Workflow ref: \`${WORKFLOW_REF}\`"
if [[ "${SOURCE}" == "ref" ]]; then
echo "- Package ref: \`${PACKAGE_REF}\`"
fi
echo "- Version: \`${PACKAGE_VERSION}\`"
echo "- SHA-256: \`${PACKAGE_SHA256}\`"
echo "- Profile: \`${SUITE_PROFILE}\`"
} >> "$GITHUB_STEP_SUMMARY"
docker_acceptance:
name: Docker product acceptance
needs: resolve_package
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
ref: ${{ inputs.workflow_ref }}
include_repo_e2e: false
include_release_path_suites: ${{ needs.resolve_package.outputs.include_release_path_suites == 'true' }}
include_openwebui: ${{ needs.resolve_package.outputs.include_openwebui == 'true' }}
docker_lanes: ${{ needs.resolve_package.outputs.docker_lanes }}
package_artifact_name: ${{ needs.resolve_package.outputs.package_artifact_name }}
include_live_suites: ${{ needs.resolve_package.outputs.include_live_suites == 'true' }}
live_models_only: false
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_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 }}
package_telegram:
name: Telegram package acceptance
needs: resolve_package
if: needs.resolve_package.outputs.telegram_enabled == 'true'
uses: ./.github/workflows/npm-telegram-beta-e2e.yml
with:
package_spec: ${{ inputs.package_spec }}
package_artifact_name: ${{ needs.resolve_package.outputs.package_artifact_name }}
package_label: openclaw@${{ needs.resolve_package.outputs.package_version }}
provider_mode: ${{ needs.resolve_package.outputs.telegram_mode }}
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
summary:
name: Verify package acceptance
needs: [resolve_package, docker_acceptance, package_telegram]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify package acceptance results
env:
DOCKER_RESULT: ${{ needs.docker_acceptance.result }}
PACKAGE_TELEGRAM_RESULT: ${{ needs.package_telegram.result }}
RESOLVE_RESULT: ${{ needs.resolve_package.result }}
shell: bash
run: |
set -euo pipefail
failed=0
for item in \
"resolve_package=${RESOLVE_RESULT}" \
"docker_acceptance=${DOCKER_RESULT}" \
"package_telegram=${PACKAGE_TELEGRAM_RESULT}"
do
name="${item%%=*}"
result="${item#*=}"
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
echo "::error::${name} ended with ${result}"
failed=1
fi
done
exit "$failed"

View File

@@ -117,6 +117,7 @@ Telegraph style. Root rules only. Read scoped `AGENTS.md` before subtree work.
## Tests
- Vitest. Colocated `*.test.ts`; e2e `*.e2e.test.ts`; example models `sonnet-4.6`, `gpt-5.4`.
- Avoid brittle tests that grep workflow/docs strings for operator policy. Prefer executable behavior, parsed config/schema checks, or live run proof; put release/CI policy reminders in AGENTS/docs instead.
- Clean timers/env/globals/mocks/sockets/temp dirs/module state; `--isolate=false` safe.
- Hot tests: avoid per-test `vi.resetModules()` + heavy imports. Measure with `pnpm test:perf:imports <file>` / `pnpm test:perf:hotspots --limit N`.
- Seam depth: pure helper/contract unit tests; one integration smoke per boundary.

View File

@@ -6,22 +6,25 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/Bonjour: keep @homebridge/ciao cancellation handlers registered across advertiser restarts so late probing cancellations cannot crash Linux and other mDNS-churned gateways. Thanks @codex.
- Plugins/startup: load the default `memory-core` slot during Gateway startup when permitted so active-memory recall can call `memory_search` and `memory_get` without requiring an explicit `plugins.slots.memory` entry, while preserving `plugins.slots.memory: "none"`. Thanks @codex.
- Auto-reply: poison inbound message dedupe after replay-unsafe provider/runtime failures so retries stay safe before visible progress but cannot duplicate messages after block output, tool side effects, or session progress. Fixes #69303; keeps #58549 and #64606 as duplicate validation. Thanks @martingarramon, @NikolaFC, and @zeroth-blip.
- Gateway/Bonjour: keep @homebridge/ciao cancellation handlers registered across advertiser restarts so late probing cancellations cannot crash Linux and other mDNS-churned gateways.
- Plugins/startup: load the default `memory-core` slot during Gateway startup when permitted so active-memory recall can call `memory_search` and `memory_get` without requiring an explicit `plugins.slots.memory` entry, while preserving `plugins.slots.memory: "none"`.
- Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet.
- Plugins/compat: inventory doctor-side deprecation migrations separately from runtime plugin compatibility so release sweeps preserve needed repairs while enforcing dated removal windows. Thanks @vincentkoc.
- Plugins/compat: add missing dated compatibility records for legacy extension-api, memory registration, provider hook/type aliases, runtime aliases, channel SDK helpers, and approval/test utility shims. Thanks @vincentkoc.
- Plugins/CLI: refresh the persisted registry after managed plugin files are removed so ClawHub uninstall cannot leave stale `plugins list` entries. Thanks @codex.
- Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds. Thanks @codex.
- Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries. Thanks @codex.
- Plugins/CLI: refresh the persisted registry after managed plugin files are removed so ClawHub uninstall cannot leave stale `plugins list` entries.
- Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds.
- Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries.
- WebChat/Control UI: support non-video file attachments in chat uploads while preserving the existing image attachment path and MIME-sniff fallback for generic image uploads. (#70947) Thanks @IAMSamuelRodda.
- Gateway/chat: keep duplicate attachment-backed `chat.send` retries with the same idempotency key on the documented in-flight path so aborts still target the real active run. Fixes #70139. Thanks @Feelw00.
- Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans. Thanks @codex.
- Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans.
- WhatsApp/Web: keep quiet but healthy linked-device sessions connected by basing the watchdog on WhatsApp Web transport activity, while retaining a longer app-silence cap so frame activity cannot mask a stuck session forever. Fixes #70678; carries forward the focused #71466 approach and keeps #63939 as related configurable-timeout follow-up. Thanks @vincentkoc and @oromeis.
## 2026.4.26
### Fixes
- Plugins/CLI: let flag-driven `openclaw channels add` install the selected channel plugin from its default source without opening an interactive prompt, fixing published npm Telegram setup in stdin-closed automation. Thanks @codex.
- Plugins/CLI: let flag-driven `openclaw channels add` install the selected channel plugin from its default source without opening an interactive prompt, fixing published npm Telegram setup in stdin-closed automation.
- Onboarding/setup: keep first-run config reads, plugin compatibility notices, and post-model sanity checks on cold metadata paths unless the user chooses to browse all models, avoiding full plugin/runtime catalog work between prompts. Thanks @shakkernerd.
- Onboarding/auth: run manifest-owned provider auth choices through scoped setup providers so selecting OpenAI Codex browser/device auth no longer loads every provider runtime before OAuth starts. Thanks @shakkernerd.
- Onboarding/auth: keep the post-auth default-model policy lookup on manifest/setup metadata so the next prompt appears without loading broad provider runtime. Thanks @shakkernerd.
@@ -118,13 +121,38 @@ Docs: https://docs.openclaw.ai
### Fixes
- Auto-reply: poison inbound message dedupe after replay-unsafe provider/runtime failures so retries stay safe before visible progress but cannot duplicate messages after block output, tool side effects, or session progress. Fixes #69303; keeps #58549 and #64606 as duplicate validation. Thanks @martingarramon, @NikolaFC, and @zeroth-blip.
- Logging/sessions: apply configured redaction patterns to persisted session transcript text and accept escaped character classes in safe custom redaction regexes, so transcript JSONL no longer keeps matching sensitive text in the clear. Fixes #42982. Thanks @panpan0000.
- Agents/OpenAI: keep Responses web search compatible with minimal thinking by raising `web_search` requests to the lowest supported reasoning effort instead of sending a rejected minimal payload.
- Agents/tools: honor the `bundle-mcp` allowlist token when deciding whether bundled MCP tools are available, so restricted tool policies can still enable bundled MCP without exposing unrelated tools.
- Agents/model fallback: jump directly to a known later live-session model redirect instead of walking unrelated fallback candidates, while preserving the already-landed live-session/fallback loop guard. Fixes #57471; related loop family already closed via #58496. Thanks @yuxiaoyang2007-prog.
- Skills/memory: restore Chokidar v5 hot reloads by watching concrete skill and memory roots with filters, including SKILL.md removals and deleted skill folders without broad workspace recursion. Fixes #27404, #33585, and #41606. Thanks @shelvenzhou, @08820048, and @rocke2020.
- 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.
- Plugins/CLI: let flag-driven `openclaw channels add` install the selected channel plugin from its default source without opening an interactive prompt, fixing published npm Telegram setup in stdin-closed automation.
- Plugins/startup: load the default `memory-core` slot during Gateway startup when permitted so active-memory recall can call `memory_search` and `memory_get` without requiring an explicit `plugins.slots.memory` entry, while preserving `plugins.slots.memory: "none"`.
- Plugins/install: materialize plugin-owned root chunks in external bundled-runtime mirrors so staged plugin dependencies resolve under native ESM in packaged installs. Fixes #72058; supersedes #72084. Thanks @amnesia106 and @drvoss.
- Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet.
- Plugins/compat: inventory doctor-side deprecation migrations separately from runtime plugin compatibility so release sweeps preserve needed repairs while enforcing dated removal windows. Thanks @vincentkoc.
- Plugins/compat: add missing dated compatibility records for legacy extension-api, memory registration, provider hook/type aliases, runtime aliases, channel SDK helpers, and approval/test utility shims. Thanks @vincentkoc.
- Plugins/CLI: refresh the persisted registry after managed plugin files are removed so ClawHub uninstall cannot leave stale `plugins list` entries.
- Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds.
- Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries.
- Gateway/chat: keep duplicate attachment-backed `chat.send` retries with the same idempotency key on the documented in-flight path so aborts still target the real active run. Fixes #70139. Thanks @Feelw00.
- Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans.
- WhatsApp/Web: keep quiet but healthy linked-device sessions connected by basing the watchdog on WhatsApp Web transport activity, while retaining a longer app-silence cap so frame activity cannot mask a stuck session forever. Fixes #70678; carries forward the focused #71466 approach and keeps #63939 as related configurable-timeout follow-up. Thanks @vincentkoc and @oromeis.
- Onboarding/setup: keep first-run config reads, plugin compatibility notices, and post-model sanity checks on cold metadata paths unless the user chooses to browse all models, avoiding full plugin/runtime catalog work between prompts. Thanks @shakkernerd.
- Onboarding/auth: run manifest-owned provider auth choices through scoped setup providers so selecting OpenAI Codex browser/device auth no longer loads every provider runtime before OAuth starts. Thanks @shakkernerd.
- Onboarding/auth: keep the post-auth default-model policy lookup on manifest/setup metadata so the next prompt appears without loading broad provider runtime. Thanks @shakkernerd.
- Onboarding/models: keep skip-auth and provider-scoped model picker prompts off the full global model catalog path, and cache provider catalog hook resolution so setup no longer stalls after auth on large plugin registries. Thanks @shakkernerd.
- Gateway/Bonjour: suppress known @homebridge/ciao cancellation and network assertion failures through scoped process handlers so malformed mDNS packets or restricted VPS networking disable/restart Bonjour instead of crashing the gateway. Fixes #67578. Thanks @zenassist26-create.
- Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse.
- Agents/subagents: deliver completed yielded-subagent results back to no-thread requester routes via direct fallback when the dormant parent announce turn produces no visible reply, and add QA-lab coverage for the regression. Thanks @vincentkoc.
- Gateway/Tailscale: let Tailscale-authenticated Control UI operator sessions with browser device identity skip the device-pairing round trip while still rejecting device-less and node-role connections. Refs #71986. Thanks @jokedul.
- Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd.
- CLI/update: run package post-update doctor with `--fix` so package updates repair config migrations before restart. Thanks @shakkernerd.
- CLI/update: retry failed npm global updates with `--omit=optional` and ignore the superseded first failure when the fallback succeeds. Thanks @shakkernerd.
- Plugins/uninstall: migrate and reset `plugins.slots.contextEngine` alongside memory slots when plugin ids change or selected plugins are removed. Thanks @shakkernerd.
- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex.
- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled.
- UI/Windows: quote resolved pnpm `.cmd` launcher paths before spawning UI install/build/test commands so Node installs under `C:\Program Files` no longer fail as `C:\Program`. Fixes #45275. Thanks @Kobevictor, @stoppieboy, and @iubns.
- Codex/agent: translate `--thinking minimal` to `low` for modern Codex models (gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.2) at request build time so the first turn is accepted instead of paying a wasted call + retry-with-low fallback. Older Codex models still receive `minimal` directly. Fixes #71946. Thanks @hclsys.
- Plugins/uninstall: remove tracked plugin files from their recorded managed extensions root even when the current state directory points somewhere else, so `openclaw plugins uninstall --force` does not leave the plugin discoverable. Thanks @shakkernerd.
@@ -226,7 +254,7 @@ Docs: https://docs.openclaw.ai
- macOS/remote SSH: keep discovered gateway hosts in `gateway.remote.sshTarget` while pinning SSH transport URLs to the local loopback tunnel, so browser automation does not regress into blocked non-loopback `ws://` endpoints. Fixes #67336.
- Gateway/proxy: bootstrap env proxy dispatching from direct Gateway startup so provider and plugin network requests honor `HTTPS_PROXY`/`HTTP_PROXY` before the first embedded agent attempt runs. (#71833) Thanks @mjamiv.
- Plugins/runtime deps: verify clean npm installs actually place requested bundled runtime packages in the managed install root, reporting exact missing specs instead of a false successful repair. (#71883) Thanks @Solvely-Colin.
- Plugins/discovery: ignore stale `plugins.load.paths` aliases that point back at packaged bundled plugin directories and have doctor remove them, keeping bundled plugins on the runtime-deps staging path. Thanks @codex.
- Plugins/discovery: ignore stale `plugins.load.paths` aliases that point back at packaged bundled plugin directories and have doctor remove them, keeping bundled plugins on the runtime-deps staging path.
- Models/LM Studio: preserve `@iq*` quant suffixes in model refs and provider matching so `/model lmstudio/...@iq3_xxs` keeps the exact LM Studio variant. Fixes #71474. (#71486) Thanks @Bartok9, @XinwuC, and @Sanjays2402.
- Matrix/cron: preserve the live Matrix delivery target when creating implicit announce reminder jobs so mixed-case room IDs are not reconstructed from lowercased session keys. Fixes #71798.
- Feishu: accept Schema 2.0 card action callbacks that report `context.open_chat_id` instead of legacy `context.chat_id`, so button callbacks no longer drop as malformed. Fixes #71670. Thanks @eddy1068.
@@ -4584,7 +4612,7 @@ Docs: https://docs.openclaw.ai
- Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example `c0abc12345`) correctly match Slack runtime IDs (`C0ABC12345`) under `groupPolicy: "allowlist"`, preventing silent channel-event drops. (#26878) Thanks @lbo728.
- Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman.
- Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r.
- Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW.
- Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881)
- Voice-call/TTS tools: hide the `tts` tool when the message provider is `voice`, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025).
- Agents/Tools: normalize non-standard plugin tool results that omit `content` so embedded runs no longer crash with `Cannot read properties of undefined (reading 'filter')` after tool completion (including `tesseramemo_query`). (#27007).
- Agents/Tool-call dispatch: trim whitespace-padded tool names in both transcript repair and live streamed embedded-runner responses so exact-match tool lookup no longer fails with `Tool .. not found` for model outputs like `" read "`. (#27094) Thanks @openperf and @Sid-Qin.

View File

@@ -65,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026042600
versionName = "2026.4.26"
versionCode = 2026042500
versionName = "2026.4.25"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -1,9 +1,5 @@
# OpenClaw iOS Changelog
## 2026.4.26 - 2026-04-26
Maintenance update for the current OpenClaw development release.
## 2026.4.25 - 2026-04-25
Maintenance update for the current OpenClaw development release.

View File

@@ -2,8 +2,8 @@
// Source of truth: apps/ios/version.json
// Generated by scripts/ios-sync-versioning.ts.
OPENCLAW_IOS_VERSION = 2026.4.26
OPENCLAW_MARKETING_VERSION = 2026.4.26
OPENCLAW_IOS_VERSION = 2026.4.25
OPENCLAW_MARKETING_VERSION = 2026.4.25
OPENCLAW_BUILD_VERSION = 1
#include? "../build/Version.xcconfig"

View File

@@ -1,3 +1,3 @@
{
"version": "2026.4.26"
"version": "2026.4.25"
}

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.4.26</string>
<string>2026.4.25</string>
<key>CFBundleVersion</key>
<string>2026042600</string>
<string>2026042500</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -1,4 +1,4 @@
7fa6e35bb9f9d3096d6281f141488be0dcfe15de40dc4f5c0305eb1ff2bc60b6 config-baseline.json
5f5fb87fd46f9cbb84d8af17e00ae3c4b74062e8ad517bc2260ba83da2e9014f config-baseline.core.json
7cd9c908f066c143eab2a201efbc9640f483ab28bba92ddeca1d18cc2b528bc3 config-baseline.channel.json
a62ead999508b18d9ea3e1c129e3cdd44244af0ff0e6f81653dfced9aa52019a config-baseline.json
3245c9a013c55ee8a24db52d5e88c42bc86e26f822d4a144fc7f37fc71e05fa8 config-baseline.core.json
080c0a4f2d4175d6d7ab1e38f76b21de32669055c518d75c96e784865d89bf25 config-baseline.channel.json
f9e0174988718959fe1923a54496ec5b9262721fe1e7306f32ccb1316d9d9c3f config-baseline.plugin.json

View File

@@ -146,6 +146,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch
## Runtime model
- Gateway owns the WhatsApp socket and reconnect loop.
- The reconnect watchdog uses WhatsApp Web transport activity, not only inbound app-message volume, so a quiet linked-device session is not restarted solely because nobody has sent a message recently. A longer application-silence cap still forces a reconnect if transport frames keep arriving but no application messages are handled for the watchdog window.
- Outbound sends require an active WhatsApp listener for the target account.
- Status and broadcast chats are ignored (`@status`, `@broadcast`).
- Direct chats use DM session rules (`session.dmScope`; default `main` collapses DMs to the agent main session).
@@ -510,6 +511,10 @@ Behavior notes:
<Accordion title="Linked but disconnected / reconnect loop">
Symptom: linked account with repeated disconnects or reconnect attempts.
Quiet accounts can stay connected past the normal message timeout; the watchdog
restarts when WhatsApp Web transport activity stops, the socket closes, or
application-level activity stays silent beyond the longer safety window.
Fix:
```bash

View File

@@ -92,7 +92,7 @@ Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests
CI workflow edits validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
CI routing-only edits, selected cheap core-test fixture edits, and narrow plugin contract helper/test-routing edits use a fast Node-only manifest path: preflight, security, and a single `checks-fast-core` task. That path avoids build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the changed files are limited to the routing or helper surfaces that the fast task exercises directly.
Windows Node checks are scoped to Windows-specific process/path wrappers, npm/pnpm/UI runner helpers, package manager config, and the CI workflow surfaces that execute that lane; unrelated source, plugin, install-smoke, and test-only changes stay on the Linux Node lanes so they do not reserve a 16-vCPU Windows worker for coverage that is already exercised by the normal test shards.
The separate `install-smoke` workflow reuses the same scope script through its own `preflight` job. It splits smoke coverage into `run_fast_install_smoke` and `run_full_install_smoke`. Pull requests run the fast path for Docker/package surfaces, bundled plugin package/manifest changes, and core plugin/channel/gateway/Plugin SDK surfaces that the Docker smoke jobs exercise. Source-only bundled plugin changes, test-only edits, and docs-only edits do not reserve Docker workers. The fast path builds the root Dockerfile image once, checks the CLI, runs the agents delete shared-workspace CLI smoke, runs the container gateway-network e2e, verifies a bundled extension build arg, and runs the bounded bundled-plugin Docker profile under a 240-second aggregate command timeout with each scenario's Docker run capped separately. The full path keeps QR package install and installer Docker/update coverage for nightly scheduled runs, manual dispatches, workflow-call release checks, and pull requests that truly touch installer/package/Docker surfaces. `main` pushes, including merge commits, do not force the full path; when changed-scope logic would request full coverage on a push, the workflow keeps the fast Docker smoke and leaves the full install smoke to nightly or release validation. The slow Bun global install image-provider smoke is separately gated by `run_bun_global_install_smoke`; it runs on the nightly schedule and from the release checks workflow, and manual `install-smoke` dispatches can opt into it, but pull requests and `main` pushes do not run it. QR and installer Docker tests keep their own install-focused Dockerfiles. Local `test:docker:all` prebuilds one shared live-test image and one shared `scripts/e2e/Dockerfile` built-app image, then runs the live/E2E smoke lanes with a weighted scheduler and `OPENCLAW_SKIP_DOCKER_BUILD=1`; tune the default main-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_PARALLELISM` and the provider-sensitive tail-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM`. Heavy lane caps default to `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=6`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=8`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7` so npm install and multi-service lanes do not overcommit Docker while lighter lanes still fill available slots. Lane starts are staggered by 2 seconds by default to avoid local Docker daemon create storms; override with `OPENCLAW_DOCKER_ALL_START_STAGGER_MS=0` or another millisecond value. The local aggregate preflights Docker, removes stale OpenClaw E2E containers, emits active-lane status, persists lane timings for longest-first ordering, and supports `OPENCLAW_DOCKER_ALL_DRY_RUN=1` for scheduler inspection. It stops scheduling new pooled lanes after the first failure by default, and each lane has a 120-minute fallback timeout overrideable with `OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS`; selected live/tail lanes use tighter per-lane caps. The reusable live/E2E workflow mirrors the shared-image pattern by building and pushing one SHA-tagged GHCR Docker E2E image before the Docker matrix, then running the matrix with `OPENCLAW_SKIP_DOCKER_BUILD=1`. The scheduled live/E2E workflow runs the full release-path Docker suite daily. The bundled update matrix is split by update target so repeated npm update and doctor repair passes can shard with other bundled checks.
The separate `install-smoke` workflow reuses the same scope script through its own `preflight` job. It splits smoke coverage into `run_fast_install_smoke` and `run_full_install_smoke`. Pull requests run the fast path for Docker/package surfaces, bundled plugin package/manifest changes, and core plugin/channel/gateway/Plugin SDK surfaces that the Docker smoke jobs exercise. Source-only bundled plugin changes, test-only edits, and docs-only edits do not reserve Docker workers. The fast path builds the root Dockerfile image once, checks the CLI, runs the agents delete shared-workspace CLI smoke, runs the container gateway-network e2e, verifies a bundled extension build arg, and runs the bounded bundled-plugin Docker profile under a 240-second aggregate command timeout with each scenario's Docker run capped separately. The full path keeps QR package install and installer Docker/update coverage for nightly scheduled runs, manual dispatches, workflow-call release checks, and pull requests that truly touch installer/package/Docker surfaces. `main` pushes, including merge commits, do not force the full path; when changed-scope logic would request full coverage on a push, the workflow keeps the fast Docker smoke and leaves the full install smoke to nightly or release validation. The slow Bun global install image-provider smoke is separately gated by `run_bun_global_install_smoke`; it runs on the nightly schedule and from the release checks workflow, and manual `install-smoke` dispatches can opt into it, but pull requests and `main` pushes do not run it. QR and installer Docker tests keep their own install-focused Dockerfiles. Local `test:docker:all` prebuilds one shared live-test image and one shared `scripts/e2e/Dockerfile` built-app image, then runs the live/E2E smoke lanes with a weighted scheduler and `OPENCLAW_SKIP_DOCKER_BUILD=1`; tune the default main-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_PARALLELISM` and the provider-sensitive tail-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM`. Heavy lane caps default to `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=6`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=8`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7` so npm install and multi-service lanes do not overcommit Docker while lighter lanes still fill available slots. Lane starts are staggered by 2 seconds by default to avoid local Docker daemon create storms; override with `OPENCLAW_DOCKER_ALL_START_STAGGER_MS=0` or another millisecond value. The local aggregate preflights Docker, removes stale OpenClaw E2E containers, emits active-lane status, persists lane timings for longest-first ordering, and supports `OPENCLAW_DOCKER_ALL_DRY_RUN=1` for scheduler inspection. It stops scheduling new pooled lanes after the first failure by default, and each lane has a 120-minute fallback timeout overrideable with `OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS`; selected live/tail lanes use tighter per-lane caps. `OPENCLAW_DOCKER_ALL_LANES=<lane[,lane]>` runs exact scheduler lanes, including release-only lanes such as `install-e2e` and split bundled update lanes such as `bundled-channel-update-acpx`, while skipping the cleanup smoke so agents can reproduce one failed lane. The reusable live/E2E workflow builds and pushes one SHA-tagged GHCR Docker E2E image, then runs the release-path Docker suite as at most three chunked jobs with `OPENCLAW_SKIP_DOCKER_BUILD=1` so each chunk pulls the shared image once and executes multiple lanes through the same weighted scheduler (`OPENCLAW_DOCKER_ALL_PROFILE=release-path`, `OPENCLAW_DOCKER_ALL_CHUNK=core|package-update|plugins-integrations`). Each chunk uploads `.artifacts/docker-tests/` with lane logs, timings, `summary.json`, and per-lane rerun commands. The workflow `docker_lanes` input runs selected lanes against the prepared image instead of the three chunk jobs, which keeps failed-lane debugging bounded to one targeted Docker job; if a selected lane is a live Docker lane, the targeted job builds the live-test image locally for that rerun. When Open WebUI is requested with the release-path suite, it runs inside the plugins/integrations chunk instead of reserving a fourth Docker worker; Open WebUI keeps a standalone job only for openwebui-only dispatches. The scheduled live/E2E workflow runs the full release-path Docker suite daily. The bundled update matrix is split by update target so repeated npm update and doctor repair passes can shard with other bundled checks.
Local changed-lane logic lives in `scripts/changed-lanes.mjs` and is executed by `scripts/check-changed.mjs`. That local gate is stricter about architecture boundaries than the broad CI platform scope: core production changes run core prod typecheck plus core tests, core test-only changes run only core test typecheck/tests, extension production changes run extension prod typecheck plus extension tests, and extension test-only changes run only extension test typecheck/tests. Public Plugin SDK or plugin-contract changes expand to extension validation because extensions depend on those core contracts. Release metadata-only version bumps run targeted version/config/root-dependency checks. Unknown root/config changes fail safe to all lanes.

View File

@@ -859,6 +859,7 @@ Notes:
- Set `logging.file` for a stable path.
- `consoleLevel` bumps to `debug` when `--verbose`.
- `maxFileBytes`: maximum active log file size in bytes before rotation (positive integer; default: `104857600` = 100 MB). OpenClaw keeps up to five numbered archives beside the active file.
- `redactSensitive` / `redactPatterns`: best-effort masking for console output, file logs, OTLP log records, and persisted session transcript text.
---

View File

@@ -52,10 +52,12 @@ You can tune console verbosity independently via:
- `logging.consoleLevel` (default `info`)
- `logging.consoleStyle` (`pretty` | `compact` | `json`)
## Tool summary redaction
## Redaction
Verbose tool summaries (e.g. `🛠️ Exec: ...`) can mask sensitive tokens before they hit the
console stream. This is **tools-only** and does not alter file logs.
OpenClaw can mask sensitive tokens before log or transcript output leaves the
process. The same redaction policy is applied at console, file-log, OTLP
log-record, and session transcript text sinks, so matching secret values are
masked before JSONL lines or messages are written to disk.
- `logging.redactSensitive`: `off` | `tools` (default: `tools`)
- `logging.redactPatterns`: array of regex strings (overrides defaults)

View File

@@ -999,7 +999,7 @@ Logs and transcripts can leak sensitive info even when access controls are corre
Recommendations:
- Keep tool summary redaction on (`logging.redactSensitive: "tools"`; default).
- Keep log and transcript redaction on (`logging.redactSensitive: "tools"`; default).
- Add custom patterns for your environment via `logging.redactPatterns` (tokens, hostnames, internal URLs).
- When sharing diagnostics, prefer `openclaw status --all` (pasteable, secrets redacted) over raw logs.
- Prune old session transcripts and log files if you dont need long retention.

View File

@@ -227,10 +227,12 @@ Notes:
- `OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL=gpt-5.2`
- `OPENCLAW_LIVE_ACP_BIND_OPENCODE_MODEL=opencode/kimi-k2.6`
- `OPENCLAW_LIVE_ACP_BIND_REQUIRE_TRANSCRIPT=1`
- `OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON=1`
- `OPENCLAW_LIVE_ACP_BIND_PARENT_MODEL=openai/gpt-5.2`
- Notes:
- This lane uses the gateway `chat.send` surface with admin-only synthetic originating-route fields so tests can attach message-channel context without pretending to deliver externally.
- When `OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND` is unset, the test uses the embedded `acpx` plugin's built-in agent registry for the selected ACP harness agent.
- Bound-session cron MCP creation is best-effort by default because external ACP harnesses can cancel MCP calls after the bind/image proof has passed; set `OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON=1` to make that post-bind cron probe strict.
Example:

View File

@@ -167,14 +167,16 @@ file log levels.
### Redaction
Tool summaries can redact sensitive tokens before they hit the console:
OpenClaw can redact sensitive tokens before they hit console output, file logs,
OTLP log records, or persisted session transcript text:
- `logging.redactSensitive`: `off` | `tools` (default: `tools`)
- `logging.redactPatterns`: list of regex strings to override the default set
Redaction applies at the logging sinks for **console output**, **stderr-routed
console diagnostics**, and **file logs**. File logs stay JSONL, but matching
secret values are masked before the line is written to disk.
File logs and session transcripts stay JSONL, but matching secret values are
masked before the line or message is written to disk. Redaction is best-effort:
it applies to text-bearing message content and log strings, not every
identifier or binary payload field.
## Diagnostics and OpenTelemetry

View File

@@ -30,8 +30,9 @@ OpenClaw has three public release lanes:
## Release cadence
- Releases move beta-first
- Stable follows only after the latest beta is validated
- Releases move tarball-first: maintainers validate the prepared npm tarball
before publishing it to npm
- Stable follows only after the latest prepared/published candidate is validated
- Maintainers normally cut releases from a `release/YYYY.M.D` branch created
from current `main`, so release validation and fixes do not block new
development on `main`
@@ -88,18 +89,25 @@ OpenClaw has three public release lanes:
- npm release preflight no longer waits on the separate release checks lane
- Run `RELEASE_TAG=vYYYY.M.D node --import tsx scripts/openclaw-npm-release-check.ts`
(or the matching beta/correction tag) before approval
- Expensive release validation should target the prepared npm tarball from the
successful preflight run before npm publish. The `OpenClaw NPM Release`
preflight uploads an `openclaw-npm-preflight-<tag-or-sha>` artifact containing
the exact `.tgz` that the real publish job later promotes.
- After npm publish, run
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts YYYY.M.D`
(or the matching beta/correction version) to verify the published registry
install path in a fresh temp prefix
- After a beta publish, run `OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC=openclaw@YYYY.M.D-beta.N OPENCLAW_NPM_TELEGRAM_CREDENTIAL_SOURCE=convex OPENCLAW_NPM_TELEGRAM_CREDENTIAL_ROLE=ci pnpm test:docker:npm-telegram-live`
- Before a beta publish, run `OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ=/path/to/openclaw-YYYY.M.D-beta.N.tgz OPENCLAW_NPM_TELEGRAM_CREDENTIAL_SOURCE=convex OPENCLAW_NPM_TELEGRAM_CREDENTIAL_ROLE=ci pnpm test:docker:npm-telegram-live`
to verify installed-package onboarding, Telegram setup, and real Telegram E2E
against the published npm package using the shared leased Telegram credential
pool. Local maintainer one-offs may omit the Convex vars and pass the three
against the prepared package tarball using the shared leased Telegram
credential pool. After publish, the same lane may be rerun with
`OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC=openclaw@YYYY.M.D-beta.N` as registry
proof. Local maintainer one-offs may omit the Convex vars and pass the three
`OPENCLAW_QA_TELEGRAM_*` env credentials directly.
- Maintainers can run the same post-publish check from GitHub Actions via the
manual `NPM Telegram Beta E2E` workflow. It is intentionally manual-only and
does not run on every merge.
- Maintainers can run the same package check from GitHub Actions via the manual
`NPM Telegram Package E2E` workflow. Pass `preflight_run_id` and
`preflight_artifact_ref` to test the prepared preflight tarball before npm
publish. It is intentionally manual-only and does not run on every merge.
- Maintainer release automation now uses preflight-then-promote:
- real npm publish must pass a successful npm `preflight_run_id`
- the real npm publish must be dispatched from the same `main` or
@@ -187,13 +195,15 @@ When cutting a stable npm release:
QA Lab parity, Matrix, and Telegram coverage
- This is separate on purpose so live coverage stays available without
recoupling long-running or flaky checks to the publish workflow
4. Save the successful `preflight_run_id`
5. Run `OpenClaw NPM Release` again with `preflight_only=false`, the same
4. Run Docker, Parallels, QA Lab, and NPM Telegram package validation against
the prepared tarball artifact from the successful preflight run
5. Save the successful `preflight_run_id`
6. Run `OpenClaw NPM Release` again with `preflight_only=false`, the same
`tag`, the same `npm_dist_tag`, and the saved `preflight_run_id`
6. If the release landed on `beta`, use the private
7. If the release landed on `beta`, use the private
`openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml`
workflow to promote that stable version from `beta` to `latest`
7. If the release intentionally published directly to `latest` and `beta`
8. If the release intentionally published directly to `latest` and `beta`
should follow the same stable build immediately, use that same private
workflow to point both dist-tags at the stable version, or let its scheduled
self-healing sync move `beta` later

View File

@@ -1,3 +1,4 @@
import fs from "node:fs";
import os from "node:os";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -207,6 +208,38 @@ describe("gateway bonjour advertiser", () => {
await expect(started.stop()).resolves.toBeUndefined();
});
it("auto-disables Bonjour in detected containers", async () => {
enableAdvertiserUnitMode();
vi.spyOn(fs, "existsSync").mockImplementation((filePath) => String(filePath) === "/.dockerenv");
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
expect(createService).not.toHaveBeenCalled();
await expect(started.stop()).resolves.toBeUndefined();
});
it("honors explicit Bonjour opt-in inside detected containers", async () => {
enableAdvertiserUnitMode();
process.env.OPENCLAW_DISABLE_BONJOUR = "0";
vi.spyOn(fs, "existsSync").mockImplementation((filePath) => String(filePath) === "/.dockerenv");
const destroy = vi.fn().mockResolvedValue(undefined);
const advertise = vi.fn().mockResolvedValue(undefined);
mockCiaoService({ advertise, destroy });
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
expect(createService).toHaveBeenCalledTimes(1);
await started.stop();
});
it("attaches conflict listeners for services", async () => {
enableAdvertiserUnitMode();

View File

@@ -1,3 +1,4 @@
import fs from "node:fs";
import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry";
import { isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env";
import { classifyCiaoProcessError, type CiaoProcessErrorClassification } from "./ciao.js";
@@ -89,16 +90,61 @@ async function loadCiaoModule(): Promise<CiaoModule> {
return ciaoModulePromise;
}
function isDisabledByEnv() {
if (isTruthyEnvValue(process.env.OPENCLAW_DISABLE_BONJOUR)) {
function readBonjourDisableOverride(): boolean | null {
const raw = process.env.OPENCLAW_DISABLE_BONJOUR;
const normalized = raw?.trim().toLowerCase();
if (!normalized) {
return null;
}
if (isTruthyEnvValue(raw)) {
return true;
}
switch (normalized) {
case "0":
case "false":
case "no":
case "off":
return false;
default:
return null;
}
}
function isContainerEnvironment() {
for (const sentinelPath of ["/.dockerenv", "/run/.containerenv", "/var/run/.containerenv"]) {
try {
if (fs.existsSync(sentinelPath)) {
return true;
}
} catch {
// ignore
}
}
try {
const cgroup = fs.readFileSync("/proc/1/cgroup", "utf8");
return /\/docker\/|cri-containerd-[0-9a-f]|containerd\/[0-9a-f]{64}|\/kubepods[/.]|\blxc\b/u.test(
cgroup,
);
} catch {
return false;
}
}
function isDisabledByEnv() {
if (process.env.NODE_ENV === "test") {
return true;
}
if (process.env.VITEST) {
return true;
}
const envOverride = readBonjourDisableOverride();
if (envOverride !== null) {
return envOverride;
}
if (isContainerEnvironment()) {
return true;
}
return false;
}

View File

@@ -5,11 +5,7 @@ import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import chokidar, { FSWatcher } from "chokidar";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
buildCaseInsensitiveExtensionGlob,
classifyMemoryMultimodalPath,
getMemoryMultimodalExtensions,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { classifyMemoryMultimodalPath } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import {
createSubsystemLogger,
onSessionTranscriptUpdate,
@@ -105,6 +101,9 @@ function shouldIgnoreMemoryWatchPath(
if (stats?.isDirectory?.()) {
return false;
}
if (!stats) {
return false;
}
const extension = normalizeLowercaseStringOrEmpty(path.extname(normalized));
if (extension.length === 0 || extension === ".md") {
return false;
@@ -383,16 +382,7 @@ export abstract class MemoryManagerSyncOps {
continue;
}
if (stat.isDirectory()) {
watchPaths.add(path.join(entry, "**", "*.md"));
if (this.settings.multimodal.enabled) {
for (const modality of this.settings.multimodal.modalities) {
for (const extension of getMemoryMultimodalExtensions(modality)) {
watchPaths.add(
path.join(entry, "**", buildCaseInsensitiveExtensionGlob(extension)),
);
}
}
}
watchPaths.add(entry);
continue;
}
if (
@@ -422,6 +412,7 @@ export abstract class MemoryManagerSyncOps {
this.watcher.on("add", markDirty);
this.watcher.on("change", markDirty);
this.watcher.on("unlink", markDirty);
this.watcher.on("unlinkDir", markDirty);
}
protected ensureSessionListener() {

View File

@@ -11,12 +11,35 @@ import { registerBuiltInMemoryEmbeddingProviders } from "./provider-adapters.js"
type WatchIgnoredFn = (watchPath: string, stats?: { isDirectory?: () => boolean }) => boolean;
const { watchMock } = vi.hoisted(() => ({
watchMock: vi.fn(() => ({
on: vi.fn(),
close: vi.fn(async () => undefined),
})),
}));
const { createdWatchers, watchMock } = vi.hoisted(() => {
type WatchEvent = "add" | "change" | "unlink" | "unlinkDir";
type WatchCallback = () => void;
function createMockWatcher() {
const handlers = new Map<WatchEvent, WatchCallback[]>();
const watcher = {
on: vi.fn((event: WatchEvent, callback: WatchCallback) => {
handlers.set(event, [...(handlers.get(event) ?? []), callback]);
return watcher;
}),
close: vi.fn(async () => undefined),
emit: (event: WatchEvent) => {
for (const callback of handlers.get(event) ?? []) {
callback();
}
},
};
return watcher;
}
const watchers: Array<ReturnType<typeof createMockWatcher>> = [];
return {
createdWatchers: watchers,
watchMock: vi.fn(() => {
const watcher = createMockWatcher();
watchers.push(watcher);
return watcher;
}),
};
});
vi.mock("chokidar", () => ({
default: { watch: watchMock },
@@ -69,7 +92,9 @@ describe("memory watcher config", () => {
});
afterEach(async () => {
vi.useRealTimers();
watchMock.mockClear();
createdWatchers.length = 0;
if (manager) {
await manager.close();
manager = null;
@@ -140,9 +165,10 @@ describe("memory watcher config", () => {
expect.arrayContaining([
path.join(workspaceDir, "MEMORY.md"),
path.join(workspaceDir, "memory"),
path.join(extraDir, "**", "*.md"),
extraDir,
]),
);
expect(watchedPaths.every((watchPath) => !watchPath.includes("*"))).toBe(true);
expect(options.ignoreInitial).toBe(true);
expect(options.awaitWriteFinish).toEqual({ stabilityThreshold: 25, pollInterval: 100 });
@@ -152,15 +178,19 @@ describe("memory watcher config", () => {
true,
);
expect(ignored?.(path.join(workspaceDir, "memory", ".venv", "lib", "python.md"))).toBe(true);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.tmp"))).toBe(true);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.json"))).toBe(true);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.tmp"), {})).toBe(true);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.json"), {})).toBe(true);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.json"), undefined)).toBe(
false,
);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.md"))).toBe(false);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.md"), {})).toBe(false);
expect(
ignored?.(path.join(workspaceDir, "memory", "project"), { isDirectory: () => true }),
).toBe(false);
});
it("watches multimodal extensions with case-insensitive globs", async () => {
it("watches multimodal extra directories with filtered extensions", async () => {
await setupWatcherWorkspace({ name: "PHOTO.PNG", contents: "png" });
const cfg = createWatcherConfig({
provider: "gemini",
@@ -177,16 +207,40 @@ describe("memory watcher config", () => {
Record<string, unknown>,
];
expect(watchedPaths).toEqual(
expect.arrayContaining([
path.join(extraDir, "**", "*.[pP][nN][gG]"),
path.join(extraDir, "**", "*.[wW][aA][vV]"),
]),
expect.arrayContaining([path.join(workspaceDir, "MEMORY.md"), path.join(extraDir)]),
);
expect(watchedPaths.every((watchPath) => !watchPath.includes("*"))).toBe(true);
const ignored = options.ignored as WatchIgnoredFn | undefined;
expect(ignored).toBeTypeOf("function");
expect(ignored?.(path.join(extraDir, "nested", "PHOTO.PNG"))).toBe(false);
expect(ignored?.(path.join(extraDir, "nested", "PHOTO.PNG"), {})).toBe(false);
expect(ignored?.(path.join(extraDir, "nested", "voice.WAV"))).toBe(false);
expect(ignored?.(path.join(extraDir, "nested", "metadata.json"))).toBe(true);
expect(ignored?.(path.join(extraDir, "nested", "voice.WAV"), {})).toBe(false);
expect(ignored?.(path.join(extraDir, "nested", "metadata.json"), {})).toBe(true);
});
it.each(["add", "change", "unlink", "unlinkDir"] as const)(
"schedules watch sync on %s",
async (event) => {
await setupWatcherWorkspace({ name: "notes.md", contents: "hello" });
const cfg = createWatcherConfig();
await expectWatcherManager(cfg);
vi.useFakeTimers();
const syncSpy = vi
.spyOn(
manager as unknown as {
sync: (params?: { reason?: string }) => Promise<void>;
},
"sync",
)
.mockResolvedValue(undefined);
createdWatchers[0]?.emit(event);
await vi.advanceTimersByTimeAsync(25);
expect(syncSpy).toHaveBeenCalledWith({ reason: "watch" });
},
);
});

View File

@@ -162,6 +162,7 @@ describe("telegram live qa runtime", () => {
sutAccountId: "sut",
});
expect(next.agents?.defaults?.skipBootstrap).toBe(true);
expect(next.plugins?.allow).toContain("telegram");
expect(next.plugins?.entries?.telegram).toEqual({ enabled: true });
expect(next.channels?.telegram).toEqual({
@@ -375,6 +376,27 @@ describe("telegram live qa runtime", () => {
matchText: "TELEGRAM_QA_NOMENTION_TOKEN",
}),
).toBe(false);
expect(
__testing.matchesTelegramScenarioReply({
allowAnySutReply: true,
groupId: "-100123",
sentMessageId: 55,
sutBotId: 88,
message: {
updateId: 3,
messageId: 12,
chatId: -100123,
senderId: 88,
senderIsBot: true,
senderUsername: "sut_bot",
text: "Protocol note: acknowledged.",
replyToMessageId: undefined,
timestamp: 1_700_000_003_000,
inlineButtons: [],
mediaKinds: [],
},
}),
).toBe(true);
});
it("validates expected Telegram reply markers", () => {

View File

@@ -51,6 +51,7 @@ type TelegramQaScenarioId =
| "telegram-mention-gating";
type TelegramQaScenarioRun = {
allowAnySutReply?: boolean;
expectReply: boolean;
input: string;
expectedTextIncludes?: string[];
@@ -268,15 +269,11 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [
id: "telegram-mentioned-message-reply",
title: "Telegram mentioned message gets a reply",
timeoutMs: 45_000,
buildRun: (sutUsername) => {
const token = `TELEGRAM_QA_REPLY_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
expectReply: true,
input: `@${sutUsername} reply with only this exact marker: ${token}`,
expectedTextIncludes: [token],
matchText: token,
};
},
buildRun: (sutUsername) => ({
allowAnySutReply: true,
expectReply: true,
input: `@${sutUsername} Telegram QA mention routing check. Reply with a short acknowledgement.`,
}),
},
{
id: "telegram-mention-gating",
@@ -476,6 +473,13 @@ function buildTelegramQaConfig(
};
return {
...baseCfg,
agents: {
...baseCfg.agents,
defaults: {
...baseCfg.agents?.defaults,
skipBootstrap: true,
},
},
plugins: {
...baseCfg.plugins,
allow: pluginAllow,
@@ -751,6 +755,7 @@ function findScenario(ids?: string[]) {
function matchesTelegramScenarioReply(params: {
groupId: string;
allowAnySutReply?: boolean;
matchText?: string;
message: TelegramObservedMessage;
sentMessageId: number;
@@ -765,6 +770,9 @@ function matchesTelegramScenarioReply(params: {
if (params.message.replyToMessageId === params.sentMessageId) {
return true;
}
if (params.allowAnySutReply === true) {
return true;
}
return Boolean(params.matchText && params.message.text.includes(params.matchText));
}
@@ -1216,6 +1224,7 @@ export async function runTelegramQaLive(params: {
observationScenarioTitle: scenario.title,
predicate: (message) =>
matchesTelegramScenarioReply({
allowAnySutReply: scenarioRun.allowAnySutReply,
groupId: runtimeEnv.groupId,
matchText: scenarioRun.matchText,
message,

View File

@@ -1,4 +1,5 @@
import "./test-helpers.js";
import { EventEmitter } from "node:events";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -42,25 +43,57 @@ type WebAutoReplyMonitorHarness = {
controller: AbortController;
run: Promise<unknown>;
};
type MockSessionSocket = {
ev: { on: ReturnType<typeof vi.fn>; off: ReturnType<typeof vi.fn> };
ws: EventEmitter & { close: ReturnType<typeof vi.fn> };
user: { id: string };
};
export const TEST_NET_IP = "93.184.216.34";
const WEB_AUTO_REPLY_SOCKETS_KEY = Symbol.for("openclaw:webAutoReplySessionSockets");
function getSessionSockets(): MockSessionSocket[] {
const store = globalThis as Record<PropertyKey, unknown>;
if (!Array.isArray(store[WEB_AUTO_REPLY_SOCKETS_KEY])) {
store[WEB_AUTO_REPLY_SOCKETS_KEY] = [];
}
return store[WEB_AUTO_REPLY_SOCKETS_KEY] as MockSessionSocket[];
}
vi.mock("./session.js", async () => {
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
return {
...actual,
createWaSocket: vi.fn(async () => ({
ev: {
on: vi.fn(),
off: vi.fn(),
},
ws: { close: vi.fn() },
user: { id: "123@s.whatsapp.net" },
})),
createWaSocket: vi.fn(async () => {
const ws = new EventEmitter() as MockSessionSocket["ws"];
ws.close = vi.fn();
const sock: MockSessionSocket = {
ev: {
on: vi.fn(),
off: vi.fn(),
},
ws,
user: { id: "123@s.whatsapp.net" },
};
getSessionSockets().push(sock);
return sock;
}),
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
};
});
export function getLastWebAutoReplySessionSocket(): MockSessionSocket {
const last = getSessionSockets().at(-1);
if (!last) {
throw new Error("No WhatsApp Web auto-reply test socket created");
}
return last;
}
export function resetWebAutoReplySessionSockets() {
getSessionSockets().length = 0;
}
vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
appendCronStyleCurrentTimeLine: (text: string) => text,
@@ -166,6 +199,7 @@ export function installWebAutoReplyUnitTestHooks(opts?: { pinDns?: boolean }) {
beforeEach(async () => {
vi.clearAllMocks();
resetWebAutoReplySessionSockets();
_resetBaileysMocks();
_resetLoadConfigMock();
if (opts?.pinDns) {

View File

@@ -12,6 +12,7 @@ import {
createMockWebListener,
createScriptedWebListenerFactory,
createWebListenerFactoryCapture,
getLastWebAutoReplySessionSocket,
installWebAutoReplyTestHomeHooks,
installWebAutoReplyUnitTestHooks,
makeSessionStore,
@@ -255,6 +256,92 @@ describe("web auto-reply connection", () => {
}
});
it("keeps quiet linked-device sessions open when transport frames keep arriving", async () => {
vi.useFakeTimers();
try {
const sleep = vi.fn(async () => {});
const scripted = createScriptedWebListenerFactory();
const { controller, run } = startWebAutoReplyMonitor({
monitorWebChannelFn: monitorWebChannel as never,
listenerFactory: scripted.listenerFactory,
sleep,
heartbeatSeconds: 60,
messageTimeoutMs: 30,
watchdogCheckMs: 5,
});
await vi.waitFor(
() => {
expect(scripted.getListenerCount()).toBe(1);
},
{ timeout: 250, interval: 2 },
);
const socket = getLastWebAutoReplySessionSocket();
await vi.advanceTimersByTimeAsync(20);
socket.ws.emit("frame");
await vi.advanceTimersByTimeAsync(20);
socket.ws.emit("frame");
await vi.advanceTimersByTimeAsync(20);
expect(scripted.getListenerCount()).toBe(1);
controller.abort();
scripted.resolveClose(0, { status: 499, isLoggedOut: false });
await Promise.resolve();
await run;
} finally {
vi.useRealTimers();
}
});
it("does not let transport frames mask application silence forever", async () => {
vi.useFakeTimers();
try {
const sleep = vi.fn(async () => {});
const scripted = createScriptedWebListenerFactory();
const { controller, run } = startWebAutoReplyMonitor({
monitorWebChannelFn: monitorWebChannel as never,
listenerFactory: scripted.listenerFactory,
sleep,
heartbeatSeconds: 60,
messageTimeoutMs: 30,
watchdogCheckMs: 5,
});
await vi.waitFor(
() => {
expect(scripted.getListenerCount()).toBe(1);
},
{ timeout: 250, interval: 2 },
);
const socket = getLastWebAutoReplySessionSocket();
for (let elapsedMs = 0; elapsedMs < 140; elapsedMs += 20) {
socket.ws.emit("frame");
await vi.advanceTimersByTimeAsync(20);
}
await vi.waitFor(
() => {
expect(scripted.getListenerCount()).toBeGreaterThanOrEqual(2);
},
{ timeout: 250, interval: 2 },
);
controller.abort();
scripted.resolveClose(scripted.getListenerCount() - 1, {
status: 499,
isLoggedOut: false,
error: "aborted",
});
await Promise.resolve();
await run;
} finally {
vi.useRealTimers();
}
});
it("gives a reconnected listener a fresh watchdog window", async () => {
vi.useFakeTimers();
try {

View File

@@ -280,6 +280,7 @@ export async function monitorWebChannel(
reconnectAttempts: snapshot.reconnectAttempts,
messagesHandled: snapshot.handledMessages,
lastInboundAt: snapshot.lastInboundAt,
lastTransportActivityAt: snapshot.lastTransportActivityAt,
authAgeMs,
uptimeMs: snapshot.uptimeMs,
...(minutesSinceLastMessage !== null && minutesSinceLastMessage > 30
@@ -297,20 +298,28 @@ export async function monitorWebChannel(
}
},
onWatchdogTimeout: (snapshot) => {
const watchdogBaselineAt = snapshot.lastInboundAt ?? snapshot.startedAt;
const minutesSinceLastMessage = Math.floor((Date.now() - watchdogBaselineAt) / 60000);
const now = Date.now();
const transportSilentMs = now - snapshot.lastTransportActivityAt;
const appBaselineAt = snapshot.lastInboundAt ?? snapshot.startedAt;
const minutesSinceTransportActivity = Math.floor(transportSilentMs / 60000);
const minutesSinceAppActivity = Math.floor((now - appBaselineAt) / 60000);
const watchdogReason =
transportSilentMs > messageTimeoutMs ? "transport-inactive" : "app-silent";
statusController.noteWatchdogStale();
heartbeatLogger.warn(
{
connectionId: snapshot.connectionId,
minutesSinceLastMessage,
watchdogReason,
minutesSinceTransportActivity,
minutesSinceAppActivity,
lastInboundAt: snapshot.lastInboundAt ? new Date(snapshot.lastInboundAt) : null,
lastTransportActivityAt: new Date(snapshot.lastTransportActivityAt),
messagesHandled: snapshot.handledMessages,
},
"Message timeout detected - forcing reconnect",
"WhatsApp watchdog timeout detected - forcing reconnect",
);
whatsappHeartbeatLog.warn(
`No messages received in ${minutesSinceLastMessage}m - restarting connection`,
`WhatsApp watchdog timeout (${watchdogReason}) - restarting connection`,
);
},
});

View File

@@ -40,8 +40,10 @@ export type WhatsAppLiveConnection = {
heartbeat: TimerHandle | null;
watchdogTimer: TimerHandle | null;
lastInboundAt: number | null;
lastTransportActivityAt: number;
handledMessages: number;
unregisterUnhandled: (() => void) | null;
unregisterTransportActivity: (() => void) | null;
backgroundTasks: Set<Promise<unknown>>;
closePromise: Promise<WebListenerCloseReason>;
resolveClose: (reason: WebListenerCloseReason) => void;
@@ -51,6 +53,7 @@ export type WhatsAppConnectionSnapshot = {
connectionId: string;
startedAt: number;
lastInboundAt: number | null;
lastTransportActivityAt: number;
handledMessages: number;
reconnectAttempts: number;
uptimeMs: number;
@@ -83,6 +86,12 @@ function createNeverResolvePromise<T>(): Promise<T> {
return new Promise<T>(() => {});
}
type SocketActivityEmitter = {
on?: (event: string, listener: (...args: unknown[]) => void) => void;
off?: (event: string, listener: (...args: unknown[]) => void) => void;
removeListener?: (event: string, listener: (...args: unknown[]) => void) => void;
};
function createLiveConnection(params: {
connectionId: string;
sock: WASocket;
@@ -108,8 +117,10 @@ function createLiveConnection(params: {
heartbeat: null,
watchdogTimer: null,
lastInboundAt: null,
lastTransportActivityAt: Date.now(),
handledMessages: 0,
unregisterUnhandled: null,
unregisterTransportActivity: null,
backgroundTasks: new Set<Promise<unknown>>(),
closePromise,
resolveClose: resolveClosePromise,
@@ -232,6 +243,7 @@ export class WhatsAppConnectionController {
private readonly heartbeatSeconds: number;
private readonly keepAlive: boolean;
private readonly messageTimeoutMs: number;
private readonly appSilenceTimeoutMs: number;
private readonly watchdogCheckMs: number;
private readonly verbose: boolean;
private readonly abortSignal?: AbortSignal;
@@ -262,6 +274,7 @@ export class WhatsAppConnectionController {
this.keepAlive = params.keepAlive;
this.heartbeatSeconds = params.heartbeatSeconds;
this.messageTimeoutMs = params.messageTimeoutMs;
this.appSilenceTimeoutMs = Math.max(params.messageTimeoutMs, params.messageTimeoutMs * 4);
this.watchdogCheckMs = params.watchdogCheckMs;
this.reconnectPolicy = params.reconnectPolicy;
this.abortSignal = params.abortSignal;
@@ -311,6 +324,14 @@ export class WhatsAppConnectionController {
}
this.current.handledMessages += 1;
this.current.lastInboundAt = timestamp;
this.current.lastTransportActivityAt = timestamp;
}
noteTransportActivity(timestamp = Date.now()): void {
if (!this.current) {
return;
}
this.current.lastTransportActivityAt = timestamp;
}
getCurrentSnapshot(
@@ -323,6 +344,7 @@ export class WhatsAppConnectionController {
connectionId: connection.connectionId,
startedAt: connection.startedAt,
lastInboundAt: connection.lastInboundAt,
lastTransportActivityAt: connection.lastTransportActivityAt,
handledMessages: connection.handledMessages,
reconnectAttempts: this.reconnectAttempts,
uptimeMs: Date.now() - connection.startedAt,
@@ -369,6 +391,7 @@ export class WhatsAppConnectionController {
const listener = await params.createListener({ sock, connection });
connection.listener = listener;
this.current = connection;
connection.unregisterTransportActivity = this.attachTransportActivityListener(sock);
registerWhatsAppConnectionController(this.accountId, this);
this.startTimers(connection, {
onHeartbeat: params.onHeartbeat,
@@ -383,6 +406,7 @@ export class WhatsAppConnectionController {
if (connection?.unregisterUnhandled) {
connection.unregisterUnhandled();
}
connection?.unregisterTransportActivity?.();
throw err;
}
}
@@ -515,6 +539,7 @@ export class WhatsAppConnectionController {
this.socketRef.current = null;
}
connection.unregisterUnhandled?.();
connection.unregisterTransportActivity?.();
if (connection.heartbeat) {
clearInterval(connection.heartbeat);
}
@@ -563,9 +588,14 @@ export class WhatsAppConnectionController {
}, this.heartbeatSeconds * 1000);
connection.watchdogTimer = setInterval(() => {
const baselineAt = connection.lastInboundAt ?? connection.startedAt;
const staleForMs = Date.now() - baselineAt;
if (staleForMs <= this.messageTimeoutMs) {
const now = Date.now();
const transportStaleForMs = now - connection.lastTransportActivityAt;
const appBaselineAt = connection.lastInboundAt ?? connection.startedAt;
const appSilentForMs = now - appBaselineAt;
if (
transportStaleForMs <= this.messageTimeoutMs &&
appSilentForMs <= this.appSilenceTimeoutMs
) {
return;
}
const snapshot = this.getCurrentSnapshot(connection);
@@ -581,6 +611,24 @@ export class WhatsAppConnectionController {
}, this.watchdogCheckMs);
}
private attachTransportActivityListener(sock: WASocket): (() => void) | null {
const ws = sock.ws as SocketActivityEmitter | undefined;
if (!ws || typeof ws.on !== "function") {
return null;
}
const noteActivity = () => this.noteTransportActivity();
ws.on("frame", noteActivity);
return () => {
if (typeof ws.off === "function") {
ws.off("frame", noteActivity);
return;
}
ws.removeListener?.("frame", noteActivity);
};
}
private stopDisconnectRetries(): void {
if (!this.disconnectRetryController.signal.aborted) {
this.disconnectRetryController.abort();

View File

@@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.4.26",
"version": "2026.4.25",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",

View File

@@ -39,13 +39,13 @@ steps:
message:
expr: config.prompt
timeoutMs:
expr: liveTurnTimeoutMs(env, 90000)
expr: liveTurnTimeoutMs(env, 180000)
- call: waitForCondition
saveAs: outbound
args:
- lambda:
expr: "state.getSnapshot().messages.filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'qa-operator' && String(candidate.text ?? '').includes(config.contextNeedle) && !normalizeLowercaseStringOrEmpty(candidate.text).includes('waiting')).at(-1)"
- expr: liveTurnTimeoutMs(env, 45000)
- expr: liveTurnTimeoutMs(env, 90000)
- expr: "env.providerMode === 'mock-openai' ? 100 : 250"
- assert:
expr: "env.mock || String(outbound.text ?? '').includes(config.contextNeedle)"

View File

@@ -0,0 +1,135 @@
#!/usr/bin/env node
// Validates the npm tarball Docker E2E lanes install.
// This is intentionally tarball-only: the check proves Docker lanes consume the
// prebuilt package artifact with dist inventory, not a source checkout.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
function usage() {
return "Usage: node scripts/check-openclaw-package-tarball.mjs <openclaw.tgz>";
}
function fail(message) {
console.error(message);
process.exit(1);
}
const tarball = process.argv[2];
if (!tarball || process.argv.length > 3) {
fail(usage());
}
if (!fs.existsSync(tarball)) {
fail(`OpenClaw package tarball does not exist: ${tarball}`);
}
const list = spawnSync("tar", ["-tf", tarball], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (list.status !== 0) {
fail(`tar -tf failed for ${tarball}: ${list.stderr || list.status}`);
}
const entries = list.stdout
.split(/\r?\n/u)
.map((entry) => entry.trim())
.filter(Boolean);
const normalized = entries.map((entry) => entry.replace(/^package\//u, ""));
const entrySet = new Set(normalized);
const errors = [];
const warnings = [];
const LEGACY_OMITTED_PRIVATE_QA_INVENTORY_PREFIXES = [
"dist/extensions/qa-channel/",
"dist/extensions/qa-lab/",
"dist/extensions/qa-matrix/",
"dist/plugin-sdk/extensions/qa-channel/",
"dist/plugin-sdk/extensions/qa-lab/",
];
const LEGACY_OMITTED_PRIVATE_QA_INVENTORY_FILES = new Set([
"dist/plugin-sdk/qa-channel.d.ts",
"dist/plugin-sdk/qa-channel.js",
"dist/plugin-sdk/qa-channel-protocol.d.ts",
"dist/plugin-sdk/qa-channel-protocol.js",
"dist/plugin-sdk/qa-lab.d.ts",
"dist/plugin-sdk/qa-lab.js",
"dist/plugin-sdk/qa-runtime.d.ts",
"dist/plugin-sdk/qa-runtime.js",
"dist/plugin-sdk/src/plugin-sdk/qa-channel.d.ts",
"dist/plugin-sdk/src/plugin-sdk/qa-channel-protocol.d.ts",
"dist/plugin-sdk/src/plugin-sdk/qa-lab.d.ts",
"dist/plugin-sdk/src/plugin-sdk/qa-runtime.d.ts",
]);
function isLegacyOmittedPrivateQaInventoryEntry(relativePath) {
return (
LEGACY_OMITTED_PRIVATE_QA_INVENTORY_FILES.has(relativePath) ||
LEGACY_OMITTED_PRIVATE_QA_INVENTORY_PREFIXES.some((prefix) => relativePath.startsWith(prefix))
);
}
function readTarEntry(entryPath) {
const candidates = [entryPath, `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;
}
}
return "";
}
for (const entry of normalized) {
if (entry.startsWith("/") || entry.split("/").includes("..")) {
errors.push(`unsafe tar entry: ${entry}`);
}
}
if (!entrySet.has("package.json")) {
errors.push("missing package.json");
}
if (!normalized.some((entry) => entry.startsWith("dist/"))) {
errors.push("missing dist/ entries");
}
if (!entrySet.has("dist/postinstall-inventory.json")) {
errors.push("missing dist/postinstall-inventory.json");
}
if (entrySet.has("dist/postinstall-inventory.json")) {
try {
const inventory = JSON.parse(readTarEntry("dist/postinstall-inventory.json"));
if (!Array.isArray(inventory) || inventory.some((entry) => typeof entry !== "string")) {
errors.push("invalid dist/postinstall-inventory.json");
} else {
for (const inventoryEntry of inventory) {
const normalizedEntry = inventoryEntry.replace(/\\/gu, "/");
if (!entrySet.has(normalizedEntry)) {
if (isLegacyOmittedPrivateQaInventoryEntry(normalizedEntry)) {
warnings.push(
`legacy inventory references omitted private QA tar entry ${normalizedEntry}`,
);
continue;
}
errors.push(`inventory references missing tar entry ${normalizedEntry}`);
}
}
}
} catch (error) {
errors.push(
`unreadable dist/postinstall-inventory.json: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
if (errors.length > 0) {
fail(`OpenClaw package tarball integrity failed:\n${errors.join("\n")}`);
}
for (const warning of warnings) {
console.warn(`OpenClaw package tarball integrity warning: ${warning}`);
}
console.log("OpenClaw package tarball integrity passed.");

View File

@@ -12,6 +12,7 @@ source "$VERIFY_HELPER_PATH"
INSTALL_URL="${OPENCLAW_INSTALL_URL:-https://openclaw.bot/install.sh}"
MODELS_MODE="${OPENCLAW_E2E_MODELS:-both}" # both|openai|anthropic
INSTALL_TAG="${OPENCLAW_INSTALL_TAG:-latest}"
INSTALL_PACKAGE_TGZ="${OPENCLAW_INSTALL_PACKAGE_TGZ:-}"
E2E_PREVIOUS_VERSION="${OPENCLAW_INSTALL_E2E_PREVIOUS:-}"
SKIP_PREVIOUS="${OPENCLAW_INSTALL_E2E_SKIP_PREVIOUS:-0}"
OPENAI_API_KEY="${OPENAI_API_KEY:-}"
@@ -48,9 +49,24 @@ elif [[ "$MODELS_MODE" == "anthropic" && -z "$ANTHROPIC_API_TOKEN" && -z "$ANTHR
fi
echo "==> Resolve npm versions"
EXPECTED_VERSION="$(quiet_npm view "openclaw@${INSTALL_TAG}" version)"
if [[ -n "$INSTALL_PACKAGE_TGZ" ]]; then
if [[ ! -f "$INSTALL_PACKAGE_TGZ" ]]; then
echo "ERROR: OPENCLAW_INSTALL_PACKAGE_TGZ does not exist: $INSTALL_PACKAGE_TGZ" >&2
exit 2
fi
EXPECTED_VERSION="$(
tar -xOf "$INSTALL_PACKAGE_TGZ" package/package.json | node -e '
const fs = require("node:fs");
const pkg = JSON.parse(fs.readFileSync(0, "utf8"));
if (typeof pkg.version !== "string" || pkg.version.length === 0) process.exit(1);
process.stdout.write(pkg.version);
'
)"
else
EXPECTED_VERSION="$(quiet_npm view "openclaw@${INSTALL_TAG}" version)"
fi
if [[ -z "$EXPECTED_VERSION" || "$EXPECTED_VERSION" == "undefined" || "$EXPECTED_VERSION" == "null" ]]; then
echo "ERROR: unable to resolve openclaw@${INSTALL_TAG} version" >&2
echo "ERROR: unable to resolve candidate OpenClaw version" >&2
exit 2
fi
if [[ -n "$E2E_PREVIOUS_VERSION" ]]; then
@@ -73,10 +89,13 @@ else
fi
echo "==> Run official installer one-liner"
if [[ "$INSTALL_TAG" == "beta" ]]; then
OPENCLAW_BETA=1 curl -fsSL "$INSTALL_URL" | bash
if [[ -n "$INSTALL_PACKAGE_TGZ" ]]; then
echo "==> Install candidate tarball"
quiet_npm install -g "$INSTALL_PACKAGE_TGZ"
elif [[ "$INSTALL_TAG" == "beta" ]]; then
curl -fsSL "$INSTALL_URL" | OPENCLAW_BETA=1 bash
elif [[ "$INSTALL_TAG" != "latest" ]]; then
OPENCLAW_VERSION="$INSTALL_TAG" curl -fsSL "$INSTALL_URL" | bash
curl -fsSL "$INSTALL_URL" | OPENCLAW_VERSION="$INSTALL_TAG" bash
else
curl -fsSL "$INSTALL_URL" | bash
fi
@@ -544,6 +563,14 @@ run_profile() {
}
trap cleanup_profile EXIT
TURN1_JSON="/tmp/agent-${profile}-1.json"
TURN2_JSON="/tmp/agent-${profile}-2.json"
TURN2B_JSON="/tmp/agent-${profile}-2b.json"
TURN3_JSON="/tmp/agent-${profile}-3.json"
TURN3B_JSON="/tmp/agent-${profile}-3b.json"
TURN4_JSON="/tmp/agent-${profile}-4.json"
HEALTH_JSON="/tmp/health-${profile}.json"
echo "==> Wait for health ($profile)"
for _ in $(seq 1 240); do
if openclaw --profile "$profile" health --timeout 5000 --json >/dev/null 2>&1; then
@@ -551,15 +578,13 @@ run_profile() {
fi
sleep 0.25
done
openclaw --profile "$profile" health --timeout 60000 --json >/dev/null
if ! openclaw --profile "$profile" health --timeout 60000 --json >"$HEALTH_JSON" 2>&1; then
echo "ERROR: gateway health failed ($profile, output=$HEALTH_JSON)" >&2
dump_profile_debug "$profile" "$HEALTH_JSON" >&2 || true
return 1
fi
echo "==> Agent turns ($profile)"
TURN1_JSON="/tmp/agent-${profile}-1.json"
TURN2_JSON="/tmp/agent-${profile}-2.json"
TURN2B_JSON="/tmp/agent-${profile}-2b.json"
TURN3_JSON="/tmp/agent-${profile}-3.json"
TURN3B_JSON="/tmp/agent-${profile}-3b.json"
TURN4_JSON="/tmp/agent-${profile}-4.json"
run_agent_turn "$profile" "$SESSION_ID" \
"Use the read tool (not exec) to read ${PROOF_TXT}. Reply with the exact contents only (no extra whitespace)." \

View File

@@ -2,6 +2,7 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-build.sh"
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
EXTRA_COMPOSE_FILE="$ROOT_DIR/docker-compose.extra.yml"
IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw:local}"
@@ -27,7 +28,7 @@ require_cmd() {
run_docker_build() {
# Dockerfile uses BuildKit-only syntax (RUN --mount=type=cache). Force
# BuildKit so hosts defaulting to the legacy builder do not fail.
DOCKER_BUILDKIT=1 docker build "$@"
docker_build_exec "$@"
}
is_truthy_value() {

View File

@@ -39,7 +39,7 @@ RUN apt-get update \\
USER appuser
EOF
echo "Building Docker image: $IMAGE_NAME"
run_logged browser-cdp-snapshot-build docker build -t "$IMAGE_NAME" -f "$build_dir/Dockerfile" "$build_dir"
docker_build_run browser-cdp-snapshot-build -t "$IMAGE_NAME" -f "$build_dir/Dockerfile" "$build_dir"
fi
echo "Starting browser CDP snapshot container..."

View File

@@ -1230,6 +1230,23 @@ if (mode === "memory-lancedb") {
},
};
}
if (mode === "acpx") {
config.plugins = {
...(config.plugins || {}),
enabled: true,
allow:
Array.isArray(config.plugins?.allow) && config.plugins.allow.length > 0
? [...new Set([...config.plugins.allow, "acpx"])]
: config.plugins?.allow,
entries: {
...(config.plugins?.entries || {}),
acpx: {
...(config.plugins?.entries?.acpx || {}),
enabled: true,
},
},
};
}
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
@@ -1465,6 +1482,7 @@ fi
if should_run_update_target acpx; then
echo "Removing ACPX runtime package and rerunning same-version update path..."
write_config acpx
remove_runtime_dep acpx acpx
assert_no_dep_available acpx acpx
run_update_and_capture acpx /tmp/openclaw-update-acpx.json

View File

@@ -170,7 +170,7 @@ async function runCronCleanupScenario(params: {
);
const initialArgs = await describeProbePid(pid);
assert(
initialArgs?.includes("openclaw-cron-mcp-cleanup-probe"),
initialArgs === undefined || initialArgs.includes("openclaw-cron-mcp-cleanup-probe"),
`cron MCP probe pid did not look like the test server: pid=${pid} args=${initialArgs}`,
);

View File

@@ -1,4 +1,6 @@
#!/usr/bin/env bash
# Installs an OpenClaw package candidate in Docker, performs Telegram
# onboarding/doctor recovery, then runs the Telegram QA live harness.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
@@ -7,6 +9,8 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-npm-telegram-live-e2e" OPENCLAW_NPM_TELEGRAM_LIVE_E2E_IMAGE)"
DOCKER_TARGET="${OPENCLAW_NPM_TELEGRAM_DOCKER_TARGET:-build}"
PACKAGE_SPEC="${OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC:-openclaw@beta}"
PACKAGE_TGZ="${OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ:-${OPENCLAW_CURRENT_PACKAGE_TGZ:-}}"
PACKAGE_LABEL="${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL:-}"
OUTPUT_DIR="${OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR:-.artifacts/qa-e2e/npm-telegram-live}"
resolve_credential_source() {
@@ -44,9 +48,48 @@ validate_openclaw_package_spec() {
exit 1
}
validate_openclaw_package_spec "$PACKAGE_SPEC"
resolve_package_tgz() {
local candidate="$1"
if [ -z "$candidate" ]; then
return 0
fi
if [ ! -f "$candidate" ]; then
echo "OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ must point to an existing .tgz file; got: $candidate" >&2
exit 1
fi
case "$candidate" in
*.tgz) ;;
*)
echo "OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ must point to a .tgz file; got: $candidate" >&2
exit 1
;;
esac
local dir
local base
dir="$(cd "$(dirname "$candidate")" && pwd)"
base="$(basename "$candidate")"
printf "%s/%s" "$dir" "$base"
}
package_mount_args=()
package_install_source="$PACKAGE_SPEC"
resolved_package_tgz="$(resolve_package_tgz "$PACKAGE_TGZ")"
if [ -n "$resolved_package_tgz" ]; then
package_install_source="/package-under-test/$(basename "$resolved_package_tgz")"
package_mount_args=(-v "$resolved_package_tgz:$package_install_source:ro")
else
validate_openclaw_package_spec "$PACKAGE_SPEC"
fi
if [ -z "$PACKAGE_LABEL" ]; then
if [ -n "$resolved_package_tgz" ]; then
PACKAGE_LABEL="$(basename "$resolved_package_tgz")"
else
PACKAGE_LABEL="$PACKAGE_SPEC"
fi
fi
docker_e2e_build_or_reuse "$IMAGE_NAME" npm-telegram-live "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "$DOCKER_TARGET"
docker_e2e_harness_mount_args
mkdir -p "$ROOT_DIR/.artifacts/qa-e2e"
run_log="$(mktemp "${TMPDIR:-/tmp}/openclaw-npm-telegram-live.XXXXXX")"
@@ -61,6 +104,7 @@ fi
docker_env=(
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0
-e OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC="$PACKAGE_SPEC"
-e OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="$PACKAGE_LABEL"
-e OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR="$OUTPUT_DIR"
-e OPENCLAW_NPM_TELEGRAM_FAST="${OPENCLAW_NPM_TELEGRAM_FAST:-1}"
)
@@ -121,10 +165,12 @@ run_logged() {
>"$run_log"
}
echo "Running published npm Telegram live Docker E2E ($PACKAGE_SPEC)..."
echo "Running package Telegram live Docker E2E ($PACKAGE_LABEL)..."
run_logged docker run --rm \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC="$PACKAGE_SPEC" \
-e OPENCLAW_NPM_TELEGRAM_INSTALL_SOURCE="$package_install_source" \
-e OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="$PACKAGE_LABEL" \
"${package_mount_args[@]}" \
-v "$npm_prefix_host:/npm-global" \
-i "$IMAGE_NAME" bash -s <<'EOF'
set -euo pipefail
@@ -133,17 +179,21 @@ export HOME="$(mktemp -d "/tmp/openclaw-npm-telegram-install.XXXXXX")"
export NPM_CONFIG_PREFIX="/npm-global"
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
package_spec="${OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC:?missing OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC}"
echo "Installing ${package_spec}..."
npm install -g "$package_spec" --no-fund --no-audit
install_source="${OPENCLAW_NPM_TELEGRAM_INSTALL_SOURCE:?missing OPENCLAW_NPM_TELEGRAM_INSTALL_SOURCE}"
package_label="${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL:-$install_source}"
echo "Installing ${package_label} from ${install_source}..."
npm install -g "$install_source" --no-fund --no-audit
command -v openclaw
openclaw --version
EOF
# Mount only test harness/plugin QA sources; the SUT itself is the installed package candidate.
run_logged docker run --rm \
"${docker_env[@]}" \
-v "$ROOT_DIR/.artifacts:/app/.artifacts" \
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
-v "$ROOT_DIR/extensions:/app/extensions:ro" \
-v "$npm_prefix_host:/npm-global" \
-i "$IMAGE_NAME" bash -s <<'EOF'
set -euo pipefail
@@ -155,7 +205,7 @@ export OPENCLAW_NPM_TELEGRAM_REPO_ROOT="/app"
dump_hotpath_logs() {
local status="$1"
echo "installed npm onboarding recovery hot path failed with exit code $status" >&2
echo "installed-package onboarding recovery hot path failed with exit code $status" >&2
for file in \
/tmp/openclaw-npm-telegram-onboard.json \
/tmp/openclaw-npm-telegram-channel-add.log \
@@ -171,8 +221,40 @@ trap 'status=$?; dump_hotpath_logs "$status"; exit "$status"' ERR
command -v openclaw
openclaw --version
mkdir -p /app/node_modules
openclaw_package_dir="/npm-global/lib/node_modules/openclaw"
# The mounted QA harness imports openclaw/plugin-sdk and package dependencies;
# point those imports at the installed package without copying source into the test image.
rm -rf /app/node_modules/openclaw
ln -sfnT "$openclaw_package_dir" /app/node_modules/openclaw
for deps_dir in "$openclaw_package_dir/node_modules" /npm-global/lib/node_modules; do
[ -d "$deps_dir" ] || continue
for dependency_dir in "$deps_dir"/*; do
[ -e "$dependency_dir" ] || continue
dependency_name="$(basename "$dependency_dir")"
case "$dependency_name" in
.bin | openclaw)
continue
;;
@*)
[ -d "$dependency_dir" ] || continue
mkdir -p "/app/node_modules/$dependency_name"
for scoped_dependency_dir in "$dependency_dir"/*; do
[ -e "$scoped_dependency_dir" ] || continue
scoped_dependency_name="$(basename "$scoped_dependency_dir")"
rm -rf "/app/node_modules/$dependency_name/$scoped_dependency_name"
ln -sfnT "$scoped_dependency_dir" "/app/node_modules/$dependency_name/$scoped_dependency_name"
done
;;
*)
rm -rf "/app/node_modules/$dependency_name"
ln -sfnT "$dependency_dir" "/app/node_modules/$dependency_name"
;;
esac
done
done
echo "Running installed npm onboarding recovery hot path..."
echo "Running installed-package onboarding recovery hot path..."
OPENAI_API_KEY="${OPENAI_API_KEY:-sk-openclaw-npm-telegram-hotpath}" openclaw onboard --non-interactive --accept-risk \
--mode local \
--auth-choice openai-api-key \
@@ -200,4 +282,4 @@ trap - ERR
node --import tsx scripts/e2e/npm-telegram-live-runner.ts
EOF
echo "published npm Telegram live Docker E2E passed ($PACKAGE_SPEC)"
echo "package Telegram live Docker E2E passed ($PACKAGE_LABEL)"

View File

@@ -1,10 +1,9 @@
#!/usr/bin/env -S node --import tsx
// Telegram package Docker harness.
// Runs QA live transport code against the package candidate installed in Docker.
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { runTelegramQaLive } from "../../extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts";
import { formatErrorMessage } from "../../src/infra/errors.ts";
function parseBoolean(value: string | undefined) {
const normalized = value?.trim().toLowerCase();
@@ -51,6 +50,8 @@ async function resolveTrustedOpenClawCommand(rawCommand: string) {
}
async function main() {
const { runTelegramQaLive } =
await import("../../extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts");
const rawSutOpenClawCommand = process.env.OPENCLAW_NPM_TELEGRAM_SUT_COMMAND?.trim();
if (!rawSutOpenClawCommand) {
throw new Error("Missing OPENCLAW_NPM_TELEGRAM_SUT_COMMAND.");
@@ -76,9 +77,9 @@ async function main() {
credentialRole: resolveCredentialRole(process.env),
});
process.stdout.write(`NPM Telegram QA report: ${result.reportPath}\n`);
process.stdout.write(`NPM Telegram QA summary: ${result.summaryPath}\n`);
process.stdout.write(`NPM Telegram QA observed messages: ${result.observedMessagesPath}\n`);
process.stdout.write(`Package Telegram QA report: ${result.reportPath}\n`);
process.stdout.write(`Package Telegram QA summary: ${result.summaryPath}\n`);
process.stdout.write(`Package Telegram QA observed messages: ${result.observedMessagesPath}\n`);
if (
!parseBoolean(process.env.OPENCLAW_NPM_TELEGRAM_ALLOW_FAILURES) &&
result.scenarios.some((scenario) => scenario.status === "fail")
@@ -87,9 +88,20 @@ async function main() {
}
}
async function formatRunnerErrorMessage(error: unknown) {
try {
const { formatErrorMessage } = await import("../../dist/infra/errors.js");
return formatErrorMessage(error);
} catch {
return error instanceof Error ? error.message : String(error);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
process.stderr.write(`npm telegram live e2e failed: ${formatErrorMessage(error)}\n`);
main().catch(async (error) => {
process.stderr.write(
`package telegram live e2e failed: ${await formatRunnerErrorMessage(error)}\n`,
);
process.exitCode = 1;
});
}

View File

@@ -383,15 +383,16 @@ const gatewayArgs = [
"--token",
token,
"--timeout",
"120000",
"240000",
"--expect-final",
"--json",
];
function gatewayCall(method, params) {
function gatewayAgent(params) {
try {
return {
ok: true,
value: JSON.parse(execFileSync("node", [...gatewayArgs, method, "--params", JSON.stringify(params)], {
value: JSON.parse(execFileSync("node", [...gatewayArgs, "agent", "--params", JSON.stringify(params)], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
})),
@@ -404,7 +405,7 @@ function gatewayCall(method, params) {
}
}
const sendRes = gatewayCall("agent", {
const result = gatewayAgent({
sessionKey,
message,
thinking: "minimal",
@@ -413,20 +414,13 @@ const sendRes = gatewayCall("agent", {
idempotencyKey: id,
});
if (!sendRes.ok) throw sendRes.error;
const runId =
sendRes.value && typeof sendRes.value === "object" && typeof sendRes.value.runId === "string"
? sendRes.value.runId
: id;
const wait = gatewayCall("agent.wait", { runId, timeoutMs: 180000 });
if (!wait.ok) throw wait.error;
if (mode === "reject") {
console.error(JSON.stringify(wait.value));
console.error(result.ok ? JSON.stringify(result.value) : String(result.error));
process.exit(0);
}
if (wait.value?.status !== "ok") {
throw new Error(`agent run did not complete successfully: ${JSON.stringify(wait.value)}`);
if (!result.ok) throw result.error;
if (result.value?.status !== "ok") {
throw new Error(`agent run did not complete successfully: ${JSON.stringify(result.value)}`);
}
NODE

View File

@@ -611,6 +611,169 @@ CLAWHUB_PLUGIN_SPEC="${OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC:-clawhub:openclaw-now4r
CLAWHUB_PLUGIN_ID="${OPENCLAW_PLUGINS_E2E_CLAWHUB_ID:-now4real}"
export CLAWHUB_PLUGIN_SPEC CLAWHUB_PLUGIN_ID
start_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-now4real";
const pluginId = "now4real";
const version = "0.1.2";
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: "Now 4 Real",
register(api) {
api.registerGatewayMethod("now4real.ping", async () => ({ ok: true }));
},
};
`,
{ date: new Date(0) },
);
zip.file(
"package/openclaw.plugin.json",
`${JSON.stringify(
{
id: pluginId,
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 json = (response, value) => {
response.writeHead(200, { "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, {
package: {
name: packageName,
displayName: "Now 4 Real",
family: "code-plugin",
channel: "official",
isOfficial: true,
runtimeId: pluginId,
latestVersion: version,
createdAt: 0,
updatedAt: 0,
compatibility: {
pluginApiRange: ">=2026.4.11",
minGatewayVersion: "2026.4.11",
},
},
});
return;
}
if (
url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/${version}`
) {
json(response, {
version: {
version,
createdAt: 0,
changelog: "Fixture package for Docker plugin E2E.",
sha256hash,
compatibility: {
pluginApiRange: ">=2026.4.11",
minGatewayVersion: "2026.4.11",
},
},
});
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 ClawHub fixture server." >&2
return 1
}
if [[ -z "${OPENCLAW_CLAWHUB_URL:-}" && -z "${CLAWHUB_URL:-}" ]]; then
# Keep the release-path smoke hermetic; live ClawHub can rate-limit CI.
clawhub_fixture_dir="$(mktemp -d "/tmp/openclaw-clawhub-fixture.XXXXXX")"
start_clawhub_fixture_server "$clawhub_fixture_dir"
fi
node - <<'NODE'
const spec = process.env.CLAWHUB_PLUGIN_SPEC;
if (!spec?.startsWith("clawhub:")) {
@@ -749,8 +912,6 @@ console.log("ok");
NODE
fi
echo "Running bundle MCP CLI-agent e2e..."
node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts src/agents/cli-runner.bundle-mcp.e2e.test.ts
EOF
then
cat "$RUN_LOG"

View File

@@ -2,7 +2,7 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
source "$ROOT_DIR/scripts/lib/docker-build.sh"
IMAGE_NAME="${OPENCLAW_QR_SMOKE_IMAGE:-openclaw-qr-smoke}"
DOCKER_BUILD_ARGS=()
@@ -15,16 +15,18 @@ if [[ "${OPENCLAW_QR_SMOKE_FORCE_INSTALL:-0}" == "1" ]]; then
fi
echo "Building Docker image..."
DOCKER_BUILD_CMD=(docker build)
if ((${#DOCKER_BUILD_ARGS[@]} > 0)); then
DOCKER_BUILD_CMD+=("${DOCKER_BUILD_ARGS[@]}")
docker_build_run qr-import-build \
"${DOCKER_BUILD_ARGS[@]}" \
-t "$IMAGE_NAME" \
-f "$ROOT_DIR/scripts/e2e/Dockerfile.qr-import" \
"$ROOT_DIR"
else
docker_build_run qr-import-build \
-t "$IMAGE_NAME" \
-f "$ROOT_DIR/scripts/e2e/Dockerfile.qr-import" \
"$ROOT_DIR"
fi
DOCKER_BUILD_CMD+=(
-t "$IMAGE_NAME"
-f "$ROOT_DIR/scripts/e2e/Dockerfile.qr-import"
"$ROOT_DIR"
)
run_logged qr-import-build "${DOCKER_BUILD_CMD[@]}"
echo "Running qrcode-tui import smoke..."
run_logged qr-import-run docker run --rm -t "$IMAGE_NAME" node -e "import('@vincentkoc/qrcode-tui').then(async (m)=>{process.stdout.write(await m.renderTerminal('qr-smoke',{small:true}))})"

View File

@@ -18,7 +18,7 @@ $ErrorActionPreference = "Stop"
$ACCENT = "`e[38;2;255;77;77m" # coral-bright
$SUCCESS = "`e[38;2;0;229;204m" # cyan-bright
$WARN = "`e[38;2;255;176;32m" # amber
$ERROR = "`e[38;2;230;57;70m" # coral-mid
$ERROR_COLOR = "`e[38;2;230;57;70m" # coral-mid
$MUTED = "`e[38;2;90;100;128m" # text-muted
$NC = "`e[0m" # No Color
@@ -27,7 +27,7 @@ function Write-Host {
$msg = switch ($Level) {
"success" { "$SUCCESS$NC $Message" }
"warn" { "$WARN!$NC $Message" }
"error" { "$ERROR$NC $Message" }
"error" { "$ERROR_COLOR$NC $Message" }
default { "$MUTED·$NC $Message" }
}
Microsoft.PowerShell.Utility\Write-Host $msg

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
DOCKER_BUILD_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if ! declare -F run_logged >/dev/null 2>&1; then
source "$DOCKER_BUILD_LIB_DIR/docker-e2e-logs.sh"
fi
docker_build_exec() {
local build_cmd=(docker build)
if [ "${OPENCLAW_DOCKER_BUILD_USE_BUILDX:-0}" = "1" ]; then
build_cmd=(docker buildx build --load)
if [ -n "${OPENCLAW_DOCKER_BUILD_CACHE_FROM:-}" ]; then
build_cmd+=(--cache-from "${OPENCLAW_DOCKER_BUILD_CACHE_FROM}")
fi
if [ -n "${OPENCLAW_DOCKER_BUILD_CACHE_TO:-}" ]; then
build_cmd+=(--cache-to "${OPENCLAW_DOCKER_BUILD_CACHE_TO}")
fi
fi
env DOCKER_BUILDKIT=1 "${build_cmd[@]}" "$@"
}
docker_build_run() {
local label="$1"
shift
local build_cmd=(docker build)
if [ "${OPENCLAW_DOCKER_BUILD_USE_BUILDX:-0}" = "1" ]; then
build_cmd=(docker buildx build --load)
if [ -n "${OPENCLAW_DOCKER_BUILD_CACHE_FROM:-}" ]; then
build_cmd+=(--cache-from "${OPENCLAW_DOCKER_BUILD_CACHE_FROM}")
fi
if [ -n "${OPENCLAW_DOCKER_BUILD_CACHE_TO:-}" ]; then
build_cmd+=(--cache-to "${OPENCLAW_DOCKER_BUILD_CACHE_TO}")
fi
fi
run_logged "$label" env DOCKER_BUILDKIT=1 "${build_cmd[@]}" "$@"
}

View File

@@ -4,6 +4,8 @@ DOCKER_E2E_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="${ROOT_DIR:-$(cd "$DOCKER_E2E_LIB_DIR/../.." && pwd)}"
source "$DOCKER_E2E_LIB_DIR/docker-e2e-logs.sh"
source "$DOCKER_E2E_LIB_DIR/docker-build.sh"
source "$DOCKER_E2E_LIB_DIR/docker-e2e-package.sh"
docker_e2e_resolve_image() {
local default_image="$1"
@@ -48,10 +50,10 @@ docker_e2e_build_or_reuse() {
fi
echo "Building Docker image: $image_name"
local build_cmd=(docker build)
local build_args=()
if [ -n "$target" ]; then
build_cmd+=(--target "$target")
build_args+=(--target "$target")
fi
build_cmd+=(-t "$image_name" -f "$dockerfile" "$context")
run_logged "$label-build" "${build_cmd[@]}"
build_args+=(-t "$image_name" -f "$dockerfile" "$context")
docker_build_run "$label-build" "${build_args[@]}"
}

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
#
# Shared package helpers for Docker E2E scripts.
# Builds or resolves one OpenClaw npm tarball and exposes mount/build-context
# helpers so Docker lanes test the package artifact instead of repo sources.
DOCKER_E2E_PACKAGE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="${ROOT_DIR:-$(cd "$DOCKER_E2E_PACKAGE_LIB_DIR/../.." && pwd)}"
if ! declare -F run_logged >/dev/null 2>&1; then
source "$DOCKER_E2E_PACKAGE_LIB_DIR/docker-e2e-logs.sh"
fi
docker_e2e_abs_path() {
local file="$1"
(cd "$(dirname "$file")" && printf '%s/%s\n' "$(pwd)" "$(basename "$file")")
}
docker_e2e_prepare_package_tgz() {
local label="$1"
local package_tgz="${2:-${OPENCLAW_CURRENT_PACKAGE_TGZ:-}}"
if [ -n "$package_tgz" ]; then
if [ ! -f "$package_tgz" ]; then
echo "OpenClaw package tarball does not exist: $package_tgz" >&2
return 1
fi
docker_e2e_abs_path "$package_tgz"
return 0
fi
local pack_dir
pack_dir="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-docker-e2e-pack.XXXXXX")"
package_tgz="$(
node "$ROOT_DIR/scripts/package-openclaw-for-docker.mjs" \
--output-dir "$pack_dir" \
--output-name openclaw-current.tgz
)"
if [ -z "$package_tgz" ]; then
echo "missing packed OpenClaw tarball" >&2
return 1
fi
docker_e2e_abs_path "$package_tgz"
}
docker_e2e_prepare_package_context() {
local package_tgz="$1"
local context_dir
context_dir="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-docker-e2e-package-context.XXXXXX")"
# BuildKit named contexts must be directories, so expose the tarball as a
# stable filename inside a tiny temporary context.
cp "$package_tgz" "$context_dir/openclaw-current.tgz"
printf '%s\n' "$context_dir"
}
docker_e2e_package_mount_args() {
local package_tgz="$1"
local target="${2:-/tmp/openclaw-current.tgz}"
DOCKER_E2E_PACKAGE_ARGS=(-v "$package_tgz:$target:ro" -e "OPENCLAW_CURRENT_PACKAGE_TGZ=$target")
}
docker_e2e_harness_mount_args() {
DOCKER_E2E_HARNESS_ARGS=(-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro")
}

View File

@@ -1742,6 +1742,14 @@ async function runInstalledModelsSet(params) {
logPath: params.logPath,
timeoutMs: 2 * 60 * 1000,
});
await runInstalledCli({
cliPath: params.cliPath,
args: ["config", "set", "agents.defaults.skipBootstrap", "true", "--strict-json"],
cwd: params.cwd,
env: params.env,
logPath: params.logPath,
timeoutMs: 2 * 60 * 1000,
});
}
async function runInstalledAgentTurn(params) {
@@ -2388,6 +2396,13 @@ async function runModelsSet(params) {
logPath: params.logPath,
timeoutMs: 2 * 60 * 1000,
});
await runOpenClaw({
lane: params.lane,
env: params.env,
args: ["config", "set", "agents.defaults.skipBootstrap", "true", "--strict-json"],
logPath: params.logPath,
timeoutMs: 2 * 60 * 1000,
});
}
async function runAgentTurn(params) {

View File

@@ -498,6 +498,36 @@ export function collectControlUiPackErrors(paths: Iterable<string>): string[] {
return errors;
}
export function collectInventoryPackMismatchErrors(
paths: Iterable<string>,
rootDir = process.cwd(),
): string[] {
const packedPaths = new Set([...paths].map(normalizePackedPath));
if (!packedPaths.has(PACKAGE_DIST_INVENTORY_RELATIVE_PATH)) {
return [];
}
let inventory: unknown;
try {
inventory = JSON.parse(
readFileSync(join(rootDir, PACKAGE_DIST_INVENTORY_RELATIVE_PATH), "utf8"),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return [`invalid package dist inventory ${PACKAGE_DIST_INVENTORY_RELATIVE_PATH}: ${message}`];
}
if (!Array.isArray(inventory) || inventory.some((entry) => typeof entry !== "string")) {
return [`invalid package dist inventory ${PACKAGE_DIST_INVENTORY_RELATIVE_PATH}`];
}
return inventory
.map((entry) => normalizePackedPath(entry))
.filter((entry) => !packedPaths.has(entry))
.map((entry) => `inventory references missing npm pack entry ${entry}`)
.toSorted((left, right) => left.localeCompare(right));
}
function collectPackedTarballErrors(): string[] {
const errors: string[] = [];
let stdout = "";
@@ -532,6 +562,7 @@ function collectPackedTarballErrors(): string[] {
return [
...collectControlUiPackErrors(packedPaths),
...collectInventoryPackMismatchErrors(packedPaths),
...collectForbiddenPackedPathErrors(packedPaths),
...collectForbiddenPackedContentErrors(packedPaths),
...collectPackedTestCargoErrors(packedPaths),

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env node
// Builds the OpenClaw package artifact used by Docker E2E.
// The script owns the build/inventory/pack sequence so local scheduler, shell
// helpers, and GitHub Actions all prepare the exact same npm tarball.
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
function parseArgs(argv) {
const options = {
outputDir: "",
outputName: "",
skipBuild: false,
sourceDir: ROOT_DIR,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--output-dir") {
options.outputDir = argv[(index += 1)] ?? "";
} else if (arg?.startsWith("--output-dir=")) {
options.outputDir = arg.slice("--output-dir=".length);
} else if (arg === "--output-name") {
options.outputName = argv[(index += 1)] ?? "";
} else if (arg?.startsWith("--output-name=")) {
options.outputName = arg.slice("--output-name=".length);
} else if (arg === "--skip-build") {
options.skipBuild = true;
} else if (arg === "--source-dir") {
options.sourceDir = argv[(index += 1)] ?? "";
} else if (arg?.startsWith("--source-dir=")) {
options.sourceDir = arg.slice("--source-dir=".length);
} else {
throw new Error(`unknown argument: ${arg}`);
}
}
return options;
}
function run(command, args, cwd) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.pipe(process.stderr, { end: false });
child.stderr.pipe(process.stderr, { end: false });
child.on("error", reject);
child.on("close", (status, signal) => {
if (status === 0) {
resolve();
return;
}
reject(new Error(`${command} ${args.join(" ")} failed with ${status ?? signal}`));
});
});
}
async function runCapture(command, args, cwd) {
return await new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.pipe(process.stderr, { end: false });
child.on("error", reject);
child.on("close", (status, signal) => {
if (status === 0) {
resolve(stdout);
return;
}
reject(new Error(`${command} ${args.join(" ")} failed with ${status ?? signal}`));
});
});
}
async function newestOpenClawTarball(outputDir, packOutput) {
let fromOutput = "";
for (const line of packOutput.split(/\r?\n/u)) {
const trimmed = line.trim();
if (/^openclaw-.*\.tgz$/u.test(trimmed)) {
fromOutput = trimmed;
}
}
if (fromOutput) {
return path.join(outputDir, fromOutput);
}
const entries = await fs.readdir(outputDir);
const packed = entries
.filter((entry) => /^openclaw-.*\.tgz$/u.test(entry))
.toSorted()
.at(-1);
if (!packed) {
throw new Error(`missing packed OpenClaw tarball in ${outputDir}`);
}
return path.join(outputDir, packed);
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const sourceDir = path.resolve(ROOT_DIR, options.sourceDir || ROOT_DIR);
const outputDir = path.resolve(
ROOT_DIR,
options.outputDir || path.join(".artifacts", "docker-e2e-package"),
);
await fs.mkdir(outputDir, { recursive: true });
if (!options.skipBuild) {
console.error("==> Building OpenClaw package artifacts");
await run("pnpm", ["build"], sourceDir);
}
console.error("==> Writing OpenClaw package inventory");
await run(
"node",
[
"--import",
"tsx",
"--input-type=module",
"-e",
"const { writePackageDistInventory } = await import('./src/infra/package-dist-inventory.ts'); await writePackageDistInventory(process.cwd());",
],
sourceDir,
);
console.error("==> Packing OpenClaw package");
const packOutput = await runCapture(
"npm",
["pack", "--silent", "--ignore-scripts", "--pack-destination", outputDir],
sourceDir,
);
let tarball = await newestOpenClawTarball(outputDir, packOutput);
if (options.outputName) {
const target = path.join(outputDir, options.outputName);
if (target !== tarball) {
await fs.rm(target, { force: true });
await fs.rename(tarball, target);
tarball = target;
}
}
console.error("==> Checking OpenClaw package tarball");
await run(
"node",
[path.join(ROOT_DIR, "scripts/check-openclaw-package-tarball.mjs"), tarball],
sourceDir,
);
process.stdout.write(`${tarball}\n`);
}
await main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});

View File

@@ -622,6 +622,35 @@ export function collectForbiddenPackContentPaths(
.toSorted((left, right) => left.localeCompare(right));
}
export function collectInventoryPackMismatchErrors(
paths: Iterable<string>,
rootDir = process.cwd(),
): string[] {
const packedPaths = new Set(paths);
if (!packedPaths.has(PACKAGE_DIST_INVENTORY_RELATIVE_PATH)) {
return [];
}
const inventoryPath = resolve(rootDir, PACKAGE_DIST_INVENTORY_RELATIVE_PATH);
let inventory: unknown;
try {
inventory = JSON.parse(readFileSync(inventoryPath, "utf8")) as unknown;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return [`invalid package dist inventory ${PACKAGE_DIST_INVENTORY_RELATIVE_PATH}: ${message}`];
}
if (!Array.isArray(inventory) || inventory.some((entry) => typeof entry !== "string")) {
return [`invalid package dist inventory ${PACKAGE_DIST_INVENTORY_RELATIVE_PATH}`];
}
return inventory
.map((entry) => entry.replace(/\\/g, "/"))
.filter((entry) => !packedPaths.has(entry))
.map((entry) => `inventory references missing npm pack entry ${entry}`)
.toSorted((left, right) => left.localeCompare(right));
}
export { collectPackUnpackedSizeErrors } from "./lib/npm-pack-budget.mjs";
function extractTag(item: string, tag: string): string | null {
@@ -799,12 +828,14 @@ async function main() {
.toSorted((left, right) => left.localeCompare(right));
const forbidden = collectForbiddenPackPaths(paths);
const forbiddenContent = collectForbiddenPackContentPaths(paths);
const inventoryMismatch = collectInventoryPackMismatchErrors(paths);
const sizeErrors = collectNpmPackUnpackedSizeErrors(results);
if (
missing.length > 0 ||
forbidden.length > 0 ||
forbiddenContent.length > 0 ||
inventoryMismatch.length > 0 ||
sizeErrors.length > 0
) {
if (missing.length > 0) {
@@ -837,6 +868,12 @@ async function main() {
console.error(` - ${path}`);
}
}
if (inventoryMismatch.length > 0) {
console.error("release-check: package dist inventory does not match npm pack:");
for (const error of inventoryMismatch) {
console.error(` - ${error}`);
}
}
if (sizeErrors.length > 0) {
console.error("release-check: npm pack unpacked size budget exceeded:");
for (const error of sizeErrors) {

View File

@@ -0,0 +1,465 @@
#!/usr/bin/env node
// Normalizes package-acceptance inputs into the tarball shape consumed by Docker E2E.
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { createWriteStream } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import { fileURLToPath } from "node:url";
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const DEFAULT_OUTPUT_NAME = "openclaw-current.tgz";
export const OPENCLAW_PACKAGE_SPEC_RE =
/^openclaw@(beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-beta\.[1-9][0-9]*)?)$/u;
function usage() {
return `Usage: node scripts/resolve-openclaw-package-candidate.mjs --source <ref|npm|url|artifact> --output-dir <dir> [options]
Options:
--package-spec <spec> Published npm spec for source=npm.
--package-ref <ref> Trusted repo ref for source=ref.
--package-url <url> HTTPS tarball URL for source=url.
--package-sha256 <sha256> Expected tarball SHA-256 for source=url or source=artifact.
--artifact-dir <dir> Directory containing exactly one .tgz for source=artifact.
--output-name <name> Output tarball filename. Default: ${DEFAULT_OUTPUT_NAME}
--metadata <file> Write package metadata JSON.
--github-output <file> Append tarball, sha256, package name/version outputs.`;
}
export function parseArgs(argv) {
const options = {
artifactDir: "",
githubOutput: "",
metadata: "",
outputDir: "",
outputName: DEFAULT_OUTPUT_NAME,
packageRef: "",
packageSha256: "",
packageSpec: "",
packageUrl: "",
source: "",
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const readValue = (name) => {
const value = argv[(index += 1)];
if (value === undefined) {
throw new Error(`${name} requires a value`);
}
return value;
};
if (arg === "--artifact-dir") {
options.artifactDir = readValue(arg);
} else if (arg === "--github-output") {
options.githubOutput = readValue(arg);
} else if (arg === "--metadata") {
options.metadata = readValue(arg);
} else if (arg === "--output-dir") {
options.outputDir = readValue(arg);
} else if (arg === "--output-name") {
options.outputName = readValue(arg);
} else if (arg === "--package-sha256") {
options.packageSha256 = readValue(arg).toLowerCase();
} else if (arg === "--package-ref") {
options.packageRef = readValue(arg);
} else if (arg === "--package-spec") {
options.packageSpec = readValue(arg);
} else if (arg === "--package-url") {
options.packageUrl = readValue(arg);
} else if (arg === "--source") {
options.source = readValue(arg);
} else if (arg === "--help" || arg === "-h") {
options.help = true;
} else {
throw new Error(`unknown argument: ${arg}`);
}
}
return options;
}
export function validateOpenClawPackageSpec(spec) {
if (!OPENCLAW_PACKAGE_SPEC_RE.test(spec)) {
throw new Error(
`package_spec must be openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: ${spec}`,
);
}
}
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: options.cwd ?? ROOT_DIR,
stdio: options.capture ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
});
let stdout = "";
let stderr = "";
if (options.capture) {
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
}
child.on("error", reject);
child.on("close", (status, signal) => {
if (status === 0) {
resolve(stdout);
return;
}
const detail = stderr.trim() ? `\n${stderr.trim()}` : "";
reject(new Error(`${command} ${args.join(" ")} failed with ${status ?? signal}${detail}`));
});
});
}
async function walkFiles(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const absolute = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await walkFiles(absolute)));
} else if (entry.isFile()) {
files.push(absolute);
}
}
return files;
}
async function sha256(file) {
const hash = createHash("sha256");
const handle = await fs.open(file, "r");
try {
for await (const chunk of handle.createReadStream()) {
hash.update(chunk);
}
} finally {
await handle.close();
}
return hash.digest("hex");
}
function assertSha256(value) {
if (!/^[a-f0-9]{64}$/u.test(value)) {
throw new Error(`package_sha256 must be a lowercase or uppercase 64-character SHA-256 digest`);
}
}
async function assertExpectedSha256(file, expected) {
if (!expected) {
return await sha256(file);
}
assertSha256(expected);
const actual = await sha256(file);
if (actual !== expected.toLowerCase()) {
throw new Error(`package SHA-256 mismatch: expected ${expected}, got ${actual}`);
}
return actual;
}
async function findSingleTarball(dir) {
const files = (await walkFiles(path.resolve(ROOT_DIR, dir)))
.filter((file) => /\.t(?:ar\.)?gz$/u.test(path.basename(file)))
.toSorted((a, b) => a.localeCompare(b));
if (files.length !== 1) {
throw new Error(
`source=artifact requires exactly one .tgz under ${dir}; found ${files.length}: ${files.join(", ")}`,
);
}
return files[0];
}
async function revParseTrustedInputRef(ref) {
const candidates = [ref, `refs/remotes/origin/${ref}`, `refs/tags/${ref}`];
for (const candidate of candidates) {
const resolved = await run("git", ["rev-parse", "--verify", `${candidate}^{commit}`], {
capture: true,
}).then(
(value) => value.trim(),
() => "",
);
if (resolved) {
return resolved;
}
}
throw new Error(`package_ref does not resolve to a commit: ${ref}`);
}
async function resolveTrustedRepoRef(ref) {
if (!ref || ref.trim() === "" || ref.startsWith("-")) {
throw new Error(
`package_ref must be a branch, tag, or full commit SHA; got: ${ref || "<empty>"}`,
);
}
await run("git", ["fetch", "--no-tags", "origin", "+refs/heads/*:refs/remotes/origin/*"]);
await run("git", ["fetch", "--tags", "origin", "+refs/tags/*:refs/tags/*"]);
const selectedSha = await revParseTrustedInputRef(ref);
const isMainAncestor = await run("git", [
"merge-base",
"--is-ancestor",
selectedSha,
"refs/remotes/origin/main",
]).then(
() => true,
() => false,
);
if (isMainAncestor) {
return { selectedSha, trustedReason: "main-ancestor" };
}
const releaseTags = (await run("git", ["tag", "--points-at", selectedSha], { capture: true }))
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean);
if (releaseTags.some((tag) => tag.startsWith("v"))) {
return { selectedSha, trustedReason: "release-tag" };
}
const containingBranches = (
await run(
"git",
[
"for-each-ref",
"--format=%(refname:short)",
"--contains",
selectedSha,
"refs/remotes/origin",
],
{ capture: true },
)
)
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean);
if (containingBranches.some((branch) => branch.startsWith("origin/"))) {
return { selectedSha, trustedReason: "repository-branch-history" };
}
throw new Error(
`package_ref ${ref} resolved to ${selectedSha}, which is not reachable from an OpenClaw branch or release tag`,
);
}
async function preparePackageSourceWorktree(ref) {
const { selectedSha, trustedReason } = await resolveTrustedRepoRef(ref);
const sourceDir = path.join(
process.env.RUNNER_TEMP || os.tmpdir(),
`openclaw-package-source-${process.pid}`,
);
await fs.rm(sourceDir, { recursive: true, force: true });
await run("git", ["worktree", "add", "--detach", sourceDir, selectedSha]);
return { selectedSha, sourceDir, trustedReason };
}
async function installPackageSourceDeps(sourceDir) {
await run(
"pnpm",
[
"install",
"--frozen-lockfile",
"--ignore-scripts=false",
"--config.engine-strict=false",
"--config.enable-pre-post-scripts=true",
],
{ cwd: sourceDir },
);
}
async function moveNewestPackedTarball(outputDir, packOutput, outputName) {
let filename = "";
try {
const parsed = JSON.parse(packOutput);
if (Array.isArray(parsed)) {
filename = parsed.find((entry) => typeof entry?.filename === "string")?.filename ?? "";
}
} catch {}
if (!filename) {
for (const line of packOutput.split(/\r?\n/u)) {
const trimmed = line.trim();
if (/^openclaw-.*\.tgz$/u.test(trimmed)) {
filename = trimmed;
}
}
}
if (!filename) {
const entries = await fs.readdir(outputDir);
filename = entries
.filter((entry) => /^openclaw-.*\.tgz$/u.test(entry))
.toSorted((a, b) => a.localeCompare(b))
.at(-1);
}
if (!filename) {
throw new Error(`npm pack produced no OpenClaw tarball in ${outputDir}`);
}
const packed = path.join(outputDir, filename);
const target = path.join(outputDir, outputName);
if (packed !== target) {
await fs.rm(target, { force: true });
await fs.rename(packed, target);
}
return target;
}
async function downloadUrl(url, target) {
const parsed = new URL(url);
if (parsed.protocol !== "https:") {
throw new Error(`package_url must use https: ${url}`);
}
const response = await fetch(parsed);
if (!response.ok || !response.body) {
throw new Error(`failed to download package_url: HTTP ${response.status}`);
}
await pipeline(response.body, createWriteStream(target));
}
async function readPackageJson(tarball) {
const raw = await run("tar", ["-xOf", tarball, "package/package.json"], { capture: true });
const pkg = JSON.parse(raw);
return {
name: typeof pkg.name === "string" ? pkg.name : "",
version: typeof pkg.version === "string" ? pkg.version : "",
};
}
async function appendGithubOutputs(file, outputs) {
if (!file) {
return;
}
const body = Object.entries(outputs)
.map(([key, value]) => `${key}=${String(value).replace(/\n/gu, " ")}`)
.join("\n");
await fs.appendFile(file, `${body}\n`);
}
async function resolveCandidate(options) {
const outputDir = path.resolve(ROOT_DIR, options.outputDir);
const target = path.join(outputDir, options.outputName || DEFAULT_OUTPUT_NAME);
await fs.mkdir(outputDir, { recursive: true });
await fs.rm(target, { force: true });
let packageRef = "";
let packageSourceSha = "";
let packageTrustedReason = "";
let packageWorktreeDir = "";
try {
if (options.source === "ref") {
packageRef = options.packageRef || "main";
const packageSource = await preparePackageSourceWorktree(packageRef);
packageWorktreeDir = packageSource.sourceDir;
packageSourceSha = packageSource.selectedSha;
packageTrustedReason = packageSource.trustedReason;
await installPackageSourceDeps(packageSource.sourceDir);
await run("node", [
"scripts/package-openclaw-for-docker.mjs",
"--source-dir",
packageSource.sourceDir,
"--output-dir",
outputDir,
"--output-name",
options.outputName || DEFAULT_OUTPUT_NAME,
]);
} else if (options.source === "npm") {
validateOpenClawPackageSpec(options.packageSpec);
const packOutput = await run(
"npm",
[
"pack",
options.packageSpec,
"--ignore-scripts",
"--json",
"--pack-destination",
outputDir,
],
{ capture: true },
);
await moveNewestPackedTarball(
outputDir,
packOutput,
options.outputName || DEFAULT_OUTPUT_NAME,
);
} else if (options.source === "url") {
if (!options.packageUrl) {
throw new Error("source=url requires --package-url");
}
if (!options.packageSha256) {
throw new Error("source=url requires --package-sha256");
}
await downloadUrl(options.packageUrl, target);
} else if (options.source === "artifact") {
if (!options.artifactDir) {
throw new Error("source=artifact requires --artifact-dir");
}
const input = await findSingleTarball(options.artifactDir);
await fs.copyFile(input, target);
} else {
throw new Error(`source must be one of: ref, npm, url, artifact. Got: ${options.source}`);
}
} finally {
if (packageWorktreeDir) {
await run("git", ["worktree", "remove", "--force", packageWorktreeDir]).catch(() => {});
}
}
const digest = await assertExpectedSha256(target, options.packageSha256);
await run("node", ["scripts/check-openclaw-package-tarball.mjs", target]);
const pkg = await readPackageJson(target);
const metadata = {
name: pkg.name,
packageRef,
packageSpec: options.packageSpec || "",
packageSourceSha,
packageTrustedReason,
sha256: digest,
source: options.source,
tarball: path.relative(ROOT_DIR, target),
version: pkg.version,
};
if (pkg.name !== "openclaw") {
throw new Error(`package candidate must be named "openclaw"; got: ${pkg.name || "<missing>"}`);
}
if (!pkg.version) {
throw new Error("package candidate package.json has no version");
}
if (options.metadata) {
await fs.mkdir(path.dirname(path.resolve(ROOT_DIR, options.metadata)), { recursive: true });
await fs.writeFile(
path.resolve(ROOT_DIR, options.metadata),
`${JSON.stringify(metadata, null, 2)}\n`,
);
}
await appendGithubOutputs(options.githubOutput, {
package_name: pkg.name,
package_version: pkg.version,
sha256: digest,
tarball: metadata.tarball,
});
return metadata;
}
export async function main(argv = process.argv.slice(2)) {
const options = parseArgs(argv);
if (options.help) {
console.log(usage());
return;
}
if (!options.outputDir) {
throw new Error("--output-dir is required");
}
const metadata = await resolveCandidate(options);
console.log(JSON.stringify(metadata, null, 2));
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
console.error(usage());
process.exit(1);
});
}

View File

@@ -1,7 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-build.sh"
IMAGE_NAME="openclaw-sandbox-browser:bookworm-slim"
docker build -t "${IMAGE_NAME}" -f Dockerfile.sandbox-browser .
docker_build_exec -t "${IMAGE_NAME}" -f "$ROOT_DIR/Dockerfile.sandbox-browser" "$ROOT_DIR"
echo "Built ${IMAGE_NAME}"

View File

@@ -1,6 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-build.sh"
BASE_IMAGE="${BASE_IMAGE:-openclaw-sandbox:bookworm-slim}"
TARGET_IMAGE="${TARGET_IMAGE:-openclaw-sandbox-common:bookworm-slim}"
PACKAGES="${PACKAGES:-curl wget jq coreutils grep nodejs npm python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file}"
@@ -17,25 +20,14 @@ OPENCLAW_DOCKER_BUILD_CACHE_TO="${OPENCLAW_DOCKER_BUILD_CACHE_TO:-}"
if ! docker image inspect "${BASE_IMAGE}" >/dev/null 2>&1; then
echo "Base image missing: ${BASE_IMAGE}"
echo "Building base image via scripts/sandbox-setup.sh..."
scripts/sandbox-setup.sh
"$ROOT_DIR/scripts/sandbox-setup.sh"
fi
echo "Building ${TARGET_IMAGE} with: ${PACKAGES}"
build_cmd=(docker build)
if [ "${OPENCLAW_DOCKER_BUILD_USE_BUILDX}" = "1" ]; then
build_cmd=(docker buildx build --load)
if [ -n "${OPENCLAW_DOCKER_BUILD_CACHE_FROM}" ]; then
build_cmd+=(--cache-from "${OPENCLAW_DOCKER_BUILD_CACHE_FROM}")
fi
if [ -n "${OPENCLAW_DOCKER_BUILD_CACHE_TO}" ]; then
build_cmd+=(--cache-to "${OPENCLAW_DOCKER_BUILD_CACHE_TO}")
fi
fi
"${build_cmd[@]}" \
docker_build_exec \
-t "${TARGET_IMAGE}" \
-f Dockerfile.sandbox-common \
-f "$ROOT_DIR/Dockerfile.sandbox-common" \
--build-arg BASE_IMAGE="${BASE_IMAGE}" \
--build-arg PACKAGES="${PACKAGES}" \
--build-arg INSTALL_PNPM="${INSTALL_PNPM}" \
@@ -44,7 +36,7 @@ fi
--build-arg INSTALL_BREW="${INSTALL_BREW}" \
--build-arg BREW_INSTALL_DIR="${BREW_INSTALL_DIR}" \
--build-arg FINAL_USER="${FINAL_USER}" \
.
"$ROOT_DIR"
cat <<NOTE
Built ${TARGET_IMAGE}.

View File

@@ -1,7 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-build.sh"
IMAGE_NAME="openclaw-sandbox:bookworm-slim"
docker build -t "${IMAGE_NAME}" -f Dockerfile.sandbox .
docker_build_exec -t "${IMAGE_NAME}" -f "$ROOT_DIR/Dockerfile.sandbox" "$ROOT_DIR"
echo "Built ${IMAGE_NAME}"

View File

@@ -2,12 +2,12 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
source "$ROOT_DIR/scripts/lib/docker-build.sh"
IMAGE_NAME="${OPENCLAW_CLEANUP_SMOKE_IMAGE:-openclaw-cleanup-smoke:local}"
PLATFORM="${OPENCLAW_CLEANUP_SMOKE_PLATFORM:-linux/amd64}"
echo "==> Build image: $IMAGE_NAME"
run_logged cleanup-build docker build \
docker_build_run cleanup-build \
-t "$IMAGE_NAME" \
-f "$ROOT_DIR/scripts/docker/cleanup-smoke/Dockerfile" \
"$ROOT_DIR"

View File

@@ -15,6 +15,11 @@ const DEFAULT_LIVE_RETRIES = 1;
const DEFAULT_STATUS_INTERVAL_MS = 30_000;
const DEFAULT_PREFLIGHT_RUN_TIMEOUT_MS = 60_000;
const DEFAULT_TIMINGS_FILE = path.join(ROOT_DIR, ".artifacts/docker-tests/lane-timings.json");
const DEFAULT_PROFILE = "all";
const RELEASE_PATH_PROFILE = "release-path";
const IS_MAIN = process.argv[1]
? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
: false;
const LIVE_PROFILE_TIMEOUT_MS = 20 * 60 * 1000;
const LIVE_CLI_TIMEOUT_MS = 20 * 60 * 1000;
const LIVE_ACP_TIMEOUT_MS = 20 * 60 * 1000;
@@ -39,6 +44,12 @@ const LIVE_RETRY_PATTERNS = [
/gateway closed \(1000 normal closure\)/i,
/ECONNRESET|ETIMEDOUT|ENOTFOUND/i,
];
const LOAD_SENSITIVE_DOCKER_RETRY_PATTERNS = [
/gateway closed \(1000 normal closure\)/i,
/gateway exited before listening/i,
/WebSocket.*(?:closed|close|timeout|error)/i,
/ECONNRESET|ETIMEDOUT|EPIPE|socket hang up/i,
];
const bundledChannelLaneCommand =
"OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps";
@@ -115,6 +126,19 @@ function serviceLane(name, command, options = {}) {
});
}
function openAiWebSearchMinimalLane() {
return serviceLane(
"openai-web-search-minimal",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-web-search-minimal",
{
retryPatterns: LOAD_SENSITIVE_DOCKER_RETRY_PATTERNS,
retries: 1,
timeoutMs: 10 * 60 * 1000,
weight: 4,
},
);
}
const bundledScenarioLanes = [
npmLane(
"bundled-channel-telegram",
@@ -274,11 +298,7 @@ const lanes = [
];
const exclusiveLanes = [
serviceLane(
"openai-web-search-minimal",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-web-search-minimal",
{ timeoutMs: 8 * 60 * 1000 },
),
openAiWebSearchMinimalLane(),
liveLane(
"live-codex-harness",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-codex-harness",
@@ -367,6 +387,140 @@ const exclusiveLanes = [
const tailLanes = exclusiveLanes;
const releasePathChunks = {
core: [
lane("qr", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:qr"),
serviceLane("onboard", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:onboard", {
weight: 2,
}),
serviceLane("gateway-network", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:gateway-network"),
serviceLane("config-reload", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:config-reload"),
lane(
"session-runtime-context",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:session-runtime-context",
),
lane(
"pi-bundle-mcp-tools",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:pi-bundle-mcp-tools",
),
serviceLane("mcp-channels", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:mcp-channels", {
resources: ["npm"],
weight: 3,
}),
],
"package-update": [
npmLane(
"install-e2e",
'OPENCLAW_INSTALL_TAG="${OPENCLAW_RELEASE_INSTALL_TAG:-beta}" OPENCLAW_INSTALL_PACKAGE_TGZ="${OPENCLAW_RELEASE_PACKAGE_TGZ:-}" OPENCLAW_E2E_MODELS=both pnpm test:install:e2e',
{
resources: ["service"],
weight: 4,
},
),
npmLane(
"npm-onboard-channel-agent",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:npm-onboard-channel-agent",
{ resources: ["service"], weight: 3 },
),
npmLane("doctor-switch", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:doctor-switch", {
weight: 3,
}),
npmLane(
"update-channel-switch",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-channel-switch",
{
timeoutMs: 30 * 60 * 1000,
weight: 3,
},
),
],
"plugins-integrations": [
lane("plugins", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugins", {
resources: ["npm", "service"],
weight: 6,
}),
npmLane("plugin-update", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-update"),
npmLane(
"bundled-channel-deps",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:bundled-channel-deps",
{ resources: ["service"], weight: 3 },
),
serviceLane(
"cron-mcp-cleanup",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:cron-mcp-cleanup",
{
resources: ["npm"],
weight: 3,
},
),
openAiWebSearchMinimalLane(),
],
};
function releasePathChunkLanes(chunk, options = {}) {
const base = releasePathChunks[chunk];
if (!base) {
throw new Error(
`OPENCLAW_DOCKER_ALL_CHUNK must be one of: ${Object.keys(releasePathChunks).join(", ")}. Got: ${JSON.stringify(chunk)}`,
);
}
if (chunk !== "plugins-integrations" || !options.includeOpenWebUI) {
return base;
}
return [
...base,
serviceLane("openwebui", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openwebui", {
timeoutMs: OPENWEBUI_TIMEOUT_MS,
weight: 5,
}),
];
}
function allReleasePathLanes(options = {}) {
return Object.keys(releasePathChunks).flatMap((chunk) =>
releasePathChunkLanes(chunk, {
includeOpenWebUI: chunk === "plugins-integrations" && options.includeOpenWebUI,
}),
);
}
function parseLaneSelection(raw) {
if (!raw) {
return [];
}
return [
...new Set(
String(raw)
.split(/[,\s]+/u)
.map((token) => token.trim())
.filter(Boolean),
),
];
}
function dedupeLanes(poolLanes) {
const byName = new Map();
for (const poolLane of poolLanes) {
if (!byName.has(poolLane.name)) {
byName.set(poolLane.name, poolLane);
}
}
return [...byName.values()];
}
function selectNamedLanes(poolLanes, selectedNames, label) {
const byName = new Map(poolLanes.map((poolLane) => [poolLane.name, poolLane]));
const missing = selectedNames.filter((name) => !byName.has(name));
if (missing.length > 0) {
throw new Error(
`${label} unknown lane(s): ${missing.join(", ")}. Available lanes: ${[...byName.keys()]
.toSorted((a, b) => a.localeCompare(b))
.join(", ")}`,
);
}
return selectedNames.map((name) => byName.get(name));
}
function parsePositiveInt(raw, fallback, label) {
if (!raw) {
return fallback;
@@ -406,6 +560,16 @@ function parseLiveMode(raw) {
);
}
function parseProfile(raw) {
const profile = raw || DEFAULT_PROFILE;
if (profile === DEFAULT_PROFILE || profile === RELEASE_PATH_PROFILE) {
return profile;
}
throw new Error(
`OPENCLAW_DOCKER_ALL_PROFILE must be one of: ${DEFAULT_PROFILE}, ${RELEASE_PATH_PROFILE}. Got: ${JSON.stringify(raw)}`,
);
}
function applyLiveMode(poolLanes, mode) {
if (mode === "all") {
return poolLanes;
@@ -456,6 +620,32 @@ function laneResources(poolLane) {
return ["docker", ...(poolLane.resources ?? [])];
}
export function describeDockerSchedulerLimits(parallelism, options) {
return `parallelism=${parallelism} weightLimit=${options.weightLimit} resources=${resourceLimitsSummary(
options.resourceLimits,
)}`;
}
export function canStartSchedulerLane(candidate, active, parallelism, options) {
const weight = laneWeight(candidate);
if (active.count >= parallelism) {
return false;
}
const exceedsWeightLimit = active.weight + weight > options.weightLimit;
const exceedsResourceLimit = laneResources(candidate).some((resource) => {
const limit = options.resourceLimits[resource] ?? options.weightLimit;
const current = active.resources.get(resource) ?? 0;
return current + weight > limit;
});
if (!exceedsWeightLimit && !exceedsResourceLimit) {
return true;
}
return active.count === 0;
}
function laneSummary(poolLane) {
const resources = laneResources(poolLane).join(",");
const timeout = poolLane.timeoutMs ? ` timeout=${Math.round(poolLane.timeoutMs / 1000)}s` : "";
@@ -488,16 +678,49 @@ function appendExtension(env, extension) {
}
function commandEnv(extra = {}) {
return {
const env = {
...process.env,
...extra,
};
const pathEntries = [
env.PATH,
env.PNPM_HOME,
env.npm_execpath ? path.dirname(env.npm_execpath) : undefined,
path.dirname(process.execPath),
]
.flatMap((entry) => (entry ? String(entry).split(path.delimiter) : []))
.filter(Boolean);
env.PATH = [...new Set(pathEntries)].join(path.delimiter);
return env;
}
function shellQuote(value) {
return `'${String(value).replaceAll("'", "'\\''")}'`;
}
function buildLaneRerunCommand(name, baseEnv) {
const build = name.startsWith("live-") ? "1" : "0";
const env = [
["OPENCLAW_DOCKER_ALL_LANES", name],
["OPENCLAW_DOCKER_ALL_BUILD", build],
["OPENCLAW_DOCKER_ALL_PREFLIGHT", "0"],
["OPENCLAW_SKIP_DOCKER_BUILD", "1"],
["OPENCLAW_DOCKER_E2E_IMAGE", baseEnv.OPENCLAW_DOCKER_E2E_IMAGE || DEFAULT_E2E_IMAGE],
];
if (baseEnv.OPENCLAW_DOCKER_ALL_PNPM_COMMAND) {
env.push(["OPENCLAW_DOCKER_ALL_PNPM_COMMAND", baseEnv.OPENCLAW_DOCKER_ALL_PNPM_COMMAND]);
}
return `${env.map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")} pnpm test:docker:all`;
}
function withResolvedPnpmCommand(command, env) {
const pnpmCommand = env.OPENCLAW_DOCKER_ALL_PNPM_COMMAND?.trim();
if (!pnpmCommand) {
return command;
}
return command.replace(/(^|\s)pnpm(?=\s)/g, `$1${shellQuote(pnpmCommand)}`);
}
function timingSeconds(timingStore, poolLane) {
const fromStore = timingStore?.lanes?.[poolLane.name]?.durationSeconds;
if (typeof fromStore === "number" && Number.isFinite(fromStore) && fromStore > 0) {
@@ -565,6 +788,17 @@ async function writeTimingStore(timingStore, results) {
console.log(`==> Docker lane timings: ${timingStore.file}`);
}
async function writeRunSummary(logDir, summary) {
const file = path.join(logDir, "summary.json");
const payload = {
...summary,
finishedAt: new Date().toISOString(),
version: 1,
};
await fs.promises.writeFile(file, `${JSON.stringify(payload, null, 2)}\n`);
console.log(`==> Docker run summary: ${file}`);
}
function printLaneManifest(label, poolLanes, timingStore) {
console.log(`==> ${label} lanes (${poolLanes.length})`);
for (const [index, poolLane] of poolLanes.entries()) {
@@ -574,6 +808,13 @@ function printLaneManifest(label, poolLanes, timingStore) {
}
}
function lanesNeedBundledPackage(poolLanes) {
return poolLanes.some(
(poolLane) =>
poolLane.name === "npm-onboard-channel-agent" || poolLane.name.startsWith("bundled-channel"),
);
}
function dockerPreflightContainerNames(raw) {
return raw
.split(/\r?\n/)
@@ -817,10 +1058,11 @@ function laneEnv(name, baseEnv, logDir, cacheKey) {
}
async function runLane(lane, baseEnv, logDir, fallbackTimeoutMs) {
const { command, name } = lane;
const { name } = lane;
const timeoutMs = lane.timeoutMs ?? fallbackTimeoutMs;
const logFile = path.join(logDir, `${name}.log`);
const env = laneEnv(name, baseEnv, logDir, lane.cacheKey);
const command = withResolvedPnpmCommand(lane.command, env);
await mkdir(env.OPENCLAW_DOCKER_CLI_TOOLS_DIR, { recursive: true });
await mkdir(env.OPENCLAW_DOCKER_CACHE_HOME_DIR, { recursive: true });
await fs.promises.writeFile(
@@ -866,6 +1108,7 @@ async function runLane(lane, baseEnv, logDir, fallbackTimeoutMs) {
logFile,
name,
elapsedSeconds,
rerunCommand: buildLaneRerunCommand(name, baseEnv),
status: result.status,
timedOut: result.timedOut,
};
@@ -921,18 +1164,7 @@ async function runLanePool(poolLanes, baseEnv, logDir, parallelism, options) {
}
function canStartLane(candidate) {
const weight = laneWeight(candidate);
if (active.count >= parallelism || active.weight + weight > options.weightLimit) {
return false;
}
for (const resource of laneResources(candidate)) {
const limit = options.resourceLimits[resource] ?? options.weightLimit;
const current = active.resources.get(resource) ?? 0;
if (current + weight > limit) {
return false;
}
}
return true;
return canStartSchedulerLane(candidate, active, parallelism, options);
}
function reserve(candidate) {
@@ -993,7 +1225,12 @@ async function runLanePool(poolLanes, baseEnv, logDir, parallelism, options) {
}
if (running.size === 0) {
const blocked = pending.map(laneSummary).join(", ");
throw new Error(`No Docker lanes fit scheduler limits: ${blocked}`);
throw new Error(
`No Docker lanes fit scheduler limits (${describeDockerSchedulerLimits(
parallelism,
options,
)}): ${blocked}. Tune OPENCLAW_DOCKER_ALL_PARALLELISM, OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT, or OPENCLAW_DOCKER_ALL_<RESOURCE>_LIMIT.`,
);
}
const { promise, result } = await Promise.race(running);
@@ -1077,6 +1314,7 @@ process.on("SIGTERM", () => {
});
async function main() {
const runStartedAt = new Date().toISOString();
const parallelism = parsePositiveInt(
process.env.OPENCLAW_DOCKER_ALL_PARALLELISM,
DEFAULT_PARALLELISM,
@@ -1117,6 +1355,19 @@ async function main() {
const preflightEnabled = parseBool(process.env.OPENCLAW_DOCKER_ALL_PREFLIGHT, true);
const preflightCleanup = parseBool(process.env.OPENCLAW_DOCKER_ALL_PREFLIGHT_CLEANUP, true);
const timingsEnabled = parseBool(process.env.OPENCLAW_DOCKER_ALL_TIMINGS, true);
const buildEnabled = parseBool(process.env.OPENCLAW_DOCKER_ALL_BUILD, true);
const profile = parseProfile(process.env.OPENCLAW_DOCKER_ALL_PROFILE);
const releaseChunk = process.env.OPENCLAW_DOCKER_ALL_CHUNK || process.env.DOCKER_E2E_CHUNK || "";
const includeOpenWebUI = parseBool(
process.env.OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI ?? process.env.INCLUDE_OPENWEBUI,
true,
);
const selectedLaneNamesRaw =
process.env.OPENCLAW_DOCKER_ALL_LANES || process.env.DOCKER_E2E_LANES || "";
const selectedLaneNames = parseLaneSelection(selectedLaneNamesRaw);
if (selectedLaneNamesRaw && selectedLaneNames.length === 0) {
throw new Error("OPENCLAW_DOCKER_ALL_LANES must include at least one lane name");
}
const liveMode = parseLiveMode(process.env.OPENCLAW_DOCKER_ALL_LIVE_MODE);
const liveRetries = parseNonNegativeInt(
process.env.OPENCLAW_DOCKER_ALL_LIVE_RETRIES,
@@ -1143,15 +1394,40 @@ async function main() {
const timingStore = await loadTimingStore(timingsFile, timingsEnabled);
const retriedMainLanes = applyLiveRetries(lanes, liveRetries);
const retriedTailLanes = applyLiveRetries(tailLanes, liveRetries);
const configuredLanes =
liveMode === "only"
? applyLiveMode([...retriedMainLanes, ...retriedTailLanes], liveMode)
: applyLiveMode(retriedMainLanes, liveMode);
const configuredTailLanes = liveMode === "only" ? [] : applyLiveMode(retriedTailLanes, liveMode);
const releaseLanes =
selectedLaneNames.length === 0 && profile === RELEASE_PATH_PROFILE
? releasePathChunkLanes(releaseChunk, { includeOpenWebUI })
: undefined;
const selectedLanes =
selectedLaneNames.length > 0
? selectNamedLanes(
dedupeLanes([
...allReleasePathLanes({ includeOpenWebUI }),
...retriedMainLanes,
...retriedTailLanes,
]),
selectedLaneNames,
"OPENCLAW_DOCKER_ALL_LANES",
)
: undefined;
const configuredLanes = selectedLanes
? selectedLanes
: releaseLanes
? releaseLanes
: liveMode === "only"
? applyLiveMode([...retriedMainLanes, ...retriedTailLanes], liveMode)
: applyLiveMode(retriedMainLanes, liveMode);
const configuredTailLanes =
selectedLanes || releaseLanes
? []
: liveMode === "only"
? []
: applyLiveMode(retriedTailLanes, liveMode);
const orderedLanes = orderLanes(configuredLanes, timingStore);
const orderedTailLanes = orderLanes(configuredTailLanes, timingStore);
console.log(`==> Docker test logs: ${logDir}`);
console.log(`==> Profile: ${profile}${releaseChunk ? ` chunk=${releaseChunk}` : ""}`);
console.log(`==> Parallelism: ${parallelism}`);
console.log(`==> Tail parallelism: ${tailParallelism}`);
console.log(`==> Lane timeout: ${laneTimeoutMs}ms`);
@@ -1166,6 +1442,13 @@ async function main() {
preflightCleanup ? " cleanup=yes" : " cleanup=no"
}`,
);
console.log(`==> Build shared Docker images: ${buildEnabled ? "yes" : "no"}`);
if (profile === RELEASE_PATH_PROFILE) {
console.log(`==> Include Open WebUI: ${includeOpenWebUI ? "yes" : "no"}`);
}
if (selectedLaneNames.length > 0) {
console.log(`==> Selected lanes: ${selectedLaneNames.join(", ")}`);
}
console.log(`==> Docker lane timings: ${timingStore.enabled ? timingsFile : "disabled"}`);
console.log(`==> Live-test bundled plugin deps: ${baseEnv.OPENCLAW_DOCKER_BUILD_EXTENSIONS}`);
const schedulerOptions = parseSchedulerOptions(process.env, parallelism);
@@ -1189,17 +1472,27 @@ async function main() {
runTimeoutMs: preflightRunTimeoutMs,
});
await runForegroundGroup(
[
["Build shared live-test image once", "pnpm test:docker:live-build"],
[
if (buildEnabled) {
const buildEntries = [];
const scheduledLanes = [...orderedLanes, ...orderedTailLanes];
if (scheduledLanes.some((poolLane) => poolLane.live)) {
buildEntries.push(["Build shared live-test image once", "pnpm test:docker:live-build"]);
}
if (scheduledLanes.some((poolLane) => !poolLane.live)) {
buildEntries.push([
`Build shared Docker E2E image once: ${baseEnv.OPENCLAW_DOCKER_E2E_IMAGE}`,
"pnpm test:docker:e2e-build",
],
],
baseEnv,
);
await prepareBundledChannelPackage(baseEnv, logDir);
]);
}
await runForegroundGroup(buildEntries, baseEnv);
} else {
console.log(`==> Shared Docker image builds: skipped`);
}
if (lanesNeedBundledPackage([...orderedLanes, ...orderedTailLanes])) {
await prepareBundledChannelPackage(baseEnv, logDir);
} else {
console.log("==> Bundled channel package: not needed for selected lanes");
}
const options = {
...schedulerOptions,
@@ -1214,34 +1507,74 @@ async function main() {
const allResults = [...mainResult.results];
await writeTimingStore(timingStore, mainResult.results);
if (failFast && failures.length > 0) {
await writeRunSummary(logDir, {
chunk: releaseChunk || undefined,
failures,
image: baseEnv.OPENCLAW_DOCKER_E2E_IMAGE,
lanes: allResults,
profile,
selectedLanes: selectedLaneNames.length > 0 ? selectedLaneNames : undefined,
startedAt: runStartedAt,
status: "failed",
});
await printFailureSummary(failures, tailLines);
process.exit(1);
}
console.log("==> Running provider-sensitive Docker tail lanes");
const tailResult = await runLanePool(orderedTailLanes, baseEnv, logDir, tailParallelism, {
...options,
...tailSchedulerOptions,
poolLabel: "tail",
});
failures.push(...tailResult.failures);
allResults.push(...tailResult.results);
await writeTimingStore(timingStore, tailResult.results);
if (orderedTailLanes.length > 0) {
console.log("==> Running provider-sensitive Docker tail lanes");
const tailResult = await runLanePool(orderedTailLanes, baseEnv, logDir, tailParallelism, {
...options,
...tailSchedulerOptions,
poolLabel: "tail",
});
failures.push(...tailResult.failures);
allResults.push(...tailResult.results);
await writeTimingStore(timingStore, tailResult.results);
} else {
console.log("==> Provider-sensitive Docker tail lanes: none");
}
if (failures.length > 0) {
await writeRunSummary(logDir, {
chunk: releaseChunk || undefined,
failures,
image: baseEnv.OPENCLAW_DOCKER_E2E_IMAGE,
lanes: allResults,
profile,
selectedLanes: selectedLaneNames.length > 0 ? selectedLaneNames : undefined,
startedAt: runStartedAt,
status: "failed",
});
await printFailureSummary(failures, tailLines);
process.exit(1);
}
await runForeground(
"Run cleanup smoke after parallel lanes",
"pnpm test:docker:cleanup",
baseEnv,
);
if (profile === DEFAULT_PROFILE && selectedLaneNames.length === 0) {
await runForeground(
"Run cleanup smoke after parallel lanes",
"pnpm test:docker:cleanup",
baseEnv,
);
} else {
console.log("==> Cleanup smoke after parallel lanes: skipped for selected/release lanes");
}
await writeTimingStore(timingStore, allResults);
await writeRunSummary(logDir, {
chunk: releaseChunk || undefined,
failures,
image: baseEnv.OPENCLAW_DOCKER_E2E_IMAGE,
lanes: allResults,
profile,
selectedLanes: selectedLaneNames.length > 0 ? selectedLaneNames : undefined,
startedAt: runStartedAt,
status: "passed",
});
console.log("==> Docker test suite passed");
}
await main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
if (IS_MAIN) {
await main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}

View File

@@ -4,6 +4,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=./docker/install-sh-common/version-parse.sh
source "$ROOT_DIR/scripts/docker/install-sh-common/version-parse.sh"
source "$ROOT_DIR/scripts/lib/docker-build.sh"
resolve_default_smoke_platform() {
local host_os
@@ -358,7 +359,7 @@ if [[ "$SKIP_SMOKE_IMAGE_BUILD" == "1" ]]; then
echo "==> Reuse prebuilt smoke image: $SMOKE_IMAGE"
else
echo "==> Build smoke image (upgrade, root, ${SMOKE_PLATFORM}): $SMOKE_IMAGE"
docker build \
docker_build_run install-smoke-build \
--platform "$SMOKE_PLATFORM" \
-t "$SMOKE_IMAGE" \
-f "$ROOT_DIR/scripts/docker/install-sh-smoke/Dockerfile" \
@@ -441,7 +442,7 @@ else
echo "==> Reuse prebuilt non-root image: $NONROOT_IMAGE"
else
echo "==> Build non-root image (${NONROOT_PLATFORM}): $NONROOT_IMAGE"
docker build \
docker_build_run install-nonroot-build \
--platform "$NONROOT_PLATFORM" \
-t "$NONROOT_IMAGE" \
-f "$ROOT_DIR/scripts/docker/install-sh-nonroot/Dockerfile" \

View File

@@ -2,24 +2,40 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-build.sh"
IMAGE_NAME="${OPENCLAW_INSTALL_E2E_IMAGE:-openclaw-install-e2e:local}"
INSTALL_URL="${OPENCLAW_INSTALL_URL:-https://openclaw.bot/install.sh}"
INSTALL_PACKAGE_TGZ="${OPENCLAW_INSTALL_PACKAGE_TGZ:-}"
OPENAI_API_KEY="${OPENAI_API_KEY:-}"
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
ANTHROPIC_API_TOKEN="${ANTHROPIC_API_TOKEN:-}"
OPENCLAW_E2E_MODELS="${OPENCLAW_E2E_MODELS:-}"
DOCKER_TGZ_ARGS=()
CONTAINER_PACKAGE_TGZ=""
if [[ -n "$INSTALL_PACKAGE_TGZ" ]]; then
INSTALL_PACKAGE_TGZ="$(node -e 'process.stdout.write(require("node:path").resolve(process.argv[1]))' "$INSTALL_PACKAGE_TGZ")"
if [[ ! -f "$INSTALL_PACKAGE_TGZ" ]]; then
echo "OPENCLAW_INSTALL_PACKAGE_TGZ does not exist: $INSTALL_PACKAGE_TGZ" >&2
exit 1
fi
CONTAINER_PACKAGE_TGZ="/tmp/openclaw-install-e2e-candidate.tgz"
DOCKER_TGZ_ARGS=(-v "$INSTALL_PACKAGE_TGZ:$CONTAINER_PACKAGE_TGZ:ro")
fi
echo "==> Build image: $IMAGE_NAME"
docker build \
docker_build_run install-e2e-build \
-t "$IMAGE_NAME" \
-f "$ROOT_DIR/scripts/docker/install-sh-e2e/Dockerfile" \
"$ROOT_DIR/scripts/docker"
echo "==> Run E2E installer test"
docker run --rm \
"${DOCKER_TGZ_ARGS[@]}" \
-e OPENCLAW_INSTALL_URL="$INSTALL_URL" \
-e OPENCLAW_INSTALL_TAG="${OPENCLAW_INSTALL_TAG:-latest}" \
-e OPENCLAW_INSTALL_PACKAGE_TGZ="$CONTAINER_PACKAGE_TGZ" \
-e OPENCLAW_E2E_MODELS="$OPENCLAW_E2E_MODELS" \
-e OPENCLAW_INSTALL_E2E_PREVIOUS="${OPENCLAW_INSTALL_E2E_PREVIOUS:-}" \
-e OPENCLAW_INSTALL_E2E_SKIP_PREVIOUS="${OPENCLAW_INSTALL_E2E_SKIP_PREVIOUS:-0}" \

View File

@@ -148,6 +148,7 @@ exec "\$script_dir/claude-real" "\$@"
WRAP
chmod +x "$NPM_CONFIG_PREFIX/bin/claude"
fi
export CLAUDE_CODE_EXECUTABLE="$NPM_CONFIG_PREFIX/bin/claude"
claude auth status || true
;;
codex)
@@ -162,8 +163,8 @@ WRAP
fi
droid --version
if [ -z "${FACTORY_API_KEY:-}" ]; then
echo "Droid Docker ACP bind requires FACTORY_API_KEY; Factory OAuth/keyring auth in ~/.factory is not portable into the container." >&2
exit 1
echo "SKIP: Droid Docker ACP bind requires FACTORY_API_KEY; Factory OAuth/keyring auth in ~/.factory is not portable into the container." >&2
exit 0
fi
;;
gemini)
@@ -262,6 +263,16 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do
DOCKER_AUTH_PRESTAGED=1
fi
if [[ "$ACP_AGENT" == "droid" && -z "${FACTORY_API_KEY:-}" ]]; then
echo "==> Run ACP bind live test in Docker"
echo "==> Agent: $ACP_AGENT"
echo "==> Profile file: $PROFILE_STATUS"
echo "==> Auth dirs: ${AUTH_DIRS_CSV:-none}"
echo "==> Auth files: ${AUTH_FILES_CSV:-none}"
echo "SKIP: Droid Docker ACP bind requires FACTORY_API_KEY; Factory OAuth/keyring auth in ~/.factory is not portable into the container." >&2
continue
fi
EXTERNAL_AUTH_MOUNTS=()
if ((${#AUTH_DIRS[@]} > 0)); then
for auth_dir in "${AUTH_DIRS[@]}"; do

View File

@@ -2,7 +2,7 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
source "$ROOT_DIR/scripts/lib/docker-build.sh"
IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw:local}"
LIVE_IMAGE_NAME="${OPENCLAW_LIVE_IMAGE:-${IMAGE_NAME}-live}"
DOCKER_BUILD_EXTENSIONS="${OPENCLAW_DOCKER_BUILD_EXTENSIONS:-${OPENCLAW_EXTENSIONS:-}}"
@@ -27,4 +27,4 @@ fi
echo "==> Build live-test image: $LIVE_IMAGE_NAME (target=build)"
echo "==> Bundled plugin deps: ${DOCKER_BUILD_EXTENSIONS}"
run_logged live-build docker build "${DOCKER_BUILD_ARGS[@]}" --target build -t "$LIVE_IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR"
docker_build_run live-build "${DOCKER_BUILD_ARGS[@]}" --target build -t "$LIVE_IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR"

View File

@@ -707,6 +707,55 @@ describe("runWithModelFallback", () => {
expect(run).toHaveBeenCalledTimes(2);
});
it("jumps directly to a later live-session model switch candidate (#57471)", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: [
"anthropic/claude-haiku-3-5",
"anthropic/claude-sonnet-4-6",
"openrouter/deepseek-chat",
],
},
},
},
});
const switchError = new LiveSessionModelSwitchError({
provider: "anthropic",
model: "claude-sonnet-4-6",
});
const run = vi.fn(async (provider: string, model: string) => {
if (provider === "openai" && model === "gpt-4.1-mini") {
throw switchError;
}
if (provider === "anthropic" && model === "claude-sonnet-4-6") {
return "ok";
}
throw new Error(`unexpected fallback candidate: ${provider}/${model}`);
});
const onError = vi.fn();
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
onError,
});
expect(result.result).toBe("ok");
expect(result.provider).toBe("anthropic");
expect(result.model).toBe("claude-sonnet-4-6");
expect(result.attempts).toEqual([]);
expect(onError).not.toHaveBeenCalled();
expect(run.mock.calls).toEqual([
["openai", "gpt-4.1-mini"],
["anthropic", "claude-sonnet-4-6"],
]);
});
it("falls back on auth errors", async () => {
await expectFallsBackToHaiku({
provider: "openai",

View File

@@ -326,6 +326,18 @@ function recordFailedCandidateAttempt(params: {
});
}
function findLaterLiveSessionModelSwitchCandidateIndex(params: {
error: LiveSessionModelSwitchError;
candidates: ModelCandidate[];
currentIndex: number;
}): number | null {
const targetKey = modelKey(params.error.provider, params.error.model);
const targetIndex = params.candidates.findIndex(
(candidate) => modelKey(candidate.provider, candidate.model) === targetKey,
);
return targetIndex > params.currentIndex ? targetIndex : null;
}
function throwFallbackFailureSummary(params: {
attempts: FallbackAttempt[];
candidates: ModelCandidate[];
@@ -924,6 +936,16 @@ export async function runWithModelFallback<T>(params: {
// instead of re-throwing and triggering infinite retry loops in the
// outer runner. (#58466)
if (err instanceof LiveSessionModelSwitchError) {
const liveSwitchTargetIndex = findLaterLiveSessionModelSwitchCandidateIndex({
error: err,
candidates,
currentIndex: i,
});
if (liveSwitchTargetIndex !== null) {
i = liveSwitchTargetIndex - 1;
continue;
}
const switchMsg = err.message;
const switchNormalized = new FailoverError(switchMsg, {
reason: "overloaded",

View File

@@ -700,6 +700,36 @@ describe("applyExtraParamsToAgent", () => {
});
});
it("keeps OpenAI Responses web_search compatible when thinking is minimal", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "openai",
applyModelId: "gpt-5",
model: {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
baseUrl: "http://127.0.0.1:19191/v1",
reasoning: true,
} as Model<"openai-responses">,
payload: {
model: "gpt-5",
input: [],
tools: [
{
type: "function",
name: "web_search",
description: "Search the web",
parameters: { type: "object", properties: {} },
},
],
reasoning: { effort: "low", summary: "auto" },
},
thinkingLevel: "minimal",
});
expect(payload.reasoning).toEqual({ effort: "low", summary: "auto" });
});
it("strips disabled reasoning payloads for proxied OpenAI responses routes", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {

View File

@@ -1,6 +1,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { guardSessionManager } from "./session-tool-result-guard-wrapper.js";
import { sanitizeToolUseResultPairing } from "./session-transcript-repair.js";
@@ -35,4 +36,46 @@ describe("guardSessionManager integration", () => {
"assistant",
]);
});
it("redacts configured text patterns before persisting transcript messages", () => {
const cfg = {
logging: {
redactSensitive: "tools",
redactPatterns: [String.raw`([\w]|[-.])+@([\w]|[-.])+\.\w+`],
},
} satisfies OpenClawConfig;
const sm = guardSessionManager(SessionManager.inMemory(), { config: cfg });
const appendMessage = sm.appendMessage.bind(sm) as unknown as (message: AgentMessage) => void;
appendMessage({
role: "assistant",
content: [
{ type: "thinking", thinking: "the email is peter@dc.io", thinkingSignature: "sig" },
{ type: "text", text: "contact peter@dc.io" },
{ type: "toolCall", id: "call_1", name: "read", arguments: { path: "/tmp/peter@dc.io" } },
],
stopReason: "toolUse",
} as AgentMessage);
appendMessage({
role: "toolResult",
toolCallId: "call_1",
toolName: "read",
content: [{ type: "text", text: "peter@dc.io\n" }],
isError: false,
} as AgentMessage);
const messages = sm
.getEntries()
.filter((e) => e.type === "message")
.map((e) => (e as { message: AgentMessage }).message);
const serialized = JSON.stringify(messages);
expect(serialized).not.toContain("the email is peter@dc.io");
expect(serialized).not.toContain("contact peter@dc.io");
expect(serialized).not.toContain("peter@dc.io\\n");
expect(serialized).toContain('"thinking":"the email is peter@d***.io"');
expect(serialized).toContain('"text":"contact peter@d***.io"');
expect(serialized).toContain('"text":"peter@d***.io\\n"');
expect(serialized).toContain('"/tmp/peter@dc.io"');
});
});

View File

@@ -159,6 +159,32 @@ describe("createOpenAIThinkingLevelWrapper", () => {
}
});
it("raises minimal reasoning for web_search on loopback Responses routes", () => {
const payloads: Array<Record<string, unknown>> = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {
reasoning: { effort: "minimal", summary: "auto" },
tools: [{ type: "function", name: "web_search" }],
};
options?.onPayload?.(payload, _model);
payloads.push(structuredClone(payload));
return createAssistantMessageEventStream();
};
const wrapped = createOpenAIThinkingLevelWrapper(baseStreamFn, "minimal");
void wrapped(
{
api: "openai-responses",
provider: "openai",
id: "gpt-5",
baseUrl: "http://127.0.0.1:19191/v1",
} as Model<"openai-responses">,
{ messages: [] },
{},
);
expect(payloads[0]?.reasoning).toEqual({ effort: "low", summary: "auto" });
});
it.each([
{
api: "openai-responses",

View File

@@ -9,6 +9,7 @@ import {
resolveCodexNativeSearchActivation,
} from "../codex-native-web-search.js";
import { flattenCompletionMessagesToStringContent } from "../openai-completions-string-content.js";
import { resolveOpenAIReasoningEffortForModel } from "../openai-reasoning-effort.js";
import {
applyOpenAIResponsesPayloadPolicy,
resolveOpenAIResponsesPayloadPolicy,
@@ -85,6 +86,66 @@ function shouldFlattenOpenAICompletionMessages(model: {
return model.api === "openai-completions" && compat?.requiresStringContent === true;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function hasResponsesWebSearchTool(tools: unknown): boolean {
if (!Array.isArray(tools)) {
return false;
}
return tools.some((tool) => {
if (!isRecord(tool)) {
return false;
}
if (tool.type === "web_search") {
return true;
}
if (tool.type === "function" && tool.name === "web_search") {
return true;
}
const fn = tool.function;
return isRecord(fn) && fn.name === "web_search";
});
}
function resolveOpenAIThinkingPayloadEffort(params: {
model: { provider?: unknown; id?: unknown; baseUrl?: unknown; api?: unknown; compat?: unknown };
payloadObj: Record<string, unknown>;
thinkingLevel: ThinkLevel;
}) {
const mapped = mapThinkingLevelToReasoningEffort(params.thinkingLevel);
if (mapped !== "minimal" || !hasResponsesWebSearchTool(params.payloadObj.tools)) {
return mapped;
}
return (
resolveOpenAIReasoningEffortForModel({
model: params.model,
effort: "low",
}) ?? mapped
);
}
function raiseMinimalReasoningForResponsesWebSearchPayload(params: {
model: { provider?: unknown; id?: unknown; baseUrl?: unknown; api?: unknown; compat?: unknown };
payloadObj: Record<string, unknown>;
}): void {
const reasoning = params.payloadObj.reasoning;
if (!isRecord(reasoning) || reasoning.effort !== "minimal") {
return;
}
if (!hasResponsesWebSearchTool(params.payloadObj.tools)) {
return;
}
const nextEffort = resolveOpenAIReasoningEffortForModel({
model: params.model,
effort: "low",
});
if (nextEffort && nextEffort !== "minimal" && nextEffort !== "none") {
reasoning.effort = nextEffort;
}
}
function normalizeOpenAIServiceTier(value: unknown): OpenAIServiceTier | undefined {
if (typeof value !== "string") {
return undefined;
@@ -240,7 +301,12 @@ export function createOpenAIThinkingLevelWrapper(
}
return (model, context, options) => {
if (!shouldApplyOpenAIReasoningCompatibility(model)) {
return underlying(model, context, options);
if (thinkingLevel === "off") {
return underlying(model, context, options);
}
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
raiseMinimalReasoningForResponsesWebSearchPayload({ model, payloadObj });
});
}
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
const existingReasoning = payloadObj.reasoning;
@@ -251,8 +317,13 @@ export function createOpenAIThinkingLevelWrapper(
return;
}
const reasoningEffort = resolveOpenAIThinkingPayloadEffort({
model,
payloadObj,
thinkingLevel,
});
if (existingReasoning === "none") {
payloadObj.reasoning = { effort: mapThinkingLevelToReasoningEffort(thinkingLevel) };
payloadObj.reasoning = { effort: reasoningEffort };
return;
}
if (
@@ -260,8 +331,8 @@ export function createOpenAIThinkingLevelWrapper(
typeof existingReasoning === "object" &&
!Array.isArray(existingReasoning)
) {
(existingReasoning as Record<string, unknown>).effort =
mapThinkingLevelToReasoningEffort(thinkingLevel);
(existingReasoning as Record<string, unknown>).effort = reasoningEffort;
raiseMinimalReasoningForResponsesWebSearchPayload({ model, payloadObj });
}
});
};

View File

@@ -117,6 +117,12 @@ describe("shouldCreateBundleMcpRuntimeForAttempt", () => {
toolsAllow: ["memory_search", "memory_get"],
}),
).toBe(false);
expect(
shouldCreateBundleMcpRuntimeForAttempt({
toolsEnabled: true,
toolsAllow: ["bundle-mcp"],
}),
).toBe(true);
expect(
shouldCreateBundleMcpRuntimeForAttempt({
toolsEnabled: true,

View File

@@ -490,7 +490,9 @@ export function shouldCreateBundleMcpRuntimeForAttempt(params: {
if (!params.toolsAllow || params.toolsAllow.length === 0) {
return true;
}
return params.toolsAllow.some((toolName) => toolName.includes(TOOL_NAME_SEPARATOR));
return params.toolsAllow.some(
(toolName) => toolName === "bundle-mcp" || toolName.includes(TOOL_NAME_SEPARATOR),
);
}
function collectAttemptExplicitToolAllowlistSources(params: {

View File

@@ -1,6 +1,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { SessionManager } from "@mariozechner/pi-coding-agent";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { redactSensitiveText } from "../logging/redact.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import {
applyInputProvenanceToUserMessage,
@@ -16,6 +17,71 @@ export type GuardedSessionManager = SessionManager & {
clearPendingToolResults?: () => void;
};
function redactTranscriptText(value: string, cfg?: OpenClawConfig): string {
if (cfg?.logging?.redactSensitive === "off") {
return value;
}
return redactSensitiveText(value, {
mode: cfg?.logging?.redactSensitive,
patterns: cfg?.logging?.redactPatterns,
});
}
function redactTranscriptContentBlock(block: unknown, cfg?: OpenClawConfig): unknown {
if (!block || typeof block !== "object" || Array.isArray(block)) {
return block;
}
const source = block as Record<string, unknown>;
let next: Record<string, unknown> | null = null;
const assign = (key: string, value: string) => {
const redacted = redactTranscriptText(value, cfg);
if (redacted === value) {
return;
}
next ??= { ...source };
next[key] = redacted;
};
if (typeof source.text === "string") {
assign("text", source.text);
}
if (typeof source.thinking === "string") {
assign("thinking", source.thinking);
}
if (typeof source.partialJson === "string") {
assign("partialJson", source.partialJson);
}
return next ?? block;
}
function redactTranscriptContent(content: unknown, cfg?: OpenClawConfig): unknown {
if (typeof content === "string") {
return redactTranscriptText(content, cfg);
}
if (!Array.isArray(content)) {
return content;
}
let changed = false;
const redacted = content.map((block) => {
const next = redactTranscriptContentBlock(block, cfg);
changed ||= next !== block;
return next;
});
return changed ? redacted : content;
}
function redactTranscriptMessage(message: AgentMessage, cfg?: OpenClawConfig): AgentMessage {
const source = message as unknown as Record<string, unknown>;
const redactedContent = redactTranscriptContent(source.content, cfg);
if (redactedContent === source.content) {
return message;
}
return {
...source,
content: redactedContent,
} as unknown as AgentMessage;
}
/**
* Apply the tool-result guard to a SessionManager exactly once and expose
* a flush method on the instance for easy teardown handling.
@@ -38,14 +104,31 @@ export function guardSessionManager(
}
const hookRunner = getGlobalHookRunner();
const beforeMessageWrite = hookRunner?.hasHooks("before_message_write")
? (event: { message: import("@mariozechner/pi-agent-core").AgentMessage }) => {
return hookRunner.runBeforeMessageWrite(event, {
agentId: opts?.agentId,
sessionKey: opts?.sessionKey,
});
const beforeMessageWrite = (event: {
message: import("@mariozechner/pi-agent-core").AgentMessage;
}) => {
let message = event.message;
let changed = false;
if (hookRunner?.hasHooks("before_message_write")) {
const result = hookRunner.runBeforeMessageWrite(event, {
agentId: opts?.agentId,
sessionKey: opts?.sessionKey,
});
if (result?.block) {
return result;
}
: undefined;
if (result?.message) {
message = result.message;
changed = true;
}
}
const redacted = redactTranscriptMessage(message, opts?.config);
if (redacted !== message) {
message = redacted;
changed = true;
}
return changed ? { message } : undefined;
};
const transform = hookRunner?.hasHooks("tool_result_persist")
? (

View File

@@ -25,6 +25,10 @@ type HeldLock = {
releasePromise?: Promise<void>;
};
type SyncClosableFileHandle = fs.FileHandle & {
[key: symbol]: unknown;
};
export type SessionLockInspection = {
lockPath: string;
pid: number | null;
@@ -180,7 +184,7 @@ async function releaseHeldLock(
*/
function releaseAllLocksSync(): void {
for (const [sessionFile, held] of HELD_LOCKS) {
void held.handle.close().catch(() => undefined);
closeFileHandleSyncBestEffort(held.handle);
try {
fsSync.rmSync(held.lockPath, { force: true });
} catch {
@@ -193,6 +197,24 @@ function releaseAllLocksSync(): void {
}
}
function closeFileHandleSyncBestEffort(handle: fs.FileHandle): void {
const syncCloseSymbol = Object.getOwnPropertySymbols(Object.getPrototypeOf(handle)).find(
(symbol) => symbol.description === "kCloseSync",
);
if (syncCloseSymbol) {
const closeSync = (handle as SyncClosableFileHandle)[syncCloseSymbol];
if (typeof closeSync === "function") {
try {
closeSync.call(handle);
return;
} catch {
// Fall back to async close below.
}
}
}
void handle.close().catch(() => undefined);
}
async function runLockWatchdogCheck(nowMs = Date.now()): Promise<number> {
let released = 0;
for (const [sessionFile, held] of HELD_LOCKS.entries()) {

View File

@@ -1,11 +1,34 @@
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { SkillsChangeEvent } from "./refresh.js";
const watchMock = vi.fn(() => ({
on: vi.fn(),
close: vi.fn(async () => undefined),
}));
type WatchEvent = "add" | "change" | "unlink" | "unlinkDir" | "error";
type WatchCallback = (watchPath: string) => void;
function createMockWatcher() {
const handlers = new Map<WatchEvent, WatchCallback[]>();
const watcher = {
on: vi.fn((event: WatchEvent, callback: WatchCallback) => {
handlers.set(event, [...(handlers.get(event) ?? []), callback]);
return watcher;
}),
close: vi.fn(async () => undefined),
emit: (event: WatchEvent, watchPath: string) => {
for (const callback of handlers.get(event) ?? []) {
callback(watchPath);
}
},
};
return watcher;
}
const createdWatchers: Array<ReturnType<typeof createMockWatcher>> = [];
const watchMock = vi.fn(() => {
const watcher = createMockWatcher();
createdWatchers.push(watcher);
return watcher;
});
let refreshModule: typeof import("./refresh.js");
@@ -24,13 +47,15 @@ describe("ensureSkillsWatcher", () => {
beforeEach(() => {
watchMock.mockClear();
createdWatchers.length = 0;
});
afterEach(async () => {
vi.useRealTimers();
await refreshModule.resetSkillsRefreshForTest();
});
it("ignores node_modules, dist, .git, and Python venvs by default", async () => {
it("watches skill roots and filters non-skill churn", async () => {
refreshModule.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace" });
expect(watchMock).toHaveBeenCalledTimes(1);
@@ -40,49 +65,64 @@ describe("ensureSkillsWatcher", () => {
const targets = firstCall?.[0] ?? [];
const opts = firstCall?.[1] ?? {};
expect(opts.ignored).toBe(refreshModule.DEFAULT_SKILLS_WATCH_IGNORED);
expect(opts.ignored).toBe(refreshModule.shouldIgnoreSkillsWatchPath);
const posix = (p: string) => p.replaceAll("\\", "/");
expect(targets).toEqual(
expect.arrayContaining([
posix(path.join("/tmp/workspace", "skills", "SKILL.md")),
posix(path.join("/tmp/workspace", "skills", "*", "SKILL.md")),
posix(path.join("/tmp/workspace", ".agents", "skills", "SKILL.md")),
posix(path.join("/tmp/workspace", ".agents", "skills", "*", "SKILL.md")),
posix(path.join(os.homedir(), ".agents", "skills", "SKILL.md")),
posix(path.join(os.homedir(), ".agents", "skills", "*", "SKILL.md")),
posix(path.join("/tmp/workspace", "skills")),
posix(path.join("/tmp/workspace", ".agents", "skills")),
posix(path.join(os.homedir(), ".agents", "skills")),
]),
);
expect(targets.every((target) => target.includes("SKILL.md"))).toBe(true);
const ignored = refreshModule.DEFAULT_SKILLS_WATCH_IGNORED;
expect(targets.every((target) => !target.includes("*"))).toBe(true);
const ignored = refreshModule.shouldIgnoreSkillsWatchPath;
// Node/JS paths
expect(ignored.some((re) => re.test("/tmp/workspace/skills/node_modules/pkg/index.js"))).toBe(
true,
);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/dist/index.js"))).toBe(true);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.git/config"))).toBe(true);
expect(ignored("/tmp/workspace/skills/node_modules/pkg/index.js")).toBe(true);
expect(ignored("/tmp/workspace/skills/dist/index.js")).toBe(true);
expect(ignored("/tmp/workspace/skills/.git/config")).toBe(true);
// Python virtual environments and caches
expect(ignored.some((re) => re.test("/tmp/workspace/skills/scripts/.venv/bin/python"))).toBe(
true,
);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/venv/lib/python3.10/site.py"))).toBe(
true,
);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/__pycache__/module.pyc"))).toBe(
true,
);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.mypy_cache/3.10/foo.json"))).toBe(
true,
);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.pytest_cache/v/cache"))).toBe(true);
expect(ignored("/tmp/workspace/skills/scripts/.venv/bin/python")).toBe(true);
expect(ignored("/tmp/workspace/skills/venv/lib/python3.10/site.py")).toBe(true);
expect(ignored("/tmp/workspace/skills/__pycache__/module.pyc")).toBe(true);
expect(ignored("/tmp/workspace/skills/.mypy_cache/3.10/foo.json")).toBe(true);
expect(ignored("/tmp/workspace/skills/.pytest_cache/v/cache")).toBe(true);
// Build artifacts and caches
expect(ignored.some((re) => re.test("/tmp/workspace/skills/build/output.js"))).toBe(true);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.cache/data.json"))).toBe(true);
expect(ignored("/tmp/workspace/skills/build/output.js")).toBe(true);
expect(ignored("/tmp/workspace/skills/.cache/data.json")).toBe(true);
// Should NOT ignore normal skill files
expect(ignored.some((re) => re.test("/tmp/.hidden/skills/index.md"))).toBe(false);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/my-skill/SKILL.md"))).toBe(false);
expect(ignored("/tmp/.hidden/skills/index.md")).toBe(false);
expect(ignored("/tmp/workspace/skills/my-skill", { isDirectory: () => true })).toBe(false);
expect(ignored("/tmp/workspace/skills/my-skill/README.md", {})).toBe(true);
expect(ignored("/tmp/workspace/skills/my-skill/SKILL.md", {})).toBe(false);
});
it.each(["add", "change", "unlink", "unlinkDir"] as const)(
"refreshes skills snapshots on %s",
async (event) => {
vi.useFakeTimers();
const seen: SkillsChangeEvent[] = [];
refreshModule.registerSkillsChangeListener((change) => {
seen.push(change);
});
refreshModule.ensureSkillsWatcher({
workspaceDir: "/tmp/workspace",
config: { skills: { load: { watchDebounceMs: 10 } } },
});
createdWatchers[0]?.emit(event, "/tmp/workspace/skills/demo/SKILL.md");
await vi.advanceTimersByTimeAsync(10);
expect(seen).toEqual([
{
workspaceDir: "/tmp/workspace",
reason: "watch",
changedPath: "/tmp/workspace/skills/demo/SKILL.md",
},
]);
},
);
});

View File

@@ -72,26 +72,36 @@ function resolveWatchPaths(workspaceDir: string, config?: OpenClawConfig): strin
return paths;
}
function toWatchGlobRoot(raw: string): string {
// Chokidar treats globs as POSIX-ish patterns. Normalize Windows separators
// so `*` works consistently across platforms.
return raw.replaceAll("\\", "/").replace(/\/+$/, "");
function toWatchRoot(raw: string): string {
const normalized = raw.replaceAll("\\", "/");
return normalized.replace(/\/+$/, "") || normalized;
}
function resolveWatchTargets(workspaceDir: string, config?: OpenClawConfig): string[] {
// Skills are defined by SKILL.md; watch only those files to avoid traversing
// or watching unrelated large trees (e.g. datasets) that can exhaust FDs.
const targets = new Set<string>();
for (const root of resolveWatchPaths(workspaceDir, config)) {
const globRoot = toWatchGlobRoot(root);
// Some configs point directly at a skill folder.
targets.add(`${globRoot}/SKILL.md`);
// Standard layout: <skillsRoot>/<skillName>/SKILL.md
targets.add(`${globRoot}/*/SKILL.md`);
targets.add(toWatchRoot(root));
}
return Array.from(targets).toSorted();
}
export function shouldIgnoreSkillsWatchPath(
watchPath: string,
stats?: { isDirectory?: () => boolean },
): boolean {
if (DEFAULT_SKILLS_WATCH_IGNORED.some((re) => re.test(watchPath))) {
return true;
}
if (stats?.isDirectory?.()) {
return false;
}
if (!stats) {
return false;
}
const normalized = watchPath.replaceAll("\\", "/");
return path.posix.basename(normalized) !== "SKILL.md";
}
export function ensureSkillsWatcher(params: { workspaceDir: string; config?: OpenClawConfig }) {
const workspaceDir = params.workspaceDir.trim();
if (!workspaceDir) {
@@ -135,9 +145,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope
stabilityThreshold: debounceMs,
pollInterval: 100,
},
// Avoid FD exhaustion on macOS when a workspace contains huge trees.
// This watcher only needs to react to SKILL.md changes.
ignored: DEFAULT_SKILLS_WATCH_IGNORED,
ignored: shouldIgnoreSkillsWatchPath,
});
const state: SkillsWatchState = { watcher, pathsKey, debounceMs };
@@ -162,6 +170,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope
watcher.on("add", (p) => schedule(p));
watcher.on("change", (p) => schedule(p));
watcher.on("unlink", (p) => schedule(p));
watcher.on("unlinkDir", (p) => schedule(p));
watcher.on("error", (err) => {
log.warn(`skills watcher error (${workspaceDir}): ${String(err)}`);
});

View File

@@ -3392,6 +3392,95 @@ describe("dispatchReplyFromConfig", () => {
);
});
it("poisons inbound dedupe when dispatch fails after a block reply", async () => {
setNoAbort();
const ctx = buildTestCtx({
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "whatsapp:+15555550125",
To: "whatsapp:+15555550125",
AccountId: "default",
MessageSid: "msg-dup-block-error",
SessionKey: "agent:main:whatsapp:direct:+15555550125",
CommandBody: "hello",
RawBody: "hello",
Body: "hello",
});
const firstDispatcher = createDispatcher();
const replyResolver = vi.fn(
async (_ctx: MsgContext, opts?: GetReplyOptions): Promise<ReplyPayload | undefined> => {
await opts?.onBlockReply?.({ text: "partial answer" });
throw new Error("provider failed after block");
},
);
await expect(
dispatchReplyFromConfig({
ctx,
cfg: emptyConfig,
dispatcher: firstDispatcher,
replyResolver,
}),
).rejects.toThrow("provider failed after block");
await dispatchReplyFromConfig({
ctx,
cfg: emptyConfig,
dispatcher: createDispatcher(),
replyResolver,
});
expect(firstDispatcher.sendBlockReply).toHaveBeenCalledWith({ text: "partial answer" });
expect(replyResolver).toHaveBeenCalledTimes(1);
});
it("poisons inbound dedupe when dispatch fails after a suppressed tool result", async () => {
setNoAbort();
sessionStoreMocks.currentEntry = {
sessionId: "s1",
updatedAt: 0,
sendPolicy: "deny",
};
const ctx = buildTestCtx({
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "whatsapp:+15555550126",
To: "whatsapp:+15555550126",
AccountId: "default",
MessageSid: "msg-dup-tool-error",
SessionKey: "agent:main:whatsapp:direct:+15555550126",
CommandBody: "hello",
RawBody: "hello",
Body: "hello",
});
const firstDispatcher = createDispatcher();
const replyResolver = vi.fn(
async (_ctx: MsgContext, opts?: GetReplyOptions): Promise<ReplyPayload | undefined> => {
await opts?.onToolResult?.({ text: "tool touched external state" });
throw new Error("provider failed after tool");
},
);
await expect(
dispatchReplyFromConfig({
ctx,
cfg: emptyConfig,
dispatcher: firstDispatcher,
replyResolver,
}),
).rejects.toThrow("provider failed after tool");
await dispatchReplyFromConfig({
ctx,
cfg: emptyConfig,
dispatcher: createDispatcher(),
replyResolver,
});
expect(firstDispatcher.sendToolResult).not.toHaveBeenCalled();
expect(replyResolver).toHaveBeenCalledTimes(1);
});
it("passes configOverride to replyResolver when provided", async () => {
setNoAbort();
const cfg = emptyConfig;

View File

@@ -343,6 +343,10 @@ export async function dispatchReplyFromConfig(
recordProcessed("skipped", { reason: "duplicate" });
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
}
let inboundDedupeReplayUnsafe = false;
const markInboundDedupeReplayUnsafe = () => {
inboundDedupeReplayUnsafe = true;
};
const initialSessionStoreEntry = resolveSessionStoreLookup(ctx, cfg);
const boundAcpDispatchSessionKey = resolveBoundAcpDispatchSessionKey({ ctx, cfg });
@@ -473,6 +477,7 @@ export async function dispatchReplyFromConfig(
if (!shouldRouteToOriginating || !routeReplyChannel || !routeReplyTo || !routeReplyRuntime) {
return null;
}
markInboundDedupeReplayUnsafe();
return await routeReplyRuntime.routeReply({
payload,
channel: routeReplyChannel,
@@ -538,6 +543,7 @@ export async function dispatchReplyFromConfig(
}
return result.ok;
}
markInboundDedupeReplayUnsafe();
return mode === "additive"
? dispatcher.sendToolResult(payload)
: dispatcher.sendFinalReply(payload);
@@ -721,6 +727,7 @@ export async function dispatchReplyFromConfig(
);
}
} else {
markInboundDedupeReplayUnsafe();
queuedFinal = dispatcher.sendFinalReply(payload);
}
} else {
@@ -744,6 +751,9 @@ export async function dispatchReplyFromConfig(
const sendFinalPayload = async (
payload: ReplyPayload,
): Promise<{ queuedFinal: boolean; routedFinalCount: number }> => {
if (resolveSendableOutboundReplyParts(payload).hasContent) {
markInboundDedupeReplayUnsafe();
}
const ttsPayload = await maybeApplyTtsToReplyPayload({
payload,
cfg,
@@ -767,6 +777,7 @@ export async function dispatchReplyFromConfig(
routedFinalCount: result.ok ? 1 : 0,
};
}
markInboundDedupeReplayUnsafe();
return {
queuedFinal: dispatcher.sendFinalReply(normalizedPayload),
routedFinalCount: 0,
@@ -898,6 +909,7 @@ export async function dispatchReplyFromConfig(
await sendPayloadAsync(payload, undefined, false);
return;
}
markInboundDedupeReplayUnsafe();
dispatcher.sendToolResult(payload);
};
const sendPlanUpdate = async (payload: {
@@ -914,6 +926,7 @@ export async function dispatchReplyFromConfig(
await sendPayloadAsync(replyPayload, undefined, false);
return;
}
markInboundDedupeReplayUnsafe();
dispatcher.sendToolResult(replyPayload);
};
const summarizeApprovalLabel = (payload: {
@@ -1019,6 +1032,7 @@ export async function dispatchReplyFromConfig(
suppressTyping: typing.suppressTyping,
onToolResult: (payload: ReplyPayload) => {
const run = async () => {
markInboundDedupeReplayUnsafe();
await onToolResultFromReplyOptions?.(payload);
if (suppressDelivery) {
return;
@@ -1055,12 +1069,14 @@ export async function dispatchReplyFromConfig(
if (shouldRouteToOriginating) {
await sendPayloadAsync(deliveryPayload, undefined, false);
} else {
markInboundDedupeReplayUnsafe();
dispatcher.sendToolResult(deliveryPayload);
}
};
return run();
},
onPlanUpdate: async (payload) => {
markInboundDedupeReplayUnsafe();
await onPlanUpdateFromReplyOptions?.(payload);
if (payload.phase !== "update" || suppressDefaultToolProgressMessages) {
return;
@@ -1068,6 +1084,7 @@ export async function dispatchReplyFromConfig(
await sendPlanUpdate({ explanation: payload.explanation, steps: payload.steps });
},
onApprovalEvent: async (payload) => {
markInboundDedupeReplayUnsafe();
await onApprovalEventFromReplyOptions?.(payload);
if (payload.phase !== "requested" || suppressDefaultToolProgressMessages) {
return;
@@ -1083,6 +1100,7 @@ export async function dispatchReplyFromConfig(
await maybeSendWorkingStatus(label);
},
onPatchSummary: async (payload) => {
markInboundDedupeReplayUnsafe();
await onPatchSummaryFromReplyOptions?.(payload);
if (payload.phase !== "end" || suppressDefaultToolProgressMessages) {
return;
@@ -1095,6 +1113,12 @@ export async function dispatchReplyFromConfig(
},
onBlockReply: (payload: ReplyPayload, context?: BlockReplyContext) => {
const run = async () => {
if (
payload.isReasoning !== true &&
resolveSendableOutboundReplyParts(payload).hasContent
) {
markInboundDedupeReplayUnsafe();
}
if (suppressDelivery) {
return;
}
@@ -1156,6 +1180,7 @@ export async function dispatchReplyFromConfig(
if (shouldRouteToOriginating) {
await sendPayloadAsync(normalizedPayload, context?.abortSignal, false);
} else {
markInboundDedupeReplayUnsafe();
dispatcher.sendBlockReply(normalizedPayload);
}
};
@@ -1268,6 +1293,7 @@ export async function dispatchReplyFromConfig(
);
}
} else {
markInboundDedupeReplayUnsafe();
const didQueue = dispatcher.sendFinalReply(normalizedTtsOnlyPayload);
queuedFinal = didQueue || queuedFinal;
}
@@ -1293,7 +1319,11 @@ export async function dispatchReplyFromConfig(
return { queuedFinal, counts };
} catch (err) {
if (inboundDedupeClaim.status === "claimed") {
releaseInboundDedupe(inboundDedupeClaim.key);
if (inboundDedupeReplayUnsafe) {
commitInboundDedupe(inboundDedupeClaim.key);
} else {
releaseInboundDedupe(inboundDedupeClaim.key);
}
}
recordProcessed("error", { error: String(err) });
markIdle("message_error");

View File

@@ -72,4 +72,33 @@ describe("inbound dedupe", () => {
inboundB.resetInboundDedupe();
}
});
it("shares claim/commit state across distinct module instances", async () => {
const inboundA = await importFreshModule<typeof import("./inbound-dedupe.js")>(
import.meta.url,
"./inbound-dedupe.js?scope=commit-a",
);
const inboundB = await importFreshModule<typeof import("./inbound-dedupe.js")>(
import.meta.url,
"./inbound-dedupe.js?scope=commit-b",
);
inboundA.resetInboundDedupe();
inboundB.resetInboundDedupe();
try {
const firstClaim = inboundA.claimInboundDedupe(sharedInboundContext);
expect(firstClaim).toMatchObject({ status: "claimed" });
if (firstClaim.status !== "claimed") {
throw new Error("expected claimed inbound dedupe result");
}
inboundA.commitInboundDedupe(firstClaim.key);
expect(inboundB.claimInboundDedupe(sharedInboundContext)).toMatchObject({
status: "duplicate",
});
} finally {
inboundA.resetInboundDedupe();
inboundB.resetInboundDedupe();
}
});
});

View File

@@ -438,6 +438,44 @@ describe("inspectGatewayRestart", () => {
expect(sleep).not.toHaveBeenCalled();
});
it("stops waiting once the expected-version gateway reports channel probe errors", async () => {
probeGateway.mockResolvedValue({
ok: true,
close: null,
server: { version: "2026.4.24", connId: "new" },
health: {
ok: true,
channels: {
telegram: {
configured: true,
probe: { ok: false, error: "This operation was aborted" },
},
},
},
});
inspectPortUsage.mockResolvedValue({
port: 18789,
status: "busy",
listeners: [{ pid: 8000, commandLine: "openclaw-gateway" }],
hints: [],
});
const { waitForGatewayHealthyRestart } = await import("./restart-health.js");
const snapshot = await waitForGatewayHealthyRestart({
service: makeGatewayService({ status: "running", pid: 8000 }),
port: 18789,
expectedVersion: "2026.4.24",
});
expect(snapshot).toMatchObject({
healthy: false,
waitOutcome: "channel-errors",
elapsedMs: 0,
channelProbeErrors: [{ id: "telegram", error: "This operation was aborted" }],
});
expect(sleep).not.toHaveBeenCalled();
});
it("treats busy ports with unavailable listener details as healthy when runtime is running", async () => {
const service = {
readRuntime: vi.fn(async () => ({ status: "running", pid: 8000 })),

View File

@@ -26,6 +26,7 @@ const WINDOWS_STOPPED_FREE_EARLY_EXIT_GRACE_MS = 90_000;
export type GatewayRestartWaitOutcome =
| "healthy"
| "plugin-errors"
| "channel-errors"
| "version-mismatch"
| "stale-pids"
| "stopped-free"
@@ -38,6 +39,7 @@ export type GatewayRestartSnapshot = {
staleGatewayPids: number[];
gatewayVersion?: string | null;
activatedPluginErrors?: PluginHealthErrorSummary[];
channelProbeErrors?: Array<{ id: string; error: string }>;
expectedVersion?: string;
versionMismatch?: {
expected: string;
@@ -56,6 +58,7 @@ type GatewayReachability = {
reachable: boolean;
gatewayVersion: string | null;
activatedPluginErrors: PluginHealthErrorSummary[];
channelProbeErrors: Array<{ id: string; error: string }>;
};
function hasListenerAttributionGap(portUsage: PortUsage): boolean {
@@ -154,6 +157,36 @@ function readActivatedPluginErrors(health: unknown): PluginHealthErrorSummary[]
});
}
function readChannelProbeErrors(health: unknown): Array<{ id: string; error: string }> {
if (!health || typeof health !== "object") {
return [];
}
const channels = (health as { channels?: unknown }).channels;
if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
return [];
}
const errors: Array<{ id: string; error: string }> = [];
for (const [id, summary] of Object.entries(channels)) {
if (!summary || typeof summary !== "object") {
continue;
}
const probe = (summary as { probe?: unknown }).probe;
if (!probe || typeof probe !== "object") {
continue;
}
const ok = (probe as { ok?: unknown }).ok;
if (ok !== false) {
continue;
}
const error = (probe as { error?: unknown }).error;
errors.push({
id,
error: typeof error === "string" && error.trim() ? error : "probe failed",
});
}
return errors;
}
function applyActivatedPluginErrors(snapshot: GatewayRestartSnapshot): GatewayRestartSnapshot {
if (!snapshot.activatedPluginErrors?.length) {
return snapshot;
@@ -161,6 +194,13 @@ function applyActivatedPluginErrors(snapshot: GatewayRestartSnapshot): GatewayRe
return { ...snapshot, healthy: false };
}
function applyChannelProbeErrors(snapshot: GatewayRestartSnapshot): GatewayRestartSnapshot {
if (!snapshot.channelProbeErrors?.length) {
return snapshot;
}
return { ...snapshot, healthy: false };
}
async function confirmGatewayReachable(params: {
port: number;
includeHealthDetails?: boolean;
@@ -177,6 +217,7 @@ async function confirmGatewayReachable(params: {
reachable: probe.ok || looksLikeAuthClose(probe.close?.code, probe.close?.reason),
gatewayVersion: probe.server?.version ?? null,
activatedPluginErrors: readActivatedPluginErrors(probe.health),
channelProbeErrors: readChannelProbeErrors(probe.health),
};
}
@@ -217,6 +258,7 @@ export async function inspectGatewayRestart(params: {
const expectedVersion = normalizeOptionalString(params.expectedVersion);
let reachability: GatewayReachability | null = null;
let activatedPluginErrors: PluginHealthErrorSummary[] = [];
let channelProbeErrors: Array<{ id: string; error: string }> = [];
const loadReachability = async () => {
if (!reachability) {
reachability = await confirmGatewayReachable({
@@ -224,6 +266,7 @@ export async function inspectGatewayRestart(params: {
includeHealthDetails: Boolean(expectedVersion),
});
activatedPluginErrors = reachability.activatedPluginErrors;
channelProbeErrors = reachability.channelProbeErrors;
}
return reachability;
};
@@ -251,19 +294,24 @@ export async function inspectGatewayRestart(params: {
try {
const reachable = await loadReachability();
if (reachable.reachable) {
return applyActivatedPluginErrors(
applyExpectedVersion(
{
runtime,
portUsage,
healthy: true,
staleGatewayPids: [],
gatewayVersion: reachable.gatewayVersion,
...(reachable.activatedPluginErrors.length > 0
? { activatedPluginErrors: reachable.activatedPluginErrors }
: {}),
},
expectedVersion,
return applyChannelProbeErrors(
applyActivatedPluginErrors(
applyExpectedVersion(
{
runtime,
portUsage,
healthy: true,
staleGatewayPids: [],
gatewayVersion: reachable.gatewayVersion,
...(reachable.activatedPluginErrors.length > 0
? { activatedPluginErrors: reachable.activatedPluginErrors }
: {}),
...(reachable.channelProbeErrors.length > 0
? { channelProbeErrors: reachable.channelProbeErrors }
: {}),
},
expectedVersion,
),
),
);
}
@@ -307,6 +355,9 @@ export async function inspectGatewayRestart(params: {
if (reachable.activatedPluginErrors.length > 0) {
healthy = false;
}
if (reachable.channelProbeErrors.length > 0) {
healthy = false;
}
} catch {
healthy = false;
}
@@ -340,17 +391,20 @@ export async function inspectGatewayRestart(params: {
]),
);
return applyActivatedPluginErrors(
applyExpectedVersion(
{
runtime,
portUsage,
healthy,
staleGatewayPids,
...(gatewayVersion !== undefined ? { gatewayVersion } : {}),
...(activatedPluginErrors.length ? { activatedPluginErrors } : {}),
},
expectedVersion,
return applyChannelProbeErrors(
applyActivatedPluginErrors(
applyExpectedVersion(
{
runtime,
portUsage,
healthy,
staleGatewayPids,
...(gatewayVersion !== undefined ? { gatewayVersion } : {}),
...(activatedPluginErrors.length ? { activatedPluginErrors } : {}),
...(channelProbeErrors.length ? { channelProbeErrors } : {}),
},
expectedVersion,
),
),
);
}
@@ -415,6 +469,9 @@ export async function waitForGatewayHealthyRestart(params: {
if (snapshot.activatedPluginErrors?.length) {
return withWaitContext(snapshot, "plugin-errors", attempt * delayMs);
}
if (snapshot.channelProbeErrors?.length) {
return withWaitContext(snapshot, "channel-errors", attempt * delayMs);
}
if (snapshot.versionMismatch) {
return withWaitContext(snapshot, "version-mismatch", attempt * delayMs);
}
@@ -493,6 +550,12 @@ export function renderRestartDiagnostics(snapshot: GatewayRestartSnapshot): stri
lines.push(`- ${plugin.id}: ${plugin.error}`);
}
}
if (snapshot.channelProbeErrors?.length) {
lines.push("Channel health probe errors:");
for (const channel of snapshot.channelProbeErrors) {
lines.push(`- ${channel.id}: ${channel.error}`);
}
}
const runtimeSummary = [
snapshot.runtime.status ? `status=${snapshot.runtime.status}` : null,
snapshot.runtime.state ? `state=${snapshot.runtime.state}` : null,

View File

@@ -1,5 +1,6 @@
import type { Command } from "commander";
import { resolveCliArgvInvocation } from "../argv-invocation.js";
import { resolveCliCommandPathPolicy } from "../command-path-policy.js";
import {
shouldEagerRegisterSubcommands,
shouldRegisterPrimarySubcommandOnly,
@@ -30,13 +31,17 @@ async function registerSubCliWithPluginCommands(
registerSubCli: () => Promise<void>,
pluginCliPosition: "before" | "after",
) {
const isHelpOrVersion = resolveCliArgvInvocation(process.argv).hasHelpOrVersion;
const invocation = resolveCliArgvInvocation(process.argv);
const shouldRegisterPluginCommands =
!invocation.hasHelpOrVersion &&
(invocation.commandPath.length <= 1 ||
resolveCliCommandPathPolicy(invocation.commandPath).loadPlugins !== "never");
const { registerPluginCliCommandsFromValidatedConfig } = await import("../../plugins/cli.js");
if (pluginCliPosition === "before" && !isHelpOrVersion) {
if (pluginCliPosition === "before" && shouldRegisterPluginCommands) {
await registerPluginCliCommandsFromValidatedConfig(program);
}
await registerSubCli();
if (pluginCliPosition === "after" && !isHelpOrVersion) {
if (pluginCliPosition === "after" && shouldRegisterPluginCommands) {
await registerPluginCliCommandsFromValidatedConfig(program);
}
}

View File

@@ -37,9 +37,22 @@ const { inferAction, registerCapabilityCli } = vi.hoisted(() => {
return { inferAction: action, registerCapabilityCli: register };
});
const { registerPluginsCli, registerPluginCliCommandsFromValidatedConfig } = vi.hoisted(() => ({
registerPluginsCli: vi.fn((program: Command) => {
const plugins = program.command("plugins");
plugins
.command("update")
.argument("[id]")
.action(() => undefined);
}),
registerPluginCliCommandsFromValidatedConfig: vi.fn(async () => null),
}));
vi.mock("../acp-cli.js", () => ({ registerAcpCli }));
vi.mock("../nodes-cli.js", () => ({ registerNodesCli }));
vi.mock("../capability-cli.js", () => ({ registerCapabilityCli }));
vi.mock("../plugins-cli.js", () => ({ registerPluginsCli }));
vi.mock("../../plugins/cli.js", () => ({ registerPluginCliCommandsFromValidatedConfig }));
vi.mock("./private-qa-cli.js", async () => {
const actual = await vi.importActual<typeof import("./private-qa-cli.js")>("./private-qa-cli.js");
return {
@@ -78,6 +91,8 @@ describe("registerSubCliCommands", () => {
loadPrivateQaCliModule.mockClear();
registerCapabilityCli.mockClear();
inferAction.mockClear();
registerPluginsCli.mockClear();
registerPluginCliCommandsFromValidatedConfig.mockClear();
});
afterEach(() => {
@@ -158,4 +173,24 @@ describe("registerSubCliCommands", () => {
expect(registerAcpCli).toHaveBeenCalledTimes(1);
expect(acpAction).toHaveBeenCalledTimes(1);
});
it("does not preload plugin CLI registrations for builtin plugins update", async () => {
process.argv = ["node", "openclaw", "plugins", "update", "lossless-claw"];
const program = new Command().name("openclaw");
await registerSubCliByName(program, "plugins");
expect(registerPluginsCli).toHaveBeenCalledTimes(1);
expect(registerPluginCliCommandsFromValidatedConfig).not.toHaveBeenCalled();
});
it("keeps plugin CLI registrations available for the plugins command root", async () => {
process.argv = ["node", "openclaw", "plugins"];
const program = new Command().name("openclaw");
await registerSubCliByName(program, "plugins");
expect(registerPluginsCli).toHaveBeenCalledTimes(1);
expect(registerPluginCliCommandsFromValidatedConfig).toHaveBeenCalledTimes(1);
});
});

View File

@@ -22,6 +22,18 @@ const readPackageName = vi.fn();
const readPackageVersion = vi.fn();
const resolveGlobalManager = vi.fn();
const serviceLoaded = vi.fn();
const readGatewayServiceState = vi.fn(async (args: { env?: NodeJS.ProcessEnv } = {}) => {
const env = args.env ?? process.env;
const loaded = Boolean(await serviceLoaded({ env }));
return {
installed: loaded,
loaded,
running: false,
env,
command: loaded ? { command: ["openclaw", "gateway", "start"] } : null,
runtime: undefined,
};
});
const prepareRestartScript = vi.fn();
const runRestartScript = vi.fn();
const mockedRunDaemonInstall = vi.fn();
@@ -164,6 +176,8 @@ vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) =
});
vi.mock("../daemon/service.js", () => ({
readGatewayServiceState: (_service: unknown, args?: { env?: NodeJS.ProcessEnv }) =>
readGatewayServiceState(args),
resolveGatewayService: vi.fn(() => ({
isLoaded: (...args: unknown[]) => serviceLoaded(...args),
readRuntime: (...args: unknown[]) => serviceReadRuntime(...args),
@@ -543,16 +557,18 @@ describe("update-cli", () => {
});
it("keeps downgrade post-update work in the current process", async () => {
setupUpdatedRootRefresh({
gatewayUpdateImpl: async () =>
makeOkUpdateResult({
mode: "npm",
root: createCaseDir("openclaw-downgraded-root"),
before: { version: "2026.4.14" },
after: { version: "2026.4.10" },
}),
});
const downgradedRoot = createCaseDir("openclaw-downgraded-root");
mockPackageInstallStatus(downgradedRoot);
pathExists.mockImplementation(async (candidate: string) =>
[path.join(downgradedRoot, "dist", "entry.js")].includes(candidate),
);
readPackageVersion.mockResolvedValue("2026.4.14");
serviceLoaded.mockResolvedValue(true);
vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({
target: "2026.4.10",
version: "2026.4.10",
nodeEngine: ">=22.14.0",
});
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "2026.4.10",
@@ -579,8 +595,7 @@ describe("update-cli", () => {
expect(spawn).not.toHaveBeenCalled();
expect(syncPluginsForUpdateChannel).toHaveBeenCalled();
expect(updateNpmInstalledPlugins).toHaveBeenCalled();
expect(runDaemonInstall).toHaveBeenCalled();
expect(probeGateway).toHaveBeenCalled();
expect(runRestartScript).toHaveBeenCalled();
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
});
@@ -1865,27 +1880,35 @@ describe("update-cli", () => {
] as const)("updateCommand service refresh behavior: $name", runUpdateCliScenario);
it("fails a package update when service env refresh cannot complete", async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
const { entrypoints, root } = setupUpdatedRootRefresh();
serviceLoaded.mockResolvedValue(true);
vi.mocked(runDaemonInstall).mockRejectedValueOnce(new Error("refresh failed"));
vi.mocked(runCommandWithTimeout).mockResolvedValueOnce({
stdout: "",
stderr: "refresh failed",
code: 1,
signal: null,
killed: false,
termination: "exit",
});
await updateCommand({ yes: true });
expect(runDaemonInstall).toHaveBeenCalledWith({
force: true,
json: undefined,
});
expect(runCommandWithTimeout).toHaveBeenCalledWith(
[expect.stringMatching(/node/), entrypoints[0], "gateway", "install", "--force"],
expect.objectContaining({ cwd: root, timeoutMs: 60_000 }),
);
expect(runRestartScript).not.toHaveBeenCalled();
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it("fails a JSON package update when fallback restart leaves the old gateway running", async () => {
const updatedRoot = createCaseDir("openclaw-updated-root");
setupUpdatedRootRefresh({
entrypoints: [path.join(updatedRoot, "dist", "entry.js")],
gatewayUpdateImpl: async () =>
makeOkUpdateResult({
mode: "npm",
root: createCaseDir("openclaw-updated-root"),
root: updatedRoot,
before: { version: "2026.4.23" },
after: { version: "2026.4.24" },
}),
@@ -1911,7 +1934,16 @@ describe("update-cli", () => {
await updateCommand({ yes: true, json: true });
expect(runRestartScript).not.toHaveBeenCalled();
expect(runDaemonRestart).toHaveBeenCalled();
expect(runCommandWithTimeout).toHaveBeenCalledWith(
[
expect.stringMatching(/node/),
expect.stringContaining(path.join("openclaw-updated-root")),
"gateway",
"restart",
"--json",
],
expect.objectContaining({ timeoutMs: 60_000 }),
);
expect(probeGateway).toHaveBeenCalledWith(expect.objectContaining({ includeDetails: true }));
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(defaultRuntime.writeJson).not.toHaveBeenCalled();
@@ -1927,22 +1959,24 @@ describe("update-cli", () => {
});
it("fails a package update when the restarted gateway reports activated plugin load errors", async () => {
const updatedRoot = createCaseDir("openclaw-updated-root");
setupUpdatedRootRefresh({
entrypoints: [path.join(updatedRoot, "dist", "entry.js")],
gatewayUpdateImpl: async () =>
makeOkUpdateResult({
mode: "npm",
root: createCaseDir("openclaw-updated-root"),
before: { version: "2026.4.23" },
after: { version: "2026.4.24" },
root: updatedRoot,
before: { version: "2026.4.24" },
after: { version: "2026.4.23" },
}),
});
readPackageVersion.mockResolvedValue("2026.4.24");
readPackageVersion.mockResolvedValue("2026.4.23");
serviceLoaded.mockResolvedValue(true);
probeGateway.mockResolvedValue({
ok: true,
close: null,
server: {
version: "2026.4.24",
version: "2026.4.23",
connId: "updated-gateway",
},
auth: { role: "operator", scopes: ["operator.read"], capability: "read_only" },

View File

@@ -4,6 +4,10 @@ import {
buildGatewayInstallEntrypointCandidates as resolveGatewayInstallEntrypointCandidates,
resolveGatewayInstallEntrypoint,
} from "../../daemon/gateway-entrypoint.js";
import {
shouldPrepareUpdatedInstallRestart,
shouldUseLegacyProcessRestartAfterUpdate,
} from "./update-command.js";
describe("resolveGatewayInstallEntrypointCandidates", () => {
it("prefers index.js before legacy entry.js", () => {
@@ -39,3 +43,55 @@ describe("resolveGatewayInstallEntrypoint", () => {
).resolves.toBe(entryPath);
});
});
describe("shouldPrepareUpdatedInstallRestart", () => {
it("prepares package update restarts when the service is installed but stopped", () => {
expect(
shouldPrepareUpdatedInstallRestart({
updateMode: "npm",
serviceInstalled: true,
serviceLoaded: false,
}),
).toBe(true);
});
it("does not install a new service for package updates when no service exists", () => {
expect(
shouldPrepareUpdatedInstallRestart({
updateMode: "npm",
serviceInstalled: false,
serviceLoaded: false,
}),
).toBe(false);
});
it("keeps non-package updates tied to the loaded service state", () => {
expect(
shouldPrepareUpdatedInstallRestart({
updateMode: "git",
serviceInstalled: true,
serviceLoaded: false,
}),
).toBe(false);
expect(
shouldPrepareUpdatedInstallRestart({
updateMode: "git",
serviceInstalled: true,
serviceLoaded: true,
}),
).toBe(true);
});
});
describe("shouldUseLegacyProcessRestartAfterUpdate", () => {
it("never restarts package updates through the pre-update process", () => {
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "npm" })).toBe(false);
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "pnpm" })).toBe(false);
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "bun" })).toBe(false);
});
it("keeps the in-process restart path for non-package updates", () => {
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "git" })).toBe(true);
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "unknown" })).toBe(true);
});
});

View File

@@ -17,7 +17,7 @@ import { formatConfigIssueLines } from "../../config/issue-format.js";
import { asResolvedSourceConfig, asRuntimeConfig } from "../../config/materialize.js";
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
import { resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js";
import { resolveGatewayService } from "../../daemon/service.js";
import { readGatewayServiceState, resolveGatewayService } from "../../daemon/service.js";
import { createLowDiskSpaceWarning } from "../../infra/disk-space.js";
import { runGlobalPackageUpdateSteps } from "../../infra/package-update-steps.js";
import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js";
@@ -133,6 +133,24 @@ function pickUpdateQuip(): string {
function isPackageManagerUpdateMode(mode: UpdateRunResult["mode"]): mode is "npm" | "pnpm" | "bun" {
return mode === "npm" || mode === "pnpm" || mode === "bun";
}
export function shouldPrepareUpdatedInstallRestart(params: {
updateMode: UpdateRunResult["mode"];
serviceInstalled: boolean;
serviceLoaded: boolean;
}): boolean {
if (isPackageManagerUpdateMode(params.updateMode)) {
return params.serviceInstalled;
}
return params.serviceLoaded;
}
export function shouldUseLegacyProcessRestartAfterUpdate(params: {
updateMode: UpdateRunResult["mode"];
}): boolean {
return !isPackageManagerUpdateMode(params.updateMode);
}
function formatCommandFailure(stdout: string, stderr: string): string {
const detail = (stderr || stdout).trim();
if (!detail) {
@@ -267,6 +285,7 @@ async function refreshGatewayServiceEnv(params: {
result: UpdateRunResult;
jsonMode: boolean;
invocationCwd?: string;
env?: NodeJS.ProcessEnv;
}): Promise<void> {
const args = ["gateway", "install", "--force"];
if (params.jsonMode) {
@@ -277,7 +296,7 @@ async function refreshGatewayServiceEnv(params: {
if (entrypoint) {
const res = await runCommandWithTimeout([resolveNodeRunner(), entrypoint, ...args], {
cwd: params.result.root,
env: resolveServiceRefreshEnv(process.env, params.invocationCwd),
env: resolveServiceRefreshEnv(params.env ?? process.env, params.invocationCwd),
timeoutMs: SERVICE_REFRESH_TIMEOUT_MS,
});
if (res.code === 0) {
@@ -288,9 +307,45 @@ async function refreshGatewayServiceEnv(params: {
);
}
if (isPackageManagerUpdateMode(params.result.mode)) {
throw new Error(
`updated install entrypoint not found under ${params.result.root ?? "unknown"}`,
);
}
await runDaemonInstall({ force: true, json: params.jsonMode || undefined });
}
async function runUpdatedInstallGatewayRestart(params: {
result: UpdateRunResult;
jsonMode: boolean;
invocationCwd?: string;
env?: NodeJS.ProcessEnv;
}): Promise<boolean> {
const entrypoint = await resolveGatewayInstallEntrypoint(params.result.root);
if (!entrypoint) {
throw new Error(
`updated install entrypoint not found under ${params.result.root ?? "unknown"}`,
);
}
const args = ["gateway", "restart"];
if (params.jsonMode) {
args.push("--json");
}
const res = await runCommandWithTimeout([resolveNodeRunner(), entrypoint, ...args], {
cwd: params.result.root,
env: resolveServiceRefreshEnv(params.env ?? process.env, params.invocationCwd),
timeoutMs: SERVICE_REFRESH_TIMEOUT_MS,
});
if (res.code === 0) {
return true;
}
throw new Error(
`updated install restart failed (${entrypoint}): ${formatCommandFailure(res.stdout, res.stderr)}`,
);
}
async function tryInstallShellCompletion(opts: {
jsonMode: boolean;
skipPrompt: boolean;
@@ -739,11 +794,26 @@ async function maybeRestartService(params: {
result: UpdateRunResult;
opts: UpdateCommandOptions;
refreshServiceEnv: boolean;
serviceEnv?: NodeJS.ProcessEnv;
gatewayPort: number;
restartScriptPath?: string | null;
invocationCwd?: string;
}): Promise<boolean> {
const verifyRestartedGateway = async (expectedGatewayVersion: string | undefined) => {
const restartAfterStaleCleanup = async () => {
if (params.refreshServiceEnv && isPackageManagerUpdateMode(params.result.mode)) {
await runUpdatedInstallGatewayRestart({
result: params.result,
jsonMode: Boolean(params.opts.json),
invocationCwd: params.invocationCwd,
env: params.serviceEnv,
});
return;
}
if (shouldUseLegacyProcessRestartAfterUpdate({ updateMode: params.result.mode })) {
await runDaemonRestart();
}
};
const service = resolveGatewayService();
let health = await waitForGatewayHealthyRestart({
service,
@@ -759,7 +829,7 @@ async function maybeRestartService(params: {
);
}
await terminateStaleGatewayPids(health.staleGatewayPids);
await runDaemonRestart();
await restartAfterStaleCleanup();
health = await waitForGatewayHealthyRestart({
service,
port: params.gatewayPort,
@@ -786,6 +856,10 @@ async function maybeRestartService(params: {
}
}
if (isPackageManagerUpdateMode(params.result.mode)) {
return false;
}
return !(health.versionMismatch || health.activatedPluginErrors?.length);
};
@@ -799,6 +873,7 @@ async function maybeRestartService(params: {
const expectedGatewayVersion = isPackageManagerUpdateMode(params.result.mode)
? normalizeOptionalString(params.result.after?.version)
: undefined;
const isPackageUpdate = isPackageManagerUpdateMode(params.result.mode);
let restarted = false;
let restartInitiated = false;
if (params.refreshServiceEnv) {
@@ -807,6 +882,7 @@ async function maybeRestartService(params: {
result: params.result,
jsonMode: Boolean(params.opts.json),
invocationCwd: params.invocationCwd,
env: params.serviceEnv,
});
} catch (err) {
// Always log the refresh failure so callers can detect it (issue #56772).
@@ -818,7 +894,7 @@ async function maybeRestartService(params: {
} else {
defaultRuntime.log(theme.warn(message));
}
if (isPackageManagerUpdateMode(params.result.mode)) {
if (isPackageUpdate) {
return false;
}
}
@@ -826,8 +902,17 @@ async function maybeRestartService(params: {
if (params.restartScriptPath) {
await runRestartScript(params.restartScriptPath);
restartInitiated = true;
} else {
} else if (params.refreshServiceEnv && isPackageUpdate) {
restarted = await runUpdatedInstallGatewayRestart({
result: params.result,
jsonMode: Boolean(params.opts.json),
invocationCwd: params.invocationCwd,
env: params.serviceEnv,
});
} else if (shouldUseLegacyProcessRestartAfterUpdate({ updateMode: params.result.mode })) {
restarted = await runDaemonRestart();
} else if (!params.opts.json) {
defaultRuntime.log(theme.muted("No installed gateway service found; skipped restart."));
}
const shouldVerifyRestart =
@@ -871,6 +956,9 @@ async function maybeRestartService(params: {
),
);
}
if (isPackageManagerUpdateMode(params.result.mode)) {
return false;
}
}
return true;
}
@@ -1419,15 +1507,25 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
let restartScriptPath: string | null = null;
let refreshGatewayServiceEnv = false;
let gatewayServiceEnv: NodeJS.ProcessEnv | undefined;
const gatewayPort = resolveGatewayPort(
postUpdateConfigSnapshot.valid ? postUpdateConfigSnapshot.config : undefined,
process.env,
);
if (shouldRestart) {
try {
const loaded = await resolveGatewayService().isLoaded({ env: process.env });
if (loaded) {
restartScriptPath = await prepareRestartScript(process.env, gatewayPort);
const serviceState = await readGatewayServiceState(resolveGatewayService(), {
env: process.env,
});
if (
shouldPrepareUpdatedInstallRestart({
updateMode: resultWithPostUpdate.mode,
serviceInstalled: serviceState.installed,
serviceLoaded: serviceState.loaded,
})
) {
gatewayServiceEnv = serviceState.env;
restartScriptPath = await prepareRestartScript(serviceState.env, gatewayPort);
refreshGatewayServiceEnv = true;
}
} catch {
@@ -1446,6 +1544,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
result: resultWithPostUpdate,
opts,
refreshServiceEnv: refreshGatewayServiceEnv,
serviceEnv: gatewayServiceEnv,
gatewayPort,
restartScriptPath,
invocationCwd,

View File

@@ -1502,6 +1502,181 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
type: "string",
minLength: 1,
},
persona: {
type: "string",
},
personas: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
properties: {
label: {
type: "string",
},
description: {
type: "string",
},
provider: {
type: "string",
minLength: 1,
},
fallbackPolicy: {
anyOf: [
{
type: "string",
const: "preserve-persona",
},
{
type: "string",
const: "provider-defaults",
},
{
type: "string",
const: "fail",
},
],
},
prompt: {
type: "object",
properties: {
profile: {
type: "string",
},
scene: {
type: "string",
},
sampleContext: {
type: "string",
},
style: {
type: "string",
},
accent: {
type: "string",
},
pacing: {
type: "string",
},
constraints: {
type: "array",
items: {
type: "string",
},
},
},
additionalProperties: false,
},
providers: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
properties: {
apiKey: {
anyOf: [
{
type: "string",
},
{
oneOf: [
{
type: "object",
properties: {
source: {
type: "string",
const: "env",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
pattern: "^[A-Z][A-Z0-9_]{0,127}$",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "file",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "exec",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
],
},
],
},
},
additionalProperties: {
anyOf: [
{
type: "string",
},
{
type: "number",
},
{
type: "boolean",
},
{
type: "null",
},
{
type: "array",
items: {},
},
{
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
],
},
},
},
},
additionalProperties: false,
},
},
summaryModel: {
type: "string",
},
@@ -2682,6 +2857,181 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
type: "string",
minLength: 1,
},
persona: {
type: "string",
},
personas: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
properties: {
label: {
type: "string",
},
description: {
type: "string",
},
provider: {
type: "string",
minLength: 1,
},
fallbackPolicy: {
anyOf: [
{
type: "string",
const: "preserve-persona",
},
{
type: "string",
const: "provider-defaults",
},
{
type: "string",
const: "fail",
},
],
},
prompt: {
type: "object",
properties: {
profile: {
type: "string",
},
scene: {
type: "string",
},
sampleContext: {
type: "string",
},
style: {
type: "string",
},
accent: {
type: "string",
},
pacing: {
type: "string",
},
constraints: {
type: "array",
items: {
type: "string",
},
},
},
additionalProperties: false,
},
providers: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
properties: {
apiKey: {
anyOf: [
{
type: "string",
},
{
oneOf: [
{
type: "object",
properties: {
source: {
type: "string",
const: "env",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
pattern: "^[A-Z][A-Z0-9_]{0,127}$",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "file",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "exec",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
],
},
],
},
},
additionalProperties: {
anyOf: [
{
type: "string",
},
{
type: "number",
},
{
type: "boolean",
},
{
type: "null",
},
{
type: "array",
items: {},
},
{
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
],
},
},
},
},
additionalProperties: false,
},
},
summaryModel: {
type: "string",
},
@@ -3792,6 +4142,78 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
default: true,
type: "boolean",
},
tts: {
type: "object",
properties: {
auto: {
type: "string",
enum: ["off", "always", "inbound", "tagged"],
},
enabled: {
type: "boolean",
},
mode: {
type: "string",
enum: ["final", "all"],
},
provider: {
type: "string",
},
persona: {
type: "string",
},
personas: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
},
summaryModel: {
type: "string",
},
modelOverrides: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
providers: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
},
prefsPath: {
type: "string",
},
maxTextLength: {
type: "integer",
minimum: 1,
maximum: 9007199254740991,
},
timeoutMs: {
type: "integer",
minimum: 1000,
maximum: 120000,
},
},
additionalProperties: false,
},
groupSessionScope: {
type: "string",
enum: ["group", "group_sender", "group_topic", "group_topic_sender"],
@@ -4345,6 +4767,78 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
resolveSenderNames: {
type: "boolean",
},
tts: {
type: "object",
properties: {
auto: {
type: "string",
enum: ["off", "always", "inbound", "tagged"],
},
enabled: {
type: "boolean",
},
mode: {
type: "string",
enum: ["final", "all"],
},
provider: {
type: "string",
},
persona: {
type: "string",
},
personas: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
},
summaryModel: {
type: "string",
},
modelOverrides: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
providers: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
},
prefsPath: {
type: "string",
},
maxTextLength: {
type: "integer",
minimum: 1,
maximum: 9007199254740991,
},
timeoutMs: {
type: "integer",
minimum: 1000,
maximum: 120000,
},
},
additionalProperties: false,
},
groupSessionScope: {
type: "string",
enum: ["group", "group_sender", "group_topic", "group_topic_sender"],

View File

@@ -466,7 +466,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
],
title: "Sensitive Data Redaction Mode",
description:
'Sensitive redaction mode: "off" disables built-in masking, while "tools" redacts sensitive tool/config payload fields. Keep "tools" in shared logs unless you have isolated secure log sinks.',
'Sensitive redaction mode: "off" disables built-in masking, while "tools" redacts sensitive tool/config payload fields in log sinks and persisted transcript text. Keep "tools" enabled unless logs and transcripts are isolated.',
},
redactPatterns: {
type: "array",
@@ -475,7 +475,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
},
title: "Custom Redaction Patterns",
description:
"Additional custom redact regex patterns applied to log output before emission/storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.",
"Additional custom redact regex patterns applied to log output and persisted transcript text before storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.",
},
},
additionalProperties: false,
@@ -23982,12 +23982,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
},
"logging.redactSensitive": {
label: "Sensitive Data Redaction Mode",
help: 'Sensitive redaction mode: "off" disables built-in masking, while "tools" redacts sensitive tool/config payload fields. Keep "tools" in shared logs unless you have isolated secure log sinks.',
help: 'Sensitive redaction mode: "off" disables built-in masking, while "tools" redacts sensitive tool/config payload fields in log sinks and persisted transcript text. Keep "tools" enabled unless logs and transcripts are isolated.',
tags: ["privacy", "observability"],
},
"logging.redactPatterns": {
label: "Custom Redaction Patterns",
help: "Additional custom redact regex patterns applied to log output before emission/storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.",
help: "Additional custom redact regex patterns applied to log output and persisted transcript text before storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.",
tags: ["privacy", "observability"],
},
"cli.banner": {
@@ -28617,6 +28617,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
tags: ["advanced", "url-secret"],
},
},
version: "2026.4.26",
version: "2026.4.25",
generatedAt: "2026-03-22T21:17:33.302Z",
};

View File

@@ -43,9 +43,9 @@ export const FIELD_HELP: Record<string, string> = {
"logging.consoleStyle":
'Console output format style: "pretty", "compact", or "json" based on operator and ingestion needs. Use json for machine parsing pipelines and pretty/compact for human-first terminal workflows.',
"logging.redactSensitive":
'Sensitive redaction mode: "off" disables built-in masking, while "tools" redacts sensitive tool/config payload fields. Keep "tools" in shared logs unless you have isolated secure log sinks.',
'Sensitive redaction mode: "off" disables built-in masking, while "tools" redacts sensitive tool/config payload fields in log sinks and persisted transcript text. Keep "tools" enabled unless logs and transcripts are isolated.',
"logging.redactPatterns":
"Additional custom redact regex patterns applied to log output before emission/storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.",
"Additional custom redact regex patterns applied to log output and persisted transcript text before storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.",
cli: "CLI presentation controls for local command output behavior such as banner and tagline style. Use this section to keep startup output aligned with operator preference without changing runtime behavior.",
"cli.banner":
"CLI startup banner controls for title/version line and tagline style behavior. Keep banner enabled for fast version/context checks, then tune tagline mode to your preferred noise level.",

View File

@@ -225,9 +225,9 @@ export type LoggingConfig = {
maxFileBytes?: number;
consoleLevel?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
consoleStyle?: "pretty" | "compact" | "json";
/** Redact sensitive tokens in tool summaries. Default: "tools". */
/** Redact sensitive tokens in log sinks and persisted transcript text. Default: "tools". */
redactSensitive?: "off" | "tools";
/** Regex patterns used to redact sensitive tokens (defaults apply when unset). */
/** Regex patterns used to redact sensitive tokens from logs and transcripts. */
redactPatterns?: string[];
};

View File

@@ -437,6 +437,31 @@ describe("channel-health-monitor", () => {
monitor.stop();
});
it("counts failed restart attempts toward cooldown and hourly caps", async () => {
const manager = createSnapshotManager(
{
discord: {
default: managedStoppedAccount("keeps crashing"),
},
},
{
startChannel: vi.fn(async () => {
throw new Error("startup failed");
}),
},
);
const monitor = startDefaultMonitor(manager, {
checkIntervalMs: 1_000,
cooldownCycles: 1,
maxRestartsPerHour: 1,
});
await vi.advanceTimersByTimeAsync(5_001);
expect(manager.startChannel).toHaveBeenCalledTimes(1);
monitor.stop();
});
it("runs checks single-flight when restart work is still in progress", async () => {
let releaseStart: (() => void) | undefined;
const startGate = new Promise<void>((resolve) => {

View File

@@ -157,15 +157,16 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann
log.info?.(`[${channelId}:${accountId}] health-monitor: restarting (reason: ${reason})`);
record.lastRestartAt = now;
record.restartsThisHour.push({ at: now });
restartRecords.set(key, record);
try {
if (status.running) {
await channelManager.stopChannel(channelId as ChannelId, accountId);
}
channelManager.resetRestartAttempts(channelId as ChannelId, accountId);
await channelManager.startChannel(channelId as ChannelId, accountId);
record.lastRestartAt = now;
record.restartsThisHour.push({ at: now });
restartRecords.set(key, record);
} catch (err) {
log.error?.(
`[${channelId}:${accountId}] health-monitor: restart failed: ${String(err)}`,

View File

@@ -36,6 +36,9 @@ const describeLive = LIVE && ACP_BIND_LIVE ? describe : describe.skip;
const CONNECT_TIMEOUT_MS = 90_000;
const LIVE_TIMEOUT_MS = 240_000;
const ACP_CRON_MCP_PROBE_MAX_ATTEMPTS = 2;
const ACP_CRON_MCP_PROBE_VERIFY_POLLS = 5;
const ACP_CRON_MCP_PROBE_VERIFY_POLL_MS = 1_000;
const DEFAULT_LIVE_CODEX_MODEL = "gpt-5.5";
const DEFAULT_LIVE_PARENT_MODEL = "openai/gpt-5.4";
type LiveAcpAgent = "claude" | "codex" | "droid" | "gemini" | "opencode";
@@ -150,6 +153,10 @@ function shouldRequireBoundAssistantTranscript(liveAgent: LiveAcpAgent): boolean
);
}
function shouldRequireCronMcpProbe(): boolean {
return isTruthyEnvValue(process.env.OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON);
}
function normalizeOpenAiModelRef(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
@@ -287,24 +294,30 @@ async function bindConversationAndWait(params: {
doctor?: () => Promise<{ message?: string; details?: string[] }>;
}
| undefined;
if (runtime?.probeAvailability) {
await runtime.probeAvailability().catch(() => {});
}
if (!(backend?.healthy?.() ?? false)) {
if (runtime?.doctor && (attempt === 1 || attempt % 6 === 0)) {
const report = await runtime.doctor().catch((error) => ({
message: error instanceof Error ? error.message : String(error),
details: [],
}));
logLiveStep(
`acpx doctor before bind attempt ${attempt}: ${report.message ?? "unknown"}${
report.details?.length ? ` (${report.details.join("; ")})` : ""
}`,
);
const backendUnavailable = !backend || (backend.healthy && !backend.healthy());
if (backendUnavailable) {
if (runtime?.probeAvailability) {
await runtime.probeAvailability().catch(() => {});
}
const backendReadyAfterProbe = backend && (!backend.healthy || backend.healthy());
if (backendReadyAfterProbe) {
logLiveStep(`acpx backend became healthy before bind attempt ${attempt}`);
} else {
if (runtime?.doctor && (attempt === 1 || attempt % 6 === 0)) {
const report = await runtime.doctor().catch((error) => ({
message: error instanceof Error ? error.message : String(error),
details: [],
}));
logLiveStep(
`acpx doctor before bind attempt ${attempt}: ${report.message ?? "unknown"}${
report.details?.length ? ` (${report.details.join("; ")})` : ""
}`,
);
}
logLiveStep(`acpx backend still unhealthy before bind attempt ${attempt}`);
await sleep(5_000);
continue;
}
logLiveStep(`acpx backend still unhealthy before bind attempt ${attempt}`);
await sleep(5_000);
continue;
}
await sendChatAndWait({
@@ -463,6 +476,25 @@ async function waitForAssistantTurn(params: {
);
}
async function pollCronJobVisibleViaCli(params: {
port: number;
token: string;
env: NodeJS.ProcessEnv;
expectedName: string;
expectedMessage: string;
}): Promise<{ job?: Awaited<ReturnType<typeof assertCronJobVisibleViaCli>>; pollsUsed: number }> {
for (let verifyAttempt = 0; verifyAttempt < ACP_CRON_MCP_PROBE_VERIFY_POLLS; verifyAttempt += 1) {
const job = await assertCronJobVisibleViaCli(params);
if (job) {
return { job, pollsUsed: verifyAttempt + 1 };
}
if (verifyAttempt < ACP_CRON_MCP_PROBE_VERIFY_POLLS - 1) {
await sleep(ACP_CRON_MCP_PROBE_VERIFY_POLL_MS);
}
}
return { pollsUsed: ACP_CRON_MCP_PROBE_VERIFY_POLLS };
}
describeLive("gateway live (ACP bind)", () => {
it(
"binds a synthetic Slack DM conversation to a live ACP session and reroutes the next turn",
@@ -852,9 +884,10 @@ describeLive("gateway live (ACP bind)", () => {
agentId: liveAgent,
sessionKey: spawnedSessionKey,
});
const requireCronMcpProbe = shouldRequireCronMcpProbe();
let cronJobId: string | undefined;
let lastCronAssistantText = "";
for (let attempt = 0; attempt < 2; attempt += 1) {
for (let attempt = 0; attempt < ACP_CRON_MCP_PROBE_MAX_ATTEMPTS; attempt += 1) {
await sendChatAndWait({
client,
sessionKey: originalSessionKey,
@@ -876,7 +909,7 @@ describeLive("gateway live (ACP bind)", () => {
cronHistory = await waitForAssistantText({
client,
sessionKey: spawnedSessionKey,
timeoutMs: liveAgent === "claude" ? 90_000 : 45_000,
timeoutMs: 20_000,
contains: cronProbe.name,
});
} catch {
@@ -885,13 +918,14 @@ describeLive("gateway live (ACP bind)", () => {
if (cronHistory) {
lastCronAssistantText = cronHistory.lastAssistantText;
}
const createdJob = await assertCronJobVisibleViaCli({
const verifyResult = await pollCronJobVisibleViaCli({
port,
token,
env: process.env,
expectedName: cronProbe.name,
expectedMessage: cronProbe.message,
});
const createdJob = verifyResult.job;
if (createdJob) {
assertCronJobMatches({
job: createdJob,
@@ -906,10 +940,15 @@ describeLive("gateway live (ACP bind)", () => {
}
break;
}
if (attempt === 1) {
if (liveAgent !== "claude") {
logLiveStep(
`cron mcp job not observed after attempt ${String(
attempt + 1,
)}; polls=${String(verifyResult.pollsUsed)}`,
);
if (attempt === ACP_CRON_MCP_PROBE_MAX_ATTEMPTS - 1) {
if (!requireCronMcpProbe) {
logLiveStep(
`cron mcp job ${cronProbe.name} not observed for ${liveAgent}; continuing after bind/image verification`,
`cron mcp job ${cronProbe.name} not observed; continuing after bind/image verification`,
);
break;
}
@@ -921,7 +960,7 @@ describeLive("gateway live (ACP bind)", () => {
}
}
if (!cronJobId) {
if (liveAgent !== "claude") {
if (!requireCronMcpProbe) {
return;
}
throw new Error(`acp cron cli verify did not create job ${cronProbe.name}`);

View File

@@ -31,6 +31,7 @@ import { shouldSuppressBuiltInModel } from "../agents/model-suppression.js";
import { ensureOpenClawModelsJson } from "../agents/models-config.js";
import { isRateLimitErrorMessage } from "../agents/pi-embedded-helpers/errors.js";
import { discoverAuthStorage, discoverModels } from "../agents/pi-model-discovery.js";
import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js";
import { clearRuntimeConfigSnapshot, loadConfig } from "../config/io.js";
import type { ModelsConfig, ModelProviderConfig, OpenClawConfig } from "../config/types.js";
import { isTruthyEnvValue } from "../infra/env.js";
@@ -736,6 +737,16 @@ describe("shouldSkipEmptyResponseForLiveModel", () => {
);
});
describe("isEmptyStreamText", () => {
it.each([
{ text: "request ended without sending any chunks", expected: true },
{ text: `not meaningful: ${STREAM_ERROR_FALLBACK_TEXT}`, expected: true },
{ text: "not meaningful: let me think", expected: false },
])("returns $expected for $text", ({ text, expected }) => {
expect(isEmptyStreamText(text)).toBe(expected);
});
});
describe("isPromptProbeMiss", () => {
it.each([
{ error: "not meaningful: let me think", expected: true },
@@ -763,7 +774,10 @@ function isMissingProfileError(error: string): boolean {
}
function isEmptyStreamText(text: string): boolean {
return text.includes("request ended without sending any chunks");
return (
text.includes("request ended without sending any chunks") ||
text.includes(STREAM_ERROR_FALLBACK_TEXT)
);
}
function buildAnthropicRefusalToken(): string {

View File

@@ -74,6 +74,7 @@ export function buildLiveCronProbeMessage(params: {
if (params.attempt === 0) {
return (
"Use the OpenClaw MCP tool `openclaw-tools/cron` (server `openclaw-tools`, tool `cron`). " +
"If the harness shows Claude-style MCP names, use `mcp__openclaw-tools__cron` or `mcp__openclaw_tools__cron`. " +
`Call it with JSON arguments ${params.argsJson}. ` +
"Preserve the JSON exactly, including job.sessionTarget and job.sessionKey; do not omit, rename, or flatten those fields. " +
"Do the actual tool call; I will verify externally with the OpenClaw cron CLI. " +
@@ -83,6 +84,7 @@ export function buildLiveCronProbeMessage(params: {
if (claudeLike) {
return (
"Retry the OpenClaw MCP tool `openclaw-tools/cron` now. " +
"If the harness shows Claude-style MCP names, use `mcp__openclaw-tools__cron` or `mcp__openclaw_tools__cron`. " +
`Use these exact JSON arguments: ${params.argsJson}. ` +
"Preserve job.sessionTarget and job.sessionKey exactly as provided. " +
`If the cron job is created, reply exactly: ${params.exactReply}. ` +
@@ -94,6 +96,7 @@ export function buildLiveCronProbeMessage(params: {
return (
"Your previous OpenClaw cron MCP tool call was cancelled before the job was created. " +
"Retry the OpenClaw MCP tool `openclaw-tools/cron` now. " +
"If the harness shows Claude-style MCP names, use `mcp__openclaw-tools__cron` or `mcp__openclaw_tools__cron`. " +
`Use these exact JSON arguments: ${params.argsJson}. ` +
"Preserve job.sessionTarget and job.sessionKey exactly as provided. " +
`If the cron job is created, reply exactly: ${params.exactReply}. ` +

View File

@@ -50,6 +50,7 @@ function createTestPlugin(params?: {
order?: number;
account?: TestAccount;
startAccount?: NonNullable<ChannelPlugin<TestAccount>["gateway"]>["startAccount"];
listAccountIds?: ChannelPlugin<TestAccount>["config"]["listAccountIds"];
includeDescribeAccount?: boolean;
describeAccount?: ChannelPlugin<TestAccount>["config"]["describeAccount"];
resolveAccount?: ChannelPlugin<TestAccount>["config"]["resolveAccount"];
@@ -59,7 +60,7 @@ function createTestPlugin(params?: {
const account = params?.account ?? { enabled: true, configured: true };
const includeDescribeAccount = params?.includeDescribeAccount !== false;
const config: ChannelPlugin<TestAccount>["config"] = {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
listAccountIds: params?.listAccountIds ?? (() => [DEFAULT_ACCOUNT_ID]),
resolveAccount: params?.resolveAccount ?? (() => account),
isEnabled: (resolved) => resolved.enabled !== false,
...(params?.isConfigured ? { isConfigured: params.isConfigured } : {}),
@@ -436,6 +437,35 @@ describe("server-channels auto restart", () => {
expect(succeedingStart).toHaveBeenCalledTimes(1);
});
it("evicts stale account lifecycle state during whole-channel reload", async () => {
let accountIds = [DEFAULT_ACCOUNT_ID];
const startAccount = vi.fn(
async ({ abortSignal }: { abortSignal: AbortSignal }) =>
await new Promise<void>((resolve) => {
abortSignal.addEventListener("abort", () => resolve(), { once: true });
}),
);
installTestRegistry(createTestPlugin({ startAccount, listAccountIds: () => accountIds }));
const manager = createManager();
await manager.startChannel("discord");
accountIds = [];
await manager.stopChannel("discord");
await manager.startChannel("discord");
accountIds = [DEFAULT_ACCOUNT_ID];
await manager.startChannel("discord");
const snapshot = manager.getRuntimeSnapshot();
const account = snapshot.channelAccounts.discord?.[DEFAULT_ACCOUNT_ID];
expect(startAccount).toHaveBeenCalledTimes(2);
expect(account?.reconnectAttempts).toBe(0);
expect(account?.lastStopAt).toBeUndefined();
await manager.stopChannel("discord");
});
it("reuses plugin account resolution for health monitor overrides", () => {
installTestRegistry(
createTestPlugin({

View File

@@ -282,6 +282,27 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
return channelRuntime ?? resolveChannelRuntime?.();
};
const evictStaleChannelAccountState = (
channelId: ChannelId,
store: ChannelRuntimeStore,
accountIds: readonly string[],
) => {
const activeAccountIds = new Set(accountIds);
for (const id of store.runtimes.keys()) {
if (
activeAccountIds.has(id) ||
store.aborts.has(id) ||
store.starting.has(id) ||
store.tasks.has(id)
) {
continue;
}
store.runtimes.delete(id);
restartAttempts.delete(restartKey(channelId, id));
manuallyStopped.delete(restartKey(channelId, id));
}
};
const startChannelInternal = async (
channelId: ChannelId,
accountId?: string,
@@ -297,6 +318,9 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
resetDirectoryCache({ channel: channelId, accountId });
const store = getStore(channelId);
const accountIds = accountId ? [accountId] : plugin.config.listAccountIds(cfg);
if (!accountId) {
evictStaleChannelAccountState(channelId, store, accountIds);
}
if (accountIds.length === 0) {
return;
}

View File

@@ -0,0 +1,174 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type ConfiguredDeferredChannelPluginIdsResolver =
typeof import("../plugins/channel-plugin-ids.js").resolveConfiguredDeferredChannelPluginIds;
type GatewayStartupPluginIdsResolver =
typeof import("../plugins/channel-plugin-ids.js").resolveGatewayStartupPluginIds;
const applyPluginAutoEnable = vi.hoisted(() =>
vi.fn((params: { config: unknown }) => ({
config: params.config,
changes: [] as string[],
autoEnabledReasons: {} as Record<string, string[]>,
})),
);
const initSubagentRegistry = vi.hoisted(() => vi.fn());
const loadGatewayStartupPlugins = vi.hoisted(() =>
vi.fn((_params: unknown) => ({
pluginRegistry: { diagnostics: [], gatewayHandlers: {}, plugins: [] },
gatewayMethods: ["ping"],
})),
);
const repairBundledRuntimeDepsInstallRoot = vi.hoisted(() => vi.fn((_params: unknown) => ({})));
const resolveBundledRuntimeDependencyPackageInstallRoot = vi.hoisted(() =>
vi.fn((_packageRoot: string, _params: unknown) => "/runtime"),
);
const resolveConfiguredDeferredChannelPluginIds = vi.hoisted(() =>
vi.fn<ConfiguredDeferredChannelPluginIdsResolver>(() => []),
);
const resolveGatewayStartupPluginIds = vi.hoisted(() =>
vi.fn<GatewayStartupPluginIdsResolver>(() => ["memory-core"]),
);
const resolveOpenClawPackageRootSync = vi.hoisted(() => vi.fn((_params: unknown) => "/package"));
const runChannelPluginStartupMaintenance = vi.hoisted(() =>
vi.fn(async (_params: unknown) => undefined),
);
const runStartupSessionMigration = vi.hoisted(() => vi.fn(async (_params: unknown) => undefined));
const scanBundledPluginRuntimeDeps = vi.hoisted(() =>
vi.fn((_params: unknown) => ({
deps: [
{ name: "chokidar", version: "^5.0.0", pluginIds: ["memory-core"] },
{ name: "typebox", version: "^1.0.0", pluginIds: ["memory-core"] },
],
missing: [{ name: "chokidar", version: "^5.0.0", pluginIds: ["memory-core"] }],
conflicts: [],
})),
);
vi.mock("../agents/agent-scope.js", () => ({
resolveAgentWorkspaceDir: () => "/workspace",
resolveDefaultAgentId: () => "default",
}));
vi.mock("../agents/subagent-registry.js", () => ({
initSubagentRegistry: () => initSubagentRegistry(),
}));
vi.mock("../channels/plugins/lifecycle-startup.js", () => ({
runChannelPluginStartupMaintenance: (params: unknown) =>
runChannelPluginStartupMaintenance(params),
}));
vi.mock("../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: (params: { config: unknown }) => applyPluginAutoEnable(params),
}));
vi.mock("../infra/openclaw-root.js", () => ({
resolveOpenClawPackageRootSync: (params: unknown) => resolveOpenClawPackageRootSync(params),
}));
vi.mock("../plugins/bundled-runtime-deps.js", () => ({
repairBundledRuntimeDepsInstallRoot: (params: unknown) =>
repairBundledRuntimeDepsInstallRoot(params),
resolveBundledRuntimeDependencyPackageInstallRoot: (packageRoot: string, params: unknown) =>
resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, params),
scanBundledPluginRuntimeDeps: (params: unknown) => scanBundledPluginRuntimeDeps(params),
}));
vi.mock("../plugins/channel-plugin-ids.js", () => ({
resolveConfiguredDeferredChannelPluginIds: (
...args: Parameters<ConfiguredDeferredChannelPluginIdsResolver>
) => resolveConfiguredDeferredChannelPluginIds(...args),
resolveGatewayStartupPluginIds: (...args: Parameters<GatewayStartupPluginIdsResolver>) =>
resolveGatewayStartupPluginIds(...args),
}));
vi.mock("../plugins/registry.js", () => ({
createEmptyPluginRegistry: () => ({ diagnostics: [], gatewayHandlers: {}, plugins: [] }),
}));
vi.mock("../plugins/runtime.js", () => ({
getActivePluginRegistry: () => undefined,
setActivePluginRegistry: vi.fn(),
}));
vi.mock("./server-methods-list.js", () => ({
listGatewayMethods: () => ["ping"],
}));
vi.mock("./server-methods.js", () => ({
coreGatewayHandlers: {},
}));
vi.mock("./server-plugin-bootstrap.js", () => ({
loadGatewayStartupPlugins: (params: unknown) => loadGatewayStartupPlugins(params),
}));
vi.mock("./server-startup-session-migration.js", () => ({
runStartupSessionMigration: (params: unknown) => runStartupSessionMigration(params),
}));
function createLog() {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
}
describe("prepareGatewayPluginBootstrap runtime-deps staging", () => {
beforeEach(() => {
applyPluginAutoEnable.mockClear();
initSubagentRegistry.mockClear();
loadGatewayStartupPlugins.mockClear();
repairBundledRuntimeDepsInstallRoot.mockReset().mockReturnValue({});
resolveBundledRuntimeDependencyPackageInstallRoot.mockClear();
resolveConfiguredDeferredChannelPluginIds.mockClear().mockReturnValue([]);
resolveGatewayStartupPluginIds.mockClear().mockReturnValue(["memory-core"]);
resolveOpenClawPackageRootSync.mockClear().mockReturnValue("/package");
runChannelPluginStartupMaintenance.mockClear();
runStartupSessionMigration.mockClear();
scanBundledPluginRuntimeDeps.mockClear().mockReturnValue({
deps: [
{ name: "chokidar", version: "^5.0.0", pluginIds: ["memory-core"] },
{ name: "typebox", version: "^1.0.0", pluginIds: ["memory-core"] },
],
missing: [{ name: "chokidar", version: "^5.0.0", pluginIds: ["memory-core"] }],
conflicts: [],
});
});
it("pre-stages runtime deps for startup-selected memory-core before plugin import", async () => {
const { prepareGatewayPluginBootstrap } = await import("./server-startup-plugins.js");
await expect(
prepareGatewayPluginBootstrap({
cfgAtStart: {},
startupRuntimeConfig: {},
minimalTestGateway: false,
log: createLog(),
}),
).resolves.toMatchObject({
baseGatewayMethods: ["ping"],
startupPluginIds: ["memory-core"],
});
expect(scanBundledPluginRuntimeDeps).toHaveBeenCalledWith(
expect.objectContaining({
packageRoot: "/package",
pluginIds: ["memory-core"],
}),
);
expect(repairBundledRuntimeDepsInstallRoot).toHaveBeenCalledWith(
expect.objectContaining({
installRoot: "/runtime",
missingSpecs: ["chokidar@^5.0.0"],
installSpecs: expect.arrayContaining(["chokidar@^5.0.0", "typebox@^1.0.0"]),
}),
);
expect(repairBundledRuntimeDepsInstallRoot.mock.invocationCallOrder[0]).toBeLessThan(
loadGatewayStartupPlugins.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
});
});

View File

@@ -3,6 +3,12 @@ import { initSubagentRegistry } from "../agents/subagent-registry.js";
import { runChannelPluginStartupMaintenance } from "../channels/plugins/lifecycle-startup.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import {
repairBundledRuntimeDepsInstallRoot,
resolveBundledRuntimeDependencyPackageInstallRoot,
scanBundledPluginRuntimeDeps,
} from "../plugins/bundled-runtime-deps.js";
import {
resolveConfiguredDeferredChannelPluginIds,
resolveGatewayStartupPluginIds,
@@ -21,6 +27,73 @@ type GatewayPluginBootstrapLog = {
debug: (message: string) => void;
};
function prestageGatewayBundledRuntimeDeps(params: {
cfg: OpenClawConfig;
pluginIds: readonly string[];
log: GatewayPluginBootstrapLog;
}): void {
if (params.pluginIds.length === 0) {
return;
}
const packageRoot = resolveOpenClawPackageRootSync({
argv1: process.argv[1],
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
if (!packageRoot) {
return;
}
let scanResult: ReturnType<typeof scanBundledPluginRuntimeDeps>;
try {
scanResult = scanBundledPluginRuntimeDeps({
packageRoot,
config: params.cfg,
pluginIds: [...params.pluginIds],
env: process.env,
});
} catch (error) {
params.log.warn(
`[plugins] failed to scan bundled runtime deps before gateway startup; gateway startup will continue with per-plugin runtime-deps installs: ${String(error)}`,
);
return;
}
const { deps, missing, conflicts } = scanResult;
if (conflicts.length > 0) {
params.log.warn(
`[plugins] bundled runtime deps have version conflicts: ${conflicts.map((conflict) => `${conflict.name} (${conflict.versions.join(", ")})`).join("; ")}`,
);
}
if (missing.length === 0) {
return;
}
const missingSpecs = missing.map((dep) => `${dep.name}@${dep.version}`);
const installSpecs = deps.map((dep) => `${dep.name}@${dep.version}`);
const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, {
env: process.env,
});
const startedAt = Date.now();
params.log.info(
`[plugins] staging bundled runtime deps before gateway startup (${missingSpecs.length} missing, ${installSpecs.length} install specs): ${missingSpecs.join(", ")}`,
);
try {
repairBundledRuntimeDepsInstallRoot({
installRoot,
missingSpecs,
installSpecs,
env: process.env,
warn: (message) => params.log.warn(`[plugins] ${message}`),
});
} catch (error) {
params.log.warn(
`[plugins] failed to stage bundled runtime deps before gateway startup after ${Date.now() - startedAt}ms; gateway startup will continue with per-plugin runtime-deps installs: ${String(error)}`,
);
return;
}
params.log.info(
`[plugins] installed bundled runtime deps before gateway startup in ${Date.now() - startedAt}ms: ${missingSpecs.join(", ")}`,
);
}
export async function prepareGatewayPluginBootstrap(params: {
cfgAtStart: OpenClawConfig;
startupRuntimeConfig: OpenClawConfig;
@@ -89,6 +162,11 @@ export async function prepareGatewayPluginBootstrap(params: {
let baseGatewayMethods = baseMethods;
if (!params.minimalTestGateway) {
prestageGatewayBundledRuntimeDeps({
cfg: gatewayPluginConfigAtStart,
pluginIds: startupPluginIds,
log: params.log,
});
({ pluginRegistry, gatewayMethods: baseGatewayMethods } = loadGatewayStartupPlugins({
cfg: gatewayPluginConfigAtStart,
activationSourceConfig: params.cfgAtStart,

View File

@@ -21,7 +21,6 @@ describe("package dist inventory", () => {
await expect(writePackageDistInventory(packageRoot)).resolves.toEqual([
"dist/current-BR6xv1a1.js",
"dist/extensions/qa-channel/runtime-api.js",
]);
await expect(collectPackageDistInventoryErrors(packageRoot)).resolves.toEqual([]);
@@ -150,9 +149,7 @@ describe("package dist inventory", () => {
);
await fs.writeFile(omittedMap, "{}", "utf8");
await expect(writePackageDistInventory(packageRoot)).resolves.toEqual([
"dist/extensions/qa-channel/runtime-api.js",
]);
await expect(writePackageDistInventory(packageRoot)).resolves.toEqual([]);
});
});

View File

@@ -5,9 +5,6 @@ import { NPM_UPDATE_COMPAT_SIDECAR_PATHS } from "./npm-update-compat-sidecars.js
export const PACKAGE_DIST_INVENTORY_RELATIVE_PATH = "dist/postinstall-inventory.json";
const LEGACY_QA_CHANNEL_DIR = ["qa", "channel"].join("-");
const LEGACY_QA_LAB_DIR = ["qa", "lab"].join("-");
const LEGACY_VERIFIER_COMPAT_INVENTORY_PATHS = [
`dist/extensions/${LEGACY_QA_CHANNEL_DIR}/runtime-api.js`,
];
const OMITTED_QA_EXTENSION_PREFIXES = [
`dist/extensions/${LEGACY_QA_CHANNEL_DIR}/`,
`dist/extensions/${LEGACY_QA_LAB_DIR}/`,
@@ -67,9 +64,6 @@ function isPackagedDistPath(relativePath: string): boolean {
if (relativePath === "dist/plugin-sdk/.tsbuildinfo") {
return false;
}
if (LEGACY_VERIFIER_COMPAT_INVENTORY_PATHS.includes(relativePath)) {
return true;
}
if (
OMITTED_PRIVATE_QA_PLUGIN_SDK_PREFIXES.some((prefix) => relativePath.startsWith(prefix)) ||
OMITTED_PRIVATE_QA_PLUGIN_SDK_FILES.has(relativePath) ||
@@ -219,12 +213,9 @@ export async function assertNoBundledRuntimeDepsStagingDebris(packageRoot: strin
export async function writePackageDistInventory(packageRoot: string): Promise<string[]> {
await assertNoBundledRuntimeDepsStagingDebris(packageRoot);
const inventory = [
...new Set([
...(await collectPackageDistInventory(packageRoot)),
...LEGACY_VERIFIER_COMPAT_INVENTORY_PATHS,
]),
].toSorted((left, right) => left.localeCompare(right));
const inventory = [...new Set(await collectPackageDistInventory(packageRoot))].toSorted(
(left, right) => left.localeCompare(right),
);
const inventoryPath = path.join(packageRoot, PACKAGE_DIST_INVENTORY_RELATIVE_PATH);
await fs.mkdir(path.dirname(inventoryPath), { recursive: true });
await fs.writeFile(inventoryPath, `${JSON.stringify(inventory, null, 2)}\n`, "utf8");

View File

@@ -132,6 +132,16 @@ describe("redactSensitiveText", () => {
expect(output).toBe("token=abcdef…ghij");
});
it("honors escaped character classes in custom patterns", () => {
const input = "contact peter@dc.io";
const output = redactSensitiveText(input, {
mode: "tools",
patterns: [String.raw`([\w]|[-.])+@([\w]|[-.])+\.\w+`],
});
expect(output).toBe("contact peter@d***.io");
expect(output).not.toContain("peter@dc.io");
});
it("ignores unsafe nested-repetition custom patterns", () => {
const input = `${"a".repeat(28)}!`;
const output = redactSensitiveText(input, {

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