Compare commits

..

187 Commits

Author SHA1 Message Date
Omar Shahine
d72b96b845 fix(codex): account for source reply SDK surface 2026-06-23 16:48:12 -07:00
Omar Shahine
19ceca6b55 fix(codex): accept numeric source message ids 2026-06-23 16:27:03 -07:00
Omar Shahine
ee79a31ba5 fix(codex): recognize message tool source replies 2026-06-23 16:27:02 -07:00
joshavant
4f5e25aa54 docs(ios): add app review notes 2026-06-23 18:22:07 -05:00
Josh Lehman
9512294e8f fix: bridge ACP metadata to session accessors (#96195)
* fix: bridge ACP metadata to session accessors

* fix: simplify ACP accessor key ownership

* fix: bind ACP metadata after session canonicalization
2026-06-23 16:14:53 -07:00
Josh Lehman
8a7b3c755a fix(memory-core): migrate dreaming cleanup lifecycle (#96193)
* fix(memory-core): migrate dreaming cleanup lifecycle

* fix(sessions): resolve lifecycle session files explicitly

* fix(ci): refresh dreaming lifecycle proof ratchets
2026-06-23 16:08:44 -07:00
Josh Lehman
f8ed4de460 refactor: add abort target session accessor (#96201)
* refactor: add abort target session accessor

* refactor: centralize command abort session lookup

* fix: keep abort runtime path best effort

* fix: preserve abort target identity on persistence failure

* fix: remember abort target when persistence is skipped

* fix: abort runtime before metadata persistence

* fix: preserve abort target fallback typing

* fix: avoid stale abort memory fallback

* fix: keep abort accessor ratchet narrow

* fix: type abort persistence test mock

* fix: align abort accessor ratchet test
2026-06-23 16:06:04 -07:00
Josh Lehman
b08d901dd2 fix: route gateway history through session accessor target (#96179) 2026-06-23 14:42:43 -07:00
Josh Lehman
a8f387ba19 refactor: route plugin host hook state through accessor (#96191)
* refactor: route plugin host hook state through accessor

* refactor: hide session accessor store internals
2026-06-23 14:38:40 -07:00
Josh Lehman
132d70bfb3 refactor: migrate bundled transcript target lookups (#89911) 2026-06-23 14:32:21 -07:00
Vincent Koc
009d6b261a fix(qa): retain crabline delivery targets 2026-06-24 05:12:26 +08:00
Vincent Koc
654544b6b7 test(ci): align plugin prerelease manifest env 2026-06-24 05:00:14 +08:00
Vincent Koc
252673d5b1 fix(qa): bootstrap raw macos package scripts 2026-06-23 22:51:04 +02:00
Vincent Koc
cc981f8a73 ci: build iOS app for iOS changes 2026-06-24 04:32:08 +08:00
Peter Steinberger
c9ddf2eca6 test(memory): clean up qmd fixture gracefully 2026-06-23 13:31:46 -07:00
Peter Steinberger
73dd758310 fix(memory): abort orphaned qmd search processes 2026-06-23 13:31:46 -07:00
Alix-007
eadd69b44c fix(memory): abort orphaned qmd subprocess on the mcporter search path too
The initial fix threaded the abort signal through the direct qmd
(runQmd/runQmdSearch) path, but the mcporter / QMD 1.1+ daemon search path
(runQmdSearchViaMcporter, runMcporterAcrossCollections) never received it, so
a grouped/mcporter search left its subprocess running on abort.

Thread the search signal through QmdMcporterSearchParams,
QmdMcporterAcrossCollectionsParams, all four mcporter call sites in search(),
and runMcporter, down to the shared runCliCommand spawn (which already
SIGKILLs the child on abort). Guard runQmdSearchViaMcporter on an
already-aborted signal so the multi-collection loop stops spawning. Reuses the
existing abort mechanism; no new machinery. Adds mcporter-path regression tests.
2026-06-23 13:31:46 -07:00
Alix-007
2a021f3b9b fix(memory): thread qmd search abort signal through grouped collection search
memory_search timeout cancellation only reached single-group direct qmd
searches. Multi-collection or mixed memory/session configs route through
runQueryAcrossCollectionGroups, which still called runQmdSearch without the
caller signal, so an aborted memory_search left the grouped qmd child running
until the qmd command timeout instead of being killed promptly.

Thread searchSignal through the grouped search path and its unsupported-option
fallback, and add a grouped multi-collection abort regression asserting the
spawned qmd child is SIGKILLed when the caller signal aborts.
2026-06-23 13:31:46 -07:00
Alix-007
78184ea7e4 fix(memory): abort orphaned qmd search subprocess when memory_search times out
PR #91742 wired memory_search's 15s deadline AbortSignal through the builtin
memory manager but missed the QMD backend behind the same
MemorySearchManager.search interface. With QMD, the tool returns "timed out
after 15s" to the agent while the spawned qmd query/search subprocess keeps
running for the full qmd command timeout (memory.qmd.limits.timeoutMs, whose
embed-heavy default was raised to 600s in #87572), leaving orphaned
embedding/search work running after the agent already moved on.

Add optional AbortSignal support to runCliCommand: an aborting signal kills the
spawned child immediately and rejects with the abort reason, funneled through a
single settle() guard so abort/timeout/error/close cannot double-settle. Thread
the search signal through QmdMemoryManager.search -> runQmdSearch -> runQmd ->
runCliCommand for the default direct-qmd subprocess path (including the query
fallback), and fast-fail search() when the signal is already aborted.
2026-06-23 13:31:46 -07:00
Peter Steinberger
21c8cf9889 fix(matrix): bound SDK response bodies 2026-06-23 13:30:55 -07:00
Alix-007
4e99ec6224 fix(matrix): use JSON-specific idle-timeout diagnostic on bounded JSON read
The non-raw JSON read in performMatrixRequest fell back to the bound
reader's default media idle-timeout message ('Matrix media download
stalled: ...'), which is misleading for a JSON control-plane read. Pass
a JSON-specific onIdleTimeout so a stalled JSON stream now rejects with
'Matrix JSON response stalled: no data received for {ms}ms', letting the
timeout diagnostic distinguish a stalled JSON read from a stalled
raw/media read. Update the regression assertion accordingly.
2026-06-23 13:30:55 -07:00
Alix-007
a36d29c347 fix(matrix): bound non-raw JSON response body in transport 2026-06-23 13:30:55 -07:00
Peter Steinberger
cf67d8dded fix(infra): preserve ClawHub body timeouts 2026-06-23 13:23:46 -07:00
Alix-007
31f1ce1af6 fix(infra): cap ClawHub install-resolution JSON via shared bounded reader
The install-resolution path (fetchClawHubSkillInstallResolution) still read
ClawHub JSON with an unbounded response.json(), the one ClawHub JSON reader
left uncapped by the prior hardening. Route it through the existing
parseClawHubJsonBody helper so every ClawHub JSON success/structured-block
body is bounded by the same 16 MiB cap and cancels the stream on overflow.
Pure reuse of the helper introduced in this PR (no new abstraction); adds a
regression test that an oversized install-resolution body is rejected and the
underlying stream is cancelled.
2026-06-23 13:23:46 -07:00
Alix-007
48853df18c fix(infra): bound ClawHub fetchJson and error response bodies
ClawHub is an external marketplace (untrusted source); fetchJson read the
success body via response.json() and readErrorBody read the error body via
response.text(), both without a byte cap, so a hostile or malfunctioning host
could exhaust memory with an unbounded response. Read both through the existing
read-response-with-limit helpers (16 MiB cap for JSON, 8 KiB / 400 chars for the
error snippet), cancelling the stream on overflow/idle. Symmetric counterpart to
the Anthropic error-stream hardening in #95108.
2026-06-23 13:23:46 -07:00
kklouzal
3a93d7fd68 test(cli): isolate service env in run and update suites 2026-06-23 13:21:29 -07:00
joshavant
fcedd37067 test(ios): guard local network permission trigger points 2026-06-23 14:48:33 -05:00
joshavant
8bddafba65 fix(ios): defer local network discovery until onboarding 2026-06-23 14:48:33 -05:00
Josh Lehman
c24d266b2d refactor: use accessor-backed transcript corpus for memory (#96162)
* refactor: ratchet memory transcript corpus access

* test: use narrow runtime config snapshot import

* test: update plugin sdk surface budgets

* refactor: split memory transcript corpus module
2026-06-23 12:37:44 -07:00
joshavant
9405b8f075 chore(android): prepare 2026.6.9 Play release 2026-06-23 14:23:52 -05:00
Patrick Erichsen
0feffda3fc fix(plugins): remove simpleicons icon color paths (#95987) 2026-06-23 12:16:02 -07:00
Patrick Erichsen
6343e1483f fix(skills): accept owner-qualified verify refs (#95992)
Merged via squash.

Prepared head SHA: de9f1e566e
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>
Reviewed-by: @Patrick-Erichsen
2026-06-23 12:06:56 -07:00
Vincent Koc
59713194fc fix(ci): avoid relinking identical node tools 2026-06-24 02:43:25 +08:00
Vincent Koc
f1c8cda090 chore(plugin-sdk): refresh API baseline hash 2026-06-24 02:29:59 +08:00
Jamil Zakirov
a86ca4f4ba perf(gateway): drop redundant per-access session-key case scan (#95699)
Merged via squash.

Prepared head SHA: 42c922460a
Co-authored-by: jzakirov <15848838+jzakirov@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-23 11:29:05 -07:00
Vincent Koc
3bed73f249 test(docs): skip i18n Go tests without toolchain 2026-06-24 01:54:22 +08:00
Josh Lehman
0dfa22c6e0 refactor: add embedded run session target seam (#90439) 2026-06-23 10:08:29 -07:00
Vincent Koc
6f80552ee9 fix(qa): prove direct reply routing via qa channel 2026-06-24 00:41:28 +08:00
Josh Lehman
258b83c438 refactor: migrate plugin transcript mirrors (#89518) 2026-06-23 09:32:45 -07:00
Vincent Koc
d095d98a02 fix(qa): reject duplicate rpc rtt controls 2026-06-23 18:00:53 +02:00
Vincent Koc
9ff7abc898 test(ci): read sparse android guard files from git 2026-06-23 23:50:51 +08:00
Vincent Koc
dc9c11be91 fix(qa): reject duplicate plugin gauntlet controls 2026-06-23 17:37:53 +02:00
Vincent Koc
58552f6d7c ci: make release maturity scorecard opt-in 2026-06-23 23:32:45 +08:00
Vincent Koc
b8811b7dde fix(qa): reject duplicate gateway cpu controls 2026-06-23 17:30:37 +02:00
Vincent Koc
0850d83de1 fix(qa): reject duplicate model resolution perf controls 2026-06-23 17:24:41 +02:00
Jesse Merhi
92c10d4edc Fix WebChat dispatch failure session status (#84352)
Merged via squash.

Prepared head SHA: 562f2ac5a8
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
Reviewed-by: @jesse-merhi
2026-06-24 01:19:21 +10:00
Vincent Koc
b22ae2a4da fix(qa): reject duplicate model bench controls 2026-06-23 17:18:47 +02:00
Vincent Koc
a822c9abaa fix(qa): reject duplicate gateway restart controls 2026-06-23 17:13:39 +02:00
Vincent Koc
c308295cd3 fix(qa): reject duplicate gateway startup controls 2026-06-23 17:08:08 +02:00
Vincent Koc
524e19726f fix(qa): reject duplicate cli bench controls 2026-06-23 17:01:42 +02:00
Vincent Koc
bc243568e7 chore(acpx): bump bundled client to 0.11.2 (#96124) 2026-06-23 22:54:51 +08:00
Vincent Koc
2cbb4e70cc fix(qa): reject duplicate telegram proof controls 2026-06-23 16:54:31 +02:00
Vincent Koc
e9b017d9dc fix(qa): reject duplicate abort leak controls 2026-06-23 16:46:39 +02:00
Vincent Koc
bde5be874a fix(qa): reject duplicate sqlite bench controls 2026-06-23 16:41:01 +02:00
Vincent Koc
8c09419f20 fix(qa): reject unknown docker timing options 2026-06-23 16:26:42 +02:00
Tony Wei
71f84f910a fix(acpx): detect wrapper orphan on any PPID change, not just init reparenting (#96032)
* fix(acpx): detect wrapper orphan on any PPID change, not just init reparenting

The codex / claude adapter wrapper's orphan watcher (emitted by
buildAdapterWrapperScript) skipped cleanup when `process.ppid !== 1`,
intending to wait for the kernel to reparent the orphaned wrapper to
PID 1 (init). This only works on bare-metal hosts without an active
user-session manager.

On systemd-managed deployments (EC2 user services, most container
runtimes), an orphaned process is reparented to the user-session
manager or container init — not to init itself. The watcher therefore
never fires, and when the gateway exits, the adapter wrapper survives
and holds its child process group (codex-acp.js + native binary)
running indefinitely.

Real-world symptom: each gateway restart accumulates 3-process trees of
leftover codex adapters. Subsequent ACP spawns then contend with these
orphans, the main event loop is starved by acpx-runtime reap attempts,
and new sessions stall at "waiting for tool execution" for minutes.

Fix: trigger orphan cleanup as soon as PPID changes from the recorded
original, regardless of what the new PPID is. The killChildTree path
already covers process-group cleanup via `kill(-pid, SIGTERM)`, so
once the watcher fires, grandchildren are reaped correctly.

Adds a regression test asserting the wrapper template does not
re-introduce the `process.ppid !== 1` guard.

* test: document maturity ref handoff

---------

Co-authored-by: t2wei <t2wei@me.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-23 22:24:29 +08:00
Vincent Koc
8e6624cb6c fix(qa): reject duplicate sibling bench cases 2026-06-23 16:20:46 +02:00
Vincent Koc
273eed4c51 fix(harness): recover Copilot native subagent tasks 2026-06-23 22:13:59 +08:00
Vincent Koc
0bc5fb86a8 feat(copilot): mirror native plan and subagent events 2026-06-23 22:13:59 +08:00
Vincent Koc
7bde374c47 fix(qa): reject duplicate startup bench cases 2026-06-23 16:11:44 +02:00
Vincent Koc
fa263affd5 test(extensions): use real chutes response mocks 2026-06-23 22:00:32 +08:00
Vincent Koc
fa0427347a test(extensions): use real provider response mocks 2026-06-23 22:00:32 +08:00
Vincent Koc
aad78d399c test(extensions): use real response mocks 2026-06-23 22:00:32 +08:00
Vincent Koc
928607ac4a fix(qa): reject missing memory fd args 2026-06-23 15:58:33 +02:00
Vincent Koc
010c7f7110 fix(qa): disable pnpm verify in cpu scenarios 2026-06-23 15:50:39 +02:00
Vincent Koc
69891cf2ac fix(maint): protect pending hosted CI reruns 2026-06-23 21:49:36 +08:00
Vincent Koc
541f9b25d2 fix(maint): choose latest hosted CI run 2026-06-23 21:49:36 +08:00
Vincent Koc
c045fbf8ec fix(maint): use rebase PR landing 2026-06-23 21:49:36 +08:00
Shakker
e63d11ea24 test: scope transcript reader env setup 2026-06-23 14:46:20 +01:00
Shakker
fed369085f fix: restore task state env through helper 2026-06-23 14:36:55 +01:00
Shakker
6834a2d47b test: scope send state env helper 2026-06-23 14:33:32 +01:00
Vincent Koc
52251261ca fix(qa): reject duplicate gauntlet selectors 2026-06-23 15:32:12 +02:00
Shakker
e94deea4f2 fix: simplify Fly Machine env cleanup 2026-06-23 14:27:02 +01:00
Shakker
b827629418 test: route network runtime env setup 2026-06-23 14:25:43 +01:00
Vincent Koc
02556f9caf fix(qa): reject polluted Tool Search proof lanes 2026-06-23 15:25:27 +02:00
Vincent Koc
3f2b205dde fix(qa): require MCP API list evidence 2026-06-23 15:20:29 +02:00
Vincent Koc
3d2c52c935 fix(qa): reject duplicate RPC RTT methods 2026-06-23 15:14:40 +02:00
Shakker
e11539234b fix: route shared auth secret env writes 2026-06-23 14:10:02 +01:00
Vincent Koc
720e295cff fix(qa): require Telegram proof report before publish 2026-06-23 15:09:04 +02:00
Shakker
20d1dc8f0a test: route shared token reload env writes 2026-06-23 14:02:24 +01:00
Vincent Koc
d3ac8e3caa fix(qa): reject duplicate Parallels platforms 2026-06-23 15:01:33 +02:00
Shakker
93cfd59dd6 fix: clear config secret refs through env helper 2026-06-23 13:55:23 +01:00
Vincent Koc
5078ffdeb4 fix(qa): reject duplicate gateway smoke options 2026-06-23 14:52:37 +02:00
Josh Lehman
475252453b refactor: add transcript update identity contract (#89912) 2026-06-23 05:52:08 -07:00
Vincent Koc
d38fb7456a fix(qa): reject duplicate otel smoke options 2026-06-23 14:46:43 +02:00
Vincent Koc
08f8de3aee fix(qa): reject duplicate ux evidence options 2026-06-23 14:41:32 +02:00
Vincent Koc
a02a8cca79 fix(qa): reject duplicate hosted gate options 2026-06-23 14:35:37 +02:00
Vincent Koc
c638f2beda fix(release): reject duplicate candidate checklist options 2026-06-23 14:30:29 +02:00
Vincent Koc
34d2d54d6c fix(plugin-sdk): refresh api baseline hash 2026-06-23 20:27:52 +08:00
Vincent Koc
7cc0879d0e fix(qa): reject duplicate docker package options 2026-06-23 14:24:41 +02:00
Vincent Koc
2af06042c2 fix(qa): reject duplicate package candidate options 2026-06-23 14:19:42 +02:00
Vincent Koc
8cda4399d0 fix(qa): reject duplicate dependency evidence options 2026-06-23 14:14:43 +02:00
Vincent Koc
3d6127f7e4 fix(qa): reject duplicate test report controls 2026-06-23 14:06:41 +02:00
Vincent Koc
72816124c9 fix(qa): reject duplicate single-value flags 2026-06-23 14:01:29 +02:00
Vincent Koc
0e091482a3 fix(qa): reject ambiguous dependency report inputs 2026-06-23 13:43:44 +02:00
Vincent Koc
d51582a936 fix(qa): reject duplicate report artifacts 2026-06-23 13:38:40 +02:00
Vincent Koc
7374ecc777 fix(qa): reject duplicate qa e2e outputs 2026-06-23 13:34:20 +02:00
Vincent Koc
e856a24754 fix(qa): bound docker e2e log replay 2026-06-23 13:26:09 +02:00
Vincent Koc
9dbdefd43c fix(ci): keep release QA evidence branch-compatible 2026-06-23 19:24:27 +08:00
Vincent Koc
0177521375 fix(ci): pass resolved ref to maturity QA evidence 2026-06-23 19:18:13 +08:00
Vincent Koc
c714bfd8b6 fix(ci): allow release QA evidence workflow calls 2026-06-23 19:06:49 +08:00
Vincent Koc
d980f2555a fix(qa): preserve active mac restart locks 2026-06-23 13:02:39 +02:00
Vincent Koc
300b09b33f fix(acpx): consume acpx 0.11.1 model capability errors
* fix(acpx): consume acpx 0.11.1 model capability errors

* fix(acpx): refresh npm shrinkwrap for 0.11.1

* test: include workflow checks in tooling plan
2026-06-23 18:55:46 +08:00
Vincent Koc
2429585046 fix(qa): isolate docker rerun artifact downloads 2026-06-23 12:55:05 +02:00
Vincent Koc
a972855150 fix(qa): avoid extension memory report collisions 2026-06-23 12:48:16 +02:00
Vincent Koc
306f0ec37f test(ci): update tooling route expectation 2026-06-23 18:40:54 +08:00
Vincent Koc
cb6b15f782 fix(qa): avoid vitest report path collisions 2026-06-23 12:40:25 +02:00
Vincent Koc
44d77de0c5 fix(qa): isolate parallels plugin temp script 2026-06-23 12:34:05 +02:00
Vincent Koc
bd9f2a5e2e fix(ci): refresh dependency audit locks 2026-06-23 18:28:29 +08:00
Vincent Koc
b3b210b706 fix(qa): allow web search smoke gateway port override 2026-06-23 12:26:07 +02:00
Vincent Koc
536b437454 fix(docs): keep maturity taxonomy renderer formatted 2026-06-23 18:10:26 +08:00
ooiuuii
dd055c4f7c fix: npm plugin updates break running gateway imports (#95589)
Merged via squash.

Prepared head SHA: 74ecbbbb98
Co-authored-by: ooiuuii <169449607+ooiuuii@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 18:07:57 +08:00
Vincent Koc
f484bf9985 fix(qa): avoid plugin update registry port collisions 2026-06-23 12:01:34 +02:00
Vincent Koc
04575a97b6 fix(qa): avoid telegram proof artifact collisions 2026-06-23 11:57:34 +02:00
Vincent Koc
318f95417a docs: place maturity pages under release reference (#96061)
Merged via squash.

Prepared head SHA: 7ab898262e
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-23 17:55:58 +08:00
Vincent Koc
1aad7d4e50 fix(ci): repair maturity docs checks 2026-06-23 17:50:47 +08:00
Vincent Koc
c313642ae2 fix(qa-lab): use scoped crabline package 2026-06-23 17:47:07 +08:00
Vincent Koc
932b58b94b fix(qa): avoid matrix qa artifact collisions 2026-06-23 11:45:29 +02:00
Vincent Koc
d37300f357 fix(anthropic): narrow stream block index guard 2026-06-23 17:45:15 +08:00
Vincent Koc
9e63323388 perf(anthropic): index active stream blocks 2026-06-23 17:45:15 +08:00
Vincent Koc
00f8b10567 perf(usage): bound session log retention 2026-06-23 17:42:28 +08:00
Vincent Koc
4dac8f47ed perf(agents): index displaced tool results 2026-06-23 17:40:21 +08:00
Vincent Koc
9089a8ab32 docs: redesign maturity scorecard pages (#96057)
Merged via squash.

Prepared head SHA: d2c680a48e
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-23 17:37:58 +08:00
Vincent Koc
67b26126ce fix(qa): avoid direct smoke artifact collisions 2026-06-23 11:29:24 +02:00
Vincent Koc
307300ac97 fix(ci): report missing workflow pre-commit runtime 2026-06-23 17:27:32 +08:00
Dallin Romney
7e0083ce0b ci: add release QA profile evidence (#95094)
* ci: add release qa profile evidence

* ci: simplify release qa profile evidence

* ci: reuse qa profile evidence workflow

* ci: remove inherited secrets lint comment

* ci: pass qa profile evidence secret explicitly

* ci: run maturity scorecard in release checks

* ci: declare maturity scorecard reusable secret
2026-06-23 02:27:00 -07:00
maweibin
740578b596 fix: assistant reply lost between compaction summary and first kept user in successor transcript (#95484)
Merged via squash.

Prepared head SHA: eff5894fb8
Co-authored-by: maweibin <18023423+maweibin@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 17:25:45 +08:00
Vincent Koc
0a986f893a fix(daemon): type Windows task env fixture 2026-06-23 17:21:05 +08:00
Sliverp
9535b102d3 Gate private QQBot group commands (#92154)
* fix: gate private qqbot group commands

* fix(qqbot): keep authorized stop urgent in groups

* fix(qqbot): preserve omitted group command level

* fix(qqbot): preserve ignore-other-mentions gate

* test(qqbot): avoid unbound mention gate mock

* fix(qqbot): close strict command visibility gaps

* fix(qqbot): gate private group commands and close strict command visibility gaps (#92154) (thanks @sliverp)
2026-06-23 17:20:50 +08:00
Dallin Romney
1ce8eb3993 docs: rename top maturity tier (#96044) 2026-06-23 02:19:45 -07:00
Vincent Koc
f354889efa fix(crabbox): share Windows hydrate handoff path 2026-06-23 11:16:13 +02:00
Vincent Koc
cdf35e83f3 fix(qa): avoid live artifact directory collisions 2026-06-23 11:11:47 +02:00
Vincent Koc
8a8c6b2a27 fix(copilot): preserve compaction metadata 2026-06-23 17:11:24 +08:00
mikasa
f5148aff25 fix #89231: [Bug]: Windows installer-created scheduled task launches gateway.cmd with visible console — should use windowless launcher (#95480)
Merged via squash.

Prepared head SHA: 8b57b0377a
Co-authored-by: mikasa0818 <244515412+mikasa0818@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 17:05:23 +08:00
Alex Knight
7bec91c8d8 fix(mattermost): block-bodied promise executor in participation test (oxlint) 2026-06-23 18:58:38 +10:00
Alex Knight
64c81f25c0 fix(mattermost): record thread participation on preview-finalized replies; document thread mention exception 2026-06-23 18:58:38 +10:00
Alex Knight
13ecb5c55e feat(mattermost): persist participated threads for mention-free follow-ups 2026-06-23 18:58:38 +10:00
Dallin Romney
db212e572e test(qa): gate maturity docs on passing evidence (#96017)
* docs: refresh maturity scorecard evidence

* test(qa): gate maturity docs on passing evidence

* test(qa): ensure UX matrix video dependencies

* test(qa): simplify maturity evidence result text

* test: align maturity docs test routing
2026-06-23 01:58:34 -07:00
Vincent Koc
5738cfb6df fix(qa): avoid default artifact directory collisions 2026-06-23 10:53:46 +02:00
Vincent Koc
33b8b72ad3 fix(qa): avoid self-check report clobbering 2026-06-23 10:46:25 +02:00
Moeed Ahmed
e998986889 fix(auto-reply): keep drain/restart-abort reply paths silent (#95431)
Merged via squash.

Prepared head SHA: edb75a944f
Co-authored-by: moeedahmed <5780040+moeedahmed@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 16:46:04 +08:00
Yuval Dinodia
9549545dd0 fix(reply): suppress per-message finals across multi-message block streaming (#95432)
Merged via squash.

Prepared head SHA: 7d7c61f3d7
Co-authored-by: yetval <102706514+yetval@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 16:44:02 +08:00
Yuval Dinodia
9f0d2427cd fix(agents): keep post-compaction user re-issue of a kept-tail prompt during compaction rotation (#94328)
Merged via squash.

Prepared head SHA: 05981b6c9f
Co-authored-by: yetval <102706514+yetval@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 16:41:12 +08:00
Vincent Koc
a59b2f2958 fix(scripts): catch namespace plugin sdk wildcard exports 2026-06-23 10:35:28 +02:00
Vincent Koc
c061373ede fix(release): track CommonJS package dist imports 2026-06-23 10:31:05 +02:00
Vincent Koc
ea0330963c fix(release): surface installed extension manifest errors 2026-06-23 10:26:10 +02:00
tangtaizong666
43890ebc3b fix(heartbeat): skip reasoning payloads when selecting heartbeat reply (#92356)
Merged via squash.

Prepared head SHA: 5885fbba0c
Co-authored-by: tangtaizong666 <212687958+tangtaizong666@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 16:24:15 +08:00
Dallin Romney
e2bcde9b1c ci: add codex maturity scorecard agent (#95919) 2026-06-23 01:22:21 -07:00
Rohit
695cea68f5 Fix recent session resume with long headers (#94578)
Merged via squash.

Prepared head SHA: 8102961184
Co-authored-by: rohitjavvadi <76606932+rohitjavvadi@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 16:21:15 +08:00
SunnyShu0925
dd76fdceb6 fix(memory-wiki): exclude durable reference pages from stale report (#94369)
Merged via squash.

Prepared head SHA: c2dca7ed9b
Co-authored-by: SunnyShu0925 <265248434+SunnyShu0925@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 16:19:36 +08:00
Dallin Romney
32dc664b4b fix(qa-lab): avoid duplicate child evidence files (#96030) 2026-06-23 01:18:24 -07:00
Vincent Koc
3d8d45fb0d fix(qa): reject out-of-range lab CLI ports 2026-06-23 10:16:45 +02:00
Coder
d63a73a1b8 fix(model-usage): coerce numeric-string costs and ignore non-finite values (#87861)
Merged via squash.

Prepared head SHA: 11bb5719ca
Co-authored-by: coder999999999 <83845889+coder999999999@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 16:16:31 +08:00
Vincent Koc
ca5905eb90 fix(crabbox): reclaim sparse reused leases 2026-06-23 10:09:38 +02:00
Vincent Koc
023394000c fix(release): reject malformed candidate API timeouts 2026-06-23 10:04:15 +02:00
Vincent Koc
a0f93cf88f fix(agents): gate subagent stream suppression 2026-06-23 15:54:57 +08:00
chenhaoqiang
1876e3e1c1 perf: skip per-chunk live parsing for subagents
Subagent runs do not have a live stream consumer; their result is delivered from
the terminal message path after the child run finishes. The intermediate
message_update stream work only feeds live preview output.

Thread suppressLiveStreamOutput from the subagent lane into the embedded runner
subscription and return from handleMessageUpdate after accumulating the raw
chunk. This keeps final delivery unchanged while skipping per-chunk visible text
and reasoning stream parsing for subagents, which reduces event-loop pressure
when multiple child agents stream long answers in parallel.

Interactive and Control UI runs keep the existing live preview path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
(cherry picked from commit e0382c2c58c3eabdf64638777ec82cb1e68514e9)
2026-06-23 15:54:57 +08:00
Jason (Json)
6f63140902 fix: avoid false macOS update failures during gateway shutdown (#95886)
Merged via squash.

Prepared head SHA: 400e87c937
Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
Reviewed-by: @fuller-stack-dev
2026-06-23 01:53:38 -06:00
Vincent Koc
d0f591893b fix(release): validate DMG resize slack 2026-06-23 09:51:38 +02:00
Vincent Koc
da32c7fe53 chore(sdk): update public surface budget 2026-06-23 15:48:27 +08:00
Vincent Koc
d3019e6127 fix(copilot): tighten harness sdk boundaries 2026-06-23 15:48:27 +08:00
Vincent Koc
21d67b168a feat(copilot): wire harness parity helpers 2026-06-23 15:48:27 +08:00
Vincent Koc
2824c02a42 fix(qa): avoid lab artifact directory collisions 2026-06-23 09:39:04 +02:00
youngting520
4c9c6f5116 fix(sessions): honor configured store for transcript mirrors (#95782) 2026-06-23 07:35:36 +00:00
Del
90d4aa7a8e fix(xiaomi): correct mimo-v2.5 and mimo-v2.5-pro max output tokens to 128K (#95934)
* fix(xiaomi): correct mimo-v2.5 and mimo-v2.5-pro max output tokens to 131072

* docs(xiaomi): correct mimo-v2.5 and mimo-v2.5-pro max output tokens to 131072
2026-06-23 07:35:10 +00:00
Yuval Dinodia
f826a665a2 fix(compaction): trim prefix when transcript ends in an oversized tool result (#95860)
findCutPoint defaulted cutIndex to the earliest valid cut (cutPoints[0],
keep everything) and only moved it forward to a cut point at or after the
backward token cursor. When the final entry is a toolResult whose estimate
alone meets keepRecentTokens, the cursor stops at that trailing toolResult
index, no valid cut point sits at or after it (toolResult entries are not
valid cut points), and the default stuck at keep-everything. Compaction then
summarized zero messages, so preflight and overflow compaction silently
no-op and the session loops on a context it cannot shrink.

Default cutIndex to the most recent valid cut before the forward search.
When a cut point exists at or after the cursor the search still finds it and
behavior is unchanged; only the trailing-tool-result case now keeps the
recent tail and summarizes the prefix.
2026-06-23 07:34:33 +00:00
Vincent Koc
add9f3c6d3 fix(test): reject pathological Docker E2E limits 2026-06-23 09:26:54 +02:00
Vincent Koc
603b250125 fix(qa): omit local temp roots from gateway artifacts 2026-06-23 09:18:41 +02:00
joshavant
19ddaa28b9 fix: harden ios screenshot uploads 2026-06-23 02:14:15 -05:00
Dallin Romney
f6b2a5ffb4 test(qa): harden all-profile evidence scenarios (#96003) 2026-06-23 00:07:51 -07:00
Vincent Koc
78a8caef38 fix(release): require postpublish evidence artifact 2026-06-23 14:53:14 +08:00
Chunyue Wang
e0d7776fff fix(context-engine): forward abortSignal through delegation bridge to runtime compaction (#89886)
Merged via squash.

Prepared head SHA: ff5a439d76
Co-authored-by: openperf <80630709+openperf@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 14:41:08 +08:00
joshavant
8efed50c4e fix: gate ios push enrollment on notification consent 2026-06-23 01:38:24 -05:00
Vincent Koc
3e84836b01 fix(ci): require release QA evidence artifacts 2026-06-23 14:30:11 +08:00
mushuiyu886
01abe0a33d fix(agents): suggest recovery for unknown tool ids (#93374)
Merged via squash.

Prepared head SHA: bee84e4eb8
Co-authored-by: mushuiyu886 <266724580+mushuiyu886@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 14:20:09 +08:00
Sunjae Kim
f0a2ba0584 Fix Gemini day freshness time range handling (#95682)
Merged via squash.

Prepared head SHA: f6038b3a33
Co-authored-by: Sunjae-k <52808029+Sunjae-k@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 14:19:25 +08:00
Vincent Koc
f24b1a9c0c test(qa): relax heartbeat target none startup probe 2026-06-23 08:18:55 +02:00
Eden Kang
7c60379589 CLI: escape zsh completion descriptions (#64490)
* CLI: escape zsh completion descriptions

* Update src/cli/completion-cli.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* CLI: use parser-safe zsh completion escaping

* CLI: escape zsh completion descriptions

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-23 14:12:27 +08:00
Vincent Koc
0fed6402be fix(ci): require OpenGrep SARIF artifacts 2026-06-23 14:08:20 +08:00
Vincent Koc
a13e2b92b3 perf(ci): widen main test fanout and move codeql off blacksmith (#95967)
* perf(ci): widen main test fanout and move codeql off blacksmith

* test(ci): update fanout guard
2026-06-23 13:56:29 +08:00
Alix-007
e583e62190 fix(cron): normalize run-log jobId on write to match read-side validation (#93567)
Merged via squash.

Prepared head SHA: ee41f84b53
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 13:55:57 +08:00
Vincent Koc
fe5c098fd7 test(ios): remove host zip dependency from IPA validator fixture 2026-06-23 13:54:42 +08:00
Vincent Koc
28a5b0a212 fix(canvas): guard native A2UI resources 2026-06-23 13:44:14 +08:00
Vincent Koc
53f9b6a36b test(qa): align release memory scenario assertions 2026-06-23 07:43:06 +02:00
joshavant
eae53595b0 fix: unblock ios release upload metadata 2026-06-23 00:39:45 -05:00
Andy Ye
ca2f4c0d67 Warn on generated wrapper overwrites and status diagnostics (#90537)
Merged via squash.

Prepared head SHA: c6b6589e6d
Co-authored-by: TurboTheTurtle <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 13:39:20 +08:00
Patrick Erichsen
f66e83154b docs: update ClawHub skill route references
Update OpenClaw ClawHub docs and user-facing copy for canonical owner-qualified skill routes.\n\nEvidence:\n- pnpm docs:list\n- pnpm test src/plugins/clawhub.test.ts src/cli/plugins-cli.install.test.ts src/gateway/server-methods/skills.clawhub.test.ts ui/src/ui/views/skills.test.ts\n- pnpm exec oxfmt --check --threads=1 docs/clawhub/cli.md docs/clawhub/publishing.md docs/cli/skills.md docs/help/faq.md docs/start/showcase.md docs/tools/creating-skills.md docs/tools/skills.md src/gateway/server-methods/skills.clawhub.test.ts src/plugins/clawhub.test.ts src/plugins/clawhub.ts ui/src/ui/views/skills.test.ts\n- git diff --check\n- exact-head hosted CI passed for 8530374388d8a73235b2ac8444b95a4a4c7d0f1c\n\nNote: repo-native scripts/pr prepare-run was attempted; local broad pnpm test was stopped after unrelated existing failures in agent/media/provider shards, while hosted exact-head CI and targeted ClawHub route/copy validation were green.
2026-06-22 22:27:57 -07:00
581 changed files with 31793 additions and 5907 deletions

View File

@@ -146,7 +146,7 @@ Default guidance:
Default Completeness bands:
- `Lovable` (95-100): complete across expected workflows, variants, and
- `Clawesome` (95-100): complete across expected workflows, variants, and
recovery branches, with only minor polish gaps.
- `Stable` (80-95): the expected workflow set is broadly present, with only
bounded missing branches.
@@ -172,7 +172,7 @@ Default Completeness bands:
Bands:
- `Lovable`: 95-100
- `Clawesome`: 95-100
- `Stable`: 80-95
- `Beta`: 70-80
- `Alpha`: 50-70

View File

@@ -0,0 +1,23 @@
# OpenClaw Maturity Scorecard Agent
You are refreshing the OpenClaw maturity score source for a release scorecard.
Goal: use the `$claw-score` skill to refresh `qa/maturity-scores.yaml` for every active surface in `taxonomy.yaml`, using the current repository and the release evidence artifacts in `.artifacts/maturity-evidence`.
Allowed tracked paths:
- `qa/maturity-scores.yaml`
Hard limits:
- Do not edit generated docs, taxonomy, workflows, scripts, package metadata, lockfiles, tests, or application code.
- Do not render docs. The workflow renders docs after validating the score source.
- Keep the score source schema valid for QA Lab maturity score validation.
Required workflow:
1. Use the `$claw-score` skill before editing.
2. Read `taxonomy.yaml`, any existing maturity score file, and the release evidence artifacts.
3. Refresh scores for every active surface in `taxonomy.yaml`.
4. Run the QA Lab maturity score validation used by this repository.
5. If no defensible score update is possible, leave a valid `qa/maturity-scores.yaml` and explain the uncertainty in the final message.

View File

@@ -198,10 +198,19 @@ jobs:
"+refs/heads/main:refs/remotes/origin/main"
node_bin="$(dirname "$(node -p 'process.execPath')")"
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
link_node_tool() {
local tool="$1"
local source="$node_bin/$tool"
local target="/usr/local/bin/$tool"
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
return
fi
sudo ln -sf "$source" "$target"
}
link_node_tool node
link_node_tool npm
link_node_tool npx
link_node_tool corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"

View File

@@ -116,10 +116,19 @@ jobs:
"+refs/heads/main:refs/remotes/origin/main"
node_bin="$(dirname "$(node -p 'process.execPath')")"
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
link_node_tool() {
local tool="$1"
local source="$node_bin/$tool"
local target="/usr/local/bin/$tool"
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
return
fi
sudo ln -sf "$source" "$target"
}
link_node_tool node
link_node_tool npm
link_node_tool npx
link_node_tool corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"

View File

@@ -105,10 +105,19 @@ jobs:
"+refs/heads/main:refs/remotes/origin/main"
node_bin="$(dirname "$(node -p 'process.execPath')")"
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
link_node_tool() {
local tool="$1"
local source="$node_bin/$tool"
local target="/usr/local/bin/$tool"
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
return
fi
sudo ln -sf "$source" "$target"
}
link_node_tool node
link_node_tool npm
link_node_tool npx
link_node_tool corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"

View File

@@ -100,6 +100,7 @@ jobs:
run_macos_node: ${{ steps.manifest.outputs.run_macos_node }}
macos_node_matrix: ${{ steps.manifest.outputs.macos_node_matrix }}
run_macos_swift: ${{ steps.manifest.outputs.run_macos_swift }}
run_ios_build: ${{ steps.manifest.outputs.run_ios_build }}
run_android_job: ${{ steps.manifest.outputs.run_android_job }}
android_matrix: ${{ steps.manifest.outputs.android_matrix }}
steps:
@@ -204,6 +205,7 @@ jobs:
OPENCLAW_CI_DOCS_CHANGED: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.docs_scope.outputs.docs_changed }}
OPENCLAW_CI_RUN_NODE: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_node || 'false' }}
OPENCLAW_CI_RUN_MACOS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_macos || 'false' }}
OPENCLAW_CI_RUN_IOS_BUILD: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_ios_build || 'false' }}
OPENCLAW_CI_RUN_ANDROID: ${{ github.event_name == 'workflow_dispatch' && (inputs.release_gate || inputs.include_android) && 'true' || steps.changed_scope.outputs.run_android || 'false' }}
OPENCLAW_CI_RUN_WINDOWS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_windows || 'false' }}
OPENCLAW_CI_RUN_NODE_FAST_ONLY: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_only || 'false' }}
@@ -267,6 +269,8 @@ jobs:
const runPluginContractShards = runNodeFull || runNodeFastPluginContracts;
const runMacos =
parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly && isCanonicalRepository;
const runIosBuild =
parseBoolean(process.env.OPENCLAW_CI_RUN_IOS_BUILD) && !docsOnly && isCanonicalRepository;
const runAndroid =
parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly && isCanonicalRepository;
const runWindows =
@@ -361,6 +365,7 @@ jobs:
runMacos ? [{ check_name: "macos-node", runtime: "node", task: "test" }] : [],
),
run_macos_swift: runMacos,
run_ios_build: runIosBuild,
run_android_job: runAndroid,
android_matrix: createMatrix(
runAndroid
@@ -1177,7 +1182,9 @@ jobs:
timeout-minutes: ${{ matrix.timeout_minutes || 60 }}
strategy:
fail-fast: false
max-parallel: 12
# The canonical main path waits for the admission debounce above, so
# modestly widen this large matrix without recreating registration bursts.
max-parallel: 16
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
steps:
- name: Checkout
@@ -2160,6 +2167,76 @@ jobs:
done
exit 1
ios-build:
permissions:
contents: read
name: "ios-build"
needs: [preflight]
if: needs.preflight.outputs.run_ios_build == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
timeout-minutes: 45
steps:
- name: Checkout
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
run: |
set -euo pipefail
git init "$GITHUB_WORKSPACE"
git -C "$GITHUB_WORKSPACE" config gc.auto 0
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
fetch_timeout_seconds=90
fetch_checkout_ref() {
git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" &
local fetch_pid="$!"
local elapsed=0
while kill -0 "$fetch_pid" 2>/dev/null; do
if [ "$elapsed" -ge "$fetch_timeout_seconds" ]; then
kill -TERM "$fetch_pid" 2>/dev/null || true
sleep 10
kill -KILL "$fetch_pid" 2>/dev/null || true
wait "$fetch_pid" || true
return 124
fi
sleep 1
elapsed=$((elapsed + 1))
done
wait "$fetch_pid"
}
fetch_checkout_ref
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Select Xcode 26
run: |
set -euo pipefail
for xcode_app in /Applications/Xcode_26.5.app /Applications/Xcode-26.5.0.app; do
if [ -d "$xcode_app/Contents/Developer" ]; then
sudo xcode-select -s "$xcode_app/Contents/Developer"
break
fi
done
xcodebuild -version
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
if [[ "$xcode_version" != 26.* ]]; then
echo "error: expected Xcode 26.x, got $xcode_version" >&2
exit 1
fi
swift --version
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Install iOS Swift tooling
run: brew install xcodegen swiftlint swiftformat
- name: Build iOS app
run: pnpm ios:build
android:
permissions:
contents: read
@@ -2311,6 +2388,7 @@ jobs:
- checks-windows
- macos-node
- macos-swift
- ios-build
- android
if: ${{ !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ubuntu-24.04

View File

@@ -22,12 +22,6 @@ on:
push:
branches:
- main
paths:
- ".github/actions/**"
- ".github/codeql/**"
- ".github/workflows/**"
- "packages/**"
- "src/**"
schedule:
- cron: "0 6 * * *"
@@ -55,32 +49,32 @@ jobs:
include:
- language: javascript-typescript
category: core-auth-secrets
runs_on: blacksmith-8vcpu-ubuntu-2404
runs_on: ubuntu-24.04
timeout_minutes: 25
config_file: ./.github/codeql/codeql-core-auth-secrets-critical-security.yml
- language: javascript-typescript
category: channel-runtime-boundary
runs_on: blacksmith-8vcpu-ubuntu-2404
runs_on: ubuntu-24.04
timeout_minutes: 25
config_file: ./.github/codeql/codeql-channel-runtime-boundary-critical-security.yml
- language: javascript-typescript
category: network-ssrf-boundary
runs_on: blacksmith-4vcpu-ubuntu-2404
runs_on: ubuntu-24.04
timeout_minutes: 25
config_file: ./.github/codeql/codeql-network-ssrf-boundary-critical-security.yml
- language: javascript-typescript
category: mcp-process-tool-boundary
runs_on: blacksmith-4vcpu-ubuntu-2404
runs_on: ubuntu-24.04
timeout_minutes: 25
config_file: ./.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml
- language: javascript-typescript
category: plugin-trust-boundary
runs_on: blacksmith-4vcpu-ubuntu-2404
runs_on: ubuntu-24.04
timeout_minutes: 25
config_file: ./.github/codeql/codeql-plugin-trust-boundary-critical-security.yml
- language: actions
category: actions
runs_on: blacksmith-8vcpu-ubuntu-2404
runs_on: ubuntu-24.04
timeout_minutes: 10
config_file: ./.github/codeql/codeql-actions-critical-security.yml
steps:

View File

@@ -171,10 +171,19 @@ jobs:
set -euo pipefail
node_bin="$(dirname "$(node -p 'process.execPath')")"
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
link_node_tool() {
local tool="$1"
local source="$node_bin/$tool"
local target="/usr/local/bin/$tool"
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
return
fi
sudo ln -sf "$source" "$target"
}
link_node_tool node
link_node_tool npm
link_node_tool npx
link_node_tool corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"
@@ -490,7 +499,7 @@ jobs:
if (-not $env:CRABBOX_ID -or $env:CRABBOX_ID -notmatch '^[A-Za-z0-9._-]+$') {
Write-Error "Invalid crabbox_id"
}
$actionsRoot = Join-Path $HOME ".crabbox\actions"
$actionsRoot = "C:\ProgramData\crabbox\actions"
New-Item -ItemType Directory -Force $actionsRoot | Out-Null
$state = Join-Path $actionsRoot "$env:CRABBOX_ID.env"
$envFile = Join-Path $actionsRoot "$env:CRABBOX_ID.env.ps1"
@@ -546,7 +555,7 @@ jobs:
if ($env:CRABBOX_KEEP_ALIVE_MINUTES -match '^[0-9]+$') {
$minutes = [int]$env:CRABBOX_KEEP_ALIVE_MINUTES
}
$stop = Join-Path $HOME ".crabbox\actions\$env:CRABBOX_ID.stop"
$stop = Join-Path "C:\ProgramData\crabbox\actions" "$env:CRABBOX_ID.stop"
$deadline = (Get-Date).AddMinutes($minutes)
while ((Get-Date) -lt $deadline) {
if (Test-Path $stop) {
@@ -584,10 +593,19 @@ jobs:
fi
node_bin="$(dirname "$(node -p 'process.execPath')")"
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
link_node_tool() {
local tool="$1"
local source="$node_bin/$tool"
local target="/usr/local/bin/$tool"
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
return
fi
sudo ln -sf "$source" "$target"
}
link_node_tool node
link_node_tool npm
link_node_tool npx
link_node_tool corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"

View File

@@ -12,6 +12,40 @@ on:
required: true
default: main
type: string
expected_sha:
description: Optional full SHA that ref must resolve to
required: false
default: ""
type: string
workflow_call:
inputs:
qa_evidence_run_id:
description: Optional workflow run id containing qa-evidence.json
required: false
default: ""
type: string
ref:
description: OpenClaw branch, tag, or SHA containing the maturity score source
required: true
type: string
expected_sha:
description: Optional full SHA that ref must resolve to
required: false
default: ""
type: string
secrets:
OPENAI_API_KEY:
description: OpenAI API key used by live QA profile scenarios
required: true
OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY:
description: Optional OpenAI API key used by maturity scorecard agent steps
required: false
GH_APP_PRIVATE_KEY:
description: Optional GitHub App private key for generated docs PR creation
required: false
GH_APP_PRIVATE_KEY_FALLBACK:
description: Optional fallback GitHub App private key for generated docs PR creation
required: false
permissions:
actions: read
@@ -43,14 +77,25 @@ jobs:
- name: Validate selected ref
id: validate
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
INPUT_REF: ${{ inputs.ref }}
shell: bash
run: |
set -euo pipefail
selected_revision="$(git rev-parse HEAD)"
expected_sha="${EXPECTED_SHA,,}"
trusted_reason=""
if [[ -n "${expected_sha// }" && ! "$expected_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "expected_sha must be a full 40-character SHA; got: ${EXPECTED_SHA}" >&2
exit 1
fi
if [[ -n "${expected_sha// }" && "${selected_revision,,}" != "$expected_sha" ]]; then
echo "Ref '${INPUT_REF}' resolved to ${selected_revision}, expected ${EXPECTED_SHA}." >&2
exit 1
fi
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
if git merge-base --is-ancestor "$selected_revision" refs/remotes/origin/main; then
@@ -87,8 +132,9 @@ jobs:
if: ${{ inputs.qa_evidence_run_id == '' }}
uses: ./.github/workflows/qa-profile-evidence.yml
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
qa_profile: all
ref: ${{ inputs.ref }}
expected_sha: ${{ needs.validate_selected_ref.outputs.selected_revision }}
qa_profile: release
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -192,8 +238,8 @@ jobs:
}
const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
if (evidence.profile !== "all") {
throw new Error(`qa-evidence.json profile must be all, got ${JSON.stringify(evidence.profile)}`);
if (evidence.profile !== "release") {
throw new Error(`qa-evidence.json profile must be release, got ${JSON.stringify(evidence.profile)}`);
}
const artifactDir = path.dirname(evidencePath);
@@ -210,14 +256,75 @@ jobs:
const manifestPath = path.join(artifactDir, manifestNames[0]);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const manifestProfile = manifest.qaProfile ?? evidence.profile;
if (manifestProfile !== "all") {
throw new Error(`QA evidence manifest profile must be all, got ${JSON.stringify(manifestProfile)}`);
if (manifestProfile !== "release") {
throw new Error(`QA evidence manifest profile must be release, got ${JSON.stringify(manifestProfile)}`);
}
if (manifest.targetSha !== targetSha) {
throw new Error(`QA evidence manifest targetSha ${manifest.targetSha} does not match selected ref ${targetSha}`);
}
NODE
- name: Ensure maturity scorecard agent key exists
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
run: |
set -euo pipefail
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo "Missing OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY or OPENAI_API_KEY secret." >&2
exit 1
fi
- name: Run Codex maturity scorecard agent
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
env:
MATURITY_EVIDENCE_DIR: .artifacts/maturity-evidence
MATURITY_SCORES_PATH: qa/maturity-scores.yaml
MATURITY_TAXONOMY_PATH: taxonomy.yaml
with:
openai-api-key: ${{ secrets.OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
prompt-file: .github/codex/prompts/maturity-scorecard-agent.md
model: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
effort: high
sandbox: workspace-write
safety-strategy: drop-sudo
- name: Enforce focused maturity score patch
run: |
set -euo pipefail
git restore --staged :/
allowed='^qa/maturity-scores\.yaml$'
bad_tracked="$(
git diff --name-only HEAD -- | while IFS= read -r path; do
if [[ ! "$path" =~ $allowed ]]; then
printf '%s\n' "$path"
fi
done
)"
if [[ -n "$bad_tracked" ]]; then
echo "Maturity scorecard agent touched forbidden tracked paths:"
printf '%s\n' "$bad_tracked"
exit 1
fi
bad_untracked="$(
git ls-files --others --exclude-standard | while IFS= read -r path; do
if [[ "$path" != "qa/maturity-scores.yaml" ]]; then
printf '%s\n' "$path"
fi
done
)"
if [[ -n "$bad_untracked" ]]; then
echo "Maturity scorecard agent created forbidden untracked paths:"
printf '%s\n' "$bad_untracked"
exit 1
fi
if [[ ! -f qa/maturity-scores.yaml ]]; then
echo "Maturity scorecard agent must produce qa/maturity-scores.yaml." >&2
exit 1
fi
- name: Validate maturity score sources
run: |
node --import tsx --input-type=module <<'NODE'
@@ -260,6 +367,7 @@ jobs:
--strict-inputs
- name: Create generated docs PR app token
if: ${{ github.event_name == 'workflow_dispatch' }}
id: app-token
continue-on-error: true
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
@@ -270,7 +378,7 @@ jobs:
permission-pull-requests: write
- name: Create generated docs PR fallback app token
if: ${{ steps.app-token.outcome == 'failure' }}
if: ${{ github.event_name == 'workflow_dispatch' && steps.app-token.outcome == 'failure' }}
id: app-token-fallback
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
@@ -280,6 +388,7 @@ jobs:
permission-pull-requests: write
- name: Open generated docs PR
if: ${{ github.event_name == 'workflow_dispatch' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
QA_EVIDENCE_RUN_ID: ${{ inputs.qa_evidence_run_id }}
@@ -291,7 +400,7 @@ jobs:
exit 1
fi
if [[ -z "$(git status --porcelain -- qa/maturity-scores.yaml docs/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md)" ]]; then
if [[ -z "$(git status --porcelain -- qa/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md)" ]]; then
{
echo
echo "- Pull request: skipped; generated scorecard matches selected ref"
@@ -311,9 +420,6 @@ jobs:
git fetch --no-tags --depth=1 origin "refs/heads/${branch}:refs/remotes/origin/${branch}" || true
git switch -C "$branch"
git add qa/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md
if git ls-files --error-unmatch docs/maturity-scores.yaml >/dev/null 2>&1 || [[ -e docs/maturity-scores.yaml ]]; then
git add docs/maturity-scores.yaml
fi
git commit -m "docs: update maturity scorecard"
git push --force-with-lease origin "$branch"

View File

@@ -44,6 +44,11 @@ on:
required: false
default: false
type: boolean
run_maturity_scorecard:
description: Render advisory maturity scorecard release docs; default release checks rely on dedicated package, QA, live, and E2E gates
required: false
default: false
type: boolean
rerun_group:
description: Release check group to run
required: false
@@ -106,6 +111,7 @@ jobs:
mode: ${{ steps.inputs.outputs.mode }}
release_profile: ${{ steps.inputs.outputs.release_profile }}
run_release_soak: ${{ steps.inputs.outputs.run_release_soak }}
run_maturity_scorecard: ${{ steps.inputs.outputs.run_maturity_scorecard }}
rerun_group: ${{ steps.inputs.outputs.rerun_group }}
live_suite_filter: ${{ steps.inputs.outputs.live_suite_filter }}
cross_os_suite_filter: ${{ steps.inputs.outputs.cross_os_suite_filter }}
@@ -279,6 +285,7 @@ jobs:
RELEASE_MODE_INPUT: ${{ inputs.mode }}
RELEASE_PROFILE_INPUT: ${{ inputs.release_profile }}
RELEASE_RUN_RELEASE_SOAK_INPUT: ${{ inputs.run_release_soak }}
RELEASE_RUN_MATURITY_SCORECARD_INPUT: ${{ inputs.run_maturity_scorecard }}
RELEASE_RERUN_GROUP_INPUT: ${{ inputs.rerun_group }}
RELEASE_LIVE_SUITE_FILTER_INPUT: ${{ inputs.live_suite_filter }}
RELEASE_CROSS_OS_SUITE_FILTER_INPUT: ${{ inputs.cross_os_suite_filter }}
@@ -319,6 +326,12 @@ jobs:
else
run_release_soak=true
fi
run_maturity_scorecard="$(printf '%s' "$RELEASE_RUN_MATURITY_SCORECARD_INPUT" | tr '[:upper:]' '[:lower:]')"
if [[ "$run_maturity_scorecard" != "true" && "$run_maturity_scorecard" != "1" && "$run_maturity_scorecard" != "yes" ]]; then
run_maturity_scorecard=false
else
run_maturity_scorecard=true
fi
release_profile="$RELEASE_PROFILE_INPUT"
if [[ "$release_profile" == "minimum" ]]; then
release_profile=beta
@@ -422,6 +435,7 @@ jobs:
printf 'mode=%s\n' "$RELEASE_MODE_INPUT"
printf 'release_profile=%s\n' "$release_profile"
printf 'run_release_soak=%s\n' "$run_release_soak"
printf 'run_maturity_scorecard=%s\n' "$run_maturity_scorecard"
printf 'rerun_group=%s\n' "$RELEASE_RERUN_GROUP_INPUT"
printf 'live_suite_filter=%s\n' "$RELEASE_LIVE_SUITE_FILTER_INPUT"
printf 'cross_os_suite_filter=%s\n' "$RELEASE_CROSS_OS_SUITE_FILTER_INPUT"
@@ -444,6 +458,7 @@ jobs:
RELEASE_MODE: ${{ inputs.mode }}
RELEASE_PROFILE: ${{ steps.inputs.outputs.release_profile }}
RUN_RELEASE_SOAK: ${{ steps.inputs.outputs.run_release_soak }}
RUN_MATURITY_SCORECARD: ${{ steps.inputs.outputs.run_maturity_scorecard }}
RELEASE_RERUN_GROUP: ${{ inputs.rerun_group }}
RELEASE_LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }}
RELEASE_CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }}
@@ -461,6 +476,7 @@ jobs:
echo "- Cross-OS mode: \`${RELEASE_MODE}\`"
echo "- Release profile: \`${RELEASE_PROFILE}\`"
echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`"
echo "- Maturity scorecard docs: \`${RUN_MATURITY_SCORECARD}\`"
echo "- Rerun group: \`${RELEASE_RERUN_GROUP}\`"
if [[ -n "${RELEASE_LIVE_SUITE_FILTER// }" ]]; then
echo "- Live suite filter: \`${RELEASE_LIVE_SUITE_FILTER}\`"
@@ -767,6 +783,20 @@ jobs:
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
maturity_scorecard_release_checks:
name: Render maturity scorecard release docs
needs: [resolve_target]
if: contains(fromJSON('["all","qa"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.run_maturity_scorecard == 'true'
permissions:
actions: read
contents: read
uses: ./.github/workflows/maturity-scorecard.yml
with:
ref: ${{ needs.resolve_target.outputs.ref }}
expected_sha: ${{ needs.resolve_target.outputs.revision }}
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
qa_lab_parity_lane_release_checks:
name: Run QA Lab parity lane (${{ matrix.lane }})
needs: [resolve_target]
@@ -853,7 +883,7 @@ jobs:
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Record advisory status
if: always()
@@ -959,7 +989,7 @@ jobs:
name: release-qa-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Record advisory status
if: always()
@@ -1131,7 +1161,7 @@ jobs:
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Record advisory status
if: always()
@@ -1241,13 +1271,13 @@ jobs:
--output .artifacts/qa-e2e/runtime-parity-standard-report/qa-runtime-tool-coverage-report.md
- name: Upload runtime tool coverage artifacts
if: always()
if: ${{ always() && steps.verify_runtime_parity_status.outputs.ready == 'true' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-qa-runtime-tool-coverage-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/runtime-parity-standard-report/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
qa_live_matrix_release_checks:
name: Run QA Lab live Matrix lane
@@ -1327,7 +1357,7 @@ jobs:
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Record advisory status
if: always()
@@ -1467,7 +1497,7 @@ jobs:
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Record advisory status
if: always()
@@ -1607,7 +1637,7 @@ jobs:
name: release-qa-live-discord-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Record advisory status
if: always()
@@ -1750,7 +1780,7 @@ jobs:
name: release-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Record advisory status
if: always()
@@ -1890,7 +1920,7 @@ jobs:
name: release-qa-live-slack-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Record advisory status
if: always()
@@ -1946,6 +1976,7 @@ jobs:
- docker_e2e_release_checks
- package_acceptance_release_checks
- qa_lab_parity_lane_release_checks
- maturity_scorecard_release_checks
- qa_lab_parity_report_release_checks
- qa_lab_runtime_parity_release_checks
- runtime_tool_coverage_release_checks
@@ -2031,6 +2062,7 @@ jobs:
"docker_e2e_release_checks=${{ needs.docker_e2e_release_checks.result }}" \
"package_acceptance_release_checks=${{ needs.package_acceptance_release_checks.result }}" \
"qa_lab_parity_lane_release_checks=${{ needs.qa_lab_parity_lane_release_checks.result }}" \
"maturity_scorecard_release_checks=${{ needs.maturity_scorecard_release_checks.result }}" \
"qa_lab_parity_report_release_checks=${{ needs.qa_lab_parity_report_release_checks.result }}" \
"qa_lab_runtime_parity_release_checks=${{ needs.qa_lab_runtime_parity_release_checks.result }}" \
"runtime_tool_coverage_release_checks=${{ needs.runtime_tool_coverage_release_checks.result }}" \

View File

@@ -1466,9 +1466,9 @@ jobs:
fi
- name: Upload postpublish evidence
if: ${{ always() }}
if: ${{ always() && inputs.publish_openclaw_npm }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: openclaw-release-postpublish-evidence-${{ inputs.tag }}
path: ${{ runner.temp }}/openclaw-release-postpublish-evidence
if-no-files-found: ignore
if-no-files-found: error

View File

@@ -66,5 +66,5 @@ jobs:
with:
name: opengrep-full-sarif
path: .opengrep-out/precise.sarif
if-no-files-found: warn
if-no-files-found: error
retention-days: 30

View File

@@ -97,5 +97,5 @@ jobs:
with:
name: opengrep-pr-diff-sarif
path: .opengrep-out/precise.sarif
if-no-files-found: warn
if-no-files-found: error
retention-days: 30

View File

@@ -89,6 +89,13 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
// Reusable workflow jobs inherit the caller event but run as
// github-actions[bot]; selected ref validation still gates secrets.
if (context.actor === "github-actions[bot]") {
core.info("Skipping manual actor permission check for a reusable workflow call.");
core.setOutput("authorized", "true");
return;
}
if (context.eventName !== "workflow_dispatch") {
core.info(`Skipping manual actor permission check for ${context.eventName}.`);
core.setOutput("authorized", "true");
@@ -243,6 +250,9 @@ jobs:
NODE_OPTIONS: --max-old-space-size=8192
run: node scripts/build-all.mjs qaRuntime
- name: Ensure Playwright Chromium
run: node scripts/ensure-playwright-chromium.mjs
- name: Run QA profile
id: run_profile
env:

View File

@@ -37,6 +37,7 @@ This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs.
#### Pull requests
- **PR #92154** fix(qqbot): gate private group commands and close strict command visibility gaps. Thanks @sliverp.
- **PR #90463** refactor: add session accessor seam with gateway consumer. Thanks @jalehman.
- **PR #88656** Drop reasoning-only length turns from replay. Thanks @abel-zer0.
- **PR #92856** feat(webui): add session workspace rail. Thanks @Solvely-Colin.

View File

@@ -2,7 +2,11 @@
## Unreleased
Maintenance update for the current OpenClaw Android release.
## 2026.6.9 - 2026-06-23
Adds settings detail panels, refreshes the Android overview controls, and routes exec approvals into the in-app inbox.
Improves chat acknowledgement handling, gateway pairing readiness, microphone foreground-service behavior, and release screenshot reliability.
## 2026.6.2 - 2026-06-02

View File

@@ -0,0 +1,3 @@
Adds settings detail panels, refreshes the Android overview controls, and routes exec approvals into the in-app inbox.
Improves chat acknowledgement handling, gateway pairing readiness, microphone foreground-service behavior, and release screenshot reliability.

View File

@@ -1 +1,3 @@
Maintenance update for the current OpenClaw Android release.
Adds settings detail panels, refreshes the Android overview controls, and routes exec approvals into the in-app inbox.
Improves chat acknowledgement handling, gateway pairing readiness, microphone foreground-service behavior, and release screenshot reliability.

View File

@@ -0,0 +1,185 @@
# App Review Notes
Use these steps to exercise the live OpenClaw iOS App Review Gateway.
## Demo Account / Setup
Use the OpenClaw iOS app with the live review Gateway setup code included in
the `Notes` field of this App Review submission.
The setup code is a single generated code string. It already contains the public
Gateway host and setup credential.
## Setup Walkthrough
1. Open the OpenClaw app.
2. Tap `Continue`.
3. On `Connect Gateway`, tap `Set Up Manually`.
4. In the `Setup Code` section, tap the `Paste setup code` field.
5. Paste the setup code string from the App Review submission `Notes` field.
6. Tap `Apply Setup Code`.
7. If `Trust and connect` appears, tap `Trust and connect`.
8. Wait for the `Connected` screen.
9. On `Connected`, tap `Open OpenClaw`.
10. Confirm the `Control` screen shows `Gateway Online`.
11. Tap `Settings`.
12. Tap `Approvals`.
13. Tap `Open Notifications`.
14. Tap `Enable Notifications`.
15. On `Enable OpenClaw Hosted Push Relay?`, tap `Continue`.
16. If iOS asks whether OpenClaw may send notifications, tap `Allow`.
17. Confirm `Notifications` shows `Enabled`.
## Chat
1. Tap the `Chat` tab.
2. Tap the text field labeled `Message main...`.
3. Send this exact message:
```text
Start Apple review checklist.
```
Expected result: the assistant replies with the available App Review demos.
## Approval Demo
1. Tap the `Chat` tab.
2. Tap the text field labeled `Message main...`.
3. Send this exact message:
```text
Run the approval demo.
```
Expected result: the iPhone shows `Exec approval required` with the harmless
command `printf 'OpenClaw App Review approval demo complete\n'`. Tap
`Allow Once`. The chat then replies:
```text
The approval demo completed.
```
## Talk
1. Tap the `Talk` tab.
2. Tap `Start Talk`.
3. If iOS asks for microphone access, tap `Allow`.
4. If iOS asks for Speech Recognition access, tap `Allow`.
5. Confirm the screen changes to `Ready to talk` and shows `Stop Talk`.
6. Say:
```text
Summarize this review setup in one sentence.
```
Expected result: the assistant responds by voice. Tap `Stop Talk` when done.
## Talk + Background Audio
1. Tap the `Talk` tab.
2. Confirm `Speakerphone` is on.
3. Confirm `Background listening` is on.
4. Tap `Start Talk`.
5. If iOS asks for microphone access, tap `Allow`.
6. If iOS asks for Speech Recognition access, tap `Allow`.
7. Confirm `Stop Talk` is visible.
8. Say:
```text
Tell me when you can hear me.
```
9. While Talk is active, send OpenClaw to the background by returning to the
Home Screen or locking the iPhone. Do not force quit the app.
10. Continue speaking then wait for assistant audio reply.
Expected result: realtime Talk audio continues while OpenClaw is backgrounded.
Reopen OpenClaw, confirm Talk is still active, then tap `Stop Talk`.
## Gateway Status
1. Tap `Control`.
2. Tap `Instances`.
3. Confirm the screen shows `Gateway online`.
4. Confirm at least one `agent` row is connected.
5. Confirm the iPhone review device appears in the connected instances list.
## Push Notification
1. Tap the `Chat` tab.
2. Tap the text field labeled `Message main...`.
3. Send this exact message:
```text
Start push notification demo.
```
4. Immediately send OpenClaw to the background and lock the iPhone. Do not
force quit the app.
Expected result: the iPhone Lock Screen receives a visible `OpenClaw`
notification with this body:
```text
OpenClaw App Review push notification demo
```
Tap the notification and unlock the iPhone if prompted. If OpenClaw opens on
`Control`, tap `Chat`. Expected chat reply:
```text
The push notification demo completed.
```
## Push Wake / Status
1. Tap the `Chat` tab.
2. Send this exact message:
```text
Start push wake demo.
```
3. Immediately send OpenClaw to the background and lock the iPhone. Do not
force quit the app.
4. Wait for the `OpenClaw` notification on the Lock Screen. It normally appears
about 10 seconds after the message is sent.
5. Tap the notification and unlock the iPhone if prompted. If OpenClaw opens on
`Control`, tap `Chat`.
Expected result: the app reconnects to the live Gateway and Chat replies:
```text
The push wake and node status demo completed.
```
## Device Permissions
1. Tap `Settings`.
2. Tap `Permissions`.
3. Confirm these current app controls are available:
- `Camera`
- `Location` with `Off`, `While Using`, and `Always`
- `Keep Awake`
4. Expand `Privacy & Access`.
5. Confirm these request controls are available:
- `Contacts` / `Request Access`
- `Calendar (Add Events)` / `Request Access`
- `Calendar (View Events)` / `Request Full Access`
- `Reminders` / `Request Access`
## Share Sheet
1. Open Safari.
2. Navigate to `https://example.com`.
3. Tap the Safari toolbar `More` button.
4. Tap `Share`.
5. Tap `OpenClaw`.
6. Confirm the OpenClaw share extension appears and shows
`Edit text, then tap Send.` and `Send to OpenClaw`.
7. Tap `Send to OpenClaw`.
Expected result: the OpenClaw share extension sends the shared Safari page to
the live review Gateway and shows `Sent to OpenClaw.` Returning to OpenClaw
Chat shows the shared `Example Domain` page.

View File

@@ -152,6 +152,7 @@ extension SettingsProTab {
}
let notificationSettings = await UNUserNotificationCenter.current().notificationSettings()
self.applyNotificationStatus(notificationSettings.authorizationStatus)
self.registerForRemoteNotificationsIfEnrollmentReady()
let issueCount = SettingsDiagnostics.issueCount(
gatewayConnected: self.gatewayDiagnosticConnected,
@@ -325,6 +326,7 @@ extension SettingsProTab {
self.setupStatusText = "Tailscale is off on this device. Turn it on, then try again."
return false
}
self.gatewayController.requestLocalNetworkAccess(reason: "settings_preflight")
self.setupStatusText = "Checking gateway reachability..."
let ok = await TCPProbe.probe(host: trimmed, port: port, timeoutSeconds: 3, queueLabel: "gateway.preflight")
if !ok {
@@ -417,6 +419,7 @@ extension SettingsProTab {
let status = settings.authorizationStatus
Task { @MainActor in
self.applyNotificationStatus(status)
self.registerForRemoteNotificationsIfEnrollmentReady()
}
}
}
@@ -437,6 +440,7 @@ extension SettingsProTab {
func requestNotificationAuthorizationFromSettings() {
guard !self.isRequestingNotificationAuthorization else { return }
PushEnrollmentConsent.markDisclosureAccepted()
self.isRequestingNotificationAuthorization = true
Task {
let granted = await (try? UNUserNotificationCenter.current().requestAuthorization(options: [
@@ -448,12 +452,19 @@ extension SettingsProTab {
await MainActor.run {
self.isRequestingNotificationAuthorization = false
self.notificationStatus = SettingsNotificationStatus(settings.authorizationStatus)
guard granted, self.notificationStatus.allowsNotifications else { return }
UIApplication.shared.registerForRemoteNotifications()
guard granted else { return }
self.registerForRemoteNotificationsIfEnrollmentReady()
}
}
}
@MainActor
func registerForRemoteNotificationsIfEnrollmentReady() {
guard PushEnrollmentConsent.disclosureAccepted else { return }
guard self.notificationStatus.allowsNotifications else { return }
UIApplication.shared.registerForRemoteNotifications()
}
@MainActor
func applyNotificationStatus(_ status: UNAuthorizationStatus) {
self.notificationStatus = SettingsNotificationStatus(status)

View File

@@ -127,6 +127,8 @@ final class GatewayConnectionController {
private let discovery = GatewayDiscoveryModel()
private let discoveryEnabled: Bool
private weak var appModel: NodeAppModel?
private var localNetworkAccessRequested: Bool
private var currentScenePhase: ScenePhase = .inactive
private var didAutoConnect = false
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
private var pendingTrustConnect: PendingTrustConnect?
@@ -137,9 +139,14 @@ final class GatewayConnectionController {
let useTLS: Bool
}
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
init(
appModel: NodeAppModel,
startDiscovery: Bool = true,
deferDiscoveryUntilLocalNetworkRequest: Bool = false)
{
self.discoveryEnabled = startDiscovery
self.appModel = appModel
self.localNetworkAccessRequested = !deferDiscoveryUntilLocalNetworkRequest
GatewaySettingsStore.bootstrapPersistence()
let defaults = UserDefaults.standard
@@ -148,7 +155,7 @@ final class GatewayConnectionController {
self.updateFromDiscovery()
self.observeDiscovery()
if self.discoveryEnabled {
if self.discoveryEnabled, self.localNetworkAccessRequested {
self.discovery.start()
}
}
@@ -157,11 +164,29 @@ final class GatewayConnectionController {
self.discovery.setDebugLoggingEnabled(enabled)
}
func requestLocalNetworkAccess(reason: String) {
guard self.discoveryEnabled else {
self.discovery.stop()
self.updateFromDiscovery()
return
}
self.localNetworkAccessRequested = true
GatewayDiagnostics.log("local network access requested reason=\(reason)")
guard self.currentScenePhase != .background else { return }
self.discovery.start()
self.updateFromDiscovery()
self.attemptAutoReconnectIfNeeded()
}
func setScenePhase(_ phase: ScenePhase) {
self.currentScenePhase = phase
guard self.discoveryEnabled else {
self.discovery.stop()
return
}
guard self.localNetworkAccessRequested else { return }
switch phase {
case .background:
@@ -181,6 +206,10 @@ final class GatewayConnectionController {
self.updateFromDiscovery()
return
}
guard self.localNetworkAccessRequested else {
self.requestLocalNetworkAccess(reason: "restart_discovery")
return
}
self.discovery.stop()
self.didAutoConnect = false
@@ -197,6 +226,7 @@ final class GatewayConnectionController {
_ gateway: GatewayDiscoveryModel.DiscoveredGateway,
forceReconnect: Bool = false) async -> String?
{
self.requestLocalNetworkAccess(reason: "connect_discovered_gateway")
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if instanceId.isEmpty {
@@ -275,6 +305,7 @@ final class GatewayConnectionController {
authOverride: ManualAuthOverride? = nil,
forceReconnect: Bool = false) async
{
self.requestLocalNetworkAccess(reason: "connect_manual")
let instanceId = GatewaySettingsStore.currentInstanceID()
let token =
authOverride.map(\.token) ?? GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
@@ -340,6 +371,7 @@ final class GatewayConnectionController {
}
func connectLastKnown() async {
self.requestLocalNetworkAccess(reason: "connect_last_known")
guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return }
switch last {
case let .manual(host, port, useTLS, _):

View File

@@ -4103,6 +4103,9 @@ extension NodeAppModel {
private func registerAPNsTokenIfNeeded() async {
let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport
guard await self.canPublishAPNsRegistration(usesRelayTransport: usesRelayTransport) else {
return
}
guard self.gatewayConnected else {
if usesRelayTransport {
GatewayDiagnostics.pushRelay.skipped("gateway_offline")
@@ -4163,6 +4166,23 @@ extension NodeAppModel {
}
}
private func canPublishAPNsRegistration(usesRelayTransport: Bool) async -> Bool {
guard PushEnrollmentConsent.disclosureAccepted else {
if usesRelayTransport {
GatewayDiagnostics.pushRelay.skipped("enrollment_disclosure_not_accepted")
}
return false
}
let status = await self.notificationAuthorizationStatus()
guard Self.isNotificationAuthorizationAllowed(status) else {
if usesRelayTransport {
GatewayDiagnostics.pushRelay.skipped("notifications_not_authorized")
}
return false
}
return true
}
private func fetchPushRelayGatewayIdentity() async throws -> PushRelayGatewayIdentity {
let response = try await self.operatorGateway.request(
method: "gateway.identity.get",
@@ -5126,6 +5146,10 @@ extension NodeAppModel {
self.setOperatorConnected(connected)
}
func _test_canPublishAPNsRegistration(usesRelayTransport: Bool = true) async -> Bool {
await self.canPublishAPNsRegistration(usesRelayTransport: usesRelayTransport)
}
nonisolated static func _test_makeWatchChatItems(from raw: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem] {
self.makeWatchChatItems(from: raw)
}

View File

@@ -73,10 +73,16 @@ struct OnboardingWizardView: View {
private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect()
let allowSkip: Bool
let onRequestLocalNetworkAccess: (String) -> Void
let onClose: () -> Void
init(allowSkip: Bool, onClose: @escaping () -> Void) {
init(
allowSkip: Bool,
onRequestLocalNetworkAccess: @escaping (String) -> Void,
onClose: @escaping () -> Void)
{
self.allowSkip = allowSkip
self.onRequestLocalNetworkAccess = onRequestLocalNetworkAccess
self.onClose = onClose
_step = State(
initialValue: OnboardingStateStore.shouldPresentFirstRunIntro() ? .intro : .welcome)
@@ -231,6 +237,7 @@ struct OnboardingWizardView: View {
}
.onAppear {
self.initializeState()
self.requestLocalNetworkAccessIfPastIntro(reason: "onboarding_appear")
}
.onDisappear {
self.discoveryRestartTask?.cancel()
@@ -864,10 +871,20 @@ extension OnboardingWizardView {
private func advanceFromIntro() {
OnboardingStateStore.markFirstRunIntroSeen()
self.requestLocalNetworkAccess(reason: "onboarding_continue")
self.statusLine = "In your OpenClaw chat, run /pair qr, then scan the code here."
self.step = .welcome
}
private func requestLocalNetworkAccessIfPastIntro(reason: String) {
guard self.step != .intro else { return }
self.requestLocalNetworkAccess(reason: reason)
}
private func requestLocalNetworkAccess(reason: String) {
self.onRequestLocalNetworkAccess(reason)
}
private func navigateBack() {
guard let target = self.step.previous else { return }
self.connectingGatewayID = nil

View File

@@ -123,10 +123,30 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
let notificationCenter = UNUserNotificationCenter.current()
notificationCenter.delegate = self
ExecApprovalNotificationBridge.registerCategory(center: notificationCenter)
application.registerForRemoteNotifications()
Task { @MainActor in
await self.registerForRemoteNotificationsIfEnrollmentReady(application)
}
return true
}
private func registerForRemoteNotificationsIfEnrollmentReady(_ application: UIApplication) async {
guard PushEnrollmentConsent.disclosureAccepted else { return }
guard await Self.isNotificationAuthorizationAllowed() else { return }
application.registerForRemoteNotifications()
}
private static func isNotificationAuthorizationAllowed() async -> Bool {
let settings = await UNUserNotificationCenter.current().notificationSettings()
switch settings.authorizationStatus {
case .authorized, .provisional, .ephemeral:
return true
case .denied, .notDetermined:
return false
@unknown default:
return false
}
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
if let appModel = self.resolvedAppModel() {
Task { @MainActor in
@@ -626,7 +646,8 @@ struct OpenClawApp: App {
_gatewayController = State(
initialValue: GatewayConnectionController(
appModel: appModel,
startDiscovery: !Self.screenshotModeEnabled))
startDiscovery: !Self.screenshotModeEnabled,
deferDiscoveryUntilLocalNetworkRequest: true))
}
var body: some Scene {

View File

@@ -0,0 +1,19 @@
import Foundation
enum PushEnrollmentConsent {
static let disclosureAcceptedKey = "push.enrollment.disclosureAccepted"
static var disclosureAccepted: Bool {
UserDefaults.standard.bool(forKey: disclosureAcceptedKey)
}
static func markDisclosureAccepted() {
UserDefaults.standard.set(true, forKey: self.disclosureAcceptedKey)
}
#if DEBUG
static func reset() {
UserDefaults.standard.removeObject(forKey: self.disclosureAcceptedKey)
}
#endif
}

View File

@@ -683,6 +683,7 @@ struct RootTabs: View {
self.updateIdleTimer()
self.updateHomeCanvasState()
guard newValue == .active else { return }
self.maybeRequestLocalNetworkAccess(reason: "scene_active")
Task {
await self.appModel.refreshGatewayOverviewIfConnected()
await MainActor.run {
@@ -729,6 +730,10 @@ struct RootTabs: View {
.onChange(of: self.onboardingRequestID) { _, _ in
self.evaluateOnboardingPresentation(force: true)
}
.onChange(of: self.showOnboarding) { _, newValue in
guard !newValue else { return }
self.maybeRequestLocalNetworkAccess(reason: "onboarding_dismissed")
}
.onChange(of: self.appModel.openChatRequestID) { _, _ in
self.selectSidebarDestination(.chat)
}
@@ -767,6 +772,9 @@ struct RootTabs: View {
.fullScreenCover(isPresented: self.$showOnboarding) {
OnboardingWizardView(
allowSkip: self.onboardingAllowSkip,
onRequestLocalNetworkAccess: { reason in
self.requestLocalNetworkAccess(reason: reason)
},
onClose: {
self.showOnboarding = false
})
@@ -1045,13 +1053,14 @@ extension RootTabs {
shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel))
switch route {
case .none:
break
self.maybeRequestLocalNetworkAccess(reason: "root_appear")
case .onboarding:
self.onboardingAllowSkip = true
self.showOnboarding = true
case .settings:
self.didAutoOpenSettings = true
self.selectSidebarDestination(.gateway)
self.maybeRequestLocalNetworkAccess(reason: "root_appear")
}
}
@@ -1078,6 +1087,7 @@ extension RootTabs {
guard route == .settings else { return }
self.didAutoOpenSettings = true
self.selectSidebarDestination(.gateway)
self.maybeRequestLocalNetworkAccess(reason: "auto_open_settings")
}
private func maybeOpenSettingsForGatewaySetup() {
@@ -1088,6 +1098,19 @@ extension RootTabs {
self.presentedSheet = nil
self.didAutoOpenSettings = true
self.selectSidebarDestination(.gateway)
self.requestLocalNetworkAccess(reason: "gateway_setup_deeplink")
}
private func maybeRequestLocalNetworkAccess(reason: String) {
guard self.didEvaluateOnboarding else { return }
guard self.scenePhase == .active else { return }
guard !self.showOnboarding else { return }
self.requestLocalNetworkAccess(reason: reason)
}
private func requestLocalNetworkAccess(reason: String) {
guard !self.appModel.isAppleReviewDemoModeEnabled else { return }
self.gatewayController.requestLocalNetworkAccess(reason: reason)
}
private func applyInitialChatSessionIfNeeded() {

View File

@@ -76,6 +76,7 @@ Sources/Permissions/PermissionRequestBridge.swift
Sources/Push/ExecApprovalNotificationBridge.swift
Sources/Push/BackgroundAliveBeacon.swift
Sources/Push/PushBuildConfig.swift
Sources/Push/PushEnrollmentConsent.swift
Sources/Push/PushRegistrationManager.swift
Sources/Push/PushRelayClient.swift
Sources/Push/PushRelayKeychainStore.swift

View File

@@ -1377,6 +1377,24 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
#expect(center.addCalls == 1)
}
@Test @MainActor func apnsRegistrationRequiresDisclosureAndNotificationAuthorization() async {
let center = MockBootstrapNotificationCenter()
center.status = .authorized
let appModel = NodeAppModel(notificationCenter: center)
PushEnrollmentConsent.reset()
defer { PushEnrollmentConsent.reset() }
#expect(await appModel._test_canPublishAPNsRegistration() == false)
#expect(await appModel._test_canPublishAPNsRegistration(usesRelayTransport: false) == false)
PushEnrollmentConsent.markDisclosureAccepted()
center.status = .notDetermined
#expect(await appModel._test_canPublishAPNsRegistration() == false)
center.status = .authorized
#expect(await appModel._test_canPublishAPNsRegistration())
}
@Test @MainActor func chatPushWithoutSpeechReturnsUnavailableWhenNotificationsOff() async throws {
let center = MockBootstrapNotificationCenter()
center.status = .notDetermined

View File

@@ -550,6 +550,20 @@ struct RootTabsSourceGuardTests {
#expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
}
@Test func `push enrollment stays behind notification disclosure flow`() throws {
let appSource = try String(contentsOf: Self.openClawAppSourceURL(), encoding: .utf8)
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
let modelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
#expect(appSource.contains("PushEnrollmentConsent.disclosureAccepted"))
#expect(appSource.contains("await Self.isNotificationAuthorizationAllowed()"))
#expect(actionsSource.contains("PushEnrollmentConsent.markDisclosureAccepted()"))
#expect(actionsSource.contains("self.registerForRemoteNotificationsIfEnrollmentReady()"))
#expect(modelSource.contains("PushEnrollmentConsent.disclosureAccepted"))
#expect(modelSource.contains("notifications_not_authorized"))
#expect(modelSource.contains("enrollment_disclosure_not_accepted"))
}
@Test func `gateway settings keeps pairing trust diagnostics and tailscale actions`() throws {
let settingsSource = try String(contentsOf: Self.settingsProTabSourceURL(), encoding: .utf8)
let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
@@ -580,6 +594,7 @@ struct RootTabsSourceGuardTests {
#expect(actionsSource.contains("self.gatewayController.refreshActiveGatewayRegistrationFromSettings()"))
#expect(actionsSource.contains("self.gatewayController.restartDiscovery()"))
#expect(actionsSource.contains("await self.appModel.refreshGatewayOverviewIfConnected()"))
#expect(actionsSource.contains("self.gatewayController.requestLocalNetworkAccess(reason: \"settings_preflight\")"))
#expect(actionsSource.contains("await TCPProbe.probe(host: trimmed, port: port"))
#expect(actionsSource.contains("Check Tailscale or LAN."))
#expect(actionsSource.contains("Tailscale is off on this device. Turn it on, then try again."))
@@ -596,6 +611,32 @@ struct RootTabsSourceGuardTests {
#expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)"))
}
@Test func `local network access is requested from visible gateway flows`() throws {
let appSource = try String(contentsOf: Self.openClawAppSourceURL(), encoding: .utf8)
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let onboardingSource = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8)
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
let controllerSource = try String(contentsOf: Self.gatewayConnectionControllerSourceURL(), encoding: .utf8)
#expect(appSource.contains("deferDiscoveryUntilLocalNetworkRequest: true"))
#expect(controllerSource.contains("func requestLocalNetworkAccess(reason: String)"))
#expect(controllerSource.contains("guard self.localNetworkAccessRequested else"))
#expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_manual\")"))
#expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_discovered_gateway\")"))
#expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_last_known\")"))
#expect(rootSource.contains("self.maybeRequestLocalNetworkAccess(reason: \"root_appear\")"))
#expect(rootSource.contains("self.maybeRequestLocalNetworkAccess(reason: \"scene_active\")"))
#expect(rootSource.contains("self.maybeRequestLocalNetworkAccess(reason: \"onboarding_dismissed\")"))
#expect(rootSource.contains("self.requestLocalNetworkAccess(reason: \"gateway_setup_deeplink\")"))
#expect(rootSource.contains("guard self.didEvaluateOnboarding else { return }"))
#expect(rootSource.contains("onRequestLocalNetworkAccess: { reason in"))
#expect(onboardingSource.contains("self.requestLocalNetworkAccess(reason: \"onboarding_continue\")"))
#expect(onboardingSource.contains("self.requestLocalNetworkAccessIfPastIntro(reason: \"onboarding_appear\")"))
#expect(actionsSource.contains("self.gatewayController.requestLocalNetworkAccess(reason: \"settings_preflight\")"))
}
@Test func `gateway settings preview matrix covers primary states`() throws {
let supportSource = try String(contentsOf: Self.settingsProTabSupportSourceURL(), encoding: .utf8)
@@ -786,6 +827,20 @@ struct RootTabsSourceGuardTests {
.appendingPathComponent("Sources/Design/SettingsProTab.swift")
}
private static func onboardingWizardSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Onboarding/OnboardingWizardView.swift")
}
private static func openClawAppSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/OpenClawApp.swift")
}
private static func notificationPermissionGuidanceDialogSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()

View File

@@ -15,13 +15,12 @@
import Foundation
import XCTest
var deviceLanguage = ""
var locale = ""
@MainActor
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
}
@MainActor
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
if waitForLoadingIndicator {
Snapshot.snapshot(name)
@@ -33,6 +32,7 @@ func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
/// - Parameters:
/// - name: The name of the snapshot
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
@MainActor
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
}
@@ -52,6 +52,7 @@ enum SnapshotError: Error, CustomDebugStringConvertible {
}
@objcMembers
@MainActor
open class Snapshot: NSObject {
static var app: XCUIApplication?
static var waitForAnimations = true
@@ -59,6 +60,8 @@ open class Snapshot: NSObject {
static var screenshotsDirectory: URL? {
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
}
static var deviceLanguage = ""
static var currentLocale = ""
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
@@ -103,17 +106,17 @@ open class Snapshot: NSObject {
do {
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
locale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
} catch {
NSLog("Couldn't detect/set locale...")
}
if locale.isEmpty && !deviceLanguage.isEmpty {
locale = Locale(identifier: deviceLanguage).identifier
if currentLocale.isEmpty && !deviceLanguage.isEmpty {
currentLocale = Locale(identifier: deviceLanguage).identifier
}
if !locale.isEmpty {
app.launchArguments += ["-AppleLocale", "\"\(locale)\""]
if !currentLocale.isEmpty {
app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""]
}
}
@@ -165,7 +168,7 @@ open class Snapshot: NSObject {
}
let screenshot = XCUIScreen.main.screenshot()
#if os(iOS)
#if os(iOS) && !targetEnvironment(macCatalyst)
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
#else
let image = screenshot.image
@@ -181,7 +184,7 @@ open class Snapshot: NSObject {
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
#if swift(<5.0)
UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
#else
try image.pngData()?.write(to: path, options: .atomic)
#endif
@@ -281,6 +284,7 @@ private extension XCUIElementQuery {
return self.containing(isNetworkLoadingIndicator)
}
@MainActor
var deviceStatusBars: XCUIElementQuery {
guard let app = Snapshot.app else {
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
@@ -306,4 +310,4 @@ private extension CGFloat {
// Please don't remove the lines below
// They are used to detect outdated configuration files
// SnapshotHelperVersion [1.27]
// SnapshotHelperVersion [1.30]

View File

@@ -10,7 +10,24 @@ default_platform(:ios)
APP_STORE_APP_IDENTIFIER = "ai.openclawfoundation.app"
DEFAULT_APP_STORE_CONNECT_KEYCHAIN_SERVICE = "openclaw-app-store-connect-key"
DEFAULT_SNAPSHOT_DEVICES = ["iPhone 16 Pro Max", "iPad Pro 13-inch (M4)"].freeze
DEFAULT_SNAPSHOT_DEVICE_FAMILIES = [
{
label: "iPhone",
patterns: [
/\AiPhone .* Pro Max\z/,
/\AiPhone .* Plus\z/,
/\AiPhone .*\z/
]
},
{
label: "13-inch iPad",
patterns: [
/\AiPad Pro 13-inch/,
/\AiPad Air 13-inch/,
/\AiPad .*13-inch/
]
}
].freeze
DEFAULT_WATCH_SNAPSHOT_DEVICE = "Apple Watch Ultra 3 (49mm)"
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY = "openclaw.watch.screenshotMode"
WATCH_SNAPSHOT_STATUS_BAR_TIME = "09:41"
@@ -77,11 +94,23 @@ end
def snapshot_devices
raw = ENV["OPENCLAW_SNAPSHOT_DEVICES"].to_s.strip
return DEFAULT_SNAPSHOT_DEVICES if raw.empty?
return default_snapshot_devices if raw.empty?
raw.split(",").map(&:strip).reject(&:empty?)
end
def default_snapshot_devices
names = available_simulator_devices.map { |device| device["name"].to_s }.reject(&:empty?).uniq
DEFAULT_SNAPSHOT_DEVICE_FAMILIES.map do |family|
match = family.fetch(:patterns).filter_map do |pattern|
names.find { |name| name.match?(pattern) }
end.first
UI.user_error!("No available #{family.fetch(:label)} simulator found for App Store screenshots.") if match.nil?
match
end
end
def watch_snapshot_device
raw = ENV["OPENCLAW_WATCH_SNAPSHOT_DEVICE"].to_s.strip
raw.empty? ? DEFAULT_WATCH_SNAPSHOT_DEVICE : raw
@@ -113,6 +142,51 @@ def resolve_simulator_device(name)
fallback
end
def install_ready_for_review_edit_state_lookup!
require "spaceship"
app_class = Spaceship::ConnectAPI::App
app_class.class_eval do
unless method_defined?(:openclaw_get_edit_app_store_version_without_ready_for_review)
alias_method :openclaw_get_edit_app_store_version_without_ready_for_review, :get_edit_app_store_version
end
unless method_defined?(:openclaw_fetch_edit_app_info_without_ready_for_review)
alias_method :openclaw_fetch_edit_app_info_without_ready_for_review, :fetch_edit_app_info
end
def get_edit_app_store_version(client: nil, platform: nil, includes: Spaceship::ConnectAPI::AppStoreVersion::ESSENTIAL_INCLUDES)
version = openclaw_get_edit_app_store_version_without_ready_for_review(client: client, platform: platform, includes: includes)
return version if version
# First public releases can leave the only version in READY_FOR_REVIEW.
# Fastlane 2.236.1 excludes that state and then tries to create an illegal
# second version; use the existing review-ready version as the edit target.
client ||= Spaceship::ConnectAPI
platform ||= Spaceship::ConnectAPI::Platform::IOS
filter = {
appVersionState: Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::READY_FOR_REVIEW,
platform: platform
}
get_app_store_versions(client: client, filter: filter, includes: includes)
.sort_by { |candidate| Gem::Version.new(candidate.version_string) }
.last
end
def fetch_edit_app_info(client: nil, includes: Spaceship::ConnectAPI::AppInfo::ESSENTIAL_INCLUDES)
app_info = openclaw_fetch_edit_app_info_without_ready_for_review(client: client, includes: includes)
return app_info if app_info
client ||= Spaceship::ConnectAPI
client
.get_app_infos(app_id: id, includes: includes)
.to_models
.find { |candidate| candidate.state == Spaceship::ConnectAPI::AppInfo::State::READY_FOR_REVIEW }
end
end
end
def bundle_identifier_for_product(product_path)
info_plist_path = File.join(product_path, "Info.plist")
UI.user_error!("Expected Info.plist at #{info_plist_path}.") unless File.exist?(info_plist_path)
@@ -210,6 +284,7 @@ def normalize_watch_screenshot_status_bar(path)
script = <<~SWIFT
import AppKit
import Foundation
import ImageIO
let path = CommandLine.arguments[1]
let timeText = CommandLine.arguments[2]
@@ -221,36 +296,37 @@ def normalize_watch_screenshot_status_bar(path)
exit(2)
}
let width = CGFloat(cgImage.width)
let height = CGFloat(cgImage.height)
guard let bitmap = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: Int(width),
pixelsHigh: Int(height),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0),
let graphicsContext = NSGraphicsContext(bitmapImageRep: bitmap)
let width = cgImage.width
let height = cgImage.height
let drawWidth = CGFloat(width)
let drawHeight = CGFloat(height)
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
guard let bitmapContext = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * 4,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)
else {
fputs("Failed to create normalized screenshot bitmap at \\(path)\\n", stderr)
exit(3)
}
bitmap.size = NSSize(width: width, height: height)
let graphicsContext = NSGraphicsContext(cgContext: bitmapContext, flipped: false)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = graphicsContext
NSColor.black.setFill()
NSBezierPath(rect: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight)).fill()
source.draw(
in: NSRect(x: 0, y: 0, width: width, height: height),
from: NSRect(x: 0, y: 0, width: width, height: height),
operation: .copy,
in: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight),
from: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight),
operation: .sourceOver,
fraction: 1.0)
NSColor.black.setFill()
NSBezierPath(rect: NSRect(x: width - 146, y: height - 92, width: 124, height: 70)).fill()
NSBezierPath(rect: NSRect(x: drawWidth - 146, y: drawHeight - 92, width: 124, height: 70)).fill()
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .right
@@ -260,17 +336,26 @@ def normalize_watch_screenshot_status_bar(path)
.paragraphStyle: paragraphStyle,
]
timeText.draw(
in: NSRect(x: width - 134, y: height - 82, width: 102, height: 44),
in: NSRect(x: drawWidth - 134, y: drawHeight - 82, width: 102, height: 44),
withAttributes: attributes)
NSGraphicsContext.restoreGraphicsState()
guard let png = bitmap.representation(using: .png, properties: [:])
guard let output = bitmapContext.makeImage(),
let destination = CGImageDestinationCreateWithURL(
URL(fileURLWithPath: path) as CFURL,
"public.png" as CFString,
1,
nil)
else {
fputs("Failed to encode normalized screenshot at \\(path)\\n", stderr)
exit(4)
}
try png.write(to: URL(fileURLWithPath: path))
CGImageDestinationAddImage(destination, output, nil)
guard CGImageDestinationFinalize(destination) else {
fputs("Failed to write normalized screenshot at \\(path)\\n", stderr)
exit(5)
}
SWIFT
Tempfile.create(["openclaw-watch-status-bar", ".swift"]) do |file|
@@ -961,6 +1046,7 @@ platform :ios do
desc "Upload App Store metadata (and optionally screenshots)"
lane :metadata do
install_ready_for_review_edit_state_lookup!
sync_ios_versioning!
version_metadata = read_ios_version_metadata
api_key = app_store_connect_api_key_config

View File

@@ -104,7 +104,7 @@ Generate deterministic App Store screenshots:
pnpm ios:screenshots
```
The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it captures the tab set on `iPhone 16 Pro Max` and `iPad Pro 13-inch (M4)`; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally.
The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it chooses one available large iPhone simulator and one available 13-inch iPad simulator from the installed Xcode runtime; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally.
Upload to App Store Connect:

View File

@@ -2,10 +2,9 @@ project("OpenClaw.xcodeproj")
scheme("OpenClawUITests")
configuration("Debug")
devices([
"iPhone 16 Pro Max",
"iPad Pro 13-inch (M4)",
])
# The Fastfile screenshot lane resolves concrete device names from the installed
# Xcode simulators. Fastlane validates Snapfile devices before lane overrides, so
# this file intentionally does not hardcode simulator model names.
languages([
"en-US",

View File

@@ -1,4 +1,3 @@
// Bundled A2UI runtime resource embedded by OpenClawKit.
var __defProp$1 = Object.defineProperty;
var __exportAll = (all, no_symbols) => {
let target = {};
@@ -11936,6 +11935,10 @@ var __runInitializers = function(thisArg, initializers, value) {
};
return _classThis;
})();
/**
* Canvas A2UI browser bootstrap that installs theme overrides and native bridge
* helpers.
*/
const modalStyles = i$10`
dialog {
position: fixed;

View File

@@ -1,2 +1,2 @@
da3373338b7f9c5f5639ad8233a32897d2346a0babe69a77386a7bff154cdcb1 plugin-sdk-api-baseline.json
17404d885e0d64ebc8e3c99443921058a8f1aebf76a5e612eb1f0cd7817d48f0 plugin-sdk-api-baseline.jsonl
f7247b5bbfe3f96bffffd25a8be2f89b37999e36731f34a159ae21ded1cedd05 plugin-sdk-api-baseline.json
ce88a53dadc194ceccc63f50146aee03a1a425f551117da826a21519d5bf80db plugin-sdk-api-baseline.jsonl

View File

@@ -155,6 +155,7 @@ Notes:
- `onchar` still responds to explicit @mentions.
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
- After the bot sends a visible reply in a channel thread, later messages in that same thread are answered without a new @mention or `onchar` prefix, so multi-turn thread conversations keep flowing. Participation is remembered for 7 days of thread inactivity (refreshed on each reply) and persists across gateway restarts. Threads the bot has only observed are unaffected; start a new top-level message to require an explicit mention again.
## Threading and sessions

View File

@@ -151,6 +151,7 @@ to a group, then mention it or configure the group to run without a mention.
groups: {
"*": {
requireMention: true,
commandLevel: "all",
historyLimit: 50,
tools: { deny: ["exec", "read", "write"] },
},
@@ -158,6 +159,7 @@ to a group, then mention it or configure the group to run without a mention.
name: "Release room",
requireMention: false,
ignoreOtherMentions: true,
commandLevel: "safety",
historyLimit: 20,
prompt: "Keep replies short and operational.",
},
@@ -172,6 +174,9 @@ to a group, then mention it or configure the group to run without a mention.
settings include:
- `requireMention`: require an @mention before the bot replies. Default: `true`.
- `commandLevel`: control which built-in slash commands can run in groups.
Default: `all`, which preserves the pre-existing QQBot group behavior when the
setting is omitted.
- `ignoreOtherMentions`: drop messages that mention someone else but not the bot.
- `historyLimit`: keep recent non-mention group messages as context for the next mentioned turn. Set `0` to disable.
- `tools`: allow/deny tools for the whole group.
@@ -179,6 +184,17 @@ settings include:
- `name`: friendly label used in logs and group context.
- `prompt`: per-group behavior prompt appended to the agent context.
`commandLevel` accepts:
- `all`: keep recognized built-in commands available as before. Some commands may
stay hidden from menus, but authorized users can still run them in the group.
- `safety`: allow common collaboration commands such as `/help`, `/btw`, and
`/stop`; ask users to run sensitive commands such as `/config`, `/tools`, and
`/bash` in private chat.
- `strict`: only allow the group-session controls needed for strict group
operation. `/stop` still stays urgent so an authorized sender can interrupt an
active run.
Old QQBot `toolPolicy` entries are retired. Run `openclaw doctor --fix` to migrate them to `tools`.
Activation modes are `mention` and `always`. `requireMention: true` maps to

View File

@@ -42,6 +42,7 @@ or an explicit manual dispatch.
| `checks-windows` | Windows-specific process/path tests plus shared runtime import specifier regressions | Windows-relevant changes |
| `macos-node` | macOS TypeScript test lane using the shared built artifacts | macOS-relevant changes |
| `macos-swift` | Swift lint, build, and tests for the macOS app | macOS-relevant changes |
| `ios-build` | Xcode project generation plus the iOS app simulator build | iOS app, shared app kit, or Swabble changes |
| `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes |
| `test-performance-agent` | Daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch |
| `openclaw-performance` | Daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.5 live lanes | Scheduled and manual dispatch |
@@ -52,7 +53,7 @@ or an explicit manual dispatch.
2. `preflight` decides which lanes exist at all. The `docs-scope` and `changed-scope` logic are steps inside this job, not standalone jobs.
3. `security-fast`, `check-*`, `check-additional-*`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs.
4. `build-artifacts` overlaps with the fast Linux lanes so downstream consumers can start as soon as the shared build is ready.
5. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-plugins-*`, `checks-fast-contracts-channels-*`, `checks-node-core-*`, `checks-windows`, `macos-node`, `macos-swift`, and `android`.
5. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-plugins-*`, `checks-fast-contracts-channels-*`, `checks-node-core-*`, `checks-windows`, `macos-node`, `macos-swift`, `ios-build`, and `android`.
GitHub may mark superseded jobs as `cancelled` when a newer push lands on the same PR or `main` ref. Treat that as CI noise unless the newest run for the same ref is also failing. Matrix jobs use `fail-fast: false`, and `build-artifacts` reports embedded channel, core-support-boundary, and gateway-watch failures directly instead of queuing tiny verifier jobs. The automatic CI concurrency key is versioned (`CI-v7-*`) so a GitHub-side zombie in an old queue group cannot indefinitely block newer main runs. Manual full-suite runs use `CI-manual-v1-*` and do not cancel in-progress runs.
@@ -80,7 +81,7 @@ When the check fails, update the PR body instead of pushing another code commit.
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. Manual dispatch skips changed-scope detection and makes the preflight manifest act as if every scoped area changed.
- **CI workflow edits** validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
- **CI workflow edits** validate the Node CI graph plus workflow linting, but do not force Windows, iOS, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
- **Workflow Sanity** runs `actionlint`, `zizmor` over all workflow YAML files, the composite-action interpolation guard, and the conflict-marker guard. The PR-scoped `security-fast` job also runs `zizmor` over changed workflow files so workflow security findings fail early in the main CI graph.
- **Docs on `main` pushes** are checked by the standalone `Docs` workflow with the same ClawHub docs mirror used by CI, so mixed code+docs pushes do not also queue the CI `check-docs` shard. Pull requests and manual CI still run `check-docs` from CI when docs changed.
- **TUI PTY** runs in the `checks-node-core-runtime-tui-pty` Linux Node shard for TUI changes. The shard runs `test/vitest/vitest.tui-pty.config.ts` with `OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1`, so it covers both the deterministic `TuiBackend` fixture lane and the slower `tui --local` smoke that mocks only the external model endpoint.
@@ -120,7 +121,7 @@ Treat GitHub titles, comments, bodies, review text, branch names, and commit mes
## Manual dispatches
Manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, and Control UI i18n. Standalone manual CI dispatches run Android only with `include_android=true`; the full release umbrella enables Android by passing `include_android=true`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled.
Manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, iOS build, and Control UI i18n. Standalone manual CI dispatches run Android only with `include_android=true`; the full release umbrella enables Android by passing `include_android=true`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled.
Manual runs use a unique concurrency group so a release-candidate full suite is not cancelled by another push or PR run on the same ref. The optional `target_ref` input lets a trusted caller run that graph against a branch, tag, or full commit SHA while using the workflow file from the selected dispatch ref.
@@ -140,7 +141,7 @@ gh workflow run full-release-validation.yml --ref main -f ref=<branch-or-sha>
| `blacksmith-16vcpu-ubuntu-2404` | `build-artifacts`, `check-lint` (CPU-sensitive enough that 8 vCPU cost more than they saved); install-smoke Docker builds (32-vCPU queue time cost more than it saved) |
| `blacksmith-8vcpu-windows-2025` | `checks-windows` |
| `blacksmith-6vcpu-macos-15` | `macos-node` on `openclaw/openclaw`; forks fall back to `macos-15` |
| `blacksmith-12vcpu-macos-26` | `macos-swift` on `openclaw/openclaw`; forks fall back to `macos-26` |
| `blacksmith-12vcpu-macos-26` | `macos-swift` and `ios-build` on `openclaw/openclaw`; forks fall back to `macos-26` |
Canonical-repo CI keeps Blacksmith as the default runner path for normal push and pull-request runs. `workflow_dispatch` and non-canonical repository runs use GitHub-hosted runners, but normal canonical runs do not currently probe Blacksmith queue health or automatically fall back to GitHub-hosted labels when Blacksmith is unavailable.
@@ -162,6 +163,7 @@ pnpm test:channels
pnpm test:contracts:channels
pnpm check:docs # docs format + lint + broken links
pnpm build # build dist when CI artifact/smoke checks matter
pnpm ios:build # generate and build the iOS app project
pnpm ci:timings # summarize the latest origin/main push CI run
pnpm ci:timings:recent # compare recent successful main CI runs
node scripts/ci-run-timings.mjs <run-id> # summarize wall time, queue time, and slowest jobs
@@ -198,7 +200,7 @@ Every lane uploads GitHub artifacts. When `CLAWGRIT_REPORTS_TOKEN` is configured
## Full Release Validation
`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, QA Lab parity, Matrix, and Telegram lanes. Stable and full profiles always include exhaustive live/E2E and Docker release-path soak coverage; the beta profile can opt in with `run_release_soak=true`. The canonical package Telegram E2E runs inside Package Acceptance, so a full candidate does not start a duplicate live poller. After publishing, pass `release_package_spec` to reuse the shipped npm package across release checks, Package Acceptance, Docker, cross-OS, and Telegram without rebuilding. Use `npm_telegram_package_spec` only for a focused published-package Telegram rerun. The Codex plugin live package lane uses the same selected state by default: published `release_package_spec=openclaw@<tag>` derives `codex_plugin_spec=npm:@openclaw/codex@<tag>`, while SHA/artifact runs pack `extensions/codex` from the selected ref. Set `codex_plugin_spec` explicitly for custom plugin sources such as `npm:`, `npm-pack:`, or `git:` specs.
`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, maturity scorecard rendering from QA profile evidence, QA Lab parity, Matrix, and Telegram lanes. Stable and full profiles always include exhaustive live/E2E and Docker release-path soak coverage; the beta profile can opt in with `run_release_soak=true`. The canonical package Telegram E2E runs inside Package Acceptance, so a full candidate does not start a duplicate live poller. After publishing, pass `release_package_spec` to reuse the shipped npm package across release checks, Package Acceptance, Docker, cross-OS, and Telegram without rebuilding. Use `npm_telegram_package_spec` only for a focused published-package Telegram rerun. The Codex plugin live package lane uses the same selected state by default: published `release_package_spec=openclaw@<tag>` derives `codex_plugin_spec=npm:@openclaw/codex@<tag>`, while SHA/artifact runs pack `extensions/codex` from the selected ref. Set `codex_plugin_spec` explicitly for custom plugin sources such as `npm:`, `npm-pack:`, or `git:` specs.
See [Full release validation](/reference/full-release-validation) for the
stage matrix, exact workflow job names, profile differences, artifacts, and

View File

@@ -23,9 +23,9 @@ OpenClaw agent or Gateway.
```bash
openclaw skills search "calendar"
openclaw skills install <slug>
openclaw skills update <slug>
openclaw skills verify <slug>
openclaw skills install @owner/<slug>
openclaw skills update @owner/<slug>
openclaw skills verify @owner/<slug>
openclaw plugins search "calendar"
openclaw plugins install clawhub:<package>

View File

@@ -24,13 +24,13 @@ where you have publisher access.
Skills are published from a skill folder. The public page is:
```text
https://clawhub.ai/<owner>/<slug>
https://clawhub.ai/<owner>/skills/<slug>
```
Example:
```text
https://clawhub.ai/alice/review-helper
https://clawhub.ai/alice/skills/review-helper
```
The publish request includes the selected owner, slug, version, changelog, and

View File

@@ -25,24 +25,24 @@ Related:
```bash
openclaw skills search "calendar"
openclaw skills search --limit 20 --json
openclaw skills install <slug>
openclaw skills install <slug> --version <version>
openclaw skills install @owner/<slug>
openclaw skills install @owner/<slug> --version <version>
openclaw skills install git:owner/repo
openclaw skills install git:owner/repo@main
openclaw skills install ./path/to/skill --as custom-name
openclaw skills install <slug> --force
openclaw skills install <slug> --agent <id>
openclaw skills install <slug> --global
openclaw skills update <slug>
openclaw skills update <slug> --global
openclaw skills install @owner/<slug> --force
openclaw skills install @owner/<slug> --agent <id>
openclaw skills install @owner/<slug> --global
openclaw skills update @owner/<slug>
openclaw skills update @owner/<slug> --global
openclaw skills update --all
openclaw skills update --all --agent <id>
openclaw skills update --all --global
openclaw skills verify <slug>
openclaw skills verify <slug> --version <version>
openclaw skills verify <slug> --tag <tag>
openclaw skills verify <slug> --card
openclaw skills verify <slug> --global
openclaw skills verify @owner/<slug>
openclaw skills verify @owner/<slug> --version <version>
openclaw skills verify @owner/<slug> --tag <tag>
openclaw skills verify @owner/<slug> --card
openclaw skills verify @owner/<slug> --global
openclaw skills list
openclaw skills list --eligible
openclaw skills list --json
@@ -64,8 +64,8 @@ openclaw skills workshop reject <proposal-id> --reason "Not reusable"
openclaw skills workshop quarantine <proposal-id> --reason "Needs security review"
```
`search`, `update`, and `verify` use ClawHub directly. `install <slug>` installs
a ClawHub skill, `install git:owner/repo[@ref]` clones a Git skill, and
`search`, `update`, and `verify` use ClawHub directly. `install @owner/<slug>`
installs a ClawHub skill, `install git:owner/repo[@ref]` clones a Git skill, and
`install ./path` copies a local skill directory. By default, `install`, `update`,
and `verify` target the active workspace `skills/` directory; with `--global`,
they target the shared managed skills directory. `list`/`info`/`check` still
@@ -94,19 +94,22 @@ Notes:
`SKILL.md`.
- `install --as <slug>` overrides the inferred slug for Git and local directory
installs.
- `install --version <version>` applies only to ClawHub skill slugs.
- `install --version <version>` applies only to ClawHub skill refs.
- `install --force` overwrites an existing workspace skill folder for the same
slug.
- `--global` targets the shared managed skills directory and cannot be combined
with `--agent <id>`.
- `--agent <id>` targets one configured agent workspace and overrides current
working directory inference.
- `update <slug>` updates a single tracked skill. Add `--global` to target the
shared managed skills directory instead of the workspace.
- `update @owner/<slug>` updates a single tracked skill. Add `--global` to
target the shared managed skills directory instead of the workspace.
- `update --all` updates tracked ClawHub installs in the selected workspace, or
in the shared managed skills directory when combined with `--global`.
- `verify <slug>` prints ClawHub's `clawhub.skill.verify.v1` JSON envelope by
default. There is no `--json` flag because JSON is already the default.
- `verify @owner/<slug>` prints ClawHub's `clawhub.skill.verify.v1` JSON
envelope by default. There is no `--json` flag because JSON is already the
default. Bare slugs remain accepted for compatibility when the skill is
already installed or unambiguous, but owner-qualified refs avoid publisher
ambiguity.
- When ClawHub returns server-resolved source provenance, verify JSON also
includes a commit-pinned `openclaw.verifiedSourceUrl`. Unavailable or
self-declared source URLs stay only in the raw provenance envelope and are not

View File

@@ -68,6 +68,14 @@
"source": "/reference/openclaw-sdk-api-design",
"destination": "/gateway/external-apps"
},
{
"source": "/reference/maturity-scorecard",
"destination": "/maturity/scorecard"
},
{
"source": "/reference/maturity-taxonomy",
"destination": "/maturity/taxonomy"
},
{
"source": "/mcp",
"destination": "/cli/mcp"
@@ -1852,6 +1860,8 @@
{
"group": "Release and CI",
"pages": [
"maturity/scorecard",
"maturity/taxonomy",
"reference/RELEASING",
"reference/full-release-validation",
"reference/release-performance-sweep",

View File

@@ -346,10 +346,10 @@ lives on the [First-run FAQ](/help/faq-first-run).
```bash
openclaw skills search "calendar"
openclaw skills search --limit 20
openclaw skills install <skill-slug>
openclaw skills install <skill-slug> --version <version>
openclaw skills install <skill-slug> --force
openclaw skills install <skill-slug> --global
openclaw skills install @owner/<skill-slug>
openclaw skills install @owner/<skill-slug> --version <version>
openclaw skills install @owner/<skill-slug> --force
openclaw skills install @owner/<skill-slug> --global
openclaw skills update --all
openclaw skills update --all --global
openclaw skills list --eligible
@@ -433,11 +433,11 @@ lives on the [First-run FAQ](/help/faq-first-run).
Install skills:
```bash
openclaw skills install <skill-slug>
openclaw skills install @owner/<skill-slug>
openclaw skills update --all
```
Native installs land in the active workspace `skills/` directory. For shared skills across all local agents, use `openclaw skills install <slug> --global` (or place them manually in `~/.openclaw/skills/<name>/SKILL.md`). If only some agents should see a shared install, configure `agents.defaults.skills` or `agents.list[].skills`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills), [Skills config](/tools/skills-config), and [ClawHub](/tools/clawhub).
Native installs land in the active workspace `skills/` directory. For shared skills across all local agents, use `openclaw skills install @owner/<skill-slug> --global` (or place them manually in `~/.openclaw/skills/<name>/SKILL.md`). If only some agents should see a shared install, configure `agents.defaults.skills` or `agents.list[].skills`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills), [Skills config](/tools/skills-config), and [ClawHub](/tools/clawhub).
</Accordion>

View File

@@ -110,14 +110,18 @@ systemctl --user daemon-reload
### Windows (Scheduled Task)
Default task name is `OpenClaw Gateway` (or `OpenClaw Gateway (<profile>)`).
The task script lives under your state dir.
The task script lives under your state dir as `gateway.cmd`; current installs may
also create a windowless `gateway.vbs` launcher that Task Scheduler runs instead
of opening `gateway.cmd` directly.
```powershell
schtasks /Delete /F /TN "OpenClaw Gateway"
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.cmd"
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.cmd" -ErrorAction SilentlyContinue
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.vbs" -ErrorAction SilentlyContinue
```
If you used a profile, delete the matching task name and `~\.openclaw-<profile>\gateway.cmd`.
If you used a profile, delete the matching task name and the `gateway.cmd` /
`gateway.vbs` files under `~\.openclaw-<profile>`.
## Normal install vs source checkout

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -124,8 +124,11 @@ openclaw gateway status --json
```
Native Windows CLI and Gateway flows are supported and continue to improve.
Managed startup uses Windows Scheduled Tasks when available and falls back to a
per-user Startup-folder login item if task creation is denied.
Managed startup uses Windows Scheduled Tasks when available. The task keeps the
readable `gateway.cmd` script in the OpenClaw state dir, but launches it through
a generated `gateway.vbs` WScript wrapper so the background Gateway does not open
a visible console window. If task creation is denied, OpenClaw falls back to a
per-user Startup-folder login item.
To install the Gateway service:

View File

@@ -259,14 +259,10 @@ under `describe("runSideQuestion")`.
fallback for whatever runtimes do not have a peer surface.
- PI session state is not migrated when an agent switches to `copilot`.
Selection is per attempt; existing PI sessions remain valid.
- **Interactive `ask_user` is not yet wired.** The SDK's
`onUserInputRequest` handler is intentionally not registered, which
per the SDK contract hides the `ask_user` tool from the model
entirely. Agents running under this harness make best-judgment
decisions from the initial prompt rather than asking clarifying
questions mid-turn. A follow-up will port the codex pattern at
`extensions/codex/src/app-server/user-input-bridge.ts` to route SDK
`UserInputRequest`s through the OpenClaw channel/TUI prompt path.
- `ask_user` uses the same OpenClaw prompt-and-reply path as the Codex
harness. When the Copilot SDK asks for user input, OpenClaw posts a
blocking prompt to the active channel/TUI and the next queued user
message resolves the SDK request.
## Permissions and ask_user
@@ -328,11 +324,15 @@ the tool bridge. The bridge also forwards the bounded tool-construction
controls it can enforce at the SDK boundary: `includeCoreTools`, the
runtime tool allowlist, and `toolConstructionPlan`.
The remaining PI tool-search/code-mode fields are intentionally **not**
forwarded at MVP and tracked as follow-ups: `toolSearchCatalogRef`,
`includeToolSearchControls`, and `toolSearchCatalogExecutor`. Those
controls drive PI's native tool-search UI and have no direct Copilot SDK
analog yet.
The bridge also uses the shared harness tool-surface helper from
`openclaw/plugin-sdk/agent-harness-tool-runtime` for PI parity. When
tool-search is enabled, the SDK sees compact control tools plus a hidden
catalog executor instead of every OpenClaw tool schema. When code mode is
enabled, the helper builds the same code-mode control surface and catalog
lifecycle used by other agent harnesses. Local-model lean defaults,
runtime-compatible schema filtering, directory hydration, and catalog
cleanup all stay in the shared helper so Copilot and Codex-adjacent
harnesses do not drift.
### Session-level GitHub token
@@ -349,7 +349,10 @@ When the resolved mode is `useLoggedInUser`, the session-level field
is omitted so the SDK keeps deriving identity from the logged-in
identity.
`ask_user` is intentionally hidden — see Limitations above.
`ask_user` uses `SessionConfig.onUserInputRequest`. The bridge accepts
choice indexes or labels for fixed-choice requests, accepts free-form
answers when the SDK request allows them, and cancels a pending request
when the OpenClaw attempt is aborted.
## Related

View File

@@ -196,6 +196,23 @@ finish. Both helpers accept the same `{ event, ctx }` payload as
`runAgentHarnessAgentEndHook(...)`; their failures do not alter the completed
attempt result.
### User input and tool surfaces
Native harnesses that expose a runtime-level user-input request should use the
user-input helpers from `openclaw/plugin-sdk/agent-harness-runtime` to format
the prompt, deliver it through OpenClaw's blocking reply path, and normalize
choice/free-form answers back into the runtime's native response shape. The
helper keeps channel/TUI presentation consistent while each harness keeps its
own protocol parsing and pending-request lifecycle.
Native harnesses that need PI-like compact tool routing should use
`createAgentHarnessToolSurfaceRuntime(...)` from
`openclaw/plugin-sdk/agent-harness-tool-runtime`. It owns
tool-search/code-mode control selection, local-model lean defaults,
runtime-compatible schema filtering, hidden catalog execution, directory
hydration, and catalog cleanup. Harnesses still own their SDK-specific tool
conversion and native execution callback.
### Native Codex harness mode
The bundled `codex` harness is the native Codex mode for embedded OpenClaw

View File

@@ -84,8 +84,8 @@ Choose the Token Plan auth choice that matches the regional base URL shown in Xi
| Model ref | Input | Context | Max output | Reasoning | Notes |
| --------------------------------- | ----------- | --------- | ---------- | --------- | ------------- |
| `xiaomi-token-plan/mimo-v2.5-pro` | text | 1,048,576 | 32,000 | Yes | Default model |
| `xiaomi-token-plan/mimo-v2.5` | text, image | 1,048,576 | 32,000 | Yes | Multimodal |
| `xiaomi-token-plan/mimo-v2.5-pro` | text | 1,048,576 | 131,072 | Yes | Default model |
| `xiaomi-token-plan/mimo-v2.5` | text, image | 1,048,576 | 131,072 | Yes | Multimodal |
<Tip>
Token Plan onboarding validates the key shape and warns when a `tp-...` key is entered into the pay-as-you-go path, or an `sk-...` key is entered into the Token Plan path.
@@ -222,7 +222,7 @@ Token Plan:
reasoning: true,
input: ["text"],
contextWindow: 1048576,
maxTokens: 32000,
maxTokens: 131072,
},
{
id: "mimo-v2.5",
@@ -230,7 +230,7 @@ Token Plan:
reasoning: true,
input: ["text", "image"],
contextWindow: 1048576,
maxTokens: 32000,
maxTokens: 131072,
},
],
},

View File

@@ -67,7 +67,7 @@ Wraps papla.media TTS and sends results as Telegram voice notes (no annoying aut
<img src="/assets/showcase/papla-tts.jpg" alt="Telegram voice note output from TTS" />
</Card>
<Card title="CodexMonitor" icon="eye" href="https://clawhub.ai/odrobnik/codexmonitor">
<Card title="CodexMonitor" icon="eye" href="https://clawhub.ai/odrobnik/skills/codexmonitor">
**@odrobnik** • `devtools` `codex` `brew`
Homebrew-installed helper to list, inspect, and watch local OpenAI Codex sessions (CLI + VS Code).
@@ -75,7 +75,7 @@ Homebrew-installed helper to list, inspect, and watch local OpenAI Codex session
<img src="/assets/showcase/codexmonitor.png" alt="CodexMonitor on ClawHub" />
</Card>
<Card title="Bambu 3D Printer Control" icon="print" href="https://clawhub.ai/tobiasbischoff/bambu-cli">
<Card title="Bambu 3D Printer Control" icon="print" href="https://clawhub.ai/tobiasbischoff/skills/bambu-cli">
**@tobiasbischoff** • `hardware` `3d-printing` `skill`
Control and troubleshoot BambuLab printers: status, jobs, camera, AMS, calibration, and more.
@@ -83,7 +83,7 @@ Control and troubleshoot BambuLab printers: status, jobs, camera, AMS, calibrati
<img src="/assets/showcase/bambu-cli.png" alt="Bambu CLI skill on ClawHub" />
</Card>
<Card title="Vienna transport (Wiener Linien)" icon="train" href="https://clawhub.ai/hjanuschka/wienerlinien">
<Card title="Vienna transport (Wiener Linien)" icon="train" href="https://clawhub.ai/hjanuschka/skills/wienerlinien">
**@hjanuschka** • `travel` `transport` `skill`
Real-time departures, disruptions, elevator status, and routing for Vienna's public transport.
@@ -97,7 +97,7 @@ Real-time departures, disruptions, elevator status, and routing for Vienna's pub
Automated UK school meal booking via ParentPay. Uses mouse coordinates for reliable table cell clicking.
</Card>
<Card title="R2 upload (Send Me My Files)" icon="cloud-arrow-up" href="https://clawhub.ai/skills/r2-upload">
<Card title="R2 upload (Send Me My Files)" icon="cloud-arrow-up" href="https://clawhub.ai/julianengel/skills/r2-upload">
**@julianengel** • `files` `r2` `presigned-urls`
Upload to Cloudflare R2/S3 and generate secure presigned download links. Useful for remote OpenClaw instances.
@@ -267,7 +267,7 @@ Speech-first entry points, phone bridges, and transcription-heavy workflows.
Vapi voice assistant to OpenClaw HTTP bridge. Near real-time phone calls with your agent.
</Card>
<Card title="OpenRouter transcription" icon="microphone" href="https://clawhub.ai/obviyus/openrouter-transcribe">
<Card title="OpenRouter transcription" icon="microphone" href="https://clawhub.ai/obviyus/skills/openrouter-transcribe">
**@obviyus** • `transcription` `multilingual` `skill`
Multi-lingual audio transcription via OpenRouter (Gemini, and more). Available on ClawHub.
@@ -289,8 +289,8 @@ Packaging, deployment, and integrations that make OpenClaw easier to run and ext
OpenClaw gateway running on Home Assistant OS with SSH tunnel support and persistent state.
</Card>
<Card title="Home Assistant skill" icon="toggle-on" href="https://clawhub.ai/skills/homeassistant">
**ClawHub**`homeassistant` `skill` `automation`
<Card title="Home Assistant skill" icon="toggle-on" href="https://clawhub.ai/homeofe/skills/openclaw-homeassistant">
**@homeofe** • `homeassistant` `skill` `automation`
Control and automate Home Assistant devices via natural language.
@@ -303,8 +303,8 @@ Control and automate Home Assistant devices via natural language.
Batteries-included nixified OpenClaw configuration for reproducible deployments.
</Card>
<Card title="CalDAV calendar" icon="calendar" href="https://clawhub.ai/skills/caldav-calendar">
**ClawHub**`calendar` `caldav` `skill`
<Card title="CalDAV calendar" icon="calendar" href="https://clawhub.ai/asleep123/skills/caldav-calendar">
**@asleep123** • `calendar` `caldav` `skill`
Calendar skill using khal and vdirsyncer. Self-hosted calendar integration.

View File

@@ -135,3 +135,753 @@ html.dark .nav-tabs-underline {
grid-template-columns: 1fr;
}
}
.maturity-hero {
display: grid;
gap: 14px;
margin: 10px 0 38px;
padding: 4px 0 26px 20px;
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 22%, transparent);
border-left: 3px solid rgb(var(--primary));
}
.maturity-hero-compact {
margin-bottom: 34px;
}
.maturity-hero h2 {
max-width: 46rem;
margin: 0;
font-size: clamp(26px, 3vw, 38px);
line-height: 1.08;
letter-spacing: -0.01em;
}
.maturity-hero-title {
max-width: 46rem;
margin: 0;
font-size: clamp(26px, 3vw, 38px);
font-weight: 750;
line-height: 1.08;
letter-spacing: -0.01em;
}
.maturity-hero > p:not(.maturity-kicker):not(.maturity-jump-links) {
max-width: 58rem;
margin: 0;
font-size: 16px;
line-height: 1.65;
opacity: 0.76;
}
.maturity-kicker {
margin: 0;
color: rgb(var(--primary));
font-size: 10px;
font-weight: 750;
letter-spacing: 0.1em;
line-height: 1.3;
text-transform: uppercase;
}
.maturity-jump-links {
margin: 0;
font-size: 13px;
line-height: 1.5;
}
.maturity-jump-links a {
text-decoration: none;
}
.maturity-jump-links a:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.maturity-score-stable,
.maturity-band-stable {
color: #4ca574;
}
.maturity-score-beta,
.maturity-band-beta {
color: #849fd2;
}
.maturity-score-alpha,
.maturity-band-alpha {
color: #d39a4b;
}
.maturity-score-experimental,
.maturity-band-experimental {
color: #dc7669;
}
.maturity-score-clawesome,
.maturity-band-clawesome {
color: #46b59a;
}
.maturity-level-pill {
display: inline-flex;
align-items: center;
gap: 6px;
width: max-content;
max-width: 100%;
padding: 3px 8px;
border: 1px solid color-mix(in oklab, currentColor 32%, transparent);
border-radius: 999px;
background: color-mix(in oklab, currentColor 10%, transparent);
color: inherit;
font-size: 10px;
font-weight: 750;
line-height: 1.25;
white-space: nowrap;
}
.maturity-level-code {
font-size: 9px;
letter-spacing: 0.04em;
opacity: 0.72;
}
.maturity-level-experimental {
color: #dc7669;
}
.maturity-level-alpha {
color: #d39a4b;
}
.maturity-level-beta {
color: #849fd2;
}
.maturity-level-stable {
color: #4ca574;
}
.maturity-level-clawesome {
color: #46b59a;
}
.maturity-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin: 14px 0 20px;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent);
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent);
}
.maturity-summary-item {
display: grid;
gap: 10px;
min-width: 0;
padding: 18px 20px 18px 0;
}
.maturity-summary-item + .maturity-summary-item {
padding-left: 20px;
border-left: 1px solid color-mix(in oklab, rgb(var(--primary)) 14%, transparent);
}
.maturity-summary-heading {
display: flex;
align-items: baseline;
gap: 9px;
}
.maturity-summary-value {
display: inline-block;
font-size: 30px;
font-weight: 750;
letter-spacing: -0.04em;
}
.maturity-summary-heading > span:not(.maturity-summary-value) {
font-size: 13px;
font-weight: 700;
}
.maturity-summary-bar {
height: 7px;
overflow: hidden;
background: color-mix(in oklab, currentColor 14%, transparent);
}
.maturity-summary-bar span {
display: block;
width: calc(var(--score) * 1%);
height: 100%;
background: currentColor;
}
.maturity-summary-meta {
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
font-size: 11px;
line-height: 1.4;
}
.maturity-summary-meta span:first-child {
font-weight: 700;
}
.maturity-summary-meta span:last-child {
opacity: 0.62;
}
.maturity-band-list {
display: flex;
margin: 12px 0 30px;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
}
.maturity-band {
display: grid;
flex: 1 1 0;
gap: 3px;
padding: 10px 12px 11px 0;
}
.maturity-band + .maturity-band {
padding-left: 12px;
border-left: 1px solid color-mix(in oklab, rgb(var(--primary)) 12%, transparent);
}
.maturity-band-title {
font-size: 12px;
font-weight: 700;
}
.maturity-band-title + span {
color: inherit;
font-size: 11px;
opacity: 0.7;
}
.maturity-band > span:last-child {
color: inherit;
font-size: 11px;
opacity: 0.7;
}
.maturity-band span {
color: inherit;
font-size: 11px;
opacity: 0.7;
}
.maturity-band .maturity-level-pill {
font-size: 10px;
opacity: 1;
}
.maturity-band .maturity-level-pill span {
font-size: inherit;
opacity: inherit;
}
.maturity-score {
display: grid;
gap: 4px;
min-width: 0;
color: inherit;
font-size: 11px;
font-weight: 700;
}
.maturity-score-label {
display: flex;
justify-content: space-between;
gap: 6px;
line-height: 1.2;
}
.maturity-score-label > span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.maturity-score-label > span:last-child {
flex: 0 0 auto;
}
.maturity-score-label .maturity-level-pill {
gap: 4px;
padding: 2px 6px;
font-size: 9px;
}
.maturity-score-label-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.maturity-summary-meta .maturity-level-pill {
opacity: 1;
}
.maturity-meter {
display: inline-block;
width: 100%;
height: 3px;
overflow: hidden;
background: color-mix(in oklab, currentColor 15%, transparent);
vertical-align: middle;
}
.maturity-meter > span {
display: block;
width: 0;
height: 100%;
background: currentColor;
}
.maturity-score-unscored,
.maturity-lts-none {
color: inherit;
opacity: 0.52;
}
.maturity-surface-table {
display: grid;
gap: 0;
margin: 8px 0 22px;
}
.maturity-surface-row {
display: grid;
grid-template-columns: minmax(190px, 1.55fr) repeat(3, minmax(110px, 1fr)) minmax(72px, 0.55fr);
gap: 12px;
align-items: center;
padding: 13px 0;
border-top: 1px solid color-mix(in oklab, currentColor 14%, transparent);
}
.maturity-surface-row-header {
padding: 0 0 9px;
border-top: 0;
color: inherit;
font-size: 10px;
font-weight: 750;
letter-spacing: 0.04em;
opacity: 0.56;
text-transform: uppercase;
}
.maturity-surface-name {
display: grid;
gap: 3px;
min-width: 0;
border-bottom: 0 !important;
text-decoration: none;
}
.maturity-surface-name:hover .maturity-surface-title {
color: rgb(var(--primary));
}
.maturity-surface-title {
overflow-wrap: anywhere;
font-size: 13px;
font-weight: 700;
line-height: 1.25;
}
.maturity-surface-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.maturity-surface-meta > span:not(.maturity-level-pill) {
font-size: 11px;
opacity: 0.62;
}
.maturity-surface-meta .maturity-level-pill {
opacity: 1;
}
.maturity-surface-metric {
min-width: 0;
}
.maturity-surface-metric-label {
display: none;
font-size: 10px;
opacity: 0.62;
}
.maturity-surface-support {
justify-self: start;
}
.maturity-lts {
display: inline-flex;
align-items: center;
gap: 5px;
color: inherit;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.maturity-lts::before {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
content: "";
}
.maturity-lts-partial {
color: #d39a4b;
}
.maturity-lts-full {
color: #4ca574;
}
.maturity-evidence-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin: 14px 0 24px;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
}
.maturity-evidence-card {
display: grid;
gap: 4px;
min-width: 0;
padding: 14px 16px 14px 0;
}
.maturity-evidence-card + .maturity-evidence-card {
padding-left: 16px;
border-left: 1px solid color-mix(in oklab, rgb(var(--primary)) 12%, transparent);
}
.maturity-evidence-title {
font-size: 13px;
font-weight: 700;
}
.maturity-evidence-card span {
font-size: 11px;
line-height: 1.4;
opacity: 0.68;
}
.maturity-readiness-summary {
margin: 0 0 12px;
font-size: 12px;
opacity: 0.65;
}
.maturity-readiness-list {
display: grid;
margin: 0;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 14%, transparent);
}
.maturity-readiness-row {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(120px, 0.8fr) minmax(110px, 0.7fr);
gap: 14px;
align-items: center;
padding: 11px 0;
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 10%, transparent);
font-size: 11px;
}
.maturity-readiness-row-header {
padding: 8px 0;
border-bottom-color: color-mix(in oklab, rgb(var(--primary)) 14%, transparent);
font-size: 10px;
font-weight: 750;
letter-spacing: 0.04em;
opacity: 0.56;
text-transform: uppercase;
}
.maturity-readiness-area {
display: grid;
gap: 3px;
min-width: 0;
}
.maturity-readiness-title {
overflow-wrap: anywhere;
font-weight: 700;
}
.maturity-readiness-status {
font-size: 10px;
opacity: 0.62;
}
.maturity-readiness-status-ready {
color: #4ca574;
}
.maturity-readiness-status-partially-reviewed {
color: #d39a4b;
}
.maturity-readiness-status-needs-review {
color: #dc7669;
}
.maturity-category-list {
display: grid;
width: 100%;
margin-top: 4px;
overflow: hidden;
}
.maturity-category-row {
display: grid;
grid-template-columns: minmax(180px, 1.55fr) repeat(3, minmax(100px, 1fr)) minmax(140px, 1.2fr);
gap: 12px;
align-items: center;
padding: 12px 0;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 11%, transparent);
font-size: 11px;
}
.maturity-category-row-header {
padding: 8px 0;
border-top: 0;
color: inherit;
font-size: 10px;
font-weight: 750;
letter-spacing: 0.04em;
opacity: 0.56;
text-transform: uppercase;
}
.maturity-category-area {
display: grid;
gap: 3px;
min-width: 0;
}
.maturity-category-title {
overflow-wrap: anywhere;
font-weight: 700;
}
.maturity-category-area > span:last-child {
font-size: 10px;
opacity: 0.62;
}
.maturity-category-docs {
min-width: 0;
overflow-wrap: anywhere;
line-height: 1.4;
}
.maturity-category-docs a {
text-decoration: none;
}
.maturity-category-docs a:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.maturity-level-list {
display: grid;
margin: 12px 0 28px;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
}
.maturity-level-row {
display: grid;
grid-template-columns: minmax(130px, 0.32fr) minmax(0, 1fr);
gap: 4px 14px;
padding: 13px 0;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 11%, transparent);
}
.maturity-level-row:first-child {
border-top: 0;
}
.maturity-level-title {
grid-row: span 2;
font-size: 13px;
font-weight: 700;
}
.maturity-level-title .maturity-level-pill {
opacity: 1;
}
.maturity-level-row span,
.maturity-level-promotion {
font-size: 12px;
line-height: 1.45;
opacity: 0.68;
}
.maturity-surface-link {
display: grid;
gap: 3px;
margin: 0;
padding: 11px 0;
border-bottom: 1px solid color-mix(in oklab, currentColor 14%, transparent);
text-decoration: none;
}
.maturity-surface-link:hover {
color: rgb(var(--primary));
}
.maturity-surface-link .maturity-surface-title {
font-size: 13px;
font-weight: 700;
}
.maturity-surface-link > .maturity-surface-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.maturity-surface-link > .maturity-surface-meta > span:not(.maturity-level-pill) {
font-size: 11px;
opacity: 0.68;
}
.maturity-surface-link > .maturity-surface-meta .maturity-level-pill {
opacity: 1;
}
.maturity-surface-rollup {
display: flex;
flex-wrap: wrap;
gap: 5px 14px;
margin: 0 0 14px;
padding: 9px 0;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 13%, transparent);
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 13%, transparent);
}
.maturity-surface-rollup > span {
color: inherit;
font-size: 11px;
font-weight: 700;
}
#content table .maturity-score,
#content table .maturity-lts {
white-space: nowrap;
}
@media (max-width: 960px) {
.maturity-summary-grid,
.maturity-evidence-grid {
grid-template-columns: 1fr;
}
.maturity-summary-item,
.maturity-summary-item + .maturity-summary-item,
.maturity-evidence-card,
.maturity-evidence-card + .maturity-evidence-card {
padding: 14px 0;
border-left: 0;
}
.maturity-summary-item + .maturity-summary-item,
.maturity-evidence-card + .maturity-evidence-card {
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 12%, transparent);
}
.maturity-surface-row {
grid-template-columns: minmax(160px, 1.35fr) repeat(3, minmax(98px, 1fr)) minmax(70px, 0.5fr);
gap: 8px;
}
.maturity-category-row {
grid-template-columns: minmax(160px, 1.35fr) repeat(3, minmax(86px, 1fr)) minmax(110px, 1fr);
gap: 8px;
}
}
@media (max-width: 640px) {
.maturity-hero {
padding-left: 14px;
}
.maturity-surface-row-header {
display: none;
}
.maturity-surface-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 9px 12px;
}
.maturity-surface-metric {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 6px;
}
.maturity-surface-metric-label {
display: block;
}
.maturity-surface-support {
justify-self: end;
}
.maturity-readiness-row,
.maturity-category-row {
grid-template-columns: 1fr;
gap: 5px;
}
.maturity-readiness-row-header,
.maturity-category-row-header {
display: none;
}
.maturity-category-docs {
padding-top: 3px;
}
.maturity-band-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.maturity-band + .maturity-band {
padding-left: 0;
border-left: 0;
}
.maturity-level-row {
grid-template-columns: 1fr;
}
.maturity-level-title {
grid-row: auto;
}
}

View File

@@ -225,7 +225,7 @@ See [Skill Workshop](/tools/skill-workshop) for the full proposal lifecycle.
metadata:
```bash
openclaw skills install clawhub-publish
openclaw skills install @openclaw/clawhub-publish
```
</Step>

View File

@@ -88,8 +88,9 @@ still returns one synthesized answer with citations rather than an N-result
list.
`freshness` accepts `day`, `week`, `month`, `year`, and the shared shortcuts
`pd`, `pw`, `pm`, and `py`. OpenClaw converts these values, or an explicit
`date_after`/`date_before` range, into Gemini Google Search grounding's
`pd`, `pw`, `pm`, and `py`. `day`/`pd` adds a recency instruction to the Gemini
query instead of a hard 24-hour range. `week`, `month`, `year`, and explicit
`date_after`/`date_before` ranges set Gemini Google Search grounding's
`timeRangeFilter`. `country`, `language`, and `domain_filter` are not supported.
## Model selection

View File

@@ -145,15 +145,15 @@ publish and sync.
| Action | Command |
| ---------------------------------- | ------------------------------------------------------ |
| Install a skill into the workspace | `openclaw skills install <slug>` |
| Install a skill into the workspace | `openclaw skills install @owner/<slug>` |
| Install from a Git repository | `openclaw skills install git:owner/repo@ref` |
| Install a local skill directory | `openclaw skills install ./path/to/skill --as my-tool` |
| Install for all local agents | `openclaw skills install <slug> --global` |
| Install for all local agents | `openclaw skills install @owner/<slug> --global` |
| Update all workspace skills | `openclaw skills update --all` |
| Update a shared managed skill | `openclaw skills update <slug> --global` |
| Update a shared managed skill | `openclaw skills update @owner/<slug> --global` |
| Update all shared managed skills | `openclaw skills update --all --global` |
| Verify a skill's trust envelope | `openclaw skills verify <slug>` |
| Print the generated Skill Card | `openclaw skills verify <slug> --card` |
| Verify a skill's trust envelope | `openclaw skills verify @owner/<slug>` |
| Print the generated Skill Card | `openclaw skills verify @owner/<slug> --card` |
| Publish / sync via ClawHub CLI | `clawhub sync --all` |
<AccordionGroup>
@@ -171,15 +171,17 @@ publish and sync.
</Accordion>
<Accordion title="Verification and security scanning">
`openclaw skills verify <slug>` asks ClawHub for the skill's
`openclaw skills verify @owner/<slug>` asks ClawHub for the skill's
`clawhub.skill.verify.v1` trust envelope. Installed ClawHub skills verify
against the version and registry recorded in `.clawhub/origin.json`.
Bare slugs remain accepted for existing installed or unambiguous skills, but
owner-qualified refs avoid publisher ambiguity.
ClawHub skill pages expose the latest security scan state before install,
with detail pages for VirusTotal, ClawScan, and static analysis. The
command exits non-zero when ClawHub marks verification as failed. Publishers
recover false positives through the ClawHub dashboard or
`clawhub skill rescan <slug>`.
`clawhub skill rescan @owner/<slug>`.
</Accordion>
<Accordion title="Private archive installs">

View File

@@ -389,8 +389,8 @@ show the `x_search` prompt.
freshness ranges require both start and end dates.
Gemini, Grok, and Kimi return one synthesized answer with citations. They
accept `count` for shared-tool compatibility, but it does not change the
grounded answer shape. Gemini supports `freshness`, `date_after`, and
`date_before` by converting them to Google Search grounding time ranges.
grounded answer shape. Gemini treats `day` freshness as a recency hint; wider
freshness values and explicit dates set Google Search grounding time ranges.
Perplexity behaves the same way when you use the Sonar/OpenRouter
compatibility path (`plugins.entries.perplexity.config.webSearch.baseUrl` /
`model` or `OPENROUTER_API_KEY`).

View File

@@ -10,7 +10,7 @@
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "0.39.0",
"@zed-industries/codex-acp": "0.15.0",
"acpx": "0.10.0",
"acpx": "0.11.2",
"zod": "4.4.3"
}
},
@@ -196,9 +196,9 @@
}
},
"node_modules/@clack/core": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.1.tgz",
"integrity": "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.1.tgz",
"integrity": "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==",
"license": "MIT",
"dependencies": {
"fast-wrap-ansi": "^0.2.0",
@@ -209,12 +209,12 @@
}
},
"node_modules/@clack/prompts": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.5.1.tgz",
"integrity": "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.4.0.tgz",
"integrity": "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==",
"license": "MIT",
"dependencies": {
"@clack/core": "1.4.1",
"@clack/core": "1.3.1",
"fast-string-width": "^3.0.2",
"fast-wrap-ansi": "^0.2.0",
"sisteransi": "^1.0.5"
@@ -701,6 +701,7 @@
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.15.0.tgz",
"integrity": "sha512-eAv7sGBeiYrYkOulF729nrM51szS7WIhBtugRj5wWq6csRKZUhAZfoUZlF8xUWdHPtOIzd/eT6MNG6gMHu6z0w==",
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"bin": {
"codex-acp": "bin/codex-acp.js"
@@ -721,6 +722,7 @@
"cpu": [
"arm64"
],
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -737,6 +739,7 @@
"cpu": [
"x64"
],
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -753,6 +756,7 @@
"cpu": [
"arm64"
],
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -769,6 +773,7 @@
"cpu": [
"x64"
],
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -785,6 +790,7 @@
"cpu": [
"arm64"
],
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -801,6 +807,7 @@
"cpu": [
"x64"
],
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -824,15 +831,15 @@
}
},
"node_modules/acpx": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/acpx/-/acpx-0.10.0.tgz",
"integrity": "sha512-hd48XV03gG3sd409T1lDrOKJTTz1ap4g0wrndXjxQ590tN85pBYlvfNLyerybvGRrtUGsZjNdt99r1jpIt6ukA==",
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/acpx/-/acpx-0.11.2.tgz",
"integrity": "sha512-ksTmfJDVqUAJJXsNDamEno03AMZ/aAZzXk/h5nt61VsLc/jcpoDMfCVpErzuYNJjwCd0V6Zm5o6F8OoqxsjQWA==",
"license": "MIT",
"dependencies": {
"@agentclientprotocol/sdk": "^0.22.1",
"commander": "^14.0.3",
"skillflag": "^0.1.4",
"tsx": "^4.22.0",
"@agentclientprotocol/sdk": "^0.28.1",
"commander": "^15.0.0",
"skillflag": "^0.2.0",
"tsx": "^4.22.4",
"zod": "^4.4.3"
},
"bin": {
@@ -842,6 +849,15 @@
"node": ">=22.13.0"
}
},
"node_modules/acpx/node_modules/@agentclientprotocol/sdk": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.28.1.tgz",
"integrity": "sha512-Z2Frs6YtPhnZZ+XwFXyQkRDXY0fn8FjCalEs0W4yUhQnY4TztmNq0/RnfzWdFN3vqT3h0jTz5klzYbZHGxCDyQ==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -1043,12 +1059,12 @@
}
},
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
"license": "MIT",
"engines": {
"node": ">=20"
"node": ">=22.12.0"
}
},
"node_modules/content-disposition": {
@@ -2045,9 +2061,9 @@
"license": "MIT"
},
"node_modules/skillflag": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/skillflag/-/skillflag-0.1.4.tgz",
"integrity": "sha512-egFg+XCF5sloOWdtzxZivTX7n4UDj5pxQoY33wbT8h+YSDjMQJ76MZUg2rXQIBXmIDtlZhLgirS1g/3R5/qaHA==",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/skillflag/-/skillflag-0.2.0.tgz",
"integrity": "sha512-7ZmEpBeEoPLc+hqZ/StAnCO/hulgEPANzPyZgOM/CZ5zc3b0ApSp3URavY5POM/OKyi5d9+UC/Q21OoiYC2kJw==",
"license": "MIT",
"dependencies": {
"@clack/prompts": "^1.0.1",

View File

@@ -10,7 +10,7 @@
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "0.39.0",
"@zed-industries/codex-acp": "0.15.0",
"acpx": "0.10.0",
"acpx": "0.11.2",
"zod": "4.4.3"
},
"devDependencies": {

View File

@@ -251,6 +251,15 @@ describe("prepareAcpxCodexAuthConfig", () => {
expect(wrapper).not.toMatch(
/forceKillTimer = setTimeout\(\(\) => killChildTree\("SIGKILL"\), 1_500\);\s*forceKillTimer\.unref\?\.\(\);\s*process\.exit\(1\);/s,
);
// Orphan detection must trigger on any PPID change, not only when the new
// PPID is init (1). Systemd user services and container init reparent
// orphaned processes to a session manager or container init (PID != 1),
// and the older `process.ppid !== 1` guard would silently leak the codex
// adapter tree there.
expect(wrapper).not.toContain("process.ppid !== 1");
expect(wrapper).toMatch(
/setInterval\(\(\) => \{[\s\S]*?if \(process\.ppid === originalParentPid\) \{\s*return;\s*\}/,
);
});
it("uses the bundled Claude ACP dependency by default when it is installed", async () => {

View File

@@ -475,7 +475,13 @@ const parentWatcher =
process.platform === "win32"
? undefined
: setInterval(() => {
if (process.ppid === originalParentPid || process.ppid !== 1) {
// Orphan detection: parent PID changed means our original parent died.
// The new parent could be PID 1 (init) on bare-metal hosts, OR a
// systemd user-session manager, OR a container init, OR a session
// leader — depending on environment. Previously this only triggered
// on PPID == 1, which missed all systemd-managed deployments and
// leaked codex-acp adapter trees on every gateway restart.
if (process.ppid === originalParentPid) {
return;
}
if (orphanCleanupStarted) {

View File

@@ -2,6 +2,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { RequestedModelUnsupportedError } from "acpx/runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
AcpRuntimeError,
@@ -708,6 +709,100 @@ describe("AcpxRuntime fresh reset wrapper", () => {
});
});
it("retries without a model when ACPX reports missing model capability", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime, delegate } = makeRuntime(baseStore, {
agentRegistry: {
resolve: (agentName: string) => (agentName === "opencode" ? "opencode acp" : agentName),
list: () => ["opencode"],
},
});
const ensure = vi
.spyOn(delegate, "ensureSession")
.mockRejectedValueOnce(
new RequestedModelUnsupportedError(
"Cannot apply --model: the ACP agent did not advertise model support",
"missing-capability",
),
)
.mockResolvedValueOnce({
sessionKey: "agent:opencode:acp:test",
backend: "acpx",
runtimeSessionName: "opencode",
});
await runtime.ensureSession({
sessionKey: "agent:opencode:acp:test",
agent: "opencode",
mode: "persistent",
model: "openrouter/owl-alpha",
});
expect(ensure).toHaveBeenCalledTimes(2);
expect(readFirstEnsureSessionInput(ensure)).toMatchObject({
model: "openrouter/owl-alpha",
sessionOptions: { model: "openrouter/owl-alpha" },
});
const [, secondCall] = ensure.mock.calls;
expect(secondCall?.[0]).not.toHaveProperty("sessionOptions");
expect((secondCall?.[0] as { model?: string } | undefined)?.model).toBeUndefined();
});
it("does not retry when ACPX rejects an explicitly unsupported model id", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime, delegate } = makeRuntime(baseStore, {
agentRegistry: {
resolve: (agentName: string) => (agentName === "opencode" ? "opencode acp" : agentName),
list: () => ["opencode"],
},
});
const ensure = vi
.spyOn(delegate, "ensureSession")
.mockRejectedValueOnce(
new RequestedModelUnsupportedError(
"Cannot apply --model: the ACP agent did not advertise that model",
"unadvertised-model",
),
);
await expect(
runtime.ensureSession({
sessionKey: "agent:opencode:acp:test",
agent: "opencode",
mode: "persistent",
model: "unknown/model",
}),
).rejects.toThrow("did not advertise that model");
expect(ensure).toHaveBeenCalledTimes(1);
});
it("does not retry an unrelated error with similar wording", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime, delegate } = makeRuntime(baseStore);
const ensure = vi
.spyOn(delegate, "ensureSession")
.mockRejectedValueOnce(new Error("the ACP agent did not advertise model support"));
await expect(
runtime.ensureSession({
sessionKey: "agent:main:acp:test",
agent: "main",
mode: "persistent",
model: "openrouter/owl-alpha",
}),
).rejects.toThrow("did not advertise model support");
expect(ensure).toHaveBeenCalledTimes(1);
});
it("injects Codex ACP startup config into the scoped registry", () => {
expect(testing.isCodexAcpCommand(CODEX_ACP_COMMAND)).toBe(true);
expect(testing.isCodexAcpCommand(CODEX_ACP_WRAPPER_COMMAND)).toBe(true);

View File

@@ -13,6 +13,7 @@ import {
createFileSessionStore,
decodeAcpxRuntimeHandleState,
encodeAcpxRuntimeHandleState,
isRequestedModelUnsupportedError,
type AcpAgentRegistry,
type AcpRuntimeDoctorReport,
type AcpRuntimeEvent,
@@ -586,6 +587,26 @@ function withAcpxSessionOptions(input: OpenClawRuntimeEnsureInput): AcpxDelegate
} as AcpxDelegateEnsureInput;
}
function isAcpModelCapabilityMissingError(error: unknown): boolean {
return isRequestedModelUnsupportedError(error) && error.reason === "missing-capability";
}
// ACPX owns the distinction between missing model capability and an invalid model id.
// Retry only the former so explicit model mistakes remain visible to the caller.
async function ensureDelegateSessionWithModelFallback(
delegate: BaseAcpxRuntime,
input: OpenClawRuntimeEnsureInput,
): Promise<AcpRuntimeHandle> {
try {
return await delegate.ensureSession(withAcpxSessionOptions(input));
} catch (error) {
if (!input.model || !isAcpModelCapabilityMissingError(error)) {
throw error;
}
return await delegate.ensureSession(withAcpxSessionOptions({ ...input, model: undefined }));
}
}
function quoteShellArg(value: string): string {
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
return value;
@@ -989,7 +1010,7 @@ export class AcpxRuntime implements AcpRuntime {
this.withCodexWrapperDiagnostics({
command: stableLaunchCommand,
fallbackCode: "ACP_SESSION_INIT_FAILED",
run: () => delegate.ensureSession(withAcpxSessionOptions(ensureInput)),
run: () => ensureDelegateSessionWithModelFallback(delegate, ensureInput),
}),
});
}

View File

@@ -1,6 +1,6 @@
{
"id": "alibaba",
"icon": "https://cdn.simpleicons.org/alibabacloud/111111",
"icon": "https://cdn.simpleicons.org/alibabacloud",
"activation": {
"onStartup": false
},

View File

@@ -2,7 +2,7 @@
"id": "anthropic-vertex",
"name": "Anthropic Vertex",
"description": "OpenClaw Anthropic Vertex provider plugin for Claude models on Google Vertex AI.",
"icon": "https://cdn.simpleicons.org/anthropic/111111",
"icon": "https://cdn.simpleicons.org/anthropic",
"activation": {
"onStartup": false
},

View File

@@ -1,6 +1,6 @@
{
"id": "anthropic",
"icon": "https://cdn.simpleicons.org/anthropic/111111",
"icon": "https://cdn.simpleicons.org/anthropic",
"activation": {
"onStartup": false
},

View File

@@ -2,7 +2,7 @@
"id": "brave",
"name": "Brave",
"description": "OpenClaw Brave Search provider plugin for web search.",
"icon": "https://cdn.simpleicons.org/brave/111111",
"icon": "https://cdn.simpleicons.org/brave",
"activation": {
"onStartup": false
},

View File

@@ -43,23 +43,39 @@ afterAll(() => {
vi.resetModules();
});
function jsonResponse(payload: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
function malformedJsonResponse(): Response {
return new Response("{ nope", {
status: 200,
headers: { "content-type": "application/json" },
});
}
function emptyWebSearchResponse(): Response {
return jsonResponse({ web: { results: [] } });
}
function installBraveLlmContextFetch() {
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return {
ok: true,
json: async () => ({
grounding: {
generic: [
{
url: "https://example.com/context",
title: "Context",
snippets: ["snippet"],
},
],
},
sources: [],
}),
} as unknown as Response;
return jsonResponse({
grounding: {
generic: [
{
url: "https://example.com/context",
title: "Context",
snippets: ["snippet"],
},
],
},
sources: [],
});
});
global.fetch = mockFetch as typeof global.fetch;
return mockFetch;
@@ -254,10 +270,7 @@ describe("brave web search provider", () => {
it("uses configured Brave baseUrl for web search requests", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return {
ok: true,
json: async () => ({ web: { results: [] } }),
} as unknown as Response;
return emptyWebSearchResponse();
});
global.fetch = mockFetch as typeof global.fetch;
@@ -310,12 +323,7 @@ describe("brave web search provider", () => {
it("reports malformed Brave web search JSON as a provider error", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return {
ok: true,
json: async () => {
throw new SyntaxError("Unexpected token");
},
} as unknown as Response;
return malformedJsonResponse();
});
global.fetch = mockFetch as typeof global.fetch;
@@ -339,12 +347,7 @@ describe("brave web search provider", () => {
it("reports malformed Brave llm-context JSON as a provider error", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return {
ok: true,
json: async () => {
throw new SyntaxError("Unexpected token");
},
} as unknown as Response;
return malformedJsonResponse();
});
global.fetch = mockFetch as typeof global.fetch;
@@ -428,10 +431,7 @@ describe("brave web search provider", () => {
it("keeps Brave cache entries isolated by baseUrl", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return {
ok: true,
json: async () => ({ web: { results: [] } }),
} as unknown as Response;
return emptyWebSearchResponse();
});
global.fetch = mockFetch as typeof global.fetch;
@@ -573,10 +573,7 @@ describe("brave web search provider", () => {
it("sends Brave web auth in the X-Subscription-Token header", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return {
ok: true,
json: async () => ({ web: { results: [] } }),
} as unknown as Response;
return emptyWebSearchResponse();
});
global.fetch = mockFetch as typeof global.fetch;
@@ -732,10 +729,7 @@ describe("brave web search provider", () => {
it("falls back unsupported country values before calling Brave", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return {
ok: true,
json: async () => ({ web: { results: [] } }),
} as unknown as Response;
return emptyWebSearchResponse();
});
global.fetch = mockFetch as typeof global.fetch;
@@ -763,21 +757,17 @@ describe("brave web search provider", () => {
it("emits brave.http diagnostics for requests, responses, and cache events", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return {
ok: true,
status: 200,
json: async () => ({
web: {
results: [
{
title: "Diagnostics",
url: "https://example.com/diagnostics",
description: "debug details",
},
],
},
}),
} as unknown as Response;
return jsonResponse({
web: {
results: [
{
title: "Diagnostics",
url: "https://example.com/diagnostics",
description: "debug details",
},
],
},
});
});
global.fetch = mockFetch as typeof global.fetch;

View File

@@ -15,8 +15,12 @@ import { resolvePnpmRunner } from "./pnpm-runner.mjs";
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const rootDir = path.resolve(pluginDir, "../..");
const require = createRequire(import.meta.url);
const hashFile = path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash");
const outputFile = path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
const hashFile =
process.env.OPENCLAW_A2UI_BUNDLE_HASH_FILE ??
path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash");
const outputFile =
process.env.OPENCLAW_A2UI_BUNDLE_OUT ??
path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
const a2uiAppDir = path.join(pluginDir, "src", "host", "a2ui-app");
const repoInputPaths = getBundleHashRepoInputPaths(rootDir);
const relativeRepoInputPaths = repoInputPaths.map((inputPath) =>

View File

@@ -11,7 +11,9 @@ const repoRoot = path.resolve(here, "../../../../..");
const require = createRequire(import.meta.url);
const uiRoot = path.resolve(repoRoot, "ui");
const fromHere = (p) => path.resolve(here, p);
const outputFile = path.resolve(here, "..", "a2ui", "a2ui.bundle.js");
const outputFile = process.env.OPENCLAW_A2UI_BUNDLE_OUT
? path.resolve(process.env.OPENCLAW_A2UI_BUNDLE_OUT)
: path.resolve(here, "..", "a2ui", "a2ui.bundle.js");
const a2uiLitIndex = require.resolve("@a2ui/lit");
const a2uiLitUi = require.resolve("@a2ui/lit/ui");

View File

@@ -15,6 +15,14 @@ function restoreEnvVar(name: string, value: string | undefined): void {
}
}
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
async function runChutesCatalog(params: { apiKey?: string; discoveryApiKey?: string }) {
const provider = await registerSingleProviderPlugin(plugin);
const result = await provider.catalog?.run({
@@ -44,10 +52,9 @@ async function withRealChutesDiscovery<T>(
delete process.env.VITEST;
delete process.env.NODE_ENV;
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ id: "chutes/private-model" }] }),
});
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ data: [{ id: "chutes/private-model" }] }));
globalThis.fetch = fetchMock as unknown as typeof fetch;
try {

View File

@@ -15,6 +15,14 @@ function restoreEnvVar(name: string, value: string | undefined): void {
}
}
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
async function withLiveChutesDiscovery<T>(
fetchMock: ReturnType<typeof vi.fn>,
run: () => Promise<T>,
@@ -45,12 +53,11 @@ async function withLiveChutesDiscovery<T>(
function createAuthEchoFetchMock() {
return vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
const auth = readAuthorizationHeader(init);
return Promise.resolve({
ok: true,
json: async () => ({
return Promise.resolve(
jsonResponse({
data: [{ id: auth ? `${auth}-model` : "public-model" }],
}),
});
);
});
}
@@ -124,9 +131,8 @@ describe("chutes-models", () => {
});
it("discoverChutesModels correctly maps API response when not in test env", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
{ id: "zai-org/GLM-4.7-TEE" },
{
@@ -140,7 +146,7 @@ describe("chutes-models", () => {
{ id: "new-provider/simple-model" },
],
}),
});
);
await withLiveChutesDiscovery(mockFetch, async () => {
const models = await discoverChutesModels("test-token-real-fetch");
expect(models.length).toBeGreaterThan(0);
@@ -158,9 +164,8 @@ describe("chutes-models", () => {
});
it("falls back from malformed live token metadata", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
{
id: "provider/bad-window",
@@ -174,7 +179,7 @@ describe("chutes-models", () => {
},
],
}),
});
);
await withLiveChutesDiscovery(mockFetch, async () => {
const models = await discoverChutesModels("malformed-token-metadata");
@@ -195,14 +200,10 @@ describe("chutes-models", () => {
it("discoverChutesModels retries without auth on 401", async () => {
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
if (readAuthorizationHeader(init) === "Bearer test-token-error") {
return Promise.resolve({
ok: false,
status: 401,
});
return Promise.resolve(new Response("", { status: 401 }));
}
return Promise.resolve({
ok: true,
json: async () => ({
return Promise.resolve(
jsonResponse({
data: [
{
id: "Qwen/Qwen3-32B",
@@ -232,7 +233,7 @@ describe("chutes-models", () => {
},
],
}),
});
);
});
await withLiveChutesDiscovery(mockFetch, async () => {
const models = await discoverChutesModels("test-token-error");
@@ -242,10 +243,7 @@ describe("chutes-models", () => {
});
it("does not cache fallback static catalog for non-OK responses", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 503,
});
const mockFetch = vi.fn().mockResolvedValue(new Response("", { status: 503 }));
await withLiveChutesDiscovery(mockFetch, async () => {
const first = await discoverChutesModels("chutes-fallback-token");
@@ -260,27 +258,24 @@ describe("chutes-models", () => {
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
const auth = readAuthorizationHeader(init);
if (auth === "Bearer chutes-token-a") {
return Promise.resolve({
ok: true,
json: async () => ({
return Promise.resolve(
jsonResponse({
data: [{ id: "private/model-a" }],
}),
});
);
}
if (auth === "Bearer chutes-token-b") {
return Promise.resolve({
ok: true,
json: async () => ({
return Promise.resolve(
jsonResponse({
data: [{ id: "private/model-b" }],
}),
});
);
}
return Promise.resolve({
ok: true,
json: async () => ({
return Promise.resolve(
jsonResponse({
data: [{ id: "public/model" }],
}),
});
);
});
await withLiveChutesDiscovery(mockFetch, async () => {
const modelsA = await discoverChutesModels("chutes-token-a");
@@ -325,17 +320,13 @@ describe("chutes-models", () => {
it("does not cache 401 fallback under the failed token key", async () => {
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
if (readAuthorizationHeader(init) === "Bearer failed-token") {
return Promise.resolve({
ok: false,
status: 401,
});
return Promise.resolve(new Response("", { status: 401 }));
}
return Promise.resolve({
ok: true,
json: async () => ({
return Promise.resolve(
jsonResponse({
data: [{ id: "public/model" }],
}),
});
);
});
await withLiveChutesDiscovery(mockFetch, async () => {
await discoverChutesModels("failed-token");

View File

@@ -1,6 +1,6 @@
{
"id": "cloudflare-ai-gateway",
"icon": "https://cdn.simpleicons.org/cloudflare/111111",
"icon": "https://cdn.simpleicons.org/cloudflare",
"activation": {
"onStartup": false
},

View File

@@ -78,13 +78,16 @@ type CodexWorkspaceBootstrapContext = CodexBootstrapContext & {
};
/** Reads mirrored Codex session history for harness hooks. */
export async function readMirroredSessionHistoryMessages(
sessionFile: string,
): Promise<AgentMessage[] | undefined> {
const messages = await readCodexMirroredSessionHistoryMessages(sessionFile);
export async function readMirroredSessionHistoryMessages(params: {
agentId?: string;
sessionFile: string;
sessionId: string;
sessionKey?: string;
}): Promise<AgentMessage[] | undefined> {
const messages = await readCodexMirroredSessionHistoryMessages(params);
if (!messages) {
embeddedAgentLog.warn("failed to read mirrored session history for codex harness hooks", {
sessionFile,
sessionFile: params.sessionFile,
});
}
return messages;

View File

@@ -1102,6 +1102,426 @@ describe("createCodexDynamicToolBridge", () => {
]);
});
it("marks delivered message-tool-only source replies as terminal", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { messageId: "imessage-6264" }),
{ sourceReplyDeliveryMode: "message_tool_only" },
);
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps message-tool-only source replies terminal when middleware redacts receipt details", async () => {
const registry = createEmptyPluginRegistry();
registry.agentToolResultMiddlewares.push({
pluginId: "receipt-redactor",
pluginName: "Receipt redactor",
rawHandler: () => undefined,
handler: (event: { result: AgentToolResult<unknown> }) => ({
result: {
content: event.result.content,
details: { redacted: true },
},
}),
runtimes: ["codex"],
source: "test",
});
setActivePluginRegistry(registry);
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", {
receipt: {
primaryPlatformMessageId: "imessage-6264",
platformMessageIds: ["imessage-6264"],
},
}),
{ sourceReplyDeliveryMode: "message_tool_only" },
);
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("does not treat target telemetry alone as delivered message-tool-only source reply evidence", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "chat-1",
});
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
expect.objectContaining({
tool: "message",
provider: "imessage",
to: "chat-1",
text: "visible reply",
}),
]);
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("keeps message-tool-only source replies terminal for explicit current source routes", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { ok: true, messageId: "imessage-853" }),
{
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:+12069106512",
currentMessagingTarget: "+12069106512",
},
);
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "853",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps message-tool-only source replies terminal when the reply receipt matches the current message id", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", {
ok: true,
messageId: "provider-message-1",
repliedTo: "provider-guid-857",
}),
{
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:any;-;+12069106512",
currentMessageId: "provider-guid-857",
},
);
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "857",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
expect.objectContaining({
tool: "message",
provider: "imessage",
to: "+12069106512",
text: "visible reply",
}),
]);
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps message-tool-only source replies terminal when a text receipt matches the current message id", async () => {
const receiptText = JSON.stringify({
ok: true,
messageId: "provider-message-1",
repliedTo: "provider-guid-861",
});
const bridge = createBridgeWithToolResult("message", textToolResult(receiptText), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:any;-;+12069106512",
currentMessageId: "provider-guid-861",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "861",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText(receiptText));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps message-tool-only source replies terminal for explicit native target segments", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:any;-;+12069106512",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "863",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps message-tool-only source replies terminal when the provider is only in the current channel id", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelId: "imessage:any;-;+12069106512",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "865",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("records message-tool-owned terminal replies as delivered source replies", async () => {
const bridge = createBridgeWithToolResult(
"message",
{
...textToolResult("Sent.", { ok: true }),
terminate: true,
} as AgentToolResult<unknown>,
{ sourceReplyDeliveryMode: "message_tool_only" },
);
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "867",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("does not treat bare send telemetry as delivered message-tool-only source reply evidence", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
sourceReplyDeliveryMode: "message_tool_only",
});
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not let prior message-send telemetry terminate a later non-delivery tool result", async () => {
const execute = vi
.fn()
.mockResolvedValueOnce(textToolResult("Sent.", { messageId: "source-reply-1" }))
.mockResolvedValueOnce(textToolResult("No message sent.", { ok: true }));
const bridge = createCodexDynamicToolBridge({
tools: [createTool({ name: "message", execute })],
signal: new AbortController().signal,
hookContext: { sourceReplyDeliveryMode: "message_tool_only" },
});
const firstResult = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
const secondResult = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-2",
namespace: null,
tool: "message",
arguments: { action: "inspect" },
});
expect(firstResult.terminate).toBe(true);
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
expect(secondResult).toEqual(expectInputText("No message sent."));
expect(secondResult.terminate).toBeUndefined();
});
it("does not mark explicit message-tool sends as terminal source replies", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { messageId: "other-chat-message" }),
{ sourceReplyDeliveryMode: "message_tool_only" },
);
const result = await handleMessageToolCall(bridge, {
action: "send",
target: "channel:other",
message: "cross-channel reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not mark mismatched explicit message-tool sends as terminal source replies", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:+12069106512",
currentMessagingTarget: "+12069106512",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "slack",
target: "+12069106512",
messageId: "853",
message: "cross-provider reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not mark same-target sibling-thread replies as terminal source replies", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "slack",
currentChannelId: "slack:C123",
currentMessagingTarget: "C123",
currentThreadId: "171.222",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "slack",
target: "C123",
threadId: "171.333",
message: "sibling thread reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not mark implicit-target sibling-thread replies as terminal source replies", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "slack",
currentChannelId: "slack:C123",
currentMessagingTarget: "C123",
currentThreadId: "171.222",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "slack",
threadId: "171.333",
message: "sibling thread reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not mark top-level source replies with explicit thread routes as terminal", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "slack",
currentChannelId: "slack:C123",
currentMessagingTarget: "C123",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "slack",
target: "C123",
threadId: "171.333",
message: "thread reply from top-level source",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not let matching reply receipts override explicit non-source routes", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", {
ok: true,
messageId: "other-chat-message",
repliedTo: "provider-guid-853",
}),
{
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:+12069106512",
currentMessagingTarget: "+12069106512",
currentMessageId: "provider-guid-853",
},
);
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "other-chat",
message: "cross-channel reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not record messaging side effects when the send fails", async () => {
const tool = createTool({
name: "message",

View File

@@ -18,6 +18,7 @@ import {
getChannelAgentToolMeta,
getPluginToolMeta,
type EmbeddedRunAttemptParams,
isDeliveredMessageToolOnlySourceReplyResult,
isReplaySafeToolCall,
isToolWrappedWithBeforeToolCallHook,
isToolResultError,
@@ -63,9 +64,11 @@ type CodexDynamicToolHookContext = {
currentChannelProvider?: string;
currentChannelId?: string;
currentMessagingTarget?: string;
currentMessageId?: string | number;
currentThreadId?: string;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
sourceReplyDeliveryMode?: EmbeddedRunAttemptParams["sourceReplyDeliveryMode"];
onToolOutcome?: EmbeddedRunAttemptParams["onToolOutcome"];
allocateToolOutcomeOrdinal?: EmbeddedRunAttemptParams["allocateToolOutcomeOrdinal"];
};
@@ -100,6 +103,218 @@ function applyCurrentMessageProvider(
return { ...args, provider };
}
function normalizeRouteToken(value: string | number | undefined): string | undefined {
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : undefined;
}
const normalized = value?.trim().toLowerCase();
return normalized ? normalized : undefined;
}
function sourceRouteTokens(hookContext: CodexDynamicToolHookContext | undefined): Set<string> {
const tokens = new Set<string>();
const currentTarget = normalizeRouteToken(hookContext?.currentMessagingTarget);
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
if (currentTarget) {
tokens.add(currentTarget);
}
if (currentChannel) {
tokens.add(currentChannel);
}
const channelPrefixIndex = currentChannel?.indexOf(":") ?? -1;
if (channelPrefixIndex >= 0 && currentChannel) {
const unprefixedChannel = currentChannel.slice(channelPrefixIndex + 1);
if (unprefixedChannel) {
tokens.add(unprefixedChannel);
for (const segment of unprefixedChannel.split(/[;,]/u)) {
const token = normalizeRouteToken(segment);
if (token) {
tokens.add(token);
}
}
}
}
if (currentProvider && currentChannel?.startsWith(`${currentProvider}:`)) {
const unprefixedChannel = currentChannel.slice(currentProvider.length + 1);
if (unprefixedChannel) {
tokens.add(unprefixedChannel);
}
}
return tokens;
}
function routeTokenMatchesSource(
token: string | undefined,
hookContext: CodexDynamicToolHookContext | undefined,
): boolean {
const normalized = normalizeRouteToken(token);
return normalized !== undefined && sourceRouteTokens(hookContext).has(normalized);
}
function routeProviderMatchesSource(
provider: string | undefined,
hookContext: CodexDynamicToolHookContext | undefined,
): boolean {
const normalized = normalizeRouteToken(provider);
if (!normalized) {
return false;
}
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
return currentProvider === normalized || currentChannel?.startsWith(`${normalized}:`) === true;
}
function routeTokenMatchesCurrentMessage(
token: string | number | undefined,
hookContext: CodexDynamicToolHookContext | undefined,
): boolean {
const normalized = normalizeRouteToken(token);
return (
normalized !== undefined && normalized === normalizeRouteToken(hookContext?.currentMessageId)
);
}
function readRouteToken(record: Record<string, unknown>, key: string): string | number | undefined {
const value = record[key];
return typeof value === "string" || typeof value === "number" ? value : undefined;
}
function explicitRouteTokensMismatchCurrent(
args: Record<string, unknown>,
keys: readonly string[],
currentToken: string | number | undefined,
): boolean {
const normalizedCurrent = normalizeRouteToken(currentToken);
if (!normalizedCurrent) {
return false;
}
return keys.some((key) => {
const normalized = normalizeRouteToken(readRouteToken(args, key));
return normalized !== undefined && normalized !== normalizedCurrent;
});
}
function explicitThreadRouteTargetsNonSource(
args: Record<string, unknown>,
hookContext: CodexDynamicToolHookContext | undefined,
messagingTarget: MessagingToolSend | undefined,
): boolean {
const normalizedCurrentThread = normalizeRouteToken(hookContext?.currentThreadId);
const explicitThreadTokens = [
...EXPLICIT_MESSAGE_THREAD_KEYS.map((key) => normalizeRouteToken(readRouteToken(args, key))),
normalizeRouteToken(messagingTarget?.threadId),
].filter((value): value is string => value !== undefined);
if (explicitThreadTokens.length === 0) {
return false;
}
return (
normalizedCurrentThread === undefined ||
explicitThreadTokens.some((value) => value !== normalizedCurrentThread)
);
}
function replyReceiptMatchesCurrentMessage(
value: unknown,
hookContext: CodexDynamicToolHookContext | undefined,
depth = 0,
): boolean {
if (depth > 4 || value === null) {
return false;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed || !["{", "["].includes(trimmed[0] ?? "")) {
return false;
}
try {
return replyReceiptMatchesCurrentMessage(JSON.parse(trimmed), hookContext, depth + 1);
} catch {
return false;
}
}
if (typeof value !== "object") {
return false;
}
if (Array.isArray(value)) {
return value.some((item) => replyReceiptMatchesCurrentMessage(item, hookContext, depth + 1));
}
const record = value as Record<string, unknown>;
for (const key of ["repliedTo", "replyTo", "replyToId", "replyToIdFull"]) {
if (
routeTokenMatchesCurrentMessage(
typeof record[key] === "string" ? record[key] : undefined,
hookContext,
)
) {
return true;
}
}
for (const key of [
"content",
"details",
"payload",
"receipt",
"result",
"results",
"sendResult",
"text",
]) {
if (replyReceiptMatchesCurrentMessage(record[key], hookContext, depth + 1)) {
return true;
}
}
return false;
}
function hasExplicitNonSourceMessageRoute(
args: Record<string, unknown>,
hookContext: CodexDynamicToolHookContext | undefined,
messagingTarget: MessagingToolSend | undefined,
): boolean {
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
for (const key of EXPLICIT_MESSAGE_PROVIDER_KEYS) {
const provider = normalizeRouteToken(typeof args[key] === "string" ? args[key] : undefined);
if (
provider &&
currentProvider !== provider &&
!routeProviderMatchesSource(provider, hookContext)
) {
return true;
}
}
const targetValues = [
...EXPLICIT_MESSAGE_TARGET_KEYS.map((key) =>
typeof args[key] === "string" ? args[key] : undefined,
),
...(Array.isArray(args.targets)
? args.targets.map((value) => (typeof value === "string" ? value : undefined))
: []),
].filter((value): value is string => normalizeRouteToken(value) !== undefined);
if (explicitThreadRouteTargetsNonSource(args, hookContext, messagingTarget)) {
return true;
}
if (
explicitRouteTokensMismatchCurrent(
args,
EXPLICIT_MESSAGE_REPLY_KEYS,
hookContext?.currentMessageId,
)
) {
return true;
}
if (targetValues.length === 0) {
return false;
}
if (targetValues.some((value) => !routeTokenMatchesSource(value, hookContext))) {
return true;
}
return (
messagingTarget?.to !== undefined && !routeTokenMatchesSource(messagingTarget.to, hookContext)
);
}
/** Runtime bridge returned to Codex app-server attempt code. */
export type CodexDynamicToolBridge = {
availableSpecs: CodexDynamicToolSpec[];
@@ -114,6 +329,7 @@ export type CodexDynamicToolBridge = {
) => Promise<CodexDynamicToolCallResponse>;
telemetry: {
didSendViaMessagingTool: boolean;
didDeliverSourceReplyViaMessageTool: boolean;
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
@@ -132,6 +348,10 @@ export const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw";
// Keep OpenClaw session spawning searchable in Codex mode so Codex's native
// spawn_agent remains the primary Codex subagent surface.
const ALWAYS_DIRECT_DYNAMIC_TOOL_NAMES = new Set(["sessions_yield"]);
const EXPLICIT_MESSAGE_PROVIDER_KEYS = ["channel", "provider"];
const EXPLICIT_MESSAGE_TARGET_KEYS = ["target", "to", "channelId"];
const EXPLICIT_MESSAGE_THREAD_KEYS = ["threadId", "thread_id", "messageThreadId", "topicId"];
const EXPLICIT_MESSAGE_REPLY_KEYS = ["replyTo", "replyToId", "replyToIdFull"];
const DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 16_000;
/**
@@ -176,6 +396,7 @@ export function createCodexDynamicToolBridge(params: {
emitQuarantinedDynamicToolDiagnostics(quarantinedTools, params.hookContext);
const telemetry: CodexDynamicToolBridge["telemetry"] = {
didSendViaMessagingTool: false,
didDeliverSourceReplyViaMessageTool: false,
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
@@ -333,10 +554,9 @@ export function createCodexDynamicToolBridge(params: {
executedArgs,
params.hookContext?.currentChannelProvider,
);
const messagingTarget =
isMessagingTool(toolName) && isMessagingToolSendAction(toolName, executedArgs)
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
: undefined;
const messagingTarget = isMessagingTool(toolName)
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
: undefined;
const confirmedMessagingTarget =
!rawIsError && messagingTarget
? extractMessagingToolSendResult(messagingTarget, telemetryRawResult)
@@ -358,12 +578,46 @@ export function createCodexDynamicToolBridge(params: {
},
terminalType,
);
const blocksSourceReplyTermination = hasExplicitNonSourceMessageRoute(
executedArgs,
params.hookContext,
confirmedMessagingTarget,
);
const deliveredSourceReply = isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: params.hookContext?.sourceReplyDeliveryMode,
toolName,
args: executedArgs,
result,
hookResult: rawResult,
isError: resultIsError,
allowExplicitSourceRoute: !blocksSourceReplyTermination,
});
const receiptConfirmedSourceReply =
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
toolName === "message" &&
normalizeRouteToken(
typeof executedArgs.action === "string" ? executedArgs.action : undefined,
) === "reply" &&
!resultIsError &&
!blocksSourceReplyTermination &&
(replyReceiptMatchesCurrentMessage(rawResult, params.hookContext) ||
replyReceiptMatchesCurrentMessage(result, params.hookContext));
const toolConfirmedSourceReply =
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
toolName === "message" &&
!resultIsError &&
(rawResult.terminate === true || result.terminate === true);
if (deliveredSourceReply || receiptConfirmedSourceReply || toolConfirmedSourceReply) {
telemetry.didDeliverSourceReplyViaMessageTool = true;
}
withDynamicToolTermination(
response,
rawResult.terminate === true ||
result.terminate === true ||
isToolResultYield(rawResult) ||
isToolResultYield(result),
isToolResultYield(result) ||
deliveredSourceReply ||
receiptConfirmedSourceReply,
);
const asyncStarted =
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result);
@@ -803,7 +1057,7 @@ function collectToolTelemetry(params: {
}
if (
!isMessagingTool(params.toolName) ||
!isMessagingToolSendAction(params.toolName, params.args)
(!isMessagingToolSendAction(params.toolName, params.args) && !params.messagingTarget)
) {
return;
}

View File

@@ -794,6 +794,19 @@ describe("CodexAppServerEventProjector", () => {
expect(result.toolMediaUrls).toStrictEqual([]);
});
it("propagates message-tool-only source reply delivery telemetry", async () => {
const projector = await createProjector();
const result = projector.buildResult({
...buildEmptyToolTelemetry(),
didSendViaMessagingTool: true,
didDeliverSourceReplyViaMessageTool: true,
});
expect(result.didSendViaMessagingTool).toBe(true);
expect(result.didDeliverSourceReplyViaMessageTool).toBe(true);
});
it("does not promote repeated tool progress text to the final assistant reply", async () => {
const onToolResult = vi.fn();
const projector = await createProjector({

View File

@@ -53,6 +53,7 @@ import { attachCodexMirrorIdentity, buildCodexUserPromptMessage } from "./transc
export type CodexAppServerToolTelemetry = {
didSendViaMessagingTool: boolean;
didDeliverSourceReplyViaMessageTool?: boolean;
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
@@ -412,6 +413,8 @@ export class CodexAppServerEventProjector {
currentAttemptAssistant,
...(this.lastNativeToolError ? { lastToolError: this.lastNativeToolError } : {}),
didSendViaMessagingTool: toolTelemetry.didSendViaMessagingTool,
didDeliverSourceReplyViaMessageTool:
toolTelemetry.didDeliverSourceReplyViaMessageTool === true,
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,
@@ -1827,7 +1830,14 @@ export class CodexAppServerEventProjector {
}
private async readMirroredSessionMessages(): Promise<AgentMessage[]> {
return (await readCodexMirroredSessionHistoryMessages(this.params.sessionFile)) ?? [];
return (
(await readCodexMirroredSessionHistoryMessages({
agentId: this.params.agentId,
sessionFile: this.params.sessionFile,
sessionId: this.params.sessionId,
sessionKey: this.params.sessionKey,
})) ?? []
);
}
private createAssistantMessage(text: string): AssistantMessage {

View File

@@ -841,15 +841,26 @@ export async function runCodexAppServerAttempt(
currentChannelProvider: resolveCodexMessageToolProvider(params),
currentChannelId: params.currentChannelId,
currentMessagingTarget: params.currentMessagingTarget,
currentMessageId: params.currentMessageId,
currentThreadId: params.currentThreadTs,
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
onToolOutcome: onCodexToolOutcome,
allocateToolOutcomeOrdinal: allocateCodexToolOutcomeOrdinal,
},
});
const hadSessionFile = await pathExists(activeSessionFile);
let historyMessages = (await readMirroredSessionHistoryMessages(activeSessionFile)) ?? [];
const activeTranscriptTarget = {
agentId: sessionAgentId,
sessionFile: activeSessionFile,
sessionId: activeSessionId,
sessionKey: contextSessionKey,
};
let historyMessages =
!activeContextEngine && initialStartupBindingHadInactiveThreadBootstrap
? []
: ((await readMirroredSessionHistoryMessages(activeTranscriptTarget)) ?? []);
const hookContextWindowFields = {
...(params.contextWindowInfo?.tokens
? { contextTokenBudget: params.contextWindowInfo.tokens }
@@ -907,7 +918,7 @@ export async function runCodexAppServerAttempt(
warn: (message) => embeddedAgentLog.warn(message),
});
historyMessages =
(await readMirroredSessionHistoryMessages(activeSessionFile)) ?? historyMessages;
(await readMirroredSessionHistoryMessages(activeTranscriptTarget)) ?? historyMessages;
}
const memoryToolNames = getCodexWorkspaceMemoryToolNames(toolBridge.availableSpecs);
const workspaceBootstrapContext = await buildCodexWorkspaceBootstrapContext({
@@ -3039,7 +3050,7 @@ export async function runCodexAppServerAttempt(
const activeContextEnginePluginIdLocal =
resolveContextEngineOwnerPluginId(activeContextEngine);
const finalMessages =
(await readMirroredSessionHistoryMessages(activeSessionFile)) ??
(await readMirroredSessionHistoryMessages(activeTranscriptTarget)) ??
historyMessages.concat(result.messagesSnapshot);
await finalizeHarnessContextEngineTurn({
contextEngine: activeContextEngine,

View File

@@ -51,6 +51,14 @@ function messageEntry(params: {
};
}
function mirroredTarget(sessionFile: string) {
return {
sessionFile,
sessionId: "codex-session",
sessionKey: "codex-session",
};
}
describe("readCodexMirroredSessionHistoryMessages", () => {
it("replays only the branch selected by a leaf control", async () => {
const sessionFile = await writeSession([
@@ -75,7 +83,9 @@ describe("readCodexMirroredSessionHistoryMessages", () => {
},
]);
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toMatchObject([
await expect(
readCodexMirroredSessionHistoryMessages(mirroredTarget(sessionFile)),
).resolves.toMatchObject([
{ role: "user", content: "root prompt" },
{ role: "assistant", content: "active answer" },
]);
@@ -93,7 +103,9 @@ describe("readCodexMirroredSessionHistoryMessages", () => {
},
]);
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toEqual([]);
await expect(
readCodexMirroredSessionHistoryMessages(mirroredTarget(sessionFile)),
).resolves.toEqual([]);
});
it("keeps visible history when continuation rows use a disjoint append cursor", async () => {
@@ -125,7 +137,9 @@ describe("readCodexMirroredSessionHistoryMessages", () => {
}),
]);
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toMatchObject([
await expect(
readCodexMirroredSessionHistoryMessages(mirroredTarget(sessionFile)),
).resolves.toMatchObject([
{ role: "user", content: "visible prompt" },
{ role: "assistant", content: "continued answer" },
]);
@@ -154,7 +168,9 @@ describe("readCodexMirroredSessionHistoryMessages", () => {
}),
]);
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toMatchObject([
await expect(
readCodexMirroredSessionHistoryMessages(mirroredTarget(sessionFile)),
).resolves.toMatchObject([
{ role: "user", content: "visible prompt" },
{ role: "assistant", content: "continued answer" },
]);

View File

@@ -10,40 +10,59 @@ import {
migrateSessionEntries,
parseSessionEntries,
} from "openclaw/plugin-sdk/agent-sessions";
import {
resolveSessionTranscriptTarget,
type SessionTranscriptTargetParams,
} from "openclaw/plugin-sdk/session-transcript-runtime";
import { sanitizeCodexHistoryImagePayloads } from "./image-payload-sanitizer.js";
function isMissingFileError(error: unknown): boolean {
return Boolean(
error &&
typeof error === "object" &&
"code" in error &&
(error as { code?: unknown }).code === "ENOENT",
);
}
export type CodexMirroredSessionHistoryTarget = {
agentId?: string;
sessionFile: string;
sessionId: string;
sessionKey?: string;
};
/** Returns sanitized session-context messages for a Codex mirrored session file. */
export async function readCodexMirroredSessionHistoryMessages(
sessionFile: string,
target: CodexMirroredSessionHistoryTarget,
): Promise<AgentMessage[] | undefined> {
try {
const raw = await fs.readFile(sessionFile, "utf-8");
await resolveSessionTranscriptTarget(resolveCodexHistoryTranscriptTarget(target));
const raw = await fs.readFile(target.sessionFile, "utf-8");
const entries = parseSessionEntries(raw);
if (entries.length === 0) {
return [];
}
const firstEntry = entries[0] as { type?: unknown; id?: unknown } | undefined;
if (firstEntry?.type !== "session" || typeof firstEntry.id !== "string") {
return undefined;
}
migrateSessionEntries(entries);
const sessionEntries = entries.filter(
(entry): entry is SessionEntry => entry.type !== "session",
);
migrateSessionEntries(entries as SessionEntry[]);
const sessionEntries = entries.filter((entry): entry is SessionEntry => {
return (
entry !== null &&
typeof entry === "object" &&
!Array.isArray(entry) &&
(entry as { type?: unknown }).type !== "session"
);
});
return sanitizeCodexHistoryImagePayloads(
buildSessionContext(sessionEntries).messages,
"codex mirrored history",
);
} catch (error) {
if (isMissingFileError(error)) {
return [];
}
} catch {
return undefined;
}
}
function resolveCodexHistoryTranscriptTarget(
target: CodexMirroredSessionHistoryTarget,
): SessionTranscriptTargetParams {
return {
...(target.agentId ? { agentId: target.agentId } : {}),
sessionFile: target.sessionFile,
sessionId: target.sessionId,
sessionKey: target.sessionKey ?? "",
};
}

View File

@@ -21,13 +21,14 @@ import {
mirrorCodexAppServerTranscript,
} from "./transcript-mirror.js";
const emitSessionTranscriptUpdateMock = vi.hoisted(() => vi.fn());
const publishSessionTranscriptUpdateByIdentityMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>();
vi.mock("openclaw/plugin-sdk/session-transcript-runtime", async (importOriginal) => {
const actual =
await importOriginal<typeof import("openclaw/plugin-sdk/session-transcript-runtime")>();
return {
...actual,
emitSessionTranscriptUpdate: emitSessionTranscriptUpdateMock,
publishSessionTranscriptUpdateByIdentity: publishSessionTranscriptUpdateByIdentityMock,
};
});
@@ -44,7 +45,7 @@ const tempDirs: string[] = [];
afterEach(async () => {
resetGlobalHookRunner();
emitSessionTranscriptUpdateMock.mockReset();
publishSessionTranscriptUpdateByIdentityMock.mockReset();
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true });
}
@@ -130,6 +131,7 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [userMessage, assistantMessage, toolResultMessage],
idempotencyScope: "scope-1",
@@ -164,30 +166,32 @@ describe("mirrorCodexAppServerTranscript", () => {
const firstMirror = await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "agent:main:main",
messages: [userMessage],
idempotencyScope: "codex-app-server:thread-1",
});
const secondMirror = await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "agent:main:main",
messages: [userMessage],
idempotencyScope: "codex-app-server:thread-1",
});
const updates = emitSessionTranscriptUpdateMock.mock.calls.map(
([update]) => update as Record<string, unknown>,
const updates = publishSessionTranscriptUpdateByIdentityMock.mock.calls.map(
([update]) => update as Record<string, unknown> & { update?: Record<string, unknown> },
);
expect(updates).toHaveLength(1);
expect(updates[0]?.sessionFile).toBe(sessionFile);
expect(updates[0]?.sessionKey).toBe("agent:main:main");
expect(updates[0]?.messageId).toEqual(expect.any(String));
expect(updates[0]?.message).toMatchObject({
expect(updates[0]?.update?.messageId).toEqual(expect.any(String));
expect(updates[0]?.update?.message).toMatchObject({
role: "user",
content: [{ type: "text", text: "show me live" }],
idempotencyKey: "codex-app-server:thread-1:turn-1:prompt",
});
expect(updates[0]?.messageSeq).toBe(1);
expect(updates[0]?.update?.messageSeq).toBe(1);
expect(firstMirror.userMessagesPresent).toHaveLength(1);
expect(firstMirror.userMessagesPresent[0]).toMatchObject({
role: "user",
@@ -207,6 +211,7 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "agent:main:main",
messages: [
attachCodexMirrorIdentity(
@@ -227,14 +232,16 @@ describe("mirrorCodexAppServerTranscript", () => {
idempotencyScope: "codex-app-server:thread-1",
});
const updates = emitSessionTranscriptUpdateMock.mock.calls.map(
([update]) => update as Record<string, unknown>,
const updates = publishSessionTranscriptUpdateByIdentityMock.mock.calls.map(
([update]) => update as Record<string, unknown> & { update?: Record<string, unknown> },
);
expect(updates.map((update) => update.messageSeq)).toEqual([1, 2]);
expect(updates.map((update) => (update.message as { role?: string }).role)).toEqual([
"user",
"assistant",
]);
expect(updates.map((update) => update.update?.messageSeq)).toEqual([1, 2]);
expect(
updates.map((update) => {
const message = update.update?.message as { role?: string } | undefined;
return message?.role;
}),
).toEqual(["user", "assistant"]);
});
it("creates the transcript directory on first mirror", async () => {
@@ -243,6 +250,7 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [
makeAgentAssistantMessage({
@@ -273,12 +281,14 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [...messages],
idempotencyScope: "scope-1",
});
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [...messages],
idempotencyScope: "scope-1",
@@ -312,6 +322,7 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [sourceMessage],
idempotencyScope: "scope-1",
@@ -348,12 +359,14 @@ describe("mirrorCodexAppServerTranscript", () => {
const first = await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [sourceMessage],
idempotencyScope: "scope-1",
});
const second = await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [sourceMessage],
idempotencyScope: "scope-1",
@@ -394,6 +407,7 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [sourceMessage],
idempotencyScope: "scope-1",
@@ -419,6 +433,7 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [
makeAgentAssistantMessage({
@@ -456,6 +471,7 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [
makeAgentAssistantMessage({
@@ -534,6 +550,7 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [userMessage, assistantMessage],
idempotencyScope: "codex-app-server:thread-X",
@@ -547,6 +564,7 @@ describe("mirrorCodexAppServerTranscript", () => {
);
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [userMessage, reasoningMessage, assistantMessage],
idempotencyScope: "codex-app-server:thread-X",
@@ -595,12 +613,14 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [userTurn1, assistantTurn1],
idempotencyScope: "codex-app-server:thread-X",
});
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [userTurn2, assistantTurn2],
idempotencyScope: "codex-app-server:thread-X",
@@ -638,6 +658,7 @@ describe("mirrorCodexAppServerTranscript", () => {
);
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [userTurn1, assistantTurn1],
idempotencyScope: "codex-app-server:thread-X",
@@ -661,6 +682,7 @@ describe("mirrorCodexAppServerTranscript", () => {
// turn 1's entries (with their original identities preserved).
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [userTurn1, assistantTurn1, userTurn2, assistantTurn2],
idempotencyScope: "codex-app-server:thread-X",
@@ -691,6 +713,7 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [userMessage, assistantMessage],
idempotencyScope: "scope-1",

View File

@@ -1,19 +1,19 @@
// Codex plugin module implements transcript mirror behavior.
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import {
acquireSessionWriteLock,
appendSessionTranscriptMessage,
embeddedAgentLog,
emitSessionTranscriptUpdate,
formatErrorMessage,
resolveSessionWriteLockOptions,
runAgentHarnessBeforeMessageWriteHook,
type AgentMessage,
type EmbeddedRunAttemptParams,
type EmbeddedRunAttemptResult,
type SessionWriteLockAcquireTimeoutConfig,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
publishSessionTranscriptUpdateByIdentity,
withSessionTranscriptWriteLock,
type SessionTranscriptTargetParams,
type SessionTranscriptWriteLockParams,
} from "openclaw/plugin-sdk/session-transcript-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
@@ -273,13 +273,13 @@ function buildMirrorDedupeIdentity(message: MirroredAgentMessage): string {
export async function mirrorCodexAppServerTranscript(params: {
sessionFile: string;
sessionId?: string;
sessionId: string;
cwd?: string;
sessionKey?: string;
agentId?: string;
messages: AgentMessage[];
idempotencyScope?: string;
config?: SessionWriteLockAcquireTimeoutConfig;
config?: SessionTranscriptWriteLockParams["config"];
}): Promise<CodexAppServerTranscriptMirrorResult> {
const messages = params.messages.filter(
(message): message is MirroredAgentMessage =>
@@ -289,120 +289,133 @@ export async function mirrorCodexAppServerTranscript(params: {
return { userMessagesPresent: [] };
}
const lock = await acquireSessionWriteLock({
sessionFile: params.sessionFile,
...resolveSessionWriteLockOptions(params.config),
});
const appendedUpdates: Array<{ messageId: string; message: AgentMessage; messageSeq: number }> =
[];
const userMessagesPresent: MirroredUserMessage[] = [];
try {
const mirrorState = await readTranscriptMirrorState(params.sessionFile);
let nextMessageSeq = mirrorState.messageCount;
for (const message of messages) {
const dedupeIdentity = buildMirrorDedupeIdentity(message);
const idempotencyKey = params.idempotencyScope
? `${params.idempotencyScope}:${dedupeIdentity}`
: undefined;
const transcriptMessage = {
...message,
...(idempotencyKey ? { idempotencyKey } : {}),
} as AgentMessage;
if (idempotencyKey && mirrorState.idempotencyKeys.has(idempotencyKey)) {
const persistedUserMessage = mirrorState.userMessagesByIdempotencyKey.get(idempotencyKey);
if (persistedUserMessage) {
userMessagesPresent.push(persistedUserMessage);
const transcriptTarget = resolveCodexMirrorTranscriptTarget(params);
const { appendedUpdates, userMessagesPresent } = await withSessionTranscriptWriteLock(
{ ...transcriptTarget, config: params.config },
async (transcript) => {
const nextAppendedUpdates: Array<{
messageId: string;
message: AgentMessage;
messageSeq: number;
}> = [];
const nextUserMessagesPresent: MirroredUserMessage[] = [];
const mirrorState = readTranscriptMirrorState(await transcript.readEvents());
let nextMessageSeq = mirrorState.messageCount;
for (const message of messages) {
const dedupeIdentity = buildMirrorDedupeIdentity(message);
const idempotencyKey = params.idempotencyScope
? `${params.idempotencyScope}:${dedupeIdentity}`
: undefined;
const transcriptMessage = {
...message,
...(idempotencyKey ? { idempotencyKey } : {}),
} as AgentMessage;
if (idempotencyKey && mirrorState.idempotencyKeys.has(idempotencyKey)) {
const persistedUserMessage = mirrorState.userMessagesByIdempotencyKey.get(idempotencyKey);
if (persistedUserMessage) {
nextUserMessagesPresent.push(persistedUserMessage);
}
continue;
}
continue;
}
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
message: transcriptMessage,
agentId: params.agentId,
sessionKey: params.sessionKey,
});
if (!nextMessage) {
continue;
}
const messageToAppend = (
idempotencyKey
? {
...(nextMessage as unknown as Record<string, unknown>),
idempotencyKey,
}
: nextMessage
) as AgentMessage;
const { messageId, message: appendedMessage } = await appendSessionTranscriptMessage({
transcriptPath: params.sessionFile,
message: messageToAppend,
idempotencyLookup: idempotencyKey ? "caller-checked" : "scan",
sessionId: params.sessionId,
cwd: params.cwd,
config: params.config,
});
if (appendedMessage.role === "user") {
userMessagesPresent.push(appendedMessage);
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
message: transcriptMessage,
agentId: params.agentId,
sessionKey: params.sessionKey,
});
if (!nextMessage) {
continue;
}
const messageToAppend = (
idempotencyKey
? {
...(nextMessage as unknown as Record<string, unknown>),
idempotencyKey,
}
: nextMessage
) as AgentMessage;
const appended = await transcript.appendMessage({
message: messageToAppend,
idempotencyLookup: idempotencyKey ? "caller-checked" : "scan",
cwd: params.cwd,
});
if (!appended) {
continue;
}
const { messageId, message: appendedMessage } = appended;
if (appendedMessage.role === "user") {
nextUserMessagesPresent.push(appendedMessage);
if (idempotencyKey) {
mirrorState.userMessagesByIdempotencyKey.set(idempotencyKey, appendedMessage);
}
}
nextMessageSeq += 1;
nextAppendedUpdates.push({
messageId,
message: appendedMessage,
messageSeq: nextMessageSeq,
});
if (idempotencyKey) {
mirrorState.userMessagesByIdempotencyKey.set(idempotencyKey, appendedMessage);
mirrorState.idempotencyKeys.add(idempotencyKey);
}
}
nextMessageSeq += 1;
appendedUpdates.push({ messageId, message: appendedMessage, messageSeq: nextMessageSeq });
if (idempotencyKey) {
mirrorState.idempotencyKeys.add(idempotencyKey);
}
}
} finally {
await lock.release();
}
return { appendedUpdates: nextAppendedUpdates, userMessagesPresent: nextUserMessagesPresent };
},
);
for (const update of appendedUpdates) {
emitSessionTranscriptUpdate({
sessionFile: params.sessionFile,
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params.agentId ? { agentId: params.agentId } : {}),
message: update.message,
messageId: update.messageId,
messageSeq: update.messageSeq,
await publishSessionTranscriptUpdateByIdentity({
...transcriptTarget,
update: {
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params.agentId ? { agentId: params.agentId } : {}),
message: update.message,
messageId: update.messageId,
messageSeq: update.messageSeq,
},
});
}
return { userMessagesPresent };
}
async function readTranscriptMirrorState(sessionFile: string): Promise<{
function resolveCodexMirrorTranscriptTarget(params: {
agentId?: string;
sessionFile: string;
sessionId: string;
sessionKey?: string;
}): SessionTranscriptTargetParams {
return {
...(params.agentId ? { agentId: params.agentId } : {}),
sessionFile: params.sessionFile,
sessionId: params.sessionId,
sessionKey: params.sessionKey ?? "",
};
}
function readTranscriptMirrorState(events: unknown[]): {
idempotencyKeys: Set<string>;
messageCount: number;
userMessagesByIdempotencyKey: Map<string, MirroredUserMessage>;
}> {
} {
const idempotencyKeys = new Set<string>();
const userMessagesByIdempotencyKey = new Map<string, MirroredUserMessage>();
let messageCount = 0;
let raw: string;
try {
raw = await fs.readFile(sessionFile, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
return { idempotencyKeys, messageCount, userMessagesByIdempotencyKey };
}
for (const line of raw.split(/\r?\n/)) {
if (!line.trim()) {
for (const event of events) {
if (!event || typeof event !== "object" || Array.isArray(event)) {
continue;
}
try {
const parsed = JSON.parse(line) as { message?: AgentMessage & { idempotencyKey?: unknown } };
if ((parsed as { type?: unknown }).type === "message") {
messageCount += 1;
const parsed = event as {
message?: AgentMessage & { idempotencyKey?: unknown };
type?: unknown;
};
if (parsed.type === "message") {
messageCount += 1;
}
if (typeof parsed.message?.idempotencyKey === "string") {
idempotencyKeys.add(parsed.message.idempotencyKey);
if (parsed.message.role === "user") {
userMessagesByIdempotencyKey.set(parsed.message.idempotencyKey, parsed.message);
}
if (typeof parsed.message?.idempotencyKey === "string") {
idempotencyKeys.add(parsed.message.idempotencyKey);
if (parsed.message.role === "user") {
userMessagesByIdempotencyKey.set(parsed.message.idempotencyKey, parsed.message);
}
}
} catch {
continue;
}
}
return { idempotencyKeys, messageCount, userMessagesByIdempotencyKey };

View File

@@ -3,7 +3,12 @@
* turns replies into app-server answer payloads.
*/
import {
buildAgentHarnessUserInputAnswers,
deliverAgentHarnessUserInputPrompt,
embeddedAgentLog,
emptyAgentHarnessUserInputAnswers,
type AgentHarnessUserInputOption,
type AgentHarnessUserInputQuestion,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { formatCodexDisplayText } from "../command-formatters.js";
@@ -19,25 +24,11 @@ type PendingUserInput = {
threadId: string;
turnId: string;
itemId: string;
questions: UserInputQuestion[];
questions: AgentHarnessUserInputQuestion[];
resolve: (value: JsonValue) => void;
cleanup: () => void;
};
type UserInputQuestion = {
id: string;
header: string;
question: string;
isOther: boolean;
isSecret: boolean;
options: UserInputOption[] | null;
};
type UserInputOption = {
label: string;
description: string;
};
type CodexUserInputBridge = {
handleRequest: (request: {
id: number | string;
@@ -142,7 +133,7 @@ function readUserInputParams(value: JsonValue | undefined):
threadId: string;
turnId: string;
itemId: string;
questions: UserInputQuestion[];
questions: AgentHarnessUserInputQuestion[];
}
| undefined {
if (!isJsonObject(value)) {
@@ -157,11 +148,11 @@ function readUserInputParams(value: JsonValue | undefined):
}
const questions = questionsRaw
.map(readQuestion)
.filter((question): question is UserInputQuestion => Boolean(question));
.filter((question): question is AgentHarnessUserInputQuestion => Boolean(question));
return { threadId, turnId, itemId, questions };
}
function readQuestion(value: JsonValue): UserInputQuestion | undefined {
function readQuestion(value: JsonValue): AgentHarnessUserInputQuestion | undefined {
if (!isJsonObject(value)) {
return undefined;
}
@@ -181,17 +172,17 @@ function readQuestion(value: JsonValue): UserInputQuestion | undefined {
};
}
function readOptions(value: JsonValue | undefined): UserInputOption[] | null {
function readOptions(value: JsonValue | undefined): AgentHarnessUserInputOption[] | null {
if (!Array.isArray(value)) {
return null;
}
const options = value
.map(readOption)
.filter((option): option is UserInputOption => Boolean(option));
.filter((option): option is AgentHarnessUserInputOption => Boolean(option));
return options.length > 0 ? options : null;
}
function readOption(value: JsonValue): UserInputOption | undefined {
function readOption(value: JsonValue): AgentHarnessUserInputOption | undefined {
if (!isJsonObject(value)) {
return undefined;
}
@@ -202,116 +193,25 @@ function readOption(value: JsonValue): UserInputOption | undefined {
async function deliverUserInputPrompt(
params: EmbeddedRunAttemptParams,
questions: UserInputQuestion[],
questions: AgentHarnessUserInputQuestion[],
): Promise<void> {
const text = formatUserInputPrompt(questions);
if (params.onBlockReply) {
await params.onBlockReply({ text });
return;
}
await params.onPartialReply?.({ text });
}
function formatUserInputPrompt(questions: UserInputQuestion[]): string {
const lines = ["Codex needs input:"];
questions.forEach((question, index) => {
if (questions.length > 1) {
lines.push(
"",
`${index + 1}. ${formatCodexDisplayText(question.header)}`,
formatCodexDisplayText(question.question),
);
} else {
lines.push(
"",
formatCodexDisplayText(question.header),
formatCodexDisplayText(question.question),
);
}
if (question.isSecret) {
lines.push("This channel may show your reply to other participants.");
}
question.options?.forEach((option, optionIndex) => {
lines.push(
`${optionIndex + 1}. ${formatCodexDisplayText(option.label)}${
option.description ? ` - ${formatCodexDisplayText(option.description)}` : ""
}`,
);
});
if (question.isOther) {
lines.push("Other: reply with your own answer.");
}
await deliverAgentHarnessUserInputPrompt(params, questions, {
formatText: formatCodexDisplayText,
intro: "Codex needs input:",
});
return lines.join("\n");
}
function buildUserInputResponse(questions: UserInputQuestion[], inputText: string): JsonObject {
function buildUserInputResponse(
questions: AgentHarnessUserInputQuestion[],
inputText: string,
): JsonObject {
// Multi-question replies may use "header: answer" or numbered lines. Keep the
// parser permissive so chat-channel replies remain ergonomic.
const answers: JsonObject = {};
if (questions.length === 1) {
const question = questions[0];
if (question) {
const answer = normalizeAnswer(inputText, question);
answers[question.id] = { answers: answer ? [answer] : [] };
}
return { answers };
}
const keyed = parseKeyedAnswers(inputText);
const fallbackLines = inputText
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
questions.forEach((question, index) => {
const key =
keyed.get(question.id.toLowerCase()) ??
keyed.get(question.header.toLowerCase()) ??
keyed.get(question.question.toLowerCase()) ??
keyed.get(String(index + 1));
const answer = key ?? fallbackLines[index] ?? "";
const normalized = answer ? normalizeAnswer(answer, question) : undefined;
answers[question.id] = { answers: normalized ? [normalized] : [] };
});
return { answers };
}
function normalizeAnswer(answer: string, question: UserInputQuestion): string | undefined {
const trimmed = answer.trim();
const options = question.options ?? [];
const optionIndex = /^\d+$/.test(trimmed) ? Number(trimmed) - 1 : -1;
const indexed = optionIndex >= 0 ? options[optionIndex] : undefined;
if (indexed) {
return indexed.label;
}
const exact = options.find((option) => option.label.toLowerCase() === trimmed.toLowerCase());
if (exact) {
return exact.label;
}
if (options.length > 0 && !question.isOther) {
return undefined;
}
return trimmed || undefined;
}
function parseKeyedAnswers(inputText: string): Map<string, string> {
const answers = new Map<string, string>();
for (const line of inputText.split(/\r?\n/)) {
const match = line.match(/^\s*([^:=-]+?)\s*[:=-]\s*(.+?)\s*$/);
if (!match) {
continue;
}
const key = match[1]?.trim().toLowerCase();
const value = match[2]?.trim();
if (key && value) {
answers.set(key, value);
}
}
return answers;
return buildAgentHarnessUserInputAnswers(questions, inputText) as unknown as JsonObject;
}
function emptyUserInputResponse(): JsonObject {
return { answers: {} };
return emptyAgentHarnessUserInputAnswers() as unknown as JsonObject;
}
function readString(record: JsonObject, key: string): string | undefined {

View File

@@ -1,6 +1,6 @@
{
"id": "copilot-proxy",
"icon": "https://cdn.simpleicons.org/githubcopilot/111111",
"icon": "https://cdn.simpleicons.org/githubcopilot",
"activation": {
"onStartup": false
},

View File

@@ -1609,6 +1609,12 @@ describe("createCopilotAgentHarness", () => {
success: true,
tokensRemoved: 123,
messagesRemoved: 4,
summaryContent: "compacted summary",
contextWindow: {
tokenLimit: 1000,
currentTokens: 777,
messagesLength: 12,
},
}));
const disconnect = vi.fn(async () => {
throw new Error("disconnect failed");
@@ -1649,6 +1655,7 @@ describe("createCopilotAgentHarness", () => {
model: "gpt-4.1",
sessionKey: "agent:main:main",
sessionId: "oc-sess-compact-1",
currentTokenCount: 900,
workspaceDir: "/this\u0000is/illegal",
customInstructions: "Keep decisions.",
});
@@ -1684,6 +1691,25 @@ describe("createCopilotAgentHarness", () => {
ok: true,
compacted: true,
reason: "copilot-sdk-history-compacted",
result: {
summary: "compacted summary",
firstKeptEntryId: "",
tokensBefore: 900,
tokensAfter: 777,
details: {
success: true,
tokensRemoved: 123,
messagesRemoved: 4,
summaryContent: "compacted summary",
contextWindow: {
tokenLimit: 1000,
currentTokens: 777,
messagesLength: 12,
},
},
sessionId: "oc-sess-compact-1",
sessionFile: "/session.json",
},
});
});

View File

@@ -62,6 +62,14 @@ interface CopilotHistoryCompactResult {
tokensRemoved: number;
messagesRemoved: number;
summaryContent?: string;
contextWindow?: {
tokenLimit: number;
currentTokens: number;
messagesLength: number;
systemTokens?: number;
conversationTokens?: number;
toolDefinitionsTokens?: number;
};
}
interface CopilotHistoryCompactSession {
@@ -872,6 +880,21 @@ export function createCopilotAgentHarness(
ok: true,
compacted,
reason: compacted ? "copilot-sdk-history-compacted" : "already under target",
...(compacted
? {
result: {
summary: compactResult.summaryContent ?? "",
firstKeptEntryId: "",
tokensBefore:
params.currentTokenCount ??
(compactResult.contextWindow?.currentTokens ?? 0) + compactResult.tokensRemoved,
tokensAfter: compactResult.contextWindow?.currentTokens,
details: compactResult,
sessionId: params.sessionId,
sessionFile: params.sessionFile,
},
}
: {}),
};
},

View File

@@ -2,7 +2,7 @@
"id": "copilot",
"name": "GitHub Copilot agent runtime",
"description": "Registers the GitHub Copilot agent runtime.",
"icon": "https://cdn.simpleicons.org/githubcopilot/111111",
"icon": "https://cdn.simpleicons.org/githubcopilot",
"version": "2026.6.2",
"activation": {
"onStartup": false,

View File

@@ -3,9 +3,11 @@ import fsp from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { CopilotClient, Tool as SdkTool } from "@github/copilot-sdk";
import type {
AgentHarnessAttemptParams,
AgentHarnessAttemptResult,
import {
abortAgentHarnessRun,
queueAgentHarnessMessage,
type AgentHarnessAttemptParams,
type AgentHarnessAttemptResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import type { SandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
@@ -1171,6 +1173,36 @@ describe("runCopilotAttempt", () => {
expect(result.externalAbort).toBe(true);
});
it("active-run abort path marks the attempt as externally aborted", async () => {
const sendDeferred = createDeferred<SessionEventShape | undefined>();
const sessionCreated = createDeferred<FakeSession>();
const sdk = makeFakeSdk({
onCreateSession: (session) => {
session.sendAndWait.mockReturnValue(sendDeferred.promise);
session.abort.mockImplementationOnce(async () => {
sendDeferred.resolve(undefined);
});
sessionCreated.resolve(session);
},
});
const pool = makeFakePool(sdk);
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
const runPromise = runCopilotAttempt(makeParams(), {
createToolBridge,
pool,
});
const session = await sessionCreated.promise;
await vi.waitFor(() => expect(session.sendAndWait).toHaveBeenCalledTimes(1));
expect(abortAgentHarnessRun("session-1")).toBe(true);
const result = await runPromise;
expect(session.abort).toHaveBeenCalledTimes(1);
expect(result.aborted).toBe(true);
expect(result.externalAbort).toBe(true);
});
it("abort path (signal already aborted)", async () => {
const controller = new AbortController();
controller.abort();
@@ -1447,18 +1479,42 @@ describe("runCopilotAttempt", () => {
expect(result.feedback).toContain("no permission policy installed");
});
it("does not register onUserInputRequest (ask_user hidden from the model in MVP)", async () => {
const sdk = makeFakeSdk();
it("registers ask_user and resolves it from the active OpenClaw queue", async () => {
const onBlockReply = vi.fn();
const sdk = makeFakeSdk({
onCreateSession: (session, cfg) => {
session.sendAndWait.mockImplementationOnce(async () => {
const handler = cfg.onUserInputRequest;
if (typeof handler !== "function") {
throw new Error("expected onUserInputRequest handler");
}
const response = await handler(
{
question: "Pick a mode",
choices: ["Fast", "Deep"],
allowFreeform: false,
},
{ sessionId: session.sessionId },
);
return makeAssistantMessageEvent(`selected ${response.answer}`);
});
},
});
const pool = makeFakePool(sdk);
await runCopilotAttempt(makeParams(), { pool });
const attempt = runCopilotAttempt(makeParams({ onBlockReply }), { pool });
await vi.waitFor(() => expect(onBlockReply).toHaveBeenCalledTimes(1));
expect(queueAgentHarnessMessage("session-1", "2")).toBe(true);
const result = await attempt;
const cfg = sdk.createSession.mock.calls[0]?.[0];
// Per the SDK contract (types.d.ts: `When provided, enables the
// ask_user tool allowing the agent to ask questions`), omitting the
// handler hides ask_user from the model entirely. The MVP keeps it
// hidden until a real channel/TUI prompt bridge exists.
expect("onUserInputRequest" in cfg).toBe(false);
expect(typeof cfg.onUserInputRequest).toBe("function");
expect(onBlockReply.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({ text: expect.stringContaining("Pick a mode") }),
);
expect(result.assistantTexts).toEqual(["selected Deep"]);
expect(queueAgentHarnessMessage("session-1", "late")).toBe(false);
});
it("enableSessionTelemetry is omitted from createSession when undefined (SDK default)", async () => {
@@ -1854,6 +1910,7 @@ describe("runCopilotAttempt", () => {
it("retains a timed-out session until later compaction reaches session.idle", async () => {
const afterCompaction = vi.fn();
const onDeferredCompaction = vi.fn();
const cleanupToolBridge = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "after_compaction", handler: afterCompaction }]),
);
@@ -1866,8 +1923,14 @@ describe("runCopilotAttempt", () => {
);
},
});
const createToolBridge = vi.fn(async () => ({
cleanup: cleanupToolBridge,
sdkTools: [],
sourceTools: [],
}));
const result = await runCopilotAttempt(makeParams(), {
createToolBridge,
onDeferredCompaction,
pool: makeFakePool(sdk),
});
@@ -1877,6 +1940,7 @@ describe("runCopilotAttempt", () => {
expect(onDeferredCompaction).toHaveBeenCalledWith(
expect.objectContaining({ sdkSessionId: "sess-1" }),
);
expect(cleanupToolBridge).not.toHaveBeenCalled();
expect(activeSession?.disconnect).not.toHaveBeenCalled();
activeSession?.emit("session.compaction_start", {});
@@ -1890,6 +1954,7 @@ describe("runCopilotAttempt", () => {
await vi.waitFor(() => {
expect(activeSession?.disconnect).toHaveBeenCalledTimes(1);
});
expect(cleanupToolBridge).toHaveBeenCalledTimes(1);
});
it("does not mark a timeout after SDK compaction has completed as active compaction", async () => {
@@ -2396,11 +2461,13 @@ describe("runCopilotAttempt", () => {
expect(dualWriteMock.dualWriteCopilotTranscriptBestEffort).toHaveBeenCalledTimes(1);
const args = dualWriteMock.dualWriteCopilotTranscriptBestEffort.mock.calls[0]?.[0] as {
sessionFile: string;
sessionId: string;
messages: Array<{ role: string }>;
idempotencyScope?: string;
};
expect(args.sessionFile).toBe("session.json");
expect(args.idempotencyScope).toMatch(/^copilot:/u);
expect(args.sessionId).toBe("session-1");
expect(args.idempotencyScope).toBe("copilot:sess-1");
expect(args.messages.length).toBeGreaterThan(0);
const roles = args.messages.map((m) => m.role);
expect(roles).toContain("user");
@@ -2447,10 +2514,9 @@ describe("runCopilotAttempt", () => {
}
const identity = message["__openclaw"]?.mirrorIdentity ?? "";
// The terminal assistant carries the turn-stable
// `${runId}:assistant:final` identity attached by attempt.ts
// (rubber-duck-validated identity scheme — survives SDK session
// reuse across turns). Caller-passed history without an
// identity falls through to the positional `${scope}:role:idx`
// `${runId}:assistant:final` identity attached by attempt.ts.
// Caller-passed history without an identity falls through to
// the positional `${scope}:role:idx`.
// fingerprint that the existing tagging map applies.
if (message.role === "assistant" && index === args.messages.length - 1) {
expect(identity).toMatch(/:assistant:final$/u);
@@ -3066,7 +3132,8 @@ describe("runCopilotAttempt", () => {
// permission policy and pollute the catalog under the default reject
// policy. `createSessionConfig` derives `availableTools` from the
// post-filter `sdkTools` so create- and resume-session always carry
// exactly the names of the tools the bridge actually exposed.
// exactly the names of the tools the bridge actually exposed plus the
// built-in `ask_user` tool owned by the registered user-input handler.
describe("availableTools surface restriction (PR #86155 [P1] round-8)", () => {
function makeFakeSdkTool(name: string): SdkTool {
return {
@@ -3090,7 +3157,11 @@ describe("runCopilotAttempt", () => {
await runCopilotAttempt(makeParams(), { createToolBridge, pool });
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual(["read", "edit"]);
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual([
"read",
"edit",
"builtin:ask_user",
]);
});
it("forwards `[]` to the SDK when the bridge returns no tools (disable / raw / fully filtered)", async () => {
@@ -3100,12 +3171,13 @@ describe("runCopilotAttempt", () => {
// (`modelRun: true` or `promptMode: "none"`), an empty
// `toolsAllow: []`, and an unsupported provider to `sdkTools: []`.
// Whatever the upstream reason, `availableTools` must be the same
// empty list so the SDK cannot fall back to its native catalog.
// ask_user-only list so the SDK cannot fall back to its native
// catalog while the registered user-input handler remains usable.
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
await runCopilotAttempt(makeParams(), { createToolBridge, pool });
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual([]);
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual(["builtin:ask_user"]);
});
it("forwards the full bridged set when the run is unrestricted (no toolsAllow)", async () => {
@@ -3131,6 +3203,7 @@ describe("runCopilotAttempt", () => {
"edit",
"exec",
"message",
"builtin:ask_user",
]);
});
@@ -3156,7 +3229,7 @@ describe("runCopilotAttempt", () => {
const resumeCall = sdk.resumeSession.mock.calls[0] as unknown[] | undefined;
const resumeCfg = resumeCall?.[1] as { availableTools?: string[] };
expect(resumeCfg?.availableTools).toEqual(["read"]);
expect(resumeCfg?.availableTools).toEqual(["read", "builtin:ask_user"]);
});
it("forwards `[]` to resumeSession when the bridge returns no tools", async () => {
@@ -3175,7 +3248,7 @@ describe("runCopilotAttempt", () => {
const resumeCall = sdk.resumeSession.mock.calls[0] as unknown[] | undefined;
const resumeCfg = resumeCall?.[1] as { availableTools?: string[] };
expect(resumeCfg?.availableTools).toEqual([]);
expect(resumeCfg?.availableTools).toEqual(["builtin:ask_user"]);
});
});

View File

@@ -24,6 +24,8 @@ import {
runAgentEndSideEffects,
runAgentHarnessLlmInputHook,
runAgentHarnessLlmOutputHook,
clearActiveEmbeddedRun,
setActiveEmbeddedRun,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveCopilotAuth } from "./auth-bridge.js";
import {
@@ -42,6 +44,7 @@ import {
type SessionLike,
} from "./event-bridge.js";
import { createHooksBridge, type CopilotHooksConfig } from "./hooks-bridge.js";
import { createCopilotNativeSubagentTaskMirror } from "./native-subagent-task-mirror.js";
import {
createPermissionBridge,
rejectAllPolicy,
@@ -55,10 +58,12 @@ import {
} from "./replay-shim.js";
import type { ClientCreateOptions, CopilotClientPool, PoolKey, PooledClient } from "./runtime.js";
import { createCopilotToolBridge } from "./tool-bridge.js";
import { createCopilotUserInputBridge } from "./user-input-bridge.js";
import { resolveCopilotWorkspaceBootstrapContext } from "./workspace-bootstrap.js";
const SUPPORTED_PROVIDERS = new Set(["github-copilot"]);
const BACKGROUND_COMPACTION_CANCEL_TIMEOUT_MS = 5_000;
const COPILOT_ASK_USER_AVAILABLE_TOOLS = ["builtin:ask_user"] as const;
type AttemptResultWithSdkSessionId = AgentHarnessAttemptResult & { sdkSessionId?: string };
type PromptErrorWithCode = Error & { code?: string; cause?: unknown };
@@ -73,6 +78,7 @@ export type CopilotSessionConfig = Pick<
| "infiniteSessions"
| "model"
| "onPermissionRequest"
| "onUserInputRequest"
| "reasoningEffort"
| "systemMessage"
| "tools"
@@ -222,6 +228,8 @@ function deferBackgroundCompactionCleanup(params: {
bridge: ReturnType<typeof attachEventBridge>;
handle: PooledClient;
pool: CopilotClientPool;
cleanupToolBridge?: () => void;
finalizeNativeSubagents?: () => void;
sdkSessionId?: string;
session: SessionLike;
timeoutMs: number;
@@ -244,12 +252,14 @@ function deferBackgroundCompactionCleanup(params: {
await cancelBackgroundCompactionBeforeTeardown(params.session);
params.bridge.settleCompactionWait();
}
params.finalizeNativeSubagents?.();
params.bridge.detach();
try {
await params.session.disconnect();
} catch {
// The attempt has already returned its timeout result.
}
params.cleanupToolBridge?.();
if (outcome !== "completed" && params.sdkSessionId) {
try {
await params.handle.client.deleteSession(params.sdkSessionId);
@@ -403,6 +413,14 @@ export async function runCopilotAttempt(
let handle: PooledClient | undefined;
let session: SessionLike | undefined;
let bridge: ReturnType<typeof attachEventBridge> | undefined;
const nativeSubagentTaskMirror = createCopilotNativeSubagentTaskMirror({
agentId: sessionAgentId,
now,
scope: input.agentHarnessTaskRuntimeScope,
});
let activeRunHandleRef: Parameters<typeof clearActiveEmbeddedRun>[1] | undefined;
let userInputBridgeRef: ReturnType<typeof createCopilotUserInputBridge> | undefined;
let cleanupToolBridge: (() => void) | undefined;
let releaseError: Error | undefined;
let downgradedFromResume = false;
let resumeFailureRecovered = false;
@@ -415,16 +433,24 @@ export async function runCopilotAttempt(
// `src/agents/pi-embedded-runner/run/types.ts:139`.
let yieldDetected = false;
const onAbort = () => {
const markExternalAbort = () => {
abortRequested = true;
externalAbort = true;
aborted = true;
};
const abortActiveSession = () => {
markExternalAbort();
if (settled || !sentTurnStarted || !session) {
return;
}
void session.abort().catch(() => undefined);
};
const onAbort = () => {
abortActiveSession();
};
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
// Sandbox parity with PI (`src/agents/pi-embedded-runner/run/attempt.ts:1232-1244`):
@@ -575,6 +601,7 @@ export async function runCopilotAttempt(
startedAt,
}),
});
cleanupToolBridge = toolBridge.cleanup;
sdkTools = toolBridge.sdkTools;
} catch (error: unknown) {
const result = createResult(input, {
@@ -655,6 +682,11 @@ export async function runCopilotAttempt(
});
};
const hasNativePromptHook = Boolean(attemptInput.hooksConfig?.onUserPromptSubmitted);
const userInputBridge = createCopilotUserInputBridge({
paramsForRun: attemptInput,
signal: params.abortSignal,
});
userInputBridgeRef = userInputBridge;
const sessionConfig = createSessionConfig(
attemptInput,
modelRef.id,
@@ -663,6 +695,7 @@ export async function runCopilotAttempt(
promptBuild.developerInstructions || undefined,
effectiveWorkspaceDir,
effectiveCwd,
userInputBridge.onUserInputRequest,
hasNativePromptHook
? {
onUserPromptSubmitted: ({ additionalContext, prompt }) =>
@@ -723,6 +756,8 @@ export async function runCopilotAttempt(
}
bridge = attachEventBridge(session, {
onAssistantDelta: input.onAssistantDelta,
onAgentEvent: input.onAgentEvent,
onNativeSubagentEvent: (event) => nativeSubagentTaskMirror?.handleEvent(event),
onCompactionStart: async () => {
const sessionFile = readString(input.sessionFile);
if (!sessionFile) {
@@ -748,6 +783,29 @@ export async function runCopilotAttempt(
isAborted: () => aborted,
});
const activeRunHandle = {
kind: "embedded" as const,
queueMessage: async (text: string) => {
if (userInputBridge.handleQueuedMessage(text)) {
return;
}
throw new Error("Copilot runtime is not waiting for user input.");
},
isStreaming: () => !settled && !aborted,
isCompacting: () => bridge?.isCompacting() ?? false,
sourceReplyDeliveryMode: input.sourceReplyDeliveryMode,
cancel: () => {
userInputBridge.cancelPending();
abortActiveSession();
},
abort: () => {
userInputBridge.cancelPending();
abortActiveSession();
},
};
setActiveEmbeddedRun(input.sessionId, activeRunHandle, input.sessionKey, input.sessionFile);
activeRunHandleRef = activeRunHandle;
const messageOptions = await createMessageOptions(attemptInput, {
effectiveCwd,
effectiveWorkspaceDir,
@@ -765,6 +823,7 @@ export async function runCopilotAttempt(
}
const result = await session.sendAndWait(messageOptions, input.timeoutMs);
await bridge.awaitDeltaChain();
await bridge.awaitAgentEventChain();
if (!bridge.recordSendResult(result) && !aborted) {
// SDK sendAndWait returning undefined is treated as a timeout by the
// capability inventory. Do not call session.abort() here: OpenClaw may
@@ -800,12 +859,22 @@ export async function runCopilotAttempt(
} catch {
// delta-flush failure must not mask the timeout state
}
await bridge?.awaitAgentEventChain();
} else {
promptError = toError(error);
}
}
} finally {
settled = true;
userInputBridgeRef?.cancelPending();
if (activeRunHandleRef) {
clearActiveEmbeddedRun(
input.sessionId,
activeRunHandleRef,
input.sessionKey,
input.sessionFile,
);
}
const retainSessionForDeferredCleanup =
bridge?.hasObservedCompaction() || (timedOut && bridge?.hasObservedSessionIdle() === false);
if (retainSessionForDeferredCleanup && bridge && session && handle) {
@@ -820,6 +889,8 @@ export async function runCopilotAttempt(
abortSignal: cleanupAbort.signal,
awaitSessionIdle: !bridge.hasObservedSessionIdle(),
bridge,
cleanupToolBridge,
finalizeNativeSubagents: () => nativeSubagentTaskMirror?.finalizeActiveRuns(),
handle,
pool: deps.pool,
sdkSessionId,
@@ -848,6 +919,9 @@ export async function runCopilotAttempt(
// defines as no background agents in flight. Timeouts retain the bridge
// until that event so compaction that starts after the timer still completes.
await bridge?.awaitCompactionChain();
await bridge?.awaitAgentEventChain();
nativeSubagentTaskMirror?.finalizeActiveRuns();
cleanupToolBridge?.();
bridge?.detach();
params.abortSignal?.removeEventListener("abort", onAbort);
@@ -931,8 +1005,9 @@ export async function runCopilotAttempt(
// extension. Identity-tagged so re-emits dedupe. Errors are
// swallowed so a mirror failure cannot break the attempt.
const sessionFileForMirror = readString(input.sessionFile);
const sessionIdForScope = sessionIdUsed ?? readString(input.sessionId);
if (sessionFileForMirror && messagesSnapshot.length > 0) {
const openClawSessionIdForMirror = readString(input.sessionId);
const mirrorScopeSessionId = sessionIdUsed ?? openClawSessionIdForMirror;
if (sessionFileForMirror && openClawSessionIdForMirror && messagesSnapshot.length > 0) {
const taggedMessages = messagesSnapshot.map((message, index) => {
if (
message.role !== "user" &&
@@ -953,15 +1028,16 @@ export async function runCopilotAttempt(
if (hasMirrorIdentity(message)) {
return message;
}
const identityScope = sdkSessionId ?? sessionIdForScope ?? "attempt";
const identityScope = sdkSessionId ?? mirrorScopeSessionId ?? "attempt";
return attachCopilotMirrorIdentity(message, `${identityScope}:${message.role}:${index}`);
});
await dualWriteCopilotTranscriptBestEffort({
sessionFile: sessionFileForMirror,
sessionId: openClawSessionIdForMirror,
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
agentId: readString(input.agentId),
messages: taggedMessages,
idempotencyScope: sessionIdForScope ? `copilot:${sessionIdForScope}` : undefined,
idempotencyScope: mirrorScopeSessionId ? `copilot:${mirrorScopeSessionId}` : undefined,
config: (input as { config?: unknown }).config as never,
}).catch((mirrorError: unknown) => {
// Defense-in-depth: the best-effort wrapper already swallows
@@ -1118,6 +1194,7 @@ function createSessionConfig(
systemMessageContent: string | undefined,
effectiveWorkspaceDir: string | undefined,
effectiveCwd: string | undefined,
onUserInputRequest: NonNullable<SessionConfig["onUserInputRequest"]>,
hooksBridgeOptions?: Parameters<typeof createHooksBridge>[1],
): CopilotSessionConfig {
const permissionPolicy = params.permissionPolicy ?? rejectAllPolicy;
@@ -1145,10 +1222,9 @@ function createSessionConfig(
// tool wrapper, and the SDK gate is a safety net for kinds we
// don't surface. See permission-bridge.ts and docs/plugins/copilot.md.
onPermissionRequest: createPermissionBridge(permissionPolicy),
// `onUserInputRequest` is intentionally NOT registered: per the SDK
// contract, omitting the handler hides the `ask_user` tool from the
// model entirely. Interactive ask_user will need a real channel/TUI
// prompt bridge before this runtime can expose the handler.
// Registers the SDK ask_user bridge. The bridge itself owns pending
// reply routing so generic mid-run steering still fails closed.
onUserInputRequest,
// Preserve the shipped native SDK hook contract. These callbacks expose
// Copilot-specific events and decisions that generic lifecycle hooks do
// not model.
@@ -1166,8 +1242,9 @@ function createSessionConfig(
...(infiniteSessions ? { infiniteSessions } : {}),
reasoningEffort: params.reasoningEffort,
tools: sdkTools,
// Restrict the SDK's tool catalog to exactly the bridged tool names
// returned by `createCopilotToolBridge`. Without this, the SDK
// Restrict the SDK's tool catalog to the bridged tool names returned
// by `createCopilotToolBridge` plus the built-in `ask_user` tool owned
// by `onUserInputRequest`. Without this, the SDK
// would still expose its native read/write/shell/url/mcp/memory/
// hook tools to the model alongside our overrides, which would
// bypass OpenClaw's wrapped-tool enforcement under any permissive
@@ -1182,7 +1259,7 @@ function createSessionConfig(
// `@github/copilot-sdk/dist/types.d.ts:1198` (it picks
// `availableTools`, so the spread into `resumeSession` covers
// the resume path too).
availableTools: sdkTools.map((tool) => tool.name),
availableTools: buildCopilotAvailableTools(sdkTools),
workingDirectory:
effectiveCwd ?? effectiveWorkspaceDir ?? readResolvedAttemptPath(params.workspaceDir),
// When a task runs from a sub-cwd, keep SDK-native project docs
@@ -1228,6 +1305,10 @@ function createSessionConfig(
};
}
function buildCopilotAvailableTools(sdkTools: SdkTool[]): string[] {
return [...new Set([...sdkTools.map((tool) => tool.name), ...COPILOT_ASK_USER_AVAILABLE_TOOLS])];
}
async function createMessageOptions(
params: AttemptParamsLike,
context: {

View File

@@ -86,6 +86,7 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [userMessage, assistantMessage, toolResultMessage],
idempotencyScope: "copilot:session-1",
@@ -113,6 +114,7 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [
makeAgentAssistantMessage({
@@ -143,12 +145,14 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [...messages],
idempotencyScope: "copilot:session-1",
});
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [...messages],
idempotencyScope: "copilot:session-1",
@@ -185,6 +189,7 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [sourceMessage],
idempotencyScope: "copilot:session-1",
@@ -210,6 +215,7 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [
makeAgentAssistantMessage({
@@ -228,6 +234,7 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [],
idempotencyScope: "copilot:session-1",
@@ -245,6 +252,7 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
messages: [message],
idempotencyScope: "scope-fp",
});
@@ -263,6 +271,7 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
messages: [tagged],
idempotencyScope: "copilot:openclaw-session-1",
});
@@ -279,6 +288,7 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
messages: [
makeAgentAssistantMessage({
content: [{ type: "text", text: "no scope" }],
@@ -306,6 +316,7 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
messages: [userMessage, systemLike],
idempotencyScope: "scope",
});
@@ -326,6 +337,7 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
messages: [second],
idempotencyScope: "scope",
});
@@ -342,6 +354,7 @@ describe("dualWriteCopilotTranscriptBestEffort", () => {
await expect(
dualWriteCopilotTranscriptBestEffort({
sessionFile,
sessionId: "session-1",
messages: [
makeAgentAssistantMessage({
content: [{ type: "text", text: "ok" }],
@@ -356,22 +369,34 @@ describe("dualWriteCopilotTranscriptBestEffort", () => {
});
it("swallows infrastructure failures and never rejects", async () => {
// Pointing sessionFile at a path under a non-existent root with an
// empty-string segment can fail differently on different platforms;
// instead force failure by passing an invalid type and asserting
// that the wrapper itself does not reject. Use any-cast for the
// bad input shape since we are testing the wrapper's catch.
await expect(
dualWriteCopilotTranscriptBestEffort({
sessionFile: "" as unknown as string,
messages: [
makeAgentAssistantMessage({
content: [{ type: "text", text: "should-not-throw" }],
timestamp: Date.now(),
}),
],
idempotencyScope: "scope",
}),
).resolves.toBeUndefined();
const root = await makeRoot("openclaw-copilot-mirror-invalid-");
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = root;
try {
await expect(
dualWriteCopilotTranscriptBestEffort({
agentId: "main",
sessionFile: "",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
messages: [
makeAgentAssistantMessage({
content: [{ type: "text", text: "should-not-throw" }],
timestamp: Date.now(),
}),
],
idempotencyScope: "scope",
}),
).resolves.toBeUndefined();
await expect(
fs.access(path.join(root, "agents", "main", "sessions", "session-1.jsonl")),
).rejects.toHaveProperty("code", "ENOENT");
} finally {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
}
});
});

View File

@@ -29,16 +29,16 @@
*/
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import {
acquireSessionWriteLock,
appendSessionTranscriptMessage,
emitSessionTranscriptUpdate,
resolveSessionWriteLockAcquireTimeoutMs,
runAgentHarnessBeforeMessageWriteHook,
type AgentMessage,
type SessionWriteLockAcquireTimeoutConfig,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
publishSessionTranscriptUpdateByIdentity,
withSessionTranscriptWriteLock,
type SessionTranscriptTargetParams,
type SessionTranscriptWriteLockParams,
} from "openclaw/plugin-sdk/session-transcript-runtime";
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
@@ -95,6 +95,7 @@ function buildMirrorDedupeIdentity(message: MirroredAgentMessage): string {
export interface MirrorCopilotTranscriptParams {
sessionFile: string;
sessionId: string;
sessionKey?: string;
agentId?: string;
messages: AgentMessage[];
@@ -106,7 +107,7 @@ export interface MirrorCopilotTranscriptParams {
* entry collide with its existing on-disk key and be a true no-op.
*/
idempotencyScope?: string;
config?: SessionWriteLockAcquireTimeoutConfig;
config?: SessionTranscriptWriteLockParams["config"];
}
export async function mirrorCopilotTranscript(
@@ -120,82 +121,91 @@ export async function mirrorCopilotTranscript(
return;
}
const lock = await acquireSessionWriteLock({
sessionFile: params.sessionFile,
timeoutMs: resolveSessionWriteLockAcquireTimeoutMs(params.config),
});
try {
const existingIdempotencyKeys = await readTranscriptIdempotencyKeys(params.sessionFile);
for (const message of messages) {
const dedupeIdentity = buildMirrorDedupeIdentity(message);
const idempotencyKey = params.idempotencyScope
? `${params.idempotencyScope}:${dedupeIdentity}`
: undefined;
if (idempotencyKey && existingIdempotencyKeys.has(idempotencyKey)) {
continue;
const transcriptTarget = resolveCopilotMirrorTranscriptTarget(params);
const didAppend = await withSessionTranscriptWriteLock(
{ ...transcriptTarget, config: params.config },
async (transcript) => {
let didAppendMessage = false;
const existingIdempotencyKeys = readTranscriptIdempotencyKeys(await transcript.readEvents());
for (const message of messages) {
const dedupeIdentity = buildMirrorDedupeIdentity(message);
const idempotencyKey = params.idempotencyScope
? `${params.idempotencyScope}:${dedupeIdentity}`
: undefined;
if (idempotencyKey && existingIdempotencyKeys.has(idempotencyKey)) {
continue;
}
const transcriptMessage = {
...message,
...(idempotencyKey ? { idempotencyKey } : {}),
} as AgentMessage;
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
message: transcriptMessage,
agentId: params.agentId,
sessionKey: params.sessionKey,
});
if (!nextMessage) {
continue;
}
const messageToAppend = (
idempotencyKey
? {
...(nextMessage as unknown as Record<string, unknown>),
idempotencyKey,
}
: nextMessage
) as AgentMessage;
const appended = await transcript.appendMessage({
message: messageToAppend,
idempotencyLookup: idempotencyKey ? "caller-checked" : "scan",
});
if (!appended) {
continue;
}
didAppendMessage = true;
if (idempotencyKey) {
existingIdempotencyKeys.add(idempotencyKey);
}
}
const transcriptMessage = {
...message,
...(idempotencyKey ? { idempotencyKey } : {}),
} as AgentMessage;
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
message: transcriptMessage,
agentId: params.agentId,
sessionKey: params.sessionKey,
});
if (!nextMessage) {
continue;
}
const messageToAppend = (
idempotencyKey
? {
...(nextMessage as unknown as Record<string, unknown>),
idempotencyKey,
}
: nextMessage
) as AgentMessage;
await appendSessionTranscriptMessage({
transcriptPath: params.sessionFile,
message: messageToAppend,
config: params.config,
});
if (idempotencyKey) {
existingIdempotencyKeys.add(idempotencyKey);
}
}
} finally {
await lock.release();
}
return didAppendMessage;
},
);
if (params.sessionKey) {
emitSessionTranscriptUpdate({ sessionFile: params.sessionFile, sessionKey: params.sessionKey });
} else {
emitSessionTranscriptUpdate(params.sessionFile);
if (didAppend) {
await publishSessionTranscriptUpdateByIdentity({
...transcriptTarget,
update: params.sessionKey ? { sessionKey: params.sessionKey } : undefined,
});
}
}
async function readTranscriptIdempotencyKeys(sessionFile: string): Promise<Set<string>> {
const keys = new Set<string>();
let raw: string;
try {
raw = await fs.readFile(sessionFile, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
return keys;
function resolveCopilotMirrorTranscriptTarget(params: {
agentId?: string;
sessionFile: string;
sessionId: string;
sessionKey?: string;
}): SessionTranscriptTargetParams {
const sessionFile = params.sessionFile.trim();
if (!sessionFile) {
throw new Error("Copilot transcript mirror requires a sessionFile target");
}
for (const line of raw.split(/\r?\n/)) {
if (!line.trim()) {
return {
...(params.agentId ? { agentId: params.agentId } : {}),
sessionFile,
sessionId: params.sessionId,
sessionKey: params.sessionKey ?? "",
};
}
function readTranscriptIdempotencyKeys(events: unknown[]): Set<string> {
const keys = new Set<string>();
for (const event of events) {
if (!event || typeof event !== "object" || Array.isArray(event)) {
continue;
}
try {
const parsed = JSON.parse(line) as { message?: { idempotencyKey?: unknown } };
if (typeof parsed.message?.idempotencyKey === "string") {
keys.add(parsed.message.idempotencyKey);
}
} catch {
continue;
const parsed = event as { message?: { idempotencyKey?: unknown } };
if (typeof parsed.message?.idempotencyKey === "string") {
keys.add(parsed.message.idempotencyKey);
}
}
return keys;

View File

@@ -15,6 +15,11 @@ const REGISTERED_EVENT_TYPES = [
"assistant.usage",
"tool.execution_start",
"tool.execution_complete",
"session.plan_changed",
"exit_plan_mode.requested",
"subagent.started",
"subagent.completed",
"subagent.failed",
"session.compaction_start",
"session.compaction_complete",
"session.idle",
@@ -455,6 +460,78 @@ describe("attachEventBridge", () => {
});
});
it("projects Copilot plan events through the generic plan stream", async () => {
const session = createFakeSession();
const onAgentEvent = vi.fn().mockResolvedValue(undefined);
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
onAgentEvent,
});
session.emit(
"session.plan_changed",
makeEvent("session.plan_changed", { operation: "update" }),
);
session.emit(
"exit_plan_mode.requested",
makeEvent("exit_plan_mode.requested", {
actions: ["approve", "edit"],
planContent: "# Plan\n- inspect\n- patch",
recommendedAction: "approve",
requestId: "request-1",
summary: "Plan ready",
}),
);
await bridge.awaitAgentEventChain();
expect(onAgentEvent).toHaveBeenCalledTimes(2);
expect(onAgentEvent).toHaveBeenNthCalledWith(1, {
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "copilot-sdk",
operation: "update",
},
});
expect(onAgentEvent).toHaveBeenNthCalledWith(2, {
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "copilot-sdk",
explanation: "Plan ready",
steps: ["# Plan", "inspect", "patch"],
actions: ["approve", "edit"],
requestId: "request-1",
recommendedAction: "approve",
},
});
});
it("forwards native Copilot subagent lifecycle events to the adapter", () => {
const session = createFakeSession();
const onNativeSubagentEvent = vi.fn();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
onNativeSubagentEvent,
});
const event = makeEvent("subagent.started", {
agentDescription: "inspect the repository",
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-1",
});
session.emit("subagent.started", event);
expect(onNativeSubagentEvent).toHaveBeenCalledWith(event);
bridge.detach();
});
it("preserves all-zero usage snapshot after an invalid assistant.usage event", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {

View File

@@ -41,6 +41,16 @@ export interface SessionLike {
export interface EventBridgeOptions {
onAssistantDelta?: (payload: OnAssistantDeltaPayload) => void | Promise<void>;
onAgentEvent?: (event: {
stream: "item" | "plan";
data: Record<string, unknown>;
}) => void | Promise<void>;
onNativeSubagentEvent?: (
event: Extract<
SessionEvent,
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
>,
) => void;
onCompactionComplete?: (payload: {
messagesRemoved?: number;
success: boolean;
@@ -72,6 +82,7 @@ export interface EventBridgeController {
awaitSessionIdle(): Promise<void>;
settleCompactionWait(): void;
awaitDeltaChain(): Promise<void>;
awaitAgentEventChain(): Promise<void>;
hasObservedCompaction(): boolean;
hasObservedSessionIdle(): boolean;
isCompacting(): boolean;
@@ -103,6 +114,7 @@ export function attachEventBridge(
let observedCompaction = false;
let deltaQueue = Promise.resolve();
let deltaChain = Promise.resolve();
let agentEventChain = Promise.resolve();
let compactionChain = Promise.resolve();
let compactionIdle = Promise.resolve();
let resolveCompactionIdle: (() => void) | undefined;
@@ -191,6 +203,51 @@ export function attachEventBridge(
}
});
registerListener(session, unsubscribeFns, "session.plan_changed", (event) => {
enqueueAgentEvent({
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "copilot-sdk",
operation: event.data.operation,
...(event.agentId ? { agentId: event.agentId } : {}),
},
});
});
registerListener(session, unsubscribeFns, "exit_plan_mode.requested", (event) => {
const steps = splitPlanText(event.data.planContent);
enqueueAgentEvent({
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "copilot-sdk",
...(event.data.summary ? { explanation: event.data.summary } : {}),
...(steps.length > 0 ? { steps } : {}),
...(event.data.actions.length > 0 ? { actions: event.data.actions } : {}),
...(event.data.requestId ? { requestId: event.data.requestId } : {}),
...(event.data.recommendedAction
? { recommendedAction: event.data.recommendedAction }
: {}),
...(event.agentId ? { agentId: event.agentId } : {}),
},
});
});
registerListener(session, unsubscribeFns, "subagent.started", (event) => {
forwardNativeSubagentEvent(event);
});
registerListener(session, unsubscribeFns, "subagent.completed", (event) => {
forwardNativeSubagentEvent(event);
});
registerListener(session, unsubscribeFns, "subagent.failed", (event) => {
forwardNativeSubagentEvent(event);
});
registerListener(session, unsubscribeFns, "session.compaction_start", (event) => {
if (!isRootCompactionEvent(event)) {
return;
@@ -276,6 +333,9 @@ export function attachEventBridge(
awaitDeltaChain() {
return deltaChain;
},
awaitAgentEventChain() {
return agentEventChain;
},
hasObservedCompaction() {
return observedCompaction;
},
@@ -334,6 +394,31 @@ export function attachEventBridge(
compactionChain = queued.catch(() => undefined);
}
function enqueueAgentEvent(event: {
stream: "item" | "plan";
data: Record<string, unknown>;
}): void {
const callback = options.onAgentEvent;
if (!callback) {
return;
}
const invoke = () => callback(event);
agentEventChain = agentEventChain.then(invoke, invoke).catch(() => undefined);
}
function forwardNativeSubagentEvent(
event: Extract<
SessionEvent,
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
>,
): void {
try {
options.onNativeSubagentEvent?.(event);
} catch {
// Native task mirroring must not corrupt the Copilot turn.
}
}
async function awaitStableCompaction(): Promise<void> {
const idle = activeCompactionCount > 0 ? compactionIdle : undefined;
if (idle) {
@@ -456,6 +541,13 @@ function joinReasoning(order: string[], reasoningById: Map<string, string>): str
return order.map((reasoningId) => reasoningById.get(reasoningId) ?? "").join("");
}
function splitPlanText(text: string | undefined): string[] {
return (text ?? "")
.split(/\r?\n/)
.map((line) => line.trim().replace(/^[-*]\s+/, ""))
.filter((line) => line.length > 0);
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}

View File

@@ -0,0 +1,200 @@
import type { SessionEvent } from "@github/copilot-sdk";
import type {
AgentHarnessTaskRecord,
AgentHarnessTaskRuntime,
} from "openclaw/plugin-sdk/agent-harness-task-runtime";
import { describe, expect, it, vi } from "vitest";
import {
CopilotNativeSubagentTaskMirror,
createCopilotNativeSubagentTaskMirror,
} from "./native-subagent-task-mirror.js";
type NativeSubagentEventType = "subagent.started" | "subagent.completed" | "subagent.failed";
function makeEvent<T extends NativeSubagentEventType>(
type: T,
data: Extract<SessionEvent, { type: T }>["data"],
agentId?: string,
): Extract<SessionEvent, { type: T }> {
return {
data,
id: `${type}-id`,
parentId: null,
timestamp: "2024-01-01T00:00:00.000Z",
type,
...(agentId ? { agentId } : {}),
} as Extract<SessionEvent, { type: T }>;
}
function createRuntime() {
const task = {} as AgentHarnessTaskRecord;
return {
tryCreateRunningTaskRun: vi.fn(() => task),
recordTaskRunProgressByRunId: vi.fn(() => []),
finalizeTaskRunByRunId: vi.fn(() => []),
} satisfies Pick<
AgentHarnessTaskRuntime,
"tryCreateRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
>;
}
describe("CopilotNativeSubagentTaskMirror", () => {
it("does not create a mirror without a host-issued task scope", () => {
expect(createCopilotNativeSubagentTaskMirror({})).toBeUndefined();
});
it("mirrors start and completion using agentId with toolCallId fallback", () => {
const runtime = createRuntime();
const mirror = new CopilotNativeSubagentTaskMirror(
{ agentId: "parent-agent", now: () => 100 },
runtime,
);
mirror.handleEvent(
makeEvent(
"subagent.started",
{
agentDescription: "inspect the repository",
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-1",
},
"child-1",
),
);
mirror.handleEvent(
makeEvent(
"subagent.completed",
{
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-1",
totalToolCalls: 2,
totalTokens: 30,
},
"child-1",
),
);
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith({
sourceId: "call-1",
agentId: "parent-agent",
runId: "copilot-agent:child-1",
label: "Researcher",
task: "inspect the repository",
notifyPolicy: "silent",
deliveryStatus: "not_applicable",
preferMetadata: true,
startedAt: 100,
lastEventAt: 100,
progressSummary: "Copilot native subagent started.",
});
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
runId: "copilot-agent:child-1",
status: "succeeded",
endedAt: 100,
lastEventAt: 100,
progressSummary: "Copilot native subagent completed.",
terminalSummary: "Copilot native subagent completed (2 tool calls, 30 tokens).",
});
});
it("uses toolCallId when the SDK omits agentId", () => {
const runtime = createRuntime();
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 200 }, runtime);
mirror.handleEvent(
makeEvent("subagent.started", {
agentDescription: "",
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-2",
}),
);
mirror.handleEvent(
makeEvent("subagent.failed", {
agentDisplayName: "Researcher",
agentName: "researcher",
error: "failed",
toolCallId: "call-2",
}),
);
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith(
expect.objectContaining({
runId: "copilot-agent:call-2",
status: "failed",
error: "failed",
}),
);
});
it("keeps parallel subagents distinct when they share a parent tool call", () => {
const runtime = createRuntime();
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 250 }, runtime);
for (const agentId of ["child-1", "child-2"]) {
mirror.handleEvent(
makeEvent(
"subagent.started",
{
agentDescription: `inspect ${agentId}`,
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-shared",
},
agentId,
),
);
}
for (const agentId of ["child-1", "child-2"]) {
mirror.handleEvent(
makeEvent(
"subagent.completed",
{
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-shared",
},
agentId,
),
);
}
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledTimes(2);
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledTimes(2);
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ runId: "copilot-agent:child-1" }),
);
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ runId: "copilot-agent:child-2" }),
);
});
it("finalizes active tasks when the parent attempt tears down", () => {
const runtime = createRuntime();
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 300 }, runtime);
mirror.handleEvent(
makeEvent("subagent.started", {
agentDescription: "inspect",
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-3",
}),
);
mirror.finalizeActiveRuns();
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
runId: "copilot-agent:call-3",
status: "cancelled",
endedAt: 300,
lastEventAt: 300,
error: "Copilot native subagent ended with its parent attempt.",
progressSummary: "Copilot native subagent cancelled with its parent attempt.",
terminalSummary: "Copilot native subagent cancelled.",
});
});
});

View File

@@ -0,0 +1,199 @@
import type { SessionEvent } from "@github/copilot-sdk";
import {
createAgentHarnessTaskRuntime,
type AgentHarnessTaskRuntime,
type AgentHarnessTaskRuntimeScope,
} from "openclaw/plugin-sdk/agent-harness-task-runtime";
const COPILOT_NATIVE_SUBAGENT_TASK_KIND = "copilot-native";
const COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX = "copilot-agent:";
type CopilotNativeSubagentEvent = Extract<
SessionEvent,
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
>;
type TaskLifecycleRuntime = Pick<
AgentHarnessTaskRuntime,
"tryCreateRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
>;
export function createCopilotNativeSubagentTaskMirror(params: {
agentId?: string;
now?: () => number;
scope?: AgentHarnessTaskRuntimeScope;
}): CopilotNativeSubagentTaskMirror | undefined {
if (!params.scope) {
return undefined;
}
return new CopilotNativeSubagentTaskMirror(
{
agentId: params.agentId,
now: params.now,
},
createAgentHarnessTaskRuntime({
runtime: "subagent",
taskKind: COPILOT_NATIVE_SUBAGENT_TASK_KIND,
scope: params.scope,
runIdPrefix: COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX,
}),
);
}
export class CopilotNativeSubagentTaskMirror {
private readonly runIdByAgentId = new Map<string, string>();
private readonly runIdByToolCallId = new Map<string, string>();
private readonly terminalRunIds = new Set<string>();
private readonly activeRunIds = new Set<string>();
private readonly now: () => number;
constructor(
private readonly params: { agentId?: string; now?: () => number },
private readonly runtime: TaskLifecycleRuntime,
) {
this.now = params.now ?? Date.now;
}
handleEvent(event: CopilotNativeSubagentEvent): void {
const toolCallId = event.data.toolCallId.trim();
if (!toolCallId) {
return;
}
const runId = this.resolveRunId(event);
if (event.type === "subagent.started") {
this.handleStarted(event, runId, toolCallId);
return;
}
if (event.type === "subagent.completed") {
this.handleCompleted(event, runId);
return;
}
this.handleFailed(event, runId);
}
finalizeActiveRuns(): void {
const eventAt = this.now();
for (const runId of this.activeRunIds) {
this.terminalRunIds.add(runId);
this.runtime.finalizeTaskRunByRunId({
runId,
status: "cancelled",
endedAt: eventAt,
lastEventAt: eventAt,
error: "Copilot native subagent ended with its parent attempt.",
progressSummary: "Copilot native subagent cancelled with its parent attempt.",
terminalSummary: "Copilot native subagent cancelled.",
});
}
this.activeRunIds.clear();
}
private handleStarted(
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.started" }>,
runId: string,
toolCallId: string,
): void {
const agentId = event.agentId?.trim();
const existingRunId = agentId
? this.runIdByAgentId.get(agentId)
: this.runIdByToolCallId.get(toolCallId);
if (existingRunId) {
return;
}
const eventAt = this.now();
const label = event.data.agentDisplayName.trim() || event.data.agentName.trim();
const task = event.data.agentDescription.trim() || `Copilot native subagent ${label}`;
const taskRecord = this.runtime.tryCreateRunningTaskRun({
sourceId: toolCallId,
agentId: this.params.agentId,
runId,
label: label || "Copilot subagent",
task,
notifyPolicy: "silent",
deliveryStatus: "not_applicable",
preferMetadata: true,
startedAt: eventAt,
lastEventAt: eventAt,
progressSummary: "Copilot native subagent started.",
});
if (!taskRecord) {
return;
}
if (agentId) {
this.runIdByAgentId.set(agentId, runId);
} else {
this.runIdByToolCallId.set(toolCallId, runId);
}
this.terminalRunIds.delete(runId);
this.activeRunIds.add(runId);
}
private handleCompleted(
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.completed" }>,
runId: string,
): void {
if (this.terminalRunIds.has(runId)) {
return;
}
const eventAt = this.now();
this.terminalRunIds.add(runId);
this.activeRunIds.delete(runId);
this.runtime.finalizeTaskRunByRunId({
runId,
status: "succeeded",
endedAt: eventAt,
lastEventAt: eventAt,
progressSummary: "Copilot native subagent completed.",
terminalSummary: buildCompletionSummary(event),
});
}
private handleFailed(
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.failed" }>,
runId: string,
): void {
if (this.terminalRunIds.has(runId)) {
return;
}
const eventAt = this.now();
this.terminalRunIds.add(runId);
this.activeRunIds.delete(runId);
this.runtime.finalizeTaskRunByRunId({
runId,
status: "failed",
endedAt: eventAt,
lastEventAt: eventAt,
error: event.data.error,
progressSummary: "Copilot native subagent failed.",
terminalSummary: "Copilot native subagent failed.",
});
}
private resolveRunId(event: CopilotNativeSubagentEvent): string {
const agentId = event.agentId?.trim();
if (agentId) {
const existing = this.runIdByAgentId.get(agentId);
if (existing) {
return existing;
}
}
const existing = this.runIdByToolCallId.get(event.data.toolCallId);
if (existing) {
return existing;
}
const identity = agentId || event.data.toolCallId.trim();
return `${COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX}${identity}`;
}
}
function buildCompletionSummary(
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.completed" }>,
): string {
const details = [
event.data.totalToolCalls !== undefined ? `${event.data.totalToolCalls} tool calls` : undefined,
event.data.totalTokens !== undefined ? `${event.data.totalTokens} tokens` : undefined,
].filter((value): value is string => value !== undefined);
return details.length > 0
? `Copilot native subagent completed (${details.join(", ")}).`
: "Copilot native subagent completed.";
}

View File

@@ -156,11 +156,184 @@ describe("createCopilotToolBridge", () => {
sessionId: "session-1",
});
expect(result.sourceTools).toBe(sourceTools);
expect(result.sourceTools).toEqual(sourceTools);
expect(result.sdkTools).toHaveLength(2);
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool-a", "tool-b"]);
});
it("compacts the Copilot tool surface behind tool_search controls when enabled", async () => {
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
const includeToolSearchControls = Boolean(
(opts as { includeToolSearchControls?: boolean }).includeToolSearchControls,
);
return includeToolSearchControls
? [
makeTool({ name: "tool_search_code" }),
makeTool({ name: "fake_hidden" }),
makeTool({ name: "read" }),
]
: [makeTool({ name: "fake_hidden" }), makeTool({ name: "read" })];
});
const result = await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { toolSearch: true } },
runId: "run-tool-search",
sessionKey: "agent:main:main",
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
expect(createOpenClawCodingTools).toHaveBeenCalledWith(
expect.objectContaining({
includeToolSearchControls: true,
toolSearchCatalogRef: expect.any(Object),
toolSearchCatalogExecutor: expect.any(Function),
}),
);
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
});
it("keeps tool_search controls visible when a narrow allowlist is active", async () => {
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
const includeToolSearchControls = Boolean(
(opts as { includeToolSearchControls?: boolean }).includeToolSearchControls,
);
return includeToolSearchControls
? [makeTool({ name: "tool_search_code" }), makeTool({ name: "read" })]
: [makeTool({ name: "read" })];
});
const result = await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { toolSearch: true } },
runId: "run-tool-search",
sessionKey: "agent:main:main",
toolsAllow: ["read"],
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
});
it("filters the hidden tool_search catalog before compacting narrowed tools", async () => {
let catalogRef: { current?: { entries?: Array<{ name: string }> } } | undefined;
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
catalogRef = (opts as { toolSearchCatalogRef?: typeof catalogRef }).toolSearchCatalogRef;
return [
makeTool({ name: "tool_search_code" }),
makeTool({ name: "read" }),
makeTool({ name: "edit" }),
makeTool({ name: "write" }),
];
});
await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { toolSearch: true } },
runId: "run-tool-search",
sessionKey: "agent:main:main",
toolsAllow: ["read"],
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
expect(catalogRef?.current?.entries?.map((entry) => entry.name)).toEqual(["read"]);
});
it("compacts the Copilot tool surface behind code-mode exec/wait when enabled", async () => {
const createOpenClawCodingTools = vi.fn(async () => [
makeTool({ name: "fake_hidden" }),
makeTool({ name: "read" }),
]);
const result = await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { codeMode: true } },
runId: "run-code-mode",
sessionKey: "agent:main:main",
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
expect(createOpenClawCodingTools).toHaveBeenCalledWith(
expect.objectContaining({
includeToolSearchControls: false,
toolSearchCatalogRef: expect.any(Object),
toolSearchCatalogExecutor: expect.any(Function),
}),
);
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
});
it("keeps code-mode controls visible when a narrow allowlist is active", async () => {
const createOpenClawCodingTools = vi.fn(async () => [
makeTool({ name: "fake_hidden" }),
makeTool({ name: "read" }),
]);
const result = await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { codeMode: true } },
runId: "run-code-mode",
sessionKey: "agent:main:main",
toolsAllow: ["read"],
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
});
it("filters the hidden code-mode catalog before compacting narrowed tools", async () => {
let catalogRef: { current?: { entries?: Array<{ name: string }> } } | undefined;
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
catalogRef = (opts as { toolSearchCatalogRef?: typeof catalogRef }).toolSearchCatalogRef;
return [makeTool({ name: "read" }), makeTool({ name: "edit" }), makeTool({ name: "write" })];
});
await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { codeMode: true } },
runId: "run-code-mode",
sessionKey: "agent:main:main",
toolsAllow: ["read"],
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
expect(catalogRef?.current?.entries?.map((entry) => entry.name)).toEqual(["read"]);
});
it("throws when createOpenClawCodingTools returns a non-array", async () => {
await expect(
createCopilotToolBridge({

View File

@@ -17,10 +17,15 @@ import {
resolveModelAuthMode,
sanitizeToolResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { createAgentHarnessToolSurfaceRuntime } from "openclaw/plugin-sdk/agent-harness-tool-runtime";
type CreateOpenClawCodingTools =
(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"];
type OpenClawCodingToolsOptions = NonNullable<Parameters<CreateOpenClawCodingTools>[0]>;
type AgentHarnessToolSurfaceRuntime = ReturnType<typeof createAgentHarnessToolSurfaceRuntime>;
type CatalogExecuteParams = Parameters<
NonNullable<AgentHarnessToolSurfaceRuntime["toolSearchCatalogExecutor"]>
>[0];
type AgentToolResultLike = {
content?: unknown;
@@ -130,6 +135,7 @@ export interface CopilotToolBridgeInput {
}
export interface CopilotToolBridge {
cleanup?: () => void;
sdkTools: SdkTool[];
sourceTools: AnyAgentTool[];
}
@@ -178,7 +184,31 @@ export async function createCopilotToolBridge(
input.createOpenClawCodingTools ??
(await import("openclaw/plugin-sdk/agent-harness")).createOpenClawCodingTools;
const toolOptions = buildOpenClawCodingToolsOptions(input, effectiveToolPlan);
const toolSurfaceRuntime = createAgentHarnessToolSurfaceRuntime({
abortSignal: input.abortSignal,
agentId: input.agentId,
config: attemptParams.config,
disableTools: attemptParams.disableTools,
executeTool: (toolParams) => executeCatalogTool(input, toolParams),
forceMessageTool: shouldForceCopilotMessageTool(attemptParams),
isRawModelRun: isCopilotRawModelRun(attemptParams),
modelToolsEnabled: true,
prompt: attemptParams.prompt,
runId: attemptParams.runId,
runtimeToolAllowlist: effectiveToolPlan.runtimeToolAllowlist,
sessionId: input.sessionId,
sessionKey: attemptParams.sandboxSessionKey ?? attemptParams.sessionKey ?? input.sessionKey,
sourceReplyDeliveryMode: attemptParams.sourceReplyDeliveryMode,
toolsAllow: attemptParams.toolsAllow,
});
const toolOptions = buildOpenClawCodingToolsOptions(
input,
{
...effectiveToolPlan,
runtimeToolAllowlist: toolSurfaceRuntime.runtimeToolAllowlist,
},
toolSurfaceRuntime,
);
let sourceTools: unknown;
try {
@@ -196,13 +226,19 @@ export async function createCopilotToolBridge(
);
}
const plannedTools = filterCopilotToolsForConstructionPlan(
const allowedSourceTools = filterCopilotToolsForAllowlist(
sourceTools as AnyAgentTool[],
toolSurfaceRuntime.runtimeToolAllowlist,
);
const compactedTools = toolSurfaceRuntime.compactTools(allowedSourceTools);
const plannedTools = filterCopilotToolsForConstructionPlan(
compactedTools.tools,
effectiveToolPlan.codingToolConstructionPlan,
{ preserveToolNames: toolSurfaceRuntime.runtimeToolAllowlist },
);
const filteredTools = filterCopilotToolsForAllowlist(
plannedTools,
effectiveToolPlan.runtimeToolAllowlist,
toolSurfaceRuntime.runtimeToolAllowlist,
);
// Run duplicate detection after filtering so a duplicate in a
@@ -214,6 +250,7 @@ export async function createCopilotToolBridge(
}
return {
cleanup: toolSurfaceRuntime.cleanup,
sdkTools: filteredTools.map((sourceTool) =>
convertOpenClawToolToSdkTool(sourceTool, {
abortSignal: input.abortSignal,
@@ -251,6 +288,7 @@ export async function createCopilotToolBridge(
function buildOpenClawCodingToolsOptions(
input: CopilotToolBridgeInput,
toolPlan: ReturnType<typeof resolveEmbeddedAttemptToolConstructionPlan>,
toolSurfaceRuntime?: ReturnType<typeof createAgentHarnessToolSurfaceRuntime>,
): OpenClawCodingToolsOptions {
const a = input.attemptParams ?? ({} as CopilotToolAttemptParams);
@@ -339,11 +377,14 @@ function buildOpenClawCodingToolsOptions(
// `resolveSandboxContext`).
sandbox,
spawnWorkspaceDir,
config: a.config,
config: toolSurfaceRuntime?.config ?? a.config,
abortSignal: input.abortSignal,
modelProvider: input.modelProvider,
modelId: input.modelId,
includeCoreTools: toolPlan.includeCoreTools,
includeToolSearchControls: toolSurfaceRuntime?.includeToolSearchControls,
toolSearchCatalogRef: toolSurfaceRuntime?.toolSearchCatalogRef,
toolSearchCatalogExecutor: toolSurfaceRuntime?.toolSearchCatalogExecutor,
runtimeToolAllowlist: toolPlan.runtimeToolAllowlist,
toolConstructionPlan: toolPlan.codingToolConstructionPlan,
modelCompat,
@@ -575,6 +616,63 @@ export function convertOpenClawToolToSdkTool(
};
}
async function executeCatalogTool(
input: CopilotToolBridgeInput,
params: CatalogExecuteParams,
): Promise<Awaited<ReturnType<AnyAgentTool["execute"]>>> {
const sourceTool = params.tool as AnyAgentTool;
const startedAt = Date.now();
let preparedArgs: unknown = params.input;
try {
preparedArgs = sourceTool.prepareArguments
? sourceTool.prepareArguments(params.input)
: params.input;
const result = await sourceTool.execute(
params.toolCallId,
preparedArgs,
params.signal ?? input.abortSignal,
params.onUpdate,
);
const sanitizedResult = sanitizeToolResult(result);
const isError = isToolResultError(sanitizedResult);
input.attemptParams?.onAgentToolResult?.({
toolName: params.toolName,
result: sanitizedResult,
isError,
});
await input.onToolCompleted?.({
toolName: params.toolName,
toolCallId: params.toolCallId,
args: toToolStartArgs(preparedArgs),
result: sanitizedResult,
...(isError
? { error: extractToolErrorMessage(sanitizedResult) ?? "tool returned an error" }
: {}),
startedAt,
});
return result;
} catch (error: unknown) {
const message = toError(error).message;
const failure = sanitizeToolResult({
content: [{ type: "text", text: message }],
details: { status: "failed", error: message },
});
input.attemptParams?.onAgentToolResult?.({
toolName: params.toolName,
result: failure,
isError: true,
});
await input.onToolCompleted?.({
toolName: params.toolName,
toolCallId: params.toolCallId,
args: toToolStartArgs(preparedArgs),
error: message,
startedAt,
});
throw error;
}
}
function toToolStartArgs(args: unknown): Record<string, unknown> {
return args && typeof args === "object" && !Array.isArray(args)
? (args as Record<string, unknown>)
@@ -712,11 +810,16 @@ function filterCopilotToolsForAllowlist<T extends { name: string }>(
function filterCopilotToolsForConstructionPlan<T extends { name: string }>(
tools: T[],
plan: ReturnType<typeof resolveEmbeddedAttemptToolConstructionPlan>["codingToolConstructionPlan"],
options: { preserveToolNames?: readonly string[] } = {},
): T[] {
if (plan.includeBaseCodingTools && plan.includeShellTools) {
return tools;
}
const preserveToolNames = new Set(options.preserveToolNames);
return tools.filter((tool) => {
if (preserveToolNames.has(tool.name)) {
return true;
}
if (!plan.includeBaseCodingTools && BASE_COPILOT_CODING_TOOL_NAMES.has(tool.name)) {
return false;
}

View File

@@ -0,0 +1,121 @@
// Copilot tests cover SDK ask_user bridge behavior.
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
import { describe, expect, it, vi } from "vitest";
import { createCopilotUserInputBridge } from "./user-input-bridge.js";
function createParams(): EmbeddedRunAttemptParams {
return {
sessionId: "session-1",
sessionKey: "agent:main:session-1",
onBlockReply: vi.fn(),
} as unknown as EmbeddedRunAttemptParams;
}
function expectFirstBlockReplyText(params: EmbeddedRunAttemptParams): string {
const onBlockReply = params.onBlockReply;
if (!onBlockReply) {
throw new Error("Expected onBlockReply callback");
}
const payload = vi.mocked(onBlockReply).mock.calls[0]?.[0];
if (typeof payload?.text !== "string") {
throw new Error("Expected first block reply text");
}
return payload.text;
}
describe("Copilot user input bridge", () => {
it("prompts through OpenClaw and resolves the SDK request from the next queued message", async () => {
const params = createParams();
const bridge = createCopilotUserInputBridge({ paramsForRun: params });
const response = bridge.onUserInputRequest(
{
question: "Pick a mode",
choices: ["Fast", "Deep"],
allowFreeform: false,
},
{ sessionId: "sdk-session-1" },
);
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
expect(expectFirstBlockReplyText(params)).toContain("Pick a mode");
expect(bridge.handleQueuedMessage("2")).toBe(true);
await expect(response).resolves.toEqual({ answer: "Deep", wasFreeform: false });
});
it("returns free-form answers when Copilot allows them", async () => {
const params = createParams();
const bridge = createCopilotUserInputBridge({ paramsForRun: params });
const response = bridge.onUserInputRequest(
{
question: "Which branch?",
allowFreeform: true,
},
{ sessionId: "sdk-session-1" },
);
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
expect(bridge.handleQueuedMessage("fix/harness-parity")).toBe(true);
await expect(response).resolves.toEqual({
answer: "fix/harness-parity",
wasFreeform: true,
});
});
it("escapes SDK-controlled prompt text before channel delivery", async () => {
const params = createParams();
const bridge = createCopilotUserInputBridge({ paramsForRun: params });
void bridge.onUserInputRequest(
{
question: "Pick [trusted](https://evil) <@U123> @here\u202e",
choices: ["One @everyone", "Two `code`"],
allowFreeform: false,
},
{ sessionId: "sdk-session-1" },
);
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
const text = expectFirstBlockReplyText(params);
expect(text).not.toContain("@here");
expect(text).not.toContain("@everyone");
expect(text).not.toContain("<@U123>");
expect(text).not.toContain("[trusted](https://evil)");
expect(text).not.toContain("`code`");
expect(text).toContain("\uff20here");
expect(text).toContain("\uff3btrusted\uff3d");
});
it("rejects queued messages when no ask_user request is pending", () => {
const bridge = createCopilotUserInputBridge({ paramsForRun: createParams() });
expect(bridge.handleQueuedMessage("late")).toBe(false);
});
it("resolves pending requests with an empty answer when aborted", async () => {
const params = createParams();
const controller = new AbortController();
const bridge = createCopilotUserInputBridge({
paramsForRun: params,
signal: controller.signal,
});
const response = bridge.onUserInputRequest(
{
question: "Continue?",
choices: ["Yes", "No"],
allowFreeform: false,
},
{ sessionId: "sdk-session-1" },
);
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
controller.abort();
await expect(response).resolves.toEqual({ answer: "", wasFreeform: true });
expect(bridge.handleQueuedMessage("1")).toBe(false);
});
});

View File

@@ -0,0 +1,161 @@
import type { SessionConfig } from "@github/copilot-sdk";
import {
buildAgentHarnessUserInputAnswers,
deliverAgentHarnessUserInputPrompt,
embeddedAgentLog,
type AgentHarnessUserInputQuestion,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
type PendingCopilotUserInput = {
question: AgentHarnessUserInputQuestion;
resolve: (value: CopilotUserInputResponse) => void;
cleanup: () => void;
};
type CopilotUserInputHandler = NonNullable<SessionConfig["onUserInputRequest"]>;
type CopilotUserInputRequest = Parameters<CopilotUserInputHandler>[0];
type CopilotUserInputResponse = Awaited<ReturnType<CopilotUserInputHandler>>;
type CopilotUserInputBridge = {
onUserInputRequest: CopilotUserInputHandler;
handleQueuedMessage: (text: string) => boolean;
cancelPending: () => void;
};
const COPILOT_USER_INPUT_QUESTION_ID = "answer";
export function createCopilotUserInputBridge(params: {
paramsForRun: EmbeddedRunAttemptParams;
signal?: AbortSignal;
}): CopilotUserInputBridge {
let pending: PendingCopilotUserInput | undefined;
const resolvePending = (value: CopilotUserInputResponse) => {
const current = pending;
if (!current) {
return;
}
pending = undefined;
current.cleanup();
current.resolve(value);
};
return {
onUserInputRequest(request) {
const question = toQuestion(request);
resolvePending(emptyCopilotUserInputResponse());
return new Promise<CopilotUserInputResponse>((resolve) => {
const abortListener = () => resolvePending(emptyCopilotUserInputResponse());
const cleanup = () => params.signal?.removeEventListener("abort", abortListener);
pending = { question, resolve, cleanup };
params.signal?.addEventListener("abort", abortListener, { once: true });
if (params.signal?.aborted) {
resolvePending(emptyCopilotUserInputResponse());
return;
}
void deliverAgentHarnessUserInputPrompt(params.paramsForRun, [question], {
intro: "Copilot needs input:",
formatText: formatCopilotDisplayText,
}).catch((error: unknown) => {
embeddedAgentLog.warn("failed to deliver copilot user input prompt", { error });
});
});
},
handleQueuedMessage(text) {
const current = pending;
if (!current) {
return false;
}
resolvePending(buildCopilotUserInputResponse(current.question, text));
return true;
},
cancelPending() {
resolvePending(emptyCopilotUserInputResponse());
},
};
}
function toQuestion(request: CopilotUserInputRequest): AgentHarnessUserInputQuestion {
return {
id: COPILOT_USER_INPUT_QUESTION_ID,
header: "Copilot needs input",
question: request.question,
isOther: request.allowFreeform !== false,
isSecret: false,
options:
request.choices && request.choices.length > 0
? request.choices.map((choice: string) => ({ label: choice }))
: null,
};
}
function buildCopilotUserInputResponse(
question: AgentHarnessUserInputQuestion,
inputText: string,
): CopilotUserInputResponse {
const rawAnswers = buildAgentHarnessUserInputAnswers([question], inputText);
const selected = rawAnswers.answers[COPILOT_USER_INPUT_QUESTION_ID]?.answers[0] ?? "";
return {
answer: selected,
wasFreeform: !isChoiceAnswer(question, selected),
};
}
function emptyCopilotUserInputResponse(): CopilotUserInputResponse {
return { answer: "", wasFreeform: true };
}
function isChoiceAnswer(question: AgentHarnessUserInputQuestion, answer: string): boolean {
return Boolean(
answer &&
question.options?.some((option) => option.label.toLowerCase() === answer.toLowerCase()),
);
}
function formatCopilotDisplayText(value: string): string {
const safe = sanitizeCopilotDisplayText(value).trim();
return escapeCopilotChatText(safe || "<unknown>");
}
function sanitizeCopilotDisplayText(value: string): string {
let safe = "";
for (const character of value) {
const codePoint = character.codePointAt(0);
safe += codePoint != null && isUnsafeDisplayCodePoint(codePoint) ? "?" : character;
}
return safe;
}
function escapeCopilotChatText(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("@", "\uff20")
.replaceAll("`", "\uff40")
.replaceAll("[", "\uff3b")
.replaceAll("]", "\uff3d")
.replaceAll("(", "\uff08")
.replaceAll(")", "\uff09")
.replaceAll("*", "\u2217")
.replaceAll("_", "\uff3f")
.replaceAll("~", "\uff5e")
.replaceAll("|", "\uff5c");
}
function isUnsafeDisplayCodePoint(codePoint: number): boolean {
return (
codePoint <= 0x001f ||
(codePoint >= 0x007f && codePoint <= 0x009f) ||
codePoint === 0x00ad ||
codePoint === 0x061c ||
codePoint === 0x180e ||
(codePoint >= 0x200b && codePoint <= 0x200f) ||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
(codePoint >= 0x2060 && codePoint <= 0x206f) ||
codePoint === 0xfeff ||
(codePoint >= 0xfff9 && codePoint <= 0xfffb) ||
(codePoint >= 0xe0000 && codePoint <= 0xe007f)
);
}

View File

@@ -1,6 +1,6 @@
{
"id": "deepgram",
"icon": "https://cdn.simpleicons.org/deepgram/111111",
"icon": "https://cdn.simpleicons.org/deepgram",
"activation": {
"onStartup": false
},

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