Compare commits

..

191 Commits

Author SHA1 Message Date
Vincent Koc
a39e548ede fix(mcp): cap channel bridge request limits 2026-06-22 00:32:32 +02:00
Vincent Koc
328a44695f chore(deadcode): remove unused agent-core prompt formatter 2026-06-22 06:23:07 +08:00
Vincent Koc
c21dcfc7c2 fix(tui): clamp local shutdown grace timeout 2026-06-22 00:22:08 +02:00
Vincent Koc
2bdcc8314d fix(sdk): preserve zero run timeout watchdog 2026-06-22 00:13:51 +02:00
Vincent Koc
464adfe5e5 chore(deadcode): remove unused agent-core harness APIs 2026-06-22 06:08:32 +08:00
Vincent Koc
b66b4504f8 fix(gateway): cap plugin session message reads 2026-06-22 00:06:04 +02:00
Vincent Koc
f6d432e545 fix(gateway): expire default node pending work 2026-06-21 23:52:44 +02:00
Vincent Koc
fd13192adc fix(qa-lab): version UI assets from repo root 2026-06-21 23:43:48 +02:00
Vincent Koc
1c63da09d8 fix(qa-lab): cap agent wait timeout payloads 2026-06-21 23:34:38 +02:00
Vincent Koc
735505442c chore(deadcode): dedupe live assistant text extraction 2026-06-22 05:32:29 +08:00
Vincent Koc
108d6d7eca fix(gateway): reject malformed restart request params 2026-06-21 23:28:53 +02:00
Vincent Koc
984c8f6ea0 chore(deadcode): remove stale channel presence helper 2026-06-22 05:21:39 +08:00
Shakker
cd9060e06a test: restore host cleanup state env 2026-06-21 22:20:58 +01:00
Vincent Koc
5b96eb0172 fix(e2e): reject flag Parallels smoke values 2026-06-21 23:19:32 +02:00
Vincent Koc
b95b725c83 fix(e2e): reject help flag Crabbox proof values 2026-06-21 23:13:12 +02:00
Shakker
4b881509eb fix: restore media cache state env 2026-06-21 22:12:46 +01:00
Vincent Koc
a03032a272 chore(plugin-sdk): refresh API baseline hash 2026-06-21 23:09:27 +02:00
Shakker
c94ebdbebd test: restore heartbeat state env 2026-06-21 22:07:04 +01:00
Alex Knight
7b46167607 fix(channels): preserve migrated account policies 2026-06-22 07:05:45 +10:00
Vincent Koc
0e53358945 fix(e2e): reject flag Telegram credential values 2026-06-21 23:04:51 +02:00
Vincent Koc
6b45e9af7a fix(scripts): validate iOS node CLI values before help 2026-06-21 23:00:30 +02:00
Vincent Koc
2ef61eb782 chore(deadcode): drop unused exact session lookup 2026-06-22 04:58:21 +08:00
Vincent Koc
6823f56d8e fix(scripts): reject flag device-pair Telegram values 2026-06-21 22:56:54 +02:00
Shakker
3e1d3c5feb fix: isolate provider runtime env cache 2026-06-21 21:54:05 +01:00
Vincent Koc
dfbc9ab246 fix(scripts): reject short flag TUI PTY values 2026-06-21 22:52:21 +02:00
Vincent Koc
179eb15554 chore(deadcode): remove unused state path readers 2026-06-22 04:50:54 +08:00
Shakker
c3b1e926e8 test: route gateway call env resets 2026-06-21 21:46:55 +01:00
Vincent Koc
89768d456b fix(scripts): reject short flag Discord smoke values 2026-06-21 22:45:03 +02:00
Vincent Koc
609d7a14b1 chore(deadcode): remove test-only helper APIs 2026-06-22 04:41:04 +08:00
Vincent Koc
3bae0d6b82 fix(qa): reject short flag gateway smoke values 2026-06-21 22:40:40 +02:00
Vincent Koc
75a997dd7c fix(scripts): reject short flag shrinkwrap refs 2026-06-21 22:40:07 +02:00
Shakker
ab8dc3af52 fix: reuse doctor env snapshot helper 2026-06-21 21:37:56 +01:00
Vincent Koc
bebc5d847d fix(scripts): reject short flag CI timing limits 2026-06-21 22:34:29 +02:00
Vincent Koc
a0f28bd3f5 fix(scripts): reject short flag version values 2026-06-21 22:33:59 +02:00
Shakker
c638617897 test: share agent state-dir env guard 2026-06-21 21:31:10 +01:00
Vincent Koc
b77d6149e1 fix(scripts): reject short flag extension memory values 2026-06-21 22:28:14 +02:00
Vincent Koc
36db108fc1 fix(scripts): reject short flag boundary values 2026-06-21 22:26:44 +02:00
heichaowo
e00c1eebc4 fix(telegram): skip duplicate mirror replies (#95069)
Fix Telegram session transcript duplicate mirror writes after the primary assistant reply already exists.

Thanks @heichaowo!
2026-06-22 01:55:08 +05:30
NIO
32d22d04cc fix(telegram): honor outbound reaction directives (#94977) 2026-06-22 01:54:55 +05:30
Vincent Koc
648ef73bde fix(qa): reject short flag UX evidence paths 2026-06-21 22:20:56 +02:00
Vincent Koc
beebb35de4 fix(scripts): reject short flag values in helper CLIs 2026-06-21 22:19:54 +02:00
Vincent Koc
55959148ca chore(deadcode): remove unused infra wrappers 2026-06-22 04:18:02 +08:00
Vincent Koc
4684bbba97 fix(scripts): reject short flag values in benchmark CLIs 2026-06-21 22:11:19 +02:00
Vincent Koc
409adfbe10 chore(deadcode): drop stale helper APIs 2026-06-22 04:10:02 +08:00
Vincent Koc
2609b97222 fix(scripts): reject short flag values in bench parsers 2026-06-21 22:06:30 +02:00
Vincent Koc
03bc600e67 fix(scripts): reject short flag startup memory paths 2026-06-21 22:06:04 +02:00
Vincent Koc
8102d5ebc3 fix(scripts): reject short flag performance summary paths 2026-06-21 22:02:05 +02:00
Vincent Koc
6399eb8191 fix(scripts): reject short flag vitest profile dirs 2026-06-21 21:53:58 +02:00
Vincent Koc
1a5839fbd8 fix(scripts): reject short flag audit severity values 2026-06-21 21:51:02 +02:00
Vincent Koc
d17045db6f chore(deadcode): drop unused helper exports 2026-06-22 03:49:17 +08:00
Vincent Koc
2a8db1fc23 fix(scripts): reject short flag ownership report values 2026-06-21 21:45:36 +02:00
Vincent Koc
82f43f0a62 fix(scripts): reject short flag values in release docs parsers 2026-06-21 21:39:41 +02:00
Vincent Koc
9adf3d92bd chore(deadcode): remove unused helper paths 2026-06-22 03:39:19 +08:00
Vincent Koc
e21164933a fix(scripts): reject short flag report values 2026-06-21 21:36:26 +02:00
Vincent Koc
1b17517969 fix(scripts): reject short flag closeout values 2026-06-21 21:32:17 +02:00
Vincent Koc
86fea26797 fix(plugin-sdk): stabilize surface report after builds 2026-06-21 21:28:47 +02:00
Vincent Koc
2e7c3ace9c chore(plugin-sdk): refresh API baseline hashes 2026-06-21 21:28:47 +02:00
Vincent Koc
e34204a1e0 fix(scripts): reject short flag package roots 2026-06-21 21:27:57 +02:00
Vincent Koc
6daf9307e0 fix(scripts): reject short flag docker package values 2026-06-21 21:24:30 +02:00
Vincent Koc
ebb670b208 fix(scripts): reject short flag gauntlet values 2026-06-21 21:18:48 +02:00
Vincent Koc
52672c7af1 fix(scripts): reject short flag gateway cpu values 2026-06-21 21:15:21 +02:00
Vincent Koc
690efd2a16 chore(deadcode): inline constant helper stubs 2026-06-22 03:14:04 +08:00
Vincent Koc
102ab759e7 fix(scripts): reject option-shaped package candidate values 2026-06-21 21:11:25 +02:00
Vincent Koc
7da955fae4 fix(config): type slack secret refs 2026-06-21 20:58:33 +02:00
Vincent Koc
06a0148072 chore(deadcode): remove inert browser relay hook 2026-06-22 02:50:55 +08:00
Vincent Koc
edd1d3319c chore(deadcode): dedupe repeated literal lists 2026-06-22 02:35:03 +08:00
Vincent Koc
0ea39a2276 chore(deadcode): remove inert memory provider bootstrap 2026-06-22 02:30:56 +08:00
Sarah Fortune
6fa944e80f [codex] Add Slack relay mode for incoming messages (#94707) 2026-06-21 11:28:33 -07:00
Vincent Koc
4c453c931f fix(test): reject dependency report flag values 2026-06-21 20:23:23 +02:00
Vincent Koc
13b0976c70 fix(release): reject dependency evidence flag values 2026-06-21 20:19:49 +02:00
Vincent Koc
43e00c06c3 fix(docs): reject sync publish flag values 2026-06-21 20:13:50 +02:00
Vincent Koc
03ce3d41b1 fix(ci): reject hosted gate flag values 2026-06-21 20:09:53 +02:00
Vincent Koc
bda05dbc2f fix(release): reject validation flag values 2026-06-21 20:05:22 +02:00
Vincent Koc
7069d95720 fix(test): reject diff ref flag values 2026-06-21 19:59:55 +02:00
Vincent Koc
8086cffd17 fix(test): reject group report flag values 2026-06-21 19:56:25 +02:00
Vincent Koc
d91aee7220 fix(ci): reject flag refs in changed scope 2026-06-21 19:54:57 +02:00
Vincent Koc
c8ab37f6fe chore(deadcode): drop inert legacy workspace doctor check 2026-06-22 01:47:27 +08:00
Vincent Koc
a823cb2b30 fix(test): bound rpc process tree sampling 2026-06-21 19:41:51 +02:00
Vincent Koc
5230ec66ae chore(plugin-sdk): refresh API surface baselines 2026-06-21 19:32:49 +02:00
Vincent Koc
eea777c9fc chore(deadcode): trim stale facade re-exports 2026-06-22 01:16:08 +08:00
Vincent Koc
0b28a72be1 fix(test): reject kova help value bypasses 2026-06-21 19:07:02 +02:00
Vincent Koc
adcba85264 fix(test): reject cpuprofile limit help tokens 2026-06-21 18:56:26 +02:00
Vincent Koc
5b79fa13e2 chore(deadcode): trim doctor alias wrappers 2026-06-22 00:54:26 +08:00
Vincent Koc
124ea48549 fix(test): reject docker timing flag limits 2026-06-21 18:49:24 +02:00
Vincent Koc
12756fc4c8 fix(test): reject env report flag paths 2026-06-21 18:46:05 +02:00
Vincent Koc
5bf459e23b fix(test): reject attestation platform flags 2026-06-21 18:43:05 +02:00
Vincent Koc
d64a27feeb chore(deadcode): drop node daemon runtime alias 2026-06-22 00:41:56 +08:00
Vincent Koc
6c42f73619 fix(test): reject i18n report flag values 2026-06-21 18:37:35 +02:00
Vincent Koc
d460f00eb9 fix(test): reject metadata ref flags 2026-06-21 18:33:10 +02:00
Vincent Koc
b83dce7b33 fix(test): reject rpc rtt flag values 2026-06-21 18:28:28 +02:00
Vincent Koc
d3c907193f fix(test): route qa otel smoke parser 2026-06-21 18:26:26 +02:00
Vincent Koc
b47c930e7e chore(deadcode): trim runtime plugin selection wrappers 2026-06-22 00:24:26 +08:00
Vincent Koc
0befd3c8f2 fix(test): route android release wrappers 2026-06-21 18:19:07 +02:00
Vincent Koc
c2ee9b0be8 fix(gateway): preserve owner MCP tools for agent RPC 2026-06-21 18:14:38 +02:00
Vincent Koc
9d27583190 fix(test): route release signing args 2026-06-21 18:10:35 +02:00
Vincent Koc
04c8c50cc4 fix(test): route testbox env hydration 2026-06-21 18:04:47 +02:00
Vincent Koc
5abf4ce2e2 chore(deadcode): trim reply runtime dead helpers 2026-06-22 00:03:31 +08:00
Vincent Koc
07d5cdec99 fix(test): route ios release wrappers 2026-06-21 17:59:21 +02:00
Vincent Koc
f5f23e739e fix(test): route proxy CA installer 2026-06-21 17:51:44 +02:00
Vincent Koc
0c183283e5 fix(test): route release preflight script 2026-06-21 17:46:59 +02:00
Vincent Koc
514b3365b5 fix(deadcode): move restart sentinels to sqlite 2026-06-21 23:39:38 +08:00
Vincent Koc
2804c24dc6 fix(test): route plugin dependency helpers 2026-06-21 17:32:43 +02:00
Vincent Koc
94c7b5a874 fix(test): route release ref resolver 2026-06-21 17:27:29 +02:00
Vincent Koc
93ec8b8c5c fix(test): route install helper scripts 2026-06-21 17:14:12 +02:00
Vincent Koc
9d83eeaccf fix(test): route release wrapper scripts 2026-06-21 17:08:40 +02:00
Vincent Koc
33eb6ab9de fix(test): route release approval script 2026-06-21 16:59:51 +02:00
Vincent Koc
757ab933f4 fix(test): route release script owners 2026-06-21 16:53:49 +02:00
Vincent Koc
63fdc57b3a fix(test): route mac helper script owners 2026-06-21 16:39:41 +02:00
Vincent Koc
a39a3b74de fix(deadcode): move restart handoffs to sqlite 2026-06-21 22:36:42 +08:00
Vincent Koc
77a859f4ae fix(ci): route mac packaging scripts to macos checks 2026-06-21 16:32:57 +02:00
Peter Lee
84cf64770f fix(whatsapp): wire missing Baileys retry/cache hooks for group message reliability (#94338)
Merged via squash.

Prepared head SHA: ee6de071f7
Co-authored-by: xialonglee <22994703+xialonglee@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-06-21 11:32:49 -03:00
Vincent Koc
0ad48dad2c fix(deadcode): move restart intents to sqlite 2026-06-21 22:29:05 +08:00
Marcus Castro
b50a5aebba fix(whatsapp): preserve native quote replies (#95483)
* fix(whatsapp): preserve native quote replies

* feat(plugin-sdk): add quote-reply live transport standard

* test(qa-lab): add WhatsApp quote-reply live scenarios
2026-06-21 11:26:58 -03:00
Vincent Koc
b84665222c fix(ci): regenerate Swift protocol model 2026-06-21 22:19:18 +08:00
snowzlmbot
6441e56465 fix(telegram): materialize streaming progress placeholders (#95183)
* fix(telegram): materialize streaming progress placeholders

* fix(telegram): cancel delayed progress drafts before final

* fix(telegram): satisfy progress placeholder lint

* fix(telegram): cancel delayed draft preview on clear

* refactor(telegram): simplify delayed preview flush

---------

Co-authored-by: snowzlmbot <snowzlmbot@users.noreply.github.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-06-21 19:48:41 +05:30
Vincent Koc
6daabd23f8 fix(deadcode): move config health state to sqlite 2026-06-21 22:07:43 +08:00
Vincent Koc
2f33999898 fix(agents): retain bounded preflight history 2026-06-21 21:58:57 +08:00
Vincent Koc
a60947fb3e fix(agents): keep live tool-result prompts cache-stable 2026-06-21 21:54:35 +08:00
Peter Lee
ac5d219be3 fix(telegram): stop clearing registered webhook on channel restart (#94506)
Prepared head SHA: f721e14a92

Co-authored-by: xialonglee <li.xialong@xydigit.com>
2026-06-21 19:22:49 +05:30
Vincent Koc
ae41b00922 fix(deadcode): move plugin binding approvals to sqlite 2026-06-21 21:52:20 +08:00
Vincent Koc
b28e68e0ce fix(macos): validate DMG layout values 2026-06-21 15:48:52 +02:00
Josh Avant
5d1e649aea fix: route mobile exec approvals to reviewer device (#95175)
* fix: route mobile exec approvals to reviewer device

* fix: surface iOS approval events in foreground

* fix: forward codex approval reviewer device

* test: harden approval reviewer device contract

* test: cover reviewer approval fallback resolvers
2026-06-21 08:47:52 -05:00
Ayaan Zaidi
d6d17709e8 fix(telegram): clear progress draft before tool output (#93002) (thanks @zhangguiping-xydt) 2026-06-21 19:14:57 +05:30
Ayaan Zaidi
6fd6bddb92 style(telegram): trim progress draft dispatch comment 2026-06-21 19:14:57 +05:30
张贵萍0668001030
f4dee99574 fix(telegram): clear progress draft before tool artifacts 2026-06-21 19:14:57 +05:30
张贵萍0668001030
db33402af0 fix(telegram): clear progress draft before verbose tool output 2026-06-21 19:14:57 +05:30
Vincent Koc
088cab5ee4 fix(macos): prefer repo pnpm for packaging 2026-06-21 15:39:17 +02:00
Vincent Koc
bd74a62118 fix(install): use repo pnpm for git installs 2026-06-21 15:34:56 +02:00
Vincent Koc
9f888d95e0 fix(deadcode): move current conversation bindings to sqlite 2026-06-21 21:30:06 +08:00
Vincent Koc
11a2e03bd4 fix(install): detect package manager launcher names 2026-06-21 15:18:57 +02:00
Vincent Koc
5693fcda78 fix(ci): validate artifact package source sha 2026-06-21 15:08:18 +02:00
Vincent Koc
eb00d499d1 fix(deadcode): move update check state to sqlite 2026-06-21 20:57:41 +08:00
Vincent Koc
8a7906c716 fix(qa): reject fractional live token usage 2026-06-21 14:49:29 +02:00
Vincent Koc
c85113e30e fix(qa): escape tool coverage markdown cells 2026-06-21 14:46:38 +02:00
Vincent Koc
bdf81a825f fix(deadcode): move voicewake settings to sqlite 2026-06-21 20:45:17 +08:00
Vincent Koc
d9dfcd6c8a fix(qa): reject impossible confidence counts 2026-06-21 14:38:42 +02:00
Alex Knight
2cafbd0774 fix(plugins): reconcile managed npm root overrides with managed peer pins 2026-06-21 22:33:18 +10:00
Vincent Koc
54b2836eab test(scripts): stabilize gauntlet termination timing 2026-06-21 14:27:10 +02:00
Vincent Koc
5b6f4b2919 fix(test): reject gauntlet flag values 2026-06-21 14:21:16 +02:00
Vincent Koc
c037a34ba7 fix(security): ignore Docker rerun artifact commands 2026-06-21 14:14:57 +02:00
Vincent Koc
12c34fc3a9 fix(security): bound trusted package URL prefixes 2026-06-21 13:51:24 +02:00
Vincent Koc
e366349730 test(deadcode): reuse gateway restart intent writer 2026-06-21 19:49:47 +08:00
Vincent Koc
89a73d08c8 test(deadcode): dedupe wizard prompter helpers 2026-06-21 19:49:46 +08:00
Vincent Koc
e6f41a4df0 fix(test): reject loose env report limits 2026-06-21 13:37:55 +02:00
Vincent Koc
b7fef7fca6 fix(mac): clean failed dSYM merges 2026-06-21 13:32:47 +02:00
Vincent Koc
d1cbe29f3d fix(qa): require sampled Kova metric counts 2026-06-21 13:24:30 +02:00
Alex Knight
9dbc21d283 fix(telegram): materialize rich message line breaks as <br>
Bot API 10.1 rich messages parse structured HTML, so bare newlines
collapse as insignificant whitespace and flatten multi-paragraph replies
and bullet runs into a single line. Materialize logical line breaks as
<br> in prepareTelegramRichHtml -- the shared rich send/edit/draft
chokepoint covering both the Markdown and explicit-HTML text modes --
while keeping newlines literal inside code/pre/math and where they only
separate structural tags (block elements plus table/figure/details
container children, so pretty-printed rich HTML keeps valid markup).

Refs #95409
2026-06-21 21:22:33 +10:00
Vincent Koc
5e86c7eef4 fix(qa): reject non-object mock Anthropic JSON 2026-06-21 13:13:37 +02:00
Vincent Koc
6f3af56952 fix(qa): reject non-object mock OpenAI JSON 2026-06-21 13:10:50 +02:00
Vincent Koc
03ee22666b chore(deadcode): drop retired memory wiki vault metadata 2026-06-21 19:08:39 +08:00
Vincent Koc
0d351b9875 fix(ci): filter ClawSweeper comment dispatches before token minting (#95308)
Merged via squash.

Prepared head SHA: b5389b59e4
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-21 19:05:08 +08:00
Vincent Koc
f69ba12a37 fix(qa): reject malformed mock OpenAI JSON 2026-06-21 12:58:20 +02:00
Vincent Koc
b796890b97 test(sdk): resolve Windows package taskkill path 2026-06-21 12:52:41 +02:00
Vincent Koc
b5fc9514c0 fix(qa): reject coerced evidence artifact indexes 2026-06-21 12:50:37 +02:00
Vincent Koc
2a140e6e6a fix(daemon): resolve Windows scheduled-task tools 2026-06-21 12:45:49 +02:00
Vincent Koc
6e5f4d685e chore(deadcode): remove stale readability compare script 2026-06-21 18:43:56 +08:00
Vincent Koc
600bace853 fix(qa): validate QA bus poll numbers 2026-06-21 12:41:22 +02:00
Vincent Koc
b8a5dac1a2 fix(qa-lab): resolve Windows PowerShell path 2026-06-21 12:40:57 +02:00
Vincent Koc
15c880aeff fix(infra): share trusted Windows process argv lookup 2026-06-21 12:31:27 +02:00
Vincent Koc
3a53eb5d77 test(deadcode): remove stale heartbeat transcript prune test 2026-06-21 18:25:28 +08:00
Vincent Koc
66e5cfdd86 fix(qa): reject QA Lab host port collisions 2026-06-21 12:23:52 +02:00
Vincent Koc
c4facb2bb3 fix(infra): resolve Windows port inspection tools 2026-06-21 12:22:18 +02:00
Vincent Koc
a70b34a3cb fix(qa-lab): resolve Windows node lookup tool 2026-06-21 12:14:39 +02:00
Vincent Koc
59bf85c586 chore(deadcode): remove stale cron usage helper 2026-06-21 18:12:36 +08:00
Vincent Koc
e486a1d1cf test(scripts): route Codex install assertions 2026-06-21 12:11:01 +02:00
Vincent Koc
72b9bc7303 fix(core): resolve Windows PATH locator tools 2026-06-21 12:09:33 +02:00
Vincent Koc
d3b44442f6 fix(infra): resolve Windows binary lookup tool 2026-06-21 12:02:02 +02:00
Vincent Koc
6ddbcbd460 chore(deadcode): remove stale proof scripts 2026-06-21 18:00:25 +08:00
Vincent Koc
7bd4aab21f test(scripts): route fixture common helper 2026-06-21 11:55:25 +02:00
Vincent Koc
a4c8b17b9e test(scripts): route Parallels lib helpers 2026-06-21 11:52:46 +02:00
Vincent Koc
d87f8325d0 test(agents): fix tools-manager mock typing 2026-06-21 11:49:00 +02:00
Vincent Koc
a5fde9119c fix(agents): resolve Windows extraction tools 2026-06-21 11:49:00 +02:00
Vincent Koc
e6c899dfa5 chore(deadcode): dedupe internal event formatting 2026-06-21 17:47:29 +08:00
Vincent Koc
425f512897 test(scripts): route ClawHub fixture server 2026-06-21 11:42:29 +02:00
Vincent Koc
735d70d9db test(extensions): pin Windows taskkill test roots 2026-06-21 11:37:59 +02:00
Vincent Koc
15300291ed fix(qa-matrix): resolve Windows taskkill path 2026-06-21 11:37:59 +02:00
Vincent Koc
eb7789c8cb test(scripts): route auth profile store assertions 2026-06-21 11:32:59 +02:00
Vincent Koc
f19052b3f3 test(scripts): route doctor install wrapper helper 2026-06-21 11:30:45 +02:00
Vincent Koc
61d1fd1f72 fix(qa-lab): resolve Windows taskkill path 2026-06-21 11:26:30 +02:00
Vincent Koc
1435fc123f test(scripts): route onboard config helpers 2026-06-21 11:23:08 +02:00
Vincent Koc
6c4028e073 fix(scripts): resolve Windows cmd shim launcher 2026-06-21 11:15:14 +02:00
Vincent Koc
9242137ca7 test(scripts): route config reload metadata helper 2026-06-21 11:13:13 +02:00
Vincent Koc
35d7cb0bff chore(deadcode): remove stale qa-matrix wrappers 2026-06-21 17:10:35 +08:00
Vincent Koc
c000e4811d fix(scripts): resolve Windows tools in kitchen sink walk 2026-06-21 11:06:56 +02:00
Vincent Koc
d25549f142 test(scripts): route plugin fixture commands 2026-06-21 11:04:38 +02:00
Vincent Koc
a192b2ea52 fix(windows): resolve taskkill in core spawns 2026-06-21 10:57:41 +02:00
Vincent Koc
7975ec0b11 test(scripts): route incremental line reader 2026-06-21 10:54:09 +02:00
Vincent Koc
e9b694ef9c fix(windows): resolve process inspection tools 2026-06-21 10:47:42 +02:00
Vincent Koc
3b332fd0a4 chore(deadcode): remove stale copilot doctor probes 2026-06-21 16:42:27 +08:00
Vincent Koc
7dd01d15c5 fix(windows): resolve cmd handoff path 2026-06-21 10:41:25 +02:00
Vincent Koc
675c56692a test(scripts): route mock OpenAI fixture helper 2026-06-21 10:31:00 +02:00
614 changed files with 16107 additions and 7300 deletions

View File

@@ -81,9 +81,27 @@ jobs:
repositories: clawsweeper
permission-contents: write
- name: Pre-filter ClawSweeper comment
id: comment_filter
if: ${{ github.event_name == 'issue_comment' }}
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
set -euo pipefail
if grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|autoclose|auto([[:space:]]+|-)?merge)\b' <<< "$COMMENT_BODY"; then
echo "is_command=true" >> "$GITHUB_OUTPUT"
else
echo "is_command=false" >> "$GITHUB_OUTPUT"
fi
- name: Create target comment token
id: target_token
if: ${{ github.event_name == 'issue_comment' && env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
if: >-
${{
github.event_name == 'issue_comment' &&
steps.comment_filter.outputs.is_command == 'true' &&
env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true'
}}
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
@@ -213,7 +231,11 @@ jobs:
fi
- name: Acknowledge and dispatch ClawSweeper comment
if: ${{ github.event_name == 'issue_comment' }}
if: >-
${{
github.event_name == 'issue_comment' &&
steps.comment_filter.outputs.is_command == 'true'
}}
env:
DISPATCH_TOKEN: ${{ steps.token.outputs.token }}
TARGET_TOKEN: ${{ steps.target_token.outputs.token }}
@@ -232,10 +254,6 @@ jobs:
. "$RUNNER_TEMP/github-api-backoff.sh"
body_file="$RUNNER_TEMP/clawsweeper-comment-body.txt"
printf '%s\n' "$COMMENT_BODY" > "$body_file"
if ! grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|automerge|autoclose)\b' "$body_file"; then
echo "No ClawSweeper command found in comment."
exit 0
fi
if [ -n "$TARGET_TOKEN" ]; then
err="$(mktemp)"
if GH_TOKEN="$TARGET_TOKEN" gh_api_with_retry -X POST \

View File

@@ -6,7 +6,7 @@ Docs: https://docs.openclaw.ai
### Highlights
- **Richer Telegram delivery:** Telegram now sends rich HTML, preserves rich markdown and sticker paths, renders progress drafts and command output more faithfully, normalizes HTML tables safely, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93088, #93281, #94891, #94856) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, @aaajiao, @zhangqueping, and @jairrab.
- **Richer Telegram delivery:** Telegram now sends rich HTML, preserves rich markdown and sticker paths, renders progress drafts and command output more faithfully, normalizes HTML tables safely, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93002, #93088, #93281, #94891, #94856) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, @aaajiao, @zhangguiping-xydt, @zhangqueping, and @jairrab.
- **More dependable agent recovery:** retries, terminal outcomes, usage after compaction, session history repair, and reply reconciliation now keep more interrupted or partial turns moving toward a visible final result. (#92191, #93073, #93228, #93084, #93469, #93291, #90943) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @yetval, @sandieman2, and @vincentkoc.
- **A stronger Codex integration:** Codex gains automatic plugin approvals, GPT-5.3 Spark OAuth routing, remote-node `exec` as a dynamic tool, and more reliable app-server teardown and terminal outcomes. (#92625, #89133, #93654, #91767, #93287) Thanks @kevinslin, @VACInc, @vincentkoc, @JPKay-AI, and @aliahnaf2013-max.
- **Standalone official provider plugins:** external provider packages are now first-class npm releases, externally installed channel plugins load at Gateway startup, and StepFun is available from npm and ClawHub. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
@@ -25,7 +25,7 @@ Docs: https://docs.openclaw.ai
- Security and privacy: redact secrets from debug/config output, block internal HTTP session overrides, audit open-DM tool exposure, and retain plugin write ownership checks. (#93333, #88496, #93443, #92883, #93353) Thanks @Alix-007, @jason-allen-oneal, @coygeek, @RichardCao, @yu-xin-c, @cjg20ss, @eleqtrizit, and @vincentkoc.
- Agent and session runtime: retry thinking-only and empty post-tool turns, prevent duplicate hook execution, preserve pending subagent delivery, preserve fresh usage through compaction, and repair partial JSON/history artifacts. (#92191, #93073, #93009, #93084, #93469, #94349, #92383, #94257) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @zenglingbiao, @dertbv, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @vincentkoc, @sallyom, @oiGaDio, @Hidetsugu55, and @Nas01010101.
- Channels and replies: fix Telegram rich delivery, table rendering, action-error handling, and ingress recovery; preserve command progress detail across channel adapters; retain WhatsApp opening text after a media failure; keep Mattermost thread replies intact; and harden Discord action handling. (#93286, #93364, #93281, #93076, #93334, #93424, #93488, #94868, #94891, #94856, #94810, #93823) Thanks @obviyus, @NianJiuZst, @mcaxtr, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, @vincentkoc, @zhangqueping, @jairrab, @ZOOWH, @parveshsaini, and @yetval.
- Channels and replies: fix Telegram rich delivery, table rendering, action-error handling, progress draft cleanup before visible tool output, and ingress recovery; preserve command progress detail across channel adapters; retain WhatsApp opening text after a media failure; keep Mattermost thread replies intact; and harden Discord action handling. (#93286, #93364, #93281, #93002, #93076, #93334, #93424, #93488, #94868, #94891, #94856, #94810, #93823) Thanks @obviyus, @NianJiuZst, @mcaxtr, @zhangguiping-xydt, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, @vincentkoc, @zhangqueping, @jairrab, @ZOOWH, @parveshsaini, and @yetval.
- Storage and migrations: avoid SQLite WAL on network filesystems, clean reindex artifacts, keep setup state out of workspace dot-directories, and import default-agent auth profiles into SQLite. (#93454, #92891, #93182, #93295, #93520, #93156) Thanks @vincentkoc, @ZengWen-DT, @Zeng-wen, @potterdigital, @Alix-007, @Pick-cat, @sallyom, @1qh, and @Tazio7.
- Provider and model behavior: fix Gemini CLI proxy OAuth, restore Codex Spark OAuth routing, correct Bedrock embedding model IDs, and preserve configured defaults in embedded runs. (#92815, #89133, #93452, #93428) Thanks @yetval, @EvetteYoung, @VACInc, @LiuwqGit, @aleck31, @zenglingbiao, @danielgerlag, and @vincentkoc.
- CLI, TUI, and apps: accept global flags after subcommands, keep terminal output and activity indicators visible, preserve CJK IME composition, and refresh stale UI state. (#93455, #93460, #93006, #93427, #93498, #93606) Thanks @ooiuuii, @Alix-007, @ZengWen-DT, @Zeng-wen, @AlethiaQuizForge, @Zhaoqj2016, @liuhao1024, @BrianClaw1955, @vincentkoc, and @NicoBoom13.
@@ -33,7 +33,7 @@ Docs: https://docs.openclaw.ai
### Complete contribution record
This audited record covers the complete v2026.6.8..HEAD history: 422 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
#### Pull requests
@@ -57,6 +57,7 @@ This audited record covers the complete v2026.6.8..HEAD history: 422 merged PRs.
- **PR #88792** fix(state): harden sqlite path caching. Thanks @vincentkoc.
- **PR #93022** fix(gateway): repair usage cost aggregation across agents. Thanks @luke-skywalker-open-claw and @stablegenius49.
- **PR #93020** fix(telegram): cool down transient sendChatAction failures. Related #56096. Thanks @Boulea7 and @sumaiazaman and @Pick-cat and @cal-rufus.
- **PR #93002** fix(telegram): clear progress drafts before visible tool output. Thanks @zhangguiping-xydt.
- **PR #89160** fix(agents): detect truncated API responses to prevent silent session hang. Related #89051. Thanks @joelnishanth and @ArthurusDent.
- **PR #93009** fix(agents): make wrapToolWithBeforeToolCallHook idempotent to prevent double hook execution (fixes #92973). Thanks @zenglingbiao and @dertbv.
- **PR #92991** fix(agents): tolerate missing attribution baseUrl. Related #92974. Thanks @samrusani and @Haderach-Ram.

View File

@@ -23,6 +23,10 @@ private struct WatchChatPreview {
var statusText: String?
}
private struct ExecApprovalGatewayEventPayload: Decodable {
var id: String
}
/// Ensures notification requests return promptly even if the system prompt blocks.
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
private let lock = NSLock()
@@ -895,26 +899,49 @@ final class NodeAppModel {
for await evt in stream {
if Task.isCancelled { return }
guard let payload = evt.payload else { continue }
switch evt.event {
case "voicewake.changed":
struct Payload: Decodable { var triggers: [String] }
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
VoiceWakePreferences.saveTriggerWords(triggers)
case "talk.mode":
struct Payload: Decodable {
var enabled: Bool
var phase: String?
}
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
default:
continue
}
await self.handleOperatorGatewayServerEvent(evt)
}
}
}
private func handleOperatorGatewayServerEvent(_ evt: EventFrame) async {
guard let payload = evt.payload else { return }
switch evt.event {
case "voicewake.changed":
struct Payload: Decodable { var triggers: [String] }
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { return }
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
VoiceWakePreferences.saveTriggerWords(triggers)
case "talk.mode":
struct Payload: Decodable {
var enabled: Bool
var phase: String?
}
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { return }
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
case ExecApprovalNotificationBridge.requestedKind:
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
await self.presentExecApprovalNotificationPrompt(
ExecApprovalNotificationPrompt(approvalId: approvalId))
case ExecApprovalNotificationBridge.resolvedKind:
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
await self.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
default:
return
}
}
private nonisolated static func execApprovalEventID(from payload: AnyCodable) -> String? {
guard let decoded = try? GatewayPayloadDecoding.decode(
payload,
as: ExecApprovalGatewayEventPayload.self)
else {
return nil
}
let approvalId = decoded.id.trimmingCharacters(in: .whitespacesAndNewlines)
return approvalId.isEmpty ? nil : approvalId
}
private func applyTalkModeSync(enabled: Bool, phase: String?) {
_ = phase
guard self.talkMode.isEnabled != enabled else { return }
@@ -5139,6 +5166,14 @@ extension NodeAppModel {
isBackgrounded: isBackgrounded)
}
nonisolated static func _test_execApprovalEventID(from payload: AnyCodable) -> String? {
self.execApprovalEventID(from: payload)
}
func _test_handleOperatorGatewayServerEvent(_ event: EventFrame) async {
await self.handleOperatorGatewayServerEvent(event)
}
nonisolated static func _test_watchExecApprovalIDsNeedingFetch(
candidateIDs: [String],
cachedApprovalIDs: [String]) -> [String]

View File

@@ -1160,6 +1160,35 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
isBackgrounded: false))
}
@Test func execApprovalEventIDDecodesGatewayPayload() {
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " approval-1 "])) == "approval-1")
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " "])) == nil)
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["other": "approval-1"])) == nil)
}
@Test @MainActor func operatorGatewayResolvedEventClearsPendingApprovalPrompt() async throws {
let appModel = NodeAppModel()
try appModel._test_presentExecApprovalPrompt(
#require(
NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-event-resolved",
commandText: "echo clear",
allowedDecisions: ["allow-once", "deny"],
host: "gateway",
nodeId: nil,
agentId: nil,
expiresAtMs: Int(Date().timeIntervalSince1970 * 1000) + 60000)))
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
type: "event",
event: ExecApprovalNotificationBridge.resolvedKind,
payload: AnyCodable(["id": "approval-event-resolved"]),
seq: nil,
stateversion: nil))
#expect(appModel._test_pendingExecApprovalPrompt() == nil)
}
@Test func watchExecApprovalHydrateFetchesOnlyMissingIDs() {
let idsToFetch = NodeAppModel._test_watchExecApprovalIDsNeedingFetch(
candidateIDs: ["cached", "pending", "cached", "other", "", " pending "],

View File

@@ -6592,6 +6592,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public let turnsourceto: AnyCodable?
public let turnsourceaccountid: AnyCodable?
public let turnsourcethreadid: AnyCodable?
public let approvalreviewerdeviceids: [String]?
public let requiredeliveryroute: Bool?
public let suppressdelivery: Bool?
public let timeoutms: Int?
@@ -6618,6 +6619,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
turnsourceto: AnyCodable?,
turnsourceaccountid: AnyCodable?,
turnsourcethreadid: AnyCodable?,
approvalreviewerdeviceids: [String]?,
requiredeliveryroute: Bool? = nil,
suppressdelivery: Bool? = nil,
timeoutms: Int?,
@@ -6643,6 +6645,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
self.turnsourceto = turnsourceto
self.turnsourceaccountid = turnsourceaccountid
self.turnsourcethreadid = turnsourcethreadid
self.approvalreviewerdeviceids = approvalreviewerdeviceids
self.requiredeliveryroute = requiredeliveryroute
self.suppressdelivery = suppressdelivery
self.timeoutms = timeoutms
@@ -6670,6 +6673,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
case turnsourceto = "turnSourceTo"
case turnsourceaccountid = "turnSourceAccountId"
case turnsourcethreadid = "turnSourceThreadId"
case approvalreviewerdeviceids = "approvalReviewerDeviceIds"
case requiredeliveryroute = "requireDeliveryRoute"
case suppressdelivery = "suppressDelivery"
case timeoutms = "timeoutMs"

View File

@@ -1,2 +1,2 @@
12393c35023a5bdddd276edc2b6669fa432454be9bee643138395e2106936945 plugin-sdk-api-baseline.json
62ffb6cd4a433281f571fdf552be9c3f953f6fa055937f822b18de7dd4e20d23 plugin-sdk-api-baseline.jsonl
172fe4e143964c0a20525428ff3e6c7631856a7d51c6ad48959a35c72363a410 plugin-sdk-api-baseline.json
a4c18ea9f0b0d2c22183bf8c082e757b7f9852b4c518c8b8cb62a21a9dd766e9 plugin-sdk-api-baseline.jsonl

View File

@@ -1,11 +1,11 @@
---
summary: "Slack setup and runtime behavior (Socket Mode + HTTP Request URLs)"
summary: "Slack setup and runtime behavior (Socket Mode, HTTP Request URLs, and relay mode)"
read_when:
- Setting up Slack or debugging Slack socket/HTTP mode
- Setting up Slack or debugging Slack socket, HTTP, or relay mode
title: "Slack"
---
Production-ready for DMs and channels via Slack app integrations. Default mode is Socket Mode; HTTP Request URLs are also supported.
Production-ready for DMs and channels via Slack app integrations. Default mode is Socket Mode; HTTP Request URLs are also supported. Relay mode is intended for managed deployments where a trusted router owns Slack ingress.
<CardGroup cols={3}>
<Card title="Pairing" icon="link" href="/channels/pairing">
@@ -41,6 +41,37 @@ Both transports are production-ready and reach feature parity for messaging, sla
**Pick HTTP Request URLs** when running multiple Gateway replicas behind a load balancer, when outbound WSS is blocked but inbound HTTPS is allowed, or when you already terminate Slack webhooks at a reverse proxy.
</Note>
### Relay mode
Relay mode separates Slack ingress from the OpenClaw gateway. A trusted router owns the
single Slack Socket Mode connection, chooses a destination gateway, and forwards a typed
event over an authenticated websocket. The gateway continues to use its bot token for
outbound Slack Web API calls.
```json5
{
channels: {
slack: {
mode: "relay",
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
relay: {
url: "wss://router.example.com/gateway/ws",
authToken: { source: "env", provider: "default", id: "SLACK_RELAY_AUTH_TOKEN" },
gatewayId: "team-gateway",
},
},
},
}
```
The relay URL must use `wss://` unless it targets localhost. Treat the bearer token and
router route table as part of the Slack authorization boundary: routed events enter the
normal Slack message handler as authorized activations. A router-provided `slack_identity`
in the websocket `hello` frame can set the default outbound username and icon; an explicit
identity supplied by the caller still wins. The relay connection reconnects with the same
bounded backoff timing used by Socket Mode and clears the router-provided identity whenever
it disconnects.
## Install
Install Slack before configuring the channel:
@@ -863,7 +894,8 @@ The default manifest enables the Slack App Home **Home** tab and subscribes to `
- `botToken` + `appToken` are required for Socket Mode.
- HTTP mode requires `botToken` + `signingSecret`.
- `botToken`, `appToken`, `signingSecret`, and `userToken` accept plaintext
- Relay mode requires `botToken` plus `relay.url`, `relay.authToken`, and `relay.gatewayId`; it does not use an app token or signing secret.
- `botToken`, `appToken`, `signingSecret`, `relay.authToken`, and `userToken` accept plaintext
strings or SecretRef objects.
- Config tokens override env fallback.
- `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` env fallback applies only to the default account.

View File

@@ -336,7 +336,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
Requirement:
- `channels.telegram.streaming` is `off | partial | block | progress` (default: `partial`)
- `progress` keeps one editable status draft for tool progress, clears it at completion, and sends the final answer as a normal message
- short initial answer previews are debounced, then materialized after a bounded delay if the run is still active
- `progress` keeps one editable status draft for tool progress, shows the stable status label when answer activity arrives before tool progress, clears it at completion, and sends the final answer as a normal message
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active)
- `streaming.preview.commandText` controls command/exec detail inside those tool-progress lines: `raw` (default, preserves released behavior) or `status` (tool label only)
- `streaming.progress.commentary` (default: `false`) opts into assistant commentary/preamble text in the temporary progress draft

View File

@@ -230,8 +230,8 @@ canonical subscription `github-copilot` provider and is **never** selected by
The harness claims its provider, runtime, CLI session key, and auth profile
prefix in `extensions/copilot/doctor-contract-api.ts`, which
`openclaw doctor` auto-loads. For configuration, auth, transcript mirroring,
compaction, the doctor probe surface, and the broader PI vs Codex vs Copilot
SDK decision, see [GitHub Copilot agent runtime](/plugins/copilot).
compaction, the declarative doctor contract, and the broader PI vs Codex vs
Copilot SDK decision, see [GitHub Copilot agent runtime](/plugins/copilot).
## Compatibility contract

View File

@@ -302,13 +302,13 @@ Live transport runners should import the shared scenario ids, baseline
coverage helpers, and scenario-selection helper from
`openclaw/plugin-sdk/qa-live-transport-scenarios`.
| Lane | Canary | Mention gating | Bot-to-bot | Allowlist block | Top-level reply | Restart resume | Thread follow-up | Thread isolation | Reaction observation | Help command | Native command registration |
| -------- | ------ | -------------- | ---------- | --------------- | --------------- | -------------- | ---------------- | ---------------- | -------------------- | ------------ | --------------------------- |
| Matrix | x | x | x | x | x | x | x | x | x | | |
| Telegram | x | x | x | | | | | | | x | |
| Discord | x | x | x | | | | | | | | x |
| Slack | x | x | x | x | x | x | x | x | | | |
| WhatsApp | x | x | | x | x | x | | | x | x | |
| Lane | Canary | Mention gating | Bot-to-bot | Allowlist block | Top-level reply | Quote reply | Restart resume | Thread follow-up | Thread isolation | Reaction observation | Help command | Native command registration |
| -------- | ------ | -------------- | ---------- | --------------- | --------------- | ----------- | -------------- | ---------------- | ---------------- | -------------------- | ------------ | --------------------------- |
| Matrix | x | x | x | x | x | | x | x | x | x | | |
| Telegram | x | x | x | | | | | | | | x | |
| Discord | x | x | x | | | | | | | | | x |
| Slack | x | x | x | x | x | | x | x | x | | | |
| WhatsApp | x | x | | x | x | x | x | | | x | x | |
This keeps `qa-channel` as the broad product-behavior suite while Matrix,
Telegram, and other live transports share one explicit transport-contract checklist.
@@ -731,8 +731,9 @@ Scenario catalog (`extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.
`whatsapp-whoami-command`, `whatsapp-context-command`,
`whatsapp-native-new-command`.
- Reply and final-output behavior: `whatsapp-tool-only-usage-footer`,
`whatsapp-reply-to-message`, `whatsapp-reply-context-isolation`,
`whatsapp-reply-delivery-shape`, `whatsapp-stream-final-message-accounting`.
`whatsapp-reply-to-message`, `whatsapp-group-reply-to-message`,
`whatsapp-reply-context-isolation`, `whatsapp-reply-delivery-shape`,
`whatsapp-stream-final-message-accounting`.
- Inbound media and structured messages: `whatsapp-inbound-image-caption`,
`whatsapp-audio-preflight`, `whatsapp-inbound-structured-messages`,
`whatsapp-group-audio-gating`. These send real WhatsApp image, audio,
@@ -749,9 +750,9 @@ Scenario catalog (`extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.
`whatsapp-approval-plugin-native`.
- Status reactions: `whatsapp-status-reactions`.
The catalog currently contains 35 scenarios. The `live-frontier` default lane is
kept small at 8 scenarios for fast smoke coverage. The `mock-openai` default
lane runs 29 deterministic scenarios through the real WhatsApp transport while
The catalog currently contains 36 scenarios. The `live-frontier` default lane is
kept small at 10 scenarios for fast smoke coverage. The `mock-openai` default
lane runs 31 deterministic scenarios through the real WhatsApp transport while
mocking only model output. Approval scenarios and a few heavier/blocking checks
remain explicit by scenario id.

View File

@@ -160,9 +160,10 @@ Legacy key migration:
Telegram:
- Uses `sendMessage` + `editMessageText` preview updates across DMs and group/topics.
- Short initial previews are still debounced for push-notification UX, but Telegram now materializes them after a bounded delay so active runs do not stay visually silent.
- Final text edits the active preview in place; long finals reuse that message for the first chunk and send only the remaining chunks.
- `block` mode rotates the preview into a new message at `streaming.preview.chunk.maxChars` (default 800, capped at Telegram's 4096 edit limit); other modes grow one preview up to 4096 characters.
- `progress` mode keeps tool progress in an editable status draft, clears that draft at completion, and sends the final answer through normal delivery.
- `progress` mode keeps tool progress in an editable status draft, materializes the status label when answer streaming is active but no tool line is available yet, clears that draft at completion, and sends the final answer through normal delivery.
- If the final edit fails before the completed text is confirmed, OpenClaw uses normal final delivery and cleans up the stale preview.
- Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming).
- `/reasoning stream` can write reasoning to a transient preview that is deleted after final delivery.

View File

@@ -249,9 +249,10 @@ Shared defaults for bounded runtime context surfaces.
- `toolResultMaxChars`: advanced live tool-result ceiling used for persisted
results and overflow recovery. Leave unset for the model-context auto cap:
`16000` chars below 100K tokens, `32000` chars at 100K+ tokens, and `64000`
chars at 200K+ tokens. The effective cap is still limited to about 30% of the
model context window. `openclaw doctor --deep` prints the effective cap, and
doctor warns only when an explicit override is stale or has no effect.
chars at 200K+ tokens. Explicit values up to `1000000` are accepted for
long-context models, but the effective cap is still limited to about 30% of
the model context window. `openclaw doctor --deep` prints the effective cap,
and doctor warns only when an explicit override is stale or has no effect.
- `postCompactionMaxChars`: AGENTS.md excerpt cap used during post-compaction
refresh injection.

View File

@@ -160,8 +160,6 @@ must be paired with `--lint`; regular doctor and repair runs reject them.
- State integrity and permissions checks (sessions, transcripts, state dir).
- Config file permission checks (chmod 600) when running locally.
- Model auth health: checks OAuth expiry, can refresh expiring tokens, and reports auth-profile cooldown/disabled states.
- Extra workspace dir detection (`~/openclaw`).
</Accordion>
<Accordion title="Gateway, services, and supervisors">
- Sandbox image repair when sandboxing is enabled.
@@ -469,14 +467,14 @@ That stages grounded durable candidates into the short-term dreaming store while
<Accordion title="10. systemd linger (Linux)">
If running as a systemd user service, doctor ensures lingering is enabled so the gateway stays alive after logout.
</Accordion>
<Accordion title="11. Workspace status (skills, plugins, and legacy dirs)">
<Accordion title="11. Workspace status (skills, plugins, and TaskFlows)">
Doctor prints a summary of the workspace state for the default agent:
- **Skills status**: counts eligible, missing-requirements, and allowlist-blocked skills.
- **Legacy workspace dirs**: warns when `~/openclaw` or other legacy workspace directories exist alongside the current workspace.
- **Plugin status**: counts enabled/disabled/errored plugins; lists plugin IDs for any errors; reports bundle plugin capabilities.
- **Plugin compatibility warnings**: flags plugins that have compatibility issues with the current runtime.
- **Plugin diagnostics**: surfaces any load-time warnings or errors emitted by the plugin registry.
- **TaskFlow recovery**: surfaces suspicious managed TaskFlows that need manual inspection or cancellation.
</Accordion>
<Accordion title="11b. Bootstrap file size">

View File

@@ -15,15 +15,18 @@ OpenClaw treats **wake words as a single global list** owned by the **Gateway**.
## Storage (Gateway host)
Wake words are stored on the gateway machine at:
Wake words and routing rules are stored in the gateway state database:
- `~/.openclaw/settings/voicewake.json`
- `~/.openclaw/state/openclaw.sqlite`
Shape:
The active tables are:
```json
{ "triggers": ["openclaw", "claude", "computer"], "updatedAtMs": 1730000000000 }
```
- `voicewake_triggers`
- `voicewake_routing_config`
- `voicewake_routing_routes`
Legacy `settings/voicewake.json` and `settings/voicewake-routing.json` files are
doctor migration inputs only; runtime reads and writes the SQLite tables.
## Protocol

View File

@@ -33,15 +33,12 @@ For the broader model/provider/runtime split, start with
- A GitHub Copilot subscription that can drive the Copilot CLI (or a
`gitHubToken` env / auth-profile entry for headless / cron runs).
- A writable `copilotHome` directory. The harness defaults to
`~/.openclaw/agents/<agentId>/copilot` for full per-agent isolation. The
platform default (`%APPDATA%\copilot` on Windows, `$XDG_CONFIG_HOME/copilot`
or `~/.config/copilot` elsewhere) is used as the doctor probe fallback when
no explicit home is set.
`<agentDir>/copilot` when OpenClaw provides an agent directory, otherwise
`~/.openclaw/agents/<agentId>/copilot` for full per-agent isolation.
`openclaw doctor` runs the plugin
[doctor contract](#doctor-and-probes) for the extension; failures there are
the canonical way to confirm the environment is ready before opting an agent
in.
[doctor contract](#doctor) for declarative session-state ownership and future
compatibility migrations. It does not run Copilot CLI environment probes.
## Plugin install
@@ -153,10 +150,6 @@ the same directory), or `~/.openclaw/agents/<agentId>/copilot` otherwise.
Override with `copilotHome: <path>` on the attempt input when you need a
custom location (for example, a shared mount for migration).
`probeCopilotAuthShape` (see [Doctor and probes](#doctor-and-probes)) is the
pure shape check that validates which of the modes above will be used.
It does not perform a live SDK handshake.
## Configuration surface
The harness reads its config from per-attempt input
@@ -239,7 +232,7 @@ asserted in
[`extensions/copilot/harness.test.ts`](https://github.com/openclaw/openclaw/blob/main/extensions/copilot/harness.test.ts)
under `describe("runSideQuestion")`.
## Doctor and probes
## Doctor
`extensions/copilot/doctor-contract-api.ts` is auto-loaded by
`src/plugins/doctor-contract-registry.ts`. It contributes:
@@ -251,18 +244,6 @@ under `describe("runSideQuestion")`.
runtime `copilot`; CLI session key `copilot`; auth profile
prefix `github-copilot:`.
`extensions/copilot/src/doctor-probes.ts` exports three imperative probes
that hosts (including `openclaw doctor`) can call to verify the environment:
| Probe | What it checks | Reasons it can fail |
| -------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `probeCopilotCliVersion` | `copilot --version` exits 0 with a non-empty version string | `non-zero-exit`, `empty-version`, `spawn-failed`, `spawn-error`, `probe-timeout` |
| `probeCopilotHomeWritable` | `mkdir -p copilotHome` + write + rm a marker file | `copilothome-not-writable` (with the underlying fs error in `details.rawError`) |
| `probeCopilotAuthShape` | At least one of `useLoggedInUser`, `gitHubToken`, or `profileId`+`profileVersion` | `no-auth-source` |
Each probe accepts a DI seam (`spawnFn`, `fsApi`) so tests do not spawn the
real Copilot CLI or touch the host fs.
## Limitations
- The harness only claims the canonical `github-copilot` provider at MVP.

View File

@@ -72,10 +72,12 @@ Scope intent:
- `channels.telegram.accounts.*.webhookSecret`
- `channels.slack.botToken`
- `channels.slack.appToken`
- `channels.slack.relay.authToken`
- `channels.slack.userToken`
- `channels.slack.signingSecret`
- `channels.slack.accounts.*.botToken`
- `channels.slack.accounts.*.appToken`
- `channels.slack.accounts.*.relay.authToken`
- `channels.slack.accounts.*.userToken`
- `channels.slack.accounts.*.signingSecret`
- `channels.sms.authToken`

View File

@@ -295,6 +295,13 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.accounts.*.relay.authToken",
"configFile": "openclaw.json",
"path": "channels.slack.accounts.*.relay.authToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.accounts.*.signingSecret",
"configFile": "openclaw.json",
@@ -323,6 +330,13 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.relay.authToken",
"configFile": "openclaw.json",
"path": "channels.slack.relay.authToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.signingSecret",
"configFile": "openclaw.json",

View File

@@ -54,7 +54,7 @@ for bounded runtime excerpts and injected runtime-owned blocks. They are
separate from bootstrap limits, startup-context limits, and skills prompt
limits.
`toolResultMaxChars` is an advanced ceiling. When it is unset, OpenClaw chooses
`toolResultMaxChars` is an advanced ceiling (up to `1000000` characters). When it is unset, OpenClaw chooses
the live tool-result cap from the effective model context window: `16000` chars
below 100K tokens, `32000` chars at 100K+ tokens, and `64000` chars at 200K+
tokens, still bounded by the runtime context-share guard.

View File

@@ -10,6 +10,12 @@ import {
resolveClaudeCliExecutionArgs,
} from "./cli-shared.js";
function expectDefaultDisallowedTools(args: readonly string[] | undefined) {
const disallowedIndex = args?.indexOf("--disallowedTools") ?? -1;
expect(disallowedIndex).toBeGreaterThanOrEqual(0);
expect(args?.[disallowedIndex + 1]).toBe("ScheduleWakeup,CronCreate");
}
describe("normalizeClaudePermissionArgs", () => {
it("leaves args alone when they omit permission flags", () => {
expect(
@@ -356,8 +362,10 @@ describe("normalizeClaudeBackendConfig", () => {
expect(backend.config.input).toBe("stdin");
expect(backend.config.args).toContain("--setting-sources");
expect(backend.config.args).toContain("user");
expectDefaultDisallowedTools(backend.config.args);
expect(backend.config.resumeArgs).toContain("--setting-sources");
expect(backend.config.resumeArgs).toContain("user");
expectDefaultDisallowedTools(backend.config.resumeArgs);
expect(backend.config.clearEnv).toEqual([...CLAUDE_CLI_CLEAR_ENV]);
expect(backend.config.clearEnv).toContain("ANTHROPIC_API_TOKEN");
expect(backend.config.clearEnv).toContain("ANTHROPIC_BASE_URL");

View File

@@ -1,12 +1,11 @@
/**
* Browser plugin runtime lifecycle helpers for startup relay setup and shutdown
* cleanup.
* Browser plugin runtime lifecycle helpers for startup and shutdown cleanup.
*/
import type { Server } from "node:http";
import { getPwAiModule } from "./pw-ai-module.js";
import { isPwAiLoaded } from "./pw-ai-state.js";
import type { BrowserServerState } from "./server-context.js";
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
import { stopKnownBrowserProfiles } from "./server-lifecycle.js";
import { startTrackedBrowserTabCleanupTimer } from "./session-tab-cleanup.js";
import { registerBrowserUnhandledRejectionHandler } from "./unhandled-rejections.js";
@@ -27,10 +26,6 @@ export async function createBrowserRuntimeState(params: {
onWarn: params.onWarn,
});
await ensureExtensionRelayForProfiles({
resolved: params.resolved,
onWarn: params.onWarn,
});
state.stopUnhandledRejectionHandler = registerBrowserUnhandledRejectionHandler();
return state;

View File

@@ -19,7 +19,6 @@ const { getUnhandledRejectionHandlers, registerUnhandledRejectionHandlerMock, re
});
const {
ensureExtensionRelayForProfilesMock,
getPwAiModuleMock,
isPwAiLoadedMock,
startTrackedBrowserTabCleanupTimerMock,
@@ -28,7 +27,6 @@ const {
} = vi.hoisted(() => {
const trackedTabCleanupMockLocal = vi.fn();
return {
ensureExtensionRelayForProfilesMock: vi.fn(async () => {}),
getPwAiModuleMock: vi.fn(),
isPwAiLoadedMock: vi.fn(() => false),
startTrackedBrowserTabCleanupTimerMock: vi.fn(() => trackedTabCleanupMockLocal),
@@ -42,7 +40,6 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
}));
vi.mock("./server-lifecycle.js", () => ({
ensureExtensionRelayForProfiles: ensureExtensionRelayForProfilesMock,
stopKnownBrowserProfiles: stopKnownBrowserProfilesMock,
}));
@@ -64,7 +61,6 @@ const { isPlaywrightDialogRaceUnhandledRejection } = await import("./unhandled-r
beforeEach(() => {
resetHandlers();
registerUnhandledRejectionHandlerMock.mockClear();
ensureExtensionRelayForProfilesMock.mockClear();
getPwAiModuleMock.mockClear();
isPwAiLoadedMock.mockReset().mockReturnValue(false);
startTrackedBrowserTabCleanupTimerMock.mockClear();

View File

@@ -19,8 +19,7 @@ vi.mock("./server-context.js", () => ({
listKnownProfileNames: listKnownProfileNamesMock,
}));
const { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } =
await import("./server-lifecycle.js");
const { stopKnownBrowserProfiles } = await import("./server-lifecycle.js");
beforeEach(() => {
createBrowserRouteContextMock.mockClear();
@@ -28,17 +27,6 @@ beforeEach(() => {
stopOpenClawChromeMock.mockClear();
});
describe("ensureExtensionRelayForProfiles", () => {
it("is a no-op after removing the Chrome extension relay path", async () => {
await expect(
ensureExtensionRelayForProfiles({
resolved: { profiles: {} } as never,
onWarn: vi.fn(),
}),
).resolves.toBeUndefined();
});
});
describe("stopKnownBrowserProfiles", () => {
it("stops all known profiles and ignores per-profile failures", async () => {
listKnownProfileNamesMock.mockReturnValue(["openclaw", "user"]);

View File

@@ -1,24 +1,13 @@
/**
* Browser server lifecycle helpers for relay setup and profile shutdown.
* Browser server lifecycle helpers for profile shutdown.
*/
import { stopOpenClawChrome } from "./chrome.js";
import type { ResolvedBrowserConfig } from "./config.js";
import {
type BrowserServerState,
createBrowserRouteContext,
listKnownProfileNames,
} from "./server-context.js";
/** Ensures extension relay compatibility hooks for configured profiles. */
export async function ensureExtensionRelayForProfiles(_params: {
resolved: ResolvedBrowserConfig;
onWarn: (message: string) => void;
}) {
// Intentional no-op: the Chrome extension relay path has been removed.
// runtime-lifecycle still calls this helper, so keep the stub until the next
// breaking cleanup rather than changing the call graph in a patch release.
}
/** Stops every known Browser profile during runtime shutdown. */
export async function stopKnownBrowserProfiles(params: {
getState: () => BrowserServerState | null;

View File

@@ -20,7 +20,6 @@ const mocks = vi.hoisted(() => ({
}),
resolveBrowserControlAuth: vi.fn(() => ({})),
shouldAutoGenerateBrowserAuth: vi.fn(() => true),
ensureExtensionRelayForProfiles: vi.fn(async () => {}),
}));
vi.mock("../config/config.js", async () => {
@@ -69,7 +68,6 @@ vi.mock("./server-context.js", () => ({
}));
vi.mock("./server-lifecycle.js", () => ({
ensureExtensionRelayForProfiles: mocks.ensureExtensionRelayForProfiles,
stopKnownBrowserProfiles: vi.fn(async () => {}),
}));
@@ -85,7 +83,6 @@ describe("browser control auth bootstrap failures", () => {
mocks.ensureBrowserControlAuth.mockClear();
mocks.resolveBrowserControlAuth.mockClear();
mocks.shouldAutoGenerateBrowserAuth.mockClear();
mocks.ensureExtensionRelayForProfiles.mockClear();
});
afterEach(async () => {
@@ -98,7 +95,6 @@ describe("browser control auth bootstrap failures", () => {
expect(started).toBeNull();
expect(mocks.ensureBrowserControlAuth).toHaveBeenCalledTimes(1);
expect(mocks.resolveBrowserControlAuth).toHaveBeenCalledTimes(1);
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
});
it("fails closed when auth bootstrap resolves empty auth in production-like mode", async () => {
@@ -111,7 +107,6 @@ describe("browser control auth bootstrap failures", () => {
expect(started).toBeNull();
expect(mocks.ensureBrowserControlAuth).toHaveBeenCalledTimes(1);
expect(mocks.resolveBrowserControlAuth).toHaveBeenCalledTimes(1);
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
});
it("fails closed when password mode has no resolved password", async () => {
@@ -123,7 +118,6 @@ describe("browser control auth bootstrap failures", () => {
const started = await startBrowserControlServerFromConfig();
expect(started).toBeNull();
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
});
it("fails closed when password mode drops an inactive token but has no password", async () => {
@@ -136,6 +130,5 @@ describe("browser control auth bootstrap failures", () => {
const started = await startBrowserControlServerFromConfig();
expect(started).toBeNull();
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
});
});

View File

@@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({
ensureBrowserControlAuth: vi.fn(async () => ({ auth: {} })),
resolveBrowserControlAuth: vi.fn(() => ({})),
shouldAutoGenerateBrowserAuth: vi.fn(() => false),
ensureExtensionRelayForProfiles: vi.fn(async () => {}),
stopKnownBrowserProfiles: vi.fn(async () => {}),
isChromeReachable: vi.fn(async () => false),
isChromeCdpReady: vi.fn(async () => false),
@@ -32,7 +31,6 @@ vi.mock("../browser/control-auth.js", () => ({
}));
vi.mock("../browser/server-lifecycle.js", () => ({
ensureExtensionRelayForProfiles: mocks.ensureExtensionRelayForProfiles,
stopKnownBrowserProfiles: mocks.stopKnownBrowserProfiles,
}));

View File

@@ -961,6 +961,26 @@ describe("Codex app-server dynamic tool build", () => {
});
});
it("passes the approval reviewer device into Codex dynamic tools", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.approvalReviewerDeviceId = "device-ios-reviewer";
params.runtimePlan = createCodexRuntimePlanFixture();
const factoryOptions: unknown[] = [];
setOpenClawCodingToolsFactoryForTests((options) => {
factoryOptions.push(options);
return [];
});
await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never });
expect(factoryOptions[0]).toMatchObject({
approvalReviewerDeviceId: "device-ios-reviewer",
});
});
it("forwards tool outcome ordering into Codex dynamic tools", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -19,10 +19,7 @@ import {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import { isToolAllowed } from "openclaw/plugin-sdk/sandbox";
import {
readCodexPluginConfig,
type CodexPluginConfig,
} from "./config.js";
import { readCodexPluginConfig, type CodexPluginConfig } from "./config.js";
import {
filterCodexDynamicTools,
isForcedPrivateQaCodexRuntime,
@@ -260,6 +257,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
...sessionKeys,
sessionId: params.sessionId,
runId: params.runId,
approvalReviewerDeviceId: params.approvalReviewerDeviceId,
agentDir,
cwd: input.effectiveCwd ?? input.effectiveWorkspace,
workspaceDir: input.effectiveWorkspace,
@@ -593,9 +591,10 @@ export function resolveCodexAppServerExecutionCwd(params: {
nativeToolSurfaceEnabled: boolean;
remoteWorkspaceRoot?: string;
}): string {
const cwd = params.environment && params.nativeToolSurfaceEnabled
? params.environment.cwd
: params.effectiveCwd;
const cwd =
params.environment && params.nativeToolSurfaceEnabled
? params.environment.cwd
: params.effectiveCwd;
return mapCodexAppServerRemoteWorkspacePath({
value: cwd,
localWorkspaceRoot: params.localWorkspaceRoot,

View File

@@ -16,7 +16,7 @@ on a model or provider entry; `auto` never picks it. PI remains the default
embedded runtime.
See [GitHub Copilot agent runtime](../../docs/plugins/copilot.md) for
configuration, doctor probes, transcript mirroring, compaction, side
configuration, the doctor contract, transcript mirroring, compaction, side
questions, replay, and the supported-surface contract.
See [qa/copilot-capabilities.md](../../qa/copilot-capabilities.md)
for the SDK capability inventory the harness is pinned to.

View File

@@ -11,14 +11,6 @@
* fields exist for copilot yet; the array is empty by design
* and normalizeCompatibilityConfig is a structural no-op so
* future retirements have a stable in-tree home.
*
* The deeper runtime probes (copilot CLI version, copilot auth,
* copilotHome writability) live in {@link ./src/doctor-probes.ts}
* because they have side effects (subprocess spawn, fs touch) and
* need to be invoked imperatively, not declaratively, from the
* doctor command. They are exported separately so callers can opt
* in. Auto-discovery of doctor-contract-api.ts at the plugin root
* keeps this file purely declarative.
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";

View File

@@ -1,284 +0,0 @@
// Copilot tests cover doctor probes plugin behavior.
import { EventEmitter } from "node:events";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
probeCopilotAuthShape,
probeCopilotCliVersion,
probeCopilotHomeWritable,
} from "./doctor-probes.js";
type FakeChildOptions = {
exitCode?: number | null;
signal?: NodeJS.Signals | null;
stdout?: string;
stderr?: string;
emitErrorMessage?: string;
/** When true, never emits close; useful for timeout tests. */
hang?: boolean;
};
function makeFakeChild(opts: FakeChildOptions = {}) {
const emitter = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: () => void;
};
emitter.stdout = new EventEmitter();
emitter.stderr = new EventEmitter();
emitter.kill = vi.fn();
queueMicrotask(() => {
if (opts.stdout) {
emitter.stdout.emit("data", Buffer.from(opts.stdout, "utf8"));
}
if (opts.stderr) {
emitter.stderr.emit("data", Buffer.from(opts.stderr, "utf8"));
}
if (opts.emitErrorMessage) {
emitter.emit("error", new Error(opts.emitErrorMessage));
return;
}
if (!opts.hang) {
emitter.emit("close", opts.exitCode ?? 0, opts.signal ?? null);
}
});
return emitter;
}
const tempDirs: string[] = [];
afterEach(async () => {
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true });
}
});
async function makeTempHome(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-copilot-doctor-"));
tempDirs.push(dir);
return dir;
}
describe("probeCopilotCliVersion", () => {
it("reports ok with trimmed version on exit 0 with stdout", async () => {
const result = await probeCopilotCliVersion({
spawnFn: () => makeFakeChild({ stdout: " 1.2.3 \n" }) as never,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.version).toBe("1.2.3");
expect(result.command).toBe("copilot");
}
});
it("uses custom command and args when provided", async () => {
const calls: Array<{ cmd: string; args: string[] }> = [];
const result = await probeCopilotCliVersion({
command: "my-copilot",
args: ["-V"],
spawnFn: ((cmd: string, args: readonly string[]) => {
calls.push({ cmd, args: [...args] });
return makeFakeChild({ stdout: "9.9.9" }) as never;
}) as never,
});
expect(calls).toEqual([{ cmd: "my-copilot", args: ["-V"] }]);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.command).toBe("my-copilot");
}
});
it("reports non-zero-exit with stderr details", async () => {
const result = await probeCopilotCliVersion({
spawnFn: () => makeFakeChild({ exitCode: 2, stderr: "boom: not installed" }) as never,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("non-zero-exit");
expect(result.details?.exitCode).toBe(2);
expect(result.details?.stderr).toBe("boom: not installed");
}
});
it("reports empty-version when exit 0 produces no stdout", async () => {
const result = await probeCopilotCliVersion({
spawnFn: () => makeFakeChild({ stdout: " \n" }) as never,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("empty-version");
}
});
it("reports spawn-failed when spawnFn throws synchronously (e.g. ENOENT)", async () => {
const result = await probeCopilotCliVersion({
spawnFn: (() => {
throw new Error("ENOENT: copilot not found");
}) as never,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("spawn-failed");
expect(result.details?.rawError).toContain("ENOENT");
}
});
it("reports spawn-error when child emits 'error'", async () => {
const result = await probeCopilotCliVersion({
spawnFn: () => makeFakeChild({ emitErrorMessage: "spawn ENOEXEC" }) as never,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("spawn-error");
expect(result.details?.rawError).toBe("spawn ENOEXEC");
}
});
it("reports probe-timeout when child hangs past timeoutMs and kills the child", async () => {
const fakeChild = makeFakeChild({ hang: true });
const result = await probeCopilotCliVersion({
timeoutMs: 10,
spawnFn: () => fakeChild as never,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("probe-timeout");
expect(result.details?.timeoutMs).toBe(10);
}
expect(fakeChild.kill).toHaveBeenCalled();
});
it("returns just the first non-empty line as version when stdout has a banner / update hint", async () => {
const result = await probeCopilotCliVersion({
spawnFn: () =>
makeFakeChild({
stdout: "GitHub Copilot CLI 1.0.48.\nRun 'copilot update' to check for updates.\n",
}) as never,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.version).toBe("GitHub Copilot CLI 1.0.48.");
expect(result.rawStdout).toBe(
"GitHub Copilot CLI 1.0.48.\nRun 'copilot update' to check for updates.",
);
}
});
it("does not surface rawStdout when stdout is already single-line", async () => {
const result = await probeCopilotCliVersion({
spawnFn: () => makeFakeChild({ stdout: "1.2.3\n" }) as never,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.version).toBe("1.2.3");
expect(result.rawStdout).toBeUndefined();
}
});
});
describe("probeCopilotHomeWritable", () => {
it("reports ok when the directory exists and is writable, cleaning up after itself", async () => {
const home = await makeTempHome();
const result = await probeCopilotHomeWritable(home);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.copilotHome).toBe(home);
expect(result.probedPath.startsWith(home)).toBe(true);
}
const entries = await fs.readdir(home);
expect(entries).toEqual([]);
});
it("creates copilotHome if missing", async () => {
const root = await makeTempHome();
const home = path.join(root, "nested", "copilot-cfg");
const result = await probeCopilotHomeWritable(home);
expect(result.ok).toBe(true);
const stat = await fs.stat(home);
expect(stat.isDirectory()).toBe(true);
});
it("reports copilothome-not-writable when fs throws on mkdir", async () => {
const result = await probeCopilotHomeWritable("/some/path", {
fsApi: {
mkdir: vi.fn().mockRejectedValueOnce(new Error("EPERM: not permitted")),
writeFile: vi.fn(),
rm: vi.fn(),
} as never,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("copilothome-not-writable");
expect(result.details?.rawError).toContain("EPERM");
}
});
it("falls back to the platform default copilotHome when argument is empty or whitespace", async () => {
const writeFile = vi.fn().mockResolvedValue(undefined);
const result = await probeCopilotHomeWritable(" ", {
fsApi: {
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile,
rm: vi.fn().mockResolvedValue(undefined),
} as never,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.copilotHome.length).toBeGreaterThan(0);
expect(result.copilotHome.toLowerCase()).toContain("copilot");
}
});
});
describe("probeCopilotAuthShape", () => {
it("resolves to useLoggedInUser when the flag is true", () => {
const result = probeCopilotAuthShape({ useLoggedInUser: true });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.resolvedMode).toBe("useLoggedInUser");
}
});
it("resolves to gitHubToken when a non-empty token is supplied", () => {
const result = probeCopilotAuthShape({ gitHubToken: "ghp_xxx" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.resolvedMode).toBe("gitHubToken");
}
});
it("resolves to profile when both profileId and profileVersion are supplied", () => {
const result = probeCopilotAuthShape({ profileId: "p1", profileVersion: "v1" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.resolvedMode).toBe("profile");
}
});
it("rejects when no auth source is provided", () => {
const result = probeCopilotAuthShape({});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("no-auth-source");
}
});
it("rejects when only one of profileId / profileVersion is provided", () => {
expect(probeCopilotAuthShape({ profileId: "p1" }).ok).toBe(false);
expect(probeCopilotAuthShape({ profileVersion: "v1" }).ok).toBe(false);
});
it("rejects useLoggedInUser:false on its own", () => {
const result = probeCopilotAuthShape({ useLoggedInUser: false });
expect(result.ok).toBe(false);
});
it("rejects an empty gitHubToken string", () => {
const result = probeCopilotAuthShape({ gitHubToken: "" });
expect(result.ok).toBe(false);
});
});

View File

@@ -1,260 +0,0 @@
/**
* Runtime doctor probes for the copilot extension.
*
* Imperative side-effecting checks used to diagnose a copilot
* deployment from within `openclaw doctor` (or any equivalent
* harness-side health check). Kept out of doctor-contract-api.ts
* because that contract is declarative and auto-loaded by the
* plugin registry, whereas these probes spawn subprocesses or
* touch the filesystem and must be invoked imperatively.
*
* All probes are pure (no module-level state) and dependency-
* injectable for tests. They never throw on a probe-negative
* result — failure is surfaced via the `ok: false` shape so the
* caller can render a structured doctor report.
*/
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export type ProbeResult<TPayload extends object = Record<string, never>> =
| ({ ok: true } & TPayload)
| { ok: false; reason: string; details?: Record<string, unknown> };
export interface ProbeCopilotCliVersionOptions {
/** Command to invoke; defaults to "copilot". */
command?: string;
/** Argv used to ask for version; defaults to ["--version"]. */
args?: readonly string[];
/** Timeout in milliseconds; defaults to 5_000. */
timeoutMs?: number;
/** Injection seam for testing. Defaults to node:child_process spawn. */
spawnFn?: typeof spawn;
}
export interface ProbeCopilotHomeOptions {
/** Injection seam for testing. */
fsApi?: Pick<typeof fs, "mkdir" | "writeFile" | "rm">;
/** Filename used for the writability probe. */
probeFileName?: string;
}
const DEFAULT_PROBE_TIMEOUT_MS = 5_000;
const DEFAULT_PROBE_FILENAME = ".copilot-doctor-probe";
/**
* Probe that the Copilot CLI is installed and prints a version.
* Treats non-zero exit, missing stdout, and timeout all as failures.
*/
export async function probeCopilotCliVersion(
options: ProbeCopilotCliVersionOptions = {},
): Promise<ProbeResult<{ version: string; command: string; rawStdout?: string }>> {
const command = options.command ?? "copilot";
const args = options.args ?? ["--version"];
const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
const spawnImpl = options.spawnFn ?? spawn;
return new Promise<ProbeResult<{ version: string; command: string; rawStdout?: string }>>(
(resolve) => {
let child: ReturnType<typeof spawn> | undefined;
let settled = false;
const settle = (
result: ProbeResult<{ version: string; command: string; rawStdout?: string }>,
): void => {
if (settled) {
return;
}
settled = true;
if (timer) {
clearTimeout(timer);
}
try {
child?.kill();
} catch {
// ignore double-kill / already-dead errors
}
resolve(result);
};
const timer = setTimeout(() => {
settle({
ok: false,
reason: "probe-timeout",
details: { command, args: [...args], timeoutMs },
});
}, timeoutMs);
try {
child = spawnImpl(command, [...args], { stdio: ["ignore", "pipe", "pipe"] });
} catch (error) {
settle({
ok: false,
reason: "spawn-failed",
details: { command, args: [...args], rawError: formatProbeError(error) },
});
return;
}
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf8");
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
});
child.on("error", (error: Error) => {
settle({
ok: false,
reason: "spawn-error",
details: { command, args: [...args], rawError: error.message },
});
});
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
if (code !== 0) {
settle({
ok: false,
reason: "non-zero-exit",
details: {
command,
args: [...args],
exitCode: code,
signal,
stderr: stderr.trim() || undefined,
},
});
return;
}
const rawStdout = stdout.trim();
if (!rawStdout) {
settle({
ok: false,
reason: "empty-version",
details: { command, args: [...args] },
});
return;
}
// Many version commands (notably the GitHub Copilot CLI's `copilot --version`)
// print a banner plus an "update available" hint on subsequent
// lines. Surface only the first non-empty line as `version` so the
// doctor UI gets a clean string; keep the full stdout in
// `rawStdout` for debugging.
const version = firstNonEmptyLine(rawStdout) ?? rawStdout;
const payload: { version: string; command: string; rawStdout?: string } = {
version,
command,
};
if (rawStdout !== version) {
payload.rawStdout = rawStdout;
}
settle({ ok: true, ...payload });
});
},
);
}
function firstNonEmptyLine(value: string): string | undefined {
for (const line of value.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
return undefined;
}
/**
* Probe that copilotHome (or default ~/.config/copilot) is writable
* by the running user. Mirrors the existing auth-bridge's expectation
* that the SDK can persist credentials under copilotHome.
*/
export async function probeCopilotHomeWritable(
copilotHome: string | undefined,
options: ProbeCopilotHomeOptions = {},
): Promise<ProbeResult<{ copilotHome: string; probedPath: string }>> {
const fsApi = options.fsApi ?? fs;
const probeFileName = options.probeFileName ?? DEFAULT_PROBE_FILENAME;
const resolvedHome =
typeof copilotHome === "string" && copilotHome.trim().length > 0
? copilotHome.trim()
: defaultCopilotHome();
const probedPath = path.join(resolvedHome, probeFileName);
try {
await fsApi.mkdir(resolvedHome, { recursive: true });
await fsApi.writeFile(probedPath, "copilot-doctor-probe", "utf8");
await fsApi.rm(probedPath, { force: true });
return { ok: true, copilotHome: resolvedHome, probedPath };
} catch (error) {
return {
ok: false,
reason: "copilothome-not-writable",
details: {
copilotHome: resolvedHome,
probedPath,
rawError: formatProbeError(error),
},
};
}
}
/**
* Probe GitHub Copilot agent runtime auth resolution given a useLoggedInUser hint.
* Validates that at least one of {useLoggedInUser, gitHubToken,
* profileId+profileVersion} is set. This is intentionally a
* shape-only probe: actually performing an SDK auth handshake
* would require a pool and is out of scope for `openclaw doctor`.
*/
export function probeCopilotAuthShape(input: {
useLoggedInUser?: boolean;
gitHubToken?: string;
profileId?: string;
profileVersion?: string;
}): ProbeResult<{ resolvedMode: "useLoggedInUser" | "gitHubToken" | "profile" }> {
if (input.useLoggedInUser === true) {
return { ok: true, resolvedMode: "useLoggedInUser" };
}
if (typeof input.gitHubToken === "string" && input.gitHubToken.length > 0) {
return { ok: true, resolvedMode: "gitHubToken" };
}
if (
typeof input.profileId === "string" &&
input.profileId.length > 0 &&
typeof input.profileVersion === "string" &&
input.profileVersion.length > 0
) {
return { ok: true, resolvedMode: "profile" };
}
return {
ok: false,
reason: "no-auth-source",
details: {
hint: "Set useLoggedInUser:true, or gitHubToken, or both profileId+profileVersion",
},
};
}
function defaultCopilotHome(): string {
// Mirrors the SDK convention; auth-bridge uses the same default.
if (process.platform === "win32") {
return path.join(process.env.APPDATA ?? os.homedir(), "copilot");
}
const xdg = process.env.XDG_CONFIG_HOME;
if (xdg && xdg.length > 0) {
return path.join(xdg, "copilot");
}
return path.join(os.homedir(), ".config", "copilot");
}
function formatProbeError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}

View File

@@ -171,7 +171,7 @@ export class SqliteBackedMatrixSyncStore extends MemoryStore {
constructor(private readonly storageRootDir: string) {
super();
this.stateKey = resolveSyncCacheStateKey(storageRootDir);
this.stateKey = SYNC_CACHE_STATE_KEY;
let restoredSavedSync: ISyncData | null = null;
let restoredClientOptions: IStoredClientOpts | undefined;
@@ -426,10 +426,6 @@ function openMatrixSyncCacheStore(
);
}
function resolveSyncCacheStateKey(_storageRootDir: string): string {
return SYNC_CACHE_STATE_KEY;
}
function metaKey(stateKey: string): string {
return `${stateKey}:meta`;
}
@@ -557,7 +553,7 @@ export async function hasMatrixSyncCacheStateInStore(params: {
storageRootDir: string;
store: Pick<PluginStateKeyedStore<MatrixSyncCacheRecord>, "lookup">;
}): Promise<boolean> {
const stateKey = resolveSyncCacheStateKey(params.storageRootDir);
const stateKey = SYNC_CACHE_STATE_KEY;
const meta = await params.store.lookup(metaKey(stateKey));
if (!isSyncCacheMeta(meta) || meta.chunkCount <= 0) {
return false;
@@ -586,7 +582,7 @@ export async function writeMatrixSyncCacheStateToStore(params: {
payload: PersistedMatrixSyncStore;
store: MatrixSyncCacheAsyncStore;
}): Promise<void> {
const stateKey = resolveSyncCacheStateKey(params.storageRootDir);
const stateKey = SYNC_CACHE_STATE_KEY;
const rows = buildSyncCacheRows(stateKey, params.payload);
for (const row of rows.chunks) {
await params.store.register(row.key, row.value);

View File

@@ -76,6 +76,32 @@ describe("resolveDefaultMattermostAccountId", () => {
expect(listMattermostAccountIds(cfg)).toEqual(["default", "work"]);
expect(resolveDefaultMattermostAccountId(cfg)).toBe("default");
});
it("inherits top-level access policy for named accounts before doctor migration", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
dmPolicy: "open",
groupPolicy: "open",
allowFrom: ["*"],
groupAllowFrom: ["*"],
accounts: {
tony: {
botToken: "tok-tony",
baseUrl: "https://chat.example.com",
},
},
},
},
};
const account = resolveMattermostAccount({ cfg, accountId: "tony" });
expect(account.config.dmPolicy).toBe("open");
expect(account.config.groupPolicy).toBe("open");
expect(account.config.allowFrom).toEqual(["*"]);
expect(account.config.groupAllowFrom).toEqual(["*"]);
});
});
describe("resolveMattermostReplyToMode", () => {

View File

@@ -108,15 +108,6 @@ describe("slash-commands", () => {
).toEqual(["oc_model", "oc_models"]);
});
it("registers the queue command mapped to the core /queue directive", () => {
const queueSpec = DEFAULT_COMMAND_SPECS.find((spec) => spec.trigger === "oc_queue");
expect(queueSpec?.originalName).toBe("queue");
const triggerMap = new Map<string, string>([["oc_queue", "queue"]]);
expect(resolveCommandText("oc_queue", " collect drop:summarize ", triggerMap)).toBe(
"/queue collect drop:summarize",
);
});
it("normalizes callback path in slash config", () => {
const config = resolveSlashCommandConfig({ callbackPath: "api/channels/mattermost/command" });
expect(config.callbackPath).toBe("/api/channels/mattermost/command");

View File

@@ -172,13 +172,6 @@ export const DEFAULT_COMMAND_SPECS: MattermostCommandSpec[] = [
autoComplete: true,
autoCompleteHint: "[on|off]",
},
{
trigger: "oc_queue",
originalName: "queue",
description: "Adjust active-run queue behavior",
autoComplete: true,
autoCompleteHint: "[steer|followup|collect|interrupt] [debounce:2s] [cap:N] [drop:old|new|summarize]",
},
];
// ─── Command registration ────────────────────────────────────────────────────

View File

@@ -17,7 +17,6 @@ import type { TSchema } from "typebox";
import { configureMemoryCoreDreamingState } from "./src/dreaming-state.js";
import { registerShortTermPromotionDreaming } from "./src/dreaming.js";
import { buildMemoryFlushPlan } from "./src/flush-plan.js";
import { registerBuiltInMemoryEmbeddingProviders } from "./src/memory/provider-adapters.js";
import { buildPromptSection } from "./src/prompt-section.js";
type MemoryToolsModule = typeof import("./src/tools.js");
@@ -185,7 +184,6 @@ export default definePluginEntry({
configureMemoryCoreDreamingState(<T>(options: OpenKeyedStoreOptions) =>
api.runtime.state.openKeyedStore<T>(options),
);
registerBuiltInMemoryEmbeddingProviders(api);
registerShortTermPromotionDreaming(api);
api.registerMemoryCapability({
promptBuilder: buildPromptSection,

View File

@@ -5,7 +5,6 @@ export {
DEFAULT_LOCAL_MODEL,
getBuiltinMemoryEmbeddingProviderDoctorMetadata,
listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata,
registerBuiltInMemoryEmbeddingProviders,
} from "./src/memory/provider-adapters.js";
export { createEmbeddingProvider } from "./src/memory/embeddings.js";
export {

View File

@@ -209,7 +209,6 @@ const LANGUAGE_STOP_WORDS = {
"할",
"해",
"했다",
"했다",
],
pathNoise: [
"cjs",

View File

@@ -151,14 +151,8 @@ function formatFallbackWriteFailure(err: unknown): string {
return "unknown error";
}
// Raw snippets and promotions are pre-processing memory staging fragments
// (session metadata, conversation summaries, operational logs). They must never
// be persisted to the human-readable dream diary. When narrative generation
// fails, always fall back to a generic placeholder so no staging content leaks
// into DREAMS.md.
function buildRequestScopedFallbackNarrative(_data: NarrativePhaseData): string {
return "A memory trace surfaced, but details were unavailable in this run.";
}
const REQUEST_SCOPED_FALLBACK_NARRATIVE =
"A memory trace surfaced, but details were unavailable in this run.";
export async function appendFallbackNarrativeEntry(params: {
workspaceDir: string;
@@ -171,7 +165,9 @@ export async function appendFallbackNarrativeEntry(params: {
try {
await appendNarrativeEntry({
workspaceDir: params.workspaceDir,
narrative: buildRequestScopedFallbackNarrative(params.data),
// Raw snippets and promotions are pre-processing memory staging fragments.
// Keep fallback diary text generic so DREAMS.md never leaks staging content.
narrative: REQUEST_SCOPED_FALLBACK_NARRATIVE,
nowMs: params.nowMs,
timezone: params.timezone,
});

View File

@@ -4,11 +4,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import {
clearMemoryEmbeddingProviders as clearRegistry,
listRegisteredMemoryEmbeddingProviderAdapters as listRegisteredAdapters,
registerMemoryEmbeddingProvider as registerAdapter,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { hashText } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { resolveOpenClawAgentSqlitePath } from "openclaw/plugin-sdk/sqlite-runtime";
@@ -25,7 +21,6 @@ import { splitSourceWideEmbeddingChunks } from "./manager-embedding-ops.js";
import { LOCAL_EMBEDDING_WORKER_ERROR_CODES } from "./manager-local-worker-errors.js";
import type { MemoryIndexMeta } from "./manager-reindex-state.js";
import { closeMemoryIndexManagersForAgent, EMBEDDING_PROBE_CACHE_TTL_MS } from "./manager.js";
import { registerBuiltInMemoryEmbeddingProviders } from "./provider-adapters.js";
// This suite performs real sqlite/media indexing and can exceed the global
// timeout when it shares a packed CI extension shard.
@@ -255,26 +250,6 @@ vi.mock("./embeddings.js", () => {
};
});
describe("memory embedding provider registration", () => {
beforeEach(() => {
vi.useRealTimers();
});
afterEach(() => {
vi.useRealTimers();
clearRegistry();
});
it("does not register a built-in local embedding provider", () => {
clearRegistry();
registerBuiltInMemoryEmbeddingProviders({ registerMemoryEmbeddingProvider: registerAdapter });
const adapter = listRegisteredAdapters().find((entry) => entry.id === "local");
expect(adapter).toBeUndefined();
});
});
describe("memory index", () => {
let fixtureRoot = "";
let workspaceDir = "";
@@ -307,7 +282,6 @@ describe("memory index", () => {
beforeEach(async () => {
vi.useRealTimers();
clearRegistry();
registerBuiltInMemoryEmbeddingProviders({ registerMemoryEmbeddingProvider: registerAdapter });
embedBatchCalls = 0;
embedBatchInputCalls = 0;
providerRuntimeBatchCalls = [];

View File

@@ -154,16 +154,12 @@ vi.mock("./embeddings.js", () => ({
}),
}));
import {
clearMemoryEmbeddingProviders as clearRegistry,
registerMemoryEmbeddingProvider as registerAdapter,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import {
closeAllMemorySearchManagers,
getMemorySearchManager,
type MemoryIndexManager,
} from "./index.js";
import { registerBuiltInMemoryEmbeddingProviders } from "./provider-adapters.js";
describe("memory watcher config", () => {
let manager: MemoryIndexManager | null = null;
@@ -176,7 +172,6 @@ describe("memory watcher config", () => {
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
vi.clearAllMocks();
clearRegistry();
registerBuiltInMemoryEmbeddingProviders({ registerMemoryEmbeddingProvider: registerAdapter });
nativeWatchMockFailingDir.current = null;
});

View File

@@ -1,41 +0,0 @@
// Memory Core tests cover provider adapter registration plugin behavior.
import { describe, expect, it } from "vitest";
import { filterUnregisteredMemoryEmbeddingProviderAdapters } from "./provider-adapter-registration.js";
describe("filterUnregisteredMemoryEmbeddingProviderAdapters", () => {
it("keeps builtin adapters that are not already registered", () => {
const adapters = filterUnregisteredMemoryEmbeddingProviderAdapters({
builtinAdapters: [
{ id: "local" },
{ id: "openai" },
{ id: "gemini" },
{ id: "voyage" },
{ id: "mistral" },
],
registeredAdapters: [],
});
expect(adapters.map((adapter) => adapter.id)).toEqual([
"local",
"openai",
"gemini",
"voyage",
"mistral",
]);
});
it("skips builtin adapters that are already registered", () => {
const adapters = filterUnregisteredMemoryEmbeddingProviderAdapters({
builtinAdapters: [
{ id: "local" },
{ id: "openai" },
{ id: "gemini" },
{ id: "voyage" },
{ id: "mistral" },
],
registeredAdapters: [{ id: "local" }, { id: "gemini" }],
});
expect(adapters.map((adapter) => adapter.id)).toEqual(["openai", "voyage", "mistral"]);
});
});

View File

@@ -1,12 +0,0 @@
// Memory Core provider module implements model/runtime integration.
type AdapterLike = {
id: string;
};
export function filterUnregisteredMemoryEmbeddingProviderAdapters<T extends AdapterLike>(params: {
builtinAdapters: readonly T[];
registeredAdapters: readonly AdapterLike[];
}): T[] {
const existingIds = new Set(params.registeredAdapters.map((adapter) => adapter.id));
return params.builtinAdapters.filter((adapter) => !existingIds.has(adapter.id));
}

View File

@@ -2,11 +2,9 @@
import {
DEFAULT_LOCAL_MODEL,
listMemoryEmbeddingProviders,
listRegisteredMemoryEmbeddingProviderAdapters,
type MemoryEmbeddingProviderAdapter,
} from "openclaw/plugin-sdk/memory-core-host-embedding-registry";
import { getProviderEnvVars } from "openclaw/plugin-sdk/provider-env-vars";
import { filterUnregisteredMemoryEmbeddingProviderAdapters } from "./provider-adapter-registration.js";
export type BuiltinMemoryEmbeddingProviderDoctorMetadata = {
providerId: string;
@@ -16,8 +14,6 @@ export type BuiltinMemoryEmbeddingProviderDoctorMetadata = {
autoSelectPriority?: number;
};
const builtinMemoryEmbeddingProviderAdapters = [] as const;
export { DEFAULT_LOCAL_MODEL };
function getBuiltinMemoryEmbeddingProviderAdapter(
@@ -26,20 +22,6 @@ function getBuiltinMemoryEmbeddingProviderAdapter(
return listMemoryEmbeddingProviders().find((adapter) => adapter.id === id);
}
export function registerBuiltInMemoryEmbeddingProviders(register: {
registerMemoryEmbeddingProvider: (adapter: MemoryEmbeddingProviderAdapter) => void;
}): void {
// Only inspect providers already registered in the current load. Falling back
// to capability discovery here can recursively trigger plugin loading while
// memory-core itself is still registering.
for (const adapter of filterUnregisteredMemoryEmbeddingProviderAdapters({
builtinAdapters: builtinMemoryEmbeddingProviderAdapters,
registeredAdapters: listRegisteredMemoryEmbeddingProviderAdapters(),
})) {
register.registerMemoryEmbeddingProvider(adapter);
}
}
export function getBuiltinMemoryEmbeddingProviderDoctorMetadata(
providerId: string,
): BuiltinMemoryEmbeddingProviderDoctorMetadata | null {

View File

@@ -35,9 +35,12 @@ describe("initializeMemoryWikiVault", () => {
await expect(fs.readFile(path.join(rootDir, "WIKI.md"), "utf8")).resolves.toContain(
"Render mode: `obsidian`",
);
await expect(
fs.readFile(path.join(rootDir, ".openclaw-wiki", "state.json"), "utf8"),
).resolves.toContain('"renderMode": "obsidian"');
await expect(fs.access(path.join(rootDir, ".openclaw-wiki", "state.json"))).rejects.toThrow(
/ENOENT/,
);
await expect(fs.access(path.join(rootDir, ".openclaw-wiki", "locks"))).rejects.toThrow(
/ENOENT/,
);
});
it("is idempotent when the vault already exists", async () => {

View File

@@ -20,7 +20,6 @@ export const WIKI_VAULT_DIRECTORIES = [
"_attachments",
"_views",
".openclaw-wiki",
".openclaw-wiki/locks",
".openclaw-wiki/cache",
] as const;
@@ -125,22 +124,6 @@ export async function initializeMemoryWikiVault(
withTrailingNewline("# Inbox\n\nDrop raw ideas, questions, and source links here.\n"),
createdFiles,
);
await writeFileIfMissing(
rootDir,
".openclaw-wiki/state.json",
withTrailingNewline(
JSON.stringify(
{
version: 1,
createdAt: resolveMemoryWikiTimestamp(options?.nowMs),
renderMode: config.vault.renderMode,
},
null,
2,
),
),
createdFiles,
);
await writeFileIfMissing(rootDir, ".openclaw-wiki/log.jsonl", "", createdFiles);
if (createdDirectories.length > 0 || createdFiles.length > 0) {

View File

@@ -125,18 +125,6 @@ function formatFrontmatterValue(value: string): string {
return value;
}
/**
* Mark an AST as "dirty" — useful for callers that mutate the AST
* structurally and want emitMd() to re-render rather than round-trip.
*
* Currently a no-op flag — emitMd() decides based on `opts.mode`. Kept
* as an extension point for a future invariant where the AST tracks
* its own dirty state.
*/
export function markDirty(_ast: MdAst): void {
// intentionally empty
}
// Re-export the frontmatter type for convenience so tests don't need
// to import from ast.ts.
export type { FrontmatterEntry };

View File

@@ -73,7 +73,7 @@ export type { JsonlParseResult } from "./jsonl/parse.js";
export type { YamlParseResult } from "./yaml/parse.js";
export type { EmitOptions } from "./emit.js";
export { emitMd, markDirty } from "./emit.js";
export { emitMd } from "./emit.js";
export type { JsoncEmitOptions } from "./jsonc/emit.js";
export { emitJsonc } from "./jsonc/emit.js";
export type { JsonlEmitOptions } from "./jsonl/emit.js";

View File

@@ -60,6 +60,20 @@ async function pollQaBus(params: {
return (await response.json()) as QaBusPollResult;
}
async function postQaBusJson(baseUrl: string, path: string, body: unknown) {
return await postQaBusRawJson(baseUrl, path, JSON.stringify(body));
}
async function postQaBusRawJson(baseUrl: string, path: string, body: string) {
return await fetch(`${baseUrl}${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body,
});
}
describe("closeQaHttpServer", () => {
it("closes idle keep-alive sockets so suite processes can exit", async () => {
const server = createServer((_req, res) => {
@@ -122,6 +136,85 @@ describe("qa-bus server", () => {
kind: "inbound-message",
});
});
it("rejects malformed poll numeric fields before long-polling", async () => {
const state = createQaBusState();
const bus = await startQaBusServer({ state });
stops.push(bus["stop"]);
const startedAt = Date.now();
const response = await postQaBusJson(bus.baseUrl, "/v1/poll", {
accountId: "acct-a",
cursor: "999",
timeoutMs: 500,
});
expect(Date.now() - startedAt).toBeLessThan(300);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "poll cursor must be an integer at least 0.",
});
});
it("rejects malformed search limits before querying state", async () => {
const state = createQaBusState();
const bus = await startQaBusServer({ state });
stops.push(bus["stop"]);
const response = await postQaBusJson(bus.baseUrl, "/v1/actions/search", {
limit: "all",
query: "anything",
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "search limit must be an integer at least 1.",
});
});
it("keeps oversized numeric poll and search fields bounded", async () => {
const state = createQaBusState();
const bus = await startQaBusServer({ state });
stops.push(bus["stop"]);
const message = state.addInboundMessage({
accountId: "acct-a",
conversation: { id: "target", kind: "direct" },
senderId: "acct-a-user",
text: "bounded numeric fields",
});
const pollResponse = await postQaBusJson(bus.baseUrl, "/v1/poll", {
accountId: "acct-a",
cursor: 0,
limit: 10_000,
timeoutMs: 60_000,
});
expect(pollResponse.status).toBe(200);
await expect(pollResponse.json()).resolves.toMatchObject({
events: [{ message: { id: message.id } }],
});
const searchResponse = await postQaBusJson(bus.baseUrl, "/v1/actions/search", {
accountId: "acct-a",
limit: 10_000,
query: "bounded",
});
expect(searchResponse.status).toBe(200);
await expect(searchResponse.json()).resolves.toMatchObject({
messages: [{ id: message.id }],
});
const extremeSearchResponse = await postQaBusRawJson(
bus.baseUrl,
"/v1/actions/search",
`{"accountId":"acct-a","limit":1e309,"query":"bounded"}`,
);
expect(extremeSearchResponse.status).toBe(200);
await expect(extremeSearchResponse.json()).resolves.toMatchObject({
messages: [{ id: message.id }],
});
});
});
describe("handleQaBusRequest", () => {

View File

@@ -23,6 +23,9 @@ import type {
const QA_HTTP_JSON_MAX_BODY_BYTES = 1024 * 1024;
const QA_HTTP_JSON_BODY_TIMEOUT_MS = 5_000;
const QA_BUS_POLL_TIMEOUT_MAX_MS = 30_000;
const QA_BUS_POLL_LIMIT_MAX = 500;
const QA_BUS_SEARCH_LIMIT_MAX = 100;
export async function readQaJsonBody(req: IncomingMessage): Promise<unknown> {
const text = (
@@ -57,6 +60,66 @@ export function writeQaRequestBodyLimitError(res: ServerResponse, error: unknown
return true;
}
function readOptionalIntegerField(
input: Record<string, unknown>,
field: string,
opts: {
label: string;
max?: number;
min: number;
},
): number | undefined {
const value = input[field];
if (value === undefined) {
return undefined;
}
if (typeof value !== "number" || value < opts.min) {
throw new Error(`${opts.label} must be an integer at least ${opts.min}.`);
}
if (opts.max !== undefined && value > opts.max) {
return opts.max;
}
if (!Number.isSafeInteger(value)) {
throw new Error(`${opts.label} must be an integer at least ${opts.min}.`);
}
return opts.max === undefined ? value : Math.min(value, opts.max);
}
function normalizeQaBusPollInput(input: Record<string, unknown>): QaBusPollInput {
const cursor = readOptionalIntegerField(input, "cursor", {
label: "poll cursor",
min: 0,
});
const limit = readOptionalIntegerField(input, "limit", {
label: "poll limit",
max: QA_BUS_POLL_LIMIT_MAX,
min: 1,
});
const timeoutMs = readOptionalIntegerField(input, "timeoutMs", {
label: "poll timeoutMs",
max: QA_BUS_POLL_TIMEOUT_MAX_MS,
min: 0,
});
return {
...input,
...(cursor !== undefined ? { cursor } : {}),
...(limit !== undefined ? { limit } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
} as QaBusPollInput;
}
function normalizeQaBusSearchInput(input: Record<string, unknown>): QaBusSearchMessagesInput {
const limit = readOptionalIntegerField(input, "limit", {
label: "search limit",
max: QA_BUS_SEARCH_LIMIT_MAX,
min: 1,
});
return {
...input,
...(limit !== undefined ? { limit } : {}),
} as QaBusSearchMessagesInput;
}
export async function closeQaHttpServer(server: Server): Promise<void> {
let forceCloseTimer: NodeJS.Timeout | undefined;
try {
@@ -146,12 +209,12 @@ export async function handleQaBusRequest(params: {
return true;
case "/v1/actions/search":
writeJson(params.res, 200, {
messages: params.state.searchMessages(body as unknown as QaBusSearchMessagesInput),
messages: params.state.searchMessages(normalizeQaBusSearchInput(body)),
});
return true;
case "/v1/poll": {
const input = body as unknown as QaBusPollInput;
const timeoutMs = Math.max(0, Math.min(input.timeoutMs ?? 0, 30_000));
const input = normalizeQaBusPollInput(body);
const timeoutMs = input.timeoutMs ?? 0;
const accountId = normalizeAccountId(input.accountId);
const initial = params.state.poll(input);
const effectiveStartCursor = resolveQaBusPollStartCursor({

View File

@@ -719,6 +719,54 @@ describe("qa confidence report", () => {
expect(report.lanes[0]?.details).toContain("count/scenario mismatch");
});
it("treats impossible suite counts as unknown", async () => {
for (const [artifact, expectedDetail] of [
[
{ counts: { total: 1, passed: -1, skipped: 0, failed: 0 } },
"counts.passed must be a non-negative integer",
],
[
{ counts: { total: 1, passed: 2, failed: 0 } },
"counts.total=1 is less than provided count sum=2",
],
[
{ counts: { total: 1, skipped: 2, failed: 0 } },
"counts.total=1 is less than provided count sum=2",
],
[
{ counts: { total: 5, passed: 2, skipped: 2, failed: 0 } },
"counts.total=5 does not match counts.passed+counts.failed+counts.skipped=4",
],
] as const) {
await writeJson("live/qa-suite-summary.json", artifact);
const report = await buildQaConfidenceReport({
manifest: {
version: 1,
profile: "codex-100",
lanes: [
{
id: "first-hour-live",
title: "First hour live",
kind: "qa-suite-summary",
artifact: "live/qa-suite-summary.json",
required: true,
failureVerdict: "qa-harness-bug",
},
],
},
artifactRoot: tempRoot,
strictZeroUnknowns: true,
generatedAt: "2026-05-13T00:00:00.000Z",
});
expect(report.pass).toBe(false);
expect(report.counts).toMatchObject({ failed: 0, unknown: 1 });
expect(report.lanes[0]).toMatchObject({ status: "unknown" });
expect(report.lanes[0]?.details).toContain(expectedDetail);
}
});
it("requires generic summary lanes to expose an explicit pass signal", async () => {
await writeJson("runtime/qa-runtime-parity-summary.json", {});

View File

@@ -152,6 +152,10 @@ function readNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function readCount(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
}
function readBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
@@ -371,9 +375,44 @@ function evaluateQaSuiteSummary(payload: unknown): QaConfidenceLaneEvaluation {
};
}
const counts = isRecord(payload.counts) ? payload.counts : undefined;
const totalCount = readNumber(counts?.total);
const passedCount = readNumber(counts?.passed);
const failedCount = readNumber(counts?.failed);
for (const key of ["total", "passed", "failed", "skipped"] as const) {
if (counts && Object.hasOwn(counts, key) && readCount(counts[key]) === undefined) {
return {
passed: false,
status: "unknown",
details: `qa-suite-summary counts.${key} must be a non-negative integer`,
};
}
}
const totalCount = readCount(counts?.total);
const passedCount = readCount(counts?.passed);
const failedCount = readCount(counts?.failed);
const explicitSkippedCount = readCount(counts?.skipped);
if (totalCount !== undefined) {
const providedCountSum =
(passedCount ?? 0) + (failedCount ?? 0) + (explicitSkippedCount ?? 0);
if (totalCount < providedCountSum) {
return {
passed: false,
status: "unknown",
details: `qa-suite-summary counts.total=${totalCount} is less than provided count sum=${providedCountSum}`,
};
}
if (
passedCount !== undefined &&
failedCount !== undefined &&
explicitSkippedCount !== undefined &&
totalCount !== providedCountSum
) {
return {
passed: false,
status: "unknown",
details: `qa-suite-summary counts.total=${totalCount} does not match counts.passed+counts.failed+counts.skipped=${
providedCountSum
}`,
};
}
}
const scenarios = Array.isArray(payload.scenarios) ? payload.scenarios : undefined;
const failedScenarios = scenarios?.filter(
(scenario) => isRecord(scenario) && scenario.status === "fail",
@@ -446,7 +485,6 @@ function evaluateQaSuiteSummary(payload: unknown): QaConfidenceLaneEvaluation {
details: `qa-suite-summary has ${unknownBlockingScenarioCount} scenario row(s) with unsupported non-pass status`,
};
}
const explicitSkippedCount = readNumber(counts?.skipped);
const inferredSkippedCount =
totalCount === undefined || passedCount === undefined
? undefined

View File

@@ -279,6 +279,63 @@ describe("runQaDockerUp", () => {
}
});
it("rejects explicit host port collisions before touching Docker", async () => {
const calls: string[] = [];
const outputDir = await mkdtemp(path.join(os.tmpdir(), "qa-docker-up-"));
try {
await expect(
runQaDockerUp(
{
repoRoot: "/repo/openclaw",
outputDir,
gatewayPort: 43124,
qaLabPort: 43124,
skipUiBuild: true,
usePrebuiltImage: true,
},
createHealthyDockerDeps(calls),
),
).rejects.toThrow(
"QA Lab gateway and UI host ports must be different. Both resolved to 43124.",
);
expect(calls).toEqual([]);
} finally {
await rm(outputDir, { recursive: true, force: true });
}
});
it("rejects resolved host port collisions before writing the harness", async () => {
const outputDir = await mkdtemp(path.join(os.tmpdir(), "qa-docker-up-"));
const resolveHostPort = vi.fn(async () => 28001);
try {
await expect(
runQaDockerUp(
{
repoRoot: "/repo/openclaw",
outputDir,
skipUiBuild: true,
usePrebuiltImage: true,
},
{
...createHealthyDockerDeps([]),
resolveHostPortImpl: resolveHostPort,
},
),
).rejects.toThrow(
"QA Lab gateway and UI host ports must be different. Both resolved to 28001.",
);
await expect(readFile(path.join(outputDir, "docker-compose.qa.yml"), "utf8")).rejects.toThrow(
"ENOENT",
);
} finally {
await rm(outputDir, { recursive: true, force: true });
}
});
it("falls back to the container IP when the host gateway port is unreachable", async () => {
const calls: string[] = [];
const fetchCalls: string[] = [];

View File

@@ -41,7 +41,11 @@ async function isQaLabDockerHealthReachable(url: string, fetchImpl: FetchLike) {
}
}
function isMissingCommandError(error: unknown, command: string, seen = new Set<unknown>()): boolean {
function isMissingCommandError(
error: unknown,
command: string,
seen = new Set<unknown>(),
): boolean {
if (!error || seen.has(error)) {
return false;
}
@@ -99,6 +103,11 @@ export async function runQaDockerUp(
params.gatewayPort != null,
);
const qaLabPort = await resolveHostPortImpl(params.qaLabPort ?? 43124, params.qaLabPort != null);
if (gatewayPort === qaLabPort) {
throw new Error(
`QA Lab gateway and UI host ports must be different. Both resolved to ${gatewayPort}.`,
);
}
const runCommand = deps?.runCommand ?? execCommand;
const fetchImpl = deps?.fetchImpl ?? fetchHealthUrl;
const sleepImpl = deps?.sleepImpl ?? sleep;

View File

@@ -1006,7 +1006,11 @@ describe("buildQaRuntimeEnv", () => {
it("force-kills Windows gateway process trees when graceful taskkill fails", () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const originalSystemRoot = process.env.SystemRoot;
const originalWindir = process.env.WINDIR;
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
process.env.SystemRoot = "C:\\Windows";
delete process.env.WINDIR;
try {
const child = Object.assign(new EventEmitter(), {
pid: 12345,
@@ -1025,11 +1029,12 @@ describe("buildQaRuntimeEnv", () => {
runTaskkill,
);
expect(runTaskkill).toHaveBeenNthCalledWith(1, "taskkill", ["/PID", "12345", "/T"], {
const taskkillPath = path.win32.join("C:\\Windows", "System32", "taskkill.exe");
expect(runTaskkill).toHaveBeenNthCalledWith(1, taskkillPath, ["/PID", "12345", "/T"], {
stdio: "ignore",
windowsHide: true,
});
expect(runTaskkill).toHaveBeenNthCalledWith(2, "taskkill", ["/PID", "12345", "/T", "/F"], {
expect(runTaskkill).toHaveBeenNthCalledWith(2, taskkillPath, ["/PID", "12345", "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
});
@@ -1038,6 +1043,16 @@ describe("buildQaRuntimeEnv", () => {
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
}
if (originalSystemRoot === undefined) {
delete process.env.SystemRoot;
} else {
process.env.SystemRoot = originalSystemRoot;
}
if (originalWindir === undefined) {
delete process.env.WINDIR;
} else {
process.env.WINDIR = originalWindir;
}
}
});

View File

@@ -50,6 +50,7 @@ import { stageQaMockAuthProfiles } from "./providers/shared/mock-auth.js";
import { seedQaAgentWorkspace } from "./qa-agent-workspace.js";
import { buildQaGatewayConfig, type QaThinkingLevel } from "./qa-gateway-config.js";
import type { QaTransportAdapter } from "./qa-transport.js";
import { resolveQaWindowsSystem32ExePath } from "./windows-system-tools.js";
export type { QaCliBackendAuthMode } from "./providers/env.js";
const QA_GATEWAY_CHILD_STARTUP_MAX_ATTEMPTS = 5;
@@ -398,11 +399,12 @@ function signalQaGatewayWindowsProcessTree(
signal: NodeJS.Signals,
runTaskkill: QaGatewayTaskkillRunner = spawnSync,
) {
const taskkillPath = resolveQaWindowsSystem32ExePath("taskkill.exe");
const args = ["/PID", String(pid), "/T"];
if (signal === "SIGKILL") {
args.push("/F");
}
const result = runTaskkill("taskkill", args, {
const result = runTaskkill(taskkillPath, args, {
stdio: "ignore",
windowsHide: true,
});
@@ -410,7 +412,7 @@ function signalQaGatewayWindowsProcessTree(
return true;
}
if (signal !== "SIGKILL") {
const forceResult = runTaskkill("taskkill", [...args, "/F"], {
const forceResult = runTaskkill(taskkillPath, [...args, "/F"], {
stdio: "ignore",
windowsHide: true,
});

View File

@@ -90,9 +90,12 @@ function listUiAssetFiles(rootDir: string, currentDir = rootDir): string[] {
return files;
}
export function resolveUiAssetVersion(overrideDir?: string | null): string | null {
export function resolveUiAssetVersion(
overrideDir?: string | null,
repoRoot = process.cwd(),
): string | null {
try {
const distDir = resolveUiDistDir(overrideDir);
const distDir = resolveUiDistDir(overrideDir, repoRoot);
const indexPath = path.join(distDir, "index.html");
if (!fs.existsSync(indexPath) || !fs.statSync(indexPath).isFile()) {
return null;

View File

@@ -11,6 +11,7 @@ import {
writeQaLabServerError,
type QaLabServerStartParams,
} from "./lab-server.js";
import { resolveUiAssetVersion } from "./lab-server-ui.js";
const qaChannelMock = vi.hoisted(() => ({
resolveAccount: vi.fn(),
@@ -557,6 +558,37 @@ describe("qa-lab server", () => {
expect(getResponse.headers.get("x-content-type-options")).toBe("nosniff");
expect(await getResponse.text()).toBe("streamed body\n");
const indexedArtifactUrl = new URL("/api/evidence/artifact", lab.baseUrl);
indexedArtifactUrl.searchParams.set(
"evidencePath",
".artifacts/qa-e2e/server/qa-evidence.json",
);
indexedArtifactUrl.searchParams.set("entryIndex", "0");
indexedArtifactUrl.searchParams.set("artifactIndex", "0");
const indexedResponse = await fetchWithRetry(indexedArtifactUrl.toString());
expect(indexedResponse.status).toBe(200);
expect(await indexedResponse.text()).toBe("streamed body\n");
const hexIndexUrl = new URL(indexedArtifactUrl);
hexIndexUrl.searchParams.set("entryIndex", "0x0");
const hexIndexResponse = await fetchWithRetry(hexIndexUrl.toString());
expect(hexIndexResponse.status).toBe(400);
const exponentIndexUrl = new URL(indexedArtifactUrl);
exponentIndexUrl.searchParams.set("artifactIndex", "1e0");
const exponentIndexResponse = await fetchWithRetry(exponentIndexUrl.toString());
expect(exponentIndexResponse.status).toBe(400);
const leadingZeroIndexUrl = new URL(indexedArtifactUrl);
leadingZeroIndexUrl.searchParams.set("entryIndex", "00");
const leadingZeroIndexResponse = await fetchWithRetry(leadingZeroIndexUrl.toString());
expect(leadingZeroIndexResponse.status).toBe(400);
const whitespaceIndexUrl = new URL(indexedArtifactUrl);
whitespaceIndexUrl.searchParams.set("entryIndex", " 0 ");
const whitespaceIndexResponse = await fetchWithRetry(whitespaceIndexUrl.toString());
expect(whitespaceIndexResponse.status).toBe(400);
await writeFile(path.join(evidenceDir, "undeclared.log"), "hidden\n", "utf8");
const undeclaredUrl = new URL(artifactUrl);
undeclaredUrl.searchParams.set("artifactPath", "undeclared.log");
@@ -788,6 +820,12 @@ describe("qa-lab server", () => {
expect(rootResponse.status).toBe(200);
expect(await rootResponse.text()).toContain("repo-root-ui");
const versionResponse = await fetchWithRetry(`${lab.baseUrl}/api/ui-version`);
expect(versionResponse.status).toBe(200);
const versionPayload = (await versionResponse.json()) as { version?: string | null };
expect(versionPayload.version).toBe(resolveUiAssetVersion(null, repoRoot));
expect(versionPayload.version).toMatch(/^[0-9a-f]{12}$/);
const runnerCatalog = await waitForRunnerCatalog(lab.baseUrl);
expect(runnerCatalog.status).toBe("ready");
const tempModel = runnerCatalog.real.find((model) => model.key === "anthropic/qa-temp-model");

View File

@@ -101,6 +101,17 @@ function withQaLabRunCounts(run: Omit<QaLabScenarioRun, "counts">): QaLabScenari
};
}
function parseQaEvidenceArtifactIndexText(value: string): number {
if (!/^(0|[1-9]\d*)$/.test(value)) {
throw new QaEvidenceGalleryError("Evidence artifact index is invalid.", 400);
}
const index = Number(value);
if (!Number.isSafeInteger(index) || String(index) !== value) {
throw new QaEvidenceGalleryError("Evidence artifact index is invalid.", 400);
}
return index;
}
function injectKickoffMessage(params: {
state: QaBusState;
defaults: QaLabBootstrapDefaults;
@@ -435,7 +446,7 @@ export async function startQaLabServer(
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
});
res.end(JSON.stringify({ version: resolveUiAssetVersion(params?.uiDistDir) }));
res.end(JSON.stringify({ version: resolveUiAssetVersion(params?.uiDistDir, repoRoot) }));
return;
}
if (req.method === "GET" && url.pathname === "/api/outcomes") {
@@ -471,8 +482,8 @@ export async function startQaLabServer(
const evidencePath = url.searchParams.get("evidencePath")?.trim();
const artifactPath = url.searchParams.get("artifactPath")?.trim();
const producerFile = url.searchParams.get("producerFile")?.trim();
const entryIndexText = url.searchParams.get("entryIndex")?.trim();
const artifactIndexText = url.searchParams.get("artifactIndex")?.trim();
const entryIndexText = url.searchParams.get("entryIndex");
const artifactIndexText = url.searchParams.get("artifactIndex");
if (
!evidencePath ||
(!artifactPath && !producerFile && (!entryIndexText || !artifactIndexText))
@@ -493,8 +504,8 @@ export async function startQaLabServer(
repoRoot,
})
: await resolveQaEvidenceArtifactFileByIndex({
artifactIndex: Number(artifactIndexText),
entryIndex: Number(entryIndexText),
artifactIndex: parseQaEvidenceArtifactIndexText(artifactIndexText!),
entryIndex: parseQaEvidenceArtifactIndexText(entryIndexText!),
evidencePath,
repoRoot,
});

View File

@@ -76,6 +76,8 @@ export const LIVE_TRANSPORT_COVERAGE_LANES: readonly LiveTransportCoverageLane[]
{ standardId: "top-level-reply-shape", scenarioId: "whatsapp-top-level-reply-shape" },
{ standardId: "restart-resume", scenarioId: "whatsapp-restart-resume" },
{ standardId: "help-command", scenarioId: "whatsapp-help-command" },
{ standardId: "quote-reply", scenarioId: "whatsapp-reply-to-message" },
{ standardId: "quote-reply", scenarioId: "whatsapp-group-reply-to-message" },
{ standardId: "reaction-observation", scenarioId: "whatsapp-status-reactions" },
{ standardId: "allowlist-block", scenarioId: "whatsapp-group-allowlist-block" },
],

View File

@@ -428,6 +428,7 @@ describe("WhatsApp QA live runtime", () => {
"top-level-reply-shape",
"restart-resume",
"help-command",
"quote-reply",
"reaction-observation",
"allowlist-block",
]);
@@ -631,6 +632,8 @@ describe("WhatsApp QA live runtime", () => {
"whatsapp-top-level-reply-shape",
"whatsapp-restart-resume",
"whatsapp-help-command",
"whatsapp-reply-to-message",
"whatsapp-group-reply-to-message",
"whatsapp-status-reactions",
"whatsapp-group-allowlist-block",
];
@@ -656,6 +659,8 @@ describe("WhatsApp QA live runtime", () => {
"whatsapp-whoami-command",
"whatsapp-context-command",
"whatsapp-tool-only-usage-footer",
"whatsapp-reply-to-message",
"whatsapp-group-reply-to-message",
"whatsapp-reply-context-isolation",
"whatsapp-inbound-image-caption",
"whatsapp-audio-preflight",
@@ -677,6 +682,68 @@ describe("WhatsApp QA live runtime", () => {
]);
});
it("defines quote-reply scenarios for DM and group replies", () => {
const scenarios = testing.findScenarios([
"whatsapp-reply-to-message",
"whatsapp-group-reply-to-message",
]);
const runs = scenarios.map((scenario) => {
const run = scenario.buildRun();
if (run.kind === "approval" || !run.verify) {
throw new Error(`${scenario.id} unexpectedly built a non-message run`);
}
return { scenario, run };
});
expect(
runs.map(({ scenario, run }) => ({
id: scenario.id,
requiresGroupJid: scenario.requiresGroupJid,
standardId: scenario.standardId,
target: run.target,
})),
).toEqual([
{
id: "whatsapp-reply-to-message",
requiresGroupJid: undefined,
standardId: "quote-reply",
target: "dm",
},
{
id: "whatsapp-group-reply-to-message",
requiresGroupJid: true,
standardId: "quote-reply",
target: "group",
},
]);
expect(runs[0]?.run.input).not.toContain("openclawqa");
expect(runs[1]?.run.input).toMatch(/^openclawqa\b/u);
for (const { run } of runs) {
expect(() =>
run.verify?.(
{
kind: "text",
observedAt: "2026-06-05T01:00:01.000Z",
quoted: { messageId: "trigger-message-id" },
text: "reply",
},
{ sent: { messageId: "trigger-message-id" } } as never,
),
).not.toThrow();
expect(() =>
run.verify?.(
{
kind: "text",
observedAt: "2026-06-05T01:00:01.000Z",
text: "reply",
},
{ sent: { messageId: "trigger-message-id" } } as never,
),
).toThrow("expected reply quote trigger-message-id, got <missing>");
}
});
it("seeds the structured-message location check through text context", () => {
const [scenario] = testing.findScenarios(["whatsapp-inbound-structured-messages"]);
if (!scenario) {

View File

@@ -70,6 +70,7 @@ type WhatsAppQaScenarioId =
| "whatsapp-context-command"
| "whatsapp-group-allowlist-block"
| "whatsapp-group-audio-gating"
| "whatsapp-group-reply-to-message"
| "whatsapp-help-command"
| "whatsapp-inbound-image-caption"
| "whatsapp-inbound-structured-messages"
@@ -423,6 +424,31 @@ const whatsappQaCredentialPayloadSchema = z.object({
groupJid: z.string().trim().min(1).optional(),
});
function buildWhatsAppQuoteReplyRun(target: "dm" | "group"): WhatsAppQaMessageScenarioRun {
const token = `WHATSAPP_QA_REPLY_TO_${target.toUpperCase()}_${randomUUID().slice(0, 8).toUpperCase()}`;
const input =
target === "group"
? `openclawqa reply with only this exact marker: ${token}`
: `Reply with only this exact marker: ${token}`;
return {
configMode: "allowlist",
expectReply: true,
input,
matchText: token,
target,
verify: (reply, context) => {
if (!context.sent.messageId) {
throw new Error("WhatsApp driver did not return a triggering message id.");
}
if (reply.quoted?.messageId !== context.sent.messageId) {
throw new Error(
`expected reply quote ${context.sent.messageId}, got ${reply.quoted?.messageId ?? "<missing>"}`,
);
}
},
};
}
const WHATSAPP_QA_SCENARIOS: WhatsAppQaScenarioDefinition[] = [
{
id: "whatsapp-canary",
@@ -649,31 +675,24 @@ const WHATSAPP_QA_SCENARIOS: WhatsAppQaScenarioDefinition[] = [
},
{
id: "whatsapp-reply-to-message",
standardId: "quote-reply",
title: "WhatsApp DM reply-to mode quotes the triggering message",
timeoutMs: 60_000,
configOverrides: {
replyToMode: "all",
},
buildRun: () => {
const token = `WHATSAPP_QA_REPLY_TO_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker: ${token}`,
matchText: token,
target: "dm",
verify: (reply, context) => {
if (!context.sent.messageId) {
throw new Error("WhatsApp driver did not return a triggering message id.");
}
if (reply.quoted?.messageId !== context.sent.messageId) {
throw new Error(
`expected reply quote ${context.sent.messageId}, got ${reply.quoted?.messageId ?? "<missing>"}`,
);
}
},
};
buildRun: () => buildWhatsAppQuoteReplyRun("dm"),
},
{
id: "whatsapp-group-reply-to-message",
standardId: "quote-reply",
title: "WhatsApp group reply-to mode quotes the triggering message",
timeoutMs: 60_000,
configOverrides: {
replyToMode: "all",
},
requiresGroupJid: true,
buildRun: () => buildWhatsAppQuoteReplyRun("group"),
},
{
id: "whatsapp-reply-context-isolation",

View File

@@ -24,6 +24,7 @@ import {
QA_CHANNEL_REQUIRED_PLUGIN_IDS,
} from "./qa-channel-transport.js";
import { buildQaGatewayConfig } from "./qa-gateway-config.js";
import { resolveQaWindowsSystem32ExePath } from "./windows-system-tools.js";
type ModelRow = {
key: string;
@@ -120,10 +121,14 @@ function killProcessTree(pid: number | undefined, signal: NodeJS.Signals) {
}
try {
if (process.platform === "win32") {
const killer = spawn("taskkill", ["/pid", String(pid), "/t", "/f"], {
stdio: "ignore",
windowsHide: true,
});
const killer = spawn(
resolveQaWindowsSystem32ExePath("taskkill.exe"),
["/pid", String(pid), "/t", "/f"],
{
stdio: "ignore",
windowsHide: true,
},
);
killer.once("error", () => {
try {
process.kill(pid, signal);

View File

@@ -1,4 +1,5 @@
// Qa Lab tests cover node exec plugin behavior.
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveQaNodeExecPath } from "./node-exec.js";
@@ -40,6 +41,29 @@ describe("resolveQaNodeExecPath", () => {
).resolves.toBe("/usr/local/bin/node");
});
it("uses trusted Windows where.exe when resolving node from PATH", async () => {
await expect(
resolveQaNodeExecPath({
execPath: String.raw`D:\Tools\bun.exe`,
platform: "win32",
versions: { ...process.versions, bun: "1.2.3" },
env: { SystemRoot: String.raw`D:\Windows` },
execFileImpl: async (file, args, options) => {
expect(file).toBe(path.win32.join(String.raw`D:\Windows`, "System32", "where.exe"));
expect(args).toEqual(["node"]);
expect(options).toEqual({
encoding: "utf8",
env: { SystemRoot: String.raw`D:\Windows` },
});
return {
stdout: String.raw`D:\nodejs\node.exe` + "\r\n",
stderr: "",
};
},
}),
).resolves.toBe(String.raw`D:\nodejs\node.exe`);
});
it("throws a clear error when node is unavailable", async () => {
await expect(
resolveQaNodeExecPath({

View File

@@ -2,6 +2,7 @@
import { execFile } from "node:child_process";
import path from "node:path";
import { promisify } from "node:util";
import { resolveQaWindowsSystem32ExePath } from "./windows-system-tools.js";
type ExecFileAsync = (
file: string,
@@ -39,7 +40,8 @@ export async function resolveQaNodeExecPath(params?: {
return execPath;
}
const locator = platform === "win32" ? "where" : "which";
const locator =
platform === "win32" ? resolveQaWindowsSystem32ExePath("where.exe", params?.env) : "which";
const execFileImpl = params?.execFileImpl ?? execFileAsync;
let stdout;
try {

View File

@@ -1,6 +1,7 @@
// Qa Lab plugin module implements process tree cpu behavior.
import { spawnSync } from "node:child_process";
import { parseStrictFiniteNumber, parseStrictInteger } from "openclaw/plugin-sdk/number-runtime";
import { resolveQaWindowsPowerShellExePath } from "./windows-system-tools.js";
type ProcessTreeSnapshot = {
childrenByParent: Map<number, number[]>;
@@ -175,7 +176,7 @@ function collectProcessTreeMetric(
function readWindowsProcessTreeSnapshot(): ProcessTreeSnapshot | null {
const result = spawnSync(
"powershell.exe",
resolveQaWindowsPowerShellExePath(),
[
"-NoProfile",
"-ExecutionPolicy",

View File

@@ -0,0 +1,52 @@
// Qa Lab tests cover Windows process tree sampling command selection.
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const spawnSyncMock = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawnSync: spawnSyncMock,
};
});
import { readProcessTreeCpuMs } from "./process-tree-cpu.js";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
spawnSyncMock.mockReset();
});
describe("readProcessTreeCpuMs on Windows", () => {
it("uses the trusted Windows PowerShell path", () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
vi.stubEnv("SystemRoot", "D:\\Windows");
spawnSyncMock.mockReturnValue({
status: 0,
stdout: JSON.stringify([
{
ProcessId: 100,
ParentProcessId: 50,
KernelModeTime: "10000",
UserModeTime: "20000",
WorkingSetSize: "1000",
},
{
ProcessId: 101,
ParentProcessId: 100,
KernelModeTime: "30000",
UserModeTime: "40000",
WorkingSetSize: "2000",
},
]),
});
expect(readProcessTreeCpuMs(100)).toBe(10);
expect(spawnSyncMock.mock.calls[0]?.[0]).toBe(
path.win32.join("D:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
);
});
});

View File

@@ -4240,7 +4240,7 @@ describe("qa mock openai server", () => {
expect(body).not.toContain("HEARTBEAT_OK");
});
it("rejects malformed Anthropic /v1/messages JSON with an invalid_request_error", async () => {
it("rejects malformed or non-object Anthropic /v1/messages JSON", async () => {
const server = await startQaMockOpenAiServer({
host: "127.0.0.1",
port: 0,
@@ -4249,20 +4249,55 @@ describe("qa mock openai server", () => {
await server.stop();
});
const response = await fetch(`${server.baseUrl}/v1/messages`, {
method: "POST",
headers: { "content-type": "application/json" },
body: '{"model":"claude-opus-4-8","messages":[',
for (const rawBody of ['{"model":"claude-opus-4-8","messages":[', "null", "[]", '"text"']) {
const response = await fetch(`${server.baseUrl}/v1/messages`, {
method: "POST",
headers: { "content-type": "application/json" },
body: rawBody,
});
expect(response.status).toBe(400);
const body = (await response.json()) as {
type: string;
error: { type: string; message: string };
};
expect(body.type).toBe("error");
expect(body.error.type).toBe("invalid_request_error");
expect(body.error.message).toContain("Malformed JSON body");
}
const health = await fetch(`${server.baseUrl}/healthz`);
expect(health.status).toBe(200);
});
it("rejects malformed OpenAI-compatible JSON without crashing the mock server", async () => {
const server = await startQaMockOpenAiServer({
host: "127.0.0.1",
port: 0,
});
cleanups.push(async () => {
await server.stop();
});
expect(response.status).toBe(400);
const body = (await response.json()) as {
type: string;
error: { type: string; message: string };
};
expect(body.type).toBe("error");
expect(body.error.type).toBe("invalid_request_error");
expect(body.error.message).toContain("Malformed JSON body");
for (const path of ["/v1/responses", "/v1/embeddings", "/v1/images/generations"]) {
for (const rawBody of ["{bad", "[]", '"text"']) {
const response = await fetch(`${server.baseUrl}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: rawBody,
});
expect(response.status).toBe(400);
const body = (await response.json()) as {
error: { type: string; message: string };
};
expect(body.error.type).toBe("invalid_request_error");
expect(body.error.message).toContain("Malformed JSON body");
}
}
const health = await fetch(`${server.baseUrl}/healthz`);
expect(health.status).toBe(200);
});
it("defaults empty-string Anthropic /v1/messages model to claude-opus-4-8", async () => {

View File

@@ -228,6 +228,26 @@ function readBody(req: IncomingMessage): Promise<string> {
});
}
function parseJsonObjectBody(raw: string): Record<string, unknown> | null {
try {
const parsed = raw ? (JSON.parse(raw) as unknown) : {};
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function writeOpenAiMalformedJsonError(res: ServerResponse, label: string) {
writeJson(res, 400, {
error: {
type: "invalid_request_error",
message: `Malformed JSON body for ${label} request.`,
},
});
}
function transcriptionTextForAudioRequest(rawBody: string) {
if (rawBody.length >= QA_GROUP_AUDIO_MIN_MULTIPART_BODY_CHARS) {
return QA_GROUP_AUDIO_TRANSCRIPTION_TEXT;
@@ -3418,7 +3438,11 @@ export async function startQaMockOpenAiServer(params?: { host?: string; port?: n
}
if (req.method === "POST" && url.pathname === "/v1/images/generations") {
const raw = await readBody(req);
const body = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
const body = parseJsonObjectBody(raw);
if (!body) {
writeOpenAiMalformedJsonError(res, "OpenAI Images");
return;
}
imageGenerationRequests.push(body);
if (imageGenerationRequests.length > 20) {
imageGenerationRequests.splice(0, imageGenerationRequests.length - 20);
@@ -3442,7 +3466,11 @@ export async function startQaMockOpenAiServer(params?: { host?: string; port?: n
}
if (req.method === "POST" && url.pathname === "/v1/embeddings") {
const raw = await readBody(req);
const body = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
const body = parseJsonObjectBody(raw);
if (!body) {
writeOpenAiMalformedJsonError(res, "OpenAI Embeddings");
return;
}
const inputs = extractEmbeddingInputTexts(body.input);
writeJson(res, 200, {
object: "list",
@@ -3464,7 +3492,11 @@ export async function startQaMockOpenAiServer(params?: { host?: string; port?: n
}
if (req.method === "POST" && url.pathname === "/v1/responses") {
const raw = await readBody(req);
const body = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
const body = parseJsonObjectBody(raw);
if (!body) {
writeOpenAiMalformedJsonError(res, "OpenAI Responses");
return;
}
const input = Array.isArray(body.input) ? (body.input as ResponsesInputItem[]) : [];
const events = await buildResponsesPayload(body, scenarioState);
const resolvedModel = typeof body.model === "string" ? body.model : "";
@@ -3502,10 +3534,8 @@ export async function startQaMockOpenAiServer(params?: { host?: string; port?: n
}
if (req.method === "POST" && url.pathname === "/v1/messages") {
const raw = await readBody(req);
let body: AnthropicMessagesRequest;
try {
body = raw ? (JSON.parse(raw) as AnthropicMessagesRequest) : {};
} catch {
const body = parseJsonObjectBody(raw) as AnthropicMessagesRequest | null;
if (!body) {
writeJson(res, 400, {
type: "error",
error: {

View File

@@ -187,7 +187,11 @@ describe("qa suite runtime agent process helpers", () => {
it("force-kills timed-out Windows qa cli process trees with taskkill", async () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const originalSystemRoot = process.env.SystemRoot;
const originalWindir = process.env.WINDIR;
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
process.env.SystemRoot = "C:\\Windows";
delete process.env.WINDIR;
try {
const child = createSpawnedProcess({ pid: 12345 });
spawnMock.mockReturnValue(child);
@@ -213,15 +217,29 @@ describe("qa suite runtime agent process helpers", () => {
await waitForSpawnCount(1);
await timeoutAssertion;
expect(spawnSyncMock).toHaveBeenCalledWith("taskkill", ["/PID", "12345", "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
});
expect(spawnSyncMock).toHaveBeenCalledWith(
path.win32.join("C:\\Windows", "System32", "taskkill.exe"),
["/PID", "12345", "/T", "/F"],
{
stdio: "ignore",
windowsHide: true,
},
);
expect(child.kill).not.toHaveBeenCalled();
} finally {
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
}
if (originalSystemRoot === undefined) {
delete process.env.SystemRoot;
} else {
process.env.SystemRoot = originalSystemRoot;
}
if (originalWindir === undefined) {
delete process.env.WINDIR;
} else {
process.env.WINDIR = originalWindir;
}
}
});
@@ -680,7 +698,7 @@ describe("qa suite runtime agent process helpers", () => {
expect(gatewayCall).toHaveBeenCalledWith(
"agent.wait",
{ runId: "run-oversized", timeoutMs: 9e15 },
{ runId: "run-oversized", timeoutMs: MAX_TIMER_TIMEOUT_MS },
{ timeoutMs: MAX_TIMER_TIMEOUT_MS },
);
});

View File

@@ -19,6 +19,7 @@ import { liveTurnTimeoutMs } from "./suite-runtime-agent-common.js";
import { waitForGatewayHealthy, waitForTransportReady } from "./suite-runtime-gateway.js";
import type { QaDreamingStatus, QaSuiteRuntimeEnv } from "./suite-runtime-types.js";
import { resolveQaGatewayTimeoutWithGraceMs } from "./timer-timeouts.js";
import { resolveQaWindowsSystem32ExePath } from "./windows-system-tools.js";
type QaMemorySearchResult = {
results?: Array<{ snippet?: string; text?: string; path?: string }>;
@@ -209,10 +210,14 @@ function signalQaCliProcessTree(
) {
if (process.platform === "win32") {
if (typeof child.pid === "number") {
const result = spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
});
const result = spawnSync(
resolveQaWindowsSystem32ExePath("taskkill.exe"),
["/PID", String(child.pid), "/T", "/F"],
{
stdio: "ignore",
windowsHide: true,
},
);
if (!result.error && result.status === 0) {
return;
}
@@ -342,15 +347,16 @@ async function waitForAgentRun(
runId: string,
timeoutMs = 30_000,
) {
const waitTimeoutMs = resolveTimerTimeoutMs(timeoutMs, 30_000);
try {
return (await env.gateway.call(
"agent.wait",
{
runId,
timeoutMs,
timeoutMs: waitTimeoutMs,
},
{
timeoutMs: resolveQaGatewayTimeoutWithGraceMs(timeoutMs),
timeoutMs: resolveQaGatewayTimeoutWithGraceMs(waitTimeoutMs),
},
)) as { status?: string; error?: string };
} catch (error) {

View File

@@ -431,26 +431,44 @@ describe("qa test file scenario runner", () => {
});
it("force-kills Windows scenario command trees when graceful taskkill fails", () => {
const originalSystemRoot = process.env.SystemRoot;
const originalWindir = process.env.WINDIR;
process.env.SystemRoot = "C:\\Windows";
delete process.env.WINDIR;
const runTaskkill = vi
.fn()
.mockReturnValueOnce({ status: 1 })
.mockReturnValueOnce({ status: 0 });
expect(
qaTestFileScenarioRunnerTesting.killQaScenarioWindowsProcessTree(
12345,
"SIGTERM",
runTaskkill,
),
).toBe(true);
expect(runTaskkill).toHaveBeenNthCalledWith(1, "taskkill", ["/pid", "12345", "/T"], {
stdio: "ignore",
windowsHide: true,
});
expect(runTaskkill).toHaveBeenNthCalledWith(2, "taskkill", ["/pid", "12345", "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
});
try {
expect(
qaTestFileScenarioRunnerTesting.killQaScenarioWindowsProcessTree(
12345,
"SIGTERM",
runTaskkill,
),
).toBe(true);
const taskkillPath = path.win32.join("C:\\Windows", "System32", "taskkill.exe");
expect(runTaskkill).toHaveBeenNthCalledWith(1, taskkillPath, ["/pid", "12345", "/T"], {
stdio: "ignore",
windowsHide: true,
});
expect(runTaskkill).toHaveBeenNthCalledWith(2, taskkillPath, ["/pid", "12345", "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
});
} finally {
if (originalSystemRoot === undefined) {
delete process.env.SystemRoot;
} else {
process.env.SystemRoot = originalSystemRoot;
}
if (originalWindir === undefined) {
delete process.env.WINDIR;
} else {
process.env.WINDIR = originalWindir;
}
}
});
it("fails script scenarios that exit cleanly after timeout termination", async () => {

View File

@@ -21,6 +21,7 @@ import type { QaProviderMode } from "./providers/index.js";
import type { QaSeedScenarioWithSource } from "./scenario-catalog.js";
import type { QaScorecardEvidenceMode } from "./scorecard-taxonomy.js";
import { shellQuote } from "./shell-quote.js";
import { resolveQaWindowsSystem32ExePath } from "./windows-system-tools.js";
export type QaTestFileScenario = QaSeedScenarioWithSource & {
execution: Extract<
@@ -196,11 +197,12 @@ function killQaScenarioWindowsProcessTree(
if (pid === undefined) {
return false;
}
const taskkillPath = resolveQaWindowsSystem32ExePath("taskkill.exe");
const args = ["/pid", String(pid), "/T"];
if (signal === "SIGKILL") {
args.push("/F");
}
const result = runTaskkill("taskkill", args, {
const result = runTaskkill(taskkillPath, args, {
stdio: "ignore",
windowsHide: true,
});
@@ -208,7 +210,7 @@ function killQaScenarioWindowsProcessTree(
return true;
}
if (signal !== "SIGKILL") {
const forceResult = runTaskkill("taskkill", [...args, "/F"], {
const forceResult = runTaskkill(taskkillPath, [...args, "/F"], {
stdio: "ignore",
windowsHide: true,
});

View File

@@ -134,6 +134,24 @@ describe("token efficiency report", () => {
]);
});
it("fails live reports with non-integer token usage evidence", () => {
const report = buildTokenEfficiencyReport({
summary: makeLiveSummary([
makeRuntimeParity(
"fractional-live-usage",
makeCell("openclaw", { inputTokens: 100.5, outputTokens: 0, totalTokens: 100.5 }),
makeCell("codex", { inputTokens: 101, outputTokens: 0, totalTokens: 101 }),
),
]),
});
expect(report.pass).toBe(false);
expect(report.failures).toEqual([
"fractional-live-usage openclaw live usage inputTokens must be a non-negative integer",
"fractional-live-usage openclaw live usage totalTokens must be a non-negative integer",
]);
});
it("fails empty live runtime summaries instead of treating them as skipped proof", () => {
const report = buildTokenEfficiencyReport({
generatedAt: "2026-05-10T00:00:00.000Z",

View File

@@ -181,6 +181,26 @@ function liveEvidenceFailures(row: TokenEfficiencyRow): string[] {
return failures;
}
function liveUsageShapeFailures(
scenarioId: string,
runtime: RuntimeId,
usage: RuntimeParityCell["usage"],
): string[] {
const failures: string[] = [];
for (const key of ["inputTokens", "outputTokens", "totalTokens"] as const) {
const value: unknown = usage[key];
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
!Number.isInteger(value) ||
value < 0
) {
failures.push(`${scenarioId} ${runtime} live usage ${key} must be a non-negative integer`);
}
}
return failures;
}
export function buildTokenEfficiencyReport(
params: BuildTokenEfficiencyReportParams,
): TokenEfficiencyReport {
@@ -218,8 +238,16 @@ export function buildTokenEfficiencyReport(
}),
);
const aggregate = buildAggregate(rows);
const failures = rows.flatMap((row) => {
const rowFailures = liveUsage ? liveEvidenceFailures(row) : [];
const failures = rows.flatMap((row, index) => {
const result = parityResults[index];
const rowFailures =
liveUsage && result
? [
...liveUsageShapeFailures(row.scenarioId, "openclaw", result.cells.openclaw.usage),
...liveUsageShapeFailures(row.scenarioId, "codex", result.cells.codex.usage),
...liveEvidenceFailures(row),
]
: [];
if (row.flagged) {
rowFailures.push(
`${row.scenarioId} token delta=${formatPercent(row.deltaPercent)} exceeds ${thresholdPercent.toFixed(1)}% Codex increase threshold`,

View File

@@ -94,6 +94,35 @@ describe("qa tool coverage report", () => {
);
});
it("escapes freeform metadata in the markdown table", () => {
const report = buildQaToolCoverageReport({
scenarios: [
makeScenario("tool-read", "read|file", {
toolCoverage: {
bucket: "codex-native-workspace",
expectedLayer: "codex-native-workspace",
capabilityLayer: "codex-native-workspace",
required: true,
tracking: "#80236",
reason: "tracked | runtime drift",
codexDefaultImpact: "P2 | default",
qaImpact: "P1 | confidence",
action: "fix | backfill",
},
}),
],
generatedAt: "2026-05-10T00:00:00.000Z",
});
const markdown = renderQaToolCoverageMarkdownReport(report);
expect(markdown).toContain("read\\|file");
expect(markdown).toContain("P2 \\| default");
expect(markdown).toContain("P1 \\| confidence");
expect(markdown).toContain("fix \\| backfill");
expect(markdown).toContain("#80236 tracked \\| runtime drift");
});
it("uses runtime parity summary rows and allows tracked known-broken drift", () => {
const report = buildQaToolCoverageReport({
scenarios: [

View File

@@ -325,9 +325,22 @@ export function renderQaToolCoverageMarkdownReport(report: QaToolCoverageReport)
];
for (const row of report.rows) {
lines.push(
`| ${row.tool} | ${row.bucket} | ${row.expectedLayer} | ${row.capabilityLayer} | ${row.required ? "yes" : "no"} | ${row.fixtureCount} | ${row.openclaw} | ${row.codex} | ${row.drift} | ${row.codexDefaultImpact ?? ""} | ${row.qaImpact ?? ""} | ${row.action ?? ""} | ${row.tracking ?? ""} |`,
);
const cells = [
row.tool,
row.bucket,
row.expectedLayer,
row.capabilityLayer,
row.required ? "yes" : "no",
row.fixtureCount.toString(),
row.openclaw,
row.codex,
row.drift,
row.codexDefaultImpact ?? "",
row.qaImpact ?? "",
row.action ?? "",
row.tracking ?? "",
].map(escapeTableCell);
lines.push(`| ${cells.join(" | ")} |`);
}
if (report.failures.length > 0) {
@@ -344,3 +357,7 @@ export function renderQaToolCoverageMarkdownReport(report: QaToolCoverageReport)
return `${lines.join("\n").trimEnd()}\n`;
}
function escapeTableCell(value: string): string {
return value.replace(/\|/gu, "\\|").replace(/\s+/gu, " ").trim();
}

View File

@@ -0,0 +1,34 @@
// Qa Lab tests cover Windows system tool path resolution.
import { describe, expect, it } from "vitest";
import {
resolveQaWindowsPowerShellExePath,
resolveQaWindowsSystem32ExePath,
resolveQaWindowsSystemRoot,
} from "./windows-system-tools.js";
describe("qa-lab windows system tools", () => {
it("resolves System32 executables from a trusted SystemRoot", () => {
expect(resolveQaWindowsSystemRoot({ SystemRoot: "D:\\Windows\\" })).toBe("D:\\Windows");
expect(resolveQaWindowsSystem32ExePath("taskkill.exe", { SystemRoot: "D:\\Windows\\" })).toBe(
"D:\\Windows\\System32\\taskkill.exe",
);
expect(resolveQaWindowsPowerShellExePath({ SystemRoot: "D:\\Windows\\" })).toBe(
"D:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
);
});
it("falls back to the default Windows root when env roots are unsafe", () => {
expect(resolveQaWindowsSystem32ExePath("taskkill.exe", { SystemRoot: "C:\\tmp;C:\\bad" })).toBe(
"C:\\Windows\\System32\\taskkill.exe",
);
});
it("rejects non-basename System32 executable names", () => {
expect(() => resolveQaWindowsSystem32ExePath("..\\taskkill.exe")).toThrow(
"Invalid Windows System32 executable name",
);
expect(() => resolveQaWindowsSystem32ExePath("taskkill")).toThrow(
"Invalid Windows System32 executable name",
);
});
});

View File

@@ -0,0 +1,74 @@
// Qa Lab resolves Windows system tools without trusting PATH.
import path from "node:path";
const DEFAULT_WINDOWS_SYSTEM_ROOT = "C:\\Windows";
function getEnvValueCaseInsensitive(
env: Record<string, string | undefined>,
expectedKey: string,
): string | undefined {
const direct = env[expectedKey];
if (direct !== undefined) {
return direct;
}
const expected = expectedKey.toUpperCase();
const actualKey = Object.keys(env).find((key) => key.toUpperCase() === expected);
return actualKey ? env[actualKey] : undefined;
}
function normalizeWindowsSystemRoot(raw: string | undefined): string | null {
const trimmed = raw?.trim();
if (
!trimmed ||
trimmed.includes("\0") ||
trimmed.includes("\r") ||
trimmed.includes("\n") ||
trimmed.includes(";")
) {
return null;
}
const normalized = path.win32.normalize(trimmed);
if (!path.win32.isAbsolute(normalized) || normalized.startsWith("\\\\")) {
return null;
}
const parsed = path.win32.parse(normalized);
if (!/^[A-Za-z]:\\$/u.test(parsed.root) || normalized.length <= parsed.root.length) {
return null;
}
return normalized.replace(/[\\/]+$/u, "");
}
export function resolveQaWindowsSystemRoot(
env: Record<string, string | undefined> = process.env,
): string {
return (
normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "SystemRoot")) ??
normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "WINDIR")) ??
DEFAULT_WINDOWS_SYSTEM_ROOT
);
}
export function resolveQaWindowsSystem32ExePath(
executableName: string,
env: Record<string, string | undefined> = process.env,
): string {
if (
path.win32.basename(executableName) !== executableName ||
!/^[A-Za-z0-9_.-]+\.exe$/u.test(executableName)
) {
throw new Error(`Invalid Windows System32 executable name: ${executableName}`);
}
return path.win32.join(resolveQaWindowsSystemRoot(env), "System32", executableName);
}
export function resolveQaWindowsPowerShellExePath(
env: Record<string, string | undefined> = process.env,
): string {
return path.win32.join(
resolveQaWindowsSystemRoot(env),
"System32",
"WindowsPowerShell",
"v1.0",
"powershell.exe",
);
}

View File

@@ -1,2 +0,0 @@
// Qa Matrix plugin module implements cli behavior.
export { runQaMatrixCommand } from "./src/cli.runtime.js";

View File

@@ -1,2 +0,0 @@
// Qa Matrix plugin module implements runtime behavior.
export { runMatrixQaLive } from "./src/runners/contract/runtime.js";

View File

@@ -63,7 +63,11 @@ describe("Matrix QA CLI runtime", () => {
it("force-kills Windows CLI process trees when graceful taskkill fails", () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const originalSystemRoot = process.env.SystemRoot;
const originalWindir = process.env.WINDIR;
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
process.env.SystemRoot = "C:\\Windows";
delete process.env.WINDIR;
try {
const killMock = vi.fn();
const child = {
@@ -77,11 +81,12 @@ describe("Matrix QA CLI runtime", () => {
testing.killMatrixQaCliChild(child, "SIGTERM", runTaskkill);
expect(runTaskkill).toHaveBeenNthCalledWith(1, "taskkill", ["/PID", "12345", "/T"], {
const taskkillPath = path.win32.join("C:\\Windows", "System32", "taskkill.exe");
expect(runTaskkill).toHaveBeenNthCalledWith(1, taskkillPath, ["/PID", "12345", "/T"], {
stdio: "ignore",
windowsHide: true,
});
expect(runTaskkill).toHaveBeenNthCalledWith(2, "taskkill", ["/PID", "12345", "/T", "/F"], {
expect(runTaskkill).toHaveBeenNthCalledWith(2, taskkillPath, ["/PID", "12345", "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
});
@@ -90,6 +95,16 @@ describe("Matrix QA CLI runtime", () => {
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
}
if (originalSystemRoot === undefined) {
delete process.env.SystemRoot;
} else {
process.env.SystemRoot = originalSystemRoot;
}
if (originalWindir === undefined) {
delete process.env.WINDIR;
} else {
process.env.WINDIR = originalWindir;
}
}
});

View File

@@ -8,6 +8,7 @@ import { setTimeout as sleep } from "node:timers/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { resolveMatrixQaWindowsSystem32ExePath } from "../../windows-system-tools.js";
export type MatrixQaCliRunResult = {
args: string[];
@@ -111,16 +112,17 @@ function killMatrixQaCliChild(
): void {
if (process.platform === "win32") {
if (child.pid) {
const taskkillPath = resolveMatrixQaWindowsSystem32ExePath("taskkill.exe");
const args = ["/PID", String(child.pid), "/T"];
if (signal === "SIGKILL") {
args.push("/F");
}
const result = runTaskkill("taskkill", args, { stdio: "ignore", windowsHide: true });
const result = runTaskkill(taskkillPath, args, { stdio: "ignore", windowsHide: true });
if (!result.error && result.status === 0) {
return;
}
if (signal !== "SIGKILL") {
const forceResult = runTaskkill("taskkill", [...args, "/F"], {
const forceResult = runTaskkill(taskkillPath, [...args, "/F"], {
stdio: "ignore",
windowsHide: true,
});

View File

@@ -0,0 +1,32 @@
// Qa Matrix tests cover Windows system tool path resolution.
import { describe, expect, it } from "vitest";
import {
resolveMatrixQaWindowsSystem32ExePath,
resolveMatrixQaWindowsSystemRoot,
} from "./windows-system-tools.js";
describe("qa-matrix windows system tools", () => {
it("resolves System32 executables from a trusted SystemRoot", () => {
expect(resolveMatrixQaWindowsSystemRoot({ SystemRoot: "D:\\Windows\\" })).toBe("D:\\Windows");
expect(
resolveMatrixQaWindowsSystem32ExePath("taskkill.exe", { SystemRoot: "D:\\Windows\\" }),
).toBe("D:\\Windows\\System32\\taskkill.exe");
});
it("falls back to the default Windows root when env roots are unsafe", () => {
expect(
resolveMatrixQaWindowsSystem32ExePath("taskkill.exe", {
WINDIR: "\\\\attacker\\share",
}),
).toBe("C:\\Windows\\System32\\taskkill.exe");
});
it("rejects non-basename System32 executable names", () => {
expect(() => resolveMatrixQaWindowsSystem32ExePath("..\\taskkill.exe")).toThrow(
"Invalid Windows System32 executable name",
);
expect(() => resolveMatrixQaWindowsSystem32ExePath("taskkill")).toThrow(
"Invalid Windows System32 executable name",
);
});
});

View File

@@ -0,0 +1,62 @@
// Qa Matrix resolves Windows system tools without trusting PATH.
import path from "node:path";
const DEFAULT_WINDOWS_SYSTEM_ROOT = "C:\\Windows";
function getEnvValueCaseInsensitive(
env: Record<string, string | undefined>,
expectedKey: string,
): string | undefined {
const direct = env[expectedKey];
if (direct !== undefined) {
return direct;
}
const expected = expectedKey.toUpperCase();
const actualKey = Object.keys(env).find((key) => key.toUpperCase() === expected);
return actualKey ? env[actualKey] : undefined;
}
function normalizeWindowsSystemRoot(raw: string | undefined): string | null {
const trimmed = raw?.trim();
if (
!trimmed ||
trimmed.includes("\0") ||
trimmed.includes("\r") ||
trimmed.includes("\n") ||
trimmed.includes(";")
) {
return null;
}
const normalized = path.win32.normalize(trimmed);
if (!path.win32.isAbsolute(normalized) || normalized.startsWith("\\\\")) {
return null;
}
const parsed = path.win32.parse(normalized);
if (!/^[A-Za-z]:\\$/u.test(parsed.root) || normalized.length <= parsed.root.length) {
return null;
}
return normalized.replace(/[\\/]+$/u, "");
}
export function resolveMatrixQaWindowsSystemRoot(
env: Record<string, string | undefined> = process.env,
): string {
return (
normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "SystemRoot")) ??
normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "WINDIR")) ??
DEFAULT_WINDOWS_SYSTEM_ROOT
);
}
export function resolveMatrixQaWindowsSystem32ExePath(
executableName: string,
env: Record<string, string | undefined> = process.env,
): string {
if (
path.win32.basename(executableName) !== executableName ||
!/^[A-Za-z0-9_.-]+\.exe$/u.test(executableName)
) {
throw new Error(`Invalid Windows System32 executable name: ${executableName}`);
}
return path.win32.join(resolveMatrixQaWindowsSystemRoot(env), "System32", executableName);
}

View File

@@ -12,6 +12,7 @@
"@slack/types": "2.21.1",
"@slack/web-api": "7.16.0",
"typebox": "1.1.39",
"ws": "8.21.0",
"zod": "4.4.3"
},
"peerDependencies": {

View File

@@ -12,6 +12,7 @@
"@slack/types": "2.21.1",
"@slack/web-api": "7.16.0",
"typebox": "1.1.39",
"ws": "8.21.0",
"zod": "4.4.3"
},
"devDependencies": {

View File

@@ -11,5 +11,13 @@ export function isSlackPluginAccountConfigured(account: ResolvedSlackAccount): b
if (mode === "http") {
return hasConfiguredAccountValue(account.config.signingSecret);
}
if (mode === "relay") {
const relay = account.config.relay;
return (
hasConfiguredAccountValue(relay?.url) &&
hasConfiguredAccountValue(relay?.authToken) &&
hasConfiguredAccountValue(relay?.gatewayId)
);
}
return Boolean(account.appToken?.trim());
}

View File

@@ -80,6 +80,7 @@ export function inspectSlackAccount(params: {
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const mode = merged.mode ?? "socket";
const isHttpMode = mode === "http";
const isRelayMode = mode === "relay";
const configBot = inspectSlackToken(merged.botToken);
const configApp = inspectSlackToken(merged.appToken);
@@ -89,9 +90,10 @@ export function inspectSlackAccount(params: {
const envBot = allowEnv
? normalizeSecretInputString(params.envBotToken ?? process.env.SLACK_BOT_TOKEN)
: undefined;
const envApp = allowEnv
? normalizeSecretInputString(params.envAppToken ?? process.env.SLACK_APP_TOKEN)
: undefined;
const envApp =
allowEnv && !isRelayMode
? normalizeSecretInputString(params.envAppToken ?? process.env.SLACK_APP_TOKEN)
: undefined;
const envUser = allowEnv
? normalizeSecretInputString(params.envUserToken ?? process.env.SLACK_USER_TOKEN)
: undefined;
@@ -100,6 +102,11 @@ export function inspectSlackAccount(params: {
const appToken = configApp.token ?? envApp;
const signingSecret = configSigningSecret.token;
const userToken = configUser.token ?? envUser;
const relayConfigured =
isRelayMode &&
Boolean(normalizeOptionalString(merged.relay?.url)) &&
hasConfiguredSecretInput(merged.relay?.authToken) &&
Boolean(normalizeOptionalString(merged.relay?.gatewayId));
const botTokenSource: SlackTokenSource = configBot.token
? "config"
: configBot.status === "configured_unavailable"
@@ -173,8 +180,10 @@ export function inspectSlackAccount(params: {
configured: isHttpMode
? (configBot.status !== "missing" || Boolean(envBot)) &&
configSigningSecret.status !== "missing"
: (configBot.status !== "missing" || Boolean(envBot)) &&
(configApp.status !== "missing" || Boolean(envApp)),
: isRelayMode
? (configBot.status !== "missing" || Boolean(envBot)) && relayConfigured
: (configBot.status !== "missing" || Boolean(envBot)) &&
(configApp.status !== "missing" || Boolean(envApp)),
config: merged,
groupPolicy: merged.groupPolicy,
textChunkLimit: merged.textChunkLimit,

View File

@@ -54,6 +54,13 @@ const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("sl
if (slack?.mode === "http") {
return hasConfiguredAccountValue(slack.signingSecret);
}
if (slack?.mode === "relay") {
return (
hasConfiguredAccountValue(slack.relay?.url) &&
hasConfiguredAccountValue(slack.relay?.authToken) &&
hasConfiguredAccountValue(slack.relay?.gatewayId)
);
}
return (
hasConfiguredAccountValue(slack?.appToken) ||
hasConfiguredAccountValue(process.env.SLACK_APP_TOKEN)
@@ -137,7 +144,7 @@ export function mergeSlackAccountConfig(
channelConfig: cfg.channels?.slack as SlackAccountConfig,
accounts: cfg.channels?.slack?.accounts as Record<string, Partial<SlackAccountConfig>>,
accountId,
nestedObjectKeys: ["botLoopProtection"],
nestedObjectKeys: ["botLoopProtection", "relay"],
});
const streaming = mergeSlackStreamingConfig(
(cfg.channels?.slack as Record<string, unknown> | undefined)?.streaming,
@@ -207,7 +214,7 @@ export function resolveSlackAccount(params: {
const mode = merged.mode ?? "socket";
const baseAllowEnv = accountId === DEFAULT_ACCOUNT_ID;
const botActive = enabled;
const appActive = enabled && mode !== "http";
const appActive = enabled && mode === "socket";
const userActive = enabled;
const envBot =
botActive && baseAllowEnv ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) : undefined;

View File

@@ -160,7 +160,7 @@ function getTokenForOperation(
account: ResolvedSlackAccount,
operation: "read" | "write",
): string | undefined {
const userToken = normalizeOptionalString(account.config.userToken);
const userToken = normalizeOptionalString(account.userToken);
const botToken = normalizeOptionalString(account.botToken);
const allowUserWrites = account.config.userTokenReadOnly === false;
if (operation === "read") {
@@ -414,7 +414,7 @@ const resolveSlackAllowlistGroupOverrides = createFlatAllowlistOverrideResolver(
const resolveSlackAllowlistNames = createAccountScopedAllowlistNameResolver({
resolveAccount: resolveSlackAccount,
resolveToken: (account: ResolvedSlackAccount) =>
normalizeOptionalString(account.config.userToken) ?? normalizeOptionalString(account.botToken),
normalizeOptionalString(account.userToken) ?? normalizeOptionalString(account.botToken),
resolveNames: async ({ token, entries }) =>
(await loadSlackResolveUsersModule()).resolveSlackUserAllowlist({ token, entries }),
});
@@ -646,7 +646,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
if (kind === "group") {
return resolveTargetsWithOptionalToken({
token:
normalizeOptionalString(account.config.userToken) ??
normalizeOptionalString(account.userToken) ??
normalizeOptionalString(account.botToken),
inputs,
missingTokenNote: "missing Slack token",
@@ -661,7 +661,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
}
return resolveTargetsWithOptionalToken({
token:
normalizeOptionalString(account.config.userToken) ??
normalizeOptionalString(account.userToken) ??
normalizeOptionalString(account.botToken),
inputs,
missingTokenNote: "missing Slack token",
@@ -713,7 +713,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
const lines = [];
const details: Record<string, unknown> = {};
const botToken = account.botToken?.trim();
const userToken = account.config.userToken?.trim();
const userToken = account.userToken?.trim();
const { fetchSlackScopes } = await loadSlackScopesModule();
const botScopes: SlackScopesResultShape = botToken
? await fetchSlackScopes(botToken, timeoutMs)
@@ -729,16 +729,19 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
},
resolveAccountSnapshot: ({ account }) => {
const mode = account.config.mode ?? "socket";
const configured =
(mode === "http"
const credentialConfigured =
mode === "http"
? resolveConfiguredFromRequiredCredentialStatuses(account, [
"botTokenStatus",
"signingSecretStatus",
])
: resolveConfiguredFromRequiredCredentialStatuses(account, [
"botTokenStatus",
"appTokenStatus",
])) ?? isSlackPluginAccountConfigured(account);
: mode === "socket"
? resolveConfiguredFromRequiredCredentialStatuses(account, [
"botTokenStatus",
"appTokenStatus",
])
: undefined;
const configured = credentialConfigured ?? isSlackPluginAccountConfigured(account);
return {
accountId: account.accountId,
name: account.name,

View File

@@ -111,6 +111,36 @@ describe("slack config schema", () => {
});
});
it("accepts relay mode with a SecretInput auth token", () => {
expectSlackConfigValid({
mode: "relay",
botToken: "xoxb-any",
relay: {
url: "wss://router.example.com/gateway/ws",
authToken: { source: "env", provider: "default", id: "SLACK_RELAY_AUTH_TOKEN" },
gatewayId: "team-gateway",
},
});
});
it("requires every relay connection field", () => {
expectSlackConfigIssue({ mode: "relay" }, "relay.url");
expectSlackConfigIssue(
{ mode: "relay", relay: { url: "wss://router.example.com/gateway/ws" } },
"relay.authToken",
);
expectSlackConfigIssue(
{
mode: "relay",
relay: {
url: "wss://router.example.com/gateway/ws",
authToken: "secret",
},
},
"relay.gatewayId",
);
});
it("rejects invalid Socket Mode ping/pong transport tuning", () => {
expectSlackConfigIssue(
{

View File

@@ -82,6 +82,22 @@ export const slackChannelConfigUiHints = {
label: "Slack Socket Mode Ping/Pong Logging",
help: "Enable Slack SDK ping/pong transport logs while debugging Socket Mode websocket health.",
},
relay: {
label: "Slack Relay Mode",
help: 'Relay-delivered Slack events. Use with mode="relay" when openclaw-slack-router owns the Slack Socket Mode connection.',
},
"relay.url": {
label: "Slack Relay URL",
help: "Full websocket URL for openclaw-slack-router. Include the route path, for example ws://127.0.0.1:8081/gateway/ws.",
},
"relay.authToken": {
label: "Slack Relay Auth Token",
help: "Bearer token used by this gateway to authenticate its reverse websocket connection to openclaw-slack-router.",
},
"relay.gatewayId": {
label: "Slack Relay Gateway ID",
help: "Destination id that openclaw-slack-router uses when routing user-group mentions to this gateway.",
},
botToken: {
label: "Slack Bot Token",
help: "Slack bot token used for standard chat actions in the configured workspace. Keep this credential scoped and rotate if workspace app permissions change.",

View File

@@ -3,6 +3,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const enqueueMock = vi.fn(async (_entry: unknown) => {});
const flushKeyMock = vi.fn(async (_key: string) => {});
const onFlushCallbacks: Array<(entries: Array<Record<string, unknown>>) => Promise<void>> = [];
const prepareSlackMessageMock = vi.fn(async () => ({ ctxPayload: {} }));
const dispatchPreparedSlackMessageMock = vi.fn(async () => {});
const resolveThreadTsMock = vi.fn(async ({ message }: { message: Record<string, unknown> }) => ({
...message,
}));
@@ -14,13 +17,18 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async () => {
);
return {
...actual,
createChannelInboundDebouncer: () => ({
debounceMs: 10,
debouncer: {
enqueue: (entry: unknown) => enqueueMock(entry),
flushKey: (key: string) => flushKeyMock(key),
},
}),
createChannelInboundDebouncer: (params: {
onFlush: (entries: Array<Record<string, unknown>>) => Promise<void>;
}) => {
onFlushCallbacks.push(params.onFlush);
return {
debounceMs: 10,
debouncer: {
enqueue: (entry: unknown) => enqueueMock(entry),
flushKey: (key: string) => flushKeyMock(key),
},
};
},
shouldDebounceTextInbound: ({ hasMedia }: { hasMedia?: boolean }) => !hasMedia,
};
});
@@ -31,6 +39,16 @@ vi.mock("./thread-resolution.js", () => ({
}),
}));
vi.mock("./message-handler/pipeline.runtime.js", () => ({
prepareSlackMessage: prepareSlackMessageMock,
dispatchPreparedSlackMessage: dispatchPreparedSlackMessageMock,
}));
vi.mock("./inbound-delivery-state.js", () => ({
hasSlackInboundMessageDelivery: vi.fn(async () => false),
recordSlackInboundMessageDeliveries: vi.fn(async () => {}),
}));
function createContext(overrides?: {
markMessageSeen?: (channel: string | undefined, ts: string | undefined) => boolean;
releaseSeenMessage?: (channel: string | undefined, ts: string | undefined) => void;
@@ -80,6 +98,9 @@ describe("createSlackMessageHandler", () => {
beforeEach(() => {
enqueueMock.mockClear();
flushKeyMock.mockClear();
onFlushCallbacks.length = 0;
prepareSlackMessageMock.mockClear();
dispatchPreparedSlackMessageMock.mockClear();
resolveThreadTsMock.mockClear();
});
@@ -201,4 +222,52 @@ describe("createSlackMessageHandler", () => {
expect(flushKeyMock).toHaveBeenCalledWith("slack:default:C111:1709000000.000100:U111");
});
it("waits for debounced dispatch completion when requested by relay delivery", async () => {
const { handler } = createHandlerWithTracker();
const handled = handler(
{
type: "message",
channel: "C111",
user: "U111",
ts: "1709000000.000500",
text: "relay message",
} as never,
{ source: "message", awaitDispatch: true },
);
await vi.waitFor(() => expect(enqueueMock).toHaveBeenCalledTimes(1));
const entry = enqueueMock.mock.calls[0]?.[0] as Record<string, unknown>;
let settled = false;
void handled.then(() => {
settled = true;
});
await Promise.resolve();
expect(settled).toBe(false);
await onFlushCallbacks[0]?.([entry]);
await expect(handled).resolves.toBeUndefined();
expect(dispatchPreparedSlackMessageMock).toHaveBeenCalledTimes(1);
});
it("propagates debounced dispatch failures to relay delivery", async () => {
dispatchPreparedSlackMessageMock.mockRejectedValueOnce(new Error("dispatch failed"));
const { handler } = createHandlerWithTracker();
const handled = handler(
{
type: "message",
channel: "C111",
user: "U111",
ts: "1709000000.000600",
text: "relay message",
} as never,
{ source: "message", awaitDispatch: true },
);
await vi.waitFor(() => expect(enqueueMock).toHaveBeenCalledTimes(1));
const entry = enqueueMock.mock.calls[0]?.[0] as Record<string, unknown>;
const handledFailure = expect(handled).rejects.toThrow("dispatch failed");
const flushFailure = expect(onFlushCallbacks[0]?.([entry])).rejects.toThrow("dispatch failed");
await Promise.all([handledFailure, flushFailure]);
});
});

View File

@@ -9,6 +9,7 @@ import {
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import type { ResolvedSlackAccount } from "../accounts.js";
import type { SlackSendIdentity } from "../send.js";
import type { SlackMessageEvent } from "../types.js";
import { stripSlackMentionsForCommandDetection } from "./commands.js";
import type { SlackMonitorContext } from "./context.js";
@@ -33,9 +34,35 @@ function loadSlackMessagePipeline(): Promise<SlackMessagePipeline> {
export type SlackMessageHandler = (
message: SlackMessageEvent,
opts: { source: "message" | "app_mention"; wasMentioned?: boolean },
opts: {
source: "message" | "app_mention";
wasMentioned?: boolean;
relayIdentity?: SlackSendIdentity;
/** Wait until any inbound debounce flush and dispatch has completed. */
awaitDispatch?: boolean;
},
) => Promise<void>;
type SlackDispatchCompletion = {
promise: Promise<void>;
resolve: () => void;
reject: (error: unknown) => void;
};
type QueuedSlackMessageOptions = Parameters<SlackMessageHandler>[1] & {
dispatchCompletion?: Omit<SlackDispatchCompletion, "promise">;
};
function createSlackDispatchCompletion(): SlackDispatchCompletion {
let resolve!: () => void;
let reject!: (error: unknown) => void;
const promise = new Promise<void>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
const APP_MENTION_RETRY_TTL_MS = 60_000;
export class SlackRetryableInboundError extends Error {
@@ -71,103 +98,123 @@ export function createSlackMessageHandler(params: {
const { ctx, account, trackEvent } = params;
const { debounceMs, debouncer } = createChannelInboundDebouncer<{
message: SlackMessageEvent;
opts: { source: "message" | "app_mention"; wasMentioned?: boolean };
opts: QueuedSlackMessageOptions;
}>({
cfg: ctx.cfg,
channel: "slack",
buildKey: (entry) => buildSlackDebounceKey(entry.message, ctx.accountId),
shouldDebounce: (entry) => shouldDebounceSlackMessage(entry.message, ctx.cfg),
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) {
return;
}
const flushedKey = buildSlackDebounceKey(last.message, ctx.accountId);
const topLevelConversationKey = buildTopLevelSlackConversationKey(
last.message,
ctx.accountId,
);
if (flushedKey && topLevelConversationKey) {
const pendingKeys = pendingTopLevelDebounceKeys.get(topLevelConversationKey);
if (pendingKeys) {
pendingKeys.delete(flushedKey);
if (pendingKeys.size === 0) {
pendingTopLevelDebounceKeys.delete(topLevelConversationKey);
}
}
}
const combinedText =
entries.length === 1
? (last.message.text ?? "")
: entries
.map((entry) => entry.message.text ?? "")
.filter(Boolean)
.join("\n");
const combinedMentioned = entries.some((entry) => Boolean(entry.opts.wasMentioned));
const syntheticMessage: SlackMessageEvent = {
...last.message,
text: combinedText,
};
const seenMessageKey = buildSeenMessageKey(last.message.channel, last.message.ts);
const completions = entries
.map((entry) => entry.opts.dispatchCompletion)
.filter((completion) => completion !== undefined);
try {
const { prepareSlackMessage, dispatchPreparedSlackMessage } =
await loadSlackMessagePipeline();
const prepared = await prepareSlackMessage({
ctx,
account,
message: syntheticMessage,
opts: {
...last.opts,
wasMentioned: combinedMentioned || last.opts.wasMentioned,
},
});
if (!prepared) {
return;
}
if (seenMessageKey) {
pruneAppMentionRetryKeys(Date.now());
if (last.opts.source === "app_mention") {
// If app_mention wins the race and dispatches first, drop the later message dispatch.
rememberExpiringAppMentionKey(appMentionDispatchedKeys, seenMessageKey);
} else if (
last.opts.source === "message" &&
appMentionDispatchedKeys.has(seenMessageKey)
) {
appMentionDispatchedKeys.delete(seenMessageKey);
appMentionRetryKeys.delete(seenMessageKey);
await (async () => {
const last = entries.at(-1);
if (!last) {
return;
}
appMentionRetryKeys.delete(seenMessageKey);
}
if (entries.length > 1) {
const ids = entries.map((entry) => entry.message.ts).filter(Boolean) as string[];
if (ids.length > 0) {
prepared.ctxPayload.MessageSids = ids;
prepared.ctxPayload.MessageSidFirst = ids[0];
prepared.ctxPayload.MessageSidLast = ids[ids.length - 1];
const flushedKey = buildSlackDebounceKey(last.message, ctx.accountId);
const topLevelConversationKey = buildTopLevelSlackConversationKey(
last.message,
ctx.accountId,
);
if (flushedKey && topLevelConversationKey) {
const pendingKeys = pendingTopLevelDebounceKeys.get(topLevelConversationKey);
if (pendingKeys) {
pendingKeys.delete(flushedKey);
if (pendingKeys.size === 0) {
pendingTopLevelDebounceKeys.delete(topLevelConversationKey);
}
}
}
}
try {
await dispatchPreparedSlackMessage(prepared);
await recordSlackInboundMessageDeliveries({
accountId: ctx.accountId,
messages: entries.map((entry) => entry.message),
});
} catch (error) {
if (!(error instanceof SlackRetryableInboundError)) {
await recordSlackInboundMessageDeliveries({
accountId: ctx.accountId,
messages: entries.map((entry) => entry.message),
const combinedText =
entries.length === 1
? (last.message.text ?? "")
: entries
.map((entry) => entry.message.text ?? "")
.filter(Boolean)
.join("\n");
const combinedMentioned = entries.some((entry) => Boolean(entry.opts.wasMentioned));
const syntheticMessage: SlackMessageEvent = {
...last.message,
text: combinedText,
};
const seenMessageKey = buildSeenMessageKey(last.message.channel, last.message.ts);
try {
const { prepareSlackMessage, dispatchPreparedSlackMessage } =
await loadSlackMessagePipeline();
const {
dispatchCompletion: _completion,
awaitDispatch: _awaitDispatch,
...lastOpts
} = last.opts;
const prepared = await prepareSlackMessage({
ctx,
account,
message: syntheticMessage,
opts: {
...lastOpts,
wasMentioned: combinedMentioned || last.opts.wasMentioned,
},
});
if (!prepared) {
return;
}
if (seenMessageKey) {
pruneAppMentionRetryKeys(Date.now());
if (last.opts.source === "app_mention") {
// If app_mention wins the race and dispatches first, drop the later message dispatch.
rememberExpiringAppMentionKey(appMentionDispatchedKeys, seenMessageKey);
} else if (
last.opts.source === "message" &&
appMentionDispatchedKeys.has(seenMessageKey)
) {
appMentionDispatchedKeys.delete(seenMessageKey);
appMentionRetryKeys.delete(seenMessageKey);
return;
}
appMentionRetryKeys.delete(seenMessageKey);
}
if (entries.length > 1) {
const ids = entries.map((entry) => entry.message.ts).filter(Boolean) as string[];
if (ids.length > 0) {
prepared.ctxPayload.MessageSids = ids;
prepared.ctxPayload.MessageSidFirst = ids[0];
prepared.ctxPayload.MessageSidLast = ids[ids.length - 1];
}
}
try {
await dispatchPreparedSlackMessage(prepared);
await recordSlackInboundMessageDeliveries({
accountId: ctx.accountId,
messages: entries.map((entry) => entry.message),
});
} catch (error) {
if (!(error instanceof SlackRetryableInboundError)) {
await recordSlackInboundMessageDeliveries({
accountId: ctx.accountId,
messages: entries.map((entry) => entry.message),
});
}
throw error;
}
} catch (error) {
if (error instanceof SlackRetryableInboundError) {
if (seenMessageKey) {
appMentionDispatchedKeys.delete(seenMessageKey);
}
ctx.releaseSeenMessage(last.message.channel, last.message.ts);
}
throw error;
}
throw error;
})();
for (const completion of completions) {
completion.resolve();
}
} catch (error) {
if (error instanceof SlackRetryableInboundError) {
if (seenMessageKey) {
appMentionDispatchedKeys.delete(seenMessageKey);
}
ctx.releaseSeenMessage(last.message.channel, last.message.ts);
for (const completion of completions) {
completion.reject(error);
}
throw error;
}
@@ -284,6 +331,21 @@ export function createSlackMessageHandler(params: {
pendingKeys.add(debounceKey);
pendingTopLevelDebounceKeys.set(conversationKey, pendingKeys);
}
await debouncer.enqueue({ message: resolvedMessage, opts });
const dispatchCompletion = opts.awaitDispatch ? createSlackDispatchCompletion() : undefined;
await debouncer.enqueue({
message: resolvedMessage,
opts: {
...opts,
...(dispatchCompletion
? {
dispatchCompletion: {
resolve: dispatchCompletion.resolve,
reject: dispatchCompletion.reject,
},
}
: {}),
},
});
await dispatchCompletion?.promise;
};
}

View File

@@ -339,6 +339,7 @@ function createPreparedSlackMessage(params?: {
typingReaction?: string;
ackReactionMessageTs?: string;
ackReactionPromise?: Promise<boolean> | null;
relayIdentity?: { username?: string; iconUrl?: string; iconEmoji?: string };
}) {
const routeSessionKey = params?.route?.sessionKey ?? "agent:agent-1:slack:C123";
const mainSessionKey = params?.route?.mainSessionKey ?? "main";
@@ -373,6 +374,7 @@ function createPreparedSlackMessage(params?: {
accountId: "default",
config: params?.accountConfig ?? {},
},
relayIdentity: params?.relayIdentity,
message,
route: {
agentId: "agent-1",
@@ -1279,6 +1281,27 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
});
it("uses the relay identity when the agent has no explicit Slack identity", async () => {
const relayIdentity = { username: "Nik Team Claw" };
await dispatchPreparedSlackMessage(createPreparedSlackMessage({ relayIdentity }));
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
expectDeliverReplyCall(0, FINAL_REPLY_TEXT, { identity: relayIdentity });
});
it("does not use native Slack streaming when a custom identity is active", async () => {
mockedNativeStreaming = true;
const relayIdentity = { username: "Nik Team Claw" };
await dispatchPreparedSlackMessage(createPreparedSlackMessage({ relayIdentity }));
expect(startSlackStreamMock).not.toHaveBeenCalled();
expect(createSlackDraftStreamMock).toHaveBeenCalledTimes(1);
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
expectDeliverReplyCall(0, FINAL_REPLY_TEXT, { identity: relayIdentity });
});
it("does not create a Slack thread for top-level messages when replyToMode is off", async () => {
mockedSlackStreamingMode = "off";
mockedSlackIsThreadReply = false;

View File

@@ -466,7 +466,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
iconUrl: outboundIdentity.avatarUrl,
iconEmoji: outboundIdentity.emoji,
}
: undefined;
: prepared.relayIdentity;
if (prepared.isDirectMessage) {
const sessionCfg = cfg.session;
@@ -688,7 +688,11 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
shouldEnableSlackPreviewStreaming({
mode: slackStreaming.mode,
});
// Slack's native streaming APIs do not accept chat:write.customize identity
// fields. Keep custom-identity replies on the draft/standard postMessage
// path so the configured username and icon are not silently discarded.
const streamingEnabled =
!slackIdentity &&
!sourceRepliesAreToolOnly &&
isSlackStreamingEnabled({
mode: slackStreaming.mode,

View File

@@ -40,6 +40,7 @@ import type { ResolvedSlackAccount } from "../../accounts.js";
import { reactSlackMessage } from "../../actions.js";
import { formatSlackError } from "../../errors.js";
import { formatSlackFileReference } from "../../file-reference.js";
import type { SlackSendIdentity } from "../../send.js";
import { hasSlackThreadParticipationWithPersistence } from "../../sent-thread-cache.js";
import type { SlackAttachment, SlackFile, SlackMessageEvent } from "../../types.js";
import { normalizeAllowListLower, normalizeSlackAllowOwnerEntry } from "../allow-list.js";
@@ -619,7 +620,11 @@ export async function prepareSlackMessage(params: {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
message: SlackMessageEvent;
opts: { source: "message" | "app_mention"; wasMentioned?: boolean };
opts: {
source: "message" | "app_mention";
wasMentioned?: boolean;
relayIdentity?: SlackSendIdentity;
};
}): Promise<PreparedSlackMessage | null> {
const { ctx, account, message, opts } = params;
const cfg = ctx.cfg;
@@ -1390,6 +1395,7 @@ export async function prepareSlackMessage(params: {
ctx,
account,
message,
...(opts.relayIdentity ? { relayIdentity: opts.relayIdentity } : {}),
route,
channelConfig,
replyTarget,

View File

@@ -4,6 +4,7 @@ import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
import type { ResolvedSlackAccount } from "../../accounts.js";
import type { SlackSendIdentity } from "../../send.js";
import type { SlackMessageEvent } from "../../types.js";
import type { SlackChannelConfigResolved } from "../channel-config.js";
import type { SlackMonitorContext } from "../context.js";
@@ -12,6 +13,7 @@ export type PreparedSlackMessage = {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
message: SlackMessageEvent;
relayIdentity?: SlackSendIdentity;
route: ResolvedAgentRoute;
channelConfig: SlackChannelConfigResolved | null;
replyTarget: string;

View File

@@ -6,6 +6,7 @@ import { formatUnknownError, waitForSlackSocketDisconnect } from "./reconnect-po
type SlackAppConstructor = typeof import("@slack/bolt").App;
type SlackHttpReceiverConstructor = typeof import("@slack/bolt").HTTPReceiver;
type SlackReceiver = import("@slack/bolt").Receiver;
type SlackSocketModeReceiverConstructor = typeof import("@slack/bolt").SocketModeReceiver;
type SlackSocketModeReceiverOptions = ConstructorParameters<SlackSocketModeReceiverConstructor>[0];
type SlackSocketModeConfig = Pick<
@@ -113,6 +114,14 @@ function installSlackNativeReconnectFailureObserver(receiver: unknown) {
);
}
function createSlackRelayReceiver(): SlackReceiver {
return {
init() {},
start: () => Promise.resolve(undefined),
stop: () => Promise.resolve(undefined),
};
}
function resolveSlackBoltModule(value: unknown): SlackBoltResolvedExports | null {
if (!value || typeof value !== "object") {
return null;
@@ -296,7 +305,7 @@ export function shouldSkipOpenClawSlackSelfEvent(args: SlackSelfFilterArgs): boo
export function createSlackBoltApp(params: {
interop: SlackBoltResolvedExports;
slackMode: "socket" | "http";
slackMode: "socket" | "http" | "relay";
botToken: string;
appToken?: string;
signingSecret?: string;
@@ -322,25 +331,31 @@ export function createSlackBoltApp(params: {
socketModeReceiverOptions.pingPongLoggingEnabled = params.socketMode.pingPongLoggingEnabled;
}
const receiver =
params.slackMode === "socket"
? new params.interop.SocketModeReceiver(socketModeReceiverOptions)
: new params.interop.HTTPReceiver({
signingSecret: params.signingSecret ?? "",
endpoints: params.slackWebhookPath,
});
let receiver:
| InstanceType<SlackSocketModeReceiverConstructor>
| InstanceType<SlackHttpReceiverConstructor>
| SlackReceiver
| undefined;
if (params.slackMode === "socket") {
receiver = new params.interop.SocketModeReceiver(socketModeReceiverOptions);
installSlackNativeReconnectFailureObserver(receiver);
} else if (params.slackMode === "http") {
receiver = new params.interop.HTTPReceiver({
signingSecret: params.signingSecret ?? "",
endpoints: params.slackWebhookPath,
});
} else {
receiver = createSlackRelayReceiver();
}
const app = new params.interop.App({
token: params.botToken,
receiver,
clientOptions: params.clientOptions,
ignoreSelf: false,
// Bolt eagerly starts an auth.test promise in the constructor when token
// verification is enabled. Invalid tokens can reject before any listener
// consumes that promise, tripping OpenClaw's fatal unhandled-rejection path.
tokenVerificationEnabled: false,
...(receiver ? { receiver } : {}),
});
app.use(async (args) => {
if (shouldSkipOpenClawSlackSelfEvent(args)) {

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