mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
98 Commits
codex/8606
...
v2026.4.25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94c1e10643 | ||
|
|
52c4f5a0a1 | ||
|
|
009216941e | ||
|
|
9048032a76 | ||
|
|
ad2db902db | ||
|
|
25a65b731d | ||
|
|
d85778ace5 | ||
|
|
2c1c51fa4b | ||
|
|
81fd54696f | ||
|
|
1b113d80f7 | ||
|
|
8fa3c94653 | ||
|
|
f934ecaa12 | ||
|
|
75bb5c6077 | ||
|
|
dedad1c00d | ||
|
|
324915c15c | ||
|
|
13d269f792 | ||
|
|
9d77d75b27 | ||
|
|
684b60cbff | ||
|
|
fa95a607f2 | ||
|
|
b02fdb8264 | ||
|
|
5e04b0f97a | ||
|
|
7677b4ca24 | ||
|
|
d8c4dcb6a4 | ||
|
|
61a539a1b7 | ||
|
|
2e8a089836 | ||
|
|
7109251318 | ||
|
|
abf0ef9cd3 | ||
|
|
f950503b77 | ||
|
|
306cfe42b5 | ||
|
|
11c46893f4 | ||
|
|
c02a556faf | ||
|
|
d8e62793bb | ||
|
|
b07811b01d | ||
|
|
53f8e9de13 | ||
|
|
218bceaa14 | ||
|
|
a410f05a09 | ||
|
|
55d1a2e0e0 | ||
|
|
c8972376cb | ||
|
|
377041cd75 | ||
|
|
d32a7916bd | ||
|
|
2c625f9368 | ||
|
|
5ea41fe40c | ||
|
|
cec1d46b30 | ||
|
|
a8ba87ee90 | ||
|
|
3f821a8888 | ||
|
|
1a3c480155 | ||
|
|
683437fe61 | ||
|
|
095e1a90f5 | ||
|
|
227a07558b | ||
|
|
773e302179 | ||
|
|
ec71b01f71 | ||
|
|
ca9fb36d53 | ||
|
|
1f194f1d55 | ||
|
|
a188d486dd | ||
|
|
a4266be808 | ||
|
|
90c40e9f90 | ||
|
|
b77514b6d9 | ||
|
|
a813219b6b | ||
|
|
4ac1406644 | ||
|
|
4d0e1470df | ||
|
|
6ecae22943 | ||
|
|
2c5ac5c0e2 | ||
|
|
8c309aa3de | ||
|
|
3c89b16fb0 | ||
|
|
ef447c43c7 | ||
|
|
ddb66a71af | ||
|
|
9b1583112a | ||
|
|
865fde8f72 | ||
|
|
ccc8d71461 | ||
|
|
a947464403 | ||
|
|
63803d78f4 | ||
|
|
dcad0256b2 | ||
|
|
12b1a63b84 | ||
|
|
6ea3f30b9b | ||
|
|
660dcf2c94 | ||
|
|
26ab654da2 | ||
|
|
5bc728d480 | ||
|
|
3779853ef9 | ||
|
|
b4ff947206 | ||
|
|
1e464867e7 | ||
|
|
ea9da71f03 | ||
|
|
1dbc246e29 | ||
|
|
41c7256420 | ||
|
|
b7733c48c0 | ||
|
|
50565b05aa | ||
|
|
2e10d87919 | ||
|
|
0ca3fae91a | ||
|
|
308ba59151 | ||
|
|
6ca5907692 | ||
|
|
b9758bf44a | ||
|
|
b923421129 | ||
|
|
c6276d6b19 | ||
|
|
399b41bbdb | ||
|
|
1ce1713139 | ||
|
|
1768995c37 | ||
|
|
ced0e96cf2 | ||
|
|
dd13141903 | ||
|
|
072a5ae4b0 |
@@ -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.
|
||||
|
||||
12
.github/workflows/docker-release.yml
vendored
12
.github/workflows/docker-release.yml
vendored
@@ -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
|
||||
|
||||
123
.github/workflows/npm-telegram-beta-e2e.yml
vendored
123
.github/workflows/npm-telegram-beta-e2e.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
517
.github/workflows/package-acceptance.yml
vendored
Normal 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"
|
||||
@@ -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.
|
||||
|
||||
48
CHANGELOG.md
48
CHANGELOG.md
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2026.4.26"
|
||||
"version": "2026.4.25"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 don’t need long retention.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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" });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw",
|
||||
"version": "2026.4.26",
|
||||
"version": "2026.4.25-beta.11",
|
||||
"description": "Multi-channel AI gateway with extensible messaging integrations",
|
||||
"keywords": [],
|
||||
"homepage": "https://github.com/openclaw/openclaw#readme",
|
||||
|
||||
@@ -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)"
|
||||
|
||||
135
scripts/check-openclaw-package-tarball.mjs
Normal file
135
scripts/check-openclaw-package-tarball.mjs
Normal 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.");
|
||||
@@ -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)." \
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}))})"
|
||||
|
||||
@@ -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
|
||||
|
||||
40
scripts/lib/docker-build.sh
Normal file
40
scripts/lib/docker-build.sh
Normal 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[@]}" "$@"
|
||||
}
|
||||
@@ -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[@]}"
|
||||
}
|
||||
|
||||
64
scripts/lib/docker-e2e-package.sh
Normal file
64
scripts/lib/docker-e2e-package.sh
Normal 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")
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
163
scripts/package-openclaw-for-docker.mjs
Normal file
163
scripts/package-openclaw-for-docker.mjs
Normal 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);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
465
scripts/resolve-openclaw-package-candidate.mjs
Normal file
465
scripts/resolve-openclaw-package-candidate.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}.
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -15,6 +15,8 @@ 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 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 +41,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 +123,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 +295,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 +384,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 +557,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;
|
||||
@@ -488,16 +649,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 +759,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 +779,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 +1029,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 +1079,7 @@ async function runLane(lane, baseEnv, logDir, fallbackTimeoutMs) {
|
||||
logFile,
|
||||
name,
|
||||
elapsedSeconds,
|
||||
rerunCommand: buildLaneRerunCommand(name, baseEnv),
|
||||
status: result.status,
|
||||
timedOut: result.timedOut,
|
||||
};
|
||||
@@ -1077,6 +1291,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 +1332,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 +1371,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 +1419,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 +1449,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,30 +1484,68 @@ 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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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}" \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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")
|
||||
? (
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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)}`);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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-beta.11",
|
||||
generatedAt: "2026-03-22T21:17:33.302Z",
|
||||
};
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}. ` +
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -60,6 +60,8 @@ const BUNDLED_RUNTIME_DEPS_LOCK_WAIT_MS = 100;
|
||||
const BUNDLED_RUNTIME_DEPS_LOCK_TIMEOUT_MS = 5 * 60_000;
|
||||
const BUNDLED_RUNTIME_DEPS_LOCK_STALE_MS = 10 * 60_000;
|
||||
const BUNDLED_RUNTIME_DEPS_OWNERLESS_LOCK_STALE_MS = 30_000;
|
||||
const BUNDLED_RUNTIME_MIRROR_MATERIALIZED_EXTENSIONS = new Set([".cjs", ".js", ".mjs"]);
|
||||
const BUNDLED_RUNTIME_MIRROR_PLUGIN_REGION_RE = /(?:^|\n)\/\/#region extensions\/[^/\s]+(?:\/|$)/u;
|
||||
|
||||
const registeredBundledRuntimeDepNodePaths = new Set<string>();
|
||||
|
||||
@@ -69,6 +71,40 @@ export type BundledRuntimeDepsNpmRunner = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
export function shouldMaterializeBundledRuntimeMirrorDistFile(sourcePath: string): boolean {
|
||||
if (!BUNDLED_RUNTIME_MIRROR_MATERIALIZED_EXTENSIONS.has(path.extname(sourcePath))) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return BUNDLED_RUNTIME_MIRROR_PLUGIN_REGION_RE.test(fs.readFileSync(sourcePath, "utf8"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function materializeBundledRuntimeMirrorDistFile(
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
): void {
|
||||
if (path.resolve(sourcePath) === path.resolve(targetPath)) {
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o755 });
|
||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
try {
|
||||
fs.linkSync(sourcePath, targetPath);
|
||||
return;
|
||||
} catch {
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
}
|
||||
try {
|
||||
const sourceMode = fs.statSync(sourcePath).mode;
|
||||
fs.chmodSync(targetPath, sourceMode | 0o600);
|
||||
} catch {
|
||||
// Readable materialized chunks are enough for ESM loading.
|
||||
}
|
||||
}
|
||||
|
||||
const BUNDLED_RUNTIME_DEP_SEGMENT_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
||||
|
||||
function normalizeInstallableRuntimeDepName(rawName: string): string | null {
|
||||
|
||||
121
src/plugins/bundled-runtime-root.test.ts
Normal file
121
src/plugins/bundled-runtime-root.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
materializeBundledRuntimeMirrorDistFile,
|
||||
resolveBundledRuntimeDependencyInstallRoot,
|
||||
} from "./bundled-runtime-deps.js";
|
||||
import { prepareBundledPluginRuntimeRoot } from "./bundled-runtime-root.js";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
function makeTempRoot(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-runtime-root-"));
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of tempRoots.splice(0)) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("prepareBundledPluginRuntimeRoot", () => {
|
||||
it("does not delete a plugin-owned chunk when the mirror source is already the target", () => {
|
||||
const packageRoot = makeTempRoot();
|
||||
const chunkPath = path.join(packageRoot, "dist", "accounts.js");
|
||||
fs.mkdirSync(path.dirname(chunkPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
chunkPath,
|
||||
[
|
||||
`//#region extensions/telegram/src/accounts.ts`,
|
||||
`export const marker = "accounts";`,
|
||||
`//#endregion`,
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
materializeBundledRuntimeMirrorDistFile(chunkPath, chunkPath);
|
||||
|
||||
expect(fs.readFileSync(chunkPath, "utf8")).toContain(`marker = "accounts"`);
|
||||
});
|
||||
|
||||
it("materializes plugin-owned root chunks in external mirrors", () => {
|
||||
const packageRoot = makeTempRoot();
|
||||
const stageDir = makeTempRoot();
|
||||
const pluginRoot = path.join(packageRoot, "dist", "extensions", "browser");
|
||||
const env = { ...process.env, OPENCLAW_PLUGIN_STAGE_DIR: stageDir };
|
||||
fs.mkdirSync(pluginRoot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(packageRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: "2026.4.24", type: "module" }),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(packageRoot, "dist", "pw-ai.js"),
|
||||
[
|
||||
`//#region extensions/browser/src/pw-ai.ts`,
|
||||
`import { marker } from "playwright-core";`,
|
||||
`export { marker };`,
|
||||
`//#endregion`,
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, "index.js"),
|
||||
`import { marker } from "../../pw-ai.js"; export default { id: "browser", marker };\n`,
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "@openclaw/browser",
|
||||
version: "1.0.0",
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"playwright-core": "1.0.0",
|
||||
},
|
||||
openclaw: { extensions: ["./index.js"] },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env });
|
||||
const depRoot = path.join(installRoot, "node_modules", "playwright-core");
|
||||
fs.mkdirSync(depRoot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(depRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "playwright-core",
|
||||
version: "1.0.0",
|
||||
type: "module",
|
||||
exports: "./index.js",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(depRoot, "index.js"), "export const marker = 'stage-ok';\n", "utf8");
|
||||
|
||||
const staleMirrorChunk = path.join(installRoot, "dist", "pw-ai.js");
|
||||
fs.mkdirSync(path.dirname(staleMirrorChunk), { recursive: true });
|
||||
fs.symlinkSync(path.join(packageRoot, "dist", "pw-ai.js"), staleMirrorChunk, "file");
|
||||
|
||||
const prepared = prepareBundledPluginRuntimeRoot({
|
||||
pluginId: "browser",
|
||||
pluginRoot,
|
||||
modulePath: path.join(pluginRoot, "index.js"),
|
||||
env,
|
||||
});
|
||||
|
||||
expect(prepared.pluginRoot).toBe(path.join(installRoot, "dist", "extensions", "browser"));
|
||||
expect(prepared.modulePath).toBe(path.join(prepared.pluginRoot, "index.js"));
|
||||
expect(fs.lstatSync(staleMirrorChunk).isSymbolicLink()).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user