Compare commits

...

79 Commits

Author SHA1 Message Date
Peter Steinberger
a374c3a5bf test(matrix): stabilize thread binding sweep persistence 2026-05-24 02:12:31 +01:00
Peter Steinberger
89c69c4264 chore(release): sync plugin shrinkwraps for 2026.5.22 2026-05-24 01:49:33 +01:00
Peter Steinberger
df3cadc4ad chore(release): sync plugin versions for 2026.5.22 2026-05-24 01:33:28 +01:00
Peter Steinberger
b0e7b0fe39 chore(release): prepare 2026.5.22 2026-05-24 01:31:07 +01:00
Peter Steinberger
24c7911cfd chore(release): refresh plugin SDK baseline 2026-05-24 00:56:52 +01:00
Peter Steinberger
0b2f8dfbdb fix(release): keep session lock backport scoped 2026-05-24 00:53:02 +01:00
Sebastien Tardif
de0cf73b0c fix(agents): add openai-responses family to non-visible turn retry guard (#85603)
openai-codex-responses can return turns where usage.output > 0 but
assistantTexts is empty (hidden reasoning tokens only). The empty
response retry guard only covered openai-completions, anthropic-messages,
and Ollama, so these turns passed through as successful completions
with no content delivered to the user.

Add the full openai-responses API family (openai-responses,
openai-codex-responses, azure-openai-responses, and their transport
variants) to RETRY_GUARD_MODEL_APIS so the empty response and
reasoning-only retry paths can fire for these providers.

Closes #85364

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
2026-05-24 00:49:03 +01:00
rendrag-git
199bfe580d fix(agents): omit empty tools array for proxy-like openai-completions endpoints
Strict OpenAI-compatible servers (vLLM, LocalAI, llama.cpp, LM Studio) and
current OpenAI itself reject requests containing tools: []. Strip the empty
tools array (and the orphan tool_choice) from outbound chat-completions
payloads when usesExplicitProxyLikeEndpoint is true. Native OpenAI/Azure/
OpenRouter routes are byte-identical.

Supersedes #70790 at the canonical payload builder seam so the gateway,
embedded runner, and public plugin-SDK consumers (zai/xiaomi/deepseek) all
benefit.
2026-05-24 00:49:00 +01:00
Mikael Goderdzishvili
8a22b33d44 fix(cli): waitForever must keep the event loop alive (#85694)
`waitForever()` is a public library export used by long-running embeds to
block until the host process is asked to exit. It called `interval.unref()`
on the keep-alive timer, which removes the timer from Node's active-handle
set. With no other ref'd handles, `await waitForever()` exits the process
in ~3ms with exit code 13 ("unsettled top-level await") instead of waiting.

Drop the `.unref()` so the interval actually keeps the loop alive, and
update the existing unit test (and comment) to lock in the new contract.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 00:49:00 +01:00
Vincent Koc
75b5c76c7f fix(docker): avoid printing gateway token 2026-05-24 00:49:00 +01:00
njuboy
8ac7cd621b fix(session-lock): enforce maxHoldMs in shouldReclaim during lock acquisition (#85764)
* fix(session-lock): enforce maxHoldMs in shouldReclaim during lock acquisition

- Adds optional maxHoldMs parameter to inspectLockPayload
- Inspect now marks locks as stale when held longer than maxHoldMs
- Passes maxHoldMs through inspectLockPayloadForSession
- acquireSessionWriteLock's shouldReclaim callback now passes maxHoldMs

This ensures that when a live process holds a lock for longer than
maxHoldMs (default 5min), other processes can reclaim it during
acquisition — matching the watchdog's existing enforcement.

Previously shouldReclaim only used staleMs (30min default), meaning
a lock held for 10+ minutes by a live PID would never be reclaimable,
causing 60s timeout failures and gateway freezes.

Closes #85762

* fix(session-lock): add dead-PID fast-path before retry loop

Adds a fast-path check at the top of acquireSessionWriteLock:
if the lock file's owner PID is dead, remove it immediately
before entering the retry loop. This saves up to timeoutMs (60s)
of futile waiting when the previous lock holder has died.

The shouldReclaim callback already handles this case, but only
iteratively through the retry loop. The fast-path eliminates
that unnecessary delay.

* fix(session-lock): enforce max hold during acquisition

* fix(session-lock): revalidate max hold safely

* fix(session-lock): honor holder max-hold policy

* fix(session-lock): keep cleanup from reclaiming live holders

* fix(session-lock): remove stale locks only when unchanged

* fix(session-lock): skip self-held max-hold reclaim

* fix(ci): refresh gateway protocol checks

---------

Co-authored-by: njuboy11 <njuboy11@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-24 00:48:38 +01:00
JC
57b956fd7c fix(gateway): omit stream-error placeholders from agent prompts (#85652)
* fix(gateway): omit stream-error placeholders from agent prompts

* fix(gateway): omit internal placeholder prompts

* fix(gateway): filter placeholder by role

* fix(gateway): preserve current prompt text

* test(plugin): align cold-boundary model normalization expectation

* fix(gateway): mark internal stream-error prompt entries

* fix(gateway): preserve empty tool prompt entries

* test(plugin): expect static xai normalization

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
(cherry picked from commit 0050245bc7)
2026-05-24 00:40:18 +01:00
samzong
ebab6d1cbe fix(gateway): preserve lifecycle cleanup
Signed-off-by: samzong <samzong.lu@gmail.com>
(cherry picked from commit bc2d501b1d)
2026-05-24 00:39:44 +01:00
samzong
5b2989cbb5 fix(gateway): preserve deferred lifecycle errors
(cherry picked from commit 9d56f4aa14)
2026-05-24 00:39:44 +01:00
Nyx
71f7f6f9df fix(gateway): pin relative state dir at startup
* fix(gateway): normalize explicit state dir overrides at startup

* test(gateway): simplify state-dir startup coverage

* test: fix state dir startup coverage

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
(cherry picked from commit 2e5be0c7ff)
2026-05-24 00:39:44 +01:00
Peter Steinberger
cefea04b9e refactor: simplify channel catalog cache
(cherry picked from commit a1c2d093c2)
2026-05-24 00:38:51 +01:00
Peter Steinberger
098bdb2bf6 perf: reduce gateway benchmark filesystem churn
(cherry picked from commit e5534dd2f3)
2026-05-24 00:38:51 +01:00
Peter Steinberger
555cc66a37 perf(gateway): reduce startup filesystem probes
(cherry picked from commit edbd833351)
2026-05-24 00:38:28 +01:00
Peter Steinberger
88fac84a18 ci: mount local installer scripts in smoke containers 2026-05-23 23:42:51 +01:00
Peter Steinberger
e03a93b1a3 test: isolate Codex replay timeout outcome 2026-05-23 23:06:32 +01:00
Peter Steinberger
12d760c1b0 test: isolate Codex hook channel context 2026-05-23 22:55:40 +01:00
Peter Steinberger
f6e9aae227 test: isolate Codex duplicate terminal diagnostics 2026-05-23 22:45:09 +01:00
Peter Steinberger
957b874385 test: isolate Codex terminal diagnostic fallback 2026-05-23 22:34:27 +01:00
Peter Steinberger
9d7034adf5 test: drain Codex app-server attempts 2026-05-23 22:23:45 +01:00
Peter Steinberger
690cb199d9 test: isolate Codex native item release guard 2026-05-23 22:13:14 +01:00
Peter Steinberger
b5f13e5611 test: isolate Codex terminal batch scheduler 2026-05-23 21:57:24 +01:00
Peter Steinberger
f63a653137 test: isolate Codex terminal release decision 2026-05-23 21:31:06 +01:00
Peter Steinberger
7fc349418e test: make Codex diagnostic test deterministic 2026-05-23 21:13:29 +01:00
Peter Steinberger
cb26ca8e7c test: bound Codex app-server cleanup waits 2026-05-23 20:47:54 +01:00
Peter Steinberger
2378e3bccc test: align full release dispatch assertion 2026-05-23 20:24:23 +01:00
Peter Steinberger
2bc3befafc test: clean up Codex app-server run failures 2026-05-23 20:12:35 +01:00
Peter Steinberger
062ccd4553 ci: retry release child workflow dispatch 2026-05-23 19:56:08 +01:00
Peter Steinberger
a0d33de9bc test: isolate Codex report snapshot tests 2026-05-23 19:47:57 +01:00
Peter Steinberger
c3ad9b26b1 ci: retry GHCR docker login 2026-05-23 19:27:51 +01:00
Peter Steinberger
0bab377d7a ci: harden manual checkout auth 2026-05-23 19:10:54 +01:00
Peter Steinberger
df3932441d ci: fix release reachability auth 2026-05-23 18:58:50 +01:00
Peter Steinberger
02bd14ce30 ci: harden release package validation 2026-05-23 18:47:51 +01:00
Peter Steinberger
1102579ef3 ci: harden beta release validation flakes 2026-05-23 18:23:28 +01:00
Peter Steinberger
3794cdcb6a test: type codex thread request mocks 2026-05-23 18:03:11 +01:00
Peter Steinberger
1ea7741136 test: avoid codex heartbeat lifecycle timeout 2026-05-23 17:56:04 +01:00
Peter Steinberger
052625f827 ci: retry npm Telegram release dispatch 2026-05-23 17:18:46 +01:00
Peter Steinberger
1ca1f8a399 test: isolate Telegram spooled timeout from stall watchdog 2026-05-23 17:07:52 +01:00
Peter Steinberger
5ecd86b149 ci: avoid duplicate release-check auth headers 2026-05-23 16:54:54 +01:00
Peter Steinberger
8d504e5220 ci: authenticate release-check reachability fetches 2026-05-23 16:45:04 +01:00
Peter Steinberger
269ef1bf9d test(ci): update plugin prerelease checkout expectation 2026-05-23 16:31:22 +01:00
Peter Steinberger
8f9d5860a9 ci: persist checkout credentials for release validation 2026-05-23 16:17:15 +01:00
Peter Steinberger
5067a84d9d test(codex): avoid searchable-tool registration flake 2026-05-23 16:03:35 +01:00
Peter Steinberger
b0153953b4 test(codex): avoid forced-tool allowlist flake 2026-05-23 15:35:22 +01:00
Peter Steinberger
6b31e1e365 test(codex): type forced-tool request mock 2026-05-23 15:07:37 +01:00
Peter Steinberger
2ba346a8eb test(codex): avoid forced-tool turn flake 2026-05-23 14:54:57 +01:00
Peter Steinberger
084b917cac test(codex): avoid startup cleanup socket flake 2026-05-23 14:29:45 +01:00
Peter Steinberger
e855317280 test(codex): make sandbox cleanup proof deterministic 2026-05-23 14:05:19 +01:00
Peter Steinberger
12e72678fa fix(release): allow large beta smoke run lists 2026-05-23 11:02:02 +01:00
Peter Steinberger
521c91d6b1 ci(release): isolate npm publish concurrency 2026-05-23 10:56:12 +01:00
Peter Steinberger
b2e84b9028 ci(release): allow beta publish after npm preflight 2026-05-23 10:31:10 +01:00
Peter Steinberger
8d5ec6bc9f ci(release): retry child workflow polling 2026-05-23 09:59:53 +01:00
Peter Steinberger
35e8fa2169 test(codex): make exec-server cleanup proof deterministic 2026-05-23 09:29:02 +01:00
Peter Steinberger
3e472acf71 docs(release): freeze release branch baseline 2026-05-23 08:45:17 +01:00
Peter Steinberger
5f0e3dfbc3 test(codex): bound sandbox exec server shutdown 2026-05-23 08:45:16 +01:00
Peter Steinberger
419f4a6325 test(codex): harden sandbox failure cleanup tests 2026-05-23 08:45:16 +01:00
Peter Steinberger
0f29e9e293 test(codex): isolate sandbox exec server tests 2026-05-23 08:45:16 +01:00
Peter Steinberger
45e3558230 ci(release): poll child workflows through actions api 2026-05-23 08:45:16 +01:00
Peter Steinberger
7441f32673 chore(release): refresh plugin inventory docs 2026-05-23 08:45:16 +01:00
Peter Steinberger
93050c7aab test(codex): avoid active run in sandbox exec-server test 2026-05-23 08:45:16 +01:00
Peter Steinberger
1a37882504 test(codex): satisfy sandbox payload test types 2026-05-23 08:45:16 +01:00
Peter Steinberger
8fe3825ac8 test(codex): avoid active run in sandbox payload test 2026-05-23 08:45:16 +01:00
Peter Steinberger
54da73d11c test(codex): isolate sandbox startup tests 2026-05-23 08:45:16 +01:00
Peter Steinberger
421d274991 test(codex): harden sandbox startup harness 2026-05-23 08:45:16 +01:00
Peter Steinberger
8843b7e9b6 test(codex): align yield abort assertion 2026-05-23 08:45:16 +01:00
Peter Steinberger
8f3eed9343 fix(qa): bundle private flow fixtures 2026-05-23 08:45:16 +01:00
Peter Steinberger
b4980b3520 fix(qa): honor full message cursor for outbound waits 2026-05-23 08:45:16 +01:00
Peter Steinberger
b53921e429 fix(config): stabilize heartbeat target docs baseline 2026-05-23 08:45:16 +01:00
Peter Steinberger
d552f66483 fix(config): make docs baseline ordering deterministic 2026-05-23 08:45:16 +01:00
Peter Steinberger
de2fc740c0 chore(release): refresh config baseline hash 2026-05-23 08:45:16 +01:00
Peter Steinberger
615faa16a5 fix(codex): keep media async starts from yielding 2026-05-23 08:45:16 +01:00
Peter Steinberger
6cf6cc9e2d test(telegram): avoid fake timer startup wait 2026-05-23 08:45:16 +01:00
Peter Steinberger
4c397d13d4 test(telegram): harden startup probe limiter tests 2026-05-23 08:45:16 +01:00
Peter Steinberger
9c63ab305b chore(release): refresh generated release artifacts 2026-05-23 08:45:16 +01:00
Peter Steinberger
490c39c870 chore(release): prepare 2026.5.22-beta.1 2026-05-23 08:45:16 +01:00
68 changed files with 2889 additions and 1943 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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-')))

View File

@@ -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)"

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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;
},

View File

@@ -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();

View File

@@ -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();
});
});
}

View File

@@ -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();

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 () => {

View File

@@ -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(

View File

@@ -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
View 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

View File

@@ -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\"'"

View File

@@ -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(

View File

@@ -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 ?? "";
}

View File

@@ -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`;

View File

@@ -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 \

View File

@@ -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",

View File

@@ -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

View File

@@ -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");

View File

@@ -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",

View File

@@ -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

View File

@@ -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,
})

View File

@@ -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({

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 }>();

View File

@@ -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();

View File

@@ -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 () => {

View File

@@ -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);

View File

@@ -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,

View File

@@ -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 */
});

View File

@@ -79,6 +79,7 @@ export type OnboardOptions = OnboardDynamicProviderOptions & {
skipSearch?: boolean;
skipHealth?: boolean;
skipUi?: boolean;
suppressGatewayTokenOutput?: boolean;
skipHooks?: boolean;
nodeManager?: NodeManagerChoice;
remoteUrl?: string;

View File

@@ -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[]): {

View File

@@ -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",

View File

@@ -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,

View File

@@ -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");
});

View File

@@ -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)) {

View File

@@ -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/);
});

View File

@@ -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);
});
});

View File

@@ -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,
});
}

View File

@@ -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,
});
}

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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();

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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, {

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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();

View File

@@ -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',

View File

@@ -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,
},