Compare commits

..

93 Commits

Author SHA1 Message Date
Dallin Romney
e0571de523 test: fold plugin lifecycle sweep into qa lab 2026-06-17 12:39:46 -07:00
Dallin Romney
af0c99be9b test: use shared temp dirs in plugin lifecycle probe 2026-06-17 11:49:15 -07:00
Dallin Romney
afa188f5ff test: fold plugin lifecycle probe into qa e2e 2026-06-17 11:46:56 -07:00
Dallin Romney
d27f84153b test: point qa code refs at migrated e2e 2026-06-17 11:46:56 -07:00
Dallin Romney
2df4512b20 test: migrate script checks into qa e2e 2026-06-17 11:46:56 -07:00
Dallin Romney
5de6ebf5fe test: fold script coverage into qa scenarios 2026-06-17 11:46:56 -07:00
Vincent Koc
7019da8c7b fix(scripts): wait for extension boundary process groups 2026-06-17 20:26:38 +02:00
Vincent Koc
5a15ea1b5c fix(scripts): wait for deadcode scan process groups 2026-06-17 20:07:43 +02:00
Vincent Koc
38988d5395 fix(scripts): skip generated asset bundles in legacy-store guard 2026-06-17 20:03:12 +02:00
Vincent Koc
d371112c41 refactor(reply): remove audio tag facade 2026-06-18 02:00:33 +08:00
Vincent Koc
34be976c6d refactor(plugins): remove status dependency barrel 2026-06-18 01:49:46 +08:00
Vincent Koc
e54c56962b fix(scripts): wait for boundary check process groups 2026-06-17 19:46:00 +02:00
Josh Lehman
c41bc58cf6 refactor: add session reset delete lifecycle seam (#93659)
* refactor: add session reset delete lifecycle seam

* refactor: expose session lifecycle seam through accessor
2026-06-17 10:43:47 -07:00
Vincent Koc
8ce486a3be fix(scripts): wait for benchmark process groups 2026-06-17 19:35:06 +02:00
Vincent Koc
9e5bebb1a2 refactor(auth): remove unused external cli helper 2026-06-18 01:22:46 +08:00
Vincent Koc
b35b1f2b7c fix(sdk): refresh plugin api baseline 2026-06-17 19:11:18 +02:00
Vincent Koc
aa498cfe11 fix(qa-lab): wait for gateway child process groups 2026-06-17 18:51:49 +02:00
Vincent Koc
27e56828ad refactor(state): remove legacy agent migration aliases 2026-06-18 00:48:29 +08:00
Vincent Koc
d8f2f5c884 test(codex): keep registered message test cheap 2026-06-17 18:42:56 +02:00
Vincent Koc
1ee2733b2f fix(qa-lab): harden live cleanup and readiness 2026-06-17 18:40:21 +02:00
Vincent Koc
dbcbafc208 fix(scripts): wait after force killing rpc gateway 2026-06-17 18:40:21 +02:00
Vincent Koc
21125352d8 refactor(outbound): remove unused read resolver 2026-06-18 00:17:16 +08:00
Vincent Koc
baa389ebed refactor(plugins): remove embedding reset alias 2026-06-18 00:14:10 +08:00
Josh Lehman
5556f19b8c fix(codex): keep message registered for internal turns (#93813)
* fix(codex): keep message in registered schema

* fix(codex): keep forced message schema direct

* test(codex): align disabled message fingerprint proof

* fix(codex): apply registered message policy to allowlists
2026-06-17 09:09:51 -07:00
Vincent Koc
59fb685884 fix(whatsapp): delay running status until startup setup 2026-06-17 18:03:50 +02:00
Vincent Koc
3c1b346115 refactor(clawhub): remove unused skill list endpoint 2026-06-17 23:53:47 +08:00
Vincent Koc
3952ac9585 fix(line): mark running after startup succeeds 2026-06-17 17:53:07 +02:00
Vincent Koc
f83693490b fix(twitch): clear status after startup failures 2026-06-17 17:45:16 +02:00
Vincent Koc
cf79735a65 fix(googlechat): clear status after startup failures 2026-06-17 17:36:48 +02:00
Vincent Koc
1579d833d6 refactor(auth): remove unused provider hook 2026-06-17 23:34:35 +08:00
Agustin Rivera
d4f11d3005 fix(feishu): enforce account tool family gates (#93363)
* fix(feishu): enforce account tool family gates

* fix(feishu): cover perm contextual account gate
2026-06-17 17:24:25 +02:00
dwc1997
62563c2cfc fix(workspace): preserve completed workspace bootstrap files
When ensureAgentWorkspace is called for an already-configured workspace
(setupCompletedAt is set), skip creating optional bootstrap files
(SOUL.md, USER.md, IDENTITY.md, HEARTBEAT.md) at the root level.

This prevents subagent spawns from recreating root-level optional
bootstrap markdown files in repository workspaces where these files
were removed intentionally or only exist under agent-specific
subdirectories (e.g., main/).

Fixes #83593
2026-06-17 23:22:37 +08:00
Vincent Koc
a7f96847ce refactor(agents): remove unused theme callbacks 2026-06-17 23:09:15 +08:00
Vincent Koc
014c4ae103 fix(kitchen-sink): stop leaked RPC gateway groups 2026-06-17 17:04:06 +02:00
Josh Lehman
c85bd45284 clawdbot-d02.1.9.1.16: add session patch projection seam (#93739) 2026-06-17 08:02:45 -07:00
Peter Lee
402c85b07a fix(session): fence stale post-run session writes
* fix(session): prevent stale finalizer from recreating deleted session rows

After sessions.delete removes a session row, updateSessionStoreAfterAgentRun
could still recreate it via the fallbackEntry path in patchSessionEntry when
preserveUserFacingRunState was false. Changed the guard from only checking
preserveUserFacingRunState to checking whether the session key exists in the
in-memory store but not on disk — indicating the session was intentionally
deleted mid-run.

Fixes #40840

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(session): cover deleted session finalizer fence

* fix(session): fence post-run writes after deletion

* fix(session): guard post-run transcript persistence

* fix(session): fence metadata after session reset

---------

Co-authored-by: Peter Lee <22994703+xialonglee@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 22:57:45 +08:00
Vincent Koc
c56a4aad85 fix(testing): route exact source test roots 2026-06-17 16:49:12 +02:00
Vincent Koc
076aa93356 refactor(agents): remove unused presentation helpers 2026-06-17 22:44:41 +08:00
Vincent Koc
405df6f166 fix(clickclack): clear gateway status after poll failures 2026-06-17 16:40:43 +02:00
Vincent Koc
45d7167ea2 fix(qa-channel): clear gateway status after poll failures 2026-06-17 16:23:23 +02:00
Vincent Koc
d1169c3dd0 refactor(plugins): remove unused session helpers 2026-06-17 22:22:40 +08:00
Alix-007
4d6befe7cd fix(doctor): clear inert legacy cron notify markers (#89396)
Stop repeated cron doctor warnings by removing inert top-level `notify` metadata when `cron.webhook` is unset. Existing delivery stays unchanged, while configured invalid webhook URLs keep the actionable warning.

Fixes #44460.

Co-authored-by: Alix-007 <li.long15@xydigit.com>
Reviewed-by: @steipete
2026-06-17 16:21:22 +02:00
github-actions[bot]
b45f65f90a chore(ui): refresh it control ui locale 2026-06-17 14:19:22 +00:00
github-actions[bot]
64afc856bc chore(ui): refresh fa control ui locale 2026-06-17 14:17:53 +00:00
github-actions[bot]
63df9f7b11 chore(ui): refresh nl control ui locale 2026-06-17 14:17:38 +00:00
github-actions[bot]
019fb52411 chore(ui): refresh vi control ui locale 2026-06-17 14:17:33 +00:00
github-actions[bot]
6f981c494a chore(ui): refresh th control ui locale 2026-06-17 14:16:57 +00:00
github-actions[bot]
dd92ea1319 chore(ui): refresh pl control ui locale 2026-06-17 14:16:38 +00:00
github-actions[bot]
d2491412f5 chore(ui): refresh id control ui locale 2026-06-17 14:16:04 +00:00
github-actions[bot]
2ea7ed6b5a chore(ui): refresh tr control ui locale 2026-06-17 14:15:42 +00:00
github-actions[bot]
05bbcabacf chore(ui): refresh uk control ui locale 2026-06-17 14:15:36 +00:00
github-actions[bot]
bc1af44e7c chore(ui): refresh ar control ui locale 2026-06-17 14:15:05 +00:00
github-actions[bot]
a77d0b0acc chore(ui): refresh fr control ui locale 2026-06-17 14:14:38 +00:00
github-actions[bot]
38e03ef4b6 chore(ui): refresh ko control ui locale 2026-06-17 14:14:21 +00:00
github-actions[bot]
f2f975112d chore(ui): refresh es control ui locale 2026-06-17 14:14:15 +00:00
github-actions[bot]
63b0e45e56 chore(ui): refresh ja-JP control ui locale 2026-06-17 14:14:11 +00:00
github-actions[bot]
2b00b39da9 chore(ui): refresh zh-TW control ui locale 2026-06-17 14:13:24 +00:00
github-actions[bot]
6c84475a50 chore(ui): refresh pt-BR control ui locale 2026-06-17 14:13:21 +00:00
github-actions[bot]
275e835aa1 chore(ui): refresh zh-CN control ui locale 2026-06-17 14:13:18 +00:00
github-actions[bot]
9ffd4c9f01 chore(ui): refresh de control ui locale 2026-06-17 14:13:13 +00:00
Vincent Koc
16a5d3b51a fix(scripts): make fast commits skip hooks 2026-06-17 16:12:47 +02:00
Jason (Json)
606f8ec669 Polish Workboard operations view (#90057)
Merged via squash.

Prepared head SHA: f12b693fda
Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
Reviewed-by: @fuller-stack-dev
2026-06-17 08:10:30 -06:00
huangjianxiong
73df6d48af fix(secrets): explicitly pass BWS_SERVER_URL to resolver for self-hosted instances (#93929)
Merged via squash after the required `scripts/pr merge-run` workflow falsely flagged a non-overlapping mainline refactor as an overlap.

Prepared head SHA: dc0bba965a
Co-authored-by: Pandah97 <80405497+Pandah97@users.noreply.github.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-17 22:04:48 +08:00
Vincent Koc
e7aa2a66f2 refactor(runtime): remove unused registry accessors 2026-06-17 22:01:20 +08:00
Vincent Koc
ec3f76b380 fix(testing): relax gateway chat async waits 2026-06-17 15:47:44 +02:00
Vincent Koc
aaa73a5ba2 fix(testing): use UUIDs for Telegram credential leases 2026-06-17 15:46:44 +02:00
Vincent Koc
d98394a865 fix(testing): use UUIDs for cross-OS release probes 2026-06-17 15:40:09 +02:00
Vincent Koc
aa4978e9ab fix(testing): use UUIDs for Vitest include files 2026-06-17 15:34:48 +02:00
Vincent Koc
6802eca299 refactor(outbound): remove unused runtime facade 2026-06-17 21:33:05 +08:00
Vincent Koc
1914cc35bd fix(testing): use UUIDs for macOS Discord smoke nonces 2026-06-17 15:24:16 +02:00
Vincent Koc
40bd375ef3 fix(testing): use UUIDs for npm update guest scripts 2026-06-17 15:20:03 +02:00
Vincent Koc
2ab883a7b8 fix(testing): use UUIDs for Parallels background scripts 2026-06-17 15:15:57 +02:00
Vincent Koc
97ce204d97 refactor(plugins): remove unused helper accessors 2026-06-17 21:13:03 +08:00
Vincent Koc
7a74bb280d fix(testing): recognize signaled Parallels server exits 2026-06-17 15:10:21 +02:00
Vincent Koc
2195b446d4 test(qa): align Docker inspect expectations 2026-06-17 15:03:49 +02:00
Vincent Koc
f3f2d398f6 fix(testing): normalize QA compose service lookup 2026-06-17 14:58:31 +02:00
Vincent Koc
45f9086d29 refactor(state): remove unused audit writers 2026-06-17 20:52:28 +08:00
Vincent Koc
5053ce248c fix(testing): avoid Parallels guest script collisions 2026-06-17 14:45:43 +02:00
Vincent Koc
47cad606f4 fix(gateway): clean pending request when send fails 2026-06-17 14:38:34 +02:00
Vincent Koc
731dfcc5f9 fix(sdk): settle transport connect on close 2026-06-17 14:33:19 +02:00
Vincent Koc
2e27a37791 refactor(runtime): remove unused helper exports 2026-06-17 20:31:20 +08:00
Vincent Koc
9d04064e73 fix(gateway): honor injected env for watchdog timeouts 2026-06-17 14:26:07 +02:00
Vincent Koc
c05acc7a14 fix(testing): bind QA docker port probes to loopback 2026-06-17 14:18:03 +02:00
Vincent Koc
4e2351dd4d refactor(runtime): remove unused internal wrappers 2026-06-17 20:11:16 +08:00
Vincent Koc
8b8b13417e docs(testing): document gauntlet guardrails 2026-06-17 14:02:16 +02:00
Vincent Koc
38723a531d fix(testing): reserve kitchen sink rpc ports 2026-06-17 13:58:15 +02:00
Alix-007
0e46fd1081 feat(docker): support offline setup reruns (#89062)
Add a strict offline rerun mode for Docker setup. Preflight required images, Docker socket/browser policy, and prevent pulls or builds across setup, restart, and rollback paths.

Fixes #70443.

Co-authored-by: Alix-007 <li.long15@xydigit.com>
Reviewed-by: @steipete
2026-06-17 13:50:46 +02:00
Vincent Koc
e2292d18e2 refactor(runtime): remove unused helper probes 2026-06-17 19:48:52 +08:00
Vincent Koc
023ce6e96c fix(testing): route command root target to both shards 2026-06-17 13:41:15 +02:00
Vincent Koc
39250bbe65 refactor(cli): remove unused startup helpers 2026-06-17 19:32:22 +08:00
Vincent Koc
fb6df23a89 fix(testing): harden script tooling checks 2026-06-17 13:31:42 +02:00
Ayaan Zaidi
b3a422d987 fix(qa): mount Telegram package output dir
Mount the configured package Telegram output directory into the Docker runtime and pass the container path to the harness, avoiding host `/home/runner` paths inside Docker.

Proof:
- pnpm test test/scripts/npm-telegram-live.test.ts
- git diff --check
- https://github.com/openclaw/openclaw/actions/runs/27685093647
2026-06-17 16:59:35 +05:30
joshavant
e3b2c1c30a ci: skip security guard before rollout 2026-06-17 13:26:06 +02:00
274 changed files with 18265 additions and 3563 deletions

View File

@@ -9,6 +9,11 @@ permissions:
pull-requests: write
issues: write
env:
# Temporary rollout bridge for PRs opened before this workflow's script landed.
# Remove once the pre-rollout PR set has drained.
OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA: 5d9c010628ea4de3492a12e32f9be5b8c5dfa9ed
concurrency:
group: security-sensitive-guard-${{ github.event.pull_request.number }}
cancel-in-progress: true
@@ -19,13 +24,40 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check security-sensitive guard rollout eligibility
id: rollout
env:
GH_TOKEN: ${{ github.token }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
status="$(
gh api \
"repos/${GITHUB_REPOSITORY}/compare/${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}...${PR_BASE_SHA}" \
--jq '.status'
)"
case "$status" in
ahead|identical)
echo "ready=true" >> "$GITHUB_OUTPUT"
;;
behind|diverged)
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "::notice::Skipping security-sensitive guard for a PR base that predates rollout commit ${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}."
;;
*)
echo "Unexpected compare status for security-sensitive guard rollout: $status" >&2
exit 1
;;
esac
- name: Check out trusted base workflow scripts
if: steps.rollout.outputs.ready == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event.pull_request.base.sha }}
ref: ${{ github.workflow_sha }}
persist-credentials: false
- name: Detect security-sensitive changes
if: steps.rollout.outputs.ready == 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
@@ -40,13 +72,40 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check security-sensitive guard rollout eligibility
id: rollout
env:
GH_TOKEN: ${{ github.token }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
status="$(
gh api \
"repos/${GITHUB_REPOSITORY}/compare/${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}...${PR_BASE_SHA}" \
--jq '.status'
)"
case "$status" in
ahead|identical)
echo "ready=true" >> "$GITHUB_OUTPUT"
;;
behind|diverged)
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "::notice::Skipping security-sensitive guard for a PR base that predates rollout commit ${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}."
;;
*)
echo "Unexpected compare status for security-sensitive guard rollout: $status" >&2
exit 1
;;
esac
- name: Check out trusted base workflow scripts
if: steps.rollout.outputs.ready == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event.pull_request.base.sha }}
ref: ${{ github.workflow_sha }}
persist-credentials: false
- name: Enforce security-sensitive guard
if: steps.rollout.outputs.ready == 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant

View File

@@ -110,7 +110,7 @@ For coordinated change sets that genuinely need more than 20 PRs, join the **#cl
- Keep PRs takeover-ready: open them from a branch maintainers can push to. For fork PRs, leave GitHub's **Allow edits by maintainers** option enabled so maintainers can finish urgent fixes, changelog entries, or merge prep when needed. If GitHub shows **Allow edits and access to secrets by maintainers**, enable it only when that workflow/secrets access is acceptable and say so in the PR.
- Do not edit `CHANGELOG.md` in contributor PRs. Maintainers or ClawSweeper add the changelog entry when landing user-facing changes.
- Run tests: `pnpm build && pnpm check && pnpm test`
- For iterative local commits, `scripts/committer --fast "message" <files...>` passes `FAST_COMMIT=1` through to the pre-commit hook so it skips the repo-wide `pnpm check`. Only use it when you've already run equivalent targeted validation for the touched surface.
- For iterative local commits, `scripts/committer --fast "message" <files...>` skips commit hooks. Only use it when you've already run equivalent targeted validation for the touched surface.
- For extension/plugin changes, run the fast local lane first:
- `pnpm test:extension <extension-name>`
- `pnpm test:extension --list` to see valid extension ids

View File

@@ -1,2 +1,2 @@
b810f3b17d1eb746a6fbc4c45095a3b2bb3e08c5cd62a5928f9add2c59bb95b9 plugin-sdk-api-baseline.json
36174a54f2a9e11b822f499b5659d0b1351198ce98112946d95283b0ee1032dd plugin-sdk-api-baseline.jsonl
c84eab270f19d11a807ce71e783d35ee95a7620295dbffcca7fff31dacfcc882 plugin-sdk-api-baseline.json
55656396a5f1941af61603402c43e23e0ffc90003e7efa7c1857c4541a0f1bb4 plugin-sdk-api-baseline.jsonl

View File

@@ -231,7 +231,7 @@ Retention and pruning are controlled in config:
## Migrating older jobs
<Note>
If you have cron jobs from before the current delivery and store format, run `openclaw doctor --fix`. Doctor normalizes legacy cron fields (`jobId`, `schedule.cron`, top-level delivery fields including legacy `threadId`, payload `provider` delivery aliases) and migrates `notify: true` webhook fallback jobs from `cron.webhook` to explicit webhook delivery. Jobs that already announce to a chat keep that delivery and get a completion webhook destination.
If you have cron jobs from before the current delivery and store format, run `openclaw doctor --fix`. Doctor normalizes legacy cron fields (`jobId`, `schedule.cron`, top-level delivery fields including legacy `threadId`, payload `provider` delivery aliases) and migrates `notify: true` webhook fallback jobs from `cron.webhook` to explicit webhook delivery. Jobs that already announce to a chat keep that delivery and get a completion webhook destination. When `cron.webhook` is unset, the inert top-level `notify` marker is removed for jobs with no migration target (the existing delivery is preserved unchanged), so `doctor --fix` no longer keeps re-warning about them.
</Note>
## Common edits

View File

@@ -373,11 +373,11 @@ That stages grounded durable candidates into the short-term dreaming store while
- top-level payload fields (`message`, `model`, `thinking`, ...) → `payload`
- top-level delivery fields (`deliver`, `channel`, `to`, `provider`, ...) → `delivery`
- payload `provider` delivery aliases → explicit `delivery.channel`
- legacy `notify: true` webhook fallback jobs → explicit webhook delivery from `cron.webhook`; announce jobs keep their chat delivery and get `delivery.completionDestination`
- legacy `notify: true` webhook fallback jobs → explicit webhook delivery from `cron.webhook` when set; announce jobs keep their chat delivery and get `delivery.completionDestination`. When `cron.webhook` is unset, the inert top-level `notify` marker is removed for no-target jobs (existing delivery, including announce, is preserved) since runtime delivery never reads it
The Gateway also sanitizes malformed cron rows at load time so valid jobs keep running. Raw malformed rows are copied to `jobs-quarantine.json` next to the active store before they are removed from `jobs.json`; doctor reports quarantined rows so you can review or repair them manually.
Doctor and Gateway startup use the same `notify: true` migration before the scheduler runs. If `cron.webhook` is missing, doctor warns and leaves the legacy notify marker for manual repair.
Gateway startup normalizes the runtime projection and ignores the top-level `notify` marker, but leaves the persisted cron config for doctor repair. When `cron.webhook` is unset, doctor removes the inert marker for jobs with no migration target (`delivery.mode` none/absent, an unusable webhook target, or existing announce/chat delivery), leaving the existing delivery untouched, so repeated `doctor --fix` runs no longer re-warn about the same job. If `cron.webhook` is set but not a valid HTTP(S) URL, doctor still warns and leaves the marker so you can fix the URL.
On Linux, doctor also warns when the user's crontab still invokes legacy `~/.openclaw/bin/ensure-whatsapp.sh`. That host-local script is not maintained by current OpenClaw and can write false `Gateway inactive` messages to `~/.openclaw/logs/whatsapp-health.log` when cron cannot reach the systemd user bus. Remove the stale crontab entry with `crontab -e`; use `openclaw channels status --probe`, `openclaw doctor`, and `openclaw gateway status` for current health checks.

View File

@@ -335,6 +335,8 @@ the config fields that accept SecretRefs.
- `BWS_ACCESS_TOKEN` available to the Gateway service.
- `PATH` passed to the resolver, or `BWS_BIN` set to the absolute `bws`
binary path.
- `BWS_SERVER_URL` must be set in the environment when using a self-hosted
Bitwarden instance.
```json5
{
@@ -343,7 +345,7 @@ the config fields that accept SecretRefs.
bws: {
source: "exec",
command: "/usr/local/bin/openclaw-bws-resolver.mjs",
passEnv: ["BWS_ACCESS_TOKEN", "PATH", "BWS_BIN"],
passEnv: ["BWS_ACCESS_TOKEN", "BWS_SERVER_URL", "PATH", "BWS_BIN"],
jsonOnly: true,
},
},

View File

@@ -46,6 +46,29 @@ Docker is **optional**. Use it only if you want a containerized gateway or to va
</Step>
<Step title="Airgapped rerun">
On offline hosts, transfer and load the image first:
```bash
docker load -i openclaw-image.tar
export OPENCLAW_IMAGE="ghcr.io/openclaw/openclaw:latest"
./scripts/docker/setup.sh --offline
```
`--offline` verifies that `OPENCLAW_IMAGE` already exists locally, disables
implicit Compose pulls and builds, then runs the normal setup flow such as
`.env` synchronization, permission fixes, onboarding, gateway config sync,
and Compose startup.
If `OPENCLAW_SANDBOX=1`, offline setup also checks the configured default
and active per-agent sandbox images on the daemon behind
`OPENCLAW_DOCKER_SOCKET`. Docker-backed browser images must also carry the
current OpenClaw browser contract label. When a required image is missing or
incompatible, setup exits without changing sandbox configuration instead of
reporting success with an unusable sandbox.
</Step>
<Step title="Complete onboarding">
The setup script runs onboarding automatically. It will:

View File

@@ -1097,11 +1097,10 @@ sessionId})`; create, branch, continue, list, and fork flows live in their
legacy `jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files and removes
the imported sources. Plugin target writebacks update matching `cron_jobs`
rows instead of loading and replacing the whole cron store.
- Doctor and Gateway startup translate legacy `notify: true` webhook fallback
into explicit SQLite delivery before the scheduler runs. Jobs that already
announce to a chat keep that delivery and receive a webhook
`completionDestination`; jobs without `cron.webhook` are reported for manual
repair.
- Gateway startup ignores legacy `notify: true` markers in the runtime
projection. Doctor translates them into explicit SQLite delivery when
`cron.webhook` is valid, removes inert markers when it is unset, and preserves
them with a warning when the configured webhook is invalid.
- Outbound and session delivery queues now store queue status, entry kind,
session key, channel, target, account id, retry count, last attempt/error,
recovery state, and platform-send markers as typed columns in the shared

View File

@@ -190,4 +190,24 @@ describe("ClickClack gateway", () => {
abort.abort();
await run;
});
it("clears running status when backlog polling fails", async () => {
mocks.client.events.mockRejectedValue(new Error("clickclack unavailable"));
const abort = new AbortController();
const ctx = createGatewayContext(abort.signal);
await expect(startClickClackGatewayAccount(ctx)).rejects.toThrow("clickclack unavailable");
expect(ctx.setStatus).toHaveBeenCalledWith({
accountId: "default",
running: true,
configured: true,
enabled: true,
baseUrl: "https://clickclack.example",
});
expect(ctx.setStatus).toHaveBeenLastCalledWith({
accountId: "default",
running: false,
});
});
});

View File

@@ -146,62 +146,67 @@ export async function startClickClackGatewayAccount(
});
let afterCursor = "";
let initialized = false;
while (!ctx.abortSignal.aborted) {
const backlog = await client.events(workspaceId, afterCursor);
if (!initialized) {
// First pass establishes the cursor without replaying historical backlog
// into fresh gateway sessions.
for (const event of backlog) {
afterCursor = event.cursor || afterCursor;
}
initialized = true;
} else {
for (const event of backlog) {
afterCursor = event.cursor || afterCursor;
await processEvent({
account,
config: ctx.cfg,
client,
event,
botUserId: account.botUserId,
});
}
}
const socket = client.websocket(workspaceId, afterCursor);
await new Promise<void>((resolve, reject) => {
const abort = () => {
socket.close();
resolve();
};
ctx.abortSignal.addEventListener("abort", abort, { once: true });
socket.on("message", (data) => {
void (async () => {
const event = parseSocketEvent(data);
if (!event) {
ctx.log?.warn?.(`[${account.accountId}] skipped malformed ClickClack websocket event`);
return;
}
try {
while (!ctx.abortSignal.aborted) {
const backlog = await client.events(workspaceId, afterCursor);
if (!initialized) {
// First pass establishes the cursor without replaying historical backlog
// into fresh gateway sessions.
for (const event of backlog) {
afterCursor = event.cursor || afterCursor;
}
initialized = true;
} else {
for (const event of backlog) {
afterCursor = event.cursor || afterCursor;
await processEvent({
account,
config: ctx.cfg,
client,
event,
botUserId: account.botUserId ?? "",
botUserId: account.botUserId,
});
})().catch(reject);
});
socket.on("close", () => {
ctx.abortSignal.removeEventListener("abort", abort);
resolve();
});
socket.on("error", reject);
});
if (!ctx.abortSignal.aborted) {
await new Promise((resolve) => {
setTimeout(resolve, account.reconnectMs);
}
}
const socket = client.websocket(workspaceId, afterCursor);
await new Promise<void>((resolve, reject) => {
const abort = () => {
socket.close();
resolve();
};
ctx.abortSignal.addEventListener("abort", abort, { once: true });
socket.on("message", (data) => {
void (async () => {
const event = parseSocketEvent(data);
if (!event) {
ctx.log?.warn?.(
`[${account.accountId}] skipped malformed ClickClack websocket event`,
);
return;
}
afterCursor = event.cursor || afterCursor;
await processEvent({
account,
config: ctx.cfg,
client,
event,
botUserId: account.botUserId ?? "",
});
})().catch(reject);
});
socket.on("close", () => {
ctx.abortSignal.removeEventListener("abort", abort);
resolve();
});
socket.on("error", reject);
});
if (!ctx.abortSignal.aborted) {
await new Promise((resolve) => {
setTimeout(resolve, account.reconnectMs);
});
}
}
} finally {
ctx.setStatus({ accountId: account.accountId, running: false });
}
ctx.setStatus({ accountId: account.accountId, running: false });
}

View File

@@ -1313,6 +1313,28 @@ describe("Codex app-server dynamic tool build", () => {
expect(shouldForceMessageTool(params)).toBe(false);
});
it("can retain message in the registered schema when disabled for the current turn", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.disableMessageTool = true;
params.sourceReplyDeliveryMode = "message_tool_only";
params.toolsAllow = [];
params.runtimePlan = createCodexRuntimePlanFixture();
setOpenClawCodingToolsFactoryForTests((options) =>
options?.disableMessageTool ? [] : [createRuntimeDynamicTool("message")],
);
const availableTools = await buildDynamicToolsForTest(params, workspaceDir);
const registeredTools = await buildDynamicToolsForTest(params, workspaceDir, {
ignoreDisableMessageTool: true,
ignoreRuntimePlan: true,
});
expect(availableTools.map((tool) => tool.name)).not.toContain("message");
expect(registeredTools.map((tool) => tool.name)).toContain("message");
});
it("passes the live run session key to Codex dynamic tools when sandbox policy uses another key", () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);

View File

@@ -76,6 +76,7 @@ export type DynamicToolBuildParams = {
pluginConfig: CodexPluginConfig;
profilerEnabled?: boolean;
forceHeartbeatTool?: boolean;
ignoreDisableMessageTool?: boolean;
ignoreRuntimePlan?: boolean;
onYieldDetected: () => void;
onCodexAppServerEvent?: (event: CodexDynamicToolBuildEvent) => void;
@@ -203,6 +204,9 @@ export function formatCodexDynamicToolBuildStageSummary(
/** Builds, filters, and normalizes Codex-compatible runtime tools for a single turn. */
export async function buildDynamicTools(input: DynamicToolBuildParams) {
const { params } = input;
const messagePolicyParams = input.ignoreDisableMessageTool
? { ...params, disableMessageTool: false }
: params;
if (params.disableTools) {
input.onWebSearchPolicyResolved?.(false);
return [];
@@ -295,8 +299,8 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
requireExplicitMessageTarget:
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
disableMessageTool: params.disableMessageTool,
forceMessageTool: shouldForceMessageTool(params),
disableMessageTool: input.ignoreDisableMessageTool ? false : params.disableMessageTool,
forceMessageTool: shouldForceMessageTool(messagePolicyParams),
enableHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
forceHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
onYield: (message) => {
@@ -375,7 +379,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
transientWebSearchRestriction &&
webSearchPolicy.persistentAllowed),
);
const toolsAllow = includeForcedCodexDynamicToolAllow(params.toolsAllow, params);
const toolsAllow = includeForcedCodexDynamicToolAllow(params.toolsAllow, messagePolicyParams);
const filteredTools = filterCodexDynamicToolsForAllowlist(visionFilteredTools, toolsAllow);
toolBuildStages.mark("allowlist-filter");
const normalizedTools = normalizeAgentRuntimeTools({

View File

@@ -74,7 +74,11 @@ import { createSandboxContext } from "./sandbox-exec-server.test-helpers.js";
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
import * as sharedClientModule from "./shared-client.js";
import { createCodexTestModel } from "./test-support.js";
import { buildTurnStartParams, startOrResumeThread } from "./thread-lifecycle.js";
import {
buildTurnStartParams,
codexDynamicToolsFingerprint,
startOrResumeThread,
} from "./thread-lifecycle.js";
function flushDiagnosticEvents() {
return waitForDiagnosticEventsDrained();
@@ -211,7 +215,7 @@ async function buildDynamicToolsForTest(
options: Partial<
Pick<
Parameters<typeof testing.buildDynamicTools>[0],
"forceHeartbeatTool" | "ignoreRuntimePlan"
"forceHeartbeatTool" | "ignoreDisableMessageTool" | "ignoreRuntimePlan"
>
> = {},
) {
@@ -309,7 +313,7 @@ function createCodexToolBridgeForTest(
tools,
registeredTools,
signal,
directToolNames: testing.shouldForceMessageTool(params) ? ["message"] : [],
directToolNames: testing.resolveCodexDynamicToolDirectNames(params),
});
}
@@ -1678,6 +1682,55 @@ describe("runCodexAppServerAttempt", () => {
expect(specNames(nextNormalBridge.specs)).toEqual(registeredToolNames);
});
it("keeps message in the registered schema when disabled for an internal turn", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.disableMessageTool = true;
params.sourceReplyDeliveryMode = "message_tool_only";
params.runtimePlan = createCodexRuntimePlanFixture();
const availableTools: RuntimeDynamicToolForTest[] = [];
const registeredTools = [createRuntimeDynamicTool("message")];
const bridge = createCodexToolBridgeForTest(params, availableTools, registeredTools);
const normalParams = createParams(sessionFile, workspaceDir);
normalParams.disableTools = false;
normalParams.sourceReplyDeliveryMode = "message_tool_only";
normalParams.runtimePlan = createCodexRuntimePlanFixture();
const normalTools = [createRuntimeDynamicTool("message")];
const normalRegisteredTools = [createRuntimeDynamicTool("message")];
const normalBridge = createCodexToolBridgeForTest(
normalParams,
normalTools,
normalRegisteredTools,
);
expect(bridge.availableSpecs.map((tool) => tool.name)).not.toContain("message");
expect(bridge.specs.map((tool) => tool.name)).toContain("message");
expect(codexDynamicToolsFingerprint(bridge.specs)).toBe(
codexDynamicToolsFingerprint(normalBridge.specs),
);
await expect(
bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "message",
arguments: {},
}),
).resolves.toMatchObject({
success: false,
contentItems: [
{
type: "inputText",
text: "OpenClaw tool is not available for this turn: message",
},
],
});
});
it("keeps the persistent dynamic schema stable across heartbeat-only turns", async () => {
testing.setOpenClawCodingToolsFactoryForTests((options) => [
createRuntimeDynamicTool("message"),

View File

@@ -778,6 +778,7 @@ export async function runCodexAppServerAttempt(
pluginConfig,
profilerEnabled,
forceHeartbeatTool: true,
ignoreDisableMessageTool: true,
ignoreRuntimePlan: true,
onYieldDetected: () => {
yieldDetected = true;
@@ -789,7 +790,7 @@ export async function runCodexAppServerAttempt(
registeredTools,
signal: runAbortController.signal,
loading: resolveCodexDynamicToolsLoadingForModel(pluginConfig, params.modelId),
directToolNames: shouldForceMessageTool(params) ? ["message"] : [],
directToolNames: resolveCodexDynamicToolDirectNames(params),
hookContext: {
agentId: sessionAgentId,
config: params.config,
@@ -3186,6 +3187,13 @@ function handleApprovalRequest(params: {
});
}
function resolveCodexDynamicToolDirectNames(params: EmbeddedRunAttemptParams): string[] {
if (params.sourceReplyDeliveryMode !== "message_tool_only") {
return [];
}
return ["message"];
}
export const testing = {
buildCodexNativeHookRelayId,
buildDeveloperInstructions,
@@ -3199,6 +3207,7 @@ export const testing = {
resolveOpenClawCodingToolsSessionKeys,
shouldEnableCodexAppServerNativeToolSurface,
shouldForceMessageTool,
resolveCodexDynamicToolDirectNames,
hasPendingDynamicToolTerminalDiagnostic,
toTranscriptToolResultForTests: toTranscriptToolResult,
withCodexStartupTimeout,

View File

@@ -5,6 +5,14 @@ import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
const createFeishuClientMock = vi.fn((creds: { appId?: string } | undefined) => ({
__appId: creds?.appId,
application: {
scope: {
list: vi.fn(async () => ({
code: 0,
data: { scopes: [] },
})),
},
},
}));
function feishuClientAppId(callIndex: number): string | undefined {
@@ -61,6 +69,28 @@ describe("feishu_doc account selection", () => {
} as OpenClawPluginApi["config"];
}
function createMixedToolConfig(): OpenClawPluginApi["config"] {
return {
channels: {
feishu: {
enabled: true,
accounts: {
a: {
appId: "app-a",
appSecret: "sec-a", // pragma: allowlist secret
tools: { doc: false, scopes: false },
},
b: {
appId: "app-b",
appSecret: "sec-b", // pragma: allowlist secret
tools: { doc: true, scopes: true },
},
},
},
},
} as OpenClawPluginApi["config"];
}
test("uses agentAccountId context when params omit accountId", async () => {
const cfg = createDocEnabledConfig();
@@ -93,4 +123,44 @@ describe("feishu_doc account selection", () => {
expect(feishuClientAppId(-1)).toBe("app-a");
});
test("rejects a disabled contextual account when another account enables docs", async () => {
const { api, resolveTool } = createToolFactoryHarness(createMixedToolConfig());
registerFeishuDocTools(api);
const docTool = resolveTool("feishu_doc", { agentAccountId: "a" });
const result = await docTool.execute("call-disabled", {
action: "list_blocks",
doc_token: "d",
});
expect(createFeishuClientMock).not.toHaveBeenCalled();
expect(result.details.error).toBe('Feishu Doc tools are disabled for account "a"');
});
test("rejects an explicit disabled account override for docs", async () => {
const { api, resolveTool } = createToolFactoryHarness(createMixedToolConfig());
registerFeishuDocTools(api);
const docTool = resolveTool("feishu_doc", { agentAccountId: "b" });
const result = await docTool.execute("call-disabled", {
action: "list_blocks",
doc_token: "d",
accountId: "a",
});
expect(createFeishuClientMock).not.toHaveBeenCalled();
expect(result.details.error).toBe('Feishu Doc tools are disabled for account "a"');
});
test("rejects a disabled contextual account when another account enables app scopes", async () => {
const { api, resolveTool } = createToolFactoryHarness(createMixedToolConfig());
registerFeishuDocTools(api);
const scopesTool = resolveTool("feishu_app_scopes", { agentAccountId: "a" });
const result = await scopesTool.execute("call-disabled", {});
expect(createFeishuClientMock).not.toHaveBeenCalled();
expect(result.details.error).toBe('Feishu App Scopes tools are disabled for account "a"');
});
});

View File

@@ -1384,14 +1384,23 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
type FeishuDocExecuteParams = FeishuDocParams & { accountId?: string };
const getClient = (params: { accountId?: string } | undefined, defaultAccountId?: string) =>
createFeishuToolClient({ api, executeParams: params, defaultAccountId });
createFeishuToolClient({
api,
executeParams: params,
defaultAccountId,
requiredTool: { family: "doc", label: "Doc" },
});
const getMediaMaxBytes = (
params: { accountId?: string } | undefined,
defaultAccountId?: string,
) =>
(resolveFeishuToolAccount({ api, executeParams: params, defaultAccountId }).config
?.mediaMaxMb ?? 30) *
(resolveFeishuToolAccount({
api,
executeParams: params,
defaultAccountId,
requiredTool: { family: "doc", label: "Doc" },
}).config?.mediaMaxMb ?? 30) *
1024 *
1024;
@@ -1584,7 +1593,13 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
parameters: Type.Object({}),
async execute() {
try {
const result = await listAppScopes(getClient(undefined, ctx.agentAccountId));
const result = await listAppScopes(
createFeishuToolClient({
api,
defaultAccountId: ctx.agentAccountId,
requiredTool: { family: "scopes", label: "App Scopes" },
}),
);
return json(result);
} catch (err) {
return json({ error: formatErrorMessage(err) });

View File

@@ -765,6 +765,7 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
api,
executeParams: p,
defaultAccountId,
requiredTool: { family: "drive", label: "Drive" },
});
switch (p.action) {
case "list":

View File

@@ -145,6 +145,7 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) {
api,
executeParams: p,
defaultAccountId,
requiredTool: { family: "perm", label: "Perm" },
});
switch (p.action) {
case "list":

View File

@@ -119,6 +119,21 @@ describe("feishu tool account routing", () => {
expect(lastClientAppId()).toBe("app-b");
});
test("wiki tool implicit fallback selects an account with wiki enabled", async () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
toolsA: { drive: true, wiki: false },
toolsB: { wiki: true },
}),
);
registerFeishuWikiTools(api);
const tool = resolveTool("feishu_wiki");
await tool.execute("call", { action: "search" });
expect(lastClientAppId()).toBe("app-b");
});
test("wiki tool prefers the active contextual account over configured defaultAccount", async () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
@@ -190,6 +205,22 @@ describe("feishu tool account routing", () => {
expect(lastClientAppId()).toBe("app-b");
});
test("drive tool rejects a disabled contextual account when another account enables it", async () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
toolsA: { drive: false },
toolsB: { drive: true },
}),
);
registerFeishuDriveTools(api);
const tool = resolveTool("feishu_drive", { agentAccountId: "a" });
const result = await tool.execute("call", { action: "unknown_action" });
expect(createFeishuClientMock).not.toHaveBeenCalled();
expect(result.details.error).toBe('Feishu Drive tools are disabled for account "a"');
});
test("perm tool registers when only second account enables it and routes to agentAccountId", async () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
@@ -205,6 +236,38 @@ describe("feishu tool account routing", () => {
expect(lastClientAppId()).toBe("app-b");
});
test("perm tool rejects a disabled contextual account when another account enables it", async () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
toolsA: { perm: false },
toolsB: { perm: true },
}),
);
registerFeishuPermTools(api);
const tool = resolveTool("feishu_perm", { agentAccountId: "a" });
const result = await tool.execute("call", { action: "unknown_action" });
expect(createFeishuClientMock).not.toHaveBeenCalled();
expect(result.details.error).toBe('Feishu Perm tools are disabled for account "a"');
});
test("perm tool rejects an explicit disabled account override", async () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
toolsA: { perm: false },
toolsB: { perm: true },
}),
);
registerFeishuPermTools(api);
const tool = resolveTool("feishu_perm", { agentAccountId: "b" });
const result = await tool.execute("call", { action: "unknown_action", accountId: "a" });
expect(createFeishuClientMock).not.toHaveBeenCalled();
expect(result.details.error).toBe('Feishu Perm tools are disabled for account "a"');
});
test("bitable tool registers when only second account enables it and routes to agentAccountId", async () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
@@ -386,6 +449,22 @@ describe("feishu tool account routing", () => {
expect(lastClientAppId()).toBe("app-a");
});
test("wiki tool rejects an explicit disabled account override", async () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
toolsA: { wiki: false },
toolsB: { wiki: true },
}),
);
registerFeishuWikiTools(api);
const tool = resolveTool("feishu_wiki", { agentAccountId: "b" });
const result = await tool.execute("call", { action: "search", accountId: "a" });
expect(createFeishuClientMock).not.toHaveBeenCalled();
expect(result.details.error).toBe('Feishu Wiki tools are disabled for account "a"');
});
test("does not silently fall back when the contextual account is real but uses non-env SecretRefs", async () => {
const { api, resolveTool } = createToolFactoryHarness({
channels: {

View File

@@ -12,11 +12,17 @@ import { resolveToolsConfig } from "./tools-config.js";
import type { FeishuToolsConfig, ResolvedFeishuAccount } from "./types.js";
type AccountAwareParams = { accountId?: string };
type FeishuToolFamily = keyof FeishuToolsConfig;
type FeishuToolRequirement = {
family: FeishuToolFamily;
label: string;
};
function resolveImplicitToolAccountId(params: {
api: Pick<OpenClawPluginApi, "config">;
executeParams?: AccountAwareParams;
defaultAccountId?: string;
requiredTool?: FeishuToolRequirement;
}): string | undefined {
const explicitAccountId = normalizeOptionalString(params.executeParams?.accountId);
if (explicitAccountId) {
@@ -45,6 +51,19 @@ function resolveImplicitToolAccountId(params: {
return configuredDefaultAccountId;
}
if (params.requiredTool && params.api.config) {
for (const accountId of listFeishuAccountIds(params.api.config)) {
const account = resolveFeishuAccount({ cfg: params.api.config, accountId });
if (
account.enabled &&
account.configured &&
resolveToolsConfig(account.config.tools)[params.requiredTool.family]
) {
return accountId;
}
}
}
return undefined;
}
@@ -52,20 +71,31 @@ export function resolveFeishuToolAccount(params: {
api: Pick<OpenClawPluginApi, "config">;
executeParams?: AccountAwareParams;
defaultAccountId?: string;
requiredTool?: FeishuToolRequirement;
}): ResolvedFeishuAccount {
if (!params.api.config) {
throw new Error("Feishu config unavailable");
}
return resolveFeishuRuntimeAccount({
const account = resolveFeishuRuntimeAccount({
cfg: params.api.config,
accountId: resolveImplicitToolAccountId(params),
});
if (
params.requiredTool &&
!resolveToolsConfig(account.config.tools)[params.requiredTool.family]
) {
throw new Error(
`Feishu ${params.requiredTool.label} tools are disabled for account "${account.accountId}"`,
);
}
return account;
}
export function createFeishuToolClient(params: {
api: Pick<OpenClawPluginApi, "config">;
executeParams?: AccountAwareParams;
defaultAccountId?: string;
requiredTool?: FeishuToolRequirement;
}): Lark.Client {
return createFeishuClient(resolveFeishuToolAccount(params));
}

View File

@@ -238,6 +238,7 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) {
api,
executeParams: p,
defaultAccountId,
requiredTool: { family: "wiki", label: "Wiki" },
});
switch (p.action) {
case "spaces":

View File

@@ -44,6 +44,17 @@ export async function startGoogleChatGatewayAccount(ctx: {
audienceType: account.config.audienceType,
audience: account.config.audience,
});
let stopped = false;
const markStopped = () => {
if (stopped) {
return;
}
stopped = true;
statusSink({
running: false,
lastStopAt: Date.now(),
});
};
if (
isGoogleChatNativeApprovalClientEnabled({
cfg: ctx.cfg,
@@ -59,26 +70,28 @@ export async function startGoogleChatGatewayAccount(ctx: {
abortSignal: ctx.abortSignal,
});
}
await runPassiveAccountLifecycle({
abortSignal: ctx.abortSignal,
start: async () =>
await startGoogleChatMonitor({
account,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
webhookUrl: account.config.webhookUrl,
statusSink,
}),
stop: async (unregister) => {
unregister?.();
},
onStop: async () => {
statusSink({
running: false,
lastStopAt: Date.now(),
});
},
});
try {
await runPassiveAccountLifecycle({
abortSignal: ctx.abortSignal,
start: async () =>
await startGoogleChatMonitor({
account,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
webhookUrl: account.config.webhookUrl,
statusSink,
}),
stop: async (unregister) => {
unregister?.();
},
onStop: async () => {
markStopped();
},
});
} catch (error) {
markStopped();
throw error;
}
}

View File

@@ -1,5 +1,6 @@
// Googlechat tests cover setup plugin behavior.
import {
createStartAccountContext,
expectLifecyclePatch,
expectPendingUntilAbort,
startAccountAndTrackLifecycle,
@@ -12,6 +13,7 @@ import {
} from "openclaw/plugin-sdk/plugin-test-runtime";
import type { WizardPrompter } from "openclaw/plugin-sdk/plugin-test-runtime";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import {
@@ -383,6 +385,22 @@ describe("googlechat setup", () => {
expectLifecyclePatch(patches, { running: true });
expectLifecyclePatch(patches, { running: false });
});
it("clears running status when monitor startup fails", async () => {
hoisted.startGoogleChatMonitor.mockRejectedValue(new Error("webhook bind failed"));
const patches: ChannelAccountSnapshot[] = [];
const task = startGoogleChatGatewayAccount(
createStartAccountContext({
account: buildAccount(),
statusPatchSink: (next) => patches.push({ ...next }),
}),
);
await expect(task).rejects.toThrow("webhook bind failed");
expectLifecyclePatch(patches, { running: true });
expectLifecyclePatch(patches, { running: false });
});
});
describe("resolveGoogleChatAccount", () => {

View File

@@ -315,6 +315,24 @@ describe("monitorLineProvider lifecycle", () => {
monitor.stop();
});
it("does not record running state when bot startup fails", async () => {
createLineBotMock.mockImplementation(() => {
throw new Error("line bot startup failed");
});
await expect(
monitorLineProvider({
channelAccessToken: "token",
channelSecret: "secret", // pragma: allowlist secret
config: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
}),
).rejects.toThrow("line bot startup failed");
expect(getLineRuntimeState("default")?.running).not.toBe(true);
expect(registerWebhookTargetWithPluginRouteMock).not.toHaveBeenCalled();
});
it("dispatches shared-path webhook posts to the account matching the signature", async () => {
const firstMonitor = await monitorLineProvider({
channelAccessToken: "first-token",

View File

@@ -175,15 +175,6 @@ export async function monitorLineProvider(
throw new Error("LINE webhook mode requires a non-empty channel secret.");
}
recordChannelRuntimeState({
channel: "line",
accountId: resolvedAccountId,
state: {
running: true,
lastStartAt: Date.now(),
},
});
const bot = createLineBot({
channelAccessToken: token,
channelSecret: secret,
@@ -473,6 +464,15 @@ export async function monitorLineProvider(
},
});
recordChannelRuntimeState({
channel: "line",
accountId: resolvedAccountId,
state: {
running: true,
lastStartAt: Date.now(),
},
});
logVerbose(`line: registered webhook handler at ${normalizedPath}`);
let stopped = false;

View File

@@ -0,0 +1,87 @@
// Qa Channel tests cover gateway lifecycle behavior.
import { createServer } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import { startQaGatewayAccount } from "./gateway.js";
import type { ChannelGatewayContext } from "./runtime-api.js";
import type { ResolvedQaChannelAccount } from "./types.js";
async function startJsonServer(
handler: (req: { url?: string | undefined }) => { statusCode?: number; body: string },
) {
const server = createServer((req, res) => {
const response = handler({ url: req.url });
res.writeHead(response.statusCode ?? 200, {
"content-type": "application/json; charset=utf-8",
});
res.end(response.body);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("test server failed to bind");
}
return {
baseUrl: `http://127.0.0.1:${address.port}`,
async stop() {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
},
};
}
describe("qa-channel gateway", () => {
const stops: Array<() => Promise<void>> = [];
afterEach(async () => {
await Promise.all(stops.splice(0).map((stop) => stop()));
});
it("clears running status when polling fails", async () => {
const server = await startJsonServer(() => ({
statusCode: 500,
body: JSON.stringify({ error: "qa bus unavailable" }),
}));
stops.push(() => server.stop());
const account: ResolvedQaChannelAccount = {
accountId: "default",
baseUrl: server.baseUrl,
botDisplayName: "QA Bot",
botUserId: "qa-bot",
config: {},
configured: true,
enabled: true,
pollTimeoutMs: 1,
};
const setStatus = vi.fn();
await expect(
startQaGatewayAccount("qa-channel", "QA Channel", {
abortSignal: new AbortController().signal,
account,
cfg: {},
setStatus,
} as unknown as ChannelGatewayContext<ResolvedQaChannelAccount>),
).rejects.toThrow("qa bus unavailable");
expect(setStatus.mock.calls.map(([status]) => status)).toEqual([
{
accountId: "default",
baseUrl: server.baseUrl,
configured: true,
enabled: true,
running: true,
},
{
accountId: "default",
running: false,
},
]);
});
});

View File

@@ -48,9 +48,10 @@ export async function startQaGatewayAccount(
if (!(error instanceof Error) || error.name !== "AbortError") {
throw error;
}
} finally {
ctx.setStatus({
accountId: account.accountId,
running: false,
});
}
ctx.setStatus({
accountId: account.accountId,
running: false,
});
}

View File

@@ -1,6 +1,5 @@
// Qa Lab tests cover docker up plugin behavior.
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
@@ -8,31 +7,6 @@ import { runQaDockerUp } from "./docker-up.runtime.js";
type QaDockerUpDeps = NonNullable<Parameters<typeof runQaDockerUp>[1]>;
async function occupyPortOrAcceptExisting(port: number): Promise<{ close: () => Promise<void> }> {
const server = createServer();
const listening = await new Promise<boolean>((resolve, reject) => {
server.once("error", (error: NodeJS.ErrnoException) => {
if (error.code === "EADDRINUSE") {
resolve(false);
return;
}
reject(error);
});
server.listen(port, "127.0.0.1", () => resolve(true));
});
return {
close: async () => {
if (!listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
},
};
}
function createHealthyDockerDeps(calls: string[]): QaDockerUpDeps {
return {
async runCommand(command, args, cwd) {
@@ -163,7 +137,8 @@ describe("runQaDockerUp", () => {
const outputDir = await mkdtemp(path.join(os.tmpdir(), "qa-docker-up-"));
const gatewayPort = 18789;
const qaLabPort = 43124;
const resolveHostPort = vi.fn(async (preferredPort: number) => {
const resolveHostPort = vi.fn(async (preferredPort: number, pinned: boolean) => {
expect(pinned).toBe(false);
if (preferredPort === gatewayPort) {
return 28001;
}
@@ -172,16 +147,12 @@ describe("runQaDockerUp", () => {
}
return preferredPort;
});
const gatewayPortReservation = await occupyPortOrAcceptExisting(18789);
const qaLabPortReservation = await occupyPortOrAcceptExisting(43124);
try {
const result = await runQaDockerUp(
{
repoRoot: "/repo/openclaw",
outputDir,
gatewayPort,
qaLabPort,
skipUiBuild: true,
usePrebuiltImage: true,
},
@@ -202,9 +173,9 @@ describe("runQaDockerUp", () => {
expect(result.qaLabUrl).not.toBe(`http://127.0.0.1:${qaLabPort}`);
expect(result.gatewayUrl).toBe("http://127.0.0.1:28001/");
expect(result.qaLabUrl).toBe("http://127.0.0.1:28002");
expect(resolveHostPort).toHaveBeenCalledWith(gatewayPort, false);
expect(resolveHostPort).toHaveBeenCalledWith(qaLabPort, false);
} finally {
await gatewayPortReservation.close();
await qaLabPortReservation.close();
await rm(outputDir, { recursive: true, force: true });
}
});
@@ -258,7 +229,7 @@ describe("runQaDockerUp", () => {
`docker compose -f ${composeFile} up -d @${repoRoot}`,
`docker compose -f ${composeFile} ps --format json openclaw-qa-gateway @${repoRoot}`,
`docker compose -f ${composeFile} ps -q openclaw-qa-gateway @${repoRoot}`,
`docker inspect --format {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}} gateway-container @${repoRoot}`,
`docker inspect --format {{range .NetworkSettings.Networks}}{{println .IPAddress}}{{end}} gateway-container @${repoRoot}`,
]);
expect(fetchCalls).toEqual([
"http://127.0.0.1:43124/healthz",

View File

@@ -978,6 +978,47 @@ describe("buildQaRuntimeEnv", () => {
expect([child.exitCode, child.signalCode]).not.toEqual([null, null]);
});
it("does not trust an exited gateway wrapper while its process group is alive", async () => {
const child = Object.assign(new EventEmitter(), {
pid: 12346,
exitCode: 0 as number | null,
signalCode: null as string | null,
kill: vi.fn(),
});
let sawForceKill = false;
let postKillLivenessChecks = 0;
const processKill = vi.spyOn(process, "kill").mockImplementation((_pid, signal) => {
if (signal === "SIGKILL") {
sawForceKill = true;
return true;
}
if (signal === 0 && sawForceKill) {
postKillLivenessChecks += 1;
if (postKillLivenessChecks >= 2) {
throw Object.assign(new Error("no such process"), { code: "ESRCH" });
}
}
return true;
});
await testing.stopQaGatewayChildProcessTree(
child as unknown as Parameters<typeof testing.stopQaGatewayChildProcessTree>[0],
{
gracefulTimeoutMs: 1,
forceTimeoutMs: 50,
},
);
if (process.platform === "win32") {
expect(child.kill).not.toHaveBeenCalled();
} else {
expect(processKill).toHaveBeenCalledWith(-12346, "SIGTERM");
expect(processKill).toHaveBeenCalledWith(-12346, "SIGKILL");
expect(postKillLivenessChecks).toBe(2);
expect(child.kill).not.toHaveBeenCalled();
}
});
it("treats bind collisions as retryable gateway startup errors", () => {
expect(
testing.isRetryableGatewayStartupError(

View File

@@ -354,6 +354,28 @@ function hasChildExited(child: ChildProcess) {
return child.exitCode !== null || child.signalCode !== null;
}
function isProcessAlreadyExitedError(error: unknown): boolean {
return (error as NodeJS.ErrnoException | undefined)?.code === "ESRCH";
}
function isQaGatewayChildProcessTreeAlive(child: ChildProcess) {
if (!child.pid) {
return false;
}
if (process.platform === "win32") {
return !hasChildExited(child);
}
try {
process.kill(-child.pid, 0);
return true;
} catch (error) {
if (isProcessAlreadyExitedError(error)) {
return false;
}
return !hasChildExited(child);
}
}
function signalQaGatewayChildProcessTree(child: ChildProcess, signal: NodeJS.Signals) {
if (!child.pid) {
return;
@@ -374,22 +396,21 @@ function signalQaGatewayChildProcessTree(child: ChildProcess, signal: NodeJS.Sig
}
async function waitForQaGatewayChildExit(child: ChildProcess, timeoutMs: number) {
if (hasChildExited(child)) {
return true;
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
if (!isQaGatewayChildProcessTreeAlive(child)) {
return true;
}
await sleep(Math.min(25, Math.max(0, deadline - Date.now())));
}
return await Promise.race([
new Promise<boolean>((resolve) => {
child.once("exit", () => resolve(true));
}),
sleep(timeoutMs).then(() => false),
]);
return !isQaGatewayChildProcessTreeAlive(child);
}
async function stopQaGatewayChildProcessTree(
child: ChildProcess,
opts?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number },
) {
if (hasChildExited(child)) {
if (!isQaGatewayChildProcessTreeAlive(child)) {
return;
}
signalQaGatewayChildProcessTree(child, "SIGTERM");

View File

@@ -4,7 +4,7 @@ import { createServer } from "node:http";
import os from "node:os";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readQaJsonBody } from "./bus-server.js";
import {
startQaLabServer,
@@ -12,7 +12,23 @@ import {
type QaLabServerStartParams,
} from "./lab-server.js";
vi.mock("@openclaw/qa-channel/api.js", async () => await import("../../qa-channel/api.js"));
const qaChannelMock = vi.hoisted(() => ({
resolveAccount: vi.fn(),
setRuntime: vi.fn(),
startAccount: vi.fn(),
}));
vi.mock("./runtime-api.js", () => ({
qaChannelPlugin: {
config: {
resolveAccount: qaChannelMock.resolveAccount,
},
gateway: {
startAccount: qaChannelMock.startAccount,
},
},
setQaChannelRuntime: qaChannelMock.setRuntime,
}));
const captureMock = vi.hoisted(() => {
const sessions: Array<Record<string, unknown>> = [];
@@ -150,6 +166,31 @@ async function startQaLabServerForTest(params?: QaLabServerStartParams) {
});
}
beforeEach(() => {
qaChannelMock.resolveAccount.mockReset();
qaChannelMock.resolveAccount.mockImplementation((_cfg: unknown, accountId: string) => ({
accountId,
configured: true,
enabled: true,
}));
qaChannelMock.setRuntime.mockReset();
qaChannelMock.startAccount.mockReset();
qaChannelMock.startAccount.mockImplementation(
async ({ abortSignal }: { abortSignal?: AbortSignal }) =>
await new Promise<void>((resolve) => {
if (!abortSignal) {
resolve();
return;
}
if (abortSignal.aborted) {
resolve();
return;
}
abortSignal.addEventListener("abort", () => resolve(), { once: true });
}),
);
});
afterEach(async () => {
captureMock.reset();
while (cleanups.length > 0) {
@@ -289,6 +330,51 @@ async function createQaLabRepoRootFixture(params?: {
}
describe("qa-lab server", () => {
it("cleans up capture state when embedded gateway setup fails", async () => {
qaChannelMock.resolveAccount.mockImplementationOnce(() => {
throw new Error("embedded setup failed");
});
await expect(
startQaLabServer({
host: "127.0.0.1",
port: 0,
}),
).rejects.toThrow("embedded setup failed");
expect(captureMock.store.close).toHaveBeenCalledTimes(1);
});
it("closes the server and capture state when embedded gateway stop fails", async () => {
qaChannelMock.startAccount.mockImplementationOnce(
async ({ abortSignal }: { abortSignal?: AbortSignal }) =>
await new Promise<void>((_resolve, reject) => {
if (!abortSignal) {
return;
}
if (abortSignal.aborted) {
reject(new Error("gateway stop failed"));
return;
}
abortSignal.addEventListener(
"abort",
() => reject(new Error("gateway stop failed")),
{ once: true },
);
}),
);
const lab = await startQaLabServer({
host: "127.0.0.1",
port: 0,
});
await expect(lab.stop()).rejects.toThrow("gateway stop failed");
expect(captureMock.store.close).toHaveBeenCalledTimes(1);
await expect(fetch(`${lab.baseUrl}/healthz`)).rejects.toThrow();
});
it("serves bootstrap state and message state", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "qa-lab-test-"));
cleanups.push(async () => {

View File

@@ -168,6 +168,10 @@ function createQaLabConfig(baseUrl: string): OpenClawConfig {
return createQaChannelGatewayConfig({ baseUrl });
}
function normalizeQaLabCleanupError(error: unknown): Error {
return error instanceof Error ? error : new Error(formatErrorMessage(error));
}
async function startQaGatewayLoop(params: { state: QaBusState; baseUrl: string }) {
const runtime = createQaRunnerRuntime();
setQaChannelRuntime(runtime);
@@ -242,7 +246,10 @@ export async function startQaLabServer(
| undefined;
const embeddedGatewayEnabled = params?.embeddedGateway !== "disabled";
let labHandle: QaLabServerHandle | null = null;
let captureStoreReleased = false;
let serverListening = false;
let listenUrl = "";
let publicBaseUrl = "";
let runnerModelCatalogPromise: Promise<void> | null = null;
let runnerModelCatalogAbort: AbortController | null = null;
@@ -628,82 +635,107 @@ export async function startQaLabServer(
})();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(params?.port ?? 0, params?.host ?? "127.0.0.1", () => resolve());
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("qa-lab failed to bind");
}
const listenUrl = resolveAdvertisedBaseUrl({
bindHost: params?.host ?? "127.0.0.1",
bindPort: address.port,
});
publicBaseUrl = resolveAdvertisedBaseUrl({
bindHost: params?.host ?? "127.0.0.1",
bindPort: address.port,
advertiseHost: params?.advertiseHost,
advertisePort: params?.advertisePort,
});
if (embeddedGatewayEnabled) {
gateway = await startQaGatewayLoop({ state, baseUrl: listenUrl });
}
if (params?.sendKickoffOnStart) {
injectKickoffMessage({
state,
defaults: bootstrapDefaults,
kickoffTask: scenarioCatalog.kickoffTask,
});
}
server.on("upgrade", (req, socket, head) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (!controlUiProxyTarget || !isControlUiProxyPath(url.pathname)) {
socket.destroy();
const releaseCaptureStore = () => {
if (captureStoreReleased) {
return;
}
proxyUpgradeRequest({
req,
socket,
head,
target: controlUiProxyTarget,
authorizationToken: controlUiProxyToken,
});
});
const lab = {
baseUrl: publicBaseUrl,
listenUrl,
state,
setControlUi(next: {
controlUiUrl?: string | null;
controlUiProxyToken?: string | null;
controlUiProxyTarget?: string | null;
}) {
controlUiUrl = sanitizeControlUiPublicUrl(next.controlUiUrl?.trim() || null);
controlUiProxyToken = next.controlUiProxyToken?.trim() || null;
controlUiProxyTarget = next.controlUiProxyTarget?.trim()
? new URL(next.controlUiProxyTarget)
: null;
},
setScenarioRun(next: Omit<QaLabScenarioRun, "counts"> | null) {
latestScenarioRun = next ? withQaLabRunCounts(next) : null;
},
setLatestReport(next: QaLabLatestReport | null) {
latestReport = next;
},
runSelfCheck,
async stop() {
runnerModelCatalogAbort?.abort();
await runnerModelCatalogPromise?.catch(() => undefined);
await gateway?.stop();
await closeQaHttpServer(server);
captureStoreLease.release();
},
captureStoreReleased = true;
captureStoreLease.release();
};
labHandle = lab;
return lab;
const stopLabServerResources = async (): Promise<Error | undefined> => {
runnerModelCatalogAbort?.abort();
await runnerModelCatalogPromise?.catch(() => undefined);
const results = await Promise.allSettled([
Promise.resolve().then(() => gateway?.stop()),
Promise.resolve().then(() => (serverListening ? closeQaHttpServer(server) : undefined)),
Promise.resolve().then(releaseCaptureStore),
]);
const failed = results.find((result) => result.status === "rejected");
return failed ? normalizeQaLabCleanupError(failed.reason) : undefined;
};
try {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(params?.port ?? 0, params?.host ?? "127.0.0.1", () => resolve());
});
serverListening = true;
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("qa-lab failed to bind");
}
listenUrl = resolveAdvertisedBaseUrl({
bindHost: params?.host ?? "127.0.0.1",
bindPort: address.port,
});
publicBaseUrl = resolveAdvertisedBaseUrl({
bindHost: params?.host ?? "127.0.0.1",
bindPort: address.port,
advertiseHost: params?.advertiseHost,
advertisePort: params?.advertisePort,
});
if (embeddedGatewayEnabled) {
gateway = await startQaGatewayLoop({ state, baseUrl: listenUrl });
}
if (params?.sendKickoffOnStart) {
injectKickoffMessage({
state,
defaults: bootstrapDefaults,
kickoffTask: scenarioCatalog.kickoffTask,
});
}
server.on("upgrade", (req, socket, head) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (!controlUiProxyTarget || !isControlUiProxyPath(url.pathname)) {
socket.destroy();
return;
}
proxyUpgradeRequest({
req,
socket,
head,
target: controlUiProxyTarget,
authorizationToken: controlUiProxyToken,
});
});
const lab = {
baseUrl: publicBaseUrl,
listenUrl,
state,
setControlUi(next: {
controlUiUrl?: string | null;
controlUiProxyToken?: string | null;
controlUiProxyTarget?: string | null;
}) {
controlUiUrl = sanitizeControlUiPublicUrl(next.controlUiUrl?.trim() || null);
controlUiProxyToken = next.controlUiProxyToken?.trim() || null;
controlUiProxyTarget = next.controlUiProxyTarget?.trim()
? new URL(next.controlUiProxyTarget)
: null;
},
setScenarioRun(next: Omit<QaLabScenarioRun, "counts"> | null) {
latestScenarioRun = next ? withQaLabRunCounts(next) : null;
},
setLatestReport(next: QaLabLatestReport | null) {
latestReport = next;
},
runSelfCheck,
async stop() {
const cleanupError = await stopLabServerResources();
if (cleanupError) {
throw cleanupError;
}
},
};
labHandle = lab;
return lab;
} catch (error) {
await stopLabServerResources().catch(() => undefined);
throw error;
}
}
function serializeSelfCheck(result: QaSelfCheckResult) {

View File

@@ -118,6 +118,44 @@ describe("telegram live qa runtime", () => {
).toBe(true);
});
it("waits until the Telegram channel account is connected", async () => {
const gateway = {
call: vi
.fn()
.mockResolvedValueOnce({
channelAccounts: {
telegram: [
{
accountId: "sut",
connected: false,
restartPending: false,
running: true,
},
],
},
})
.mockResolvedValueOnce({
channelAccounts: {
telegram: [
{
accountId: "sut",
connected: true,
restartPending: false,
running: true,
},
],
},
}),
};
await testing.waitForTelegramChannelRunning(gateway as never, "sut", {
pollMs: 1,
timeoutMs: 100,
});
expect(gateway.call).toHaveBeenCalledTimes(2);
});
it("normalizes the Telegram QA canary timeout env", () => {
expect(testing.resolveTelegramQaCanaryTimeoutMs({})).toBe(30_000);
expect(

View File

@@ -1229,9 +1229,15 @@ function assertTelegramScenarioMessageSet(params: {
async function waitForTelegramChannelRunning(
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>,
accountId: string,
options?: {
pollMs?: number;
timeoutMs?: number;
},
) {
const startedAt = Date.now();
while (Date.now() - startedAt < 45_000) {
const timeoutMs = options?.timeoutMs ?? 45_000;
const pollMs = options?.pollMs ?? 500;
while (Date.now() - startedAt < timeoutMs) {
try {
const payload = (await gateway.call(
"channels.status",
@@ -1240,19 +1246,24 @@ async function waitForTelegramChannelRunning(
)) as {
channelAccounts?: Record<
string,
Array<{ accountId?: string; running?: boolean; restartPending?: boolean }>
Array<{
accountId?: string;
connected?: boolean;
running?: boolean;
restartPending?: boolean;
}>
>;
};
const accounts = payload.channelAccounts?.telegram ?? [];
const match = accounts.find((entry) => entry.accountId === accountId);
if (match?.running && match.restartPending !== true) {
if (match?.running && match.connected === true && match.restartPending !== true) {
return;
}
} catch {
// retry
}
await new Promise((resolve) => {
setTimeout(resolve, 500);
setTimeout(resolve, pollMs);
});
}
throw new Error(`telegram account "${accountId}" did not become ready`);
@@ -2254,6 +2265,7 @@ export const testing = {
shouldLogTelegramQaLiveProgress,
formatTelegramQaProgressDetails,
renderTelegramQaMarkdown,
waitForTelegramChannelRunning,
waitForObservedMessage,
};
export { testing as __testing };

View File

@@ -129,6 +129,46 @@ describe("runQaManualLane", () => {
expect(result.reply).toBe("Protocol note: mock reply.");
});
it("cleans up lab and mock provider when gateway startup fails", async () => {
startQaGatewayChild.mockRejectedValueOnce(new Error("gateway startup failed"));
await expect(
runQaManualLane({
repoRoot: "/tmp/openclaw-repo",
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.5",
alternateModel: "mock-openai/gpt-5.5-alt",
message: "check the kickoff file",
timeoutMs: 5_000,
replySettleMs: 0,
}),
).rejects.toThrow("gateway startup failed");
expect(gatewayStop).not.toHaveBeenCalled();
expect(mockStop).toHaveBeenCalledTimes(1);
expect(labStop).toHaveBeenCalledTimes(1);
});
it("continues provider and lab teardown when gateway stop fails", async () => {
gatewayStop.mockRejectedValueOnce(new Error("gateway stop failed"));
await expect(
runQaManualLane({
repoRoot: "/tmp/openclaw-repo",
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.5",
alternateModel: "mock-openai/gpt-5.5-alt",
message: "check the kickoff file",
timeoutMs: 5_000,
replySettleMs: 0,
}),
).rejects.toThrow("gateway stop failed");
expect(gatewayStop).toHaveBeenCalledTimes(1);
expect(mockStop).toHaveBeenCalledTimes(1);
expect(labStop).toHaveBeenCalledTimes(1);
});
it("caps the gateway client timeout for oversized manual waits", async () => {
const result = await runQaManualLane({
repoRoot: "/tmp/openclaw-repo",

View File

@@ -24,6 +24,30 @@ type QaManualLaneParams = {
replySettleMs?: number;
};
type ManualLaneResult = {
model: string;
waited: { status?: string; error?: string };
reply: string | null;
watchUrl: string;
};
function normalizeManualLaneCleanupError(error: unknown): Error {
return error instanceof Error ? error : new Error(formatErrorMessage(error));
}
async function stopManualLaneResources(resources: {
gateway?: { stop: () => Promise<void> | void };
lab?: { stop: () => Promise<void> | void };
mock?: { stop: () => Promise<void> | void } | null;
}): Promise<Error | undefined> {
const stopTasks = [resources.gateway, resources.mock, resources.lab]
.filter((resource): resource is { stop: () => Promise<void> | void } => Boolean(resource))
.map((resource) => Promise.resolve().then(() => resource.stop()));
const results = await Promise.allSettled(stopTasks);
const failed = results.find((result) => result.status === "rejected");
return failed ? normalizeManualLaneCleanupError(failed.reason) : undefined;
}
function resolveManualLaneTimeoutMs(params: {
providerMode: QaProviderMode;
primaryModel: string;
@@ -50,35 +74,42 @@ function resolveManualLaneTimeoutMs(params: {
export async function runQaManualLane(params: QaManualLaneParams) {
const sessionSuffix = params.primaryModel.replace(/[^a-z0-9._-]+/gi, "-");
const lab = await startQaLabServer({
repoRoot: params.repoRoot,
embeddedGateway: "disabled",
});
const transport = createQaTransportAdapter({
id: params.transportId ?? "qa-channel",
state: lab.state,
});
const mock = await startQaProviderServer(params.providerMode);
const gateway = await startQaGatewayChild({
repoRoot: params.repoRoot,
providerBaseUrl: mock ? `${mock.baseUrl}/v1` : undefined,
transport,
transportBaseUrl: lab.listenUrl,
providerMode: params.providerMode,
primaryModel: params.primaryModel,
alternateModel: params.alternateModel,
fastMode: params.fastMode,
thinkingDefault: params.thinkingDefault,
controlUiEnabled: false,
});
let gateway: Awaited<ReturnType<typeof startQaGatewayChild>> | undefined;
let lab: Awaited<ReturnType<typeof startQaLabServer>> | undefined;
let mock: Awaited<ReturnType<typeof startQaProviderServer>> | undefined;
let result: ManualLaneResult | undefined;
let cleanupError: Error | undefined;
let runError: unknown;
const timeoutMs = resolveManualLaneTimeoutMs({
providerMode: params.providerMode,
primaryModel: params.primaryModel,
alternateModel: params.alternateModel,
timeoutMs: params.timeoutMs,
});
try {
lab = await startQaLabServer({
repoRoot: params.repoRoot,
embeddedGateway: "disabled",
});
const transport = createQaTransportAdapter({
id: params.transportId ?? "qa-channel",
state: lab.state,
});
mock = await startQaProviderServer(params.providerMode);
gateway = await startQaGatewayChild({
repoRoot: params.repoRoot,
providerBaseUrl: mock ? `${mock.baseUrl}/v1` : undefined,
transport,
transportBaseUrl: lab.listenUrl,
providerMode: params.providerMode,
primaryModel: params.primaryModel,
alternateModel: params.alternateModel,
fastMode: params.fastMode,
thinkingDefault: params.thinkingDefault,
controlUiEnabled: false,
});
const timeoutMs = resolveManualLaneTimeoutMs({
providerMode: params.providerMode,
primaryModel: params.primaryModel,
alternateModel: params.alternateModel,
timeoutMs: params.timeoutMs,
});
const delivery = transport.buildAgentDelivery({
target: "dm:qa-operator",
});
@@ -124,17 +155,26 @@ export async function runQaManualLane(params: QaManualLaneParams) {
candidate.direction === "outbound" && candidate.conversation.id === "qa-operator",
)?.text ?? null;
return {
result = {
model: params.primaryModel,
waited,
reply,
watchUrl: lab.baseUrl,
};
} catch (error) {
throw new Error(formatErrorMessage(error), { cause: error });
runError = error;
} finally {
await gateway.stop();
await mock?.stop();
await lab.stop();
cleanupError = await stopManualLaneResources({ gateway, lab, mock });
}
if (runError) {
throw new Error(formatErrorMessage(runError), { cause: runError });
}
if (cleanupError) {
throw cleanupError;
}
if (!result) {
throw new Error("manual lane did not produce a result");
}
return result;
}

View File

@@ -51,8 +51,18 @@ describe("qa scenario catalog", () => {
expect(
pack.scenarios
.filter((scenario) => scenario.execution?.kind !== "flow")
.map((scenario) => scenario.id),
).toStrictEqual(["control-ui-chat-flow-playwright"]);
.map((scenario) => scenario.id)
.toSorted(),
).toStrictEqual(
[
"channel-message-flows",
"control-ui-chat-flow-playwright",
"gateway-smoke",
"package-openclaw-for-docker",
"plugin-lifecycle-probe",
"qa-otel-smoke",
].toSorted(),
);
expect(
pack.scenarios
.filter((scenario) => scenario.execution.kind === "flow")

View File

@@ -174,7 +174,7 @@ describe("matrix harness runtime", () => {
`docker compose -f ${outputDir}/docker-compose.matrix-qa.yml ps -q matrix-qa-homeserver @/repo/openclaw`,
);
expect(calls).toContain(
"docker inspect --format {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}} container-123 @/repo/openclaw",
"docker inspect --format {{range .NetworkSettings.Networks}}{{println .IPAddress}}{{end}} container-123 @/repo/openclaw",
);
},
);

View File

@@ -1,10 +1,12 @@
// Twitch tests cover plugin.lifecycle plugin behavior.
import {
createStartAccountContext,
expectLifecyclePatch,
expectStopPendingUntilAbort,
startAccountAndTrackLifecycle,
waitForStartedMocks,
} from "openclaw/plugin-sdk/channel-test-helpers";
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TwitchAccountConfig } from "./types.js";
@@ -84,4 +86,20 @@ describe("twitch startAccount lifecycle", () => {
expect(hoisted.monitorTwitchProvider).toHaveBeenCalledOnce();
expect(stop).toHaveBeenCalledOnce();
});
it("clears running status when monitor startup fails", async () => {
hoisted.monitorTwitchProvider.mockRejectedValue(new Error("irc join failed"));
const patches: ChannelAccountSnapshot[] = [];
const task = requireStartAccount()(
createStartAccountContext({
account: buildAccount(),
statusPatchSink: (next) => patches.push({ ...next }),
}),
);
await expect(task).rejects.toThrow("irc join failed");
expectLifecyclePatch(patches, { running: true });
expectLifecyclePatch(patches, { running: false });
});
});

View File

@@ -186,20 +186,29 @@ export const twitchPlugin: ChannelPlugin<ResolvedTwitchAccount> =
// Keep startAccount pending until abort fires; otherwise the channel
// supervisor reads the settled task as `channel exited without an
// error` and triggers a restart loop. See #60071.
await runStoppablePassiveMonitor({
abortSignal: ctx.abortSignal,
start: async () => {
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
const { monitorTwitchProvider } = await import("./monitor.js");
return monitorTwitchProvider({
account,
accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
});
},
});
try {
await runStoppablePassiveMonitor({
abortSignal: ctx.abortSignal,
start: async () => {
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
const { monitorTwitchProvider } = await import("./monitor.js");
return monitorTwitchProvider({
account,
accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
});
},
});
} catch (error) {
ctx.setStatus?.({
accountId,
running: false,
lastStopAt: Date.now(),
});
throw error;
}
},
stopAccount: async (ctx): Promise<void> => {
const account = ctx.account;

View File

@@ -140,6 +140,26 @@ describe("web auto-reply connection", () => {
expect(formatEnvelopeTimestamp(d, " America/Los_Angeles ")).toBe("Tue 2024-12-31 16:00:00 PST");
});
it("does not publish running status when config loading fails", async () => {
setLoadConfigMock(() => {
throw new Error("config snapshot failed");
});
const statuses: Array<{ running?: boolean }> = [];
const listenerFactory = vi.fn(async () => createMockWebListener());
const { run } = startWebAutoReplyMonitor({
monitorWebChannelFn: monitorWebChannel as never,
listenerFactory,
sleep: vi.fn(async () => {}),
statusSink: (next) => statuses.push({ ...next }),
});
await expect(run).rejects.toThrow("config snapshot failed");
expect(listenerFactory).not.toHaveBeenCalled();
expect(statuses.some((status) => status.running === true)).toBe(false);
});
it("handles reconnect progress and max-attempt stop behavior", async () => {
for (const scenario of [
{

View File

@@ -159,9 +159,6 @@ export async function monitorWebChannel(
const replyLogger = getChildLogger({ module: "web-auto-reply", runId });
const heartbeatLogger = getChildLogger({ module: "web-heartbeat", runId });
const reconnectLogger = getChildLogger({ module: "web-reconnect", runId });
const statusController = createWebChannelStatusController(tuning.statusSink);
statusController.emit();
const baseCfg = getRuntimeConfig();
const sourceCfg = getRuntimeConfigSourceSnapshot();
const { cfg, account } = resolveWebMonitorConfigSnapshot({
@@ -234,6 +231,8 @@ export async function monitorWebChannel(
sleep,
isNonRetryableStatus: isNonRetryableWebCloseStatus,
});
const statusController = createWebChannelStatusController(tuning.statusSink);
statusController.emit();
try {
while (true) {

View File

@@ -825,9 +825,7 @@ export class CoreAgentHarness<
throw new AgentHarnessError("auth", "No auth available for compaction");
}
const branchEntries = await this.session.getBranch();
const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS, {
force: true,
});
const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS);
if (!preparationResult.ok) {
throw preparationResult.error;
}

View File

@@ -1,9 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { createAssistantMessageEventStream } from "../../llm.js";
import type { AssistantMessage, Model, StreamFn } from "../../llm.js";
import { buildSessionContext } from "../session/session.js";
import type { SessionTreeEntry } from "../types.js";
import { DEFAULT_COMPACTION_SETTINGS, prepareCompaction, generateSummary } from "./compaction.js";
import { generateSummary } from "./compaction.js";
describe("generateSummary thinking options", () => {
it("maps explicit Fable off to low effort for compaction", async () => {
@@ -62,197 +60,3 @@ describe("generateSummary thinking options", () => {
expect(streamFn).toHaveBeenCalledOnce();
});
});
describe("prepareCompaction", () => {
function createHighUsageSmallTranscriptEntries(): SessionTreeEntry[] {
return [
{
type: "message",
id: "user-1",
parentId: null,
timestamp: "2026-06-17T08:45:00.000Z",
message: { role: "user", content: "What do you see in your history?", timestamp: 1 },
},
{
type: "message",
id: "assistant-1",
parentId: "user-1",
timestamp: "2026-06-17T08:45:10.000Z",
message: {
role: "assistant",
content: [{ type: "text", text: "Stored." }],
api: "openai-responses",
provider: "openai",
model: "gpt-test",
usage: {
input: 625,
output: 6,
cacheRead: 172_928,
cacheWrite: 0,
totalTokens: 173_559,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 2,
},
},
];
}
it("skips automatic no-op summaries when usage is high but transcript text is below the kept-tail budget", () => {
const entries = createHighUsageSmallTranscriptEntries();
const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS);
expect(preparation).toEqual({ ok: true, value: undefined });
});
it("forces manual preparation when usage is high but transcript text is below the kept-tail budget", () => {
const entries = createHighUsageSmallTranscriptEntries();
const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS, { force: true });
expect(preparation).toEqual({
ok: true,
value: expect.objectContaining({
firstKeptEntryId: "assistant-1",
messagesToSummarize: entries.map((entry) =>
entry.type === "message" ? entry.message : undefined,
),
tokensBefore: 173_559,
turnPrefixMessages: [],
}),
});
});
it("anchors a forced boundary on the assistant tool call, not a trailing tool result", () => {
const entries: SessionTreeEntry[] = [
{
type: "message",
id: "user-1",
parentId: null,
timestamp: "2026-06-17T08:45:00.000Z",
message: { role: "user", content: "Read the notes file.", timestamp: 1 },
},
{
type: "message",
id: "assistant-1",
parentId: "user-1",
timestamp: "2026-06-17T08:45:10.000Z",
message: {
role: "assistant",
content: [
{ type: "toolCall", id: "call-1", name: "read_file", arguments: { path: "notes.md" } },
],
api: "openai-responses",
provider: "openai",
model: "gpt-test",
usage: {
input: 625,
output: 6,
cacheRead: 172_928,
cacheWrite: 0,
totalTokens: 173_559,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: 2,
},
},
{
type: "message",
id: "tool-1",
parentId: "assistant-1",
timestamp: "2026-06-17T08:45:11.000Z",
message: {
role: "toolResult",
toolCallId: "call-1",
toolName: "read_file",
content: [{ type: "text", text: "notes body" }],
isError: false,
timestamp: 3,
},
},
];
const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS, { force: true });
// Anchor must be the assistant that owns the tool call, never the trailing
// tool result, or the rebuilt context would replay an orphaned tool result.
expect(preparation).toEqual({
ok: true,
value: expect.objectContaining({ firstKeptEntryId: "assistant-1" }),
});
const compactedContext = buildSessionContext([
...entries,
{
type: "compaction",
id: "compaction-1",
parentId: "tool-1",
timestamp: "2026-06-17T08:45:20.000Z",
summary: "Checkpoint of the file read.",
firstKeptEntryId: "assistant-1",
tokensBefore: 173_559,
},
]);
expect(compactedContext.messages.map((message) => message.role)).toEqual([
"compactionSummary",
"assistant",
"toolResult",
]);
});
it("shows why the old empty-summary compaction replayed the whole transcript", () => {
const entries: SessionTreeEntry[] = [
{
type: "message",
id: "user-1",
parentId: null,
timestamp: "2026-06-17T08:45:00.000Z",
message: { role: "user", content: "What do you see in your history?", timestamp: 1 },
},
{
type: "message",
id: "assistant-1",
parentId: "user-1",
timestamp: "2026-06-17T08:45:10.000Z",
message: {
role: "assistant",
content: [{ type: "text", text: "Stored." }],
api: "openai-responses",
provider: "openai",
model: "gpt-test",
usage: {
input: 625,
output: 6,
cacheRead: 172_928,
cacheWrite: 0,
totalTokens: 173_559,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 2,
},
},
];
const compactedContext = buildSessionContext([
...entries,
{
type: "compaction",
id: "compaction-1",
parentId: "assistant-1",
timestamp: "2026-06-17T08:45:20.000Z",
summary: "No prior conversation content provided.",
firstKeptEntryId: "user-1",
tokensBefore: 173_559,
},
]);
expect(compactedContext.messages.map((message) => message.role)).toEqual([
"compactionSummary",
"user",
"assistant",
]);
});
});

View File

@@ -626,16 +626,10 @@ export interface CompactionPreparation {
settings: CompactionSettings;
}
export interface CompactionPreparationOptions {
/** Prepare a real summary even when the kept-tail heuristic would otherwise summarize nothing. */
force?: boolean;
}
/** Prepare session entries for compaction, or return undefined when compaction is not applicable. */
export function prepareCompaction(
pathEntries: SessionTreeEntry[],
settings: CompactionSettings,
options: CompactionPreparationOptions = {},
): Result<CompactionPreparation | undefined, CompactionError> {
if (pathEntries.length === 0 || pathEntries[pathEntries.length - 1].type === "compaction") {
return ok(undefined);
@@ -692,41 +686,6 @@ export function prepareCompaction(
}
}
}
if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) {
if (options.force === true) {
const forcedMessagesToSummarize: AgentMessage[] = [];
for (let i = boundaryStart; i < boundaryEnd; i++) {
const msg = getMessageFromEntryForCompaction(pathEntries[i]);
if (msg) {
forcedMessagesToSummarize.push(msg);
}
}
// Anchor the kept tail on the last valid cut point, not the raw final entry.
// findValidCutPoints excludes tool results, so a forced boundary that is not
// collapsed to summary-only later never keeps an orphaned tool result.
const forcedCutPoints = findValidCutPoints(pathEntries, boundaryStart, boundaryEnd);
const forcedKeepIndex =
forcedCutPoints.length > 0 ? forcedCutPoints[forcedCutPoints.length - 1] : -1;
if (forcedMessagesToSummarize.length > 0 && forcedKeepIndex >= 0) {
const forcedFileOps = extractFileOperations(
forcedMessagesToSummarize,
pathEntries,
prevCompactionIndex,
);
return ok({
firstKeptEntryId: pathEntries[forcedKeepIndex].id,
messagesToSummarize: forcedMessagesToSummarize,
turnPrefixMessages: [],
isSplitTurn: false,
tokensBefore,
previousSummary,
fileOps: forcedFileOps,
settings,
});
}
}
return ok(undefined);
}
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
if (cutPoint.isSplitTurn) {
for (const msg of turnPrefixMessages) {

View File

@@ -46,7 +46,6 @@ export {
shouldCompact,
type CompactionDetails,
type CompactionPreparation,
type CompactionPreparationOptions,
type CompactionResult,
type CompactionSettings,
type ContextUsageEstimate,

View File

@@ -500,10 +500,11 @@ function formatGatewayClientErrorForLog(err: unknown): string {
export function resolveGatewayClientConnectChallengeTimeoutMs(
opts: Pick<
GatewayClientOptions,
"connectChallengeTimeoutMs" | "connectDelayMs" | "preauthHandshakeTimeoutMs"
"connectChallengeTimeoutMs" | "connectDelayMs" | "env" | "preauthHandshakeTimeoutMs"
>,
): number {
return resolveConnectChallengeTimeoutMs(readConnectChallengeTimeoutOverride(opts), {
env: opts.env,
configuredTimeoutMs: opts.preauthHandshakeTimeoutMs,
});
}
@@ -1598,7 +1599,14 @@ export class GatewayClient {
});
signal?.addEventListener("abort", abortHandler, { once: true });
});
this.ws.send(JSON.stringify(frame));
try {
this.ws.send(JSON.stringify(frame));
} catch (error) {
const pending = this.pending.get(id);
this.pending.delete(id);
pending?.cleanup?.();
throw error;
}
return p;
}
}

View File

@@ -125,6 +125,11 @@ describe("GatewayClient", () => {
preauthHandshakeTimeoutMs: 30_000,
}),
).toBe(30_000);
expect(
resolveGatewayClientConnectChallengeTimeoutMs({
env: { OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS: "6000" },
}),
).toBe(6_000);
});
test("closes on missing ticks", async () => {
@@ -312,6 +317,27 @@ describe("GatewayClient", () => {
}
});
test("cleans pending request state when websocket send throws", async () => {
const client = new GatewayClient({
requestTimeoutMs: 25,
});
const sendError = new Error("synthetic send failure");
(
client as unknown as {
ws: WebSocket | { readyState: number; send: () => void; close: () => void };
}
).ws = {
readyState: WebSocket.OPEN,
send: vi.fn(() => {
throw sendError;
}),
close: vi.fn(),
};
await expect(client.request("status")).rejects.toThrow("synthetic send failure");
expect(getPendingCount(client)).toBe(0);
});
test("does not auto-timeout expectFinal requests", async () => {
vi.useFakeTimers();
try {

View File

@@ -0,0 +1,28 @@
// Gateway Client tests cover readiness behavior.
import { describe, expect, it, vi } from "vitest";
import { startGatewayClientWithReadinessWait } from "./readiness.js";
describe("startGatewayClientWithReadinessWait", () => {
it("uses the injected client env when resolving the readiness timeout", async () => {
const waitForReady = vi.fn(async () => ({
ready: true,
aborted: false,
elapsedMs: 0,
checks: 1,
maxDriftMs: 0,
}));
const client = { start: vi.fn() };
await startGatewayClientWithReadinessWait(waitForReady, client, {
clientOptions: {
env: { OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS: "6000" },
},
});
expect(waitForReady).toHaveBeenCalledWith({
maxWaitMs: 6_000,
signal: undefined,
});
expect(client.start).toHaveBeenCalledTimes(1);
});
});

View File

@@ -21,7 +21,7 @@ export type GatewayClientStartReadinessOptions = {
timeoutMs?: number;
clientOptions?: Pick<
GatewayClientOptions,
"connectChallengeTimeoutMs" | "connectDelayMs" | "preauthHandshakeTimeoutMs"
"connectChallengeTimeoutMs" | "connectDelayMs" | "env" | "preauthHandshakeTimeoutMs"
>;
signal?: AbortSignal;
};
@@ -42,6 +42,7 @@ function resolveGatewayClientStartReadinessTimeoutMs(
? clientOptions.connectDelayMs
: undefined;
return resolveConnectChallengeTimeoutMs(timeoutOverride, {
env: clientOptions.env,
configuredTimeoutMs: clientOptions.preauthHandshakeTimeoutMs,
});
}

View File

@@ -0,0 +1,53 @@
// OpenClaw SDK tests cover transport behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GatewayClientTransport } from "./transport.js";
type MockGatewayClientInstance = {
opts: {
onConnectError?: (error: Error) => void;
onHelloOk?: (hello: unknown) => void;
};
request: ReturnType<typeof vi.fn>;
start: ReturnType<typeof vi.fn>;
stopAndWait: ReturnType<typeof vi.fn>;
};
const gatewayClientMocks = vi.hoisted(() => ({
instances: [] as MockGatewayClientInstance[],
}));
vi.mock("@openclaw/gateway-client", () => ({
GatewayClient: class {
readonly opts: MockGatewayClientInstance["opts"];
readonly request = vi.fn();
readonly start = vi.fn();
readonly stopAndWait = vi.fn(async () => {});
constructor(opts: MockGatewayClientInstance["opts"]) {
this.opts = opts;
gatewayClientMocks.instances.push(this);
}
},
}));
describe("GatewayClientTransport", () => {
beforeEach(() => {
gatewayClientMocks.instances.length = 0;
});
it("rejects a pending connect when the transport closes before hello-ok", async () => {
const transport = new GatewayClientTransport();
const connect = transport.connect();
const connectExpectation = expect(connect).rejects.toThrow(
"gateway transport closed before connect completed",
);
const client = gatewayClientMocks.instances[0];
expect(client?.start).toHaveBeenCalledTimes(1);
await transport.close();
await connectExpectation;
expect(client?.stopAndWait).toHaveBeenCalledTimes(1);
});
});

View File

@@ -78,6 +78,7 @@ export class GatewayClientTransport implements ConnectableOpenClawTransport {
private readonly options: GatewayClientTransportOptions;
private client: GatewayClientLike | null = null;
private connectPromise: Promise<void> | null = null;
private rejectPendingConnect: ((error: Error) => void) | null = null;
private closePromise: Promise<void> | null = null;
constructor(options: GatewayClientTransportOptions = {}) {
@@ -89,6 +90,7 @@ export class GatewayClientTransport implements ConnectableOpenClawTransport {
return this.connectPromise;
}
this.connectPromise = new Promise<void>((resolve, reject) => {
this.rejectPendingConnect = reject;
const client = new GatewayClient({
...this.options,
onEvent: (event: unknown) => {
@@ -98,6 +100,7 @@ export class GatewayClientTransport implements ConnectableOpenClawTransport {
},
onHelloOk: (_hello: unknown) => {
this.options.onHelloOk?.(_hello);
this.rejectPendingConnect = null;
resolve();
},
onConnectError: (error: Error) => {
@@ -109,6 +112,7 @@ export class GatewayClientTransport implements ConnectableOpenClawTransport {
this.connectPromise = null;
}
void client.stopAndWait().catch(() => {});
this.rejectPendingConnect = null;
reject(error);
},
onReconnectPaused: this.options.onReconnectPaused,
@@ -145,6 +149,9 @@ export class GatewayClientTransport implements ConnectableOpenClawTransport {
this.eventsHub.close();
const client = this.client;
this.client = null;
const rejectPendingConnect = this.rejectPendingConnect;
this.rejectPendingConnect = null;
rejectPendingConnect?.(new Error("gateway transport closed before connect completed"));
this.connectPromise = null;
this.closePromise = client?.stopAndWait() ?? Promise.resolve();
await this.closePromise;

View File

@@ -0,0 +1,27 @@
title: Channel message flows QA evidence
scenario:
id: channel-message-flows
surface: channel-framework
coverage:
primary:
- outbound-direct-text-media-sends
secondary:
- channels.streaming
- channels.direct-visible-replies
objective: Exercise Telegram-shaped streamed previews and durable final text delivery through QA Lab evidence.
successCriteria:
- Telegram flow flags parse channel, target, account, thread, timing, and flow mode.
- Thinking preview updates flush, clear, and then send a durable final answer.
- Failed preview streaming clears the preview and does not send the final answer.
- Working preview updates render rich tool/status text before the durable final answer.
docsRefs:
- docs/channels/telegram.md
- docs/channels/qa-channel.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- test/e2e/qa-lab/channels/channel-message-flows.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/channels/channel-message-flows.e2e.test.ts
summary: Vitest coverage for channel preview clearing and durable final text sends.

View File

@@ -0,0 +1,26 @@
title: Plugin lifecycle probe evidence
scenario:
id: plugin-lifecycle-probe
surface: plugins
coverage:
primary:
- plugins.lifecycle
secondary:
- plugin-validation-and-repair
- plugin-setup
objective: Exercise strict plugin load/uninstall proof parsing through QA Lab evidence.
successCriteria:
- Enabled loaded plugin inspect JSON is accepted as proof.
- Pending or missing inspect JSON is rejected instead of treated as loaded.
- Malformed config during uninstall proof fails with a bounded diagnostic.
docsRefs:
- docs/plugins/manifest.md
- docs/cli/plugins.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts
summary: Vitest coverage for plugin lifecycle proof parsing.

View File

@@ -0,0 +1,28 @@
title: Gateway smoke QA evidence
scenario:
id: gateway-smoke
surface: gateway-runtime
coverage:
primary:
- health-apis
secondary:
- websocket-transport
- connect-request
- hello-ok-snapshot
objective: Exercise loopback Gateway WebSocket connect and health checks through QA Lab evidence.
successCriteria:
- Gateway smoke passes against a loopback Gateway WebSocket using the real client.
- The smoke sends the expected operator connect request before health.
- Failed connect and health responses close the WebSocket client and report bounded errors.
- Unpaired iOS-shaped smoke clients do not call scoped chat history.
docsRefs:
- docs/gateway/protocol.md
- docs/gateway/index.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- test/e2e/qa-lab/runtime/gateway-smoke.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/gateway-smoke.e2e.test.ts
summary: Vitest coverage for Gateway smoke connect and health behavior.

View File

@@ -0,0 +1,28 @@
title: Docker package artifact QA evidence
scenario:
id: package-openclaw-for-docker
surface: docker-podman-hosting
coverage:
primary:
- docker-e2e-package-artifact-generation
secondary:
- package-manager-installs
- runtime.package-update
objective: Exercise bounded OpenClaw package artifact generation through QA Lab evidence.
successCriteria:
- Package artifact output flags are parsed strictly.
- The Docker package path uses the single bounded build-all step before npm pack.
- Changelog trimming is restored after successful and failed ignore-scripts packaging.
- Timed-out and externally terminated child process groups are cleaned up without leaked descendants.
- Captured command output is bounded.
docsRefs:
- docs/install/updating.md
- docs/help/testing.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts
summary: Vitest coverage for Docker package artifact creation and cleanup behavior.

View File

@@ -0,0 +1,28 @@
title: QA OTEL smoke evidence
scenario:
id: qa-otel-smoke
surface: telemetry
coverage:
primary:
- telemetry.otel
secondary:
- harness.qa-lab
- plugin-sdk-diagnostic-runtime-exports
objective: Exercise bounded local OTLP capture and OpenTelemetry smoke assertions through QA Lab evidence.
successCriteria:
- Package-manager forwarded QA OTEL smoke arguments parse correctly.
- Body-size limits are strict positive integers.
- Local OTLP receiver rejects malformed, oversized, or truncated protobuf payloads with bounded diagnostics.
- Captured OTLP body text is bounded and leak needles remain detectable.
- Active local receiver sockets close during cleanup.
- Smoke assertions fail on non-2xx OTLP requests and missing release-critical signals.
docsRefs:
- docs/gateway/opentelemetry.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- test/e2e/qa-lab/runtime/qa-otel-smoke.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/qa-otel-smoke.e2e.test.ts
summary: Vitest coverage for QA OTEL smoke receiver bounds and signal assertions.

View File

@@ -219,6 +219,12 @@ function isSourceFile(filePath) {
return sourceFileExtensions.has(path.extname(filePath));
}
function isGeneratedAssetSourceFile(filePath) {
return /(?:^|\/)extensions\/[^/]+\/assets\/[^/]+\.[cm]?js$/u.test(
filePath.replaceAll(path.sep, "/"),
);
}
function isTestLikeSourceFile(filePath) {
return sourceTestSuffixes.some((suffix) => filePath.endsWith(suffix));
}
@@ -235,7 +241,11 @@ async function collectSourceFiles(targetPath) {
}
if (stat.isFile()) {
return isSourceFile(targetPath) && !isTestLikeSourceFile(targetPath) ? [targetPath] : [];
return isSourceFile(targetPath) &&
!isTestLikeSourceFile(targetPath) &&
!isGeneratedAssetSourceFile(targetPath)
? [targetPath]
: [];
}
const entries = await fs.readdir(targetPath, { withFileTypes: true });
@@ -249,7 +259,12 @@ async function collectSourceFiles(targetPath) {
files.push(...(await collectSourceFiles(entryPath)));
continue;
}
if (entry.isFile() && isSourceFile(entryPath) && !isTestLikeSourceFile(entryPath)) {
if (
entry.isFile() &&
isSourceFile(entryPath) &&
!isTestLikeSourceFile(entryPath) &&
!isGeneratedAssetSourceFile(entryPath)
) {
files.push(entryPath);
}
}

View File

@@ -17,6 +17,8 @@ export const KNIP_TIMEOUT_MS = 10 * 60 * 1000;
* Grace period before force-killing a timed-out knip child process.
*/
export const KNIP_KILL_GRACE_MS = 5_000;
const KNIP_PROCESS_TREE_EXIT_POLL_MS = 25;
const KNIP_POST_FORCE_KILL_WAIT_MS = 1_000;
/**
* Heartbeat interval used while knip runs without output.
*/
@@ -154,6 +156,34 @@ function signalProcessTree(child, signal) {
}
}
function processTreeAlive(child) {
if (!child.pid) {
return false;
}
if (process.platform === "win32") {
return child.exitCode === null && child.signalCode === null;
}
try {
process.kill(-child.pid, 0);
return true;
} catch (error) {
return error?.code === "EPERM";
}
}
async function waitForProcessTreeExit(child, timeoutMs) {
const deadlineAt = Date.now() + timeoutMs;
while (Date.now() < deadlineAt) {
if (!processTreeAlive(child)) {
return true;
}
await new Promise((resolvePoll) => {
setTimeout(resolvePoll, KNIP_PROCESS_TREE_EXIT_POLL_MS);
});
}
return !processTreeAlive(child);
}
/**
* Runs knip and returns parsed unused-file results.
*/
@@ -230,6 +260,16 @@ export async function runKnipUnusedFiles(params = {}) {
output: output.join(""),
});
};
const finishAfterProcessTreeCleanup = async (result) => {
if (processTreeAlive(child)) {
await waitForProcessTreeExit(child, killGraceMs);
}
if (processTreeAlive(child)) {
signalProcessTree(child, "SIGKILL");
await waitForProcessTreeExit(child, KNIP_POST_FORCE_KILL_WAIT_MS);
}
finish(result);
};
const appendOutput = (chunk) => {
if (settled) {
@@ -283,7 +323,7 @@ export async function runKnipUnusedFiles(params = {}) {
exitSignal = exitSignal ?? signal;
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
if (timedOut) {
finish({
void finishAfterProcessTreeCleanup({
errorCode: "ETIMEDOUT",
errorMessage: `Knip unused-file scan timed out after ${elapsedSeconds}s`,
signal: exitSignal,
@@ -292,7 +332,7 @@ export async function runKnipUnusedFiles(params = {}) {
return;
}
if (bufferExceeded) {
finish({
void finishAfterProcessTreeCleanup({
errorCode: "ENOBUFS",
errorMessage: `Knip unused-file scan exceeded ${maxBufferBytes} output bytes`,
signal: exitSignal,

View File

@@ -35,6 +35,8 @@ const prepareBoundaryArtifactsBin = resolve(
const extensionPackageBoundaryBaseConfig = "../tsconfig.package-boundary.base.json";
const FAILURE_OUTPUT_TAIL_LINES = 40;
const STEP_OUTPUT_MAX_CHARS = 256 * 1024;
const STEP_PROCESS_GROUP_EXIT_POLL_MS = 25;
const STEP_POST_FORCE_KILL_WAIT_MS = 1_000;
const SLOW_COMPILE_SUMMARY_LIMIT = 10;
const COMPILE_INPUT_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".json"]);
const ROOTDIR_BOUNDARY_CANARY_IMPORT_PATH =
@@ -420,6 +422,34 @@ export function runNodeStepAsync(label, args, timeoutMs, params = {}) {
child.kill(signal);
}
};
const processGroupAlive = () => {
if (platform === "win32" || typeof child.pid !== "number") {
return false;
}
try {
killProcess(-child.pid, 0);
return true;
} catch (error) {
return error?.code === "EPERM";
}
};
const waitForProcessGroupExit = async (ms) => {
const deadlineAt = Date.now() + ms;
while (Date.now() < deadlineAt) {
if (!processGroupAlive()) {
return true;
}
await new Promise((resolvePoll) => {
setTimeout(resolvePoll, STEP_PROCESS_GROUP_EXIT_POLL_MS);
});
}
return !processGroupAlive();
};
const waitAfterForceKill = async () => {
if (processGroupAlive()) {
await waitForProcessGroupExit(STEP_POST_FORCE_KILL_WAIT_MS);
}
};
const abortSignal = abortController?.signal;
const abortListener = () => {
signalChild("SIGTERM");
@@ -443,30 +473,33 @@ export function runNodeStepAsync(label, args, timeoutMs, params = {}) {
settled = true;
cleanup();
signalChild("SIGKILL");
const stdoutText = formatCapturedStepOutput(stdout);
const stderrText = formatCapturedStepOutput(stderr);
const error = attachStepFailureMetadata(
new Error(
formatStepFailure(label, {
void (async () => {
await waitAfterForceKill();
const stdoutText = formatCapturedStepOutput(stdout);
const stderrText = formatCapturedStepOutput(stderr);
const error = attachStepFailureMetadata(
new Error(
formatStepFailure(label, {
stdout: stdoutText,
stderr: stderrText,
kind: "timeout",
elapsedMs: Date.now() - startedAt,
note: `${label} timed out after ${timeoutMs}ms`,
}),
),
label,
{
stdout: stdoutText,
stderr: stderrText,
kind: "timeout",
elapsedMs: Date.now() - startedAt,
note: `${label} timed out after ${timeoutMs}ms`,
}),
),
label,
{
stdout: stdoutText,
stderr: stderrText,
kind: "timeout",
elapsedMs: Date.now() - startedAt,
note: `${label} timed out after ${timeoutMs}ms`,
},
);
onFailure?.(error);
abortSiblingSteps(abortController);
rejectPromise(toLintErrorObject(error, "Step timed out"));
},
);
onFailure?.(error);
abortSiblingSteps(abortController);
rejectPromise(toLintErrorObject(error, "Step timed out"));
})();
}, timeoutMs);
child.stdout.setEncoding("utf8");

View File

@@ -209,6 +209,13 @@ Options:
--cpu-core-warn <ratio> Hot CPU threshold (default: 0.9)
--hot-wall-warn-ms <ms> Minimum wall time for hot CPU observations (default: 30000)
--max-rss-warn-mb <mb> Maximum RSS warning threshold (default: 1536)
--wall-anomaly-multiplier <n> Wall-time anomaly multiplier (default: 3)
--rss-anomaly-multiplier <n> RSS anomaly multiplier (default: 2.5)
--qa-cpu-regression-multiplier <n> QA baseline CPU regression multiplier (default: 2)
--qa-wall-regression-multiplier <n> QA baseline wall regression multiplier (default: 2)
--command-timeout-ms <ms> Lifecycle/slash command timeout (default: 120000)
--build-timeout-ms <ms> Prebuild command timeout (default: 600000)
--qa-timeout-ms <ms> QA chunk timeout (default: 900000)
--skip-prebuild Skip the upfront build used to avoid per-command rebuild noise
--skip-lifecycle Skip plugin install/inspect/disable/enable/doctor/uninstall
--skip-qa Skip QA Lab RPC conversation runs
@@ -216,6 +223,14 @@ Options:
--allow-empty Allow zero-command runs when every active phase is skipped
--fail-on-observation Treat RSS/CPU/wall observation rows as guard failures
--keep-run-root Preserve isolated HOME/state/log temp root after success
Environment:
OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_IDS Comma-separated plugin ids to include
OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_TOTAL Total plugin shards
OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_INDEX Zero-based shard index
OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_FAIL_ON_OBSERVATION=1
OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_KEEP_RUN_ROOT=1
OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_QA_SUMMARY_MAX_BYTES QA summary read ceiling
`);
}

View File

@@ -68,6 +68,7 @@ export const migratedSessionAccessorFiles = new Set([
"src/gateway/sessions-resolve.ts",
"src/gateway/server-methods/sessions.ts",
"src/infra/outbound/message-action-tts.ts",
"src/tui/embedded-backend.ts",
]);
export const migratedBundledPluginSessionAccessorFiles = new Set([
@@ -98,6 +99,7 @@ export const migratedSessionAccessorWriteFiles = new Set([
"src/auto-reply/reply/session-reset-model.ts",
"src/auto-reply/reply/session-updates.ts",
"src/auto-reply/reply/session-usage.ts",
"src/tui/embedded-backend.ts",
]);
export const migratedTranscriptWriterFiles = new Set([
@@ -290,8 +292,13 @@ export async function main() {
"src/cron",
"src/gateway",
"src/infra",
"src/tui",
]);
const writeSourceRoots = resolveSourceRoots(repoRoot, [
"src/agents",
"src/auto-reply",
"src/tui",
]);
const writeSourceRoots = resolveSourceRoots(repoRoot, ["src/agents", "src/auto-reply"]);
const transcriptWriterSourceRoots = resolveSourceRoots(repoRoot, [
"src/agents/command",
"src/agents/embedded-agent-runner",

View File

@@ -3,10 +3,12 @@
// Uses installed tools when present, otherwise falls back to pinned hooks where
// possible, then runs repo-specific workflow guards.
import { spawnSync } from "node:child_process";
import { readdirSync } from "node:fs";
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ACTIONLINT_VERSION = "1.7.11";
const PRE_COMMIT_VERSION = "4.2.0";
const WORKFLOW_DIR = ".github/workflows";
function commandExists(command, args = ["--version"]) {
@@ -25,6 +27,59 @@ function run(command, args) {
}
}
function runChecked(command, args) {
const result = spawnSync(command, args, { stdio: "inherit" });
if (result.error) {
return {
message: `[check-workflows] failed to run ${command}: ${result.error.message}`,
status: 1,
};
}
if (result.status !== 0) {
return {
message: null,
status: result.status ?? 1,
};
}
return null;
}
function runPreCommitFromTempVenv(hook, hookArgs) {
if (!commandExists("python3", ["--version"])) {
return false;
}
const venvDir = mkdtempSync(join(tmpdir(), "openclaw-check-workflows-pre-commit-"));
const python = join(venvDir, process.platform === "win32" ? "Scripts/python.exe" : "bin/python");
let failure;
try {
failure = runChecked("python3", ["-m", "venv", venvDir]);
if (!failure) {
failure = runChecked(python, [
"-m",
"pip",
"install",
"--disable-pip-version-check",
`pre-commit==${PRE_COMMIT_VERSION}`,
]);
}
if (!failure) {
failure = runChecked(python, ["-m", "pre_commit", ...hookArgs]);
}
if (failure) {
return false;
}
return true;
} finally {
rmSync(venvDir, { force: true, recursive: true });
if (failure) {
if (failure.message) {
console.error(failure.message);
}
process.exit(failure.status);
}
}
}
function workflowFiles() {
return readdirSync(WORKFLOW_DIR)
.filter((file) => file.endsWith(".yml") || file.endsWith(".yaml"))
@@ -42,9 +97,12 @@ function runPreCommitHook(hook, files) {
run("python3", ["-m", "pre_commit", ...hookArgs]);
return;
}
if (runPreCommitFromTempVenv(hook, hookArgs)) {
return;
}
console.error(
`[check-workflows] missing pre-commit runtime for ${hook}: install pre-commit or python3 pre_commit.`,
`[check-workflows] missing pre-commit runtime for ${hook}: install pre-commit or Python venv support for pre-commit ${PRE_COMMIT_VERSION}.`,
);
process.exit(1);
}
@@ -57,7 +115,8 @@ if (commandExists("actionlint")) {
run("go", ["run", `github.com/rhysd/actionlint/cmd/actionlint@v${ACTIONLINT_VERSION}`]);
} else if (
commandExists("pre-commit") ||
commandExists("python3", ["-m", "pre_commit", "--version"])
commandExists("python3", ["-m", "pre_commit", "--version"]) ||
commandExists("python3", ["--version"])
) {
runPreCommitHook("actionlint", workflows);
} else {

View File

@@ -216,7 +216,7 @@ fi
committed=false
if [ "$fast_commit" = true ]; then
declare -a commit_env=(FAST_COMMIT=1)
if run_git_with_lock_retry "commit" env "${commit_env[@]}" git commit -m "$commit_message"; then
if run_git_with_lock_retry "commit" env "${commit_env[@]}" git commit --no-verify -m "$commit_message"; then
committed=true
fi
else

View File

@@ -15,12 +15,28 @@ TIMEZONE="${OPENCLAW_TZ:-}"
RAW_SKIP_ONBOARDING="${OPENCLAW_SKIP_ONBOARDING:-}"
SKIP_ONBOARDING=""
DOCKER_PULL_TIMEOUT="${OPENCLAW_DOCKER_SETUP_PULL_TIMEOUT:-600s}"
OFFLINE_MODE=""
DEFAULT_SANDBOX_IMAGE="openclaw-sandbox:bookworm-slim"
DEFAULT_SANDBOX_BROWSER_IMAGE="openclaw-sandbox-browser:bookworm-slim"
SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH="2026-05-12-cdp-relay-auth"
fail() {
echo "ERROR: $*" >&2
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--offline)
OFFLINE_MODE="1"
;;
*)
fail "Unknown option: $1"
;;
esac
shift
done
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing dependency: $1" >&2
@@ -47,6 +63,14 @@ run_docker_pull() {
docker pull "$image"
}
require_local_docker_image() {
local image="$1"
if docker image inspect "$image" >/dev/null 2>&1; then
return 0
fi
fail "Offline Docker setup requires preloaded image $image. Load it with 'docker load -i <image.tar>' before running scripts/docker/setup.sh --offline."
}
is_truthy_value() {
local raw="${1:-}"
raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
@@ -154,8 +178,16 @@ sync_gateway_config() {
fi
}
run_compose_one_off() {
local -a run_args=(run)
if [[ -n "$OFFLINE_MODE" ]]; then
run_args+=(--pull never)
fi
docker compose "${COMPOSE_ARGS[@]}" "${run_args[@]}" "$@"
}
run_prestart_gateway() {
docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps "$@"
run_compose_one_off --rm --no-deps "$@"
}
run_prestart_cli() {
@@ -182,7 +214,11 @@ run_runtime_cli() {
shift 2
local -a compose_args
local -a run_args=(run --rm)
local -a run_args=(run)
if [[ -n "$OFFLINE_MODE" ]]; then
run_args+=(--pull never)
fi
run_args+=(--rm)
case "$compose_scope" in
current) compose_args=("${COMPOSE_ARGS[@]}") ;;
@@ -199,6 +235,181 @@ run_runtime_cli() {
docker compose "${compose_args[@]}" "${run_args[@]}" openclaw-cli "$@"
}
run_gateway_up() {
local compose_scope="${1:-current}"
shift
local -a compose_args
local -a up_args=(up -d)
case "$compose_scope" in
current) compose_args=("${COMPOSE_ARGS[@]}") ;;
base) compose_args=("${BASE_COMPOSE_ARGS[@]}") ;;
*) fail "Unknown gateway compose scope: $compose_scope" ;;
esac
if [[ -n "$OFFLINE_MODE" ]]; then
up_args+=(--pull never --no-build)
fi
up_args+=("$@")
docker compose "${compose_args[@]}" "${up_args[@]}" openclaw-gateway
}
resolve_offline_sandbox_images() {
local agents_json sandbox_tools_json
agents_json="$(run_prestart_cli config get agents --json 2>/dev/null || true)"
if [[ -z "$agents_json" ]]; then
agents_json="{}"
fi
sandbox_tools_json="$(
run_prestart_cli config get tools.sandbox.tools --json 2>/dev/null || true
)"
if [[ -z "$sandbox_tools_json" ]]; then
sandbox_tools_json="{}"
fi
printf '%s' "$agents_json" | run_prestart_gateway \
-T --entrypoint node openclaw-gateway -e '
const fs = require("node:fs");
const agents = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
const globalToolPolicy = JSON.parse(process.argv[3] || "{}");
const defaultSandbox = agents?.defaults?.sandbox ?? {};
const defaultDockerImage = defaultSandbox?.docker?.image ?? process.argv[1];
const defaultBrowserImage = defaultSandbox?.browser?.image ?? process.argv[2];
const images = new Set();
const configuredEntries = Array.isArray(agents?.list)
? agents.list.filter((entry) => entry !== null && typeof entry === "object")
: [];
const entries = configuredEntries.length > 0 ? configuredEntries : [{ sandbox: {} }];
const matchesBrowser = (rawPattern) => {
const pattern = String(rawPattern ?? "").trim().toLowerCase();
if (pattern === "group:openclaw" || pattern === "group:ui") {
return true;
}
if (!pattern) {
return false;
}
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`^${escaped.replaceAll("*", ".*")}$`).test("browser");
};
const permitsBrowser = (entry) => {
const agentPolicy = entry?.tools?.sandbox?.tools ?? {};
const allow = Array.isArray(agentPolicy.allow)
? agentPolicy.allow
: Array.isArray(globalToolPolicy?.allow)
? globalToolPolicy.allow
: undefined;
const alsoAllow = Array.isArray(agentPolicy.alsoAllow)
? agentPolicy.alsoAllow
: Array.isArray(globalToolPolicy?.alsoAllow)
? globalToolPolicy.alsoAllow
: undefined;
const deny = Array.isArray(agentPolicy.deny)
? agentPolicy.deny
: Array.isArray(globalToolPolicy?.deny)
? globalToolPolicy.deny
: undefined;
// Browser is absent from the default allowlist and present in the default
// denylist. Explicit allow patterns re-enable it unless an explicit deny wins.
const explicitAllows = [...(allow ?? []), ...(alsoAllow ?? [])];
const allowedByAllowlist = Array.isArray(allow)
? allow.length === 0 || explicitAllows.some(matchesBrowser)
: (alsoAllow ?? []).some(matchesBrowser);
const denied = Array.isArray(deny)
? deny.some(matchesBrowser)
: !explicitAllows.some(matchesBrowser);
return allowedByAllowlist && !denied;
};
for (const entry of entries) {
const sandbox = entry?.sandbox ?? {};
const mode = sandbox.mode ?? "non-main";
const backend = (
sandbox.backend?.trim() ||
defaultSandbox.backend?.trim() ||
"docker"
).toLowerCase();
if (mode === "off" || backend !== "docker") {
continue;
}
// Setup writes defaults scope=agent. Explicit per-agent scope still wins,
// and shared scope intentionally ignores per-agent Docker/browser overrides.
const scope = sandbox.scope ?? "agent";
const agentDocker = scope === "shared" ? undefined : sandbox.docker;
images.add(`sandbox\t${agentDocker?.image ?? defaultDockerImage}`);
const agentBrowser = scope === "shared" ? undefined : sandbox.browser;
const browserEnabled = agentBrowser?.enabled ?? defaultSandbox?.browser?.enabled ?? false;
if (browserEnabled && permitsBrowser(entry)) {
images.add(`browser\t${agentBrowser?.image ?? defaultBrowserImage}`);
}
}
process.stdout.write([...images].join("\n"));
' "$DEFAULT_SANDBOX_IMAGE" "$DEFAULT_SANDBOX_BROWSER_IMAGE" "$sandbox_tools_json"
}
validate_offline_sandbox_prerequisites() {
if [[ ! -S "$DOCKER_SOCKET_PATH" ]]; then
fail "Offline sandbox setup requires a Docker socket at $DOCKER_SOCKET_PATH."
fi
local sandbox_images
sandbox_images="$(resolve_offline_sandbox_images)"
local -a sandbox_image_errors=()
local image_kind sandbox_image browser_contract
while IFS=$'\t' read -r image_kind sandbox_image; do
[[ -n "$image_kind" ]] || continue
case "$image_kind" in
sandbox)
if ! docker --host "unix://$DOCKER_SOCKET_PATH" image inspect "$sandbox_image" >/dev/null 2>&1; then
sandbox_image_errors+=("$sandbox_image (missing)")
fi
;;
browser)
if ! browser_contract="$(
docker --host "unix://$DOCKER_SOCKET_PATH" image inspect \
-f '{{ index .Config.Labels "org.openclaw.sandbox-browser.contract" }}' \
"$sandbox_image" 2>/dev/null
)"; then
sandbox_image_errors+=("$sandbox_image (missing)")
elif [[ "$browser_contract" != "$SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH" ]]; then
sandbox_image_errors+=(
"$sandbox_image (browser contract=${browser_contract:-missing}, expected=$SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH)"
)
fi
;;
*)
fail "Unknown offline sandbox image kind: $image_kind"
;;
esac
done <<<"$sandbox_images"
if [[ ${#sandbox_image_errors[@]} -gt 0 ]]; then
echo "WARNING: offline Docker setup cannot use required sandbox images:" >&2
local sandbox_image_error
for sandbox_image_error in "${sandbox_image_errors[@]}"; do
echo " - $sandbox_image_error" >&2
done
echo " Load them with 'docker load -i <sandbox-image.tar>' before enabling sandboxed agents." >&2
fail "Offline sandbox prerequisites are incomplete; sandbox configuration was not changed."
fi
echo "Using preloaded sandbox images:"
while IFS=$'\t' read -r _ sandbox_image; do
if [[ -n "$sandbox_image" ]]; then
echo " - $sandbox_image"
fi
done <<<"$sandbox_images"
if ! run_compose_one_off --rm --entrypoint docker openclaw-gateway --version >/dev/null 2>&1; then
fail "Offline sandbox setup requires Docker CLI in $IMAGE_NAME."
fi
}
contains_disallowed_chars() {
local value="$1"
[[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]]
@@ -539,7 +750,10 @@ upsert_env "$ENV_FILE" \
OPENCLAW_OTEL_PRELOADED \
OPENCLAW_SKIP_ONBOARDING
if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
if [[ -n "$OFFLINE_MODE" ]]; then
require_local_docker_image "$IMAGE_NAME"
echo "==> Using preloaded Docker image: $IMAGE_NAME"
elif [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
echo "==> Building Docker image: $IMAGE_NAME"
run_docker_build \
--build-arg "OPENCLAW_IMAGE_APT_PACKAGES=${OPENCLAW_IMAGE_APT_PACKAGES}" \
@@ -618,9 +832,15 @@ echo "Discord (bot token):"
echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel discord --token <token>"
echo "Docs: https://docs.openclaw.ai/channels"
if [[ -n "$SANDBOX_ENABLED" && -n "$OFFLINE_MODE" ]]; then
echo ""
echo "==> Sandbox preflight"
validate_offline_sandbox_prerequisites
fi
echo ""
echo "==> Starting gateway"
docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway
run_gateway_up current
# --- Sandbox setup (opt-in via OPENCLAW_SANDBOX=1) ---
if [[ -n "$SANDBOX_ENABLED" ]]; then
@@ -628,13 +848,19 @@ if [[ -n "$SANDBOX_ENABLED" ]]; then
echo "==> Sandbox setup"
sandbox_dockerfile="$ROOT_DIR/scripts/docker/sandbox/Dockerfile"
if [[ -f "$sandbox_dockerfile" ]]; then
echo "Building sandbox image: openclaw-sandbox:bookworm-slim"
if [[ -z "$OFFLINE_MODE" && ! -S "$DOCKER_SOCKET_PATH" ]]; then
echo "WARNING: OPENCLAW_SANDBOX enabled but Docker socket not found at $DOCKER_SOCKET_PATH." >&2
echo " Sandbox requires Docker socket access. Skipping sandbox setup." >&2
SANDBOX_ENABLED=""
fi
if [[ -n "$SANDBOX_ENABLED" && -z "$OFFLINE_MODE" && -f "$sandbox_dockerfile" ]]; then
echo "Building sandbox image: $DEFAULT_SANDBOX_IMAGE"
run_docker_build \
-t "openclaw-sandbox:bookworm-slim" \
-t "$DEFAULT_SANDBOX_IMAGE" \
-f "$sandbox_dockerfile" \
"$ROOT_DIR"
else
elif [[ -n "$SANDBOX_ENABLED" && -z "$OFFLINE_MODE" ]]; then
echo "WARNING: sandbox Dockerfile not found at $sandbox_dockerfile" >&2
echo " Sandbox config will be applied but no sandbox image will be built." >&2
echo " Agent exec may fail if the configured sandbox image does not exist." >&2
@@ -643,7 +869,8 @@ if [[ -n "$SANDBOX_ENABLED" ]]; then
# Defense-in-depth: verify Docker CLI in the running image before enabling
# sandbox. This avoids claiming sandbox is enabled when the image cannot
# launch sandbox containers.
if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --entrypoint docker openclaw-gateway --version >/dev/null 2>&1; then
if [[ -n "$SANDBOX_ENABLED" && -z "$OFFLINE_MODE" ]] &&
! run_compose_one_off --rm --entrypoint docker openclaw-gateway --version >/dev/null 2>&1; then
echo "WARNING: Docker CLI not found inside the container image." >&2
echo " Sandbox requires Docker CLI. Rebuild with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1" >&2
echo " or use a local build (OPENCLAW_IMAGE=openclaw:local). Skipping sandbox setup." >&2
@@ -656,27 +883,21 @@ if [[ -n "$SANDBOX_ENABLED" ]]; then
# Mount Docker socket via a dedicated compose overlay. This overlay is
# created only after sandbox prerequisites pass, so the socket is never
# exposed when sandbox cannot actually run.
if [[ -S "$DOCKER_SOCKET_PATH" ]]; then
SANDBOX_COMPOSE_FILE="$ROOT_DIR/docker-compose.sandbox.yml"
cat >"$SANDBOX_COMPOSE_FILE" <<YAML
SANDBOX_COMPOSE_FILE="$ROOT_DIR/docker-compose.sandbox.yml"
cat >"$SANDBOX_COMPOSE_FILE" <<YAML
services:
openclaw-gateway:
volumes:
- $(quote_yaml_string "${DOCKER_SOCKET_PATH}:/var/run/docker.sock")
YAML
if [[ -n "${DOCKER_GID:-}" ]]; then
cat >>"$SANDBOX_COMPOSE_FILE" <<YAML
if [[ -n "${DOCKER_GID:-}" ]]; then
cat >>"$SANDBOX_COMPOSE_FILE" <<YAML
group_add:
- "${DOCKER_GID}"
YAML
fi
COMPOSE_ARGS+=("-f" "$SANDBOX_COMPOSE_FILE")
echo "==> Sandbox: added Docker socket mount"
else
echo "WARNING: OPENCLAW_SANDBOX enabled but Docker socket not found at $DOCKER_SOCKET_PATH." >&2
echo " Sandbox requires Docker socket access. Skipping sandbox setup." >&2
SANDBOX_ENABLED=""
fi
COMPOSE_ARGS+=("-f" "$SANDBOX_COMPOSE_FILE")
echo "==> Sandbox: added Docker socket mount"
fi
if [[ -n "$SANDBOX_ENABLED" ]]; then
@@ -702,7 +923,7 @@ if [[ -n "$SANDBOX_ENABLED" ]]; then
echo "Sandbox enabled: mode=non-main, scope=agent, workspaceAccess=none"
echo "Docs: https://docs.openclaw.ai/gateway/sandboxing"
# Restart gateway with sandbox compose overlay to pick up socket mount + config.
docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway
run_gateway_up current
else
echo "WARNING: Sandbox config was partially applied. Check errors above." >&2
echo " Skipping gateway restart to avoid exposing Docker socket without a full sandbox policy." >&2
@@ -716,7 +937,7 @@ if [[ -n "$SANDBOX_ENABLED" ]]; then
rm -f "$SANDBOX_COMPOSE_FILE"
fi
# Ensure gateway service definition is reset without sandbox overlay mount.
docker compose "${BASE_COMPOSE_ARGS[@]}" up -d --force-recreate openclaw-gateway
run_gateway_up base --force-recreate
fi
else
# Keep reruns deterministic: if sandbox is not active for this run, reset

View File

@@ -2,6 +2,7 @@
import childProcess from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
@@ -30,7 +31,6 @@ const DEFAULT_MAX_COMMAND_RSS_MIB = 8192;
const DEFAULT_OUTPUT_CAPTURE_CHARS = 1024 * 1024;
const GATEWAY_TEARDOWN_GRACE_MS = 10000;
const GATEWAY_TEARDOWN_KILL_GRACE_MS = 2000;
const DEFAULT_PORT = 19000 + Math.floor(Math.random() * 1000);
const LOG_SCAN_CHUNK_BYTES = 64 * 1024;
const LOG_SCAN_MAX_LINE_CHARS = 16 * 1024;
const LOG_TAIL_BYTES = 256 * 1024;
@@ -65,12 +65,17 @@ Environment:
OPENCLAW_ENTRY Built OpenClaw entrypoint. Defaults to dist/index.mjs or dist/index.js.
OPENCLAW_KITCHEN_SINK_NPM_SPEC Plugin package spec. Default: npm:@openclaw/kitchen-sink@latest.
OPENCLAW_KITCHEN_SINK_PLUGIN_ID Plugin id. Default: openclaw-kitchen-sink-fixture.
OPENCLAW_KITCHEN_SINK_PERSONALITY Plugin fixture personality. Default: conformance.
OPENCLAW_KITCHEN_SINK_RPC_PORT Gateway loopback port. Default: OS-selected free port.
OPENCLAW_KITCHEN_SINK_RPC_READY_MS Gateway readiness timeout.
OPENCLAW_KITCHEN_SINK_RPC_COMMAND_MS OpenClaw command timeout.
OPENCLAW_KITCHEN_SINK_RPC_INSTALL_MS Plugin install timeout.
OPENCLAW_KITCHEN_SINK_RPC_CALL_MS RPC call timeout.
OPENCLAW_KITCHEN_SINK_RPC_FETCH_MS HTTP readiness probe timeout.
OPENCLAW_KITCHEN_SINK_RPC_FETCH_BODY_BYTES HTTP readiness probe response ceiling.
OPENCLAW_KITCHEN_SINK_MAX_RSS_MIB Gateway RSS ceiling.
OPENCLAW_KITCHEN_SINK_COMMAND_MAX_RSS_MIB Install/CLI command RSS ceiling.
OPENCLAW_KITCHEN_SINK_OUTPUT_CAPTURE_CHARS Per-command stdout/stderr capture ceiling.
OPENCLAW_KITCHEN_SINK_KEEP_TMP=1 Preserve the isolated temp home.
`;
}
@@ -145,6 +150,42 @@ export function resolveKitchenSinkRpcConfig(env = process.env) {
};
}
export async function findAvailableLoopbackPort(options = {}) {
const createServer = options.createServer ?? (() => net.createServer());
const server = createServer();
return await new Promise((resolve, reject) => {
const fail = (error) => {
server.close?.(() => {});
reject(toLintErrorObject(error, "Unable to reserve Kitchen Sink RPC loopback port"));
};
server.once("error", fail);
server.listen(0, "127.0.0.1", () => {
server.off?.("error", fail);
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
server.close((error) => {
if (error) {
reject(toLintErrorObject(error, "Unable to close Kitchen Sink RPC loopback port"));
return;
}
if (!Number.isSafeInteger(port) || port <= 0) {
reject(new Error(`unable to reserve Kitchen Sink RPC loopback port: ${String(port)}`));
return;
}
resolve(port);
});
});
});
}
export async function resolveKitchenSinkRpcPort(env = process.env, options = {}) {
const rawPort = (env.OPENCLAW_KITCHEN_SINK_RPC_PORT || "").trim();
if (rawPort) {
return readPositiveInt(rawPort, 0, "OPENCLAW_KITCHEN_SINK_RPC_PORT");
}
return await (options.findAvailablePort ?? findAvailableLoopbackPort)();
}
function resolveOpenClawRunner() {
if (process.env.OPENCLAW_ENTRY) {
return {
@@ -980,7 +1021,8 @@ async function startGateway(runner, port, env, logPath) {
}
export async function stopGateway(child, options = {}) {
if (!child || hasChildExited(child)) {
const killProcess = options.killProcess ?? defaultKillProcess;
if (!child || !isGatewayAlive(child, killProcess)) {
return;
}
const teardownGraceMs = Math.max(0, options.teardownGraceMs ?? GATEWAY_TEARDOWN_GRACE_MS);
@@ -988,18 +1030,21 @@ export async function stopGateway(child, options = {}) {
const exited = new Promise((resolve) => {
child.once("exit", resolve);
});
const waitForExit = async (ms) =>
hasChildExited(child)
? true
: await Promise.race([exited.then(() => true), delay(ms).then(() => false)]);
const waitForExit = async (ms) => {
if (!isGatewayAlive(child, killProcess)) {
return true;
}
await Promise.race([exited, delay(ms)]);
return !isGatewayAlive(child, killProcess);
};
if (!signalGateway(child, "SIGTERM")) {
if (!signalGateway(child, "SIGTERM", killProcess)) {
return;
}
if (await waitForExit(teardownGraceMs)) {
return;
}
if (!signalGateway(child, "SIGKILL")) {
if (!signalGateway(child, "SIGKILL", killProcess)) {
return;
}
if (await waitForExit(killGraceMs)) {
@@ -1012,6 +1057,25 @@ export function hasChildExited(child) {
return child.exitCode !== null || child.signalCode !== null;
}
function defaultKillProcess(pid, signal) {
return process.kill(pid, signal);
}
function isGatewayAlive(child, killProcess) {
if (process.platform !== "win32" && typeof child.pid === "number") {
try {
killProcess(-child.pid, 0);
return true;
} catch (error) {
if (error?.code === "ESRCH") {
return false;
}
throw error;
}
}
return !hasChildExited(child);
}
function createChildExitPromise(child) {
if (!child || typeof child.once !== "function") {
return null;
@@ -1028,10 +1092,10 @@ function releaseUnsettledGatewayChild(child) {
child.unref?.();
}
function signalGateway(child, signal) {
function signalGateway(child, signal, killProcess = defaultKillProcess) {
if (process.platform !== "win32" && typeof child.pid === "number") {
try {
process.kill(-child.pid, signal);
killProcess(-child.pid, signal);
return true;
} catch (error) {
if (error?.code === "ESRCH") {
@@ -2200,11 +2264,7 @@ function isNonEmptyString(value) {
export async function main() {
const config = resolveKitchenSinkRpcConfig();
let runner = resolveOpenClawRunner();
const port = readPositiveInt(
process.env.OPENCLAW_KITCHEN_SINK_RPC_PORT,
DEFAULT_PORT,
"OPENCLAW_KITCHEN_SINK_RPC_PORT",
);
const port = await resolveKitchenSinkRpcPort();
const { root, env } = makeEnv();
const logPath = path.join(root, "gateway.log");
const keepTmp = process.env.OPENCLAW_KITCHEN_SINK_KEEP_TMP === "1";

View File

@@ -1,159 +0,0 @@
// Probe script for plugin lifecycle matrix E2E scenarios.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
const home = os.homedir();
function openclawPath(...parts) {
return path.join(home, ".openclaw", ...parts);
}
function readJson(file) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return {};
}
}
function readRequiredJson(file) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`failed to read JSON from ${file}: ${message}`, { cause: error });
}
}
function records() {
return readPluginInstallRecords();
}
function recordFor(pluginId) {
return records()[pluginId];
}
function config() {
return readJson(process.env.OPENCLAW_CONFIG_PATH ?? openclawPath("openclaw.json"));
}
function requiredConfig() {
return readRequiredJson(process.env.OPENCLAW_CONFIG_PATH ?? openclawPath("openclaw.json"));
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function assertVersion(pluginId, version) {
const record = recordFor(pluginId);
assert(record, `install record missing for ${pluginId}`);
assert(record.source === "npm", `expected npm source for ${pluginId}, got ${record.source}`);
assert(
record.resolvedVersion === version || record.version === version,
`expected ${pluginId} record version ${version}, got ${JSON.stringify(record)}`,
);
assert(record.installPath, `install path missing for ${pluginId}`);
const packageJson = readJson(path.join(record.installPath, "package.json"));
assert(
packageJson.version === version,
`expected installed package version ${version}, got ${packageJson.version}`,
);
}
function assertNpmProjectRoot(pluginId, packageName) {
const record = recordFor(pluginId);
assert(record?.installPath, `install path missing for ${pluginId}`);
const relative = path.relative(openclawPath("npm", "projects"), record.installPath);
assert(
!relative.startsWith("..") && !path.isAbsolute(relative),
`install path outside npm projects: ${record.installPath}`,
);
const segments = relative.split(path.sep);
const packageSegments = packageName.split("/");
assert(
segments.length === 2 + packageSegments.length,
`unexpected npm project install path: ${record.installPath}`,
);
assert(Boolean(segments[0]), `missing npm project directory: ${record.installPath}`);
assert(
segments[1] === "node_modules",
`missing project node_modules segment: ${record.installPath}`,
);
for (let index = 0; index < packageSegments.length; index++) {
assert(
segments[index + 2] === packageSegments[index],
`package path mismatch: ${record.installPath}`,
);
}
assert(
!fs.existsSync(openclawPath("npm", "node_modules", ...packageSegments)),
`legacy flat npm install path exists for ${packageName}`,
);
}
function assertInspectLoaded(pluginId, inspectPath) {
assert(inspectPath, "inspect JSON path is required");
const inspect = readRequiredJson(inspectPath);
const plugin = inspect.plugin;
assert(plugin?.id === pluginId, `expected inspected plugin id ${pluginId}, got ${plugin?.id}`);
assert(plugin.enabled === true, `expected ${pluginId} inspect enabled=true`);
assert(
plugin.status === "loaded",
`expected ${pluginId} inspect status loaded, got ${plugin.status}`,
);
}
function assertEnabled(pluginId, expectedRaw) {
const expected = expectedRaw === "true";
const entry = config().plugins?.entries?.[pluginId];
assert(entry?.enabled === expected, `expected ${pluginId} enabled=${expected}`);
}
function printInstallPath(pluginId) {
const record = recordFor(pluginId);
assert(record?.installPath, `install path missing for ${pluginId}`);
process.stdout.write(record.installPath);
}
function assertUninstalled(pluginId) {
const cfg = requiredConfig();
const record = recordFor(pluginId);
assert(!record, `install record still present for ${pluginId}`);
assert(!cfg.plugins?.entries?.[pluginId], `plugin config entry still present for ${pluginId}`);
assert(!(cfg.plugins?.allow ?? []).includes(pluginId), `allowlist still contains ${pluginId}`);
assert(!(cfg.plugins?.deny ?? []).includes(pluginId), `denylist still contains ${pluginId}`);
const loadPaths = cfg.plugins?.load?.paths ?? [];
assert(
!loadPaths.some((entry) => String(entry).includes(pluginId)),
`load path still references ${pluginId}: ${loadPaths.join(", ")}`,
);
}
const [command, pluginId, arg] = process.argv.slice(2);
switch (command) {
case "assert-version":
assertVersion(pluginId, arg);
break;
case "assert-npm-project-root":
assertNpmProjectRoot(pluginId, arg);
break;
case "assert-inspect-loaded":
assertInspectLoaded(pluginId, arg);
break;
case "assert-enabled":
assertEnabled(pluginId, arg);
break;
case "install-path":
printInstallPath(pluginId);
break;
case "assert-uninstalled":
assertUninstalled(pluginId);
break;
default:
throw new Error(`unknown plugin lifecycle matrix probe command: ${command ?? "<missing>"}`);
}

View File

@@ -1,86 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
openclaw_e2e_install_package /tmp/openclaw-plugin-lifecycle-install.log "mounted OpenClaw package" /tmp/npm-prefix
package_root="$(openclaw_e2e_package_root /tmp/npm-prefix)"
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
export PATH="/tmp/npm-prefix/bin:$PATH"
export npm_config_loglevel=error
export npm_config_fund=false
export npm_config_audit=false
source scripts/e2e/lib/plugins/fixtures.sh
plugin_id="lifecycle-claw"
package_name="@openclaw/lifecycle-claw"
probe="scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs"
measure="scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs"
resource_dir="$(mktemp -d "/tmp/openclaw-plugin-lifecycle-matrix.XXXXXX")"
pack_root=""
registry_root=""
tarball_v1="$resource_dir/lifecycle-claw-1.0.0.tgz"
tarball_v2="$resource_dir/lifecycle-claw-2.0.0.tgz"
inspect_v1="$resource_dir/plugin-lifecycle-inspect-v1.json"
cleanup() {
openclaw_plugins_cleanup_fixture_servers
rm -rf "$resource_dir"
}
trap cleanup EXIT
summary_tsv="$resource_dir/resource-summary.tsv"
printf "phase\tmax_rss_kb\tcpu_seconds\twall_ms\tcpu_core_ratio\tsignal\n" >"$summary_tsv"
run_measured() {
local phase="$1"
shift
echo "Running plugin lifecycle phase: $phase"
node "$measure" "$summary_tsv" "$phase" -- "$@"
}
pack_root="$(mktemp -d "$resource_dir/pack.XXXXXX")"
registry_root="$(mktemp -d "$resource_dir/registry.XXXXXX")"
pack_fixture_plugin "$pack_root/v1" "$tarball_v1" "$plugin_id" 1.0.0 lifecycle.v1 "Lifecycle Claw"
pack_fixture_plugin "$pack_root/v2" "$tarball_v2" "$plugin_id" 2.0.0 lifecycle.v2 "Lifecycle Claw"
start_npm_fixture_registry "$package_name" 1.0.0 "$tarball_v1" "$registry_root" "$package_name" 2.0.0 "$tarball_v2"
trap cleanup EXIT
run_measured install-v1 node "$entry" plugins install "npm:$package_name@1.0.0"
node "$probe" assert-version "$plugin_id" 1.0.0
node "$probe" assert-npm-project-root "$plugin_id" "$package_name"
run_measured inspect-v1 bash -c 'node "$1" plugins inspect "$2" --runtime --json >"$3"' bash "$entry" "$plugin_id" "$inspect_v1"
node "$probe" assert-inspect-loaded "$plugin_id" "$inspect_v1"
run_measured disable node "$entry" plugins disable "$plugin_id"
node "$probe" assert-enabled "$plugin_id" false
run_measured enable node "$entry" plugins enable "$plugin_id"
node "$probe" assert-enabled "$plugin_id" true
run_measured upgrade-v2 node "$entry" plugins update "$package_name@2.0.0"
node "$probe" assert-version "$plugin_id" 2.0.0
node "$probe" assert-npm-project-root "$plugin_id" "$package_name"
run_measured downgrade-v1 node "$entry" plugins update "$package_name@1.0.0"
node "$probe" assert-version "$plugin_id" 1.0.0
node "$probe" assert-npm-project-root "$plugin_id" "$package_name"
install_path="$(node "$probe" install-path "$plugin_id")"
rm -rf "$install_path"
if [[ -e "$install_path" ]]; then
echo "Failed to remove plugin code before missing-code uninstall: $install_path" >&2
exit 1
fi
run_measured missing-code-uninstall node "$entry" plugins uninstall "$plugin_id" --force
node "$probe" assert-uninstalled "$plugin_id"
echo "Plugin lifecycle resource summary:"
cat "$summary_tsv"
echo "Plugin lifecycle matrix passed."

View File

@@ -13,6 +13,11 @@ PACKAGE_TGZ="${OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ:-${OPENCLAW_CURRENT_PACKAGE_TGZ
PACKAGE_LABEL="${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL:-}"
RUN_ID="${OPENCLAW_NPM_TELEGRAM_RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}"
OUTPUT_DIR="${OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR:-.artifacts/qa-e2e/npm-telegram-live/$RUN_ID}"
case "$OUTPUT_DIR" in
/*) OUTPUT_DIR_HOST="$OUTPUT_DIR" ;;
*) OUTPUT_DIR_HOST="$ROOT_DIR/$OUTPUT_DIR" ;;
esac
OUTPUT_DIR_CONTAINER="/app/.artifacts/qa-e2e/npm-telegram-live-output"
resolve_credential_source() {
if [ -n "${OPENCLAW_NPM_TELEGRAM_CREDENTIAL_SOURCE:-}" ]; then
@@ -156,6 +161,7 @@ validate_credential_preflight
docker_e2e_build_or_reuse "$IMAGE_NAME" npm-telegram-live "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "$DOCKER_TARGET"
mkdir -p "$ROOT_DIR/.artifacts/qa-e2e"
mkdir -p "$OUTPUT_DIR_HOST"
run_log="$(mktemp "${TMPDIR:-/tmp}/openclaw-npm-telegram-live.XXXXXX")"
npm_prefix_host="$(mktemp -d "$ROOT_DIR/.artifacts/qa-e2e/npm-telegram-live-prefix.XXXXXX")"
trap 'rm -f "$run_log"; rm -rf "$npm_prefix_host"' EXIT
@@ -166,7 +172,7 @@ docker_env=(
-e TMPDIR=/tmp
-e OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC="$PACKAGE_SPEC"
-e OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="$PACKAGE_LABEL"
-e OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR="$OUTPUT_DIR"
-e OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR="$OUTPUT_DIR_CONTAINER"
-e OPENCLAW_QA_PACKAGE_SOURCE="$package_install_source"
-e OPENCLAW_QA_PACKAGE_SOURCE_KIND="$package_source_kind"
-e OPENCLAW_QA_RUNNER="${OPENCLAW_QA_RUNNER:-docker}"
@@ -290,6 +296,7 @@ EOF
run_logged docker_e2e_run_with_harness \
"${docker_env[@]}" \
-v "$ROOT_DIR/.artifacts:/app/.artifacts" \
-v "$OUTPUT_DIR_HOST:$OUTPUT_DIR_CONTAINER" \
-v "$ROOT_DIR/extensions/qa-lab:/app/extensions/qa-lab:ro" \
-v "$npm_prefix_host:/npm-global" \
-i "$IMAGE_NAME" bash -s <<'EOF'

View File

@@ -1,4 +1,5 @@
// Guest Transports script supports OpenClaw repository automation.
import { randomUUID } from "node:crypto";
import { run } from "./host-command.ts";
import type { PhaseRunner } from "./phase-runner.ts";
import { encodePowerShell, psSingleQuote } from "./powershell.ts";
@@ -24,6 +25,10 @@ export interface WindowsBackgroundPowerShellOptions {
vmName: string;
}
function guestScriptName(extension: string): string {
return `openclaw-parallels-${randomUUID()}.${extension}`;
}
function appendOutput(
append: ((chunk: string | Uint8Array) => void) | undefined,
result: CommandResult,
@@ -65,7 +70,7 @@ export async function runWindowsBackgroundPowerShell(
const pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? 5_000));
const runCommand = options.runCommand ?? run;
const safeLabel = options.label.replaceAll(/[^A-Za-z0-9_-]/g, "-");
const nonce = `${safeLabel}-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
const nonce = `${safeLabel}-${randomUUID()}`;
const fileBase = `openclaw-parallels-${nonce}`;
const pathsScript = `$base = Join-Path $env:TEMP ${psSingleQuote(fileBase)}
$scriptPath = "$base.ps1"
@@ -366,7 +371,7 @@ export class LinuxGuest {
}
bash(script: string): string {
const scriptPath = `/tmp/openclaw-parallels-${process.pid}-${Date.now()}.sh`;
const scriptPath = `/tmp/${guestScriptName("sh")}`;
const write = run(
"prlctl",
[
@@ -450,7 +455,7 @@ export class MacosGuest {
}
sh(script: string, env: Record<string, string> = {}): string {
const scriptPath = `/tmp/openclaw-parallels-${process.pid}-${Date.now()}.sh`;
const scriptPath = `/tmp/${guestScriptName("sh")}`;
this.exec(["/bin/dd", `of=${scriptPath}`, "bs=1048576"], {
input: `umask 022\n${script}`,
});
@@ -486,7 +491,7 @@ export class WindowsGuest {
}
powershell(script: string, options: GuestExecOptions = {}): string {
const scriptName = `openclaw-parallels-${process.pid}-${Date.now()}.ps1`;
const scriptName = guestScriptName("ps1");
const writeScript = `$scriptPath = Join-Path $env:TEMP ${JSON.stringify(scriptName)}
[System.IO.File]::WriteAllText($scriptPath, [Console]::In.ReadToEnd(), [System.Text.UTF8Encoding]::new($false))`;
const write = run(

View File

@@ -94,7 +94,7 @@ async function stopHostServerChild(
terminateTimeoutMs = 2_000,
killTimeoutMs = 1_500,
): Promise<boolean> {
if (child.exitCode != null) {
if (hasHostServerChildExited(child)) {
return true;
}
child.kill("SIGTERM");
@@ -109,13 +109,13 @@ async function waitForChildExit(
child: ChildProcessWithoutNullStreams,
timeoutMs: number,
): Promise<boolean> {
if (child.exitCode != null) {
if (hasHostServerChildExited(child)) {
return true;
}
return await new Promise<boolean>((resolve) => {
let settled = false;
const onExit = () => settle(true);
const timeout = setTimeout(() => settle(child.exitCode != null), timeoutMs);
const timeout = setTimeout(() => settle(hasHostServerChildExited(child)), timeoutMs);
timeout.unref();
function settle(exited: boolean): void {
if (settled) {
@@ -130,6 +130,10 @@ async function waitForChildExit(
});
}
function hasHostServerChildExited(child: ChildProcessWithoutNullStreams): boolean {
return child.exitCode != null || child.signalCode != null;
}
async function waitForHostServer(
child: ChildProcessWithoutNullStreams,
port: number,

View File

@@ -1,4 +1,5 @@
// Macos Discord script supports OpenClaw repository automation.
import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import type { MacosGuest } from "./guest-transports.ts";
@@ -58,7 +59,7 @@ ${this.input.guestNode} ${this.input.guestOpenClawEntry} channels status --probe
}
async runRoundtrip(phase: DiscordSmokePhase): Promise<void> {
const nonce = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
const nonce = randomUUID();
const outboundNonce = `${phase}-out-${nonce}`;
const inboundNonce = `${phase}-in-${nonce}`;
const outboundLog = path.join(this.input.runDir, `${phase}.discord-send.json`);

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env -S pnpm tsx
// Npm Update Smoke script supports OpenClaw repository automation.
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import { copyFile, readFile, rm } from "node:fs/promises";
@@ -1098,7 +1099,7 @@ export class NpmUpdateSmoke {
}
private writeGuestScript(vm: string, script: string, prefix: string): string {
const scriptPath = `/tmp/${prefix}-${process.pid}-${Date.now()}.sh`;
const scriptPath = `/tmp/${prefix}-${randomUUID()}.sh`;
const write = run("prlctl", ["exec", vm, "/usr/bin/tee", scriptPath], {
check: false,
input: script,

View File

@@ -17,12 +17,10 @@ PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz plugin-lifecycle-matrix "${OPENCLA
docker_e2e_package_mount_args "$PACKAGE_TGZ"
docker_e2e_build_or_reuse "$IMAGE_NAME" plugin-lifecycle-matrix "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD"
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 plugin-lifecycle-matrix empty)"
DOCKER_ENV_ARGS=(
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0
-e OPENCLAW_SKIP_CHANNELS=1
-e OPENCLAW_SKIP_PROVIDERS=1
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64"
)
if [ -n "${OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS:-}" ]; then
DOCKER_ENV_ARGS+=(-e "OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS=$OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS")
@@ -45,6 +43,6 @@ docker_e2e_run_with_harness \
"${DOCKER_ENV_ARGS[@]}" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
"$IMAGE_NAME" \
bash scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh
tsx test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts --lifecycle-matrix
echo "Plugin lifecycle matrix Docker E2E passed."

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env -S node --import tsx
// Telegram User Credential script supports OpenClaw repository automation.
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { copyFile, mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -224,6 +224,10 @@ async function postBroker(params: {
return payload;
}
export function buildTelegramUserCredentialOwnerId() {
return `telegram-user-${randomUUID()}`;
}
async function resolveConvexLeaseConfig(opts: Map<string, string>) {
const envFile = opts.get("env-file") || DEFAULT_CONVEX_ENV_FILE;
const fileEnv = await readEnvFile(envFile);
@@ -259,7 +263,7 @@ async function resolveConvexLeaseConfig(opts: Map<string, string>) {
ownerId:
opts.get("owner-id") ||
process.env.OPENCLAW_QA_CREDENTIAL_OWNER_ID?.trim() ||
`telegram-user-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`,
buildTelegramUserCredentialOwnerId(),
};
}

View File

@@ -251,6 +251,8 @@ docker_e2e_harness_mount_args() {
DOCKER_E2E_HARNESS_ARGS=(
-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro"
-v "$ROOT_DIR/scripts/lib:/app/scripts/lib:ro"
-v "$ROOT_DIR/test/e2e/qa-lab:/app/test/e2e/qa-lab:ro"
-v "$ROOT_DIR/test/helpers:/app/test/helpers:ro"
-v "$ROOT_DIR/scripts/windows-cmd-helpers.mjs:/app/scripts/windows-cmd-helpers.mjs:ro"
)
}

View File

@@ -3,6 +3,7 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process";
const TEARDOWN_GRACE_MS = 2_000;
const TEARDOWN_KILL_GRACE_MS = 1_000;
const EXIT_POLL_MS = 10;
export type ChildExit = {
exitCode: number | null;
@@ -23,43 +24,95 @@ export async function stopChild(
child: ChildProcessWithoutNullStreams,
options: { killGraceMs?: number; teardownGraceMs?: number } = {},
): Promise<StopChildResult> {
const currentExit = (): ChildExit | null =>
child.exitCode != null || child.signalCode != null
const teardownGraceMs = options.teardownGraceMs ?? TEARDOWN_GRACE_MS;
const killGraceMs = options.killGraceMs ?? TEARDOWN_KILL_GRACE_MS;
let observedExit: ChildExit | null = null;
const directExit = (): ChildExit | null =>
observedExit ??
(child.exitCode != null || child.signalCode != null
? { exitCode: child.exitCode, signal: child.signalCode }
: null;
: null);
const currentExit = (): ChildExit | null => {
const exit = directExit();
if (exit == null || isProcessTreeAlive(child)) {
return null;
}
return exit;
};
const waitForProcessTreeExit = async (ms: number): Promise<boolean> => {
const deadlineAt = Date.now() + ms;
while (Date.now() < deadlineAt) {
if (!isProcessTreeAlive(child)) {
return true;
}
await delay(Math.min(EXIT_POLL_MS, deadlineAt - Date.now()));
}
return !isProcessTreeAlive(child);
};
const cleanupExitedProcessTree = async (
exit: ChildExit,
exitedBeforeTeardown: boolean,
): Promise<StopChildResult> => {
if (!isProcessTreeAlive(child)) {
return { ...exit, exitedBeforeTeardown };
}
const sentTeardownSignal = killProcessTree(child, "SIGTERM");
if (sentTeardownSignal) {
await waitForProcessTreeExit(teardownGraceMs);
}
if (sentTeardownSignal && isProcessTreeAlive(child)) {
killProcessTree(child, "SIGKILL");
await waitForProcessTreeExit(killGraceMs);
}
if (!sentTeardownSignal) {
releaseUnsettledChild(child);
}
return { ...exit, exitedBeforeTeardown };
};
const existingExit = currentExit();
const existingExit = directExit();
if (existingExit != null) {
return { ...existingExit, exitedBeforeTeardown: true };
return await cleanupExitedProcessTree(existingExit, true);
}
let observedExit: ChildExit | null = null;
const exited = new Promise<ChildExit>((resolve) => {
child.once("exit", (exitCode, signal) => {
observedExit = { exitCode, signal };
resolve(observedExit);
});
});
const waitForExit = async (ms: number): Promise<ChildExit | null> =>
await Promise.race([exited, delay(ms).then(() => null)]);
const waitForExit = async (ms: number): Promise<ChildExit | null> => {
const deadlineAt = Date.now() + ms;
while (Date.now() < deadlineAt) {
const waitMs = Math.min(EXIT_POLL_MS, deadlineAt - Date.now());
if (directExit() == null) {
await Promise.race([exited, delay(waitMs)]);
} else {
await delay(waitMs);
}
const exit = currentExit();
if (exit != null) {
return exit;
}
}
return currentExit();
};
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
const queuedExit = observedExit ?? currentExit();
const queuedExit = directExit();
if (queuedExit != null) {
return { ...queuedExit, exitedBeforeTeardown: true };
return await cleanupExitedProcessTree(queuedExit, true);
}
const teardownGraceMs = options.teardownGraceMs ?? TEARDOWN_GRACE_MS;
const killGraceMs = options.killGraceMs ?? TEARDOWN_KILL_GRACE_MS;
const sentTeardownSignal = killProcessTree(child, "SIGTERM");
const gracefulExit = await waitForExit(teardownGraceMs);
if (gracefulExit != null) {
return { ...gracefulExit, exitedBeforeTeardown: !sentTeardownSignal };
}
const postGraceExit = currentExit() ?? observedExit;
const postGraceExit = currentExit();
if (postGraceExit != null) {
return { ...postGraceExit, exitedBeforeTeardown: !sentTeardownSignal };
}
@@ -70,7 +123,7 @@ export async function stopChild(
killProcessTree(child, "SIGKILL");
const killedExit = await waitForExit(killGraceMs);
const finalExit = killedExit ?? currentExit() ?? observedExit;
const finalExit = killedExit ?? currentExit();
if (finalExit != null) {
return { ...finalExit, exitedBeforeTeardown: false };
}
@@ -86,6 +139,23 @@ function releaseUnsettledChild(child: ChildProcessWithoutNullStreams): void {
child.unref();
}
function isProcessTreeAlive(child: ChildProcessWithoutNullStreams): boolean {
if (process.platform === "win32" || child.pid === undefined) {
return false;
}
try {
process.kill(-child.pid, 0);
return true;
} catch (error) {
return isProcessStillExistsError(error);
}
}
function isProcessStillExistsError(error: unknown): boolean {
const code = (error as { code?: unknown }).code;
return code === "EPERM";
}
function killProcessTree(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): boolean {
if (process.platform !== "win32" && child.pid !== undefined) {
try {

View File

@@ -16,6 +16,7 @@ const DEFAULT_ITERATIONS = 10;
export const READY_TIMEOUT_MS = 120_000;
/** Per-probe timeout used while polling gateway readiness endpoints. */
export const READY_PROBE_TIMEOUT_MS = 1_000;
const GATEWAY_FORCE_KILL_GRACE_MS = 250;
const PARENT_TERMINATION_SIGNALS = ["SIGHUP", "SIGINT", "SIGTERM"];
const IS_DIRECT_RUN =
typeof process.argv[1] === "string" &&
@@ -320,10 +321,12 @@ export async function stopGateway(child, options = {}) {
return;
}
const killGraceMs = Math.max(0, options.killGraceMs ?? 1_500);
const forceKillGraceMs = Math.max(0, options.forceKillGraceMs ?? GATEWAY_FORCE_KILL_GRACE_MS);
signalGatewayProcess(child, "SIGTERM", options.killProcess);
const exited = await waitForGatewayExit(child, killGraceMs, options.killProcess);
if (!exited) {
signalGatewayProcess(child, "SIGKILL", options.killProcess);
await waitForGatewayExit(child, forceKillGraceMs, options.killProcess);
}
}

View File

@@ -3,6 +3,7 @@
// Executed directly via Node.js + tsx in the release workflow.
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import {
appendFileSync,
chmodSync,
@@ -2354,7 +2355,7 @@ async function runInstalledModelsSet(params) {
async function runInstalledAgentTurn(params) {
let lastError;
for (let attempt = 1; attempt <= 2; attempt += 1) {
const sessionId = `cross-os-release-check-${params.label}-${Date.now()}-${attempt}`;
const sessionId = buildCrossOsReleaseAgentSessionId(params.label, attempt);
try {
const logOffset = readLogFileSize(params.logPath);
const result = await runInstalledCli({
@@ -2756,8 +2757,7 @@ async function maybeRunDiscordRoundtrip(params) {
return "skipped-missing-config";
}
const outboundNonce = `native-cross-os-outbound-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
const inboundNonce = `native-cross-os-inbound-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
const { outboundNonce, inboundNonce } = buildCrossOsDiscordRoundtripNonces();
let sentMessageId = null;
let hostMessageId = null;
try {
@@ -3282,7 +3282,7 @@ async function runModelsSet(params) {
async function runAgentTurn(params) {
let lastError;
for (let attempt = 1; attempt <= 2; attempt += 1) {
const sessionId = `cross-os-release-check-${params.label}-${Date.now()}-${attempt}`;
const sessionId = buildCrossOsReleaseAgentSessionId(params.label, attempt);
try {
const logOffset = readLogFileSize(params.logPath);
const result = await runOpenClaw({
@@ -3365,6 +3365,17 @@ export function shouldSkipOptionalCrossOsAgentTurnError(error, logPath) {
return /"status"\s*:\s*"timeout"|Request timed out before a response was generated/u.test(log);
}
export function buildCrossOsReleaseAgentSessionId(label, attempt) {
return `cross-os-release-check-${label}-${randomUUID()}-${attempt}`;
}
export function buildCrossOsDiscordRoundtripNonces() {
return {
outboundNonce: `native-cross-os-outbound-${randomUUID()}`,
inboundNonce: `native-cross-os-inbound-${randomUUID()}`,
};
}
function buildReleaseAgentTurnArgs(sessionId) {
return [
"agent",

View File

@@ -14,6 +14,8 @@ const DEFAULT_PACKAGE_INVENTORY_TIMEOUT_MS = 5 * 60 * 1000;
const DEFAULT_PACKAGE_PACK_TIMEOUT_MS = 5 * 60 * 1000;
const DEFAULT_PACKAGE_TARBALL_CHECK_TIMEOUT_MS = 5 * 60 * 1000;
const DEFAULT_TIMEOUT_KILL_AFTER_MS = 5_000;
const PROCESS_GROUP_EXIT_POLL_MS = 25;
const POST_FORCE_KILL_WAIT_MS = 1_000;
const DEFAULT_CAPTURED_STDOUT_MAX_BYTES = 1024 * 1024;
const ACTIVE_CHILD_KILLERS = new Set();
const SIGNAL_EXIT_CODES = {
@@ -208,6 +210,18 @@ function run(command, args, cwd, options = {}) {
return error?.code === "EPERM";
}
};
const waitForProcessGroupExit = async (timeoutMs) => {
const deadlineAt = Date.now() + timeoutMs;
while (Date.now() < deadlineAt) {
if (!processGroupAlive()) {
return true;
}
await new Promise((resolvePoll) => {
setTimeout(resolvePoll, PROCESS_GROUP_EXIT_POLL_MS);
});
}
return !processGroupAlive();
};
const terminateChild = () => {
killChild("SIGTERM");
forceKillTimeout = setTimeout(() => {
@@ -228,6 +242,16 @@ function run(command, args, cwd, options = {}) {
terminateChild();
}, options.timeoutMs);
timeout?.unref?.();
const finishAfterTeardown = async (error, value = "") => {
if (processGroupAlive()) {
await waitForProcessGroupExit(options.killAfterMs ?? DEFAULT_TIMEOUT_KILL_AFTER_MS);
}
if (processGroupAlive()) {
killChild("SIGKILL");
await waitForProcessGroupExit(POST_FORCE_KILL_WAIT_MS);
}
finish(error, value);
};
if (options.captureStdout) {
child.stdout.on("data", (chunk) => {
if (outputLimitExceeded) {
@@ -250,11 +274,13 @@ function run(command, args, cwd, options = {}) {
child.on("error", (error) => finish(error));
child.on("close", (status, signal) => {
if (timedOut) {
finish(new Error(`${command} ${args.join(" ")} timed out after ${options.timeoutMs}ms`));
void finishAfterTeardown(
new Error(`${command} ${args.join(" ")} timed out after ${options.timeoutMs}ms`),
);
return;
}
if (outputLimitExceeded) {
finish(
void finishAfterTeardown(
new Error(
`${command} ${args.join(" ")} exceeded captured stdout limit (${maxCapturedStdoutBytes} bytes)`,
),

View File

@@ -7,6 +7,8 @@ import { performance } from "node:perf_hooks";
const DEFAULT_CHECK_TIMEOUT_MS = 10 * 60 * 1000;
const DEFAULT_OUTPUT_MAX_BYTES = 512 * 1024;
const TIMEOUT_KILL_GRACE_MS = 5_000;
const PROCESS_GROUP_EXIT_POLL_MS = 25;
const POST_FORCE_KILL_WAIT_MS = 1_000;
/** Ordered list of supplemental boundary checks used by CI sharding. */
export const BOUNDARY_CHECKS = [
@@ -227,6 +229,31 @@ function terminateChild(child, signal) {
child.kill(signal);
}
function processGroupAlive(child) {
if (process.platform === "win32" || !child.pid) {
return false;
}
try {
process.kill(-child.pid, 0);
return true;
} catch (error) {
return error?.code === "EPERM";
}
}
async function waitForProcessGroupExit(child, timeoutMs) {
const deadlineAt = Date.now() + timeoutMs;
while (Date.now() < deadlineAt) {
if (!processGroupAlive(child)) {
return true;
}
await new Promise((resolvePoll) => {
setTimeout(resolvePoll, PROCESS_GROUP_EXIT_POLL_MS);
});
}
return !processGroupAlive(child);
}
function terminateActiveChildren(activeChildren, signal) {
for (const child of activeChildren) {
terminateChild(child, signal);
@@ -317,6 +344,16 @@ export function runSingleCheck(
output: output.read(),
});
};
const finishAfterTimeoutTeardown = async (code, signal) => {
if (processGroupAlive(child)) {
await waitForProcessGroupExit(child, TIMEOUT_KILL_GRACE_MS);
}
if (processGroupAlive(child)) {
terminateChild(child, "SIGKILL");
await waitForProcessGroupExit(child, POST_FORCE_KILL_WAIT_MS);
}
finish(code, signal);
};
const timeout = setTimeout(() => {
timedOut = true;
output.append(
@@ -341,7 +378,13 @@ export function runSingleCheck(
output.append(`${error.stack ?? error.message}\n`);
finish(1, null);
});
child.on("close", (code, signal) => finish(code, signal));
child.on("close", (code, signal) => {
if (timedOut) {
void finishAfterTimeoutTeardown(code, signal);
return;
}
finish(code, signal);
});
});
}

View File

@@ -51,6 +51,7 @@ const main = async () => {
encoding: "utf8",
env: {
BWS_ACCESS_TOKEN: process.env.BWS_ACCESS_TOKEN,
BWS_SERVER_URL: process.env.BWS_SERVER_URL,
PATH: process.env.PATH || "",
},
maxBuffer: 1024 * 1024,

View File

@@ -2,11 +2,13 @@
// scripts/test-projects.mjs, and focused tests. Exports are intentionally
// granular so project selection stays testable without spawning Vitest.
import { spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { isChannelSurfaceTestFile } from "../test/vitest/vitest.channel-paths.mjs";
import {
commandsLightTestFiles,
isCommandsLightTarget,
resolveCommandsLightIncludePattern,
} from "../test/vitest/vitest.commands-light-paths.mjs";
@@ -36,11 +38,13 @@ import { isWhatsAppExtensionRoot } from "../test/vitest/vitest.extension-whatsap
import { isZaloExtensionRoot } from "../test/vitest/vitest.extension-zalo-paths.mjs";
import {
isPluginSdkLightTarget,
pluginSdkLightTestFiles,
resolvePluginSdkLightIncludePattern,
} from "../test/vitest/vitest.plugin-sdk-paths.mjs";
import { fullSuiteVitestShards } from "../test/vitest/vitest.test-shards.mjs";
import { isUnitUiTestTarget } from "../test/vitest/vitest.ui-paths.mjs";
import {
getUnitFastTestFiles,
resolveUnitFastTestIncludePattern,
resolveUnitFastTimerTestIncludePattern,
} from "../test/vitest/vitest.unit-fast-paths.mjs";
@@ -254,6 +258,10 @@ function uniqueOrdered(values) {
return [...new Set(values)];
}
function isPathAtOrUnder(relative, root) {
return relative === root || relative.startsWith(`${root}/`);
}
/**
* Orders full-suite specs so expensive shards start first in parallel runs.
*/
@@ -584,7 +592,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
["scripts/package-changelog.mjs", ["test/scripts/package-changelog.test.ts"]],
["scripts/package-mac-app.sh", ["test/scripts/package-mac-app.test.ts"]],
["scripts/package-mac-dist.sh", ["test/scripts/package-mac-dist.test.ts"]],
["scripts/package-openclaw-for-docker.mjs", ["test/scripts/package-openclaw-for-docker.test.ts"]],
[
"scripts/package-openclaw-for-docker.mjs",
["test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts"],
],
["scripts/postinstall-bundled-plugins.mjs", ["test/scripts/postinstall-bundled-plugins.test.ts"]],
["scripts/prepare-git-hooks.mjs", ["test/scripts/prepare-git-hooks.test.ts"]],
[
@@ -639,14 +650,6 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
"scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs",
["test/scripts/plugin-lifecycle-measure.test.ts"],
],
[
"scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs",
["test/scripts/plugin-lifecycle-probe.test.ts"],
],
[
"scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh",
["test/scripts/plugin-lifecycle-probe.test.ts"],
],
[
"scripts/e2e/release-media-memory-docker.sh",
["test/scripts/docker-e2e-plan.test.ts", "test/scripts/release-media-memory-scenario.test.ts"],
@@ -660,6 +663,12 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
["scripts/test-projects.test-support.d.mts", ["test/scripts/test-projects.test.ts"]],
["scripts/test-projects.test-support.mjs", ["test/scripts/test-projects.test.ts"]],
["scripts/tsdown-build.mjs", ["test/scripts/tsdown-build.test.ts"]],
[
"scripts/dev/channel-message-flows.ts",
["test/e2e/qa-lab/channels/channel-message-flows.e2e.test.ts"],
],
["scripts/dev/gateway-smoke.ts", ["test/e2e/qa-lab/runtime/gateway-smoke.e2e.test.ts"]],
["scripts/qa-otel-smoke.ts", ["test/e2e/qa-lab/runtime/qa-otel-smoke.e2e.test.ts"]],
["scripts/bundled-plugin-assets.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]],
["scripts/bundle-a2ui.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]],
["scripts/build-diffs-viewer-runtime.mjs", ["test/scripts/build-diffs-viewer-runtime.test.ts"]],
@@ -1024,6 +1033,14 @@ function isExistingFileTarget(arg, cwd) {
}
}
function isExistingDirectoryTarget(arg, cwd) {
try {
return fs.statSync(path.resolve(cwd, arg)).isDirectory();
} catch {
return false;
}
}
function isGlobTarget(arg) {
return /[*?[\]{}]/u.test(arg);
}
@@ -1166,6 +1183,14 @@ function expandExplicitSourceTestTargets(targetArgs, cwd) {
}).length;
const forceFullImportGraph = sourceTargetCount > EXPLICIT_SOURCE_FULL_IMPORT_GRAPH_THRESHOLD;
return targetArgs.flatMap((targetArg) => {
const relative = toRepoRelativeTarget(targetArg, cwd);
if (relative === "src/commands" && isExistingDirectoryTarget(targetArg, cwd)) {
return [COMMANDS_LIGHT_VITEST_CONFIG, COMMANDS_VITEST_CONFIG];
}
const exactDirectoryTargets = resolveExactSourceDirectoryTestTargets(targetArg, cwd);
if (exactDirectoryTargets) {
return exactDirectoryTargets;
}
const targets = resolveExplicitSourceTestTargets(targetArg, cwd, {
forceFullImportGraph,
});
@@ -1173,6 +1198,55 @@ function expandExplicitSourceTestTargets(targetArgs, cwd) {
});
}
const exactSourceDirectoryRoots = [
"src/acp",
"src/agents",
"src/auto-reply",
"src/channels",
"src/cli",
"src/commands",
"src/config",
"src/cron",
"src/daemon",
"src/gateway",
"src/hooks",
"src/infra",
"src/logging",
"src/media",
"src/media-understanding",
"src/plugin-sdk",
"src/plugins",
"src/process",
"src/secrets",
"src/shared",
"src/tasks",
"src/tui",
"src/utils",
"src/wizard",
"ui/src",
];
function isExactSourceDirectoryTarget(relative) {
return exactSourceDirectoryRoots.some((root) => isPathAtOrUnder(relative, root));
}
function resolveExactSourceDirectoryTestTargets(targetArg, cwd) {
if (!isExistingDirectoryTarget(targetArg, cwd)) {
return null;
}
const relative = toRepoRelativeTarget(targetArg, cwd).replace(/\/+$/u, "");
if (!isExactSourceDirectoryTarget(relative)) {
return null;
}
const prefix = `${relative}/`;
const lightTargets = uniqueOrdered([
...getUnitFastTestFiles(),
...pluginSdkLightTestFiles,
...commandsLightTestFiles,
]).filter((file) => file.startsWith(prefix));
return lightTargets.length > 0 ? [...lightTargets, targetArg] : null;
}
/**
* Finds explicit test path targets that do not match any known project plan.
*/
@@ -1922,7 +1996,7 @@ function classifyTarget(arg, cwd) {
if (isControlUiE2eTarget(relative)) {
return "uiE2e";
}
if (relative.startsWith("ui/src/")) {
if (isPathAtOrUnder(relative, "ui/src")) {
if (isUnitUiTestTarget(relative)) {
return "unitUi";
}
@@ -2042,6 +2116,7 @@ function classifyTarget(arg, cwd) {
}
if (
relative.startsWith("test/") ||
relative === "src/scripts" ||
relative.startsWith("src/scripts/") ||
relative === "src/config/doc-baseline.integration.test.ts" ||
relative === "src/config/schema.base.generated.test.ts" ||
@@ -2052,76 +2127,76 @@ function classifyTarget(arg, cwd) {
if (isBundledPluginDependentUnitTestFile(relative)) {
return "bundled";
}
if (relative.startsWith("src/channels/")) {
if (isPathAtOrUnder(relative, "src/channels")) {
return "channel";
}
if (relative.startsWith("src/gateway/")) {
if (isPathAtOrUnder(relative, "src/gateway")) {
return "gateway";
}
if (relative.startsWith("src/hooks/")) {
if (isPathAtOrUnder(relative, "src/hooks")) {
return "hooks";
}
if (relative.startsWith("src/infra/")) {
if (isPathAtOrUnder(relative, "src/infra")) {
return "infra";
}
if (relative.startsWith("src/config/")) {
if (isPathAtOrUnder(relative, "src/config")) {
return "runtimeConfig";
}
if (relative.startsWith("src/cron/")) {
if (isPathAtOrUnder(relative, "src/cron")) {
return "cron";
}
if (relative.startsWith("src/daemon/")) {
if (isPathAtOrUnder(relative, "src/daemon")) {
return "daemon";
}
if (relative.startsWith("src/media-understanding/")) {
if (isPathAtOrUnder(relative, "src/media-understanding")) {
return "mediaUnderstanding";
}
if (relative.startsWith("src/media/")) {
if (isPathAtOrUnder(relative, "src/media")) {
return "media";
}
if (relative.startsWith("src/logging/")) {
if (isPathAtOrUnder(relative, "src/logging")) {
return "logging";
}
if (relative.startsWith("src/plugin-sdk/")) {
if (isPathAtOrUnder(relative, "src/plugin-sdk")) {
return isPluginSdkLightTarget(relative) ? "pluginSdkLight" : "pluginSdk";
}
if (relative.startsWith("src/process/")) {
if (isPathAtOrUnder(relative, "src/process")) {
return "process";
}
if (relative.startsWith("src/secrets/")) {
if (isPathAtOrUnder(relative, "src/secrets")) {
return "secrets";
}
if (relative.startsWith("src/shared/")) {
if (isPathAtOrUnder(relative, "src/shared")) {
return "sharedCore";
}
if (relative.startsWith("src/tasks/")) {
if (isPathAtOrUnder(relative, "src/tasks")) {
return "tasks";
}
if (relative.startsWith("src/tui/")) {
if (isPathAtOrUnder(relative, "src/tui")) {
return "tui";
}
if (relative.startsWith("src/acp/")) {
if (isPathAtOrUnder(relative, "src/acp")) {
return "acp";
}
if (relative.startsWith("src/cli/")) {
if (isPathAtOrUnder(relative, "src/cli")) {
return "cli";
}
if (relative.startsWith("src/commands/")) {
if (isPathAtOrUnder(relative, "src/commands")) {
return isCommandsLightTarget(relative) ? "commandLight" : "command";
}
if (relative.startsWith("src/auto-reply/")) {
if (isPathAtOrUnder(relative, "src/auto-reply")) {
return "autoReply";
}
if (relative.startsWith("src/agents/")) {
if (isPathAtOrUnder(relative, "src/agents")) {
return "agent";
}
if (relative.startsWith("src/plugins/")) {
if (isPathAtOrUnder(relative, "src/plugins")) {
return "plugin";
}
if (relative.startsWith("src/utils/")) {
if (isPathAtOrUnder(relative, "src/utils")) {
return "utils";
}
if (relative.startsWith("src/wizard/")) {
if (isPathAtOrUnder(relative, "src/wizard")) {
return "wizard";
}
return "default";
@@ -2661,7 +2736,7 @@ export function createVitestRunSpecs(args, params = {}) {
const includeFilePath = plan.includePatterns
? path.join(
params.tempDir ?? os.tmpdir(),
`openclaw-vitest-include-${process.pid}-${Date.now()}-${index}.json`,
`openclaw-vitest-include-${randomUUID()}-${index}.json`,
)
: null;
return {

View File

@@ -998,10 +998,16 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
meta: { durationMs: 0, stopReason: "end_turn" },
}));
state.persistCliTurnTranscriptMock.mockImplementation(
async (params: { sessionEntry?: unknown }) => params.sessionEntry,
async (params: { sessionEntry?: unknown }) => ({
kind: "persisted",
sessionEntry: params.sessionEntry,
}),
);
state.persistAcpTurnTranscriptMock.mockImplementation(
async (params: { sessionEntry?: unknown }) => params.sessionEntry,
async (params: { sessionEntry?: unknown }) => ({
kind: "persisted",
sessionEntry: params.sessionEntry,
}),
);
state.runCliTurnCompactionLifecycleMock.mockImplementation(
async (params: { sessionEntry?: unknown }) => params.sessionEntry,
@@ -1244,7 +1250,7 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
state.persistAcpTurnTranscriptMock.mockImplementation(
async (params: { sessionEntry?: unknown }) => {
controller.abort(createAgentRunRestartAbortError());
return params.sessionEntry;
return { kind: "persisted", sessionEntry: params.sessionEntry };
},
);
@@ -1792,7 +1798,10 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
state.updateSessionStoreAfterAgentRunMock.mockImplementation(async () => {
state.sessionStoreMock = { "agent:main:main": rotatedEntry };
});
state.persistCliTurnTranscriptMock.mockResolvedValue(rotatedEntry);
state.persistCliTurnTranscriptMock.mockResolvedValue({
kind: "persisted",
sessionEntry: rotatedEntry,
});
state.runCliTurnCompactionLifecycleMock.mockResolvedValue(rotatedEntry);
await runBasicAgentCommand();
@@ -1813,6 +1822,33 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
});
});
it("skips post-run persistence after the session is deleted", async () => {
setupSingleAttemptFallback();
setupSessionTouchStore();
const result = makeSuccessResult("openai", "gpt-5.4") as ReturnType<
typeof makeSuccessResult
> & {
meta: Record<string, unknown> & { executionTrace: Record<string, unknown> };
};
result.meta.executionTrace = {
runner: "cli",
fallbackUsed: false,
winnerProvider: "openai",
winnerModel: "gpt-5.4",
};
state.runAgentAttemptMock.mockResolvedValue(result);
state.persistCliTurnTranscriptMock.mockResolvedValue({
kind: "session-rebound",
sessionEntry: undefined,
});
await runBasicAgentCommand();
expect(state.persistCliTurnTranscriptMock).toHaveBeenCalledTimes(1);
expect(state.runCliTurnCompactionLifecycleMock).not.toHaveBeenCalled();
expect(state.deliverAgentCommandResultMock).toHaveBeenCalledTimes(1);
});
it("does not treat backend CLI session id as OpenClaw session identity", async () => {
setupSingleAttemptFallback();
setupSessionTouchStore();

View File

@@ -891,6 +891,7 @@ async function agentCommandInternal(
assertAgentRunLifecycleGenerationCurrent(lifecycleGeneration);
const effectiveCwd = cwd ? resolveUserPath(cwd) : workspaceDir;
let sessionEntry = prepared.sessionEntry;
let sessionReboundDuringRun = false;
let trackedRestartRecoveryDeliveryContext = false;
let currentRunDeliveryContext: DeliveryContext | undefined;
@@ -1099,7 +1100,7 @@ async function agentCommandInternal(
sessionFile: internalSessionFile,
}
: sessionEntry;
sessionEntry = await attemptExecutionRuntime.persistAcpTurnTranscript({
const transcriptResult = await attemptExecutionRuntime.persistAcpTurnTranscript({
body,
transcriptBody,
finalText: finalTextRaw,
@@ -1113,6 +1114,7 @@ async function agentCommandInternal(
sessionCwd: resolveAcpSessionCwd(acpResolution.meta) ?? workspaceDir,
config: cfg,
});
sessionEntry = transcriptResult.sessionEntry;
if (internalSessionFile) {
sessionEntry = prepared.sessionEntry;
}
@@ -2166,7 +2168,10 @@ async function agentCommandInternal(
transcriptPersistenceRunner === "embedded" ||
(transcriptPersistenceRunner === undefined &&
Boolean(result.meta.finalAssistantVisibleText?.trim()));
if (transcriptPersistenceRunner === "cli" || embeddedAssistantGapFill) {
if (
!sessionReboundDuringRun &&
(transcriptPersistenceRunner === "cli" || embeddedAssistantGapFill)
) {
let persistedCliTurnTranscript = false;
try {
const transcriptSessionEntry: SessionEntry | undefined = suppressVisibleSessionEffects
@@ -2180,7 +2185,7 @@ async function agentCommandInternal(
sessionFile: effectiveSessionFile,
}
: sessionEntry;
sessionEntry = await attemptExecutionRuntime.persistCliTurnTranscript({
const transcriptResult = await attemptExecutionRuntime.persistCliTurnTranscript({
body,
transcriptBody,
result,
@@ -2195,10 +2200,12 @@ async function agentCommandInternal(
config: cfg,
embeddedAssistantGapFill,
});
sessionEntry = transcriptResult.sessionEntry;
sessionReboundDuringRun = transcriptResult.kind === "session-rebound";
if (suppressVisibleSessionEffects) {
sessionEntry = prepared.sessionEntry;
}
persistedCliTurnTranscript = true;
persistedCliTurnTranscript = transcriptResult.kind === "persisted";
} catch (error) {
log.warn(
`Turn transcript persistence failed for ${sessionKey ?? sessionId}: ${error instanceof Error ? error.message : String(error)}`,
@@ -2240,6 +2247,7 @@ async function agentCommandInternal(
sessionStore &&
sessionKey &&
!suppressVisibleSessionEffects &&
!sessionReboundDuringRun &&
payloads.length > 0 &&
!isSubagentSessionKey(sessionKey)
) {
@@ -2316,7 +2324,8 @@ async function agentCommandInternal(
sessionStore &&
sessionKey &&
!isSubagentSessionKey(sessionKey) &&
!suppressVisibleSessionEffects
!suppressVisibleSessionEffects &&
!sessionReboundDuringRun
) {
const entry = sessionStore[sessionKey] ?? sessionEntry;
const noPendingTextForThisRun =
@@ -2348,7 +2357,12 @@ async function agentCommandInternal(
throw error;
}
} finally {
if (trackedRestartRecoveryDeliveryContext && sessionStore && sessionKey) {
if (
!sessionReboundDuringRun &&
trackedRestartRecoveryDeliveryContext &&
sessionStore &&
sessionKey
) {
try {
const entry = sessionStore[sessionKey] ?? sessionEntry;
if (entry?.restartRecoveryDeliveryContext && entry.restartRecoveryDeliveryRunId === runId) {

View File

@@ -12,7 +12,6 @@ export type { AuthProfileEligibilityReasonCode } from "./auth-profiles/order.js"
export { resolveAuthProfileDisplayLabel } from "./auth-profiles/display.js";
export { formatAuthDoctorHint } from "./auth-profiles/doctor.js";
export {
externalCliDiscoveryExisting,
externalCliDiscoveryForConfigStatus,
externalCliDiscoveryForProviderAuth,
externalCliDiscoveryForProviders,

View File

@@ -64,20 +64,6 @@ export function externalCliDiscoveryNone(params?: {
};
}
/** Allows discovery of already-existing external CLI auth profiles. */
export function externalCliDiscoveryExisting(params?: {
config?: OpenClawConfig;
allowKeychainPrompt?: boolean;
}): ExternalCliAuthDiscovery {
return {
mode: "existing",
...(params?.allowKeychainPrompt !== undefined
? { allowKeychainPrompt: params.allowKeychainPrompt }
: {}),
...(params?.config ? { config: params.config } : {}),
};
}
/** Allows external CLI auth discovery for specific providers and/or profiles. */
export function externalCliDiscoveryScoped(params: {
config?: OpenClawConfig;

View File

@@ -326,13 +326,6 @@ async function buildHostApprovalDecisionParams(
};
}
/** Requests and waits for an approval decision for host/node exec. */
export async function requestExecApprovalDecisionForHost(
params: HostExecApprovalParams,
): Promise<string | null> {
return await requestExecApprovalDecision(await buildHostApprovalDecisionParams(params));
}
/** Registers a host/node approval request without waiting for a decision. */
export async function registerExecApprovalRequestForHost(
params: HostExecApprovalParams,

View File

@@ -127,6 +127,16 @@ function makeCliResult(text: string): EmbeddedAgentRunResult {
};
}
async function persistCliTranscriptEntry(
params: Parameters<typeof persistCliTurnTranscript>[0],
): Promise<SessionEntry | undefined> {
const result = await persistCliTurnTranscript(params);
if (result.kind !== "persisted") {
throw new Error("expected CLI transcript persistence to keep the current session");
}
return result.sessionEntry;
}
async function readSessionMessages(sessionFile: string) {
return (await readSessionFileJsonLines<{ type?: string; message?: unknown }>(sessionFile))
.filter((entry) => entry.type === "message")
@@ -1143,7 +1153,7 @@ describe("CLI attempt execution", () => {
});
let updatedEntry: SessionEntry | undefined;
try {
updatedEntry = await persistCliTurnTranscript({
updatedEntry = await persistCliTranscriptEntry({
body: "persist this",
result: makeCliResult("hello from cli"),
sessionId: sessionEntry.sessionId,
@@ -1204,6 +1214,40 @@ describe("CLI attempt execution", () => {
expect(sessionStore[sessionKey]?.updatedAt).toBe(persisted[sessionKey]?.updatedAt);
});
it("does not append a CLI transcript after the session is deleted", async () => {
const sessionKey = "agent:main:subagent:cli-transcript-deleted";
const staleSessionFile = path.join(tmpDir, "session-cli-stale.jsonl");
const staleEntry: SessionEntry = {
sessionId: "session-cli-stale",
sessionFile: staleSessionFile,
updatedAt: 1,
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: staleEntry };
await fs.writeFile(storePath, JSON.stringify({}, null, 2), "utf-8");
clearSessionStoreCacheForTest();
const result = await persistCliTurnTranscript({
body: "late prompt",
result: makeCliResult("late reply"),
sessionId: staleEntry.sessionId,
sessionKey,
sessionEntry: staleEntry,
sessionStore,
storePath,
sessionAgentId: "main",
sessionCwd: tmpDir,
config: {},
});
expect(result).toEqual({ kind: "session-rebound", sessionEntry: undefined });
await expect(fs.stat(staleSessionFile)).rejects.toMatchObject({ code: "ENOENT" });
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
string,
SessionEntry
>;
expect(persisted[sessionKey]).toBeUndefined();
});
it("embedded assistant gap-fill skips user mirror and dedupes identical assistant tails", async () => {
const sessionKey = "agent:main:subagent:embedded-gap-fill";
const sessionEntry: SessionEntry = {
@@ -1221,7 +1265,7 @@ describe("CLI attempt execution", () => {
runner: "embedded",
};
const updatedFirst = await persistCliTurnTranscript({
const updatedFirst = await persistCliTranscriptEntry({
body: "ignored for gap fill",
transcriptBody: "also ignored",
result,
@@ -1278,7 +1322,7 @@ describe("CLI attempt execution", () => {
runner: "embedded",
};
const updatedFirst = await persistCliTurnTranscript({
const updatedFirst = await persistCliTranscriptEntry({
body: "ignored for gap fill",
result,
sessionId: sessionEntry.sessionId,
@@ -1344,7 +1388,7 @@ describe("CLI attempt execution", () => {
runner: "embedded",
};
const updatedFirst = await persistCliTurnTranscript({
const updatedFirst = await persistCliTranscriptEntry({
body: "ignored for gap fill",
result,
sessionId: sessionEntry.sessionId,
@@ -1415,7 +1459,7 @@ describe("CLI attempt execution", () => {
runner: "embedded",
};
const updatedFirst = await persistCliTurnTranscript({
const updatedFirst = await persistCliTranscriptEntry({
body: "ignored for gap fill",
result,
sessionId: sessionEntry.sessionId,
@@ -1484,7 +1528,7 @@ describe("CLI attempt execution", () => {
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
const updatedEntry = await persistCliTurnTranscript({
const updatedEntry = await persistCliTranscriptEntry({
body: [
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"secret runtime context",

View File

@@ -127,6 +127,10 @@ type PersistTextTurnTranscriptParams = {
};
};
type PersistTextTurnTranscriptResult =
| { kind: "persisted"; sessionEntry: SessionEntry | undefined }
| { kind: "session-rebound"; sessionEntry: undefined };
type HarnessAuthProfileSelection = {
authProfileId?: string;
authProfileIdSource?: "auto" | "user";
@@ -285,11 +289,11 @@ function resolveTranscriptUsage(usage: PersistTextTurnTranscriptParams["assistan
async function persistTextTurnTranscript(
params: PersistTextTurnTranscriptParams,
): Promise<SessionEntry | undefined> {
): Promise<PersistTextTurnTranscriptResult> {
const promptText = params.transcriptBody ?? params.body;
const replyText = params.finalText;
if (!promptText && !replyText) {
return params.sessionEntry;
return { kind: "persisted", sessionEntry: params.sessionEntry };
}
const messages = [];
@@ -356,9 +360,13 @@ async function persistTextTurnTranscript(
publishWhen: "always",
touchSessionEntry: true,
updateMode: "file-only",
...(params.sessionStore && params.storePath ? { expectedSessionId: params.sessionId } : {}),
},
);
return turn.sessionEntry;
if (turn.rejectedReason === "session-rebound") {
return { kind: "session-rebound", sessionEntry: undefined };
}
return { kind: "persisted", sessionEntry: turn.sessionEntry };
}
function resolveCliTranscriptReplyText(result: EmbeddedAgentRunResult): string {
@@ -391,7 +399,7 @@ export async function persistAcpTurnTranscript(params: {
threadId?: string | number;
sessionCwd: string;
config: OpenClawConfig;
}): Promise<SessionEntry | undefined> {
}): Promise<PersistTextTurnTranscriptResult> {
return await persistTextTurnTranscript({
...params,
assistant: {
@@ -417,7 +425,7 @@ export async function persistCliTurnTranscript(params: {
sessionCwd: string;
config: OpenClawConfig;
embeddedAssistantGapFill?: boolean;
}): Promise<SessionEntry | undefined> {
}): Promise<PersistTextTurnTranscriptResult> {
const replyText = resolveCliTranscriptReplyText(params.result);
const provider = params.result.meta.agentMeta?.provider?.trim() ?? "cli";
const model = params.result.meta.agentMeta?.model?.trim() ?? "default";

View File

@@ -679,6 +679,7 @@ export async function runCliTurnCompactionLifecycle(params: {
sessionKey: params.sessionKey,
sessionStore: params.sessionStore,
storePath: params.storePath,
expectedSessionId: params.sessionId,
})) ?? params.sessionEntry
);
}
@@ -696,6 +697,7 @@ export async function runCliTurnCompactionLifecycle(params: {
tokensAfter: nativeCompactionResult?.result?.tokensAfter,
newSessionId: nativeCompactionResult?.result?.sessionId,
newSessionFile: nativeCompactionResult?.result?.sessionFile,
expectedSessionId: params.sessionId,
})) ?? params.sessionEntry
);
}

View File

@@ -1849,7 +1849,10 @@ describe("updateSessionStoreAfterAgentRun", () => {
});
});
it("does not recreate a missing persisted row while preserving user-facing state", async () => {
it.each([
["normal", false],
["user-facing state preserving", true],
])("does not recreate a missing persisted row after a %s run", async (_mode, preserve) => {
await withTempSessionStore(async ({ storePath }) => {
const cfg = {} as OpenClawConfig;
const sessionKey = "agent:main:explicit:missing-visible-row";
@@ -1872,7 +1875,7 @@ describe("updateSessionStoreAfterAgentRun", () => {
sessionStore,
defaultProvider: "claude-cli",
defaultModel: "claude-sonnet-4-6",
preserveUserFacingSessionModelState: true,
preserveUserFacingSessionModelState: preserve,
result: {
meta: {
durationMs: 1,
@@ -1895,6 +1898,88 @@ describe("updateSessionStoreAfterAgentRun", () => {
});
});
it("creates a missing persisted row for a new normal run", async () => {
await withTempSessionStore(async ({ storePath }) => {
const cfg = {} as OpenClawConfig;
const sessionKey = "agent:main:explicit:new-normal-row";
const sessionId = "new-normal-row-session";
const sessionStore: Record<string, SessionEntry> = {};
await fs.writeFile(storePath, JSON.stringify({}, null, 2), "utf8");
await updateSessionStoreAfterAgentRun({
cfg,
sessionId,
sessionKey,
storePath,
sessionStore,
defaultProvider: "openai",
defaultModel: "gpt-5.5",
result: {
meta: {
durationMs: 1,
agentMeta: {
sessionId,
provider: "openai",
model: "gpt-5.5",
},
},
},
});
expect(sessionStore[sessionKey]).toMatchObject({ sessionId });
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toMatchObject({
sessionId,
});
});
});
it("does not overwrite a replacement persisted row after a normal run", async () => {
await withTempSessionStore(async ({ storePath }) => {
const cfg = {} as OpenClawConfig;
const sessionKey = "agent:main:explicit:rebound-visible-row";
const sessionId = "run-session-id";
const replacementEntry: SessionEntry = {
sessionId: "replacement-session-id",
updatedAt: 2,
modelProvider: "openai",
model: "gpt-5.5",
};
const sessionStore: Record<string, SessionEntry> = {
[sessionKey]: {
sessionId,
updatedAt: 1,
modelProvider: "anthropic",
model: "claude-sonnet-4-6",
},
};
await fs.writeFile(storePath, JSON.stringify({ [sessionKey]: replacementEntry }, null, 2));
await updateSessionStoreAfterAgentRun({
cfg,
sessionId,
sessionKey,
storePath,
sessionStore,
defaultProvider: "anthropic",
defaultModel: "claude-sonnet-4-6",
result: {
meta: {
durationMs: 1,
agentMeta: {
sessionId,
provider: "anthropic",
model: "claude-sonnet-4-6",
},
},
},
});
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual(
replacementEntry,
);
});
});
it("leaves contextTokens unset when entry has prior model but no contextTokens (heartbeat bleed guard)", async () => {
await withTempSessionStore(async ({ storePath }) => {
const cfg = {} as OpenClawConfig;
@@ -2301,6 +2386,35 @@ describe("recordCliCompactionInStore", () => {
expect(persisted?.cliSessionBindings?.codex).toBeUndefined();
});
});
it("does not recreate a missing row when a post-run compaction has an expected session id", async () => {
await withTempSessionStore(async ({ storePath }) => {
const sessionKey = "agent:main:explicit:test-record-cli-compaction-deleted";
const sessionId = "test-record-cli-compaction-deleted-session";
const sessionStore: Record<string, SessionEntry> = {
[sessionKey]: {
sessionId,
updatedAt: 1,
cliSessionIds: {
codex: "stale-cli-session",
},
},
};
await fs.writeFile(storePath, JSON.stringify({}, null, 2), "utf8");
const result = await recordCliCompactionInStore({
provider: "codex",
sessionKey,
sessionStore,
storePath,
expectedSessionId: sessionId,
tokensAfter: 42,
});
expect(result).toEqual(sessionStore[sessionKey]);
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toBeUndefined();
});
});
});
describe("clearCliSessionInStore", () => {
@@ -2435,4 +2549,29 @@ describe("clearCliSessionInStore", () => {
expect(persisted?.claudeCliSessionId).toBeUndefined();
});
});
it("does not recreate a missing row when a post-run binding clear has an expected session id", async () => {
await withTempSessionStore(async ({ storePath }) => {
const sessionKey = "agent:main:explicit:test-clear-cli-deleted-row";
const sessionId = "openclaw-session-1";
const sessionStore: Record<string, SessionEntry> = {
[sessionKey]: {
sessionId,
updatedAt: 1,
claudeCliSessionId: "claude-session-1",
},
};
await fs.writeFile(storePath, JSON.stringify({}, null, 2), "utf8");
await clearCliSessionInStore({
provider: "claude-cli",
sessionKey,
sessionStore,
storePath,
expectedSessionId: sessionId,
});
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toBeUndefined();
});
});
});

View File

@@ -299,7 +299,14 @@ export async function updateSessionStoreAfterAgentRun(params: {
sessionKey,
},
(_currentEntry, context) => {
if (preserveUserFacingRunState && !context.existingEntry) {
if (
(!preserveUserFacingRunState &&
context.existingEntry &&
context.existingEntry.sessionId !== entry.sessionId) ||
(!context.existingEntry && sessionStore[sessionKey])
) {
// A normal run may rotate its session id, so compare to the pre-run entry.
// Do not merge stale finalizer metadata after a delete or a competing reset.
return null;
}
return metadataPatch;
@@ -320,8 +327,9 @@ export async function clearCliSessionInStore(params: {
sessionKey: string;
sessionStore: Record<string, SessionEntry>;
storePath: string;
expectedSessionId?: string;
}): Promise<SessionEntry | undefined> {
const { provider, sessionKey, sessionStore, storePath } = params;
const { provider, sessionKey, sessionStore, storePath, expectedSessionId } = params;
const entry = sessionStore[sessionKey];
if (!entry) {
return undefined;
@@ -336,7 +344,15 @@ export async function clearCliSessionInStore(params: {
storePath,
sessionKey,
},
() => next,
(currentEntry, context) => {
if (
expectedSessionId &&
(!context.existingEntry || currentEntry.sessionId !== expectedSessionId)
) {
return null;
}
return next;
},
{ fallbackEntry: entry },
);
if (persisted) {
@@ -354,8 +370,9 @@ export async function recordCliCompactionInStore(params: {
tokensAfter?: number;
newSessionId?: string;
newSessionFile?: string;
expectedSessionId?: string;
}): Promise<SessionEntry | undefined> {
const { provider, sessionKey, sessionStore, storePath } = params;
const { provider, sessionKey, sessionStore, storePath, expectedSessionId } = params;
const entry = sessionStore[sessionKey];
if (!entry) {
return undefined;
@@ -410,7 +427,15 @@ export async function recordCliCompactionInStore(params: {
storePath,
sessionKey,
},
() => next,
(currentEntry, context) => {
if (
expectedSessionId &&
(!context.existingEntry || currentEntry.sessionId !== expectedSessionId)
) {
return null;
}
return next;
},
{ fallbackEntry: entry },
);
if (persisted) {

View File

@@ -1,17 +1,11 @@
/**
* Interactive terminal theme loader.
*
* Validates theme JSON, resolves color variables, watches custom theme files, and exposes Pi TUI theme adapters.
* Validates theme JSON, resolves color variables, watches custom theme files, and exposes terminal styling helpers.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import {
type EditorTheme,
getCapabilities,
type MarkdownTheme,
type SelectListTheme,
type SettingsListTheme,
} from "@earendil-works/pi-tui";
import { getCapabilities } from "@earendil-works/pi-tui";
import chalk from "chalk";
import { type Static, Type } from "typebox";
import { Compile } from "typebox/compile";
@@ -468,59 +462,6 @@ function getBuiltinThemes(): Record<string, ThemeJson> {
return BUILTIN_THEMES;
}
export function getAvailableThemes(): string[] {
const themes = new Set<string>(Object.keys(getBuiltinThemes()));
const customThemesDir = getCustomThemesDir();
if (fs.existsSync(customThemesDir)) {
const files = fs.readdirSync(customThemesDir);
for (const file of files) {
if (file.endsWith(".json")) {
themes.add(file.slice(0, -5));
}
}
}
for (const name of registeredThemes.keys()) {
themes.add(name);
}
return Array.from(themes).toSorted();
}
export interface ThemeInfo {
name: string;
path: string | undefined;
}
export function getAvailableThemesWithPaths(): ThemeInfo[] {
const themesDir = getThemesDir();
const customThemesDir = getCustomThemesDir();
const result: ThemeInfo[] = [];
// Built-in themes
for (const name of Object.keys(getBuiltinThemes())) {
result.push({ name, path: path.join(themesDir, `${name}.json`) });
}
// Custom themes
if (fs.existsSync(customThemesDir)) {
for (const file of fs.readdirSync(customThemesDir)) {
if (file.endsWith(".json")) {
const name = file.slice(0, -5);
if (!result.some((t) => t.name === name)) {
result.push({ name, path: path.join(customThemesDir, file) });
}
}
}
}
for (const [name, theme] of registeredThemes.entries()) {
if (!result.some((t) => t.name === name)) {
result.push({ name, path: theme.sourcePath });
}
}
return result.toSorted((a, b) => a.name.localeCompare(b.name));
}
function parseThemeJson(label: string, json: unknown): ThemeJson {
if (!validateThemeJson.Check(json)) {
const errors = Array.from(validateThemeJson.Errors(json));
@@ -635,14 +576,6 @@ function loadTheme(name: string, mode?: ColorMode): Theme {
return createTheme(themeJson, mode);
}
export function getThemeByName(name: string): Theme | undefined {
try {
return loadTheme(name);
} catch {
return undefined;
}
}
export type TerminalTheme = "dark" | "light";
export interface RgbColor {
@@ -685,67 +618,6 @@ function getAnsiColorLuminance(index: number): number {
return getRgbColorLuminance(hexToRgb(ansi256ToHex(index)));
}
export function getThemeForRgbColor(rgb: RgbColor): TerminalTheme {
return getRgbColorLuminance(rgb) >= 0.5 ? "light" : "dark";
}
function parseOscHexChannel(channel: string): number | undefined {
if (!/^[0-9a-f]+$/i.test(channel)) {
return undefined;
}
const max = 16 ** channel.length - 1;
if (max <= 0) {
return undefined;
}
return Math.round((Number.parseInt(channel, 16) / max) * 255);
}
export function parseOsc11BackgroundColor(data: string): RgbColor | undefined {
const prefix = "\u001B]11;";
const belSuffix = "\u0007";
const escSuffix = "\u001B\\";
if (!data.startsWith(prefix)) {
return undefined;
}
const suffixLength = data.endsWith(belSuffix)
? belSuffix.length
: data.endsWith(escSuffix)
? escSuffix.length
: 0;
if (suffixLength === 0) {
return undefined;
}
const value = data.slice(prefix.length, -suffixLength).trim();
if (value.includes("\u0007") || value.includes("\u001B")) {
return undefined;
}
if (value.startsWith("#")) {
const hex = value.slice(1);
if (/^[0-9a-f]{6}$/i.test(hex)) {
return hexToRgb(value);
}
if (/^[0-9a-f]{12}$/i.test(hex)) {
const r = parseOscHexChannel(hex.slice(0, 4));
const g = parseOscHexChannel(hex.slice(4, 8));
const b = parseOscHexChannel(hex.slice(8, 12));
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
}
return undefined;
}
const rgbValue = value.replace(/^rgba?:/i, "");
const [red, green, blue] = rgbValue.split("/");
if (red === undefined || green === undefined || blue === undefined) {
return undefined;
}
const r = parseOscHexChannel(red);
const g = parseOscHexChannel(green);
const b = parseOscHexChannel(blue);
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
}
export function detectTerminalBackground(
options: TerminalThemeDetectionOptions = {},
): TerminalThemeDetection {
@@ -799,18 +671,8 @@ function setGlobalTheme(t: Theme): void {
let currentThemeName: string | undefined;
let themeWatcher: fs.FSWatcher | undefined;
let themeReloadTimer: NodeJS.Timeout | undefined;
let onThemeChangeCallback: (() => void) | undefined;
const registeredThemes = new Map<string, Theme>();
export function setRegisteredThemes(themes: Theme[]): void {
registeredThemes.clear();
for (const themeLocal of themes) {
if (themeLocal.name) {
registeredThemes.set(themeLocal.name, themeLocal);
}
}
}
export function initTheme(themeName?: string, enableWatcher = false): void {
const name = themeName ?? getDefaultTheme();
currentThemeName = name;
@@ -837,9 +699,6 @@ export function setTheme(
if (enableWatcher) {
startThemeWatcher();
}
if (onThemeChangeCallback) {
onThemeChangeCallback();
}
return { success: true };
} catch (error) {
// Theme is invalid - fall back to dark theme
@@ -853,19 +712,6 @@ export function setTheme(
}
}
export function setThemeInstance(themeInstance: Theme): void {
setGlobalTheme(themeInstance);
currentThemeName = "<in-memory>";
stopThemeWatcher(); // Can't watch a direct instance
if (onThemeChangeCallback) {
onThemeChangeCallback();
}
}
export function onThemeChange(callback: () => void): void {
onThemeChangeCallback = callback;
}
function startThemeWatcher(): void {
stopThemeWatcher();
@@ -906,10 +752,6 @@ function startThemeWatcher(): void {
const reloadedTheme = loadThemeFromPath(themeFile);
registeredThemes.set(watchedThemeName, reloadedTheme);
setGlobalTheme(reloadedTheme);
// Notify callback (to invalidate UI)
if (onThemeChangeCallback) {
onThemeChangeCallback();
}
} catch {
// Ignore errors (file might be in invalid state while being edited)
}
@@ -998,83 +840,6 @@ function ansi256ToHex(index: number): string {
return `#${grayHex}${grayHex}${grayHex}`;
}
/**
* Get resolved theme colors as CSS-compatible hex strings.
* Used by HTML export to generate CSS custom properties.
*/
export function getResolvedThemeColors(themeName?: string): Record<string, string> {
const name = themeName ?? currentThemeName ?? getDefaultTheme();
const isLight = name === "light";
const themeJson = loadThemeJson(name);
const resolved = resolveThemeColors(themeJson.colors, themeJson.vars);
// Default text color for empty values (terminal uses default fg color)
const defaultText = isLight ? "#000000" : "#e5e5e7";
const cssColors: Record<string, string> = {};
for (const [key, value] of Object.entries(resolved)) {
if (typeof value === "number") {
cssColors[key] = ansi256ToHex(value);
} else if (value === "") {
// Empty means default terminal color - use sensible fallback for HTML
cssColors[key] = defaultText;
} else {
cssColors[key] = value;
}
}
return cssColors;
}
/**
* Check if a theme is a "light" theme (for CSS that needs light/dark variants).
*/
export function isLightTheme(themeName?: string): boolean {
// Currently just check the name - could be extended to analyze colors
return themeName === "light";
}
/**
* Get explicit export colors from theme JSON, if specified.
* Returns undefined for each color that isn't explicitly set.
*/
export function getThemeExportColors(themeName?: string): {
pageBg?: string;
cardBg?: string;
infoBg?: string;
} {
const name = themeName ?? currentThemeName ?? getDefaultTheme();
try {
const themeJson = loadThemeJson(name);
const exportSection = themeJson.export;
if (!exportSection) {
return {};
}
const vars = themeJson.vars ?? {};
const resolve = (value: ColorValue | undefined): string | undefined => {
if (value === undefined) {
return undefined;
}
const resolved = resolveVarRefs(value, vars);
if (typeof resolved === "number") {
return ansi256ToHex(resolved);
}
if (resolved === "") {
return undefined;
}
return resolved;
};
return {
pageBg: resolve(exportSection.pageBg),
cardBg: resolve(exportSection.cardBg),
infoBg: resolve(exportSection.infoBg),
};
} catch {
return {};
}
}
// ============================================================================
// TUI Helpers
// ============================================================================
@@ -1209,70 +974,3 @@ export function getLanguageFromPath(filePath: string): string | undefined {
return extToLang[ext];
}
export function getMarkdownTheme(): MarkdownTheme {
return {
heading: (text: string) => theme.fg("mdHeading", text),
link: (text: string) => theme.fg("mdLink", text),
linkUrl: (text: string) => theme.fg("mdLinkUrl", text),
code: (text: string) => theme.fg("mdCode", text),
codeBlock: (text: string) => theme.fg("mdCodeBlock", text),
codeBlockBorder: (text: string) => theme.fg("mdCodeBlockBorder", text),
quote: (text: string) => theme.fg("mdQuote", text),
quoteBorder: (text: string) => theme.fg("mdQuoteBorder", text),
hr: (text: string) => theme.fg("mdHr", text),
listBullet: (text: string) => theme.fg("mdListBullet", text),
bold: (text: string) => theme.bold(text),
italic: (text: string) => theme.italic(text),
underline: (text: string) => theme.underline(text),
strikethrough: (text: string) => chalk.strikethrough(text),
highlightCode: (code: string, lang?: string): string[] => {
// Validate language before highlighting to avoid stderr spam from cli-highlight
const validLang = lang && supportsLanguage(lang) ? lang : undefined;
// Skip highlighting when no valid language is specified. cli-highlight's
// auto-detection is unreliable and can misidentify prose as AppleScript,
// LiveCodeServer, etc., coloring random English words as keywords.
if (!validLang) {
return code.split("\n").map((line) => theme.fg("mdCodeBlock", line));
}
const opts = {
language: validLang,
ignoreIllegals: true,
theme: getCliHighlightTheme(theme),
};
try {
return highlight(code, opts).split("\n");
} catch {
return code.split("\n").map((line) => theme.fg("mdCodeBlock", line));
}
},
};
}
export function getSelectListTheme(): SelectListTheme {
return {
selectedPrefix: (text: string) => theme.fg("accent", text),
selectedText: (text: string) => theme.fg("accent", text),
description: (text: string) => theme.fg("muted", text),
scrollInfo: (text: string) => theme.fg("muted", text),
noMatch: (text: string) => theme.fg("muted", text),
};
}
export function getEditorTheme(): EditorTheme {
return {
borderColor: (text: string) => theme.fg("borderMuted", text),
selectList: getSelectListTheme(),
};
}
export function getSettingsListTheme(): SettingsListTheme {
return {
label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text),
value: (text: string, selected: boolean) =>
selected ? theme.fg("accent", text) : theme.fg("muted", text),
description: (text: string) => theme.fg("dim", text),
cursor: theme.fg("accent", "→ "),
hint: (text: string) => theme.fg("dim", text),
};
}

View File

@@ -1920,9 +1920,7 @@ export class AgentSession {
}
const pathEntries = this.sessionManager.getBranch();
const preparation = unwrapCoreResult(
prepareCompaction(pathEntries, options.settings, { force: isManual }),
);
const preparation = unwrapCoreResult(prepareCompaction(pathEntries, options.settings));
if (!preparation) {
if (isManual) {
const lastEntry = pathEntries[pathEntries.length - 1];

View File

@@ -21,7 +21,6 @@ import {
openClawAgentCoreRuntime,
type CompactionDetails,
type CompactionPreparation,
type CompactionPreparationOptions,
type CompactionResult,
type CompactionSettings,
type ContextUsageEstimate,
@@ -59,9 +58,8 @@ function unwrapCompactionResult<T>(result: Result<T, Error>): T {
export function prepareCompaction(
pathEntries: SessionEntry[],
settings: CompactionSettings,
options?: CompactionPreparationOptions,
): CompactionPreparation | undefined {
return unwrapCompactionResult(prepareCompactionCore(pathEntries, settings, options));
return unwrapCompactionResult(prepareCompactionCore(pathEntries, settings));
}
/** Generates a compaction summary through the shared agent-core runtime. */

View File

@@ -713,6 +713,56 @@ describe("ensureAgentWorkspace", () => {
"# Add tasks below when you want the agent to check something periodically.",
);
});
it("does not recreate optional bootstrap files when workspace setup is already completed", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
// First call: set up the workspace and complete setup by customizing profile files.
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await writeWorkspaceFile({
dir: tempDir,
name: DEFAULT_IDENTITY_FILENAME,
content: "custom identity",
});
await writeWorkspaceFile({
dir: tempDir,
name: DEFAULT_USER_FILENAME,
content: "custom user",
});
// Delete BOOTSTRAP.md to trigger completion on next ensure call.
await fs.unlink(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
// Verify setup is completed.
const state = await readWorkspaceState(tempDir);
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
// Delete optional bootstrap files and customize AGENTS.md to simulate
// a repository workspace where optional files only exist under agent
// subdirectories but the root still has customized required files.
await fs.unlink(path.join(tempDir, DEFAULT_SOUL_FILENAME));
await fs.unlink(path.join(tempDir, DEFAULT_IDENTITY_FILENAME));
await fs.unlink(path.join(tempDir, DEFAULT_USER_FILENAME));
await fs.unlink(path.join(tempDir, DEFAULT_HEARTBEAT_FILENAME));
await writeWorkspaceFile({
dir: tempDir,
name: DEFAULT_AGENTS_FILENAME,
content: "custom agents instructions\n",
});
// Third call: should NOT recreate optional files for an already-configured workspace.
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
// Verify optional files are NOT recreated at the root level.
await expectPathMissing(path.join(tempDir, DEFAULT_SOUL_FILENAME));
await expectPathMissing(path.join(tempDir, DEFAULT_IDENTITY_FILENAME));
await expectPathMissing(path.join(tempDir, DEFAULT_USER_FILENAME));
await expectPathMissing(path.join(tempDir, DEFAULT_HEARTBEAT_FILENAME));
// Verify required files (AGENTS.md, TOOLS.md) still exist.
await expect(fs.access(path.join(tempDir, DEFAULT_AGENTS_FILENAME))).resolves.toBeUndefined();
await expect(fs.access(path.join(tempDir, DEFAULT_TOOLS_FILENAME))).resolves.toBeUndefined();
});
});
describe("loadWorkspaceBootstrapFiles", () => {

View File

@@ -954,6 +954,15 @@ export async function ensureAgentWorkspace(params?: {
const userTemplate = await loadTemplate(DEFAULT_USER_FILENAME);
const heartbeatTemplate = await loadTemplate(DEFAULT_HEARTBEAT_FILENAME);
const skipOptionalBootstrapFiles = new Set(params?.skipOptionalBootstrapFiles ?? []);
// When the workspace is already configured, skip optional bootstrap files to
// prevent subagent spawns from recreating root-level SOUL.md, USER.md,
// IDENTITY.md, or HEARTBEAT.md that were removed intentionally or only exist
// under agent-specific subdirectories.
if (await isWorkspaceSetupCompleted(dir)) {
for (const filename of OPTIONAL_BOOTSTRAP_FILENAMES) {
skipOptionalBootstrapFiles.add(filename);
}
}
const shouldWriteBootstrapFile = (fileName: string): boolean =>
!OPTIONAL_BOOTSTRAP_FILENAMES.has(fileName) || !skipOptionalBootstrapFiles.has(fileName);

View File

@@ -44,11 +44,6 @@ function hasInboundHistoryMedia(ctx: MsgContext): boolean {
);
}
/** True when current or recent inbound history may contain agent-turn attachments. */
export function hasPotentialAgentTurnAttachments(ctx: MsgContext): boolean {
return hasInboundMedia(ctx) || hasInboundHistoryMedia(ctx);
}
/** Resolves image attachments for the current agent turn and recent image history. */
export async function resolveAgentTurnAttachments(params: {
ctx: MsgContext;

View File

@@ -1,2 +0,0 @@
/** Reply-layer facade for parsing audio presentation tags. */
export { parseAudioTag } from "../../media/audio-tags.js";

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