mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
79 Commits
codex/node
...
v2026.5.22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a374c3a5bf | ||
|
|
89c69c4264 | ||
|
|
df3cadc4ad | ||
|
|
b0e7b0fe39 | ||
|
|
24c7911cfd | ||
|
|
0b2f8dfbdb | ||
|
|
de0cf73b0c | ||
|
|
199bfe580d | ||
|
|
8a22b33d44 | ||
|
|
75b5c76c7f | ||
|
|
8ac7cd621b | ||
|
|
57b956fd7c | ||
|
|
ebab6d1cbe | ||
|
|
5b2989cbb5 | ||
|
|
71f7f6f9df | ||
|
|
cefea04b9e | ||
|
|
098bdb2bf6 | ||
|
|
555cc66a37 | ||
|
|
88fac84a18 | ||
|
|
e03a93b1a3 | ||
|
|
12d760c1b0 | ||
|
|
f6e9aae227 | ||
|
|
957b874385 | ||
|
|
9d7034adf5 | ||
|
|
690cb199d9 | ||
|
|
b5f13e5611 | ||
|
|
f63a653137 | ||
|
|
7fc349418e | ||
|
|
cb26ca8e7c | ||
|
|
2378e3bccc | ||
|
|
2bc3befafc | ||
|
|
062ccd4553 | ||
|
|
a0d33de9bc | ||
|
|
c3ad9b26b1 | ||
|
|
0bab377d7a | ||
|
|
df3932441d | ||
|
|
02bd14ce30 | ||
|
|
1102579ef3 | ||
|
|
3794cdcb6a | ||
|
|
1ea7741136 | ||
|
|
052625f827 | ||
|
|
1ca1f8a399 | ||
|
|
5ecd86b149 | ||
|
|
8d504e5220 | ||
|
|
269ef1bf9d | ||
|
|
8f9d5860a9 | ||
|
|
5067a84d9d | ||
|
|
b0153953b4 | ||
|
|
6b31e1e365 | ||
|
|
2ba346a8eb | ||
|
|
084b917cac | ||
|
|
e855317280 | ||
|
|
12e72678fa | ||
|
|
521c91d6b1 | ||
|
|
b2e84b9028 | ||
|
|
8d5ec6bc9f | ||
|
|
35e8fa2169 | ||
|
|
3e472acf71 | ||
|
|
5f0e3dfbc3 | ||
|
|
419f4a6325 | ||
|
|
0f29e9e293 | ||
|
|
45e3558230 | ||
|
|
7441f32673 | ||
|
|
93050c7aab | ||
|
|
1a37882504 | ||
|
|
8fe3825ac8 | ||
|
|
54da73d11c | ||
|
|
421d274991 | ||
|
|
8843b7e9b6 | ||
|
|
8f3eed9343 | ||
|
|
b4980b3520 | ||
|
|
b53921e429 | ||
|
|
d552f66483 | ||
|
|
de2fc740c0 | ||
|
|
615faa16a5 | ||
|
|
6cf6cc9e2d | ||
|
|
4c397d13d4 | ||
|
|
9c63ab305b | ||
|
|
490c39c870 |
@@ -22,6 +22,17 @@ Use this skill for release and publish-time workflow. Keep ordinary development
|
||||
- Before release branching, pull latest `main` and confirm current `main` CI is
|
||||
green. Then branch from that commit so regular development can continue on
|
||||
`main` while release validation runs.
|
||||
- After the release branch or release tag exists, treat its base commit as
|
||||
frozen for that release attempt. Do not autonomously pull `main`, rebase the
|
||||
release branch, merge `main`, or move the release baseline just because the
|
||||
operator previously said "rebase" or because `main` advanced. A new rebase
|
||||
needs an explicit, current instruction that names rebasing the active release
|
||||
branch.
|
||||
- When a release is blocked by a failing test, first fix the release branch in
|
||||
place. If `main` already has a specific commit that directly fixes that exact
|
||||
blocker, cherry-pick only that targeted fix after confirming the diff is
|
||||
narrow and explaining why it matches the failure. Do not use the blocker as a
|
||||
reason to rebase onto all of `main`.
|
||||
- Before release branching, commit any dirty files in coherent groups, push,
|
||||
pull/rebase, then run `/changelog` on `main` and commit/push/pull that
|
||||
changelog rewrite immediately before creating the release branch.
|
||||
|
||||
64
.github/workflows/ci.yml
vendored
64
.github/workflows/ci.yml
vendored
@@ -81,7 +81,7 @@ jobs:
|
||||
ref: ${{ inputs.target_ref || github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
|
||||
- name: Resolve checkout SHA
|
||||
@@ -304,7 +304,7 @@ jobs:
|
||||
ref: ${{ inputs.target_ref || github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
|
||||
- name: Ensure security base commit
|
||||
@@ -416,8 +416,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
@@ -429,12 +427,11 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
@@ -627,8 +624,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
@@ -640,12 +635,11 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
@@ -717,8 +711,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
@@ -730,12 +722,11 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
@@ -801,8 +792,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
@@ -814,12 +803,11 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
@@ -882,8 +870,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
@@ -895,12 +881,11 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
@@ -961,8 +946,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
@@ -974,12 +957,11 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
@@ -1087,8 +1069,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
@@ -1100,12 +1080,11 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
@@ -1221,8 +1200,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
@@ -1234,12 +1211,11 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
@@ -1374,8 +1350,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
@@ -1387,12 +1361,11 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
@@ -1423,7 +1396,7 @@ jobs:
|
||||
repository: openclaw/clawhub
|
||||
path: clawhub-source
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
|
||||
- name: Check docs
|
||||
env:
|
||||
@@ -1442,7 +1415,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
|
||||
- name: Setup Python
|
||||
@@ -1485,7 +1458,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
|
||||
- name: Try to exclude workspace from Windows Defender (best-effort)
|
||||
@@ -1578,7 +1551,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -1619,7 +1592,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
|
||||
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
||||
@@ -1725,8 +1698,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
@@ -1738,12 +1709,11 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
|
||||
184
.github/workflows/full-release-validation.yml
vendored
184
.github/workflows/full-release-validation.yml
vendored
@@ -134,7 +134,7 @@ jobs:
|
||||
ref: ${{ github.ref_name }}
|
||||
path: workflow
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
|
||||
- name: Resolve target SHA
|
||||
@@ -232,7 +232,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.resolve_target.outputs.sha }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
|
||||
- name: Verify Docker runtime-assets prune path
|
||||
env:
|
||||
@@ -270,9 +270,31 @@ jobs:
|
||||
shift
|
||||
|
||||
local before_json dispatch_output run_id status conclusion url poll_count
|
||||
before_json="$(gh run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
gh_with_retry() {
|
||||
local output status attempt
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
set +e
|
||||
output="$(gh "$@" 2>&1)"
|
||||
status=$?
|
||||
set -e
|
||||
if [[ "$status" -eq 0 ]]; then
|
||||
printf '%s\n' "$output"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* ]]; then
|
||||
echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2
|
||||
sleep $((attempt * 10))
|
||||
continue
|
||||
fi
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
done
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
}
|
||||
before_json="$(gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
|
||||
dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
|
||||
dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")"
|
||||
printf '%s\n' "$dispatch_output"
|
||||
run_id="$(
|
||||
printf '%s\n' "$dispatch_output" |
|
||||
@@ -283,7 +305,7 @@ jobs:
|
||||
if [[ -z "$run_id" ]]; then
|
||||
for _ in $(seq 1 60); do
|
||||
run_id="$(
|
||||
BEFORE_IDS="$before_json" gh run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
BEFORE_IDS="$before_json" gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
@@ -301,6 +323,14 @@ jobs:
|
||||
echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
echo "run_id=${run_id}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
fetch_child_run_json() {
|
||||
gh_with_retry api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
}
|
||||
|
||||
fetch_child_jobs() {
|
||||
gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]'
|
||||
}
|
||||
|
||||
cancel_child() {
|
||||
if [[ -n "${run_id:-}" ]]; then
|
||||
echo "Cancelling child workflow ${workflow}: ${run_id}" >&2
|
||||
@@ -311,26 +341,26 @@ jobs:
|
||||
|
||||
poll_count=0
|
||||
while true; do
|
||||
status="$(gh run view "$run_id" --json status --jq '.status')"
|
||||
status="$(fetch_child_run_json | jq -r '.status')"
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
break
|
||||
fi
|
||||
poll_count=$((poll_count + 1))
|
||||
if (( poll_count % 10 == 0 )); then
|
||||
echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true
|
||||
fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
trap - EXIT INT TERM
|
||||
|
||||
conclusion="$(gh run view "$run_id" --json conclusion --jq '.conclusion')"
|
||||
url="$(gh run view "$run_id" --json url --jq '.url')"
|
||||
conclusion="$(fetch_child_run_json | jq -r '.conclusion // ""')"
|
||||
url="$(fetch_child_run_json | jq -r '.html_url')"
|
||||
echo "${workflow} finished with ${conclusion}: ${url}"
|
||||
echo "url=${url}" >> "$GITHUB_OUTPUT"
|
||||
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$conclusion" != "success" ]]; then
|
||||
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
|
||||
fetch_child_jobs | jq 'select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url: .html_url}' || true
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@@ -370,9 +400,31 @@ jobs:
|
||||
shift
|
||||
|
||||
local before_json dispatch_output run_id status conclusion url poll_count
|
||||
before_json="$(gh run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
gh_with_retry() {
|
||||
local output status attempt
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
set +e
|
||||
output="$(gh "$@" 2>&1)"
|
||||
status=$?
|
||||
set -e
|
||||
if [[ "$status" -eq 0 ]]; then
|
||||
printf '%s\n' "$output"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* ]]; then
|
||||
echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2
|
||||
sleep $((attempt * 10))
|
||||
continue
|
||||
fi
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
done
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
}
|
||||
before_json="$(gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
|
||||
dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
|
||||
dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")"
|
||||
printf '%s\n' "$dispatch_output"
|
||||
run_id="$(
|
||||
printf '%s\n' "$dispatch_output" |
|
||||
@@ -383,7 +435,7 @@ jobs:
|
||||
if [[ -z "$run_id" ]]; then
|
||||
for _ in $(seq 1 60); do
|
||||
run_id="$(
|
||||
BEFORE_IDS="$before_json" gh run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
BEFORE_IDS="$before_json" gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
@@ -401,6 +453,14 @@ jobs:
|
||||
echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
echo "run_id=${run_id}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
fetch_child_run_json() {
|
||||
gh_with_retry api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
}
|
||||
|
||||
fetch_child_jobs() {
|
||||
gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]'
|
||||
}
|
||||
|
||||
cancel_child() {
|
||||
if [[ -n "${run_id:-}" ]]; then
|
||||
echo "Cancelling child workflow ${workflow}: ${run_id}" >&2
|
||||
@@ -411,26 +471,26 @@ jobs:
|
||||
|
||||
poll_count=0
|
||||
while true; do
|
||||
status="$(gh run view "$run_id" --json status --jq '.status')"
|
||||
status="$(fetch_child_run_json | jq -r '.status')"
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
break
|
||||
fi
|
||||
poll_count=$((poll_count + 1))
|
||||
if (( poll_count % 10 == 0 )); then
|
||||
echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true
|
||||
fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
trap - EXIT INT TERM
|
||||
|
||||
conclusion="$(gh run view "$run_id" --json conclusion --jq '.conclusion')"
|
||||
url="$(gh run view "$run_id" --json url --jq '.url')"
|
||||
conclusion="$(fetch_child_run_json | jq -r '.conclusion // ""')"
|
||||
url="$(fetch_child_run_json | jq -r '.html_url')"
|
||||
echo "${workflow} finished with ${conclusion}: ${url}"
|
||||
echo "url=${url}" >> "$GITHUB_OUTPUT"
|
||||
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$conclusion" != "success" ]]; then
|
||||
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
|
||||
fetch_child_jobs | jq 'select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url: .html_url}' || true
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@@ -480,9 +540,31 @@ jobs:
|
||||
shift
|
||||
|
||||
local before_json dispatch_output run_id status conclusion url poll_count run_json
|
||||
before_json="$(gh run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
gh_with_retry() {
|
||||
local output status attempt
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
set +e
|
||||
output="$(gh "$@" 2>&1)"
|
||||
status=$?
|
||||
set -e
|
||||
if [[ "$status" -eq 0 ]]; then
|
||||
printf '%s\n' "$output"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* ]]; then
|
||||
echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2
|
||||
sleep $((attempt * 10))
|
||||
continue
|
||||
fi
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
done
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
}
|
||||
before_json="$(gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
|
||||
dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
|
||||
dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")"
|
||||
printf '%s\n' "$dispatch_output"
|
||||
run_id="$(
|
||||
printf '%s\n' "$dispatch_output" |
|
||||
@@ -493,7 +575,7 @@ jobs:
|
||||
if [[ -z "$run_id" ]]; then
|
||||
for _ in $(seq 1 60); do
|
||||
run_id="$(
|
||||
BEFORE_IDS="$before_json" gh run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
BEFORE_IDS="$before_json" gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
@@ -511,6 +593,14 @@ jobs:
|
||||
echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
echo "run_id=${run_id}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
fetch_child_run_json() {
|
||||
gh_with_retry api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
}
|
||||
|
||||
fetch_child_jobs() {
|
||||
gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]'
|
||||
}
|
||||
|
||||
release_check_blocking_job() {
|
||||
case "$1" in
|
||||
"resolve_target" | \
|
||||
@@ -561,20 +651,25 @@ jobs:
|
||||
|
||||
poll_count=0
|
||||
while true; do
|
||||
status="$(gh run view "$run_id" --json status --jq '.status')"
|
||||
status="$(fetch_child_run_json | jq -r '.status')"
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
break
|
||||
fi
|
||||
poll_count=$((poll_count + 1))
|
||||
if (( poll_count % 10 == 0 )); then
|
||||
echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true
|
||||
fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
trap - EXIT INT TERM
|
||||
|
||||
run_json="$(gh run view "$run_id" --json conclusion,url,jobs)"
|
||||
jobs_json="$(fetch_child_jobs | jq -s '{jobs: [.[] | {name, conclusion, url: .html_url}]}')"
|
||||
run_json="$(
|
||||
jq -s '.[0] + .[1]' \
|
||||
<(fetch_child_run_json | jq '{conclusion: (.conclusion // ""), url: .html_url}') \
|
||||
<(printf '%s\n' "$jobs_json")
|
||||
)"
|
||||
conclusion="$(jq -r '.conclusion' <<< "$run_json")"
|
||||
url="$(jq -r '.url' <<< "$run_json")"
|
||||
echo "${workflow} finished with ${conclusion}: ${url}"
|
||||
@@ -669,7 +764,7 @@ jobs:
|
||||
- name: Checkout trusted workflow ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
ref: ${{ github.ref_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -747,7 +842,30 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
before_json="$(gh run list --workflow npm-telegram-beta-e2e.yml --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
gh_with_retry() {
|
||||
local output status attempt
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
set +e
|
||||
output="$(gh "$@" 2>&1)"
|
||||
status=$?
|
||||
set -e
|
||||
if [[ "$status" -eq 0 ]]; then
|
||||
printf '%s\n' "$output"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* ]]; then
|
||||
echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2
|
||||
sleep $((attempt * 10))
|
||||
continue
|
||||
fi
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
done
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
}
|
||||
|
||||
before_json="$(gh_with_retry run list --workflow npm-telegram-beta-e2e.yml --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
|
||||
args=(-f package_spec="${PACKAGE_SPEC:-openclaw@beta}" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE")
|
||||
if [[ -z "${PACKAGE_SPEC// }" ]]; then
|
||||
@@ -765,12 +883,12 @@ jobs:
|
||||
args+=(-f scenario="$SCENARIO")
|
||||
fi
|
||||
|
||||
gh workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}"
|
||||
gh_with_retry workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}"
|
||||
|
||||
run_id=""
|
||||
for _ in $(seq 1 60); do
|
||||
run_id="$(
|
||||
BEFORE_IDS="$before_json" gh run list --workflow npm-telegram-beta-e2e.yml --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
BEFORE_IDS="$before_json" gh_with_retry run list --workflow npm-telegram-beta-e2e.yml --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
@@ -797,26 +915,26 @@ jobs:
|
||||
|
||||
poll_count=0
|
||||
while true; do
|
||||
status="$(gh run view "$run_id" --json status --jq '.status')"
|
||||
status="$(gh_with_retry run view "$run_id" --json status --jq '.status')"
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
break
|
||||
fi
|
||||
poll_count=$((poll_count + 1))
|
||||
if (( poll_count % 10 == 0 )); then
|
||||
echo "Still waiting on npm-telegram-beta-e2e.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true
|
||||
gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
trap - EXIT INT TERM
|
||||
|
||||
conclusion="$(gh run view "$run_id" --json conclusion --jq '.conclusion')"
|
||||
url="$(gh run view "$run_id" --json url --jq '.url')"
|
||||
conclusion="$(gh_with_retry run view "$run_id" --json conclusion --jq '.conclusion')"
|
||||
url="$(gh_with_retry run view "$run_id" --json url --jq '.url')"
|
||||
echo "npm-telegram-beta-e2e.yml finished with ${conclusion}: ${url}"
|
||||
echo "url=${url}" >> "$GITHUB_OUTPUT"
|
||||
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$conclusion" != "success" ]]; then
|
||||
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
|
||||
gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
7
.github/workflows/install-smoke.yml
vendored
7
.github/workflows/install-smoke.yml
vendored
@@ -109,6 +109,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
@@ -219,6 +220,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
@@ -290,6 +292,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run QR package install smoke
|
||||
env:
|
||||
@@ -305,6 +308,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
@@ -410,6 +414,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
@@ -477,6 +482,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
@@ -515,6 +521,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
|
||||
@@ -338,7 +338,7 @@ jobs:
|
||||
ref: ${{ steps.workflow_ref.outputs.value }}
|
||||
path: workflow
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
|
||||
- name: Checkout public source ref
|
||||
if: inputs.candidate_artifact_name == ''
|
||||
@@ -348,7 +348,7 @@ jobs:
|
||||
ref: ${{ inputs.ref }}
|
||||
path: source
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Node.js
|
||||
@@ -537,7 +537,7 @@ jobs:
|
||||
ref: ${{ needs.prepare.outputs.workflow_ref }}
|
||||
path: workflow
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -756,6 +756,7 @@ jobs:
|
||||
if: contains(matrix.profiles, inputs.release_test_profile)
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -763,17 +764,17 @@ jobs:
|
||||
if: contains(matrix.profiles, inputs.release_test_profile)
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
path: .release-harness
|
||||
|
||||
- name: Log in to GHCR for shared Docker E2E image
|
||||
if: contains(matrix.profiles, inputs.release_test_profile)
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
run: bash .release-harness/scripts/ci-docker-login-ghcr.sh
|
||||
env:
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Setup Node environment
|
||||
if: contains(matrix.profiles, inputs.release_test_profile)
|
||||
@@ -905,6 +906,7 @@ jobs:
|
||||
- name: Checkout trusted release harness
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -995,22 +997,23 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Checkout trusted release harness
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
path: .release-harness
|
||||
|
||||
- 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 }}
|
||||
run: bash .release-harness/scripts/ci-docker-login-ghcr.sh
|
||||
env:
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
@@ -1162,11 +1165,10 @@ jobs:
|
||||
path: .release-harness
|
||||
|
||||
- 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 }}
|
||||
run: bash .release-harness/scripts/ci-docker-login-ghcr.sh
|
||||
env:
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
@@ -1421,11 +1423,10 @@ jobs:
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: steps.plan.outputs.needs_e2e_image == '1'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
run: bash .release-harness/scripts/ci-docker-login-ghcr.sh
|
||||
env:
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Check existing shared Docker E2E images
|
||||
id: image_exists
|
||||
@@ -1536,11 +1537,10 @@ jobs:
|
||||
echo "Shared live-test image: \`${live_image}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
run: bash scripts/ci-docker-login-ghcr.sh
|
||||
env:
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Check existing shared live-test image
|
||||
id: image_exists
|
||||
@@ -1682,11 +1682,10 @@ jobs:
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: contains(matrix.profiles, inputs.release_test_profile)
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
run: bash .release-harness/scripts/ci-docker-login-ghcr.sh
|
||||
env:
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Validate provider credential
|
||||
if: contains(matrix.profiles, inputs.release_test_profile)
|
||||
@@ -1857,11 +1856,10 @@ jobs:
|
||||
run: bash scripts/ci-hydrate-live-auth.sh
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
run: bash .release-harness/scripts/ci-docker-login-ghcr.sh
|
||||
env:
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Validate provider credentials
|
||||
shell: bash
|
||||
@@ -2386,11 +2384,10 @@ jobs:
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || (inputs.live_suite_filter == 'live-gateway-advisory-docker' && startsWith(matrix.suite_id, 'live-gateway-advisory-docker-')))
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
run: bash .release-harness/scripts/ci-docker-login-ghcr.sh
|
||||
env:
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Configure suite-specific env
|
||||
if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || (inputs.live_suite_filter == 'live-gateway-advisory-docker' && startsWith(matrix.suite_id, 'live-gateway-advisory-docker-')))
|
||||
|
||||
15
.github/workflows/openclaw-npm-release.yml
vendored
15
.github/workflows/openclaw-npm-release.yml
vendored
@@ -35,7 +35,7 @@ on:
|
||||
- latest
|
||||
|
||||
concurrency:
|
||||
group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', inputs.tag, inputs.npm_dist_tag) || github.ref }}
|
||||
group: ${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only && format('openclaw-npm-release-{0}-{1}-preflight', inputs.tag, inputs.npm_dist_tag) || github.event_name == 'workflow_dispatch' && format('openclaw-npm-release-{0}-{1}-publish-{2}', inputs.tag, inputs.npm_dist_tag, github.run_id) || format('openclaw-npm-release-{0}', github.ref) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only && inputs.npm_dist_tag == 'alpha' }}
|
||||
|
||||
env:
|
||||
@@ -390,6 +390,8 @@ jobs:
|
||||
|
||||
- name: Require preflight artifact promotion on real publish
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
@@ -400,8 +402,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${FULL_RELEASE_VALIDATION_RUN_ID}" ]]; then
|
||||
echo "Real publish requires full_release_validation_run_id from a successful Full Release Validation run." >&2
|
||||
exit 1
|
||||
if [[ "${RELEASE_TAG}" == *"-beta."* && "${RELEASE_NPM_DIST_TAG}" == "beta" ]]; then
|
||||
echo "::warning::Beta publish is proceeding from npm preflight only; full release validation remains required before stable/latest promotion."
|
||||
else
|
||||
echo "Real publish requires full_release_validation_run_id from a successful Full Release Validation run." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" && "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
|
||||
echo "Workflow-dispatched real publish requires release_publish_run_id from the approved OpenClaw Release Publish workflow." >&2
|
||||
@@ -518,6 +524,7 @@ jobs:
|
||||
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw NPM Release"], ["headBranch", process.env.EXPECTED_PREFLIGHT_BRANCH], ["event", "workflow_dispatch"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced npm preflight run ${process.env.PREFLIGHT_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } console.log(`Using npm preflight run ${process.env.PREFLIGHT_RUN_ID}: ${run.url}`);'
|
||||
|
||||
- name: Verify full release validation run metadata
|
||||
if: ${{ inputs.full_release_validation_run_id != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
|
||||
@@ -581,6 +588,7 @@ jobs:
|
||||
download_preflight_artifact
|
||||
|
||||
- name: Download full release validation manifest
|
||||
if: ${{ inputs.full_release_validation_run_id != '' }}
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: full-release-validation-${{ inputs.full_release_validation_run_id }}
|
||||
@@ -646,6 +654,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Verify full release validation target
|
||||
if: ${{ inputs.full_release_validation_run_id != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
EXPECTED_RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
|
||||
46
.github/workflows/openclaw-release-checks.yml
vendored
46
.github/workflows/openclaw-release-checks.yml
vendored
@@ -191,11 +191,21 @@ jobs:
|
||||
working-directory: source
|
||||
env:
|
||||
RELEASE_REF: ${{ inputs.ref }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SELECTED_SHA="$(git rev-parse HEAD)"
|
||||
git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'
|
||||
git fetch --tags origin '+refs/tags/*:refs/tags/*'
|
||||
git_fetch_with_checkout_auth() {
|
||||
if git config --get-all http.https://github.com/.extraheader >/dev/null; then
|
||||
git fetch "$@"
|
||||
return
|
||||
fi
|
||||
local auth_header
|
||||
auth_header="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')"
|
||||
git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" fetch "$@"
|
||||
}
|
||||
git_fetch_with_checkout_auth --no-tags origin '+refs/heads/*:refs/remotes/origin/*'
|
||||
git_fetch_with_checkout_auth --tags origin '+refs/tags/*:refs/tags/*'
|
||||
|
||||
if git tag --points-at "${SELECTED_SHA}" | grep -Eq '^v'; then
|
||||
exit 0
|
||||
@@ -238,6 +248,7 @@ jobs:
|
||||
env:
|
||||
SELECTED_SHA: ${{ steps.ref.outputs.sha }}
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
@@ -245,7 +256,16 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
alpha_branch="${WORKFLOW_REF#refs/heads/}"
|
||||
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
|
||||
git_fetch_with_checkout_auth() {
|
||||
if git config --get-all http.https://github.com/.extraheader >/dev/null; then
|
||||
git fetch "$@"
|
||||
return
|
||||
fi
|
||||
local auth_header
|
||||
auth_header="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')"
|
||||
git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" fetch "$@"
|
||||
}
|
||||
git_fetch_with_checkout_auth --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
|
||||
if ! git merge-base --is-ancestor "${SELECTED_SHA}" "refs/remotes/origin/${alpha_branch}"; then
|
||||
echo "Alpha release target ${SELECTED_SHA} must be reachable from ${alpha_branch}." >&2
|
||||
exit 1
|
||||
@@ -474,7 +494,7 @@ jobs:
|
||||
- name: Checkout trusted workflow ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
ref: ${{ github.ref_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -763,7 +783,7 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -834,7 +854,7 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -899,7 +919,7 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -1014,7 +1034,7 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -1066,7 +1086,7 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -1145,7 +1165,7 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -1240,7 +1260,7 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -1338,7 +1358,7 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -1433,7 +1453,7 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
5
.github/workflows/package-acceptance.yml
vendored
5
.github/workflows/package-acceptance.yml
vendored
@@ -541,6 +541,11 @@ jobs:
|
||||
docker_acceptance:
|
||||
name: Docker product acceptance
|
||||
needs: [resolve_package, package_integrity]
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: write
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
|
||||
with:
|
||||
advisory: ${{ inputs.advisory }}
|
||||
|
||||
10
.github/workflows/plugin-prerelease.yml
vendored
10
.github/workflows/plugin-prerelease.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
||||
ref: ${{ inputs.target_ref }}
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
|
||||
- name: Build plugin prerelease manifest
|
||||
@@ -221,7 +221,7 @@ jobs:
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -330,7 +330,7 @@ jobs:
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -362,7 +362,7 @@ jobs:
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
|
||||
46
CHANGELOG.md
46
CHANGELOG.md
@@ -6,6 +6,13 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Gateway/perf: reuse process-stable channel catalog reads, avoid repeated bundled-channel boundary checks, and rotate gateway watch CPU profiles so benchmark runs do not accumulate unbounded artifacts.
|
||||
- Gateway/perf: reuse immutable plugin metadata snapshots across startup, config, model, channel, setup, and secret metadata readers so hot paths avoid repeated plugin file stats and manifest registry reloads.
|
||||
- Gateway/perf: lazy-load startup-idle plugin work, core gateway method handlers, and the embedded ACPX runtime so Gateway health and ready signals no longer wait on unused handler trees or ACPX probes.
|
||||
- Gateway/perf: cache plugin SDK public-surface alias maps and skip irrelevant macOS Linuxbrew PATH probes so Gateway startup avoids repeated filesystem walks and slow missing-directory stats.
|
||||
- Meeting Notes: add a source-only external meeting-notes plugin and SDK source-provider contract outside the core npm package, with auto-start capture config, manual transcript imports, read-only `openclaw meeting-notes` CLI access, and Discord voice as the first live source.
|
||||
- Docs/channels/config: add Signal `configPath`, Telegram wildcard topic defaults, local-time backup archive names, Termux home fallback, include-path validation, secret-scanner-safe placeholder guidance, Gemini CLI/Antigravity media guidance, and macOS VM auto-login guidance. Thanks @NorseGaud, @yudistiraashadi, @huangqian8, @VibhorGautam, @maweibin, @tianxingleo, @IgnacioPro, and @xzcxzcyy-claw.
|
||||
- Docs: clarify model-usage portability, Codex migration prerequisites, status bootstrap wording, thread-bound subagent limits, hook ownership, and config-preserving safety guidance. Thanks @aniruddhaadak80, @leno23, @TomDjerry, @matthewxmurphy, @vincentkoc, and @stablegenius49.
|
||||
- Docs: clarify README onboarding and Gateway startup paths, WhatsApp QR/408 recovery, cron output language prompts, skill advanced features, gateway upstream 403 troubleshooting, and plugin fallback override guidance. Thanks @deepujain, @Zacxxx, @Jah-yee, @neyric, @usimic, @Renu-Cybe, @BigUncle, and @SeashoreShi.
|
||||
- Docs: clarify context-pruning ratio bounds, local dashboard recovery, CLI env markers, remote onboarding token behavior, and Peekaboo Bridge permissions for subprocess agents. Thanks @ayesha-aziz123, @dishraters, @hougangdev, and @brandonlipman.
|
||||
- Docs: clarify browser CDP diagnostics, Plugin SDK allowlist imports, status-reaction timing defaults, queue steering behavior, limited-tool troubleshooting, cron HEARTBEAT handling, Telegram multi-agent groups, Bitwarden SecretRef setup, and EasyRunner deployments. Thanks @Quratulain-bilal, @mbelinky, @Mickey-, @vancece, @xenouzik, @posigit, @surlymochan, @janaka, and @choiking.
|
||||
@@ -48,6 +55,45 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- WebChat: summarize internal message-tool source replies so tool cards no longer duplicate the visible reply body. (#84773) Thanks @jason-allen-oneal.
|
||||
- Gateway: preserve deferred lifecycle-error cleanup across later non-terminal events so provider timeouts can persist failed session state instead of leaving sessions stuck running. (#85256, fixes #63819) Thanks @samzong.
|
||||
- Agents/subagents: report tool-only child progress during timeout summaries instead of showing no visible output.
|
||||
- Telegram/ACP: preserve explicit `:topic:` conversation suffixes when inbound ACP targets do not carry a separate thread id.
|
||||
- Browser/proxy: bypass the managed proxy for the exact local managed Chrome CDP readiness and DevTools WebSocket endpoints, so `openclaw browser start` works when the operator proxy blocks loopback egress. (#83255) Thanks @lightcap.
|
||||
- Ollama: bypass the managed proxy for configured local embedding origins while keeping SSRF guardrails on unconfigured targets. Thanks @Kaspre.
|
||||
- OpenAI/images: route Codex API-key image generation through the native OpenAI Images API instead of the Codex OAuth streaming backend, avoiding 401s from valid API keys.
|
||||
- Agents/OpenAI completions: omit empty tool payload fields for proxy-like OpenAI-compatible endpoints so strict vLLM-style servers accept tool-free turns. (#85835) Thanks @rendrag-git.
|
||||
- Checks/Windows: route full `pnpm check` stage commands through the managed child runner so Windows avoids Node shell-argv deprecation warnings there too.
|
||||
- Checks/Windows: run managed child commands through explicit `cmd.exe` wrapping instead of Node shell mode with argv, avoiding Node 24 subprocess deprecation warnings during changed checks.
|
||||
- Gateway: omit internal stream-error placeholder entries from agent prompt history so failed assistant turns are not replayed as model-authored text. (#85652) Thanks @anyech.
|
||||
- Sessions: enforce the session write-lock max-hold policy during lock acquisition so long-held locks can be reclaimed before the stale-lock window. (#85764) Thanks @njuboy11.
|
||||
- Models: prune retired Groq, GitHub Copilot, OpenAI, xAI, and old Claude catalog entries, with doctor migration to upgrade existing configs to current provider refs.
|
||||
- Doctor/update: recognize junction-backed source checkouts as git installs by comparing canonical paths before showing package-manager update guidance. Fixes #82215. Thanks @igormf.
|
||||
- Channels: honor `/verbose on` for tool/progress summaries across direct chats, groups, channels, and forum topics while preserving quiet default behavior. (#85488) Thanks @kurplunkin.
|
||||
- CLI/skills: show an all-ready note with next-step commands when skill setup has no missing dependencies to install. (#85032) Thanks @aniruddhaadak80.
|
||||
- Microsoft Foundry: route DeepSeek V4 Pro and Flash models through the Foundry Responses API while keeping older DeepSeek models on their existing path. (#85549) Thanks @roslinmahmud.
|
||||
- Status/usage: show configured cost estimates for AWS SDK models in full usage output while keeping token-only usage replies cost-free. (#85619) Thanks @ItsOtherMauridian.
|
||||
- Agents/OpenAI Responses: retry non-visible reasoning-only turns for OpenAI Responses API families instead of treating them as empty failed turns. (#85603) Thanks @SebTardif.
|
||||
- Directive tags: preserve message and content-part object identity when display stripping makes no directive-tag changes. (#85682) Thanks @willamhou.
|
||||
- Telegram: send local `path`/`filePath` and structured attachment media from `sendMessage` actions instead of dropping them or sending text-only messages. (#85219) Thanks @keshavbotagent.
|
||||
- Sessions/status: show the estimated context budget when fresh provider usage is unavailable and clear stale estimates across session resets and compaction boundaries. (#84830) Thanks @giodl73-repo.
|
||||
- Gateway/config: pin relative `OPENCLAW_STATE_DIR` overrides to an absolute path at startup so later working-directory changes cannot retarget gateway state. (#52264) Thanks @PerfectPan.
|
||||
- Release/package: run npm release, prepublish, and postpublish verification through Windows-safe npm command shims so native Windows checks can execute `npm.cmd` instead of treating it as a binary.
|
||||
- Agents/harness: pass CLI runtime aliases through harness selection so provider-owned CLI aliases no longer get rejected before reaching the right runtime. (#85631) Thanks @potterdigital.
|
||||
- Secrets: show the irreversible apply warning after interactive `secrets configure` confirmation so confirmed migrations still get the final safety prompt. (#85638) Thanks @alkor2000.
|
||||
- Agents/CLI output: ignore cumulative Claude `stream-json` result usage when assistant usage events are present, preventing inflated cache-read accounting. (#85625) Thanks @zhouhe-xydt.
|
||||
- CLI: keep `waitForever()` alive by leaving its keep-alive interval ref'd so the public helper no longer exits immediately with Node's unsettled-await code. (#85694) Thanks @m1qaweb.
|
||||
- Agents/bootstrap: guard bootstrap name checks against missing file names so malformed bootstrap entries warn and truncate instead of crashing. Fixes #85523. (#85615) Thanks @zhouhe-xydt.
|
||||
- CLI/tasks: reject partially numeric `openclaw tasks audit --limit` values so audit limits must be real positive integers instead of accepting strings like `5abc`. (#84901) Thanks @jbetala7.
|
||||
- Status/diagnostics: bound deep Docker audit probes so `openclaw status --deep` reports slow container checks instead of hanging behind unbounded inspection. (#85476) Thanks @giodl73-repo.
|
||||
- Providers/Anthropic: migrate 1M context handling to GA-capable Claude 4.x models by sizing eligible models at 1M without the retired `context-1m-2025-08-07` beta, ignoring that retired beta in older configs, and preserving OAuth-required Anthropic beta headers. (#45613) Thanks @haoyu-haoyu.
|
||||
- Cron/Telegram: parse forum-topic delivery targets through the Telegram plugin instead of cron core, including `:topic:` and `:topicId` forms for announce delivery. Thanks @etticat.
|
||||
- Twitch: keep stale message-handler cleanup callbacks from removing newer handler registrations for the same account, preserving inbound message delivery after reconnects. Fixes #83888. (#85425) Thanks @alkor2000.
|
||||
- Memory/LanceDB: expose public memory artifacts through the active memory provider bridge so memory-wiki imports durable memory files, daily notes, dream reports, and event logs without depending on memory-core internals. Fixes #83604. (#85060) Thanks @brokemac79.
|
||||
- Crabbox: keep AWS hydration compatible with local Actions replay by inlining the hydrate workflow's Node/pnpm setup instead of invoking repo-local composite actions.
|
||||
- Agents/subagents: simplify native sub-agent completion handoff so children report their latest visible assistant result to the requester without using `message`, while keeping parent-owned message-tool delivery policy intact. Fixes #85070. (#85089) Thanks @brokemac79.
|
||||
- Docker setup: stop printing the Gateway bearer token in setup logs and printed follow-up commands.
|
||||
- Agents: let embedded compaction fallback retries proceed when PI-compatible candidates do not need agent harness plugin preparation.
|
||||
- Agents/tools: honor configured custom provider API keys when deciding whether media, image-generation, video-generation, music-generation, and PDF tools are available. (#85570)
|
||||
- StepFun: stop advertising stale generic API key auth choices so onboarding only offers runtime-backed Standard and Step Plan choices.
|
||||
- Diagnostics: keep OpenTelemetry log bodies behind explicit content capture and scrub scoped agent-session keys from OpenTelemetry and Prometheus labels while preserving bounded queue-lane prefixes.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
5482b1a125a5c41856f6f49dfd70e2efe9e52a7cc0e2d4c24a56d99adfeda6be config-baseline.json
|
||||
3d686075da4d4f6c6319c3247e93f486a6c48314a28a2961cd4acab7f3fa5389 config-baseline.core.json
|
||||
11839c7a1b858c66075156f0e203aa8367cd8321047684679a18e18b7c8fe1f7 config-baseline.channel.json
|
||||
5c214ab364011fd95735755f9fa4298aa4de8ad81144ae8dd08d969bb7ba318b config-baseline.plugin.json
|
||||
fdf49e9b06dc3baa556d42d46ec654a697ff9d82069fb9963b2d9c83755272b7 config-baseline.json
|
||||
5be4b1e3d1f3b5fde9cc6c75f799e5673f8dd503d79ba83952df476114bde8f7 config-baseline.core.json
|
||||
e7d370393611c16f18563419fba9307d40cd60fdbb73a2cac0a1eb22c4359eac config-baseline.channel.json
|
||||
a32d6d010f2a10e1ea510ce8110895d77d087b553985df8f642a76db6868c7f7 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
e07c1b7a7bc8a6eb25a832961c2367f56d60a1fa54096dda460f8db1e572aa2a plugin-sdk-api-baseline.json
|
||||
34f2af745b9ed47eec90350b2c2a9000566744b8982440feee1c4a405d0a28ca plugin-sdk-api-baseline.jsonl
|
||||
ea7c5ab8be38bf72db61838849188e452eb9e78d8872f72e413dcecee6dcc366 plugin-sdk-api-baseline.json
|
||||
704c0b5b8930c6cd66f66ad165a42e6089fb442088932e529ece1920c0a4ebea plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -16,23 +16,8 @@ Adds policy-backed doctor checks for workspace conformance.
|
||||
|
||||
## Surface
|
||||
|
||||
plugin; CLI command: [`openclaw policy`](/cli/policy)
|
||||
|
||||
## Behavior
|
||||
|
||||
The Policy plugin contributes doctor health checks for policy-managed OpenClaw
|
||||
settings and governed workspace declarations. Policy currently covers channel
|
||||
conformance, governed tool metadata, MCP server posture, model-provider posture,
|
||||
private-network access posture, Gateway exposure posture, agent workspace/tool
|
||||
posture, and OpenClaw config secret provider/auth profile posture.
|
||||
|
||||
Policy stores authored requirements in `policy.jsonc`, observes existing
|
||||
OpenClaw settings and workspace declarations as evidence, and reports drift
|
||||
through `openclaw policy check` and `openclaw doctor --lint`. A clean policy
|
||||
check emits policy, evidence, findings, and attestation hashes that operators
|
||||
can record for audit.
|
||||
plugin
|
||||
|
||||
## Related docs
|
||||
|
||||
- [Policy CLI](/cli/policy)
|
||||
- [Doctor lint mode](/cli/doctor#lint-mode)
|
||||
- [policy](/cli/policy)
|
||||
|
||||
@@ -9,54 +9,6 @@ title: "Voyage plugin"
|
||||
|
||||
Adds memory embedding provider support.
|
||||
|
||||
## Setup
|
||||
|
||||
Voyage is a remote memory embedding provider, so it needs a Voyage API key before
|
||||
memory search can use it.
|
||||
|
||||
Set the key with either:
|
||||
|
||||
- Environment variable: `VOYAGE_API_KEY`
|
||||
- Config key: `models.providers.voyage.apiKey`
|
||||
|
||||
For an interactive setup, run:
|
||||
|
||||
```bash
|
||||
openclaw configure --section model
|
||||
```
|
||||
|
||||
To make memory search use Voyage explicitly, set the memory search provider to
|
||||
`voyage` and choose a Voyage embedding model:
|
||||
|
||||
```ts
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "voyage",
|
||||
model: "voyage-3-large",
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
voyage: {
|
||||
apiKey: "${VOYAGE_API_KEY}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Verify the runtime credential and embedding provider path with:
|
||||
|
||||
```bash
|
||||
openclaw memory status --deep
|
||||
```
|
||||
|
||||
For the full memory embedding provider matrix and API key resolution order, see
|
||||
[Memory config](/reference/memory-config).
|
||||
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/voyage-provider`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -302,6 +302,33 @@ function hasCodexAppServerPotentialSideEffectEvidence(result: EmbeddedRunAttempt
|
||||
return result.replayMetadata.hadPotentialSideEffects;
|
||||
}
|
||||
|
||||
function buildCodexAppServerPromptTimeoutOutcome(params: {
|
||||
result: EmbeddedRunAttemptResult;
|
||||
turnCompletionIdleTimedOut: boolean;
|
||||
}): EmbeddedRunAttemptResult["promptTimeoutOutcome"] {
|
||||
const completionIdleTimeoutHadPotentialSideEffects = hasCodexAppServerPotentialSideEffectEvidence(
|
||||
params.result,
|
||||
);
|
||||
if (
|
||||
!params.turnCompletionIdleTimedOut ||
|
||||
(params.result.itemLifecycle.completedCount === 0 &&
|
||||
!completionIdleTimeoutHadPotentialSideEffects)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
message: completionIdleTimeoutHadPotentialSideEffects
|
||||
? CODEX_APP_SERVER_MISSING_TERMINAL_EVENT_SIDE_EFFECT_USER_MESSAGE
|
||||
: CODEX_APP_SERVER_MISSING_TERMINAL_EVENT_USER_MESSAGE,
|
||||
...(completionIdleTimeoutHadPotentialSideEffects
|
||||
? {
|
||||
replayInvalid: true,
|
||||
livenessState: "abandoned" as const,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCodexAppServerReplayBlockedReason(
|
||||
result: EmbeddedRunAttemptResult,
|
||||
):
|
||||
@@ -2156,13 +2183,15 @@ export async function runCodexAppServerAttempt(
|
||||
durationMs: number;
|
||||
}) => {
|
||||
if (
|
||||
completed ||
|
||||
runAbortController.signal.aborted ||
|
||||
!params.response.success ||
|
||||
currentTurnHadNonTerminalDynamicToolResult ||
|
||||
activeAppServerTurnRequests > 0 ||
|
||||
activeTurnItemIds.size > 0 ||
|
||||
pendingOpenClawDynamicToolCompletionIds.size > 0
|
||||
!shouldReleaseTurnAfterTerminalDynamicTool({
|
||||
completed,
|
||||
aborted: runAbortController.signal.aborted,
|
||||
responseSuccess: params.response.success,
|
||||
currentTurnHadNonTerminalDynamicToolResult,
|
||||
activeAppServerTurnRequests,
|
||||
activeTurnItemIdsCount: activeTurnItemIds.size,
|
||||
pendingOpenClawDynamicToolCompletionIdsCount: pendingOpenClawDynamicToolCompletionIds.size,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -2193,20 +2222,6 @@ export async function runCodexAppServerAttempt(
|
||||
resolveCompletion?.();
|
||||
};
|
||||
|
||||
const finalizeDynamicToolBatchIfIdle = () => {
|
||||
if (
|
||||
activeAppServerTurnRequests > 0 ||
|
||||
pendingOpenClawDynamicToolCompletionIds.size > 0 ||
|
||||
activeTurnItemIds.size > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (currentTurnHadNonTerminalDynamicToolResult) {
|
||||
pendingTerminalDynamicToolRelease = undefined;
|
||||
currentTurnHadNonTerminalDynamicToolResult = false;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleTerminalDynamicToolReleaseCheck = () => {
|
||||
if (
|
||||
terminalDynamicToolReleaseCheckScheduled ||
|
||||
@@ -2218,10 +2233,19 @@ export async function runCodexAppServerAttempt(
|
||||
terminalDynamicToolReleaseCheckScheduled = true;
|
||||
const immediate = setImmediate(() => {
|
||||
terminalDynamicToolReleaseCheckScheduled = false;
|
||||
if (pendingTerminalDynamicToolRelease) {
|
||||
const action = resolveTerminalDynamicToolBatchAction({
|
||||
activeAppServerTurnRequests,
|
||||
activeTurnItemIdsCount: activeTurnItemIds.size,
|
||||
pendingOpenClawDynamicToolCompletionIdsCount: pendingOpenClawDynamicToolCompletionIds.size,
|
||||
currentTurnHadNonTerminalDynamicToolResult,
|
||||
hasPendingTerminalDynamicToolRelease: pendingTerminalDynamicToolRelease !== undefined,
|
||||
});
|
||||
if (action === "release-pending-terminal" && pendingTerminalDynamicToolRelease) {
|
||||
releaseTurnAfterTerminalDynamicTool(pendingTerminalDynamicToolRelease);
|
||||
} else if (action === "clear-nonterminal-batch") {
|
||||
pendingTerminalDynamicToolRelease = undefined;
|
||||
currentTurnHadNonTerminalDynamicToolResult = false;
|
||||
}
|
||||
finalizeDynamicToolBatchIfIdle();
|
||||
});
|
||||
immediate.unref?.();
|
||||
};
|
||||
@@ -3091,23 +3115,10 @@ export async function runCodexAppServerAttempt(
|
||||
const codexAppServerReplayBlockedReason = codexAppServerFailureKind
|
||||
? resolveCodexAppServerReplayBlockedReason(result)
|
||||
: undefined;
|
||||
const completionIdleTimeoutHadPotentialSideEffects =
|
||||
hasCodexAppServerPotentialSideEffectEvidence(result);
|
||||
const promptTimeoutOutcome =
|
||||
turnCompletionIdleTimedOut &&
|
||||
(result.itemLifecycle.completedCount > 0 || completionIdleTimeoutHadPotentialSideEffects)
|
||||
? {
|
||||
message: completionIdleTimeoutHadPotentialSideEffects
|
||||
? CODEX_APP_SERVER_MISSING_TERMINAL_EVENT_SIDE_EFFECT_USER_MESSAGE
|
||||
: CODEX_APP_SERVER_MISSING_TERMINAL_EVENT_USER_MESSAGE,
|
||||
...(completionIdleTimeoutHadPotentialSideEffects
|
||||
? {
|
||||
replayInvalid: true,
|
||||
livenessState: "abandoned" as const,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
const promptTimeoutOutcome = buildCodexAppServerPromptTimeoutOutcome({
|
||||
result,
|
||||
turnCompletionIdleTimedOut,
|
||||
});
|
||||
recordCodexTrajectoryCompletion(trajectoryRecorder, {
|
||||
attempt: params,
|
||||
result,
|
||||
@@ -3458,6 +3469,63 @@ type TerminalToolExecutionDiagnostic = Extract<
|
||||
{ type: "tool.execution.blocked" | "tool.execution.completed" | "tool.execution.error" }
|
||||
>;
|
||||
|
||||
type TerminalDynamicToolReleaseState = {
|
||||
completed: boolean;
|
||||
aborted: boolean;
|
||||
responseSuccess: boolean;
|
||||
currentTurnHadNonTerminalDynamicToolResult: boolean;
|
||||
activeAppServerTurnRequests: number;
|
||||
activeTurnItemIdsCount: number;
|
||||
pendingOpenClawDynamicToolCompletionIdsCount: number;
|
||||
};
|
||||
|
||||
function shouldReleaseTurnAfterTerminalDynamicTool(
|
||||
state: TerminalDynamicToolReleaseState,
|
||||
): boolean {
|
||||
return (
|
||||
!state.completed &&
|
||||
!state.aborted &&
|
||||
state.responseSuccess &&
|
||||
!state.currentTurnHadNonTerminalDynamicToolResult &&
|
||||
state.activeAppServerTurnRequests === 0 &&
|
||||
state.activeTurnItemIdsCount === 0 &&
|
||||
state.pendingOpenClawDynamicToolCompletionIdsCount === 0
|
||||
);
|
||||
}
|
||||
|
||||
type TerminalDynamicToolBatchAction =
|
||||
| "idle"
|
||||
| "wait"
|
||||
| "clear-nonterminal-batch"
|
||||
| "release-pending-terminal";
|
||||
|
||||
type TerminalDynamicToolBatchState = {
|
||||
activeAppServerTurnRequests: number;
|
||||
activeTurnItemIdsCount: number;
|
||||
pendingOpenClawDynamicToolCompletionIdsCount: number;
|
||||
currentTurnHadNonTerminalDynamicToolResult: boolean;
|
||||
hasPendingTerminalDynamicToolRelease: boolean;
|
||||
};
|
||||
|
||||
function resolveTerminalDynamicToolBatchAction(
|
||||
state: TerminalDynamicToolBatchState,
|
||||
): TerminalDynamicToolBatchAction {
|
||||
if (
|
||||
state.activeAppServerTurnRequests > 0 ||
|
||||
state.activeTurnItemIdsCount > 0 ||
|
||||
state.pendingOpenClawDynamicToolCompletionIdsCount > 0
|
||||
) {
|
||||
return "wait";
|
||||
}
|
||||
if (state.currentTurnHadNonTerminalDynamicToolResult) {
|
||||
return "clear-nonterminal-batch";
|
||||
}
|
||||
if (state.hasPendingTerminalDynamicToolRelease) {
|
||||
return "release-pending-terminal";
|
||||
}
|
||||
return "idle";
|
||||
}
|
||||
|
||||
function isDynamicToolTerminalDiagnosticEvent(
|
||||
event: DiagnosticEventPayload,
|
||||
): event is TerminalToolExecutionDiagnostic {
|
||||
@@ -3901,6 +3969,12 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
data: { name: "sessions_yield", message },
|
||||
});
|
||||
},
|
||||
onAsyncTaskStarted: (message) => {
|
||||
emitCodexAppServerEvent(params, {
|
||||
stream: "codex_app_server.tool",
|
||||
data: { name: "media_async_task_started", message },
|
||||
});
|
||||
},
|
||||
});
|
||||
const codexFilteredTools = addNodeShellDynamicToolsIfNeeded(
|
||||
addSandboxShellDynamicToolsIfAvailable(
|
||||
@@ -5573,20 +5647,28 @@ export const testing = {
|
||||
buildDynamicTools,
|
||||
addSandboxShellDynamicToolsIfAvailable,
|
||||
filterCodexDynamicToolsForAllowlist,
|
||||
includeForcedCodexDynamicToolAllow,
|
||||
filterToolsForVisionInputs,
|
||||
hasWildcardCodexToolsAllow,
|
||||
handleDynamicToolCallWithTimeout,
|
||||
isInvalidCodexImagePayloadError,
|
||||
buildCodexSystemPromptReport,
|
||||
remapCodexContextFilePath,
|
||||
resolveDynamicToolCallTimeoutMs,
|
||||
resolveCodexDynamicToolsLoading,
|
||||
rotateOversizedCodexAppServerStartupBinding,
|
||||
resolveCodexAppServerForOpenClawToolPolicy,
|
||||
resolveCodexAppServerHookChannelId,
|
||||
buildCodexAppServerPromptTimeoutOutcome,
|
||||
resolveOpenClawCodingToolsSessionKeys,
|
||||
shouldProjectMirroredHistoryForCodexStart,
|
||||
shouldEnableCodexAppServerNativeToolSurface,
|
||||
shouldForceMessageTool,
|
||||
shouldReleaseTurnAfterTerminalDynamicTool,
|
||||
resolveTerminalDynamicToolBatchAction,
|
||||
hasPendingDynamicToolTerminalDiagnostic,
|
||||
buildCodexPluginThreadConfigEligibilityLogData,
|
||||
withCodexStartupTimeout,
|
||||
setOpenClawCodingToolsFactoryForTests(factory: OpenClawCodingToolsFactory): void {
|
||||
openClawCodingToolsFactoryForTests = factory;
|
||||
},
|
||||
|
||||
@@ -433,6 +433,20 @@ describe("OpenClaw Codex sandbox exec-server", () => {
|
||||
await expect(openSocket(execServerUrl)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("closes connected exec-server clients when its sandbox environment is released", async () => {
|
||||
const sandbox = createSandboxContext({});
|
||||
const client = createClient();
|
||||
await ensureCodexSandboxExecServerEnvironment({
|
||||
client: client as never,
|
||||
sandbox,
|
||||
});
|
||||
const socket = await openSocket(execServerUrlFromClient(client));
|
||||
|
||||
await releaseCodexSandboxExecServerEnvironment(sandbox);
|
||||
|
||||
await expect(waitForSocketClose(socket)).resolves.toEqual({ code: 1001 });
|
||||
});
|
||||
|
||||
it("keeps a shared exec-server open when another turn reacquires during release", async () => {
|
||||
const sandbox = createSandboxContext({});
|
||||
const client = createClient();
|
||||
|
||||
@@ -43,6 +43,7 @@ export type CodexSandboxExecEnvironment = {
|
||||
};
|
||||
|
||||
const SANDBOX_EXEC_SERVERS = new Map<string, Promise<OpenClawExecServer>>();
|
||||
const EXEC_SERVER_CLOSE_GRACE_MS = 1_000;
|
||||
|
||||
export async function closeCodexSandboxExecServersForTests(): Promise<void> {
|
||||
const servers = await Promise.allSettled(SANDBOX_EXEC_SERVERS.values());
|
||||
@@ -247,7 +248,22 @@ async function closeOpenClawExecServer(execServer: OpenClawExecServer): Promise<
|
||||
client.close(1001, "shutdown");
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
execServer.server.close(() => resolve());
|
||||
let fallbackTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const forceCloseTimer = setTimeout(() => {
|
||||
for (const client of execServer.server.clients) {
|
||||
client.terminate();
|
||||
}
|
||||
fallbackTimer = setTimeout(resolve, EXEC_SERVER_CLOSE_GRACE_MS);
|
||||
fallbackTimer.unref?.();
|
||||
}, EXEC_SERVER_CLOSE_GRACE_MS);
|
||||
forceCloseTimer.unref?.();
|
||||
execServer.server.close(() => {
|
||||
clearTimeout(forceCloseTimer);
|
||||
if (fallbackTimer) {
|
||||
clearTimeout(fallbackTimer);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -330,12 +330,14 @@ describe("matrix thread bindings", () => {
|
||||
placement: "current",
|
||||
});
|
||||
|
||||
const sendCallCount = sendMessageMatrixMock.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(61_000);
|
||||
|
||||
await vi.waitFor(
|
||||
() => expect(sendMessageMatrixMock.mock.calls.length).toBeGreaterThanOrEqual(2),
|
||||
() =>
|
||||
expect(sendMessageMatrixMock.mock.calls.length).toBeGreaterThanOrEqual(sendCallCount + 2),
|
||||
{
|
||||
interval: 1,
|
||||
interval: 10,
|
||||
timeout: 1_000,
|
||||
},
|
||||
);
|
||||
@@ -346,7 +348,7 @@ describe("matrix thread bindings", () => {
|
||||
expect(persisted.version).toBe(1);
|
||||
expect(persisted.bindings).toEqual([]);
|
||||
},
|
||||
{ interval: 1, timeout: 100 },
|
||||
{ interval: 10, timeout: 1_000 },
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -97,6 +97,38 @@ describe("qa suite transport helpers", () => {
|
||||
await expect(pending).rejects.toThrow("Tool read not found");
|
||||
});
|
||||
|
||||
it("uses sinceIndex as a full message-bus cursor for outbound waits", async () => {
|
||||
const state = createQaBusState();
|
||||
state.addInboundMessage({
|
||||
conversation: { id: "qa-operator", kind: "direct" },
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
text: "before",
|
||||
});
|
||||
const sinceIndex = state.getSnapshot().messages.length;
|
||||
const pending = waitForOutboundMessage(
|
||||
state,
|
||||
(candidate) => candidate.text.includes("QA-CURSOR-OK"),
|
||||
5_000,
|
||||
{ sinceIndex },
|
||||
);
|
||||
|
||||
state.addInboundMessage({
|
||||
conversation: { id: "qa-operator", kind: "direct" },
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
text: "during",
|
||||
});
|
||||
state.addOutboundMessage({
|
||||
to: "dm:qa-operator",
|
||||
text: "QA-CURSOR-OK",
|
||||
senderId: "openclaw",
|
||||
senderName: "OpenClaw QA",
|
||||
});
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ text: "QA-CURSOR-OK" });
|
||||
});
|
||||
|
||||
it("fails raw scenario waitForCondition calls when a classified failure reply arrives", async () => {
|
||||
const state = createQaBusState();
|
||||
const waitForCondition = createScenarioWaitForCondition(state);
|
||||
|
||||
@@ -26,14 +26,15 @@ async function waitForOutboundMessage(
|
||||
options?: { sinceIndex?: number },
|
||||
) {
|
||||
return await waitForQaTransportCondition(() => {
|
||||
const failureMessage = findFailureOutboundMessage(state, options);
|
||||
const cursorOptions = { ...options, cursorSpace: "all" as const };
|
||||
const failureMessage = findFailureOutboundMessage(state, cursorOptions);
|
||||
if (failureMessage) {
|
||||
throw new Error(extractQaFailureReplyText(failureMessage.text) ?? failureMessage.text);
|
||||
}
|
||||
const match = state
|
||||
.getSnapshot()
|
||||
.messages.filter((message: QaBusMessage) => message.direction === "outbound")
|
||||
.slice(options?.sinceIndex ?? 0)
|
||||
.messages.slice(options?.sinceIndex ?? 0)
|
||||
.filter((message: QaBusMessage) => message.direction === "outbound")
|
||||
.find(predicate);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
|
||||
@@ -145,7 +145,7 @@ function sendMessageOptionsAt(index: number): Record<string, unknown> {
|
||||
return options;
|
||||
}
|
||||
|
||||
async function waitForCondition(check: () => boolean, message: string, attempts = 100) {
|
||||
async function waitForCondition(check: () => boolean, message: string, attempts = 5_000) {
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
if (check()) {
|
||||
return;
|
||||
@@ -429,7 +429,9 @@ describe("telegramPlugin gateway startup", () => {
|
||||
const releaseProbe: Array<() => void> = [];
|
||||
let activeProbes = 0;
|
||||
let maxActiveProbes = 0;
|
||||
let startedProbes = 0;
|
||||
probeTelegram.mockImplementation(async () => {
|
||||
startedProbes += 1;
|
||||
activeProbes += 1;
|
||||
maxActiveProbes = Math.max(maxActiveProbes, activeProbes);
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -445,29 +447,42 @@ describe("telegramPlugin gateway startup", () => {
|
||||
});
|
||||
monitorTelegramProvider.mockResolvedValue(undefined);
|
||||
|
||||
const first = startTelegramAccount("alpha");
|
||||
const second = startTelegramAccount("bravo");
|
||||
const third = startTelegramAccount("charlie");
|
||||
const firstAbort = new AbortController();
|
||||
const secondAbort = new AbortController();
|
||||
const thirdAbort = new AbortController();
|
||||
const first = startTelegramAccount("alpha", {}, firstAbort.signal);
|
||||
const second = startTelegramAccount("bravo", {}, secondAbort.signal);
|
||||
const third = startTelegramAccount("charlie", {}, thirdAbort.signal);
|
||||
|
||||
await waitForCondition(
|
||||
() => probeTelegram.mock.calls.length === 2,
|
||||
"expected two startup probes to begin",
|
||||
);
|
||||
expect(maxActiveProbes).toBe(2);
|
||||
expect(releaseProbe).toHaveLength(2);
|
||||
try {
|
||||
await waitForCondition(
|
||||
() => startedProbes >= 2 && releaseProbe.length >= 2,
|
||||
"expected two startup probes to begin",
|
||||
);
|
||||
expect(maxActiveProbes).toBe(2);
|
||||
expect(releaseProbe).toHaveLength(2);
|
||||
|
||||
releaseProbe.shift()?.();
|
||||
await waitForCondition(
|
||||
() => probeTelegram.mock.calls.length === 3,
|
||||
"expected queued startup probe to begin after a slot opens",
|
||||
);
|
||||
expect(maxActiveProbes).toBe(2);
|
||||
releaseProbe.shift()?.();
|
||||
await waitForCondition(
|
||||
() => startedProbes >= 3 && releaseProbe.length >= 2,
|
||||
"expected queued startup probe to begin after a slot opens",
|
||||
);
|
||||
expect(maxActiveProbes).toBe(2);
|
||||
|
||||
for (const release of releaseProbe.splice(0)) {
|
||||
release();
|
||||
for (const release of releaseProbe.splice(0)) {
|
||||
release();
|
||||
}
|
||||
await Promise.all([first.task, second.task, third.task]);
|
||||
expect(monitorTelegramProvider).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
firstAbort.abort();
|
||||
secondAbort.abort();
|
||||
thirdAbort.abort();
|
||||
for (const release of releaseProbe.splice(0)) {
|
||||
release();
|
||||
}
|
||||
await Promise.allSettled([first.task, second.task, third.task]);
|
||||
}
|
||||
await Promise.all([first.task, second.task, third.task]);
|
||||
expect(monitorTelegramProvider).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("abandons a queued startup probe when the account aborts", async () => {
|
||||
@@ -490,23 +505,35 @@ describe("telegramPlugin gateway startup", () => {
|
||||
});
|
||||
monitorTelegramProvider.mockResolvedValue(undefined);
|
||||
|
||||
const first = startTelegramAccount("alpha");
|
||||
const second = startTelegramAccount("bravo");
|
||||
const firstAbort = new AbortController();
|
||||
const secondAbort = new AbortController();
|
||||
const abortQueued = new AbortController();
|
||||
const first = startTelegramAccount("alpha", {}, firstAbort.signal);
|
||||
const second = startTelegramAccount("bravo", {}, secondAbort.signal);
|
||||
const queued = startTelegramAccount("charlie", {}, abortQueued.signal);
|
||||
|
||||
await waitForCondition(
|
||||
() => probeTelegram.mock.calls.length === 2,
|
||||
"expected startup probe slots to fill",
|
||||
);
|
||||
abortQueued.abort();
|
||||
try {
|
||||
await waitForCondition(
|
||||
() => startedProbes >= 2 && releaseProbe.length >= 2,
|
||||
"expected startup probe slots to fill",
|
||||
);
|
||||
abortQueued.abort();
|
||||
|
||||
for (const release of releaseProbe.splice(0)) {
|
||||
release();
|
||||
for (const release of releaseProbe.splice(0)) {
|
||||
release();
|
||||
}
|
||||
await Promise.all([first.task, second.task, queued.task]);
|
||||
expect(startedProbes).toBe(2);
|
||||
expect(monitorTelegramProvider).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
firstAbort.abort();
|
||||
secondAbort.abort();
|
||||
abortQueued.abort();
|
||||
for (const release of releaseProbe.splice(0)) {
|
||||
release();
|
||||
}
|
||||
await Promise.allSettled([first.task, second.task, queued.task]);
|
||||
}
|
||||
await Promise.all([first.task, second.task, queued.task]);
|
||||
expect(probeTelegram).toHaveBeenCalledTimes(2);
|
||||
expect(monitorTelegramProvider).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("releases a stopped stale polling lease for the account token", async () => {
|
||||
|
||||
@@ -2523,6 +2523,7 @@ describe("TelegramPollingSession", () => {
|
||||
spoolDir: tempDir,
|
||||
createWorker,
|
||||
drainIntervalMs: 100,
|
||||
spooledUpdateHandlerTimeoutMs: 100,
|
||||
spooledUpdateHandlerAbortGraceMs: 100,
|
||||
},
|
||||
});
|
||||
@@ -2537,7 +2538,7 @@ describe("TelegramPollingSession", () => {
|
||||
});
|
||||
expect(statusPatches(setStatus).some((patch) => patch.connected === true)).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25 * 60_000 + 100);
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
|
||||
@@ -38,7 +38,7 @@ steps:
|
||||
- set: gate
|
||||
value:
|
||||
expr: plugin.createCodexPluginInstallGate()
|
||||
- set: turn
|
||||
- set: turnHandle
|
||||
value:
|
||||
expr: "({ promise: gate.runFirstTurnAfterInstall({ inputTokens: 17, run: () => config.expectedText }) })"
|
||||
- assert:
|
||||
@@ -48,7 +48,7 @@ steps:
|
||||
- call: gate.markInstalled
|
||||
- set: completed
|
||||
value:
|
||||
expr: await turn.promise
|
||||
expr: await turnHandle.promise
|
||||
- assert:
|
||||
expr: "completed.text === config.expectedText && completed.responseCount === config.expectedResponseCount && completed.inputTokens === 17"
|
||||
message:
|
||||
|
||||
25
scripts/ci-docker-login-ghcr.sh
Executable file
25
scripts/ci-docker-login-ghcr.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
registry="${GHCR_REGISTRY:-ghcr.io}"
|
||||
username="${GHCR_USERNAME:-${GITHUB_ACTOR:-github-actions[bot]}}"
|
||||
|
||||
if [[ -z "${GITHUB_TOKEN:-}" ]]; then
|
||||
echo "GITHUB_TOKEN is required for GHCR login." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for attempt in 1 2 3 4; do
|
||||
if printf '%s' "$GITHUB_TOKEN" | docker login "$registry" --username "$username" --password-stdin; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$attempt" -eq 4 ]]; then
|
||||
break
|
||||
fi
|
||||
sleep_seconds=$((attempt * 5))
|
||||
echo "GHCR login failed on attempt ${attempt}; retrying in ${sleep_seconds}s." >&2
|
||||
sleep "$sleep_seconds"
|
||||
done
|
||||
|
||||
echo "GHCR login failed after 4 attempts." >&2
|
||||
exit 1
|
||||
@@ -572,11 +572,17 @@ else
|
||||
else
|
||||
echo "Bonjour/mDNS advertising: explicitly enabled (OPENCLAW_DISABLE_BONJOUR=$OPENCLAW_DISABLE_BONJOUR)."
|
||||
fi
|
||||
echo "Gateway token: $OPENCLAW_GATEWAY_TOKEN"
|
||||
echo "Gateway token: stored in Docker environment/config (not printed)."
|
||||
echo "Tailscale exposure: Off (use host-level tailnet/Tailscale setup separately)."
|
||||
echo "Install Gateway daemon: No (managed by Docker Compose)"
|
||||
echo ""
|
||||
run_prestart_cli onboard --mode local --no-install-daemon
|
||||
run_prestart_cli onboard \
|
||||
--mode local \
|
||||
--no-install-daemon \
|
||||
--gateway-auth token \
|
||||
--gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN \
|
||||
--skip-ui \
|
||||
--suppress-gateway-token-output
|
||||
fi
|
||||
|
||||
echo ""
|
||||
@@ -711,8 +717,8 @@ echo "Gateway running with host port mapping."
|
||||
echo "Access from tailnet devices via the host's tailnet IP."
|
||||
echo "Config: $OPENCLAW_CONFIG_DIR"
|
||||
echo "Workspace: $OPENCLAW_WORKSPACE_DIR"
|
||||
echo "Token: $OPENCLAW_GATEWAY_TOKEN"
|
||||
echo "Token: stored in Docker environment/config (not printed)."
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " ${COMPOSE_HINT} logs -f openclaw-gateway"
|
||||
echo " ${COMPOSE_HINT} exec openclaw-gateway node dist/index.js health --token \"$OPENCLAW_GATEWAY_TOKEN\""
|
||||
echo " ${COMPOSE_HINT} exec openclaw-gateway sh -lc 'node dist/index.js health --token \"\$OPENCLAW_GATEWAY_TOKEN\"'"
|
||||
|
||||
@@ -8,7 +8,9 @@ const TMUX_ATTACH_DISABLE_VALUES = new Set(["0", "false", "no", "off"]);
|
||||
const TMUX_ATTACH_FORCE_VALUES = new Set(["1", "true", "yes", "on"]);
|
||||
const DEFAULT_PROFILE_NAME = "main";
|
||||
const DEFAULT_BENCHMARK_PROFILE_DIR = ".artifacts/gateway-watch-profiles";
|
||||
const DEFAULT_BENCHMARK_PROFILE_MAX_FILES = "40";
|
||||
const RUN_NODE_CPU_PROF_DIR_ENV = "OPENCLAW_RUN_NODE_CPU_PROF_DIR";
|
||||
const RUN_NODE_CPU_PROF_MAX_FILES_ENV = "OPENCLAW_RUN_NODE_CPU_PROF_MAX_FILES";
|
||||
const RUN_NODE_OUTPUT_LOG_ENV = "OPENCLAW_RUN_NODE_OUTPUT_LOG";
|
||||
const RUN_NODE_FILTER_SYNC_IO_STDERR_ENV = "OPENCLAW_RUN_NODE_FILTER_SYNC_IO_STDERR";
|
||||
const RAW_WATCH_SCRIPT = "scripts/watch-node.mjs";
|
||||
@@ -21,6 +23,7 @@ const TMUX_CHILD_ENV_KEYS = [
|
||||
"OPENCLAW_HOME",
|
||||
"OPENCLAW_PROFILE",
|
||||
RUN_NODE_CPU_PROF_DIR_ENV,
|
||||
RUN_NODE_CPU_PROF_MAX_FILES_ENV,
|
||||
RUN_NODE_FILTER_SYNC_IO_STDERR_ENV,
|
||||
RUN_NODE_OUTPUT_LOG_ENV,
|
||||
"OPENCLAW_SKIP_CHANNELS",
|
||||
@@ -106,6 +109,7 @@ const resolveGatewayWatchBenchmarkArgs = ({ args = [], env = process.env } = {})
|
||||
if (benchmarkFlagSeen) {
|
||||
nextEnv[RUN_NODE_CPU_PROF_DIR_ENV] =
|
||||
benchmarkDir || nextEnv[RUN_NODE_CPU_PROF_DIR_ENV] || DEFAULT_BENCHMARK_PROFILE_DIR;
|
||||
nextEnv[RUN_NODE_CPU_PROF_MAX_FILES_ENV] ??= DEFAULT_BENCHMARK_PROFILE_MAX_FILES;
|
||||
nextEnv.OPENCLAW_TRACE_SYNC_IO ??= "0";
|
||||
if (nextEnv.OPENCLAW_TRACE_SYNC_IO === "1") {
|
||||
nextEnv[RUN_NODE_OUTPUT_LOG_ENV] ??= joinArtifactPath(
|
||||
|
||||
@@ -84,16 +84,18 @@ function requireValue(argv: string[], index: number, flag: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
const CAPTURE_MAX_BUFFER_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
function run(command: string, args: string[], input?: { capture?: boolean }): string {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: CAPTURE_MAX_BUFFER_BYTES,
|
||||
stdio: input?.capture ? ["ignore", "pipe", "pipe"] : "inherit",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
const reason = result.status ?? result.signal ?? result.error?.message ?? "unknown";
|
||||
const stderr = result.stderr ? `\n${result.stderr}` : "";
|
||||
throw new Error(
|
||||
`${command} ${args.join(" ")} failed with ${result.status ?? "signal"}${stderr}`,
|
||||
);
|
||||
throw new Error(`${command} ${args.join(" ")} failed with ${reason}${stderr}`);
|
||||
}
|
||||
return result.stdout ?? "";
|
||||
}
|
||||
|
||||
@@ -613,6 +613,7 @@ const getSignalExitCode = (signal) => (isSignalKey(signal) ? SIGNAL_EXIT_CODES[s
|
||||
|
||||
const RUN_NODE_OUTPUT_LOG_ENV = "OPENCLAW_RUN_NODE_OUTPUT_LOG";
|
||||
const RUN_NODE_CPU_PROF_DIR_ENV = "OPENCLAW_RUN_NODE_CPU_PROF_DIR";
|
||||
const RUN_NODE_CPU_PROF_MAX_FILES_ENV = "OPENCLAW_RUN_NODE_CPU_PROF_MAX_FILES";
|
||||
const RUN_NODE_FILTER_SYNC_IO_STDERR_ENV = "OPENCLAW_RUN_NODE_FILTER_SYNC_IO_STDERR";
|
||||
const RUN_NODE_BUILD_LOCK_TIMEOUT_ENV = "OPENCLAW_RUN_NODE_BUILD_LOCK_TIMEOUT_MS";
|
||||
const RUN_NODE_BUILD_LOCK_POLL_ENV = "OPENCLAW_RUN_NODE_BUILD_LOCK_POLL_MS";
|
||||
@@ -774,6 +775,52 @@ const sanitizeCpuProfileNamePart = (value) => {
|
||||
return normalized || "command";
|
||||
};
|
||||
|
||||
const parsePositiveInteger = (value) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
};
|
||||
|
||||
const listRunNodeCpuProfiles = (deps, absoluteProfileDir, commandName) => {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = deps.fs.readdirSync(absoluteProfileDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const prefix = `openclaw-${commandName}-`;
|
||||
return entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile() && entry.name.startsWith(prefix) && entry.name.endsWith(".cpuprofile"),
|
||||
)
|
||||
.flatMap((entry) => {
|
||||
const filePath = path.join(absoluteProfileDir, entry.name);
|
||||
try {
|
||||
const stat = deps.fs.statSync(filePath);
|
||||
return [{ filePath, mtimeMs: stat.mtimeMs }];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
.toSorted((left, right) => left.mtimeMs - right.mtimeMs);
|
||||
};
|
||||
|
||||
const pruneRunNodeCpuProfiles = (deps, absoluteProfileDir, commandName) => {
|
||||
const maxFiles = parsePositiveInteger(deps.env[RUN_NODE_CPU_PROF_MAX_FILES_ENV]);
|
||||
if (!maxFiles) {
|
||||
return;
|
||||
}
|
||||
const profiles = listRunNodeCpuProfiles(deps, absoluteProfileDir, commandName);
|
||||
const deleteCount = Math.max(0, profiles.length - maxFiles + 1);
|
||||
for (const profile of profiles.slice(0, deleteCount)) {
|
||||
try {
|
||||
deps.fs.rmSync(profile.filePath, { force: true });
|
||||
} catch {
|
||||
// Best-effort artifact rotation; profiling should not fail the command.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resolveRunNodeCpuProfileArgs = (deps) => {
|
||||
const profileDir = deps.env[RUN_NODE_CPU_PROF_DIR_ENV]?.trim();
|
||||
if (!profileDir) {
|
||||
@@ -785,6 +832,7 @@ const resolveRunNodeCpuProfileArgs = (deps) => {
|
||||
deps.env[RUN_NODE_CPU_PROF_DIR_ENV] = absoluteProfileDir;
|
||||
|
||||
const commandName = sanitizeCpuProfileNamePart(deps.args[0]);
|
||||
pruneRunNodeCpuProfiles(deps, absoluteProfileDir, commandName);
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const pid = Number.isInteger(deps.process.pid) && deps.process.pid > 0 ? deps.process.pid : "pid";
|
||||
const profileName = `openclaw-${commandName}-${pid}-${timestamp}.cpuprofile`;
|
||||
|
||||
@@ -176,6 +176,10 @@ NPM_CACHE_DIR="${OPENCLAW_INSTALL_SMOKE_NPM_CACHE_DIR:-}"
|
||||
NPM_CACHE_OWNED=0
|
||||
NPM_CACHE_PREPARED=0
|
||||
NPM_CACHE_DOCKER_ARGS=()
|
||||
INSTALL_SCRIPT_DOCKER_ARGS=(
|
||||
-v "$ROOT_DIR/scripts/install.sh:/tmp/openclaw-install.sh:ro"
|
||||
-v "$ROOT_DIR/scripts/install-cli.sh:/tmp/openclaw-install-cli.sh:ro"
|
||||
)
|
||||
|
||||
remove_owned_npm_cache() {
|
||||
if [[ "$NPM_CACHE_OWNED" != "1" || -z "$NPM_CACHE_DIR" || ! -d "$NPM_CACHE_DIR" ]]; then
|
||||
@@ -403,6 +407,7 @@ else
|
||||
--platform "$SMOKE_PLATFORM" \
|
||||
${UPDATE_DOCKER_HOST_ARGS[@]+"${UPDATE_DOCKER_HOST_ARGS[@]}"} \
|
||||
"${NPM_CACHE_DOCKER_ARGS[@]}" \
|
||||
"${INSTALL_SCRIPT_DOCKER_ARGS[@]}" \
|
||||
-v "${LATEST_DIR}:/out" \
|
||||
-e OPENCLAW_INSTALL_URL="$INSTALL_URL" \
|
||||
-e OPENCLAW_INSTALL_PACKAGE="$PACKAGE_NAME" \
|
||||
@@ -465,7 +470,7 @@ else
|
||||
docker run --rm -t \
|
||||
--platform "$SMOKE_PLATFORM" \
|
||||
"${NPM_CACHE_DOCKER_ARGS[@]}" \
|
||||
-v "$ROOT_DIR/scripts/install.sh:/tmp/openclaw-install.sh:ro" \
|
||||
"${INSTALL_SCRIPT_DOCKER_ARGS[@]}" \
|
||||
-e OPENCLAW_INSTALL_URL="$FRESHNESS_INSTALL_URL" \
|
||||
-e OPENCLAW_INSTALL_PACKAGE="$PACKAGE_NAME" \
|
||||
-e OPENCLAW_INSTALL_SMOKE_MODE=freshness \
|
||||
@@ -497,6 +502,7 @@ else
|
||||
echo "==> Run installer non-root test: $INSTALL_URL"
|
||||
docker run --rm -t \
|
||||
--platform "$NONROOT_PLATFORM" \
|
||||
"${INSTALL_SCRIPT_DOCKER_ARGS[@]}" \
|
||||
-e OPENCLAW_INSTALL_URL="$INSTALL_URL" \
|
||||
-e OPENCLAW_INSTALL_PACKAGE="$PACKAGE_NAME" \
|
||||
-e OPENCLAW_INSTALL_METHOD=npm \
|
||||
@@ -521,6 +527,7 @@ echo "==> Run CLI installer non-root test (same image)"
|
||||
docker run --rm -t \
|
||||
--platform "$NONROOT_PLATFORM" \
|
||||
--entrypoint /bin/bash \
|
||||
"${INSTALL_SCRIPT_DOCKER_ARGS[@]}" \
|
||||
-e OPENCLAW_INSTALL_URL="$INSTALL_URL" \
|
||||
-e OPENCLAW_INSTALL_CLI_URL="$CLI_INSTALL_URL" \
|
||||
-e OPENCLAW_NO_ONBOARD=1 \
|
||||
|
||||
@@ -5627,6 +5627,142 @@ describe("openai transport stream", () => {
|
||||
expect(params).toHaveProperty("tool_choice", "required");
|
||||
});
|
||||
|
||||
it("omits empty tools and tool_choice for proxy-like openai-completions endpoints when context.tools is []", () => {
|
||||
const params = buildOpenAICompletionsParams(
|
||||
{
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
api: "openai-completions",
|
||||
provider: "vllm",
|
||||
baseUrl: "http://localhost:8000/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 4096,
|
||||
maxTokens: 2048,
|
||||
} satisfies Model<"openai-completions">,
|
||||
{
|
||||
systemPrompt: "You are a helpful assistant",
|
||||
messages: [],
|
||||
tools: [],
|
||||
} as never,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(params).not.toHaveProperty("tools");
|
||||
expect(params).not.toHaveProperty("tool_choice");
|
||||
});
|
||||
|
||||
it("omits tools for proxy-like openai-completions endpoints when only prior tool history is present", () => {
|
||||
const params = buildOpenAICompletionsParams(
|
||||
{
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
api: "openai-completions",
|
||||
provider: "vllm",
|
||||
baseUrl: "http://localhost:8000/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 4096,
|
||||
maxTokens: 2048,
|
||||
} satisfies Model<"openai-completions">,
|
||||
{
|
||||
systemPrompt: "You are a helpful assistant",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_abc",
|
||||
name: "get_weather",
|
||||
arguments: "{}",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
content: [{ type: "text", text: "sunny" }],
|
||||
toolCallId: "call_abc",
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(params).not.toHaveProperty("tools");
|
||||
expect(params).not.toHaveProperty("tool_choice");
|
||||
});
|
||||
|
||||
it("preserves empty tools array for native openai-completions endpoints (existing behavior)", () => {
|
||||
const params = buildOpenAICompletionsParams(
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 4096,
|
||||
maxTokens: 2048,
|
||||
} satisfies Model<"openai-completions">,
|
||||
{
|
||||
systemPrompt: "You are a helpful assistant",
|
||||
messages: [],
|
||||
tools: [],
|
||||
} as never,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(params).toHaveProperty("tools");
|
||||
expect((params as { tools: unknown[] }).tools).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves tools: [] fallback for native openai-completions endpoints when only prior tool history is present (existing behavior)", () => {
|
||||
const params = buildOpenAICompletionsParams(
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 4096,
|
||||
maxTokens: 2048,
|
||||
} satisfies Model<"openai-completions">,
|
||||
{
|
||||
systemPrompt: "You are a helpful assistant",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_abc",
|
||||
name: "get_weather",
|
||||
arguments: "{}",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
content: [{ type: "text", text: "sunny" }],
|
||||
toolCallId: "call_abc",
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(params).toHaveProperty("tools");
|
||||
expect((params as { tools: unknown[] }).tools).toEqual([]);
|
||||
});
|
||||
|
||||
it("resets stopReason to stop when finish_reason is tool_calls but tool_calls array is empty", async () => {
|
||||
const model = {
|
||||
id: "nemotron-3-super",
|
||||
|
||||
@@ -3403,6 +3403,14 @@ export function buildOpenAICompletionsParams(
|
||||
} else if (hasToolHistory(context.messages)) {
|
||||
params.tools = [];
|
||||
}
|
||||
if (
|
||||
compatDetection.capabilities.usesExplicitProxyLikeEndpoint &&
|
||||
Array.isArray(params.tools) &&
|
||||
params.tools.length === 0
|
||||
) {
|
||||
delete params.tools;
|
||||
delete params.tool_choice;
|
||||
}
|
||||
}
|
||||
const completionsReasoningEffort = resolveOpenAICompletionsReasoningEffort(options);
|
||||
const resolvedCompletionsReasoningEffort = completionsReasoningEffort
|
||||
|
||||
@@ -39,6 +39,7 @@ import { createGatewayTool } from "./tools/gateway-tool.js";
|
||||
import { createHeartbeatResponseTool } from "./tools/heartbeat-response-tool.js";
|
||||
import { createImageGenerateTool } from "./tools/image-generate-tool.js";
|
||||
import { createImageTool } from "./tools/image-tool.js";
|
||||
import type { MediaGenerateAsyncStartCallback } from "./tools/media-generate-background-shared.js";
|
||||
import { createMessageTool } from "./tools/message-tool.js";
|
||||
import { createMusicGenerateTool } from "./tools/music-generate-tool.js";
|
||||
import { createNodesTool } from "./tools/nodes-tool.js";
|
||||
@@ -156,6 +157,8 @@ export function createOpenClawTools(
|
||||
spawnWorkspaceDir?: string;
|
||||
/** Callback invoked when sessions_yield tool is called. */
|
||||
onYield?: (message: string) => Promise<void> | void;
|
||||
/** Callback invoked when a media tool starts async background work. */
|
||||
onAsyncTaskStarted?: MediaGenerateAsyncStartCallback;
|
||||
/** Allow plugin tools for this tool set to late-bind the gateway subagent. */
|
||||
allowGatewaySubagentBinding?: boolean;
|
||||
} & SpawnedToolContext,
|
||||
@@ -231,7 +234,7 @@ export function createOpenClawTools(
|
||||
workspaceDir,
|
||||
sandbox,
|
||||
fsPolicy: options?.fsPolicy,
|
||||
onAsyncTaskStarted: options?.onYield,
|
||||
onAsyncTaskStarted: options?.onAsyncTaskStarted,
|
||||
})
|
||||
: null;
|
||||
options?.recordToolPrepStage?.("openclaw-tools:image-generate-tool");
|
||||
@@ -245,7 +248,7 @@ export function createOpenClawTools(
|
||||
workspaceDir,
|
||||
sandbox,
|
||||
fsPolicy: options?.fsPolicy,
|
||||
onAsyncTaskStarted: options?.onYield,
|
||||
onAsyncTaskStarted: options?.onAsyncTaskStarted,
|
||||
})
|
||||
: null;
|
||||
options?.recordToolPrepStage?.("openclaw-tools:video-generate-tool");
|
||||
@@ -259,7 +262,7 @@ export function createOpenClawTools(
|
||||
workspaceDir,
|
||||
sandbox,
|
||||
fsPolicy: options?.fsPolicy,
|
||||
onAsyncTaskStarted: options?.onYield,
|
||||
onAsyncTaskStarted: options?.onAsyncTaskStarted,
|
||||
})
|
||||
: null;
|
||||
options?.recordToolPrepStage?.("openclaw-tools:music-generate-tool");
|
||||
|
||||
@@ -1594,6 +1594,54 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
|
||||
expect(retryInstruction).toBeNull();
|
||||
});
|
||||
|
||||
it("retries empty openai-codex-responses turns with non-zero output tokens (#85364)", () => {
|
||||
const retryInstruction = resolveEmptyResponseRetryInstruction({
|
||||
provider: "openai-codex",
|
||||
modelId: "gpt-5.5",
|
||||
modelApi: "openai-codex-responses",
|
||||
payloadCount: 0,
|
||||
aborted: false,
|
||||
timedOut: false,
|
||||
attempt: makeAttemptResult({
|
||||
assistantTexts: [],
|
||||
lastAssistant: {
|
||||
role: "assistant",
|
||||
stopReason: "stop",
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.5",
|
||||
content: [],
|
||||
usage: { input: 24794, output: 111, cacheRead: 4608, totalTokens: 29513 },
|
||||
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(retryInstruction).toBe(EMPTY_RESPONSE_RETRY_INSTRUCTION);
|
||||
});
|
||||
|
||||
it("retries empty openai-responses turns without visible text", () => {
|
||||
const retryInstruction = resolveEmptyResponseRetryInstruction({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.5",
|
||||
modelApi: "openai-responses",
|
||||
payloadCount: 0,
|
||||
aborted: false,
|
||||
timedOut: false,
|
||||
attempt: makeAttemptResult({
|
||||
assistantTexts: [],
|
||||
lastAssistant: {
|
||||
role: "assistant",
|
||||
stopReason: "stop",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
content: [],
|
||||
usage: { input: 5000, output: 200, totalTokens: 5200 },
|
||||
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(retryInstruction).toBe(EMPTY_RESPONSE_RETRY_INSTRUCTION);
|
||||
});
|
||||
|
||||
it("retries generic empty OpenAI-compatible turns from custom endpoints", () => {
|
||||
const retryInstruction = resolveEmptyResponseRetryInstruction({
|
||||
provider: "llama-cpp-local",
|
||||
|
||||
@@ -133,6 +133,19 @@ const GEMINI_INCOMPLETE_TURN_MODEL_ID_PATTERN = /^gemini(?:[.-]|$)/;
|
||||
// Ollama native `/api/chat` can finish with only thinking/internal blocks when
|
||||
// constrained, but it should not inherit the stricter planning-only/ack prompts.
|
||||
const OLLAMA_INCOMPLETE_TURN_PROVIDER_ID_PATTERN = /^ollama(?:-|$)/;
|
||||
// Model APIs eligible for the non-visible turn retry guard. OpenAI Responses
|
||||
// family can produce reasoning-only turns where usage.output > 0 but no visible
|
||||
// text is emitted; without the guard these pass through as successful. (#85364)
|
||||
const RETRY_GUARD_MODEL_APIS = new Set([
|
||||
"openai-completions",
|
||||
"anthropic-messages",
|
||||
"bedrock-converse-stream",
|
||||
"openai-responses",
|
||||
"openai-codex-responses",
|
||||
"azure-openai-responses",
|
||||
"openclaw-openai-responses-transport",
|
||||
"openclaw-azure-openai-responses-transport",
|
||||
]);
|
||||
const DEFAULT_PLANNING_ONLY_RETRY_LIMIT = 1;
|
||||
const STRICT_AGENTIC_PLANNING_ONLY_RETRY_LIMIT = 2;
|
||||
// Allow one immediate continuation plus one follow-up continuation before
|
||||
@@ -627,11 +640,7 @@ function shouldApplyNonVisibleTurnRetryGuard(params: {
|
||||
if (shouldApplyPlanningOnlyRetryGuard(params)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
normalizeLowercaseStringOrEmpty(params.modelApi ?? "") === "openai-completions" ||
|
||||
normalizeLowercaseStringOrEmpty(params.modelApi ?? "") === "anthropic-messages" ||
|
||||
normalizeLowercaseStringOrEmpty(params.modelApi ?? "") === "bedrock-converse-stream"
|
||||
) {
|
||||
if (RETRY_GUARD_MODEL_APIS.has(normalizeLowercaseStringOrEmpty(params.modelApi ?? ""))) {
|
||||
return true;
|
||||
}
|
||||
// Non-visible final turns are narrower than planning-only turns: there is no
|
||||
|
||||
@@ -99,6 +99,7 @@ import {
|
||||
type ToolSearchCatalogRef,
|
||||
type ToolSearchCatalogToolExecutor,
|
||||
} from "./tool-search.js";
|
||||
import type { MediaGenerateAsyncStartCallback } from "./tools/media-generate-background-shared.js";
|
||||
import { resolveWorkspaceRoot } from "./workspace-dir.js";
|
||||
|
||||
function isOpenAIProvider(provider?: string) {
|
||||
@@ -464,6 +465,8 @@ export function createOpenClawCodingTools(options?: {
|
||||
authProfileStore?: AuthProfileStore;
|
||||
/** Callback invoked when sessions_yield tool is called. */
|
||||
onYield?: (message: string) => Promise<void> | void;
|
||||
/** Callback invoked when a media tool starts async background work. */
|
||||
onAsyncTaskStarted?: MediaGenerateAsyncStartCallback;
|
||||
/** Optional instrumentation callback for tool preparation stage timing. */
|
||||
recordToolPrepStage?: (name: string) => void;
|
||||
/** Live observer called after wrapped tool outcomes are recorded. */
|
||||
@@ -967,6 +970,7 @@ export function createOpenClawCodingTools(options?: {
|
||||
inheritedToolAllowlist,
|
||||
inheritedToolDenylist,
|
||||
onYield: options?.onYield,
|
||||
onAsyncTaskStarted: options?.onAsyncTaskStarted,
|
||||
allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding,
|
||||
recordToolPrepStage: options?.recordToolPrepStage,
|
||||
})
|
||||
|
||||
@@ -311,6 +311,66 @@ describe("acquireSessionWriteLock", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("marks live lock payloads stale once they exceed max hold", () => {
|
||||
const nowMs = Date.now();
|
||||
const inspected = testing.inspectLockPayloadForTest(
|
||||
{
|
||||
pid: process.pid,
|
||||
createdAt: new Date(nowMs - 30_000).toISOString(),
|
||||
maxHoldMs: 10_000,
|
||||
},
|
||||
60_000,
|
||||
nowMs,
|
||||
{ respectMaxHold: true },
|
||||
);
|
||||
|
||||
expect(inspected.stale).toBe(true);
|
||||
expect(inspected.staleReasons).toEqual(["hold-exceeded"]);
|
||||
});
|
||||
|
||||
it("keeps live lock payloads fresh until their recorded holder max hold expires", () => {
|
||||
const nowMs = Date.now();
|
||||
const inspected = testing.inspectLockPayloadForTest(
|
||||
{
|
||||
pid: process.pid,
|
||||
createdAt: new Date(nowMs - 30_000).toISOString(),
|
||||
maxHoldMs: 60_000,
|
||||
},
|
||||
60_000,
|
||||
nowMs,
|
||||
{ respectMaxHold: true },
|
||||
);
|
||||
|
||||
expect(inspected.stale).toBe(false);
|
||||
expect(inspected.staleReasons).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not reclaim an active in-process lock through max-hold acquisition", async () => {
|
||||
await withTempSessionLockFile(async ({ sessionFile, lockPath }) => {
|
||||
const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500, maxHoldMs: 1 });
|
||||
await fs.writeFile(
|
||||
lockPath,
|
||||
JSON.stringify({
|
||||
pid: process.pid,
|
||||
createdAt: new Date(Date.now() - 30_000).toISOString(),
|
||||
maxHoldMs: 1,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expect(
|
||||
acquireSessionWriteLock({
|
||||
sessionFile,
|
||||
timeoutMs: 5,
|
||||
staleMs: 60_000,
|
||||
allowReentrant: false,
|
||||
}),
|
||||
).rejects.toThrow(/session file locked/);
|
||||
await expect(fs.access(lockPath)).resolves.toBeUndefined();
|
||||
await lock.release();
|
||||
});
|
||||
});
|
||||
|
||||
it("watchdog releases stale in-process locks", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-"));
|
||||
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
@@ -457,6 +517,47 @@ describe("acquireSessionWriteLock", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not clean live OpenClaw locks just because holder max hold expired", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-policy-"));
|
||||
const sessionsDir = path.join(root, "sessions");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
const nowMs = Date.now();
|
||||
const lockPath = path.join(sessionsDir, "held-past-max.jsonl.lock");
|
||||
|
||||
try {
|
||||
await fs.writeFile(
|
||||
lockPath,
|
||||
JSON.stringify({
|
||||
pid: process.pid,
|
||||
createdAt: new Date(nowMs - 30_000).toISOString(),
|
||||
maxHoldMs: 10_000,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await cleanStaleLockFiles({
|
||||
sessionsDir,
|
||||
staleMs: 60_000,
|
||||
nowMs,
|
||||
removeStale: true,
|
||||
readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "agent"],
|
||||
});
|
||||
|
||||
expect(lockCleanupRecords(result.locks)).toEqual([
|
||||
{
|
||||
name: "held-past-max.jsonl.lock",
|
||||
removed: false,
|
||||
stale: false,
|
||||
staleReasons: [],
|
||||
},
|
||||
]);
|
||||
expect(result.cleaned).toEqual([]);
|
||||
await expect(fs.access(lockPath)).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps max hold for effectively no-timeout runs", () => {
|
||||
expect(
|
||||
resolveSessionLockMaxHoldFromTimeout({
|
||||
|
||||
@@ -12,6 +12,7 @@ type LockFilePayload = {
|
||||
createdAt?: string;
|
||||
/** Process start time in clock ticks (from /proc/pid/stat field 22). */
|
||||
starttime?: number;
|
||||
maxHoldMs?: number;
|
||||
};
|
||||
|
||||
function isValidLockNumber(value: unknown): value is number {
|
||||
@@ -378,6 +379,9 @@ async function readLockPayload(lockPath: string): Promise<LockFilePayload | null
|
||||
if (isValidLockNumber(parsed.starttime)) {
|
||||
payload.starttime = parsed.starttime;
|
||||
}
|
||||
if (isValidLockNumber(parsed.maxHoldMs) && parsed.maxHoldMs > 0) {
|
||||
payload.maxHoldMs = parsed.maxHoldMs;
|
||||
}
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -449,6 +453,7 @@ function inspectLockPayload(
|
||||
payload: LockFilePayload | null,
|
||||
staleMs: number,
|
||||
nowMs: number,
|
||||
opts: { respectMaxHold?: boolean } = {},
|
||||
): LockInspectionDetails {
|
||||
const pid = isValidLockNumber(payload?.pid) && payload.pid > 0 ? payload.pid : null;
|
||||
const pidAlive = pid !== null ? isPidAlive(pid) : false;
|
||||
@@ -481,6 +486,16 @@ function inspectLockPayload(
|
||||
} else if (ageMs > staleMs) {
|
||||
staleReasons.push("too-old");
|
||||
}
|
||||
const holderMaxHoldMs =
|
||||
isValidLockNumber(payload?.maxHoldMs) && payload.maxHoldMs > 0 ? payload.maxHoldMs : undefined;
|
||||
if (
|
||||
opts.respectMaxHold === true &&
|
||||
typeof holderMaxHoldMs === "number" &&
|
||||
ageMs !== null &&
|
||||
ageMs > holderMaxHoldMs
|
||||
) {
|
||||
staleReasons.push("hold-exceeded");
|
||||
}
|
||||
|
||||
return {
|
||||
pid,
|
||||
@@ -552,39 +567,6 @@ function sessionLockHeldByThisProcess(normalizedSessionFile: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
async function removeReportedStaleLockIfStillStale(params: {
|
||||
lockPath: string;
|
||||
normalizedSessionFile: string;
|
||||
staleMs: number;
|
||||
readOwnerProcessArgs?: SessionLockOwnerProcessArgsReader;
|
||||
}): Promise<boolean> {
|
||||
const nowMs = Date.now();
|
||||
const payload = await readLockPayload(params.lockPath);
|
||||
if (payload === null) {
|
||||
try {
|
||||
await fs.access(params.lockPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const inspected = inspectLockPayloadForSession({
|
||||
payload,
|
||||
staleMs: params.staleMs,
|
||||
nowMs,
|
||||
heldByThisProcess: sessionLockHeldByThisProcess(params.normalizedSessionFile),
|
||||
reclaimLockWithoutStarttime: true,
|
||||
readOwnerProcessArgs: params.readOwnerProcessArgs ?? readProcessArgsSync,
|
||||
});
|
||||
if (!(await shouldReclaimContendedLockFile(params.lockPath, inspected, params.staleMs, nowMs))) {
|
||||
return false;
|
||||
}
|
||||
await fs.rm(params.lockPath, { force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldTreatAsOrphanSelfLock(params: {
|
||||
payload: LockFilePayload | null;
|
||||
heldByThisProcess: boolean;
|
||||
@@ -616,8 +598,11 @@ function inspectLockPayloadForSession(params: {
|
||||
heldByThisProcess: boolean;
|
||||
reclaimLockWithoutStarttime: boolean;
|
||||
readOwnerProcessArgs: SessionLockOwnerProcessArgsReader;
|
||||
respectMaxHold?: boolean;
|
||||
}): LockInspectionDetails {
|
||||
const inspected = inspectLockPayload(params.payload, params.staleMs, params.nowMs);
|
||||
const inspected = inspectLockPayload(params.payload, params.staleMs, params.nowMs, {
|
||||
respectMaxHold: params.respectMaxHold,
|
||||
});
|
||||
if (
|
||||
shouldTreatAsOrphanSelfLock({
|
||||
payload: params.payload,
|
||||
@@ -745,18 +730,20 @@ export async function acquireSessionWriteLock(params: {
|
||||
const normalizedSessionFile = await resolveNormalizedSessionFile(sessionFile);
|
||||
const lockPath = `${normalizedSessionFile}.lock`;
|
||||
await fs.mkdir(sessionDir, { recursive: true });
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const lock = await SESSION_LOCKS.acquire(sessionFile, {
|
||||
staleMs,
|
||||
timeoutMs,
|
||||
retry: { minTimeout: 50, maxTimeout: 1000, factor: 1 },
|
||||
staleRecovery: "remove-if-unchanged",
|
||||
allowReentrant,
|
||||
metadata: { maxHoldMs },
|
||||
payload: () => {
|
||||
const createdAt = new Date().toISOString();
|
||||
const starttime = resolveProcessStartTimeForLock(process.pid);
|
||||
const lockPayload: LockFilePayload = { pid: process.pid, createdAt };
|
||||
const lockPayload: LockFilePayload = { pid: process.pid, createdAt, maxHoldMs };
|
||||
if (starttime !== null) {
|
||||
lockPayload.starttime = starttime;
|
||||
}
|
||||
@@ -770,24 +757,27 @@ export async function acquireSessionWriteLock(params: {
|
||||
heldByThisProcess,
|
||||
reclaimLockWithoutStarttime: true,
|
||||
readOwnerProcessArgs: readProcessArgsSync,
|
||||
respectMaxHold: !heldByThisProcess,
|
||||
});
|
||||
return await shouldReclaimContendedLockFile(lockPath, inspected, staleMs, nowMs);
|
||||
},
|
||||
shouldRemoveStaleLock: async ({ lockPath, normalizedTargetPath, payload }) => {
|
||||
const nowMs = Date.now();
|
||||
const heldByThisProcess = sessionLockHeldByThisProcess(normalizedTargetPath);
|
||||
const inspected = inspectLockPayloadForSession({
|
||||
payload: payload as LockFilePayload | null,
|
||||
staleMs,
|
||||
nowMs,
|
||||
heldByThisProcess,
|
||||
reclaimLockWithoutStarttime: true,
|
||||
readOwnerProcessArgs: readProcessArgsSync,
|
||||
respectMaxHold: !heldByThisProcess,
|
||||
});
|
||||
return await shouldReclaimContendedLockFile(lockPath, inspected, staleMs, nowMs);
|
||||
},
|
||||
});
|
||||
return { release: lock.release };
|
||||
} catch (err) {
|
||||
if (isFileLockError(err, "file_lock_stale")) {
|
||||
const staleLockPath = (err as { lockPath?: string }).lockPath ?? lockPath;
|
||||
if (
|
||||
await removeReportedStaleLockIfStillStale({
|
||||
lockPath: staleLockPath,
|
||||
normalizedSessionFile,
|
||||
staleMs,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!isFileLockError(err, "file_lock_timeout")) {
|
||||
throw err;
|
||||
}
|
||||
@@ -802,6 +792,7 @@ export async function acquireSessionWriteLock(params: {
|
||||
export const testing = {
|
||||
cleanupSignals: [...CLEANUP_SIGNALS],
|
||||
handleTerminationSignal,
|
||||
inspectLockPayloadForTest: inspectLockPayload,
|
||||
releaseAllLocksSync,
|
||||
runLockWatchdogCheck,
|
||||
setProcessStartTimeResolverForTest(resolver: ((pid: number) => number | null) | null): void {
|
||||
|
||||
@@ -94,7 +94,9 @@ type BundledChannelLoadContext = {
|
||||
|
||||
const log = createSubsystemLogger("channels");
|
||||
const MAX_BUNDLED_CHANNEL_LOAD_CONTEXTS = 32;
|
||||
const MAX_BUNDLED_CHANNEL_BOUNDARY_ROOTS = 256;
|
||||
const bundledChannelLoadContextsByRoot = new Map<string, BundledChannelLoadContext>();
|
||||
const bundledChannelBoundaryRoots = new Map<string, string>();
|
||||
const sourceBundledEntryLoaderCache: PluginModuleLoaderCache = new Map();
|
||||
|
||||
function isSourceModulePath(modulePath: string): boolean {
|
||||
@@ -161,27 +163,55 @@ function resolveBundledChannelBoundaryRoot(params: {
|
||||
metadata: BundledChannelPluginMetadata;
|
||||
modulePath: string;
|
||||
}): string {
|
||||
const cacheKey = [
|
||||
params.packageRoot,
|
||||
params.pluginsDir ?? "",
|
||||
params.metadata.dirName,
|
||||
params.modulePath,
|
||||
].join("\0");
|
||||
const cached = bundledChannelBoundaryRoots.get(cacheKey);
|
||||
if (cached) {
|
||||
bundledChannelBoundaryRoots.delete(cacheKey);
|
||||
bundledChannelBoundaryRoots.set(cacheKey, cached);
|
||||
return cached;
|
||||
}
|
||||
const isModuleUnderRoot = (root: string) => isPathInside(path.resolve(root), params.modulePath);
|
||||
const overrideRoot = params.pluginsDir
|
||||
? path.resolve(params.pluginsDir, params.metadata.dirName)
|
||||
: null;
|
||||
let boundaryRoot: string;
|
||||
if (overrideRoot && isModuleUnderRoot(overrideRoot)) {
|
||||
return overrideRoot;
|
||||
boundaryRoot = overrideRoot;
|
||||
} else {
|
||||
const distRoot = path.resolve(
|
||||
params.packageRoot,
|
||||
"dist",
|
||||
"extensions",
|
||||
params.metadata.dirName,
|
||||
);
|
||||
if (isModuleUnderRoot(distRoot)) {
|
||||
boundaryRoot = distRoot;
|
||||
} else {
|
||||
const distRuntimeRoot = path.resolve(
|
||||
params.packageRoot,
|
||||
"dist-runtime",
|
||||
"extensions",
|
||||
params.metadata.dirName,
|
||||
);
|
||||
boundaryRoot = isModuleUnderRoot(distRuntimeRoot)
|
||||
? distRuntimeRoot
|
||||
: path.resolve(params.packageRoot, "extensions", params.metadata.dirName);
|
||||
}
|
||||
}
|
||||
const distRoot = path.resolve(params.packageRoot, "dist", "extensions", params.metadata.dirName);
|
||||
if (isModuleUnderRoot(distRoot)) {
|
||||
return distRoot;
|
||||
bundledChannelBoundaryRoots.set(cacheKey, boundaryRoot);
|
||||
while (bundledChannelBoundaryRoots.size > MAX_BUNDLED_CHANNEL_BOUNDARY_ROOTS) {
|
||||
const oldestKey = bundledChannelBoundaryRoots.keys().next().value;
|
||||
if (oldestKey === undefined) {
|
||||
break;
|
||||
}
|
||||
bundledChannelBoundaryRoots.delete(oldestKey);
|
||||
}
|
||||
const distRuntimeRoot = path.resolve(
|
||||
params.packageRoot,
|
||||
"dist-runtime",
|
||||
"extensions",
|
||||
params.metadata.dirName,
|
||||
);
|
||||
if (isModuleUnderRoot(distRuntimeRoot)) {
|
||||
return distRuntimeRoot;
|
||||
}
|
||||
return path.resolve(params.packageRoot, "extensions", params.metadata.dirName);
|
||||
return boundaryRoot;
|
||||
}
|
||||
|
||||
function resolveBundledChannelScanDir(rootScope: BundledChannelRootScope): string | undefined {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import path from "node:path";
|
||||
import { MANIFEST_KEY } from "../../compat/legacy-names.js";
|
||||
import type { PluginInstallRecord } from "../../config/types.plugins.js";
|
||||
import { tryReadJsonSync } from "../../infra/json-files.js";
|
||||
import { isPrereleaseSemverVersion, parseRegistryNpmSpec } from "../../infra/npm-registry-spec.js";
|
||||
import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js";
|
||||
import { listChannelCatalogEntries } from "../../plugins/channel-catalog-registry.js";
|
||||
import type { PluginDiscoveryResult } from "../../plugins/discovery.js";
|
||||
import {
|
||||
describePluginInstallSource,
|
||||
type PluginInstallSourceInfo,
|
||||
@@ -52,6 +54,8 @@ type CatalogOptions = {
|
||||
officialCatalogPaths?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
excludeWorkspace?: boolean;
|
||||
installRecords?: Record<string, PluginInstallRecord>;
|
||||
discovery?: PluginDiscoveryResult;
|
||||
};
|
||||
|
||||
const ORIGIN_PRIORITY: Record<PluginOrigin, number> = {
|
||||
@@ -73,6 +77,7 @@ type ExternalCatalogEntry = {
|
||||
const ENV_CATALOG_PATHS = ["OPENCLAW_PLUGIN_CATALOG_PATHS", "OPENCLAW_MPM_CATALOG_PATHS"];
|
||||
const OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH = path.join("dist", "channel-catalog.json");
|
||||
const officialCatalogEntriesByPath = new Map<string, ExternalCatalogEntry[] | null>();
|
||||
const externalCatalogEntriesByPath = new Map<string, ExternalCatalogEntry[] | null>();
|
||||
|
||||
type ManifestKey = typeof MANIFEST_KEY;
|
||||
|
||||
@@ -129,17 +134,33 @@ function loadExternalCatalogEntries(options: CatalogOptions): ExternalCatalogEnt
|
||||
const paths = resolveExternalCatalogPaths(options).map((rawPath) =>
|
||||
resolveUserPath(rawPath, options.env ?? process.env),
|
||||
);
|
||||
return loadCatalogEntriesFromPaths(paths);
|
||||
return loadCatalogEntriesFromPaths(paths, externalCatalogEntriesByPath);
|
||||
}
|
||||
|
||||
function loadCatalogEntriesFromPaths(paths: Iterable<string>): ExternalCatalogEntry[] {
|
||||
function readCatalogEntriesFromPath(resolvedPath: string): ExternalCatalogEntry[] | null {
|
||||
const payload = tryReadJsonSync(resolvedPath);
|
||||
return payload === null ? null : parseCatalogEntries(payload);
|
||||
}
|
||||
|
||||
function loadCatalogEntriesFromPaths(
|
||||
paths: Iterable<string>,
|
||||
cache?: Map<string, ExternalCatalogEntry[] | null>,
|
||||
): ExternalCatalogEntry[] {
|
||||
const entries: ExternalCatalogEntry[] = [];
|
||||
for (const resolvedPath of paths) {
|
||||
const payload = tryReadJsonSync(resolvedPath);
|
||||
if (payload === null) {
|
||||
if (cache?.has(resolvedPath)) {
|
||||
const cached = cache.get(resolvedPath);
|
||||
if (cached) {
|
||||
entries.push(...cached);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
entries.push(...parseCatalogEntries(payload));
|
||||
const parsed = readCatalogEntriesFromPath(resolvedPath);
|
||||
cache?.set(resolvedPath, parsed);
|
||||
if (parsed === null) {
|
||||
continue;
|
||||
}
|
||||
entries.push(...parsed);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
@@ -399,6 +420,8 @@ export function listChannelPluginCatalogEntries(
|
||||
const manifestEntries = listChannelCatalogEntries({
|
||||
workspaceDir: options.workspaceDir,
|
||||
env: options.env,
|
||||
installRecords: options.installRecords,
|
||||
discovery: options.discovery,
|
||||
});
|
||||
const resolved = new Map<string, { entry: ChannelPluginCatalogEntry; priority: number }>();
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { waitForever } from "./wait.js";
|
||||
|
||||
describe("waitForever", () => {
|
||||
it("creates an unref'ed interval and returns a pending promise", () => {
|
||||
it("keeps the event loop alive (ref'd interval) and returns a pending promise", () => {
|
||||
const unref = vi.fn();
|
||||
const interval = { unref } as unknown as ReturnType<typeof setInterval>;
|
||||
const setIntervalSpy = vi.spyOn(global, "setInterval").mockReturnValue(interval);
|
||||
@@ -20,7 +20,11 @@ describe("waitForever", () => {
|
||||
const [callback, delay] = setIntervalSpy.mock.calls[0] ?? [];
|
||||
expect(typeof callback).toBe("function");
|
||||
expect(delay).toBe(1_000_000);
|
||||
expect(unref).toHaveBeenCalledTimes(1);
|
||||
// Regression guard for the previous `.unref()` bug: an unref'd interval
|
||||
// does NOT keep the event loop alive, so `await waitForever()` would
|
||||
// exit immediately with code 13 ("unsettled top-level await"). The
|
||||
// function must NOT unref the interval.
|
||||
expect(unref).not.toHaveBeenCalled();
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
} finally {
|
||||
setIntervalSpy.mockRestore();
|
||||
|
||||
@@ -25,6 +25,8 @@ type GatewayLoopStart = (params?: { startupStartedAt?: number }) => Promise<unkn
|
||||
const runGatewayLoop = vi.fn(async ({ start }: { start: GatewayLoopStart }) => {
|
||||
await start();
|
||||
});
|
||||
const normalizeStateDirEnv = vi.fn((_env?: NodeJS.ProcessEnv) => undefined);
|
||||
const callOrder = vi.hoisted(() => [] as string[]);
|
||||
const gatewayLogMessages = vi.hoisted(() => [] as string[]);
|
||||
const configState = vi.hoisted(() => ({
|
||||
cfg: {} as Record<string, unknown>,
|
||||
@@ -65,6 +67,7 @@ vi.mock("../../config/config.js", () => ({
|
||||
|
||||
vi.mock("../../config/paths.js", () => ({
|
||||
CONFIG_PATH: "/tmp/openclaw-test-missing-config.json",
|
||||
normalizeStateDirEnv: (env?: NodeJS.ProcessEnv) => normalizeStateDirEnv(env),
|
||||
resolveStateDir: () => "/tmp",
|
||||
resolveGatewayPort: (cfg?: { gateway?: { port?: number } }) => cfg?.gateway?.port ?? 18789,
|
||||
}));
|
||||
@@ -236,6 +239,8 @@ describe("gateway run option collisions", () => {
|
||||
waitForPortBindable.mockClear();
|
||||
ensureDevGatewayConfig.mockClear();
|
||||
runGatewayLoop.mockClear();
|
||||
normalizeStateDirEnv.mockReset();
|
||||
callOrder.length = 0;
|
||||
});
|
||||
|
||||
async function runGatewayCli(argv: string[]) {
|
||||
@@ -265,6 +270,14 @@ describe("gateway run option collisions", () => {
|
||||
}
|
||||
|
||||
it("forwards parent-captured options to `gateway run` subcommand", async () => {
|
||||
normalizeStateDirEnv.mockImplementation((_env?: NodeJS.ProcessEnv) => {
|
||||
callOrder.push("normalize");
|
||||
});
|
||||
startGatewayServer.mockImplementationOnce(async (_port: number, _opts?: unknown) => {
|
||||
callOrder.push("start");
|
||||
return { close: vi.fn(async () => {}) };
|
||||
});
|
||||
|
||||
await runGatewayCli([
|
||||
"gateway",
|
||||
"run",
|
||||
@@ -283,6 +296,8 @@ describe("gateway run option collisions", () => {
|
||||
).toEqual({ intervalMs: 150, timeoutMs: 3000 });
|
||||
expect(setGatewayWsLogStyle).toHaveBeenCalledWith("full");
|
||||
expect(gatewayStartOptions().auth?.token).toBe("tok_run");
|
||||
expect(normalizeStateDirEnv).toHaveBeenCalledWith(process.env);
|
||||
expect(callOrder).toEqual(["normalize", "start"]);
|
||||
});
|
||||
|
||||
it("marks service-mode gateway descendants with the live gateway pid", async () => {
|
||||
@@ -297,6 +312,7 @@ describe("gateway run option collisions", () => {
|
||||
expect(process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV]).toBe(String(process.pid));
|
||||
},
|
||||
);
|
||||
expect(normalizeStateDirEnv).toHaveBeenCalledWith(process.env);
|
||||
});
|
||||
|
||||
it("blocks --force port cleanup from an older binary with newer config", async () => {
|
||||
|
||||
@@ -9,7 +9,12 @@ import type {
|
||||
GatewayTailscaleMode,
|
||||
ReadConfigFileSnapshotWithPluginMetadataResult,
|
||||
} from "../../config/config.js";
|
||||
import { CONFIG_PATH, resolveGatewayPort, resolveStateDir } from "../../config/paths.js";
|
||||
import {
|
||||
CONFIG_PATH,
|
||||
normalizeStateDirEnv,
|
||||
resolveGatewayPort,
|
||||
resolveStateDir,
|
||||
} from "../../config/paths.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { hasConfiguredSecretInput } from "../../config/types.secrets.js";
|
||||
import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../../daemon/constants.js";
|
||||
@@ -465,6 +470,7 @@ async function maybeWriteGatewayStartupFailureBundle(err: unknown): Promise<void
|
||||
}
|
||||
|
||||
export async function runGatewayCommand(opts: GatewayRunOpts) {
|
||||
normalizeStateDirEnv(process.env);
|
||||
installQaParentWatchdog();
|
||||
if (process.env.OPENCLAW_SERVICE_MARKER?.trim()) {
|
||||
process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV] = String(process.pid);
|
||||
|
||||
@@ -167,6 +167,7 @@ export function registerOnboardCommand(program: Command): void {
|
||||
.option("--skip-search", "Skip search provider setup")
|
||||
.option("--skip-health", "Skip health check")
|
||||
.option("--skip-ui", "Skip Control UI/TUI prompts")
|
||||
.option("--suppress-gateway-token-output", "Suppress token-bearing Gateway/UI output")
|
||||
.option("--skip-hooks", "Skip hook setup")
|
||||
.option("--node-manager <name>", "Node manager for skills: npm|pnpm|bun")
|
||||
.option("--import-from <provider>", "Migration provider to run during onboarding")
|
||||
@@ -246,6 +247,7 @@ export function registerOnboardCommand(program: Command): void {
|
||||
skipSearch: Boolean(opts.skipSearch),
|
||||
skipHealth: Boolean(opts.skipHealth),
|
||||
skipUi: Boolean(opts.skipUi),
|
||||
suppressGatewayTokenOutput: Boolean(opts.suppressGatewayTokenOutput),
|
||||
skipHooks: Boolean(opts.skipHooks),
|
||||
nodeManager: opts.nodeManager as NodeManagerChoice | undefined,
|
||||
importFrom: opts.importFrom as string | undefined,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
export function waitForever() {
|
||||
// Keep event loop alive via an unref'ed interval plus a pending promise.
|
||||
const interval = setInterval(() => {}, 1_000_000);
|
||||
interval.unref();
|
||||
// Keep the event loop alive with a ref'd interval. A pending Promise is not
|
||||
// an active handle on its own, so without the interval, Node exits the
|
||||
// process with code 13 ("unsettled top-level await") as soon as nothing
|
||||
// else is keeping the loop open — defeating the "wait forever" contract.
|
||||
// The handle is intentionally not retained: there is no caller-visible way
|
||||
// to stop a "forever" wait, and the interval lives for the lifetime of the
|
||||
// process.
|
||||
setInterval(() => {}, 1_000_000);
|
||||
return new Promise<void>(() => {
|
||||
/* never resolve */
|
||||
});
|
||||
|
||||
@@ -79,6 +79,7 @@ export type OnboardOptions = OnboardDynamicProviderOptions & {
|
||||
skipSearch?: boolean;
|
||||
skipHealth?: boolean;
|
||||
skipUi?: boolean;
|
||||
suppressGatewayTokenOutput?: boolean;
|
||||
skipHooks?: boolean;
|
||||
nodeManager?: NodeManagerChoice;
|
||||
remoteUrl?: string;
|
||||
|
||||
@@ -106,6 +106,10 @@ function logConfigDocBaselineDebug(message: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function compareConfigDocBaselineStrings(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function resolveRepoRoot(): string {
|
||||
const fromPackage = resolveOpenClawPackageRootSync({
|
||||
cwd: path.dirname(fileURLToPath(import.meta.url)),
|
||||
@@ -152,7 +156,7 @@ function normalizeJsonValue(value: unknown): JsonValue | undefined {
|
||||
}
|
||||
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.toSorted(([left], [right]) => compareConfigDocBaselineStrings(left, right))
|
||||
.map(([key, entry]) => {
|
||||
const normalized = normalizeJsonValue(entry);
|
||||
return normalized === undefined ? null : ([key, normalized] as const);
|
||||
@@ -266,7 +270,7 @@ function normalizeTypeValue(value: string | string[] | undefined): string | stri
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const normalized = [...new Set(value)].toSorted((left, right) => left.localeCompare(right));
|
||||
const normalized = [...new Set(value)].toSorted(compareConfigDocBaselineStrings);
|
||||
return normalized.length === 1 ? normalized[0] : normalized;
|
||||
}
|
||||
return value;
|
||||
@@ -312,7 +316,7 @@ function mergeJsonValueArrays(
|
||||
merged.set(JSON.stringify(value), value);
|
||||
}
|
||||
return [...merged.entries()]
|
||||
.toSorted(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
||||
.toSorted(([leftKey], [rightKey]) => compareConfigDocBaselineStrings(leftKey, rightKey))
|
||||
.map(([, value]) => value);
|
||||
}
|
||||
|
||||
@@ -335,9 +339,7 @@ function mergeConfigDocBaselineEntry(
|
||||
defaultValue,
|
||||
deprecated: current.deprecated || next.deprecated,
|
||||
sensitive: current.sensitive || next.sensitive,
|
||||
tags: [...new Set([...current.tags, ...next.tags])].toSorted((left, right) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
tags: [...new Set([...current.tags, ...next.tags])].toSorted(compareConfigDocBaselineStrings),
|
||||
label,
|
||||
help,
|
||||
hasChildren: current.hasChildren || next.hasChildren,
|
||||
@@ -416,7 +418,7 @@ export function collectConfigDocBaselineEntries(
|
||||
defaultValue: normalizeJsonValue(schema.default),
|
||||
deprecated: schema.deprecated === true,
|
||||
sensitive: hint?.sensitive === true,
|
||||
tags: [...(hint?.tags ?? [])].toSorted((left, right) => left.localeCompare(right)),
|
||||
tags: [...(hint?.tags ?? [])].toSorted(compareConfigDocBaselineStrings),
|
||||
label: hint?.label,
|
||||
help: hint?.help,
|
||||
hasChildren: resolveSchemaHasChildren(schema),
|
||||
@@ -424,8 +426,8 @@ export function collectConfigDocBaselineEntries(
|
||||
}
|
||||
|
||||
const requiredKeys = new Set(schema.required ?? []);
|
||||
for (const key of Object.keys(schema.properties ?? {}).toSorted((left, right) =>
|
||||
left.localeCompare(right),
|
||||
for (const key of Object.keys(schema.properties ?? {}).toSorted(
|
||||
compareConfigDocBaselineStrings,
|
||||
)) {
|
||||
const child = asSchemaObject(schema.properties?.[key]);
|
||||
if (!child) {
|
||||
@@ -488,7 +490,9 @@ export function dedupeConfigDocBaselineEntries(
|
||||
const current = byPath.get(entry.path);
|
||||
byPath.set(entry.path, current ? mergeConfigDocBaselineEntry(current, entry) : entry);
|
||||
}
|
||||
return [...byPath.values()].toSorted((left, right) => left.path.localeCompare(right.path));
|
||||
return [...byPath.values()].toSorted((left, right) =>
|
||||
compareConfigDocBaselineStrings(left.path, right.path),
|
||||
);
|
||||
}
|
||||
|
||||
function splitConfigDocBaselineEntries(entries: ConfigDocBaselineEntry[]): {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { withTempDir } from "../test-helpers/temp-dir.js";
|
||||
import {
|
||||
DEFAULT_GATEWAY_PORT,
|
||||
normalizeStateDirEnv,
|
||||
resolveDefaultConfigCandidates,
|
||||
resolveConfigPathCandidate,
|
||||
resolveConfigPath,
|
||||
@@ -123,6 +124,30 @@ describe("state + config path candidates", () => {
|
||||
expect(resolveStateDir(env, () => "/home/test")).toBe(path.resolve("/new/state"));
|
||||
});
|
||||
|
||||
it("normalizes relative OPENCLAW_STATE_DIR overrides to absolute paths", () => {
|
||||
const env = {
|
||||
OPENCLAW_STATE_DIR: ".",
|
||||
OPENCLAW_HOME: "/srv/openclaw-home",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
normalizeStateDirEnv(env);
|
||||
|
||||
expect(env.OPENCLAW_STATE_DIR).toBe(path.resolve("."));
|
||||
});
|
||||
|
||||
it("pins a relative state-dir override before later resolution", () => {
|
||||
const env = {
|
||||
OPENCLAW_STATE_DIR: "relative-state",
|
||||
OPENCLAW_HOME: "/srv/openclaw-home",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
normalizeStateDirEnv(env);
|
||||
const normalized = env.OPENCLAW_STATE_DIR;
|
||||
|
||||
expect(normalized).toBe(path.resolve("relative-state"));
|
||||
expect(resolveStateDir(env, () => "/srv/other-home")).toBe(normalized);
|
||||
});
|
||||
|
||||
it("uses OPENCLAW_HOME for default state/config locations", () => {
|
||||
const env = {
|
||||
OPENCLAW_HOME: "/srv/openclaw-home",
|
||||
|
||||
@@ -88,6 +88,14 @@ export function resolveStateDir(
|
||||
return newDir;
|
||||
}
|
||||
|
||||
export function normalizeStateDirEnv(env: NodeJS.ProcessEnv = process.env): void {
|
||||
const effectiveHomedir = () => resolveRequiredHomeDir(env, envHomedir(env));
|
||||
const openclawOverride = env.OPENCLAW_STATE_DIR?.trim();
|
||||
if (openclawOverride) {
|
||||
env.OPENCLAW_STATE_DIR = resolveUserPath(openclawOverride, env, effectiveHomedir);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUserPath(
|
||||
input: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
|
||||
@@ -323,6 +323,9 @@ describe("config schema", () => {
|
||||
const listHint = res.uiHints["agents.list.*.heartbeat.target"];
|
||||
expect(defaultsHint?.help).toContain("imessage");
|
||||
expect(defaultsHint?.help).toContain("last");
|
||||
expect(defaultsHint?.help).not.toContain("wecom");
|
||||
expect(defaultsHint?.help).not.toContain("openclaw-weixin");
|
||||
expect(defaultsHint?.help).not.toContain("yuanbao");
|
||||
expect(listHint?.help).toContain("imessage");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import crypto from "node:crypto";
|
||||
import { CHANNEL_IDS } from "../channels/ids.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
||||
import { GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA } from "./bundled-channel-config-metadata.generated.js";
|
||||
import { computeBaseConfigSchemaResponse } from "./schema-base.js";
|
||||
@@ -361,14 +360,6 @@ function applyChannelHints(hints: ConfigUiHints, channels: ChannelUiMetadata[]):
|
||||
function listHeartbeatTargetChannels(channels: ChannelUiMetadata[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
for (const id of CHANNEL_IDS) {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(id);
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
ordered.push(normalized);
|
||||
}
|
||||
for (const channel of channels) {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(channel.id);
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
|
||||
@@ -141,7 +141,8 @@ function runDockerSetup(
|
||||
cwd: sandbox.rootDir,
|
||||
env: createEnv(sandbox, overrides),
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -284,8 +285,11 @@ describe("scripts/docker/setup.sh", () => {
|
||||
const log = await readDockerLog(activeSandbox);
|
||||
expect(log).toContain("--build-arg OPENCLAW_IMAGE_APT_PACKAGES=curl wget");
|
||||
expect(log).toContain(
|
||||
`run --rm --no-deps ${prestartContainerEnvFlags} --entrypoint node openclaw-gateway dist/index.js onboard --mode local --no-install-daemon`,
|
||||
`run --rm --no-deps ${prestartContainerEnvFlags} --entrypoint node openclaw-gateway dist/index.js onboard --mode local --no-install-daemon --gateway-auth token --gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN --skip-ui --suppress-gateway-token-output`,
|
||||
);
|
||||
expect(result.stdout).toContain("Gateway token: stored in Docker environment/config");
|
||||
expect(result.stdout).not.toContain("test-token");
|
||||
expect(result.stdout).not.toContain("#token=");
|
||||
expect(log).toContain(
|
||||
`run --rm --no-deps ${prestartContainerEnvFlags} --entrypoint node openclaw-gateway dist/index.js config set --batch-json [{"path":"gateway.mode","value":"local"},{"path":"gateway.bind","value":"lan"},{"path":"gateway.controlUi.allowedOrigins","value":["http://localhost:18789","http://127.0.0.1:18789"]}]`,
|
||||
);
|
||||
@@ -702,7 +706,9 @@ describe("scripts/docker/setup.sh", () => {
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const log = await readDockerLog(activeSandbox);
|
||||
expect(log).toContain("onboard --mode local --no-install-daemon");
|
||||
expect(log).toContain(
|
||||
"onboard --mode local --no-install-daemon --gateway-auth token --gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN --skip-ui --suppress-gateway-token-output",
|
||||
);
|
||||
const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8");
|
||||
expect(envFile).toMatch(/OPENCLAW_SKIP_ONBOARDING=\n/);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js";
|
||||
import { buildHistoryContextFromEntries } from "../auto-reply/reply/history.js";
|
||||
import { extractTextFromChatContent } from "../shared/chat-content.js";
|
||||
import { buildAgentMessageFromConversationEntries } from "./agent-prompt.js";
|
||||
@@ -94,4 +95,74 @@ describe("gateway agent prompt", () => {
|
||||
|
||||
expect(buildAgentMessageFromConversationEntries([...entries])).toBe(expected);
|
||||
});
|
||||
it("omits internal stream-error placeholder text from replay history", () => {
|
||||
const entries = [
|
||||
{ role: "user", entry: { sender: "User", body: "first" } },
|
||||
{
|
||||
role: "assistant",
|
||||
internalStreamError: true,
|
||||
entry: {
|
||||
sender: "Assistant",
|
||||
body: [{ type: "text", text: STREAM_ERROR_FALLBACK_TEXT }] as unknown as string,
|
||||
},
|
||||
},
|
||||
{ role: "user", entry: { sender: "User", body: "retry" } },
|
||||
] as const;
|
||||
|
||||
const prompt = buildAgentMessageFromConversationEntries([...entries]);
|
||||
expect(prompt).not.toContain(STREAM_ERROR_FALLBACK_TEXT);
|
||||
expect(prompt).not.toContain("Assistant:");
|
||||
expect(prompt).toContain("User: first");
|
||||
expect(prompt).toContain("User: retry");
|
||||
});
|
||||
|
||||
it("preserves ordinary assistant text that merely mentions the stream-error placeholder", () => {
|
||||
const mention = `Diagnostic note: ${STREAM_ERROR_FALLBACK_TEXT}`;
|
||||
const entries = [
|
||||
{ role: "assistant", entry: { sender: "Assistant", body: mention } },
|
||||
{ role: "user", entry: { sender: "User", body: "next" } },
|
||||
] as const;
|
||||
|
||||
const prompt = buildAgentMessageFromConversationEntries([...entries]);
|
||||
expect(prompt).toContain(mention);
|
||||
});
|
||||
|
||||
it("preserves exact stream-error placeholder text from user history", () => {
|
||||
const entries = [
|
||||
{ role: "user", entry: { sender: "User", body: STREAM_ERROR_FALLBACK_TEXT } },
|
||||
{ role: "user", entry: { sender: "User", body: "next" } },
|
||||
] as const;
|
||||
|
||||
const prompt = buildAgentMessageFromConversationEntries([...entries]);
|
||||
expect(prompt).toContain(`User: ${STREAM_ERROR_FALLBACK_TEXT}`);
|
||||
});
|
||||
|
||||
it("preserves exact stream-error placeholder text from assistant history without provenance", () => {
|
||||
const entries = [
|
||||
{ role: "assistant", entry: { sender: "Assistant", body: STREAM_ERROR_FALLBACK_TEXT } },
|
||||
{ role: "user", entry: { sender: "User", body: "next" } },
|
||||
] as const;
|
||||
|
||||
const prompt = buildAgentMessageFromConversationEntries([...entries]);
|
||||
expect(prompt).toContain(`Assistant: ${STREAM_ERROR_FALLBACK_TEXT}`);
|
||||
});
|
||||
|
||||
it("preserves empty tool outputs in replay history", () => {
|
||||
const entries = [
|
||||
{ role: "user", entry: { sender: "User", body: "lookup" } },
|
||||
{ role: "tool", entry: { sender: "Tool:call_1", body: "" } },
|
||||
{ role: "user", entry: { sender: "User", body: "continue" } },
|
||||
] as const;
|
||||
|
||||
const prompt = buildAgentMessageFromConversationEntries([...entries]);
|
||||
expect(prompt).toContain("Tool:call_1: ");
|
||||
expect(prompt).toContain("User: continue");
|
||||
});
|
||||
|
||||
it("preserves current user text that looks like internal display metadata", () => {
|
||||
const body = "[Thu 2026-03-12 07:00 UTC] what happened then?";
|
||||
expect(
|
||||
buildAgentMessageFromConversationEntries([{ role: "user", entry: { sender: "User", body } }]),
|
||||
).toBe(body);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js";
|
||||
import { buildHistoryContextFromEntries, type HistoryEntry } from "../auto-reply/reply/history.js";
|
||||
import { extractTextFromChatContent } from "../shared/chat-content.js";
|
||||
|
||||
export type ConversationEntry = {
|
||||
role: "user" | "assistant" | "tool";
|
||||
entry: HistoryEntry;
|
||||
internalStreamError?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -12,10 +14,22 @@ export type ConversationEntry = {
|
||||
* [object Object] if used directly in a template literal.
|
||||
*/
|
||||
function safeBody(body: unknown): string {
|
||||
if (typeof body === "string") {
|
||||
return body;
|
||||
return typeof body === "string" ? body : (extractTextFromChatContent(body) ?? "");
|
||||
}
|
||||
|
||||
function toPromptEntry(entry: ConversationEntry): HistoryEntry | null {
|
||||
const body = safeBody(entry.entry.body);
|
||||
if (
|
||||
entry.role === "assistant" &&
|
||||
entry.internalStreamError === true &&
|
||||
body.trim() === STREAM_ERROR_FALLBACK_TEXT
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return extractTextFromChatContent(body) ?? "";
|
||||
return {
|
||||
...entry.entry,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAgentMessageFromConversationEntries(entries: ConversationEntry[]): string {
|
||||
@@ -37,20 +51,28 @@ export function buildAgentMessageFromConversationEntries(entries: ConversationEn
|
||||
currentIndex = entries.length - 1;
|
||||
}
|
||||
|
||||
const currentEntry = entries[currentIndex]?.entry;
|
||||
if (!currentEntry) {
|
||||
const currentConversationEntry = entries[currentIndex];
|
||||
const currentEntry = currentConversationEntry?.entry;
|
||||
if (!currentConversationEntry || !currentEntry) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const historyEntries = entries.slice(0, currentIndex).map((e) => e.entry);
|
||||
const historyEntries = entries
|
||||
.slice(0, currentIndex)
|
||||
.map(toPromptEntry)
|
||||
.filter((entry): entry is HistoryEntry => entry !== null);
|
||||
const currentPromptEntry = toPromptEntry(currentConversationEntry);
|
||||
if (!currentPromptEntry) {
|
||||
return "";
|
||||
}
|
||||
if (historyEntries.length === 0) {
|
||||
return safeBody(currentEntry.body);
|
||||
return currentPromptEntry.body;
|
||||
}
|
||||
|
||||
const formatEntry = (entry: HistoryEntry) => `${entry.sender}: ${safeBody(entry.body)}`;
|
||||
const formatEntry = (entry: HistoryEntry) => `${entry.sender}: ${entry.body}`;
|
||||
return buildHistoryContextFromEntries({
|
||||
entries: [...historyEntries, currentEntry],
|
||||
currentMessage: formatEntry(currentEntry),
|
||||
entries: [...historyEntries, currentPromptEntry],
|
||||
currentMessage: formatEntry(currentPromptEntry),
|
||||
formatEntry,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { ClientToolDefinition } from "../agents/command/shared-types.js";
|
||||
import type { ImageContent } from "../agents/command/types.js";
|
||||
import { isClientToolNameConflictError } from "../agents/pi-tool-definition-adapter.js";
|
||||
import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js";
|
||||
import {
|
||||
hasNonzeroUsage,
|
||||
normalizeUsage,
|
||||
@@ -62,6 +63,7 @@ type OpenAiChatMessage = {
|
||||
name?: unknown;
|
||||
tool_call_id?: unknown;
|
||||
tool_calls?: unknown;
|
||||
stopReason?: unknown;
|
||||
};
|
||||
|
||||
type OpenAiChatCompletionRequest = {
|
||||
@@ -654,6 +656,10 @@ function buildAgentPrompt(
|
||||
conversationEntries.push({
|
||||
role: normalizedRole,
|
||||
entry: { sender, body: messageContent },
|
||||
internalStreamError:
|
||||
normalizedRole === "assistant" &&
|
||||
normalizeOptionalString(msg.stopReason) === "error" &&
|
||||
messageContent.trim() === STREAM_ERROR_FALLBACK_TEXT,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2050,6 +2050,20 @@ describe("agent event handler", () => {
|
||||
expect(fallbackPayload.runId).toBe("run-fallback-client");
|
||||
expect(fallbackPayload.data?.phase).toBe("fallback");
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(chatRunState.registry.peek("run-fallback-retry")).toEqual({
|
||||
sessionKey: "session-fallback",
|
||||
clientRunId: "run-fallback-client",
|
||||
});
|
||||
expect(
|
||||
chatBroadcastCalls(broadcast).some(
|
||||
([, payload]) => (payload as { state?: string }).state === "error",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(clearAgentRunContext).not.toHaveBeenCalled();
|
||||
expect(agentRunSeq.get("run-fallback-retry")).toBe(3);
|
||||
|
||||
emitLifecycleEnd(handler, "run-fallback-retry", 4);
|
||||
|
||||
expect(
|
||||
@@ -2110,6 +2124,163 @@ describe("agent event handler", () => {
|
||||
expect(agentRunSeq.has("run-terminal-error")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps deferred lifecycle-error cleanup across later non-terminal events", () => {
|
||||
vi.useFakeTimers();
|
||||
const { broadcast, clearAgentRunContext, agentRunSeq, handler } = createHarness({
|
||||
resolveSessionKeyForRun: () => "session-terminal-error",
|
||||
lifecycleErrorRetryGraceMs: 100,
|
||||
});
|
||||
registerAgentRunContext("run-terminal-late-tool", {
|
||||
sessionKey: "session-terminal-error",
|
||||
});
|
||||
|
||||
handler({
|
||||
runId: "run-terminal-late-tool",
|
||||
seq: 1,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
data: { phase: "start" },
|
||||
});
|
||||
handler({
|
||||
runId: "run-terminal-late-tool",
|
||||
seq: 2,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
data: { phase: "error", error: "request timed out" },
|
||||
});
|
||||
handler({
|
||||
runId: "run-terminal-late-tool",
|
||||
seq: 3,
|
||||
stream: "tool",
|
||||
ts: Date.now(),
|
||||
data: { phase: "result", name: "exec" },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(99);
|
||||
|
||||
expect(clearAgentRunContext).not.toHaveBeenCalled();
|
||||
expect(agentRunSeq.get("run-terminal-late-tool")).toBe(3);
|
||||
expect(
|
||||
chatBroadcastCalls(broadcast).some(
|
||||
([, payload]) => (payload as { state?: string }).state === "error",
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
|
||||
const finalPayload = chatBroadcastCalls(broadcast).at(-1)?.[1] as {
|
||||
state?: string;
|
||||
runId?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
expect(finalPayload.state).toBe("error");
|
||||
expect(finalPayload.runId).toBe("run-terminal-late-tool");
|
||||
expect(finalPayload.errorMessage).toContain("request timed out");
|
||||
expect(clearAgentRunContext).toHaveBeenCalledWith("run-terminal-late-tool");
|
||||
expect(agentRunSeq.has("run-terminal-late-tool")).toBe(false);
|
||||
expect(
|
||||
persistGatewaySessionLifecycleEventMock.mock.calls.some(
|
||||
([params]) =>
|
||||
(params as { event?: { data?: { phase?: string } } }).event?.data?.phase === "error",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps deferred lifecycle-error cleanup across phase-less lifecycle events", () => {
|
||||
vi.useFakeTimers();
|
||||
const { broadcast, clearAgentRunContext, agentRunSeq, handler } = createHarness({
|
||||
resolveSessionKeyForRun: () => "session-terminal-error",
|
||||
lifecycleErrorRetryGraceMs: 100,
|
||||
});
|
||||
registerAgentRunContext("run-terminal-late-lifecycle", {
|
||||
sessionKey: "session-terminal-error",
|
||||
});
|
||||
|
||||
handler({
|
||||
runId: "run-terminal-late-lifecycle",
|
||||
seq: 1,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
data: { phase: "start" },
|
||||
});
|
||||
handler({
|
||||
runId: "run-terminal-late-lifecycle",
|
||||
seq: 2,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
data: { phase: "error", error: "request timed out" },
|
||||
});
|
||||
handler({
|
||||
runId: "run-terminal-late-lifecycle",
|
||||
seq: 3,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
data: { msg: "status update" },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
const finalPayload = chatBroadcastCalls(broadcast).at(-1)?.[1] as {
|
||||
state?: string;
|
||||
runId?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
expect(finalPayload.state).toBe("error");
|
||||
expect(finalPayload.runId).toBe("run-terminal-late-lifecycle");
|
||||
expect(finalPayload.errorMessage).toContain("request timed out");
|
||||
expect(clearAgentRunContext).toHaveBeenCalledWith("run-terminal-late-lifecycle");
|
||||
expect(agentRunSeq.has("run-terminal-late-lifecycle")).toBe(false);
|
||||
});
|
||||
|
||||
it("cancels deferred lifecycle-error cleanup when the run restarts", () => {
|
||||
vi.useFakeTimers();
|
||||
const { broadcast, clearAgentRunContext, agentRunSeq, handler } = createHarness({
|
||||
resolveSessionKeyForRun: () => "session-terminal-retry",
|
||||
lifecycleErrorRetryGraceMs: 100,
|
||||
});
|
||||
registerAgentRunContext("run-terminal-retry", {
|
||||
sessionKey: "session-terminal-retry",
|
||||
});
|
||||
|
||||
handler({
|
||||
runId: "run-terminal-retry",
|
||||
seq: 1,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
data: { phase: "start" },
|
||||
});
|
||||
handler({
|
||||
runId: "run-terminal-retry",
|
||||
seq: 2,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
data: { phase: "error", error: "attempt failed" },
|
||||
});
|
||||
handler({
|
||||
runId: "run-terminal-retry",
|
||||
seq: 3,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
data: { phase: "start" },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(
|
||||
chatBroadcastCalls(broadcast).some(
|
||||
([, payload]) => (payload as { state?: string }).state === "error",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(clearAgentRunContext).not.toHaveBeenCalled();
|
||||
expect(agentRunSeq.get("run-terminal-retry")).toBe(3);
|
||||
expect(
|
||||
persistGatewaySessionLifecycleEventMock.mock.calls.filter(
|
||||
([params]) =>
|
||||
(params as { event?: { data?: { phase?: string } } }).event?.data?.phase === "error",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("adds detected errorKind to chat lifecycle error payloads", () => {
|
||||
const { broadcast, nodeSendToSession, handler } = createHarness({
|
||||
resolveSessionKeyForRun: () => "session-detected-error",
|
||||
|
||||
@@ -229,7 +229,14 @@ export function createAgentEventHandler({
|
||||
lifecycleErrorRetryGraceMs = AGENT_LIFECYCLE_ERROR_RETRY_GRACE_MS,
|
||||
isChatSendRunActive = () => false,
|
||||
}: AgentEventHandlerOptions) {
|
||||
const pendingTerminalLifecycleErrors = new Map<string, NodeJS.Timeout>();
|
||||
type TerminalLifecycleOptions = { skipChatErrorFinal?: boolean };
|
||||
type PendingTerminalLifecycleError = {
|
||||
timer: NodeJS.Timeout;
|
||||
event: AgentEventPayload;
|
||||
opts?: TerminalLifecycleOptions;
|
||||
};
|
||||
|
||||
const pendingTerminalLifecycleErrors = new Map<string, PendingTerminalLifecycleError>();
|
||||
|
||||
type AgentTextThrottleStream = "assistant" | "thinking";
|
||||
|
||||
@@ -263,7 +270,7 @@ export function createAgentEventHandler({
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending);
|
||||
clearTimeout(pending.timer);
|
||||
pendingTerminalLifecycleErrors.delete(runId);
|
||||
};
|
||||
|
||||
@@ -362,10 +369,7 @@ export function createAgentEventHandler({
|
||||
};
|
||||
};
|
||||
|
||||
const finalizeLifecycleEvent = (
|
||||
evt: AgentEventPayload,
|
||||
opts?: { skipChatErrorFinal?: boolean },
|
||||
) => {
|
||||
const finalizeLifecycleEvent = (evt: AgentEventPayload, opts?: TerminalLifecycleOptions) => {
|
||||
const lifecyclePhase =
|
||||
evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null;
|
||||
if (lifecyclePhase !== "end" && lifecyclePhase !== "error") {
|
||||
@@ -458,15 +462,19 @@ export function createAgentEventHandler({
|
||||
|
||||
const scheduleTerminalLifecycleError = (
|
||||
evt: AgentEventPayload,
|
||||
opts?: { skipChatErrorFinal?: boolean },
|
||||
opts?: TerminalLifecycleOptions,
|
||||
) => {
|
||||
clearPendingTerminalLifecycleError(evt.runId);
|
||||
const timer = setSafeTimeout(() => {
|
||||
const pending = pendingTerminalLifecycleErrors.get(evt.runId);
|
||||
if (!pending || pending.timer !== timer) {
|
||||
return;
|
||||
}
|
||||
pendingTerminalLifecycleErrors.delete(evt.runId);
|
||||
finalizeLifecycleEvent(evt, opts);
|
||||
finalizeLifecycleEvent(pending.event, pending.opts);
|
||||
}, lifecycleErrorRetryGraceMs);
|
||||
timer.unref?.();
|
||||
pendingTerminalLifecycleErrors.set(evt.runId, timer);
|
||||
pendingTerminalLifecycleErrors.set(evt.runId, { timer, event: evt, opts });
|
||||
};
|
||||
|
||||
const emitChatDelta = (
|
||||
@@ -806,7 +814,7 @@ export function createAgentEventHandler({
|
||||
return (evt: AgentEventPayload) => {
|
||||
const lifecyclePhase =
|
||||
evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null;
|
||||
if (evt.stream !== "lifecycle" || lifecyclePhase !== "error") {
|
||||
if (lifecyclePhase !== null && lifecyclePhase !== "error") {
|
||||
clearPendingTerminalLifecycleError(evt.runId);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
setRuntimeConfigSnapshot,
|
||||
type ReadConfigFileSnapshotWithPluginMetadataResult,
|
||||
} from "../config/io.js";
|
||||
import { isNixMode } from "../config/paths.js";
|
||||
import { isNixMode, normalizeStateDirEnv } from "../config/paths.js";
|
||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||
import { applyConfigOverrides } from "../config/runtime-overrides.js";
|
||||
import { resolveMainSessionKey } from "../config/sessions.js";
|
||||
@@ -532,6 +532,7 @@ export async function startGatewayServer(
|
||||
port = 18789,
|
||||
opts: GatewayServerOptions = {},
|
||||
): Promise<GatewayServer> {
|
||||
normalizeStateDirEnv(process.env);
|
||||
const { bootstrapGatewayNetworkRuntime } = await import("./server-network-runtime.js");
|
||||
bootstrapGatewayNetworkRuntime();
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
expect(code).toBe(0);
|
||||
const command = spawnShellCommand(spawnSync, 1);
|
||||
expect(command).toContain("'OPENCLAW_RUN_NODE_CPU_PROF_DIR=.artifacts/gateway-watch-profiles'");
|
||||
expect(command).toContain("'OPENCLAW_RUN_NODE_CPU_PROF_MAX_FILES=40'");
|
||||
expect(command).toContain("'OPENCLAW_TRACE_SYNC_IO=0'");
|
||||
expect(command).not.toContain("--benchmark");
|
||||
expect(command).toContain("'gateway'");
|
||||
@@ -130,6 +131,31 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves an explicit benchmark CPU profile retention cap", () => {
|
||||
const stdout = createOutput();
|
||||
const stderr = createOutput();
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ status: 1, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" });
|
||||
|
||||
const code = runGatewayWatchTmuxMain({
|
||||
args: ["gateway", "--force", "--benchmark"],
|
||||
cwd: "/repo",
|
||||
env: { OPENCLAW_RUN_NODE_CPU_PROF_MAX_FILES: "8", SHELL: "/bin/zsh" },
|
||||
nodePath: "/node",
|
||||
spawnSync,
|
||||
stderr: stderr.stream,
|
||||
stdout: stdout.stream,
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
const command = spawnShellCommand(spawnSync, 1);
|
||||
expect(command).toContain("'OPENCLAW_RUN_NODE_CPU_PROF_MAX_FILES=8'");
|
||||
});
|
||||
|
||||
it("preserves explicit sync I/O tracing in benchmark mode", () => {
|
||||
const stdout = createOutput();
|
||||
const stderr = createOutput();
|
||||
|
||||
@@ -366,4 +366,42 @@ describe("ensureOpenClawCliOnPath", () => {
|
||||
expect(updated).not.toContain(maliciousBin);
|
||||
expect(updated).not.toContain(maliciousSbin);
|
||||
});
|
||||
|
||||
it("does not probe Linuxbrew fallbacks on macOS unless already inherited", () => {
|
||||
const { tmp, appCli } = setupAppCliRoot("case-no-darwin-linuxbrew");
|
||||
const homeLinuxbrewBin = path.join(tmp, ".linuxbrew", "bin");
|
||||
const globalLinuxbrewBin = "/home/linuxbrew/.linuxbrew/bin";
|
||||
setDir(path.join(tmp, ".linuxbrew"));
|
||||
setDir(homeLinuxbrewBin);
|
||||
setDir("/home");
|
||||
setDir("/home/linuxbrew");
|
||||
setDir("/home/linuxbrew/.linuxbrew");
|
||||
setDir(globalLinuxbrewBin);
|
||||
resetBootstrapEnv("/usr/bin:/bin");
|
||||
|
||||
const updated = bootstrapPath({
|
||||
execPath: appCli,
|
||||
cwd: tmp,
|
||||
homeDir: tmp,
|
||||
platform: "darwin",
|
||||
});
|
||||
|
||||
expect(updated).not.toContain(homeLinuxbrewBin);
|
||||
expect(updated).not.toContain(globalLinuxbrewBin);
|
||||
});
|
||||
|
||||
it("keeps inherited Linuxbrew path entries on macOS", () => {
|
||||
const { tmp, appCli } = setupAppCliRoot("case-keep-darwin-linuxbrew");
|
||||
const globalLinuxbrewBin = "/home/linuxbrew/.linuxbrew/bin";
|
||||
resetBootstrapEnv(`${globalLinuxbrewBin}:/usr/bin:/bin`);
|
||||
|
||||
const updated = bootstrapPath({
|
||||
execPath: appCli,
|
||||
cwd: tmp,
|
||||
homeDir: tmp,
|
||||
platform: "darwin",
|
||||
});
|
||||
|
||||
expect(updated).toContain(globalLinuxbrewBin);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,37 @@ function isDirectory(dirPath: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function splitPathParts(pathEnv: string): Set<string> {
|
||||
return new Set(
|
||||
pathEnv
|
||||
.split(path.delimiter)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function isKnownPathDir(existingPathParts: ReadonlySet<string>, dirPath: string): boolean {
|
||||
return existingPathParts.has(dirPath) || isDirectory(dirPath);
|
||||
}
|
||||
|
||||
function isLinuxbrewPath(dirPath: string): boolean {
|
||||
return dirPath.split(path.sep).includes(".linuxbrew");
|
||||
}
|
||||
|
||||
function resolvePathBootstrapBrewDirs(params: {
|
||||
homeDir: string;
|
||||
platform: NodeJS.Platform;
|
||||
existingPathParts: ReadonlySet<string>;
|
||||
}): string[] {
|
||||
const candidates = resolveBrewPathDirs({ homeDir: params.homeDir });
|
||||
if (params.platform !== "darwin") {
|
||||
return candidates;
|
||||
}
|
||||
return candidates.filter(
|
||||
(candidate) => !isLinuxbrewPath(candidate) || params.existingPathParts.has(candidate),
|
||||
);
|
||||
}
|
||||
|
||||
function mergePath(params: { existing: string; prepend?: string[]; append?: string[] }): string {
|
||||
const partsExisting = params.existing
|
||||
.split(path.delimiter)
|
||||
@@ -49,7 +80,10 @@ function mergePath(params: { existing: string; prepend?: string[]; append?: stri
|
||||
return merged.join(path.delimiter);
|
||||
}
|
||||
|
||||
function candidateBinDirs(opts: EnsureOpenClawPathOpts): { prepend: string[]; append: string[] } {
|
||||
function candidateBinDirs(
|
||||
opts: EnsureOpenClawPathOpts,
|
||||
existingPathParts: ReadonlySet<string>,
|
||||
): { prepend: string[]; append: string[] } {
|
||||
const execPath = opts.execPath ?? process.execPath;
|
||||
const cwd = opts.cwd ?? process.cwd();
|
||||
const homeDir = opts.homeDir ?? os.homedir();
|
||||
@@ -100,10 +134,10 @@ function candidateBinDirs(opts: EnsureOpenClawPathOpts): { prepend: string[]; ap
|
||||
// shadow trusted OS binaries.
|
||||
// This includes Brew/Homebrew dirs, which are useful for finding `openclaw`
|
||||
// in launchd/minimal environments but must not be treated as trusted.
|
||||
append.push(...resolveBrewPathDirs({ homeDir }));
|
||||
append.push(...resolvePathBootstrapBrewDirs({ homeDir, platform, existingPathParts }));
|
||||
const miseDataDir = process.env.MISE_DATA_DIR ?? path.join(homeDir, ".local", "share", "mise");
|
||||
const miseShims = path.join(miseDataDir, "shims");
|
||||
if (isDirectory(miseShims)) {
|
||||
if (isKnownPathDir(existingPathParts, miseShims)) {
|
||||
append.push(miseShims);
|
||||
}
|
||||
if (platform === "darwin") {
|
||||
@@ -117,7 +151,10 @@ function candidateBinDirs(opts: EnsureOpenClawPathOpts): { prepend: string[]; ap
|
||||
append.push(path.join(homeDir, ".bun", "bin"));
|
||||
append.push(path.join(homeDir, ".yarn", "bin"));
|
||||
|
||||
return { prepend: prepend.filter(isDirectory), append: append.filter(isDirectory) };
|
||||
return {
|
||||
prepend: prepend.filter((candidate) => isKnownPathDir(existingPathParts, candidate)),
|
||||
append: append.filter((candidate) => isKnownPathDir(existingPathParts, candidate)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,7 +168,8 @@ export function ensureOpenClawCliOnPath(opts: EnsureOpenClawPathOpts = {}) {
|
||||
process.env.OPENCLAW_PATH_BOOTSTRAPPED = "1";
|
||||
|
||||
const existing = opts.pathEnv ?? process.env.PATH ?? "";
|
||||
const { prepend, append } = candidateBinDirs(opts);
|
||||
const existingPathParts = splitPathParts(existing);
|
||||
const { prepend, append } = candidateBinDirs(opts, existingPathParts);
|
||||
if (prepend.length === 0 && append.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -673,6 +673,60 @@ describe("run-node script", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rotates old Node CPU profiles when a retention cap is set", async () => {
|
||||
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
|
||||
await setupTrackedProject(tmp, {
|
||||
files: {
|
||||
[ROOT_SRC]: "export const value = 1;\n",
|
||||
},
|
||||
oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE],
|
||||
buildPaths: [DIST_ENTRY, BUILD_STAMP],
|
||||
});
|
||||
const profileDir = path.join(tmp, ".artifacts", "profiles");
|
||||
fsSync.mkdirSync(profileDir, { recursive: true });
|
||||
const oldProfiles = [
|
||||
"openclaw-status-oldest.cpuprofile",
|
||||
"openclaw-status-middle.cpuprofile",
|
||||
"openclaw-status-newest.cpuprofile",
|
||||
];
|
||||
for (const [index, name] of oldProfiles.entries()) {
|
||||
const filePath = path.join(profileDir, name);
|
||||
fsSync.writeFileSync(filePath, "{}");
|
||||
const mtime = new Date(1_700_000_000_000 + index * 1000);
|
||||
fsSync.utimesSync(filePath, mtime, mtime);
|
||||
}
|
||||
fsSync.writeFileSync(path.join(profileDir, "openclaw-models-old.cpuprofile"), "{}");
|
||||
|
||||
const spawn = () => createExitedProcess(0);
|
||||
const { spawnSync } = createSpawnRecorder({
|
||||
gitHead: "abc123\n",
|
||||
gitStatus: "",
|
||||
});
|
||||
|
||||
const exitCode = await runNodeMain({
|
||||
cwd: tmp,
|
||||
args: ["status"],
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_RUNNER_LOG: "0",
|
||||
OPENCLAW_RUN_NODE_CPU_PROF_DIR: ".artifacts/profiles",
|
||||
OPENCLAW_RUN_NODE_CPU_PROF_MAX_FILES: "2",
|
||||
},
|
||||
spawn,
|
||||
spawnSync,
|
||||
execPath: process.execPath,
|
||||
platform: process.platform,
|
||||
process: createFakeProcess(),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(fsSync.existsSync(path.join(profileDir, oldProfiles[0]))).toBe(false);
|
||||
expect(fsSync.existsSync(path.join(profileDir, oldProfiles[1]))).toBe(false);
|
||||
expect(fsSync.existsSync(path.join(profileDir, oldProfiles[2]))).toBe(true);
|
||||
expect(fsSync.existsSync(path.join(profileDir, "openclaw-models-old.cpuprofile"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("adds Node sync I/O tracing flag to the launched OpenClaw child when requested", async () => {
|
||||
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
|
||||
await setupTrackedProject(tmp, {
|
||||
|
||||
@@ -264,6 +264,9 @@ const cachedPluginSdkExportedSubpaths = new PluginLruCache<string[]>(
|
||||
const cachedPluginSdkScopedAliasMaps = new PluginLruCache<Record<string, string>>(
|
||||
MAX_PLUGIN_LOADER_ALIAS_CACHE_ENTRIES,
|
||||
);
|
||||
const cachedBundledPluginPublicSurfaceAliasMaps = new PluginLruCache<Record<string, string>>(
|
||||
MAX_PLUGIN_LOADER_ALIAS_CACHE_ENTRIES,
|
||||
);
|
||||
const PLUGIN_SDK_PACKAGE_NAMES = ["openclaw/plugin-sdk", "@openclaw/plugin-sdk"] as const;
|
||||
const OFFICIAL_CODEX_PLUGIN_PACKAGE_NAME = "@openclaw/codex";
|
||||
const CODEX_NATIVE_TASK_RUNTIME_PLUGIN_SDK_SUBPATH = "codex-native-task-runtime";
|
||||
@@ -417,19 +420,25 @@ function resolveBundledPluginPackagePublicSurfaceAliasMap(params: {
|
||||
if (!packageRoot) {
|
||||
return {};
|
||||
}
|
||||
const extensionsRoot = path.join(packageRoot, "extensions");
|
||||
let extensionDirs: fs.Dirent[];
|
||||
try {
|
||||
extensionDirs = fs.readdirSync(extensionsRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
const orderedKinds = resolvePluginSdkAliasCandidateOrder({
|
||||
modulePath: params.modulePath,
|
||||
isProduction: process.env.NODE_ENV === "production",
|
||||
pluginSdkResolution: params.pluginSdkResolution,
|
||||
});
|
||||
const includePrivateQa = shouldIncludePrivateLocalOnlyPluginSdkSubpaths();
|
||||
const cacheKey = `${packageRoot}::${orderedKinds.join(",")}::privateQa=${includePrivateQa ? "1" : "0"}`;
|
||||
const cached = cachedBundledPluginPublicSurfaceAliasMaps.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const extensionsRoot = path.join(packageRoot, "extensions");
|
||||
let extensionDirs: fs.Dirent[];
|
||||
try {
|
||||
extensionDirs = fs.readdirSync(extensionsRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
cachedBundledPluginPublicSurfaceAliasMaps.set(cacheKey, {});
|
||||
return {};
|
||||
}
|
||||
const aliasMap: Record<string, string> = {};
|
||||
for (const entry of extensionDirs) {
|
||||
if (!entry.isDirectory()) {
|
||||
@@ -458,6 +467,7 @@ function resolveBundledPluginPackagePublicSurfaceAliasMap(params: {
|
||||
aliasMap[`${packageName}/${basename}.js`] = normalizeJitiAliasTargetPath(target);
|
||||
}
|
||||
}
|
||||
cachedBundledPluginPublicSurfaceAliasMaps.set(cacheKey, aliasMap);
|
||||
return aliasMap;
|
||||
}
|
||||
|
||||
|
||||
@@ -594,6 +594,43 @@ describe("finalizeSetupWizard", () => {
|
||||
expect(gatewayServiceInstall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("suppresses token-bearing onboarding output when requested", async () => {
|
||||
const prompter = createLaterPrompter();
|
||||
|
||||
await finalizeSetupWizard({
|
||||
flow: "advanced",
|
||||
opts: {
|
||||
acceptRisk: true,
|
||||
authChoice: "skip",
|
||||
installDaemon: false,
|
||||
skipHealth: true,
|
||||
skipUi: true,
|
||||
suppressGatewayTokenOutput: true,
|
||||
},
|
||||
baseConfig: {},
|
||||
nextConfig: {},
|
||||
workspaceDir: "/tmp",
|
||||
settings: {
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
authMode: "token",
|
||||
gatewayToken: "session-token",
|
||||
tailscaleMode: "off",
|
||||
tailscaleResetOnExit: false,
|
||||
},
|
||||
prompter,
|
||||
runtime: createRuntime(),
|
||||
});
|
||||
|
||||
const output = vi
|
||||
.mocked(prompter.note)
|
||||
.mock.calls.map((call) => call.join("\n"))
|
||||
.join("\n");
|
||||
expect(output).toContain("http://127.0.0.1:18789");
|
||||
expect(output).not.toContain("session-token");
|
||||
expect(output).not.toContain("#token=");
|
||||
});
|
||||
|
||||
it("stops after a scheduled restart instead of reinstalling the service", async () => {
|
||||
const progressUpdate = vi.fn();
|
||||
const progressStop = vi.fn();
|
||||
|
||||
@@ -77,6 +77,7 @@ export async function finalizeSetupWizard(
|
||||
options: FinalizeOnboardingOptions,
|
||||
): Promise<{ launchedTui: boolean }> {
|
||||
const { flow, opts, baseConfig, nextConfig, settings, prompter, runtime } = options;
|
||||
const suppressGatewayTokenOutput = opts.suppressGatewayTokenOutput === true;
|
||||
let gatewayProbe: { ok: boolean; detail?: string } = { ok: true };
|
||||
let resolvedGatewayPassword = "";
|
||||
|
||||
@@ -392,7 +393,7 @@ export async function finalizeSetupWizard(
|
||||
tlsEnabled: nextConfig.gateway?.tls?.enabled === true,
|
||||
});
|
||||
const authedUrl =
|
||||
settings.authMode === "token" && settings.gatewayToken
|
||||
settings.authMode === "token" && settings.gatewayToken && !suppressGatewayTokenOutput
|
||||
? `${links.httpUrl}#token=${encodeURIComponent(settings.gatewayToken)}`
|
||||
: links.httpUrl;
|
||||
if (opts.skipHealth || !gatewayProbe.ok) {
|
||||
@@ -419,7 +420,7 @@ export async function finalizeSetupWizard(
|
||||
await prompter.note(
|
||||
[
|
||||
t("wizard.finalize.webUiUrl", { url: links.httpUrl }),
|
||||
settings.authMode === "token" && settings.gatewayToken
|
||||
settings.authMode === "token" && settings.gatewayToken && !suppressGatewayTokenOutput
|
||||
? t("wizard.finalize.webUiWithTokenUrl", { url: authedUrl })
|
||||
: undefined,
|
||||
t("wizard.finalize.gatewayWsUrl", { url: links.wsUrl }),
|
||||
@@ -450,24 +451,22 @@ export async function finalizeSetupWizard(
|
||||
}
|
||||
|
||||
if (gatewayProbe.ok) {
|
||||
await prompter.note(
|
||||
[
|
||||
t("wizard.finalize.gatewayTokenShared"),
|
||||
t("wizard.finalize.gatewayTokenStored"),
|
||||
t("wizard.finalize.gatewayTokenView", {
|
||||
command: formatCliCommand("openclaw config get gateway.auth.token"),
|
||||
}),
|
||||
t("wizard.finalize.gatewayTokenGenerate", {
|
||||
command: formatCliCommand("openclaw doctor --generate-gateway-token"),
|
||||
}),
|
||||
t("wizard.finalize.dashboardTokenMemory"),
|
||||
t("wizard.finalize.dashboardOpenAnytime", {
|
||||
command: formatCliCommand("openclaw dashboard --no-open"),
|
||||
}),
|
||||
t("wizard.finalize.dashboardTokenPrompt"),
|
||||
].join("\n"),
|
||||
"Token",
|
||||
);
|
||||
const tokenNotes = [
|
||||
t("wizard.finalize.gatewayTokenShared"),
|
||||
t("wizard.finalize.gatewayTokenStored"),
|
||||
t("wizard.finalize.gatewayTokenView", {
|
||||
command: formatCliCommand("openclaw config get gateway.auth.token"),
|
||||
}),
|
||||
t("wizard.finalize.gatewayTokenGenerate", {
|
||||
command: formatCliCommand("openclaw doctor --generate-gateway-token"),
|
||||
}),
|
||||
suppressGatewayTokenOutput ? undefined : t("wizard.finalize.dashboardTokenMemory"),
|
||||
t("wizard.finalize.dashboardOpenAnytime", {
|
||||
command: formatCliCommand("openclaw dashboard --no-open"),
|
||||
}),
|
||||
suppressGatewayTokenOutput ? undefined : t("wizard.finalize.dashboardTokenPrompt"),
|
||||
].filter(Boolean);
|
||||
await prompter.note(tokenNotes.join("\n"), "Token");
|
||||
}
|
||||
|
||||
const hatchOptions: { value: "tui" | "web" | "later"; label: string }[] = [
|
||||
@@ -505,14 +504,20 @@ export async function finalizeSetupWizard(
|
||||
controlUiOpenHint = formatControlUiSshHint({
|
||||
port: settings.port,
|
||||
basePath: controlUiBasePath,
|
||||
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
|
||||
token:
|
||||
settings.authMode === "token" && !suppressGatewayTokenOutput
|
||||
? settings.gatewayToken
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
controlUiOpenHint = formatControlUiSshHint({
|
||||
port: settings.port,
|
||||
basePath: controlUiBasePath,
|
||||
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
|
||||
token:
|
||||
settings.authMode === "token" && !suppressGatewayTokenOutput
|
||||
? settings.gatewayToken
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
await prompter.note(
|
||||
@@ -553,6 +558,7 @@ export async function finalizeSetupWizard(
|
||||
gatewayProbe.ok &&
|
||||
settings.authMode === "token" &&
|
||||
Boolean(settings.gatewayToken) &&
|
||||
!suppressGatewayTokenOutput &&
|
||||
hatchChoice === null;
|
||||
if (shouldOpenControlUi) {
|
||||
const browserSupport = await detectBrowserOpenSupport();
|
||||
|
||||
@@ -219,6 +219,9 @@ describe("package acceptance workflow", () => {
|
||||
expect(workflow).toContain(
|
||||
'[[ "$CHILD_WORKFLOW_REF" == release-ci/* && -n "${TARGET_SHA// }" && "$head_sha" != "$TARGET_SHA" ]]',
|
||||
);
|
||||
expect(workflow).toContain(
|
||||
'gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@"',
|
||||
);
|
||||
expect(workflow).toContain("child run used ${head_sha}, expected ${TARGET_SHA}");
|
||||
expect(workflow).toContain(
|
||||
"Dispatch Full Release Validation from a ref pinned to the target SHA",
|
||||
@@ -774,7 +777,9 @@ describe("package artifact reuse", () => {
|
||||
const dispatchStep = workflowStep(npmTelegramJob, "Dispatch and monitor npm Telegram E2E");
|
||||
|
||||
expect(workflow).toContain("CHILD_WORKFLOW_REF: ${{ github.ref_name }}");
|
||||
expect(workflow).toContain('gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@"');
|
||||
expect(workflow).toContain(
|
||||
'gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@"',
|
||||
);
|
||||
expect(preparePackageJob.name).toBe("Prepare release package artifact");
|
||||
expect(preparePackageJob.needs).toEqual(["resolve_target", "docker_runtime_assets_preflight"]);
|
||||
expect(preparePackageJob.if).toContain("inputs.rerun_group == 'all'");
|
||||
@@ -807,7 +812,8 @@ describe("package artifact reuse", () => {
|
||||
TARGET_SHA: "${{ needs.resolve_target.outputs.sha }}",
|
||||
});
|
||||
expectTextToIncludeAll(dispatchStep.run, [
|
||||
'gh workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}"',
|
||||
'gh_with_retry workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}"',
|
||||
'before_json="$(gh_with_retry run list --workflow npm-telegram-beta-e2e.yml',
|
||||
'-f harness_ref="$TARGET_SHA"',
|
||||
'args=(-f package_spec="${PACKAGE_SPEC:-openclaw@beta}"',
|
||||
'if [[ -z "${PACKAGE_SPEC// }" ]]; then',
|
||||
|
||||
@@ -277,7 +277,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => {
|
||||
with: {
|
||||
"fetch-depth": 1,
|
||||
"fetch-tags": false,
|
||||
"persist-credentials": false,
|
||||
"persist-credentials": true,
|
||||
ref: "${{ needs.preflight.outputs.checkout_revision }}",
|
||||
submodules: false,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user