Compare commits

..

239 Commits

Author SHA1 Message Date
Alex Knight
fd09d2e7d0 fix(compaction): anchor forced manual boundary off trailing tool results 2026-06-17 21:21:25 +10:00
Alex Knight
3aecc4ee9d Force manual compaction past empty kept-tail cuts 2026-06-17 21:21:25 +10:00
Ayaan Zaidi
02330f372c fix(qa): use writable tmp in Telegram package runner
Set TMPDIR=/tmp inside the package Telegram Docker runner so runtime scratch files are written to a writable container path.

Proof:
- pnpm test test/scripts/npm-telegram-live.test.ts
- git diff --check
2026-06-17 16:45:34 +05:30
Vincent Koc
5645dd4d22 refactor(agents): delete unused helper paths 2026-06-17 19:11:20 +08:00
Alex Knight
5a7857dc18 feat(agents): trace compaction summarization model calls
Compaction summarization consumes the model stream via result() only (no
iteration), so it never emitted model.call diagnostic spans. Observe the
stream's result() in the diagnostic wrapper and wire the wrapper into the
direct compaction path so these LLM calls are traced (request/response
content, byte accounting, traceparent).

Decouple underlying-iterator cleanup from terminal-event dedup. The agent
loop awaits result() on the terminal event then abandons the iterator, so
once result() also emits the terminal event, gating safeReturnIterator on
terminalEventEmitted skipped provider cleanup (idle-timeout abort listeners
on the long-lived run signal, SSE readers). Track iterator settlement
separately so return() cleanup always runs; emit dedup stays on
terminalEventEmitted.

Parent compaction model-call spans to the active run/harness trace rather
than a phantom child trace that emits no span of its own.
2026-06-17 21:06:44 +10:00
Vincent Koc
25bd8a7191 fix(ci): install docker heartbeat traps before launch 2026-06-17 19:04:31 +08:00
nas
df87b40bec fix(telegram): guard UTF-16 surrogate pairs in outbound chunkers (#93938)
Merged via squash.

Prepared head SHA: 583b22354d
Co-authored-by: Nas01010101 <156536069+Nas01010101@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-17 18:56:25 +08:00
joshavant
5d9c010628 ci: add security-sensitive file guard 2026-06-17 12:50:18 +02:00
Vincent Koc
03ca096e84 test(qa): cover otel smoke safety checks 2026-06-17 12:42:28 +02:00
joshavant
22ddf87d2c docs: explain Android signing sync 2026-06-17 12:37:29 +02:00
joshavant
2147312aa2 android: add release signing sync 2026-06-17 12:37:29 +02:00
Vincent Koc
9698070939 fix(qa): allow safe otel log bodies 2026-06-17 12:33:45 +02:00
Vincent Koc
1c0b38f960 fix(sdk): refresh plugin surface baselines 2026-06-17 12:25:42 +02:00
Vincent Koc
0842cb71eb refactor(runtime): hide default constants 2026-06-17 18:20:05 +08:00
Vincent Koc
392bd16a1d refactor(config): hide io constants 2026-06-17 18:14:08 +08:00
Vincent Koc
f3050ab614 refactor(config): hide default constants 2026-06-17 18:11:28 +08:00
Vincent Koc
6e798c02d8 fix(codex): refresh app server protocol mirrors 2026-06-17 12:10:52 +02:00
Vincent Koc
911cd683d5 refactor(commands): hide onboarding defaults 2026-06-17 18:08:16 +08:00
Vincent Koc
4637b65470 refactor(agents): hide compaction warning helpers 2026-06-17 18:05:16 +08:00
Vincent Koc
e2b6753b87 fix(qa-lab): bound credential payload reads 2026-06-17 11:59:55 +02:00
Vincent Koc
366ef93641 test(agents): inline auth profile ordering fixtures 2026-06-17 17:56:13 +08:00
Vincent Koc
dc881a6a31 refactor(acp): hide policy helpers 2026-06-17 17:53:31 +08:00
Vincent Koc
ea72a3382d refactor(acp): remove file event ledger runtime 2026-06-17 17:50:53 +08:00
Vincent Koc
19677bd4ef refactor(acp): hide permission relay helpers 2026-06-17 17:47:13 +08:00
Vincent Koc
9c9c884526 refactor(entry): hide respawn internals 2026-06-17 17:44:12 +08:00
Vincent Koc
120fd2f702 refactor(cli): hide shell support internals 2026-06-17 17:41:44 +08:00
Vincent Koc
582c2d41b9 fix(msteams): unwrap adaptive card submit data 2026-06-17 11:40:52 +02:00
Vincent Koc
30955d3660 refactor(channels): narrow status helper exports 2026-06-17 17:33:40 +08:00
Vincent Koc
5370e73ee9 refactor(channels): hide internal channel types 2026-06-17 17:31:04 +08:00
Vincent Koc
cf7850040e fix(codex): align network proxy profile config 2026-06-17 17:27:34 +08:00
Vincent Koc
1380a9e094 refactor(auto-reply): hide local reply types 2026-06-17 17:23:32 +08:00
Vincent Koc
5055f32ee3 refactor(auto-reply): hide internal command types 2026-06-17 17:18:39 +08:00
Vincent Koc
1075f3819c refactor(utils): narrow helper exports 2026-06-17 17:13:29 +08:00
Vincent Koc
c09ed1954f refactor(utils): trim delivery queue helpers 2026-06-17 17:10:12 +08:00
joshavant
5372c7146b android: add release preflight lane 2026-06-17 11:05:53 +02:00
joshavant
529150868c android: derive release notes from changelog 2026-06-17 11:05:53 +02:00
Vincent Koc
08e0b8cf6b refactor(utils): hide usage pricing types 2026-06-17 17:02:24 +08:00
Vincent Koc
5c34695491 feat(codex): support app-server network proxy profiles (#93538)
Merged via squash.

Prepared head SHA: 9900b14dd5
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-17 17:01:47 +08:00
Vincent Koc
f3ae525211 refactor(wizard): hide prompt session types 2026-06-17 16:59:00 +08:00
Vincent Koc
d47371d9c4 refactor(video): trim runtime helper exports 2026-06-17 16:56:47 +08:00
Vincent Koc
b962c53e78 refactor(web): trim runtime helper exports 2026-06-17 16:53:46 +08:00
Vincent Koc
4ae94d1d46 refactor(trajectory): trim trace config exports 2026-06-17 16:48:13 +08:00
Vincent Koc
102c1f4ec7 refactor(utils): trim helper type exports 2026-06-17 16:42:06 +08:00
Vincent Koc
71645bb8a3 refactor(tasks): trim registry helper exports 2026-06-17 16:37:22 +08:00
Momo
db4bcd7d09 Expose verified ClawHub source in skill verify output (#93532)
* fix(skills): expose verified ClawHub source in verify output

* fix(ci): repair verify check regressions

* fix(ci): refresh prompt snapshots

* fix(skills): require pinned ClawHub verify commits
2026-06-17 16:35:36 +08:00
Vincent Koc
745b011632 refactor(tasks): hide flow audit internals 2026-06-17 16:34:14 +08:00
Vincent Koc
a18cbcb7c6 refactor(status): hide internal helpers 2026-06-17 16:31:42 +08:00
Vincent Koc
2ca375fc1a fix(codex): rotate mcp bindings before transient search 2026-06-17 10:30:46 +02:00
Vincent Koc
22356395a2 fix(codex): drop unused transient binding assignment 2026-06-17 10:30:46 +02:00
Vincent Koc
759b7902ee test(codex): dedupe context engine binding helper 2026-06-17 10:30:46 +02:00
Vincent Koc
f1c44e2d6d fix(codex): rotate mismatched bindings before transient search 2026-06-17 10:30:46 +02:00
Vincent Koc
ffeccce5f9 test(telegram): isolate model callback session state 2026-06-17 10:30:45 +02:00
Vincent Koc
d4fb49f3c4 test(channels): update command output progress expectations 2026-06-17 10:30:45 +02:00
Vincent Koc
f7178a74ef test(feishu): expect send receipts 2026-06-17 10:30:45 +02:00
Vincent Koc
da67802baf fix(codex): respect lifecycle mismatch rotations 2026-06-17 10:30:45 +02:00
Vincent Koc
5b46a11d2d fix(telegram): preserve default model and sticker cache state 2026-06-17 10:30:45 +02:00
Vincent Koc
5ee0f13a54 fix(channels): reset completed command output detail 2026-06-17 10:30:45 +02:00
Vincent Koc
3f18ee4567 refactor(skills): hide workshop internals 2026-06-17 16:28:40 +08:00
Vincent Koc
5b1ba437ba refactor(skills): trim loader export surface 2026-06-17 16:25:01 +08:00
Vincent Koc
4132ce155e fix(cohere): translate system prompts 2026-06-17 16:23:52 +08:00
Vincent Koc
91bcc4cf2a docs(i18n): add Cohere glossary entries 2026-06-17 16:23:52 +08:00
Vincent Koc
a079d98eb4 docs(plugins): refresh Cohere inventory 2026-06-17 16:23:52 +08:00
Vincent Koc
85d5d94519 feat(cohere): add provider plugin 2026-06-17 16:23:52 +08:00
Vincent Koc
cb1e4356aa refactor(skills): hide clawhub lifecycle internals 2026-06-17 16:20:09 +08:00
Alix-007
93e3bcef7a fix(cli): clarify MCP list registry scope (#87487)
Clarify that `openclaw mcp list`, `show`, `set`, and `unset` manage the OpenClaw `mcp.servers` registry and do not include the separate mcporter registry.

Co-authored-by: Alix-007 <li.long15@xydigit.com>
2026-06-17 10:18:18 +02:00
Vincent Koc
8decb546f7 refactor(skills): hide status upload internals 2026-06-17 16:16:55 +08:00
Vincent Koc
e99a6d4c19 refactor(skills): hide internal result types 2026-06-17 16:12:06 +08:00
Vincent Koc
19c7731292 fix(plugins): classify npm-pack security events as archives 2026-06-17 16:11:32 +08:00
Vincent Koc
3c64a575dd fix(gateway): classify pairing rejection events 2026-06-17 16:11:32 +08:00
Vincent Koc
81df1b239b fix(plugins): satisfy install security lint 2026-06-17 16:11:32 +08:00
Vincent Koc
80c47ecb99 test(plugins): narrow npm install mock options 2026-06-17 16:11:32 +08:00
Vincent Koc
41a0b8df36 fix(agents): classify tool block security events 2026-06-17 16:11:32 +08:00
Vincent Koc
122f29e5ea fix(plugins): preserve install security provenance 2026-06-17 16:11:32 +08:00
Vincent Koc
b6714bf109 fix(diagnostics): preserve plugin security identities 2026-06-17 16:11:32 +08:00
Vincent Koc
df86f36a57 fix(agents): emit inline exec approval decisions 2026-06-17 16:11:32 +08:00
Vincent Koc
7279f43bbb fix(plugins): avoid duplicate npm install security events 2026-06-17 16:11:32 +08:00
Vincent Koc
f51b52ceca fix(diagnostics): narrow security severity text 2026-06-17 16:11:32 +08:00
Vincent Koc
10da9ae248 fix(diagnostics): satisfy security severity lint 2026-06-17 16:11:32 +08:00
Vincent Koc
49e95c5308 fix(logging): project security diagnostics for stability 2026-06-17 16:11:32 +08:00
Vincent Koc
7de5bdca19 test(diagnostics): satisfy security event fixture types 2026-06-17 16:11:32 +08:00
Vincent Koc
7a880bcf29 feat(security): emit audit summary events 2026-06-17 16:11:32 +08:00
Vincent Koc
b86b891326 feat(plugins): emit security events for installs 2026-06-17 16:11:32 +08:00
Vincent Koc
481fd10988 feat(agents): emit security events for exec approvals 2026-06-17 16:11:32 +08:00
Vincent Koc
299d31c56e feat(gateway): emit security events for auth handshakes 2026-06-17 16:11:32 +08:00
Vincent Koc
d6774e46e0 feat(gateway): emit security events for device pairing 2026-06-17 16:11:32 +08:00
Vincent Koc
d491018a45 feat(agents): emit security events for tool vetoes 2026-06-17 16:11:32 +08:00
Vincent Koc
f3a1d1fcb0 feat(diagnostics): export security events to OTLP logs 2026-06-17 16:11:32 +08:00
Vincent Koc
6456d03868 feat(diagnostics): add trusted security events 2026-06-17 16:11:32 +08:00
Alex Knight
e68db3a1b8 fix(config): remove unnecessary dm warning conversion 2026-06-17 18:07:35 +10:00
Alex Knight
57b66b2ec8 fix(config): tighten dm policy warnings 2026-06-17 18:07:35 +10:00
Alex Knight
90e72a67a3 fix(config): resolve DM allowFrom via canonical-or-legacy before warning
The generic dmPolicy/allowFrom warning read only the canonical top-level
allowFrom, so channels that keep their wildcard under the legacy dm.allowFrom
alias (e.g. Discord/Slack, mode=topOnly/topOrNested) got a false 'all DMs
dropped' warning even though runtime honors dm.allowFrom. Resolve policy and
allowFrom through the shared resolveChannelDm* helpers with the channel's
dmAllowFromMode (matching runtime and doctor), and skip nestedOnly channels
whose canonical fields live under dm.* and do not match this warning's
top-level paths. Adds a Discord legacy-alias regression test.

Addresses ClawSweeper review finding P1 (false positives on legacy dm.allowFrom).
2026-06-17 18:07:35 +10:00
Alex Knight
6810c67f0c refactor(config): make DM policy/allowFrom validation generic across channels
Replace the hardcoded Mattermost-only open-DM config check with a generic,
plugin-agnostic warning driven by a single shared evaluator
(evaluateDmPolicyAllowFromDependency) reused by the Zod refinements and the
CLI validator. Surface warnings at 'config validate' and on config load.
Remove the Mattermost-specific status-issues module now covered generically;
keep the runtime drop-log diagnostic.
2026-06-17 18:07:35 +10:00
Alex Knight
ba91eb7acf Fix Mattermost open DM validation 2026-06-17 18:07:35 +10:00
Vincent Koc
c12d921291 refactor(shared): trim helper constants 2026-06-17 16:06:41 +08:00
Vincent Koc
884a6a113c refactor(shared): hide helper option types 2026-06-17 16:03:38 +08:00
Vincent Koc
bed5bf339e fix(sdk): refresh plugin api baseline 2026-06-17 10:00:29 +02:00
Vincent Koc
d1923085e3 refactor(shared): hide parser helper internals 2026-06-17 15:59:58 +08:00
Vincent Koc
3b4808100d refactor(sessions): hide internal helper types 2026-06-17 15:56:51 +08:00
Vincent Koc
0455028a3c refactor(agents): trim session tool internals 2026-06-17 15:53:58 +08:00
Vincent Koc
e0b1cb76e0 refactor(agents): trim session helper exports 2026-06-17 15:50:28 +08:00
Vincent Koc
be4c541176 refactor(agents): trim sandbox helper exports 2026-06-17 15:43:09 +08:00
Vincent Koc
cbf6f0001b refactor(agents): narrow runner harness helper types 2026-06-17 15:38:50 +08:00
heichl_xydigit
bda7581126 fix(feishu): fetch quoted content before empty-message guard (#90192)
* fix(feishu): fetch quoted content before empty-message guard

Moves the quoted/replied message content fetching before the empty-message
early return so a reply with only @bot mention (no text, no media) is not
dropped when it quotes a message with meaningful content. The guard now also
checks that quoted text is empty before skipping.

Note: because the fetch is now unconditional on parentId after passing the
group admission/mention gate, an empty-text reply that quotes a parent in an
open group (requireMention: false) without mentioning the bot will now be
dispatched, where before it was dropped. This is the intended behavior for
open groups — any non-empty turn (including one where context comes from a
quote) should reach the agent. For requireMention:true groups, unmentioned
messages still exit at the mention gate before the fetch, so no over-fetch
occurs.

Adds group-based regression tests for the #90177 scenario:
- Positive: mention-only reply in requireMention:true group with quoted
  parent — dispatches with [Replying to: "..."] in the body.
- Negative: empty reply with no bot mention in requireMention:true group —
  getMessageFeishu is never called and nothing is dispatched.

* fix(feishu): fetch quoted content before empty-message guard (#90192) (thanks @bladin)

---------

Co-authored-by: 黑承亮0668000844 <bladin@users.noreply.github.com>
Co-authored-by: sliverp <870080352@qq.com>
2026-06-17 15:34:23 +08:00
Vincent Koc
e349bdb949 refactor(agents): narrow command helper types 2026-06-17 15:33:16 +08:00
Vincent Koc
768704e906 refactor(agents): hide sqlite cache store internals 2026-06-17 15:30:50 +08:00
Vincent Koc
ba1403604d refactor(agents): remove external auth oauth aliases 2026-06-17 15:28:44 +08:00
Vincent Koc
e939963784 refactor(agents): trim provider config helper exports 2026-06-17 15:26:19 +08:00
Vincent Koc
5ff7242391 refactor(agents): trim live media classifiers 2026-06-17 15:20:09 +08:00
Vincent Koc
c25a4e6d0b refactor(agents): trim runtime constant exports 2026-06-17 15:16:50 +08:00
Vincent Koc
4ea1b4fc4a refactor(agents): trim exec helper exports 2026-06-17 15:13:35 +08:00
Vincent Koc
3881cb3426 refactor(agents): trim media helper exports 2026-06-17 15:10:01 +08:00
Vincent Koc
bfd11ee29f refactor(agents): trim session helper facades 2026-06-17 15:06:19 +08:00
Vincent Koc
664948e7bf refactor(agents): hide tool helper internals 2026-06-17 15:01:41 +08:00
Vincent Koc
8142c12db2 fix(agents): route BTW through canonical Codex runtime (#93881)
Merged via squash.

Prepared head SHA: e88fb50880
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-17 14:58:32 +08:00
Vincent Koc
963783e3be refactor(auto-reply): hide command turn helper types 2026-06-17 14:54:41 +08:00
Vincent Koc
283e8cf793 refactor(agents): hide tool provider helper types 2026-06-17 14:49:43 +08:00
Vincent Koc
78f7ef88eb ci: allow longer testbox helper runs 2026-06-17 08:47:42 +02:00
Vincent Koc
abee98feaa refactor(agents): hide media tool helper exports 2026-06-17 14:46:41 +08:00
Vincent Koc
53ff3085f9 refactor(agents): hide sandbox helper types 2026-06-17 14:44:11 +08:00
Vincent Koc
e2fa4f396b refactor(agents): hide settings helper types 2026-06-17 14:39:39 +08:00
Vincent Koc
2d91aaa9ed refactor(agents): hide steering queue types 2026-06-17 14:34:11 +08:00
Vincent Koc
e15dadec64 refactor(agents): drop runtime context re-export 2026-06-17 14:32:08 +08:00
Vincent Koc
18aa327655 refactor(agents): drop preemptive route re-export 2026-06-17 14:30:10 +08:00
Vincent Koc
94e79a052c refactor(agents): hide message merge helper types 2026-06-17 14:28:14 +08:00
Vincent Koc
0804901c11 refactor(agents): hide failover policy types 2026-06-17 14:26:24 +08:00
Vincent Koc
7b03f11084 test(agents): hide spawn workspace support internals 2026-06-17 14:24:22 +08:00
Vincent Koc
812dcc5d4d refactor(agents): hide bootstrap context type 2026-06-17 14:22:17 +08:00
Vincent Koc
0b698709d8 refactor(agents): hide message transform type 2026-06-17 14:14:22 +08:00
Vincent Koc
58ec07c598 refactor(agents): hide bootstrap routing types 2026-06-17 14:12:28 +08:00
Vincent Koc
7aac97c1a9 refactor(agents): hide prompt helper types 2026-06-17 14:09:03 +08:00
Vincent Koc
d9c4f9a964 refactor(agents): hide session owner timeout error 2026-06-17 14:06:55 +08:00
Vincent Koc
f06539d8ba refactor(agents): hide queue message timeout 2026-06-17 14:05:01 +08:00
Vincent Koc
9e3db6bedd refactor(agents): remove unused compaction reserve helper 2026-06-17 14:03:05 +08:00
Vincent Koc
580bba0637 refactor(agents): remove unused transcript runtime helpers 2026-06-17 13:57:07 +08:00
Ayaan Zaidi
68eb5031bd fix(telegram): surface rich-message disabled state 2026-06-17 11:26:52 +05:30
Vincent Koc
aca48b55ad refactor(agents): drop stream resolution test alias 2026-06-17 13:55:05 +08:00
Vincent Koc
69abb2c090 fix(codex): send legacy dynamic tool start specs 2026-06-17 07:54:25 +02:00
Vincent Koc
6ee989a235 refactor(agents): drop stale model alias re-export 2026-06-17 13:53:22 +08:00
Vincent Koc
d4e67ebc9a refactor(agents): hide extra params test internals 2026-06-17 13:51:36 +08:00
Vincent Koc
05bbe75212 refactor(agents): hide compaction harness mocks 2026-06-17 13:49:39 +08:00
Vincent Koc
a48a9bbd7d refactor(agents): hide provider error pattern internals 2026-06-17 13:48:22 +08:00
Vincent Koc
e655357197 refactor(agents): hide thinking recovery type 2026-06-17 13:46:59 +08:00
Vincent Koc
c3c4d44f6e refactor(agents): hide manual compaction boundary type 2026-06-17 13:45:37 +08:00
Vincent Koc
0a314c61b1 fix(ci): remove unused cross-spawn dependency 2026-06-17 13:43:14 +08:00
Vincent Koc
c1ac18e481 refactor(agents): hide attempt policy types 2026-06-17 13:42:31 +08:00
Vincent Koc
956856ae07 refactor(agents): hide attempt helper types 2026-06-17 13:40:14 +08:00
Vincent Koc
c624ae49db refactor(agents): hide compaction helper types 2026-06-17 13:37:50 +08:00
Vincent Koc
074a4ef7e6 refactor(agents): hide runner transcript types 2026-06-17 13:35:46 +08:00
Vincent Koc
31b69a1256 refactor(agents): hide runner guard types 2026-06-17 13:34:05 +08:00
Vincent Koc
44a4b21d9c refactor(agents): hide runner helper types 2026-06-17 13:31:56 +08:00
Vincent Koc
1474f4af2b refactor(agents): hide runner evidence internals 2026-06-17 13:30:01 +08:00
Vincent Koc
eebb5d73f4 refactor(agents): hide cache ttl entry type 2026-06-17 13:28:24 +08:00
Vincent Koc
c784f649b1 test(agents): drop unused oauth store helper 2026-06-17 13:26:36 +08:00
Vincent Koc
cea318bcc6 fix(ci): remove unused child process import 2026-06-17 13:26:09 +08:00
Vincent Koc
7186f0d654 refactor(agents): trim auth persistence exports 2026-06-17 13:22:49 +08:00
Vincent Koc
0479b9ed5d refactor(agents): hide oauth refresh helper type 2026-06-17 13:21:06 +08:00
Vincent Koc
2c3c4b0122 refactor(agents): drop stale auth constants 2026-06-17 13:19:37 +08:00
Vincent Koc
834c7c2e47 refactor(agents): hide auth profile helper types 2026-06-17 13:17:19 +08:00
Vincent Koc
f0488dd6aa refactor(agents): trim interactive helper exports 2026-06-17 13:15:37 +08:00
Vincent Koc
47059e4ebc refactor(channels): hide local helper types 2026-06-17 13:12:32 +08:00
Vincent Koc
8ea5342c99 refactor(agents): trim utility helper exports 2026-06-17 13:09:17 +08:00
Vincent Koc
cda11ced07 refactor(auto-reply): hide delivery helper types 2026-06-17 13:04:43 +08:00
Vincent Koc
20ef410d64 fix(ci): remove unused Claude permission type 2026-06-17 13:01:33 +08:00
Vincent Koc
3d3d8a5bef refactor(agents): hide session tool helper types 2026-06-17 13:00:14 +08:00
Vincent Koc
0baaa63def refactor(agents): hide helper metadata types 2026-06-17 12:56:04 +08:00
Vincent Koc
c0c1a92967 refactor(acp): remove unused reset helper 2026-06-17 12:52:45 +08:00
Vincent Koc
f9439715e9 refactor(acp): hide helper types 2026-06-17 12:48:07 +08:00
Vincent Koc
8ad356403e refactor(media): hide understanding helper types 2026-06-17 12:42:33 +08:00
Vincent Koc
d9d4da0608 test(codex): seed context-engine web search binding 2026-06-17 06:40:24 +02:00
Vincent Koc
7758f5e224 refactor(media): hide MCP media helper types 2026-06-17 12:40:00 +08:00
Vincent Koc
54bcdea342 refactor(logging): hide diagnostic capture type 2026-06-17 12:36:52 +08:00
Vincent Koc
dbb62bba85 refactor(logging): hide stability evidence types 2026-06-17 12:35:19 +08:00
Vincent Koc
0c6ebcd6c0 refactor(logging): hide diagnostic bundle types 2026-06-17 12:33:20 +08:00
Vincent Koc
22405223c2 refactor(logging): hide recovery helper types 2026-06-17 12:31:19 +08:00
Vincent Koc
f8fc316b0c refactor(infra): hide backup helper types 2026-06-17 12:29:14 +08:00
Vincent Koc
e098eb735f refactor(infra): hide utility option types 2026-06-17 12:26:37 +08:00
Vincent Koc
a0a0e5e4cb refactor(infra): hide local helper types 2026-06-17 12:23:59 +08:00
Vincent Koc
ada70ece6f refactor(gateway): hide websocket helper types 2026-06-17 12:21:22 +08:00
Vincent Koc
d7d4852e5e refactor(gateway): hide local helper types 2026-06-17 12:18:54 +08:00
Vincent Koc
cc451f98cb refactor(config): hide session helper types 2026-06-17 12:15:59 +08:00
Vincent Koc
04255b247c revert(providers): remove ClawRouter provider 2026-06-17 12:15:17 +08:00
Vincent Koc
97b9bd1d81 refactor(config): hide local helper types 2026-06-17 12:13:51 +08:00
Vincent Koc
71ce525c69 refactor(commands): hide migrate helper types 2026-06-17 12:11:47 +08:00
Vincent Koc
f559b75918 refactor(commands): hide doctor helper types 2026-06-17 12:09:48 +08:00
Vincent Koc
5c3a29a1c2 refactor(commands): trim status type exports 2026-06-17 12:05:11 +08:00
Vincent Koc
62fad3da86 fix(update): use configured npm registry for update metadata (#93879)
Merged via squash.

Prepared head SHA: ae8bbb0303
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-17 12:04:44 +08:00
Vincent Koc
f33cf5c866 refactor(commands): hide custom provider helper types 2026-06-17 12:02:40 +08:00
Alix-007
4559a8d736 fix(cron): reject invalid absolute timestamps (#93903)
* fix(cron): reject invalid absolute timestamps

* fix(cron): preserve ISO end of day

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 12:00:53 +08:00
Vincent Koc
087e3f56dc refactor(commands): trim custom provider type reexports 2026-06-17 12:00:23 +08:00
Vincent Koc
3f25c578c1 refactor(flows): hide setup helper types 2026-06-17 11:58:15 +08:00
Vincent Koc
a73f026c2d fix(macos): preserve approvals migration data (#93880)
Merged via squash.

Prepared head SHA: a8a0dd0cbb
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-17 11:57:32 +08:00
Vincent Koc
508ce22468 refactor(commands): trim setup barrel type exports 2026-06-17 11:55:52 +08:00
Vincent Koc
9911a682f4 refactor(commands): trim doctor probe type exports 2026-06-17 11:53:39 +08:00
Vincent Koc
0c909ea97f refactor(commands): hide doctor state helper types 2026-06-17 11:50:49 +08:00
Vincent Koc
9b8102d774 refactor(commands): hide doctor utility types 2026-06-17 11:48:43 +08:00
Vincent Koc
922aea7d28 fix(sdk): refresh plugin api baseline 2026-06-17 05:47:07 +02:00
Vincent Koc
8c6139006a fix(providers): align ClawRouter package boundary 2026-06-17 11:46:57 +08:00
Vincent Koc
ed88457f3b fix(providers): compose ClawRouter native auth 2026-06-17 11:46:57 +08:00
Vincent Koc
c6d4c5299a fix(providers): wrap direct fallback streams 2026-06-17 11:46:57 +08:00
Vincent Koc
5baca82072 fix(providers): apply wrappers to direct streams 2026-06-17 11:46:57 +08:00
Vincent Koc
d667dcfb90 fix(providers): route ClawRouter direct streams 2026-06-17 11:46:57 +08:00
Vincent Koc
ad81cb44ba fix(providers): require runnable ClawRouter Gemini routes 2026-06-17 11:46:57 +08:00
Vincent Koc
f465ae08e2 fix(providers): preserve ClawRouter catalog model ids 2026-06-17 11:46:57 +08:00
Vincent Koc
5af0ccfd5f fix(providers): cover ClawRouter runtime auth paths 2026-06-17 11:46:57 +08:00
Vincent Koc
8a4d92d362 fix(providers): isolate ClawRouter runtime credentials 2026-06-17 11:46:57 +08:00
Vincent Koc
c4e0d27ade fix(providers): restore ClawRouter native runtime routes 2026-06-17 11:46:57 +08:00
Vincent Koc
8c8866c921 fix(providers): consume canonical ClawRouter catalog field 2026-06-17 11:46:57 +08:00
Vincent Koc
ca2fbece8b chore(deps): register ClawRouter workspace 2026-06-17 11:46:57 +08:00
Vincent Koc
4c8ac47dbe fix(providers): preserve ClawRouter native replay policies 2026-06-17 11:46:57 +08:00
Vincent Koc
d32e241ca0 chore(providers): align ClawRouter package version 2026-06-17 11:46:57 +08:00
Vincent Koc
c83c37b4d2 docs(providers): document ClawRouter integration 2026-06-17 11:46:57 +08:00
Vincent Koc
95dafc824e feat(providers): add ClawRouter managed proxy 2026-06-17 11:46:57 +08:00
Vincent Koc
ee2d4e1f79 refactor(commands): hide doctor repair helper types 2026-06-17 11:46:16 +08:00
Alix-007
d2bf67f4b7 fix(slack): recognize MiniMax mm: namespaced reasoning tags in monitor preview (#93874) 2026-06-17 11:44:19 +08:00
Vincent Koc
12eeb5cb63 refactor(commands): trim setup type reexports 2026-06-17 11:43:46 +08:00
Vincent Koc
83ad2cddee refactor(commands): hide status task helper types 2026-06-17 11:41:25 +08:00
Vincent Koc
66a8d0a7ec fix(ui): harden chromium test runner 2026-06-17 05:39:06 +02:00
Vincent Koc
c48657b920 refactor(commands): hide onboarding install result 2026-06-17 11:38:33 +08:00
Vincent Koc
2a02746bd7 test(codex): refresh dynamic tool snapshots 2026-06-17 11:36:17 +08:00
Vincent Koc
16f66e367c refactor(commands): trim backup type exports 2026-06-17 11:35:50 +08:00
Vincent Koc
e84d68e794 refactor(commands): trim migrate option reexports 2026-06-17 11:32:59 +08:00
Vincent Koc
d320e69326 refactor(commands): trim health type reexports 2026-06-17 11:30:34 +08:00
Vincent Koc
f50812dd56 refactor(commands): hide health summary internals 2026-06-17 11:28:14 +08:00
Vincent Koc
8da30037b3 refactor(commands): hide gateway readiness types 2026-06-17 11:25:32 +08:00
Vincent Koc
09bd5d5d19 refactor(commands): hide doctor service install mock 2026-06-17 11:22:31 +08:00
Vincent Koc
4f1e2efaa1 refactor(commands): hide doctor e2e mock internals 2026-06-17 11:19:45 +08:00
Vincent Koc
d3cf3d70f8 refactor(commands): hide doctor harness mocks 2026-06-17 11:16:29 +08:00
Vincent Koc
d79d5487aa fix(deps): remediate Dependabot alerts (#93857)
Merged via squash.

Prepared head SHA: 51ece24eef
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-17 11:15:31 +08:00
Vincent Koc
d03ef9d717 refactor(tests): hide gateway state helper types 2026-06-17 11:12:22 +08:00
Vincent Koc
490ef68864 refactor(tests): hide helper-only types 2026-06-17 11:08:54 +08:00
Vincent Koc
f0acd91478 refactor(acp): hide test helper internals 2026-06-17 11:06:42 +08:00
Vincent Koc
975d2e9b2b fix(slack): preserve completed native progress titles 2026-06-17 11:06:28 +08:00
Vincent Koc
27c8fae1ce refactor(cli): hide compile cache and media internals 2026-06-17 11:04:10 +08:00
Vincent Koc
d0ae6ead8b fix(agents): tolerate uncloneable adjusted tool params 2026-06-17 05:03:48 +02:00
Vincent Koc
bdbc8c6592 refactor(agents): hide fallback skip cache internals 2026-06-17 11:00:11 +08:00
Vincent Koc
80238595ed refactor(agents): hide context window thresholds 2026-06-17 10:57:15 +08:00
Shakker
0da5861e74 docs: add Gemini CLI auth changelog entry 2026-06-17 03:53:37 +01:00
Vincent Koc
46b6aa9044 refactor(browser): hide cdp reachability defaults 2026-06-17 10:51:41 +08:00
Vincent Koc
3312c7f467 refactor(feishu): hide timeout config type 2026-06-17 10:49:28 +08:00
637 changed files with 15852 additions and 7685 deletions

5
.github/CODEOWNERS vendored
View File

@@ -12,9 +12,14 @@
/.github/workflows/codeql-android-critical-security.yml @openclaw/openclaw-secops
/.github/workflows/codeql-critical-quality.yml @openclaw/openclaw-secops
/.github/workflows/dependency-guard.yml @openclaw/openclaw-secops
/.github/workflows/security-sensitive-guard.yml @openclaw/openclaw-secops
/test/scripts/dependency-guard-workflow.test.ts @openclaw/openclaw-secops
/test/scripts/dependency-guard-script.test.ts @openclaw/openclaw-secops
/test/scripts/security-sensitive-guard-workflow.test.ts @openclaw/openclaw-secops
/test/scripts/security-sensitive-guard-script.test.ts @openclaw/openclaw-secops
/scripts/github/dependency-guard.mjs @openclaw/openclaw-secops
/scripts/github/security-sensitive-guard.mjs @openclaw/openclaw-secops
/.gitignore @openclaw/openclaw-secops
/package-lock.json @openclaw/openclaw-secops
/npm-shrinkwrap.json @openclaw/openclaw-secops
/extensions/*/package-lock.json @openclaw/openclaw-secops

View File

@@ -6,6 +6,10 @@ on:
type: string
description: "Testbox session ID"
required: true
timeout_minutes:
type: number
description: "Maximum GitHub job runtime for long Testbox commands"
default: 120
pull_request:
paths:
- ".github/workflows/**"
@@ -25,7 +29,7 @@ jobs:
contents: read
name: "check"
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 30
timeout-minutes: ${{ fromJSON(inputs.timeout_minutes || '30') }}
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043

View File

@@ -0,0 +1,55 @@
name: Security Sensitive Guard
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] checks trusted base script only; never checks out PR head
types: [opened, reopened, synchronize, ready_for_review]
permissions:
contents: read
pull-requests: write
issues: write
concurrency:
group: security-sensitive-guard-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
security-sensitive-guard-detect:
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check out trusted base workflow scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Detect security-sensitive changes
env:
GITHUB_TOKEN: ${{ github.token }}
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
OPENCLAW_SECURITY_SENSITIVE_GUARD_MODE: detect
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
run: node scripts/github/security-sensitive-guard.mjs
security-sensitive-guard:
if: ${{ !github.event.pull_request.draft && always() }}
needs:
- security-sensitive-guard-detect
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check out trusted base workflow scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Enforce security-sensitive guard
env:
GITHUB_TOKEN: ${{ github.token }}
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
OPENCLAW_SECURITY_SENSITIVE_GUARD_MODE: enforce
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
run: node scripts/github/security-sensitive-guard.mjs

1
.gitignore vendored
View File

@@ -77,6 +77,7 @@ extensions/canvas/src/host/a2ui/*.map
# fastlane (iOS)
apps/ios/fastlane/README.md
apps/android/fastlane/README.md
apps/ios/fastlane/report.xml
apps/ios/fastlane/Preview.html
apps/ios/fastlane/screenshots/

View File

@@ -25,6 +25,8 @@ Docs: https://docs.openclaw.ai
### Fixes
- Channels and delivery: preserve account-scoped DM channel send policy, intentional rich-message line breaks in Telegram and status output, rich Telegram final replies, rich Telegram tables and lists, Telegram thread-create CLI remapping, Feishu dynamic-agent routes after persisted binding reuse, Slack outbound `message_sent` hooks, contributed message-tool schema optionality, same-channel generated media completions, and channel chunking around surrogate pairs and Infinity limits. (#92788, #93164, #92679, #89421, #89943, #42837, #92814, #91137, #91246, #92735) Thanks @yetval, @obviyus, @spacegeologist, @rishitamrakar, @liuhao1024, @lundog, @TurboTheTurtle, and @yhterrance.
- Gemini CLI: use the selected OpenClaw OAuth/API-key auth profile in an isolated Gemini CLI runtime home, preventing ambient Google machine credentials from overriding the chosen profile. (#88748) Thanks @jason-allen-oneal and @shakkernerd.
- Feishu: fetch quoted/replied message content before the empty-message guard so a mention-only reply that quotes a message with meaningful content is no longer dropped. (#90192) Thanks @bladin.
- Discord: give generated auto-thread titles a 60-second timeout and 4,096-token reasoning-model output budget, clamped to the selected model output cap. (#64734) Thanks @hanamizuki.
- Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, clamp trusted subagent thinking overrides through provider/model fallback, preserve yielded media completions, deliver channel message-tool final replies through auto-reply while hiding internal delivery hints, restore reset archive fallback reads when active async transcripts are missing, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions, slash-command block replies, and trajectory export commands in WebChat, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92412, #92146, #92879, #91287, #92468, #92510, #91246, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @masatohoshino, @CadanHu, @ZengWen-DT, @zhangguiping-xydt, and @TurboTheTurtle.
- Providers and model replay: preserve storeless OpenAI Responses replay compatibility, recover invalid OpenAI reasoning signatures and genericized Anthropic thinking-signature replay errors, route OAuth image defaults through Codex for eligible OpenAI profiles, avoid eager tool streaming for Claude 4.5 in Copilot, quarantine unreadable and post-hook OpenAI/Anthropic-family tool schemas without broadening allowed tool choices, deliver explicit thinking-off requests to LM Studio binary-thinking models, honor profile auth for SecretRef model entries, bound model browsing, strip provider prefixes where runtimes need bare IDs, and surface nested embedding fetch failures. (#90706, #92941, #92201, #92916, #92824, #75393, #92908, #92921, #92928, #92002, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @mmyzwl, @CarlCapital, @bek91, @Kailigithub, @vincentkoc, @rohitjavvadi, @samson910022, @nxmxbbd, @liuhao1024, @bymle, and @mushuiyu886.

11
apps/android/CHANGELOG.md Normal file
View File

@@ -0,0 +1,11 @@
# OpenClaw Android Changelog
## Unreleased
Maintenance update for the current OpenClaw Android release.
## 2026.6.2 - 2026-06-02
OpenClaw is now available on Android.
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows.

View File

@@ -0,0 +1,14 @@
{
"signingRepo": "git@github.com:openclaw/apps-signing.git",
"signingBranch": "main",
"assetPath": "android/openclaw",
"uploadKeystoreEncryptedFile": "upload-keystore.jks.enc",
"gradlePropertiesEncryptedFile": "gradle.properties.enc",
"materializedRoot": "apps/android/build/release-signing",
"gradlePropertyNames": [
"OPENCLAW_ANDROID_STORE_FILE",
"OPENCLAW_ANDROID_STORE_PASSWORD",
"OPENCLAW_ANDROID_KEY_ALIAS",
"OPENCLAW_ANDROID_KEY_PASSWORD"
]
}

View File

@@ -53,6 +53,16 @@ pnpm android:version:pin -- --from-gateway
pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501
```
Release-owner signing sync:
```bash
pnpm android:release:signing:plan
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:check
```
The signing sync pulls encrypted Android upload-key assets from the shared `apps-signing` repo and materializes decrypted files under `apps/android/build/release-signing/`.
Generate raw Google Play screenshots:
```bash
@@ -64,7 +74,7 @@ pnpm android:screenshots
- Play build: `openclaw-<version>-play-release.aab`
- Third-party build: `openclaw-<version>-third-party-release.apk`
`pnpm android:bundle:release` is an alias for the same archive helper.
`pnpm android:bundle:release` is an alias for the same Fastlane archive lane.
See `apps/android/VERSIONING.md` and `apps/android/fastlane/SETUP.md` for the release workflow.

View File

@@ -8,6 +8,8 @@ Android release builds use pinned app metadata instead of auto-bumping `build.gr
- `version` is the Play `versionName` and uses CalVer: `YYYY.M.D`.
- `versionCode` uses `YYYYMMDDNN`, where `NN` is a two-digit build number for that pinned app version.
- `apps/android/Config/Version.properties` is generated from `version.json` and read by Gradle.
- `apps/android/CHANGELOG.md` is the Android-only changelog and release-note source.
- `apps/android/fastlane/metadata/android/en-US/release_notes.txt` is generated from the changelog.
Examples:
@@ -23,16 +25,41 @@ pnpm android:version:check
pnpm android:version:sync
pnpm android:version:pin -- --from-gateway
pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501
pnpm android:release:signing:plan
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
pnpm android:release:preflight
```
## Release-note resolution order
When generating `apps/android/fastlane/metadata/android/en-US/release_notes.txt`, the tooling reads the first available changelog section in this order:
1. exact pinned version, for example `## 2026.6.2`
2. `## Unreleased`
Recommended workflow:
- while iterating on a Play internal testing train, keep pending notes under `## Unreleased`
- before the production release, move or copy the final notes under `## <pinned version>` and run sync again
## Release Workflow
1. Pin Android to the intended release version.
2. Run `pnpm android:version:sync`.
3. Update `apps/android/fastlane/metadata/android/en-US/release_notes.txt`.
4. Run `pnpm android:screenshots` to refresh raw Google Play screenshots.
5. Run `pnpm android:release:archive` to produce the signed Play AAB and third-party APK.
6. Run `pnpm android:release:upload` to upload metadata, screenshots, and the Play AAB to Google Play internal testing.
7. Promote to production manually in Google Play Console.
3. Update `apps/android/CHANGELOG.md`, then run `pnpm android:version:sync` again if needed.
4. Run `MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull` to materialize encrypted Android signing assets from `apps-signing`.
5. Run `pnpm android:release:preflight` to validate Play auth, signing, synced versioning, and release notes.
6. Run `pnpm android:screenshots` to refresh raw Google Play screenshots.
7. Run `pnpm android:release:archive` to produce the signed Play AAB and third-party APK.
8. Run `pnpm android:release:upload` to upload metadata, screenshots, and the Play AAB to Google Play internal testing.
9. Promote to production manually in Google Play Console.
The third-party flavor is archived as a signed APK for non-Play distribution. It is not uploaded by the Play release lane.
## Signing model
`apps/android/Config/ReleaseSigning.json` pins the Android signing assets in the shared private `apps-signing` repo. The Android pipeline uses the same `MATCH_PASSWORD` release-owner secret as iOS, but the Android files are managed by `scripts/android-release-signing.mjs` instead of Fastlane `match`.
`sync:pull` decrypts the Play upload keystore and Gradle signing properties into `apps/android/build/release-signing/`. That directory is gitignored, and Fastlane exports the materialized values as Gradle project properties for the current release command.
If `MATCH_PASSWORD` is not set, the existing manual Gradle-property signing path still works: provide `OPENCLAW_ANDROID_STORE_FILE`, `OPENCLAW_ANDROID_STORE_PASSWORD`, `OPENCLAW_ANDROID_KEY_ALIAS`, and `OPENCLAW_ANDROID_KEY_PASSWORD` through your local Gradle user properties before running release tasks.

View File

@@ -9,6 +9,12 @@ default_platform(:android)
DEFAULT_PLAY_PACKAGE_NAME = "ai.openclaw.app"
DEFAULT_PLAY_TRACK = "internal"
DEFAULT_PLAY_RELEASE_STATUS = "completed"
ANDROID_RELEASE_SIGNING_GRADLE_PROPERTIES = [
"OPENCLAW_ANDROID_STORE_FILE",
"OPENCLAW_ANDROID_STORE_PASSWORD",
"OPENCLAW_ANDROID_KEY_ALIAS",
"OPENCLAW_ANDROID_KEY_PASSWORD"
].freeze
def load_env_file(path)
return unless File.exist?(path)
@@ -36,6 +42,14 @@ def repo_root
File.expand_path("../..", android_root)
end
def android_release_signing_script
File.join(repo_root, "scripts", "android-release-signing.mjs")
end
def android_release_signing_materialized_properties_path
File.join(android_root, "build", "release-signing", "gradle.properties")
end
def shell_join(args)
args.shelljoin
end
@@ -136,17 +150,22 @@ def android_release_notes_path
File.join(__dir__, "metadata", "android", "en-US", "release_notes.txt")
end
def validate_android_release_notes!
release_notes_path = android_release_notes_path
UI.user_error!("Missing Android release notes at #{release_notes_path}. Run `pnpm android:version:sync`.") unless File.exist?(release_notes_path)
UI.user_error!("Android release notes at #{release_notes_path} are empty.") unless env_present?(File.read(release_notes_path))
end
def android_changelog_path(version_code)
File.join(__dir__, "metadata", "android", "en-US", "changelogs", "#{version_code}.txt")
end
def sync_android_changelog!(version_code)
release_notes_path = android_release_notes_path
UI.user_error!("Missing Android release notes at #{release_notes_path}.") unless File.exist?(release_notes_path)
validate_android_release_notes!
changelog_path = android_changelog_path(version_code)
FileUtils.mkdir_p(File.dirname(changelog_path))
File.write(changelog_path, File.read(release_notes_path))
File.write(changelog_path, File.read(android_release_notes_path))
changelog_path
end
@@ -178,6 +197,69 @@ def capture_android_screenshots!
sh(shell_join(["bash", File.join(repo_root, "scripts", "android-screenshots.sh")]))
end
def read_android_release_signing_properties!(path)
UI.user_error!("Missing materialized Android release signing properties at #{path}.") unless File.exist?(path)
properties = {}
File.foreach(path) do |line|
stripped = line.strip
next if stripped.empty? || stripped.start_with?("#")
key, value = stripped.split("=", 2)
next if key.nil? || key.empty? || value.nil?
properties[key] = value.strip
end
missing = ANDROID_RELEASE_SIGNING_GRADLE_PROPERTIES.reject { |key| env_present?(properties[key]) }
UI.user_error!("Materialized Android release signing properties are missing: #{missing.join(', ')}.") unless missing.empty?
properties
end
def export_android_release_signing_properties!(path)
read_android_release_signing_properties!(path).each do |key, value|
ENV["ORG_GRADLE_PROJECT_#{key}"] = value
end
end
def sync_android_release_signing!
sh(shell_join(["node", android_release_signing_script, "--mode", "sync-pull"]))
export_android_release_signing_properties!(android_release_signing_materialized_properties_path)
end
def prepare_android_release_signing!
if env_present?(ENV["MATCH_PASSWORD"])
sync_android_release_signing!
elsif File.exist?(android_release_signing_materialized_properties_path)
export_android_release_signing_properties!(android_release_signing_materialized_properties_path)
end
end
def validate_android_release_signing!
Dir.chdir(android_root) do
sh(shell_join(["./gradlew", ":app:bundlePlayRelease", "--dry-run"]))
end
end
def print_android_release_plan!(version_metadata)
UI.message("Android Play release plan:")
UI.message(" package: #{play_package_name}")
UI.message(" track: #{play_track}")
UI.message(" release_status: #{play_release_status}")
UI.message(" validate_only: #{play_validate_only?}")
UI.message(" versionName: #{version_metadata.fetch(:version)}")
UI.message(" versionCode: #{version_metadata.fetch(:version_code)}")
end
def validate_android_release_preflight!(version_metadata)
validate_play_auth!
prepare_android_release_signing!
validate_android_release_signing!
validate_android_release_notes!
print_android_release_plan!(version_metadata)
end
def upload_play_store_metadata!(version_metadata)
validate_android_screenshots!
sync_android_changelog!(version_metadata.fetch(:version_code))
@@ -230,6 +312,38 @@ platform :android do
UI.success("Google Play API credentials are valid.")
end
desc "Print the Android release signing plan"
lane :signing_plan do
sh(shell_join(["node", android_release_signing_script, "--mode", "plan"]))
end
desc "Pull encrypted Android release signing assets and validate Gradle release signing"
lane :signing_check do
sync_android_release_signing!
validate_android_release_signing!
UI.success("Android release signing assets are available locally.")
end
desc "Pull encrypted Android release signing assets from the shared signing repo"
lane :signing_sync_pull do
sync_android_release_signing!
UI.success("Pulled Android release signing assets.")
end
desc "Create or refresh encrypted Android release signing assets in the shared signing repo"
lane :signing_sync_push do
sh(shell_join(["node", android_release_signing_script, "--mode", "sync-push"]))
UI.success("Pushed Android release signing assets.")
end
desc "Validate Android Play release auth, signing, versioning, and release notes"
lane :release_preflight do
sync_android_versioning!
version_metadata = read_android_version_metadata
validate_android_release_preflight!(version_metadata)
UI.success("Android Play release preflight passed for #{version_metadata[:version]} (#{version_metadata[:version_code]}).")
end
desc "Upload Google Play metadata, changelog, and optional screenshots"
lane :metadata do
sync_android_versioning!
@@ -242,6 +356,7 @@ platform :android do
desc "Build signed Android release artifacts locally without uploading"
lane :play_store_archive do
sync_android_versioning!
prepare_android_release_signing!
build_release_artifacts!
end
@@ -260,9 +375,9 @@ platform :android do
desc "Upload Android metadata, archive release artifacts, then upload the Play AAB"
lane :release_upload do
auth_check
sync_android_versioning!
version_metadata = read_android_version_metadata
validate_android_release_preflight!(version_metadata)
screenshots
ENV["SUPPLY_UPLOAD_METADATA"] = "1"
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1"

View File

@@ -20,6 +20,35 @@ Optional app targeting:
GOOGLE_PLAY_PACKAGE_NAME=ai.openclaw.app
```
Android release signing uses the same private `apps-signing` repository and `MATCH_PASSWORD` secret as iOS, but with Android-specific encrypted assets. Pull the shared upload key before release validation:
```bash
pnpm android:release:signing:plan
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:check
```
The pull command materializes decrypted signing files under `apps/android/build/release-signing/`, which is gitignored. Later Fastlane release commands reload those materialized values and export them to Gradle for the current process.
For the first setup or rotation, provide the Play upload keystore and a local signing properties file, then push encrypted assets to `apps-signing`:
```bash
MATCH_PASSWORD=<signing repo password> \
OPENCLAW_ANDROID_UPLOAD_KEYSTORE=<path-to-upload-keystore.jks> \
OPENCLAW_ANDROID_SIGNING_PROPERTIES=<path-to-android-signing.properties> \
pnpm android:release:signing:sync:push
```
The source signing properties file must contain:
```properties
OPENCLAW_ANDROID_STORE_PASSWORD=<store-password>
OPENCLAW_ANDROID_KEY_ALIAS=<upload-key-alias>
OPENCLAW_ANDROID_KEY_PASSWORD=<key-password>
```
Store the Google Play upload key, not the irreplaceable app signing key, when Play App Signing is enabled.
Validate auth:
```bash
@@ -56,12 +85,19 @@ Release rules:
- `apps/android/version.json` is the pinned Android release version source.
- `apps/android/Config/Version.properties` is generated from that source and read by Gradle.
- `apps/android/CHANGELOG.md` is the Android-only changelog and release-note source.
- `apps/android/fastlane/metadata/android/en-US/release_notes.txt` is generated from that changelog by `pnpm android:version:sync`.
- `apps/android/Config/ReleaseSigning.json` pins the encrypted Android signing assets in the shared signing repo.
- `MATCH_PASSWORD` enables Fastlane to pull encrypted Android signing assets into `apps/android/build/release-signing/` before release validation or archive builds.
- Supported pinned Android versions use CalVer: `YYYY.M.D`.
- `versionCode` uses `YYYYMMDDNN`, where `NN` is a two-digit build number for the pinned version.
- `pnpm android:version:pin -- --from-gateway` promotes the current root gateway version into the pinned Android release version.
- `pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060502` increments another build on the same Android release train.
- `pnpm android:version:sync` updates generated version artifacts.
- `pnpm android:version:check` validates checked-in Android version artifacts.
- `pnpm android:release:preflight` validates Google Play auth, Android release signing, synced versioning, release notes, and prints the package/track/version/versionCode that will be uploaded.
- `pnpm android:release:signing:sync:pull` pulls encrypted Android signing assets from `apps-signing`.
- `pnpm android:release:signing:sync:push` creates or refreshes encrypted Android signing assets in `apps-signing`.
- `pnpm android:screenshots` builds and installs the Play debug app, launches deterministic screenshot scenes, and captures raw PNGs.
- `pnpm android:release:archive` builds the signed Play AAB and third-party APK into `apps/android/build/release-artifacts/`.
- `pnpm android:release:upload` uploads the Play AAB to the configured Google Play track. The default track is `internal`.

View File

@@ -368,7 +368,7 @@ enum ExecApprovalsStore {
tempURL.path,
targetURL.path,
nil,
copyfile_flags_t(COPYFILE_EXCL))
copyfile_flags_t(COPYFILE_DATA | COPYFILE_EXCL))
if copied == -1 {
if errno == EEXIST {
try? FileManager().removeItem(at: tempURL)

View File

@@ -1,2 +1,2 @@
81da795739997dcacbbea7969fab46788bf43a4482ef06c84115e6710c53d7fa plugin-sdk-api-baseline.json
87ae6b9342eb5887dd87c17d97744675f487adeb267cb7ec9b44ee5648b53efc plugin-sdk-api-baseline.jsonl
b810f3b17d1eb746a6fbc4c45095a3b2bb3e08c5cd62a5928f9add2c59bb95b9 plugin-sdk-api-baseline.json
36174a54f2a9e11b822f499b5659d0b1351198ce98112946d95283b0ee1032dd plugin-sdk-api-baseline.jsonl

View File

@@ -1175,8 +1175,24 @@
"source": "Control UI",
"target": "Control UI"
},
{
"source": "Models CLI",
"target": "模型 CLI"
},
{
"source": "Z.AI (GLM)",
"target": "Z.AI (GLM)"
},
{
"source": "Cohere",
"target": "Cohere"
},
{
"source": "Cohere plugin",
"target": "Cohere 插件"
},
{
"source": "cohere",
"target": "cohere"
}
]

View File

@@ -151,7 +151,6 @@ 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"] },
},
@@ -159,7 +158,6 @@ 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.",
},
@@ -174,9 +172,6 @@ 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.
@@ -184,17 +179,6 @@ 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

@@ -422,7 +422,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
</Accordion>
<Accordion title="Rich message formatting">
Outbound text uses standard Telegram HTML messages by default so replies remain readable across current Telegram clients.
Outbound text uses standard Telegram HTML messages by default so replies remain readable across current Telegram clients. This compatibility mode supports normal bold, italic, links, code, spoilers, and quotes, but not Bot API 10.1 rich-only blocks such as native tables, details, rich media, and formulas.
Set `channels.telegram.richMessages: true` to opt into Bot API 10.1 rich messages:
@@ -436,13 +436,16 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
}
```
When enabled:
- The agent is told that Telegram rich messages are available for this bot/account.
- Markdown text is rendered through OpenClaw's Markdown IR and sent as Telegram rich HTML.
- Explicit rich HTML payloads preserve supported Bot API 10.1 tags such as headings, tables, details, rich media, and formulas.
- Media captions still use Telegram HTML captions because rich messages do not replace captions.
This keeps model text away from Telegram Rich Markdown sigils, so currency like `$400-600K` is not parsed as math. Long rich text is split automatically across Telegram's rich text and rich block limits. Tables over Telegram's column limit are sent as code blocks.
Rich messages require compatible Telegram clients. Some current Desktop, Web, Android, and third-party clients display accepted rich messages as unsupported, so keep this option disabled unless every client used with the bot can render them.
Default: off for client compatibility. Rich messages require compatible Telegram clients; some current Desktop, Web, Android, and third-party clients display accepted rich messages as unsupported. Keep this option disabled unless every client used with the bot can render them. `/status` shows whether the current Telegram session has rich messages on or off.
Link previews are enabled by default. `channels.telegram.linkPreview: false` skips automatic entity detection for rich text.

View File

@@ -11,13 +11,17 @@ sidebarTitle: "MCP"
`openclaw mcp` has two jobs:
- run OpenClaw as an MCP server with `openclaw mcp serve`
- manage OpenClaw-owned outbound MCP server definitions with `list`, `show`, `status`, `doctor`, `probe`, `add`, `set`, `configure`, `tools`, `login`, `logout`, `reload`, and `unset`
- manage OpenClaw-managed outbound MCP server definitions with `list`, `show`, `status`, `doctor`, `probe`, `add`, `set`, `configure`, `tools`, `login`, `logout`, `reload`, and `unset`
In other words:
- `serve` is OpenClaw acting as an MCP server
- the other subcommands are OpenClaw acting as an MCP client-side registry for MCP servers its runtimes may consume later
<Note>
`list`, `show`, `set`, and `unset` only read and write OpenClaw-managed `mcp.servers` entries in OpenClaw config. They do not include mcporter servers from `config/mcporter.json`; use `mcporter list` for that registry.
</Note>
Use [`openclaw acp`](/cli/acp) when OpenClaw should host a coding harness session itself and route that runtime through ACP.
## Choose the right MCP path
@@ -368,7 +372,7 @@ For broader testing context, see [Testing](/help/testing).
This is the `openclaw mcp list`, `show`, `status`, `doctor`, `probe`, `add`, `set`,
`configure`, `tools`, `login`, `logout`, `reload`, and `unset` path.
These commands do not expose OpenClaw over MCP. They manage OpenClaw-owned MCP server definitions under `mcp.servers` in OpenClaw config.
These commands do not expose OpenClaw over MCP. They manage OpenClaw-managed MCP server definitions under `mcp.servers` in OpenClaw config. They do not read mcporter servers from `config/mcporter.json`.
Those saved definitions are for runtimes that OpenClaw launches or configures later, such as embedded OpenClaw and other runtime adapters. OpenClaw stores the definitions centrally so those runtimes do not need to keep their own duplicate MCP server lists.

View File

@@ -107,6 +107,10 @@ Notes:
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.
- 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
promoted.
- `verify` uses `.clawhub/origin.json` for installed ClawHub skills, so it
verifies the installed version against the registry it came from. `--version`
and `--tag` override the version selector but keep that installed registry

View File

@@ -296,6 +296,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
| --------------------------------------- | -------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------- |
| BytePlus | `byteplus` / `byteplus-plan` | `BYTEPLUS_API_KEY` | `byteplus-plan/ark-code-latest` |
| Cerebras | `cerebras` | `CEREBRAS_API_KEY` | `cerebras/zai-glm-4.7` |
| Cohere | `cohere` | `COHERE_API_KEY` | `cohere/command-a-03-2025` |
| Cloudflare AI Gateway | `cloudflare-ai-gateway` | `CLOUDFLARE_AI_GATEWAY_API_KEY` | - |
| DeepInfra | `deepinfra` | `DEEPINFRA_API_KEY` | `deepinfra/deepseek-ai/DeepSeek-V4-Flash` |
| DeepSeek | `deepseek` | `DEEPSEEK_API_KEY` | `deepseek/deepseek-v4-flash` |

View File

@@ -1417,6 +1417,7 @@
"providers/azure-speech",
"providers/cerebras",
"providers/chutes",
"providers/cohere",
"providers/claude-max-api-proxy",
"providers/cloudflare-ai-gateway",
"providers/comfy",

View File

@@ -103,8 +103,46 @@ Supported `appServer` fields:
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed. |
| `defaultWorkspaceDir` | current process directory | Workspace used by `/codex bind` when `--cwd` is omitted. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, and `null` clears the override. Legacy `"fast"` is accepted as `"priority"`. |
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects it with `default_permissions` instead of sending `sandbox`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
`appServer.networkProxy` is explicit because it changes the Codex sandbox
contract. When enabled, OpenClaw also sets `features.network_proxy.enabled` and
`default_permissions` in the Codex thread config so the generated permission
profile can start Codex managed networking. By default, OpenClaw generates a
collision-resistant `openclaw-network-<fingerprint>` profile name from the
profile body; use `profileName` only when a stable local name is required.
```js
export default {
plugins: {
entries: {
codex: {
config: {
appServer: {
sandbox: "workspace-write",
networkProxy: {
enabled: true,
domains: {
"api.openai.com": "allow",
"blocked.example.com": "deny",
},
allowUpstreamProxy: true,
proxyUrl: "http://127.0.0.1:3128",
},
},
},
},
},
},
};
```
If the normal app-server runtime would be `danger-full-access`, enabling
`networkProxy` uses workspace-style filesystem access for the generated
permission profile. Codex managed network enforcement is sandboxed networking,
so a full-access profile would not protect outbound traffic.
The plugin blocks older or unversioned app-server handshakes. Codex app-server
must report stable version `0.125.0` or newer.

View File

@@ -561,8 +561,52 @@ Supported `appServer` fields:
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start/resume. Guardian defaults prefer `"workspace-write"` when allowed, otherwise `"read-only"`. When an OpenClaw sandbox is active, `danger-full-access` turns use Codex `workspace-write` with network access derived from the OpenClaw sandbox egress setting. |
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed, otherwise `guardian_subagent` or `user`. `guardian_subagent` remains a legacy alias. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, `null` clears the override, and legacy `"fast"` is accepted as `"priority"`. |
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects it with `default_permissions` instead of sending `sandbox`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
`appServer.networkProxy` is explicit because it changes the Codex sandbox
contract. When enabled, OpenClaw also sets `features.network_proxy.enabled` and
`default_permissions` in the Codex thread config so the generated permission
profile can start Codex managed networking. By default, OpenClaw generates a
collision-resistant `openclaw-network-<fingerprint>` profile name from the
profile body; use `profileName` only when a stable local name is required.
```js
export default {
plugins: {
entries: {
codex: {
config: {
appServer: {
sandbox: "workspace-write",
networkProxy: {
enabled: true,
domains: {
"api.openai.com": "allow",
"blocked.example.com": "deny",
},
unixSockets: {
"/tmp/proxy.sock": "allow",
"/tmp/blocked.sock": "none",
},
allowUpstreamProxy: true,
proxyUrl: "http://127.0.0.1:3128",
},
},
},
},
},
},
};
```
If the normal app-server runtime would be `danger-full-access`, enabling
`networkProxy` uses workspace-style filesystem access for the generated
permission profile. Codex managed network enforcement is sandboxed networking,
so a full-access profile would not protect outbound traffic.
Domain entries use `allow` or `deny`; Unix socket entries use Codex's
`allow` or `none` values.
OpenClaw-owned dynamic tool calls are bounded independently from
`appServer.requestTimeoutMs`: Codex `item/tool/call` requests use a 90 second
OpenClaw watchdog by default. A positive per-call `timeoutMs` argument extends

View File

@@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
## Core npm package
90 plugins
91 plugins
- **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint.
@@ -81,6 +81,8 @@ Each entry lists the package, distribution route, and description.
- **[codex-supervisor](/plugins/reference/codex-supervisor)** (`@openclaw/codex-supervisor`) - included in OpenClaw. Supervise Codex app-server sessions from OpenClaw.
- **[cohere](/plugins/reference/cohere)** (`@openclaw/cohere-provider`) - included in OpenClaw. Adds Cohere model provider support to OpenClaw.
- **[comfy](/plugins/reference/comfy)** (`@openclaw/comfy-provider`) - included in OpenClaw. Adds ComfyUI model provider support to OpenClaw.
- **[copilot-proxy](/plugins/reference/copilot-proxy)** (`@openclaw/copilot-proxy`) - included in OpenClaw. Adds Copilot Proxy model provider support to OpenClaw.

View File

@@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and
pnpm plugins:inventory:gen
```
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 127
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 128
generated plugin reference pages by distribution, package, and description.

View File

@@ -0,0 +1,23 @@
---
summary: "Adds Cohere model provider support to OpenClaw."
read_when:
- You are installing, configuring, or auditing the cohere plugin
title: "Cohere plugin"
---
# Cohere plugin
Adds Cohere model provider support to OpenClaw.
## Distribution
- Package: `@openclaw/cohere-provider`
- Install route: included in OpenClaw
## Surface
providers: cohere
## Related docs
- [cohere](/providers/cohere)

63
docs/providers/cohere.md Normal file
View File

@@ -0,0 +1,63 @@
---
summary: "Cohere setup (auth + model selection)"
title: "Cohere"
read_when:
- You want to use Cohere with OpenClaw
- You need the Cohere API key env var or CLI auth choice
---
[Cohere](https://cohere.com) provides OpenAI-compatible inference through its Compatibility API. OpenClaw includes a bundled Cohere provider plugin with the Command A model catalog.
| Property | Value |
| --------------- | ---------------------------------------- |
| Provider id | `cohere` |
| Plugin | bundled, `enabledByDefault: true` |
| Auth env var | `COHERE_API_KEY` |
| Onboarding flag | `--auth-choice cohere-api-key` |
| Direct CLI flag | `--cohere-api-key <key>` |
| API | OpenAI-compatible (`openai-completions`) |
| Base URL | `https://api.cohere.ai/compatibility/v1` |
| Default model | `cohere/command-a-03-2025` |
## Get started
1. Create a Cohere API key.
2. Run onboarding:
```bash
openclaw onboard --non-interactive \
--auth-choice cohere-api-key \
--cohere-api-key "$COHERE_API_KEY"
```
3. Confirm the catalog is available:
```bash
openclaw models list --provider cohere
```
The default model is set only when no primary model is already configured.
## Environment-only setup
Make `COHERE_API_KEY` available to the Gateway process, then select the bundled model:
```json5
{
agents: {
defaults: {
model: { primary: "cohere/command-a-03-2025" },
},
},
}
```
<Note>
If the Gateway runs as a daemon or in Docker, configure `COHERE_API_KEY` for that service. Exporting it only in an interactive shell does not make it available to an already-running Gateway.
</Note>
## Related
- [Model providers](/concepts/model-providers)
- [Models CLI](/cli/models)
- [Provider directory](/providers)

View File

@@ -33,6 +33,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [BytePlus (International)](/concepts/model-providers#byteplus-international)
- [Cerebras](/providers/cerebras)
- [Chutes](/providers/chutes)
- [Cohere](/providers/cohere)
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
- [ComfyUI](/providers/comfy)
- [DeepSeek](/providers/deepseek)

View File

@@ -27,6 +27,7 @@ model as `provider/model`.
- [Anthropic (API + Claude CLI)](/providers/anthropic)
- [BytePlus (International)](/concepts/model-providers#byteplus-international)
- [Chutes](/providers/chutes)
- [Cohere](/providers/cohere)
- [ComfyUI](/providers/comfy)
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
- [DeepInfra](/providers/deepinfra)

View File

@@ -675,9 +675,10 @@ is disabled, uninstalled, or rolled back:
clearCodeModeNamespacesForPlugin(pluginId);
```
Use `unregisterCodeModeNamespace(namespaceId)` only when removing one known
namespace. Tests can call `clearCodeModeNamespacesForTest()` to avoid leaking
registrations across cases.
Code-mode cleanup is plugin-owned; clear the plugin's namespace registrations
when its lifecycle ends instead of keeping per-namespace teardown handles. Tests
can call `clearCodeModeNamespacesForTest()` to avoid leaking registrations
across cases.
### Test checklist

View File

@@ -362,8 +362,8 @@ OpenClaw also enforces a safety floor for embedded runs:
Why: leave enough headroom for multi-turn "housekeeping" (like memory writes) before compaction becomes unavoidable.
Implementation: `ensureAgentCompactionReserveTokens()` in `src/agents/agent-settings.ts`
(called from `src/agents/embedded-agent-runner.ts`).
Implementation: `applyAgentCompactionSettingsFromConfig()` in `src/agents/agent-settings.ts`
(called from embedded-runner turn and compaction setup paths).
---

View File

@@ -27,9 +27,9 @@ export const CHROME_STOP_TIMEOUT_MS = 2500;
export const CHROME_STOP_PROBE_TIMEOUT_MS = 200;
export const CHROME_STDERR_HINT_MAX_CHARS = 2000;
export const PROFILE_HTTP_REACHABILITY_TIMEOUT_MS = 300;
export const PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS = 200;
export const PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS = 2000;
const PROFILE_HTTP_REACHABILITY_TIMEOUT_MS = 300;
const PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS = 200;
const PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS = 2000;
export const PROFILE_ATTACH_RETRY_TIMEOUT_MS = 1200;
export const PROFILE_POST_RESTART_WS_TIMEOUT_MS = 600;
export const CHROME_MCP_ATTACH_READY_WINDOW_MS = 8000;

View File

@@ -2,15 +2,14 @@
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js";
import {
PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS,
PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS,
resolveCdpReachabilityTimeouts,
} from "./cdp-timeouts.js";
import { resolveCdpReachabilityTimeouts } from "./cdp-timeouts.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { assertBrowserNavigationAllowed } from "./navigation-guard.js";
const PROFILE_HTTP_REACHABILITY_TIMEOUT_MS = 300;
const PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS = 200;
const PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS = 2000;
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {

View File

@@ -3,15 +3,14 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import "./server-context.chrome-test-harness.js";
import {
PROFILE_ATTACH_RETRY_TIMEOUT_MS,
PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
} from "./cdp-timeouts.js";
import { PROFILE_ATTACH_RETRY_TIMEOUT_MS } from "./cdp-timeouts.js";
import * as chromeModule from "./chrome.js";
import { BrowserProfileUnavailableError } from "./errors.js";
import { createBrowserRouteContext } from "./server-context.js";
import { makeBrowserServerState, mockLaunchedChrome } from "./server-context.test-harness.js";
const PROFILE_HTTP_REACHABILITY_TIMEOUT_MS = 300;
function setupEnsureBrowserAvailableHarness() {
vi.useFakeTimers();

View File

@@ -193,6 +193,47 @@
"enum": ["user", "auto_review", "guardian_subagent"]
},
"serviceTier": { "type": ["string", "null"] },
"networkProxy": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": false
},
"profileName": { "type": "string" },
"baseProfile": {
"type": "string",
"enum": ["read-only", "workspace"]
},
"mode": {
"type": "string",
"enum": ["limited", "full"]
},
"domains": {
"type": "object",
"additionalProperties": {
"type": "string",
"enum": ["allow", "deny"]
}
},
"unixSockets": {
"type": "object",
"additionalProperties": {
"type": "string",
"enum": ["allow", "none"]
}
},
"proxyUrl": { "type": "string" },
"socksUrl": { "type": "string" },
"enableSocks5": { "type": "boolean" },
"enableSocks5Udp": { "type": "boolean" },
"allowUpstreamProxy": { "type": "boolean" },
"allowLocalBinding": { "type": "boolean" },
"dangerouslyAllowNonLoopbackProxy": { "type": "boolean" },
"dangerouslyAllowAllUnixSockets": { "type": "boolean" }
}
},
"defaultWorkspaceDir": {
"type": "string"
},
@@ -385,6 +426,81 @@
"help": "Optional Codex app-server service tier. Use priority, flex, or null. Legacy fast is accepted as priority.",
"advanced": true
},
"appServer.networkProxy": {
"label": "Network Proxy",
"help": "Enable Codex permissions-profile networking for app-server commands.",
"advanced": true
},
"appServer.networkProxy.enabled": {
"label": "Network Proxy Enabled",
"help": "When enabled, OpenClaw defines a Codex permissions profile and selects it with default_permissions instead of sandbox fields.",
"advanced": true
},
"appServer.networkProxy.profileName": {
"label": "Network Proxy Profile",
"help": "Optional stable Codex permissions profile name. Leave unset to use a generated openclaw-network fingerprint name.",
"advanced": true
},
"appServer.networkProxy.baseProfile": {
"label": "Network Proxy Base",
"help": "Filesystem access used by the generated profile. Defaults to read-only for read-only sandboxes and workspace otherwise.",
"advanced": true
},
"appServer.networkProxy.domains": {
"label": "Network Domains",
"help": "Domain allow and deny rules for Codex sandboxed networking.",
"advanced": true
},
"appServer.networkProxy.unixSockets": {
"label": "Unix Sockets",
"help": "Unix socket allow and none rules for Codex sandboxed networking.",
"advanced": true
},
"appServer.networkProxy.proxyUrl": {
"label": "HTTP Proxy URL",
"help": "HTTP listener URL used by Codex sandboxed networking.",
"advanced": true
},
"appServer.networkProxy.socksUrl": {
"label": "SOCKS Proxy URL",
"help": "SOCKS listener URL used by Codex sandboxed networking.",
"advanced": true
},
"appServer.networkProxy.enableSocks5": {
"label": "Enable SOCKS5",
"help": "Expose SOCKS5 support for the generated Codex permissions profile.",
"advanced": true
},
"appServer.networkProxy.enableSocks5Udp": {
"label": "Enable SOCKS5 UDP",
"help": "Allow UDP over the SOCKS5 listener when SOCKS5 is enabled.",
"advanced": true
},
"appServer.networkProxy.allowUpstreamProxy": {
"label": "Allow Upstream Proxy",
"help": "Allow Codex sandboxed networking to chain through inherited HTTP(S)_PROXY or ALL_PROXY settings.",
"advanced": true
},
"appServer.networkProxy.allowLocalBinding": {
"label": "Allow Local Binding",
"help": "Permit broader local and private-network access through Codex sandboxed networking.",
"advanced": true
},
"appServer.networkProxy.mode": {
"label": "Network Mode",
"help": "Codex sandboxed networking mode for subprocess traffic.",
"advanced": true
},
"appServer.networkProxy.dangerouslyAllowNonLoopbackProxy": {
"label": "Allow Non-Loopback Proxy",
"help": "Permit non-loopback bind addresses for Codex sandboxed networking listeners.",
"advanced": true
},
"appServer.networkProxy.dangerouslyAllowAllUnixSockets": {
"label": "Allow All Unix Sockets",
"help": "Bypass Codex's Unix socket allowlist for tightly controlled environments.",
"advanced": true
},
"appServer.defaultWorkspaceDir": {
"label": "Default Workspace",
"help": "Workspace used by /codex bind when --cwd is omitted.",

View File

@@ -10,7 +10,10 @@ import { AUTH_PROFILE_RUNTIME_CONTRACT } from "openclaw/plugin-sdk/agent-runtime
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl } from "./run-attempt.js";
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
import {
readCodexAppServerBinding,
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
} from "./session-binding.js";
import { createCodexTestModel } from "./test-support.js";
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
@@ -58,6 +61,25 @@ function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAtt
} as EmbeddedRunAttemptParams;
}
const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
"features.standalone_web_search": false,
web_search: "disabled",
});
function writeCodexAppServerBinding(
...args: Parameters<typeof writeRawCodexAppServerBinding>
) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
{
webSearchThreadConfigFingerprint: DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
...binding,
},
lookup,
);
}
function threadStartResult(threadId = "thread-auth-contract") {
return {
thread: {

View File

@@ -10,6 +10,7 @@ import {
CODEX_PLUGINS_CONFIG_KEYS,
canUseCodexModelBackedApprovalsReviewerForModel,
codexAppServerStartOptionsKey,
fingerprintCodexAppServerNetworkProxyConfigPatch,
readCodexPluginConfig,
resolveCodexAppServerRuntimeOptions,
resolveCodexComputerUseConfig,
@@ -83,6 +84,21 @@ describe("Codex app-server config", () => {
sandbox: "danger-full-access",
}),
).toBe(false);
expect(
shouldAutoApproveCodexAppServerApprovals({
approvalPolicy: "never",
sandbox: "danger-full-access",
networkProxy: {
profileName: "openclaw-network",
configFingerprint: "network-proxy-v1",
configPatch: {
"features.network_proxy.enabled": true,
default_permissions: "openclaw-network",
permissions: {},
},
},
}),
).toBe(false);
});
it("parses typed plugin config before falling back to environment knobs", () => {
@@ -125,6 +141,102 @@ describe("Codex app-server config", () => {
});
});
it("builds Codex permissions-profile config for app-server network proxy", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {
appServer: {
sandbox: "workspace-write",
networkProxy: {
enabled: true,
profileName: "mock-proxy",
mode: "limited",
domains: {
" api.openai.com ": "allow",
"blocked.example.com": "deny",
},
unixSockets: {
" /tmp/mock-proxy.sock ": "allow",
"/tmp/blocked.sock": "none",
},
proxyUrl: "http://127.0.0.1:3128",
socksUrl: "socks5h://127.0.0.1:8081",
enableSocks5: true,
enableSocks5Udp: false,
allowUpstreamProxy: true,
allowLocalBinding: false,
},
},
},
});
const networkProxy = runtime.networkProxy;
if (!networkProxy) {
throw new Error("Expected network proxy runtime config");
}
expect(networkProxy).toEqual({
profileName: "mock-proxy",
configFingerprint: expect.any(String),
configPatch: {
"features.network_proxy.enabled": true,
default_permissions: "mock-proxy",
permissions: {
"mock-proxy": {
filesystem: {
":minimal": "read",
":project_roots": {
".": "write",
},
},
network: {
enabled: true,
mode: "limited",
domains: {
"api.openai.com": "allow",
"blocked.example.com": "deny",
},
unix_sockets: {
"/tmp/mock-proxy.sock": "allow",
"/tmp/blocked.sock": "none",
},
proxy_url: "http://127.0.0.1:3128",
socks_url: "socks5h://127.0.0.1:8081",
enable_socks5: true,
enable_socks5_udp: false,
allow_upstream_proxy: true,
allow_local_binding: false,
},
},
},
},
});
expect(networkProxy.configFingerprint).toBe(
fingerprintCodexAppServerNetworkProxyConfigPatch(networkProxy.configPatch),
);
});
it("uses read-only filesystem rules for read-only network proxy profiles", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {
appServer: {
sandbox: "read-only",
networkProxy: {
enabled: true,
domains: { "example.com": "allow" },
},
},
},
});
const profileName = runtime.networkProxy?.profileName;
const permissions = runtime.networkProxy?.configPatch.permissions as Record<
string,
{ filesystem: { ":project_roots": { ".": string } } }
>;
expect(profileName).toMatch(/^openclaw-network-[a-f0-9]{16}$/u);
expect(runtime.networkProxy?.configPatch.default_permissions).toBe(profileName);
expect(permissions[profileName ?? ""]?.filesystem[":project_roots"]["."]).toBe("read");
});
it("clamps oversized app-server timer config", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {

View File

@@ -1,5 +1,5 @@
// Codex helper module supports config behavior.
import { createHmac, randomBytes } from "node:crypto";
import { createHash, createHmac, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { hostname as readHostName } from "node:os";
import path from "node:path";
@@ -16,7 +16,7 @@ import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
import { detectWindowsSpawnCommandInlineArgs } from "openclaw/plugin-sdk/windows-spawn";
import { z } from "zod";
import type { CodexSandboxPolicy, CodexServiceTier } from "./protocol.js";
import type { CodexSandboxPolicy, CodexServiceTier, JsonObject, JsonValue } from "./protocol.js";
const START_OPTIONS_KEY_SECRET_SYMBOL = Symbol.for("openclaw.codexAppServerStartOptionsKeySecret");
const START_OPTIONS_KEY_SECRET = getStartOptionsKeySecret();
@@ -111,6 +111,34 @@ export type CodexAppServerExperimentalConfig = {
sandboxExecServer?: boolean;
};
export type CodexAppServerNetworkProxyDomainPermission = "allow" | "deny";
export type CodexAppServerNetworkProxyUnixSocketPermission = "allow" | "none";
export type CodexAppServerNetworkProxyBaseProfile = "read-only" | "workspace";
export type CodexAppServerNetworkProxyMode = "limited" | "full";
export type CodexAppServerNetworkProxyConfig = {
enabled?: boolean;
profileName?: string;
baseProfile?: CodexAppServerNetworkProxyBaseProfile;
mode?: CodexAppServerNetworkProxyMode;
domains?: Record<string, CodexAppServerNetworkProxyDomainPermission>;
unixSockets?: Record<string, CodexAppServerNetworkProxyUnixSocketPermission>;
proxyUrl?: string;
socksUrl?: string;
enableSocks5?: boolean;
enableSocks5Udp?: boolean;
allowUpstreamProxy?: boolean;
allowLocalBinding?: boolean;
dangerouslyAllowNonLoopbackProxy?: boolean;
dangerouslyAllowAllUnixSockets?: boolean;
};
export type ResolvedCodexAppServerNetworkProxyConfig = {
profileName: string;
configFingerprint: string;
configPatch: JsonObject;
};
export type ResolvedCodexPluginPolicy = {
configKey: string;
marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
@@ -151,6 +179,7 @@ export type CodexAppServerRuntimeOptions = {
sandbox: CodexAppServerSandboxMode;
approvalsReviewer: CodexAppServerApprovalsReviewer;
serviceTier?: CodexServiceTier;
networkProxy?: ResolvedCodexAppServerNetworkProxyConfig;
};
export type CodexModelBackedReviewerContext = {
@@ -188,15 +217,20 @@ export type CodexPluginConfig = {
sandbox?: CodexAppServerSandboxMode;
approvalsReviewer?: CodexAppServerApprovalsReviewer;
serviceTier?: CodexServiceTier | null;
networkProxy?: CodexAppServerNetworkProxyConfig;
defaultWorkspaceDir?: string;
experimental?: CodexAppServerExperimentalConfig;
};
};
export function shouldAutoApproveCodexAppServerApprovals(
appServer: Pick<CodexAppServerRuntimeOptions, "approvalPolicy" | "sandbox">,
appServer: Pick<CodexAppServerRuntimeOptions, "approvalPolicy" | "networkProxy" | "sandbox">,
): boolean {
return appServer.approvalPolicy === "never" && appServer.sandbox === "danger-full-access";
return (
appServer.networkProxy === undefined &&
appServer.approvalPolicy === "never" &&
appServer.sandbox === "danger-full-access"
);
}
export const CODEX_APP_SERVER_CONFIG_KEYS = [
@@ -216,6 +250,7 @@ export const CODEX_APP_SERVER_CONFIG_KEYS = [
"sandbox",
"approvalsReviewer",
"serviceTier",
"networkProxy",
"defaultWorkspaceDir",
"experimental",
] as const;
@@ -249,6 +284,7 @@ export const CODEX_PLUGIN_ENTRY_CONFIG_KEYS = [
const DEFAULT_CODEX_COMPUTER_USE_PLUGIN_NAME = "computer-use";
const DEFAULT_CODEX_COMPUTER_USE_MCP_SERVER_NAME = "computer-use";
const DEFAULT_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS = 60_000;
const DEFAULT_CODEX_APP_SERVER_NETWORK_PROXY_PROFILE_PREFIX = "openclaw-network";
const codexAppServerTransportSchema = z.enum(["stdio", "websocket"]);
const codexAppServerPolicyModeSchema = z.enum(["yolo", "guardian"]);
@@ -273,6 +309,26 @@ const codexAppServerExperimentalSchema = z
sandboxExecServer: z.boolean().optional(),
})
.strict();
const codexAppServerNetworkProxyDomainPermissionSchema = z.enum(["allow", "deny"]);
const codexAppServerNetworkProxyUnixSocketPermissionSchema = z.enum(["allow", "none"]);
const codexAppServerNetworkProxySchema = z
.object({
enabled: z.boolean().optional(),
profileName: z.string().trim().min(1).optional(),
baseProfile: z.enum(["read-only", "workspace"]).optional(),
mode: z.enum(["limited", "full"]).optional(),
domains: z.record(z.string(), codexAppServerNetworkProxyDomainPermissionSchema).optional(),
unixSockets: z.record(z.string(), codexAppServerNetworkProxyUnixSocketPermissionSchema).optional(),
proxyUrl: z.string().trim().min(1).optional(),
socksUrl: z.string().trim().min(1).optional(),
enableSocks5: z.boolean().optional(),
enableSocks5Udp: z.boolean().optional(),
allowUpstreamProxy: z.boolean().optional(),
allowLocalBinding: z.boolean().optional(),
dangerouslyAllowNonLoopbackProxy: z.boolean().optional(),
dangerouslyAllowAllUnixSockets: z.boolean().optional(),
})
.strict();
const codexPluginEntryConfigSchema = z
.object({
@@ -334,6 +390,7 @@ const codexPluginConfigSchema = z
sandbox: codexAppServerSandboxSchema.optional(),
approvalsReviewer: codexAppServerApprovalsReviewerSchema.optional(),
serviceTier: codexAppServerServiceTierSchema,
networkProxy: codexAppServerNetworkProxySchema.optional(),
defaultWorkspaceDir: z.string().optional(),
experimental: codexAppServerExperimentalSchema.optional(),
})
@@ -549,6 +606,11 @@ export function resolveCodexAppServerRuntimeOptions(
? normalizedPolicyMode
: (explicitPolicyMode ?? normalizedPolicyMode ?? defaultPolicy?.mode ?? "yolo");
const serviceTier = normalizeCodexServiceTier(config.serviceTier);
const resolvedSandbox =
forcedPolicy?.sandbox ??
configuredSandbox ??
defaultPolicy?.sandbox ??
(policyMode === "guardian" ? "workspace-write" : "danger-full-access");
if (transport === "websocket" && !url) {
throw new Error(
"plugins.entries.codex.config.appServer.url is required when appServer.transport is websocket",
@@ -597,17 +659,14 @@ export function resolveCodexAppServerRuntimeOptions(
: {}),
approvalPolicy: forcedPolicy?.approvalPolicy ?? approvalPolicy,
approvalPolicySource,
sandbox:
forcedPolicy?.sandbox ??
configuredSandbox ??
defaultPolicy?.sandbox ??
(policyMode === "guardian" ? "workspace-write" : "danger-full-access"),
sandbox: resolvedSandbox,
approvalsReviewer:
forcedPolicy?.approvalsReviewer ??
explicitApprovalsReviewer ??
defaultPolicy?.approvalsReviewer ??
(policyMode === "guardian" ? "auto_review" : "user"),
...(serviceTier ? { serviceTier } : {}),
...resolveCodexAppServerNetworkProxy(config.networkProxy, resolvedSandbox),
};
}
@@ -821,6 +880,104 @@ export function codexSandboxPolicyForTurn(
};
}
function resolveCodexAppServerNetworkProxy(
config: CodexAppServerNetworkProxyConfig | undefined,
sandbox: CodexAppServerSandboxMode,
): { networkProxy?: ResolvedCodexAppServerNetworkProxyConfig } {
if (config?.enabled !== true) {
return {};
}
const fileSystemMode =
config.baseProfile === "read-only" || (!config.baseProfile && sandbox === "read-only")
? "read"
: "write";
const networkConfig = removeUndefinedJsonFields({
enabled: true,
mode: config.mode,
domains: normalizeNetworkProxyPermissionMap(config.domains),
unix_sockets: normalizeNetworkProxyPermissionMap(config.unixSockets),
proxy_url: readNonEmptyString(config.proxyUrl),
socks_url: readNonEmptyString(config.socksUrl),
enable_socks5: config.enableSocks5,
enable_socks5_udp: config.enableSocks5Udp,
allow_upstream_proxy: config.allowUpstreamProxy,
allow_local_binding: config.allowLocalBinding,
dangerously_allow_non_loopback_proxy: config.dangerouslyAllowNonLoopbackProxy,
dangerously_allow_all_unix_sockets: config.dangerouslyAllowAllUnixSockets,
});
const profile = {
filesystem: {
":minimal": "read",
":project_roots": {
".": fileSystemMode,
},
},
network: networkConfig,
};
const profileName = resolveNetworkProxyPermissionProfileName(config, profile);
const configPatch: JsonObject = {
"features.network_proxy.enabled": true,
default_permissions: profileName,
permissions: {
[profileName]: profile,
},
};
return {
networkProxy: {
profileName,
configFingerprint: fingerprintCodexAppServerNetworkProxyConfigPatch(configPatch),
configPatch,
},
};
}
function resolveNetworkProxyPermissionProfileName(
config: CodexAppServerNetworkProxyConfig,
profile: JsonObject,
): string {
const explicitProfileName = readNonEmptyString(config.profileName);
if (explicitProfileName) {
return explicitProfileName;
}
const suffix = createHash("sha256")
.update(stableStringifyJson({ version: 1, profile }))
.digest("hex")
.slice(0, 16);
return `${DEFAULT_CODEX_APP_SERVER_NETWORK_PROXY_PROFILE_PREFIX}-${suffix}`;
}
export function fingerprintCodexAppServerNetworkProxyConfigPatch(configPatch: JsonObject): string {
return createHash("sha256").update(stableStringifyJson(configPatch)).digest("hex");
}
function normalizeNetworkProxyPermissionMap<TPermission extends string>(
value: Record<string, TPermission> | undefined,
): Record<string, TPermission> | undefined {
const entries = Object.entries(value ?? {})
.map(([key, permission]) => [key.trim(), permission] as const)
.filter(([key]) => key.length > 0);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
function removeUndefinedJsonFields(value: Record<string, JsonValue | undefined>): JsonObject {
return Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, JsonValue] => entry[1] !== undefined),
);
}
function stableStringifyJson(value: JsonValue): string {
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringifyJson(item)).join(",")}]`;
}
if (value && typeof value === "object") {
return `{${Object.entries(value)
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringifyJson(item)}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
export function withMcpElicitationsApprovalPolicy(
policy: CodexAppServerEffectiveApprovalPolicy,
): CodexAppServerEffectiveApprovalPolicy {

View File

@@ -161,7 +161,7 @@ describe("OpenClaw-owned tool runtime contract — Codex app-server adapter", ()
expectRecordFields(eventRecord, {
toolName: "exec",
toolCallId: "call-middleware",
args: { command: "status" },
args: mergedParams,
});
expectRecordFields(requireRecord(eventRecord.result, "tool_result middleware result"), {
content: [{ type: "text", text: "raw output" }],

View File

@@ -1055,14 +1055,6 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"

View File

@@ -1055,14 +1055,6 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"

View File

@@ -84,6 +84,18 @@ export type CodexDynamicToolNamespaceSpec = JsonObject & {
export type CodexDynamicToolSpec = CodexDynamicToolFunctionSpec | CodexDynamicToolNamespaceSpec;
export type CodexLegacyDynamicToolFunctionSpec = JsonObject & {
name: string;
description: string;
inputSchema: JsonValue;
deferLoading?: boolean;
namespace?: string;
};
export type CodexThreadStartDynamicToolSpec =
| CodexDynamicToolSpec
| CodexLegacyDynamicToolFunctionSpec;
export function flattenCodexDynamicToolFunctions(
tools: readonly CodexDynamicToolSpec[] | undefined,
): CodexDynamicToolFunctionSpec[] {
@@ -105,7 +117,7 @@ export type CodexThreadStartParams = JsonObject & {
approvalsReviewer?: string | null;
sandbox?: string;
serviceTier?: CodexServiceTier | null;
dynamicTools?: CodexDynamicToolSpec[] | null;
dynamicTools?: CodexThreadStartDynamicToolSpec[] | null;
developerInstructions?: string;
experimentalRawEvents?: boolean;
environments?: CodexTurnEnvironmentParams[] | null;

View File

@@ -14,7 +14,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import type { CodexServerNotification } from "./protocol.js";
import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl } from "./run-attempt.js";
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
import {
readCodexAppServerBinding,
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
} from "./session-binding.js";
import { createCodexTestModel } from "./test-support.js";
let tempDir: string;
@@ -63,6 +66,25 @@ function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAtt
} as EmbeddedRunAttemptParams;
}
const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
"features.standalone_web_search": false,
web_search: "disabled",
});
function writeCodexAppServerBinding(
...args: Parameters<typeof writeRawCodexAppServerBinding>
) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
{
webSearchThreadConfigFingerprint: DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
...binding,
},
lookup,
);
}
function assistantMessage(text: string, timestamp: number): AgentMessage {
return {
role: "assistant",

View File

@@ -18,10 +18,32 @@ import {
tempDir,
} from "./run-attempt-test-harness.js";
import { testing } from "./run-attempt.js";
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
import {
readCodexAppServerBinding,
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
} from "./session-binding.js";
setupRunAttemptTestHooks();
const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
"features.standalone_web_search": false,
web_search: "disabled",
});
function writeCodexAppServerBinding(
...args: Parameters<typeof writeRawCodexAppServerBinding>
) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
{
webSearchThreadConfigFingerprint: DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
...binding,
},
lookup,
);
}
describe("runCodexAppServerAttempt native hook relay", () => {
it("registers native hook relay config for an enabled Codex turn and cleans it up", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
@@ -609,6 +631,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
nativeHookRelayGeneration: "generation-from-failed-resume",
});
const harness = createStartedThreadHarness(async (method) => {

View File

@@ -116,6 +116,11 @@ function expectResumeRequest(
}
}
const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
"features.standalone_web_search": false,
web_search: "disabled",
});
async function writeExistingBinding(
sessionFile: string,
workspaceDir: string,
@@ -126,6 +131,7 @@ async function writeExistingBinding(
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
webSearchThreadConfigFingerprint: DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
...overrides,
});
}

View File

@@ -38,11 +38,30 @@ import { testing } from "./run-attempt.js";
import {
readCodexAppServerBinding,
resolveCodexAppServerBindingPath,
writeCodexAppServerBinding,
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
} from "./session-binding.js";
setupRunAttemptTestHooks();
const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
"features.standalone_web_search": false,
web_search: "disabled",
});
function writeCodexAppServerBinding(
...args: Parameters<typeof writeRawCodexAppServerBinding>
) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
{
webSearchThreadConfigFingerprint: DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
...binding,
},
lookup,
);
}
const tinyPngBase64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";

View File

@@ -97,7 +97,7 @@ describe("Codex app-server dynamic tool schema boundary contract", () => {
vi.restoreAllMocks();
});
it("passes prepared executable dynamic tool schemas through thread start unchanged", async () => {
it("passes prepared executable dynamic tool schemas through legacy thread start specs", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const parameterFreeTool = createParameterFreeTool("message");
@@ -128,7 +128,13 @@ describe("Codex app-server dynamic tool schema boundary contract", () => {
throw new Error(`expected thread/start request, got ${method}`);
}
const startPayload = payload as CodexThreadStartParams | undefined;
expect(startPayload?.dynamicTools).toStrictEqual([dynamicTool]);
expect(startPayload?.dynamicTools).toStrictEqual([
{
name: dynamicTool.name,
description: dynamicTool.description,
inputSchema: dynamicTool.inputSchema,
},
]);
expect(startPayload?.cwd).toBe(workspaceDir);
expect(startPayload?.model).toBe("gpt-5.4");
expect(startPayload?.modelProvider).toBeUndefined();

View File

@@ -60,6 +60,8 @@ describe("codex app-server session binding", () => {
cwd: tempDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
networkProxyProfileName: "openclaw-network",
networkProxyConfigFingerprint: "network-proxy-v1",
dynamicToolsFingerprint: "tools-v1",
webSearchThreadConfigFingerprint: "web-search-v1",
userMcpServersFingerprint: "user-mcp-v1",
@@ -74,6 +76,8 @@ describe("codex app-server session binding", () => {
expect(binding?.cwd).toBe(tempDir);
expect(binding?.model).toBe("gpt-5.4-codex");
expect(binding?.modelProvider).toBe("openai");
expect(binding?.networkProxyProfileName).toBe("openclaw-network");
expect(binding?.networkProxyConfigFingerprint).toBe("network-proxy-v1");
expect(binding?.dynamicToolsFingerprint).toBe("tools-v1");
expect(binding?.webSearchThreadConfigFingerprint).toBe("web-search-v1");
expect(binding?.userMcpServersFingerprint).toBe("user-mcp-v1");

View File

@@ -66,6 +66,8 @@ export type CodexAppServerThreadBinding = {
approvalPolicy?: CodexAppServerApprovalPolicy;
sandbox?: CodexAppServerSandboxMode;
serviceTier?: CodexServiceTier;
networkProxyProfileName?: string;
networkProxyConfigFingerprint?: string;
dynamicToolsFingerprint?: string;
dynamicToolsContainDeferred?: boolean;
webSearchThreadConfigFingerprint?: string;
@@ -181,6 +183,14 @@ export async function readCodexAppServerBinding(
approvalPolicy: readApprovalPolicy(parsed.approvalPolicy),
sandbox: readSandboxMode(parsed.sandbox),
serviceTier: readServiceTier(parsed.serviceTier),
networkProxyProfileName:
typeof parsed.networkProxyProfileName === "string"
? parsed.networkProxyProfileName
: undefined,
networkProxyConfigFingerprint:
typeof parsed.networkProxyConfigFingerprint === "string"
? parsed.networkProxyConfigFingerprint
: undefined,
dynamicToolsFingerprint:
typeof parsed.dynamicToolsFingerprint === "string"
? parsed.dynamicToolsFingerprint
@@ -256,6 +266,8 @@ export async function writeCodexAppServerBinding(
approvalPolicy: binding.approvalPolicy,
sandbox: binding.sandbox,
serviceTier: binding.serviceTier,
networkProxyProfileName: binding.networkProxyProfileName,
networkProxyConfigFingerprint: binding.networkProxyConfigFingerprint,
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,
dynamicToolsContainDeferred: binding.dynamicToolsContainDeferred,
webSearchThreadConfigFingerprint: binding.webSearchThreadConfigFingerprint,

View File

@@ -1151,6 +1151,53 @@ describe("runCodexAppServerSideQuestion", () => {
expect(config?.["features.code_mode_only"]).toBe(true);
});
it("applies network-proxy config to side-thread forks", async () => {
const client = createFakeClient();
getSharedCodexAppServerClientMock.mockResolvedValue(client);
await expect(
runCodexAppServerSideQuestion(sideParams(), {
pluginConfig: {
appServer: {
networkProxy: {
enabled: true,
profileName: "side-proxy",
domains: { "api.openai.com": "allow" },
unixSockets: { "/tmp/proxy.sock": "allow" },
allowUpstreamProxy: true,
proxyUrl: "http://127.0.0.1:3128",
},
},
},
}),
).resolves.toEqual({ text: "Side answer." });
const forkParams = mockCall(client.request)[1] as Record<string, unknown> | undefined;
const config = forkParams?.config as Record<string, unknown> | undefined;
expect(forkParams).not.toHaveProperty("sandbox");
expect(config).toMatchObject({
"features.network_proxy.enabled": true,
default_permissions: "side-proxy",
permissions: {
"side-proxy": {
filesystem: {
":minimal": "read",
":project_roots": { ".": "write" },
},
network: {
enabled: true,
domains: { "api.openai.com": "allow" },
unix_sockets: { "/tmp/proxy.sock": "allow" },
allow_upstream_proxy: true,
proxy_url: "http://127.0.0.1:3128",
},
},
},
});
expect(config?.["features.code_mode"]).toBe(true);
expect(config?.["features.code_mode_only"]).toBe(false);
});
it("keeps Codex code-mode-only while disabling Guardian for provider-qualified local models", async () => {
const client = createFakeClient();
getSharedCodexAppServerClientMock.mockResolvedValue(client);

View File

@@ -322,12 +322,16 @@ export async function runCodexAppServerSideQuestion(
threadId: childThreadId,
turnId,
nativeHookRelay,
execPolicy,
execReviewerAgentId: sessionAgentId,
internalExecAutoReview: modelScopedAppServer.approvalsReviewer === "user",
autoApprove: shouldAutoApproveCodexAppServerApprovals({ approvalPolicy, sandbox }),
signal: runAbortController.signal,
});
execPolicy,
execReviewerAgentId: sessionAgentId,
internalExecAutoReview: modelScopedAppServer.approvalsReviewer === "user",
autoApprove: shouldAutoApproveCodexAppServerApprovals({
approvalPolicy,
networkProxy: modelScopedAppServer.networkProxy,
sandbox,
}),
signal: runAbortController.signal,
});
}
if (request.method !== "item/tool/call") {
return undefined;
@@ -415,8 +419,12 @@ export async function runCodexAppServerSideQuestion(
nativeCodeModeEnabled: nativeToolSurfaceEnabled,
nativeCodeModeOnlyEnabled: appServer.codeModeOnly,
});
const threadConfig =
mergeCodexThreadConfigs(nativeHookRelayConfig, runtimeThreadConfig) ?? runtimeThreadConfig;
const threadConfig =
mergeCodexThreadConfigs(
nativeHookRelayConfig,
runtimeThreadConfig,
modelScopedAppServer.networkProxy?.configPatch,
) ?? runtimeThreadConfig;
const forkResponse = assertCodexThreadForkResponse(
await forkCodexSideThread(
client,
@@ -428,7 +436,7 @@ export async function runCodexAppServerSideQuestion(
cwd,
approvalPolicy,
approvalsReviewer: modelScopedAppServer.approvalsReviewer,
sandbox,
...(modelScopedAppServer.networkProxy ? {} : { sandbox }),
...(serviceTier ? { serviceTier } : {}),
config: threadConfig,
developerInstructions: SIDE_DEVELOPER_INSTRUCTIONS,

View File

@@ -12,6 +12,7 @@ import {
readCodexAppServerBinding,
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
} from "./session-binding.js";
import { fingerprintCodexAppServerNetworkProxyConfigPatch } from "./config.js";
import { startOrResumeThread } from "./thread-lifecycle.js";
function createThreadLifecycleAppServerOptions(): Parameters<
@@ -33,6 +34,38 @@ function createThreadLifecycleAppServerOptions(): Parameters<
};
}
function createNetworkProxyThreadLifecycleAppServerOptions() {
const configPatch = {
"features.network_proxy.enabled": true,
default_permissions: "openclaw-network",
permissions: {
"openclaw-network": {
filesystem: {
":minimal": "read",
":project_roots": {
".": "write",
},
},
network: {
enabled: true,
domains: {
"api.openai.com": "allow",
},
proxy_url: "http://127.0.0.1:3128",
},
},
},
};
return {
...createThreadLifecycleAppServerOptions(),
networkProxy: {
profileName: "openclaw-network",
configFingerprint: fingerprintCodexAppServerNetworkProxyConfigPatch(configPatch),
configPatch,
},
};
}
function createParams(sessionFile: string, workspaceDir: string) {
const params = createRunAttemptParams(sessionFile, workspaceDir);
params.disableTools = false;
@@ -290,6 +323,47 @@ describe("Codex app-server thread lifecycle bindings", () => {
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
});
it("sends legacy flat dynamic tools on thread start", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-flat-tools");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [
createMessageDynamicTool("Send a message."),
createDeferredNamedDynamicTool("web_search"),
],
appServer,
});
const startParams = request.mock.calls.find(([method]) => method === "thread/start")?.[1] as
| { dynamicTools?: unknown[] }
| undefined;
expect(startParams?.dynamicTools).toEqual([
expect.objectContaining({
name: "message",
description: "Send a message.",
}),
expect.objectContaining({
name: "web_search",
namespace: "openclaw",
deferLoading: true,
}),
]);
expect(startParams?.dynamicTools?.[0]).not.toHaveProperty("type");
expect(startParams?.dynamicTools?.[1]).not.toHaveProperty("type");
});
it("keeps the bound local provider when recoverable resume failure starts a fresh thread", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
@@ -1406,6 +1480,42 @@ describe("Codex app-server thread lifecycle bindings", () => {
expect(binding?.threadId).toBe("thread-existing");
});
it("starts a new thread when the network proxy config is not active on the binding", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
});
const appServer = createNetworkProxyThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-network-proxy");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer,
});
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
expect(requestCalls[0]?.[1]).not.toHaveProperty("sandbox");
expect(requestCalls[0]?.[1].config).toMatchObject(appServer.networkProxy.configPatch);
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.threadId).toBe("thread-network-proxy");
expect(binding?.networkProxyProfileName).toBe("openclaw-network");
expect(binding?.networkProxyConfigFingerprint).toBe(appServer.networkProxy.configFingerprint);
});
it("passes native hook relay config on thread start and resume", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -5,6 +5,7 @@ import path from "node:path";
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js";
import { fingerprintCodexAppServerNetworkProxyConfigPatch } from "./config.js";
import { createCodexTestModel } from "./test-support.js";
import {
buildDeveloperInstructions,
@@ -83,6 +84,39 @@ function createAppServerOptions() {
approvalPolicy: "on-request",
approvalsReviewer: "user",
sandbox: "workspace-write",
};
}
function createNetworkProxyAppServerOptions() {
const configPatch = {
"features.network_proxy.enabled": true,
default_permissions: "mock-proxy",
permissions: {
"mock-proxy": {
filesystem: {
":minimal": "read",
":project_roots": {
".": "write",
},
},
network: {
enabled: true,
domains: {
"api.openai.com": "allow",
},
allow_upstream_proxy: true,
proxy_url: "http://127.0.0.1:3128",
},
},
},
} as const;
return {
...createAppServerOptions(),
networkProxy: {
profileName: "mock-proxy",
configFingerprint: fingerprintCodexAppServerNetworkProxyConfigPatch(configPatch),
configPatch,
},
} as const;
}
@@ -423,6 +457,55 @@ describe("Codex app-server native code mode config", () => {
});
});
it("selects the Codex network-proxy permissions profile in thread/start config", () => {
const request = buildThreadStartParams(createAttemptParams({ provider: "openai" }), {
cwd: "/repo",
dynamicTools: [],
appServer: createNetworkProxyAppServerOptions() as never,
developerInstructions: "test instructions",
});
expect(request).not.toHaveProperty("permissions");
expect(request).not.toHaveProperty("sandbox");
expect(request.config).toMatchObject({
"features.network_proxy.enabled": true,
default_permissions: "mock-proxy",
permissions: {
"mock-proxy": {
network: {
enabled: true,
allow_upstream_proxy: true,
proxy_url: "http://127.0.0.1:3128",
},
},
},
});
});
it("selects the Codex network-proxy permissions profile in thread/resume config", () => {
const request = buildThreadResumeParams(createAttemptParams({ provider: "openai" }), {
threadId: "thread-1",
appServer: createNetworkProxyAppServerOptions() as never,
developerInstructions: "test instructions",
});
expect(request).not.toHaveProperty("permissions");
expect(request).not.toHaveProperty("sandbox");
expect(request.config).toMatchObject({
"features.network_proxy.enabled": true,
default_permissions: "mock-proxy",
permissions: {
"mock-proxy": {
network: {
domains: {
"api.openai.com": "allow",
},
},
},
},
});
});
it("disables Codex tool-search features for nano models", () => {
const request = buildThreadStartParams(
createAttemptParams({ provider: "openai", modelId: "gpt-5.4-nano" }),
@@ -641,6 +724,35 @@ describe("Codex app-server turn input image sanitizing", () => {
});
});
it("uses Codex permissions for network-proxy turn/start requests", () => {
const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), {
threadId: "thread-1",
cwd: "/repo",
appServer: createNetworkProxyAppServerOptions() as never,
});
expect(request).not.toHaveProperty("permissions");
expect(request).not.toHaveProperty("sandboxPolicy");
});
it("keeps explicit sandbox policy overrides ahead of network-proxy turn permissions", () => {
const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), {
threadId: "thread-1",
cwd: "/repo",
appServer: createNetworkProxyAppServerOptions() as never,
sandboxPolicy: {
type: "externalSandbox",
networkAccess: "enabled",
},
});
expect(request).not.toHaveProperty("permissions");
expect(request.sandboxPolicy).toEqual({
type: "externalSandbox",
networkAccess: "enabled",
});
});
it("attaches turn-scoped developer instructions without changing thread config", () => {
const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), {
threadId: "thread-1",

View File

@@ -39,7 +39,9 @@ import {
import {
flattenCodexDynamicToolFunctions,
isJsonObject,
type CodexDynamicToolFunctionSpec,
type CodexDynamicToolSpec,
type CodexLegacyDynamicToolFunctionSpec,
type CodexSandboxPolicy,
type CodexThreadResumeParams,
type CodexThreadStartParams,
@@ -336,6 +338,7 @@ export async function startOrResumeThread(params: {
}),
);
const webSearchThreadConfigFingerprint = fingerprintJsonObject(webSearchPlan.threadConfig);
const networkProxyConfigFingerprint = params.appServer.networkProxy?.configFingerprint;
const contextEngineBinding = lifecycleTiming.measureSync("context-engine-binding", () =>
buildContextEngineBinding(params.params, params.contextEngineProjection),
);
@@ -393,6 +396,39 @@ export async function startOrResumeThread(params: {
binding.webSearchThreadConfigFingerprint !== webSearchThreadConfigFingerprint;
const persistentWebSearchRestriction =
params.webSearchAllowed === false && params.persistentWebSearchAllowed === false;
const transientNativeToolRestriction =
params.nativeCodeModeEnabled === false && !persistentWebSearchRestriction;
const transientWebSearchRestriction = isTransientWebSearchRestriction(params);
const explicitTransientWebSearchRestriction =
params.webSearchAllowed === false &&
params.persistentWebSearchAllowed !== false &&
transientWebSearchRestriction;
const unknownProviderWebSearchSupport = params.nativeProviderWebSearchSupport === "unknown";
if (
binding?.threadId &&
params.mcpServersFingerprintEvaluated === true &&
binding.mcpServersFingerprint !== params.mcpServersFingerprint
) {
if (
transientNativeToolRestriction ||
(webSearchBindingChanged &&
(explicitTransientWebSearchRestriction || unknownProviderWebSearchSupport))
) {
embeddedAgentLog.debug(
"codex app-server MCP config changed during transient restricted turn; starting transient thread",
{
threadId: binding.threadId,
},
);
preserveExistingBinding = true;
} else {
embeddedAgentLog.debug("codex app-server MCP config changed; starting a new thread", {
threadId: binding.threadId,
});
await clearCodexAppServerBinding(params.params.sessionFile);
}
binding = undefined;
}
// A transient native-tool restriction must not replace a legacy binding just
// because that binding predates search fingerprints. Explicit persistent
// search denial still rotates first so the restricted thread can persist.
@@ -405,7 +441,6 @@ export async function startOrResumeThread(params: {
webSearchBindingChanged &&
!deferLegacyWebSearchRotationToTransientNativeSurface
) {
const transientWebSearchRestriction = isTransientWebSearchRestriction(params);
if (transientWebSearchRestriction) {
embeddedAgentLog.debug(
"codex app-server web search restricted for turn; starting transient thread",
@@ -424,11 +459,7 @@ export async function startOrResumeThread(params: {
}
binding = undefined;
}
if (
binding?.threadId &&
params.nativeCodeModeEnabled === false &&
!persistentWebSearchRestriction
) {
if (binding?.threadId && transientNativeToolRestriction) {
embeddedAgentLog.debug(
"codex app-server native tool surface disabled for turn; starting transient thread",
{
@@ -484,10 +515,10 @@ export async function startOrResumeThread(params: {
}
if (
binding?.threadId &&
params.mcpServersFingerprintEvaluated === true &&
binding.mcpServersFingerprint !== params.mcpServersFingerprint
(binding.networkProxyConfigFingerprint !== networkProxyConfigFingerprint ||
binding.networkProxyProfileName !== params.appServer.networkProxy?.profileName)
) {
embeddedAgentLog.debug("codex app-server MCP config changed; starting a new thread", {
embeddedAgentLog.debug("codex app-server network proxy config changed; starting a new thread", {
threadId: binding.threadId,
});
await clearCodexAppServerBinding(params.params.sessionFile);
@@ -529,17 +560,6 @@ export async function startOrResumeThread(params: {
binding = undefined;
}
}
if (
binding?.threadId &&
params.mcpServersFingerprintEvaluated === true &&
binding.mcpServersFingerprint !== params.mcpServersFingerprint
) {
embeddedAgentLog.debug("codex app-server MCP config changed; starting a new thread", {
threadId: binding.threadId,
});
await clearCodexAppServerBinding(params.params.sessionFile);
binding = undefined;
}
if (binding?.threadId) {
if (
binding.dynamicToolsFingerprint &&
@@ -588,11 +608,12 @@ export async function startOrResumeThread(params: {
await clearCodexAppServerBinding(params.params.sessionFile);
}
} else {
const resumeBinding = binding;
try {
const authProfileId = params.params.authProfileId ?? binding.authProfileId;
const authProfileId = params.params.authProfileId ?? resumeBinding.authProfileId;
const finalConfigPatch = params.buildFinalConfigPatch?.({
action: "resume",
binding,
binding: resumeBinding,
}) ?? {
configPatch: params.finalConfigPatch,
nativeHookRelayGeneration: params.nativeHookRelayGeneration,
@@ -604,7 +625,7 @@ export async function startOrResumeThread(params: {
);
const resumeParams = lifecycleTiming.measureSync("thread-resume-params", () =>
buildThreadResumeParams(params.params, {
threadId: binding.threadId,
threadId: resumeBinding.threadId,
authProfileId,
model: startModelSelection.model,
modelProvider: startModelProvider,
@@ -632,7 +653,7 @@ export async function startOrResumeThread(params: {
const nextMcpServersFingerprint =
params.mcpServersFingerprintEvaluated === true
? params.mcpServersFingerprint
: binding.mcpServersFingerprint;
: resumeBinding.mcpServersFingerprint;
await lifecycleTiming.measure("thread-resume-write-binding", () =>
writeCodexAppServerBinding(
params.params.sessionFile,
@@ -647,14 +668,17 @@ export async function startOrResumeThread(params: {
webSearchThreadConfigFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
networkProxyProfileName: params.appServer.networkProxy?.profileName,
networkProxyConfigFingerprint,
nativeHookRelayGeneration:
finalConfigPatch.nativeHookRelayGeneration ?? binding.nativeHookRelayGeneration,
pluginAppsFingerprint: binding.pluginAppsFingerprint,
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
finalConfigPatch.nativeHookRelayGeneration ??
resumeBinding.nativeHookRelayGeneration,
pluginAppsFingerprint: resumeBinding.pluginAppsFingerprint,
pluginAppsInputFingerprint: resumeBinding.pluginAppsInputFingerprint,
pluginAppPolicyContext: resumeBinding.pluginAppPolicyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
createdAt: binding.createdAt,
createdAt: resumeBinding.createdAt,
},
{
authProfileStore: params.params.authProfileStore,
@@ -684,7 +708,7 @@ export async function startOrResumeThread(params: {
});
const activeTurnIds = readActiveCodexTurnIds(response.thread);
return {
...binding,
...resumeBinding,
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: boundAuthProfileId,
@@ -695,11 +719,13 @@ export async function startOrResumeThread(params: {
webSearchThreadConfigFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
networkProxyProfileName: params.appServer.networkProxy?.profileName,
networkProxyConfigFingerprint,
nativeHookRelayGeneration:
finalConfigPatch.nativeHookRelayGeneration ?? binding.nativeHookRelayGeneration,
pluginAppsFingerprint: binding.pluginAppsFingerprint,
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
finalConfigPatch.nativeHookRelayGeneration ?? resumeBinding.nativeHookRelayGeneration,
pluginAppsFingerprint: resumeBinding.pluginAppsFingerprint,
pluginAppsInputFingerprint: resumeBinding.pluginAppsInputFingerprint,
pluginAppPolicyContext: resumeBinding.pluginAppPolicyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
lifecycle: {
@@ -795,6 +821,8 @@ export async function startOrResumeThread(params: {
webSearchThreadConfigFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
networkProxyProfileName: params.appServer.networkProxy?.profileName,
networkProxyConfigFingerprint,
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
@@ -843,6 +871,8 @@ export async function startOrResumeThread(params: {
dynamicToolsContainDeferred,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
networkProxyProfileName: params.appServer.networkProxy?.profileName,
networkProxyConfigFingerprint,
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
@@ -1052,7 +1082,7 @@ export function buildThreadStartParams(
cwd: options.cwd,
approvalPolicy: options.appServer.approvalPolicy,
approvalsReviewer: options.appServer.approvalsReviewer,
sandbox: options.appServer.sandbox,
...codexThreadSandboxOrPermissions(options.appServer),
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
serviceName: "OpenClaw",
@@ -1061,17 +1091,39 @@ export function buildThreadStartParams(
nativeProviderWebSearchSupport: options.nativeProviderWebSearchSupport,
nativeCodeModeOnlyEnabled: options.nativeCodeModeOnlyEnabled,
webSearchAllowed: options.webSearchAllowed,
appServer: options.appServer,
}),
...resolveCodexThreadEnvironmentSelection(options),
developerInstructions:
options.developerInstructions ??
buildDeveloperInstructions(params, { dynamicTools: options.dynamicTools }),
dynamicTools: options.dynamicTools,
dynamicTools: toCodexThreadStartDynamicTools(options.dynamicTools),
experimentalRawEvents: true,
persistExtendedHistory: true,
};
}
function toCodexThreadStartDynamicTools(
dynamicTools: readonly CodexDynamicToolSpec[],
): CodexLegacyDynamicToolFunctionSpec[] {
// Managed stable Codex still accepts the legacy flat start payload. Keep
// OpenClaw namespaces internally, but omit `type` on the wire so Codex does
// not reject a mixed canonical/legacy shape before thread creation.
return dynamicTools.flatMap((tool) =>
tool.type === "namespace"
? tool.tools.map((child) => toCodexLegacyDynamicTool(child, tool.name))
: [toCodexLegacyDynamicTool(tool)],
);
}
function toCodexLegacyDynamicTool(
tool: CodexDynamicToolFunctionSpec,
namespace?: string,
): CodexLegacyDynamicToolFunctionSpec {
const { type: _type, ...legacyTool } = tool;
return namespace ? { ...legacyTool, namespace } : legacyTool;
}
export function buildThreadResumeParams(
params: EmbeddedRunAttemptParams,
options: {
@@ -1110,7 +1162,7 @@ export function buildThreadResumeParams(
...(modelSelection.modelProvider ? { modelProvider: modelSelection.modelProvider } : {}),
approvalPolicy: options.appServer.approvalPolicy,
approvalsReviewer: options.appServer.approvalsReviewer,
sandbox: options.appServer.sandbox,
...codexThreadSandboxOrPermissions(options.appServer),
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
config: buildCodexRuntimeThreadConfigForRun(params, options.config, {
@@ -1118,6 +1170,7 @@ export function buildThreadResumeParams(
nativeProviderWebSearchSupport: options.nativeProviderWebSearchSupport,
nativeCodeModeOnlyEnabled: options.nativeCodeModeOnlyEnabled,
webSearchAllowed: options.webSearchAllowed,
appServer: options.appServer,
}),
developerInstructions:
options.developerInstructions ??
@@ -1271,6 +1324,7 @@ function buildCodexRuntimeThreadConfigForRun(
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
nativeCodeModeOnlyEnabled?: boolean;
webSearchAllowed?: boolean;
appServer?: Pick<CodexAppServerRuntimeOptions, "networkProxy">;
} = {},
): JsonObject {
const webSearchConfig = resolveCodexWebSearchPlan({
@@ -1287,6 +1341,7 @@ function buildCodexRuntimeThreadConfigForRun(
const runtimeConfig =
mergeCodexThreadConfigs(
baseConfig,
options.appServer?.networkProxy?.configPatch,
shouldDisableCodexToolSearchForModel(params.modelId)
? CODEX_TOOL_SEARCH_UNSUPPORTED_THREAD_CONFIG
: undefined,
@@ -1327,14 +1382,20 @@ export function buildTurnStartParams(
agentDir: params.agentDir,
config: params.config,
});
const useThreadPermissionProfile = options.appServer.networkProxy && !options.sandboxPolicy;
return {
threadId: options.threadId,
input: buildUserInput(params, options.promptText),
cwd: options.cwd,
approvalPolicy: options.appServer.approvalPolicy,
approvalsReviewer: options.appServer.approvalsReviewer,
sandboxPolicy:
options.sandboxPolicy ?? codexSandboxPolicyForTurn(options.appServer.sandbox, options.cwd),
...(useThreadPermissionProfile
? {}
: {
sandboxPolicy:
options.sandboxPolicy ??
codexSandboxPolicyForTurn(options.appServer.sandbox, options.cwd),
}),
model: modelSelection.model,
personality: CODEX_NATIVE_PERSONALITY_NONE,
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
@@ -1350,6 +1411,15 @@ export function buildTurnStartParams(
};
}
function codexThreadSandboxOrPermissions(
appServer: Pick<CodexAppServerRuntimeOptions, "networkProxy" | "sandbox">,
): Pick<CodexThreadStartParams, "sandbox"> {
if (appServer.networkProxy) {
return {};
}
return { sandbox: appServer.sandbox };
}
function resolveCodexThreadEnvironmentSelection(options: {
nativeCodeModeEnabled?: boolean;
environmentSelection?: CodexTurnEnvironmentParams[];

View File

@@ -300,6 +300,7 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
});
const request = vi.fn(async (method: string, _params: unknown) => {
if (method === "thread/start") {
@@ -338,6 +339,87 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
expect(preservedBinding?.threadId).toBe("thread-native");
});
it("preserves MCP-mismatched bindings across transient native-tool-disabled turns", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-native",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
mcpServersFingerprint: "mcp-v1",
});
const request = vi.fn(async (method: string, _params: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-restricted");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer: createAppServerOptions(),
mcpServersFingerprint: undefined,
mcpServersFingerprintEvaluated: true,
nativeCodeModeEnabled: false,
userMcpServersEnabled: false,
});
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
const startParams = request.mock.calls[0]?.[1] as {
config?: {
"features.code_mode"?: boolean;
mcp_servers?: Record<string, unknown>;
};
};
expect(startParams?.config?.["features.code_mode"]).toBe(false);
expect(startParams?.config?.mcp_servers).toBeUndefined();
const preservedBinding = await readCodexAppServerBinding(sessionFile);
expect(preservedBinding?.threadId).toBe("thread-native");
expect(preservedBinding?.mcpServersFingerprint).toBe("mcp-v1");
});
it("preserves MCP-mismatched bindings when provider web-search support is unknown", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-native",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
webSearchThreadConfigFingerprint: "web-search-v1",
mcpServersFingerprint: "mcp-v1",
});
const request = vi.fn(async (method: string, _params: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-fallback");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer: createAppServerOptions(),
mcpServersFingerprint: undefined,
mcpServersFingerprintEvaluated: true,
nativeProviderWebSearchSupport: "unknown",
userMcpServersEnabled: false,
});
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
const preservedBinding = await readCodexAppServerBinding(sessionFile);
expect(preservedBinding?.threadId).toBe("thread-native");
expect(preservedBinding?.mcpServersFingerprint).toBe("mcp-v1");
});
it("starts a new thread without user MCP servers when runtime policy disables them", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -74,9 +74,62 @@ import {
handleCodexConversationInboundClaim,
startCodexConversationThread,
} from "./conversation-binding.js";
import { resolveCodexAppServerRuntimeOptions } from "./app-server/config.js";
let tempDir: string;
const NETWORK_PROXY_PLUGIN_CONFIG = {
appServer: {
networkProxy: {
enabled: true,
domains: { "api.openai.com": "allow" },
allowUpstreamProxy: true,
proxyUrl: "http://127.0.0.1:3128",
},
},
};
const NETWORK_PROXY_RUNTIME = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
pluginConfig: NETWORK_PROXY_PLUGIN_CONFIG,
});
const NETWORK_PROXY_PROFILE_NAME = NETWORK_PROXY_RUNTIME.networkProxy?.profileName ?? "missing";
const NETWORK_PROXY_CONFIG_PATCH = NETWORK_PROXY_RUNTIME.networkProxy?.configPatch ?? {};
const NETWORK_PROXY_CONFIG_FINGERPRINT =
NETWORK_PROXY_RUNTIME.networkProxy?.configFingerprint ?? "missing";
function conversationThreadStartResult(threadId: string) {
return {
approvalPolicy: "never",
approvalsReviewer: "user",
cwd: tempDir,
model: "gpt-5.4-mini",
modelProvider: "openai",
sandbox: { type: "workspaceWrite", networkAccess: false },
serviceTier: null,
activePermissionProfile: null,
thread: {
id: threadId,
sessionId: "session-1",
preview: "",
ephemeral: false,
modelProvider: "openai",
createdAt: 1,
updatedAt: 1,
status: { type: "idle" },
path: null,
cwd: tempDir,
cliVersion: "0.125.0",
source: "unknown",
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
turns: [],
},
};
}
function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0): unknown {
const call = mock.mock.calls[callIndex];
if (!call) {
@@ -180,6 +233,70 @@ describe("codex conversation binding", () => {
);
});
it("selects Codex network-proxy permissions through app-server bind thread config", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
requests.push({ method, params: requestParams });
return {
thread: { id: "thread-new", sessionId: "session-1", cwd: tempDir },
model: "gpt-5.4-mini",
};
}),
});
await startCodexConversationThread({
pluginConfig: NETWORK_PROXY_PLUGIN_CONFIG,
sessionFile,
workspaceDir: tempDir,
model: "gpt-5.4-mini",
modelProvider: "openai",
});
expect(requests).toHaveLength(1);
expect(requests[0]?.method).toBe("thread/start");
expect(requests[0]?.params).not.toHaveProperty("permissions");
expect(requests[0]?.params).not.toHaveProperty("sandbox");
expect(requests[0]?.params.config).toMatchObject(NETWORK_PROXY_CONFIG_PATCH);
});
it("starts a fresh proxy-backed thread when binding an explicit app-server thread id", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
requests.push({ method, params: requestParams });
if (method === "thread/resume") {
throw new Error("thread/resume should not receive network proxy config");
}
return conversationThreadStartResult("thread-new");
}),
});
await startCodexConversationThread({
pluginConfig: NETWORK_PROXY_PLUGIN_CONFIG,
sessionFile,
threadId: "thread-old",
workspaceDir: tempDir,
model: "gpt-5.4-mini",
modelProvider: "openai",
});
expect(requests.map((request) => request.method)).toEqual(["thread/start"]);
expect(requests[0]?.params).not.toHaveProperty("threadId");
expect(requests[0]?.params).not.toHaveProperty("sandbox");
expect(requests[0]?.params.config).toMatchObject(NETWORK_PROXY_CONFIG_PATCH);
const bindingAfterStart = JSON.parse(
await fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8"),
) as Record<string, unknown>;
expect(bindingAfterStart.threadId).toBe("thread-new");
expect(bindingAfterStart.networkProxyProfileName).toBe(NETWORK_PROXY_PROFILE_NAME);
expect(bindingAfterStart.networkProxyConfigFingerprint).toBe(
NETWORK_PROXY_CONFIG_FINGERPRINT,
);
});
it("preserves Codex auth and omits the public OpenAI provider for native bind threads", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({
@@ -937,7 +1054,7 @@ describe("codex conversation binding", () => {
await fs.writeFile(
`${sessionFile}.codex-app-server.json`,
JSON.stringify({
schemaVersion: 1,
schemaVersion: 2,
threadId: "thread-1",
cwd: tempDir,
approvalPolicy: "never",
@@ -1203,6 +1320,196 @@ describe("codex conversation binding", () => {
});
});
it("keeps network-proxy bound app-server turns on their thread permissions profile", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
await fs.writeFile(
`${sessionFile}.codex-app-server.json`,
JSON.stringify({
schemaVersion: 2,
threadId: "thread-1",
cwd: tempDir,
networkProxyProfileName: NETWORK_PROXY_PROFILE_NAME,
networkProxyConfigFingerprint: NETWORK_PROXY_CONFIG_FINGERPRINT,
}),
);
let notificationHandler: ((notification: unknown) => void) | undefined;
const turnStartParams: Record<string, unknown>[] = [];
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
if (method === "turn/start") {
turnStartParams.push(requestParams);
setImmediate(() =>
notificationHandler?.({
method: "turn/completed",
params: {
threadId: "thread-1",
turn: {
id: "turn-1",
status: "completed",
items: [{ type: "agentMessage", id: "item-1", text: "done" }],
},
},
}),
);
return { turn: { id: "turn-1" } };
}
throw new Error(`unexpected method: ${method}`);
}),
addNotificationHandler: vi.fn((handler: (notification: unknown) => void) => {
notificationHandler = handler;
return () => undefined;
}),
addRequestHandler: vi.fn(() => () => undefined),
});
const result = await handleCodexConversationInboundClaim(
{
content: "hello",
channel: "telegram",
isGroup: false,
commandAuthorized: true,
},
{
channelId: "telegram",
pluginBinding: {
bindingId: "binding-1",
pluginId: "codex",
pluginRoot: tempDir,
channel: "telegram",
accountId: "default",
conversationId: "5185575566",
boundAt: Date.now(),
data: {
kind: "codex-app-server-session",
version: 1,
sessionFile,
workspaceDir: tempDir,
},
},
},
{
pluginConfig: {
appServer: {
networkProxy: {
enabled: true,
domains: { "api.openai.com": "allow" },
allowUpstreamProxy: true,
proxyUrl: "http://127.0.0.1:3128",
},
},
},
timeoutMs: 50,
},
);
expect(result).toEqual({ handled: true, reply: { text: "done" } });
expect(turnStartParams[0]).not.toHaveProperty("permissions");
expect(turnStartParams[0]).not.toHaveProperty("sandboxPolicy");
});
it("refreshes stale network-proxy bound app-server threads before the turn", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
await fs.writeFile(
`${sessionFile}.codex-app-server.json`,
JSON.stringify({
schemaVersion: 2,
threadId: "thread-old",
cwd: tempDir,
networkProxyProfileName: "openclaw-network-stale",
networkProxyConfigFingerprint: "stale-proxy-config",
}),
);
let notificationHandler: ((notification: unknown) => void) | undefined;
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
requests.push({ method, params: requestParams });
if (method === "thread/start") {
return conversationThreadStartResult("thread-new");
}
if (method === "turn/start") {
setImmediate(() =>
notificationHandler?.({
method: "turn/completed",
params: {
threadId: "thread-new",
turn: {
id: "turn-1",
status: "completed",
items: [{ type: "agentMessage", id: "item-1", text: "done" }],
},
},
}),
);
return { turn: { id: "turn-1" } };
}
throw new Error(`unexpected method: ${method}`);
}),
addNotificationHandler: vi.fn((handler: (notification: unknown) => void) => {
notificationHandler = handler;
return () => undefined;
}),
addRequestHandler: vi.fn(() => () => undefined),
});
const result = await handleCodexConversationInboundClaim(
{
content: "hello",
channel: "telegram",
isGroup: false,
commandAuthorized: true,
},
{
channelId: "telegram",
pluginBinding: {
bindingId: "binding-1",
pluginId: "codex",
pluginRoot: tempDir,
channel: "telegram",
accountId: "default",
conversationId: "5185575566",
boundAt: Date.now(),
data: {
kind: "codex-app-server-session",
version: 1,
sessionFile,
workspaceDir: tempDir,
},
},
},
{
pluginConfig: {
appServer: {
serviceTier: "priority",
networkProxy: {
enabled: true,
domains: { "api.openai.com": "allow" },
allowUpstreamProxy: true,
proxyUrl: "http://127.0.0.1:3128",
},
},
},
timeoutMs: 50,
},
);
expect(result).toEqual({ handled: true, reply: { text: "done" } });
expect(requests.map((request) => request.method)).toEqual(["thread/start", "turn/start"]);
expect(requests[0]?.params.config).toMatchObject(NETWORK_PROXY_CONFIG_PATCH);
expect(requests[0]?.params).not.toHaveProperty("sandbox");
expect(requests[0]?.params.serviceTier).toBe("priority");
expect(requests[1]?.params.threadId).toBe("thread-new");
expect(requests[1]?.params).not.toHaveProperty("sandboxPolicy");
const bindingAfterRefresh = JSON.parse(
await fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8"),
) as Record<string, unknown>;
expect(bindingAfterRefresh.threadId).toBe("thread-new");
expect(bindingAfterRefresh.networkProxyProfileName).toBe(NETWORK_PROXY_PROFILE_NAME);
expect(bindingAfterRefresh.networkProxyConfigFingerprint).toBe(
NETWORK_PROXY_CONFIG_FINGERPRINT,
);
});
it("blocks Guardian-mode bound turns with stale no-approval policy on custom model providers", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
await fs.writeFile(

View File

@@ -33,6 +33,7 @@ import type {
CodexThreadResumeResponse,
CodexThreadStartResponse,
CodexTurnStartResponse,
JsonObject,
JsonValue,
} from "./app-server/protocol.js";
import {
@@ -51,6 +52,7 @@ import {
getLeasedSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient,
} from "./app-server/shared-client.js";
import { assertCodexThreadStartResponse } from "./app-server/protocol-validators.js";
import {
CODEX_NATIVE_PERSONALITY_NONE,
resolveCodexAppServerRequestModelSelection,
@@ -156,6 +158,8 @@ async function resolveConversationAppServerRuntime(params: {
}
const CODEX_CONVERSATION_GLOBAL_STATE = Symbol.for("openclaw.codex.conversationBinding");
const CODEX_CONVERSATION_THREAD_DEVELOPER_INSTRUCTIONS =
"This Codex thread is bound to an OpenClaw conversation. Answer normally; OpenClaw will deliver your final response back to the conversation.";
function getGlobalState(): CodexConversationGlobalState {
const globalState = globalThis as typeof globalThis & {
@@ -415,22 +419,60 @@ function buildThreadRequestRuntimeOptions(
): {
approvalPolicy: ConversationAppServerRuntime["runtime"]["approvalPolicy"];
approvalsReviewer: ConversationAppServerRuntime["runtime"]["approvalsReviewer"];
sandbox: ConversationAppServerRuntime["runtime"]["sandbox"];
sandbox?: ConversationAppServerRuntime["runtime"]["sandbox"];
serviceTier?: CodexServiceTier;
config?: JsonObject;
} {
const serviceTier = params.serviceTier ?? resolved.runtime.serviceTier;
const sandbox = resolved.execPolicy?.touched
? resolved.runtime.sandbox
: (params.sandbox ?? resolved.runtime.sandbox);
return {
approvalPolicy: resolved.execPolicy?.touched
? resolved.runtime.approvalPolicy
: (params.approvalPolicy ?? resolved.runtime.approvalPolicy),
approvalsReviewer: resolved.runtime.approvalsReviewer,
sandbox: resolved.execPolicy?.touched
? resolved.runtime.sandbox
: (params.sandbox ?? resolved.runtime.sandbox),
...codexConversationSandboxOrPermissions(resolved.runtime, sandbox),
...(serviceTier ? { serviceTier } : {}),
};
}
function codexConversationSandboxOrPermissions(
runtime: Pick<ConversationAppServerRuntime["runtime"], "networkProxy">,
sandbox: ConversationAppServerRuntime["runtime"]["sandbox"],
): {
sandbox?: ConversationAppServerRuntime["runtime"]["sandbox"];
config?: JsonObject;
} {
const networkProxy = runtime.networkProxy;
if (networkProxy) {
return {
config: networkProxy.configPatch,
};
}
return { sandbox };
}
async function requestNewConversationBindingThread(
params: CodexThreadBindingParams,
resolved: CodexThreadBindingRuntime,
): Promise<CodexThreadStartResponse> {
return await resolved.client.request(
"thread/start",
{
cwd: params.workspaceDir,
...(resolved.model ? { model: resolved.model } : {}),
...(resolved.modelProvider ? { modelProvider: resolved.modelProvider } : {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
...buildThreadRequestRuntimeOptions(params, resolved),
developerInstructions: CODEX_CONVERSATION_THREAD_DEVELOPER_INSTRUCTIONS,
experimentalRawEvents: true,
persistExtendedHistory: true,
},
{ timeoutMs: resolved.runtime.requestTimeoutMs },
);
}
async function writeThreadBindingFromResponse(
params: CodexThreadBindingParams,
resolved: CodexThreadBindingRuntime,
@@ -459,6 +501,8 @@ async function writeThreadBindingFromResponse(
? resolved.runtime.sandbox
: (params.sandbox ?? resolved.runtime.sandbox),
serviceTier: params.serviceTier ?? resolved.runtime.serviceTier,
networkProxyProfileName: resolved.runtime.networkProxy?.profileName,
networkProxyConfigFingerprint: resolved.runtime.networkProxy?.configFingerprint,
},
{
...resolved.agentLookup,
@@ -473,18 +517,23 @@ async function attachExistingThread(
): Promise<void> {
const resolved = await resolveThreadBindingRuntime(params);
try {
const response: CodexThreadResumeResponse = await resolved.client.request(
CODEX_CONTROL_METHODS.resumeThread,
{
threadId: params.threadId,
...(resolved.model ? { model: resolved.model } : {}),
...(resolved.modelProvider ? { modelProvider: resolved.modelProvider } : {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
...buildThreadRequestRuntimeOptions(params, resolved),
persistExtendedHistory: true,
},
{ timeoutMs: resolved.runtime.requestTimeoutMs },
);
// Codex applies network-proxy permission profiles at thread/start. Resuming
// an arbitrary existing thread cannot prove that profile is active.
const response: CodexThreadResumeResponse | CodexThreadStartResponse =
resolved.runtime.networkProxy
? await requestNewConversationBindingThread(params, resolved)
: await resolved.client.request(
CODEX_CONTROL_METHODS.resumeThread,
{
threadId: params.threadId,
...(resolved.model ? { model: resolved.model } : {}),
...(resolved.modelProvider ? { modelProvider: resolved.modelProvider } : {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
...buildThreadRequestRuntimeOptions(params, resolved),
persistExtendedHistory: true,
},
{ timeoutMs: resolved.runtime.requestTimeoutMs },
);
await writeThreadBindingFromResponse(params, resolved, response);
} finally {
releaseLeasedSharedCodexAppServerClient(resolved.client);
@@ -494,21 +543,7 @@ async function attachExistingThread(
async function createThread(params: CodexThreadBindingParams): Promise<void> {
const resolved = await resolveThreadBindingRuntime(params);
try {
const response: CodexThreadStartResponse = await resolved.client.request(
"thread/start",
{
cwd: params.workspaceDir,
...(resolved.model ? { model: resolved.model } : {}),
...(resolved.modelProvider ? { modelProvider: resolved.modelProvider } : {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
...buildThreadRequestRuntimeOptions(params, resolved),
developerInstructions:
"This Codex thread is bound to an OpenClaw conversation. Answer normally; OpenClaw will deliver your final response back to the conversation.",
experimentalRawEvents: true,
persistExtendedHistory: true,
},
{ timeoutMs: resolved.runtime.requestTimeoutMs },
);
const response = await requestNewConversationBindingThread(params, resolved);
await writeThreadBindingFromResponse(params, resolved, response);
} finally {
releaseLeasedSharedCodexAppServerClient(resolved.client);
@@ -526,10 +561,10 @@ async function runBoundTurn(params: {
}): Promise<BoundTurnResult> {
const agentLookup = buildAgentLookup({ agentDir: params.data.agentDir, config: params.config });
const binding = await readCodexAppServerBinding(params.data.sessionFile, agentLookup);
const threadId = binding?.threadId;
if (!threadId) {
if (!binding?.threadId) {
throw new Error("bound Codex conversation has no thread binding");
}
let threadId = binding.threadId;
const workspaceDir = binding.cwd || params.data.workspaceDir;
const reviewerModelProvider = resolveModelBackedReviewerPolicyProvider({
authProfileId: binding.authProfileId,
@@ -568,6 +603,16 @@ async function runBoundTurn(params: {
const sandbox = useModelScopedPolicy
? modelScopedRuntime.sandbox
: (binding.sandbox ?? modelScopedRuntime.sandbox);
const permissionProfile = modelScopedRuntime.networkProxy?.profileName;
const networkProxyConfigFingerprint = modelScopedRuntime.networkProxy?.configFingerprint;
const networkProxyBindingChanged =
binding.networkProxyProfileName !== permissionProfile ||
binding.networkProxyConfigFingerprint !== networkProxyConfigFingerprint;
const serviceTier = binding.serviceTier ?? runtime.serviceTier;
let useStickyNetworkProfile =
permissionProfile !== undefined &&
binding.networkProxyProfileName === permissionProfile &&
binding.networkProxyConfigFingerprint === networkProxyConfigFingerprint;
assertNativeConversationApprovalPolicySupported({
execPolicy,
approvalPolicy,
@@ -589,12 +634,59 @@ async function runBoundTurn(params: {
authProfileId: binding.authProfileId,
...agentLookup,
});
const collector = createCodexConversationTurnCollector(threadId);
const notificationCleanup = client.addNotificationHandler((notification) =>
collector.handleNotification(notification),
);
const requestCleanup = client.addRequestHandler(
async (request): Promise<JsonValue | undefined> => {
let notificationCleanup: () => void = () => undefined;
let requestCleanup: () => void = () => undefined;
try {
if (networkProxyBindingChanged) {
const response = assertCodexThreadStartResponse(
await client.request(
"thread/start",
{
cwd: workspaceDir,
...(modelSelection?.model ? { model: modelSelection.model } : {}),
...(modelSelection?.modelProvider ? { modelProvider: modelSelection.modelProvider } : {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
approvalPolicy,
approvalsReviewer: modelScopedRuntime.approvalsReviewer,
...(modelScopedRuntime.networkProxy
? { config: modelScopedRuntime.networkProxy.configPatch }
: { sandbox }),
...(serviceTier ? { serviceTier } : {}),
developerInstructions: CODEX_CONVERSATION_THREAD_DEVELOPER_INSTRUCTIONS,
experimentalRawEvents: true,
persistExtendedHistory: true,
},
{ timeoutMs: runtime.requestTimeoutMs },
),
);
threadId = response.thread.id;
await writeCodexAppServerBinding(
params.data.sessionFile,
{
threadId,
cwd: response.thread.cwd ?? workspaceDir,
authProfileId: binding.authProfileId,
model: response.model ?? modelSelection?.model ?? binding.model,
modelProvider: normalizeCodexAppServerBindingModelProvider({
authProfileId: binding.authProfileId,
modelProvider: response.modelProvider ?? modelSelection?.modelProvider ?? binding.modelProvider,
...agentLookup,
}),
approvalPolicy: typeof approvalPolicy === "string" ? approvalPolicy : undefined,
sandbox,
serviceTier,
networkProxyProfileName: modelScopedRuntime.networkProxy?.profileName,
networkProxyConfigFingerprint: modelScopedRuntime.networkProxy?.configFingerprint,
},
agentLookup,
);
useStickyNetworkProfile = modelScopedRuntime.networkProxy !== undefined;
}
const collector = createCodexConversationTurnCollector(threadId);
notificationCleanup = client.addNotificationHandler((notification) =>
collector.handleNotification(notification),
);
requestCleanup = client.addRequestHandler(async (request): Promise<JsonValue | undefined> => {
if (request.method === "item/tool/call") {
return {
contentItems: [
@@ -627,9 +719,7 @@ async function runBoundTurn(params: {
};
}
return undefined;
},
);
try {
});
const response: CodexTurnStartResponse = await client.request(
"turn/start",
{
@@ -641,12 +731,12 @@ async function runBoundTurn(params: {
cwd: workspaceDir,
approvalPolicy,
approvalsReviewer: modelScopedRuntime.approvalsReviewer,
sandboxPolicy: codexSandboxPolicyForTurn(sandbox, workspaceDir),
...(useStickyNetworkProfile
? {}
: { sandboxPolicy: codexSandboxPolicyForTurn(sandbox, workspaceDir) }),
...(modelSelection?.model ? { model: modelSelection.model } : {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
...((binding.serviceTier ?? runtime.serviceTier)
? { serviceTier: binding.serviceTier ?? runtime.serviceTier }
: {}),
...(serviceTier ? { serviceTier } : {}),
},
{ timeoutMs: runtime.requestTimeoutMs },
);

View File

@@ -0,0 +1,121 @@
import { readFileSync } from "node:fs";
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import type { Context, Model } from "openclaw/plugin-sdk/llm";
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
import { buildOpenAICompletionsParams } from "openclaw/plugin-sdk/provider-transport-runtime";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
import { buildCohereProvider } from "./provider-catalog.js";
import { createCohereCompletionsWrapper } from "./stream.js";
function readManifest() {
return JSON.parse(readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8")) as {
providerAuthChoices?: Array<{ choiceId?: string; optionKey?: string; cliFlag?: string }>;
setup?: { providers?: Array<{ id?: string; envVars?: string[] }> };
};
}
function requireCohereModel(): Model<"openai-completions"> {
const model = buildCohereProvider().models?.[0];
if (!model) {
throw new Error("Cohere catalog did not provide a model");
}
return model as Model<"openai-completions">;
}
function captureCoherePayload(context: Context): Record<string, unknown> {
let captured: Record<string, unknown> | undefined;
const baseStreamFn: StreamFn = (model, streamContext, options) => {
const payload = buildOpenAICompletionsParams(
model as Model<"openai-completions">,
streamContext,
{ maxTokens: 2048 } as never,
);
options?.onPayload?.(payload, model);
return {} as ReturnType<StreamFn>;
};
const wrappedStreamFn = createCohereCompletionsWrapper(baseStreamFn);
if (!wrappedStreamFn) {
throw new Error("Cohere wrapper did not return a stream function");
}
void wrappedStreamFn(requireCohereModel(), context, {
onPayload: (payload) => {
captured = payload as Record<string, unknown>;
},
});
if (!captured) {
throw new Error("Cohere payload was not captured");
}
return captured;
}
describe("Cohere provider plugin", () => {
it("registers the manifest-owned API key onboarding flow", async () => {
const provider = await registerSingleProviderPlugin(plugin);
expect(provider.auth.map((method) => method.wizard?.choiceId)).toEqual(["cohere-api-key"]);
expect(provider).toMatchObject({
id: "cohere",
envVars: ["COHERE_API_KEY"],
});
expect(provider.auth[0]).toMatchObject({
id: "api-key",
kind: "api_key",
wizard: { choiceId: "cohere-api-key" },
});
expect(readManifest().providerAuthChoices).toEqual([
expect.objectContaining({
choiceId: "cohere-api-key",
optionKey: "cohereApiKey",
cliFlag: "--cohere-api-key",
}),
]);
expect(readManifest().setup?.providers).toEqual([
{ id: "cohere", envVars: ["COHERE_API_KEY"] },
]);
});
it("exposes the static Cohere catalog", () => {
expect(buildCohereProvider()).toMatchObject({
baseUrl: "https://api.cohere.ai/compatibility/v1",
api: "openai-completions",
models: [
expect.objectContaining({
id: "command-a-03-2025",
compat: {
supportsStore: false,
supportsUsageInStreaming: false,
maxTokensField: "max_tokens",
},
}),
],
});
});
it("uses Cohere's OpenAI-compatible completions payload fields", () => {
const params = captureCoherePayload({
systemPrompt: "system",
messages: [],
tools: [
{
name: "lookup",
description: "Look up a value",
parameters: { type: "object", properties: {} },
},
],
} as Context);
expect(params.max_tokens).toBe(2048);
expect(params).not.toHaveProperty("max_completion_tokens");
expect(params).not.toHaveProperty("store");
expect(params).not.toHaveProperty("stream_options");
expect(params).not.toHaveProperty("tool_choice");
expect(params.messages).toEqual(
expect.arrayContaining([expect.objectContaining({ role: "developer", content: "system" })]),
);
expect(params.messages).not.toEqual(
expect.arrayContaining([expect.objectContaining({ role: "system", content: "system" })]),
);
});
});

View File

@@ -0,0 +1,37 @@
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { applyCohereConfig, COHERE_DEFAULT_MODEL_REF } from "./onboard.js";
import { buildCohereProvider } from "./provider-catalog.js";
import { createCohereCompletionsWrapper } from "./stream.js";
export default defineSingleProviderPluginEntry({
id: "cohere",
name: "Cohere Provider",
description: "Bundled Cohere provider plugin",
provider: {
label: "Cohere",
docsPath: "/providers/cohere",
auth: [
{
methodId: "api-key",
label: "Cohere API key",
hint: "OpenAI-compatible inference",
optionKey: "cohereApiKey",
flagName: "--cohere-api-key",
envVar: "COHERE_API_KEY",
promptMessage: "Enter Cohere API key",
defaultModel: COHERE_DEFAULT_MODEL_REF,
applyConfig: (cfg) => applyCohereConfig(cfg),
wizard: {
groupLabel: "Cohere",
groupHint: "OpenAI-compatible inference",
},
},
],
catalog: {
buildProvider: buildCohereProvider,
buildStaticProvider: buildCohereProvider,
},
wrapStreamFn: (ctx) => createCohereCompletionsWrapper(ctx.streamFn),
wrapSimpleCompletionStreamFn: (ctx) => createCohereCompletionsWrapper(ctx.streamFn),
},
});

View File

@@ -0,0 +1,27 @@
/**
* Cohere model catalog helpers derived from the plugin manifest.
*/
import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
import manifest from "./openclaw.plugin.json" with { type: "json" };
const COHERE_MANIFEST_CATALOG = manifest.modelCatalog.providers.cohere;
export const COHERE_BASE_URL = COHERE_MANIFEST_CATALOG.baseUrl;
export const COHERE_MODEL_CATALOG = COHERE_MANIFEST_CATALOG.models;
export function buildCohereCatalogModels(): ModelDefinitionConfig[] {
return buildManifestModelProviderConfig({
providerId: "cohere",
catalog: COHERE_MANIFEST_CATALOG,
}).models;
}
export function buildCohereModelDefinition(
model: (typeof COHERE_MODEL_CATALOG)[number],
): ModelDefinitionConfig {
return buildManifestModelProviderConfig({
providerId: "cohere",
catalog: { ...COHERE_MANIFEST_CATALOG, models: [model] },
}).models[0];
}

View File

@@ -0,0 +1,49 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveAgentModelPrimaryValue } from "openclaw/plugin-sdk/provider-onboard";
import { describe, expect, it } from "vitest";
import { buildCohereCatalogModels, COHERE_BASE_URL, COHERE_MODEL_CATALOG } from "./models.js";
import {
applyCohereConfig,
applyCohereProviderConfig,
COHERE_DEFAULT_MODEL_ID,
COHERE_DEFAULT_MODEL_REF,
} from "./onboard.js";
describe("Cohere onboarding", () => {
it("registers the manifest catalog through the compatibility endpoint", () => {
const result = applyCohereProviderConfig({});
const provider = result.models?.providers?.cohere;
expect(provider).toMatchObject({
baseUrl: COHERE_BASE_URL,
api: "openai-completions",
});
expect(provider?.models?.map((model) => model.id)).toEqual([COHERE_DEFAULT_MODEL_ID]);
expect(buildCohereCatalogModels()).toHaveLength(COHERE_MODEL_CATALOG.length);
});
it("sets Cohere only when there is no primary model", () => {
const existing: OpenClawConfig = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.5" },
},
},
};
const result = applyCohereConfig(existing);
expect(resolveAgentModelPrimaryValue(result.agents?.defaults?.model)).toBe("openai/gpt-5.5");
expect(result.agents?.defaults?.models?.[COHERE_DEFAULT_MODEL_REF]).toEqual({
alias: "Cohere Command A",
});
});
it("uses Cohere as the first configured primary model", () => {
const result = applyCohereConfig({});
expect(resolveAgentModelPrimaryValue(result.agents?.defaults?.model)).toBe(
COHERE_DEFAULT_MODEL_REF,
);
});
});

View File

@@ -0,0 +1,27 @@
import {
createModelCatalogPresetAppliers,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { buildCohereModelDefinition, COHERE_BASE_URL, COHERE_MODEL_CATALOG } from "./models.js";
export const COHERE_DEFAULT_MODEL_ID = "command-a-03-2025";
export const COHERE_DEFAULT_MODEL_REF = `cohere/${COHERE_DEFAULT_MODEL_ID}`;
const coherePresetAppliers = createModelCatalogPresetAppliers({
primaryModelRef: COHERE_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig) => ({
providerId: "cohere",
api: "openai-completions",
baseUrl: COHERE_BASE_URL,
catalogModels: COHERE_MODEL_CATALOG.map(buildCohereModelDefinition),
aliases: [{ modelRef: COHERE_DEFAULT_MODEL_REF, alias: "Cohere Command A" }],
}),
});
export function applyCohereProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return coherePresetAppliers.applyProviderConfig(cfg);
}
export function applyCohereConfig(cfg: OpenClawConfig): OpenClawConfig {
return coherePresetAppliers.applyConfig(cfg);
}

View File

@@ -0,0 +1,67 @@
{
"id": "cohere",
"activation": {
"onStartup": false
},
"enabledByDefault": true,
"providers": ["cohere"],
"modelCatalog": {
"providers": {
"cohere": {
"baseUrl": "https://api.cohere.ai/compatibility/v1",
"api": "openai-completions",
"models": [
{
"id": "command-a-03-2025",
"name": "Command A",
"input": ["text"],
"contextWindow": 256000,
"maxTokens": 8000,
"cost": {
"input": 2.5,
"output": 10,
"cacheRead": 0,
"cacheWrite": 0
},
"compat": {
"supportsStore": false,
"supportsUsageInStreaming": false,
"maxTokensField": "max_tokens"
}
}
]
}
},
"discovery": {
"cohere": "static"
}
},
"setup": {
"providers": [
{
"id": "cohere",
"envVars": ["COHERE_API_KEY"]
}
]
},
"providerAuthChoices": [
{
"provider": "cohere",
"method": "api-key",
"choiceId": "cohere-api-key",
"choiceLabel": "Cohere API key",
"groupId": "cohere",
"groupLabel": "Cohere",
"groupHint": "OpenAI-compatible inference",
"optionKey": "cohereApiKey",
"cliFlag": "--cohere-api-key",
"cliOption": "--cohere-api-key <key>",
"cliDescription": "Cohere API key"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "@openclaw/cohere-provider",
"version": "2026.6.8",
"private": true,
"description": "OpenClaw Cohere provider plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,10 @@
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { buildCohereCatalogModels, COHERE_BASE_URL } from "./models.js";
export function buildCohereProvider(): ModelProviderConfig {
return {
baseUrl: COHERE_BASE_URL,
api: "openai-completions",
models: buildCohereCatalogModels(),
};
}

View File

@@ -0,0 +1,26 @@
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
import { createPayloadPatchStreamWrapper } from "openclaw/plugin-sdk/provider-stream-shared";
function patchCoherePayload(payload: Record<string, unknown>): void {
// Cohere's Compatibility API uses developer, not system, for instructions.
if (Array.isArray(payload.messages)) {
payload.messages = payload.messages.map((message) =>
message &&
typeof message === "object" &&
(message as Record<string, unknown>).role === "system"
? { ...(message as Record<string, unknown>), role: "developer" }
: message,
);
}
// Cohere lets tool-capable models choose a tool when tool_choice is omitted.
delete payload.tool_choice;
}
export function createCohereCompletionsWrapper(
baseStreamFn: ProviderWrapStreamFnContext["streamFn"],
): ProviderWrapStreamFnContext["streamFn"] {
return createPayloadPatchStreamWrapper(baseStreamFn, ({ payload }) =>
patchCoherePayload(payload),
);
}

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -9,15 +9,15 @@
"version": "2026.6.8",
"dependencies": {
"@opentelemetry/api": "1.9.1",
"@opentelemetry/api-logs": "0.218.0",
"@opentelemetry/exporter-logs-otlp-proto": "0.218.0",
"@opentelemetry/exporter-metrics-otlp-proto": "0.218.0",
"@opentelemetry/exporter-trace-otlp-proto": "0.218.0",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-logs": "0.218.0",
"@opentelemetry/sdk-metrics": "2.7.1",
"@opentelemetry/sdk-node": "0.218.0",
"@opentelemetry/sdk-trace-base": "2.7.1",
"@opentelemetry/api-logs": "0.219.0",
"@opentelemetry/exporter-logs-otlp-proto": "0.219.0",
"@opentelemetry/exporter-metrics-otlp-proto": "0.219.0",
"@opentelemetry/exporter-trace-otlp-proto": "0.219.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/sdk-logs": "0.219.0",
"@opentelemetry/sdk-metrics": "2.8.0",
"@opentelemetry/sdk-node": "0.219.0",
"@opentelemetry/sdk-trace-base": "2.8.0",
"@opentelemetry/semantic-conventions": "1.41.1"
}
},
@@ -72,9 +72,9 @@
}
},
"node_modules/@opentelemetry/api-logs": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.218.0.tgz",
"integrity": "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==",
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz",
"integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.3.0"
@@ -84,12 +84,12 @@
}
},
"node_modules/@opentelemetry/configuration": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/configuration/-/configuration-0.218.0.tgz",
"integrity": "sha512-W8wIz7H2R1pufR5jfjb3gU2XkMpm2x/7b1RJcsuzvd70Il/rWWE+g5/Od7hQKrxRTSrTrOWlru101PWXz5I1EQ==",
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/configuration/-/configuration-0.219.0.tgz",
"integrity": "sha512-wXZUYv4ngu43nA4WEhuXNacm46LW+17LRM8nKyIhBzroRA24PBYjMnakwzR/w777nFUB5xlgsYTTeuXxumZM1Q==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/core": "2.8.0",
"yaml": "^2.0.0"
},
"engines": {
@@ -100,9 +100,9 @@
}
},
"node_modules/@opentelemetry/context-async-hooks": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.7.1.tgz",
"integrity": "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.8.0.tgz",
"integrity": "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw==",
"license": "Apache-2.0",
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -112,9 +112,9 @@
}
},
"node_modules/@opentelemetry/core": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz",
"integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz",
"integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -127,17 +127,17 @@
}
},
"node_modules/@opentelemetry/exporter-logs-otlp-grpc": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.218.0.tgz",
"integrity": "sha512-hoxrNH1l/Xy6F9WTJ5IK+6j1r9nQFlPOmrnTlhYHTySdunfXLmUCPv3bQtKYntxag9h3wLYBZQ2HI6FOx+BT2g==",
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.219.0.tgz",
"integrity": "sha512-7SvzDCIclHWAcCwZ1MTOLcwn4BVNPGI3QxS/DJraPNe1TTL+4TvUBq5zeQV8tsnYvtDN7wKW2qocVmaCP2l7sQ==",
"license": "Apache-2.0",
"dependencies": {
"@grpc/grpc-js": "^1.14.3",
"@opentelemetry/core": "2.7.1",
"@opentelemetry/otlp-exporter-base": "0.218.0",
"@opentelemetry/otlp-grpc-exporter-base": "0.218.0",
"@opentelemetry/otlp-transformer": "0.218.0",
"@opentelemetry/sdk-logs": "0.218.0"
"@opentelemetry/core": "2.8.0",
"@opentelemetry/otlp-exporter-base": "0.219.0",
"@opentelemetry/otlp-grpc-exporter-base": "0.219.0",
"@opentelemetry/otlp-transformer": "0.219.0",
"@opentelemetry/sdk-logs": "0.219.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -147,16 +147,16 @@
}
},
"node_modules/@opentelemetry/exporter-logs-otlp-http": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.218.0.tgz",
"integrity": "sha512-Qx+4rpVHzgg89dawcWRHyt+XRXeLnhFz/qBtvggmjkcgPUdr+NAB0/u/eIPA8yAeJV0J80Vz43JZCh/XFvZFGw==",
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.219.0.tgz",
"integrity": "sha512-mhl2HL6GmZI8b8PwPfqMws/5ovJfbRTxwc9Y5agVVHiQ+e5SL1btsFr/kJDgt7YCexDtsUn5HAreHQO9szFS0A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.218.0",
"@opentelemetry/core": "2.7.1",
"@opentelemetry/otlp-exporter-base": "0.218.0",
"@opentelemetry/otlp-transformer": "0.218.0",
"@opentelemetry/sdk-logs": "0.218.0"
"@opentelemetry/api-logs": "0.219.0",
"@opentelemetry/core": "2.8.0",
"@opentelemetry/otlp-exporter-base": "0.219.0",
"@opentelemetry/otlp-transformer": "0.219.0",
"@opentelemetry/sdk-logs": "0.219.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -166,18 +166,18 @@
}
},
"node_modules/@opentelemetry/exporter-logs-otlp-proto": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.218.0.tgz",
"integrity": "sha512-1/noQNsp9gXD75HPzgjBrcF1+XTtry7pFAUfxVEJgg7mPv2AawKQuYkhMmJ8qjxz4Ubc3Y8bwvfxevXsKTq4cg==",
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.219.0.tgz",
"integrity": "sha512-Ayw4Gf71PS9jhBVaYywa4WsajnqfDehMkTdVH3TSAVHqPcsAv/AhH/wTNRYNt99szeYr6Gbd/D6RjZD77wAxHg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.218.0",
"@opentelemetry/core": "2.7.1",
"@opentelemetry/otlp-exporter-base": "0.218.0",
"@opentelemetry/otlp-transformer": "0.218.0",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-logs": "0.218.0",
"@opentelemetry/sdk-trace-base": "2.7.1"
"@opentelemetry/api-logs": "0.219.0",
"@opentelemetry/core": "2.8.0",
"@opentelemetry/otlp-exporter-base": "0.219.0",
"@opentelemetry/otlp-transformer": "0.219.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/sdk-logs": "0.219.0",
"@opentelemetry/sdk-trace-base": "2.8.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -187,19 +187,19 @@
}
},
"node_modules/@opentelemetry/exporter-metrics-otlp-grpc": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.218.0.tgz",
"integrity": "sha512-YapQ9vNMX0NSZF6LK5pWAFfjpJleV2O9uYWfYGeb/5F1Kb9rPGK8tZDMJFa/sOksgdFuflDvYuA0B4qjDB4fjQ==",
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.219.0.tgz",
"integrity": "sha512-6LaaSrPxK5L55bXevWajvOMxGOpNm0n12tG53TeZaUeNzXwLPg6d2KCC1zAlGsojan+xRG71mA4Qqs9K2VVrKQ==",
"license": "Apache-2.0",
"dependencies": {
"@grpc/grpc-js": "^1.14.3",
"@opentelemetry/core": "2.7.1",
"@opentelemetry/exporter-metrics-otlp-http": "0.218.0",
"@opentelemetry/otlp-exporter-base": "0.218.0",
"@opentelemetry/otlp-grpc-exporter-base": "0.218.0",
"@opentelemetry/otlp-transformer": "0.218.0",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-metrics": "2.7.1"
"@opentelemetry/core": "2.8.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.219.0",
"@opentelemetry/otlp-exporter-base": "0.219.0",
"@opentelemetry/otlp-grpc-exporter-base": "0.219.0",
"@opentelemetry/otlp-transformer": "0.219.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/sdk-metrics": "2.8.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -209,16 +209,16 @@
}
},
"node_modules/@opentelemetry/exporter-metrics-otlp-http": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.218.0.tgz",
"integrity": "sha512-bV7d2OuMpZu2+gAaxUAhzfZ0h3WVZk8ETQUEE3DNSntbTaMpuITjtm8I0rNyHFdm7Ax57K6ty7SgFXlBmOLIvQ==",
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.219.0.tgz",
"integrity": "sha512-6CaDRbMVHZSDWzNXwrR8y/H4B/Z1eMNnkHiPQlTx3Ojz2OHY4X/aff/UC4P/3pHUQSuTfi3oh2UsPPZppw+Vrg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/otlp-exporter-base": "0.218.0",
"@opentelemetry/otlp-transformer": "0.218.0",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-metrics": "2.7.1"
"@opentelemetry/core": "2.8.0",
"@opentelemetry/otlp-exporter-base": "0.219.0",
"@opentelemetry/otlp-transformer": "0.219.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/sdk-metrics": "2.8.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -228,17 +228,17 @@
}
},
"node_modules/@opentelemetry/exporter-metrics-otlp-proto": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.218.0.tgz",
"integrity": "sha512-ubLddKjWULhla9YZRCj/rTBeppjJYE4e9w0icx5mTu3eFhWjQzbV75NYjXuIlEG+NJsBl6d+sTFw5Qu+oej4oQ==",
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.219.0.tgz",
"integrity": "sha512-DUS7XyIiEnoeccQUvuKy0G2/YqeKhpN8FVIrGbrLNIVMj10yeIFLRzRv0tibCI2kXXvlTTABVexGAk78wHk2ug==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/exporter-metrics-otlp-http": "0.218.0",
"@opentelemetry/otlp-exporter-base": "0.218.0",
"@opentelemetry/otlp-transformer": "0.218.0",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-metrics": "2.7.1"
"@opentelemetry/core": "2.8.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.219.0",
"@opentelemetry/otlp-exporter-base": "0.219.0",
"@opentelemetry/otlp-transformer": "0.219.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/sdk-metrics": "2.8.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -248,14 +248,14 @@
}
},
"node_modules/@opentelemetry/exporter-prometheus": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.218.0.tgz",
"integrity": "sha512-RT5oEyu1kddZJ1vt7/BUo5wV+P7hpNAESsR3dUd3+8deHuX7gWNoCOZn+SfDT+hJHlIJ5h/AxiCLXIrutswDJg==",
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.219.0.tgz",
"integrity": "sha512-TxOnJ85eWJY5JyOJsNMXiRTYlkDcOv0u3KbXEzWCc+tUS9sjL/BC6BcdxZ0B9r2OFVqsrZFXUzSD2sZUy42Ucw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-metrics": "2.7.1",
"@opentelemetry/core": "2.8.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/sdk-metrics": "2.8.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
@@ -266,18 +266,18 @@
}
},
"node_modules/@opentelemetry/exporter-trace-otlp-grpc": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.218.0.tgz",
"integrity": "sha512-3fXxVQEj9TNAFaCi79JeFKfeLd0sDtInaR3gaZDVlzNSPHtz8PZuCV34JKWjD4XXzT20IdMe8IpX6mRVNDA4Tw==",
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.219.0.tgz",
"integrity": "sha512-BkDNv1UD6BscW19MxbAxVmSYSSFuyeqR6buV2/HTYqA7GrR0EbTFzqG6h86T3PtXmpdbsWjMGLDdjG2rikG27Q==",
"license": "Apache-2.0",
"dependencies": {
"@grpc/grpc-js": "^1.14.3",
"@opentelemetry/core": "2.7.1",
"@opentelemetry/otlp-exporter-base": "0.218.0",
"@opentelemetry/otlp-grpc-exporter-base": "0.218.0",
"@opentelemetry/otlp-transformer": "0.218.0",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-trace-base": "2.7.1"
"@opentelemetry/core": "2.8.0",
"@opentelemetry/otlp-exporter-base": "0.219.0",
"@opentelemetry/otlp-grpc-exporter-base": "0.219.0",
"@opentelemetry/otlp-transformer": "0.219.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/sdk-trace-base": "2.8.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -287,16 +287,16 @@
}
},
"node_modules/@opentelemetry/exporter-trace-otlp-http": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.218.0.tgz",
"integrity": "sha512-8dqezsmPhtKitIK/eTipZhYl9EX2/gNQ5zUMhaz3uxEURwfkNf8IPvo6yNfrzbxdtpAOybS/+h7wmIWYqFSpiw==",
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.219.0.tgz",
"integrity": "sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/otlp-exporter-base": "0.218.0",
"@opentelemetry/otlp-transformer": "0.218.0",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-trace-base": "2.7.1"
"@opentelemetry/core": "2.8.0",
"@opentelemetry/otlp-exporter-base": "0.219.0",
"@opentelemetry/otlp-transformer": "0.219.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/sdk-trace-base": "2.8.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -306,16 +306,16 @@
}
},
"node_modules/@opentelemetry/exporter-trace-otlp-proto": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.218.0.tgz",
"integrity": "sha512-r1Msf8SNLRmwh9J6XQ5uh82D7CdDWMNHnPB7LAVHjzut0TkSeKc5KcIvr4SvHvfk/xwN5gxC+VLKQ1k0o8PSPw==",
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.219.0.tgz",
"integrity": "sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/otlp-exporter-base": "0.218.0",
"@opentelemetry/otlp-transformer": "0.218.0",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-trace-base": "2.7.1"
"@opentelemetry/core": "2.8.0",
"@opentelemetry/otlp-exporter-base": "0.219.0",
"@opentelemetry/otlp-transformer": "0.219.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/sdk-trace-base": "2.8.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -325,14 +325,14 @@
}
},
"node_modules/@opentelemetry/exporter-zipkin": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-zipkin/-/exporter-zipkin-2.7.1.tgz",
"integrity": "sha512-mfsD9bKAxcKrh5+y08TPodvClBO0CznBE3p79YAGnO81WI4LrdsGA65T53e4iTSbCalW4WaUpkbeJcbpyIUHfg==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-zipkin/-/exporter-zipkin-2.8.0.tgz",
"integrity": "sha512-Mj84UkEa17BK2o903VTXW3wM8CrSZexGs4tRGVZVIMM9ni1T6TuGx5IrRfoWKAbshx42D5/kc7YV+axypLPYyA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-trace-base": "2.7.1",
"@opentelemetry/core": "2.8.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/sdk-trace-base": "2.8.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
@@ -343,12 +343,12 @@
}
},
"node_modules/@opentelemetry/instrumentation": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.218.0.tgz",
"integrity": "sha512-mIZil8Es+sYDK5m+DQiwAwF57F14TF2YlEqvIjZ/RQWcxDBwRGsKfdK2Tv65OU9meQKCMzSIFS9mxAcnAb6Bkg==",
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.219.0.tgz",
"integrity": "sha512-X5t7I8GyIO9rmGHwoedZLREpQqrF1WW2nxzNNym6HOKpFiE+rvqV3ngC0xcZVO2YwIGf3KKmRdWrYwdwz3H9RQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.218.0",
"@opentelemetry/api-logs": "0.219.0",
"import-in-the-middle": "^3.0.0",
"require-in-the-middle": "^8.0.0"
},
@@ -360,13 +360,13 @@
}
},
"node_modules/@opentelemetry/otlp-exporter-base": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.218.0.tgz",
"integrity": "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA==",
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.219.0.tgz",
"integrity": "sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/otlp-transformer": "0.218.0"
"@opentelemetry/core": "2.8.0",
"@opentelemetry/otlp-transformer": "0.219.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -376,15 +376,15 @@
}
},
"node_modules/@opentelemetry/otlp-grpc-exporter-base": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.218.0.tgz",
"integrity": "sha512-H/lCGJ536N98VpYJOaWTQOkv4Dx6TnmStK6Rqfu1W7KkFbPAx04hjdYEMZF/YbnHzPUSIK4kM6OE2GKGBTpV9A==",
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.219.0.tgz",
"integrity": "sha512-iIk/s8QQu39zpTrRRmsW/Eg3SE2+Hg8tLWepr2FLRgmwUpNd0IpCTLJEHJ77hpt4hgIS8MAh44UYI4xQPZwWlw==",
"license": "Apache-2.0",
"dependencies": {
"@grpc/grpc-js": "^1.14.3",
"@opentelemetry/core": "2.7.1",
"@opentelemetry/otlp-exporter-base": "0.218.0",
"@opentelemetry/otlp-transformer": "0.218.0"
"@opentelemetry/core": "2.8.0",
"@opentelemetry/otlp-exporter-base": "0.219.0",
"@opentelemetry/otlp-transformer": "0.219.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -394,17 +394,17 @@
}
},
"node_modules/@opentelemetry/otlp-transformer": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.218.0.tgz",
"integrity": "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ==",
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.219.0.tgz",
"integrity": "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.218.0",
"@opentelemetry/core": "2.7.1",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-logs": "0.218.0",
"@opentelemetry/sdk-metrics": "2.7.1",
"@opentelemetry/sdk-trace-base": "2.7.1"
"@opentelemetry/api-logs": "0.219.0",
"@opentelemetry/core": "2.8.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/sdk-logs": "0.219.0",
"@opentelemetry/sdk-metrics": "2.8.0",
"@opentelemetry/sdk-trace-base": "2.8.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -414,12 +414,12 @@
}
},
"node_modules/@opentelemetry/propagator-b3": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-2.7.1.tgz",
"integrity": "sha512-RJid6E2CKyeGfKBzXKF21ejabGMHypFkPAh3qZ+NvI+SGjuIye79t3PmiqcDgtRzdKH6ynXzbfslQ8DfpRUg2A==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-2.8.0.tgz",
"integrity": "sha512-SazlvuSKi5533rPHTW2TwBwdMakhjZST4SYs0YauuvfGDkT13KbG1gJS75hV0uWVeevhtVP9sAIlaZLTHdSbMg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1"
"@opentelemetry/core": "2.8.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -429,12 +429,12 @@
}
},
"node_modules/@opentelemetry/propagator-jaeger": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-2.7.1.tgz",
"integrity": "sha512-KMjVBHzP4N60bOzxja76M1F1hZZ43lGPga5ix+mkv9+kk1nx9SbkxSvJsMbuVUxdPQmsPTqGShmhN8ulrMOg6Q==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-2.8.0.tgz",
"integrity": "sha512-Xnz9zZvvQzUw+9DrOn0MomR7BxFCkA2pcfXBQuHC28ndJpSbjLs7knzYb05kw5SyCjSsEWombkZMgGcJSk8JVg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1"
"@opentelemetry/core": "2.8.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -444,12 +444,12 @@
}
},
"node_modules/@opentelemetry/resources": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz",
"integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz",
"integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/core": "2.8.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
@@ -460,14 +460,14 @@
}
},
"node_modules/@opentelemetry/sdk-logs": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.218.0.tgz",
"integrity": "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==",
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz",
"integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.218.0",
"@opentelemetry/core": "2.7.1",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/api-logs": "0.219.0",
"@opentelemetry/core": "2.8.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
@@ -478,13 +478,13 @@
}
},
"node_modules/@opentelemetry/sdk-metrics": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz",
"integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.8.0.tgz",
"integrity": "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/resources": "2.7.1"
"@opentelemetry/core": "2.8.0",
"@opentelemetry/resources": "2.8.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -494,35 +494,36 @@
}
},
"node_modules/@opentelemetry/sdk-node": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-node/-/sdk-node-0.218.0.tgz",
"integrity": "sha512-tPMjHrLV5gsfNdYqoRHjeGbCAZBXXD9c1Qo/2ut7VwnUABDNh76xNxrT0SEhkIIJuCN45bbN1vZnYL1gY0IkOg==",
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-node/-/sdk-node-0.219.0.tgz",
"integrity": "sha512-NWLpWLEb8gV3+JBHYoIrktbM385wyHpRJoh3J/4Q52d4PR+AlPMNGJT3DzBUrDSUEVbKAXoHR+EDAPxtiNcj8g==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.218.0",
"@opentelemetry/configuration": "0.218.0",
"@opentelemetry/context-async-hooks": "2.7.1",
"@opentelemetry/core": "2.7.1",
"@opentelemetry/exporter-logs-otlp-grpc": "0.218.0",
"@opentelemetry/exporter-logs-otlp-http": "0.218.0",
"@opentelemetry/exporter-logs-otlp-proto": "0.218.0",
"@opentelemetry/exporter-metrics-otlp-grpc": "0.218.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.218.0",
"@opentelemetry/exporter-metrics-otlp-proto": "0.218.0",
"@opentelemetry/exporter-prometheus": "0.218.0",
"@opentelemetry/exporter-trace-otlp-grpc": "0.218.0",
"@opentelemetry/exporter-trace-otlp-http": "0.218.0",
"@opentelemetry/exporter-trace-otlp-proto": "0.218.0",
"@opentelemetry/exporter-zipkin": "2.7.1",
"@opentelemetry/instrumentation": "0.218.0",
"@opentelemetry/otlp-exporter-base": "0.218.0",
"@opentelemetry/propagator-b3": "2.7.1",
"@opentelemetry/propagator-jaeger": "2.7.1",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-logs": "0.218.0",
"@opentelemetry/sdk-metrics": "2.7.1",
"@opentelemetry/sdk-trace-base": "2.7.1",
"@opentelemetry/sdk-trace-node": "2.7.1",
"@opentelemetry/api-logs": "0.219.0",
"@opentelemetry/configuration": "0.219.0",
"@opentelemetry/context-async-hooks": "2.8.0",
"@opentelemetry/core": "2.8.0",
"@opentelemetry/exporter-logs-otlp-grpc": "0.219.0",
"@opentelemetry/exporter-logs-otlp-http": "0.219.0",
"@opentelemetry/exporter-logs-otlp-proto": "0.219.0",
"@opentelemetry/exporter-metrics-otlp-grpc": "0.219.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.219.0",
"@opentelemetry/exporter-metrics-otlp-proto": "0.219.0",
"@opentelemetry/exporter-prometheus": "0.219.0",
"@opentelemetry/exporter-trace-otlp-grpc": "0.219.0",
"@opentelemetry/exporter-trace-otlp-http": "0.219.0",
"@opentelemetry/exporter-trace-otlp-proto": "0.219.0",
"@opentelemetry/exporter-zipkin": "2.8.0",
"@opentelemetry/instrumentation": "0.219.0",
"@opentelemetry/otlp-exporter-base": "0.219.0",
"@opentelemetry/otlp-grpc-exporter-base": "0.219.0",
"@opentelemetry/propagator-b3": "2.8.0",
"@opentelemetry/propagator-jaeger": "2.8.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/sdk-logs": "0.219.0",
"@opentelemetry/sdk-metrics": "2.8.0",
"@opentelemetry/sdk-trace-base": "2.8.0",
"@opentelemetry/sdk-trace-node": "2.8.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
@@ -533,13 +534,13 @@
}
},
"node_modules/@opentelemetry/sdk-trace-base": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz",
"integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz",
"integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/core": "2.8.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
@@ -550,14 +551,14 @@
}
},
"node_modules/@opentelemetry/sdk-trace-node": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.7.1.tgz",
"integrity": "sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.8.0.tgz",
"integrity": "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/context-async-hooks": "2.7.1",
"@opentelemetry/core": "2.7.1",
"@opentelemetry/sdk-trace-base": "2.7.1"
"@opentelemetry/context-async-hooks": "2.8.0",
"@opentelemetry/core": "2.8.0",
"@opentelemetry/sdk-trace-base": "2.8.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -575,6 +576,78 @@
"node": ">=14"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -748,12 +821,23 @@
"license": "MIT"
},
"node_modules/protobufjs": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.1.tgz",
"integrity": "sha512-oXf2UgIty8jnwfN4yvL1x79VLhL5uiKjZJbSGXGCIUmHmItTP4eS/UIlWDCeNx3seg+ujfn9vDlPMSrsh7wO+Q==",
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.3.tgz",
"integrity": "sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.3.2"
},
"engines": {
@@ -808,6 +892,12 @@
"node": ">=8"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View File

@@ -9,15 +9,15 @@
"type": "module",
"dependencies": {
"@opentelemetry/api": "1.9.1",
"@opentelemetry/api-logs": "0.218.0",
"@opentelemetry/exporter-logs-otlp-proto": "0.218.0",
"@opentelemetry/exporter-metrics-otlp-proto": "0.218.0",
"@opentelemetry/exporter-trace-otlp-proto": "0.218.0",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-logs": "0.218.0",
"@opentelemetry/sdk-metrics": "2.7.1",
"@opentelemetry/sdk-node": "0.218.0",
"@opentelemetry/sdk-trace-base": "2.7.1",
"@opentelemetry/api-logs": "0.219.0",
"@opentelemetry/exporter-logs-otlp-proto": "0.219.0",
"@opentelemetry/exporter-metrics-otlp-proto": "0.219.0",
"@opentelemetry/exporter-trace-otlp-proto": "0.219.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/sdk-logs": "0.219.0",
"@opentelemetry/sdk-metrics": "2.8.0",
"@opentelemetry/sdk-node": "0.219.0",
"@opentelemetry/sdk-trace-base": "2.8.0",
"@opentelemetry/semantic-conventions": "1.41.1"
},
"devDependencies": {

View File

@@ -173,6 +173,7 @@ import {
import {
emitDiagnosticEventWithTrustedTraceContext,
emitInternalDiagnosticEventForTest,
emitTrustedSecurityEvent,
logMessageDispatchStarted,
logMessageProcessed,
onTrustedInternalDiagnosticEvent,
@@ -953,6 +954,119 @@ describe("diagnostics-otel service", () => {
await service.stop?.(ctx);
});
test("exports trusted security events as bounded OTLP logs", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { logs: true });
const trace = createDiagnosticTraceContext({
traceId: TRACE_ID,
spanId: SPAN_ID,
traceFlags: "01",
});
await service.start(ctx);
emitTrustedSecurityEvent({
eventId: "security-event-1",
category: "tool",
action: "tool.execution.blocked",
outcome: "denied",
severity: "medium",
reason: "tools.deny",
actor: {
kind: "agent",
idHash: "agent-hash-1",
role: "operator",
scopes: ["operator.read", "operator.approvals"],
},
target: {
kind: "plugin",
name: "@acme/security-event-plugin",
owner: "plugin-installer",
},
policy: {
id: "tools.exec",
decision: "deny",
reason: "allowlist.miss",
},
control: {
id: "exec-approval",
family: "approval",
},
attributes: {
params_kind: "object",
secretish: "token sk-test-secret",
[PROTO_KEY]: "blocked",
},
trace,
});
await flushDiagnosticEvents();
const emitCall = mockCallArg(logEmit, 0) as {
attributes?: Record<string, unknown>;
body?: string;
context?: unknown;
severityNumber?: number;
severityText?: string;
};
expect(emitCall.body).toBe("openclaw.security.event");
expect(emitCall.severityText).toBe("WARN");
expect(emitCall.severityNumber).toBe(13);
expect(emitCall.attributes).toMatchObject({
"openclaw.security.event_id": "security-event-1",
"openclaw.security.category": "tool",
"openclaw.security.action": "tool.execution.blocked",
"openclaw.security.outcome": "denied",
"openclaw.security.severity": "medium",
"openclaw.security.reason": "tools.deny",
"openclaw.security.actor.kind": "agent",
"openclaw.security.actor.id_hash": "agent-hash-1",
"openclaw.security.actor.role": "operator",
"openclaw.security.actor.scopes": "operator.read,operator.approvals",
"openclaw.security.target.kind": "plugin",
"openclaw.security.target.name": "@acme/security-event-plugin",
"openclaw.security.target.owner": "plugin-installer",
"openclaw.security.policy.id": "tools.exec",
"openclaw.security.policy.decision": "deny",
"openclaw.security.policy.reason": "allowlist.miss",
"openclaw.security.control.id": "exec-approval",
"openclaw.security.control.family": "approval",
"openclaw.security.attribute.params_kind": "object",
"openclaw.security.attribute.secretish": "unknown",
});
expect(emitCall.context).toEqual({
spanContext: {
traceId: TRACE_ID,
spanId: SPAN_ID,
traceFlags: 1,
isRemote: true,
},
});
expect(Object.hasOwn(emitCall.attributes ?? {}, "openclaw.security.attribute.__proto__")).toBe(
false,
);
expect(JSON.stringify(emitCall)).not.toContain("sk-test-secret");
await service.stop?.(ctx);
});
test("does not export security events when OTLP logs are disabled", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { logs: false, metrics: true });
await service.start(ctx);
emitTrustedSecurityEvent({
eventId: "security-event-logs-disabled",
category: "auth",
action: "gateway.auth.failed",
outcome: "failure",
severity: "high",
});
await flushDiagnosticEvents();
expect(logEmit).not.toHaveBeenCalled();
await service.stop?.(ctx);
});
test("records liveness warning diagnostics", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });

View File

@@ -67,6 +67,7 @@ const DROPPED_OTEL_ATTRIBUTE_KEYS = new Set([
"openclaw.trace_id",
]);
const LOW_CARDINALITY_VALUE_RE = /^[A-Za-z0-9_.:-]{1,120}$/u;
const SECURITY_TARGET_NAME_VALUE_RE = /^[A-Za-z0-9@/_.:-]{1,256}$/u;
const MAX_OTEL_CONTENT_ATTRIBUTE_CHARS = 128 * 1024;
const MAX_OTEL_CONTENT_ARRAY_ITEMS = 200;
const MAX_OTEL_LOG_BODY_CHARS = 4 * 1024;
@@ -138,6 +139,7 @@ type SessionRecoveryDiagnosticEvent = Extract<
{ type: "session.recovery.requested" | "session.recovery.completed" }
>;
type TalkDiagnosticEvent = Extract<DiagnosticEventPayload, { type: "talk.event" }>;
type SecuritySeverityText = "FATAL" | "ERROR" | "WARN" | "INFO";
type TrustedSpanAliasOwner = { kind: "run"; id: string };
const NO_CONTENT_CAPTURE: OtelContentCapturePolicy = {
@@ -318,6 +320,18 @@ function lowCardinalityAttr(value: string | undefined, fallback = "unknown"): st
return LOW_CARDINALITY_VALUE_RE.test(redacted) ? redacted : fallback;
}
function securityTargetNameAttr(value: string | undefined, fallback = "unknown"): string {
if (!value) {
return fallback;
}
const redacted = redactSensitiveText(value.trim());
const redactedLower = redacted.toLowerCase();
if (redactedLower.startsWith("agent:") || redactedLower.includes(":agent:")) {
return fallback;
}
return SECURITY_TARGET_NAME_VALUE_RE.test(redacted) ? redacted : fallback;
}
function lowCardinalityQueueLaneAttr(value: string | undefined, fallback = "unknown"): string {
if (!value) {
return fallback;
@@ -1009,6 +1023,173 @@ function assignOtelLogEventAttributes(
}
}
function assignOtelSecurityEventAttributes(
attributes: Record<string, string | number | boolean>,
eventAttributes: Record<string, string | number | boolean> | undefined,
): void {
if (!eventAttributes) {
return;
}
for (const rawKey in eventAttributes) {
if (Object.keys(attributes).length >= MAX_OTEL_LOG_ATTRIBUTE_COUNT) {
break;
}
if (!Object.hasOwn(eventAttributes, rawKey)) {
continue;
}
const key = rawKey.trim();
if (BLOCKED_OTEL_LOG_ATTRIBUTE_KEYS.has(key)) {
continue;
}
if (redactSensitiveText(key) !== key) {
continue;
}
if (!OTEL_LOG_RAW_ATTRIBUTE_KEY_RE.test(key)) {
continue;
}
const value = eventAttributes[rawKey];
assignOtelLogAttribute(
attributes,
`openclaw.security.attribute.${key}`,
typeof value === "string" ? lowCardinalityAttr(value) : value,
);
}
}
function securitySeverityText(
severity: Extract<DiagnosticEventPayload, { type: "security.event" }>["severity"],
): SecuritySeverityText {
switch (severity) {
case "critical":
return "FATAL";
case "high":
return "ERROR";
case "medium":
return "WARN";
case "info":
case "low":
return "INFO";
}
const unreachable: never = severity;
return unreachable;
}
function assignOtelSecurityAttributes(
attributes: Record<string, string | number | boolean>,
evt: Extract<DiagnosticEventPayload, { type: "security.event" }>,
): void {
assignOtelLogAttribute(attributes, "openclaw.security.event_id", evt.eventId);
assignOtelLogAttribute(attributes, "openclaw.security.category", evt.category);
assignOtelLogAttribute(attributes, "openclaw.security.action", lowCardinalityAttr(evt.action));
assignOtelLogAttribute(attributes, "openclaw.security.outcome", evt.outcome);
assignOtelLogAttribute(attributes, "openclaw.security.severity", evt.severity);
if (evt.reason) {
assignOtelLogAttribute(attributes, "openclaw.security.reason", lowCardinalityAttr(evt.reason));
}
if (evt.actor) {
assignOtelLogAttribute(attributes, "openclaw.security.actor.kind", evt.actor.kind);
if (evt.actor.idHash) {
assignOtelLogAttribute(
attributes,
"openclaw.security.actor.id_hash",
lowCardinalityAttr(evt.actor.idHash),
);
}
if (evt.actor.deviceIdHash) {
assignOtelLogAttribute(
attributes,
"openclaw.security.actor.device_id_hash",
lowCardinalityAttr(evt.actor.deviceIdHash),
);
}
if (evt.actor.channel) {
assignOtelLogAttribute(
attributes,
"openclaw.security.actor.channel",
lowCardinalityAttr(evt.actor.channel),
);
}
if (evt.actor.role) {
assignOtelLogAttribute(
attributes,
"openclaw.security.actor.role",
lowCardinalityAttr(evt.actor.role),
);
}
if (evt.actor.scopes?.length) {
assignOtelLogAttribute(
attributes,
"openclaw.security.actor.scopes",
evt.actor.scopes.map((scope) => lowCardinalityAttr(scope)).join(","),
);
}
}
if (evt.target) {
assignOtelLogAttribute(attributes, "openclaw.security.target.kind", evt.target.kind);
if (evt.target.idHash) {
assignOtelLogAttribute(
attributes,
"openclaw.security.target.id_hash",
lowCardinalityAttr(evt.target.idHash),
);
}
if (evt.target.name) {
assignOtelLogAttribute(
attributes,
"openclaw.security.target.name",
securityTargetNameAttr(evt.target.name),
);
}
if (evt.target.owner) {
assignOtelLogAttribute(
attributes,
"openclaw.security.target.owner",
lowCardinalityAttr(evt.target.owner),
);
}
}
if (evt.policy) {
if (evt.policy.id) {
assignOtelLogAttribute(
attributes,
"openclaw.security.policy.id",
lowCardinalityAttr(evt.policy.id),
);
}
if (evt.policy.decision) {
assignOtelLogAttribute(
attributes,
"openclaw.security.policy.decision",
evt.policy.decision,
);
}
if (evt.policy.reason) {
assignOtelLogAttribute(
attributes,
"openclaw.security.policy.reason",
lowCardinalityAttr(evt.policy.reason),
);
}
}
if (evt.control) {
if (evt.control.id) {
assignOtelLogAttribute(
attributes,
"openclaw.security.control.id",
lowCardinalityAttr(evt.control.id),
);
}
if (evt.control.family) {
assignOtelLogAttribute(
attributes,
"openclaw.security.control.family",
evt.control.family,
);
}
}
assignOtelSecurityEventAttributes(attributes, evt.attributes);
}
function traceFlagsToOtel(traceFlags: string | undefined): TraceFlags {
const parsed = Number.parseInt(traceFlags ?? "00", 16);
return (parsed & TraceFlags.SAMPLED) !== 0 ? TraceFlags.SAMPLED : TraceFlags.NONE;
@@ -1585,6 +1766,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
metadata: DiagnosticEventMetadata,
) => void)
| undefined;
let recordSecurityEvent:
| ((
evt: Extract<DiagnosticEventPayload, { type: "security.event" }>,
metadata: DiagnosticEventMetadata,
) => void)
| undefined;
if (logsEnabled) {
let logRecordExportFailureLastReportedAt = Number.NEGATIVE_INFINITY;
const logExporter = new OTLPLogExporter({
@@ -1662,6 +1849,47 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
}
}
};
recordSecurityEvent = (evt, metadata) => {
if (!metadata.trusted) {
return;
}
try {
const severityText = securitySeverityText(evt.severity);
const attributes = Object.create(null) as Record<string, string | number | boolean>;
assignOtelSecurityAttributes(attributes, evt);
const logRecord: LogRecord = {
body: "openclaw.security.event",
severityText,
severityNumber: logSeverityMap[severityText] ?? (9 as SeverityNumber),
attributes: redactOtelAttributes(attributes),
timestamp: evt.ts,
};
const logContext = contextForTrustedTraceContext(evt, metadata);
if (logContext) {
logRecord.context = logContext;
}
otelLogger.emit(logRecord);
} catch (err) {
emitExporterEvent({
exporter: "diagnostics-otel",
signal: "logs",
status: "failure",
reason: "emit_failed",
errorCategory: errorCategory(err),
});
const now = Date.now();
if (
now - logRecordExportFailureLastReportedAt >=
LOG_RECORD_EXPORT_FAILURE_REPORT_INTERVAL_MS
) {
logRecordExportFailureLastReportedAt = now;
ctx.logger.error(
`diagnostics-otel: security event export failed: ${formatError(err)}`,
);
}
}
};
}
const spanWithDuration = (
@@ -3443,6 +3671,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
case "log.record":
recordLogRecord?.(evt, metadata);
return;
case "security.event":
recordSecurityEvent?.(evt, metadata);
return;
case "tool.loop":
recordToolLoop(evt);
return;

View File

@@ -3057,8 +3057,8 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
const lastUpdate = draftStream.update.mock.calls.at(-1)?.[0];
expect(lastUpdate).toContain("install dependencies");
expect(lastUpdate).not.toContain("completed");
expect(lastUpdate).toContain("completed");
expect(lastUpdate).not.toContain("install dependencies");
});
it("drops later tool warning finals after progress preview final replies", async () => {

View File

@@ -36,6 +36,78 @@
"ws": "^8.19.0"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -363,12 +435,23 @@
}
},
"node_modules/protobufjs": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.1.tgz",
"integrity": "sha512-oXf2UgIty8jnwfN4yvL1x79VLhL5uiKjZJbSGXGCIUmHmItTP4eS/UIlWDCeNx3seg+ujfn9vDlPMSrsh7wO+Q==",
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.3.tgz",
"integrity": "sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.3.2"
},
"engines": {
@@ -497,6 +580,12 @@
"integrity": "sha512-vj0afVtOfLQvv0GR0VxVagYxsXN64btL7Z9XoaG0ZggH3mruMMkOO6hXdgMsjCY3shZgEvooAWVeznQVs5c43w==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",

View File

@@ -424,6 +424,7 @@ async function dispatchMessage(params: {
currentCfg?: ClawdbotConfig;
event: FeishuMessageEvent;
channelRuntime?: PluginRuntime["channel"];
botOpenId?: string;
}) {
const runtime = createRuntimeEnv();
const feishuConfig = params.cfg.channels?.feishu;
@@ -444,6 +445,7 @@ async function dispatchMessage(params: {
await handleFeishuMessage({
cfg,
event: params.event,
botOpenId: params.botOpenId,
runtime,
channelRuntime: params.channelRuntime,
});
@@ -4164,6 +4166,150 @@ describe("handleFeishuMessage command authorization", () => {
// No reply should be dispatched: empty message is silently skipped
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
});
it("does not drop empty-text message when it quotes a parent message (#90177)", async () => {
// A Feishu reply containing only @bot (no additional text) was being
// dropped before the quoted message content was fetched. The handler
// should fetch quoted content first and only skip if all of current
// text, media, and quoted content are empty.
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockGetMessageFeishu.mockResolvedValueOnce({
messageId: "om_quoted_001",
chatId: "oc-dm",
content: "quoted message content from parent",
contentType: "text",
});
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-reply-only-bot",
},
},
message: {
message_id: "msg-empty-with-quote",
parent_id: "om_quoted_001",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
// Empty text — only @bot mention, no additional content
content: JSON.stringify({ text: "" }),
},
};
await dispatchMessage({ cfg, event });
// A reply should be dispatched because quoted content provides context
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
it("dispatches mention-only group reply with quoted content in requireMention:true group (#90177)", async () => {
// #90177 is specifically about group chats. The empty-message drop happens
// after the group admission/mention gate, so the fix must also work when
// the sender mentions the bot in a requireMention:true group and quotes a
// parent message with meaningful content — the reply should dispatch with
// the quoted text in the body.
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockGetMessageFeishu.mockResolvedValueOnce({
messageId: "om_group_quoted_001",
chatId: "oc-group-90177",
content: "parent message with context",
contentType: "text",
});
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groupPolicy: "open",
groups: {
"oc-group-90177": {
requireMention: true,
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-group-sender",
},
},
message: {
message_id: "msg-group-empty-with-quote",
parent_id: "om_group_quoted_001",
chat_id: "oc-group-90177",
chat_type: "group",
message_type: "text",
// Empty text — only @bot mention, no additional content
content: JSON.stringify({ text: "" }),
// Bot mention so the message passes the requireMention gate
mentions: [
{ key: "@_bot_1", id: { open_id: "ou-bot-90177" }, name: "Bot", tenant_key: "" },
],
},
};
await dispatchMessage({ cfg, event, botOpenId: "ou-bot-90177" });
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
const context = mockCallArg<{ Body?: string }>(mockFinalizeInboundContext, 0, 0);
expect(context.Body).toContain("[Replying to:");
expect(context.Body).toContain("parent message with context");
});
it("does not over-fetch quoted message for unmentioned empty reply in requireMention:true group (#90177)", async () => {
// An empty-text reply that quotes a parent but does NOT mention the bot
// in a requireMention:true group should be rejected at the mention gate
// before the quoted message is fetched, so getMessageFeishu is never
// called and nothing is dispatched.
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groupPolicy: "open",
groups: {
"oc-group-90177-neg": {
requireMention: true,
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-group-sender-neg",
},
},
message: {
message_id: "msg-group-unmentioned-empty-quote",
parent_id: "om_group_quoted_neg",
chat_id: "oc-group-90177-neg",
chat_type: "group",
message_type: "text",
// Empty text with no bot mention
content: JSON.stringify({ text: "" }),
},
};
await dispatchMessage({ cfg, event, botOpenId: "ou-bot-90177-neg" });
expect(mockGetMessageFeishu).not.toHaveBeenCalled();
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
});
});
describe("createFeishuMessageReceiveHandler media dedupe", () => {

View File

@@ -1026,15 +1026,57 @@ export async function handleFeishuMessage(params: {
log,
accountId: account.accountId,
});
// Skip messages with no text content and no media attachments. Feishu can
// deliver empty-text events (e.g. `{"text":""}`) when a user sends a blank
// message or when media parsing produces an empty string. Writing a blank
// user turn to the session causes downstream LLM providers (e.g. MiniMax)
// to reject the request with "messages must not be empty" errors. Logging
// the skip avoids silent loss without polluting the agent session.
if (!ctx.content.trim() && mediaList.length === 0) {
// Fetch quoted/replied message content before the empty-message guard
// so a reply with only @bot (no text, no media) is not dropped when
// the quoted message carries meaningful content.
let quotedMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> = null;
let quotedContent: string | undefined;
if (ctx.parentId) {
try {
quotedMessageInfo = await getMessageFeishu({
cfg,
messageId: ctx.parentId,
accountId: account.accountId,
});
if (
quotedMessageInfo &&
(await shouldIncludeFetchedGroupContextMessage({
cfg,
accountId: account.accountId,
chatId: ctx.chatId,
isGroup,
allowFrom: effectiveGroupSenderAllowFrom,
mode: contextVisibilityMode,
kind: "quote",
senderId: quotedMessageInfo.senderId,
senderType: quotedMessageInfo.senderType,
}))
) {
quotedContent = quotedMessageInfo.content;
log(
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
);
} else if (quotedMessageInfo) {
log(
`feishu[${account.accountId}]: skipped quoted message from sender ${quotedMessageInfo.senderId ?? "unknown"} (mode=${contextVisibilityMode})`,
);
}
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
}
}
// Skip messages with no text content, no media attachments, and no quoted
// content. Feishu can deliver empty-text events (e.g. `{"text":""}`) when
// a user sends a blank message or when media parsing produces an empty
// string. Writing a blank user turn to the session causes downstream LLM
// providers (e.g. MiniMax) to reject the request with "messages must not
// be empty" errors. Logging the skip avoids silent loss without polluting
// the agent session. Quoted content is checked too so a reply-only @bot
// with quoted context is not dropped.
if (!ctx.content.trim() && mediaList.length === 0 && !quotedContent?.trim()) {
log(
`feishu[${account.accountId}]: skipping empty message (no text, no media) from ${ctx.senderOpenId}`,
`feishu[${account.accountId}]: skipping empty message (no text, no media, no quoted) from ${ctx.senderOpenId}`,
);
return;
}
@@ -1107,44 +1149,6 @@ export async function handleFeishuMessage(params: {
).commandAccess.authorized
: undefined;
// Fetch quoted/replied message content if parentId exists
let quotedMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> = null;
let quotedContent: string | undefined;
if (ctx.parentId) {
try {
quotedMessageInfo = await getMessageFeishu({
cfg,
messageId: ctx.parentId,
accountId: account.accountId,
});
if (
quotedMessageInfo &&
(await shouldIncludeFetchedGroupContextMessage({
cfg,
accountId: account.accountId,
chatId: ctx.chatId,
isGroup,
allowFrom: effectiveGroupSenderAllowFrom,
mode: contextVisibilityMode,
kind: "quote",
senderId: quotedMessageInfo.senderId,
senderType: quotedMessageInfo.senderType,
}))
) {
quotedContent = quotedMessageInfo.content;
log(
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
);
} else if (quotedMessageInfo) {
log(
`feishu[${account.accountId}]: skipped quoted message from sender ${quotedMessageInfo.senderId ?? "unknown"} (mode=${contextVisibilityMode})`,
);
}
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
}
}
const isTopicSessionForThread =
isGroup &&
(groupSession?.groupSessionScope === "group_topic" ||

View File

@@ -7,7 +7,7 @@ export const FEISHU_HTTP_TIMEOUT_MS = 30_000;
export const FEISHU_HTTP_TIMEOUT_MAX_MS = 300_000;
export const FEISHU_HTTP_TIMEOUT_ENV_VAR = "OPENCLAW_FEISHU_HTTP_TIMEOUT_MS";
export type FeishuClientTimeoutConfig = {
type FeishuClientTimeoutConfig = {
httpTimeoutMs?: number;
config?: Pick<FeishuConfig, "httpTimeoutMs">;
};

View File

@@ -258,7 +258,39 @@ describe("getMessageFeishu", () => {
}),
},
});
expect(result).toEqual({ messageId: "om_mentions", chatId: "oc_send" });
expect(typeof result.receipt.sentAt).toBe("number");
expect(result).toEqual({
messageId: "om_mentions",
chatId: "oc_send",
receipt: {
primaryPlatformMessageId: "om_mentions",
platformMessageIds: ["om_mentions"],
parts: [
{
platformMessageId: "om_mentions",
kind: "text",
index: 0,
raw: {
channel: "feishu",
messageId: "om_mentions",
chatId: "oc_send",
conversationId: "oc_send",
},
threadId: "oc_send",
},
],
threadId: "oc_send",
sentAt: result.receipt.sentAt,
raw: [
{
channel: "feishu",
messageId: "om_mentions",
chatId: "oc_send",
conversationId: "oc_send",
},
],
},
});
});
it("extracts text content from interactive card elements", async () => {

View File

@@ -1543,9 +1543,9 @@
}
},
"node_modules/tar": {
"version": "7.5.15",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
"version": "7.5.16",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz",
"integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",

View File

@@ -858,6 +858,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
extra: {
botTokenSource: account.botTokenSource,
baseUrl: account.baseUrl,
dmPolicy: account.config.dmPolicy ?? "pairing",
connected: runtime?.connected ?? false,
lastConnectedAt: runtime?.lastConnectedAt ?? null,
lastDisconnect: runtime?.lastDisconnect ?? null,

View File

@@ -30,6 +30,21 @@ describe("MattermostConfigSchema", () => {
expect(result.success).toBe(true);
});
it('rejects dmPolicy="open" without wildcard allowFrom', () => {
const result = MattermostConfigSchema.safeParse({
dmPolicy: "open",
});
expect(result.success).toBe(false);
});
it('accepts dmPolicy="open" with wildcard allowFrom', () => {
const result = MattermostConfigSchema.safeParse({
dmPolicy: "open",
allowFrom: ["*"],
});
expect(result.success).toBe(true);
});
it("accepts documented streaming modes and progress config", () => {
const result = MattermostConfigSchema.safeParse({
streaming: {

View File

@@ -11,6 +11,7 @@ vi.mock("./runtime-api.js", () => ({
describe("mattermost monitor auth", () => {
let authorizeMattermostCommandInvocation: typeof import("./monitor-auth.js").authorizeMattermostCommandInvocation;
let formatMattermostDirectMessageDropLog: typeof import("./monitor-auth.js").formatMattermostDirectMessageDropLog;
let isMattermostSenderAllowed: typeof import("./monitor-auth.js").isMattermostSenderAllowed;
let normalizeMattermostAllowEntry: typeof import("./monitor-auth.js").normalizeMattermostAllowEntry;
let normalizeMattermostAllowList: typeof import("./monitor-auth.js").normalizeMattermostAllowList;
@@ -18,6 +19,7 @@ describe("mattermost monitor auth", () => {
beforeAll(async () => {
({
authorizeMattermostCommandInvocation,
formatMattermostDirectMessageDropLog,
isMattermostSenderAllowed,
normalizeMattermostAllowEntry,
normalizeMattermostAllowList,
@@ -58,6 +60,18 @@ describe("mattermost monitor auth", () => {
});
});
it("formats direct-message drops with the ingress reason and open-policy hint", () => {
expect(
formatMattermostDirectMessageDropLog({
senderId: "alice-id",
dmPolicy: "open",
reasonCode: "dm_policy_not_allowlisted",
}),
).toBe(
"mattermost: drop dm sender=alice-id (dmPolicy=open reason=dm_policy_not_allowlisted hint=add-allowFrom-wildcard)",
);
});
it("resolves direct command authorization from shared ingress", async () => {
isDangerousNameMatchingEnabled.mockReturnValue(false);
resolveAllowlistMatchSimple.mockReturnValue({ allowed: false });

View File

@@ -61,6 +61,19 @@ export function normalizeMattermostAllowList(entries: Array<string | number>): s
return uniqueStrings(normalized);
}
export function formatMattermostDirectMessageDropLog(params: {
senderId: string;
dmPolicy: string;
reasonCode?: string;
}): string {
const reason = params.reasonCode ? ` reason=${params.reasonCode}` : "";
const hint =
params.dmPolicy === "open" && params.reasonCode === "dm_policy_not_allowlisted"
? " hint=add-allowFrom-wildcard"
: "";
return `mattermost: drop dm sender=${params.senderId} (dmPolicy=${params.dmPolicy}${reason}${hint})`;
}
export function isMattermostSenderAllowed(params: {
senderId: string;
senderName?: string;

View File

@@ -57,6 +57,7 @@ import {
} from "./model-picker.js";
import {
authorizeMattermostCommandInvocation,
formatMattermostDirectMessageDropLog,
normalizeMattermostAllowEntry,
resolveMattermostMonitorInboundAccess,
} from "./monitor-auth.js";
@@ -1391,7 +1392,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
}
return;
}
logVerboseMessage(`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`);
logVerboseMessage(
formatMattermostDirectMessageDropLog({
senderId,
dmPolicy,
reasonCode: accessDecision.senderAccess.reasonCode,
}),
);
return;
}
if (accessDecision.ingress.reasonCode === "group_policy_disabled") {

View File

@@ -94,12 +94,57 @@ function createActivityHandler() {
return { handler, run };
}
async function runAdaptiveCardInvoke(
registered: MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
},
value: unknown,
) {
await registered.run({
activity: {
id: "invoke-1",
type: "invoke",
name: "adaptiveCard/action",
channelId: "msteams",
serviceUrl: "https://service.example.test",
from: {
id: "user-bf",
aadObjectId: "user-aad",
name: "User",
},
recipient: {
id: "bot-id",
name: "Bot",
},
conversation: {
id: "19:personal-chat;messageid=abc123",
conversationType: "personal",
},
channelData: {},
attachments: [],
value,
},
sendActivity: vi.fn(async () => ({ id: "activity-id" })),
sendActivities: async () => [],
} as unknown as MSTeamsTurnContext);
}
function lastDispatchedCtxPayload(): Record<string, unknown> {
const dispatched = runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mock.calls.at(
-1,
)?.[0] as { ctxPayload?: Record<string, unknown> } | undefined;
if (!dispatched?.ctxPayload) {
throw new Error("expected dispatched context payload");
}
return dispatched.ctxPayload;
}
describe("msteams adaptive card action invoke", () => {
beforeEach(() => {
runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mockClear();
});
it("forwards adaptive card invoke values to the agent as message text", async () => {
it("forwards adaptive card submitted data to the agent as message text", async () => {
const deps = createDeps();
const { handler, run } = createActivityHandler();
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
@@ -116,44 +161,117 @@ describe("msteams adaptive card action invoke", () => {
trigger: "button-click",
};
await registered.run({
activity: {
id: "invoke-1",
type: "invoke",
name: "adaptiveCard/action",
channelId: "msteams",
serviceUrl: "https://service.example.test",
from: {
id: "user-bf",
aadObjectId: "user-aad",
name: "User",
},
recipient: {
id: "bot-id",
name: "Bot",
},
conversation: {
id: "19:personal-chat;messageid=abc123",
conversationType: "personal",
},
channelData: {},
attachments: [],
value: payload,
},
sendActivity: vi.fn(async () => ({ id: "activity-id" })),
sendActivities: async () => [],
} as unknown as MSTeamsTurnContext);
await runAdaptiveCardInvoke(registered, payload);
expect(run).not.toHaveBeenCalled();
expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).toHaveBeenCalledTimes(
1,
);
const dispatched = runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mock
.calls[0]?.[0] as { ctxPayload?: Record<string, unknown> } | undefined;
expect(dispatched?.ctxPayload?.RawBody).toBe(JSON.stringify(payload));
expect(dispatched?.ctxPayload?.BodyForAgent).toBe(JSON.stringify(payload));
expect(dispatched?.ctxPayload?.CommandBody).toBe(JSON.stringify(payload));
expect(dispatched?.ctxPayload?.SessionKey).toBe("msteams:direct:user-aad");
expect(dispatched?.ctxPayload?.SenderId).toBe("user-aad");
const expectedBody = JSON.stringify(payload.action.data);
const ctxPayload = lastDispatchedCtxPayload();
expect(ctxPayload.RawBody).toBe(expectedBody);
expect(ctxPayload.BodyForAgent).toBe(expectedBody);
expect(ctxPayload.CommandBody).toBe(expectedBody);
expect(ctxPayload.SessionKey).toBe("msteams:direct:user-aad");
expect(ctxPayload.SenderId).toBe("user-aad");
});
it("routes Teams imBack actions as the submitted message text", async () => {
const deps = createDeps();
const { handler } = createActivityHandler();
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
await runAdaptiveCardInvoke(registered, {
action: {
type: "Action.Submit",
data: { msteams: { type: "imBack", value: "Summarize my last meeting" } },
},
});
const ctxPayload = lastDispatchedCtxPayload();
expect(ctxPayload.BodyForAgent).toBe("Summarize my last meeting");
expect(ctxPayload.CommandBody).toBe("Summarize my last meeting");
});
it("routes typed command submit actions as command text", async () => {
const deps = createDeps();
const { handler } = createActivityHandler();
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
await runAdaptiveCardInvoke(registered, {
action: {
type: "Action.Submit",
data: "/codex plugins menu",
},
});
const ctxPayload = lastDispatchedCtxPayload();
expect(ctxPayload.BodyForAgent).toBe("/codex plugins menu");
expect(ctxPayload.CommandBody).toBe("/codex plugins menu");
});
it("preserves legacy presentation submit values as structured data", async () => {
const deps = createDeps();
const { handler } = createActivityHandler();
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
const data = { value: "/codex permissions yolo", label: "Run" };
await runAdaptiveCardInvoke(registered, {
action: {
type: "Action.Submit",
data,
},
});
const ctxPayload = lastDispatchedCtxPayload();
expect(ctxPayload.BodyForAgent).toBe(JSON.stringify(data));
expect(ctxPayload.CommandBody).toBe(JSON.stringify(data));
});
it("preserves arbitrary submitted data with a value field", async () => {
const deps = createDeps();
const { handler } = createActivityHandler();
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
const data = { value: "selected", formId: "deploy-approval", choices: ["canary"] };
await runAdaptiveCardInvoke(registered, {
action: {
type: "Action.Submit",
data,
},
});
const ctxPayload = lastDispatchedCtxPayload();
expect(ctxPayload.BodyForAgent).toBe(JSON.stringify(data));
expect(ctxPayload.CommandBody).toBe(JSON.stringify(data));
});
it("preserves generic Action.Execute verb metadata", async () => {
const deps = createDeps();
const { handler } = createActivityHandler();
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
const payload = {
action: {
type: "Action.Execute",
verb: "ticket.approve",
data: { ticketId: "ticket-123" },
},
};
await runAdaptiveCardInvoke(registered, payload);
const ctxPayload = lastDispatchedCtxPayload();
expect(ctxPayload.BodyForAgent).toBe(JSON.stringify(payload));
expect(ctxPayload.CommandBody).toBe(JSON.stringify(payload));
});
});

View File

@@ -1,5 +1,9 @@
// Msteams plugin module implements monitor handler behavior.
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
isRecord,
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { formatUnknownError } from "./errors.js";
import { resolveMSTeamsSenderAccess } from "./monitor-handler/access.js";
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
@@ -25,16 +29,43 @@ export type MSTeamsActivityHandler = {
run?: (context: unknown) => Promise<void>;
};
function extractAdaptiveCardSubmittedData(value: unknown): unknown {
if (!isRecord(value)) {
return value;
}
const action = isRecord(value.action) ? value.action : undefined;
if (action && normalizeOptionalLowercaseString(action.type) === "action.submit" && "data" in action) {
return action.data;
}
return value;
}
function readMSTeamsImBackValue(value: unknown): string | null {
if (!isRecord(value)) {
return null;
}
const msteams = isRecord(value.msteams) ? value.msteams : undefined;
if (!msteams || normalizeOptionalLowercaseString(msteams.type) !== "imback") {
return null;
}
return normalizeOptionalString(msteams.value) ?? null;
}
function serializeAdaptiveCardActionValue(value: unknown): string | null {
if (typeof value === "string") {
const trimmed = value.trim();
const submittedValue = extractAdaptiveCardSubmittedData(value);
if (typeof submittedValue === "string") {
const trimmed = submittedValue.trim();
return trimmed ? trimmed : null;
}
if (value === undefined) {
const imBackValue = readMSTeamsImBackValue(submittedValue);
if (imBackValue) {
return imBackValue;
}
if (submittedValue == null) {
return null;
}
try {
return JSON.stringify(value);
return JSON.stringify(submittedValue);
} catch {
return null;
}

View File

@@ -75,6 +75,7 @@ vi.mock("./model-selection.runtime.js", () => ({
import { resolveRepoRelativeOutputDir } from "./cli-paths.js";
import {
runQaLabSelfCheckCommand,
runQaCredentialsAddCommand,
runQaDockerBuildImageCommand,
runQaDockerScaffoldCommand,
runQaDockerUpCommand,
@@ -2188,6 +2189,30 @@ describe("qa cli runtime", () => {
expectWriteContains(stdoutWrite, "QA self-check report: /tmp/failed-report.md");
});
it("rejects oversized credential payload files before broker setup", async () => {
const previousMaxBytes = process.env.OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES;
const payloadPath = path.join(suiteArtifactsDir, "oversized-credential.json");
await fs.writeFile(payloadPath, JSON.stringify({ blob: "x".repeat(64) }), "utf8");
process.env.OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES = "32";
try {
await expect(
runQaCredentialsAddCommand({
kind: "telegram",
payloadFile: payloadPath,
}),
).rejects.toThrow(
"Payload file exceeds OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES (32 bytes).",
);
} finally {
if (previousMaxBytes === undefined) {
delete process.env.OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES;
} else {
process.env.OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES = previousMaxBytes;
}
}
});
it("resolves docker scaffold paths relative to the explicit repo root", async () => {
await runQaDockerScaffoldCommand({
repoRoot: "/tmp/openclaw-repo",

View File

@@ -2,6 +2,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
buildQaAgenticParityComparison,
@@ -90,6 +91,8 @@ import {
} from "./tool-coverage-report.js";
const QA_SUITE_INFRA_RETRY_LIMIT = 1;
const QA_CREDENTIAL_PAYLOAD_MAX_BYTES_ENV = "OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES";
const DEFAULT_QA_CREDENTIAL_PAYLOAD_MAX_BYTES = 64 * 1024 * 1024;
const QA_SUITE_INFRA_RETRY_NETWORK_ERROR_CODES = new Set([
"ECONNRESET",
"ECONNREFUSED",
@@ -543,7 +546,29 @@ async function runInterruptibleServer(label: string, server: InterruptibleServer
await new Promise(() => {});
}
function resolveQaCredentialPayloadFileMaxBytes(env: NodeJS.ProcessEnv = process.env) {
const raw = env[QA_CREDENTIAL_PAYLOAD_MAX_BYTES_ENV]?.trim();
if (!raw) {
return DEFAULT_QA_CREDENTIAL_PAYLOAD_MAX_BYTES;
}
const parsed = parseStrictPositiveInteger(raw);
if (parsed === undefined) {
throw new Error(`${QA_CREDENTIAL_PAYLOAD_MAX_BYTES_ENV} must be a positive integer.`);
}
return parsed;
}
async function readQaCredentialPayloadFile(filePath: string) {
const maxBytes = resolveQaCredentialPayloadFileMaxBytes();
const stat = await fs.stat(filePath);
if (!stat.isFile()) {
throw new Error("Payload file must be a regular JSON file.");
}
if (stat.size > maxBytes) {
throw new Error(
`Payload file exceeds ${QA_CREDENTIAL_PAYLOAD_MAX_BYTES_ENV} (${maxBytes} bytes).`,
);
}
const text = await fs.readFile(filePath, "utf8");
let payload: unknown;
try {

View File

@@ -60,27 +60,6 @@
"secretInput": {
"anyOf": [{ "type": "string", "minLength": 1 }, { "$ref": "#/$defs/secretRef" }]
},
"group": {
"type": "object",
"additionalProperties": true,
"properties": {
"requireMention": { "type": "boolean" },
"commandLevel": {
"type": "string",
"enum": ["all", "safety", "strict"]
},
"ignoreOtherMentions": { "type": "boolean" },
"historyLimit": { "type": "number" },
"name": { "type": "string" },
"prompt": { "type": "string" }
}
},
"groups": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/group"
}
},
"account": {
"type": "object",
"additionalProperties": true,
@@ -128,8 +107,7 @@
}
}
]
},
"groups": { "$ref": "#/$defs/groups" }
}
}
}
},
@@ -185,8 +163,7 @@
"$ref": "#/$defs/account"
}
},
"defaultAccount": { "type": "string" },
"groups": { "$ref": "#/$defs/groups" }
"defaultAccount": { "type": "string" }
}
}
}

View File

@@ -11,7 +11,6 @@
import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
import type { SlashCommandContext } from "../../engine/commands/slash-commands.js";
import type { QQBotGroupCommandLevel } from "../../engine/config/group.js";
import type { ResolvedQQBotAccount } from "../../types.js";
import type { QQBotFromParseResult } from "./from-parser.js";
@@ -33,7 +32,6 @@ interface BuildFrameworkSlashContextInput {
account: ResolvedQQBotAccount;
from: QQBotFromParseResult;
commandName: string;
groupCommandLevel?: QQBotGroupCommandLevel;
}
export function buildFrameworkSlashContext({
@@ -41,7 +39,6 @@ export function buildFrameworkSlashContext({
account,
from,
commandName,
groupCommandLevel,
}: BuildFrameworkSlashContextInput): SlashCommandContext {
const args = ctx.args ?? "";
const rawContent = args ? `/${commandName} ${args}` : `/${commandName}`;
@@ -58,7 +55,6 @@ export function buildFrameworkSlashContext({
appId: account.appId,
accountConfig: account.config as unknown as Record<string, unknown>,
commandAuthorized: ctx.isAuthorizedSender,
groupCommandLevel,
queueSnapshot: { ...DEFAULT_QUEUE_SNAPSHOT },
};
}

View File

@@ -94,25 +94,9 @@ describe("registerQQBotFrameworkCommands", () => {
createCommandContext(config, "qqbot:group:GROUP_OPENID"),
);
expect(missingFromResult).toEqual({ text: "该命令仅限私聊使用,请在私聊中发送。" });
expect(nonQQBotResult).toEqual({ text: "该命令仅限私聊使用,请在私聊中发送。" });
expect(groupResult).toEqual({ text: "该命令仅限私聊使用,请在私聊中发送。" });
expect(writes).toHaveLength(0);
});
it("keeps private-only framework commands private when command level is all", async () => {
const config = createConfig();
const qqbot = config.channels?.qqbot as Record<string, unknown>;
qqbot.groups = {
GROUP_OPENID: { commandLevel: "all" },
};
const writes: OpenClawConfig[] = [];
installCommandRuntime(config, writes);
const command = findCommand(registerCommands(), "bot-streaming");
const result = await command.handler(createCommandContext(config, "qqbot:group:GROUP_OPENID"));
expect(result).toEqual({ text: "该命令仅限私聊使用,请在私聊中发送。" });
expect(missingFromResult).toEqual({ text: "💡 请在私聊中使用此指令" });
expect(nonQQBotResult).toEqual({ text: "💡 请在私聊中使用此指令" });
expect(groupResult).toEqual({ text: "💡 请在私聊中使用此指令" });
expect(writes).toHaveLength(0);
});

View File

@@ -12,14 +12,14 @@
*/
import type { OpenClawPluginApi, PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
import { PRIVATE_CHAT_ONLY_TEXT } from "../../engine/commands/command-visibility.js";
import { getFrameworkCommands } from "../../engine/commands/slash-commands-impl.js";
import { resolveGroupCommandLevelFromAccountConfig } from "../../engine/config/group.js";
import { resolveQQBotAccount } from "../config.js";
import { buildFrameworkSlashContext } from "./framework-context-adapter.js";
import { parseQQBotFrom } from "./from-parser.js";
import { dispatchFrameworkSlashResult } from "./result-dispatcher.js";
const PRIVATE_CHAT_ONLY_TEXT = "💡 请在私聊中使用此指令";
function isExplicitQQBotC2cFrom(from: string | undefined | null): boolean {
const raw = (from ?? "").trim();
const stripped = raw.replace(/^qqbot:/iu, "");
@@ -41,25 +41,17 @@ export function registerQQBotFrameworkCommands(api: OpenClawPluginApi): void {
requireAuth: true,
acceptsArgs: true,
handler: async (ctx: PluginCommandContext) => {
const from = parseQQBotFrom(ctx.from);
const account = resolveQQBotAccount(ctx.config, ctx.accountId ?? undefined);
const groupCommandLevel =
from.msgType === "group" || from.msgType === "guild"
? resolveGroupCommandLevelFromAccountConfig(
account.config as unknown as Record<string, unknown>,
from.targetId,
)
: undefined;
if (cmd.c2cOnly && !isExplicitQQBotC2cFrom(ctx.from)) {
return { text: PRIVATE_CHAT_ONLY_TEXT };
}
const from = parseQQBotFrom(ctx.from);
const account = resolveQQBotAccount(ctx.config, ctx.accountId ?? undefined);
const slashCtx = buildFrameworkSlashContext({
ctx,
account,
from,
commandName: cmd.name,
groupCommandLevel,
});
const result = await cmd.handler(slashCtx);
return await dispatchFrameworkSlashResult({

View File

@@ -54,12 +54,10 @@ const QQBotExecApprovalsSchema = z
const QQBotDmPolicySchema = z.enum(["open", "allowlist", "disabled"]).optional();
const QQBotGroupPolicySchema = z.enum(["open", "allowlist", "disabled"]).optional();
const QQBotGroupCommandLevelSchema = z.enum(["all", "safety", "strict"]).optional();
const QQBotGroupSchema = z
.object({
requireMention: z.boolean().optional(),
commandLevel: QQBotGroupCommandLevelSchema,
ignoreOtherMentions: z.boolean().optional(),
historyLimit: z.number().optional(),
name: z.string().optional(),

View File

@@ -137,7 +137,6 @@ describe("qqbot config", () => {
groups: {
G1: {
requireMention: true,
commandLevel: "safety",
tools: { deny: ["*"] },
toolsBySender: {
"id:alice": { allow: ["read"] },
@@ -147,7 +146,7 @@ describe("qqbot config", () => {
accounts: {
bot2: {
groups: {
G1: { commandLevel: "strict", tools: { allow: [] } },
G1: { tools: { allow: [] } },
},
},
},

View File

@@ -1,84 +0,0 @@
// Qqbot tests cover group command visibility classification.
import { describe, expect, it } from "vitest";
import { classifyCoreCommandForGroup, parseSlashCommandName } from "./command-visibility.js";
describe("QQBot command visibility", () => {
it("parses slash command names case-insensitively", () => {
expect(parseSlashCommandName(" /NEW now ")).toBe("new");
expect(parseSlashCommandName("/CONFIG: show")).toBe("config");
expect(parseSlashCommandName("/configshow")).toBe("config");
expect(parseSlashCommandName("/config@bot show")).toBe("config");
expect(parseSlashCommandName("hello")).toBeUndefined();
});
it("keeps safe collaboration commands visible in groups", () => {
for (const command of ["/help", "/btw side question", "/stop"]) {
expect(classifyCoreCommandForGroup(command).visibility).toBe("group");
}
});
it("keeps group-session controls callable but hidden from group menus", () => {
for (const command of ["/new", "/reset", "/compact"]) {
expect(classifyCoreCommandForGroup(command).visibility).toBe("hidden");
}
});
it("marks sensitive core commands as private-only in groups", () => {
for (const command of [
"/config",
"/bash",
"/export-session",
"/diagnostics",
"/tts",
"/steer",
"/tell",
"/model",
"/models",
"/status",
"/verbose",
"/v",
"/config: show",
"/model@bot sonnet",
]) {
expect(classifyCoreCommandForGroup(command, "safety").visibility).toBe("private");
}
});
it("keeps omitted command level compatible with all mode", () => {
for (const command of ["/config", "/bash", "/new", "/status"]) {
expect(classifyCoreCommandForGroup(command).visibility).not.toBe("private");
}
});
it("allows every recognized core command in all mode", () => {
for (const command of ["/config", "/bash", "/new", "/status"]) {
expect(classifyCoreCommandForGroup(command, "all").visibility).not.toBe("private");
}
});
it("keeps urgent stop callable in strict mode", () => {
expect(classifyCoreCommandForGroup("/stop", "strict").visibility).toBe("group");
});
it("limits other core commands in strict mode", () => {
expect(classifyCoreCommandForGroup("/new", "strict").visibility).toBe("hidden");
expect(classifyCoreCommandForGroup("/reset", "strict").visibility).toBe("hidden");
expect(classifyCoreCommandForGroup("/status", "strict").visibility).toBe("private");
expect(classifyCoreCommandForGroup("/config", "strict").visibility).toBe("private");
});
it("keeps strict mode fail-closed for unclassified slash commands", () => {
expect(classifyCoreCommandForGroup("/bot-dynamic", "strict").visibility).toBe("private");
expect(classifyCoreCommandForGroup("/unknown", "strict").visibility).toBe("private");
});
it("does not make plugin and unknown slash commands private in all mode", () => {
expect(classifyCoreCommandForGroup("/bot-help").visibility).not.toBe("private");
expect(classifyCoreCommandForGroup("/unknown").visibility).not.toBe("private");
});
it("leaves plugin and unknown slash commands to their existing dispatch path in safety mode", () => {
expect(classifyCoreCommandForGroup("/bot-help", "safety").visibility).toBe("unknown");
expect(classifyCoreCommandForGroup("/unknown", "safety").visibility).toBe("unknown");
});
});

View File

@@ -1,118 +0,0 @@
// Qqbot plugin module classifies slash-command visibility for QQ group chats.
import type { QQBotGroupCommandLevel } from "../config/group.js";
export type GroupCommandVisibility = "group" | "hidden" | "private" | "unknown";
export const PRIVATE_CHAT_ONLY_TEXT = "该命令仅限私聊使用,请在私聊中发送。";
const GROUP_VISIBLE_CORE_COMMANDS = new Set(["help", "btw", "side", "stop"]);
const STRICT_CORE_COMMANDS = new Set(["new", "reset"]);
const GROUP_HIDDEN_CORE_COMMANDS = new Set([
"goal",
"usage",
"activation",
"send",
"reset",
"new",
"compact",
"think",
"thinking",
"t",
"fast",
"reasoning",
"reason",
"queue",
]);
const PRIVATE_ONLY_CORE_COMMANDS = new Set([
"commands",
"tools",
"skill",
"diagnostics",
"crestodian",
"tasks",
"allowlist",
"approve",
"context",
"export-session",
"export",
"export-trajectory",
"trajectory",
"tts",
"whoami",
"id",
"session",
"subagents",
"acp",
"focus",
"unfocus",
"agents",
"steer",
"tell",
"config",
"mcp",
"plugins",
"plugin",
"debug",
"status",
"restart",
"trace",
"verbose",
"v",
"elevated",
"elev",
"exec",
"model",
"models",
"bash",
]);
export function parseSlashCommandName(content: string | undefined | null): string | undefined {
const trimmed = (content ?? "").trim();
if (!trimmed.startsWith("/")) {
return undefined;
}
const firstToken = trimmed.slice(1).split(/\s+/, 1)[0]?.trim().toLowerCase() ?? "";
const commandName = firstToken.split(/[@:]/u, 1)[0] ?? "";
return commandName || undefined;
}
export function classifyCoreCommandForGroup(
content: string | undefined | null,
commandLevel: QQBotGroupCommandLevel = "all",
): {
commandName?: string;
visibility: GroupCommandVisibility;
} {
const commandName = parseSlashCommandName(content);
if (!commandName) {
return { visibility: "unknown" };
}
if (commandLevel === "all") {
return {
commandName,
visibility: GROUP_VISIBLE_CORE_COMMANDS.has(commandName) ? "group" : "hidden",
};
}
if (commandLevel === "strict") {
if (commandName === "stop") {
return { commandName, visibility: "group" };
}
if (STRICT_CORE_COMMANDS.has(commandName)) {
return { commandName, visibility: "hidden" };
}
return { commandName, visibility: "private" };
}
if (GROUP_VISIBLE_CORE_COMMANDS.has(commandName)) {
return { commandName, visibility: "group" };
}
if (GROUP_HIDDEN_CORE_COMMANDS.has(commandName)) {
return { commandName, visibility: "hidden" };
}
if (PRIVATE_ONLY_CORE_COMMANDS.has(commandName)) {
return { commandName, visibility: "private" };
}
return { commandName, visibility: "unknown" };
}

View File

@@ -27,27 +27,6 @@ function createStreamingMessage(): QueuedMessage {
};
}
function createGroupStopMessage(): QueuedMessage {
return {
type: "group",
senderId: "TRUSTED_OPENID",
content: "/stop",
messageId: "msg-stop",
timestamp: "2026-01-01T00:00:00.000Z",
groupOpenid: "GROUP_OPENID",
};
}
function createDmStopMessage(): QueuedMessage {
return {
type: "c2c",
senderId: "TRUSTED_OPENID",
content: "/stop",
messageId: "msg-stop-dm",
timestamp: "2026-01-01T00:00:00.000Z",
};
}
function createAccount(): GatewayAccount {
return {
accountId: "default",
@@ -61,10 +40,6 @@ function createAccount(): GatewayAccount {
};
}
function authorizeGroupCommands(account: GatewayAccount): void {
account.config.groupAllowFrom = ["TRUSTED_OPENID"];
}
describe("trySlashCommand", () => {
beforeEach(() => {
vi.mocked(sendText).mockClear();
@@ -105,77 +80,4 @@ describe("trySlashCommand", () => {
expect(qqbot?.streaming).toBe(true);
expect(vi.mocked(sendText).mock.calls.at(0)?.[1]).toContain("已开启");
});
it("keeps group /stop urgent when command level is strict", async () => {
const account = createAccount();
authorizeGroupCommands(account);
account.config.groups = {
GROUP_OPENID: { commandLevel: "strict" },
};
const result = await trySlashCommand(createGroupStopMessage(), {
account,
cfg: {},
getMessagePeerId: () => "group:GROUP_OPENID",
getQueueSnapshot: () => ({
totalPending: 0,
activeUsers: 0,
maxConcurrentUsers: 1,
senderPending: 0,
}),
});
expect(result).toBe("urgent");
});
it("keeps group /stop urgent outside strict command level", async () => {
const account = createAccount();
authorizeGroupCommands(account);
const result = await trySlashCommand(createGroupStopMessage(), {
account,
cfg: {},
getMessagePeerId: () => "group:GROUP_OPENID",
getQueueSnapshot: () => ({
totalPending: 0,
activeUsers: 0,
maxConcurrentUsers: 1,
senderPending: 0,
}),
});
expect(result).toBe("urgent");
});
it("does not let unauthorized group /stop bypass the queue", async () => {
const result = await trySlashCommand(createGroupStopMessage(), {
account: createAccount(),
cfg: {},
getMessagePeerId: () => "group:GROUP_OPENID",
getQueueSnapshot: () => ({
totalPending: 0,
activeUsers: 0,
maxConcurrentUsers: 1,
senderPending: 0,
}),
});
expect(result).toBe("enqueue");
});
it("keeps open DM /stop urgent", async () => {
const result = await trySlashCommand(createDmStopMessage(), {
account: createAccount(),
cfg: {},
getMessagePeerId: () => "c2c:TRUSTED_OPENID",
getQueueSnapshot: () => ({
totalPending: 0,
activeUsers: 0,
maxConcurrentUsers: 1,
senderPending: 0,
}),
});
expect(result).toBe("urgent");
});
});

View File

@@ -5,7 +5,6 @@
* Handles urgent commands, normal slash commands, and file delivery.
*/
import { resolveGroupCommandLevelFromAccountConfig } from "../config/group.js";
import type { QueuedMessage } from "../gateway/message-queue.js";
import type { GatewayAccount, EngineLogger } from "../gateway/types.js";
import { sendDocument } from "../messaging/outbound.js";
@@ -59,13 +58,20 @@ export async function trySlashCommand(
return "enqueue";
}
// Urgent command detection — bypass queue and execute immediately.
const contentLower = content.toLowerCase();
const isUrgentCommand = URGENT_COMMANDS.some(
(cmd) => contentLower === cmd.toLowerCase() || contentLower.startsWith(cmd.toLowerCase() + " "),
);
if (isUrgentCommand) {
log?.info(`Urgent command detected: ${content.slice(0, 20)}`);
return "urgent";
}
// Normal slash command — try to match and execute.
const receivedAt = Date.now();
const peerId = ctx.getMessagePeerId(msg);
const isGroup = msg.type === "group" || msg.type === "guild";
const groupCommandLevel = isGroup
? resolveGroupCommandLevelFromAccountConfig(
account.config,
msg.groupOpenid ?? msg.channelId ?? null,
)
: undefined;
const commandsAllowFrom = resolveQQBotCommandsAllowFrom(ctx.cfg);
const commandAuthorized = ctx.resolveCommandAuthorized
? await ctx.resolveCommandAuthorized({
@@ -83,23 +89,6 @@ export async function trySlashCommand(
groupAllowFrom: account.config?.groupAllowFrom,
commandsAllowFrom,
});
// Urgent command detection — bypass queue and execute immediately.
const contentLower = content.toLowerCase();
const isUrgentCommand = URGENT_COMMANDS.some(
(cmd) => contentLower === cmd.toLowerCase() || contentLower.startsWith(cmd.toLowerCase() + " "),
);
if (isUrgentCommand) {
if (isGroup && !commandAuthorized) {
return "enqueue";
}
log?.info(`Urgent command detected: ${content.slice(0, 20)}`);
return "urgent";
}
// Normal slash command — try to match and execute.
const receivedAt = Date.now();
const peerId = ctx.getMessagePeerId(msg);
const cmdCtx: SlashCommandContext = {
type: msg.type,
senderId: msg.senderId,
@@ -115,7 +104,6 @@ export async function trySlashCommand(
appId: account.appId,
accountConfig: account.config,
commandAuthorized,
groupCommandLevel,
queueSnapshot: ctx.getQueueSnapshot(peerId),
};

View File

@@ -73,68 +73,6 @@ describe("QQBot framework slash commands", () => {
expect(getFrameworkCommands().map((command) => command.name)).toContain("bot-streaming");
});
it("rejects private-only plugin commands in groups with the shared private-chat message", async () => {
const result = await matchSlashCommand(
createStreamingContext({
type: "group",
rawContent: "/bot-me",
groupOpenid: "group-1",
commandAuthorized: true,
}),
);
expect(result).toBe("该命令仅限私聊使用,请在私聊中发送。");
});
it("keeps private-only plugin commands private even when command level is all", async () => {
const result = await matchSlashCommand(
createStreamingContext({
type: "group",
rawContent: "/bot-me",
groupOpenid: "group-1",
commandAuthorized: true,
groupCommandLevel: "all",
}),
);
expect(result).toBe("该命令仅限私聊使用,请在私聊中发送。");
});
it("rejects plugin commands in groups when command level is strict", async () => {
const result = await matchSlashCommand(
createStreamingContext({
type: "group",
rawContent: "/bot-ping",
groupOpenid: "group-1",
commandAuthorized: true,
groupCommandLevel: "strict",
}),
);
expect(result).toBe("该命令仅限私聊使用,请在私聊中发送。");
});
it("keeps requireAuth commands gated in default all group mode", async () => {
const registry = new SlashCommandRegistry();
registry.register({
name: "shared-admin",
description: "shared admin command",
requireAuth: true,
handler: () => "ok",
});
const result = await registry.matchSlashCommand(
createStreamingContext({
type: "group",
rawContent: "/shared-admin",
groupOpenid: "group-1",
commandAuthorized: false,
}),
);
expect(result).toContain("权限不足");
});
it("does not write streaming config when the sender is not command-authorized", async () => {
const writes: OpenClawConfig[] = [];
installCommandRuntime(

View File

@@ -10,9 +10,6 @@
* Zero external dependencies.
*/
import type { QQBotGroupCommandLevel } from "../config/group.js";
import { PRIVATE_CHAT_ONLY_TEXT } from "./command-visibility.js";
// ============ Types ============
/** Slash command context (message metadata plus runtime state). */
@@ -45,8 +42,6 @@ export interface SlashCommandContext {
accountConfig?: Record<string, unknown>;
/** Whether the sender is authorized per the allowFrom config. */
commandAuthorized: boolean;
/** Effective per-group command level for group invocations. */
groupCommandLevel?: QQBotGroupCommandLevel;
/** Queue snapshot for the current sender. */
queueSnapshot: QueueSnapshot;
}
@@ -178,15 +173,9 @@ export class SlashCommandRegistry {
return null;
}
const isGroup = ctx.type === "group" || ctx.type === "guild";
const groupCommandLevel = ctx.groupCommandLevel ?? "all";
if (isGroup && groupCommandLevel === "strict") {
return PRIVATE_CHAT_ONLY_TEXT;
}
// Reject c2cOnly commands when invoked outside private chat.
if (cmd.c2cOnly && ctx.type !== "c2c") {
return PRIVATE_CHAT_ONLY_TEXT;
return `💡 请在私聊中使用此指令`;
}
// Gate sensitive commands behind the allowFrom authorization check.
@@ -194,6 +183,7 @@ export class SlashCommandRegistry {
log?.info?.(
`[qqbot] Slash command /${cmd.name} rejected: sender ${ctx.senderId} is not authorized`,
);
const isGroup = ctx.type === "group" || ctx.type === "guild";
const configHint = isGroup ? "groupAllowFrom" : "allowFrom";
return `⛔ 权限不足:请先在 channels.qqbot.${configHint} 中配置明确的发送者列表后再使用 /${cmd.name}`;
}

View File

@@ -2,7 +2,6 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_GROUP_HISTORY_LIMIT,
resolveGroupCommandLevelFromAccountConfig,
resolveGroupConfig,
resolveGroupName,
resolveGroupPrompt,
@@ -20,7 +19,6 @@ describe("engine/config/group", () => {
expect(cfg).toStrictEqual({
requireMention: true,
ignoreOtherMentions: false,
commandLevel: "all",
name: "",
prompt: undefined,
historyLimit: DEFAULT_GROUP_HISTORY_LIMIT,
@@ -35,7 +33,6 @@ describe("engine/config/group", () => {
groups: {
"*": {
requireMention: false,
commandLevel: "strict",
historyLimit: 20,
name: "wild",
},
@@ -45,7 +42,6 @@ describe("engine/config/group", () => {
};
const resolved = resolveGroupConfig(cfg, "G1");
expect(resolved.requireMention).toBe(false);
expect(resolved.commandLevel).toBe("strict");
expect(resolved.historyLimit).toBe(20);
expect(resolved.name).toBe("wild");
});
@@ -56,15 +52,14 @@ describe("engine/config/group", () => {
qqbot: {
appId: "1",
groups: {
"*": { requireMention: true, commandLevel: "strict", historyLimit: 20 },
GROUPA: { requireMention: false, commandLevel: "all", historyLimit: 5, name: "A" },
"*": { requireMention: true, historyLimit: 20 },
GROUPA: { requireMention: false, historyLimit: 5, name: "A" },
},
},
},
};
const resolved = resolveGroupConfig(cfg, "GROUPA");
expect(resolved.requireMention).toBe(false);
expect(resolved.commandLevel).toBe("all");
expect(resolved.historyLimit).toBe(5);
expect(resolved.name).toBe("A");
});
@@ -163,26 +158,6 @@ describe("engine/config/group", () => {
});
});
describe("resolveGroupCommandLevelFromAccountConfig", () => {
it("defaults to all when unset", () => {
expect(resolveGroupCommandLevelFromAccountConfig({}, "G")).toBe("all");
});
it("uses specific group before wildcard", () => {
expect(
resolveGroupCommandLevelFromAccountConfig(
{
groups: {
"*": { commandLevel: "strict" },
G1: { commandLevel: "all" },
},
},
"G1",
),
).toBe("all");
});
});
describe("resolveGroupName", () => {
it("uses the first 8 chars of openid when name is unset", () => {
expect(resolveGroupName({}, "ABCDEFGH1234")).toBe("ABCDEFGH");

View File

@@ -6,18 +6,12 @@ import { resolveAccountBase } from "./resolve.js";
interface GroupConfig {
requireMention: boolean;
ignoreOtherMentions: boolean;
commandLevel: QQBotGroupCommandLevel;
name: string;
prompt?: string;
historyLimit: number;
}
export type QQBotGroupCommandLevel = "all" | "safety" | "strict";
export const DEFAULT_GROUP_HISTORY_LIMIT = 50;
// Omitted commandLevel preserves shipped QQBot group behavior. Operators opt in to
// the fail-closed safety/strict modes per group or wildcard group config.
export const DEFAULT_GROUP_COMMAND_LEVEL: QQBotGroupCommandLevel = "all";
export const DEFAULT_GROUP_PROMPT =
"If the sender is a bot, respond only when they explicitly @mention you to ask a question or request assistance with a specific task; keep your replies concise and clear, avoiding the urge to race other bots to answer or engage in lengthy, unproductive exchanges. In group chats, prioritize responding to messages from human users; bots should maintain a collaborative rather than competitive dynamic to ensure the conversation remains orderly and does not result in message flooding.";
@@ -25,7 +19,6 @@ export const DEFAULT_GROUP_PROMPT =
const DEFAULT_GROUP_CONFIG: Readonly<Omit<GroupConfig, "prompt">> = {
requireMention: true,
ignoreOtherMentions: false,
commandLevel: DEFAULT_GROUP_COMMAND_LEVEL,
name: "",
historyLimit: DEFAULT_GROUP_HISTORY_LIMIT,
};
@@ -58,14 +51,6 @@ function readString(obj: Record<string, unknown>, key: string): string | undefin
return typeof v === "string" && v.length > 0 ? v : undefined;
}
function readCommandLevel(
obj: Record<string, unknown>,
key: string,
): QQBotGroupCommandLevel | undefined {
const v = readString(obj, key);
return v === "all" || v === "safety" || v === "strict" ? v : undefined;
}
function readHistoryLimit(obj: Record<string, unknown>, key: string): number | undefined {
const v = obj[key];
if (typeof v !== "number" || !Number.isFinite(v)) {
@@ -97,10 +82,6 @@ export function resolveGroupConfig(
readBoolean(specific, "ignoreOtherMentions") ??
readBoolean(wildcard, "ignoreOtherMentions") ??
DEFAULT_GROUP_CONFIG.ignoreOtherMentions,
commandLevel:
readCommandLevel(specific, "commandLevel") ??
readCommandLevel(wildcard, "commandLevel") ??
DEFAULT_GROUP_CONFIG.commandLevel,
name: readString(specific, "name") ?? readString(wildcard, "name") ?? DEFAULT_GROUP_CONFIG.name,
prompt: readString(specific, "prompt") ?? readString(wildcard, "prompt"),
historyLimit:
@@ -110,20 +91,6 @@ export function resolveGroupConfig(
};
}
export function resolveGroupCommandLevelFromAccountConfig(
accountConfig: Record<string, unknown> | undefined,
groupOpenid?: string | null,
): QQBotGroupCommandLevel {
const groups = asRecord(accountConfig?.groups);
const wildcard = asRecord(groups?.["*"]) ?? {};
const specific = groupOpenid ? (asRecord(groups?.[groupOpenid]) ?? {}) : {};
return (
readCommandLevel(specific, "commandLevel") ??
readCommandLevel(wildcard, "commandLevel") ??
DEFAULT_GROUP_CONFIG.commandLevel
);
}
export function resolveHistoryLimit(
cfg: Record<string, unknown>,
groupOpenid?: string | null,

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