Compare commits

..

179 Commits

Author SHA1 Message Date
Dallin Romney
963830c46a test: refresh mirrored QA routing expectation 2026-06-20 14:13:36 -07:00
Dallin Romney
a404915620 test: pin folded QA coverage ids 2026-06-20 14:12:15 -07:00
Dallin Romney
c6d19d86c9 test: avoid overclaiming gateway tool API coverage 2026-06-20 14:12:15 -07:00
Dallin Romney
fecc451f41 test: preserve chat tools profile build guard 2026-06-20 14:12:15 -07:00
Dallin Romney
75169f8349 test: update mirrored QA routing expectation 2026-06-20 14:12:15 -07:00
Dallin Romney
1cf089ef20 test: keep native QA evidence out of parity tiers 2026-06-20 14:11:45 -07:00
Dallin Romney
c4b2dd3fe0 test: align folded QA coverage ids 2026-06-20 14:11:45 -07:00
Dallin Romney
30889ef336 test: trim folded QA Lab script cruft 2026-06-20 14:11:45 -07:00
Dallin Romney
563d640d6c test: relax QA native scenario catalog inventory 2026-06-20 14:11:45 -07:00
Dallin Romney
b988d762e8 test: remove folded HTTP API script tests 2026-06-20 14:11:45 -07:00
Dallin Romney
c0d659f0d7 test: fold HTTP API script proof into QA Lab 2026-06-20 14:11:24 -07:00
Vincent Koc
15a2d74320 test(scripts): focus installer routing changes 2026-06-20 23:05:21 +02:00
Shakker
77f07a11e7 fix: share operator approval env snapshots 2026-06-20 22:02:27 +01:00
Josh Lehman
7a0d36f3d0 refactor: add SDK transcript identity target API (#95030) 2026-06-20 14:01:07 -07:00
Vincent Koc
0a707afb9a chore(deadcode): inline exec approval wait helper 2026-06-21 04:58:14 +08:00
Shakker
bdeda6553b test: finish gateway token env routing 2026-06-20 21:50:55 +01:00
Shakker
3499b277e3 fix: route gateway env setup through helpers 2026-06-20 21:50:55 +01:00
Vincent Koc
8c8857c3ef fix(qa): keep telegram credential tests sparse safe 2026-06-20 22:45:25 +02:00
Vincent Koc
d75613e794 chore(deadcode): reuse tool result details reader 2026-06-21 04:42:48 +08:00
Shakker
beb8897f49 test: keep Claude seed HOME fallback covered 2026-06-20 21:36:15 +01:00
Shakker
add5f76a1e fix: isolate Claude history HOME setup 2026-06-20 21:34:58 +01:00
Vincent Koc
9a9f4dbefe test(rpc): map rtt measurement script changes 2026-06-20 22:32:17 +02:00
Vincent Koc
5beaaf343c test(qa): map qa e2e script changes 2026-06-20 22:29:33 +02:00
Vincent Koc
1db811282c fix(release): validate plugin manifest runner args 2026-06-20 22:23:30 +02:00
Vincent Koc
aa23d9f34e chore(deadcode): inline approval abort classification 2026-06-21 04:22:12 +08:00
Vincent Koc
2962c95010 fix(release): validate plugin runtime build args 2026-06-20 22:19:50 +02:00
Vincent Koc
80d3b132a5 fix(release): validate package dist check args 2026-06-20 22:16:26 +02:00
Shakker
1a5d84d3fe test: reuse discovery env snapshot 2026-06-20 21:09:10 +01:00
Vincent Koc
71a75b9b28 fix(release): validate package tarball check args 2026-06-20 22:08:25 +02:00
Vincent Koc
b1f562570a fix(release): validate openclaw npm verifier args 2026-06-20 22:03:38 +02:00
Vincent Koc
bdcc691745 chore(deadcode): inline message provider tool filtering 2026-06-21 04:00:09 +08:00
Shakker
4461e257e3 fix: restore env warning flags with helper 2026-06-20 20:58:13 +01:00
Vincent Koc
76014cfe95 fix(release): validate plugin npm verifier args 2026-06-20 21:57:13 +02:00
Vincent Koc
498ff1fb5a fix(release): validate plugin clawhub publish args 2026-06-20 21:53:59 +02:00
Shakker
ae81aa018d test: reuse update method env wrapper 2026-06-20 20:52:09 +01:00
Vincent Koc
1706bfda2c fix(release): validate plugin npm publish args 2026-06-20 21:51:32 +02:00
Vincent Koc
a1201e99fc fix(release): validate npm publish wrapper args 2026-06-20 21:48:01 +02:00
Shakker
90d2f161c9 fix: scope config open path env 2026-06-20 20:46:29 +01:00
Vincent Koc
bff7134a69 fix(mac): validate notarization wrapper args 2026-06-20 21:44:09 +02:00
Vincent Koc
e59d0b540e fix(mac): reject invalid codesign args 2026-06-20 21:41:34 +02:00
Shakker
aa5fcf70f7 test: share gateway credential env guard 2026-06-20 20:40:57 +01:00
Vincent Koc
63ac2e2ce0 fix(mac): reject build-and-run wrapper args 2026-06-20 21:36:42 +02:00
Shakker
803064c6e0 fix: localize session transcript env 2026-06-20 20:35:32 +01:00
Vincent Koc
577e5a4692 fix(mac): reject unknown restart options 2026-06-20 21:33:48 +02:00
Vincent Koc
a49f3f9362 fix(qa): parse qa e2e wrapper flags 2026-06-20 21:29:18 +02:00
Vincent Koc
7b9ddbda99 chore(deadcode): inline inbound prompt prefix 2026-06-21 03:27:50 +08:00
Shakker
0f83051353 test: share release journey env wrapper 2026-06-20 20:22:18 +01:00
Vincent Koc
4341cf24cc fix(crabbox): detect node-wrapped changed gates 2026-06-20 21:19:03 +02:00
Shakker
6a3f990140 fix: isolate plugin index loader env 2026-06-20 20:13:24 +01:00
scotthuang
81abc2b21b fix: preserve cron delivery awareness for target sessions (#93580)
Merged via squash.

Prepared head SHA: 460562ceff
Co-authored-by: scotthuang <1670837+scotthuang@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-20 12:13:10 -07:00
Shakker
09fcafffbc test: scope package root fallback env 2026-06-20 20:11:46 +01:00
Vincent Koc
2a93d7b9c5 chore(deadcode): inline runtime context builders 2026-06-21 03:09:43 +08:00
Shakker
0eaefc9050 fix: share npm verifier env guard 2026-06-20 20:02:45 +01:00
Shakker
52e01676be test: reuse memory fd env helper 2026-06-20 19:58:05 +01:00
Shakker
df68b81006 fix: isolate bundled probe env 2026-06-20 19:57:16 +01:00
Vincent Koc
a5417b5c6c chore(deadcode): inline bootstrap routing helpers 2026-06-21 02:55:16 +08:00
Shakker
da2c7e2d2b test: reuse startup bench env helper 2026-06-20 19:45:59 +01:00
Shakker
3a14f247ad fix: scope bundled skills env 2026-06-20 19:44:37 +01:00
Vincent Koc
5c36001fcb chore(deadcode): inline tool-search allowlist helpers 2026-06-21 02:40:32 +08:00
Shakker
05bed72a8d test: restore plugin trust env 2026-06-20 19:34:22 +01:00
Vincent Koc
c2433d41a7 fix(ci): reject release metadata option typos 2026-06-20 20:32:50 +02:00
Shakker
d368fd620c fix: restore clawhub home env 2026-06-20 19:31:26 +01:00
Vincent Koc
7dc7deaa13 fix(ci): reject mistyped changed gate options 2026-06-20 20:28:15 +02:00
Vincent Koc
a2ff59fdb2 chore(deadcode): inline same-model retry backoff 2026-06-21 02:24:56 +08:00
Vincent Koc
b12223a79f fix(qa): reject empty qa lab port flags 2026-06-20 20:17:52 +02:00
Vincent Koc
f519ceab9c fix(ci): allow gtimeout for docker pull retry 2026-06-20 20:12:30 +02:00
Vincent Koc
1f1b1aee6b chore(deadcode): remove duplicate Gemini schema helper 2026-06-21 02:09:19 +08:00
Vincent Koc
62b2e9ef14 fix(scripts): honor gtimeout in host setup wrappers 2026-06-20 20:07:50 +02:00
Vincent Koc
0f67474251 fix(docker): keep upgrade survivor auto-auth summary safe 2026-06-20 20:02:14 +02:00
Gio Della-Libera
e56fd1dc04 Keep core doctor health in contribution order (#86627)
Merged via squash.

Prepared head SHA: e0955797c1
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-20 10:59:31 -07:00
Vincent Koc
b3968f69c9 fix(package): accept uppercase artifact digests 2026-06-20 19:52:59 +02:00
Vincent Koc
b0df6dc10e fix(package): scope trusted URL auth to original origin 2026-06-20 19:50:09 +02:00
Vincent Koc
141fb2b119 fix(crabbox): bootstrap macOS stdin shell scripts 2026-06-20 19:44:40 +02:00
Vincent Koc
64b6488f6c fix(crabbox): bootstrap env-option macOS stdin scripts 2026-06-20 19:39:05 +02:00
Vincent Koc
e1fc4683bb chore(deadcode): remove unused cron run log reader 2026-06-21 01:32:51 +08:00
Vincent Koc
85ab952956 fix(release): reject zero correction tags 2026-06-20 19:30:26 +02:00
Vincent Koc
abd5fb4494 fix(release): guard appcast cleanup before notes path 2026-06-20 19:28:42 +02:00
Vincent Koc
aea050b43e fix(mac): clean failed notary zip staging 2026-06-20 19:25:38 +02:00
Vincent Koc
85f552bf37 fix(qa): clean failed Parallels package locks 2026-06-20 19:20:40 +02:00
Vincent Koc
dafd98dd98 chore(deadcode): drop unused llm provider helpers 2026-06-21 01:17:06 +08:00
Vincent Koc
3632c62f85 fix(qa): isolate OTEL smoke exporter env 2026-06-20 19:14:06 +02:00
Vincent Koc
ad5d2cbc1b fix(mac): clean dSYM staging on zip failure 2026-06-20 19:07:04 +02:00
Vincent Koc
7cda58c109 fix(package): keep artifact duplicate diagnostics relative 2026-06-20 19:02:54 +02:00
Vincent Koc
5c0b99ae2b chore(deadcode): remove unused task flow retry path 2026-06-21 01:00:42 +08:00
Vincent Koc
979925c194 fix(openwebui): redact failed chat diagnostics 2026-06-20 18:58:30 +02:00
Vincent Koc
2f9f45f734 fix(telegram): include session probe artifacts 2026-06-20 18:51:20 +02:00
Vincent Koc
32cbaecd09 fix(telegram): stage full proof artifacts safely 2026-06-20 18:47:12 +02:00
Vincent Koc
1989726eb6 chore(deadcode): remove unused cron failure target wrapper 2026-06-21 00:40:26 +08:00
Vincent Koc
2454acc287 fix(crabbox): bound macos bun bootstrap fetches 2026-06-20 18:38:00 +02:00
Vincent Koc
fce5db415b fix(crabbox): bound macos node bootstrap downloads 2026-06-20 18:33:48 +02:00
Vincent Koc
2166652eb3 fix(parallels): bound update tarball probe 2026-06-20 18:28:13 +02:00
Vincent Koc
7a9c269541 chore(deadcode): drop unused cron summary guard 2026-06-21 00:27:23 +08:00
Vincent Koc
aa893b9228 fix(parallels): bound linux smoke downloads 2026-06-20 18:25:57 +02:00
Vincent Koc
98a7741468 fix(parallels): bound windows smoke downloads 2026-06-20 18:24:13 +02:00
Vincent Koc
3df4341e5a fix(parallels): bound macos smoke downloads 2026-06-20 18:20:55 +02:00
Vincent Koc
ecac665bf3 fix(parallels): pace background launch probes 2026-06-20 18:14:08 +02:00
Vincent Koc
021fd5de2b chore(deadcode): remove unused channel sender validator 2026-06-21 00:11:51 +08:00
Vincent Koc
60159b9f00 fix(parallels): keep fresh malformed package locks 2026-06-20 18:10:32 +02:00
Vincent Koc
165440117e fix(canvas): ignore stale pnpm execpath 2026-06-20 18:05:23 +02:00
Vincent Koc
fddfcbe10e fix(canvas): use corepack for a2ui pnpm fallback 2026-06-20 18:02:17 +02:00
Vincent Koc
7c850bdf38 fix(test): kill SDK package command trees 2026-06-20 17:54:16 +02:00
Vincent Koc
2bc20f2ec5 fix(test): use pnpm runner for SDK package build 2026-06-20 17:51:21 +02:00
Vincent Koc
ed500dda25 fix(qa): use corepack for lab docker build fallback 2026-06-20 17:45:09 +02:00
Vincent Koc
bc754b3160 fix(ci): restore Vitest watchdog cleanup 2026-06-20 23:42:22 +08:00
Vincent Koc
b972956173 test(ci): use public Feishu temp-dir helper 2026-06-20 23:42:22 +08:00
Vincent Koc
29444b26f2 chore(deadcode): dedupe plugin JSON logger 2026-06-20 23:37:00 +08:00
Vincent Koc
7fc5a72433 fix(qa): cap chunked credential lease payloads 2026-06-20 17:34:38 +02:00
Vincent Koc
a590f7f690 fix(qa): require boundary entry shim outputs 2026-06-20 17:25:11 +02:00
Vincent Koc
2252674168 fix(qa): reject matrix output symlink escapes 2026-06-20 17:15:45 +02:00
Vincent Koc
60612ff492 chore(deadcode): inline auto-reply display wrappers 2026-06-20 23:14:23 +08:00
Vincent Koc
c5623e72f3 fix(qa): quote generated compose paths 2026-06-20 17:08:40 +02:00
Vincent Koc
947c21ee5a refactor(qa): reuse qa shell quote helper 2026-06-20 17:05:10 +02:00
Vincent Koc
99f58ae6d6 fix(qa): quote qa docker stop command 2026-06-20 16:59:14 +02:00
Vincent Koc
3f0e740f83 chore(deadcode): inline session visibility wrappers 2026-06-20 22:56:40 +08:00
Vincent Koc
106961b513 fix(e2e): resolve mounted macOS desktop homes 2026-06-20 16:51:20 +02:00
Vincent Koc
d0001f96f0 fix(e2e): ignore bundled plugin list diagnostics 2026-06-20 16:44:11 +02:00
Vincent Koc
527bd807b9 fix(e2e): ignore runtime smoke rpc log records 2026-06-20 16:40:14 +02:00
Vincent Koc
7546231762 fix(run-node): type signal process injection 2026-06-20 22:37:26 +08:00
Vincent Koc
a977dc843d chore(deadcode): delete unused route wrappers 2026-06-20 22:37:26 +08:00
Vincent Koc
6ad7f66af2 fix(e2e): ignore inline kitchen sink json diagnostics 2026-06-20 16:34:52 +02:00
Vincent Koc
1b4fb6291d fix(e2e): parse secret proof json records 2026-06-20 16:31:09 +02:00
Vincent Koc
ee69465fe9 fix(e2e): ignore embedded diagnostic reply json 2026-06-20 16:26:00 +02:00
Vincent Koc
7b329ade32 fix(e2e): reject malformed package lock pids 2026-06-20 16:21:27 +02:00
Vincent Koc
44422b2151 fix(e2e): isolate Windows background control markers 2026-06-20 16:17:04 +02:00
Vincent Koc
48b338a5a9 fix(e2e): report signaled host server startups 2026-06-20 16:14:16 +02:00
Vincent Koc
d4f68475fd fix(e2e): preserve spaced macOS desktop homes 2026-06-20 16:11:03 +02:00
Vincent Koc
d81ae7a441 chore(deadcode): inline unused CLI helpers 2026-06-20 22:09:32 +08:00
Vincent Koc
99d8549de6 fix(crabbox): always mark shell changed gates as remote 2026-06-20 16:04:05 +02:00
Vincent Koc
7a077ffead fix(run-node): bind process signal cleanup 2026-06-20 15:55:16 +02:00
Vincent Koc
b980d678a4 fix(run-node): clean child groups on forwarded signals 2026-06-20 15:55:16 +02:00
Vincent Koc
e02e3d6971 chore(deadcode): remove unused CLI helper exports 2026-06-20 21:51:36 +08:00
Vincent Koc
6fa05685ea fix(check): clean managed child groups after forwarded signals 2026-06-20 15:46:14 +02:00
Vincent Koc
6585cb3b44 fix(watch): clean child groups on watcher shutdown 2026-06-20 15:43:04 +02:00
Vincent Koc
730c7269ef fix(test): clean Vitest runner child groups on signal 2026-06-20 15:35:33 +02:00
Vincent Koc
d72f7edf2d chore(deadcode): move gateway live probe helper out of prod path 2026-06-20 21:21:19 +08:00
Vincent Koc
24b6e6ba96 fix(test-live): force cleanup shard child groups on parent signal 2026-06-20 15:19:22 +02:00
Vincent Koc
c33f8c20ef fix(test-live): force cleanup Vitest child groups on parent signal 2026-06-20 15:16:15 +02:00
Vincent Koc
1c0c072bc2 fix(boundary): force cleanup tsc child trees on parent signal 2026-06-20 15:10:53 +02:00
Vincent Koc
aaf335af04 fix(deadcode): clean Knip child trees on parent signal 2026-06-20 15:07:19 +02:00
Vincent Koc
ad049ef083 fix(build): clean tsdown child trees on parent signal 2026-06-20 15:03:37 +02:00
Vincent Koc
6dc121eb6a chore(deadcode): move gateway auth helper out of prod path 2026-06-20 21:01:29 +08:00
Vincent Koc
0742a2f37a fix(test-report): clean parent-signaled child trees 2026-06-20 14:59:00 +02:00
Vincent Koc
e2c567538d fix(boundary): clean active check child trees 2026-06-20 14:52:00 +02:00
Vincent Koc
5c8fa5da5c chore(deadcode): move plugin test mocks out of prod paths 2026-06-20 20:41:02 +08:00
Vincent Koc
9953b85e6d fix(install-smoke): clean Bun timeout child trees 2026-06-20 14:39:39 +02:00
Vincent Koc
048014d1ab fix(memory): clean extension profiler child trees 2026-06-20 14:30:28 +02:00
Vincent Koc
0cd6975352 fix(prompt-probe): clean direct prompt child trees 2026-06-20 14:20:09 +02:00
Vincent Koc
5384b91866 fix(prompt-probe): clean gateway child trees 2026-06-20 14:09:17 +02:00
Vincent Koc
19ec9d8979 chore(deadcode): remove msteams memory test stores 2026-06-20 20:03:23 +08:00
Vincent Koc
e65619dd0c fix(crabbox): clean wrapper child trees on parent signal 2026-06-20 13:52:34 +02:00
Vincent Koc
2f0f085826 chore(deadcode): remove bedrock test injection hooks 2026-06-20 19:44:10 +08:00
Vincent Koc
0cd8db97f9 fix(bench): kill gateway child trees on windows 2026-06-20 13:30:33 +02:00
Vincent Koc
087d999fce fix(secret-providers): clean PTY configure timeout trees 2026-06-20 13:29:56 +02:00
Vincent Koc
4514b5a387 fix(runtime-smoke): kill bundled child trees on windows 2026-06-20 13:24:20 +02:00
Vincent Koc
6b82d4ecb7 chore(deadcode): remove telegram topic cache test helpers 2026-06-20 19:22:42 +08:00
Vincent Koc
f719f0cf77 fix(rpc): kill measurement gateway trees on windows 2026-06-20 13:18:02 +02:00
Vincent Koc
8ee638236a fix(secret-providers): clean command trees on parent signal 2026-06-20 13:17:14 +02:00
Vincent Koc
36934fd9f5 fix(kitchen-sink): clean command groups on parent signal 2026-06-20 13:12:00 +02:00
Vincent Koc
84895e9276 fix(docker): clean active shell groups on parent signal 2026-06-20 13:04:14 +02:00
Vincent Koc
a6e41a0cc1 fix(qa-lab): kill script timeout trees on windows 2026-06-20 12:58:52 +02:00
Vincent Koc
1ede829fbf fix(qa-lab): leave vitest timeout cleanup to wrapper 2026-06-20 12:53:39 +02:00
Vincent Koc
b93b07ee1b test(qa-lab): use temp harness in scenario runner tests 2026-06-20 12:53:39 +02:00
Vincent Koc
405e5072fd fix(qa-lab): bound test file scenario commands 2026-06-20 12:53:39 +02:00
Vincent Koc
b79dfc739c fix(gauntlet): clean measured groups on parent signal 2026-06-20 12:49:02 +02:00
Vincent Koc
ff4808f94d chore(deadcode): remove stale feishu download helpers 2026-06-20 18:47:54 +08:00
Vincent Koc
602bc0baa9 fix(bench): clean timed-out sample process groups 2026-06-20 12:31:47 +02:00
Vincent Koc
a1d278b174 fix(crabbox): preserve telegram proof kill grace 2026-06-20 12:25:03 +02:00
Vincent Koc
0fd5dae36f test(ci): allow control ui runner startup 2026-06-20 18:22:56 +08:00
Vincent Koc
984e058624 fix(e2e): reap signaled PTY command trees 2026-06-20 12:20:16 +02:00
Vincent Koc
a6e4afe0fa fix(parallels): preserve npm update stream kill grace 2026-06-20 12:15:30 +02:00
Vincent Koc
66c62d52ad chore(deadcode): remove stale msteams mention helpers 2026-06-20 18:14:41 +08:00
Vincent Koc
9e3ef487eb test(ci): cover stable closeout retries 2026-06-20 18:13:27 +08:00
Vincent Koc
739636fc33 fix(parallels): reap signaled host command groups 2026-06-20 12:08:29 +02:00
Vincent Koc
ccc1415f6d fix(ui): clean up wrapper signal descendants 2026-06-20 12:08:07 +02:00
Vincent Koc
b1608b4a4e test(ci): refresh temp-dir helper routing 2026-06-20 18:05:14 +08:00
Vincent Koc
703dfbf453 chore(deadcode): remove stale auto-reply helpers 2026-06-20 17:58:59 +08:00
Vincent Koc
7cd58cca2a fix(qa-lab): keep lifecycle probe timeout trees tracked 2026-06-20 11:58:14 +02:00
Vincent Koc
2d603c90dc fix(i18n): reap control ui process groups on signal 2026-06-20 11:54:43 +02:00
Vincent Koc
4296ecb78c fix(qa-matrix): clean up killed CLI process groups 2026-06-20 11:50:38 +02:00
309 changed files with 12367 additions and 4052 deletions

View File

@@ -128,14 +128,9 @@ const config = {
"**/*.test-utils.ts",
"test/helpers/live-image-probe.ts",
"src/secrets/credential-matrix.ts",
"src/gateway/live-tool-probe-utils.ts",
"src/gateway/server.auth.shared.ts",
"src/shared/text/assistant-visible-text.ts",
bundledPluginFile("telegram", "src/bot/reply-threading.ts"),
bundledPluginFile("telegram", "src/draft-chunking.ts"),
bundledPluginFile("msteams", "src/conversation-store-memory.ts"),
bundledPluginFile("msteams", "src/polls-store-memory.ts"),
bundledPluginFile("voice-call", "src/providers/index.ts"),
],
ignore: ["packages/*/dist/**"],
workspaces: {

View File

@@ -1,2 +1,2 @@
118c0f05ded3d3671e4caca646f8c5c13799757705fec2d769b1657367ec0243 plugin-sdk-api-baseline.json
6795c59b8ce6c8203bfca5d932b562d3d2b718e93701faa3a52e57cb45d277d4 plugin-sdk-api-baseline.jsonl
9edb033535fe1325c18b431190672dc3a826dba312e376c13c98fcf9043060dd plugin-sdk-api-baseline.json
78f26963fe2e6d7903ce2e1067699200d825f391c0010df46f48d9abd2915e65 plugin-sdk-api-baseline.jsonl

View File

@@ -172,10 +172,12 @@ A finding includes:
| `ocPath` | Precise `oc://` address when a check can point to one. |
| `fixHint` | Suggested operator action or repair summary. |
This release registers the modernized core doctor checks on the structured
health path. The `openclaw/plugin-sdk/health` subpath exposes the same
contract for bundled follow-up consumers, but plugin-backed checks only run
after their owning package registers them in the active command path.
Modernized core doctor checks stay attached to the ordered doctor contribution
that owns their human `doctor` / `doctor --fix` behavior. The shared structured
health registry is the extension point: bundled and plugin-backed checks run
after core doctor checks once their owning package registers them in the active
command path. The `openclaw/plugin-sdk/health` subpath exposes the same
contract for those extension consumers.
## Check Selection

View File

@@ -166,7 +166,9 @@ two-party event loops that do not go through the shared inbound reply runner.
Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted.
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are kept only during the transition before SQLite migration for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers must migrate to entry helpers before the SQLite storage flip.
For transcript reads and writes, import `openclaw/plugin-sdk/session-transcript-runtime` and use `resolveSessionTranscriptIdentity(...)`, `resolveSessionTranscriptTarget(...)`, `readSessionTranscriptEvents(...)`, `appendSessionTranscriptMessageByIdentity(...)`, `publishSessionTranscriptUpdateByIdentity(...)`, or `withSessionTranscriptWriteLock(...)` with `{ agentId, sessionKey, sessionId }`. These APIs let plugins identify a transcript, read its events, append messages, publish updates, and run related operations under the same transcript write lock. Pass `sessionFile` only when adapting code that already receives an active transcript artifact and needs each helper to operate on that same artifact.
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are compatibility helpers for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers should migrate to entry helpers.
</Accordion>
<Accordion title="api.runtime.agent.defaults">

View File

@@ -248,6 +248,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
| `plugin-sdk/session-transcript-runtime` | Transcript identity, scoped target/read/write helpers, update publishing, write locks, and transcript memory hit keys |
| `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers for first-party runtime |
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |

View File

@@ -6,8 +6,6 @@ type SharedIniFileLoader = {
loadSharedConfigFiles(init?: { ignoreCache?: boolean }): Promise<unknown>;
};
let sharedIniFileLoaderForTest: SharedIniFileLoader | null | undefined;
function hasStaticAwsCredentialEnv(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY);
}
@@ -21,12 +19,6 @@ export function shouldRefreshAwsSharedConfigCacheForBedrock(env: NodeJS.ProcessE
}
async function loadSharedIniFileLoader(): Promise<SharedIniFileLoader> {
if (sharedIniFileLoaderForTest !== undefined) {
if (!sharedIniFileLoaderForTest) {
throw new Error("AWS shared INI file loader unavailable");
}
return sharedIniFileLoaderForTest;
}
return (await import("@smithy/shared-ini-file-loader")) as SharedIniFileLoader;
}
@@ -40,10 +32,3 @@ export async function refreshAwsSharedConfigCacheForBedrock(
const loader = await loadSharedIniFileLoader();
await loader.loadSharedConfigFiles({ ignoreCache: true });
}
/** Override the shared INI loader for Bedrock credential-refresh tests. */
export function setAwsSharedIniFileLoaderForTest(
loader: SharedIniFileLoader | null | undefined,
): void {
sharedIniFileLoaderForTest = loader;
}

View File

@@ -9,14 +9,9 @@ import {
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { withEnvAsync } from "openclaw/plugin-sdk/test-env";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setAwsSharedIniFileLoaderForTest } from "./aws-credential-refresh.js";
import { supportsBedrockPromptCaching } from "./bedrock-options.js";
import { resetBedrockDiscoveryCacheForTest } from "./discovery.js";
import amazonBedrockPlugin from "./index.js";
import {
resetBedrockAppProfileCacheEligibilityForTest,
setBedrockAppProfileControlPlaneForTest,
} from "./register.sync.runtime.js";
type BedrockClientResult =
| {
@@ -96,6 +91,10 @@ vi.mock("@aws-sdk/client-bedrock", () => {
};
});
vi.mock("@smithy/shared-ini-file-loader", () => ({
loadSharedConfigFiles: refreshSharedConfigCache,
}));
type RegisteredProviderPlugin = Awaited<ReturnType<typeof registerSingleProviderPlugin>>;
/** Register the amazon-bedrock plugin with an optional pluginConfig override. */
@@ -149,6 +148,8 @@ const ANTHROPIC_MODEL_DESCRIPTOR = {
const APP_INFERENCE_PROFILE_ARN =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-claude-profile";
const OPUS_APP_INFERENCE_PROFILE_ARN =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/opus-temperature-profile";
const APP_INFERENCE_PROFILE_DESCRIPTOR = {
api: "openai-completions",
provider: "amazon-bedrock",
@@ -267,26 +268,12 @@ describe("amazon-bedrock provider plugin", () => {
inferenceProfileGetResults.length = 0;
bedrockClientConfigs.length = 0;
refreshSharedConfigCache.mockClear();
setAwsSharedIniFileLoaderForTest({ loadSharedConfigFiles: refreshSharedConfigCache });
sendBedrockCommand.mockClear();
resetBedrockDiscoveryCacheForTest();
resetBedrockAppProfileCacheEligibilityForTest();
setBedrockAppProfileControlPlaneForTest((region) => ({
async getInferenceProfile(input) {
class GetInferenceProfileCommand {
constructor(readonly inputLocal: Record<string, unknown> = {}) {}
}
bedrockClientConfigs.push(region ? { region } : {});
return await sendBedrockCommand(new GetInferenceProfileCommand(input));
},
}));
});
afterEach(() => {
setBedrockAppProfileControlPlaneForTest(undefined);
setAwsSharedIniFileLoaderForTest(undefined);
resetBedrockDiscoveryCacheForTest();
resetBedrockAppProfileCacheEligibilityForTest();
});
afterAll(() => {
@@ -1501,8 +1488,8 @@ describe("amazon-bedrock provider plugin", () => {
await callWrappedStreamWithPayload(
provider,
APP_INFERENCE_PROFILE_ARN,
APP_INFERENCE_PROFILE_DESCRIPTOR,
OPUS_APP_INFERENCE_PROFILE_ARN,
makeAppInferenceProfileDescriptor(OPUS_APP_INFERENCE_PROFILE_ARN),
{ temperature: 0.3, maxTokens: 10, cacheRetention: "short" },
payload,
);

View File

@@ -254,27 +254,7 @@ type BedrockControlPlane = {
}) => Promise<BedrockGetInferenceProfileResponse>;
};
type BedrockControlPlaneFactory = (region: string | undefined) => BedrockControlPlane;
let bedrockControlPlaneOverride: BedrockControlPlaneFactory | undefined;
/** Reset app-profile prompt-cache eligibility state for tests. */
export function resetBedrockAppProfileCacheEligibilityForTest(): void {
appProfileTraitsCache.clear();
}
/** Override Bedrock app-profile control-plane checks for tests. */
export function setBedrockAppProfileControlPlaneForTest(
controlPlane: BedrockControlPlaneFactory | undefined,
): void {
bedrockControlPlaneOverride = controlPlane;
resetBedrockAppProfileCacheEligibilityForTest();
}
async function createBedrockControlPlane(region: string | undefined): Promise<BedrockControlPlane> {
if (bedrockControlPlaneOverride) {
return bedrockControlPlaneOverride(region);
}
await refreshAwsSharedConfigCacheForBedrock();
const { BedrockClient, GetInferenceProfileCommand } = await import("@aws-sdk/client-bedrock");
const client = new BedrockClient(region ? { region } : {});

View File

@@ -1,5 +1,7 @@
export interface PnpmRunnerParams {
comSpec?: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
nodeArgs?: string[];
nodeExecPath?: string;
npmExecPath?: string;

View File

@@ -2,6 +2,7 @@
* Cross-platform pnpm command resolver used by Canvas build scripts.
*/
import { accessSync, closeSync, constants, openSync, readSync, statSync } from "node:fs";
import path from "node:path";
const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>%\r\n]/;
const PNPM_EXECUTABLE_RE = /^pnpm(?:-cli)?(?:\.(?:[cm]?js|cmd|exe))?$/;
@@ -48,13 +49,56 @@ function isExecutableFile(value) {
}
}
function isFile(value) {
try {
return statSync(value).isFile();
} catch {
return false;
}
}
function resolvePathEnvKey(env) {
return Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH";
}
function findExecutableOnPath(command, envPath, platform, env, cwd) {
if (typeof envPath !== "string" || envPath.length === 0) {
return undefined;
}
const extensions =
platform === "win32"
? (env[Object.keys(env).find((key) => key.toLowerCase() === "pathext") ?? "PATHEXT"] ??
".COM;.EXE;.BAT;.CMD")
.split(";")
.filter(Boolean)
.map((extension) => extension.toLowerCase())
: [""];
const pathImpl = platform === "win32" ? path.win32 : path;
const pathDelimiter = platform === "win32" ? ";" : path.delimiter;
for (const directory of envPath.split(pathDelimiter)) {
if (!directory) {
continue;
}
const resolvedDirectory = pathImpl.isAbsolute(directory)
? directory
: pathImpl.resolve(cwd, directory);
for (const extension of extensions) {
const candidate = pathImpl.join(resolvedDirectory, `${command}${extension}`);
if ((platform === "win32" ? isFile(candidate) : isExecutableFile(candidate))) {
return candidate;
}
}
}
return undefined;
}
function isNodeRunnablePnpmExecPath(value) {
if (!isPnpmExecPath(value)) {
return false;
}
const { extension } = inspectExecutablePath(value);
if (NODE_RUNNABLE_EXTENSIONS.has(extension)) {
return true;
return isFile(value);
}
if (extension.length > 0) {
return false;
@@ -129,6 +173,22 @@ export function resolvePnpmRunner(params = {}) {
const pnpmArgs = params.pnpmArgs ?? [];
const platform = params.platform ?? process.platform;
const env = params.env ?? process.env;
const envPath = env[platform === "win32" ? resolvePathEnvKey(env) : "PATH"];
const cwd = params.cwd ?? process.cwd();
const pnpmPath = findExecutableOnPath("pnpm", envPath, platform, env, cwd);
if (pnpmPath) {
return platform === "win32"
? windowsCmdSpec(pnpmPath, pnpmArgs, params.comSpec ?? process.env.ComSpec ?? "cmd.exe")
: { args: pnpmArgs, command: pnpmPath, shell: false };
}
const corepackPath = findExecutableOnPath("corepack", envPath, platform, env, cwd);
if (corepackPath) {
const args = ["pnpm", ...pnpmArgs];
return platform === "win32"
? windowsCmdSpec(corepackPath, args, params.comSpec ?? process.env.ComSpec ?? "cmd.exe")
: { args, command: corepackPath, shell: false };
}
if (platform === "win32") {
return windowsCmdSpec("pnpm.cmd", pnpmArgs, params.comSpec ?? process.env.ComSpec ?? "cmd.exe");
}

View File

@@ -17,6 +17,7 @@ describe("canvas pnpm runner", () => {
try {
expect(
resolvePnpmRunner({
env: { PATH: "" },
npmExecPath,
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
@@ -40,6 +41,7 @@ describe("canvas pnpm runner", () => {
try {
expect(
resolvePnpmRunner({
env: { PATH: "" },
npmExecPath,
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
@@ -53,4 +55,79 @@ describe("canvas pnpm runner", () => {
rmSync(tempDir, { recursive: true, force: true });
}
});
posixIt("uses Corepack when pnpm is not directly available on PATH", () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-corepack-"));
const corepackPath = path.join(tempDir, "corepack");
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
chmodSync(corepackPath, 0o755);
try {
expect(
resolvePnpmRunner({
env: { PATH: tempDir },
npmExecPath: "",
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
}),
).toEqual({
args: ["pnpm", "exec", "rolldown", "-c"],
command: corepackPath,
shell: false,
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
posixIt("ignores a missing pnpm JS npm_execpath before checking PATH", () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-missing-"));
const corepackPath = path.join(tempDir, "corepack");
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
chmodSync(corepackPath, 0o755);
try {
expect(
resolvePnpmRunner({
env: { PATH: tempDir },
npmExecPath: path.join(tempDir, "missing-pnpm.mjs"),
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
}),
).toEqual({
args: ["pnpm", "exec", "rolldown", "-c"],
command: corepackPath,
shell: false,
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
posixIt("prefers a direct pnpm executable over Corepack", () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-path-"));
const pnpmPath = path.join(tempDir, "pnpm");
const corepackPath = path.join(tempDir, "corepack");
writeFileSync(pnpmPath, "#!/bin/sh\nexit 0\n");
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
chmodSync(pnpmPath, 0o755);
chmodSync(corepackPath, 0o755);
try {
expect(
resolvePnpmRunner({
env: { PATH: tempDir },
npmExecPath: "",
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
}),
).toEqual({
args: ["exec", "rolldown", "-c"],
command: pnpmPath,
shell: false,
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});

View File

@@ -351,7 +351,6 @@ vi.mock("./send.js", () => ({
}));
vi.mock("./media.js", () => ({
downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu,
saveMessageResourceFeishu: mockDownloadMessageResourceFeishu,
}));

View File

@@ -1,10 +1,8 @@
// Feishu tests cover media plugin behavior.
import { realpathSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { withTempDir } from "openclaw/plugin-sdk/test-env";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../runtime-api.js";
@@ -17,7 +15,6 @@ const runFfmpegMock = vi.hoisted(() => vi.fn());
const fileCreateMock = vi.hoisted(() => vi.fn());
const imageCreateMock = vi.hoisted(() => vi.fn());
const imageGetMock = vi.hoisted(() => vi.fn());
const messageCreateMock = vi.hoisted(() => vi.fn());
const messageResourceGetMock = vi.hoisted(() => vi.fn());
const messageReplyMock = vi.hoisted(() => vi.fn());
@@ -55,23 +52,11 @@ vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
};
});
let downloadImageFeishu: typeof import("./media.js").downloadImageFeishu;
let downloadMessageResourceFeishu: typeof import("./media.js").downloadMessageResourceFeishu;
let saveMessageResourceFeishu: typeof import("./media.js").saveMessageResourceFeishu;
let sanitizeFileNameForUpload: typeof import("./media.js").sanitizeFileNameForUpload;
let sendMediaFeishu: typeof import("./media.js").sendMediaFeishu;
let shouldSuppressFeishuTextForVoiceMedia: typeof import("./media.js").shouldSuppressFeishuTextForVoiceMedia;
function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
expect(pathValue).not.toContain(key);
expect(pathValue).not.toContain("..");
const tmpRoot = realpathSync(resolvePreferredOpenClawTmpDir());
const resolved = path.resolve(pathValue);
const rel = path.relative(tmpRoot, resolved);
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
}
function expectMediaTimeoutClientConfigured(): void {
const options = mockCallArg<{ httpTimeoutMs?: number }>(createFeishuClientMock, 0, 0);
expect(options.httpTimeoutMs).toBe(FEISHU_MEDIA_HTTP_TIMEOUT_MS);
@@ -113,11 +98,25 @@ function callData<T>(
return arg.data as T;
}
async function withIsolatedHome<T>(run: () => Promise<T>): Promise<T> {
const originalHome = process.env.HOME;
return await withTempDir("openclaw-feishu-media-", async (tempHome) => {
try {
process.env.HOME = tempHome;
return await run();
} finally {
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
}
});
}
describe("sendMediaFeishu msg_type routing", () => {
beforeAll(async () => {
({
downloadImageFeishu,
downloadMessageResourceFeishu,
saveMessageResourceFeishu,
sanitizeFileNameForUpload,
sendMediaFeishu,
@@ -148,7 +147,6 @@ describe("sendMediaFeishu msg_type routing", () => {
},
image: {
create: imageCreateMock,
get: imageGetMock,
},
message: {
create: messageCreateMock,
@@ -186,7 +184,6 @@ describe("sendMediaFeishu msg_type routing", () => {
contentType: "audio/ogg",
});
imageGetMock.mockResolvedValue(Buffer.from("image-bytes"));
messageResourceGetMock.mockResolvedValue(Buffer.from("resource-bytes"));
runFfmpegMock.mockImplementation(async (args: string[]) => {
await fs.writeFile(args.at(-1) ?? "", Buffer.from("opus-output"));
@@ -500,74 +497,25 @@ describe("sendMediaFeishu msg_type routing", () => {
expect(messageReplyMock).not.toHaveBeenCalled();
});
it("uses isolated temp paths for image downloads", async () => {
const imageKey = "img_v3_01abc123";
let capturedPath: string | undefined;
imageGetMock.mockResolvedValueOnce({
writeFile: async (tmpPath: string) => {
capturedPath = tmpPath;
await fs.writeFile(tmpPath, Buffer.from("image-data"));
},
});
const result = await downloadImageFeishu({
cfg: emptyConfig,
imageKey,
});
const request = mockCallArg<{ path?: { image_key?: string } }>(imageGetMock, 0, 0);
expect(request.path).toEqual({ image_key: imageKey });
expectMediaTimeoutClientConfigured();
expect(result.buffer).toEqual(Buffer.from("image-data"));
if (!capturedPath) {
throw new Error("expected Feishu image temp path");
}
expectPathIsolatedToTmpRoot(capturedPath, imageKey);
});
it("uses isolated temp paths for message resource downloads", async () => {
const fileKey = "file_v3_01abc123";
let capturedPath: string | undefined;
messageResourceGetMock.mockResolvedValueOnce({
writeFile: async (tmpPath: string) => {
capturedPath = tmpPath;
await fs.writeFile(tmpPath, Buffer.from("resource-data"));
},
});
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_123",
fileKey,
type: "image",
});
expect(result.buffer).toEqual(Buffer.from("resource-data"));
if (!capturedPath) {
throw new Error("expected Feishu resource temp path");
}
expectPathIsolatedToTmpRoot(capturedPath, fileKey);
});
it("rejects oversized message resource streams before buffering the rest", async () => {
it("rejects oversized message resource streams before saving the rest", async () => {
messageResourceGetMock.mockResolvedValueOnce({
getReadableStream: () => Readable.from([Buffer.alloc(4), Buffer.alloc(4)]),
});
await expect(
downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_123",
fileKey: "file_v3_01abc123",
type: "file",
maxBytes: 7,
}),
withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_123",
fileKey: "file_v3_01abc123",
type: "file",
maxBytes: 7,
}),
),
).rejects.toThrow(/Media exceeds/i);
});
it("rejects oversized writeFile downloads before reading the temp file", async () => {
it("rejects oversized writeFile resources before saving the temp file", async () => {
messageResourceGetMock.mockResolvedValueOnce({
writeFile: async (tmpPath: string) => {
await fs.writeFile(tmpPath, Buffer.alloc(8));
@@ -575,34 +523,26 @@ describe("sendMediaFeishu msg_type routing", () => {
});
await expect(
downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_123",
fileKey: "file_v3_01abc123",
type: "file",
maxBytes: 7,
}),
withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_123",
fileKey: "file_v3_01abc123",
type: "file",
maxBytes: 7,
}),
),
).rejects.toThrow(/Media exceeds/i);
});
it("rejects invalid image keys before calling feishu api", async () => {
await expect(
downloadImageFeishu({
cfg: emptyConfig,
imageKey: "a/../../bad",
}),
).rejects.toThrow("invalid image_key");
expect(imageGetMock).not.toHaveBeenCalled();
});
it("rejects invalid file keys before calling feishu api", async () => {
await expect(
downloadMessageResourceFeishu({
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_123",
fileKey: "x/../../bad",
type: "file",
maxBytes: 30 * 1024 * 1024,
}),
).rejects.toThrow("invalid file_key");
@@ -687,7 +627,7 @@ describe("sanitizeFileNameForUpload", () => {
});
});
describe("downloadMessageResourceFeishu", () => {
describe("saveMessageResourceFeishu", () => {
function httpStatusError(status: number): Error & { response: { status: number } } {
return Object.assign(new Error(`Request failed with status code ${status}`), {
response: { status },
@@ -712,12 +652,15 @@ describe("downloadMessageResourceFeishu", () => {
// Regression: Feishu API only supports type=image|file for messageResource.get.
// Audio/video resources must use type=file, not type=audio (#8746).
it("forwards provided type=file for non-image resources", async () => {
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_audio_msg",
fileKey: "file_key_audio",
type: "file",
});
const result = await withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_audio_msg",
fileKey: "file_key_audio",
type: "file",
maxBytes: 1024,
}),
);
const request = mockCallArg<{
params?: { type?: string };
@@ -726,18 +669,21 @@ describe("downloadMessageResourceFeishu", () => {
expect(request.path).toEqual({ message_id: "om_audio_msg", file_key: "file_key_audio" });
expect(request.params).toEqual({ type: "file" });
expectMediaTimeoutClientConfigured();
expect(result.buffer).toBeInstanceOf(Buffer);
expect(result.saved.size).toBe("fake-audio-data".length);
});
it("image uses type=image", async () => {
messageResourceGetMock.mockResolvedValue(Buffer.from("fake-image-data"));
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_img_msg",
fileKey: "img_key_1",
type: "image",
});
const result = await withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_img_msg",
fileKey: "img_key_1",
type: "image",
maxBytes: 1024,
}),
);
const request = mockCallArg<{
params?: { type?: string };
@@ -746,7 +692,7 @@ describe("downloadMessageResourceFeishu", () => {
expect(request.path).toEqual({ message_id: "om_img_msg", file_key: "img_key_1" });
expect(request.params).toEqual({ type: "image" });
expectMediaTimeoutClientConfigured();
expect(result.buffer).toBeInstanceOf(Buffer);
expect(result.saved.size).toBe("fake-image-data".length);
});
it("extracts content-type and filename metadata from download headers", async () => {
@@ -758,14 +704,17 @@ describe("downloadMessageResourceFeishu", () => {
},
});
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_video_msg",
fileKey: "file_key_video",
type: "file",
});
const result = await withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_video_msg",
fileKey: "file_key_video",
type: "file",
maxBytes: 1024,
}),
);
expect(result.buffer).toEqual(Buffer.from("fake-video-data"));
expect(result.saved.size).toBe("fake-video-data".length);
expect(result.contentType).toBe("video/mp4");
expect(result.fileName).toBe("clip.mp4");
});
@@ -780,12 +729,15 @@ describe("downloadMessageResourceFeishu", () => {
},
});
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_ios_video_msg",
fileKey: "file_key_ios_video",
type: "file",
});
const result = await withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_ios_video_msg",
fileKey: "file_key_ios_video",
type: "file",
maxBytes: 1024,
}),
);
const firstRequest = mockCallArg<{
params?: { type?: string };
@@ -805,7 +757,7 @@ describe("downloadMessageResourceFeishu", () => {
file_key: "file_key_ios_video",
});
expect(secondRequest.params).toEqual({ type: "media" });
expect(result.buffer).toEqual(Buffer.from("fake-ios-video-data"));
expect(result.saved.size).toBe("fake-ios-video-data".length);
expect(result.contentType).toBe("video/mp4");
expect(result.fileName).toBe("ios-video.mp4");
});
@@ -817,12 +769,15 @@ describe("downloadMessageResourceFeishu", () => {
.mockRejectedValueOnce(new Error("media retry failed"));
await expect(
downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_ios_video_msg",
fileKey: "file_key_ios_video",
type: "file",
}),
withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_ios_video_msg",
fileKey: "file_key_ios_video",
type: "file",
maxBytes: 1024,
}),
),
).rejects.toBe(originalError);
expect(
@@ -843,12 +798,15 @@ describe("downloadMessageResourceFeishu", () => {
messageResourceGetMock.mockRejectedValueOnce(originalError);
await expect(
downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: scenario.messageId,
fileKey: scenario.fileKey,
type: scenario.type,
}),
withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: scenario.messageId,
fileKey: scenario.fileKey,
type: scenario.type,
maxBytes: 1024,
}),
),
).rejects.toBe(originalError);
expect(messageResourceGetMock).toHaveBeenCalledTimes(1);
@@ -871,12 +829,15 @@ describe("downloadMessageResourceFeishu", () => {
},
});
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_file_msg",
fileKey: "file_key_csv",
type: "file",
});
const result = await withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_file_msg",
fileKey: "file_key_csv",
type: "file",
maxBytes: 1024,
}),
);
expect(result.fileName).toBe(fileName);
});
@@ -889,12 +850,15 @@ describe("downloadMessageResourceFeishu", () => {
},
});
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_latin1_msg",
fileKey: "file_key_latin1",
type: "file",
});
const result = await withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_latin1_msg",
fileKey: "file_key_latin1",
type: "file",
maxBytes: 1024,
}),
);
expect(result.fileName).toBe("café-©.txt");
});
@@ -907,21 +871,21 @@ describe("downloadMessageResourceFeishu", () => {
file_name: latin1LookingFileName,
});
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_json_file_msg",
fileKey: "file_key_json",
type: "file",
});
const result = await withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_json_file_msg",
fileKey: "file_key_json",
type: "file",
maxBytes: 1024,
}),
);
expect(result.fileName).toBe(latin1LookingFileName);
});
it("saves message resource streams directly to the media store", async () => {
const originalHome = process.env.HOME;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-media-"));
try {
process.env.HOME = tempHome;
await withIsolatedHome(async () => {
messageResourceGetMock.mockResolvedValueOnce({
getReadableStream: () => Readable.from([Buffer.from([0xff, 0xd8, 0xff, 0x00])]),
headers: {
@@ -944,23 +908,13 @@ describe("downloadMessageResourceFeishu", () => {
await expect(fs.readFile(result.saved.path)).resolves.toEqual(
Buffer.from([0xff, 0xd8, 0xff, 0x00]),
);
} finally {
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
await fs.rm(tempHome, { recursive: true, force: true });
}
});
});
it("recovers CJK filenames from the inbound message payload fallback", async () => {
const originalHome = process.env.HOME;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-media-"));
const fileName = "武汉15座山登山信息汇总.csv";
const latin1LookingFileName = Buffer.from(fileName, "utf8").toString("latin1");
try {
process.env.HOME = tempHome;
await withIsolatedHome(async () => {
messageResourceGetMock.mockResolvedValueOnce({
getReadableStream: () => Readable.from([Buffer.from("a,b\n1,2\n")]),
headers: { "content-type": "text/csv" },
@@ -976,13 +930,6 @@ describe("downloadMessageResourceFeishu", () => {
});
expect(result.saved.id).toMatch(/^武汉15座山登山信息汇总---[a-f0-9-]{36}\.csv$/);
} finally {
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
await fs.rm(tempHome, { recursive: true, force: true });
}
});
});
});

View File

@@ -7,7 +7,6 @@ import type { MessageReceipt } from "openclaw/plugin-sdk/channel-outbound";
import { mediaKindFromMime } from "openclaw/plugin-sdk/media-mime";
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime";
import { saveMediaBuffer, saveMediaStream, type SavedMedia } from "openclaw/plugin-sdk/media-store";
import { readByteStreamWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { readRegularFile, writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
@@ -48,17 +47,6 @@ const FEISHU_TRANSCODABLE_AUDIO_EXTS = new Set([
".wma",
]);
export type DownloadImageResult = {
buffer: Buffer;
contentType?: string;
};
export type DownloadMessageResourceResult = {
buffer: Buffer;
contentType?: string;
fileName?: string;
};
export type SaveMessageResourceResult = {
saved: SavedMedia;
contentType?: string;
@@ -87,10 +75,7 @@ type FeishuUploadResponse =
| Awaited<ReturnType<Lark.Client["im"]["image"]["create"]>>
| Awaited<ReturnType<Lark.Client["im"]["file"]["create"]>>;
type FeishuDownloadResponse =
| Awaited<ReturnType<Lark.Client["im"]["image"]["get"]>>
| Awaited<ReturnType<Lark.Client["im"]["file"]["get"]>>
| Awaited<ReturnType<Lark.Client["im"]["messageResource"]["get"]>>;
type FeishuDownloadResponse = Awaited<ReturnType<Lark.Client["im"]["messageResource"]["get"]>>;
type FeishuHeaderMap = Record<string, string | string[]>;
type FeishuMessageResourceDownloadType = "image" | "file" | "media";
@@ -255,78 +240,6 @@ function mediaLimitError(maxBytes: number): Error {
return new Error(`Media exceeds ${Math.round(maxBytes / (1024 * 1024))}MB limit`);
}
function assertBufferWithinLimit(buffer: Buffer, maxBytes: number): Buffer {
if (buffer.byteLength > maxBytes) {
throw mediaLimitError(maxBytes);
}
return buffer;
}
async function readFeishuResponseBuffer(params: {
response: FeishuDownloadResponse;
tmpDirPrefix: string;
errorPrefix: string;
maxBytes: number;
}): Promise<Buffer> {
const { response, maxBytes } = params;
if (Buffer.isBuffer(response)) {
return assertBufferWithinLimit(response, maxBytes);
}
if (response instanceof ArrayBuffer) {
return assertBufferWithinLimit(Buffer.from(response), maxBytes);
}
const responseWithOptionalFields = response as FeishuDownloadResponse & {
code?: number;
msg?: string;
data?: Buffer | ArrayBuffer;
[Symbol.asyncIterator]?: () => AsyncIterator<Buffer | Uint8Array | string>;
};
if (responseWithOptionalFields.code !== undefined && responseWithOptionalFields.code !== 0) {
throw new Error(
`${params.errorPrefix}: ${responseWithOptionalFields.msg || `code ${responseWithOptionalFields.code}`}`,
);
}
if (responseWithOptionalFields.data && Buffer.isBuffer(responseWithOptionalFields.data)) {
return assertBufferWithinLimit(responseWithOptionalFields.data, maxBytes);
}
if (responseWithOptionalFields.data instanceof ArrayBuffer) {
return assertBufferWithinLimit(Buffer.from(responseWithOptionalFields.data), maxBytes);
}
if (typeof response.getReadableStream === "function") {
return readByteStreamWithLimit(response.getReadableStream(), {
maxBytes,
onOverflow: () => mediaLimitError(maxBytes),
});
}
if (typeof response.writeFile === "function") {
return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
await response.writeFile(tmpPath);
const stat = await fs.promises.stat(tmpPath);
if (stat.size > maxBytes) {
throw mediaLimitError(maxBytes);
}
return await fs.promises.readFile(tmpPath);
});
}
if (responseWithOptionalFields[Symbol.asyncIterator]) {
const asyncIterable = responseWithOptionalFields as AsyncIterable<Buffer | Uint8Array | string>;
return readByteStreamWithLimit(asyncIterable, {
maxBytes,
onOverflow: () => mediaLimitError(maxBytes),
});
}
if (response instanceof Readable) {
return readByteStreamWithLimit(response, {
maxBytes,
onOverflow: () => mediaLimitError(maxBytes),
});
}
const keys = Object.keys(response as object);
throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${keys.join(", ")}]`);
}
async function saveFeishuResponseMedia(params: {
response: FeishuDownloadResponse;
tmpDirPrefix: string;
@@ -409,58 +322,6 @@ async function saveFeishuResponseMedia(params: {
throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${keys.join(", ")}]`);
}
/**
* Download an image from Feishu using image_key.
* Used for downloading images sent in messages.
*/
export async function downloadImageFeishu(params: {
cfg: ClawdbotConfig;
imageKey: string;
accountId?: string;
maxBytes?: number;
}): Promise<DownloadImageResult> {
const { cfg, imageKey, accountId, maxBytes = 30 * 1024 * 1024 } = params;
const normalizedImageKey = normalizeFeishuExternalKey(imageKey);
if (!normalizedImageKey) {
throw new Error("Feishu image download failed: invalid image_key");
}
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
const response = await client.im.image.get({
path: { image_key: normalizedImageKey },
});
const buffer = await readFeishuResponseBuffer({
response,
tmpDirPrefix: "openclaw-feishu-img-",
errorPrefix: "Feishu image download failed",
maxBytes,
});
const meta = extractFeishuDownloadMetadata(response);
return { buffer, contentType: meta.contentType };
}
async function downloadMessageResourceWithType(params: {
client: ReturnType<typeof createFeishuClient>;
messageId: string;
fileKey: string;
type: FeishuMessageResourceDownloadType;
maxBytes: number;
}): Promise<DownloadMessageResourceResult> {
const response = await params.client.im.messageResource.get({
path: { message_id: params.messageId, file_key: params.fileKey },
params: { type: params.type },
});
const buffer = await readFeishuResponseBuffer({
response,
tmpDirPrefix: "openclaw-feishu-resource-",
errorPrefix: "Feishu message resource download failed",
maxBytes: params.maxBytes,
});
return { buffer, ...extractFeishuDownloadMetadata(response) };
}
async function saveMessageResourceWithType(params: {
client: ReturnType<typeof createFeishuClient>;
messageId: string;
@@ -489,51 +350,6 @@ async function saveMessageResourceWithType(params: {
return { saved, ...meta };
}
/**
* Download a message resource (file/image/audio/video) from Feishu.
* Used for downloading files, audio, and video from messages.
*/
export async function downloadMessageResourceFeishu(params: {
cfg: ClawdbotConfig;
messageId: string;
fileKey: string;
type: "image" | "file";
accountId?: string;
maxBytes?: number;
}): Promise<DownloadMessageResourceResult> {
const { cfg, messageId, fileKey, type, accountId, maxBytes = 30 * 1024 * 1024 } = params;
const normalizedFileKey = normalizeFeishuExternalKey(fileKey);
if (!normalizedFileKey) {
throw new Error("Feishu message resource download failed: invalid file_key");
}
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
try {
return await downloadMessageResourceWithType({
client,
messageId,
fileKey: normalizedFileKey,
type,
maxBytes,
});
} catch (err) {
if (type !== "file" || !isHttpStatusError(err, 502)) {
throw err;
}
try {
return await downloadMessageResourceWithType({
client,
messageId,
fileKey: normalizedFileKey,
type: "media",
maxBytes,
});
} catch {
throw err;
}
}
}
export async function saveMessageResourceFeishu(params: {
cfg: ClawdbotConfig;
messageId: string;

View File

@@ -17,7 +17,7 @@ import {
setMemorySearchImpl,
setMemoryWorkspaceDir,
type MemoryReadParams,
} from "./memory-tool-manager-mock.js";
} from "./memory-tool-manager.test-mocks.js";
import { testing as shortTermPromotionTesting } from "./short-term-promotion.js";
import { createMemoryCoreTestHarness } from "./test-helpers.js";
import { testing as memoryToolsTesting } from "./tools.js";

View File

@@ -6,7 +6,7 @@ import {
resetMemoryToolMockState,
setMemoryBackend,
setMemorySearchImpl,
} from "./memory-tool-manager-mock.js";
} from "./memory-tool-manager.test-mocks.js";
import { createMemorySearchTool } from "./tools.js";
type RecordShortTermRecallsFn = (params: {

View File

@@ -11,7 +11,7 @@ import {
setMemoryCustomStatus,
setMemorySearchImpl,
setMemorySearchManagerImpl,
} from "./memory-tool-manager-mock.js";
} from "./memory-tool-manager.test-mocks.js";
import { createMemorySearchTool, testing as memoryToolsTesting } from "./tools.js";
import { MemoryGetSchema, MemorySearchSchema } from "./tools.shared.js";
import {

View File

@@ -1,52 +0,0 @@
// Msteams plugin module implements conversation store memory behavior.
import {
findPreferredDmConversationByUserId,
mergeStoredConversationReference,
normalizeStoredConversationId,
toConversationStoreEntries,
} from "./conversation-store-helpers.js";
import type {
MSTeamsConversationStore,
MSTeamsConversationStoreEntry,
StoredConversationReference,
} from "./conversation-store.js";
export function createMSTeamsConversationStoreMemory(
initial: MSTeamsConversationStoreEntry[] = [],
): MSTeamsConversationStore {
const map = new Map<string, StoredConversationReference>();
for (const { conversationId, reference } of initial) {
map.set(normalizeStoredConversationId(conversationId), reference);
}
const findPreferredDmByUserId = async (
id: string,
): Promise<MSTeamsConversationStoreEntry | null> => {
return findPreferredDmConversationByUserId(toConversationStoreEntries(map.entries()), id);
};
return {
upsert: async (conversationId, reference) => {
const normalizedId = normalizeStoredConversationId(conversationId);
map.set(
normalizedId,
mergeStoredConversationReference(
map.get(normalizedId),
reference,
new Date().toISOString(),
),
);
},
get: async (conversationId) => {
return map.get(normalizeStoredConversationId(conversationId)) ?? null;
},
list: async () => {
return toConversationStoreEntries(map.entries());
},
remove: async (conversationId) => {
return map.delete(normalizeStoredConversationId(conversationId));
},
findPreferredDmByUserId,
findByUserId: findPreferredDmByUserId,
};
}

View File

@@ -4,9 +4,18 @@ import os from "node:os";
import path from "node:path";
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createMSTeamsConversationStoreMemory } from "./conversation-store-memory.js";
import {
findPreferredDmConversationByUserId,
mergeStoredConversationReference,
normalizeStoredConversationId,
toConversationStoreEntries,
} from "./conversation-store-helpers.js";
import { createMSTeamsConversationStoreState } from "./conversation-store-state.js";
import type { MSTeamsConversationStore } from "./conversation-store.js";
import type {
MSTeamsConversationStore,
MSTeamsConversationStoreEntry,
StoredConversationReference,
} from "./conversation-store.js";
import { setMSTeamsRuntime } from "./runtime.js";
import { msteamsRuntimeStub } from "./test-support/runtime.js";
@@ -15,6 +24,39 @@ type StoreFactory = {
createStore: () => Promise<MSTeamsConversationStore>;
};
function createMemoryConversationStore(
initial: MSTeamsConversationStoreEntry[] = [],
): MSTeamsConversationStore {
const map = new Map<string, StoredConversationReference>();
for (const { conversationId, reference } of initial) {
map.set(normalizeStoredConversationId(conversationId), reference);
}
const findPreferredDmByUserId = async (
id: string,
): Promise<MSTeamsConversationStoreEntry | null> =>
findPreferredDmConversationByUserId(toConversationStoreEntries(map.entries()), id);
return {
upsert: async (conversationId, reference) => {
const normalizedId = normalizeStoredConversationId(conversationId);
map.set(
normalizedId,
mergeStoredConversationReference(
map.get(normalizedId),
reference,
new Date().toISOString(),
),
);
},
get: async (conversationId) => map.get(normalizeStoredConversationId(conversationId)) ?? null,
list: async () => toConversationStoreEntries(map.entries()),
remove: async (conversationId) => map.delete(normalizeStoredConversationId(conversationId)),
findPreferredDmByUserId,
findByUserId: findPreferredDmByUserId,
};
}
const storeFactories: StoreFactory[] = [
{
name: "state",
@@ -28,7 +70,7 @@ const storeFactories: StoreFactory[] = [
},
{
name: "memory",
createStore: async () => createMSTeamsConversationStoreMemory(),
createStore: async () => createMemoryConversationStore(),
},
];

View File

@@ -1,6 +1,6 @@
// Msteams tests cover mentions plugin behavior.
import { describe, expect, it } from "vitest";
import { buildMentionEntities, formatMentionText, parseMentions } from "./mentions.js";
import { parseMentions } from "./mentions.js";
function requireFirstEntity(result: ReturnType<typeof parseMentions>) {
const entity = result.entities[0];
@@ -15,29 +15,12 @@ function requireOnlyEntity(result: ReturnType<typeof parseMentions>) {
return requireFirstEntity(result);
}
const mentionFreeTextCases = [
{
name: "parseMentions",
assert: () => {
const result = parseMentions("Hello world!");
expect(result.text).toBe("Hello world!");
expect(result.entities).toHaveLength(0);
},
},
{
name: "formatMentionText",
assert: () => {
const mentions = [{ id: "28:xxx", name: "John" }];
expect(formatMentionText("Hello world", mentions)).toBe("Hello world");
},
},
];
describe("mention-free text contract", () => {
it.each(mentionFreeTextCases)("$name handles text without mentions", ({ assert }) => {
assert();
it("parseMentions handles text without mentions", () => {
const result = parseMentions("Hello world!");
expect(result.text).toBe("Hello world!");
expect(result.entities).toHaveLength(0);
});
});
@@ -175,81 +158,3 @@ describe("parseMentions", () => {
expect(result.text).toBe("See @[docs](https://example.com) for details");
});
});
describe("buildMentionEntities", () => {
it("builds entities from mention info", () => {
const mentions = [
{ id: "28:aaa", name: "Alice" },
{ id: "28:bbb", name: "Bob" },
];
const entities = buildMentionEntities(mentions);
expect(entities).toHaveLength(2);
expect(entities[0]).toEqual({
type: "mention",
text: "<at>Alice</at>",
mentioned: {
id: "28:aaa",
name: "Alice",
},
});
expect(entities[1]).toEqual({
type: "mention",
text: "<at>Bob</at>",
mentioned: {
id: "28:bbb",
name: "Bob",
},
});
});
it("handles empty list", () => {
const entities = buildMentionEntities([]);
expect(entities).toHaveLength(0);
});
});
describe("formatMentionText", () => {
it("formats text with single mention", () => {
const text = "Hello @John!";
const mentions = [{ id: "28:xxx", name: "John" }];
const result = formatMentionText(text, mentions);
expect(result).toBe("Hello <at>John</at>!");
});
it("formats text with multiple mentions", () => {
const text = "Hey @Alice and @Bob";
const mentions = [
{ id: "28:aaa", name: "Alice" },
{ id: "28:bbb", name: "Bob" },
];
const result = formatMentionText(text, mentions);
expect(result).toBe("Hey <at>Alice</at> and <at>Bob</at>");
});
it("handles case-insensitive matching", () => {
const text = "Hey @alice and @ALICE";
const mentions = [{ id: "28:aaa", name: "Alice" }];
const result = formatMentionText(text, mentions);
expect(result).toBe("Hey <at>Alice</at> and <at>Alice</at>");
});
it("escapes regex metacharacters in names", () => {
const text = "Hey @John(Test) and @Alice.Smith";
const mentions = [
{ id: "28:xxx", name: "John(Test)" },
{ id: "28:yyy", name: "Alice.Smith" },
];
const result = formatMentionText(text, mentions);
expect(result).toBe("Hey <at>John(Test)</at> and <at>Alice.Smith</at>");
});
});

View File

@@ -15,13 +15,6 @@ type MentionEntity = {
};
};
type MentionInfo = {
/** User/bot ID (e.g., "28:xxx" or AAD object ID) */
id: string;
/** Display name */
name: string;
};
/**
* Check whether an ID looks like a valid Teams user/bot identifier.
* Accepts:
@@ -82,33 +75,3 @@ export function parseMentions(text: string): {
entities,
};
}
/**
* Build mention entities array from a list of mentions.
* Use this when you already have the mention info and formatted text.
*/
export function buildMentionEntities(mentions: MentionInfo[]): MentionEntity[] {
return mentions.map((mention) => ({
type: "mention",
text: `<at>${mention.name}</at>`,
mentioned: {
id: mention.id,
name: mention.name,
},
}));
}
/**
* Format text with mentions using <at> tags.
* This is a convenience function when you want to manually format mentions.
*/
export function formatMentionText(text: string, mentions: MentionInfo[]): string {
let formatted = text;
for (const mention of mentions) {
// Replace @Name or @name with <at>Name</at>
const escapedName = mention.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const namePattern = new RegExp(`@${escapedName}`, "gi");
formatted = formatted.replace(namePattern, `<at>${mention.name}</at>`);
}
return formatted;
}

View File

@@ -1,6 +1,10 @@
// Msteams tests cover monitor handler.sso plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { createMSTeamsSsoTokenStoreMemory } from "./sso-token-store.js";
import {
makeMSTeamsSsoTokenStoreKey,
type MSTeamsSsoStoredToken,
type MSTeamsSsoTokenStore,
} from "./sso-token-store.js";
import {
type MSTeamsSsoFetch,
handleSigninTokenExchangeInvoke,
@@ -9,8 +13,23 @@ import {
parseSigninVerifyStateValue,
} from "./sso.js";
function createMemorySsoTokenStore(): MSTeamsSsoTokenStore {
const tokens = new Map<string, MSTeamsSsoStoredToken>();
return {
async get({ connectionName, userId }) {
return tokens.get(makeMSTeamsSsoTokenStoreKey(connectionName, userId)) ?? null;
},
async save(token) {
tokens.set(makeMSTeamsSsoTokenStoreKey(token.connectionName, token.userId), { ...token });
},
async remove({ connectionName, userId }) {
return tokens.delete(makeMSTeamsSsoTokenStoreKey(connectionName, userId));
},
};
}
function createSsoDeps(params: { fetchImpl: MSTeamsSsoFetch }) {
const tokenStore = createMSTeamsSsoTokenStoreMemory();
const tokenStore = createMemorySsoTokenStore();
const tokenProvider = {
getAccessToken: vi.fn(async () => "bf-service-token"),
};

View File

@@ -1,33 +0,0 @@
// Msteams plugin module implements polls store memory behavior.
import {
type MSTeamsPoll,
type MSTeamsPollStore,
normalizeMSTeamsPollSelections,
} from "./polls.js";
export function createMSTeamsPollStoreMemory(initial: MSTeamsPoll[] = []): MSTeamsPollStore {
const polls = new Map<string, MSTeamsPoll>();
for (const poll of initial) {
polls.set(poll.id, { ...poll });
}
const createPoll = async (poll: MSTeamsPoll) => {
polls.set(poll.id, { ...poll });
};
const getPoll = async (pollId: string) => polls.get(pollId) ?? null;
const recordVote = async (params: { pollId: string; voterId: string; selections: string[] }) => {
const poll = polls.get(params.pollId);
if (!poll) {
return null;
}
const normalized = normalizeMSTeamsPollSelections(poll, params.selections);
poll.votes[params.voterId] = normalized;
poll.updatedAt = new Date().toISOString();
polls.set(poll.id, poll);
return poll;
};
return { createPoll, getPoll, recordVote };
}

View File

@@ -8,13 +8,13 @@ import {
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { beforeEach, describe, expect, it } from "vitest";
import { createMSTeamsPollStoreMemory } from "./polls-store-memory.js";
import {
buildMSTeamsPollCard,
createMSTeamsPollStoreState,
extractMSTeamsPollVote,
normalizeMSTeamsPollSelections,
type MSTeamsPoll,
type MSTeamsPollStore,
} from "./polls.js";
import { setMSTeamsRuntime } from "./runtime.js";
import { msteamsRuntimeStub } from "./test-support/runtime.js";
@@ -112,7 +112,32 @@ const createStateStore = async () => {
return createMSTeamsPollStoreState({ stateDir });
};
const createMemoryStore = () => createMSTeamsPollStoreMemory();
function createMemoryPollStore(initial: MSTeamsPoll[] = []): MSTeamsPollStore {
const polls = new Map<string, MSTeamsPoll>();
for (const poll of initial) {
polls.set(poll.id, { ...poll });
}
return {
createPoll: async (poll) => {
polls.set(poll.id, { ...poll });
},
getPoll: async (pollId) => polls.get(pollId) ?? null,
recordVote: async ({ pollId, voterId, selections }) => {
const poll = polls.get(pollId);
if (!poll) {
return null;
}
const normalized = normalizeMSTeamsPollSelections(poll, selections);
poll.votes[voterId] = normalized;
poll.updatedAt = new Date().toISOString();
polls.set(poll.id, poll);
return poll;
},
};
}
const createMemoryStore = () => createMemoryPollStore();
describe.each([
{ name: "memory", createStore: createMemoryStore },
@@ -339,7 +364,7 @@ describe("state poll store", () => {
describe("memory poll store", () => {
it("reads seeded polls back, updates timestamps, and returns null for missing polls", async () => {
const store = createMSTeamsPollStoreMemory([
const store = createMemoryPollStore([
{
id: "poll-1",
question: "Pick one",

View File

@@ -137,19 +137,3 @@ export function createMSTeamsSsoTokenStoreFs(params?: {
},
};
}
/** In-memory store, primarily useful for tests. */
export function createMSTeamsSsoTokenStoreMemory(): MSTeamsSsoTokenStore {
const tokens = new Map<string, MSTeamsSsoStoredToken>();
return {
async get({ connectionName, userId }) {
return tokens.get(makeMSTeamsSsoTokenStoreKey(connectionName, userId)) ?? null;
},
async save(token) {
tokens.set(makeMSTeamsSsoTokenStoreKey(token.connectionName, token.userId), { ...token });
},
async remove({ connectionName, userId }) {
return tokens.delete(makeMSTeamsSsoTokenStoreKey(connectionName, userId));
},
};
}

View File

@@ -1,5 +1,5 @@
// Qa Lab tests cover docker harness plugin behavior.
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@@ -19,6 +19,7 @@ function parseComposeServices(compose: string) {
services?: Record<
string,
{
build?: { context?: string };
environment?: Record<string, string>;
volumes?: string[];
}
@@ -156,4 +157,32 @@ describe("qa docker harness", () => {
"docker build -t openclaw:qa-local-prebaked --build-arg OPENCLAW_EXTENSIONS=qa-channel qa-lab -f Dockerfile . @/repo/openclaw",
]);
});
it("quotes generated compose paths so shell-sensitive repo paths survive YAML parsing", async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-docker-paths-"));
const outputDir = path.join(tempRoot, "scaffold");
const repoRoot = path.join(tempRoot, "repo #hash");
cleanups.push(async () => {
await rm(tempRoot, { recursive: true, force: true });
});
await mkdir(repoRoot, { recursive: true });
await writeQaDockerHarnessFiles({
outputDir,
repoRoot,
gatewayToken: "qa-token",
usePrebuiltImage: false,
bindUiDist: true,
});
const compose = await readFile(path.join(outputDir, "docker-compose.qa.yml"), "utf8");
const services = parseComposeServices(compose);
expect(services["qa-mock-openai"]?.build?.context).toBe("../repo #hash");
expect(services["qa-lab"]?.volumes).toContain(
"../repo #hash/extensions/qa-lab/web/dist:/opt/openclaw-qa-lab-ui:ro",
);
expect(services["openclaw-qa-gateway"]?.volumes).toContain(
"../repo #hash:/opt/openclaw-repo:ro",
);
});
});

View File

@@ -18,6 +18,10 @@ function toPosixRelative(fromDir: string, toPath: string): string {
return path.relative(fromDir, toPath).split(path.sep).join("/");
}
function yamlDoubleQuoted(value: string) {
return JSON.stringify(value);
}
function renderImageBlock(params: {
outputDir: string;
repoRoot: string;
@@ -28,7 +32,7 @@ function renderImageBlock(params: {
return ` image: ${params.imageName}\n`;
}
const context = toPosixRelative(params.outputDir, params.repoRoot) || ".";
return ` build:\n context: ${context}\n dockerfile: Dockerfile\n args:\n OPENCLAW_EXTENSIONS: "qa-channel qa-lab"\n`;
return ` build:\n context: ${yamlDoubleQuoted(context)}\n dockerfile: Dockerfile\n args:\n OPENCLAW_EXTENSIONS: "qa-channel qa-lab"\n`;
}
function renderCompose(params: {
@@ -81,7 +85,7 @@ ${imageBlock} pull_policy: never
- "127.0.0.1:${params.qaLabPort}:${QA_LAB_INTERNAL_PORT}"
volumes:
- ./state:/opt/openclaw-scaffold:ro
${params.bindUiDist ? ` - ${qaLabUiMount}:${QA_LAB_UI_OVERLAY_DIR}:ro\n` : ""} healthcheck:
${params.bindUiDist ? ` - ${yamlDoubleQuoted(`${qaLabUiMount}:${QA_LAB_UI_OVERLAY_DIR}:ro`)}\n` : ""} healthcheck:
test:
- CMD
- node
@@ -124,7 +128,7 @@ ${imageBlock} pull_policy: never
OPENCLAW_PROFILE: ""
volumes:
- ./state:/opt/openclaw-scaffold:ro
- ${repoMount}:/opt/openclaw-repo:ro
- ${yamlDoubleQuoted(`${repoMount}:/opt/openclaw-repo:ro`)}
healthcheck:
test:
- CMD

View File

@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { runQaDockerUp } from "./docker-up.runtime.js";
import { shellQuote } from "./shell-quote.js";
type QaDockerUpDeps = NonNullable<Parameters<typeof runQaDockerUp>[1]>;
@@ -68,12 +69,39 @@ describe("runQaDockerUp", () => {
expect(result.qaLabUrl).toBe("http://127.0.0.1:43124");
expect(result.gatewayUrl).toBe("http://127.0.0.1:18889/");
expect(result.composeFile).toBe(composeFile);
expect(result.stopCommand).toBe(`docker compose -f ${composeFile} down`);
expect(result.stopCommand).toBe(`docker compose -f ${shellQuote(composeFile)} down`);
} finally {
await rm(outputDir, { recursive: true, force: true });
}
});
it("quotes the printed stop command when the compose path is shell-sensitive", async () => {
const calls: string[] = [];
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-docker-up-"));
const outputDir = path.join(tempRoot, "mac path's qa lab");
const repoRoot = path.resolve("/repo/openclaw");
const composeFile = path.join(outputDir, "docker-compose.qa.yml");
try {
const result = await runQaDockerUp(
{
repoRoot,
outputDir,
usePrebuiltImage: true,
skipUiBuild: true,
},
createHealthyDockerDeps(calls),
);
expect(result.stopCommand).toBe(`docker compose -f ${shellQuote(composeFile)} down`);
expect(calls).toContain(
`docker compose -f ${composeFile} down --remove-orphans @${repoRoot}`,
);
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
it("skips UI build and compose --build for prebuilt images", async () => {
const calls: string[] = [];
const outputDir = await mkdtemp(path.join(os.tmpdir(), "qa-docker-up-"));
@@ -105,6 +133,77 @@ describe("runQaDockerUp", () => {
}
});
it("falls back to Corepack for the QA UI build when pnpm is unavailable", async () => {
const calls: string[] = [];
const outputDir = await mkdtemp(path.join(os.tmpdir(), "qa-docker-up-"));
const repoRoot = path.resolve("/repo/openclaw");
const composeFile = path.join(outputDir, "docker-compose.qa.yml");
try {
await runQaDockerUp(
{
repoRoot,
outputDir,
usePrebuiltImage: true,
},
{
async runCommand(command, args, cwd) {
calls.push([command, ...args, `@${cwd}`].join(" "));
if (command === "pnpm") {
throw Object.assign(new Error("spawn pnpm ENOENT"), { code: "ENOENT" });
}
if (args.join(" ").includes("ps --format json openclaw-qa-gateway")) {
return { stdout: '{"Health":"healthy","State":"running"}\n', stderr: "" };
}
return { stdout: "", stderr: "" };
},
fetchImpl: vi.fn(async () => ({ ok: true })),
sleepImpl: vi.fn(async () => {}),
},
);
expect(calls).toEqual([
`pnpm qa:lab:build @${repoRoot}`,
`corepack pnpm qa:lab:build @${repoRoot}`,
`docker compose -f ${composeFile} down --remove-orphans @${repoRoot}`,
`docker compose -f ${composeFile} up -d @${repoRoot}`,
`docker compose -f ${composeFile} ps --format json openclaw-qa-gateway @${repoRoot}`,
]);
} finally {
await rm(outputDir, { recursive: true, force: true });
}
});
it("does not hide real QA UI build failures behind the Corepack fallback", async () => {
const calls: string[] = [];
const outputDir = await mkdtemp(path.join(os.tmpdir(), "qa-docker-up-"));
const repoRoot = path.resolve("/repo/openclaw");
try {
await expect(
runQaDockerUp(
{
repoRoot,
outputDir,
usePrebuiltImage: true,
},
{
async runCommand(command, args, cwd) {
calls.push([command, ...args, `@${cwd}`].join(" "));
throw Object.assign(new Error("qa lab build failed"), { code: 1 });
},
fetchImpl: vi.fn(async () => ({ ok: true })),
sleepImpl: vi.fn(async () => {}),
},
),
).rejects.toThrow("qa lab build failed");
expect(calls).toEqual([`pnpm qa:lab:build @${repoRoot}`]);
} finally {
await rm(outputDir, { recursive: true, force: true });
}
});
it("uses a repo-root-relative default output dir when none is provided", async () => {
const calls: string[] = [];
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-docker-root-"));

View File

@@ -1,6 +1,7 @@
// Qa Lab plugin module implements docker up behavior.
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { writeQaDockerHarnessFiles } from "./docker-harness.js";
import {
execCommand,
@@ -12,6 +13,7 @@ import {
type FetchLike,
type RunCommand,
} from "./docker-runtime.js";
import { shellQuote } from "./shell-quote.js";
type QaDockerUpResult = {
outputDir: string;
@@ -39,6 +41,37 @@ async function isQaLabDockerHealthReachable(url: string, fetchImpl: FetchLike) {
}
}
function isMissingCommandError(error: unknown, command: string, seen = new Set<unknown>()): boolean {
if (!error || seen.has(error)) {
return false;
}
seen.add(error);
if (typeof error !== "object") {
return formatErrorMessage(error).includes(`spawn ${command} ENOENT`);
}
const candidate = error as { cause?: unknown; code?: unknown; message?: unknown };
const message = typeof candidate.message === "string" ? candidate.message : "";
if (
candidate.code === "ENOENT" ||
message.includes(`spawn ${command} ENOENT`) ||
message.includes(`${command}: command not found`)
) {
return true;
}
return isMissingCommandError(candidate.cause, command, seen);
}
async function runQaLabBuild(repoRoot: string, runCommand: RunCommand) {
try {
await runCommand("pnpm", ["qa:lab:build"], repoRoot);
} catch (error) {
if (!isMissingCommandError(error, "pnpm")) {
throw error;
}
await runCommand("corepack", ["pnpm", "qa:lab:build"], repoRoot);
}
}
export async function runQaDockerUp(
params: {
repoRoot?: string;
@@ -71,7 +104,7 @@ export async function runQaDockerUp(
const sleepImpl = deps?.sleepImpl ?? sleep;
if (!params.skipUiBuild) {
await runCommand("pnpm", ["qa:lab:build"], repoRoot);
await runQaLabBuild(repoRoot, runCommand);
}
await writeQaDockerHarnessFiles({
@@ -147,6 +180,6 @@ export async function runQaDockerUp(
composeFile,
qaLabUrl,
gatewayUrl,
stopCommand: `docker compose -f ${composeFile} down`,
stopCommand: `docker compose -f ${shellQuote(composeFile)} down`,
};
}

View File

@@ -204,6 +204,129 @@ describe("credential lease runtime", () => {
expect(lease.payload.driverToken).toBe("driv\u00e9r");
});
it("rejects chunked convex payload markers above the configured chunk cap", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
jsonResponse({
status: "ok",
credentialId: "cred-many-chunks",
leaseToken: "lease-many-chunks",
payload: {
__openclawQaCredentialPayloadChunksV1: true,
byteLength: 1,
chunkCount: 3,
},
}),
)
.mockResolvedValueOnce(jsonResponse({ status: "ok" }));
await expect(
acquireQaCredentialLease({
kind: "telegram",
source: "convex",
role: "ci",
env: {
OPENCLAW_QA_CONVEX_SITE_URL: "https://qa-cred.example.convex.site",
OPENCLAW_QA_CONVEX_SECRET_CI: "ci-secret",
OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_CHUNKS: "2",
},
fetchImpl,
resolveEnvPayload: () => ({ groupId: "-1", driverToken: "unused", sutToken: "unused" }),
parsePayload: (payload) =>
payload as { groupId: string; driverToken: string; sutToken: string },
}),
).rejects.toThrow("Chunked credential payload marker exceeds 2 chunks.");
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(fetchUrl(fetchImpl, 1)).toBe(
"https://qa-cred.example.convex.site/qa-credentials/v1/release",
);
});
it("rejects chunked convex payload markers above the configured byte cap", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
jsonResponse({
status: "ok",
credentialId: "cred-large-payload",
leaseToken: "lease-large-payload",
payload: {
__openclawQaCredentialPayloadChunksV1: true,
byteLength: 33,
chunkCount: 1,
},
}),
)
.mockResolvedValueOnce(jsonResponse({ status: "ok" }));
await expect(
acquireQaCredentialLease({
kind: "telegram",
source: "convex",
role: "ci",
env: {
OPENCLAW_QA_CONVEX_SITE_URL: "https://qa-cred.example.convex.site",
OPENCLAW_QA_CONVEX_SECRET_CI: "ci-secret",
OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES: "32",
},
fetchImpl,
resolveEnvPayload: () => ({ groupId: "-1", driverToken: "unused", sutToken: "unused" }),
parsePayload: (payload) =>
payload as { groupId: string; driverToken: string; sutToken: string },
}),
).rejects.toThrow("Chunked credential payload marker exceeds 32 bytes.");
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(fetchUrl(fetchImpl, 1)).toBe(
"https://qa-cred.example.convex.site/qa-credentials/v1/release",
);
});
it("stops chunked convex payload hydration when chunk data exceeds the marker", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
jsonResponse({
status: "ok",
credentialId: "cred-overrun",
leaseToken: "lease-overrun",
payload: {
__openclawQaCredentialPayloadChunksV1: true,
byteLength: 2,
chunkCount: 2,
},
}),
)
.mockResolvedValueOnce(jsonResponse({ status: "ok", data: "abc" }))
.mockResolvedValueOnce(jsonResponse({ status: "ok" }));
await expect(
acquireQaCredentialLease({
kind: "telegram",
source: "convex",
role: "ci",
env: {
OPENCLAW_QA_CONVEX_SITE_URL: "https://qa-cred.example.convex.site",
OPENCLAW_QA_CONVEX_SECRET_CI: "ci-secret",
},
fetchImpl,
resolveEnvPayload: () => ({ groupId: "-1", driverToken: "unused", sutToken: "unused" }),
parsePayload: (payload) =>
payload as { groupId: string; driverToken: string; sutToken: string },
}),
).rejects.toThrow("Chunked credential payload exceeded declared byteLength.");
expect(fetchImpl).toHaveBeenCalledTimes(3);
expect(fetchUrl(fetchImpl, 1)).toBe(
"https://qa-cred.example.convex.site/qa-credentials/v1/payload-chunk",
);
expect(fetchUrl(fetchImpl, 2)).toBe(
"https://qa-cred.example.convex.site/qa-credentials/v1/release",
);
});
it("defaults convex credential role to maintainer outside CI", async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValueOnce(
jsonResponse({

View File

@@ -17,6 +17,10 @@ const DEFAULT_ENDPOINT_PREFIX = QA_CREDENTIALS_DEFAULT_ENDPOINT_PREFIX;
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
const DEFAULT_HTTP_TIMEOUT_MS = 15_000;
const DEFAULT_LEASE_TTL_MS = 20 * 60 * 1_000;
const DEFAULT_CHUNKED_PAYLOAD_MAX_BYTES = 64 * 1024 * 1024;
const DEFAULT_CHUNKED_PAYLOAD_MAX_CHUNKS = 4096;
const CHUNKED_PAYLOAD_MAX_BYTES_ENV = "OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES";
const CHUNKED_PAYLOAD_MAX_CHUNKS_ENV = "OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_CHUNKS";
const RETRY_BACKOFF_MS = [500, 1_000, 2_000, 4_000, 5_000] as const;
const RETRYABLE_ACQUIRE_CODES = new Set(["POOL_EXHAUSTED", "NO_CREDENTIAL_AVAILABLE"]);
const CHUNKED_PAYLOAD_MARKER = "__openclawQaCredentialPayloadChunksV1";
@@ -55,6 +59,8 @@ type ConvexCredentialBrokerConfig = {
httpTimeoutMs: number;
leaseTtlMs: number;
ownerId: string;
payloadMaxBytes: number;
payloadMaxChunks: number;
payloadChunkUrl: string;
releaseUrl: string;
role: QaCredentialRole;
@@ -203,6 +209,16 @@ function resolveConvexCredentialBrokerConfig(params: {
"OPENCLAW_QA_CREDENTIAL_HTTP_TIMEOUT_MS",
DEFAULT_HTTP_TIMEOUT_MS,
),
payloadMaxBytes: parsePositiveIntegerEnv(
params.env,
CHUNKED_PAYLOAD_MAX_BYTES_ENV,
DEFAULT_CHUNKED_PAYLOAD_MAX_BYTES,
),
payloadMaxChunks: parsePositiveIntegerEnv(
params.env,
CHUNKED_PAYLOAD_MAX_CHUNKS_ENV,
DEFAULT_CHUNKED_PAYLOAD_MAX_CHUNKS,
),
acquireUrl: joinQaCredentialEndpoint(baseUrl, endpointPrefix, "acquire"),
heartbeatUrl: joinQaCredentialEndpoint(baseUrl, endpointPrefix, "heartbeat"),
payloadChunkUrl: joinQaCredentialEndpoint(baseUrl, endpointPrefix, "payload-chunk"),
@@ -210,7 +226,10 @@ function resolveConvexCredentialBrokerConfig(params: {
};
}
function parseChunkedPayloadMarker(payload: unknown) {
function parseChunkedPayloadMarker(
payload: unknown,
limits: { maxBytes: number; maxChunks: number },
) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return null;
}
@@ -225,6 +244,9 @@ function parseChunkedPayloadMarker(payload: unknown) {
) {
throw new Error("Chunked credential payload marker has an invalid chunkCount.");
}
if (record.chunkCount > limits.maxChunks) {
throw new Error(`Chunked credential payload marker exceeds ${limits.maxChunks} chunks.`);
}
if (
typeof record.byteLength !== "number" ||
!Number.isInteger(record.byteLength) ||
@@ -232,6 +254,9 @@ function parseChunkedPayloadMarker(payload: unknown) {
) {
throw new Error("Chunked credential payload marker has an invalid byteLength.");
}
if (record.byteLength > limits.maxBytes) {
throw new Error(`Chunked credential payload marker exceeds ${limits.maxBytes} bytes.`);
}
return {
chunkCount: record.chunkCount,
byteLength: record.byteLength,
@@ -304,11 +329,15 @@ async function resolveConvexCredentialPayload(params: {
fetchImpl: typeof fetch;
kind: string;
}) {
const marker = parseChunkedPayloadMarker(params.acquired.payload);
const marker = parseChunkedPayloadMarker(params.acquired.payload, {
maxBytes: params.config.payloadMaxBytes,
maxChunks: params.config.payloadMaxChunks,
});
if (!marker) {
return params.acquired.payload;
}
const chunks: string[] = [];
let serializedBytes = 0;
for (let index = 0; index < marker.chunkCount; index += 1) {
const payload = await postConvexBroker({
fetchImpl: params.fetchImpl,
@@ -325,10 +354,14 @@ async function resolveConvexCredentialPayload(params: {
},
});
const parsed = convexPayloadChunkSuccessSchema.parse(payload);
serializedBytes += Buffer.byteLength(parsed.data, "utf8");
if (serializedBytes > marker.byteLength) {
throw new Error("Chunked credential payload exceeded declared byteLength.");
}
chunks.push(parsed.data);
}
const serialized = chunks.join("");
if (Buffer.byteLength(serialized, "utf8") !== marker.byteLength) {
if (serializedBytes !== marker.byteLength) {
throw new Error("Chunked credential payload length mismatch.");
}
return JSON.parse(serialized) as unknown;

View File

@@ -50,22 +50,19 @@ describe("qa scenario catalog", () => {
expect(
scenarioIds.filter((scenarioId) => requiredScenarioIds.includes(scenarioId)).toSorted(),
).toEqual(requiredScenarioIds);
expect(
pack.scenarios
.filter((scenario) => scenario.execution?.kind !== "flow")
.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",
"ux-matrix-evidence-dashboard",
].toSorted(),
const nativeExecutionScenarios = pack.scenarios.filter(
(scenario) => scenario.execution.kind !== "flow",
);
expect(nativeExecutionScenarios.length).toBeGreaterThan(0);
for (const scenario of nativeExecutionScenarios) {
const execution = scenario.execution;
if (execution.kind === "flow") {
throw new Error(`expected native execution scenario: ${scenario.id}`);
}
expect(["playwright", "script", "vitest"]).toContain(execution.kind);
expect(fs.existsSync(execution.path), `${scenario.id} execution.path exists`).toBe(true);
expect(execution.flow).toBeUndefined();
}
expect(
pack.scenarios
.filter((scenario) => scenario.execution.kind === "flow")
@@ -176,6 +173,21 @@ describe("qa scenario catalog", () => {
expect(uxMatrix.coverage?.primary).toContain("qa.artifact-safety");
});
it("loads folded HTTP API script scenarios with primary taxonomy coverage", () => {
expect(readQaScenarioById("openai-compatible-chat-tools").coverage?.primary).toStrictEqual([
"gateway.openai-compatible-apis",
]);
expect(readQaScenarioById("openai-web-search-minimal").coverage?.primary).toStrictEqual([
"runtime.reasoning-and-cache-controls",
]);
expect(
readQaScenarioById("openai-web-search-native-assertions").coverage?.primary,
).toStrictEqual(["web-search.openai-native-web-search", "plugins.web-search-and-fetch"]);
expect(readQaScenarioById("openwebui-openai-compatible").coverage?.primary).toStrictEqual([
"gateway.openai-compatible-apis",
]);
});
it("loads runtime parity tier metadata for first-hour and soak lanes", () => {
const firstHour = readQaScenarioById("runtime-first-hour-20-turn");
const soak = readQaScenarioById("runtime-soak-100-turn");

View File

@@ -1,15 +1,54 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { afterEach, describe, expect, it } from "vitest";
import { validateQaEvidenceSummaryJson } from "./evidence-summary.js";
import { readQaScenarioById, type QaSeedScenarioWithSource } from "./scenario-catalog.js";
import { createTempDirHarness } from "./temp-dir.test-helper.js";
import {
runQaTestFileScenarios,
type QaScenarioCommandExecution,
} from "./test-file-scenario-runner.js";
const tempRoots: string[] = [];
const { cleanup: cleanupTempDirs, makeTempDir } = createTempDirHarness();
function isProcessRunning(pid: number) {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function readPid(filePath: string, timeoutMs: number) {
const deadlineAt = Date.now() + timeoutMs;
while (Date.now() < deadlineAt) {
try {
const pid = Number(await fs.readFile(filePath, "utf8"));
if (Number.isInteger(pid) && pid > 0) {
return pid;
}
} catch {
// retry until the process writes its pid
}
await sleep(25);
}
throw new Error(`timeout waiting for pid in ${filePath}`);
}
async function waitForDead(pid: number, timeoutMs: number) {
const deadlineAt = Date.now() + timeoutMs;
while (Date.now() < deadlineAt) {
if (!isProcessRunning(pid)) {
return;
}
await sleep(25);
}
throw new Error(`process ${pid} still alive`);
}
function makeTestFileScenario(
executionKind: "script" | "vitest" | "playwright",
@@ -51,9 +90,10 @@ async function makeTempRepo(prefix: string) {
describe("qa test file scenario runner", () => {
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
);
await Promise.all([
cleanupTempDirs(),
...tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
]);
});
it("runs Playwright scenarios with the repo UI e2e command and writes Playwright evidence", async () => {
@@ -92,6 +132,7 @@ describe("qa test file scenario runner", () => {
"--reporter=verbose",
],
]);
expect(commands.map((command) => command.timeoutMs)).toEqual([undefined, undefined]);
const evidence = validateQaEvidenceSummaryJson(
JSON.parse(await fs.readFile(result.evidencePath, "utf8")),
);
@@ -168,6 +209,7 @@ describe("qa test file scenario runner", () => {
"--reporter=verbose",
],
]);
expect(commands.map((command) => command.timeoutMs)).toEqual([undefined]);
const evidence = validateQaEvidenceSummaryJson(
JSON.parse(await fs.readFile(result.evidencePath, "utf8")),
);
@@ -304,6 +346,7 @@ describe("qa test file scenario runner", () => {
path.join(repoRoot, ".artifacts", "qa-e2e", "scenario-script", "scenario-script"),
],
]);
expect(commands.map((command) => command.timeoutMs)).toEqual([30 * 60_000]);
const evidence = validateQaEvidenceSummaryJson(
JSON.parse(await fs.readFile(result.evidencePath, "utf8")),
);
@@ -335,6 +378,84 @@ describe("qa test file scenario runner", () => {
});
});
it("times out script scenarios and kills descendant process groups", async () => {
if (process.platform === "win32") {
return;
}
const repoRoot = process.cwd();
const tempRoot = await makeTempDir("qa-script-timeout-");
const scriptPath = path.join(tempRoot, "hanging-producer.ts");
const descendantPidPath = path.join(tempRoot, "descendant.pid");
let descendantPid: number | undefined;
try {
const descendantScript = [
"process.on('SIGTERM', () => {});",
"setInterval(() => {}, 1000);",
].join("\n");
await fs.writeFile(
scriptPath,
[
"import { spawn } from 'node:child_process';",
"import { writeFileSync } from 'node:fs';",
`const descendant = spawn(process.execPath, ['-e', ${JSON.stringify(descendantScript)}], { stdio: 'ignore' });`,
`writeFileSync(${JSON.stringify(descendantPidPath)}, String(descendant.pid));`,
"process.stdout.write('script still running\\n');",
"process.on('SIGTERM', () => {});",
"setInterval(() => {}, 1000);",
].join("\n"),
"utf8",
);
const run = runQaTestFileScenarios({
repoRoot,
outputDir: path.join(tempRoot, "out"),
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.5",
scenarios: [makeTestFileScenario("script", scriptPath)],
commandTimeoutMs: 500,
});
descendantPid = await readPid(descendantPidPath, 2_000);
const result = await run;
expect(result.results[0]?.status).toBe("fail");
expect(result.results[0]?.failureMessage).toMatch(/timed out after 500ms/u);
await waitForDead(descendantPid, 2_000);
} finally {
if (descendantPid !== undefined && isProcessRunning(descendantPid)) {
process.kill(descendantPid, "SIGKILL");
}
}
});
it("fails script scenarios that exit cleanly after timeout termination", async () => {
const repoRoot = process.cwd();
const tempRoot = await makeTempDir("qa-script-timeout-clean-exit-");
const scriptPath = path.join(tempRoot, "clean-exit-after-timeout.ts");
await fs.writeFile(
scriptPath,
[
"process.stdout.write('waiting for timeout\\n');",
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
].join("\n"),
"utf8",
);
const result = await runQaTestFileScenarios({
repoRoot,
outputDir: path.join(tempRoot, "out"),
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.5",
scenarios: [makeTestFileScenario("script", scriptPath)],
commandTimeoutMs: 100,
});
expect(result.results[0]?.status).toBe("fail");
expect(result.results[0]?.failureMessage).toMatch(/timed out after 100ms/u);
});
it("imports producer QA evidence artifacts from failed script scenarios", async () => {
const repoRoot = await makeTempRepo("qa-script-failed-scenario-");
const result = await runQaTestFileScenarios({

View File

@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolvePositiveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { assertQaSuiteArtifactWritten } from "./artifact-assertion.js";
import { isRepoRootRelativeRef, toRepoRelativePath } from "./cli-paths.js";
import {
@@ -31,6 +32,7 @@ export type QaTestFileScenario = QaSeedScenarioWithSource & {
export type QaTestFileExecutionKind = "script" | "vitest" | "playwright";
export type QaTestFileScenarioRunParams = {
commandTimeoutMs?: number;
evidenceMode?: QaScorecardEvidenceMode;
env?: NodeJS.ProcessEnv;
outputDir: string;
@@ -46,10 +48,12 @@ export type QaScenarioCommandExecution = {
command: string;
cwd: string;
env: NodeJS.ProcessEnv;
timeoutMs?: number;
};
type QaScenarioCommandResult = {
exitCode: number;
failureMessage?: string;
signal?: NodeJS.Signals | null;
stdout: string;
stderr: string;
@@ -86,6 +90,11 @@ type QaTestFileRunnerDefinition = {
buildSteps(scenario: QaTestFileScenario, context: { outputDir: string }): QaScenarioCommandStep[];
};
const DEFAULT_QA_TEST_FILE_COMMAND_TIMEOUT_MS = 30 * 60_000;
const QA_TEST_FILE_COMMAND_TIMEOUT_KILL_GRACE_MS = 2_000;
const QA_TEST_FILE_COMMAND_TIMEOUT_FORCE_SETTLE_MS = 500;
const QA_TEST_FILE_COMMAND_PARENT_SIGNALS = ["SIGINT", "SIGTERM"] as const;
export function isQaTestFileScenario(
scenario: QaSeedScenarioWithSource,
): scenario is QaTestFileScenario {
@@ -177,31 +186,203 @@ function formatCommand(step: QaScenarioCommandStep) {
return [step.command, ...step.args].map(shellQuote).join(" ");
}
function killQaScenarioWindowsProcessTree(pid: number | undefined, signal: NodeJS.Signals) {
if (pid === undefined) {
return false;
}
const args = ["/pid", String(pid), "/T"];
if (signal === "SIGKILL") {
args.push("/F");
}
try {
const killer = spawn("taskkill", args, {
stdio: "ignore",
windowsHide: true,
});
killer.on("error", () => undefined);
killer.unref();
return true;
} catch {
return false;
}
}
function runQaScenarioCommand(
execution: QaScenarioCommandExecution,
): Promise<QaScenarioCommandResult> {
return new Promise((resolve, reject) => {
const useProcessGroup = process.platform !== "win32";
const child = spawn(execution.command, execution.args, {
cwd: execution.cwd,
detached: useProcessGroup,
env: execution.env,
stdio: ["ignore", "pipe", "pipe"],
});
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
const timeoutMs = execution.timeoutMs;
let forceKillTimer: NodeJS.Timeout | undefined;
let forceSettleTimer: NodeJS.Timeout | undefined;
let settled = false;
let timedOut = false;
let timeoutTimer: NodeJS.Timeout | undefined;
const readOutput = () => ({
stdout: Buffer.concat(stdout).toString("utf8"),
stderr: Buffer.concat(stderr).toString("utf8"),
});
const commandLabel = () => path.basename(execution.command);
const clearForcedTimers = () => {
if (forceKillTimer) {
clearTimeout(forceKillTimer);
forceKillTimer = undefined;
}
if (forceSettleTimer) {
clearTimeout(forceSettleTimer);
forceSettleTimer = undefined;
}
};
const clearTimers = () => {
if (timeoutTimer) {
clearTimeout(timeoutTimer);
timeoutTimer = undefined;
}
clearForcedTimers();
};
const signalChild = (signal: NodeJS.Signals) => {
if (useProcessGroup && child.pid) {
try {
process.kill(-child.pid, signal);
return;
} catch {
// The process group may already be gone; fall back to the direct child.
}
}
if (!useProcessGroup && process.platform === "win32") {
if (killQaScenarioWindowsProcessTree(child.pid, signal)) {
return;
}
}
child.kill(signal);
};
const handleParentExit = () => {
signalChild("SIGKILL");
};
const removeParentSignalHandlers = () => {
for (const signal of QA_TEST_FILE_COMMAND_PARENT_SIGNALS) {
process.removeListener(signal, handleParentSignal);
}
};
const cleanupParentHandlers = () => {
removeParentSignalHandlers();
process.removeListener("exit", handleParentExit);
};
const handleParentSignal = (signal: (typeof QA_TEST_FILE_COMMAND_PARENT_SIGNALS)[number]) => {
removeParentSignalHandlers();
signalChild(signal);
scheduleForcedCleanup({
exitCode: 1,
failureMessage: `${commandLabel()} interrupted by ${signal}`,
signal,
});
process.kill(process.pid, signal);
};
const isProcessGroupRunning = () => {
if (!useProcessGroup || !child.pid) {
return false;
}
try {
process.kill(-child.pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
};
const finish = (
result: Pick<QaScenarioCommandResult, "exitCode" | "failureMessage" | "signal">,
) => {
if (settled) {
return;
}
settled = true;
clearTimers();
cleanupParentHandlers();
resolve({
...result,
...readOutput(),
});
};
const scheduleForcedCleanup = (
result: Pick<QaScenarioCommandResult, "exitCode" | "failureMessage" | "signal">,
) => {
if (forceKillTimer || forceSettleTimer) {
return;
}
forceKillTimer = setTimeout(() => {
forceKillTimer = undefined;
signalChild("SIGKILL");
forceSettleTimer = setTimeout(() => {
forceSettleTimer = undefined;
const stillRunning = isProcessGroupRunning();
const failureMessage =
result.failureMessage ??
(stillRunning ? `${commandLabel()} left background processes running` : undefined);
finish({
exitCode: stillRunning ? 1 : result.exitCode,
signal: result.signal,
...(failureMessage ? { failureMessage } : {}),
});
}, QA_TEST_FILE_COMMAND_TIMEOUT_FORCE_SETTLE_MS);
}, QA_TEST_FILE_COMMAND_TIMEOUT_KILL_GRACE_MS);
};
timeoutTimer =
timeoutMs === undefined
? undefined
: setTimeout(() => {
timeoutTimer = undefined;
timedOut = true;
signalChild("SIGTERM");
scheduleForcedCleanup({
exitCode: 1,
failureMessage: `${commandLabel()} timed out after ${timeoutMs}ms`,
signal: null,
});
}, timeoutMs);
child.stdout?.on("data", (chunk: Buffer) => {
stdout.push(chunk);
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr.push(chunk);
});
child.on("error", reject);
process.once("exit", handleParentExit);
for (const signal of QA_TEST_FILE_COMMAND_PARENT_SIGNALS) {
process.once(signal, handleParentSignal);
}
child.on("error", (error) => {
clearTimers();
cleanupParentHandlers();
reject(error);
});
child.on("close", (exitCode, signal) => {
resolve({
exitCode: exitCode ?? (signal ? 1 : 0),
if (!timedOut && timeoutTimer) {
clearTimeout(timeoutTimer);
timeoutTimer = undefined;
}
const result = {
exitCode: timedOut ? 1 : (exitCode ?? (signal ? 1 : 0)),
signal,
stdout: Buffer.concat(stdout).toString("utf8"),
stderr: Buffer.concat(stderr).toString("utf8"),
});
...(timedOut ? { failureMessage: `${commandLabel()} timed out after ${timeoutMs}ms` } : {}),
};
if (timedOut && !useProcessGroup && (forceKillTimer || forceSettleTimer)) {
return;
}
if (isProcessGroupRunning()) {
if (!timedOut) {
signalChild("SIGTERM");
}
scheduleForcedCleanup(result);
return;
}
finish(result);
});
});
}
@@ -219,6 +400,7 @@ function buildScenarioEvidenceTarget(scenario: QaTestFileScenario) {
}
async function runScenarioCommandSteps(params: {
commandTimeoutMs: number;
env: NodeJS.ProcessEnv;
outputDir: string;
repoRoot: string;
@@ -233,11 +415,14 @@ async function runScenarioCommandSteps(params: {
for (const step of params.steps) {
logChunks.push(`$ ${formatCommand(step)}\n`);
try {
const timeoutMs =
params.scenario.execution.kind === "script" ? params.commandTimeoutMs : undefined;
const result = await params.runCommand({
command: step.command,
args: step.args,
cwd: params.repoRoot,
env: params.env,
...(timeoutMs === undefined ? {} : { timeoutMs }),
});
if (result.stdout) {
logChunks.push(result.stdout);
@@ -245,10 +430,12 @@ async function runScenarioCommandSteps(params: {
if (result.stderr) {
logChunks.push(result.stderr);
}
if (result.exitCode !== 0 || result.signal) {
failureMessage = result.signal
? `${path.basename(step.command)} terminated by ${result.signal}`
: `${path.basename(step.command)} exited with ${result.exitCode}`;
if (result.failureMessage || result.exitCode !== 0 || result.signal) {
failureMessage =
result.failureMessage ??
(result.signal
? `${path.basename(step.command)} terminated by ${result.signal}`
: `${path.basename(step.command)} exited with ${result.exitCode}`);
break;
}
} catch (error) {
@@ -271,6 +458,7 @@ async function runScenarioCommandSteps(params: {
async function runQaTestFileScenario(params: {
env: NodeJS.ProcessEnv;
commandTimeoutMs: number;
outputDir: string;
repoRoot: string;
runCommand: QaScenarioCommandRunner;
@@ -556,6 +744,10 @@ export async function runQaTestFileScenarios(
}
await fs.mkdir(params.outputDir, { recursive: true });
const runCommand = params.runCommand ?? runQaScenarioCommand;
const commandTimeoutMs = resolvePositiveTimerTimeoutMs(
params.commandTimeoutMs,
DEFAULT_QA_TEST_FILE_COMMAND_TIMEOUT_MS,
);
const env = {
...process.env,
...params.env,
@@ -565,6 +757,7 @@ export async function runQaTestFileScenarios(
results.push(
await runQaTestFileScenario({
env,
commandTimeoutMs,
outputDir: params.outputDir,
repoRoot: params.repoRoot,
runCommand,

View File

@@ -1,5 +1,6 @@
// Qa Matrix plugin module implements cli paths behavior.
import path from "node:path";
import { assertNoSymlinkParents, pathScope } from "openclaw/plugin-sdk/security-runtime";
export function resolveRepoRelativeOutputDir(repoRoot: string, outputDir?: string) {
if (!outputDir) {
@@ -15,3 +16,33 @@ export function resolveRepoRelativeOutputDir(repoRoot: string, outputDir?: strin
}
return resolved;
}
function assertRepoRelativePath(repoRoot: string, targetPath: string, label: string) {
const relative = path.relative(repoRoot, targetPath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`${label} must stay within the repo root.`);
}
}
export async function ensureRepoBoundDirectory(repoRoot: string, targetDir: string, label: string) {
const repoRootResolved = path.resolve(repoRoot);
const targetResolved = path.resolve(targetDir);
assertRepoRelativePath(repoRootResolved, targetResolved, label);
try {
await assertNoSymlinkParents({
rootDir: repoRootResolved,
targetPath: targetResolved,
messagePrefix: label,
});
} catch (error) {
if (error instanceof Error && error.message.includes("symlink")) {
throw new Error(`${label} must not traverse symlinks.`, { cause: error });
}
throw error;
}
const result = await pathScope(repoRootResolved, { label }).ensureDir(targetResolved);
if (!result.ok) {
throw new Error(`${label} must stay within the repo root.`);
}
return result.path;
}

View File

@@ -1,5 +1,5 @@
// Qa Matrix tests cover cli plugin behavior.
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, symlink } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -123,6 +123,28 @@ describe("matrix qa cli runtime", () => {
await expectPathMissing(outputPath);
});
it.runIf(process.platform !== "win32")(
"rejects output dirs that traverse repo-local symlinks",
async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-cli-"));
const externalOutputRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-external-"));
tmpDirs.push(repoRoot, externalOutputRoot);
await mkdir(path.join(repoRoot, ".artifacts"), { recursive: true });
await symlink(externalOutputRoot, path.join(repoRoot, ".artifacts", "qa-e2e"));
await expect(
runQaMatrixCommand({
repoRoot,
outputDir: ".artifacts/qa-e2e/matrix",
providerMode: "mock-openai",
credentialSource: "env",
}),
).rejects.toThrow("Matrix QA output dir must not traverse symlinks.");
expect(runMatrixQaLive).not.toHaveBeenCalled();
},
);
it("preserves the Matrix QA failure when output log cleanup also fails", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-cli-"));
tmpDirs.push(repoRoot);

View File

@@ -3,6 +3,7 @@ import {
printLiveTransportQaArtifacts,
startLiveTransportQaOutputTee,
} from "openclaw/plugin-sdk/qa-runtime";
import { ensureRepoBoundDirectory } from "./cli-paths.js";
import { runMatrixQaLive } from "./runners/contract/runtime.js";
import type { LiveTransportQaCommandOptions } from "./shared/live-transport-cli.js";
import { resolveLiveTransportQaRunOptions } from "./shared/live-transport-cli.runtime.js";
@@ -57,12 +58,18 @@ export async function runQaMatrixCommand(opts: LiveTransportQaCommandOptions) {
);
}
const outputTee = await createMatrixQaCommandOutputTee(runOptions.outputDir);
const outputDir = await ensureRepoBoundDirectory(
runOptions.repoRoot,
runOptions.outputDir,
"Matrix QA output dir",
);
const checkedRunOptions = { ...runOptions, outputDir };
const outputTee = await createMatrixQaCommandOutputTee(checkedRunOptions.outputDir);
let primaryError: unknown;
let outputTeeError: unknown;
try {
process.stdout.write(`Matrix QA output: ${outputTee.outputPath}\n`);
const result = await runMatrixQaLive(runOptions);
const result = await runMatrixQaLive(checkedRunOptions);
printLiveTransportQaArtifacts("Matrix QA", {
report: result.reportPath,
summary: result.summaryPath,

View File

@@ -378,4 +378,57 @@ describe("Matrix QA CLI runtime", () => {
await rm(root, { force: true, recursive: true });
}
});
it("kills ignored-stdio descendants after manual CLI session kill", async () => {
if (process.platform === "win32") {
return;
}
const root = await mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-session-kill-ignored-stdio-"),
);
const childPidPath = path.join(root, "child.pid");
const grandchildPidPath = path.join(root, "grandchild.pid");
let childPid: number | undefined;
let grandchildPid: number | undefined;
try {
await mkdir(path.join(root, "dist"));
await writeFile(
path.join(root, "dist", "index.mjs"),
[
"import { spawn } from 'node:child_process';",
"import { writeFileSync } from 'node:fs';",
`writeFileSync(${JSON.stringify(childPidPath)}, String(process.pid));`,
"const grandchild = spawn(process.execPath, ['-e', 'process.on(\\'SIGTERM\\', () => {}); setInterval(() => {}, 1000);'], { stdio: 'ignore' });",
"grandchild.unref();",
`writeFileSync(${JSON.stringify(grandchildPidPath)}, String(grandchild.pid));`,
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
].join("\n"),
);
const session = startMatrixQaOpenClawCli({
args: ["matrix", "verify", "self"],
cwd: root,
env: process.env,
timeoutMs: 10_000,
});
await waitForFile(grandchildPidPath, 2_000);
await sleep(300);
session.kill();
await sleep(500);
childPid = Number(await readFile(childPidPath, "utf8"));
grandchildPid = Number(await readFile(grandchildPidPath, "utf8"));
expect(isProcessRunning(childPid)).toBe(false);
expect(isProcessRunning(grandchildPid)).toBe(false);
} finally {
for (const pid of [grandchildPid, childPid]) {
if (pid && isProcessRunning(pid)) {
process.kill(pid, "SIGKILL");
}
}
await rm(root, { force: true, recursive: true });
}
});
});

View File

@@ -148,6 +148,7 @@ export function startMatrixQaOpenClawCli(params: {
let closed = false;
let closeError: Error | undefined;
let closeResult: MatrixQaCliRunResult | undefined;
let killRequested = false;
let timedOut = false;
let forceKillTimeout: NodeJS.Timeout | undefined;
let forceSettleTimeout: NodeJS.Timeout | undefined;
@@ -187,6 +188,13 @@ export function startMatrixQaOpenClawCli(params: {
const finishTimeout = (result: MatrixQaCliRunResult) => {
finish(result, new Error(formatMatrixQaCliTimeoutError(result, params.timeoutMs)));
};
const finishResult = (result: MatrixQaCliRunResult) => {
if (result.exitCode !== 0 && params.allowNonZero !== true) {
finish(result, new Error(formatMatrixQaCliExitError(result)));
return;
}
finish(result);
};
const clearForcedTimeouts = () => {
if (forceKillTimeout) {
clearTimeout(forceKillTimeout);
@@ -197,16 +205,23 @@ export function startMatrixQaOpenClawCli(params: {
forceSettleTimeout = undefined;
}
};
const timeout = setTimeout(() => {
timedOut = true;
killMatrixQaCliChild(child, "SIGTERM");
const finishForcedCleanup = (result: MatrixQaCliRunResult) => {
if (timedOut) {
finishTimeout(result);
return;
}
finishResult(result);
};
const scheduleForcedCleanup = () => {
if (forceKillTimeout || forceSettleTimeout) {
return;
}
forceKillTimeout = setTimeout(() => {
forceKillTimeout = undefined;
killMatrixQaCliChild(child, "SIGKILL");
forceSettleTimeout = setTimeout(() => {
forceSettleTimeout = undefined;
finishTimeout(
finishForcedCleanup(
buildMatrixQaCliResult({
args: params.args,
exitCode: 1,
@@ -215,6 +230,12 @@ export function startMatrixQaOpenClawCli(params: {
);
}, MATRIX_QA_CLI_TIMEOUT_FORCE_SETTLE_MS);
}, MATRIX_QA_CLI_TIMEOUT_KILL_GRACE_MS);
};
const timeout = setTimeout(() => {
timedOut = true;
killMatrixQaCliChild(child, "SIGTERM");
scheduleForcedCleanup();
}, params.timeoutMs);
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
@@ -241,20 +262,17 @@ export function startMatrixQaOpenClawCli(params: {
exitCode: exitCode ?? 1,
output: readOutput(),
});
if (timedOut) {
if (timedOut || killRequested) {
// A closed parent is not proof that detached, ignored-stdio descendants are gone.
if (isMatrixQaCliChildProcessGroupRunning(child)) {
return;
}
clearForcedTimeouts();
finishTimeout(result);
finishForcedCleanup(result);
return;
}
clearForcedTimeouts();
if (result.exitCode !== 0 && params.allowNonZero !== true) {
finish(result, new Error(formatMatrixQaCliExitError(result)));
return;
}
finish(result);
finishResult(result);
});
return {
@@ -310,7 +328,10 @@ export function startMatrixQaOpenClawCli(params: {
},
kill: () => {
if (!closed) {
clearTimeout(timeout);
killRequested = true;
killMatrixQaCliChild(child, "SIGTERM");
scheduleForcedCleanup();
}
},
};

View File

@@ -1,16 +1,45 @@
// Telegram tests cover topic name cache plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearTopicNameCache,
getTopicEntry,
getTopicName,
resetTopicNameCacheForTest,
setTelegramTopicNameStoreFactoryForTest,
topicNameCacheSize,
updateTopicName,
} from "./topic-name-cache.js";
type TopicEntry = NonNullable<Awaited<ReturnType<typeof getTopicEntry>>>;
type TopicEntry = {
name: string;
iconColor?: number;
iconCustomEmojiId?: string;
closed?: boolean;
updatedAt: number;
};
function topicKey(chatId: number | string, threadId: number | string): string {
return `${chatId}:${threadId}`;
}
function getStoredTopicEntry(
stores: Map<string, Map<string, TopicEntry>>,
chatId: number | string,
threadId: number | string,
): TopicEntry | undefined {
const key = topicKey(chatId, threadId);
for (const entries of stores.values()) {
const entry = entries.get(key);
if (entry) {
return entry;
}
}
return undefined;
}
function topicStoreSize(stores: Map<string, Map<string, TopicEntry>>): number {
return Array.from(stores.values(), (entries) => entries.size).reduce(
(total, size) => total + size,
0,
);
}
function installMemoryStores() {
const stores = new Map<string, Map<string, TopicEntry>>();
@@ -36,10 +65,11 @@ function installMemoryStores() {
}
describe("topic-name-cache", () => {
let stores: Map<string, Map<string, TopicEntry>>;
beforeEach(async () => {
vi.useRealTimers();
installMemoryStores();
await clearTopicNameCache();
stores = installMemoryStores();
resetTopicNameCacheForTest();
});
@@ -67,13 +97,13 @@ describe("topic-name-cache", () => {
await updateTopicName(-100123, 42, { name: "Deployments" });
await updateTopicName(-100123, 42, { closed: true });
await expect(getTopicName(-100123, 42)).resolves.toBe("Deployments");
expect((await getTopicEntry(-100123, 42))?.closed).toBe(true);
expect(getStoredTopicEntry(stores, -100123, 42)?.closed).toBe(true);
});
it("marks topic as reopened", async () => {
await updateTopicName(-100123, 42, { name: "Deployments", closed: true });
await updateTopicName(-100123, 42, { closed: false });
expect((await getTopicEntry(-100123, 42))?.closed).toBe(false);
expect(getStoredTopicEntry(stores, -100123, 42)?.closed).toBe(false);
});
it("stores icon metadata", async () => {
@@ -82,7 +112,7 @@ describe("topic-name-cache", () => {
iconColor: 0x6fb9f0,
iconCustomEmojiId: "emoji123",
});
const entry = await getTopicEntry(-100123, 42);
const entry = getStoredTopicEntry(stores, -100123, 42);
expect(entry?.iconColor).toBe(0x6fb9f0);
expect(entry?.iconCustomEmojiId).toBe("emoji123");
});
@@ -90,16 +120,16 @@ describe("topic-name-cache", () => {
it("does not store entries with empty name and no prior entry", async () => {
await updateTopicName(-100123, 42, { closed: true });
await expect(getTopicName(-100123, 42)).resolves.toBeUndefined();
expect(topicNameCacheSize()).toBe(0);
expect(topicStoreSize(stores)).toBe(0);
});
it("updates timestamps on write", async () => {
vi.useFakeTimers();
await updateTopicName(-100123, 42, { name: "A" });
const t1 = (await getTopicEntry(-100123, 42))?.updatedAt ?? 0;
const t1 = getStoredTopicEntry(stores, -100123, 42)?.updatedAt ?? 0;
await vi.advanceTimersByTimeAsync(10);
await updateTopicName(-100123, 42, { name: "B" });
const t2 = (await getTopicEntry(-100123, 42))?.updatedAt ?? 0;
const t2 = getStoredTopicEntry(stores, -100123, 42)?.updatedAt ?? 0;
expect(t2).toBeGreaterThan(t1);
});
@@ -112,7 +142,7 @@ describe("topic-name-cache", () => {
for (let i = 0; i < 2049; i++) {
await updateTopicName(-100000, i, { name: `Topic ${i}` });
}
expect(topicNameCacheSize()).toBe(2048);
expect(topicStoreSize(stores)).toBe(2048);
await expect(getTopicName(-100000, 0)).resolves.toBeUndefined();
await expect(getTopicName(-100000, 2048)).resolves.toBe("Topic 2048");
});
@@ -127,7 +157,7 @@ describe("topic-name-cache", () => {
await getTopicName(-100000, 1);
await updateTopicName(-100000, 9999, { name: "Newcomer" });
await expect(getTopicName(-100000, 1)).resolves.toBe("Active");
expect(topicNameCacheSize()).toBe(2048);
expect(topicStoreSize(stores)).toBe(2048);
});
it("reloads persisted entries from plugin state", async () => {

View File

@@ -163,12 +163,6 @@ async function hydrateTopicStoreState(state: TopicNameStoreState): Promise<void>
await state.hydratePromise;
}
async function getTopicStore(scope?: string): Promise<TopicNameStore> {
const state = getTopicStoreState(scope);
await hydrateTopicStoreState(state);
return state.store;
}
function nextUpdatedAt(scope?: string): number {
const state = getTopicStoreState(scope);
const now = Date.now();
@@ -223,14 +217,6 @@ export async function getTopicName(
return entry?.name;
}
export async function getTopicEntry(
chatId: number | string,
threadId: number | string,
scope?: string,
): Promise<TopicEntry | undefined> {
return (await getTopicStore(scope)).get(cacheKey(chatId, threadId));
}
export async function listTelegramLegacyTopicNameCacheEntries(params: {
persistedPath: string;
maxEntries?: number;
@@ -246,18 +232,6 @@ export async function listTelegramLegacyTopicNameCacheEntries(params: {
.map(([key, entry]) => ({ key, value: entry }));
}
export async function clearTopicNameCache(): Promise<void> {
const state = getTopicNameCacheState();
await Promise.all(
[...state.stores.values()].map((storeState) => storeState.persistentStore.clear()),
);
state.stores.clear();
}
export function topicNameCacheSize(scope?: string): number {
return getTopicStoreState(scope).store.size;
}
export function resetTopicNameCacheForTest(): void {
getTopicNameCacheState().stores.clear();
}

View File

@@ -1,10 +1,10 @@
// Zalouser tests cover monitor.account scope plugin behavior.
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
import "./monitor.send-mocks.js";
import "./monitor.send.test-mocks.js";
import { testing } from "./monitor.js";
import "./zalo-js.test-mocks.js";
import { sendMessageZalouserMock } from "./monitor.send-mocks.js";
import { sendMessageZalouserMock } from "./monitor.send.test-mocks.js";
import { setZalouserRuntime } from "./runtime.js";
import { createZalouserRuntimeEnv } from "./test-helpers.js";
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";

View File

@@ -2,7 +2,7 @@
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
import "./monitor.send-mocks.js";
import "./monitor.send.test-mocks.js";
import "./zalo-js.test-mocks.js";
import { resolveZalouserAccountSync } from "./accounts.js";
import { testing, monitorZalouserProvider } from "./monitor.js";
@@ -11,7 +11,7 @@ import {
sendMessageZalouserMock,
sendSeenZalouserMock,
sendTypingZalouserMock,
} from "./monitor.send-mocks.js";
} from "./monitor.send.test-mocks.js";
import { setZalouserRuntime } from "./runtime.js";
import { createZalouserRuntimeEnv } from "./test-helpers.js";
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";

View File

@@ -975,6 +975,10 @@
"types": "./dist/plugin-sdk/session-store-runtime.d.ts",
"default": "./dist/plugin-sdk/session-store-runtime.js"
},
"./plugin-sdk/session-transcript-runtime": {
"types": "./dist/plugin-sdk/session-transcript-runtime.d.ts",
"default": "./dist/plugin-sdk/session-transcript-runtime.js"
},
"./plugin-sdk/sqlite-runtime": {
"types": "./dist/plugin-sdk/sqlite-runtime.d.ts",
"default": "./dist/plugin-sdk/sqlite-runtime.js"

View File

@@ -1,11 +1,12 @@
// OpenClaw SDK tests cover package behavior.
import { spawn } from "node:child_process";
import { spawn, type SpawnOptionsWithoutStdio } from "node:child_process";
import { createReadStream } from "node:fs";
import fs from "node:fs/promises";
import { createServer, type Server } from "node:http";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createPnpmRunnerSpawnSpec } from "../../../scripts/pnpm-runner.mjs";
import { createNodeEvalArgs } from "../../../src/test-utils/node-process.js";
type CommandResult = {
@@ -36,24 +37,24 @@ type PackedPackage = {
function runCommand(
command: string,
args: string[],
options: { cwd: string; timeoutMs?: number },
options: { cwd: string; timeoutMs?: number } & Pick<
SpawnOptionsWithoutStdio,
"env" | "shell" | "windowsVerbatimArguments"
>,
): Promise<CommandResult> {
return new Promise((resolve, reject) => {
const stdout: string[] = [];
const stderr: string[] = [];
const child = spawn(command, args, {
cwd: options.cwd,
env: {
...process.env,
CI: process.env.CI ?? "true",
npm_config_audit: "false",
npm_config_fund: "false",
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false",
},
detached: process.platform !== "win32",
env: options.env ?? createCommandEnv(),
shell: options.shell,
stdio: ["ignore", "pipe", "pipe"],
windowsVerbatimArguments: options.windowsVerbatimArguments,
});
const timer = setTimeout(() => {
child.kill("SIGKILL");
signalCommandProcess(child, "SIGKILL");
reject(
new Error(
`command timed out after ${options.timeoutMs ?? COMMAND_TIMEOUT_MS}ms: ${[
@@ -88,6 +89,50 @@ function runCommand(
});
}
function signalCommandProcess(child: ReturnType<typeof spawn>, signal: NodeJS.Signals): void {
if (process.platform !== "win32" && typeof child.pid === "number") {
try {
process.kill(-child.pid, signal);
return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ESRCH") {
return;
}
}
}
child.kill(signal);
}
function createCommandEnv(): NodeJS.ProcessEnv {
return {
...process.env,
CI: process.env.CI ?? "true",
npm_config_audit: "false",
npm_config_fund: "false",
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false",
};
}
function runPnpmCommand(
args: string[],
options: { cwd: string; timeoutMs?: number },
): Promise<CommandResult> {
const spec = createPnpmRunnerSpawnSpec({
cwd: options.cwd,
env: createCommandEnv(),
pnpmArgs: args,
stdio: ["ignore", "pipe", "pipe"],
});
const cwd = typeof spec.options.cwd === "string" ? spec.options.cwd : options.cwd;
return runCommand(spec.command, spec.args, {
cwd,
env: spec.options.env,
shell: spec.options.shell,
timeoutMs: options.timeoutMs,
windowsVerbatimArguments: spec.options.windowsVerbatimArguments,
});
}
function normalizeWorkspaceDependencies(
dependencies: Record<string, string> | undefined,
): Record<string, string> | undefined {
@@ -226,7 +271,7 @@ describe("OpenClaw SDK package e2e", () => {
tempDirs.push(tempDir);
for (const packageName of WORKSPACE_PACKAGE_NAMES) {
await runCommand("pnpm", ["--filter", packageName, "build"], {
await runPnpmCommand(["--filter", packageName, "build"], {
cwd: repoRoot,
timeoutMs: 180_000,
});

View File

@@ -0,0 +1,29 @@
title: OpenAI-compatible chat tools HTTP API
scenario:
id: openai-compatible-chat-tools
surface: runtime
coverage:
primary:
- gateway.openai-compatible-apis
secondary:
- runtime.hosted-tool-use
objective: Verify the OpenAI-compatible chat-completions client and Docker lane preserve strict tool-call API behavior.
successCriteria:
- The Docker lane fails missing or placeholder OpenAI auth before Docker build work starts.
- The generated config preserves strict positive gateway port and timeout values.
- The chat-completions client posts to `/v1/chat/completions` with the expected gateway token and model header.
- Tool-call-only responses are accepted, visible content beside a tool call is rejected, and response bodies remain bounded.
docsRefs:
- docs/gateway/protocol.md
- docs/help/testing.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- scripts/e2e/lib/openai-chat-tools/client.mjs
- scripts/e2e/lib/openai-chat-tools/write-config.mjs
- scripts/e2e/openai-chat-tools-docker.sh
- test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts
summary: Vitest coverage for OpenAI-compatible chat-completions tool-call API behavior.

View File

@@ -0,0 +1,29 @@
title: OpenAI web_search minimal reasoning gate
scenario:
id: openai-web-search-minimal
surface: model-provider
coverage:
primary:
- runtime.reasoning-and-cache-controls
secondary:
- web-search.openai-native-web-search
- tools.web-search
objective: Verify the OpenAI web_search minimal-reasoning E2E client distinguishes successful grounded turns from provider schema rejection.
successCriteria:
- Reject mode accepts the expected raw OpenAI schema rejection and the gateway schema wrapper.
- Reject mode fails if the agent run unexpectedly succeeds or fails for unrelated transport reasons.
- Success mode requires an `ok` agent result with the expected marker in visible reply payloads.
- Gateway ports are parsed strictly before connecting.
docsRefs:
- docs/tools/web.md
- docs/help/testing.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- scripts/e2e/lib/openai-web-search-minimal/client.mjs
- scripts/e2e/openai-web-search-minimal-docker.sh
- test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts
summary: Vitest coverage for OpenAI web_search minimal-reasoning success and rejection validation.

View File

@@ -0,0 +1,30 @@
title: OpenAI native web_search request assertions
scenario:
id: openai-web-search-native-assertions
surface: model-provider
coverage:
primary:
- web-search.openai-native-web-search
- plugins.web-search-and-fetch
secondary:
- web-search.model-and-filter-routing
- tools.web-search
objective: Verify the OpenAI web_search Docker lane assertions require native Responses web_search evidence with bounded diagnostics.
successCriteria:
- A successful request must hit `/v1/responses` with native `web_search` and non-minimal reasoning.
- Large request logs are scanned without missing later success requests.
- Failure diagnostics are bounded and do not dump stale or oversized request bodies.
- Function-shaped `web_search` is rejected as native Responses proof.
docsRefs:
- docs/tools/web.md
- docs/help/testing.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- scripts/e2e/lib/openai-web-search-minimal/assertions.mjs
- scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs
- test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts
summary: Vitest coverage for native OpenAI web_search request-log assertions.

View File

@@ -0,0 +1,28 @@
title: OpenWebUI OpenAI-compatible API probe
scenario:
id: openwebui-openai-compatible
surface: runtime
coverage:
primary:
- gateway.openai-compatible-apis
secondary:
- runtime.hosted-provider-turns
- runtime.provider-specific-model-options
objective: Verify the OpenWebUI E2E probe exercises OpenClaw through OpenWebUI's OpenAI-compatible model and chat APIs.
successCriteria:
- Probe environment limits are parsed strictly and control-plane requests time out quickly.
- Sign-in and model-list error bodies are bounded before diagnostics are emitted.
- Models mode authenticates and finds the OpenClaw model exposed by OpenWebUI.
- Chat mode posts to `/api/chat/completions`, validates the expected nonce, and fails when the reply omits it.
docsRefs:
- docs/help/testing.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- scripts/e2e/openwebui-probe.mjs
- scripts/e2e/openwebui-docker.sh
- test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts
summary: Vitest coverage for OpenWebUI model and chat-completions probe behavior.

View File

@@ -61,6 +61,11 @@ const GATEWAY_TIMEOUT_MS = parseStrictIntegerOption({
min: 1,
raw: process.env.OPENCLAW_PROMPT_GATEWAY_TIMEOUT_MS,
});
const GATEWAY_PARENT_SIGNAL_EXIT_CODES = new Map<NodeJS.Signals, number>([
["SIGHUP", 129],
["SIGINT", 130],
["SIGTERM", 143],
]);
const CAPTURE_PROXY_MAX_BODY_BYTES = parseStrictIntegerOption({
fallback: 2 * 1024 * 1024,
label: "OPENCLAW_PROMPT_CAPTURE_MAX_BODY_BYTES",
@@ -121,6 +126,7 @@ type TokenSource = {
type StoppableGatewayChild = {
exitCode: number | null;
pid?: number;
signalCode: NodeJS.Signals | null;
kill(signal: NodeJS.Signals): boolean;
once(event: "exit", listener: () => void): unknown;
@@ -468,21 +474,45 @@ async function runDirectPrompt(prompt: string): Promise<PromptResult> {
ANTHROPIC_API_KEY: "",
ANTHROPIC_API_KEY_OLD: "",
},
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", (chunk) => stdout.push(String(chunk)));
child.stderr.on("data", (chunk) => stderr.push(String(chunk)));
const exit = await withTimeout(
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
const exitPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
(resolve, reject) => {
child.once("error", reject);
child.once("exit", (code, signal) => resolve({ code, signal }));
}),
TIMEOUT_MS,
() => {
child.kill("SIGKILL");
return { code: null, signal: "SIGKILL" as NodeJS.Signals };
},
);
const stopDirectChild = async (signal: NodeJS.Signals = "SIGKILL") => {
signalGatewayPromptChildTree(child, signal);
await waitForGatewayPromptChildTreeExit(
child,
exitPromise.then(() => undefined),
1_500,
);
};
const removeParentSignalHandlers = installGatewayPromptParentSignalHandlers(
child,
stopDirectChild,
);
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
const exit = await Promise.race([
exitPromise,
new Promise<{ code: null; signal: NodeJS.Signals }>((resolve) => {
timeoutTimer = setTimeout(() => {
void stopDirectChild("SIGKILL").finally(() => {
resolve({ code: null, signal: "SIGKILL" });
});
}, TIMEOUT_MS);
}),
]).finally(() => {
if (timeoutTimer) {
clearTimeout(timeoutTimer);
}
removeParentSignalHandlers();
});
const joinedStdout = stdout.join("");
const joinedStderr = stderr.join("");
return {
@@ -535,6 +565,7 @@ async function startGatewayProcess(params: {
ANTHROPIC_API_KEY: "",
ANTHROPIC_API_KEY_OLD: "",
},
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
},
);
@@ -554,16 +585,25 @@ async function startGatewayProcess(params: {
};
child.stdout.on("data", trackLogWrite);
child.stderr.on("data", trackLogWrite);
let stopPromise: Promise<boolean> | undefined;
let removeParentSignalHandlers = () => {};
const stopOnce = async (): Promise<boolean> => {
stopPromise ??= stopGatewayPromptChild(
child,
logFile,
1_500,
1_500,
pendingLogWrites,
logWriteErrors,
).finally(() => {
removeParentSignalHandlers();
});
return await stopPromise;
};
removeParentSignalHandlers = installGatewayPromptParentSignalHandlers(child, stopOnce);
return {
async stop(): Promise<boolean> {
return await stopGatewayPromptChild(
child,
logFile,
1_500,
1_500,
pendingLogWrites,
logWriteErrors,
);
return await stopOnce();
},
};
}
@@ -586,20 +626,16 @@ async function stopGatewayPromptChild(
});
});
if (!exited) {
child.kill("SIGINT");
signalGatewayPromptChildTree(child, "SIGINT");
}
const exitedAfterSigint = await withTimeout(
exitPromise.then(() => true),
const exitedAfterSigint = await waitForGatewayPromptChildTreeExit(
child,
exitPromise,
sigintTimeoutMs,
() => false,
);
if (!exitedAfterSigint && !exited) {
child.kill("SIGKILL");
await withTimeout(
exitPromise.then(() => true),
sigkillTimeoutMs,
() => false,
);
if (!exitedAfterSigint) {
signalGatewayPromptChildTree(child, "SIGKILL");
await waitForGatewayPromptChildTreeExit(child, exitPromise, sigkillTimeoutMs);
}
const failedLogWrite = (await Promise.allSettled(pendingLogWrites)).find(
(result): result is PromiseRejectedResult => result.status === "rejected",
@@ -612,6 +648,93 @@ async function stopGatewayPromptChild(
return exited;
}
function installGatewayPromptParentSignalHandlers(
child: StoppableGatewayChild,
stopGateway: () => Promise<unknown>,
): () => void {
let parentSignalShutdownStarted = false;
const handlers = new Map<NodeJS.Signals, () => void>();
const removeHandlers = () => {
for (const [signal, handler] of handlers) {
process.off(signal, handler);
}
handlers.clear();
};
for (const signal of GATEWAY_PARENT_SIGNAL_EXIT_CODES.keys()) {
const handler = () => {
if (parentSignalShutdownStarted) {
signalGatewayPromptChildTree(child, "SIGKILL");
return;
}
parentSignalShutdownStarted = true;
void stopGateway()
.catch(() => undefined)
.finally(() => {
removeHandlers();
process.exit(GATEWAY_PARENT_SIGNAL_EXIT_CODES.get(signal) ?? 1);
});
};
handlers.set(signal, handler);
process.on(signal, handler);
}
return removeHandlers;
}
async function waitForGatewayPromptChildTreeExit(
child: StoppableGatewayChild,
exitPromise: Promise<void>,
timeoutMs: number,
): Promise<boolean> {
let leaderExited = child.exitCode !== null || child.signalCode !== null;
const trackedExit = exitPromise.then(() => {
leaderExited = true;
});
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (leaderExited && !gatewayPromptChildTreeIsAlive(child)) {
return true;
}
const waitMs = Math.min(50, Math.max(0, deadline - Date.now()));
if (leaderExited) {
await sleep(waitMs);
} else {
await Promise.race([trackedExit, sleep(waitMs)]);
}
}
return leaderExited && !gatewayPromptChildTreeIsAlive(child);
}
function signalGatewayPromptChildTree(
child: StoppableGatewayChild,
signal: NodeJS.Signals,
): boolean {
if (process.platform !== "win32" && typeof child.pid === "number") {
try {
process.kill(-child.pid, signal);
return true;
} catch {
return child.kill(signal);
}
}
return child.kill(signal);
}
function gatewayPromptChildTreeIsAlive(child: StoppableGatewayChild): boolean {
if (process.platform === "win32" || typeof child.pid !== "number") {
return false;
}
try {
process.kill(-child.pid, 0);
return true;
} catch (error) {
return !isMissingProcessError(error);
}
}
function isMissingProcessError(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH";
}
async function waitForGatewayReady(url: string, token: string): Promise<void> {
const deadline = Date.now() + 45_000;
let lastError = "gateway start timeout";
@@ -834,6 +957,7 @@ async function main() {
export const testing = {
cleanupPromptProbeTmpDir,
installGatewayPromptParentSignalHandlers,
matchesExtraUsage400,
promptProbeTmpResult,
readLogTail,

View File

@@ -105,6 +105,8 @@ type CliOptions = {
const DEFAULT_RUNS = 5;
const DEFAULT_WARMUP = 1;
const DEFAULT_TIMEOUT_MS = 30_000;
const TIMEOUT_KILL_GRACE_MS = 1_000;
const PROCESS_GROUP_EXIT_POLL_MS = 25;
const DEFAULT_ENTRY = "openclaw.mjs";
const MAX_RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__=";
const VALUE_FLAGS = new Set([
@@ -708,12 +710,16 @@ async function runSample(params: {
let stderr = "";
let settled = false;
let timedOut = false;
let forceKillAt: number | null = null;
let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
const maxOutputLength = 32 * 1024 * 1024;
try {
return await new Promise<Sample>((resolve) => {
const useProcessGroup = process.platform !== "win32";
const proc = spawn(process.execPath, nodeArgs, {
cwd: process.cwd(),
detached: useProcessGroup,
env: {
...process.env,
HOME: runRoot,
@@ -733,6 +739,10 @@ async function runSample(params: {
return;
}
settled = true;
if (forceKillTimer) {
clearTimeout(forceKillTimer);
forceKillTimer = null;
}
const ms = Number(process.hrtime.bigint() - started) / 1e6;
resolve({
ms,
@@ -751,18 +761,11 @@ async function runSample(params: {
const timeout = setTimeout(() => {
timedOut = true;
try {
proc.kill("SIGTERM");
} catch {
// Best-effort timeout cleanup.
}
setTimeout(() => {
try {
proc.kill("SIGKILL");
} catch {
// Best-effort timeout cleanup.
}
}, 1_000).unref?.();
signalSampleProcess(proc, "SIGTERM", useProcessGroup);
forceKillAt = Date.now() + TIMEOUT_KILL_GRACE_MS;
forceKillTimer = setTimeout(() => {
signalSampleProcess(proc, "SIGKILL", useProcessGroup);
}, TIMEOUT_KILL_GRACE_MS).unref?.();
}, params.timeoutMs);
timeout.unref?.();
@@ -790,16 +793,27 @@ async function runSample(params: {
});
proc.once("close", (code, signal) => {
clearTimeout(timeout);
finish({
exitCode: code,
signal,
...(code === 0 && signal == null
? {}
: {
stdoutTail: tailLines(stdout, 20),
stderrTail: tailLines(stderr, 20),
}),
});
const complete = () =>
finish({
exitCode: code,
signal,
...(code === 0 && signal == null
? {}
: {
stdoutTail: tailLines(stdout, 20),
stderrTail: tailLines(stderr, 20),
}),
});
if (timedOut && isSampleProcessGroupAlive(proc, useProcessGroup)) {
void finishAfterTimeoutCleanup({
complete,
forceKillAt,
proc,
useProcessGroup,
});
return;
}
complete();
});
});
} finally {
@@ -807,6 +821,80 @@ async function runSample(params: {
}
}
async function finishAfterTimeoutCleanup(params: {
complete: () => void;
forceKillAt: number | null;
proc: ReturnType<typeof spawn>;
useProcessGroup: boolean;
}): Promise<void> {
const graceRemainingMs =
params.forceKillAt === null
? TIMEOUT_KILL_GRACE_MS
: Math.max(0, params.forceKillAt - Date.now());
if (graceRemainingMs > 0) {
await waitForSampleProcessGroupExit(params.proc, params.useProcessGroup, graceRemainingMs);
}
if (isSampleProcessGroupAlive(params.proc, params.useProcessGroup)) {
signalSampleProcess(params.proc, "SIGKILL", params.useProcessGroup);
}
await waitForSampleProcessGroupExit(params.proc, params.useProcessGroup, TIMEOUT_KILL_GRACE_MS);
params.complete();
}
function signalSampleProcess(
proc: ReturnType<typeof spawn>,
signal: NodeJS.Signals,
useProcessGroup: boolean,
): void {
if (!proc.pid) {
return;
}
try {
if (useProcessGroup) {
process.kill(-proc.pid, signal);
} else {
proc.kill(signal);
}
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code !== "ESRCH" && code !== "EPERM") {
throw error;
}
}
}
function isSampleProcessGroupAlive(
proc: ReturnType<typeof spawn>,
useProcessGroup: boolean,
): boolean {
if (!useProcessGroup || !proc.pid) {
return false;
}
try {
process.kill(-proc.pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException | undefined)?.code === "EPERM";
}
}
async function waitForSampleProcessGroupExit(
proc: ReturnType<typeof spawn>,
useProcessGroup: boolean,
timeoutMs: number,
): Promise<boolean> {
const deadlineAt = Date.now() + timeoutMs;
while (Date.now() < deadlineAt) {
if (!isSampleProcessGroupAlive(proc, useProcessGroup)) {
return true;
}
await new Promise((resolvePoll) => {
setTimeout(resolvePoll, PROCESS_GROUP_EXIT_POLL_MS);
});
}
return !isSampleProcessGroupAlive(proc, useProcessGroup);
}
async function runCase(params: {
entry: string;
commandCase: CommandCase;

View File

@@ -1,6 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/../apps/macos"
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
APP_DIR="$ROOT_DIR/apps/macos"
usage() {
printf 'Usage: %s\n' "$(basename "$0")"
printf 'Build, stop, and relaunch the local debug OpenClaw macOS app.\n'
}
for arg in "$@"; do
case "$arg" in
--help|-h)
usage
exit 0
;;
--) ;;
*) printf 'ERROR: Unknown build-and-run-mac option: %s\n' "$arg" >&2; exit 1 ;;
esac
done
cd "$APP_DIR"
BUILD_PATH=".build-local"
PRODUCT="OpenClaw"

View File

@@ -505,6 +505,9 @@ function toSnakeCase(value) {
}
function parseArgs(argv) {
const separatorIndex = argv.indexOf("--");
const flagArgv = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
const explicitPaths = separatorIndex === -1 ? [] : argv.slice(separatorIndex + 1);
const args = {
base: "origin/main",
head: "HEAD",
@@ -515,8 +518,8 @@ function parseArgs(argv) {
help: false,
paths: [],
};
return parseFlagArgs(
argv,
const parsed = parseFlagArgs(
flagArgv,
args,
[
stringFlag("--base", "base"),
@@ -530,14 +533,16 @@ function parseArgs(argv) {
],
{
onUnhandledArg(arg, target) {
if (arg === "--") {
return "handled";
if (arg.startsWith("-")) {
throw new Error(`Unknown option: ${arg}`);
}
target.paths.push(arg);
return "handled";
},
},
);
parsed.paths.push(...explicitPaths);
return parsed;
}
function printUsage() {
@@ -586,7 +591,13 @@ function printHuman(result) {
}
if (isDirectRun()) {
const args = parseArgs(process.argv.slice(2));
let args;
try {
args = parseArgs(process.argv.slice(2));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
if (args.help) {
printUsage();
process.exit(0);

View File

@@ -565,6 +565,10 @@ function printSummary(timings, options) {
}
function parseArgs(argv) {
const separatorIndex = argv.indexOf("--");
const flagArgv = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
const explicitPaths =
separatorIndex === -1 ? [] : argv.slice(separatorIndex + 1).map(normalizeChangedPath);
const args = {
base: "origin/main",
head: "HEAD",
@@ -575,8 +579,8 @@ function parseArgs(argv) {
help: false,
paths: [],
};
return parseFlagArgs(
argv,
const parsed = parseFlagArgs(
flagArgv,
args,
[
stringFlag("--base", "base"),
@@ -590,14 +594,16 @@ function parseArgs(argv) {
],
{
onUnhandledArg(arg, target) {
if (arg === "--") {
return "handled";
if (arg.startsWith("-")) {
throw new Error(`Unknown option: ${arg}`);
}
target.paths.push(normalizeChangedPath(arg));
return "handled";
},
},
);
parsed.paths.push(...explicitPaths);
return parsed;
}
function printUsage() {
@@ -624,7 +630,13 @@ function isDirectRun() {
if (isDirectRun()) {
const argv = process.argv.slice(2);
const args = parseArgs(argv);
let args;
try {
args = parseArgs(argv);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
if (args.help) {
printUsage();
process.exitCode = 0;

View File

@@ -228,6 +228,28 @@ export async function runKnipUnusedFiles(params = {}) {
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
});
const parentSignalHandlers = [];
const cleanupParentSignalHandlers = () => {
for (const { signal, handler } of parentSignalHandlers) {
process.off(signal, handler);
}
parentSignalHandlers.length = 0;
};
const relayParentSignal = (signal) => {
const handler = () => {
signalProcessTree(child, signal);
signalProcessTree(child, "SIGKILL");
cleanupParentSignalHandlers();
process.kill(process.pid, signal);
};
parentSignalHandlers.push({ signal, handler });
process.once(signal, handler);
};
if (process.platform !== "win32") {
relayParentSignal("SIGINT");
relayParentSignal("SIGTERM");
relayParentSignal("SIGHUP");
}
const heartbeatTimer = setInterval(() => {
writeStatus(
@@ -255,6 +277,7 @@ export async function runKnipUnusedFiles(params = {}) {
clearTimeout(timeoutTimer);
clearInterval(heartbeatTimer);
clearTimeout(killTimer);
cleanupParentSignalHandlers();
resolve({
...result,
output: output.join(""),

View File

@@ -471,6 +471,7 @@ export function runNodeStepAsync(label, args, timeoutMs, params = {}) {
abortSignal?.addEventListener("abort", abortListener, { once: true });
const teardownProcessCleanup = installVitestProcessGroupCleanup({
child,
forceSignal: "SIGKILL",
onSignal: (signal) => {
forwardedSignal ??= signal;
},
@@ -566,7 +567,10 @@ export function runNodeStepAsync(label, args, timeoutMs, params = {}) {
cleanup();
settled = true;
if (forwardedSignal) {
process.kill(process.pid, forwardedSignal);
signalChild("SIGKILL");
void waitAfterForceKill().finally(() => {
process.kill(process.pid, forwardedSignal);
});
return;
}
if (abortController?.signal.aborted) {

View File

@@ -23,10 +23,37 @@ function fail(message) {
process.exit(1);
}
const tarball = process.argv[2];
if (!tarball || process.argv.length > 3) {
fail(usage());
function parseArgs(argv) {
const args = argv[0] === "--" ? argv.slice(1) : argv;
const tarball = args[0]?.trim() ?? "";
if (tarball === "--help" || tarball === "-h") {
return { help: true, tarball: "" };
}
if (!tarball) {
throw new Error(usage());
}
if (tarball.startsWith("-")) {
throw new Error(`Unknown OpenClaw package tarball check option: ${tarball}`);
}
const extraArg = args[1]?.trim();
if (extraArg) {
throw new Error(`Unexpected OpenClaw package tarball check argument: ${extraArg}`);
}
return { help: false, tarball };
}
let cliArgs;
try {
cliArgs = parseArgs(process.argv.slice(2));
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
if (cliArgs.help) {
console.log(usage());
process.exit(0);
}
const { tarball } = cliArgs;
if (!fs.existsSync(tarball)) {
fail(`OpenClaw package tarball does not exist: ${tarball}`);
}

View File

@@ -13,11 +13,37 @@ function fail(message) {
process.exit(1);
}
const packageRoot = path.resolve(process.argv[2] ?? process.cwd());
if (process.argv.length > 3) {
fail(usage());
function parseArgs(argv) {
const args = argv[0] === "--" ? argv.slice(1) : argv;
const packageRootArg = args[0]?.trim() ?? "";
if (packageRootArg === "--help" || packageRootArg === "-h") {
return { help: true, packageRoot: "" };
}
if (packageRootArg.startsWith("-")) {
throw new Error(`Unknown package dist import check option: ${packageRootArg}`);
}
const extraArg = args[1]?.trim();
if (extraArg) {
throw new Error(`Unexpected package dist import check argument: ${extraArg}`);
}
return {
help: false,
packageRoot: path.resolve(packageRootArg || process.cwd()),
};
}
let cliArgs;
try {
cliArgs = parseArgs(process.argv.slice(2));
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
if (cliArgs.help) {
console.log(usage());
process.exit(0);
}
const { packageRoot } = cliArgs;
const distRoot = path.join(packageRoot, "dist");
if (!fs.existsSync(distRoot)) {
fail(`missing dist directory: ${distRoot}`);

View File

@@ -484,6 +484,7 @@ export function runMeasuredCommandLive(params) {
let settled = false;
let forceKillTimeout = null;
let forceKillAt = 0;
let parentTerminationSignal = null;
const maxBufferBytes = params.maxBufferBytes ?? COMMAND_OUTPUT_MAX_BUFFER_BYTES;
const maxRelayBytes = params.consoleOutputMaxBytes ?? maxBufferBytes;
const timeoutKillGraceMs = params.timeoutKillGraceMs ?? 5_000;
@@ -530,6 +531,14 @@ export function runMeasuredCommandLive(params) {
}
return !processGroupAlive();
};
const scheduleForceKill = () => {
forceKillAt = Date.now() + timeoutKillGraceMs;
forceKillTimeout ??= setTimeout(() => {
forceKillTimeout = null;
killMeasuredProcess("SIGKILL");
}, timeoutKillGraceMs);
forceKillTimeout.unref?.();
};
const parentSignalHandlers = new Map();
const removeParentSignalHandlers = () => {
for (const [signal, handler] of parentSignalHandlers) {
@@ -541,9 +550,12 @@ export function runMeasuredCommandLive(params) {
process.platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"];
for (const signal of parentSignals) {
const handler = () => {
if (parentTerminationSignal) {
return;
}
parentTerminationSignal = signal;
killMeasuredProcess(signal);
removeParentSignalHandlers();
process.kill(process.pid, signal);
scheduleForceKill();
};
parentSignalHandlers.set(signal, handler);
process.once(signal, handler);
@@ -637,11 +649,7 @@ export function runMeasuredCommandLive(params) {
message: `Command timed out after ${params.timeoutMs}ms`,
};
killMeasuredProcess();
forceKillAt = Date.now() + timeoutKillGraceMs;
forceKillTimeout = setTimeout(() => {
killMeasuredProcess("SIGKILL");
}, timeoutKillGraceMs);
forceKillTimeout.unref?.();
scheduleForceKill();
}, params.timeoutMs)
: null;
timeout?.unref?.();
@@ -695,7 +703,7 @@ export function runMeasuredCommandLive(params) {
...parseTimedMetrics(finalStderr, wallMs, mode),
});
};
const finishAfterTimeoutTeardown = async (status, signal) => {
const waitForTerminationCleanup = async () => {
const remainingGraceMs = Math.max(0, forceKillAt - Date.now());
if (remainingGraceMs > 0) {
await waitForProcessGroupExit(remainingGraceMs);
@@ -704,8 +712,30 @@ export function runMeasuredCommandLive(params) {
killMeasuredProcess("SIGKILL");
await waitForProcessGroupExit(100);
}
};
const rethrowParentTermination = () => {
settled = true;
if (timeout) {
clearTimeout(timeout);
}
if (forceKillTimeout) {
clearTimeout(forceKillTimeout);
}
removeParentSignalHandlers();
process.kill(process.pid, parentTerminationSignal);
};
const finishAfterTimeoutTeardown = async (status, signal) => {
await waitForTerminationCleanup();
if (parentTerminationSignal) {
rethrowParentTermination();
return;
}
finish(status, signal);
};
const finishAfterParentTermination = async () => {
await waitForTerminationCleanup();
rethrowParentTermination();
};
child.on("error", (error) => {
spawnError = {
code: typeof error.code === "string" ? error.code : null,
@@ -714,6 +744,10 @@ export function runMeasuredCommandLive(params) {
finish(null, null);
});
child.on("close", (status, signal) => {
if (parentTerminationSignal) {
void finishAfterParentTermination();
return;
}
if (timedOut) {
void finishAfterTimeoutTeardown(status, signal);
return;

View File

@@ -19,18 +19,24 @@ function readPackageArgValue(argv, index) {
return value;
}
function usage() {
return "usage: node scripts/check-plugin-npm-runtime-builds.mjs [--package extensions/<id> ...]";
}
export function parseArgs(argv) {
const args = argv[0] === "--" ? argv.slice(1) : argv;
if (args[0] === "--help" || args[0] === "-h") {
return { help: true, packageDirs: [] };
}
const packageDirs = [];
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--package") {
packageDirs.push(readPackageArgValue(argv, index));
packageDirs.push(readPackageArgValue(args, index));
index += 1;
continue;
}
throw new Error(
"usage: node scripts/check-plugin-npm-runtime-builds.mjs [--package extensions/<id> ...]",
);
throw new Error(usage());
}
return { packageDirs };
}
@@ -75,6 +81,10 @@ export async function checkPluginNpmRuntimeBuilds(params = {}) {
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(usage());
process.exit(0);
}
const rows = await checkPluginNpmRuntimeBuilds(args);
const builtCount = rows.filter((row) => row.status === "built").length;
console.log(`checked ${rows.length} publishable plugins; built ${builtCount} npm runtimes`);

View File

@@ -29,23 +29,28 @@ function readRefOptionValue(argv, index, optionName) {
}
export function parseArgs(argv) {
const separatorIndex = argv.indexOf("--");
const flagArgv = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
const explicitPaths =
separatorIndex === -1 ? [] : argv.slice(separatorIndex + 1).map(normalizePath);
const args = { staged: false, base: "origin/main", head: "HEAD", paths: [] };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--") {
continue;
} else if (arg === "--staged") {
for (let index = 0; index < flagArgv.length; index += 1) {
const arg = flagArgv[index];
if (arg === "--staged") {
args.staged = true;
} else if (arg === "--base") {
args.base = readRefOptionValue(argv, index, arg);
args.base = readRefOptionValue(flagArgv, index, arg);
index += 1;
} else if (arg === "--head") {
args.head = readRefOptionValue(argv, index, arg);
args.head = readRefOptionValue(flagArgv, index, arg);
index += 1;
} else if (arg.startsWith("-")) {
throw new Error(`Unknown option: ${arg}`);
} else {
args.paths.push(normalizePath(arg));
}
}
args.paths.push(...explicitPaths);
return args;
}
@@ -164,5 +169,10 @@ export function main(argv = process.argv.slice(2)) {
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
main();
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}

View File

@@ -1,6 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="${BASH_SOURCE[0]%/*}"
# shellcheck source=scripts/lib/host-timeout.sh
source "$SCRIPT_DIR/lib/host-timeout.sh"
if [[ "$#" -ne 1 || -z "${1// }" ]]; then
echo "usage: $0 <image>" >&2
exit 2
@@ -28,14 +32,15 @@ fi
last_status=1
run_docker_pull() {
if ! command -v timeout >/dev/null 2>&1; then
echo "timeout command not found; cannot bound Docker pull after ${timeout_seconds}s" >&2
local timeout_bin
if ! timeout_bin="$(openclaw_host_timeout_bin)"; then
echo "timeout or gtimeout command not found; cannot bound Docker pull after ${timeout_seconds}s" >&2
return 127
fi
if timeout --kill-after=1s 1s true >/dev/null 2>&1; then
timeout --kill-after=30s "${timeout_seconds}s" docker pull "$image"
if "$timeout_bin" --kill-after=1s 1s true >/dev/null 2>&1; then
"$timeout_bin" --kill-after=30s "${timeout_seconds}s" docker pull "$image"
else
timeout "${timeout_seconds}s" docker pull "$image"
"$timeout_bin" "${timeout_seconds}s" docker pull "$image"
fi
}

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
APP_BUNDLE="${1:-dist/OpenClaw.app}"
APP_BUNDLE="dist/OpenClaw.app"
IDENTITY="${SIGN_IDENTITY:-}"
TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}"
DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}"
@@ -14,7 +14,7 @@ cleanup() {
fi
}
if [[ "${APP_BUNDLE}" == "--help" || "${APP_BUNDLE}" == "-h" ]]; then
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
cat <<'HELP'
Usage: scripts/codesign-mac-app.sh [app-bundle]
@@ -28,6 +28,20 @@ HELP
exit 0
fi
if [[ "${1:-}" == "--" ]]; then
shift
fi
if [[ "$#" -gt 0 ]]; then
case "$1" in
-*) echo "ERROR: Unknown codesign option: $1" >&2; exit 1 ;;
*) APP_BUNDLE="$1"; shift ;;
esac
fi
if [[ "$#" -gt 0 ]]; then
echo "ERROR: Unexpected codesign argument: $1" >&2
exit 1
fi
if [ ! -d "$APP_BUNDLE" ]; then
echo "App bundle not found: $APP_BUNDLE" >&2
exit 1

View File

@@ -27,6 +27,11 @@ type GlossaryEntry = {
target: string;
};
type RunProcessParentSignalState = {
done: boolean;
signal: NodeJS.Signals | null;
};
type TranslationMemoryEntry = {
cache_key: string;
model: string;
@@ -102,6 +107,7 @@ const DEFAULT_PROMPT_TIMEOUT_MS = 120_000;
const RUN_PROCESS_OUTPUT_MAX_CHARS = 1024 * 1024;
const RUN_PROCESS_TIMEOUT_MS = 120_000;
const RUN_PROCESS_KILL_GRACE_MS = 5_000;
const activeRunProcessParentSignals = new Set<RunProcessParentSignalState>();
const PROGRESS_HEARTBEAT_MS = 30_000;
const ENV_PROVIDER = "OPENCLAW_CONTROL_UI_I18N_PROVIDER";
const ENV_MODEL = "OPENCLAW_CONTROL_UI_I18N_MODEL";
@@ -991,6 +997,15 @@ function formatProcessOutput(capture: ProcessOutputCapture): string {
return `[output truncated ${capture.truncatedChars} chars; showing tail]\n${capture.text}`;
}
function maybeReraiseRunProcessParentSignal(signal: NodeJS.Signals): void {
for (const state of activeRunProcessParentSignals) {
if (state.signal === null || !state.done) {
return;
}
}
process.kill(process.pid, signal);
}
export async function runProcess(
executable: string,
args: string[],
@@ -1015,6 +1030,9 @@ export async function runProcess(
let waitingForKillGrace = false;
let childClosedResult: { code: number | null; signal: NodeJS.Signals | null } | null = null;
let killTimer: ReturnType<typeof setTimeout> | undefined;
let parentSignalPending: NodeJS.Signals | null = null;
const parentSignalState: RunProcessParentSignalState = { done: false, signal: null };
activeRunProcessParentSignals.add(parentSignalState);
const parentSignalHandlers: { handler: () => void; signal: NodeJS.Signals }[] = [];
const cleanupParentSignalHandlers = () => {
for (const { signal, handler } of parentSignalHandlers) {
@@ -1058,9 +1076,28 @@ export async function runProcess(
};
const relayParentSignal = (signal: NodeJS.Signals) => {
const handler = () => {
parentSignalPending = signal;
parentSignalState.signal = signal;
signalChild(signal);
cleanupParentSignalHandlers();
process.kill(process.pid, signal);
if (!processGroupIsAlive()) {
parentSignalState.done = true;
maybeReraiseRunProcessParentSignal(signal);
return;
}
if (killTimer) {
clearTimeout(killTimer);
}
waitingForKillGrace = true;
// Keep this timer ref'ed so parent signal relay can force-kill stubborn
// process groups before re-raising the original signal.
killTimer = setTimeout(() => {
waitingForKillGrace = false;
killTimer = undefined;
signalChild("SIGKILL");
parentSignalState.done = true;
maybeReraiseRunProcessParentSignal(signal);
}, killGraceMs);
};
parentSignalHandlers.push({ handler, signal });
process.once(signal, handler);
@@ -1087,9 +1124,12 @@ export async function runProcess(
}
settled = true;
clearTimeout(timeout);
if (killTimer) {
if (!parentSignalPending && killTimer) {
clearTimeout(killTimer);
}
if (!parentSignalPending) {
activeRunProcessParentSignals.delete(parentSignalState);
}
cleanupParentSignalHandlers();
callback();
};
@@ -1158,6 +1198,19 @@ export async function runProcess(
child.stdin.end();
}
child.once("close", (code, signal) => {
if (parentSignalPending) {
if (processGroupIsAlive()) {
childClosedResult = { code, signal };
return;
}
if (killTimer) {
clearTimeout(killTimer);
killTimer = undefined;
}
parentSignalState.done = true;
maybeReraiseRunProcessParentSignal(parentSignalPending);
return;
}
if (waitingForKillGrace && processGroupIsAlive()) {
childClosedResult = { code, signal };
return;

View File

@@ -167,6 +167,102 @@ const shellInlineCommandOptionsWithNextValue = new Set([
"--init-file",
"--rcfile",
]);
const nodeOptionsWithNextValueBeforeScript = new Set([
"--allow-fs-read",
"--allow-fs-write",
"--conditions",
"--cpu-prof-dir",
"--cpu-prof-interval",
"--cpu-prof-name",
"--debug-port",
"--diagnostic-dir",
"--disable-proto",
"--disable-warning",
"--dns-result-order",
"--env-file",
"--env-file-if-exists",
"--experimental-config-file",
"--experimental-loader",
"--experimental-test-isolation",
"--heap-prof-dir",
"--heap-prof-interval",
"--heap-prof-name",
"--heapsnapshot-near-heap-limit",
"--heapsnapshot-signal",
"--icu-data-dir",
"--import",
"--inspect-port",
"--inspect-publish-uid",
"--initial-old-space-size",
"--localstorage-file",
"--loader",
"--max-http-header-size",
"--max-old-space-size",
"--max-old-space-size-percentage",
"--max-semi-space-size",
"--network-family-autoselection-attempt-timeout",
"--openssl-config",
"--redirect-warnings",
"--report-dir",
"--report-directory",
"--report-filename",
"--report-signal",
"--require",
"--secure-heap",
"--secure-heap-min",
"--snapshot-blob",
"--test-concurrency",
"--test-coverage-branches",
"--test-coverage-exclude",
"--test-coverage-functions",
"--test-coverage-include",
"--test-coverage-lines",
"--test-global-setup",
"--test-isolation",
"--test-name-pattern",
"--test-reporter",
"--test-reporter-destination",
"--test-rerun-failures",
"--test-shard",
"--test-skip-pattern",
"--test-timeout",
"--title",
"--tls-cipher-list",
"--tls-keylog",
"--trace-event-categories",
"--trace-event-file-pattern",
"--trace-require-module",
"--unhandled-rejections",
"--use-largepages",
"--v8-pool-size",
"--watch-kill-signal",
"--watch-path",
"-C",
"-r",
]);
const nodeOptionsWithoutScript = new Set([
"--build-sea",
"--build-snapshot",
"--build-snapshot-config",
"--check",
"--completion-bash",
"--eval",
"--experimental-sea-config",
"--help",
"--input-type",
"--interactive",
"--print",
"--prof-process",
"--run",
"--v8-options",
"--version",
"-c",
"-e",
"-h",
"-i",
"-p",
"-v",
]);
function escapeBatchCommand(command) {
return `${command}`.replace(cmdMetaCharactersRe, "^$1");
@@ -842,6 +938,12 @@ function commandWordsRuntimeEntrypoint(wordsInput) {
return "";
}
function commandWordsShellEntrypoint(wordsInput) {
const words = normalizeExecutableWords(wordsInput);
const first = shellWordBasename(words[0]);
return shellInlineCommandInterpreters.has(first) ? first : "";
}
function commandNeedsAwsMacosPackageManager(commandArgs) {
if (isChangedGateCommand(commandArgs)) {
return true;
@@ -913,10 +1015,56 @@ function isChangedGateWords(wordsInput) {
return (
(words[0] === "pnpm" && words[1] === "check:changed") ||
(words[0] === "pnpm" && words[1] === "run" && words[2] === "check:changed") ||
(words[0] === "node" && (words[1] ?? "").endsWith("scripts/check-changed.mjs"))
nodeScriptWord(words)?.endsWith("scripts/check-changed.mjs")
);
}
function nodeScriptWord(words) {
if (shellWordBasename(words[0]) !== "node") {
return "";
}
for (let index = 1; index < words.length; index += 1) {
const word = words[index] ?? "";
if (!word) {
return "";
}
if (word === "--") {
return words[index + 1] ?? "";
}
if (nodeOptionsWithoutScript.has(word) || nodeOptionsWithoutScriptPrefix(word)) {
return "";
}
const valueMode = nodeOptionValueModeBeforeScript(word);
if (valueMode === "next") {
index += 1;
continue;
}
if (valueMode === "inline") {
continue;
}
if (word.startsWith("-") && word !== "-") {
continue;
}
return word;
}
return "";
}
function nodeOptionsWithoutScriptPrefix(word) {
return word.startsWith("--eval=") || word.startsWith("--print=");
}
function nodeOptionValueModeBeforeScript(word) {
if (nodeOptionsWithNextValueBeforeScript.has(word)) {
return "next";
}
const equalsIndex = word.indexOf("=");
if (equalsIndex > 0 && nodeOptionsWithNextValueBeforeScript.has(word.slice(0, equalsIndex))) {
return "inline";
}
return "";
}
function shellInlineCommand(words) {
const command = shellWordBasename(words[0]);
if (!shellInlineCommandInterpreters.has(command)) {
@@ -1591,11 +1739,7 @@ function injectRemoteChangedGateEnvironment(commandArgs) {
}
function markShellChangedGateAsRemoteChild(command) {
const missingEnv = remoteChangedGateEnv.filter((assignment) => !command.includes(assignment));
if (missingEnv.length === 0) {
return command;
}
return `export ${missingEnv.join(" ")}; ${command}`;
return `export ${remoteChangedGateEnv.join(" ")}; ${command}`;
}
function markDirectChangedGateAsRemoteChild(commandArgs) {
@@ -1784,8 +1928,8 @@ function remoteAwsMacosJsBootstrap({ packageManager = false, bun = false } = {})
'tmp_dir="$(mktemp -d)" || { release_install_lock; return 1; };',
'pkg="node-v${node_version}-darwin-${node_arch}.tar.gz";',
'base_url="https://nodejs.org/dist/v${node_version}";',
'curl -fsSLo "$tmp_dir/$pkg" "$base_url/$pkg" || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
'curl -fsSLo "$tmp_dir/SHASUMS256.txt" "$base_url/SHASUMS256.txt" || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
'curl -fsSL --connect-timeout 10 --max-time 300 --retry 2 --retry-delay 2 -o "$tmp_dir/$pkg" "$base_url/$pkg" || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
'curl -fsSL --connect-timeout 10 --max-time 60 --retry 2 --retry-delay 2 -o "$tmp_dir/SHASUMS256.txt" "$base_url/SHASUMS256.txt" || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
'(cd "$tmp_dir" && grep " $pkg$" SHASUMS256.txt | shasum -a 256 -c -) || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
'rm -rf "$node_dir" || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
'tar -xzf "$tmp_dir/$pkg" -C "$tool_root" || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
@@ -1854,7 +1998,7 @@ function remoteAwsMacosJsBootstrap({ packageManager = false, bun = false } = {})
'if [ ! -x "$bun_root/bin/bun" ] || [ ! -f "$bun_ready_marker" ]; then',
'rm -rf "$bun_root" || { status=$?; release_bun_install_lock; return "$status"; };',
'mkdir -p "$bun_root" || { status=$?; release_bun_install_lock; return "$status"; };',
'npm install --global --prefix "$bun_root" "bun@${bun_version}" || { status=$?; release_bun_install_lock; return "$status"; };',
'npm install --global --prefix "$bun_root" --fetch-timeout=120000 --fetch-retries=2 --fetch-retry-mintimeout=2000 --fetch-retry-maxtimeout=15000 "bun@${bun_version}" || { status=$?; release_bun_install_lock; return "$status"; };',
'touch "$bun_ready_marker" || { status=$?; release_bun_install_lock; return "$status"; };',
"fi;",
"release_bun_install_lock;",
@@ -2013,15 +2157,14 @@ function awsMacosScriptBootstrapRequirements(script) {
const requirements = { packageManager: false, bun: false };
const firstLine = script.match(/^[^\r\n]*/u)?.[0] ?? "";
if (firstLine.startsWith("#!")) {
let words = firstLine.slice(2).trim().split(/\s+/u).filter(Boolean);
if ((words[0] ?? "").split("/").pop() === "env") {
words = words.slice(1);
while ((words[0] ?? "").startsWith("-")) {
words = words.slice(1);
}
}
const words = firstLine.slice(2).trim().split(/\s+/u).filter(Boolean);
requirements.packageManager = commandWordsNeedEntrypoint(words, awsMacosCorepackEntrypoints);
requirements.bun = commandWordsNeedEntrypoint(words, awsMacosBunEntrypoints);
if (commandWordsShellEntrypoint(words)) {
const body = script.slice(firstLine.length).replace(/^\r?\n/u, "");
requirements.packageManager ||= commandNeedsAwsMacosPackageManager([body]);
requirements.bun ||= commandNeedsAwsMacosBun([body]);
}
return requirements;
}
requirements.packageManager = commandNeedsAwsMacosPackageManager([script]);
@@ -2560,23 +2703,23 @@ const childInvocation = spawnInvocation(binary, childArgs, childEnv, process.pla
const child = spawn(childInvocation.command, childInvocation.args, {
cwd: childCwd,
stdio: "inherit",
detached: process.platform !== "win32",
env: childEnv,
windowsVerbatimArguments: childInvocation.windowsVerbatimArguments,
});
const childKillGraceMs = 5_000;
let childForceKillTimer;
let childTreeShutdownStarted = false;
if (fullCheckout) {
try {
stopFullCheckoutKeepalive = startFullCheckoutKeepalive(fullCheckout, {
intervalMs: fullCheckoutKeepaliveIntervalMsValue,
onMissing: () => {
if (!child.killed) {
child.kill("SIGTERM");
}
void exitAfterChildTreeTermination(child, "SIGTERM", 1);
},
});
} catch (error) {
if (!child.killed) {
child.kill("SIGTERM");
}
signalChildProcessTree(child, "SIGTERM");
cleanupOnce();
throw error;
}
@@ -2588,17 +2731,17 @@ const signalExitCodes = new Map([
["SIGTERM", 143],
]);
for (const signal of signalExitCodes.keys()) {
process.once(signal, () => {
if (!child.killed) {
child.kill(signal);
}
cleanupOnce();
process.exit(signalExitCodes.get(signal) ?? 1);
process.on(signal, () => {
void exitAfterChildTreeTermination(child, signal, signalExitCodes.get(signal) ?? 1);
});
}
process.once("exit", cleanupOnce);
child.on("exit", (code, signal) => {
clearChildForceKillTimer();
if (childTreeShutdownStarted) {
return;
}
let fullCheckoutAvailable = true;
if (fullCheckout) {
fullCheckoutAvailable = assertFullCheckoutAvailableBeforeExit(fullCheckout.dir);
@@ -2612,6 +2755,10 @@ child.on("exit", (code, signal) => {
});
child.on("error", (error) => {
clearChildForceKillTimer();
if (childTreeShutdownStarted) {
return;
}
if (fullCheckout) {
assertFullCheckoutAvailableBeforeExit(fullCheckout.dir);
}
@@ -2619,3 +2766,81 @@ child.on("error", (error) => {
console.error(`[crabbox] failed to execute ${displayBinary}: ${error.message}`);
process.exit(2);
});
async function exitAfterChildTreeTermination(childProcess, signal, exitCode) {
if (childTreeShutdownStarted) {
signalChildProcessTree(childProcess, "SIGKILL");
return;
}
childTreeShutdownStarted = true;
signalChildProcessTree(childProcess, signal);
await waitForChildTreeExit(childProcess, childKillGraceMs);
if (childProcessTreeIsAlive(childProcess)) {
signalChildProcessTree(childProcess, "SIGKILL");
}
await waitForChildTreeExit(childProcess, childKillGraceMs);
cleanupOnce();
process.exit(exitCode);
}
function signalChildProcessTree(childProcess, signal) {
if (
process.platform === "win32" &&
(childProcess.exitCode !== null || childProcess.signalCode !== null)
) {
return;
}
try {
if (process.platform !== "win32" && typeof childProcess.pid === "number") {
process.kill(-childProcess.pid, signal);
} else {
childProcess.kill(signal);
}
} catch (error) {
if (error?.code !== "ESRCH") {
try {
childProcess.kill(signal);
} catch {}
}
}
if (signal !== "SIGKILL" && !childForceKillTimer) {
childForceKillTimer = setTimeout(() => {
childForceKillTimer = undefined;
signalChildProcessTree(childProcess, "SIGKILL");
}, childKillGraceMs);
childForceKillTimer.unref?.();
}
}
function clearChildForceKillTimer() {
if (childForceKillTimer) {
clearTimeout(childForceKillTimer);
childForceKillTimer = undefined;
}
}
function childProcessTreeIsAlive(childProcess) {
if (process.platform === "win32" || typeof childProcess.pid !== "number") {
return childProcess.exitCode === null && childProcess.signalCode === null;
}
try {
process.kill(-childProcess.pid, 0);
return true;
} catch (error) {
return error?.code === "EPERM";
}
}
async function waitForChildTreeExit(childProcess, timeoutMs) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (!childProcessTreeIsAlive(childProcess)) {
clearChildForceKillTimer();
return true;
}
await new Promise((done) => {
setTimeout(done, 50);
});
}
return !childProcessTreeIsAlive(childProcess);
}

View File

@@ -17,7 +17,6 @@ export const KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST = [
"extensions/diffs/src/viewer-client.ts",
"extensions/diffs/src/viewer-payload.ts",
"extensions/matrix/src/plugin-entry.runtime.js",
"extensions/memory-core/src/memory-tool-manager-mock.ts",
"ui/src/ui/browser-redact.ts",
"src/agents/subagent-registry.runtime.ts",
"src/auto-reply/reply/get-reply.test-loader.ts",

View File

@@ -3,6 +3,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-build.sh"
source "$ROOT_DIR/scripts/lib/host-timeout.sh"
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
EXTRA_COMPOSE_FILE="$ROOT_DIR/docker-compose.extra.yml"
IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw:local}"
@@ -52,15 +53,7 @@ run_docker_build() {
run_docker_pull() {
local image="$1"
if command -v timeout >/dev/null 2>&1; then
if timeout --kill-after=1s 1s true >/dev/null 2>&1; then
timeout --kill-after=30s "$DOCKER_PULL_TIMEOUT" docker pull "$image"
else
timeout "$DOCKER_PULL_TIMEOUT" docker pull "$image"
fi
return
fi
docker pull "$image"
openclaw_host_timeout_cmd "$DOCKER_PULL_TIMEOUT" docker pull "$image"
}
require_local_docker_image() {

View File

@@ -31,6 +31,7 @@ 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 COMMAND_PARENT_SIGNAL_KILL_GRACE_MS = 2000;
const COMMAND_PROCESS_TREE_EXIT_POLL_MS = 50;
const LOG_SCAN_CHUNK_BYTES = 64 * 1024;
const LOG_SCAN_MAX_LINE_CHARS = 16 * 1024;
@@ -56,6 +57,40 @@ const ERROR_LOG_ALLOW_PATTERNS = [
];
let callGatewayModulePromise;
const activeCommandChildren = new Set();
const commandParentSignals =
process.platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"];
let commandShutdownPromise;
let commandSignalHandlersInstalled = false;
function installCommandSignalHandlers() {
if (commandSignalHandlersInstalled) {
return;
}
commandSignalHandlersInstalled = true;
for (const signal of commandParentSignals) {
process.on(signal, commandSignalHandlers.get(signal));
}
}
function removeCommandSignalHandlers() {
if (!commandSignalHandlersInstalled) {
return;
}
commandSignalHandlersInstalled = false;
for (const signal of commandParentSignals) {
process.off(signal, commandSignalHandlers.get(signal));
}
}
const commandSignalHandlers = new Map(
commandParentSignals.map((signal) => [
signal,
() => {
void shutdownActiveCommands(signal);
},
]),
);
function usage() {
return `Usage: node scripts/e2e/kitchen-sink-rpc-walk.mjs
@@ -301,6 +336,11 @@ function formatCapturedOutput(label, buffer) {
}
export function runCommand(command, args, options = {}) {
if (commandShutdownPromise) {
return commandShutdownPromise.then(() => {
throw new Error(`${command} ${args.join(" ")} skipped during parent signal shutdown`);
});
}
return new Promise((resolve, reject) => {
const config = resolveKitchenSinkRpcConfig();
const {
@@ -320,6 +360,8 @@ export function runCommand(command, args, options = {}) {
...spawnOptions,
detached: spawnOptions.detached ?? process.platform !== "win32",
});
activeCommandChildren.add(child);
installCommandSignalHandlers();
const startedAt = Date.now();
let stdout = { text: "", truncatedChars: 0 };
let stderr = { text: "", truncatedChars: 0 };
@@ -393,6 +435,7 @@ export function runCommand(command, args, options = {}) {
clearTimeout(timer);
clearTimeout(forceKillTimer);
forceKillAt = undefined;
releaseCommandChild(child);
void stopResourceSampling().finally(() =>
reject(toLintErrorObject(error, "Command failed before exit")),
);
@@ -402,6 +445,7 @@ export function runCommand(command, args, options = {}) {
const finish = () => {
clearTimeout(forceKillTimer);
forceKillAt = undefined;
releaseCommandChild(child);
void stopResourceSampling().then((resourceSampleFailure) => {
if (!timedOut && status === 0) {
if (resourceSampleFailure) {
@@ -470,6 +514,38 @@ async function finishTimedOutCommandProcessTree(child, { forceKillAt, timeoutKil
await waitForCommandProcessTreeExit(child, timeoutKillGraceMs);
}
function releaseCommandChild(child) {
activeCommandChildren.delete(child);
if (activeCommandChildren.size === 0 && !commandShutdownPromise) {
removeCommandSignalHandlers();
}
}
async function shutdownActiveCommands(signal) {
if (commandShutdownPromise) {
for (const child of activeCommandChildren) {
signalProcessGroup(child, "SIGKILL");
}
return commandShutdownPromise;
}
const children = [...activeCommandChildren];
for (const child of children) {
signalProcessGroup(child, signal);
}
commandShutdownPromise = Promise.all(
children.map((child) =>
finishTimedOutCommandProcessTree(child, {
forceKillAt: Date.now() + COMMAND_PARENT_SIGNAL_KILL_GRACE_MS,
timeoutKillGraceMs: COMMAND_PARENT_SIGNAL_KILL_GRACE_MS,
}),
),
).finally(() => {
removeCommandSignalHandlers();
process.kill(process.pid, signal);
});
return commandShutdownPromise;
}
async function waitForCommandProcessTreeExit(child, timeoutMs) {
const deadlineAt = Date.now() + timeoutMs;
while (Date.now() < deadlineAt) {
@@ -548,7 +624,7 @@ async function resolveOpenClawCommand(runner, args, env, options = {}) {
};
}
function parseJsonOutput(stdout) {
export function parseJsonOutput(stdout) {
const trimmed = stdout.trim();
if (!trimmed) {
throw new Error("command produced no JSON output");
@@ -681,6 +757,9 @@ function extractBalancedJsonObjects(text) {
if (text[index] !== "{") {
continue;
}
if (!isJsonObjectRecordStart(text, index)) {
continue;
}
const end = findBalancedJsonObjectEnd(text, index);
if (end > index) {
candidates.push(text.slice(index, end + 1));
@@ -690,6 +769,17 @@ function extractBalancedJsonObjects(text) {
return candidates;
}
function isJsonObjectRecordStart(text, index) {
if (index === 0) {
return true;
}
let cursor = index - 1;
while (cursor >= 0 && (text[cursor] === " " || text[cursor] === "\t")) {
cursor -= 1;
}
return cursor < 0 || text[cursor] === "\n" || text[cursor] === "\r";
}
function findBalancedJsonObjectEnd(text, startIndex) {
let depth = 0;
let inString = false;

View File

@@ -67,6 +67,19 @@ function parseJson(text) {
}
}
function isJsonObjectRecordStart(text, index) {
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
const char = text[cursor];
if (char === "\n" || char === "\r") {
return true;
}
if (char !== " " && char !== "\t") {
return false;
}
}
return true;
}
function parseJsonObjectsFromText(text) {
const payloads = [];
let start = -1;
@@ -77,7 +90,7 @@ function parseJsonObjectsFromText(text) {
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (start === -1) {
if (char === "{") {
if (char === "{" && isJsonObjectRecordStart(text, index)) {
start = index;
depth = 1;
inString = false;

View File

@@ -2,6 +2,7 @@
import { spawn } from "node:child_process";
const DEFAULT_TIMEOUT_KILL_GRACE_MS = 30_000;
const PARENT_TERMINATION_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
const usage = () => {
console.error("Usage: assertions.mjs <run-with-timeout|assert-image-providers> [...]");
@@ -60,6 +61,17 @@ const waitForProcessGroupExit = async (child, timeout) => {
return !processGroupAlive(child);
};
const resolveSignalExitCode = (signal) => {
switch (signal) {
case "SIGINT":
return 130;
case "SIGHUP":
return 129;
default:
return 143;
}
};
const runWithTimeout = async (timeout, command, commandArgs) => {
const killGrace = parsePositiveNumber(
process.env.OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_KILL_GRACE_MS ??
@@ -72,8 +84,14 @@ const runWithTimeout = async (timeout, command, commandArgs) => {
stdio: ["ignore", "pipe", "pipe"],
});
let timedOut = false;
let parentSignal = null;
let killTimer;
let killDeadlineAt = 0;
const scheduleForceKill = () => {
killDeadlineAt = Date.now() + killGrace;
killTimer ??= setTimeout(() => signalChild(child, "SIGKILL"), killGrace);
killTimer.unref();
};
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
@@ -83,12 +101,29 @@ const runWithTimeout = async (timeout, command, commandArgs) => {
const timeoutTimer = setTimeout(() => {
timedOut = true;
signalChild(child, "SIGTERM");
killDeadlineAt = Date.now() + killGrace;
killTimer = setTimeout(() => signalChild(child, "SIGKILL"), killGrace);
killTimer.unref();
scheduleForceKill();
}, timeout);
timeoutTimer.unref();
const parentSignalHandlers = new Map(
PARENT_TERMINATION_SIGNALS.map((signal) => [
signal,
() => {
parentSignal ??= signal;
signalChild(child, signal);
scheduleForceKill();
},
]),
);
for (const [signal, handler] of parentSignalHandlers) {
process.on(signal, handler);
}
const cleanupParentSignalHandlers = () => {
for (const [signal, handler] of parentSignalHandlers) {
process.off(signal, handler);
}
};
let spawnError;
child.on("error", (error) => {
spawnError = error;
@@ -98,7 +133,8 @@ const runWithTimeout = async (timeout, command, commandArgs) => {
});
clearTimeout(timeoutTimer);
if (timedOut) {
cleanupParentSignalHandlers();
if (timedOut || parentSignal) {
const remainingGraceMs = Math.max(0, killDeadlineAt - Date.now());
if (remainingGraceMs > 0) {
await waitForProcessGroupExit(child, remainingGraceMs);
@@ -108,6 +144,11 @@ const runWithTimeout = async (timeout, command, commandArgs) => {
await waitForProcessGroupExit(child, 100);
}
clearTimeout(killTimer);
}
if (parentSignal) {
process.exit(resolveSignalExitCode(parentSignal));
}
if (timedOut) {
console.error(`command timed out after ${timeout}ms: ${command}`);
process.exit(1);
}

View File

@@ -102,10 +102,44 @@ function readPluginsList() {
`Unable to list packaged bundled plugins: ${result.stderr || result.stdout || `exit ${result.status}`}`,
);
}
const payload = JSON.parse(result.stdout);
const payload = parsePluginListOutput(result.stdout);
return Array.isArray(payload.plugins) ? payload.plugins : [];
}
function parsePluginListOutput(stdout) {
const trimmed = stdout.trim();
const parsed = parseJsonValue(trimmed);
if (parsed.ok) {
return parsed.value;
}
let lastParsed;
for (const line of trimmed.split(/\r?\n/u).toReversed()) {
if (!line.trimStart().startsWith("{")) {
continue;
}
const candidate = parseJsonValue(line);
if (!candidate.ok) {
continue;
}
lastParsed ??= candidate.value;
if (Array.isArray(candidate.value?.plugins)) {
return candidate.value;
}
}
if (lastParsed !== undefined) {
return lastParsed;
}
throw new Error(`Unable to parse packaged bundled plugin list JSON: ${trimmed}`);
}
function parseJsonValue(text) {
try {
return { ok: true, value: JSON.parse(text) };
} catch {
return { ok: false };
}
}
function pluginRequiresConfig(pluginDir) {
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
if (!fs.existsSync(manifestPath)) {

View File

@@ -641,8 +641,16 @@ function processTreeIsAlive(child) {
}
}
function signalChildProcessTree(child, signal) {
if (process.platform !== "win32" && typeof child.pid === "number") {
function defaultRunTaskkill(command, args, options) {
return childProcess.spawnSync(command, args, options);
}
export function signalChildProcessTree(
child,
signal,
{ platform = process.platform, runTaskkill = defaultRunTaskkill } = {},
) {
if (platform !== "win32" && typeof child.pid === "number") {
try {
process.kill(-child.pid, signal);
return;
@@ -651,6 +659,16 @@ function signalChildProcessTree(child, signal) {
// the legacy direct-child kill path as the fallback.
}
}
if (platform === "win32" && typeof child.pid === "number") {
const args = ["/PID", String(child.pid), "/T"];
if (signal === "SIGKILL") {
args.push("/F");
}
const result = runTaskkill("taskkill", args, { stdio: "ignore" });
if (!result?.error && result?.status === 0) {
return;
}
}
try {
child.kill(signal);
} catch (error) {
@@ -912,26 +930,52 @@ function parseJsonOutput(stdout) {
if (!trimmed) {
throw new Error("gateway call produced no JSON output");
}
try {
return JSON.parse(trimmed);
} catch {
const jsonStart = trimmed.indexOf("{");
if (jsonStart >= 0) {
try {
return JSON.parse(trimmed.slice(jsonStart));
} catch {
// Fall through to the line-oriented fallback below.
}
}
const jsonLine = trimmed
.split(/\r?\n/u)
.toReversed()
.find((line) => line.trim().startsWith("{"));
if (!jsonLine) {
throw new Error(`gateway call JSON output was not parseable:\n${trimmed}`);
}
return JSON.parse(jsonLine);
const parsed = parseJsonValue(trimmed);
if (parsed.ok) {
return parsed.value;
}
let lastParsed;
const lines = trimmed.split(/\r?\n/u);
for (let start = lines.length - 1; start >= 0; start -= 1) {
if (!lines[start].trimStart().startsWith("{")) {
continue;
}
let candidate = "";
for (let end = start; end < lines.length; end += 1) {
candidate = candidate ? `${candidate}\n${lines[end]}` : lines[end];
const candidateParsed = parseJsonValue(candidate);
if (!candidateParsed.ok) {
continue;
}
lastParsed ??= candidateParsed.value;
if (isGatewayJsonOutput(candidateParsed.value)) {
return candidateParsed.value;
}
break;
}
}
if (lastParsed !== undefined) {
return lastParsed;
}
throw new Error(`gateway call JSON output was not parseable:\n${trimmed}`);
}
function parseJsonValue(text) {
try {
return { ok: true, value: JSON.parse(text) };
} catch {
return { ok: false };
}
}
function isGatewayJsonOutput(raw) {
return (
raw?.ok === false ||
hasOwnPayloadField(raw, "result") ||
hasOwnPayloadField(raw, "payload") ||
hasOwnPayloadField(raw, "data")
);
}
function hasOwnPayloadField(raw, field) {

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env node
// Runs an E2E command under a pseudo-terminal.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import process from "node:process";
import { spawn } from "@lydell/node-pty";
import { spawn as spawnPty } from "@lydell/node-pty";
import { readPositiveIntEnv } from "./env-limits.mjs";
const [logPath, command, ...args] = process.argv.slice(2);
@@ -17,6 +17,9 @@ if (!logPath || !command) {
let exiting = false;
let forwardedSignal = null;
let forceKillTimer = null;
let terminationDrainTimer = null;
let terminationPids = [];
let pendingExitCode = null;
let logFailed = false;
const outputLimitMarker = `\n[run-with-pty output truncated after ${OUTPUT_MAX_BYTES} bytes]\n`;
const outputState = {
@@ -25,7 +28,7 @@ const outputState = {
};
const log = fs.createWriteStream(logPath, { flags: "w" });
const pty = spawn(command, args, {
const pty = spawnPty(command, args, {
name: process.env.TERM || "xterm-256color",
cols: readPositiveIntEnv("COLUMNS", 120),
rows: readPositiveIntEnv("LINES", 40),
@@ -43,11 +46,7 @@ log.on("error", (error) => {
process.exit(1);
}
if (!exiting) {
pty.kill("SIGTERM");
forceKillTimer ??= setTimeout(() => {
pty.kill("SIGKILL");
}, FORCE_KILL_MS);
forceKillTimer.unref?.();
terminatePtyTree("SIGTERM");
}
});
@@ -86,18 +85,23 @@ pty.onData((data) => {
pty.onExit(({ exitCode, signal }) => {
exiting = true;
clearTimeout(forceKillTimer);
if (terminationPids.length === 0) {
clearTerminationTimers();
}
if (logFailed) {
process.exit(1);
exitWhenTerminationDrains(1);
return;
}
log.end(() => {
if (forwardedSignal) {
process.exit(signalExitCode(forwardedSignal));
exitWhenTerminationDrains(signalExitCode(forwardedSignal));
return;
}
if (typeof exitCode === "number") {
process.exit(exitCode);
exitWhenTerminationDrains(exitCode);
return;
}
process.exit(signal ? 128 + signal : 1);
exitWhenTerminationDrains(signal ? 128 + signal : 1);
});
});
@@ -109,15 +113,108 @@ for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
process.on(signal, () => {
if (!exiting) {
forwardedSignal ??= signal;
pty.kill(signal);
forceKillTimer ??= setTimeout(() => {
pty.kill("SIGKILL");
}, FORCE_KILL_MS);
forceKillTimer.unref?.();
terminatePtyTree(signal);
}
});
}
function terminatePtyTree(signal) {
// node-pty kill() targets only pty.pid on Unix; wrapper-owned shutdowns
// keep the captured child tree alive until ignored descendants drain.
if (terminationPids.length === 0) {
terminationPids = collectPtyProcessTreePids();
}
signalPtyProcessTree(signal);
forceKillTimer ??= setTimeout(() => {
signalPtyProcessTree("SIGKILL");
}, FORCE_KILL_MS);
forceKillTimer.unref?.();
}
function exitWhenTerminationDrains(exitCode) {
pendingExitCode = exitCode;
if (processTreeIsAlive(terminationPids)) {
terminationDrainTimer ??= setInterval(finishIfTerminationDrained, 25);
return;
}
finishIfTerminationDrained();
}
function finishIfTerminationDrained() {
if (processTreeIsAlive(terminationPids)) {
return;
}
clearTerminationTimers();
process.exit(pendingExitCode ?? 1);
}
function clearTerminationTimers() {
if (forceKillTimer) {
clearTimeout(forceKillTimer);
forceKillTimer = null;
}
if (terminationDrainTimer) {
clearInterval(terminationDrainTimer);
terminationDrainTimer = null;
}
}
function collectPtyProcessTreePids() {
if (process.platform === "win32" || typeof pty.pid !== "number") {
return typeof pty.pid === "number" ? [pty.pid] : [];
}
const ps = spawnSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" });
if (ps.status !== 0) {
return [pty.pid];
}
const childrenByParent = new Map();
for (const line of ps.stdout.split("\n")) {
const match = line.trim().match(/^(\d+)\s+(\d+)$/u);
if (!match) {
continue;
}
const pid = Number(match[1]);
const ppid = Number(match[2]);
const siblings = childrenByParent.get(ppid) ?? [];
siblings.push(pid);
childrenByParent.set(ppid, siblings);
}
const pids = [pty.pid];
for (const parentPid of pids) {
for (const pid of childrenByParent.get(parentPid) ?? []) {
pids.push(pid);
}
}
return [...new Set(pids)];
}
function processTreeIsAlive(pids) {
return pids.some((pid) => {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error?.code === "EPERM";
}
});
}
function signalPtyProcessTree(signal) {
if (process.platform === "win32" || terminationPids.length === 0) {
pty.kill(signal);
return;
}
for (const pid of terminationPids.toReversed()) {
try {
process.kill(pid, signal);
} catch (error) {
if (error?.code !== "ESRCH") {
throw error;
}
}
}
}
function signalExitCode(signal) {
switch (signal) {
case "SIGHUP":

View File

@@ -147,7 +147,9 @@ function escapeRegExp(value) {
}
function redactDiagnosticText(text, extraSecrets = []) {
let redacted = text;
let redacted = text
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/giu, "Bearer <redacted>")
.replace(/openwebui-session=[^;"\s]+/giu, "openwebui-session=<redacted>");
for (const secret of [email, password, ...extraSecrets]) {
if (!secret) {
continue;
@@ -175,6 +177,13 @@ function cookieSecretValues(cookieHeader) {
.filter(Boolean);
}
function authDiagnosticSecretValues(authHeaders) {
const authorization = typeof authHeaders.authorization === "string" ? authHeaders.authorization : "";
const bearerToken = authorization.startsWith("Bearer ") ? authorization.slice("Bearer ".length) : "";
const cookie = typeof authHeaders.cookie === "string" ? authHeaders.cookie : "";
return [bearerToken, authorization, cookie, ...cookieSecretValues(cookie)].filter(Boolean);
}
async function fetchSignin() {
return await withRequestTimeout(
"Open WebUI signin",
@@ -325,7 +334,11 @@ const chatJson = await fetchChatCompletion(authHeaders, targetModel, diagnosticS
const reply =
chatJson?.choices?.[0]?.message?.content ?? chatJson?.message?.content ?? chatJson?.content ?? "";
if (typeof reply !== "string" || !reply.includes(expectedNonce)) {
throw new Error(`chat reply missing nonce: ${JSON.stringify(reply)}`);
const diagnosticReply = redactDiagnosticText(JSON.stringify(reply), [
...diagnosticSecrets,
...authDiagnosticSecretValues(authHeaders),
]);
throw new Error(`chat reply missing nonce: ${diagnosticReply}`);
}
console.log(JSON.stringify({ ok: true, model: targetModel, reply }, null, 2));

View File

@@ -4,6 +4,7 @@ export * from "./env-limits.ts";
export * from "./host-command.ts";
export * from "./host-server.ts";
export * from "./lane-runner.ts";
export * from "./macos-users.ts";
export * from "./package-artifact.ts";
export * from "./parallels-vm.ts";
export * from "./plugin-isolation.ts";

View File

@@ -94,6 +94,10 @@ export async function runWindowsBackgroundPowerShell(
const safeLabel = options.label.replaceAll(/[^A-Za-z0-9_-]/g, "-");
const nonce = `${safeLabel}-${randomUUID()}`;
const fileBase = `openclaw-parallels-${nonce}`;
const logLengthPrefix = `__OPENCLAW_LOG_LENGTH__:${nonce}:`;
const logOffsetPrefix = `__OPENCLAW_LOG_OFFSET__:${nonce}:`;
const backgroundExitPrefix = `__OPENCLAW_BACKGROUND_EXIT__:${nonce}:`;
const backgroundDoneMarker = `__OPENCLAW_BACKGROUND_DONE__:${nonce}`;
const pathsScript = `$base = Join-Path $env:TEMP ${psSingleQuote(fileBase)}
$scriptPath = "$base.ps1"
$logPath = "$base.log"
@@ -187,10 +191,11 @@ Write-OpenClawUtf8File $pidPath ([string]$process.Id)
}
lastLaunchStatus = launch.status;
if (launch.status === 0 || launch.status === 124) {
const materialized = waitForWindowsBackgroundMaterialized({
const materialized = await waitForWindowsBackgroundMaterialized({
append,
deadline,
pathsScript,
pollIntervalMs,
runCommand,
vmName: options.vmName,
});
@@ -237,7 +242,7 @@ if (Test-Path $logPath) {
$stream = [System.IO.File]::Open($logPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
try {
$length = $stream.Length
"__OPENCLAW_LOG_LENGTH__:$length"
${psSingleQuote(logLengthPrefix)} + $length
if ($length -gt $offset) {
[void]$stream.Seek($offset, [System.IO.SeekOrigin]::Begin)
$count = [int][Math]::Min($length - $offset, ${logChunkBytes})
@@ -245,7 +250,7 @@ if (Test-Path $logPath) {
$read = $stream.Read($buffer, 0, $count)
if ($read -gt 0) {
$nextOffset = $offset + $read
"__OPENCLAW_LOG_OFFSET__:$nextOffset"
${psSingleQuote(logOffsetPrefix)} + $nextOffset
[System.Text.Encoding]::UTF8.GetString($buffer, 0, $read)
}
}
@@ -255,8 +260,8 @@ if (Test-Path $logPath) {
}
if (Test-Path $donePath) {
$backgroundExit = if (Test-Path $exitPath) { (Get-Content -Path $exitPath -Raw).Trim() } else { '0' }
"__OPENCLAW_BACKGROUND_EXIT__:$backgroundExit"
'__OPENCLAW_BACKGROUND_DONE__'
${psSingleQuote(backgroundExitPrefix)} + $backgroundExit
${psSingleQuote(backgroundDoneMarker)}
if ($backgroundExit -ne '0') { exit 23 }
exit 0
}`),
@@ -264,21 +269,20 @@ if (Test-Path $donePath) {
{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 30_000) },
);
appendOutput(append, poll);
const offsetMatch = poll.stdout.match(/__OPENCLAW_LOG_OFFSET__:(\d+)/);
if (offsetMatch) {
lastLogOffset = Number(offsetMatch[1]);
const offsetRaw = findControlValue(poll.stdout, logOffsetPrefix);
if (offsetRaw) {
lastLogOffset = Number(offsetRaw);
}
const lengthMatch = poll.stdout.match(/__OPENCLAW_LOG_LENGTH__:(\d+)/);
const logLength = lengthMatch ? Number(lengthMatch[1]) : lastLogOffset;
if (poll.stdout.includes("__OPENCLAW_BACKGROUND_DONE__")) {
const lengthRaw = findControlValue(poll.stdout, logLengthPrefix);
const logLength = lengthRaw ? Number(lengthRaw) : lastLogOffset;
if (hasControlLine(poll.stdout, backgroundDoneMarker)) {
doneSeen = true;
completedLogDrainDeadline ||= Date.now() + completedLogDrainGraceMs;
if (lastLogOffset < logLength) {
await sleep(Math.min(pollIntervalMs, 100));
continue;
}
const exitMatch = poll.stdout.match(/__OPENCLAW_BACKGROUND_EXIT__:(\S+)/);
const backgroundExit = exitMatch?.[1] ?? "0";
const backgroundExit = findControlValue(poll.stdout, backgroundExitPrefix) ?? "0";
if (backgroundExit !== "0" || (poll.status !== 0 && poll.status !== 124)) {
throw new Error(`${options.label} failed`);
}
@@ -297,13 +301,23 @@ if (Test-Path $donePath) {
}
}
function waitForWindowsBackgroundMaterialized(params: {
function findControlValue(output: string, prefix: string): string | undefined {
const line = output.split(/\r?\n/u).find((entry) => entry.startsWith(prefix));
return line?.slice(prefix.length).trim();
}
function hasControlLine(output: string, marker: string): boolean {
return output.split(/\r?\n/u).some((entry) => entry.trimEnd() === marker);
}
async function waitForWindowsBackgroundMaterialized(params: {
append?: (chunk: string | Uint8Array) => void;
deadline: number;
pathsScript: string;
pollIntervalMs: number;
runCommand: typeof run;
vmName: string;
}): boolean {
}): Promise<boolean> {
const materializeDeadline = Math.min(Date.now() + 45_000, params.deadline);
while (Date.now() < materializeDeadline) {
const result = params.runCommand(
@@ -328,6 +342,7 @@ if ((Test-Path $logPath) -or (Test-Path $donePath)) {
if (result.stdout.includes("materialized")) {
return true;
}
await sleep(Math.min(params.pollIntervalMs, Math.max(1, materializeDeadline - Date.now())));
}
return false;
}

View File

@@ -114,6 +114,9 @@ let timedOut = false;
let killTimer;
let killDeadlineAt = 0;
let outputExceeded = false;
let forwardedSignal;
let forwardedSignalKillTimer;
let forwardedSignalPostForceTimer;
let stderrBytes = 0;
let stdoutBytes = 0;
@@ -189,6 +192,43 @@ function finishTimedOutAfterCleanup() {
}, Math.max(0, killDeadlineAt - Date.now()));
}
function finishForwardedSignal() {
if (!forwardedSignal) {
return;
}
if (forwardedSignalKillTimer) {
clearTimeout(forwardedSignalKillTimer);
}
if (forwardedSignalPostForceTimer) {
clearTimeout(forwardedSignalPostForceTimer);
}
process.kill(process.pid, forwardedSignal);
}
function finishForwardedSignalAfterCleanup() {
if (!forwardedSignal) {
return;
}
if (!groupAlive()) {
finishForwardedSignal();
return;
}
if (forwardedSignalKillTimer) {
return;
}
forwardedSignalKillTimer = setTimeout(() => {
if (groupAlive()) {
signalGroup("SIGKILL");
forwardedSignalPostForceTimer = setTimeout(
finishForwardedSignal,
Math.max(1, Math.min(25, payload.timeoutKillGraceMs)),
);
} else {
finishForwardedSignal();
}
}, payload.timeoutKillGraceMs);
}
function forwardBounded(stream, chunk) {
const currentBytes = stream === "stdout" ? stdoutBytes : stderrBytes;
const nextBytes = currentBytes + chunk.byteLength;
@@ -219,8 +259,9 @@ function forwardBounded(stream, chunk) {
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
process.once(signal, () => {
forwardedSignal ||= signal;
signalGroup(signal);
process.kill(process.pid, signal);
finishForwardedSignalAfterCleanup();
});
}
@@ -257,6 +298,10 @@ child.on("error", (error) => {
});
child.on("close", (code, signal) => {
clearTimeout(timeout);
if (forwardedSignal) {
finishForwardedSignalAfterCleanup();
return;
}
if (timedOut) {
finishTimedOutAfterCleanup();
return;
@@ -547,18 +592,63 @@ export async function runStreaming(
signalStreamingChild("SIGTERM");
});
const parentSignalHandlers = new Map<NodeJS.Signals, () => void>();
let forwardedParentSignal: NodeJS.Signals | undefined;
let parentSignalKillTimer: NodeJS.Timeout | undefined;
let parentSignalPostForceTimer: NodeJS.Timeout | undefined;
const removeParentSignalHandlers = (): void => {
for (const [signal, handler] of parentSignalHandlers) {
process.off(signal, handler);
}
parentSignalHandlers.clear();
};
const clearParentSignalTimers = (): void => {
if (parentSignalKillTimer) {
clearTimeout(parentSignalKillTimer);
parentSignalKillTimer = undefined;
}
if (parentSignalPostForceTimer) {
clearTimeout(parentSignalPostForceTimer);
parentSignalPostForceTimer = undefined;
}
};
const finishParentSignal = (): void => {
if (!forwardedParentSignal) {
return;
}
clearParentSignalTimers();
removeParentSignalHandlers();
process.kill(process.pid, forwardedParentSignal);
};
const finishParentSignalAfterCleanup = (): void => {
if (!forwardedParentSignal) {
return;
}
if (!streamingProcessGroupAlive()) {
finishParentSignal();
return;
}
if (parentSignalKillTimer) {
return;
}
parentSignalKillTimer = setTimeout(() => {
if (streamingProcessGroupAlive()) {
signalHostCommandProcess(childPid, "SIGKILL");
parentSignalPostForceTimer = setTimeout(
finishParentSignal,
HOST_COMMAND_POST_FORCE_KILL_WAIT_MS,
);
} else {
finishParentSignal();
}
}, HOST_COMMAND_TIMEOUT_KILL_GRACE_MS);
};
if (process.platform !== "win32" && options.timeoutMs != null) {
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] as const) {
const handler = (): void => {
forwardedParentSignal ??= signal;
signalHostCommandProcess(childPid, signal);
removeParentSignalHandlers();
process.kill(process.pid, signal);
finishParentSignalAfterCleanup();
};
parentSignalHandlers.set(signal, handler);
process.once(signal, handler);
@@ -637,6 +727,7 @@ export async function runStreaming(
if (killTimer) {
clearTimeout(killTimer);
}
clearParentSignalTimers();
removeParentSignalHandlers();
logStream?.destroy();
reject(error);
@@ -646,6 +737,10 @@ export async function runStreaming(
if (timer) {
clearTimeout(timer);
}
if (forwardedParentSignal) {
finishParentSignalAfterCleanup();
return;
}
removeParentSignalHandlers();
if (timedOut) {
await waitForStreamingTimeoutCleanup();
@@ -653,6 +748,7 @@ export async function runStreaming(
if (killTimer) {
clearTimeout(killTimer);
}
clearParentSignalTimers();
if (logStream) {
logStream.end();
await finished(logStream);

View File

@@ -151,11 +151,11 @@ async function waitForHostServer(
});
const startedAt = Date.now();
while (Date.now() - startedAt < 10_000) {
if (child.exitCode != null) {
if (hasHostServerChildExited(child)) {
if (!childClosed) {
await Promise.race([childClose, delay(HOST_SERVER_STDERR_DRAIN_MS)]);
}
die(`host artifact server exited early: ${stderr.trim() || `exit ${child.exitCode}`}`);
die(`host artifact server exited early: ${stderr.trim() || formatHostServerExit(child)}`);
}
if (await canConnect(port)) {
return;
@@ -176,6 +176,10 @@ function appendBoundedOutput(previous: string, chunk: Buffer, limitBytes: number
return combined.subarray(combined.byteLength - limitBytes).toString("utf8");
}
function formatHostServerExit(child: ChildProcessWithoutNullStreams): string {
return child.signalCode ? `signal ${child.signalCode}` : `exit ${child.exitCode ?? "unknown"}`;
}
async function canConnect(port: number): Promise<boolean> {
return await new Promise((resolve) => {
const socket = createConnection({ host: "127.0.0.1", port });

View File

@@ -510,9 +510,13 @@ run_apt_with_lock_retry apt-get -o DPkg::Lock::Timeout=30 install -y curl ca-cer
this.guest.bash(`
set -e
if command -v curl >/dev/null 2>&1; then
curl -fsSL ${shellQuote(url)} -o ${shellQuote(outputPath)}
curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${shellQuote(
url,
)} -o ${shellQuote(outputPath)}
else
wget -q -O ${shellQuote(outputPath)} ${shellQuote(url)}
wget -q --timeout=10 --read-timeout=120 --tries=3 -O ${shellQuote(outputPath)} ${shellQuote(
url,
)}
fi`);
}

View File

@@ -10,8 +10,10 @@ import {
currentRunningSnapshotInfo,
extractLastOpenClawVersionFromLog,
makeTempDir,
isLikelyMacosDesktopHome,
packageBuildCommitFromTgz,
packageVersionFromTgz,
parseMacosDsclUserHomeLine,
packOpenClaw,
parseMode,
parseProvider,
@@ -690,10 +692,11 @@ exec node "$entry" ${argv}`,
},
).stdout.replaceAll("\r", "");
for (const line of users.split("\n")) {
const [user, home] = line.trim().split(/\s+/);
const parsed = parseMacosDsclUserHomeLine(line);
const user = parsed?.user;
if (
user &&
home?.startsWith("/Users/") &&
isLikelyMacosDesktopHome(parsed?.home) &&
!user.startsWith("_") &&
user !== "Shared" &&
user !== ".localized"
@@ -806,7 +809,9 @@ rm -f /tmp/openclaw-parallels-macos-gateway.log`);
private installLatestRelease(): void {
this.guestSh(
`export OPENCLAW_NO_ONBOARD=1
curl -fsSL ${shellQuote(this.options.installUrl)} -o /tmp/openclaw-install.sh
curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${shellQuote(
this.options.installUrl,
)} -o /tmp/openclaw-install.sh
bash /tmp/openclaw-install.sh --version ${shellQuote(this.installVersion)}
${guestOpenClaw} --version`,
);
@@ -834,7 +839,9 @@ ${guestOpenClaw} --version`);
}
const tgzUrl = this.server.urlFor(this.artifact.path);
this.guestSh(`printf 'install-source: host-tgz %s\\n' ${shellQuote(tgzUrl)}
curl -fsSL ${shellQuote(tgzUrl)} -o /tmp/${tempName}
curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${shellQuote(
tgzUrl,
)} -o /tmp/${tempName}
${guestNpm} install -g /tmp/${tempName}
${guestOpenClaw} --version`);
}

View File

@@ -0,0 +1,13 @@
// macOS user helpers support Parallels guest fallback discovery.
export function parseMacosDsclUserHomeLine(line: string): { user: string; home: string } | null {
const match = /^(\S+)\s+(.+?)\s*$/u.exec(line.replaceAll("\r", ""));
if (!match) {
return null;
}
return { user: match[1], home: match[2] };
}
export function isLikelyMacosDesktopHome(home: string | undefined): boolean {
const normalized = home?.trim();
return Boolean(normalized) && /(?:^|\/)Users\/[^/]+$/u.test(normalized);
}

View File

@@ -10,10 +10,12 @@ import {
die,
ensureValue,
extractLastOpenClawVersionFromLog,
isLikelyMacosDesktopHome,
makeTempDir,
packOpenClaw,
packageBuildCommitFromTgz,
packageVersionFromTgz,
parseMacosDsclUserHomeLine,
parsePlatformList,
parseProvider,
readPositiveIntEnv,
@@ -823,10 +825,16 @@ export class NpmUpdateSmoke {
}
const output = run(
"bash",
["-lc", `curl -fsSL ${shellQuote(tarball)} | tar -xzOf - package/dist/build-info.json`],
[
"-lc",
`curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${shellQuote(
tarball,
)} | tar -xzOf - package/dist/build-info.json`,
],
{
check: false,
quiet: true,
timeoutMs: 150_000,
},
).stdout.trim();
if (!output) {
@@ -1096,10 +1104,11 @@ export class NpmUpdateSmoke {
{ check: false, quiet: true, timeoutMs: 30_000 },
).stdout.replaceAll("\r", "");
for (const line of users.split("\n")) {
const [user, home] = line.trim().split(/\s+/);
const parsed = parseMacosDsclUserHomeLine(line);
const user = parsed?.user;
if (
user &&
home?.startsWith("/Users/") &&
isLikelyMacosDesktopHome(parsed?.home) &&
!user.startsWith("_") &&
user !== "Shared" &&
user !== ".localized"
@@ -1116,8 +1125,8 @@ export class NpmUpdateSmoke {
["exec", this.macosVm, "/usr/bin/dscl", ".", "-read", `/Users/${user}`, "NFSHomeDirectory"],
{ check: false, quiet: true, timeoutMs: 30_000 },
).stdout.replaceAll("\r", "");
const match = /NFSHomeDirectory:\s*(\S+)/.exec(output);
return match?.[1] ?? `/Users/${user}`;
const match = /^NFSHomeDirectory:\s+(.+)$/m.exec(output);
return match?.[1]?.trim() || `/Users/${user}`;
}
private async guestWindows(
@@ -1226,6 +1235,8 @@ export class NpmUpdateSmoke {
let timedOut = false;
let killTimer: NodeJS.Timeout | undefined;
let forceKillAt: number | undefined;
const timeoutKillGraceMs = freshLaneTimeoutKillGraceMs;
const signalChild = (signal: NodeJS.Signals): void => {
if (!child.pid) {
return;
@@ -1246,7 +1257,8 @@ export class NpmUpdateSmoke {
}
timedOut = true;
signalChild("SIGTERM");
killTimer = setTimeout(() => signalChild("SIGKILL"), 2_000);
forceKillAt = Date.now() + timeoutKillGraceMs;
killTimer = setTimeout(() => signalChild("SIGKILL"), timeoutKillGraceMs);
killTimer.unref();
};
if (ctx.signal.aborted) {
@@ -1273,8 +1285,10 @@ export class NpmUpdateSmoke {
clearTimeout(killTimer);
}
if (timedOut) {
signalChild("SIGKILL");
resolve(124);
void finishTimedOutLoggedProcessTree(child, {
forceKillAt,
timeoutKillGraceMs,
}).then(() => resolve(124), reject);
return;
}
resolve(code ?? (signal ? 128 : 1));

View File

@@ -194,18 +194,27 @@ async function withPackageLock<T>(lockDir: string, fn: () => Promise<T>): Promis
}
}
async function acquirePackageLock(lockDir: string, ownerToken: string): Promise<void> {
async function acquirePackageLock(
lockDir: string,
ownerToken: string,
params: { writeOwner?: (lockDir: string, ownerToken: string) => Promise<void> } = {},
): Promise<void> {
const timeoutMs = readPositiveIntEnv("OPENCLAW_PARALLELS_PACKAGE_LOCK_TIMEOUT_MS", 30 * 60_000);
const staleMs = readPositiveIntEnv("OPENCLAW_PARALLELS_PACKAGE_LOCK_STALE_MS", 2 * 60 * 60_000);
const startedAt = Date.now();
let waitAnnouncementBudget = 1;
const consumeWaitAnnouncement = () => waitAnnouncementBudget-- > 0;
while (Date.now() - startedAt < timeoutMs) {
let createdLockDir = false;
try {
await mkdir(lockDir);
await writeLockOwner(lockDir, ownerToken);
createdLockDir = true;
await (params.writeOwner ?? writeLockOwner)(lockDir, ownerToken);
return;
} catch (error) {
if (createdLockDir) {
await rm(lockDir, { force: true, recursive: true }).catch(() => undefined);
}
if (!isErrorCode(error, "EEXIST")) {
throw error;
}
@@ -248,7 +257,7 @@ async function removeStalePackageLock(lockDir: string, staleMs: number): Promise
return;
}
const ageMs = Date.now() - ((await stat(lockDir).catch(() => undefined))?.mtimeMs ?? Date.now());
if (owner || ageMs >= staleMs) {
if (owner?.pid !== undefined || staleMs <= 0 || ageMs >= staleMs) {
await rm(lockDir, { force: true, recursive: true }).catch(() => undefined);
}
}
@@ -261,7 +270,10 @@ async function readLockOwner(lockDir: string): Promise<{ pid?: number; token?: s
try {
const parsed = JSON.parse(text) as { pid?: unknown; token?: unknown };
return {
pid: typeof parsed.pid === "number" ? parsed.pid : undefined,
pid:
typeof parsed.pid === "number" && Number.isSafeInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: undefined,
token: typeof parsed.token === "string" ? parsed.token : undefined,
};
} catch {
@@ -287,3 +299,9 @@ async function delay(ms: number): Promise<void> {
setTimeout(resolve, ms);
});
}
export const testing = {
acquirePackageLock,
removeStalePackageLock,
readLockOwner,
};

View File

@@ -125,7 +125,7 @@ if (Test-Path $portableGit) {
Remove-Item $portableGit -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $portableGit | Out-Null
curl.exe -fsSL ${psSingleQuote(minGitUrl)} -o $archive
curl.exe -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${psSingleQuote(minGitUrl)} -o $archive
tar.exe -xf $archive -C $portableGit
Remove-Item $archive -Force -ErrorAction SilentlyContinue
$env:PATH = "$portableGit\\cmd;$portableGit\\mingw64\\bin;$portableGit\\usr\\bin;$env:PATH"

View File

@@ -542,7 +542,7 @@ ${cleanScript}`,
const versionArg = this.installVersion ? ` -Tag ${psSingleQuote(this.installVersion)}` : "";
this.guestPowerShell(
`$ErrorActionPreference = 'Stop'
$script = Invoke-RestMethod -Uri ${psSingleQuote(this.options.installUrl)}
$script = Invoke-RestMethod -Uri ${psSingleQuote(this.options.installUrl)} -TimeoutSec 120
& ([scriptblock]::Create($script))${versionArg} -NoOnboard
if ($LASTEXITCODE -ne 0) { throw "installer failed with exit code $LASTEXITCODE" }
Invoke-OpenClaw --version
@@ -559,7 +559,7 @@ if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LAST
this.guestPowerShell(
`$ErrorActionPreference = 'Stop'
$tgz = Join-Path $env:TEMP ${psSingleQuote(tempName)}
curl.exe -fsSL ${psSingleQuote(tgzUrl)} -o $tgz
curl.exe -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${psSingleQuote(tgzUrl)} -o $tgz
npm.cmd install -g $tgz --no-fund --no-audit --loglevel=error
if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE" }
Invoke-OpenClaw --version

View File

@@ -190,12 +190,76 @@ function parseJsonOutput(stdout) {
if (!text) {
throw new Error("expected JSON output, got empty stdout");
}
const first = text.indexOf("{");
const last = text.lastIndexOf("}");
if (first < 0 || last < first) {
const parsed = parseJsonObjectsFromMixedOutput(text).at(-1);
if (parsed === undefined) {
throw new Error(`expected JSON object output, got: ${scrub(text.slice(0, 500))}`);
}
return JSON.parse(text.slice(first, last + 1));
return parsed;
}
function isJsonRecordStart(text, index) {
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
const char = text[cursor];
if (char === "\n" || char === "\r") {
return true;
}
if (char !== " " && char !== "\t") {
return false;
}
}
return true;
}
function parseJsonObjectsFromMixedOutput(text) {
const objects = [];
let start = -1;
let depth = 0;
let inString = false;
let escaped = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (start === -1) {
if (char === "{" && isJsonRecordStart(text, index)) {
start = index;
depth = 1;
inString = false;
escaped = false;
}
continue;
}
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === "{") {
depth += 1;
continue;
}
if (char !== "}") {
continue;
}
depth -= 1;
if (depth === 0) {
try {
objects.push(JSON.parse(text.slice(start, index + 1)));
} catch {}
start = -1;
}
}
return objects;
}
function resolveOpenClawRunner() {
@@ -297,6 +361,7 @@ function runCommand(command, args, options = {}) {
const stderr = createOutputCapture("stderr");
let timedOut = false;
let aborted = false;
let parentSignalPending = null;
let killTimer;
let forceKillAt;
const armForceKill = () => {
@@ -339,15 +404,33 @@ function runCommand(command, args, options = {}) {
}
parentSignalHandlers.clear();
};
const finishTerminatedTree = async () => {
await finishTimedOutCommandProcessTree(child, {
forceKillAt,
timeoutKillGraceMs: COMMAND_TIMEOUT_KILL_GRACE_MS,
});
if (killTimer) {
clearTimeout(killTimer);
}
forceKillAt = undefined;
};
if (process.platform !== "win32" && child.pid) {
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
const handler = () => {
if (parentSignalPending) {
terminateProcessTree(child, "SIGKILL");
return;
}
parentSignalPending = signal;
terminateProcessTree(child, signal);
removeParentSignalHandlers();
process.kill(process.pid, signal);
armForceKill();
void finishTerminatedTree().finally(() => {
removeParentSignalHandlers();
process.kill(process.pid, signal);
});
};
parentSignalHandlers.set(signal, handler);
process.once(signal, handler);
process.on(signal, handler);
}
}
child.on("error", (error) => {
@@ -363,24 +446,18 @@ function runCommand(command, args, options = {}) {
child.on("close", (code, signal) => {
clearTimeout(timer);
abortSignal?.removeEventListener("abort", abort);
removeParentSignalHandlers();
const result = { code, signal, stdout: stdout.text(), stderr: stderr.text() };
const finishTerminatedTree = async () => {
await finishTimedOutCommandProcessTree(child, {
forceKillAt,
timeoutKillGraceMs: COMMAND_TIMEOUT_KILL_GRACE_MS,
});
if (killTimer) {
clearTimeout(killTimer);
}
forceKillAt = undefined;
};
if (aborted) {
removeParentSignalHandlers();
void finishTerminatedTree().finally(() =>
reject(new Error(scrub(`command aborted: ${command} ${args.join(" ")}`))),
);
return;
}
if (parentSignalPending) {
return;
}
removeParentSignalHandlers();
if (timedOut) {
void finishTerminatedTree().finally(() =>
reject(new Error(scrub(`command timed out: ${command} ${args.join(" ")}`))),
@@ -1687,7 +1764,7 @@ async function p12OpenAiLiveProof() {
return "OpenAI model auth probe consumed API key through plugin-managed auth-profile SecretRef";
}
async function runPtySecretsConfigurePreset(envCtx) {
async function runPtySecretsConfigurePreset(envCtx, options = {}) {
const { spawn } = await import("@lydell/node-pty");
const command = await resolveOpenClawCommand(
["secrets", "configure", "--providers-only", "--apply", "--yes", "--allow-exec", "--json"],
@@ -1702,16 +1779,39 @@ async function runPtySecretsConfigurePreset(envCtx) {
});
const output = createOutputCapture("secrets configure stdout");
let phase = "providers-menu";
const keyTimers = new Set();
const clearKeyTimers = () => {
for (const keyTimer of keyTimers) {
clearTimeout(keyTimer);
}
keyTimers.clear();
};
const sendKeys = (keys) => {
keys.forEach((key, index) => {
setTimeout(() => child.write(key), index * 80);
const keyTimer = setTimeout(() => {
keyTimers.delete(keyTimer);
child.write(key);
}, index * 80);
keyTimers.add(keyTimer);
});
};
return await new Promise((resolve, reject) => {
let timedOut = false;
let forceKillAt;
let forceKillTimer;
const timeoutMs = options.timeoutMs ?? 60000;
const timeoutKillGraceMs = options.timeoutKillGraceMs ?? COMMAND_TIMEOUT_KILL_GRACE_MS;
const timer = setTimeout(() => {
child.kill();
reject(new Error(`secrets configure preset timed out: ${scrub(output.text())}`));
}, 60000);
timedOut = true;
signalPtyProcessTree(child, "SIGHUP");
forceKillAt = Date.now() + timeoutKillGraceMs;
forceKillTimer = setTimeout(() => {
forceKillTimer = undefined;
forceKillAt = undefined;
signalPtyProcessTree(child, "SIGKILL");
}, timeoutKillGraceMs);
forceKillTimer.unref?.();
}, timeoutMs);
child.onData((data) => {
output.append(data);
const outputText = output.text();
@@ -1733,6 +1833,20 @@ async function runPtySecretsConfigurePreset(envCtx) {
});
child.onExit(({ exitCode }) => {
clearTimeout(timer);
clearKeyTimers();
if (timedOut) {
void finishTimedOutPtyProcessTree(child, {
forceKillAt,
forceKillTimer,
timeoutKillGraceMs,
}).finally(() =>
reject(new Error(`secrets configure preset timed out: ${scrub(output.text())}`)),
);
return;
}
if (forceKillTimer) {
clearTimeout(forceKillTimer);
}
if (exitCode !== 0) {
reject(new Error(`secrets configure preset failed (${exitCode}): ${scrub(output.text())}`));
return;
@@ -1742,6 +1856,57 @@ async function runPtySecretsConfigurePreset(envCtx) {
});
}
async function finishTimedOutPtyProcessTree(
child,
{ forceKillAt, forceKillTimer, timeoutKillGraceMs },
) {
const graceRemainingMs =
forceKillAt === undefined ? timeoutKillGraceMs : Math.max(0, forceKillAt - Date.now());
if (graceRemainingMs > 0) {
await waitForPtyProcessTreeExit(child, graceRemainingMs);
}
if (forceKillTimer) {
clearTimeout(forceKillTimer);
}
if (ptyProcessTreeIsAlive(child)) {
signalPtyProcessTree(child, "SIGKILL");
}
await waitForPtyProcessTreeExit(child, timeoutKillGraceMs);
}
function ptyProcessTreeIsAlive(child) {
if (process.platform === "win32" || typeof child.pid !== "number") {
return false;
}
try {
process.kill(-child.pid, 0);
return true;
} catch (error) {
return error?.code === "EPERM";
}
}
async function waitForPtyProcessTreeExit(child, timeoutMs) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (!ptyProcessTreeIsAlive(child)) {
return true;
}
await delay(50);
}
return !ptyProcessTreeIsAlive(child);
}
function signalPtyProcessTree(child, signal) {
if (process.platform !== "win32" && typeof child.pid === "number") {
try {
process.kill(-child.pid, signal);
return;
} catch {}
}
child.kill(signal);
}
async function p13SecretsConfigurePreset() {
await withProofEnv("p13", async (envCtx) => {
const port = await allocatePort();
@@ -1955,6 +2120,7 @@ export {
cleanupEnv,
expectGatewayStartupFails,
gatewayCall,
parseJsonOutput,
runPtySecretsConfigurePreset,
runWithProof,
runCommand,

View File

@@ -148,6 +148,7 @@ export const COMMAND_STDERR_TAIL_CHARS = 256 * 1024;
export const COMMAND_FAILURE_STDOUT_TAIL_CHARS = 64 * 1024;
export const COMMAND_TIMEOUT_MS = 30 * 60 * 1000;
export const COMMAND_TIMEOUT_KILL_GRACE_MS = 5_000;
const COMMAND_PROCESS_TREE_EXIT_POLL_MS = 25;
export const REMOTE_SETUP_COMMAND_TIMEOUT_MS = 90 * 60 * 1000;
const REMOTE_ROOT = "/tmp/openclaw-telegram-user-crabbox";
const CREDENTIAL_SCRIPT = fileURLToPath(new URL("./telegram-user-credential.ts", import.meta.url));
@@ -608,6 +609,44 @@ function commandProcessTreeAlive(child: ChildProcess) {
}
}
async function waitForCommandProcessTreeExit(child: ChildProcess, timeoutMs: number) {
const deadlineAt = Date.now() + timeoutMs;
while (Date.now() < deadlineAt) {
if (!commandProcessTreeAlive(child)) {
return true;
}
await new Promise((resolvePoll) => {
setTimeout(resolvePoll, COMMAND_PROCESS_TREE_EXIT_POLL_MS);
});
}
return !commandProcessTreeAlive(child);
}
async function finishTimedOutCommandProcessTree(
child: ChildProcess,
options: {
forceKillAt: number | undefined;
timeoutKillGraceMs: number;
},
) {
if (!commandProcessTreeAlive(child)) {
activeCommandChildren.delete(child);
return;
}
const graceRemainingMs =
options.forceKillAt === undefined
? options.timeoutKillGraceMs
: Math.max(0, options.forceKillAt - Date.now());
if (graceRemainingMs > 0) {
await waitForCommandProcessTreeExit(child, graceRemainingMs);
}
if (commandProcessTreeAlive(child)) {
signalCommandTree(child, "SIGKILL");
await waitForCommandProcessTreeExit(child, options.timeoutKillGraceMs);
}
activeCommandChildren.delete(child);
}
function untrackCommandChild(child: ChildProcess) {
if (!commandProcessTreeAlive(child)) {
activeCommandChildren.delete(child);
@@ -664,6 +703,7 @@ export function runCommand(params: {
let settled = false;
let stdoutLimitError: string | null = null;
let timeoutError: Error | null = null;
let forceKillAt: number | undefined;
let killTimer: NodeJS.Timeout | undefined;
const timeoutMs = params.timeoutMs ?? COMMAND_TIMEOUT_MS;
const timeoutKillGraceMs = params.timeoutKillGraceMs ?? COMMAND_TIMEOUT_KILL_GRACE_MS;
@@ -684,6 +724,7 @@ export function runCommand(params: {
)}`,
);
signalCommandTree(child, "SIGTERM");
forceKillAt = Date.now() + timeoutKillGraceMs;
killTimer = setTimeout(() => {
signalCommandTree(child, "SIGKILL");
}, timeoutKillGraceMs);
@@ -736,9 +777,16 @@ export function runCommand(params: {
settled = true;
untrackCommandChild(child);
if (timeoutError) {
signalCommandTree(child, "SIGKILL");
const error = timeoutError;
clearTimers();
reject(timeoutError);
void finishTimedOutCommandProcessTree(child, {
forceKillAt,
timeoutKillGraceMs,
}).then(
() => reject(error),
(cleanupError: unknown) =>
reject(cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError))),
);
return;
}
clearTimers();
@@ -1882,6 +1930,40 @@ function writeSession(pathname: string, session: SessionFile) {
fs.chmodSync(pathname, 0o600);
}
const FULL_ARTIFACT_JSON_NAMES = new Set([
"probe.json",
"status.json",
"telegram-user-crabbox-proof-summary.json",
"telegram-user-crabbox-session-summary.json",
]);
const FULL_ARTIFACT_FILE_EXTENSIONS = new Set([".gif", ".log", ".md", ".mp4", ".png"]);
const TIMESTAMPED_PROBE_ARTIFACT_JSON = /^probe-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.json$/u;
function isFullArtifactJsonName(name: string) {
return FULL_ARTIFACT_JSON_NAMES.has(name) || TIMESTAMPED_PROBE_ARTIFACT_JSON.test(name);
}
export function stageFullSessionArtifacts(outputDir: string) {
const publishDir = path.join(outputDir, "publish-full-artifacts");
fs.rmSync(publishDir, { force: true, recursive: true });
fs.mkdirSync(publishDir, { recursive: true });
for (const entry of fs.readdirSync(outputDir, { withFileTypes: true })) {
if (!entry.isFile()) {
continue;
}
const extension = path.extname(entry.name);
const isPublishableArtifact =
FULL_ARTIFACT_FILE_EXTENSIONS.has(extension) || isFullArtifactJsonName(entry.name);
if (!isPublishableArtifact) {
continue;
}
fs.copyFileSync(path.join(outputDir, entry.name), path.join(publishDir, entry.name));
}
return publishDir;
}
function readSession(root: string, opts: Options, outputDir: string) {
const pathname = sessionPath(root, opts, outputDir);
if (!fs.existsSync(pathname)) {
@@ -2414,7 +2496,7 @@ async function publishSessionArtifacts(root: string, opts: Options, outputDir: s
);
const publishGifPath = fs.existsSync(croppedMotionGifPath) ? croppedMotionGifPath : motionGifPath;
const publishDir = opts.publishFullArtifacts
? session.outputDir
? stageFullSessionArtifacts(session.outputDir)
: path.join(session.outputDir, "publish-gif-only");
if (!opts.publishFullArtifacts) {
if (!fs.existsSync(publishGifPath)) {

View File

@@ -6,7 +6,6 @@ import { copyFile, mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { normalizeCredentialPayloadForKind } from "../../qa/convex-credential-broker/convex/payload-validation.js";
import { fetchJsonWithTimeout, runCommand } from "./telegram-user-credential-io.ts";
import { expandHome, writePrivateJson } from "./telegram-user-credential-paths.ts";
@@ -18,6 +17,9 @@ const DEFAULT_BOT_CREDENTIALS_FILE =
const DEFAULT_CONVEX_ENV_FILE = "~/.codex/skills/custom/telegram-e2e-bot-to-bot/convex.local.env";
const CHUNKED_PAYLOAD_MARKER = "__openclawQaCredentialPayloadChunksV1";
const TELEGRAM_USER_QA_CREDENTIAL_KIND = "telegram-user";
const SHA256_HEX_RE = /^[a-f0-9]{64}$/u;
const TELEGRAM_CHAT_ID_RE = /^-?\d+$/u;
const TELEGRAM_USER_ID_RE = /^\d+$/u;
const DEFAULT_CHUNKED_PAYLOAD_MAX_BYTES = 64 * 1024 * 1024;
const DEFAULT_CHUNKED_PAYLOAD_MAX_CHUNKS = 4096;
const COMMAND_TIMEOUT_MS = optionalPositiveInteger(
@@ -175,8 +177,83 @@ function optionalPositiveInteger(value: string | undefined, fallback: number, la
return parsed;
}
function throwCredentialPayloadError(message: string): never {
throw new Error(message);
}
function requireTelegramUserPayloadString(payload: Record<string, unknown>, key: string): string {
const raw = payload[key];
if (typeof raw !== "string") {
throwCredentialPayloadError(
`Credential payload for kind "${TELEGRAM_USER_QA_CREDENTIAL_KIND}" must include "${key}" as a string.`,
);
}
const value = raw.trim();
if (!value) {
throwCredentialPayloadError(
`Credential payload for kind "${TELEGRAM_USER_QA_CREDENTIAL_KIND}" must include a non-empty "${key}" value.`,
);
}
return value;
}
function parseTelegramUserQaCredentialPayload(payload: Record<string, unknown>): JsonObject {
return normalizeCredentialPayloadForKind(TELEGRAM_USER_QA_CREDENTIAL_KIND, payload);
const groupId = requireTelegramUserPayloadString(payload, "groupId");
if (!TELEGRAM_CHAT_ID_RE.test(groupId)) {
throwCredentialPayloadError(
'Credential payload for kind "telegram-user" must include a numeric "groupId" string.',
);
}
const testerUserId = requireTelegramUserPayloadString(payload, "testerUserId");
if (!TELEGRAM_USER_ID_RE.test(testerUserId)) {
throwCredentialPayloadError(
'Credential payload for kind "telegram-user" must include a numeric "testerUserId" string.',
);
}
const telegramApiId = requireTelegramUserPayloadString(payload, "telegramApiId");
if (!TELEGRAM_USER_ID_RE.test(telegramApiId)) {
throwCredentialPayloadError(
'Credential payload for kind "telegram-user" must include a numeric "telegramApiId" string.',
);
}
const tdlibArchiveSha256 = requireTelegramUserPayloadString(
payload,
"tdlibArchiveSha256",
).toLowerCase();
const desktopTdataArchiveSha256 = requireTelegramUserPayloadString(
payload,
"desktopTdataArchiveSha256",
).toLowerCase();
if (!SHA256_HEX_RE.test(tdlibArchiveSha256)) {
throwCredentialPayloadError(
'Credential payload for kind "telegram-user" must include "tdlibArchiveSha256" as a SHA-256 hex string.',
);
}
if (!SHA256_HEX_RE.test(desktopTdataArchiveSha256)) {
throwCredentialPayloadError(
'Credential payload for kind "telegram-user" must include "desktopTdataArchiveSha256" as a SHA-256 hex string.',
);
}
return {
groupId,
sutToken: requireTelegramUserPayloadString(payload, "sutToken"),
testerUserId,
testerUsername: requireTelegramUserPayloadString(payload, "testerUsername"),
telegramApiId,
telegramApiHash: requireTelegramUserPayloadString(payload, "telegramApiHash"),
tdlibDatabaseEncryptionKey: requireTelegramUserPayloadString(
payload,
"tdlibDatabaseEncryptionKey",
),
tdlibArchiveBase64: requireTelegramUserPayloadString(payload, "tdlibArchiveBase64"),
tdlibArchiveSha256,
desktopTdataArchiveBase64: requireTelegramUserPayloadString(
payload,
"desktopTdataArchiveBase64",
),
desktopTdataArchiveSha256,
};
}
async function fileSha256(pathValue: string) {

View File

@@ -372,6 +372,7 @@ echo "Verifying config and state survived update..."
node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-config
node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-state
startup_summary="n/a"
if [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then
echo "Gateway restart was handled by openclaw update."
else
@@ -387,6 +388,7 @@ else
openclaw_e2e_print_log "$GATEWAY_LOG" >&2
exit 1
fi
startup_summary="${start_seconds}s"
fi
echo "Checking gateway HTTP probes..."
@@ -428,5 +430,5 @@ if [ "$status_seconds" -gt "$STATUS_BUDGET" ]; then
fi
node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-status-json /tmp/openclaw-upgrade-survivor-status.json
echo "Upgrade survivor Docker E2E passed scenario=${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base} updateRestartMode=${UPDATE_RESTART_MODE} startup=${start_seconds}s status=${status_seconds}s."
echo "Upgrade survivor Docker E2E passed scenario=${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base} updateRestartMode=${UPDATE_RESTART_MODE} startup=${startup_summary} status=${status_seconds}s."
'

View File

@@ -1,5 +1,5 @@
// Gateway Bench Child script supports OpenClaw repository automation.
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
const TEARDOWN_GRACE_MS = 2_000;
const TEARDOWN_KILL_GRACE_MS = 1_000;
@@ -14,6 +14,13 @@ export type StopChildResult = ChildExit & {
exitedBeforeTeardown: boolean;
};
export type StopChildOptions = {
killGraceMs?: number;
platform?: NodeJS.Platform;
runTaskkill?: typeof spawnSync;
teardownGraceMs?: number;
};
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
@@ -22,10 +29,14 @@ export function delay(ms: number): Promise<void> {
export async function stopChild(
child: ChildProcessWithoutNullStreams,
options: { killGraceMs?: number; teardownGraceMs?: number } = {},
options: StopChildOptions = {},
): Promise<StopChildResult> {
const teardownGraceMs = options.teardownGraceMs ?? TEARDOWN_GRACE_MS;
const killGraceMs = options.killGraceMs ?? TEARDOWN_KILL_GRACE_MS;
const processTreeOptions = {
platform: options.platform ?? process.platform,
runTaskkill: options.runTaskkill ?? spawnSync,
};
let observedExit: ChildExit | null = null;
const directExit = (): ChildExit | null =>
observedExit ??
@@ -34,7 +45,7 @@ export async function stopChild(
: null);
const currentExit = (): ChildExit | null => {
const exit = directExit();
if (exit == null || isProcessTreeAlive(child)) {
if (exit == null || isProcessTreeAlive(child, processTreeOptions)) {
return null;
}
return exit;
@@ -42,26 +53,26 @@ export async function stopChild(
const waitForProcessTreeExit = async (ms: number): Promise<boolean> => {
const deadlineAt = Date.now() + ms;
while (Date.now() < deadlineAt) {
if (!isProcessTreeAlive(child)) {
if (!isProcessTreeAlive(child, processTreeOptions)) {
return true;
}
await delay(Math.min(EXIT_POLL_MS, deadlineAt - Date.now()));
}
return !isProcessTreeAlive(child);
return !isProcessTreeAlive(child, processTreeOptions);
};
const cleanupExitedProcessTree = async (
exit: ChildExit,
exitedBeforeTeardown: boolean,
): Promise<StopChildResult> => {
if (!isProcessTreeAlive(child)) {
if (!isProcessTreeAlive(child, processTreeOptions)) {
return { ...exit, exitedBeforeTeardown };
}
const sentTeardownSignal = killProcessTree(child, "SIGTERM");
const sentTeardownSignal = killProcessTree(child, "SIGTERM", processTreeOptions);
if (sentTeardownSignal) {
await waitForProcessTreeExit(teardownGraceMs);
}
if (sentTeardownSignal && isProcessTreeAlive(child)) {
killProcessTree(child, "SIGKILL");
if (sentTeardownSignal && isProcessTreeAlive(child, processTreeOptions)) {
killProcessTree(child, "SIGKILL", processTreeOptions);
await waitForProcessTreeExit(killGraceMs);
}
if (!sentTeardownSignal) {
@@ -106,7 +117,7 @@ export async function stopChild(
return await cleanupExitedProcessTree(queuedExit, true);
}
const sentTeardownSignal = killProcessTree(child, "SIGTERM");
const sentTeardownSignal = killProcessTree(child, "SIGTERM", processTreeOptions);
const gracefulExit = await waitForExit(teardownGraceMs);
if (gracefulExit != null) {
return { ...gracefulExit, exitedBeforeTeardown: !sentTeardownSignal };
@@ -121,7 +132,7 @@ export async function stopChild(
return { exitCode: null, exitedBeforeTeardown: true, signal: null };
}
killProcessTree(child, "SIGKILL");
killProcessTree(child, "SIGKILL", processTreeOptions);
const killedExit = await waitForExit(killGraceMs);
const finalExit = killedExit ?? currentExit();
if (finalExit != null) {
@@ -139,8 +150,11 @@ function releaseUnsettledChild(child: ChildProcessWithoutNullStreams): void {
child.unref();
}
function isProcessTreeAlive(child: ChildProcessWithoutNullStreams): boolean {
if (process.platform === "win32" || child.pid === undefined) {
function isProcessTreeAlive(
child: ChildProcessWithoutNullStreams,
{ platform = process.platform }: Pick<StopChildOptions, "platform"> = {},
): boolean {
if (platform === "win32" || child.pid === undefined) {
return false;
}
try {
@@ -156,8 +170,12 @@ function isProcessStillExistsError(error: unknown): boolean {
return code === "EPERM";
}
function killProcessTree(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): boolean {
if (process.platform !== "win32" && child.pid !== undefined) {
function killProcessTree(
child: ChildProcessWithoutNullStreams,
signal: NodeJS.Signals,
{ platform = process.platform, runTaskkill = spawnSync }: StopChildOptions = {},
): boolean {
if (platform !== "win32" && child.pid !== undefined) {
try {
process.kill(-child.pid, signal);
return true;
@@ -165,5 +183,15 @@ function killProcessTree(child: ChildProcessWithoutNullStreams, signal: NodeJS.S
// Fall back to the direct child below.
}
}
if (platform === "win32" && child.pid !== undefined) {
const args = ["/PID", String(child.pid), "/T"];
if (signal === "SIGKILL") {
args.push("/F");
}
const result = runTaskkill("taskkill", args, { stdio: "ignore" });
if (!result.error && result.status === 0) {
return true;
}
}
return child.kill(signal);
}

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
openclaw_host_timeout_bin() {
if command -v timeout >/dev/null 2>&1; then
printf '%s\n' timeout
elif command -v gtimeout >/dev/null 2>&1; then
printf '%s\n' gtimeout
else
return 1
fi
}
openclaw_host_timeout_cmd() {
local timeout_value="$1"
shift
local timeout_bin
if ! timeout_bin="$(openclaw_host_timeout_bin)"; then
"$@"
return
fi
if "$timeout_bin" --kill-after=1s 1s true >/dev/null 2>&1; then
"$timeout_bin" --kill-after=30s "$timeout_value" "$@"
else
"$timeout_bin" "$timeout_value" "$@"
fi
}

View File

@@ -103,6 +103,9 @@ export async function runManagedCommand({
if (managedChild.forceKillTimer) {
clearTimeout(managedChild.forceKillTimer);
}
if (managedChild.receivedSignal) {
terminateManagedChild(child, "SIGKILL");
}
resolve(
managedChild.receivedSignal
? signalExitCode(managedChild.receivedSignal)

View File

@@ -695,6 +695,9 @@ function readRunPackageDir(argv) {
}
export function parseRunArgs(argv) {
if (argv[0] === "--help" || argv[0] === "-h") {
return { help: true, packageDir: "", command: "", args: [] };
}
if (argv[0] !== "--run") {
throw new Error(RUN_USAGE);
}
@@ -703,6 +706,9 @@ export function parseRunArgs(argv) {
if (!packageDir || separatorIndex === -1 || separatorIndex === argv.length - 1) {
throw new Error(RUN_USAGE);
}
if (separatorIndex !== 2) {
throw new Error(`unexpected plugin npm package manifest run argument: ${argv[2]}`);
}
return {
packageDir,
command: argv[separatorIndex + 1],
@@ -711,7 +717,12 @@ export function parseRunArgs(argv) {
}
function main(argv = process.argv.slice(2)) {
const { packageDir, command, args } = parseRunArgs(argv);
const parsedArgs = parseRunArgs(argv);
if (parsedArgs.help) {
console.log(RUN_USAGE);
return 0;
}
const { packageDir, command, args } = parsedArgs;
return withAugmentedPluginNpmManifestForPackage(
{
packageDir,

View File

@@ -297,22 +297,38 @@ export async function buildPluginNpmRuntime(params) {
};
}
function usage() {
return "usage: node scripts/lib/plugin-npm-runtime-build.mjs <package-dir>";
}
function readPackageDirArg(argv) {
const packageDir = argv[0];
if (!packageDir || packageDir.startsWith("--")) {
throw new Error("usage: node scripts/lib/plugin-npm-runtime-build.mjs <package-dir>");
const args = argv[0] === "--" ? argv.slice(1) : argv;
const packageDir = args[0];
if (packageDir === "--help" || packageDir === "-h") {
return { help: true, packageDir: "" };
}
return packageDir;
if (!packageDir || packageDir.startsWith("--")) {
throw new Error(usage());
}
const extraArg = args[1];
if (extraArg) {
throw new Error(`unexpected plugin npm runtime build argument: ${extraArg}`);
}
return { packageDir };
}
export function parseArgs(argv) {
const packageDir = readPackageDirArg(argv);
return { packageDir };
return readPackageDirArg(argv);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
const { packageDir } = parseArgs(process.argv.slice(2));
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(usage());
process.exit(0);
}
const { packageDir } = args;
const result = await buildPluginNpmRuntime({ packageDir });
if (result) {
console.error(

View File

@@ -114,6 +114,9 @@ export const pluginSdkDocMetadata = {
"runtime-store": {
category: "runtime",
},
"session-transcript-runtime": {
category: "runtime",
},
"sqlite-runtime": {
category: "runtime",
},

View File

@@ -217,6 +217,7 @@
"session-binding-runtime",
"session-key-runtime",
"session-store-runtime",
"session-transcript-runtime",
"sqlite-runtime",
"sqlite-runtime-testing",
"session-transcript-hit",

View File

@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
const STABLE_RELEASE_TAG_RE = /^v(?<version>\d{4}\.\d{1,2}\.\d{1,2})(?:-\d+)?$/u;
const STABLE_RELEASE_TAG_RE = /^v(?<version>\d{4}\.\d{1,2}\.\d{1,2})(?:-[1-9]\d*)?$/u;
const MAX_ROLLBACK_DRILL_AGE_MS = 90 * 24 * 60 * 60 * 1000;
function parseStableReleaseTagDetails(tag) {

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { spawnPnpmRunner } from "../pnpm-runner.mjs";
import {
forceKillVitestProcessGroup,
installVitestProcessGroupCleanup,
shouldUseDetachedVitestProcessGroup,
} from "../vitest-process-group.mjs";
@@ -26,8 +27,10 @@ export async function runVitestBatch(params) {
});
const teardownChildCleanup = installVitestProcessGroupCleanup({
child,
forceSignal: "SIGKILL",
forceSignalDelayMs: 100,
onSignal(signal) {
forwardedSignal = signal;
forwardedSignal ??= signal;
},
});
@@ -37,12 +40,13 @@ export async function runVitestBatch(params) {
});
child.on("exit", (code, signal) => {
teardownChildCleanup();
if (signal) {
process.kill(process.pid, signal);
if (forwardedSignal) {
forceKillVitestProcessGroup(child);
process.kill(process.pid, forwardedSignal);
return;
}
if (forwardedSignal) {
process.kill(process.pid, forwardedSignal);
if (signal) {
process.kill(process.pid, signal);
return;
}
resolve(code ?? 1);

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