Compare commits

..

147 Commits

Author SHA1 Message Date
Robin Waslander
85c4eb7d26 gitignore: ignore .dev-state openclaw#41848 thanks @smysle 2026-03-10 13:19:08 +01:00
smysle
b7be3f614a chore: add .dev-state to .gitignore
The .dev-state file is generated at runtime by the gateway process and
should not be tracked in version control.

Closes #41781
2026-03-10 13:18:50 +01:00
Echo
bda63c3c7f fix(mattermost): preserve markdown formatting and native tables (#18655)
Merged via squash.

Prepared head SHA: d30fff1776
Co-authored-by: echo931 <259437483+echo931@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
2026-03-10 17:40:01 +05:30
Pejman Pour-Moezzi
aca216bfcf feat(acp): add resumeSessionId to sessions_spawn for ACP session resume (#41847)
* feat(acp): add resumeSessionId to sessions_spawn for ACP session resume

Thread resumeSessionId through the ACP session spawn pipeline so agents
can resume existing sessions (e.g. a prior Codex conversation) instead
of starting fresh.

Flow: sessions_spawn tool → spawnAcpDirect → initializeSession →
ensureSession → acpx --resume-session flag → agent session/load

- Add resumeSessionId param to sessions-spawn-tool schema with
  description so agents can discover and use it
- Thread through SpawnAcpParams → AcpInitializeSessionInput →
  AcpRuntimeEnsureInput → acpx extension runtime
- Pass as --resume-session flag to acpx CLI
- Error hard (exit 4) on non-existent session, no silent fallback
- All new fields optional for backward compatibility

Depends on acpx >= 0.1.16 (openclaw/acpx#85, merged, pending release).

Tests: 26/26 pass (runtime + tool schema)
Verified e2e: Discord → sessions_spawn(resumeSessionId) → Codex
resumed session and recalled stored secret.

🤖 AI-assisted

* fix: guard resumeSessionId against non-ACP runtime

Add early-return error when resumeSessionId is passed without
runtime="acp" (mirrors existing streamTo guard). Without this,
the parameter is silently ignored and the agent gets a fresh
session instead of resuming.

Also update schema description to note the runtime=acp requirement.

Addresses Greptile review feedback.

* ACP: add changelog entry for session resume (#41847) (thanks @pejmanjohn)

---------

Co-authored-by: Pejman Pour-Moezzi <481729+pejmanjohn@users.noreply.github.com>
Co-authored-by: Onur <onur@textcortex.com>
2026-03-10 10:36:13 +01:00
Bob
c2eb12bbc5 ACPX: bump bundled acpx to 0.1.16 (#41975)
* ACPX: bump bundled acpx to 0.1.16

* fix: bump acpx pin to 0.1.16 (#41975) (thanks @dutifulbob)

---------

Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
2026-03-10 10:18:09 +01:00
Teconomix
6d0547dc2e mattermost: fix DM media upload for unprefixed user IDs (#29925)
Merged via squash.

Prepared head SHA: 5cffcb072c
Co-authored-by: teconomix <6959299+teconomix@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
2026-03-10 14:22:24 +05:30
Brad Groux
568b0a22bb fix(msteams): use General channel conversation ID as team key for Bot Framework compatibility (#41838)
* fix(msteams): use General channel conversation ID as team key for Bot Framework compatibility

Bot Framework sends `activity.channelData.team.id` as the General channel's
conversation ID (e.g. `19:abc@thread.tacv2`), not the Graph API group GUID
(e.g. `fa101332-cf00-431b-b0ea-f701a85fde81`). The startup resolver was
storing the Graph GUID as the team config key, so runtime matching always
failed and every channel message was silently dropped.

Fix: always call `listChannelsForTeam` during resolution to find the General
channel, then use its conversation ID as the stored `teamId`. When a specific
channel is also configured, reuse the same channel list rather than issuing a
second API call. Falls back to the Graph GUID if the General channel cannot
be found (renamed/deleted edge case).

Fixes #41390

* fix(msteams): handle listChannelsForTeam failure gracefully

* fix(msteams): trim General channel ID and guard against empty string

* fix: document MS Teams allowlist team-key fix (#41838) (thanks @BradGroux)

---------

Co-authored-by: bradgroux <bradgroux@users.noreply.github.com>
Co-authored-by: Onur <onur@textcortex.com>
2026-03-10 09:13:41 +01:00
Daniel Hnyk
450d49ea52 fix(mattermost): read replyTo param in plugin handleAction send (#41176)
Merged via squash.

Prepared head SHA: 33cac4c33f
Co-authored-by: hnykda <2741256+hnykda@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
2026-03-10 13:19:54 +05:30
Daniel Reis
3495563cfe fix(sandbox): pass real workspace to sessions_spawn when workspaceAccess is ro (#40757)
Merged via squash.

Prepared head SHA: 0e8b27bf80
Co-authored-by: dsantoreis <66363641+dsantoreis@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-03-10 04:12:50 -03:00
Austin
9d403fd415 fix(ui): replace Manual RPC text input with sorted method dropdown (#14967)
Merged via squash.

Prepared head SHA: 1bb49b2e64
Co-authored-by: rixau <112558420+rixau@users.noreply.github.com>
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Reviewed-by: @BunsDev
2026-03-10 01:30:31 -05:00
Val Alexander
5296147c20 CI: select Swift 6.2 toolchain for CodeQL (#41787)
Merged via squash.

Prepared head SHA: 8abc6c1657
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Reviewed-by: @BunsDev
2026-03-10 01:22:41 -05:00
Frank Yang
8306eabf85 fix(agents): forward memory flush write path (#41761)
Merged via squash.

Prepared head SHA: 0a8ebf8e5b
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-10 14:18:41 +08:00
Eugene
45b74fb56c fix(telegram): move network fallback to resolver-scoped dispatchers (#40740)
Merged via squash.

Prepared head SHA: a4456d48b4
Co-authored-by: sircrumpet <4436535+sircrumpet@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-10 11:28:51 +05:30
Urian Paul Danut
d1a59557b5 fix(security): harden replaceMarkers() to catch space/underscore boundary marker variants (#35983)
Merged via squash.

Prepared head SHA: ff07dc45a9
Co-authored-by: urianpaul94 <33277984+urianpaul94@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-10 13:54:23 +08:00
Laurie Luo
cf9db91b61 fix(web-search): recover OpenRouter Perplexity citations from message annotations (#40881)
Merged via squash.

Prepared head SHA: 66c8bb2c6a
Co-authored-by: laurieluo <89195476+laurieluo@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-10 10:37:44 +05:30
futuremind2026
382287026b cron: record lastErrorReason in job state (#14382)
Merged via squash.

Prepared head SHA: baa6b5d566
Co-authored-by: futuremind2026 <258860756+futuremind2026@users.noreply.github.com>
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Reviewed-by: @BunsDev
2026-03-10 00:01:45 -05:00
Wayne
da4fec6641 fix(telegram): prevent duplicate messages when preview edit times out (#41662)
Merged via squash.

Prepared head SHA: 2780e62d07
Co-authored-by: hougangdev <105773686+hougangdev@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-10 10:17:39 +05:30
Frank Yang
96e4975922 fix: protect bootstrap files during memory flush (#38574)
Merged via squash.

Prepared head SHA: a0b9a02e2e
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-10 12:44:33 +08:00
Benji Peng
989ee21b24 ui: fix sessions table collapse on narrow widths (#12175)
Merged via squash.

Prepared head SHA: b1fcfba868
Co-authored-by: benjipeng <11394934+benjipeng@users.noreply.github.com>
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Reviewed-by: @BunsDev
2026-03-09 23:14:07 -05:00
Tak Hoffman
705c6a422d Add provider routing details to bug report form (#41712) 2026-03-09 23:01:55 -05:00
Josh Avant
f0eb67923c fix(secrets): resolve web tool SecretRefs atomically at runtime 2026-03-09 22:57:03 -05:00
Ayaan Zaidi
93c44e3dad ci: drop gha cache from docker release (#41692) 2026-03-10 09:14:57 +05:30
Shadow
e42c4f4513 docs: harden PR review gates against unsubstantiated fixes 2026-03-09 22:43:56 -05:00
Ayane
391f9430ca fix(feishu): pass mediaLocalRoots in sendText local-image auto-convert shim (openclaw#40623)
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: ayanesakura <40628300+ayanesakura@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-09 22:26:06 -05:00
Ayaan Zaidi
e74666cd0a build: raise extension openclaw peer floor 2026-03-10 08:47:56 +05:30
Ayaan Zaidi
731f1aa906 test: avoid detect-secrets churn in observation fixtures 2026-03-10 08:43:19 +05:30
Harold Hunt
de49a8b72c Telegram: exec approvals for OpenCode/Codex (#37233)
Merged via squash.

Prepared head SHA: f243379094
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
2026-03-09 23:04:35 -04:00
Ayaan Zaidi
9432a8bb3f test: allowlist detect-secrets fixture strings 2026-03-10 08:14:35 +05:30
Zhe Liu
25c2facc2b fix(agents): fix Brave llm-context empty snippets (#41387)
Merged via squash.

Prepared head SHA: 1e6f1d9d51
Co-authored-by: zheliu2 <15888718+zheliu2@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-10 08:09:57 +05:30
Shadow
1720174757 fix: auto-close no-ci PR label and document triage labels 2026-03-09 21:30:47 -05:00
Neerav Makwana
5decb00e9d fix(swiftformat): sync GatewayModels exclusions with OpenClawProtocol (#41242)
Co-authored-by: Shadow <shadow@openclaw.ai>
2026-03-09 20:42:54 -05:00
Val Alexander
6b87489890 Revert "feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2)"
This reverts commit 5a659b0b61.
2026-03-09 18:47:44 -05:00
Val Alexander
9f0a64f855 Revert "Update ui/src/ui/chat/export.ts"
This reverts commit d648dd7643.
2026-03-09 18:47:40 -05:00
Val Alexander
8e412bad0e Revert "fix(ui): address review feedback on chat infra slice"
This reverts commit 8a6cd808a1.
2026-03-09 18:47:37 -05:00
Val Alexander
8a6cd808a1 fix(ui): address review feedback on chat infra slice
- export.ts: handle array content blocks (Claude API format) instead
  of silently exporting empty strings
- slash-command-executor.ts: restrict /kill all to current session's
  subagent subtree instead of all sessions globally
- slash-command-executor.ts: only count truly aborted runs (check
  aborted !== false) in /kill summary
2026-03-09 18:34:47 -05:00
Val Alexander
d648dd7643 Update ui/src/ui/chat/export.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-09 18:34:47 -05:00
Val Alexander
5a659b0b61 feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2)
New self-contained chat modules extracted from dashboard-v2-structure:

- chat/slash-commands.ts: slash command definitions and completions
- chat/slash-command-executor.ts: execute slash commands via gateway RPC
- chat/slash-command-executor.node.test.ts: test coverage
- chat/speech.ts: speech-to-text (STT) support
- chat/input-history.ts: per-session input history navigation
- chat/pinned-messages.ts: pinned message management
- chat/deleted-messages.ts: deleted message tracking
- chat/export.ts: shared exportChatMarkdown helper
- chat-export.ts: re-export shim for backwards compat

Gateway fix:
- Restore usage/cost stripping in chat.history sanitization
- Add test coverage for sanitization behavior

These modules are additive and tree-shaken — no existing code
imports them yet. They will be wired in subsequent slices.
2026-03-09 18:34:47 -05:00
Julia Barth
c0cba7fb72 Fix one-shot exit hangs by tearing down cached memory managers (#40389)
Merged via squash.

Prepared head SHA: 0e600e89cf
Co-authored-by: Julbarth <72460857+Julbarth@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-10 07:34:46 +08:00
Vincent Koc
b48291e01e Exec: mark child command env with OPENCLAW_CLI (#41411) 2026-03-09 19:14:08 -04:00
Xinhua Gu
4790e40ac6 fix(plugins): expose model auth API to context-engine plugins (#41090)
Merged via squash.

Prepared head SHA: ee96e96bb9
Co-authored-by: xinhuagu <562450+xinhuagu@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-09 16:07:26 -07:00
alan blount
c9a6c542ef Add HTTP 499 to transient error codes for model fallback (#41468)
Merged via squash.

Prepared head SHA: 0053bae140
Co-authored-by: zeroasterisk <23422+zeroasterisk@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-10 01:55:10 +03:00
Altay
de4c3db3e3 Logging: harden probe suppression for observations (#41338)
Merged via squash.

Prepared head SHA: d18356cb80
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-10 01:40:15 +03:00
Hermione
64746c150c fix(discord): apply effective maxLinesPerMessage in live replies (#40133)
Merged via squash.

Prepared head SHA: 031d032534
Co-authored-by: rbutera <6047293+rbutera@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-10 01:30:24 +03:00
Mariano
56f787e3c0 build(protocol): regenerate Swift models after pending node work schemas (#41477)
Merged via squash.

Prepared head SHA: cae0aaf1c2
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 23:22:09 +01:00
Altay
531e8362b1 Agents: add fallback error observations (#41337)
Merged via squash.

Prepared head SHA: 852469c82f
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-10 01:12:10 +03:00
Mariano
3c3474360b acp: harden follow-up reliability and attachments (#41464)
Merged via squash.

Prepared head SHA: 7d167dff54
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 23:03:50 +01:00
Altay
0669b0ddc2 fix(agents): probe single-provider billing cooldowns (#41422)
Merged via squash.

Prepared head SHA: bbc4254b94
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-10 00:58:51 +03:00
Mariano
0c7f07818f acp: add regression coverage and smoke-test docs (#41456)
Merged via squash.

Prepared head SHA: 514d587352
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 22:40:14 +01:00
Mariano
4aebff78bc acp: forward attachments into ACP runtime sessions (#41427)
Merged via squash.

Prepared head SHA: f2ac51df2c
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 22:32:32 +01:00
Mariano
8e3f3bc3cf acp: enrich streaming updates for ide clients (#41442)
Merged via squash.

Prepared head SHA: 0764368e80
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 22:26:46 +01:00
Altay
30340d6835 Sandbox: import STATE_DIR from paths directly (#41439) 2026-03-10 00:18:41 +03:00
Mariano
d346f2d9ce acp: restore session context and controls (#41425)
Merged via squash.

Prepared head SHA: fcabdf7c31
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 22:17:19 +01:00
Mariano
e6e4169e82 acp: fail honestly in bridge mode (#41424)
Merged via squash.

Prepared head SHA: b5e6e13afe
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 22:01:30 +01:00
Mariano
1bc59cc09d Gateway: tighten node pending drain semantics (#41429)
Merged via squash.

Prepared head SHA: 361c2eb5c8
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 21:56:00 +01:00
Mariano
ef95975411 Gateway: add pending node work primitives (#41409)
Merged via squash.

Prepared head SHA: a6d7ca90d7
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 21:42:57 +01:00
zerone0x
5f90883ad3 fix(auth): reset cooldown error counters on expiry to prevent infinite escalation (#41028)
Merged via squash.

Prepared head SHA: 89bd83f09a
Co-authored-by: zerone0x <39543393+zerone0x@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-09 23:40:11 +03:00
Robin Waslander
2b2e5e2038 fix(cron): do not misclassify empty/NO_REPLY as interim acknowledgement (#41401)
* fix(cron): do not misclassify empty/NO_REPLY as interim acknowledgement

When a cron task's agent returns NO_REPLY, the payload filter strips the
silent token, leaving an empty text string. isLikelyInterimCronMessage()
previously returned true for empty input, causing the cron runner to
inject a forced rerun prompt ('Your previous response was only an
acknowledgement...').

Change the empty-string branch to return false: empty text after payload
filtering means the agent deliberately chose silent completion, not that
it sent an interim 'on it' message.

Fixes #41246

* fix(cron): do not misclassify empty/NO_REPLY as interim acknowledgement

Fixes #41246. (#41383) thanks @jackal092927.

---------

Co-authored-by: xaeon2026 <xaeon2026@gmail.com>
2026-03-09 21:16:28 +01:00
Mariano
0bcddb3d4f iOS: reconnect gateway on foreground return (#41384)
Merged via squash.

Prepared head SHA: 0e2e0dcc36
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 21:12:23 +01:00
Vincent Koc
d86647d7db Doctor: fix non-interactive cron repair gating (#41386) 2026-03-09 12:35:31 -07:00
Altay
87d939be79 Agents: add embedded error observations (#41336)
Merged via squash.

Prepared head SHA: 4900042298
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-09 22:27:05 +03:00
Mariano
d4e59a3666 Cron: enforce cron-owned delivery contract (#40998)
Merged via squash.

Prepared head SHA: 5877389e33
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 20:12:37 +01:00
Vincent Koc
7b88249c9e fix(telegram): bridge direct delivery to internal message:sent hooks (#40185)
* telegram: bridge direct delivery message hooks

* telegram: align sent hooks with command session
2026-03-09 11:21:19 -07:00
Vincent Koc
12702e11a5 plugins: harden global hook runner state (#40184) 2026-03-09 11:20:33 -07:00
Pejman Pour-Moezzi
14bbcad169 fix(acp): propagate setSessionMode gateway errors to client (#41185)
* fix(acp): propagate setSessionMode gateway errors to client

* fix: add changelog entry for ACP setSessionMode propagation (#41185) (thanks @pejmanjohn)

---------

Co-authored-by: Pejman Pour-Moezzi <481729+pejmanjohn@users.noreply.github.com>
Co-authored-by: Onur <onur@textcortex.com>
2026-03-09 17:50:38 +01:00
Pejman Pour-Moezzi
eab39c721b fix(acp): map error states to end_turn instead of unconditional refusal (#41187)
* fix(acp): map error states to end_turn instead of unconditional refusal

* fix: map ACP error stop reason to end_turn (#41187) (thanks @pejmanjohn)

---------

Co-authored-by: Pejman Pour-Moezzi <481729+pejmanjohn@users.noreply.github.com>
Co-authored-by: Onur <onur@textcortex.com>
2026-03-09 17:37:33 +01:00
Radek Sienkiewicz
4815dc0603 Update CONTRIBUTING.md 2026-03-09 17:27:29 +01:00
Robin Waslander
2cce45962f Add Robin Waslander to maintainers 2026-03-09 17:23:56 +01:00
Radek Sienkiewicz
258b7902a4 Update CONTRIBUTING.md 2026-03-09 17:13:16 +01:00
xaeon2026
425bd89b48 Allow ACP sessions.patch lineage fields on ACP session keys (#40995)
Merged via squash.

Prepared head SHA: c1191edc08
Co-authored-by: xaeon2026 <264572156+xaeon2026@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 17:08:11 +01:00
Charles Dusek
54be30ef89 fix(agents): bound compaction retry wait and drain embedded runs on restart (#40324)
Merged via squash.

Prepared head SHA: cfd99562d6
Co-authored-by: cgdusek <38732970+cgdusek@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-09 08:27:29 -07:00
Daniel Reis
fbf5d56366 test(context-engine): add bundle chunk isolation tests for registry (#40460)
Merged via squash.

Prepared head SHA: 44622abfbc
Co-authored-by: dsantoreis <220753637+dsantoreis@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-09 08:15:35 -07:00
Joshua Lelon Mitchell
98ea71aca5 fix(swiftformat): exclude HostEnvSecurityPolicy.generated.swift from formatters (#39969) 2026-03-09 07:30:43 -07:00
opriz
51bae75120 fix(kimi-coding): fix kimi tool format: use native Anthropic tool schema instead of OpenAI … (openclaw#40008)
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: opriz <51957849+opriz@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-09 08:28:47 -05:00
Radek Sienkiewicz
f2f561fab1 fix(ui): preserve control-ui auth across refresh (#40892)
Merged via squash.

Prepared head SHA: f9b2375892
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-09 12:50:47 +01:00
Peter Steinberger
f6d0712f50 build: sync plugin versions for 2026.3.9 2026-03-09 08:39:52 +00:00
Peter Steinberger
6c579d7842 fix: stabilize launchd paths and appcast secret scan 2026-03-09 08:37:37 +00:00
Peter Steinberger
f9706fde6a build: bump unreleased version to 2026.3.9 2026-03-09 08:33:58 +00:00
Peter Steinberger
7217b97658 fix(onboard): avoid persisting talk fallback on fresh setup 2026-03-09 08:33:58 +00:00
Peter Steinberger
ce9e91fdfc fix(launchd): harden macOS launchagent install permissions 2026-03-09 08:14:46 +00:00
Peter Steinberger
3caab9260c test: narrow gateway loop signal harness 2026-03-09 07:42:15 +00:00
Peter Steinberger
d0847ee322 chore: prepare 2026.3.8 npm release 2026-03-09 07:37:50 +00:00
Peter Steinberger
1d3dde8d21 fix(update): re-enable launchd service before updater bootstrap 2026-03-09 07:27:11 +00:00
Peter Steinberger
cc0f30f5fb test: fix windows runtime and restart loop harnesses 2026-03-09 07:22:23 +00:00
Peter Steinberger
250d3c949e chore: update appcast for 2026.3.8-beta.1 2026-03-09 07:20:08 +00:00
Peter Steinberger
5fca4c0de0 chore: prepare 2026.3.8-beta.1 release 2026-03-09 07:09:37 +00:00
Peter Steinberger
66c581c64c fix: normalize windows runtime shim executables 2026-03-09 07:01:42 +00:00
Peter Steinberger
912aa8744a test: fix Windows fake runtime bin fixtures 2026-03-09 06:50:52 +00:00
Peter Steinberger
8d2d6db9ad test: fix Node 24+ test runner and subagent registry mocks 2026-03-09 06:45:13 +00:00
Peter Steinberger
2d55ad05f3 docs: move 2026.3.8 entries back to unreleased 2026-03-09 06:34:53 +00:00
Peter Steinberger
9631f4665c chore: refresh secrets baseline 2026-03-09 06:31:35 +00:00
Peter Steinberger
e2a1a4a3db build: sync pnpm lockfile 2026-03-09 06:25:01 +00:00
Peter Steinberger
f82931ba8b docs: reorder 2026.3.8 changelog by impact 2026-03-09 06:24:29 +00:00
Peter Steinberger
17599a8ea2 refactor: flatten supervisor marker hints 2026-03-09 06:19:30 +00:00
Peter Steinberger
e86b38f09d refactor: split cron startup catch-up flow 2026-03-09 06:19:10 +00:00
Peter Steinberger
1d301f74a6 refactor: extract telegram polling session 2026-03-09 06:18:07 +00:00
Peter Steinberger
2e79d82198 build: update app deps except carbon 2026-03-09 06:09:33 +00:00
Peter Steinberger
96d17f3cb1 fix: stagger missed cron jobs on restart (#18925) (thanks @rexlunae) 2026-03-09 06:07:43 +00:00
rexlunae
79853aca9c fix(cron): stagger missed jobs on restart to prevent gateway overload
When the gateway restarts with many overdue cron jobs, they are now
executed with staggered delays to prevent overwhelming the gateway.

- Add missedJobStaggerMs config (default 5s between jobs)
- Add maxMissedJobsPerRestart limit (default 5 jobs immediately)
- Prioritize most overdue jobs by sorting by nextRunAtMs
- Reschedule deferred jobs to fire gradually via normal timer

Fixes #18892
2026-03-09 06:07:43 +00:00
Peter Steinberger
2d5e70f3e7 fix: abort telegram getupdates on shutdown (#23950) (thanks @Gkinthecodeland) 2026-03-09 06:03:46 +00:00
George Kalogirou
6186f620d2 fix(telegram): use manual signal forwarding to avoid cross-realm AbortSignal
AbortSignal.any() fails in Node.js when signals come from different module
contexts (grammY's internal signal vs local AbortController), producing:
"The signals[0] argument must be an instance of AbortSignal. Received an
instance of AbortSignal".

Replace with manual event forwarding that works across all realms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 06:03:46 +00:00
George Kalogirou
2767907abf fix(telegram): abort in-flight getUpdates fetch on shutdown
When the gateway receives SIGTERM, runner.stop() stops the grammY polling
loop but does not abort the in-flight getUpdates HTTP request. That request
hangs for up to 30 seconds (the Telegram API timeout). If a new gateway
instance starts polling during that window, Telegram returns a 409 Conflict
error, causing message loss and requiring exponential backoff recovery.

This is especially problematic with service managers (launchd, systemd)
that restart the process immediately after SIGTERM.

Wire an AbortController into the fetch layer so every Telegram API request
(especially the long-polling getUpdates) aborts immediately on shutdown:

- bot.ts: Accept optional fetchAbortSignal in TelegramBotOptions; wrap
  the grammY fetch with AbortSignal.any() to merge the shutdown signal.
- monitor.ts: Create a per-iteration AbortController, pass its signal to
  createTelegramBot, and abort it from the SIGTERM handler, force-restart
  path, and finally block.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 06:03:46 +00:00
Peter Steinberger
9abf014f35 fix(skills): pin validated download roots 2026-03-09 06:00:50 +00:00
Peter Steinberger
cf3a479bd1 fix(node-host): bind bun and deno approval scripts 2026-03-09 05:59:32 +00:00
Peter Steinberger
fd902b0651 fix: detect launchd supervision via xpc service name (#20555) (thanks @dimat) 2026-03-09 05:57:35 +00:00
dimatu
cf796e2a22 fix(gateway): detect launchd supervision via XPC_SERVICE_NAME
On macOS, launchd sets XPC_SERVICE_NAME on managed processes but does
not set LAUNCH_JOB_LABEL or LAUNCH_JOB_NAME. Without checking
XPC_SERVICE_NAME, isLikelySupervisedProcess() returns false for
launchd-managed gateways, causing restartGatewayProcessWithFreshPid()
to fork a detached child instead of returning "supervised". The
detached child holds the gateway lock while launchd simultaneously
respawns the original process (KeepAlive=true), leading to an infinite
lock-timeout / restart loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 05:57:35 +00:00
merlin
f84adcbe88 fix: release gateway lock on restart failure + reply to Codex reviews
- Release gateway lock when in-process restart fails, so daemon
  restart/stop can still manage the process (Codex P2)
- P1 (env mismatch) already addressed: best-effort by design, documented
  in JSDoc
2026-03-09 05:53:52 +00:00
merlin
f184e7811c fix: move config pre-flight before onNotLoaded in runServiceRestart (Codex P2)
The config check was positioned after onNotLoaded, which could send
SIGUSR1 to an unmanaged process before config was validated.
2026-03-09 05:53:52 +00:00
merlin
c79a0dbdb4 fix: address bot review feedback on #35862
- Remove dead 'return false' in runServiceStart (Greptile)
- Include stack trace in run-loop crash guard error log (Greptile)
- Only catch startup errors on subsequent restarts, not initial start (Codex P1)
- Add JSDoc note about env var false positive edge case (Codex P1)
2026-03-09 05:53:52 +00:00
merlin
335223af32 test: add runServiceStart config pre-flight tests (#35862)
Address Greptile review: add test coverage for runServiceStart path.
The error message copy-paste issue was already fixed in the DRY refactor
(uses params.serviceNoun instead of hardcoded 'restart').
2026-03-09 05:53:52 +00:00
merlin
6740cdf160 fix(gateway): catch startup failure in run loop to prevent process exit (#35862)
When an in-process restart (SIGUSR1) triggers a config-triggered restart
and the new config is invalid, params.start() throws and the while loop
exits, killing the process. On macOS this loses TCC permissions.

Wrap params.start() in try/catch: on failure, set server=null, log the
error, and wait for the next SIGUSR1 instead of crashing.
2026-03-09 05:53:52 +00:00
merlin
eea925b12b fix(gateway): validate config before restart to prevent crash + macOS permission loss (#35862)
When 'openclaw gateway restart' is run with an invalid config, the new
process crashes on startup due to config validation failure. On macOS,
this causes Full Disk Access (TCC) permissions to be lost because the
respawned process has a different PID.

Add getConfigValidationError() helper and pre-flight config validation
in both runServiceRestart() and runServiceStart(). If config is invalid,
abort with a clear error message instead of crashing.

The config watcher's hot-reload path already had this guard
(handleInvalidSnapshot), but the CLI restart/start commands did not.

AI-assisted (OpenClaw agent, fully tested)
2026-03-09 05:53:52 +00:00
Peter Steinberger
88aee9161e fix(msteams): enforce sender allowlists with route allowlists 2026-03-09 05:52:19 +00:00
Peter Steinberger
03a6e3b460 test(cron): cover owner-only tool availability 2026-03-09 05:52:04 +00:00
Peter Steinberger
41e023a80b fix(cron): restore owner-only tools for isolated runs 2026-03-09 05:49:20 +00:00
Peter Steinberger
93775ef6a4 fix(browser): enforce redirect-hop SSRF checks 2026-03-09 05:41:36 +00:00
Peter Steinberger
31402b8542 fix: add changelog for restart timeout recovery (#40380) (thanks @dsantoreis) 2026-03-09 05:38:54 +00:00
DevMac
4bb8104810 test(secrets): skip ACL-dependent runtime snapshot tests on windows 2026-03-09 05:38:54 +00:00
Daniel dos Santos Reis
1d6a2d0165 fix(gateway): exit non-zero on restart shutdown timeout
When a config-change restart hits the force-exit timeout, exit with
code 1 instead of 0 so launchd/systemd treats it as a failure and
triggers a clean process restart. Stop-timeout stays at exit(0)
since graceful stops should not cause supervisor recovery.

Closes #36822
2026-03-09 05:38:54 +00:00
scoootscooob
44beb7be1f fix(daemon): also enable LaunchAgent in repairLaunchAgentBootstrap
The repair/recovery path had the same missing `enable` guard as
`restartLaunchAgent`.  If launchd persists a "disabled" state after a
previous `bootout`, the `bootstrap` call in `repairLaunchAgentBootstrap`
fails silently, leaving the gateway unloaded in the recovery flow.

Add the same `enable` guard before `bootstrap` that was already applied
to `installLaunchAgent` and (in this PR) `restartLaunchAgent`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 05:36:27 +00:00
scoootscooob
69cd376e3b fix(daemon): enable LaunchAgent before bootstrap on restart
restartLaunchAgent was missing the launchctl enable call that
installLaunchAgent already performs. launchd can persist a "disabled"
state after bootout, causing bootstrap to silently fail and leaving the
gateway unloaded until a manual reinstall.

Fixes #39211

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 05:36:27 +00:00
Peter Steinberger
41eef15cdc test: fix windows secrets runtime ci 2026-03-09 05:24:09 +00:00
GazeKingNuWu
41450187dd fix: clear plugin discovery cache after plugin installation (openclaw#39752)
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: GazeKingNuWu <264914544+GazeKingNuWu@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-09 00:16:25 -05:00
Ayaan Zaidi
a40c29b11a Fix cron text announce delivery for Telegram targets (#40575)
Merged via squash.

Prepared head SHA: 54b1513c78
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-09 10:26:17 +05:30
Bronko
d4a960fcca fix(matrix): restore robust DM routing without the memberCount heuristic (#19736)
* fix(matrix): remove memberCount heuristic from DM detection

The memberCount === 2 check in isDirectMessage() misclassifies 2-person
group rooms (admin channels, monitoring rooms) as DMs, routing them to
the main session instead of their room-specific session.

Matrix already distinguishes DMs from groups at the protocol level via
m.direct account data and is_direct member state flags. Both are already
checked by client.dms.isDm() and hasDirectFlag(). The memberCount
heuristic only adds false positives for 2-person groups.

Move resolveMemberCount() below the protocol-level checks so it is only
reached for rooms not matched by m.direct or is_direct. This narrows its
role to diagnostic logging for confirmed group rooms.

Refs: #19739

* fix(matrix): add conservative fallback for broken DM flags

Some homeservers (notably Continuwuity) have broken m.direct account
data or never set is_direct on invite events. With the memberCount
heuristic removed, these DMs are no longer detected.

Add a conservative fallback that requires two signals before classifying
as DM: memberCount === 2 AND no explicit m.room.name. Group rooms almost
always have explicit names; DMs almost never do.

Error handling distinguishes M_NOT_FOUND (missing state event, expected
for unnamed rooms) from network/auth errors. Non-404 errors fall through
to group classification rather than guessing.

This is independently revertable — removing this commit restores pure
protocol-based detection without any heuristic fallback.

* fix(matrix): add parentPeer for DM room binding support

Add parentPeer to DM routes so conversations are bindable by room ID
while preserving DM trust semantics (secure 1:1, no group restrictions).

Suggested by @KirillShchetinin.

* fix(matrix): override DM detection for explicitly configured rooms

Builds on @robertcorreiro's config-driven approach from #9106.

Move resolveMatrixRoomConfig() before the DM check. If a room matches
a non-wildcard config entry (matchSource === "direct") and was
classified as DM, override the classification to group. This gives users
a deterministic escape hatch for misclassified rooms.

Wildcards are excluded from the override to avoid breaking DM routing
when a "*" catch-all exists. roomConfig is gated behind isRoom so DMs
never inherit group settings (skills, systemPrompt, autoReply).

This commit is independently droppable if the scope is too broad.

* test(matrix): add DM detection and config override tests

- 15 unit tests for direct.ts: all detection paths, priority order,
  M_NOT_FOUND vs network error handling, edge cases (whitespace names,
  API failures)
- 8 unit tests for rooms.ts: matchSource classification, wildcard
  safety for DM override, direct match priority over wildcard

* Changelog: note matrix DM routing follow-up

* fix(matrix): preserve DM fallback and room bindings

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-08 23:26:48 -05:00
Ayaan Zaidi
26e76f9a61 fix: dedupe inbound Telegram DM replies per agent (#40519)
Merged via squash.

Prepared head SHA: 6e235e7d1f
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-09 09:31:05 +05:30
Peter Steinberger
8befd88119 build(protocol): sync generated swift models 2026-03-09 03:49:50 +00:00
Peter Steinberger
99cbda83a2 fix(media): accept reader read result type 2026-03-09 03:49:50 +00:00
Peter Steinberger
e8775cda93 fix(agents): re-expose configured tools under restrictive profiles 2026-03-09 03:49:50 +00:00
Tak Hoffman
ef36cb8cbc chore(acpx): move runtime test fixtures to test-utils (openclaw#40548)
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini
2026-03-08 22:47:04 -05:00
Ayaan Zaidi
f114a5c638 test: fix android talk config contract fixture 2026-03-09 09:15:49 +05:30
Kyle
a438ff4397 fix(plugin-sdk): remove remaining bundled plugin src imports (openclaw#39638)
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Kyle <3477429+kyledh@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-08 22:32:45 -05:00
Kesku
adec8b28bb alphabetize web search providers (#40259)
Merged via squash.

Prepared head SHA: be6350e5ae
Co-authored-by: kesku <62210496+kesku@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-09 08:54:54 +05:30
Mariano
e3df94365b ACP: add optional ingress provenance receipts (#40473)
Merged via squash.

Prepared head SHA: b63e46dd94
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 04:19:03 +01:00
Tyson Cung
4d501e4ccf fix(telegram): add download timeout to prevent polling loop hang (#40098)
Merged via squash.

Prepared head SHA: abdfa1a35f
Co-authored-by: tysoncung <45380903+tysoncung@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-09 08:29:21 +05:30
yuweuii
f6243916b5 fix(models): use 1M context for openai-codex gpt-5.4 (#37876)
Merged via squash.

Prepared head SHA: c41020779e
Co-authored-by: yuweuii <82372187+yuweuii@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-08 18:23:49 -07:00
Radek Sienkiewicz
b34158086a docs(changelog): correct Control UI contributor credit (#40420)
Merged via squash.

Prepared head SHA: e4295fe18b
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-09 02:18:30 +01:00
Vincent Koc
eabda6e3a4 fix(tests): correct security check failure 2026-03-08 18:13:35 -07:00
Vincent Koc
6d5e142b93 Docker: improve build cache reuse (#40351)
* Docker: improve build cache reuse

* Tests: cover Docker build cache layout

* Docker: fix sandbox cache mount continuations

* Docker: document qr-import manifest scope

* Docker: narrow e2e install inputs

* CI: cache Docker builds in workflows

* CI: route sandbox smoke through setup script

* CI: keep sandbox smoke on script path
2026-03-08 17:57:46 -07:00
Radek Sienkiewicz
4f42c03a49 gateway: fix global Control UI 404s for symlinked wrappers and bundled package roots (#40385)
Merged via squash.

Prepared head SHA: 567b3ed684
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-09 01:50:42 +01:00
Peter Steinberger
13bd3db307 chore(docs): drop refactor cleanup tracker 2026-03-09 00:26:20 +00:00
Peter Steinberger
ff4745fc3f refactor(models): split provider discovery helpers 2026-03-09 00:26:20 +00:00
Peter Steinberger
c29b098744 refactor(models): split models.json planning from writes 2026-03-09 00:26:20 +00:00
Peter Steinberger
24b53fcf47 refactor(agents): extract provider model normalization 2026-03-09 00:26:20 +00:00
Peter Steinberger
dfc18b7a2b refactor(models): extract list row builders 2026-03-09 00:26:20 +00:00
Peter Steinberger
141738f717 refactor: harden browser runtime profile handling 2026-03-09 00:25:43 +00:00
bbblending
4ff4ed7ec9 fix(config): refresh runtime snapshot from disk after write. Fixes #37175 (#37313)
Merged via squash.

Prepared head SHA: 69e1861abf
Co-authored-by: bbblending <122739024+bbblending@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-08 19:49:15 -04:00
Peter Steinberger
362248e559 refactor: harden browser relay CDP flows 2026-03-08 23:46:10 +00:00
309 changed files with 16899 additions and 2583 deletions

View File

@@ -76,6 +76,37 @@ body:
label: Install method
description: How OpenClaw was installed or launched.
placeholder: npm global / pnpm dev / docker / mac app
- type: input
id: model
attributes:
label: Model
description: Effective model under test.
placeholder: minimax/text-01 / openrouter/anthropic/claude-opus-4.1 / anthropic/claude-sonnet-4.5
validations:
required: true
- type: input
id: provider_chain
attributes:
label: Provider / routing chain
description: Effective request path through gateways, proxies, providers, or model routers.
placeholder: openclaw -> cloudflare-ai-gateway -> minimax
validations:
required: true
- type: input
id: config_location
attributes:
label: Config file / key location
description: Optional. Relevant config source or key path if this bug depends on overrides or custom provider setup. Redact secrets.
placeholder: ~/.openclaw/openclaw.json ; models.providers.cloudflare-ai-gateway.baseUrl ; ~/.openclaw/agents/<agentId>/agent/models.json
- type: textarea
id: provider_setup_details
attributes:
label: Additional provider/model setup details
description: Optional. Include redacted routing details, per-agent overrides, auth-profile interactions, env/config context, or anything else needed to explain the effective provider/model setup. Do not include API keys, tokens, or passwords.
placeholder: |
Default route is openclaw -> cloudflare-ai-gateway -> minimax.
Previous setup was openclaw -> cloudflare-ai-gateway -> openrouter -> minimax.
Relevant config lives in ~/.openclaw/openclaw.json under models.providers.minimax and models.providers.cloudflare-ai-gateway.
- type: textarea
id: logs
attributes:

View File

@@ -51,6 +51,7 @@ jobs:
},
{
label: "r: no-ci-pr",
close: true,
message:
"Please don't make PRs for test failures on main.\n\n" +
"The team is aware of those and will handle them directly on the codebase, not only fixing the tests but also investigating what the root cause is. Having to sift through test-fix-PRs (including some that have been out of date for weeks...) on top of that doesn't help. There are already way too many PRs for humans to manage; please don't make the flood worse.\n\n" +

View File

@@ -93,7 +93,11 @@ jobs:
- name: Setup Swift build tools
if: matrix.needs_swift_tools
run: brew install xcodegen swiftlint swiftformat
run: |
sudo xcode-select -s /Applications/Xcode_26.1.app
xcodebuild -version
brew install xcodegen swiftlint swiftformat
swift --version
- name: Initialize CodeQL
uses: github/codeql-action/init@v4

View File

@@ -109,8 +109,6 @@ jobs:
labels: ${{ steps.labels.outputs.value }}
provenance: false
push: true
cache-from: type=gha,scope=docker-release-amd64
cache-to: type=gha,mode=max,scope=docker-release-amd64
- name: Build and push amd64 slim image
id: build-slim
@@ -124,8 +122,6 @@ jobs:
labels: ${{ steps.labels.outputs.value }}
provenance: false
push: true
cache-from: type=gha,scope=docker-release-amd64
cache-to: type=gha,mode=max,scope=docker-release-amd64
# Build arm64 images (default + slim share the build stage cache)
build-arm64:
@@ -214,8 +210,6 @@ jobs:
labels: ${{ steps.labels.outputs.value }}
provenance: false
push: true
cache-from: type=gha,scope=docker-release-arm64
cache-to: type=gha,mode=max,scope=docker-release-arm64
- name: Build and push arm64 slim image
id: build-slim
@@ -229,8 +223,6 @@ jobs:
labels: ${{ steps.labels.outputs.value }}
provenance: false
push: true
cache-from: type=gha,scope=docker-release-arm64
cache-to: type=gha,mode=max,scope=docker-release-arm64
# Create multi-platform manifests
create-manifest:

1
.gitignore vendored
View File

@@ -121,3 +121,4 @@ dist/protocol.schema.json
# Synthing
**/.stfolder/
.dev-state

View File

@@ -9,7 +9,19 @@ Input
- If ambiguous: ask.
Do (review-only)
Goal: produce a thorough review and a clear recommendation (READY for /landpr vs NEEDS WORK). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command.
Goal: produce a thorough review and a clear recommendation (READY FOR /landpr vs NEEDS WORK vs INVALID CLAIM). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command.
0. Truthfulness + reality gate (required for bug-fix claims)
- Do not trust the issue text or PR summary by default; verify in code and evidence.
- If the PR claims to fix a bug linked to an issue, confirm the bug exists now (repro steps, logs, failing test, or clear code-path proof).
- Prove root cause with exact location (`path/file.ts:line` + explanation of why behavior is wrong).
- Verify fix targets the same code path as the root cause.
- Require a regression test when feasible (fails before fix, passes after fix). If not feasible, require explicit justification + manual verification evidence.
- Hallucination/BS red flags (treat as BLOCKER until disproven):
- claimed behavior not present in repo,
- issue/PR says "fixes #..." but changed files do not touch implicated path,
- only docs/comments changed for a runtime bug claim,
- vague AI-generated rationale without concrete evidence.
1. Identify PR meta + context
@@ -56,6 +68,7 @@ Goal: produce a thorough review and a clear recommendation (READY for /landpr vs
- Any deprecations, docs, types, or lint rules we should adjust?
8. Key questions to answer explicitly
- Is the core claim substantiated by evidence, or is it likely invalid/hallucinated?
- Can we fix everything ourselves in a follow-up, or does the contributor need to update this PR?
- Any blocking concerns (must-fix before merge)?
- Is this PR ready to land, or does it need work?
@@ -65,18 +78,32 @@ Goal: produce a thorough review and a clear recommendation (READY for /landpr vs
A) TL;DR recommendation
- One of: READY FOR /landpr | NEEDS WORK | NEEDS DISCUSSION
- One of: READY FOR /landpr | NEEDS WORK | INVALID CLAIM (issue/bug not substantiated) | NEEDS DISCUSSION
- 13 sentence rationale.
B) What changed
B) Claim verification matrix (required)
- Fill this table:
| Field | Evidence |
| ----------------------------------------------- | -------- |
| Claimed problem | ... |
| Evidence observed (repro/log/test/code) | ... |
| Root cause location (`path:line`) | ... |
| Why this fix addresses that root cause | ... |
| Regression coverage (test name or manual proof) | ... |
- If any row is missing/weak, default to `NEEDS WORK` or `INVALID CLAIM`.
C) What changed
- Brief bullet summary of the diff/behavioral changes.
C) What's good
D) What's good
- Bullets: correctness, simplicity, tests, docs, ergonomics, etc.
D) Concerns / questions (actionable)
E) Concerns / questions (actionable)
- Numbered list.
- Mark each item as:
@@ -84,17 +111,19 @@ D) Concerns / questions (actionable)
- IMPORTANT (should fix before merge)
- NIT (optional)
- For each: point to the file/area and propose a concrete fix or alternative.
- If evidence for the core bug claim is missing, add a `BLOCKER` explicitly.
E) Tests
F) Tests
- What exists.
- What's missing (specific scenarios).
- State clearly whether there is a regression test for the claimed bug.
F) Follow-ups (optional)
G) Follow-ups (optional)
- Non-blocking refactors/tickets to open later.
G) Suggested PR comment (optional)
H) Suggested PR comment (optional)
- Offer: "Want me to draft a PR comment to the author?"
- If yes, provide a ready-to-paste comment summarizing the above, with clear asks.

View File

@@ -205,7 +205,7 @@
"filename": "apps/macos/Sources/OpenClawProtocol/GatewayModels.swift",
"hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd",
"is_verified": false,
"line_number": 1763
"line_number": 1859
}
],
"apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift": [
@@ -266,7 +266,7 @@
"filename": "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
"hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd",
"is_verified": false,
"line_number": 1763
"line_number": 1859
}
],
"docs/.i18n/zh-CN.tm.jsonl": [
@@ -11659,7 +11659,7 @@
"filename": "src/agents/tools/web-search.ts",
"hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b",
"is_verified": false,
"line_number": 292
"line_number": 291
}
],
"src/agents/tools/web-tools.enabled-defaults.e2e.test.ts": [
@@ -13013,5 +13013,5 @@
}
]
},
"generated_at": "2026-03-09T08:37:13Z"
"generated_at": "2026-03-10T03:11:06Z"
}

View File

@@ -48,4 +48,4 @@
--allman false
# Exclusions
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol,apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/OpenClawProtocol,apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift

View File

@@ -18,7 +18,7 @@ excluded:
- coverage
- "*.playground"
# Generated (protocol-gen-swift.ts)
- apps/macos/Sources/MoltbotProtocol/GatewayModels.swift
- apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
# Generated (generate-host-env-security-policy-swift.mjs)
- apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift

View File

@@ -10,6 +10,35 @@
- GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search
- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries.
## Auto-close labels (issues and PRs)
- If an issue/PR matches one of the reasons below, apply the label and let `.github/workflows/auto-response.yml` handle comment/close/lock.
- Do not manually close + manually comment for these reasons.
- Why: keeps wording consistent, preserves automation behavior (`state_reason`, locking), and keeps triage/reporting searchable by label.
- `r:*` labels can be used on both issues and PRs.
- `r: skill`: close with guidance to publish skills on Clawhub.
- `r: support`: close with redirect to Discord support + stuck FAQ.
- `r: no-ci-pr`: close test-fix-only PRs for failing `main` CI and post the standard explanation.
- `r: too-many-prs`: close when author exceeds active PR limit.
- `r: testflight`: close requests asking for TestFlight access/builds. OpenClaw does not provide TestFlight distribution yet, so use the standard response (“Not available, build from source.”) instead of ad-hoc replies.
- `r: third-party-extension`: close with guidance to ship as third-party plugin.
- `r: moltbook`: close + lock as off-topic (not affiliated).
- `invalid`: close invalid items (issues are closed as `not_planned`; PRs are closed).
- `dirty`: close PRs with too many unrelated/unexpected changes (PR-only label).
## PR truthfulness and bug-fix validation
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.
- Before `/landpr`, run `/reviewpr` and require explicit evidence for bug-fix claims.
- Minimum merge gate for bug-fix PRs:
1. symptom evidence (repro/log/failing test),
2. verified root cause in code with file/line,
3. fix touches the implicated code path,
4. regression test (fail before/pass after) when feasible; if not feasible, include manual verification proof and why no test was added.
- If claim is unsubstantiated or likely hallucinated/BS: do not merge. Request evidence/changes, or close with `invalid` when appropriate.
- If linked issue appears wrong/outdated, correct triage first; do not merge speculative fixes.
## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).

View File

@@ -6,10 +6,19 @@ Docs: https://docs.openclaw.ai
### Changes
- Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky.
- Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle.
- ACP/sessions_spawn: add optional `resumeSessionId` for `runtime: "acp"` so spawned ACP sessions can resume an existing ACPX/Codex conversation instead of always starting fresh. (#41847) Thanks @pejmanjohn.
### Breaking
- Cron/doctor: tighten isolated cron delivery so cron jobs can no longer notify through ad hoc agent sends or fallback main-session summaries, and add `openclaw doctor --fix` migration for legacy cron storage and legacy notify/webhook delivery metadata. (#40998) Thanks @mbelinky.
### Fixes
- Resolve web tool SecretRefs atomically at runtime. (#41599) Thanks @joshavant.
- Feishu/local image auto-convert: pass `mediaLocalRoots` through the `sendText` local-image shim so allowed local image paths upload as Feishu images again instead of falling back to raw path text. (#40623) Thanks @ayanesakura.
- ACP/ACPX plugin: bump the bundled `acpx` pin to `0.1.16` so plugin-local installs and strict version checks match the latest published CLI. (#41975) Thanks @dutifulbob.
- macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes.
- Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark.
- Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz.
@@ -18,6 +27,39 @@ Docs: https://docs.openclaw.ai
- ACP/sessions.patch: allow `spawnedBy` and `spawnDepth` lineage fields on ACP session keys so `sessions_spawn` with `runtime: "acp"` no longer fails during child-session setup. Fixes #40971. (#40995) thanks @xaeon2026.
- ACP/stop reason mapping: resolve gateway chat `state: "error"` completions as ACP `end_turn` instead of `refusal` so transient backend failures are not surfaced as deliberate refusals. (#41187) thanks @pejmanjohn.
- ACP/setSessionMode: propagate gateway `sessions.patch` failures back to ACP clients so rejected mode changes no longer return silent success. (#41185) thanks @pejmanjohn.
- Agents/embedded logs: add structured, sanitized lifecycle and failover observation events so overload and provider failures are easier to tail and filter. (#41336) thanks @altaywtf.
- iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky.
- Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927.
- Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x.
- Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky.
- ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky.
- ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky.
- ACP/tool streaming: enrich `tool_call` and `tool_call_update` events with best-effort text content and file-location hints so IDE clients can follow bridge tool activity more naturally. (#41442) Thanks @mbelinky.
- ACP/runtime attachments: forward normalized inbound image attachments into ACP runtime turns so ACPX sessions can preserve image prompt content on the runtime path. (#41427) Thanks @mbelinky.
- ACP/regressions: add gateway RPC coverage for ACP lineage patching, ACPX runtime coverage for image prompt serialization, and an operator smoke-test procedure for live ACP spawn verification. (#41456) Thanks @mbelinky.
- Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf.
- ACP/follow-up hardening: make session restore and prompt completion degrade gracefully on transcript/update failures, enforce bounded tool-location traversal, and skip non-image ACPX turns the runtime cannot serialize. (#41464) Thanks @mbelinky.
- Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf.
- Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky.
- Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera.
- Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf.
- Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk.
- Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu.
- CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth.
- Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2.
- Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo.
- Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng.
- Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev.
- Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026.
- Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo.
- Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94.
- Telegram/network env-proxy: apply configured transport policy to proxied HTTPS dispatchers as well as direct `NO_PROXY` bypasses, so resolver-scoped IPv4 fallback and network settings work consistently for env-proxied Telegram traffic. (#40740) Thanks @sircrumpet.
- Agents/memory flush: forward `memoryFlushWritePath` through `runEmbeddedPiAgent` so memory-triggered flush turns keep the append-only write guard without aborting before tool setup. Follows up on #38574. (#41761) Thanks @frankekn.
- CI/CodeQL Swift toolchain: select Xcode 26.1 before installing Swift build tools so the CodeQL Swift job uses Swift tools 6.2 on `macos-latest`. (#41787) thanks @BunsDev.
- Sandbox/subagents: pass the real configured workspace through `sessions_spawn` inheritance when a parent agent runs in a copied-workspace sandbox, so child `/agent` mounts point at the configured workspace instead of the parent sandbox copy. (#40757) Thanks @dsantoreis.
- Mattermost/plugin send actions: normalize direct `replyTo` fallback handling so threaded plugin sends trim blank IDs and reuse the correct reply target again. (#41176) Thanks @hnykda.
- MS Teams/allowlist resolution: use the General channel conversation ID as the resolved team key (with Graph GUID fallback) so Bot Framework runtime `channelData.team.id` matching works for team and team/channel allowlist entries. (#41838) Thanks @BradGroux.
- Mattermost/Markdown formatting: preserve first-line indentation when stripping bot mentions so nested list items and indented code blocks keep their structure, and render Mattermost tables natively by default instead of fenced-code fallback. (#18655) thanks @echo931.
## 2026.3.8
@@ -83,6 +125,7 @@ Docs: https://docs.openclaw.ai
- MS Teams/authz: keep `groupPolicy: "allowlist"` enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent.
- Security/system.run: bind approved `bun` and `deno run` script operands to on-disk file snapshots so post-approval script rewrites are denied before execution.
- Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey.
- Control UI/Debug: replace the Manual RPC free-text method field with a sorted dropdown sourced from gateway-advertised methods, and stack the form vertically for narrower layouts. (#14967) thanks @rixau.
## 2026.3.7
@@ -440,6 +483,8 @@ Docs: https://docs.openclaw.ai
- Control UI/Telegram sender labels: preserve inbound sender labels in sanitized chat history so dashboard user-message groups split correctly and show real group-member names instead of `You`. (#39414) Thanks @obviyus.
- Agents/failover 402 recovery: keep temporary spend-limit `402` payloads retryable, preserve explicit insufficient-credit billing detection even in long provider payloads, and allow throttled billing-cooldown probes so single-provider setups can recover instead of staying locked out. (#38533) Thanks @xialonglee.
- Browser/config schema: accept `browser.profiles.*.driver: "openclaw"` while preserving legacy `"clawd"` compatibility in validated config. (#39374; based on #35621) Thanks @gambletan and @ingyukoh.
- Memory flush/bootstrap file protection: restrict memory-flush runs to append-only `read`/`write` tools and route host-side memory appends through root-enforced safe file handles so flush turns cannot overwrite bootstrap files via `exec` or unsafe raw rewrites. (#38574) Thanks @frankekn.
- Mattermost/DM media uploads: resolve bare 26-character Mattermost IDs user-first for direct messages so media sends no longer fail with `403 Forbidden` when targets are configured as unprefixed user IDs. (#29925) Thanks @teconomix.
## 2026.3.2

View File

@@ -362,7 +362,14 @@ final class NodeAppModel {
await MainActor.run {
self.operatorConnected = false
self.gatewayConnected = false
// Foreground recovery must actively restart the saved gateway config.
// Disconnecting stale sockets alone can leave us idle if the old
// reconnect tasks were suppressed or otherwise got stuck in background.
self.gatewayStatusText = "Reconnecting…"
self.talkMode.updateGatewayConnected(false)
if let cfg = self.activeGatewayConnectConfig {
self.applyGatewayConnectConfig(cfg)
}
}
}
}

View File

@@ -950,6 +950,102 @@ public struct NodeEventParams: Codable, Sendable {
}
}
public struct NodePendingDrainParams: Codable, Sendable {
public let maxitems: Int?
public init(
maxitems: Int?)
{
self.maxitems = maxitems
}
private enum CodingKeys: String, CodingKey {
case maxitems = "maxItems"
}
}
public struct NodePendingDrainResult: Codable, Sendable {
public let nodeid: String
public let revision: Int
public let items: [[String: AnyCodable]]
public let hasmore: Bool
public init(
nodeid: String,
revision: Int,
items: [[String: AnyCodable]],
hasmore: Bool)
{
self.nodeid = nodeid
self.revision = revision
self.items = items
self.hasmore = hasmore
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case revision
case items
case hasmore = "hasMore"
}
}
public struct NodePendingEnqueueParams: Codable, Sendable {
public let nodeid: String
public let type: String
public let priority: String?
public let expiresinms: Int?
public let wake: Bool?
public init(
nodeid: String,
type: String,
priority: String?,
expiresinms: Int?,
wake: Bool?)
{
self.nodeid = nodeid
self.type = type
self.priority = priority
self.expiresinms = expiresinms
self.wake = wake
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case type
case priority
case expiresinms = "expiresInMs"
case wake
}
}
public struct NodePendingEnqueueResult: Codable, Sendable {
public let nodeid: String
public let revision: Int
public let queued: [String: AnyCodable]
public let waketriggered: Bool
public init(
nodeid: String,
revision: Int,
queued: [String: AnyCodable],
waketriggered: Bool)
{
self.nodeid = nodeid
self.revision = revision
self.queued = queued
self.waketriggered = waketriggered
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case revision
case queued
case waketriggered = "wakeTriggered"
}
}
public struct NodeInvokeRequestEvent: Codable, Sendable {
public let id: String
public let nodeid: String

View File

@@ -950,6 +950,102 @@ public struct NodeEventParams: Codable, Sendable {
}
}
public struct NodePendingDrainParams: Codable, Sendable {
public let maxitems: Int?
public init(
maxitems: Int?)
{
self.maxitems = maxitems
}
private enum CodingKeys: String, CodingKey {
case maxitems = "maxItems"
}
}
public struct NodePendingDrainResult: Codable, Sendable {
public let nodeid: String
public let revision: Int
public let items: [[String: AnyCodable]]
public let hasmore: Bool
public init(
nodeid: String,
revision: Int,
items: [[String: AnyCodable]],
hasmore: Bool)
{
self.nodeid = nodeid
self.revision = revision
self.items = items
self.hasmore = hasmore
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case revision
case items
case hasmore = "hasMore"
}
}
public struct NodePendingEnqueueParams: Codable, Sendable {
public let nodeid: String
public let type: String
public let priority: String?
public let expiresinms: Int?
public let wake: Bool?
public init(
nodeid: String,
type: String,
priority: String?,
expiresinms: Int?,
wake: Bool?)
{
self.nodeid = nodeid
self.type = type
self.priority = priority
self.expiresinms = expiresinms
self.wake = wake
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case type
case priority
case expiresinms = "expiresInMs"
case wake
}
}
public struct NodePendingEnqueueResult: Codable, Sendable {
public let nodeid: String
public let revision: Int
public let queued: [String: AnyCodable]
public let waketriggered: Bool
public init(
nodeid: String,
revision: Int,
queued: [String: AnyCodable],
waketriggered: Bool)
{
self.nodeid = nodeid
self.revision = revision
self.queued = queued
self.waketriggered = waketriggered
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case revision
case queued
case waketriggered = "wakeTriggered"
}
}
public struct NodeInvokeRequestEvent: Codable, Sendable {
public let id: String
public let nodeid: String

View File

@@ -17,6 +17,51 @@ Key goals:
- Works with existing Gateway session store (list/resolve/reset).
- Safe defaults (isolated ACP session keys by default).
## Bridge Scope
`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor
runtime. It is designed to route IDE prompts into an existing OpenClaw Gateway
session with predictable session mapping and basic streaming updates.
## Compatibility Matrix
| ACP area | Status | Notes |
| --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. |
| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. |
| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key and replays stored user/assistant text history. Tool/system history is not reconstructed yet. |
| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. |
| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. |
| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. |
| Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. |
| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. |
| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. |
| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. |
| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. |
## Known Limitations
- `loadSession` replays stored user and assistant text history, but it does not
reconstruct historic tool calls, system notices, or richer ACP-native event
types.
- If multiple ACP clients share the same Gateway session key, event and cancel
routing are best-effort rather than strictly isolated per client. Prefer the
default isolated `acp:<uuid>` sessions when you need clean editor-local
turns.
- Gateway stop states are translated into ACP stop reasons, but that mapping is
less expressive than a fully ACP-native runtime.
- Initial session controls currently surface a focused subset of Gateway knobs:
thought level, tool verbosity, reasoning, usage detail, and elevated
actions. Model selection and exec-host controls are not yet exposed as ACP
config options.
- `session_info_update` and `usage_update` are derived from Gateway session
snapshots, not live ACP-native runtime accounting. Usage is approximate,
carries no cost data, and is only emitted when the Gateway marks total token
data as fresh.
- Tool follow-along data is best-effort. The bridge can surface file paths that
appear in known tool args/results, but it does not yet emit ACP terminals or
structured file diffs.
## How can I use this
Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to
@@ -181,9 +226,11 @@ updates. Terminal Gateway states map to ACP `done` with stop reasons:
## Compatibility
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.13.x).
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.15.x).
- Works with ACP clients that implement `initialize`, `newSession`,
`loadSession`, `prompt`, `cancel`, and `listSessions`.
- Bridge mode rejects per-session `mcpServers` instead of silently ignoring
them. Configure MCP at the Gateway or agent layer.
## Testing

View File

@@ -29,6 +29,7 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
- Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = "<url>"`.
- Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode.
- For upgrades, `openclaw doctor --fix` can normalize legacy cron store fields before the scheduler touches them.
## Quick start (actionable)
@@ -261,6 +262,7 @@ If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the mai
Target format reminders:
- Slack/Discord/Mattermost (plugin) targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
Mattermost bare 26-char IDs are resolved **user-first** (DM if user exists, channel otherwise) — use `user:<id>` or `channel:<id>` for deterministic routing.
- Telegram topics should use the `:topic:` form (see below).
#### Telegram delivery targets (topics / forum threads)

View File

@@ -153,7 +153,14 @@ Use these target formats with `openclaw message send` or cron/webhooks:
- `user:<id>` for a DM
- `@username` for a DM (resolved via the Mattermost API)
Bare IDs are treated as channels.
Bare opaque IDs (like `64ifufp...`) are **ambiguous** in Mattermost (user ID vs channel ID).
OpenClaw resolves them **user-first**:
- If the ID exists as a user (`GET /api/v4/users/<id>` succeeds), OpenClaw sends a **DM** by resolving the direct channel via `/api/v4/channels/direct`.
- Otherwise the ID is treated as a **channel ID**.
If you need deterministic behavior, always use the explicit prefixes (`user:<id>` / `channel:<id>`).
## Reactions (message tool)

View File

@@ -760,6 +760,34 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \
- `channels.telegram.actions.poll=false` disables Telegram poll creation while leaving regular sends enabled
</Accordion>
<Accordion title="Exec approvals in Telegram">
Telegram supports exec approvals in approver DMs and can optionally post approval prompts in the originating chat or topic.
Config path:
- `channels.telegram.execApprovals.enabled`
- `channels.telegram.execApprovals.approvers`
- `channels.telegram.execApprovals.target` (`dm` | `channel` | `both`, default: `dm`)
- `agentFilter`, `sessionFilter`
Approvers must be numeric Telegram user IDs. When `enabled` is false or `approvers` is empty, Telegram does not act as an exec approval client. Approval requests fall back to other configured approval routes or the exec approval fallback policy.
Delivery rules:
- `target: "dm"` sends approval prompts only to configured approver DMs
- `target: "channel"` sends the prompt back to the originating Telegram chat/topic
- `target: "both"` sends to approver DMs and the originating chat/topic
Only configured approvers can approve or deny. Non-approvers cannot use `/approve` and cannot use Telegram approval buttons.
Channel delivery shows the command text in the chat, so only enable `channel` or `both` in trusted groups/topics. When the prompt lands in a forum topic, OpenClaw preserves the topic for both the approval prompt and the post-approval follow-up.
Inline approval buttons also depend on `channels.telegram.capabilities.inlineButtons` allowing the target surface (`dm`, `group`, or `all`).
Related docs: [Exec approvals](/tools/exec-approvals)
</Accordion>
</AccordionGroup>
## Troubleshooting
@@ -859,10 +887,16 @@ Primary reference:
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (group fields + topic-only `agentId`).
- `channels.telegram.groups.<id>.topics.<threadId>.agentId`: route this topic to a specific agent (overrides group-level and binding routing).
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
- top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)).
- `channels.telegram.direct.<id>.topics.<threadId>.agentId`: route DM topics to a specific agent (same behavior as forum topics).
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
- top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)).
- `channels.telegram.direct.<id>.topics.<threadId>.agentId`: route DM topics to a specific agent (same behavior as forum topics).
- `channels.telegram.execApprovals.enabled`: enable Telegram as a chat-based exec approval client for this account.
- `channels.telegram.execApprovals.approvers`: Telegram user IDs allowed to approve or deny exec requests. Required when exec approvals are enabled.
- `channels.telegram.execApprovals.target`: `dm | channel | both` (default: `dm`). `channel` and `both` preserve the originating Telegram topic when present.
- `channels.telegram.execApprovals.agentFilter`: optional agent ID filter for forwarded approval prompts.
- `channels.telegram.execApprovals.sessionFilter`: optional session key filter (substring or regex) for forwarded approval prompts.
- `channels.telegram.accounts.<account>.execApprovals`: per-account override for Telegram exec approval routing and approver authorization.
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
- `channels.telegram.commands.nativeSkills`: enable/disable Telegram native skills commands.
@@ -894,6 +928,7 @@ Telegram-specific high-signal fields:
- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*`
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`, top-level `bindings[]` (`type: "acp"`)
- exec approvals: `execApprovals`, `accounts.*.execApprovals`
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
- threading/replies: `replyToMode`
- streaming: `streaming` (preview), `blockStreaming`

View File

@@ -13,6 +13,49 @@ Run the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) bridge t
This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway
over WebSocket. It keeps ACP sessions mapped to Gateway session keys.
`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor
runtime. It focuses on session routing, prompt delivery, and basic streaming
updates.
## Compatibility Matrix
| ACP area | Status | Notes |
| --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. |
| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. |
| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key and replays stored user/assistant text history. Tool/system history is not reconstructed yet. |
| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. |
| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. |
| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. |
| Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. |
| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. |
| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. |
| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. |
| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. |
## Known Limitations
- `loadSession` replays stored user and assistant text history, but it does not
reconstruct historic tool calls, system notices, or richer ACP-native event
types.
- If multiple ACP clients share the same Gateway session key, event and cancel
routing are best-effort rather than strictly isolated per client. Prefer the
default isolated `acp:<uuid>` sessions when you need clean editor-local
turns.
- Gateway stop states are translated into ACP stop reasons, but that mapping is
less expressive than a fully ACP-native runtime.
- Initial session controls currently surface a focused subset of Gateway knobs:
thought level, tool verbosity, reasoning, usage detail, and elevated
actions. Model selection and exec-host controls are not yet exposed as ACP
config options.
- `session_info_update` and `usage_update` are derived from Gateway session
snapshots, not live ACP-native runtime accounting. Usage is approximate,
carries no cost data, and is only emitted when the Gateway marks total token
data as fresh.
- Tool follow-along data is best-effort. The bridge can surface file paths that
appear in known tool args/results, but it does not yet emit ACP terminals or
structured file diffs.
## Usage
```bash
@@ -96,6 +139,10 @@ Each ACP session maps to a single Gateway session key. One agent can have many
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
the key or label.
Per-session `mcpServers` are not supported in bridge mode. If an ACP client
sends them during `newSession` or `loadSession`, the bridge returns a clear
error instead of silently ignoring them.
## Use from `acpx` (Codex, Claude, other ACP clients)
If you want a coding agent such as Codex or Claude Code to talk to your

View File

@@ -30,6 +30,12 @@ Note: retention/pruning is controlled in config:
- `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions.
- `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/<jobId>.jsonl`.
Upgrade note: if you have older cron jobs from before the current delivery/store format, run
`openclaw doctor --fix`. Doctor now normalizes legacy cron fields (`jobId`, `schedule.cron`,
top-level delivery fields, payload `provider` delivery aliases) and migrates simple
`notify: true` webhook fallback jobs to explicit webhook delivery when `cron.webhook` is
configured.
## Common edits
Update delivery settings without changing the message:

View File

@@ -28,6 +28,7 @@ Notes:
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
- `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal.
- State integrity checks now detect orphan transcript files in the sessions directory and can archive them as `.deleted.<timestamp>` to reclaim space safely.
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.
- Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing.
- If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`).

View File

@@ -65,6 +65,7 @@ cat ~/.openclaw/openclaw.json
- Config normalization for legacy values.
- OpenCode Zen provider override warnings (`models.providers.opencode`).
- Legacy on-disk state migration (sessions/agent dir/WhatsApp auth).
- Legacy cron store migration (`jobId`, `schedule.cron`, top-level delivery/payload fields, payload `provider`, simple `notify: true` webhook fallback jobs).
- State integrity and permissions checks (sessions, transcripts, state dir).
- Config file permission checks (chmod 600) when running locally.
- Model auth health: checks OAuth expiry, can refresh expiring tokens, and reports auth-profile cooldown/disabled states.
@@ -158,6 +159,25 @@ the legacy sessions + agent dir on startup so history/auth/models land in the
per-agent path without a manual doctor run. WhatsApp auth is intentionally only
migrated via `openclaw doctor`.
### 3b) Legacy cron store migrations
Doctor also checks the cron job store (`~/.openclaw/cron/jobs.json` by default,
or `cron.store` when overridden) for old job shapes that the scheduler still
accepts for compatibility.
Current cron cleanups include:
- `jobId``id`
- `schedule.cron``schedule.expr`
- top-level payload fields (`message`, `model`, `thinking`, ...) → `payload`
- top-level delivery fields (`deliver`, `channel`, `to`, `provider`, ...) → `delivery`
- payload `provider` delivery aliases → explicit `delivery.channel`
- simple legacy `notify: true` webhook fallback jobs → explicit `delivery.mode="webhook"` with `delivery.to=cron.webhook`
Doctor only auto-migrates `notify: true` jobs when it can do so without
changing behavior. If a job combines legacy notify fallback with an existing
non-webhook delivery mode, doctor warns and leaves that job for manual review.
### 4) State integrity checks (session persistence, routing, and safety)
The state directory is the operational brainstem. If it vanishes, you lose

View File

@@ -38,7 +38,8 @@ Examples of inactive surfaces:
- Top-level channel credentials that no enabled account inherits.
- Disabled tool/feature surfaces.
- Web search provider-specific keys that are not selected by `tools.web.search.provider`.
In auto mode (provider unset), provider-specific keys are also active for provider auto-detection.
In auto mode (provider unset), keys are consulted by precedence for provider auto-detection until one resolves.
After selection, non-selected provider keys are treated as inactive until selected.
- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active (when `gateway.remote.enabled` is not `false`) if one of these is true:
- `gateway.mode=remote`
- `gateway.remote.url` is configured

View File

@@ -1489,10 +1489,16 @@ Set `cli.banner.taglineMode` in config:
### How do I enable web search and web fetch
`web_fetch` works without an API key. `web_search` requires a Brave Search API
key. **Recommended:** run `openclaw configure --section web` to store it in
`tools.web.search.apiKey`. Environment alternative: set `BRAVE_API_KEY` for the
Gateway process.
`web_fetch` works without an API key. `web_search` requires a key for your
selected provider (Brave, Gemini, Grok, Kimi, or Perplexity).
**Recommended:** run `openclaw configure --section web` and choose a provider.
Environment alternatives:
- Brave: `BRAVE_API_KEY`
- Gemini: `GEMINI_API_KEY`
- Grok: `XAI_API_KEY`
- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
```json5
{
@@ -1500,6 +1506,7 @@ Gateway process.
web: {
search: {
enabled: true,
provider: "brave",
apiKey: "BRAVE_API_KEY_HERE",
maxResults: 5,
},

View File

@@ -71,11 +71,14 @@ Optional legacy controls:
**Via config:** run `openclaw configure --section web`. It stores the key in
`~/.openclaw/openclaw.json` under `tools.web.search.perplexity.apiKey`.
That field also accepts SecretRef objects.
**Via environment:** set `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
in the Gateway process environment. For a gateway install, put it in
`~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
If `provider: "perplexity"` is configured and the Perplexity key SecretRef is unresolved with no env fallback, startup/reload fails fast.
## Tool parameters
These parameters apply to the native Perplexity Search API path.

View File

@@ -80,10 +80,10 @@ See [Memory](/concepts/memory).
`web_search` uses API keys and may incur usage charges depending on your provider:
- **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
- **Gemini (Google Search)**: `GEMINI_API_KEY`
- **Grok (xAI)**: `XAI_API_KEY`
- **Kimi (Moonshot)**: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
- **Perplexity Search API**: `PERPLEXITY_API_KEY`
- **Gemini (Google Search)**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey`
- **Grok (xAI)**: `XAI_API_KEY` or `tools.web.search.grok.apiKey`
- **Kimi (Moonshot)**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey`
- **Perplexity Search API**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey`
**Brave Search free credit:** Each Brave plan includes $5/month in renewing
free credit. The Search plan costs $5 per 1,000 requests, so the credit covers

View File

@@ -31,6 +31,7 @@ Scope intent:
- `talk.providers.*.apiKey`
- `messages.tts.elevenlabs.apiKey`
- `messages.tts.openai.apiKey`
- `tools.web.fetch.firecrawl.apiKey`
- `tools.web.search.apiKey`
- `tools.web.search.gemini.apiKey`
- `tools.web.search.grok.apiKey`
@@ -102,7 +103,8 @@ Notes:
- For SecretRef-managed model providers, generated `agents/*/agent/models.json` entries persist non-secret markers (not resolved secret values) for `apiKey`/header surfaces.
- For web search:
- In explicit provider mode (`tools.web.search.provider` set), only the selected provider key is active.
- In auto mode (`tools.web.search.provider` unset), `tools.web.search.apiKey` and provider-specific keys are active.
- In auto mode (`tools.web.search.provider` unset), only the first provider key that resolves by precedence is active.
- In auto mode, non-selected provider refs are treated as inactive until selected.
## Unsupported credentials

View File

@@ -454,6 +454,13 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "tools.web.fetch.firecrawl.apiKey",
"configFile": "openclaw.json",
"path": "tools.web.fetch.firecrawl.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "tools.web.search.apiKey",
"configFile": "openclaw.json",

View File

@@ -246,6 +246,46 @@ Interface details:
- `streamTo` (optional): `"parent"` streams initial ACP run progress summaries back to the requester session as system events.
- When available, accepted responses include `streamLogPath` pointing to a session-scoped JSONL log (`<sessionId>.acp-stream.jsonl`) you can tail for full relay history.
### Operator smoke test
Use this after a gateway deploy when you want a quick live check that ACP spawn
is actually working end-to-end, not just passing unit tests.
Recommended gate:
1. Verify the deployed gateway version/commit on the target host.
2. Confirm the deployed source includes the ACP lineage acceptance in
`src/gateway/sessions-patch.ts` (`subagent:* or acp:* sessions`).
3. Open a temporary ACPX bridge session to a live agent (for example
`razor(main)` on `jpclawhq`).
4. Ask that agent to call `sessions_spawn` with:
- `runtime: "acp"`
- `agentId: "codex"`
- `mode: "run"`
- task: `Reply with exactly LIVE-ACP-SPAWN-OK`
5. Verify the agent reports:
- `accepted=yes`
- a real `childSessionKey`
- no validator error
6. Clean up the temporary ACPX bridge session.
Example prompt to the live agent:
```text
Use the sessions_spawn tool now with runtime: "acp", agentId: "codex", and mode: "run".
Set the task to: "Reply with exactly LIVE-ACP-SPAWN-OK".
Then report only: accepted=<yes/no>; childSessionKey=<value or none>; error=<exact text or none>.
```
Notes:
- Keep this smoke test on `mode: "run"` unless you are intentionally testing
thread-bound persistent ACP sessions.
- Do not require `streamTo: "parent"` for the basic gate. That path depends on
requester/session capabilities and is a separate integration check.
- Treat thread-bound `mode: "session"` testing as a second, richer integration
pass from a real Discord thread or Telegram topic.
## Sandbox compatibility
ACP sessions currently run on the host runtime, not inside the OpenClaw sandbox.

View File

@@ -309,6 +309,32 @@ Reply in chat:
/approve <id> deny
```
### Built-in chat approval clients
Discord and Telegram can also act as explicit exec approval clients with channel-specific config.
- Discord: `channels.discord.execApprovals.*`
- Telegram: `channels.telegram.execApprovals.*`
These clients are opt-in. If a channel does not have exec approvals enabled, OpenClaw does not treat
that channel as an approval surface just because the conversation happened there.
Shared behavior:
- only configured approvers can approve or deny
- the requester does not need to be an approver
- when channel delivery is enabled, approval prompts include the command text
- if no operator UI or configured approval client can accept the request, the prompt falls back to `askFallback`
Telegram defaults to approver DMs (`target: "dm"`). You can switch to `channel` or `both` when you
want approval prompts to appear in the originating Telegram chat/topic as well. For Telegram forum
topics, OpenClaw preserves the topic for the approval prompt and the post-approval follow-up.
See:
- [Discord](/channels/discord#exec-approvals-in-discord)
- [Telegram](/channels/telegram#exec-approvals-in-telegram)
### macOS IPC flow
```

View File

@@ -40,7 +40,8 @@ with JS-heavy sites or pages that block plain HTTP fetches.
Notes:
- `firecrawl.enabled` defaults to true when an API key is present.
- `firecrawl.enabled` defaults to `true` unless explicitly set to `false`.
- Firecrawl fallback attempts run only when an API key is available (`tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`).
- `maxAgeMs` controls how old cached results can be (ms). Default is 2 days.
## Stealth / bot circumvention

View File

@@ -2,7 +2,7 @@
summary: "Web search + fetch tools (Brave, Gemini, Grok, Kimi, and Perplexity providers)"
read_when:
- You want to enable web_search or web_fetch
- You need Brave or Perplexity Search API key setup
- You need provider API key setup
- You want to use Gemini with Google Search grounding
title: "Web Tools"
---
@@ -49,6 +49,12 @@ The table above is alphabetical. If no `provider` is explicitly set, runtime aut
If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one).
Runtime SecretRef behavior:
- Web tool SecretRefs are resolved atomically at gateway startup/reload.
- In auto-detect mode, OpenClaw resolves only the selected provider key. Non-selected provider SecretRefs stay inactive until selected.
- If the selected provider SecretRef is unresolved and no provider env fallback exists, startup/reload fails fast.
## Setting up web search
Use `openclaw configure --section web` to set up your API key and choose a provider.
@@ -77,9 +83,25 @@ See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quicks
### Where to store the key
**Via config:** run `openclaw configure --section web`. It stores the key under `tools.web.search.apiKey` or `tools.web.search.perplexity.apiKey`, depending on provider.
**Via config:** run `openclaw configure --section web`. It stores the key under the provider-specific config path:
**Via environment:** set `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `BRAVE_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
- Brave: `tools.web.search.apiKey`
- Gemini: `tools.web.search.gemini.apiKey`
- Grok: `tools.web.search.grok.apiKey`
- Kimi: `tools.web.search.kimi.apiKey`
- Perplexity: `tools.web.search.perplexity.apiKey`
All of these fields also support SecretRef objects.
**Via environment:** set provider env vars in the Gateway process environment:
- Brave: `BRAVE_API_KEY`
- Gemini: `GEMINI_API_KEY`
- Grok: `XAI_API_KEY`
- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
For a gateway install, put these in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
### Config examples
@@ -216,6 +238,7 @@ Search the web using your configured provider.
- **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey`
- **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey`
- **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey`
- All provider key fields above support SecretRef objects.
### Config
@@ -310,6 +333,7 @@ Fetch a URL and extract readable content.
- `tools.web.fetch.enabled` must not be `false` (default: enabled)
- Optional Firecrawl fallback: set `tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`.
- `tools.web.fetch.firecrawl.apiKey` supports SecretRef objects.
### web_fetch config
@@ -351,6 +375,8 @@ Notes:
- `web_fetch` uses Readability (main-content extraction) first, then Firecrawl (if configured). If both fail, the tool returns an error.
- Firecrawl requests use bot-circumvention mode and cache results by default.
- Firecrawl SecretRefs are resolved only when Firecrawl is active (`tools.web.fetch.enabled !== false` and `tools.web.fetch.firecrawl.enabled !== false`).
- If Firecrawl is active and its SecretRef is unresolved with no `FIRECRAWL_API_KEY` fallback, startup/reload fails fast.
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
- `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`).
- `maxChars` is clamped to `tools.web.fetch.maxCharsCap`.

View File

@@ -67,7 +67,7 @@
},
"expectedVersion": {
"label": "Expected acpx Version",
"help": "Exact version to enforce (for example 0.1.15) or \"any\" to skip strict version matching."
"help": "Exact version to enforce (for example 0.1.16) or \"any\" to skip strict version matching."
},
"cwd": {
"label": "Default Working Directory",

View File

@@ -4,7 +4,7 @@
"description": "OpenClaw ACP runtime backend via acpx",
"type": "module",
"dependencies": {
"acpx": "0.1.15"
"acpx": "0.1.16"
},
"openclaw": {
"extensions": [

View File

@@ -8,7 +8,7 @@ export type AcpxPermissionMode = (typeof ACPX_PERMISSION_MODES)[number];
export const ACPX_NON_INTERACTIVE_POLICIES = ["deny", "fail"] as const;
export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_POLICIES)[number];
export const ACPX_PINNED_VERSION = "0.1.15";
export const ACPX_PINNED_VERSION = "0.1.16";
export const ACPX_VERSION_ANY = "any";
const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx";
export const ACPX_PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");

View File

@@ -127,6 +127,65 @@ describe("AcpxRuntime", () => {
expect(promptArgs).toContain("--approve-all");
});
it("uses sessions new with --resume-session when resumeSessionId is provided", async () => {
const { runtime, logPath } = await createMockRuntimeFixture();
const resumeSessionId = "sid-resume-123";
const sessionKey = "agent:codex:acp:resume";
const handle = await runtime.ensureSession({
sessionKey,
agent: "codex",
mode: "persistent",
resumeSessionId,
});
expect(handle.backend).toBe("acpx");
expect(handle.acpxRecordId).toBe("rec-" + sessionKey);
const logs = await readMockRuntimeLogEntries(logPath);
expect(logs.some((entry) => entry.kind === "ensure")).toBe(false);
const resumeEntry = logs.find(
(entry) => entry.kind === "new" && String(entry.sessionName ?? "") === sessionKey,
);
expect(resumeEntry).toBeDefined();
const resumeArgs = (resumeEntry?.args as string[]) ?? [];
const resumeFlagIndex = resumeArgs.indexOf("--resume-session");
expect(resumeFlagIndex).toBeGreaterThanOrEqual(0);
expect(resumeArgs[resumeFlagIndex + 1]).toBe(resumeSessionId);
});
it("serializes text plus image attachments into ACP prompt blocks", async () => {
const { runtime, logPath } = await createMockRuntimeFixture();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:with-image",
agent: "codex",
mode: "persistent",
});
for await (const _event of runtime.runTurn({
handle,
text: "describe this image",
attachments: [{ mediaType: "image/png", data: "aW1hZ2UtYnl0ZXM=" }],
mode: "prompt",
requestId: "req-image",
})) {
// Consume stream to completion so prompt logging is finalized.
}
const logs = await readMockRuntimeLogEntries(logPath);
const prompt = logs.find(
(entry) =>
entry.kind === "prompt" && String(entry.sessionName ?? "") === "agent:codex:acp:with-image",
);
expect(prompt).toBeDefined();
const stdinBlocks = JSON.parse(String(prompt?.stdinText ?? ""));
expect(stdinBlocks).toEqual([
{ type: "text", text: "describe this image" },
{ type: "image", mimeType: "image/png", data: "aW1hZ2UtYnl0ZXM=" },
]);
});
it("preserves leading spaces across streamed text deltas", async () => {
const runtime = sharedFixture?.runtime;
expect(runtime).toBeDefined();

View File

@@ -203,10 +203,14 @@ export class AcpxRuntime implements AcpRuntime {
}
const cwd = asTrimmedString(input.cwd) || this.config.cwd;
const mode = input.mode;
const resumeSessionId = asTrimmedString(input.resumeSessionId);
const ensureSubcommand = resumeSessionId
? ["sessions", "new", "--name", sessionName, "--resume-session", resumeSessionId]
: ["sessions", "ensure", "--name", sessionName];
const ensureCommand = await this.buildVerbArgs({
agent,
cwd,
command: ["sessions", "ensure", "--name", sessionName],
command: ensureSubcommand,
});
let events = await this.runControlCommand({
@@ -221,7 +225,7 @@ export class AcpxRuntime implements AcpRuntime {
asOptionalString(event.acpxRecordId),
);
if (!ensuredEvent) {
if (!ensuredEvent && !resumeSessionId) {
const newCommand = await this.buildVerbArgs({
agent,
cwd,
@@ -238,12 +242,14 @@ export class AcpxRuntime implements AcpRuntime {
asOptionalString(event.acpxSessionId) ||
asOptionalString(event.acpxRecordId),
);
if (!ensuredEvent) {
throw new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`ACP session init failed: neither 'sessions ensure' nor 'sessions new' returned valid session identifiers for ${sessionName}.`,
);
}
}
if (!ensuredEvent) {
throw new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
resumeSessionId
? `ACP session init failed: 'sessions new --resume-session' returned no session identifiers for ${sessionName}.`
: `ACP session init failed: neither 'sessions ensure' nor 'sessions new' returned valid session identifiers for ${sessionName}.`,
);
}
const acpxRecordId = ensuredEvent ? asOptionalString(ensuredEvent.acpxRecordId) : undefined;
@@ -310,7 +316,20 @@ export class AcpxRuntime implements AcpRuntime {
// Ignore EPIPE when the child exits before stdin flush completes.
});
child.stdin.end(input.text);
if (input.attachments && input.attachments.length > 0) {
const blocks: unknown[] = [];
if (input.text) {
blocks.push({ type: "text", text: input.text });
}
for (const attachment of input.attachments) {
if (attachment.mediaType.startsWith("image/")) {
blocks.push({ type: "image", mimeType: attachment.mediaType, data: attachment.data });
}
}
child.stdin.end(blocks.length > 0 ? JSON.stringify(blocks) : input.text);
} else {
child.stdin.end(input.text);
}
let stderr = "";
child.stderr.on("data", (chunk) => {

View File

@@ -52,6 +52,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
to: "chat_1",
text: file,
accountId: "main",
mediaLocalRoots: [dir],
});
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
@@ -59,6 +60,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
to: "chat_1",
mediaUrl: file,
accountId: "main",
mediaLocalRoots: [dir],
}),
);
expect(sendMessageFeishuMock).not.toHaveBeenCalled();

View File

@@ -81,7 +81,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
sendText: async ({ cfg, to, text, accountId, replyToId, threadId, mediaLocalRoots }) => {
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
// Scheme A compatibility shim:
// when upstream accidentally returns a local image path as plain text,
@@ -95,6 +95,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
mediaUrl: localImagePath,
accountId: accountId ?? undefined,
replyToMessageId,
mediaLocalRoots,
});
return { channel: "feishu", ...result };
} catch (err) {

View File

@@ -8,7 +8,7 @@
"google-auth-library": "^10.6.1"
},
"peerDependencies": {
"openclaw": ">=2026.3.2"
"openclaw": ">=2026.3.7"
},
"peerDependenciesMeta": {
"openclaw": {

View File

@@ -214,6 +214,57 @@ describe("mattermostPlugin", () => {
]);
expect(result?.details).toEqual({});
});
it("maps replyTo to replyToId for send actions", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.({
channel: "mattermost",
action: "send",
params: {
to: "channel:CHAN1",
message: "hello",
replyTo: "post-root",
},
cfg,
accountId: "default",
} as any);
expect(sendMessageMattermostMock).toHaveBeenCalledWith(
"channel:CHAN1",
"hello",
expect.objectContaining({
accountId: "default",
replyToId: "post-root",
}),
);
});
it("falls back to trimmed replyTo when replyToId is blank", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.({
channel: "mattermost",
action: "send",
params: {
to: "channel:CHAN1",
message: "hello",
replyToId: " ",
replyTo: " post-root ",
},
cfg,
accountId: "default",
} as any);
expect(sendMessageMattermostMock).toHaveBeenCalledWith(
"channel:CHAN1",
"hello",
expect.objectContaining({
accountId: "default",
replyToId: "post-root",
}),
);
});
});
describe("outbound", () => {

View File

@@ -35,6 +35,7 @@ import { monitorMattermostProvider } from "./mattermost/monitor.js";
import { probeMattermost } from "./mattermost/probe.js";
import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js";
import { sendMessageMattermost } from "./mattermost/send.js";
import { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js";
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
import { mattermostOnboardingAdapter } from "./onboarding.js";
import { getMattermostRuntime } from "./runtime.js";
@@ -157,7 +158,9 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
}
const message = typeof params.message === "string" ? params.message : "";
const replyToId = typeof params.replyToId === "string" ? params.replyToId : undefined;
// Match the shared runner semantics: trim empty reply IDs away before
// falling back from replyToId to replyTo on direct plugin calls.
const replyToId = readMattermostReplyToId(params);
const resolvedAccountId = accountId || undefined;
const mediaUrl =
@@ -201,6 +204,18 @@ const meta = {
quickstartAllowFrom: true,
} as const;
function readMattermostReplyToId(params: Record<string, unknown>): string | undefined {
const readNormalizedValue = (value: unknown) => {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
};
return readNormalizedValue(params.replyToId) ?? readNormalizedValue(params.replyTo);
}
function normalizeAllowEntry(entry: string): string {
return entry
.trim()
@@ -326,6 +341,21 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
targetResolver: {
looksLikeId: looksLikeMattermostTargetId,
hint: "<channelId|user:ID|channel:ID>",
resolveTarget: async ({ cfg, accountId, input }) => {
const resolved = await resolveMattermostOpaqueTarget({
input,
cfg,
accountId,
});
if (!resolved) {
return null;
}
return {
to: resolved.to,
kind: resolved.kind,
source: "directory",
};
},
},
},
outbound: {

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { normalizeMention } from "./monitor-helpers.js";
describe("normalizeMention", () => {
it("returns trimmed text when no mention provided", () => {
expect(normalizeMention(" hello world ", undefined)).toBe("hello world");
});
it("strips bot mention from text", () => {
expect(normalizeMention("@echobot hello", "echobot")).toBe("hello");
});
it("strips mention case-insensitively", () => {
expect(normalizeMention("@EchoBot hello", "echobot")).toBe("hello");
});
it("preserves newlines in multi-line messages", () => {
const input = "@echobot\nline1\nline2\nline3";
const result = normalizeMention(input, "echobot");
expect(result).toBe("line1\nline2\nline3");
});
it("preserves Markdown headings", () => {
const input = "@echobot\n# Heading\n\nSome text";
const result = normalizeMention(input, "echobot");
expect(result).toContain("# Heading");
expect(result).toContain("\n");
});
it("preserves Markdown blockquotes", () => {
const input = "@echobot\n> quoted line\n> second line";
const result = normalizeMention(input, "echobot");
expect(result).toContain("> quoted line");
expect(result).toContain("> second line");
});
it("preserves Markdown lists", () => {
const input = "@echobot\n- item A\n- item B\n - sub B1";
const result = normalizeMention(input, "echobot");
expect(result).toContain("- item A");
expect(result).toContain("- item B");
});
it("preserves task lists", () => {
const input = "@echobot\n- [ ] todo\n- [x] done";
const result = normalizeMention(input, "echobot");
expect(result).toContain("- [ ] todo");
expect(result).toContain("- [x] done");
});
it("handles mention in middle of text", () => {
const input = "hey @echobot check this\nout";
const result = normalizeMention(input, "echobot");
expect(result).toBe("hey check this\nout");
});
it("preserves leading indentation for nested lists", () => {
const input = "@echobot\n- item\n - nested\n - deep";
const result = normalizeMention(input, "echobot");
expect(result).toContain(" - nested");
expect(result).toContain(" - deep");
});
it("preserves first-line indentation for nested list items", () => {
const input = "@echobot\n - nested\n - deep";
const result = normalizeMention(input, "echobot");
expect(result).toBe(" - nested\n - deep");
});
it("preserves indented code blocks", () => {
const input = "@echobot\ntext\n code line 1\n code line 2";
const result = normalizeMention(input, "echobot");
expect(result).toContain(" code line 1");
expect(result).toContain(" code line 2");
});
it("preserves first-line indentation for indented code blocks", () => {
const input = "@echobot\n code line 1\n code line 2";
const result = normalizeMention(input, "echobot");
expect(result).toBe(" code line 1\n code line 2");
});
});

View File

@@ -70,3 +70,38 @@ export function resolveThreadSessionKeys(params: {
normalizeThreadId: (threadId) => threadId,
});
}
/**
* Strip bot mention from message text while preserving newlines and
* block-level Markdown formatting (headings, lists, blockquotes).
*/
export function normalizeMention(text: string, mention: string | undefined): string {
if (!mention) {
return text.trim();
}
const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const hasMentionRe = new RegExp(`@${escaped}\\b`, "i");
const leadingMentionRe = new RegExp(`^([\\t ]*)@${escaped}\\b[\\t ]*`, "i");
const trailingMentionRe = new RegExp(`[\\t ]*@${escaped}\\b[\\t ]*$`, "i");
const normalizedLines = text.split("\n").map((line) => {
const hadMention = hasMentionRe.test(line);
const normalizedLine = line
.replace(leadingMentionRe, "$1")
.replace(trailingMentionRe, "")
.replace(new RegExp(`@${escaped}\\b`, "gi"), "")
.replace(/(\S)[ \t]{2,}/g, "$1 ");
return {
text: normalizedLine,
mentionOnlyBlank: hadMention && normalizedLine.trim() === "",
};
});
while (normalizedLines[0]?.mentionOnlyBlank) {
normalizedLines.shift();
}
while (normalizedLines.at(-1)?.text.trim() === "") {
normalizedLines.pop();
}
return normalizedLines.map((line) => line.text).join("\n");
}

View File

@@ -70,6 +70,7 @@ import {
import {
createDedupeCache,
formatInboundFromLabel,
normalizeMention,
resolveThreadSessionKeys,
} from "./monitor-helpers.js";
import { resolveOncharPrefixes, stripOncharPrefix } from "./monitor-onchar.js";
@@ -143,15 +144,6 @@ function resolveRuntime(opts: MonitorMattermostOpts): RuntimeEnv {
);
}
function normalizeMention(text: string, mention: string | undefined): string {
if (!mention) {
return text.trim();
}
const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`@${escaped}\\b`, "gi");
return text.replace(re, " ").replace(/\s+/g, " ").trim();
}
function isSystemPost(post: MattermostPost): boolean {
const type = post.type?.trim();
return Boolean(type);

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { parseMattermostTarget, sendMessageMattermost } from "./send.js";
import { resetMattermostOpaqueTargetCacheForTests } from "./target-resolution.js";
const mockState = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({})),
@@ -14,6 +15,7 @@ const mockState = vi.hoisted(() => ({
createMattermostPost: vi.fn(),
fetchMattermostChannelByName: vi.fn(),
fetchMattermostMe: vi.fn(),
fetchMattermostUser: vi.fn(),
fetchMattermostUserTeams: vi.fn(),
fetchMattermostUserByUsername: vi.fn(),
normalizeMattermostBaseUrl: vi.fn((input: string | undefined) => input?.trim() ?? ""),
@@ -34,6 +36,7 @@ vi.mock("./client.js", () => ({
createMattermostPost: mockState.createMattermostPost,
fetchMattermostChannelByName: mockState.fetchMattermostChannelByName,
fetchMattermostMe: mockState.fetchMattermostMe,
fetchMattermostUser: mockState.fetchMattermostUser,
fetchMattermostUserTeams: mockState.fetchMattermostUserTeams,
fetchMattermostUserByUsername: mockState.fetchMattermostUserByUsername,
normalizeMattermostBaseUrl: mockState.normalizeMattermostBaseUrl,
@@ -77,9 +80,11 @@ describe("sendMessageMattermost", () => {
mockState.createMattermostPost.mockReset();
mockState.fetchMattermostChannelByName.mockReset();
mockState.fetchMattermostMe.mockReset();
mockState.fetchMattermostUser.mockReset();
mockState.fetchMattermostUserTeams.mockReset();
mockState.fetchMattermostUserByUsername.mockReset();
mockState.uploadMattermostFile.mockReset();
resetMattermostOpaqueTargetCacheForTests();
mockState.createMattermostClient.mockReturnValue({});
mockState.createMattermostPost.mockResolvedValue({ id: "post-1" });
mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-user" });
@@ -182,6 +187,61 @@ describe("sendMessageMattermost", () => {
}),
);
});
it("resolves a bare Mattermost user id as a DM target before upload", async () => {
const userId = "dthcxgoxhifn3pwh65cut3ud3w";
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
mockState.createMattermostDirectChannel.mockResolvedValueOnce({ id: "dm-channel-1" });
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
buffer: Buffer.from("media-bytes"),
fileName: "photo.png",
contentType: "image/png",
kind: "image",
});
const result = await sendMessageMattermost(userId, "hello", {
mediaUrl: "file:///tmp/agent-workspace/photo.png",
mediaLocalRoots: ["/tmp/agent-workspace"],
});
expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, userId);
expect(mockState.createMattermostDirectChannel).toHaveBeenCalledWith({}, ["bot-user", userId]);
expect(mockState.uploadMattermostFile).toHaveBeenCalledWith(
{},
expect.objectContaining({
channelId: "dm-channel-1",
}),
);
expect(result.channelId).toBe("dm-channel-1");
});
it("falls back to a channel target when bare Mattermost id is not a user", async () => {
const channelId = "aaaaaaaaaaaaaaaaaaaaaaaaaa";
mockState.fetchMattermostUser.mockRejectedValueOnce(
new Error("Mattermost API 404 Not Found: user not found"),
);
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
buffer: Buffer.from("media-bytes"),
fileName: "photo.png",
contentType: "image/png",
kind: "image",
});
const result = await sendMessageMattermost(channelId, "hello", {
mediaUrl: "file:///tmp/agent-workspace/photo.png",
mediaLocalRoots: ["/tmp/agent-workspace"],
});
expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, channelId);
expect(mockState.createMattermostDirectChannel).not.toHaveBeenCalled();
expect(mockState.uploadMattermostFile).toHaveBeenCalledWith(
{},
expect.objectContaining({
channelId,
}),
);
expect(result.channelId).toBe(channelId);
});
});
describe("parseMattermostTarget", () => {
@@ -266,3 +326,110 @@ describe("parseMattermostTarget", () => {
expect(parseMattermostTarget("Mattermost:QRS")).toEqual({ kind: "user", id: "QRS" });
});
});
// Each test uses a unique (token, id) pair to avoid module-level cache collisions.
// userIdResolutionCache and dmChannelCache are module singletons that survive across tests.
// Using unique cache keys per test ensures full isolation without needing a cache reset API.
describe("sendMessageMattermost user-first resolution", () => {
function makeAccount(token: string) {
return {
accountId: "default",
botToken: token,
baseUrl: "https://mattermost.example.com",
};
}
beforeEach(() => {
vi.clearAllMocks();
mockState.createMattermostClient.mockReturnValue({});
mockState.createMattermostPost.mockResolvedValue({ id: "post-id" });
mockState.createMattermostDirectChannel.mockResolvedValue({ id: "dm-channel-id" });
mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-id" });
});
it("resolves unprefixed 26-char id as user and sends via DM channel", async () => {
// Unique token + id to avoid cache pollution from other tests
const userId = "aaaaaa1111111111aaaaaa1111"; // 26 chars
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-user-dm-t1"));
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
const res = await sendMessageMattermost(userId, "hello");
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
expect(mockState.createMattermostDirectChannel).toHaveBeenCalledTimes(1);
const params = mockState.createMattermostPost.mock.calls[0]?.[1];
expect(params.channelId).toBe("dm-channel-id");
expect(res.channelId).toBe("dm-channel-id");
expect(res.messageId).toBe("post-id");
});
it("falls back to channel id when user lookup returns 404", async () => {
// Unique token + id for this test
const channelId = "bbbbbb2222222222bbbbbb2222"; // 26 chars
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-404-t2"));
const err = new Error("Mattermost API 404: user not found");
mockState.fetchMattermostUser.mockRejectedValueOnce(err);
const res = await sendMessageMattermost(channelId, "hello");
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
expect(mockState.createMattermostDirectChannel).not.toHaveBeenCalled();
const params = mockState.createMattermostPost.mock.calls[0]?.[1];
expect(params.channelId).toBe(channelId);
expect(res.channelId).toBe(channelId);
});
it("falls back to channel id without caching negative result on transient error", async () => {
// Two unique tokens so each call has its own cache namespace
const userId = "cccccc3333333333cccccc3333"; // 26 chars
const tokenA = "token-transient-t3a";
const tokenB = "token-transient-t3b";
const transientErr = new Error("Mattermost API 503: service unavailable");
// First call: transient error → fall back to channel id, do NOT cache negative
mockState.resolveMattermostAccount.mockReturnValue(makeAccount(tokenA));
mockState.fetchMattermostUser.mockRejectedValueOnce(transientErr);
const res1 = await sendMessageMattermost(userId, "first");
expect(res1.channelId).toBe(userId);
// Second call with a different token (new cache key) → retries user lookup
vi.clearAllMocks();
mockState.createMattermostClient.mockReturnValue({});
mockState.createMattermostPost.mockResolvedValue({ id: "post-id-2" });
mockState.createMattermostDirectChannel.mockResolvedValue({ id: "dm-channel-id" });
mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-id" });
mockState.resolveMattermostAccount.mockReturnValue(makeAccount(tokenB));
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
const res2 = await sendMessageMattermost(userId, "second");
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
expect(res2.channelId).toBe("dm-channel-id");
});
it("does not apply user-first resolution for explicit user: prefix", async () => {
// Unique token + id — explicit user: prefix bypasses probe, goes straight to DM
const userId = "dddddd4444444444dddddd4444"; // 26 chars
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-explicit-user-t4"));
const res = await sendMessageMattermost(`user:${userId}`, "hello");
expect(mockState.fetchMattermostUser).not.toHaveBeenCalled();
expect(mockState.createMattermostDirectChannel).toHaveBeenCalledTimes(1);
expect(res.channelId).toBe("dm-channel-id");
});
it("does not apply user-first resolution for explicit channel: prefix", async () => {
// Unique token + id — explicit channel: prefix, no probe, no DM
const chanId = "eeeeee5555555555eeeeee5555"; // 26 chars
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-explicit-chan-t5"));
const res = await sendMessageMattermost(`channel:${chanId}`, "hello");
expect(mockState.fetchMattermostUser).not.toHaveBeenCalled();
expect(mockState.createMattermostDirectChannel).not.toHaveBeenCalled();
const params = mockState.createMattermostPost.mock.calls[0]?.[1];
expect(params.channelId).toBe(chanId);
expect(res.channelId).toBe(chanId);
});
});

View File

@@ -19,6 +19,7 @@ import {
setInteractionSecret,
type MattermostInteractiveButtonInput,
} from "./interactions.js";
import { isMattermostId, resolveMattermostOpaqueTarget } from "./target-resolution.js";
export type MattermostSendOpts = {
cfg?: OpenClawConfig;
@@ -50,6 +51,7 @@ type MattermostTarget =
const botUserCache = new Map<string, MattermostUser>();
const userByNameCache = new Map<string, MattermostUser>();
const channelByNameCache = new Map<string, string>();
const dmChannelCache = new Map<string, string>();
const getCore = () => getMattermostRuntime();
@@ -66,12 +68,6 @@ function normalizeMessage(text: string, mediaUrl?: string): string {
function isHttpUrl(value: string): boolean {
return /^https?:\/\//i.test(value);
}
/** Mattermost IDs are 26-character lowercase alphanumeric strings. */
function isMattermostId(value: string): boolean {
return /^[a-z0-9]{26}$/.test(value);
}
export function parseMattermostTarget(raw: string): MattermostTarget {
const trimmed = raw.trim();
if (!trimmed) {
@@ -208,12 +204,18 @@ async function resolveTargetChannelId(params: {
token: params.token,
username: params.target.username ?? "",
});
const dmKey = `${cacheKey(params.baseUrl, params.token)}::dm::${userId}`;
const cachedDm = dmChannelCache.get(dmKey);
if (cachedDm) {
return cachedDm;
}
const botUser = await resolveBotUser(params.baseUrl, params.token);
const client = createMattermostClient({
baseUrl: params.baseUrl,
botToken: params.token,
});
const channel = await createMattermostDirectChannel(client, [botUser.id, userId]);
dmChannelCache.set(dmKey, channel.id);
return channel.id;
}
@@ -248,7 +250,18 @@ async function resolveMattermostSendContext(
);
}
const target = parseMattermostTarget(to);
const trimmedTo = to?.trim() ?? "";
const opaqueTarget = await resolveMattermostOpaqueTarget({
input: trimmedTo,
token,
baseUrl,
});
const target =
opaqueTarget?.kind === "user"
? { kind: "user" as const, id: opaqueTarget.id }
: opaqueTarget?.kind === "channel"
? { kind: "channel" as const, id: opaqueTarget.id }
: parseMattermostTarget(trimmedTo);
const channelId = await resolveTargetChannelId({
target,
baseUrl,

View File

@@ -0,0 +1,97 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
import { resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,
fetchMattermostUser,
normalizeMattermostBaseUrl,
} from "./client.js";
export type MattermostOpaqueTargetResolution = {
kind: "user" | "channel";
id: string;
to: string;
};
const mattermostOpaqueTargetCache = new Map<string, boolean>();
function cacheKey(baseUrl: string, token: string, id: string): string {
return `${baseUrl}::${token}::${id}`;
}
/** Mattermost IDs are 26-character lowercase alphanumeric strings. */
export function isMattermostId(value: string): boolean {
return /^[a-z0-9]{26}$/.test(value);
}
export function isExplicitMattermostTarget(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
return (
/^(channel|user|mattermost):/i.test(trimmed) ||
trimmed.startsWith("@") ||
trimmed.startsWith("#")
);
}
export function parseMattermostApiStatus(err: unknown): number | undefined {
if (!err || typeof err !== "object") {
return undefined;
}
const msg = "message" in err ? String((err as { message?: unknown }).message ?? "") : "";
const match = /Mattermost API (\d{3})\b/.exec(msg);
if (!match) {
return undefined;
}
const code = Number(match[1]);
return Number.isFinite(code) ? code : undefined;
}
export async function resolveMattermostOpaqueTarget(params: {
input: string;
cfg?: OpenClawConfig;
accountId?: string | null;
token?: string;
baseUrl?: string;
}): Promise<MattermostOpaqueTargetResolution | null> {
const input = params.input.trim();
if (!input || isExplicitMattermostTarget(input) || !isMattermostId(input)) {
return null;
}
const account =
params.cfg && (!params.token || !params.baseUrl)
? resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId })
: null;
const token = params.token?.trim() || account?.botToken?.trim();
const baseUrl = normalizeMattermostBaseUrl(params.baseUrl ?? account?.baseUrl);
if (!token || !baseUrl) {
return null;
}
const key = cacheKey(baseUrl, token, input);
const cached = mattermostOpaqueTargetCache.get(key);
if (cached === true) {
return { kind: "user", id: input, to: `user:${input}` };
}
if (cached === false) {
return { kind: "channel", id: input, to: `channel:${input}` };
}
const client = createMattermostClient({ baseUrl, botToken: token });
try {
await fetchMattermostUser(client, input);
mattermostOpaqueTargetCache.set(key, true);
return { kind: "user", id: input, to: `user:${input}` };
} catch (err) {
if (parseMattermostApiStatus(err) === 404) {
mattermostOpaqueTargetCache.set(key, false);
}
return { kind: "channel", id: input, to: `channel:${input}` };
}
}
export function resetMattermostOpaqueTargetCacheForTests(): void {
mattermostOpaqueTargetCache.clear();
}

View File

@@ -5,7 +5,7 @@
"description": "OpenClaw core memory search plugin",
"type": "module",
"peerDependencies": {
"openclaw": ">=2026.3.2"
"openclaw": ">=2026.3.7"
},
"peerDependenciesMeta": {
"openclaw": {

View File

@@ -54,10 +54,12 @@ describe("resolveMSTeamsUserAllowlist", () => {
describe("resolveMSTeamsChannelAllowlist", () => {
it("resolves team/channel by team name + channel display name", async () => {
listTeamsByName.mockResolvedValueOnce([{ id: "team-1", displayName: "Product Team" }]);
// After the fix, listChannelsForTeam is called once and reused for both
// General channel resolution and channel matching.
listTeamsByName.mockResolvedValueOnce([{ id: "team-guid-1", displayName: "Product Team" }]);
listChannelsForTeam.mockResolvedValueOnce([
{ id: "channel-1", displayName: "General" },
{ id: "channel-2", displayName: "Roadmap" },
{ id: "19:general-conv-id@thread.tacv2", displayName: "General" },
{ id: "19:roadmap-conv-id@thread.tacv2", displayName: "Roadmap" },
]);
const [result] = await resolveMSTeamsChannelAllowlist({
@@ -65,14 +67,80 @@ describe("resolveMSTeamsChannelAllowlist", () => {
entries: ["Product Team/Roadmap"],
});
// teamId is now the General channel's conversation ID — not the Graph GUID —
// because that's what Bot Framework sends as channelData.team.id at runtime.
expect(result).toEqual({
input: "Product Team/Roadmap",
resolved: true,
teamId: "team-1",
teamId: "19:general-conv-id@thread.tacv2",
teamName: "Product Team",
channelId: "channel-2",
channelId: "19:roadmap-conv-id@thread.tacv2",
channelName: "Roadmap",
note: "multiple channels; chose first",
});
});
it("uses General channel conversation ID as team key for team-only entry", async () => {
// When no channel is specified we still resolve the General channel so the
// stored key matches what Bot Framework sends as channelData.team.id.
listTeamsByName.mockResolvedValueOnce([{ id: "guid-engineering", displayName: "Engineering" }]);
listChannelsForTeam.mockResolvedValueOnce([
{ id: "19:eng-general@thread.tacv2", displayName: "General" },
{ id: "19:eng-standups@thread.tacv2", displayName: "Standups" },
]);
const [result] = await resolveMSTeamsChannelAllowlist({
cfg: {},
entries: ["Engineering"],
});
expect(result).toEqual({
input: "Engineering",
resolved: true,
teamId: "19:eng-general@thread.tacv2",
teamName: "Engineering",
});
});
it("falls back to Graph GUID when listChannelsForTeam throws", async () => {
// Edge case: API call fails (rate limit, network error). We fall back to
// the Graph GUID as the team key — the pre-fix behavior — so resolution
// still succeeds instead of propagating the error.
listTeamsByName.mockResolvedValueOnce([{ id: "guid-flaky", displayName: "Flaky Team" }]);
listChannelsForTeam.mockRejectedValueOnce(new Error("429 Too Many Requests"));
const [result] = await resolveMSTeamsChannelAllowlist({
cfg: {},
entries: ["Flaky Team"],
});
expect(result).toEqual({
input: "Flaky Team",
resolved: true,
teamId: "guid-flaky",
teamName: "Flaky Team",
});
});
it("falls back to Graph GUID when General channel is not found", async () => {
// Edge case: General channel was renamed or deleted. We fall back to the
// Graph GUID so resolution still succeeds rather than silently breaking.
listTeamsByName.mockResolvedValueOnce([{ id: "guid-ops", displayName: "Operations" }]);
listChannelsForTeam.mockResolvedValueOnce([
{ id: "19:ops-announce@thread.tacv2", displayName: "Announcements" },
{ id: "19:ops-random@thread.tacv2", displayName: "Random" },
]);
const [result] = await resolveMSTeamsChannelAllowlist({
cfg: {},
entries: ["Operations"],
});
expect(result).toEqual({
input: "Operations",
resolved: true,
teamId: "guid-ops",
teamName: "Operations",
});
});
});

View File

@@ -120,11 +120,26 @@ export async function resolveMSTeamsChannelAllowlist(params: {
return { input, resolved: false, note: "team not found" };
}
const teamMatch = teams[0];
const teamId = teamMatch.id?.trim();
const graphTeamId = teamMatch.id?.trim();
const teamName = teamMatch.displayName?.trim() || team;
if (!teamId) {
if (!graphTeamId) {
return { input, resolved: false, note: "team id missing" };
}
// Bot Framework sends the General channel's conversation ID as
// channelData.team.id at runtime, NOT the Graph API group GUID.
// Fetch channels upfront so we can resolve the correct key format for
// runtime matching and reuse the list for channel lookups.
let teamChannels: Awaited<ReturnType<typeof listChannelsForTeam>> = [];
try {
teamChannels = await listChannelsForTeam(token, graphTeamId);
} catch {
// API failure (rate limit, network error) — fall back to Graph GUID as team key
}
const generalChannel = teamChannels.find((ch) => ch.displayName?.toLowerCase() === "general");
// Use the General channel's conversation ID as the team key — this
// matches what Bot Framework sends at runtime. Fall back to the Graph
// GUID if the General channel isn't found (renamed or deleted).
const teamId = generalChannel?.id?.trim() || graphTeamId;
if (!channel) {
return {
input,
@@ -134,11 +149,11 @@ export async function resolveMSTeamsChannelAllowlist(params: {
note: teams.length > 1 ? "multiple teams; chose first" : undefined,
};
}
const channels = await listChannelsForTeam(token, teamId);
// Reuse teamChannels — already fetched above
const channelMatch =
channels.find((item) => item.id === channel) ??
channels.find((item) => item.displayName?.toLowerCase() === channel.toLowerCase()) ??
channels.find((item) =>
teamChannels.find((item) => item.id === channel) ??
teamChannels.find((item) => item.displayName?.toLowerCase() === channel.toLowerCase()) ??
teamChannels.find((item) =>
item.displayName?.toLowerCase().includes(channel.toLowerCase() ?? ""),
);
if (!channelMatch?.id) {
@@ -151,7 +166,7 @@ export async function resolveMSTeamsChannelAllowlist(params: {
teamName,
channelId: channelMatch.id,
channelName: channelMatch.displayName ?? channel,
note: channels.length > 1 ? "multiple channels; chose first" : undefined,
note: teamChannels.length > 1 ? "multiple channels; chose first" : undefined,
};
},
});

View File

@@ -57,18 +57,38 @@ function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: strin
const probeTelegram = vi.fn(async () =>
params?.probeOk ? { ok: true, bot: { username: params.botUsername ?? "bot" } } : { ok: false },
);
const collectUnmentionedGroupIds = vi.fn(() => ({
groupIds: [] as string[],
unresolvedGroups: 0,
hasWildcardUnmentionedGroups: false,
}));
const auditGroupMembership = vi.fn(async () => ({
ok: true,
checkedGroups: 0,
unresolvedGroups: 0,
hasWildcardUnmentionedGroups: false,
groups: [],
elapsedMs: 0,
}));
setTelegramRuntime({
channel: {
telegram: {
monitorTelegramProvider,
probeTelegram,
collectUnmentionedGroupIds,
auditGroupMembership,
},
},
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime);
return { monitorTelegramProvider, probeTelegram };
return {
monitorTelegramProvider,
probeTelegram,
collectUnmentionedGroupIds,
auditGroupMembership,
};
}
describe("telegramPlugin duplicate token guard", () => {
@@ -149,6 +169,85 @@ describe("telegramPlugin duplicate token guard", () => {
);
});
it("passes account proxy and network settings into Telegram probes", async () => {
const { probeTelegram } = installGatewayRuntime({
probeOk: true,
botUsername: "opsbot",
});
const cfg = createCfg();
cfg.channels!.telegram!.accounts!.ops = {
...cfg.channels!.telegram!.accounts!.ops,
proxy: "http://127.0.0.1:8888",
network: {
autoSelectFamily: false,
dnsResultOrder: "ipv4first",
},
};
const account = telegramPlugin.config.resolveAccount(cfg, "ops");
await telegramPlugin.status!.probeAccount!({
account,
timeoutMs: 5000,
cfg,
});
expect(probeTelegram).toHaveBeenCalledWith("token-ops", 5000, {
accountId: "ops",
proxyUrl: "http://127.0.0.1:8888",
network: {
autoSelectFamily: false,
dnsResultOrder: "ipv4first",
},
});
});
it("passes account proxy and network settings into Telegram membership audits", async () => {
const { collectUnmentionedGroupIds, auditGroupMembership } = installGatewayRuntime({
probeOk: true,
botUsername: "opsbot",
});
collectUnmentionedGroupIds.mockReturnValue({
groupIds: ["-100123"],
unresolvedGroups: 0,
hasWildcardUnmentionedGroups: false,
});
const cfg = createCfg();
cfg.channels!.telegram!.accounts!.ops = {
...cfg.channels!.telegram!.accounts!.ops,
proxy: "http://127.0.0.1:8888",
network: {
autoSelectFamily: false,
dnsResultOrder: "ipv4first",
},
groups: {
"-100123": { requireMention: false },
},
};
const account = telegramPlugin.config.resolveAccount(cfg, "ops");
await telegramPlugin.status!.auditAccount!({
account,
timeoutMs: 5000,
probe: { ok: true, bot: { id: 123 }, elapsedMs: 1 },
cfg,
});
expect(auditGroupMembership).toHaveBeenCalledWith({
token: "token-ops",
botId: 123,
groupIds: ["-100123"],
proxyUrl: "http://127.0.0.1:8888",
network: {
autoSelectFamily: false,
dnsResultOrder: "ipv4first",
},
timeoutMs: 5000,
});
});
it("forwards mediaLocalRoots to sendMessageTelegram for outbound media sends", async () => {
const sendMessageTelegram = vi.fn(async () => ({ messageId: "tg-1" }));
setTelegramRuntime({
@@ -179,6 +278,41 @@ describe("telegramPlugin duplicate token guard", () => {
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-1" });
});
it("preserves buttons for outbound text payload sends", async () => {
const sendMessageTelegram = vi.fn(async () => ({ messageId: "tg-2" }));
setTelegramRuntime({
channel: {
telegram: {
sendMessageTelegram,
},
},
} as unknown as PluginRuntime);
const result = await telegramPlugin.outbound!.sendPayload!({
cfg: createCfg(),
to: "12345",
text: "",
payload: {
text: "Approval required",
channelData: {
telegram: {
buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]],
},
},
},
accountId: "ops",
});
expect(sendMessageTelegram).toHaveBeenCalledWith(
"12345",
"Approval required",
expect.objectContaining({
buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]],
}),
);
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-2" });
});
it("ignores accounts with missing tokens during duplicate-token checks", async () => {
const cfg = createCfg();
cfg.channels!.telegram!.accounts!.ops = {} as never;

View File

@@ -91,6 +91,10 @@ const telegramMessageActions: ChannelMessageActionAdapter = {
},
};
type TelegramInlineButtons = ReadonlyArray<
ReadonlyArray<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }>
>;
const telegramConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom,
@@ -317,6 +321,62 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
chunkerMode: "markdown",
textChunkLimit: 4000,
pollMaxOptions: 10,
sendPayload: async ({
cfg,
to,
payload,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
silent,
}) => {
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
const replyToMessageId = parseTelegramReplyToMessageId(replyToId);
const messageThreadId = parseTelegramThreadId(threadId);
const telegramData = payload.channelData?.telegram as
| { buttons?: TelegramInlineButtons; quoteText?: string }
| undefined;
const quoteText =
typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;
const text = payload.text ?? "";
const mediaUrls = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
const baseOpts = {
verbose: false,
cfg,
mediaLocalRoots,
messageThreadId,
replyToMessageId,
quoteText,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
};
if (mediaUrls.length === 0) {
const result = await send(to, text, {
...baseOpts,
buttons: telegramData?.buttons,
});
return { channel: "telegram", ...result };
}
let finalResult: Awaited<ReturnType<typeof send>> | undefined;
for (let i = 0; i < mediaUrls.length; i += 1) {
const mediaUrl = mediaUrls[i];
const isFirst = i === 0;
finalResult = await send(to, isFirst ? text : "", {
...baseOpts,
mediaUrl,
...(isFirst ? { buttons: telegramData?.buttons } : {}),
});
}
return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) };
},
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => {
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
const replyToMessageId = parseTelegramReplyToMessageId(replyToId);
@@ -378,11 +438,11 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
collectStatusIssues: collectTelegramStatusIssues,
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
probeAccount: async ({ account, timeoutMs }) =>
getTelegramRuntime().channel.telegram.probeTelegram(
account.token,
timeoutMs,
account.config.proxy,
),
getTelegramRuntime().channel.telegram.probeTelegram(account.token, timeoutMs, {
accountId: account.accountId,
proxyUrl: account.config.proxy,
network: account.config.network,
}),
auditAccount: async ({ account, timeoutMs, probe, cfg }) => {
const groups =
cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ??
@@ -408,6 +468,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
botId,
groupIds,
proxyUrl: account.config.proxy,
network: account.config.network,
timeoutMs,
});
return { ...audit, unresolvedGroups, hasWildcardUnmentionedGroups };
@@ -471,11 +532,11 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
const token = (account.token ?? "").trim();
let telegramBotLabel = "";
try {
const probe = await getTelegramRuntime().channel.telegram.probeTelegram(
token,
2500,
account.config.proxy,
);
const probe = await getTelegramRuntime().channel.telegram.probeTelegram(token, 2500, {
accountId: account.accountId,
proxyUrl: account.config.proxy,
network: account.config.network,
});
const username = probe.ok ? probe.bot?.username?.trim() : null;
if (username) {
telegramBotLabel = ` (@${username})`;

View File

@@ -253,6 +253,11 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
state: {
resolveStateDir: vi.fn(() => "/tmp/openclaw"),
},
modelAuth: {
getApiKeyForModel: vi.fn() as unknown as PluginRuntime["modelAuth"]["getApiKeyForModel"],
resolveApiKeyForProvider:
vi.fn() as unknown as PluginRuntime["modelAuth"]["resolveApiKeyForProvider"],
},
subagent: {
run: vi.fn(),
waitForRun: vi.fn(),

582
pnpm-lock.yaml generated
View File

@@ -254,8 +254,8 @@ importers:
extensions/acpx:
dependencies:
acpx:
specifier: 0.1.15
version: 0.1.15(zod@4.3.6)
specifier: 0.1.16
version: 0.1.16(zod@4.3.6)
extensions/bluebubbles:
dependencies:
@@ -338,8 +338,8 @@ importers:
specifier: ^10.6.1
version: 10.6.1
openclaw:
specifier: '>=2026.3.2'
version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3))
specifier: '>=2026.3.7'
version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3))
extensions/imessage: {}
@@ -399,8 +399,8 @@ importers:
extensions/memory-core:
dependencies:
openclaw:
specifier: '>=2026.3.2'
version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3))
specifier: '>=2026.3.7'
version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3))
extensions/memory-lancedb:
dependencies:
@@ -576,11 +576,6 @@ importers:
packages:
'@agentclientprotocol/sdk@0.14.1':
resolution: {integrity: sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w==}
peerDependencies:
zod: ^3.25.0 || ^4.0.0
'@agentclientprotocol/sdk@0.15.0':
resolution: {integrity: sha512-TH4utu23Ix8ec34srBHmDD4p3HI0cYleS1jN9lghRczPfhFlMBNrQgZWeBBe12DWy27L11eIrtciY2MXFSEiDg==}
peerDependencies:
@@ -618,18 +613,10 @@ packages:
'@aws-crypto/util@5.2.0':
resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==}
'@aws-sdk/client-bedrock-runtime@3.1000.0':
resolution: {integrity: sha512-GA96wgTFB4Z5vhysm+hErbgiEWZ9JqAl09BxARajL7Oanpf0KvdIjxuLp2rD/XqEIks9yG/5Rh9XIAoCUUTZXw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-bedrock-runtime@3.1004.0':
resolution: {integrity: sha512-t8cl+bPLlHZQD2Sw1a4hSLUybqJZU71+m8znkyeU8CHntFqEp2mMbuLKdHKaAYQ1fAApXMsvzenCAkDzNeeJlw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-bedrock@3.1000.0':
resolution: {integrity: sha512-wGU8uJXrPW/hZuHdPNVe1kAFIBiKcslBcoDBN0eYBzS13um8p5jJiQJ9WsD1nSpKCmyx7qZXc6xjcbIQPyOrrA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-bedrock@3.1004.0':
resolution: {integrity: sha512-JbfZSV85IL+43S7rPBmeMbvoOYXs1wmrfbEpHkDBjkvbukRQWtoetiPAXNSKDfFq1qVsoq8sWPdoerDQwlUO8w==}
engines: {node: '>=20.0.0'}
@@ -718,18 +705,10 @@ packages:
resolution: {integrity: sha512-g2Z9s6Y4iNh0wICaEqutgYgt/Pmhv5Ev9G3eKGFe2w9VuZDhc76vYdop6I5OocmpHV79d4TuLG+JWg5rQIVDVA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/eventstream-handler-node@3.972.9':
resolution: {integrity: sha512-mKPiiVssgFDWkAXdEDh8+wpr2pFSX/fBn2onXXnrfIAYbdZhYb4WilKbZ3SJMUnQi+Y48jZMam5J0RrgARluaA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-bucket-endpoint@3.972.6':
resolution: {integrity: sha512-3H2bhvb7Cb/S6WFsBy/Dy9q2aegC9JmGH1inO8Lb2sWirSqpLJlZmvQHPE29h2tIxzv6el/14X/tLCQ8BQU6ZQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-eventstream@3.972.6':
resolution: {integrity: sha512-mB2+3G/oxRC+y9WRk0KCdradE2rSfxxJpcOSmAm+vDh3ex3WQHVLZ1catNIe1j5NQ+3FLBsNMRPVGkZ43PRpjw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-eventstream@3.972.7':
resolution: {integrity: sha512-VWndapHYCfwLgPpCb/xwlMKG4imhFzKJzZcKOEioGn7OHY+6gdr0K7oqy1HZgbLa3ACznZ9fku+DzmAi8fUC0g==}
engines: {node: '>=20.0.0'}
@@ -786,10 +765,6 @@ packages:
resolution: {integrity: sha512-Km90fcXt3W/iqujHzuM6IaDkYCj73gsYufcuWXApWdzoTy6KGk8fnchAjePMARU0xegIR3K4N3yIo1vy7OVe8A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-websocket@3.972.10':
resolution: {integrity: sha512-uNqRpbL6djE+XXO4cQ+P8ra37cxNNBP+2IfkVOXu1xFdGMfW+uOTxBQuDPpP43i40PBRBXK5un79l/oYpbzYkA==}
engines: {node: '>= 14.0.0'}
'@aws-sdk/middleware-websocket@3.972.12':
resolution: {integrity: sha512-iyPP6FVDKe/5wy5ojC0akpDFG1vX3FeCUU47JuwN8xfvT66xlEI8qUJZPtN55TJVFzzWZJpWL78eqUE31md08Q==}
engines: {node: '>= 14.0.0'}
@@ -818,10 +793,6 @@ packages:
resolution: {integrity: sha512-gQYI/Buwp0CAGQxY7mR5VzkP56rkWq2Y1ROkFuXh5XY94DsSjJw62B3I0N0lysQmtwiL2ht2KHI9NylM/RP4FA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1000.0':
resolution: {integrity: sha512-eOI+8WPtWpLdlYBGs8OCK3k5uIMUHVsNG3AFO4kaRaZcKReJ/2OO6+2O2Dd/3vTzM56kRjSKe7mBOCwa4PdYqg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1004.0':
resolution: {integrity: sha512-j9BwZZId9sFp+4GPhf6KrwO8Tben2sXibZA8D1vv2I1zBdvkUHcBA2g4pkqIpTRalMTLC0NPkBPX0gERxfy/iA==}
engines: {node: '>=20.0.0'}
@@ -980,15 +951,9 @@ packages:
'@cacheable/utils@2.3.4':
resolution: {integrity: sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA==}
'@clack/core@1.0.1':
resolution: {integrity: sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==}
'@clack/core@1.1.0':
resolution: {integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==}
'@clack/prompts@1.0.1':
resolution: {integrity: sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q==}
'@clack/prompts@1.1.0':
resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==}
@@ -1222,15 +1187,6 @@ packages:
'@eshaz/web-worker@1.2.2':
resolution: {integrity: sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw==}
'@google/genai@1.43.0':
resolution: {integrity: sha512-hklCsJNdMlDM1IwcCVcGQFBg2izY0+t5BIGbRsxi2UnKi6AGKL7pqJqmBDNRbw0bYCs4y3NA7TB+fkKfP/Nrdw==}
engines: {node: '>=20.0.0'}
peerDependencies:
'@modelcontextprotocol/sdk': ^1.25.2
peerDependenciesMeta:
'@modelcontextprotocol/sdk':
optional: true
'@google/genai@1.44.0':
resolution: {integrity: sha512-kRt9ZtuXmz+tLlcNntN/VV4LRdpl6ZOu5B1KbfNgfR65db15O6sUQcwnwLka8sT/V6qysD93fWrgJHF2L7dA9A==}
engines: {node: '>=20.0.0'}
@@ -1644,38 +1600,20 @@ packages:
resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==}
hasBin: true
'@mariozechner/pi-agent-core@0.55.3':
resolution: {integrity: sha512-rqbfpQ9BrP6BDiW+Ps3A8Z/p9+Md/pAfc/ECq8JP6cwnZL/jQgU355KWZKtF8zM9az1p0Q9hIWi9cQygVo6Auw==}
engines: {node: '>=20.0.0'}
'@mariozechner/pi-agent-core@0.57.1':
resolution: {integrity: sha512-WXsBbkNWOObFGHkhixaT8GXJpHDd3+fn8QntYF+4R8Sa9WB90ENXWidO6b7vcKX+JX0jjO5dIsQxmzosARJKlg==}
engines: {node: '>=20.0.0'}
'@mariozechner/pi-ai@0.55.3':
resolution: {integrity: sha512-f9jWoDzJR9Wy/H8JPMbjoM4WvVUeFZ65QdYA9UHIfoOopDfwWE8F8JHQOj5mmmILMacXuzsqA3J7MYqNWZRvvQ==}
engines: {node: '>=20.0.0'}
hasBin: true
'@mariozechner/pi-ai@0.57.1':
resolution: {integrity: sha512-Bd/J4a3YpdzJVyHLih0vDSdB0QPL4ti0XsAwtHOK/8eVhB0fHM1CpcgIrcBFJ23TMcKXMi0qamz18ERfp8tmgg==}
engines: {node: '>=20.0.0'}
hasBin: true
'@mariozechner/pi-coding-agent@0.55.3':
resolution: {integrity: sha512-5SFbB7/BIp/Crjre7UNjUeNfpoU1KSW/i6LXa+ikJTBqI5LukWq2avE5l0v0M8Pg/dt1go2XCLrNFlQJiQDSPQ==}
engines: {node: '>=20.0.0'}
hasBin: true
'@mariozechner/pi-coding-agent@0.57.1':
resolution: {integrity: sha512-u5MQEduj68rwVIsRsqrWkJYiJCyPph/a6bMoJAQKo1sb+Pc17Y/ojwa+wGssnUMjEB38AQKofWTVe8NFEpSWNw==}
engines: {node: '>=20.6.0'}
hasBin: true
'@mariozechner/pi-tui@0.55.3':
resolution: {integrity: sha512-Gh4wkYgiSPCJJaB/4wEWSL7Ga8bxSq1Crp1RPRT4vKybE/DG0W/MQr5VJDvktarxtJrD16ixScwE4dzdox/PIA==}
engines: {node: '>=20.0.0'}
'@mariozechner/pi-tui@0.57.1':
resolution: {integrity: sha512-cjoRghLbeAHV0tTJeHgZXaryUi5zzBZofeZ7uJun1gztnckLLRjoVeaPTujNlc5BIfyKvFqhh1QWCZng/MXlpg==}
engines: {node: '>=20.0.0'}
@@ -1692,9 +1630,6 @@ packages:
resolution: {integrity: sha512-570oJr93l1RcCNNaMVpOm+PgQkRgno/F65nH1aCWLIKLnw0o7iPoj+8Z5b7mnLMidg9lldVSCcf0dBxqTGE1/w==}
engines: {node: '>=20.0.0'}
'@mistralai/mistralai@1.10.0':
resolution: {integrity: sha512-tdIgWs4Le8vpvPiUEWne6tK0qbVc+jMenujnvTqOjogrJUsCSQhus0tHTU1avDDh5//Rq2dFgP9mWRAdIEoBqg==}
'@mistralai/mistralai@1.14.1':
resolution: {integrity: sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==}
@@ -3198,93 +3133,6 @@ packages:
resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==}
engines: {node: '>=18.0.0'}
'@snazzah/davey-android-arm-eabi@0.1.9':
resolution: {integrity: sha512-Dq0WyeVGBw+uQbisV/6PeCQV2ndJozfhZqiNIfQxu6ehIdXB7iHILv+oY+AQN2n+qxiFmLh/MOX9RF+pIWdPbA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
'@snazzah/davey-android-arm64@0.1.9':
resolution: {integrity: sha512-OE16OZjv7F/JrD7Mzw5eL2gY2vXRPC8S7ZrmkcMyz/sHHJsGHlT+L7X5s56Bec1YDTVmzAsH4UBuvVBoXuIWEQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@snazzah/davey-darwin-arm64@0.1.9':
resolution: {integrity: sha512-z7oORvAPExikFkH6tvHhbUdZd77MYZp9VqbCpKEiI+sisWFVXgHde7F7iH3G4Bz6gUYJfgvKhWXiDRc+0SC4dg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@snazzah/davey-darwin-x64@0.1.9':
resolution: {integrity: sha512-f1LzGyRGlM414KpXml3OgWVSd7CgylcdYaFj/zDBb8bvWjxyvsI9iMeuPfe/cduloxRj8dELde/yCDZtFR6PdQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@snazzah/davey-freebsd-x64@0.1.9':
resolution: {integrity: sha512-k6p3JY2b8rD6j0V9Ql7kBUMR4eJdcpriNwiHltLzmtGuz/nK5RGQdkEP68gTLc+Uj3xs5Cy0jRKmv2xJQBR4sA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@snazzah/davey-linux-arm-gnueabihf@0.1.9':
resolution: {integrity: sha512-xDaAFUC/1+n/YayNwKsqKOBMuW0KI6F0SjgWU+krYTQTVmAKNjOM80IjemrVoqTpBOxBsT80zEtct2wj11CE3Q==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@snazzah/davey-linux-arm64-gnu@0.1.9':
resolution: {integrity: sha512-t1VxFBzWExPNpsNY/9oStdAAuHqFvwZvIO2YPYyVNstxfi2KmAbHMweHUW7xb2ppXuhVQZ4VGmmeXiXcXqhPBw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@snazzah/davey-linux-arm64-musl@0.1.9':
resolution: {integrity: sha512-Xvlr+nBPzuFV4PXHufddlt08JsEyu0p8mX2DpqdPxdpysYIH4I8V86yJiS4tk04a6pLBDd8IxTbBwvXJKqd/LQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@snazzah/davey-linux-x64-gnu@0.1.9':
resolution: {integrity: sha512-6Uunc/NxiEkg1reroAKZAGfOtjl1CGa7hfTTVClb2f+DiA8ZRQWBh+3lgkq/0IeL262B4F14X8QRv5Bsv128qw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@snazzah/davey-linux-x64-musl@0.1.9':
resolution: {integrity: sha512-fFQ/n3aWt1lXhxSdy+Ge3gi5bR3VETMVsWhH0gwBALUKrbo3ZzgSktm4lNrXE9i0ncMz/CDpZ5i0wt/N3XphEQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@snazzah/davey-wasm32-wasi@0.1.9':
resolution: {integrity: sha512-xWvzej8YCVlUvzlpmqJMIf0XmLlHqulKZ2e7WNe2TxQmsK+o0zTZqiQYs2MwaEbrNXBhYlHDkdpuwoXkJdscNQ==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@snazzah/davey-win32-arm64-msvc@0.1.9':
resolution: {integrity: sha512-sTqry/DfltX2OdW1CTLKa3dFYN5FloAEb2yhGsY1i5+Bms6OhwByXfALvyMHYVo61Th2+sD+9BJpQffHFKDA3w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@snazzah/davey-win32-ia32-msvc@0.1.9':
resolution: {integrity: sha512-twD3LwlkGnSwphsCtpGb5ztpBIWEvGdc0iujoVkdzZ6nJiq5p8iaLjJMO4hBm9h3s28fc+1Qd7AMVnagiOasnA==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@snazzah/davey-win32-x64-msvc@0.1.9':
resolution: {integrity: sha512-eMnXbv4GoTngWYY538i/qHz2BS+RgSXFsvKltPzKqnqzPzhQZIY7TemEJn3D5yWGfW4qHve9u23rz93FQqnQMA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@snazzah/davey@0.1.9':
resolution: {integrity: sha512-vNZk5y+IsxjwzTAXikvzz5pqMLb35YytC64nVF2MAFVhjpXu9ITOKUriZ0JG/llwzCAi56jb5x0cXDRIyE2A2A==}
engines: {node: '>= 10'}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -3670,9 +3518,9 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
acpx@0.1.15:
resolution: {integrity: sha512-1r+tmPT9Oe2Ulv5b4r7O2hCCq5CHVru/H2tcPeTpZek9jR1zBQoBfZ/RcK+9sC9/mnDvWYO5R7Iae64v2LMO+A==}
engines: {node: '>=18'}
acpx@0.1.16:
resolution: {integrity: sha512-CxHkUIP9dPSjh+RyoZkQg0AXjSiSus/dF4xKEeG9c+7JboZp5bZuWie/n4V7sBeKTMheMoEYGrMUslrdUadrqg==}
engines: {node: '>=22.12.0'}
hasBin: true
agent-base@6.0.2:
@@ -4059,10 +3907,6 @@ packages:
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
engines: {node: '>=14'}
commander@13.1.0:
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
engines: {node: '>=18'}
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
@@ -4210,9 +4054,6 @@ packages:
discord-api-types@0.38.37:
resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==}
discord-api-types@0.38.40:
resolution: {integrity: sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==}
discord-api-types@0.38.41:
resolution: {integrity: sha512-yMECyR8j9c2fVTvCQ+Qc24pweYFIZk/XoxDOmt1UvPeSw5tK6gXBd/2hhP+FEAe9Y6ny8pRMaf618XDK4U53OQ==}
@@ -4614,10 +4455,6 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
grammy@1.41.0:
resolution: {integrity: sha512-CAAu74SLT+/QCg40FBhUuYJalVsxxCN3D0c31TzhFBsWWTdXrMXYjGsKngBdfvN6hQ/VzHczluj/ugZVetFNCQ==}
engines: {node: ^12.20.0 || >=14.13.1}
grammy@1.41.1:
resolution: {integrity: sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==}
engines: {node: ^12.20.0 || >=14.13.1}
@@ -5466,18 +5303,6 @@ packages:
oniguruma-to-es@4.3.4:
resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==}
openai@6.10.0:
resolution: {integrity: sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.25 || ^4.0
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
openai@6.26.0:
resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==}
hasBin: true
@@ -5502,8 +5327,8 @@ packages:
zod:
optional: true
openclaw@2026.3.2:
resolution: {integrity: sha512-Gkqx24m7PF1DUXPI968DuC9n52lTZ5hI3X5PIi0HosC7J7d6RLkgVppj1mxvgiQAWMp41E41elvoi/h4KBjFcQ==}
openclaw@2026.3.8:
resolution: {integrity: sha512-e5Rk2Aj55sD/5LyX94mdYCQj7zpHXo0xIZsl+k140+nRopePfPAxC7nsu0V/NyypPRtaotP1riFfzK7IhaYkuQ==}
engines: {node: '>=22.12.0'}
hasBin: true
peerDependencies:
@@ -6746,9 +6571,6 @@ packages:
zod@3.25.75:
resolution: {integrity: sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg==}
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
@@ -6757,10 +6579,6 @@ packages:
snapshots:
'@agentclientprotocol/sdk@0.14.1(zod@4.3.6)':
dependencies:
zod: 4.3.6
'@agentclientprotocol/sdk@0.15.0(zod@4.3.6)':
dependencies:
zod: 4.3.6
@@ -6818,58 +6636,6 @@ snapshots:
'@smithy/util-utf8': 2.3.0
tslib: 2.8.1
'@aws-sdk/client-bedrock-runtime@3.1000.0':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
'@aws-crypto/sha256-js': 5.2.0
'@aws-sdk/core': 3.973.15
'@aws-sdk/credential-provider-node': 3.972.14
'@aws-sdk/eventstream-handler-node': 3.972.9
'@aws-sdk/middleware-eventstream': 3.972.6
'@aws-sdk/middleware-host-header': 3.972.6
'@aws-sdk/middleware-logger': 3.972.6
'@aws-sdk/middleware-recursion-detection': 3.972.6
'@aws-sdk/middleware-user-agent': 3.972.15
'@aws-sdk/middleware-websocket': 3.972.10
'@aws-sdk/region-config-resolver': 3.972.6
'@aws-sdk/token-providers': 3.1000.0
'@aws-sdk/types': 3.973.4
'@aws-sdk/util-endpoints': 3.996.3
'@aws-sdk/util-user-agent-browser': 3.972.6
'@aws-sdk/util-user-agent-node': 3.973.0
'@smithy/config-resolver': 4.4.9
'@smithy/core': 3.23.6
'@smithy/eventstream-serde-browser': 4.2.10
'@smithy/eventstream-serde-config-resolver': 4.3.10
'@smithy/eventstream-serde-node': 4.2.10
'@smithy/fetch-http-handler': 5.3.11
'@smithy/hash-node': 4.2.10
'@smithy/invalid-dependency': 4.2.10
'@smithy/middleware-content-length': 4.2.10
'@smithy/middleware-endpoint': 4.4.20
'@smithy/middleware-retry': 4.4.37
'@smithy/middleware-serde': 4.2.11
'@smithy/middleware-stack': 4.2.10
'@smithy/node-config-provider': 4.3.10
'@smithy/node-http-handler': 4.4.12
'@smithy/protocol-http': 5.3.10
'@smithy/smithy-client': 4.12.0
'@smithy/types': 4.13.0
'@smithy/url-parser': 4.2.10
'@smithy/util-base64': 4.3.1
'@smithy/util-body-length-browser': 4.2.1
'@smithy/util-body-length-node': 4.2.2
'@smithy/util-defaults-mode-browser': 4.3.36
'@smithy/util-defaults-mode-node': 4.2.39
'@smithy/util-endpoints': 3.3.1
'@smithy/util-middleware': 4.2.10
'@smithy/util-retry': 4.2.10
'@smithy/util-stream': 4.5.15
'@smithy/util-utf8': 4.2.1
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/client-bedrock-runtime@3.1004.0':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
@@ -6922,51 +6688,6 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/client-bedrock@3.1000.0':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
'@aws-crypto/sha256-js': 5.2.0
'@aws-sdk/core': 3.973.15
'@aws-sdk/credential-provider-node': 3.972.14
'@aws-sdk/middleware-host-header': 3.972.6
'@aws-sdk/middleware-logger': 3.972.6
'@aws-sdk/middleware-recursion-detection': 3.972.6
'@aws-sdk/middleware-user-agent': 3.972.15
'@aws-sdk/region-config-resolver': 3.972.6
'@aws-sdk/token-providers': 3.1000.0
'@aws-sdk/types': 3.973.4
'@aws-sdk/util-endpoints': 3.996.3
'@aws-sdk/util-user-agent-browser': 3.972.6
'@aws-sdk/util-user-agent-node': 3.973.0
'@smithy/config-resolver': 4.4.9
'@smithy/core': 3.23.6
'@smithy/fetch-http-handler': 5.3.11
'@smithy/hash-node': 4.2.10
'@smithy/invalid-dependency': 4.2.10
'@smithy/middleware-content-length': 4.2.10
'@smithy/middleware-endpoint': 4.4.20
'@smithy/middleware-retry': 4.4.37
'@smithy/middleware-serde': 4.2.11
'@smithy/middleware-stack': 4.2.10
'@smithy/node-config-provider': 4.3.10
'@smithy/node-http-handler': 4.4.12
'@smithy/protocol-http': 5.3.10
'@smithy/smithy-client': 4.12.0
'@smithy/types': 4.13.0
'@smithy/url-parser': 4.2.10
'@smithy/util-base64': 4.3.1
'@smithy/util-body-length-browser': 4.2.1
'@smithy/util-body-length-node': 4.2.2
'@smithy/util-defaults-mode-browser': 4.3.36
'@smithy/util-defaults-mode-node': 4.2.39
'@smithy/util-endpoints': 3.3.1
'@smithy/util-middleware': 4.2.10
'@smithy/util-retry': 4.2.10
'@smithy/util-utf8': 4.2.1
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/client-bedrock@3.1004.0':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
@@ -7324,13 +7045,6 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
'@aws-sdk/eventstream-handler-node@3.972.9':
dependencies:
'@aws-sdk/types': 3.973.4
'@smithy/eventstream-codec': 4.2.10
'@smithy/types': 4.13.0
tslib: 2.8.1
'@aws-sdk/middleware-bucket-endpoint@3.972.6':
dependencies:
'@aws-sdk/types': 3.973.4
@@ -7341,13 +7055,6 @@ snapshots:
'@smithy/util-config-provider': 4.2.1
tslib: 2.8.1
'@aws-sdk/middleware-eventstream@3.972.6':
dependencies:
'@aws-sdk/types': 3.973.4
'@smithy/protocol-http': 5.3.10
'@smithy/types': 4.13.0
tslib: 2.8.1
'@aws-sdk/middleware-eventstream@3.972.7':
dependencies:
'@aws-sdk/types': 3.973.5
@@ -7471,21 +7178,6 @@ snapshots:
'@smithy/util-retry': 4.2.11
tslib: 2.8.1
'@aws-sdk/middleware-websocket@3.972.10':
dependencies:
'@aws-sdk/types': 3.973.4
'@aws-sdk/util-format-url': 3.972.6
'@smithy/eventstream-codec': 4.2.10
'@smithy/eventstream-serde-browser': 4.2.10
'@smithy/fetch-http-handler': 5.3.11
'@smithy/protocol-http': 5.3.10
'@smithy/signature-v4': 5.3.10
'@smithy/types': 4.13.0
'@smithy/util-base64': 4.3.1
'@smithy/util-hex-encoding': 4.2.1
'@smithy/util-utf8': 4.2.1
tslib: 2.8.1
'@aws-sdk/middleware-websocket@3.972.12':
dependencies:
'@aws-sdk/types': 3.973.5
@@ -7623,18 +7315,6 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
'@aws-sdk/token-providers@3.1000.0':
dependencies:
'@aws-sdk/core': 3.973.15
'@aws-sdk/nested-clients': 3.996.3
'@aws-sdk/types': 3.973.4
'@smithy/property-provider': 4.2.10
'@smithy/shared-ini-file-loader': 4.4.5
'@smithy/types': 4.13.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/token-providers@3.1004.0':
dependencies:
'@aws-sdk/core': 3.973.18
@@ -7858,21 +7538,10 @@ snapshots:
hashery: 1.5.0
keyv: 5.6.0
'@clack/core@1.0.1':
dependencies:
picocolors: 1.1.1
sisteransi: 1.0.5
'@clack/core@1.1.0':
dependencies:
sisteransi: 1.0.5
'@clack/prompts@1.0.1':
dependencies:
'@clack/core': 1.0.1
picocolors: 1.1.1
sisteransi: 1.0.5
'@clack/prompts@1.1.0':
dependencies:
'@clack/core': 1.1.0
@@ -8100,17 +7769,6 @@ snapshots:
'@eshaz/web-worker@1.2.2':
optional: true
'@google/genai@1.43.0':
dependencies:
google-auth-library: 10.6.1
p-retry: 4.6.2
protobufjs: 7.5.4
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
'@google/genai@1.44.0':
dependencies:
google-auth-library: 10.6.1
@@ -8122,21 +7780,11 @@ snapshots:
- supports-color
- utf-8-validate
'@grammyjs/runner@2.0.3(grammy@1.41.0)':
dependencies:
abort-controller: 3.0.0
grammy: 1.41.0
'@grammyjs/runner@2.0.3(grammy@1.41.1)':
dependencies:
abort-controller: 3.0.0
grammy: 1.41.1
'@grammyjs/transformer-throttler@1.2.1(grammy@1.41.0)':
dependencies:
bottleneck: 2.19.5
grammy: 1.41.0
'@grammyjs/transformer-throttler@1.2.1(grammy@1.41.1)':
dependencies:
bottleneck: 2.19.5
@@ -8501,18 +8149,6 @@ snapshots:
std-env: 3.10.0
yoctocolors: 2.1.2
'@mariozechner/pi-agent-core@0.55.3(ws@8.19.0)(zod@4.3.6)':
dependencies:
'@mariozechner/pi-ai': 0.55.3(ws@8.19.0)(zod@4.3.6)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- aws-crt
- bufferutil
- supports-color
- utf-8-validate
- ws
- zod
'@mariozechner/pi-agent-core@0.57.1(ws@8.19.0)(zod@4.3.6)':
dependencies:
'@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6)
@@ -8525,30 +8161,6 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.55.3(ws@8.19.0)(zod@4.3.6)':
dependencies:
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
'@aws-sdk/client-bedrock-runtime': 3.1000.0
'@google/genai': 1.43.0
'@mistralai/mistralai': 1.10.0
'@sinclair/typebox': 0.34.48
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
chalk: 5.6.2
openai: 6.10.0(ws@8.19.0)(zod@4.3.6)
partial-json: 0.1.7
proxy-agent: 6.5.0
undici: 7.22.0
zod-to-json-schema: 3.25.1(zod@4.3.6)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- aws-crt
- bufferutil
- supports-color
- utf-8-validate
- ws
- zod
'@mariozechner/pi-ai@0.57.1(ws@8.19.0)(zod@4.3.6)':
dependencies:
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
@@ -8573,37 +8185,6 @@ snapshots:
- ws
- zod
'@mariozechner/pi-coding-agent@0.55.3(ws@8.19.0)(zod@4.3.6)':
dependencies:
'@mariozechner/jiti': 2.6.5
'@mariozechner/pi-agent-core': 0.55.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai': 0.55.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': 0.55.3
'@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2
cli-highlight: 2.1.11
diff: 8.0.3
extract-zip: 2.0.1
file-type: 21.3.0
glob: 13.0.6
hosted-git-info: 9.0.2
ignore: 7.0.5
marked: 15.0.12
minimatch: 10.2.4
proper-lockfile: 4.1.2
strip-ansi: 7.2.0
yaml: 2.8.2
optionalDependencies:
'@mariozechner/clipboard': 0.3.2
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- aws-crt
- bufferutil
- supports-color
- utf-8-validate
- ws
- zod
'@mariozechner/pi-coding-agent@0.57.1(ws@8.19.0)(zod@4.3.6)':
dependencies:
'@mariozechner/jiti': 2.6.5
@@ -8636,15 +8217,6 @@ snapshots:
- ws
- zod
'@mariozechner/pi-tui@0.55.3':
dependencies:
'@types/mime-types': 2.1.4
chalk: 5.6.2
get-east-asian-width: 1.5.0
koffi: 2.15.1
marked: 15.0.12
mime-types: 3.0.2
'@mariozechner/pi-tui@0.57.1':
dependencies:
'@types/mime-types': 2.1.4
@@ -8684,11 +8256,6 @@ snapshots:
- debug
- supports-color
'@mistralai/mistralai@1.10.0':
dependencies:
zod: 3.25.76
zod-to-json-schema: 3.25.1(zod@3.25.76)
'@mistralai/mistralai@1.14.1':
dependencies:
ws: 8.19.0
@@ -10291,67 +9858,6 @@ snapshots:
dependencies:
tslib: 2.8.1
'@snazzah/davey-android-arm-eabi@0.1.9':
optional: true
'@snazzah/davey-android-arm64@0.1.9':
optional: true
'@snazzah/davey-darwin-arm64@0.1.9':
optional: true
'@snazzah/davey-darwin-x64@0.1.9':
optional: true
'@snazzah/davey-freebsd-x64@0.1.9':
optional: true
'@snazzah/davey-linux-arm-gnueabihf@0.1.9':
optional: true
'@snazzah/davey-linux-arm64-gnu@0.1.9':
optional: true
'@snazzah/davey-linux-arm64-musl@0.1.9':
optional: true
'@snazzah/davey-linux-x64-gnu@0.1.9':
optional: true
'@snazzah/davey-linux-x64-musl@0.1.9':
optional: true
'@snazzah/davey-wasm32-wasi@0.1.9':
dependencies:
'@napi-rs/wasm-runtime': 1.1.1
optional: true
'@snazzah/davey-win32-arm64-msvc@0.1.9':
optional: true
'@snazzah/davey-win32-ia32-msvc@0.1.9':
optional: true
'@snazzah/davey-win32-x64-msvc@0.1.9':
optional: true
'@snazzah/davey@0.1.9':
optionalDependencies:
'@snazzah/davey-android-arm-eabi': 0.1.9
'@snazzah/davey-android-arm64': 0.1.9
'@snazzah/davey-darwin-arm64': 0.1.9
'@snazzah/davey-darwin-x64': 0.1.9
'@snazzah/davey-freebsd-x64': 0.1.9
'@snazzah/davey-linux-arm-gnueabihf': 0.1.9
'@snazzah/davey-linux-arm64-gnu': 0.1.9
'@snazzah/davey-linux-arm64-musl': 0.1.9
'@snazzah/davey-linux-x64-gnu': 0.1.9
'@snazzah/davey-linux-x64-musl': 0.1.9
'@snazzah/davey-wasm32-wasi': 0.1.9
'@snazzah/davey-win32-arm64-msvc': 0.1.9
'@snazzah/davey-win32-ia32-msvc': 0.1.9
'@snazzah/davey-win32-x64-msvc': 0.1.9
'@standard-schema/spec@1.1.0': {}
'@swc/helpers@0.5.19':
@@ -10860,10 +10366,10 @@ snapshots:
acorn@8.16.0: {}
acpx@0.1.15(zod@4.3.6):
acpx@0.1.16(zod@4.3.6):
dependencies:
'@agentclientprotocol/sdk': 0.14.1(zod@4.3.6)
commander: 13.1.0
'@agentclientprotocol/sdk': 0.15.0(zod@4.3.6)
commander: 14.0.3
skillflag: 0.1.4
transitivePeerDependencies:
- bare-abort-controller
@@ -11257,8 +10763,6 @@ snapshots:
commander@10.0.1: {}
commander@13.1.0: {}
commander@14.0.3: {}
commander@5.1.0: {}
@@ -11364,8 +10868,6 @@ snapshots:
discord-api-types@0.38.37: {}
discord-api-types@0.38.40: {}
discord-api-types@0.38.41: {}
doctypes@1.1.0: {}
@@ -11876,16 +11378,6 @@ snapshots:
graceful-fs@4.2.11: {}
grammy@1.41.0:
dependencies:
'@grammyjs/types': 3.25.0
abort-controller: 3.0.0
debug: 4.4.3
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
- supports-color
grammy@1.41.1:
dependencies:
'@grammyjs/types': 3.25.0
@@ -12287,7 +11779,8 @@ snapshots:
klona@2.0.6: {}
koffi@2.15.1: {}
koffi@2.15.1:
optional: true
leac@0.6.0: {}
@@ -12806,11 +12299,6 @@ snapshots:
regex: 6.1.0
regex-recursion: 6.0.2
openai@6.10.0(ws@8.19.0)(zod@4.3.6):
optionalDependencies:
ws: 8.19.0
zod: 4.3.6
openai@6.26.0(ws@8.19.0)(zod@4.3.6):
optionalDependencies:
ws: 8.19.0
@@ -12821,29 +12309,28 @@ snapshots:
ws: 8.19.0
zod: 4.3.6
openclaw@2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)):
openclaw@2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)):
dependencies:
'@agentclientprotocol/sdk': 0.14.1(zod@4.3.6)
'@aws-sdk/client-bedrock': 3.1000.0
'@agentclientprotocol/sdk': 0.15.0(zod@4.3.6)
'@aws-sdk/client-bedrock': 3.1004.0
'@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1)
'@clack/prompts': 1.0.1
'@clack/prompts': 1.1.0
'@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1)
'@grammyjs/runner': 2.0.3(grammy@1.41.0)
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.41.0)
'@grammyjs/runner': 2.0.3(grammy@1.41.1)
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.41.1)
'@homebridge/ciao': 1.3.5
'@larksuiteoapi/node-sdk': 1.59.0
'@line/bot-sdk': 10.6.0
'@lydell/node-pty': 1.2.0-beta.3
'@mariozechner/pi-agent-core': 0.55.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai': 0.55.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-coding-agent': 0.55.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': 0.55.3
'@mariozechner/pi-agent-core': 0.57.1(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-coding-agent': 0.57.1(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': 0.57.1
'@mozilla/readability': 0.6.0
'@napi-rs/canvas': 0.1.95
'@sinclair/typebox': 0.34.48
'@slack/bolt': 4.6.0(@types/express@5.0.6)
'@slack/web-api': 7.14.1
'@snazzah/davey': 0.1.9
'@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
ajv: 8.18.0
chalk: 5.6.2
@@ -12851,13 +12338,11 @@ snapshots:
cli-highlight: 2.1.11
commander: 14.0.3
croner: 10.0.1
discord-api-types: 0.38.40
discord-api-types: 0.38.41
dotenv: 17.3.1
express: 5.2.1
file-type: 21.3.0
gaxios: 7.1.3
google-auth-library: 10.6.1
grammy: 1.41.0
grammy: 1.41.1
https-proxy-agent: 7.0.6
ipaddr.js: 2.3.0
jiti: 2.6.1
@@ -12866,7 +12351,6 @@ snapshots:
linkedom: 0.18.12
long: 5.3.2
markdown-it: 14.1.1
node-domexception: '@nolyfill/domexception@1.0.28'
node-edge-tts: 1.2.10
node-llama-cpp: 3.16.2(typescript@5.9.3)
opusscript: 0.1.1
@@ -12876,16 +12360,14 @@ snapshots:
qrcode-terminal: 0.12.0
sharp: 0.34.5
sqlite-vec: 0.1.7-alpha.2
strip-ansi: 7.2.0
tar: 7.5.10
tslog: 4.10.2
undici: 7.22.0
ws: 8.19.0
yaml: 2.8.2
zod: 4.3.6
optionalDependencies:
'@discordjs/opus': 0.10.0
transitivePeerDependencies:
- '@discordjs/opus'
- '@modelcontextprotocol/sdk'
- '@types/express'
- audio-decode
@@ -14298,18 +13780,12 @@ snapshots:
- bufferutil
- utf-8-validate
zod-to-json-schema@3.25.1(zod@3.25.76):
dependencies:
zod: 3.25.76
zod-to-json-schema@3.25.1(zod@4.3.6):
dependencies:
zod: 4.3.6
zod@3.25.75: {}
zod@3.25.76: {}
zod@4.3.6: {}
zwitch@2.0.4: {}

View File

@@ -234,6 +234,7 @@ export class AcpSessionManager {
sessionKey,
agent,
mode: input.mode,
resumeSessionId: input.resumeSessionId,
cwd: requestedCwd,
}),
fallbackCode: "ACP_SESSION_INIT_FAILED",
@@ -655,6 +656,7 @@ export class AcpSessionManager {
for await (const event of runtime.runTurn({
handle,
text: input.text,
attachments: input.attachments,
mode: input.mode,
requestId: input.requestId,
signal: combinedSignal,

View File

@@ -43,14 +43,21 @@ export type AcpInitializeSessionInput = {
sessionKey: string;
agent: string;
mode: AcpRuntimeSessionMode;
resumeSessionId?: string;
cwd?: string;
backendId?: string;
};
export type AcpTurnAttachment = {
mediaType: string;
data: string;
};
export type AcpRunTurnInput = {
cfg: OpenClawConfig;
sessionKey: string;
text: string;
attachments?: AcpTurnAttachment[];
mode: AcpRuntimePromptMode;
requestId: string;
signal?: AbortSignal;

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { extractToolCallLocations } from "./event-mapper.js";
describe("extractToolCallLocations", () => {
it("enforces the global node visit cap across nested structures", () => {
const nested = Array.from({ length: 20 }, (_, outer) =>
Array.from({ length: 20 }, (_, inner) =>
inner === 19 ? { path: `/tmp/file-${outer}.txt` } : { note: `${outer}-${inner}` },
),
);
const locations = extractToolCallLocations(nested);
expect(locations).toBeDefined();
expect(locations?.length).toBeLessThan(20);
expect(locations).not.toContainEqual({ path: "/tmp/file-19.txt" });
});
});

View File

@@ -1,4 +1,10 @@
import type { ContentBlock, ImageContent, ToolKind } from "@agentclientprotocol/sdk";
import type {
ContentBlock,
ImageContent,
ToolCallContent,
ToolCallLocation,
ToolKind,
} from "@agentclientprotocol/sdk";
export type GatewayAttachment = {
type: string;
@@ -6,6 +12,39 @@ export type GatewayAttachment = {
content: string;
};
const TOOL_LOCATION_PATH_KEYS = [
"path",
"filePath",
"file_path",
"targetPath",
"target_path",
"targetFile",
"target_file",
"sourcePath",
"source_path",
"destinationPath",
"destination_path",
"oldPath",
"old_path",
"newPath",
"new_path",
"outputPath",
"output_path",
"inputPath",
"input_path",
] as const;
const TOOL_LOCATION_LINE_KEYS = [
"line",
"lineNumber",
"line_number",
"startLine",
"start_line",
] as const;
const TOOL_RESULT_PATH_MARKER_RE = /^(?:FILE|MEDIA):(.+)$/gm;
const TOOL_LOCATION_MAX_DEPTH = 4;
const TOOL_LOCATION_MAX_NODES = 100;
const INLINE_CONTROL_ESCAPE_MAP: Readonly<Record<string, string>> = {
"\0": "\\0",
"\r": "\\r",
@@ -56,6 +95,152 @@ function escapeResourceTitle(value: string): string {
return escapeInlineControlChars(value).replace(/[()[\]]/g, (char) => `\\${char}`);
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function normalizeToolLocationPath(value: string): string | undefined {
const trimmed = value.trim();
if (
!trimmed ||
trimmed.length > 4096 ||
trimmed.includes("\u0000") ||
trimmed.includes("\r") ||
trimmed.includes("\n")
) {
return undefined;
}
if (/^https?:\/\//i.test(trimmed)) {
return undefined;
}
if (/^file:\/\//i.test(trimmed)) {
try {
const parsed = new URL(trimmed);
return decodeURIComponent(parsed.pathname || "") || undefined;
} catch {
return undefined;
}
}
return trimmed;
}
function normalizeToolLocationLine(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) {
return undefined;
}
const line = Math.floor(value);
return line > 0 ? line : undefined;
}
function extractToolLocationLine(record: Record<string, unknown>): number | undefined {
for (const key of TOOL_LOCATION_LINE_KEYS) {
const line = normalizeToolLocationLine(record[key]);
if (line !== undefined) {
return line;
}
}
return undefined;
}
function addToolLocation(
locations: Map<string, ToolCallLocation>,
rawPath: string,
line?: number,
): void {
const path = normalizeToolLocationPath(rawPath);
if (!path) {
return;
}
for (const [existingKey, existing] of locations.entries()) {
if (existing.path !== path) {
continue;
}
if (line === undefined || existing.line === line) {
return;
}
if (existing.line === undefined) {
locations.delete(existingKey);
}
}
const locationKey = `${path}:${line ?? ""}`;
if (locations.has(locationKey)) {
return;
}
locations.set(locationKey, line ? { path, line } : { path });
}
function collectLocationsFromTextMarkers(
text: string,
locations: Map<string, ToolCallLocation>,
): void {
for (const match of text.matchAll(TOOL_RESULT_PATH_MARKER_RE)) {
const candidate = match[1]?.trim();
if (candidate) {
addToolLocation(locations, candidate);
}
}
}
function collectToolLocations(
value: unknown,
locations: Map<string, ToolCallLocation>,
state: { visited: number },
depth: number,
): void {
if (state.visited >= TOOL_LOCATION_MAX_NODES || depth > TOOL_LOCATION_MAX_DEPTH) {
return;
}
state.visited += 1;
if (typeof value === "string") {
collectLocationsFromTextMarkers(value, locations);
return;
}
if (!value || typeof value !== "object") {
return;
}
if (Array.isArray(value)) {
for (const item of value) {
collectToolLocations(item, locations, state, depth + 1);
if (state.visited >= TOOL_LOCATION_MAX_NODES) {
return;
}
}
return;
}
const record = value as Record<string, unknown>;
const line = extractToolLocationLine(record);
for (const key of TOOL_LOCATION_PATH_KEYS) {
const rawPath = record[key];
if (typeof rawPath === "string") {
addToolLocation(locations, rawPath, line);
}
}
const content = Array.isArray(record.content) ? record.content : undefined;
if (content) {
for (const block of content) {
const entry = asRecord(block);
if (entry?.type === "text" && typeof entry.text === "string") {
collectLocationsFromTextMarkers(entry.text, locations);
}
}
}
for (const [key, nested] of Object.entries(record)) {
if (key === "content") {
continue;
}
collectToolLocations(nested, locations, state, depth + 1);
if (state.visited >= TOOL_LOCATION_MAX_NODES) {
return;
}
}
}
export function extractTextFromPrompt(prompt: ContentBlock[], maxBytes?: number): string {
const parts: string[] = [];
// Track accumulated byte count per block to catch oversized prompts before full concatenation
@@ -152,3 +337,74 @@ export function inferToolKind(name?: string): ToolKind {
}
return "other";
}
export function extractToolCallContent(value: unknown): ToolCallContent[] | undefined {
if (typeof value === "string") {
return value.trim()
? [
{
type: "content",
content: {
type: "text",
text: value,
},
},
]
: undefined;
}
const record = asRecord(value);
if (!record) {
return undefined;
}
const contents: ToolCallContent[] = [];
const blocks = Array.isArray(record.content) ? record.content : [];
for (const block of blocks) {
const entry = asRecord(block);
if (entry?.type === "text" && typeof entry.text === "string" && entry.text.trim()) {
contents.push({
type: "content",
content: {
type: "text",
text: entry.text,
},
});
}
}
if (contents.length > 0) {
return contents;
}
const fallbackText =
typeof record.text === "string"
? record.text
: typeof record.message === "string"
? record.message
: typeof record.error === "string"
? record.error
: undefined;
if (!fallbackText?.trim()) {
return undefined;
}
return [
{
type: "content",
content: {
type: "text",
text: fallbackText,
},
},
];
}
export function extractToolCallLocations(...values: unknown[]): ToolCallLocation[] | undefined {
const locations = new Map<string, ToolCallLocation>();
for (const value of values) {
collectToolLocations(value, locations, { visited: 0 }, 0);
}
return locations.size > 0 ? [...locations.values()] : undefined;
}

View File

@@ -35,13 +35,20 @@ export type AcpRuntimeEnsureInput = {
sessionKey: string;
agent: string;
mode: AcpRuntimeSessionMode;
resumeSessionId?: string;
cwd?: string;
env?: Record<string, string>;
};
export type AcpRuntimeTurnAttachment = {
mediaType: string;
data: string;
};
export type AcpRuntimeTurnInput = {
handle: AcpRuntimeHandle;
text: string;
attachments?: AcpRuntimeTurnAttachment[];
mode: AcpRuntimePromptMode;
requestId: string;
signal?: AbortSignal;

View File

@@ -2,9 +2,12 @@ import type {
LoadSessionRequest,
NewSessionRequest,
PromptRequest,
SetSessionConfigOptionRequest,
SetSessionModeRequest,
} from "@agentclientprotocol/sdk";
import { describe, expect, it, vi } from "vitest";
import type { GatewayClient } from "../gateway/client.js";
import type { EventFrame } from "../gateway/protocol/index.js";
import { createInMemorySessionStore } from "./session.js";
import { AcpGatewayAgent } from "./translator.js";
import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js";
@@ -38,6 +41,65 @@ function createPromptRequest(
} as unknown as PromptRequest;
}
function createSetSessionModeRequest(sessionId: string, modeId: string): SetSessionModeRequest {
return {
sessionId,
modeId,
_meta: {},
} as unknown as SetSessionModeRequest;
}
function createSetSessionConfigOptionRequest(
sessionId: string,
configId: string,
value: string,
): SetSessionConfigOptionRequest {
return {
sessionId,
configId,
value,
_meta: {},
} as unknown as SetSessionConfigOptionRequest;
}
function createToolEvent(params: {
sessionKey: string;
phase: "start" | "update" | "result";
toolCallId: string;
name: string;
args?: Record<string, unknown>;
partialResult?: unknown;
result?: unknown;
isError?: boolean;
}): EventFrame {
return {
event: "agent",
payload: {
sessionKey: params.sessionKey,
stream: "tool",
data: {
phase: params.phase,
toolCallId: params.toolCallId,
name: params.name,
args: params.args,
partialResult: params.partialResult,
result: params.result,
isError: params.isError,
},
},
} as unknown as EventFrame;
}
function createChatFinalEvent(sessionKey: string): EventFrame {
return {
event: "chat",
payload: {
sessionKey,
state: "final",
},
} as unknown as EventFrame;
}
async function expectOversizedPromptRejected(params: { sessionId: string; text: string }) {
const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"];
const sessionStore = createInMemorySessionStore();
@@ -97,6 +159,732 @@ describe("acp session creation rate limit", () => {
});
});
describe("acp unsupported bridge session setup", () => {
it("rejects per-session MCP servers on newSession", async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
const sessionUpdate = connection.__sessionUpdateMock;
const agent = new AcpGatewayAgent(connection, createAcpGateway(), {
sessionStore,
});
await expect(
agent.newSession({
...createNewSessionRequest(),
mcpServers: [{ name: "docs", command: "mcp-docs" }] as never[],
}),
).rejects.toThrow(/does not support per-session MCP servers/i);
expect(sessionStore.hasSession("docs-session")).toBe(false);
expect(sessionUpdate).not.toHaveBeenCalled();
sessionStore.clearAllSessionsForTest();
});
it("rejects per-session MCP servers on loadSession", async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
const sessionUpdate = connection.__sessionUpdateMock;
const agent = new AcpGatewayAgent(connection, createAcpGateway(), {
sessionStore,
});
await expect(
agent.loadSession({
...createLoadSessionRequest("docs-session"),
mcpServers: [{ name: "docs", command: "mcp-docs" }] as never[],
}),
).rejects.toThrow(/does not support per-session MCP servers/i);
expect(sessionStore.hasSession("docs-session")).toBe(false);
expect(sessionUpdate).not.toHaveBeenCalled();
sessionStore.clearAllSessionsForTest();
});
});
describe("acp session UX bridge behavior", () => {
it("returns initial modes and thought-level config options for new sessions", async () => {
const sessionStore = createInMemorySessionStore();
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(), {
sessionStore,
});
const result = await agent.newSession(createNewSessionRequest());
expect(result.modes?.currentModeId).toBe("adaptive");
expect(result.modes?.availableModes.map((mode) => mode.id)).toContain("adaptive");
expect(result.configOptions).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "thought_level",
currentValue: "adaptive",
category: "thought_level",
}),
expect.objectContaining({
id: "verbose_level",
currentValue: "off",
}),
expect.objectContaining({
id: "reasoning_level",
currentValue: "off",
}),
expect.objectContaining({
id: "response_usage",
currentValue: "off",
}),
expect.objectContaining({
id: "elevated_level",
currentValue: "off",
}),
]),
);
sessionStore.clearAllSessionsForTest();
});
it("replays user and assistant text history on loadSession and returns initial controls", async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
const sessionUpdate = connection.__sessionUpdateMock;
const request = vi.fn(async (method: string) => {
if (method === "sessions.list") {
return {
ts: Date.now(),
path: "/tmp/sessions.json",
count: 1,
defaults: {
modelProvider: null,
model: null,
contextTokens: null,
},
sessions: [
{
key: "agent:main:work",
label: "main-work",
displayName: "Main work",
derivedTitle: "Fix ACP bridge",
kind: "direct",
updatedAt: 1_710_000_000_000,
thinkingLevel: "high",
modelProvider: "openai",
model: "gpt-5.4",
verboseLevel: "full",
reasoningLevel: "stream",
responseUsage: "tokens",
elevatedLevel: "ask",
totalTokens: 4096,
totalTokensFresh: true,
contextTokens: 8192,
},
],
};
}
if (method === "sessions.get") {
return {
messages: [
{ role: "user", content: [{ type: "text", text: "Question" }] },
{ role: "assistant", content: [{ type: "text", text: "Answer" }] },
{ role: "system", content: [{ type: "text", text: "ignore me" }] },
{ role: "assistant", content: [{ type: "image", image: "skip" }] },
],
};
}
return { ok: true };
}) as GatewayClient["request"];
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
sessionStore,
});
const result = await agent.loadSession(createLoadSessionRequest("agent:main:work"));
expect(result.modes?.currentModeId).toBe("high");
expect(result.modes?.availableModes.map((mode) => mode.id)).toContain("xhigh");
expect(result.configOptions).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "thought_level",
currentValue: "high",
}),
expect.objectContaining({
id: "verbose_level",
currentValue: "full",
}),
expect.objectContaining({
id: "reasoning_level",
currentValue: "stream",
}),
expect.objectContaining({
id: "response_usage",
currentValue: "tokens",
}),
expect.objectContaining({
id: "elevated_level",
currentValue: "ask",
}),
]),
);
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "agent:main:work",
update: {
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "Question" },
},
});
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "agent:main:work",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Answer" },
},
});
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "agent:main:work",
update: expect.objectContaining({
sessionUpdate: "available_commands_update",
}),
});
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "agent:main:work",
update: {
sessionUpdate: "session_info_update",
title: "Fix ACP bridge",
updatedAt: "2024-03-09T16:00:00.000Z",
},
});
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "agent:main:work",
update: {
sessionUpdate: "usage_update",
used: 4096,
size: 8192,
_meta: {
source: "gateway-session-store",
approximate: true,
},
},
});
sessionStore.clearAllSessionsForTest();
});
it("falls back to an empty transcript when sessions.get fails during loadSession", async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
const sessionUpdate = connection.__sessionUpdateMock;
const request = vi.fn(async (method: string) => {
if (method === "sessions.list") {
return {
ts: Date.now(),
path: "/tmp/sessions.json",
count: 1,
defaults: {
modelProvider: null,
model: null,
contextTokens: null,
},
sessions: [
{
key: "agent:main:recover",
label: "recover",
displayName: "Recover session",
kind: "direct",
updatedAt: 1_710_000_000_000,
thinkingLevel: "adaptive",
modelProvider: "openai",
model: "gpt-5.4",
},
],
};
}
if (method === "sessions.get") {
throw new Error("sessions.get unavailable");
}
return { ok: true };
}) as GatewayClient["request"];
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
sessionStore,
});
const result = await agent.loadSession(createLoadSessionRequest("agent:main:recover"));
expect(result.modes?.currentModeId).toBe("adaptive");
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "agent:main:recover",
update: expect.objectContaining({
sessionUpdate: "available_commands_update",
}),
});
expect(sessionUpdate).not.toHaveBeenCalledWith({
sessionId: "agent:main:recover",
update: expect.objectContaining({
sessionUpdate: "user_message_chunk",
}),
});
sessionStore.clearAllSessionsForTest();
});
});
describe("acp setSessionMode bridge behavior", () => {
it("surfaces gateway mode patch failures instead of succeeding silently", async () => {
const sessionStore = createInMemorySessionStore();
const request = vi.fn(async (method: string) => {
if (method === "sessions.patch") {
throw new Error("gateway rejected mode");
}
return { ok: true };
}) as GatewayClient["request"];
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
sessionStore,
});
await agent.loadSession(createLoadSessionRequest("mode-session"));
await expect(
agent.setSessionMode(createSetSessionModeRequest("mode-session", "high")),
).rejects.toThrow(/gateway rejected mode/i);
sessionStore.clearAllSessionsForTest();
});
it("emits current mode and thought-level config updates after a successful mode change", async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
const sessionUpdate = connection.__sessionUpdateMock;
const request = vi.fn(async (method: string) => {
if (method === "sessions.list") {
return {
ts: Date.now(),
path: "/tmp/sessions.json",
count: 1,
defaults: {
modelProvider: null,
model: null,
contextTokens: null,
},
sessions: [
{
key: "mode-session",
kind: "direct",
updatedAt: Date.now(),
thinkingLevel: "high",
modelProvider: "openai",
model: "gpt-5.4",
},
],
};
}
return { ok: true };
}) as GatewayClient["request"];
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
sessionStore,
});
await agent.loadSession(createLoadSessionRequest("mode-session"));
sessionUpdate.mockClear();
await agent.setSessionMode(createSetSessionModeRequest("mode-session", "high"));
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "mode-session",
update: {
sessionUpdate: "current_mode_update",
currentModeId: "high",
},
});
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "mode-session",
update: {
sessionUpdate: "config_option_update",
configOptions: expect.arrayContaining([
expect.objectContaining({
id: "thought_level",
currentValue: "high",
}),
]),
},
});
sessionStore.clearAllSessionsForTest();
});
});
describe("acp setSessionConfigOption bridge behavior", () => {
it("updates the thought-level config option and returns refreshed options", async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
const sessionUpdate = connection.__sessionUpdateMock;
const request = vi.fn(async (method: string) => {
if (method === "sessions.list") {
return {
ts: Date.now(),
path: "/tmp/sessions.json",
count: 1,
defaults: {
modelProvider: null,
model: null,
contextTokens: null,
},
sessions: [
{
key: "config-session",
kind: "direct",
updatedAt: Date.now(),
thinkingLevel: "minimal",
modelProvider: "openai",
model: "gpt-5.4",
},
],
};
}
return { ok: true };
}) as GatewayClient["request"];
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
sessionStore,
});
await agent.loadSession(createLoadSessionRequest("config-session"));
sessionUpdate.mockClear();
const result = await agent.setSessionConfigOption(
createSetSessionConfigOptionRequest("config-session", "thought_level", "minimal"),
);
expect(result.configOptions).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "thought_level",
currentValue: "minimal",
}),
]),
);
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "config-session",
update: {
sessionUpdate: "current_mode_update",
currentModeId: "minimal",
},
});
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "config-session",
update: {
sessionUpdate: "config_option_update",
configOptions: expect.arrayContaining([
expect.objectContaining({
id: "thought_level",
currentValue: "minimal",
}),
]),
},
});
sessionStore.clearAllSessionsForTest();
});
it("updates non-mode ACP config options through gateway session patches", async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
const sessionUpdate = connection.__sessionUpdateMock;
const request = vi.fn(async (method: string) => {
if (method === "sessions.list") {
return {
ts: Date.now(),
path: "/tmp/sessions.json",
count: 1,
defaults: {
modelProvider: null,
model: null,
contextTokens: null,
},
sessions: [
{
key: "reasoning-session",
kind: "direct",
updatedAt: Date.now(),
thinkingLevel: "minimal",
modelProvider: "openai",
model: "gpt-5.4",
reasoningLevel: "stream",
},
],
};
}
return { ok: true };
}) as GatewayClient["request"];
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
sessionStore,
});
await agent.loadSession(createLoadSessionRequest("reasoning-session"));
sessionUpdate.mockClear();
const result = await agent.setSessionConfigOption(
createSetSessionConfigOptionRequest("reasoning-session", "reasoning_level", "stream"),
);
expect(result.configOptions).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "reasoning_level",
currentValue: "stream",
}),
]),
);
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "reasoning-session",
update: {
sessionUpdate: "config_option_update",
configOptions: expect.arrayContaining([
expect.objectContaining({
id: "reasoning_level",
currentValue: "stream",
}),
]),
},
});
sessionStore.clearAllSessionsForTest();
});
});
describe("acp tool streaming bridge behavior", () => {
it("maps Gateway tool partial output and file locations into ACP tool updates", async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
const sessionUpdate = connection.__sessionUpdateMock;
const request = vi.fn(async (method: string) => {
if (method === "chat.send") {
return new Promise(() => {});
}
return { ok: true };
}) as GatewayClient["request"];
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
sessionStore,
});
await agent.loadSession(createLoadSessionRequest("tool-session"));
sessionUpdate.mockClear();
const promptPromise = agent.prompt(createPromptRequest("tool-session", "Inspect app.ts"));
await agent.handleGatewayEvent(
createToolEvent({
sessionKey: "tool-session",
phase: "start",
toolCallId: "tool-1",
name: "read",
args: { path: "src/app.ts", line: 12 },
}),
);
await agent.handleGatewayEvent(
createToolEvent({
sessionKey: "tool-session",
phase: "update",
toolCallId: "tool-1",
name: "read",
partialResult: {
content: [{ type: "text", text: "partial output" }],
details: { path: "src/app.ts" },
},
}),
);
await agent.handleGatewayEvent(
createToolEvent({
sessionKey: "tool-session",
phase: "result",
toolCallId: "tool-1",
name: "read",
result: {
content: [{ type: "text", text: "FILE:src/app.ts" }],
details: { path: "src/app.ts" },
},
}),
);
await agent.handleGatewayEvent(createChatFinalEvent("tool-session"));
await promptPromise;
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "tool-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tool-1",
title: "read: path: src/app.ts, line: 12",
status: "in_progress",
rawInput: { path: "src/app.ts", line: 12 },
kind: "read",
locations: [{ path: "src/app.ts", line: 12 }],
},
});
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "tool-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
status: "in_progress",
rawOutput: {
content: [{ type: "text", text: "partial output" }],
details: { path: "src/app.ts" },
},
content: [
{
type: "content",
content: { type: "text", text: "partial output" },
},
],
locations: [{ path: "src/app.ts", line: 12 }],
},
});
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "tool-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
status: "completed",
rawOutput: {
content: [{ type: "text", text: "FILE:src/app.ts" }],
details: { path: "src/app.ts" },
},
content: [
{
type: "content",
content: { type: "text", text: "FILE:src/app.ts" },
},
],
locations: [{ path: "src/app.ts", line: 12 }],
},
});
sessionStore.clearAllSessionsForTest();
});
});
describe("acp session metadata and usage updates", () => {
it("emits a fresh usage snapshot after prompt completion when gateway totals are available", async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
const sessionUpdate = connection.__sessionUpdateMock;
const request = vi.fn(async (method: string) => {
if (method === "sessions.list") {
return {
ts: Date.now(),
path: "/tmp/sessions.json",
count: 1,
defaults: {
modelProvider: null,
model: null,
contextTokens: null,
},
sessions: [
{
key: "usage-session",
displayName: "Usage session",
kind: "direct",
updatedAt: 1_710_000_123_000,
thinkingLevel: "adaptive",
modelProvider: "openai",
model: "gpt-5.4",
totalTokens: 1200,
totalTokensFresh: true,
contextTokens: 4000,
},
],
};
}
if (method === "chat.send") {
return new Promise(() => {});
}
return { ok: true };
}) as GatewayClient["request"];
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
sessionStore,
});
await agent.loadSession(createLoadSessionRequest("usage-session"));
sessionUpdate.mockClear();
const promptPromise = agent.prompt(createPromptRequest("usage-session", "hello"));
await agent.handleGatewayEvent(createChatFinalEvent("usage-session"));
await promptPromise;
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "usage-session",
update: {
sessionUpdate: "session_info_update",
title: "Usage session",
updatedAt: "2024-03-09T16:02:03.000Z",
},
});
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "usage-session",
update: {
sessionUpdate: "usage_update",
used: 1200,
size: 4000,
_meta: {
source: "gateway-session-store",
approximate: true,
},
},
});
sessionStore.clearAllSessionsForTest();
});
it("still resolves prompts when snapshot updates fail after completion", async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
const sessionUpdate = connection.__sessionUpdateMock;
const request = vi.fn(async (method: string) => {
if (method === "sessions.list") {
return {
ts: Date.now(),
path: "/tmp/sessions.json",
count: 1,
defaults: {
modelProvider: null,
model: null,
contextTokens: null,
},
sessions: [
{
key: "usage-session",
displayName: "Usage session",
kind: "direct",
updatedAt: 1_710_000_123_000,
thinkingLevel: "adaptive",
modelProvider: "openai",
model: "gpt-5.4",
totalTokens: 1200,
totalTokensFresh: true,
contextTokens: 4000,
},
],
};
}
if (method === "chat.send") {
return new Promise(() => {});
}
return { ok: true };
}) as GatewayClient["request"];
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
sessionStore,
});
await agent.loadSession(createLoadSessionRequest("usage-session"));
sessionUpdate.mockClear();
sessionUpdate.mockRejectedValueOnce(new Error("session update transport failed"));
const promptPromise = agent.prompt(createPromptRequest("usage-session", "hello"));
await agent.handleGatewayEvent(createChatFinalEvent("usage-session"));
await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" });
const session = sessionStore.getSession("usage-session");
expect(session?.activeRunId).toBeNull();
expect(session?.abortController).toBeNull();
sessionStore.clearAllSessionsForTest();
});
});
describe("acp prompt size hardening", () => {
it("rejects oversized prompt blocks without leaking active runs", async () => {
await expectOversizedPromptRejected({

View File

@@ -2,10 +2,16 @@ import type { AgentSideConnection } from "@agentclientprotocol/sdk";
import { vi } from "vitest";
import type { GatewayClient } from "../gateway/client.js";
export function createAcpConnection(): AgentSideConnection {
export type TestAcpConnection = AgentSideConnection & {
__sessionUpdateMock: ReturnType<typeof vi.fn>;
};
export function createAcpConnection(): TestAcpConnection {
const sessionUpdate = vi.fn(async () => {});
return {
sessionUpdate: vi.fn(async () => {}),
} as unknown as AgentSideConnection;
sessionUpdate,
__sessionUpdateMock: sessionUpdate,
} as unknown as TestAcpConnection;
}
export function createAcpGateway(

View File

@@ -16,14 +16,21 @@ import type {
NewSessionResponse,
PromptRequest,
PromptResponse,
SessionConfigOption,
SessionModeState,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
SetSessionModeRequest,
SetSessionModeResponse,
StopReason,
ToolCallLocation,
ToolKind,
} from "@agentclientprotocol/sdk";
import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
import { listThinkingLevels } from "../auto-reply/thinking.js";
import type { GatewayClient } from "../gateway/client.js";
import type { EventFrame } from "../gateway/protocol/index.js";
import type { SessionsListResult } from "../gateway/session-utils.js";
import type { GatewaySessionRow, SessionsListResult } from "../gateway/session-utils.js";
import {
createFixedWindowRateLimiter,
type FixedWindowRateLimiter,
@@ -32,6 +39,8 @@ import { shortenHomePath } from "../utils.js";
import { getAvailableCommands } from "./commands.js";
import {
extractAttachmentsFromPrompt,
extractToolCallContent,
extractToolCallLocations,
extractTextFromPrompt,
formatToolTitle,
inferToolKind,
@@ -43,6 +52,12 @@ import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js";
// Maximum allowed prompt size (2MB) to prevent DoS via memory exhaustion (CWE-400, GHSA-cxpw-2g23-2vgw)
const MAX_PROMPT_BYTES = 2 * 1024 * 1024;
const ACP_THOUGHT_LEVEL_CONFIG_ID = "thought_level";
const ACP_VERBOSE_LEVEL_CONFIG_ID = "verbose_level";
const ACP_REASONING_LEVEL_CONFIG_ID = "reasoning_level";
const ACP_RESPONSE_USAGE_CONFIG_ID = "response_usage";
const ACP_ELEVATED_LEVEL_CONFIG_ID = "elevated_level";
const ACP_LOAD_SESSION_REPLAY_LIMIT = 1_000_000;
type PendingPrompt = {
sessionId: string;
@@ -52,16 +67,240 @@ type PendingPrompt = {
reject: (err: Error) => void;
sentTextLength?: number;
sentText?: string;
toolCalls?: Set<string>;
toolCalls?: Map<string, PendingToolCall>;
};
type PendingToolCall = {
kind: ToolKind;
locations?: ToolCallLocation[];
rawInput?: Record<string, unknown>;
title: string;
};
type AcpGatewayAgentOptions = AcpServerOptions & {
sessionStore?: AcpSessionStore;
};
type GatewaySessionPresentationRow = Pick<
GatewaySessionRow,
| "displayName"
| "label"
| "derivedTitle"
| "updatedAt"
| "thinkingLevel"
| "modelProvider"
| "model"
| "verboseLevel"
| "reasoningLevel"
| "responseUsage"
| "elevatedLevel"
| "totalTokens"
| "totalTokensFresh"
| "contextTokens"
>;
type SessionPresentation = {
configOptions: SessionConfigOption[];
modes: SessionModeState;
};
type SessionMetadata = {
title?: string | null;
updatedAt?: string | null;
};
type SessionUsageSnapshot = {
size: number;
used: number;
};
type SessionSnapshot = SessionPresentation & {
metadata?: SessionMetadata;
usage?: SessionUsageSnapshot;
};
type GatewayTranscriptMessage = {
role?: unknown;
content?: unknown;
};
const SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS = 120;
const SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS = 10_000;
function formatThinkingLevelName(level: string): string {
switch (level) {
case "xhigh":
return "Extra High";
case "adaptive":
return "Adaptive";
default:
return level.length > 0 ? `${level[0].toUpperCase()}${level.slice(1)}` : "Unknown";
}
}
function buildThinkingModeDescription(level: string): string | undefined {
if (level === "adaptive") {
return "Use the Gateway session default thought level.";
}
return undefined;
}
function formatConfigValueName(value: string): string {
switch (value) {
case "xhigh":
return "Extra High";
default:
return value.length > 0 ? `${value[0].toUpperCase()}${value.slice(1)}` : "Unknown";
}
}
function buildSelectConfigOption(params: {
id: string;
name: string;
description: string;
currentValue: string;
values: readonly string[];
category?: string;
}): SessionConfigOption {
return {
type: "select",
id: params.id,
name: params.name,
category: params.category,
description: params.description,
currentValue: params.currentValue,
options: params.values.map((value) => ({
value,
name: formatConfigValueName(value),
})),
};
}
function buildSessionPresentation(params: {
row?: GatewaySessionPresentationRow;
overrides?: Partial<GatewaySessionPresentationRow>;
}): SessionPresentation {
const row = {
...params.row,
...params.overrides,
};
const availableLevelIds: string[] = [...listThinkingLevels(row.modelProvider, row.model)];
const currentModeId = row.thinkingLevel?.trim() || "adaptive";
if (!availableLevelIds.includes(currentModeId)) {
availableLevelIds.push(currentModeId);
}
const modes: SessionModeState = {
currentModeId,
availableModes: availableLevelIds.map((level) => ({
id: level,
name: formatThinkingLevelName(level),
description: buildThinkingModeDescription(level),
})),
};
const configOptions: SessionConfigOption[] = [
buildSelectConfigOption({
id: ACP_THOUGHT_LEVEL_CONFIG_ID,
name: "Thought level",
category: "thought_level",
description:
"Controls how much deliberate reasoning OpenClaw requests from the Gateway model.",
currentValue: currentModeId,
values: availableLevelIds,
}),
buildSelectConfigOption({
id: ACP_VERBOSE_LEVEL_CONFIG_ID,
name: "Tool verbosity",
description:
"Controls how much tool progress and output detail OpenClaw keeps enabled for the session.",
currentValue: row.verboseLevel?.trim() || "off",
values: ["off", "on", "full"],
}),
buildSelectConfigOption({
id: ACP_REASONING_LEVEL_CONFIG_ID,
name: "Reasoning stream",
description: "Controls whether reasoning-capable models emit reasoning text for the session.",
currentValue: row.reasoningLevel?.trim() || "off",
values: ["off", "on", "stream"],
}),
buildSelectConfigOption({
id: ACP_RESPONSE_USAGE_CONFIG_ID,
name: "Usage detail",
description:
"Controls how much usage information OpenClaw attaches to responses for the session.",
currentValue: row.responseUsage?.trim() || "off",
values: ["off", "tokens", "full"],
}),
buildSelectConfigOption({
id: ACP_ELEVATED_LEVEL_CONFIG_ID,
name: "Elevated actions",
description: "Controls how aggressively the session allows elevated execution behavior.",
currentValue: row.elevatedLevel?.trim() || "off",
values: ["off", "on", "ask", "full"],
}),
];
return { configOptions, modes };
}
function extractReplayText(content: unknown): string | undefined {
if (typeof content === "string") {
return content.length > 0 ? content : undefined;
}
if (!Array.isArray(content)) {
return undefined;
}
const text = content
.map((block) => {
if (!block || typeof block !== "object" || Array.isArray(block)) {
return "";
}
const typedBlock = block as { type?: unknown; text?: unknown };
return typedBlock.type === "text" && typeof typedBlock.text === "string"
? typedBlock.text
: "";
})
.join("");
return text.length > 0 ? text : undefined;
}
function buildSessionMetadata(params: {
row?: GatewaySessionPresentationRow;
sessionKey: string;
}): SessionMetadata {
const title =
params.row?.derivedTitle?.trim() ||
params.row?.displayName?.trim() ||
params.row?.label?.trim() ||
params.sessionKey;
const updatedAt =
typeof params.row?.updatedAt === "number" && Number.isFinite(params.row.updatedAt)
? new Date(params.row.updatedAt).toISOString()
: null;
return { title, updatedAt };
}
function buildSessionUsageSnapshot(
row?: GatewaySessionPresentationRow,
): SessionUsageSnapshot | undefined {
const totalTokens = row?.totalTokens;
const contextTokens = row?.contextTokens;
if (
row?.totalTokensFresh !== true ||
typeof totalTokens !== "number" ||
!Number.isFinite(totalTokens) ||
typeof contextTokens !== "number" ||
!Number.isFinite(contextTokens) ||
contextTokens <= 0
) {
return undefined;
}
const size = Math.max(0, Math.floor(contextTokens));
const used = Math.max(0, Math.min(Math.floor(totalTokens), size));
return { size, used };
}
function buildSystemInputProvenance(originSessionId: string) {
return {
kind: "external_user" as const,
@@ -170,9 +409,7 @@ export class AcpGatewayAgent implements Agent {
}
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
if (params.mcpServers.length > 0) {
this.log(`ignoring ${params.mcpServers.length} MCP servers`);
}
this.assertSupportedSessionSetup(params.mcpServers);
this.enforceSessionCreateRateLimit("newSession");
const sessionId = randomUUID();
@@ -188,14 +425,21 @@ export class AcpGatewayAgent implements Agent {
cwd: params.cwd,
});
this.log(`newSession: ${session.sessionId} -> ${session.sessionKey}`);
const sessionSnapshot = await this.getSessionSnapshot(session.sessionKey);
await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, {
includeControls: false,
});
await this.sendAvailableCommands(session.sessionId);
return { sessionId: session.sessionId };
const { configOptions, modes } = sessionSnapshot;
return {
sessionId: session.sessionId,
configOptions,
modes,
};
}
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
if (params.mcpServers.length > 0) {
this.log(`ignoring ${params.mcpServers.length} MCP servers`);
}
this.assertSupportedSessionSetup(params.mcpServers);
if (!this.sessionStore.hasSession(params.sessionId)) {
this.enforceSessionCreateRateLimit("loadSession");
}
@@ -212,8 +456,20 @@ export class AcpGatewayAgent implements Agent {
cwd: params.cwd,
});
this.log(`loadSession: ${session.sessionId} -> ${session.sessionKey}`);
const [sessionSnapshot, transcript] = await Promise.all([
this.getSessionSnapshot(session.sessionKey),
this.getSessionTranscript(session.sessionKey).catch((err) => {
this.log(`session transcript fallback for ${session.sessionKey}: ${String(err)}`);
return [];
}),
]);
await this.replaySessionTranscript(session.sessionId, transcript);
await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, {
includeControls: false,
});
await this.sendAvailableCommands(session.sessionId);
return {};
const { configOptions, modes } = sessionSnapshot;
return { configOptions, modes };
}
async unstable_listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
@@ -254,13 +510,52 @@ export class AcpGatewayAgent implements Agent {
thinkingLevel: params.modeId,
});
this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`);
const sessionSnapshot = await this.getSessionSnapshot(session.sessionKey, {
thinkingLevel: params.modeId,
});
await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, {
includeControls: true,
});
} catch (err) {
this.log(`setSessionMode error: ${String(err)}`);
throw err;
throw err instanceof Error ? err : new Error(String(err));
}
return {};
}
async setSessionConfigOption(
params: SetSessionConfigOptionRequest,
): Promise<SetSessionConfigOptionResponse> {
const session = this.sessionStore.getSession(params.sessionId);
if (!session) {
throw new Error(`Session ${params.sessionId} not found`);
}
const sessionPatch = this.resolveSessionConfigPatch(params.configId, params.value);
try {
await this.gateway.request("sessions.patch", {
key: session.sessionKey,
...sessionPatch.patch,
});
this.log(
`setSessionConfigOption: ${session.sessionId} -> ${params.configId}=${params.value}`,
);
const sessionSnapshot = await this.getSessionSnapshot(
session.sessionKey,
sessionPatch.overrides,
);
await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, {
includeControls: true,
});
return {
configOptions: sessionSnapshot.configOptions,
};
} catch (err) {
this.log(`setSessionConfigOption error: ${String(err)}`);
throw err instanceof Error ? err : new Error(String(err));
}
}
async prompt(params: PromptRequest): Promise<PromptResponse> {
const session = this.sessionStore.getSession(params.sessionId);
if (!session) {
@@ -338,7 +633,6 @@ export class AcpGatewayAgent implements Agent {
if (!session) {
return;
}
this.sessionStore.cancelActiveRun(params.sessionId);
try {
await this.gateway.request("chat.abort", { sessionKey: session.sessionKey });
@@ -401,22 +695,48 @@ export class AcpGatewayAgent implements Agent {
if (phase === "start") {
if (!pending.toolCalls) {
pending.toolCalls = new Set();
pending.toolCalls = new Map();
}
if (pending.toolCalls.has(toolCallId)) {
return;
}
pending.toolCalls.add(toolCallId);
const args = data.args as Record<string, unknown> | undefined;
const title = formatToolTitle(name, args);
const kind = inferToolKind(name);
const locations = extractToolCallLocations(args);
pending.toolCalls.set(toolCallId, {
title,
kind,
rawInput: args,
locations,
});
await this.connection.sessionUpdate({
sessionId: pending.sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId,
title: formatToolTitle(name, args),
title,
status: "in_progress",
rawInput: args,
kind: inferToolKind(name),
kind,
locations,
},
});
return;
}
if (phase === "update") {
const toolState = pending.toolCalls?.get(toolCallId);
const partialResult = data.partialResult;
await this.connection.sessionUpdate({
sessionId: pending.sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status: "in_progress",
rawOutput: partialResult,
content: extractToolCallContent(partialResult),
locations: extractToolCallLocations(toolState?.locations, partialResult),
},
});
return;
@@ -424,6 +744,8 @@ export class AcpGatewayAgent implements Agent {
if (phase === "result") {
const isError = Boolean(data.isError);
const toolState = pending.toolCalls?.get(toolCallId);
pending.toolCalls?.delete(toolCallId);
await this.connection.sessionUpdate({
sessionId: pending.sessionId,
update: {
@@ -431,6 +753,8 @@ export class AcpGatewayAgent implements Agent {
toolCallId,
status: isError ? "failed" : "completed",
rawOutput: data.result,
content: extractToolCallContent(data.result),
locations: extractToolCallLocations(toolState?.locations, data.result),
},
});
}
@@ -466,11 +790,11 @@ export class AcpGatewayAgent implements Agent {
if (state === "final") {
const rawStopReason = payload.stopReason as string | undefined;
const stopReason: StopReason = rawStopReason === "max_tokens" ? "max_tokens" : "end_turn";
this.finishPrompt(pending.sessionId, pending, stopReason);
await this.finishPrompt(pending.sessionId, pending, stopReason);
return;
}
if (state === "aborted") {
this.finishPrompt(pending.sessionId, pending, "cancelled");
await this.finishPrompt(pending.sessionId, pending, "cancelled");
return;
}
if (state === "error") {
@@ -478,7 +802,7 @@ export class AcpGatewayAgent implements Agent {
// do not treat transient backend errors (timeouts, rate-limits) as deliberate
// refusals. TODO: when ChatEventSchema gains a structured errorKind field
// (e.g. "refusal" | "timeout" | "rate_limit"), use it to distinguish here.
this.finishPrompt(pending.sessionId, pending, "end_turn");
void this.finishPrompt(pending.sessionId, pending, "end_turn");
}
}
@@ -511,9 +835,21 @@ export class AcpGatewayAgent implements Agent {
});
}
private finishPrompt(sessionId: string, pending: PendingPrompt, stopReason: StopReason): void {
private async finishPrompt(
sessionId: string,
pending: PendingPrompt,
stopReason: StopReason,
): Promise<void> {
this.pendingPrompts.delete(sessionId);
this.sessionStore.clearActiveRun(sessionId);
const sessionSnapshot = await this.getSessionSnapshot(pending.sessionKey);
try {
await this.sendSessionSnapshotUpdate(sessionId, sessionSnapshot, {
includeControls: false,
});
} catch (err) {
this.log(`session snapshot update failed for ${sessionId}: ${String(err)}`);
}
pending.resolve({ stopReason });
}
@@ -536,6 +872,183 @@ export class AcpGatewayAgent implements Agent {
});
}
private async getSessionSnapshot(
sessionKey: string,
overrides?: Partial<GatewaySessionPresentationRow>,
): Promise<SessionSnapshot> {
try {
const row = await this.getGatewaySessionRow(sessionKey);
return {
...buildSessionPresentation({ row, overrides }),
metadata: buildSessionMetadata({ row, sessionKey }),
usage: buildSessionUsageSnapshot(row),
};
} catch (err) {
this.log(`session presentation fallback for ${sessionKey}: ${String(err)}`);
return {
...buildSessionPresentation({ overrides }),
metadata: buildSessionMetadata({ sessionKey }),
};
}
}
private async getGatewaySessionRow(
sessionKey: string,
): Promise<GatewaySessionPresentationRow | undefined> {
const result = await this.gateway.request<SessionsListResult>("sessions.list", {
limit: 200,
search: sessionKey,
includeDerivedTitles: true,
});
const session = result.sessions.find((entry) => entry.key === sessionKey);
if (!session) {
return undefined;
}
return {
displayName: session.displayName,
label: session.label,
derivedTitle: session.derivedTitle,
updatedAt: session.updatedAt,
thinkingLevel: session.thinkingLevel,
modelProvider: session.modelProvider,
model: session.model,
verboseLevel: session.verboseLevel,
reasoningLevel: session.reasoningLevel,
responseUsage: session.responseUsage,
elevatedLevel: session.elevatedLevel,
totalTokens: session.totalTokens,
totalTokensFresh: session.totalTokensFresh,
contextTokens: session.contextTokens,
};
}
private resolveSessionConfigPatch(
configId: string,
value: string,
): {
overrides: Partial<GatewaySessionPresentationRow>;
patch: Record<string, string>;
} {
switch (configId) {
case ACP_THOUGHT_LEVEL_CONFIG_ID:
return {
patch: { thinkingLevel: value },
overrides: { thinkingLevel: value },
};
case ACP_VERBOSE_LEVEL_CONFIG_ID:
return {
patch: { verboseLevel: value },
overrides: { verboseLevel: value },
};
case ACP_REASONING_LEVEL_CONFIG_ID:
return {
patch: { reasoningLevel: value },
overrides: { reasoningLevel: value },
};
case ACP_RESPONSE_USAGE_CONFIG_ID:
return {
patch: { responseUsage: value },
overrides: { responseUsage: value as GatewaySessionPresentationRow["responseUsage"] },
};
case ACP_ELEVATED_LEVEL_CONFIG_ID:
return {
patch: { elevatedLevel: value },
overrides: { elevatedLevel: value },
};
default:
throw new Error(`ACP bridge mode does not support session config option "${configId}".`);
}
}
private async getSessionTranscript(sessionKey: string): Promise<GatewayTranscriptMessage[]> {
const result = await this.gateway.request<{ messages?: unknown[] }>("sessions.get", {
key: sessionKey,
limit: ACP_LOAD_SESSION_REPLAY_LIMIT,
});
if (!Array.isArray(result.messages)) {
return [];
}
return result.messages as GatewayTranscriptMessage[];
}
private async replaySessionTranscript(
sessionId: string,
transcript: ReadonlyArray<GatewayTranscriptMessage>,
): Promise<void> {
for (const message of transcript) {
const role = typeof message.role === "string" ? message.role : "";
if (role !== "user" && role !== "assistant") {
continue;
}
const text = extractReplayText(message.content);
if (!text) {
continue;
}
await this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: role === "user" ? "user_message_chunk" : "agent_message_chunk",
content: { type: "text", text },
},
});
}
}
private async sendSessionSnapshotUpdate(
sessionId: string,
sessionSnapshot: SessionSnapshot,
options: { includeControls: boolean },
): Promise<void> {
if (options.includeControls) {
await this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "current_mode_update",
currentModeId: sessionSnapshot.modes.currentModeId,
},
});
await this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "config_option_update",
configOptions: sessionSnapshot.configOptions,
},
});
}
if (sessionSnapshot.metadata) {
await this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "session_info_update",
...sessionSnapshot.metadata,
},
});
}
if (sessionSnapshot.usage) {
await this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "usage_update",
used: sessionSnapshot.usage.used,
size: sessionSnapshot.usage.size,
_meta: {
source: "gateway-session-store",
approximate: true,
},
},
});
}
}
private assertSupportedSessionSetup(mcpServers: ReadonlyArray<unknown>): void {
if (mcpServers.length === 0) {
return;
}
throw new Error(
"ACP bridge mode does not support per-session MCP servers. Configure MCP on the OpenClaw gateway or agent instead.",
);
}
private enforceSessionCreateRateLimit(method: "newSession" | "loadSession"): void {
const budget = this.sessionCreateRateLimiter.consume();
if (budget.allowed) {

View File

@@ -56,6 +56,7 @@ export type SpawnAcpParams = {
task: string;
label?: string;
agentId?: string;
resumeSessionId?: string;
cwd?: string;
mode?: SpawnAcpMode;
thread?: boolean;
@@ -426,6 +427,7 @@ export async function spawnAcpDirect(
sessionKey,
agent: targetAgentId,
mode: runtimeMode,
resumeSessionId: params.resumeSessionId,
cwd: params.cwd,
backendId: cfg.acp?.backend,
});

View File

@@ -190,6 +190,58 @@ describe("markAuthProfileFailure", () => {
}
});
it("resets error count when previous cooldown has expired to prevent escalation", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
try {
const authPath = path.join(agentDir, "auth-profiles.json");
const now = Date.now();
// Simulate state left on disk after 3 rapid failures within a 1-min cooldown
// window. The cooldown has since expired, but clearExpiredCooldowns() only
// ran in-memory and never persisted — so disk still carries errorCount: 3.
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
},
usageStats: {
"anthropic:default": {
errorCount: 3,
failureCounts: { rate_limit: 3 },
lastFailureAt: now - 120_000, // 2 minutes ago
cooldownUntil: now - 60_000, // expired 1 minute ago
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
await markAuthProfileFailure({
store,
profileId: "anthropic:default",
reason: "rate_limit",
agentDir,
});
const stats = store.usageStats?.["anthropic:default"];
// Error count should reset to 1 (not escalate to 4) because the
// previous cooldown expired. Cooldown should be ~1 min, not ~60 min.
expect(stats?.errorCount).toBe(1);
expect(stats?.failureCounts?.rate_limit).toBe(1);
const cooldownMs = (stats?.cooldownUntil ?? 0) - now;
// calculateAuthProfileCooldownMs(1) = 60_000 (1 minute)
expect(cooldownMs).toBeLessThan(120_000);
expect(cooldownMs).toBeGreaterThan(0);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("does not persist cooldown windows for OpenRouter profiles", async () => {
await withAuthProfileStore(async ({ agentDir, store }) => {
await markAuthProfileFailure({

View File

@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { resetLogger, setLoggerOverride } from "../../logging/logger.js";
import { logAuthProfileFailureStateChange } from "./state-observation.js";
afterEach(() => {
setLoggerOverride(null);
resetLogger();
});
describe("logAuthProfileFailureStateChange", () => {
it("sanitizes consoleMessage fields before logging", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
setLoggerOverride({ level: "silent", consoleLevel: "warn" });
logAuthProfileFailureStateChange({
runId: "run-1\nforged\tentry\rtest",
profileId: "openai:profile-1",
provider: "openai\u001b]8;;https://evil.test\u0007",
reason: "overloaded",
previous: undefined,
next: {
errorCount: 1,
cooldownUntil: 1_700_000_060_000,
failureCounts: { overloaded: 1 },
},
now: 1_700_000_000_000,
});
const consoleLine = warnSpy.mock.calls[0]?.[0];
expect(typeof consoleLine).toBe("string");
expect(consoleLine).toContain("runId=run-1 forged entry test");
expect(consoleLine).toContain("provider=openai]8;;https://evil.test");
expect(consoleLine).not.toContain("\n");
expect(consoleLine).not.toContain("\r");
expect(consoleLine).not.toContain("\t");
expect(consoleLine).not.toContain("\u001b");
});
});

View File

@@ -0,0 +1,59 @@
import { redactIdentifier } from "../../logging/redact-identifier.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { sanitizeForConsole } from "../pi-embedded-error-observation.js";
import type { AuthProfileFailureReason, ProfileUsageStats } from "./types.js";
const observationLog = createSubsystemLogger("agent/embedded");
export function logAuthProfileFailureStateChange(params: {
runId?: string;
profileId: string;
provider: string;
reason: AuthProfileFailureReason;
previous: ProfileUsageStats | undefined;
next: ProfileUsageStats;
now: number;
}): void {
const windowType =
params.reason === "billing" || params.reason === "auth_permanent" ? "disabled" : "cooldown";
const previousCooldownUntil = params.previous?.cooldownUntil;
const previousDisabledUntil = params.previous?.disabledUntil;
// Active cooldown/disable windows are intentionally immutable; log whether this
// update reused the existing window instead of extending it.
const windowReused =
windowType === "disabled"
? typeof previousDisabledUntil === "number" &&
Number.isFinite(previousDisabledUntil) &&
previousDisabledUntil > params.now &&
previousDisabledUntil === params.next.disabledUntil
: typeof previousCooldownUntil === "number" &&
Number.isFinite(previousCooldownUntil) &&
previousCooldownUntil > params.now &&
previousCooldownUntil === params.next.cooldownUntil;
const safeProfileId = redactIdentifier(params.profileId, { len: 12 });
const safeRunId = sanitizeForConsole(params.runId) ?? "-";
const safeProvider = sanitizeForConsole(params.provider) ?? "-";
observationLog.warn("auth profile failure state updated", {
event: "auth_profile_failure_state_updated",
tags: ["error_handling", "auth_profiles", windowType],
runId: params.runId,
profileId: safeProfileId,
provider: params.provider,
reason: params.reason,
windowType,
windowReused,
previousErrorCount: params.previous?.errorCount,
errorCount: params.next.errorCount,
previousCooldownUntil,
cooldownUntil: params.next.cooldownUntil,
previousDisabledUntil,
disabledUntil: params.next.disabledUntil,
previousDisabledReason: params.previous?.disabledReason,
disabledReason: params.next.disabledReason,
failureCounts: params.next.failureCounts,
consoleMessage:
`auth profile failure state updated: runId=${safeRunId} profile=${safeProfileId} provider=${safeProvider} ` +
`reason=${params.reason} window=${windowType} reused=${String(windowReused)}`,
});
}

View File

@@ -608,6 +608,10 @@ describe("markAuthProfileFailure — active windows do not extend on retry", ()
});
}
// When a cooldown/disabled window expires, the error count resets to prevent
// stale counters from escalating the next cooldown (the root cause of
// infinite cooldown loops — see #40989). The next failure should compute
// backoff from errorCount=1, not from the accumulated stale count.
const expiredWindowCases = [
{
label: "cooldownUntil",
@@ -617,7 +621,8 @@ describe("markAuthProfileFailure — active windows do not extend on retry", ()
errorCount: 3,
lastFailureAt: now - 60_000,
}),
expectedUntil: (now: number) => now + 60 * 60 * 1000,
// errorCount resets → calculateAuthProfileCooldownMs(1) = 60_000
expectedUntil: (now: number) => now + 60_000,
readUntil: (stats: WindowStats | undefined) => stats?.cooldownUntil,
},
{
@@ -630,7 +635,9 @@ describe("markAuthProfileFailure — active windows do not extend on retry", ()
failureCounts: { billing: 2 },
lastFailureAt: now - 60_000,
}),
expectedUntil: (now: number) => now + 20 * 60 * 60 * 1000,
// errorCount resets, billing count resets to 1 →
// calculateAuthProfileBillingDisableMsWithConfig(1, 5h, 24h) = 5h
expectedUntil: (now: number) => now + 5 * 60 * 60 * 1000,
readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil,
},
{
@@ -643,7 +650,9 @@ describe("markAuthProfileFailure — active windows do not extend on retry", ()
failureCounts: { auth_permanent: 2 },
lastFailureAt: now - 60_000,
}),
expectedUntil: (now: number) => now + 20 * 60 * 60 * 1000,
// errorCount resets, auth_permanent count resets to 1 →
// calculateAuthProfileBillingDisableMsWithConfig(1, 5h, 24h) = 5h
expectedUntil: (now: number) => now + 5 * 60 * 60 * 1000,
readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil,
},
];

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../../config/config.js";
import { normalizeProviderId } from "../model-selection.js";
import { logAuthProfileFailureStateChange } from "./state-observation.js";
import { saveAuthProfileStore, updateAuthProfileStoreWithLock } from "./store.js";
import type { AuthProfileFailureReason, AuthProfileStore, ProfileUsageStats } from "./types.js";
@@ -400,9 +401,19 @@ function computeNextProfileUsageStats(params: {
params.existing.lastFailureAt > 0 &&
params.now - params.existing.lastFailureAt > windowMs;
const baseErrorCount = windowExpired ? 0 : (params.existing.errorCount ?? 0);
// If the previous cooldown has already expired, reset error counters so the
// profile gets a fresh backoff window. clearExpiredCooldowns() does this
// in-memory during profile ordering, but the on-disk state may still carry
// the old counters when the lock-based updater reads a fresh store. Without
// this check, stale error counts from an expired cooldown cause the next
// failure to escalate to a much longer cooldown (e.g. 1 min → 25 min).
const unusableUntil = resolveProfileUnusableUntil(params.existing);
const previousCooldownExpired = typeof unusableUntil === "number" && params.now >= unusableUntil;
const shouldResetCounters = windowExpired || previousCooldownExpired;
const baseErrorCount = shouldResetCounters ? 0 : (params.existing.errorCount ?? 0);
const nextErrorCount = baseErrorCount + 1;
const failureCounts = windowExpired ? {} : { ...params.existing.failureCounts };
const failureCounts = shouldResetCounters ? {} : { ...params.existing.failureCounts };
failureCounts[params.reason] = (failureCounts[params.reason] ?? 0) + 1;
const updatedStats: ProfileUsageStats = {
@@ -452,12 +463,16 @@ export async function markAuthProfileFailure(params: {
reason: AuthProfileFailureReason;
cfg?: OpenClawConfig;
agentDir?: string;
runId?: string;
}): Promise<void> {
const { store, profileId, reason, agentDir, cfg } = params;
const { store, profileId, reason, agentDir, cfg, runId } = params;
const profile = store.profiles[profileId];
if (!profile || isAuthCooldownBypassedForProvider(profile.provider)) {
return;
}
let nextStats: ProfileUsageStats | undefined;
let previousStats: ProfileUsageStats | undefined;
let updateTime = 0;
const updated = await updateAuthProfileStoreWithLock({
agentDir,
updater: (freshStore) => {
@@ -472,19 +487,32 @@ export async function markAuthProfileFailure(params: {
providerId: providerKey,
});
updateUsageStatsEntry(freshStore, profileId, (existing) =>
computeNextProfileUsageStats({
existing: existing ?? {},
now,
reason,
cfgResolved,
}),
);
previousStats = freshStore.usageStats?.[profileId];
updateTime = now;
const computed = computeNextProfileUsageStats({
existing: previousStats ?? {},
now,
reason,
cfgResolved,
});
nextStats = computed;
updateUsageStatsEntry(freshStore, profileId, () => computed);
return true;
},
});
if (updated) {
store.usageStats = updated.usageStats;
if (nextStats) {
logAuthProfileFailureStateChange({
runId,
profileId,
provider: profile.provider,
reason,
previous: previousStats,
next: nextStats,
now: updateTime,
});
}
return;
}
if (!store.profiles[profileId]) {
@@ -498,15 +526,25 @@ export async function markAuthProfileFailure(params: {
providerId: providerKey,
});
updateUsageStatsEntry(store, profileId, (existing) =>
computeNextProfileUsageStats({
existing: existing ?? {},
now,
reason,
cfgResolved,
}),
);
previousStats = store.usageStats?.[profileId];
const computed = computeNextProfileUsageStats({
existing: previousStats ?? {},
now,
reason,
cfgResolved,
});
nextStats = computed;
updateUsageStatsEntry(store, profileId, () => computed);
saveAuthProfileStore(store, agentDir);
logAuthProfileFailureStateChange({
runId,
profileId,
provider: store.profiles[profileId]?.provider ?? profile.provider,
reason,
previous: previousStats,
next: nextStats,
now,
});
}
/**
@@ -518,12 +556,14 @@ export async function markAuthProfileCooldown(params: {
store: AuthProfileStore;
profileId: string;
agentDir?: string;
runId?: string;
}): Promise<void> {
await markAuthProfileFailure({
store: params.store,
profileId: params.profileId,
reason: "unknown",
agentDir: params.agentDir,
runId: params.runId,
});
}

View File

@@ -0,0 +1,61 @@
import { callGatewayTool } from "./tools/gateway.js";
type ExecApprovalFollowupParams = {
approvalId: string;
sessionKey?: string;
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
resultText: string;
};
export function buildExecApprovalFollowupPrompt(resultText: string): string {
return [
"An async command the user already approved has completed.",
"Do not run the command again.",
"",
"Exact completion details:",
resultText.trim(),
"",
"Reply to the user in a helpful way.",
"If it succeeded, share the relevant output.",
"If it failed, explain what went wrong.",
].join("\n");
}
export async function sendExecApprovalFollowup(
params: ExecApprovalFollowupParams,
): Promise<boolean> {
const sessionKey = params.sessionKey?.trim();
const resultText = params.resultText.trim();
if (!sessionKey || !resultText) {
return false;
}
const channel = params.turnSourceChannel?.trim();
const to = params.turnSourceTo?.trim();
const threadId =
params.turnSourceThreadId != null && params.turnSourceThreadId !== ""
? String(params.turnSourceThreadId)
: undefined;
await callGatewayTool(
"agent",
{ timeoutMs: 60_000 },
{
sessionKey,
message: buildExecApprovalFollowupPrompt(resultText),
deliver: true,
bestEffortDeliver: true,
channel: channel && to ? channel : undefined,
to: channel && to ? to : undefined,
accountId: channel && to ? params.turnSourceAccountId?.trim() || undefined : undefined,
threadId: channel && to ? threadId : undefined,
idempotencyKey: `exec-approval-followup:${params.approvalId}`,
},
{ expectFinal: true },
);
return true;
}

View File

@@ -1,4 +1,10 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { loadConfig } from "../config/config.js";
import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js";
import {
hasConfiguredExecApprovalDmRoute,
resolveExecApprovalInitiatingSurfaceState,
} from "../infra/exec-approval-surface.js";
import {
addAllowlistEntry,
type ExecAsk,
@@ -13,6 +19,7 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js";
import { logInfo } from "../logger.js";
import { markBackgrounded, tail } from "./bash-process-registry.js";
import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js";
import {
buildExecApprovalRequesterContext,
buildExecApprovalTurnSourceContext,
@@ -25,9 +32,9 @@ import {
resolveExecHostApprovalContext,
} from "./bash-tools.exec-host-shared.js";
import {
buildApprovalPendingMessage,
DEFAULT_NOTIFY_TAIL_CHARS,
createApprovalSlug,
emitExecSystemEvent,
normalizeNotifyOutput,
runExecProcess,
} from "./bash-tools.exec-runtime.js";
@@ -141,8 +148,6 @@ export async function processGatewayAllowlist(
const {
approvalId,
approvalSlug,
contextKey,
noticeSeconds,
warningText,
expiresAtMs: defaultExpiresAtMs,
preResolvedDecision: defaultPreResolvedDecision,
@@ -174,19 +179,37 @@ export async function processGatewayAllowlist(
});
expiresAtMs = registration.expiresAtMs;
preResolvedDecision = registration.finalDecision;
const initiatingSurface = resolveExecApprovalInitiatingSurfaceState({
channel: params.turnSourceChannel,
accountId: params.turnSourceAccountId,
});
const cfg = loadConfig();
const sentApproverDms =
(initiatingSurface.kind === "disabled" || initiatingSurface.kind === "unsupported") &&
hasConfiguredExecApprovalDmRoute(cfg);
const unavailableReason =
preResolvedDecision === null
? "no-approval-route"
: initiatingSurface.kind === "disabled"
? "initiating-platform-disabled"
: initiatingSurface.kind === "unsupported"
? "initiating-platform-unsupported"
: null;
void (async () => {
const decision = await resolveApprovalDecisionOrUndefined({
approvalId,
preResolvedDecision,
onFailure: () =>
emitExecSystemEvent(
`Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
),
void sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
}),
});
if (decision === undefined) {
return;
@@ -230,13 +253,15 @@ export async function processGatewayAllowlist(
}
if (deniedReason) {
emitExecSystemEvent(
`Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
);
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`,
}).catch(() => {});
return;
}
@@ -262,32 +287,21 @@ export async function processGatewayAllowlist(
timeoutSec: effectiveTimeout,
});
} catch {
emitExecSystemEvent(
`Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
);
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`,
}).catch(() => {});
return;
}
markBackgrounded(run.session);
let runningTimer: NodeJS.Timeout | null = null;
if (params.approvalRunningNoticeMs > 0) {
runningTimer = setTimeout(() => {
emitExecSystemEvent(
`Exec running (gateway id=${approvalId}, session=${run?.session.id}, >${noticeSeconds}s): ${params.command}`,
{ sessionKey: params.notifySessionKey, contextKey },
);
}, params.approvalRunningNoticeMs);
}
const outcome = await run.promise;
if (runningTimer) {
clearTimeout(runningTimer);
}
const output = normalizeNotifyOutput(
tail(outcome.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS),
);
@@ -295,7 +309,15 @@ export async function processGatewayAllowlist(
const summary = output
? `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})\n${output}`
: `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})`;
emitExecSystemEvent(summary, { sessionKey: params.notifySessionKey, contextKey });
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: summary,
}).catch(() => {});
})();
return {
@@ -304,19 +326,45 @@ export async function processGatewayAllowlist(
{
type: "text",
text:
`${warningText}Approval required (id ${approvalSlug}). ` +
"Approve to run; updates will arrive after completion.",
unavailableReason !== null
? (buildExecApprovalUnavailableReplyPayload({
warningText,
reason: unavailableReason,
channelLabel: initiatingSurface.channelLabel,
sentApproverDms,
}).text ?? "")
: buildApprovalPendingMessage({
warningText,
approvalSlug,
approvalId,
command: params.command,
cwd: params.workdir,
host: "gateway",
}),
},
],
details: {
status: "approval-pending",
approvalId,
approvalSlug,
expiresAtMs,
host: "gateway",
command: params.command,
cwd: params.workdir,
},
details:
unavailableReason !== null
? ({
status: "approval-unavailable",
reason: unavailableReason,
channelLabel: initiatingSurface.channelLabel,
sentApproverDms,
host: "gateway",
command: params.command,
cwd: params.workdir,
warningText,
} satisfies ExecToolDetails)
: ({
status: "approval-pending",
approvalId,
approvalSlug,
expiresAtMs,
host: "gateway",
command: params.command,
cwd: params.workdir,
warningText,
} satisfies ExecToolDetails),
},
};
}

View File

@@ -1,5 +1,11 @@
import crypto from "node:crypto";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { loadConfig } from "../config/config.js";
import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js";
import {
hasConfiguredExecApprovalDmRoute,
resolveExecApprovalInitiatingSurfaceState,
} from "../infra/exec-approval-surface.js";
import {
type ExecApprovalsFile,
type ExecAsk,
@@ -12,6 +18,7 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
import { buildNodeShellCommand } from "../infra/node-shell.js";
import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-context.js";
import { logInfo } from "../logger.js";
import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js";
import {
buildExecApprovalRequesterContext,
buildExecApprovalTurnSourceContext,
@@ -23,7 +30,12 @@ import {
resolveApprovalDecisionOrUndefined,
resolveExecHostApprovalContext,
} from "./bash-tools.exec-host-shared.js";
import { createApprovalSlug, emitExecSystemEvent } from "./bash-tools.exec-runtime.js";
import {
buildApprovalPendingMessage,
DEFAULT_NOTIFY_TAIL_CHARS,
createApprovalSlug,
normalizeNotifyOutput,
} from "./bash-tools.exec-runtime.js";
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
import { callGatewayTool } from "./tools/gateway.js";
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
@@ -187,6 +199,7 @@ export async function executeNodeHostCommand(
approvedByAsk: boolean,
approvalDecision: "allow-once" | "allow-always" | null,
runId?: string,
suppressNotifyOnExit?: boolean,
) =>
({
nodeId,
@@ -202,6 +215,7 @@ export async function executeNodeHostCommand(
approved: approvedByAsk,
approvalDecision: approvalDecision ?? undefined,
runId: runId ?? undefined,
suppressNotifyOnExit: suppressNotifyOnExit === true ? true : undefined,
},
idempotencyKey: crypto.randomUUID(),
}) satisfies Record<string, unknown>;
@@ -210,8 +224,6 @@ export async function executeNodeHostCommand(
const {
approvalId,
approvalSlug,
contextKey,
noticeSeconds,
warningText,
expiresAtMs: defaultExpiresAtMs,
preResolvedDecision: defaultPreResolvedDecision,
@@ -243,16 +255,37 @@ export async function executeNodeHostCommand(
});
expiresAtMs = registration.expiresAtMs;
preResolvedDecision = registration.finalDecision;
const initiatingSurface = resolveExecApprovalInitiatingSurfaceState({
channel: params.turnSourceChannel,
accountId: params.turnSourceAccountId,
});
const cfg = loadConfig();
const sentApproverDms =
(initiatingSurface.kind === "disabled" || initiatingSurface.kind === "unsupported") &&
hasConfiguredExecApprovalDmRoute(cfg);
const unavailableReason =
preResolvedDecision === null
? "no-approval-route"
: initiatingSurface.kind === "disabled"
? "initiating-platform-disabled"
: initiatingSurface.kind === "unsupported"
? "initiating-platform-unsupported"
: null;
void (async () => {
const decision = await resolveApprovalDecisionOrUndefined({
approvalId,
preResolvedDecision,
onFailure: () =>
emitExecSystemEvent(
`Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
{ sessionKey: params.notifySessionKey, contextKey },
),
void sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
}),
});
if (decision === undefined) {
return;
@@ -278,44 +311,67 @@ export async function executeNodeHostCommand(
}
if (deniedReason) {
emitExecSystemEvent(
`Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
);
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`,
}).catch(() => {});
return;
}
let runningTimer: NodeJS.Timeout | null = null;
if (params.approvalRunningNoticeMs > 0) {
runningTimer = setTimeout(() => {
emitExecSystemEvent(
`Exec running (node=${nodeId} id=${approvalId}, >${noticeSeconds}s): ${params.command}`,
{ sessionKey: params.notifySessionKey, contextKey },
);
}, params.approvalRunningNoticeMs);
}
try {
await callGatewayTool(
const raw = await callGatewayTool<{
payload?: {
stdout?: string;
stderr?: string;
error?: string | null;
exitCode?: number | null;
timedOut?: boolean;
};
}>(
"node.invoke",
{ timeoutMs: invokeTimeoutMs },
buildInvokeParams(approvedByAsk, approvalDecision, approvalId),
buildInvokeParams(approvedByAsk, approvalDecision, approvalId, true),
);
const payload =
raw?.payload && typeof raw.payload === "object"
? (raw.payload as {
stdout?: string;
stderr?: string;
error?: string | null;
exitCode?: number | null;
timedOut?: boolean;
})
: {};
const combined = [payload.stdout, payload.stderr, payload.error].filter(Boolean).join("\n");
const output = normalizeNotifyOutput(combined.slice(-DEFAULT_NOTIFY_TAIL_CHARS));
const exitLabel = payload.timedOut ? "timeout" : `code ${payload.exitCode ?? "?"}`;
const summary = output
? `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})\n${output}`
: `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})`;
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: summary,
}).catch(() => {});
} catch {
emitExecSystemEvent(
`Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
);
} finally {
if (runningTimer) {
clearTimeout(runningTimer);
}
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`,
}).catch(() => {});
}
})();
@@ -324,20 +380,48 @@ export async function executeNodeHostCommand(
{
type: "text",
text:
`${warningText}Approval required (id ${approvalSlug}). ` +
"Approve to run; updates will arrive after completion.",
unavailableReason !== null
? (buildExecApprovalUnavailableReplyPayload({
warningText,
reason: unavailableReason,
channelLabel: initiatingSurface.channelLabel,
sentApproverDms,
}).text ?? "")
: buildApprovalPendingMessage({
warningText,
approvalSlug,
approvalId,
command: prepared.cmdText,
cwd: runCwd,
host: "node",
nodeId,
}),
},
],
details: {
status: "approval-pending",
approvalId,
approvalSlug,
expiresAtMs,
host: "node",
command: params.command,
cwd: params.workdir,
nodeId,
},
details:
unavailableReason !== null
? ({
status: "approval-unavailable",
reason: unavailableReason,
channelLabel: initiatingSurface.channelLabel,
sentApproverDms,
host: "node",
command: params.command,
cwd: params.workdir,
nodeId,
warningText,
} satisfies ExecToolDetails)
: ({
status: "approval-pending",
approvalId,
approvalSlug,
expiresAtMs,
host: "node",
command: params.command,
cwd: params.workdir,
nodeId,
warningText,
} satisfies ExecToolDetails),
};
}

View File

@@ -230,6 +230,40 @@ export function createApprovalSlug(id: string) {
return id.slice(0, APPROVAL_SLUG_LENGTH);
}
export function buildApprovalPendingMessage(params: {
warningText?: string;
approvalSlug: string;
approvalId: string;
command: string;
cwd: string;
host: "gateway" | "node";
nodeId?: string;
}) {
let fence = "```";
while (params.command.includes(fence)) {
fence += "`";
}
const commandBlock = `${fence}sh\n${params.command}\n${fence}`;
const lines: string[] = [];
const warningText = params.warningText?.trim();
if (warningText) {
lines.push(warningText, "");
}
lines.push(`Approval required (id ${params.approvalSlug}, full ${params.approvalId}).`);
lines.push(`Host: ${params.host}`);
if (params.nodeId) {
lines.push(`Node: ${params.nodeId}`);
}
lines.push(`CWD: ${params.cwd}`);
lines.push("Command:");
lines.push(commandBlock);
lines.push("Mode: foreground (interactive approvals available).");
lines.push("Background mode requires pre-approved policy (allow-always or ask=off).");
lines.push(`Reply with: /approve ${params.approvalSlug} allow-once|allow-always|deny`);
lines.push("If the short code is ambiguous, use the full id in /approve.");
return lines.join("\n");
}
export function resolveApprovalRunningNoticeMs(value?: number) {
if (typeof value !== "number" || !Number.isFinite(value)) {
return DEFAULT_APPROVAL_RUNNING_NOTICE_MS;

View File

@@ -60,4 +60,19 @@ export type ExecToolDetails =
command: string;
cwd?: string;
nodeId?: string;
warningText?: string;
}
| {
status: "approval-unavailable";
reason:
| "initiating-platform-disabled"
| "initiating-platform-unsupported"
| "no-approval-route";
channelLabel?: string;
sentApproverDms?: boolean;
host: ExecHost;
command: string;
cwd?: string;
nodeId?: string;
warningText?: string;
};

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { clearConfigCache } from "../config/config.js";
import { buildSystemRunPreparePayload } from "../test-utils/system-run-prepare-payload.js";
vi.mock("./tools/gateway.js", () => ({
@@ -63,6 +64,7 @@ describe("exec approvals", () => {
afterEach(() => {
vi.resetAllMocks();
clearConfigCache();
if (previousHome === undefined) {
delete process.env.HOME;
} else {
@@ -77,6 +79,7 @@ describe("exec approvals", () => {
it("reuses approval id as the node runId", async () => {
let invokeParams: unknown;
let agentParams: unknown;
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
if (method === "exec.approval.request") {
@@ -85,6 +88,10 @@ describe("exec approvals", () => {
if (method === "exec.approval.waitDecision") {
return { decision: "allow-once" };
}
if (method === "agent") {
agentParams = params;
return { status: "ok" };
}
if (method === "node.invoke") {
const invoke = params as { command?: string };
if (invoke.command === "system.run.prepare") {
@@ -102,11 +109,24 @@ describe("exec approvals", () => {
host: "node",
ask: "always",
approvalRunningNoticeMs: 0,
sessionKey: "agent:main:main",
});
const result = await tool.execute("call1", { command: "ls -la" });
expect(result.details.status).toBe("approval-pending");
const approvalId = (result.details as { approvalId: string }).approvalId;
const details = result.details as { approvalId: string; approvalSlug: string };
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
expect(pendingText).toContain(
`Reply with: /approve ${details.approvalSlug} allow-once|allow-always|deny`,
);
expect(pendingText).toContain(`full ${details.approvalId}`);
expect(pendingText).toContain("Host: node");
expect(pendingText).toContain("Node: node-1");
expect(pendingText).toContain(`CWD: ${process.cwd()}`);
expect(pendingText).toContain("Command:\n```sh\nls -la\n```");
expect(pendingText).toContain("Mode: foreground (interactive approvals available).");
expect(pendingText).toContain("Background mode requires pre-approved policy");
const approvalId = details.approvalId;
await expect
.poll(() => (invokeParams as { params?: { runId?: string } } | undefined)?.params?.runId, {
@@ -114,6 +134,12 @@ describe("exec approvals", () => {
interval: 20,
})
.toBe(approvalId);
expect(
(invokeParams as { params?: { suppressNotifyOnExit?: boolean } } | undefined)?.params,
).toMatchObject({
suppressNotifyOnExit: true,
});
await expect.poll(() => agentParams, { timeout: 2_000, interval: 20 }).toBeTruthy();
});
it("skips approval when node allowlist is satisfied", async () => {
@@ -287,11 +313,181 @@ describe("exec approvals", () => {
const result = await tool.execute("call4", { command: "echo ok", elevated: true });
expect(result.details.status).toBe("approval-pending");
const details = result.details as { approvalId: string; approvalSlug: string };
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
expect(pendingText).toContain(
`Reply with: /approve ${details.approvalSlug} allow-once|allow-always|deny`,
);
expect(pendingText).toContain(`full ${details.approvalId}`);
expect(pendingText).toContain("Host: gateway");
expect(pendingText).toContain(`CWD: ${process.cwd()}`);
expect(pendingText).toContain("Command:\n```sh\necho ok\n```");
await approvalSeen;
expect(calls).toContain("exec.approval.request");
expect(calls).toContain("exec.approval.waitDecision");
});
it("starts a direct agent follow-up after approved gateway exec completes", async () => {
const agentCalls: Array<Record<string, unknown>> = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
if (method === "exec.approval.request") {
return { status: "accepted", id: (params as { id?: string })?.id };
}
if (method === "exec.approval.waitDecision") {
return { decision: "allow-once" };
}
if (method === "agent") {
agentCalls.push(params as Record<string, unknown>);
return { status: "ok" };
}
return { ok: true };
});
const tool = createExecTool({
host: "gateway",
ask: "always",
approvalRunningNoticeMs: 0,
sessionKey: "agent:main:main",
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
});
const result = await tool.execute("call-gw-followup", {
command: "echo ok",
workdir: process.cwd(),
gatewayUrl: undefined,
gatewayToken: undefined,
});
expect(result.details.status).toBe("approval-pending");
await expect.poll(() => agentCalls.length, { timeout: 3_000, interval: 20 }).toBe(1);
expect(agentCalls[0]).toEqual(
expect.objectContaining({
sessionKey: "agent:main:main",
deliver: true,
idempotencyKey: expect.stringContaining("exec-approval-followup:"),
}),
);
expect(typeof agentCalls[0]?.message).toBe("string");
expect(agentCalls[0]?.message).toContain(
"An async command the user already approved has completed.",
);
});
it("requires a separate approval for each elevated command after allow-once", async () => {
const requestCommands: string[] = [];
const requestIds: string[] = [];
const waitIds: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
if (method === "exec.approval.request") {
const request = params as { id?: string; command?: string };
if (typeof request.command === "string") {
requestCommands.push(request.command);
}
if (typeof request.id === "string") {
requestIds.push(request.id);
}
return { status: "accepted", id: request.id };
}
if (method === "exec.approval.waitDecision") {
const wait = params as { id?: string };
if (typeof wait.id === "string") {
waitIds.push(wait.id);
}
return { decision: "allow-once" };
}
return { ok: true };
});
const tool = createExecTool({
ask: "on-miss",
security: "allowlist",
approvalRunningNoticeMs: 0,
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
});
const first = await tool.execute("call-seq-1", {
command: "npm view diver --json",
elevated: true,
});
const second = await tool.execute("call-seq-2", {
command: "brew outdated",
elevated: true,
});
expect(first.details.status).toBe("approval-pending");
expect(second.details.status).toBe("approval-pending");
expect(requestCommands).toEqual(["npm view diver --json", "brew outdated"]);
expect(requestIds).toHaveLength(2);
expect(requestIds[0]).not.toBe(requestIds[1]);
expect(waitIds).toEqual(requestIds);
});
it("shows full chained gateway commands in approval-pending message", async () => {
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
calls.push(method);
if (method === "exec.approval.request") {
return { status: "accepted", id: (params as { id?: string })?.id };
}
if (method === "exec.approval.waitDecision") {
return { decision: "deny" };
}
return { ok: true };
});
const tool = createExecTool({
host: "gateway",
ask: "on-miss",
security: "allowlist",
approvalRunningNoticeMs: 0,
});
const result = await tool.execute("call-chain-gateway", {
command: "npm view diver --json | jq .name && brew outdated",
});
expect(result.details.status).toBe("approval-pending");
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
expect(pendingText).toContain(
"Command:\n```sh\nnpm view diver --json | jq .name && brew outdated\n```",
);
expect(calls).toContain("exec.approval.request");
});
it("shows full chained node commands in approval-pending message", async () => {
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
calls.push(method);
if (method === "node.invoke") {
const invoke = params as { command?: string };
if (invoke.command === "system.run.prepare") {
return buildPreparedSystemRunPayload(params);
}
}
return { ok: true };
});
const tool = createExecTool({
host: "node",
ask: "always",
security: "full",
approvalRunningNoticeMs: 0,
});
const result = await tool.execute("call-chain-node", {
command: "npm view diver --json | jq .name && brew outdated",
});
expect(result.details.status).toBe("approval-pending");
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
expect(pendingText).toContain(
"Command:\n```sh\nnpm view diver --json | jq .name && brew outdated\n```",
);
expect(calls).toContain("exec.approval.request");
});
it("waits for approval registration before returning approval-pending", async () => {
const calls: string[] = [];
let resolveRegistration: ((value: unknown) => void) | undefined;
@@ -354,6 +550,111 @@ describe("exec approvals", () => {
);
});
it("returns an unavailable approval message instead of a local /approve prompt when discord exec approvals are disabled", async () => {
const configPath = path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({
channels: {
discord: {
enabled: true,
execApprovals: { enabled: false },
},
},
}),
);
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
if (method === "exec.approval.request") {
return { status: "accepted", id: "approval-id" };
}
if (method === "exec.approval.waitDecision") {
return { decision: null };
}
return { ok: true };
});
const tool = createExecTool({
host: "gateway",
ask: "always",
approvalRunningNoticeMs: 0,
messageProvider: "discord",
accountId: "default",
currentChannelId: "1234567890",
});
const result = await tool.execute("call-unavailable", {
command: "npm view diver name version description",
});
expect(result.details.status).toBe("approval-unavailable");
const text = result.content.find((part) => part.type === "text")?.text ?? "";
expect(text).toContain("chat exec approvals are not enabled on Discord");
expect(text).toContain("Web UI or terminal UI");
expect(text).not.toContain("/approve");
expect(text).not.toContain("npm view diver name version description");
expect(text).not.toContain("Pending command:");
expect(text).not.toContain("Host:");
expect(text).not.toContain("CWD:");
});
it("tells Telegram users that allowed approvers were DMed when Telegram approvals are disabled but Discord DM approvals are enabled", async () => {
const configPath = path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
channels: {
telegram: {
enabled: true,
execApprovals: { enabled: false },
},
discord: {
enabled: true,
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
},
},
},
null,
2,
),
);
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
if (method === "exec.approval.request") {
return { status: "accepted", id: "approval-id" };
}
if (method === "exec.approval.waitDecision") {
return { decision: null };
}
return { ok: true };
});
const tool = createExecTool({
host: "gateway",
ask: "always",
approvalRunningNoticeMs: 0,
messageProvider: "telegram",
accountId: "default",
currentChannelId: "-1003841603622",
});
const result = await tool.execute("call-tg-unavailable", {
command: "npm view diver name version description",
});
expect(result.details.status).toBe("approval-unavailable");
const text = result.content.find((part) => part.type === "text")?.text ?? "";
expect(text).toContain("Approval required. I sent the allowed approvers DMs.");
expect(text).not.toContain("/approve");
expect(text).not.toContain("npm view diver name version description");
expect(text).not.toContain("Pending command:");
expect(text).not.toContain("Host:");
expect(text).not.toContain("CWD:");
});
it("denies node obfuscated command when approval request times out", async () => {
vi.mocked(detectCommandObfuscation).mockReturnValue({
detected: true,

View File

@@ -67,6 +67,7 @@ describe("failover-error", () => {
expect(resolveFailoverReasonFromError({ statusCode: "429" })).toBe("rate_limit");
expect(resolveFailoverReasonFromError({ status: 403 })).toBe("auth");
expect(resolveFailoverReasonFromError({ status: 408 })).toBe("timeout");
expect(resolveFailoverReasonFromError({ status: 499 })).toBe("timeout");
expect(resolveFailoverReasonFromError({ status: 400 })).toBe("format");
// Keep the status-only path behavior-preserving and conservative.
expect(resolveFailoverReasonFromError({ status: 500 })).toBeNull();
@@ -93,6 +94,12 @@ describe("failover-error", () => {
message: ANTHROPIC_OVERLOADED_PAYLOAD,
}),
).toBe("overloaded");
expect(
resolveFailoverReasonFromError({
status: 499,
message: ANTHROPIC_OVERLOADED_PAYLOAD,
}),
).toBe("overloaded");
expect(
resolveFailoverReasonFromError({
status: 429,

View File

@@ -0,0 +1,93 @@
import { createSubsystemLogger } from "../logging/subsystem.js";
import { sanitizeForLog } from "../terminal/ansi.js";
import type { FallbackAttempt, ModelCandidate } from "./model-fallback.types.js";
import { buildTextObservationFields } from "./pi-embedded-error-observation.js";
import type { FailoverReason } from "./pi-embedded-helpers.js";
const decisionLog = createSubsystemLogger("model-fallback").child("decision");
function buildErrorObservationFields(error?: string): {
errorPreview?: string;
errorHash?: string;
errorFingerprint?: string;
httpCode?: string;
providerErrorType?: string;
providerErrorMessagePreview?: string;
requestIdHash?: string;
} {
const observed = buildTextObservationFields(error);
return {
errorPreview: observed.textPreview,
errorHash: observed.textHash,
errorFingerprint: observed.textFingerprint,
httpCode: observed.httpCode,
providerErrorType: observed.providerErrorType,
providerErrorMessagePreview: observed.providerErrorMessagePreview,
requestIdHash: observed.requestIdHash,
};
}
export function logModelFallbackDecision(params: {
decision:
| "skip_candidate"
| "probe_cooldown_candidate"
| "candidate_failed"
| "candidate_succeeded";
runId?: string;
requestedProvider: string;
requestedModel: string;
candidate: ModelCandidate;
attempt?: number;
total?: number;
reason?: FailoverReason | null;
status?: number;
code?: string;
error?: string;
nextCandidate?: ModelCandidate;
isPrimary?: boolean;
requestedModelMatched?: boolean;
fallbackConfigured?: boolean;
allowTransientCooldownProbe?: boolean;
profileCount?: number;
previousAttempts?: FallbackAttempt[];
}): void {
const nextText = params.nextCandidate
? `${sanitizeForLog(params.nextCandidate.provider)}/${sanitizeForLog(params.nextCandidate.model)}`
: "none";
const reasonText = params.reason ?? "unknown";
const observedError = buildErrorObservationFields(params.error);
decisionLog.warn("model fallback decision", {
event: "model_fallback_decision",
tags: ["error_handling", "model_fallback", params.decision],
runId: params.runId,
decision: params.decision,
requestedProvider: params.requestedProvider,
requestedModel: params.requestedModel,
candidateProvider: params.candidate.provider,
candidateModel: params.candidate.model,
attempt: params.attempt,
total: params.total,
reason: params.reason,
status: params.status,
code: params.code,
...observedError,
nextCandidateProvider: params.nextCandidate?.provider,
nextCandidateModel: params.nextCandidate?.model,
isPrimary: params.isPrimary,
requestedModelMatched: params.requestedModelMatched,
fallbackConfigured: params.fallbackConfigured,
allowTransientCooldownProbe: params.allowTransientCooldownProbe,
profileCount: params.profileCount,
previousAttempts: params.previousAttempts?.map((attempt) => ({
provider: attempt.provider,
model: attempt.model,
reason: attempt.reason,
status: attempt.status,
code: attempt.code,
...buildErrorObservationFields(attempt.error),
})),
consoleMessage:
`model fallback decision: decision=${params.decision} requested=${sanitizeForLog(params.requestedProvider)}/${sanitizeForLog(params.requestedModel)} ` +
`candidate=${sanitizeForLog(params.candidate.provider)}/${sanitizeForLog(params.candidate.model)} reason=${reasonText} next=${nextText}`,
});
}

View File

@@ -1,5 +1,8 @@
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { registerLogTransport, resetLogger, setLoggerOverride } from "../logging/logger.js";
import type { AuthProfileStore } from "./auth-profiles.js";
import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js";
@@ -28,6 +31,7 @@ const mockedResolveProfilesUnavailableReason = vi.mocked(resolveProfilesUnavaila
const mockedResolveAuthProfileOrder = vi.mocked(resolveAuthProfileOrder);
const makeCfg = makeModelFallbackCfg;
let unregisterLogTransport: (() => void) | undefined;
function expectFallbackUsed(
result: { result: unknown; attempts: Array<{ reason?: string }> },
@@ -149,6 +153,10 @@ describe("runWithModelFallback probe logic", () => {
afterEach(() => {
Date.now = realDateNow;
unregisterLogTransport?.();
unregisterLogTransport = undefined;
setLoggerOverride(null);
resetLogger();
vi.restoreAllMocks();
});
@@ -194,6 +202,99 @@ describe("runWithModelFallback probe logic", () => {
expectPrimaryProbeSuccess(result, run, "probed-ok");
});
it("logs primary metadata on probe success and failure fallback decisions", async () => {
const cfg = makeCfg();
const records: Array<Record<string, unknown>> = [];
mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 60 * 1000);
setLoggerOverride({
level: "trace",
consoleLevel: "silent",
file: path.join(os.tmpdir(), `openclaw-model-fallback-probe-${Date.now()}.log`),
});
unregisterLogTransport = registerLogTransport((record) => {
records.push(record);
});
const run = vi.fn().mockResolvedValue("probed-ok");
const result = await runPrimaryCandidate(cfg, run);
expectPrimaryProbeSuccess(result, run, "probed-ok");
_probeThrottleInternals.lastProbeAttempt.clear();
const fallbackCfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: ["anthropic/claude-haiku-3-5", "google/gemini-2-flash"],
},
},
},
} as Partial<OpenClawConfig>);
mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 60 * 1000);
const fallbackRun = vi
.fn()
.mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 }))
.mockResolvedValueOnce("fallback-ok");
const fallbackResult = await runPrimaryCandidate(fallbackCfg, fallbackRun);
expect(fallbackResult.result).toBe("fallback-ok");
expect(fallbackRun).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini", {
allowTransientCooldownProbe: true,
});
expect(fallbackRun).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5");
const decisionPayloads = records
.filter(
(record) =>
record["2"] === "model fallback decision" &&
record["1"] &&
typeof record["1"] === "object",
)
.map((record) => record["1"] as Record<string, unknown>);
expect(decisionPayloads).toEqual(
expect.arrayContaining([
expect.objectContaining({
event: "model_fallback_decision",
decision: "probe_cooldown_candidate",
candidateProvider: "openai",
candidateModel: "gpt-4.1-mini",
allowTransientCooldownProbe: true,
}),
expect.objectContaining({
event: "model_fallback_decision",
decision: "candidate_succeeded",
candidateProvider: "openai",
candidateModel: "gpt-4.1-mini",
isPrimary: true,
requestedModelMatched: true,
}),
expect.objectContaining({
event: "model_fallback_decision",
decision: "candidate_failed",
candidateProvider: "openai",
candidateModel: "gpt-4.1-mini",
isPrimary: true,
requestedModelMatched: true,
nextCandidateProvider: "anthropic",
nextCandidateModel: "claude-haiku-3-5",
}),
expect.objectContaining({
event: "model_fallback_decision",
decision: "candidate_succeeded",
candidateProvider: "anthropic",
candidateModel: "claude-haiku-3-5",
isPrimary: false,
requestedModelMatched: false,
}),
]),
);
});
it("probes primary model when cooldown already expired", async () => {
const cfg = makeCfg();
// Cooldown expired 5 min ago
@@ -251,6 +352,36 @@ describe("runWithModelFallback probe logic", () => {
expectPrimaryProbeSuccess(result, run, "probed-ok");
});
it("prunes stale probe throttle entries before checking eligibility", () => {
_probeThrottleInternals.lastProbeAttempt.set(
"stale",
NOW - _probeThrottleInternals.PROBE_STATE_TTL_MS - 1,
);
_probeThrottleInternals.lastProbeAttempt.set("fresh", NOW - 5_000);
expect(_probeThrottleInternals.lastProbeAttempt.has("stale")).toBe(true);
expect(_probeThrottleInternals.isProbeThrottleOpen(NOW, "fresh")).toBe(false);
expect(_probeThrottleInternals.lastProbeAttempt.has("stale")).toBe(false);
expect(_probeThrottleInternals.lastProbeAttempt.has("fresh")).toBe(true);
});
it("caps probe throttle state by evicting the oldest entries", () => {
for (let i = 0; i < _probeThrottleInternals.MAX_PROBE_KEYS; i += 1) {
_probeThrottleInternals.lastProbeAttempt.set(`key-${i}`, NOW - (i + 1));
}
_probeThrottleInternals.markProbeAttempt(NOW, "freshest");
expect(_probeThrottleInternals.lastProbeAttempt.size).toBe(
_probeThrottleInternals.MAX_PROBE_KEYS,
);
expect(_probeThrottleInternals.lastProbeAttempt.has("freshest")).toBe(true);
expect(_probeThrottleInternals.lastProbeAttempt.has("key-255")).toBe(false);
expect(_probeThrottleInternals.lastProbeAttempt.has("key-0")).toBe(true);
});
it("handles non-finite soonest safely (treats as probe-worthy)", async () => {
const cfg = makeCfg();
@@ -346,7 +477,7 @@ describe("runWithModelFallback probe logic", () => {
});
});
it("skips billing-cooldowned primary when no fallback candidates exist", async () => {
it("probes billing-cooldowned primary when no fallback candidates exist", async () => {
const cfg = makeCfg({
agents: {
defaults: {
@@ -358,20 +489,28 @@ describe("runWithModelFallback probe logic", () => {
},
} as Partial<OpenClawConfig>);
// Billing cooldown far from expiry — would normally be skipped
// Single-provider setups need periodic probes even when the billing
// cooldown is far from expiry, otherwise topping up credits never recovers
// without a restart.
const expiresIn30Min = NOW + 30 * 60 * 1000;
mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn30Min);
mockedResolveProfilesUnavailableReason.mockReturnValue("billing");
await expect(
runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
fallbacksOverride: [],
run: vi.fn().mockResolvedValue("billing-recovered"),
}),
).rejects.toThrow("All models failed");
const run = vi.fn().mockResolvedValue("billing-recovered");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
fallbacksOverride: [],
run,
});
expect(result.result).toBe("billing-recovered");
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini", {
allowTransientCooldownProbe: true,
});
});
it("probes billing-cooldowned primary with fallbacks when near cooldown expiry", async () => {

View File

@@ -207,6 +207,7 @@ async function runEmbeddedFallback(params: {
cfg,
provider: "openai",
model: "mock-1",
runId: params.runId,
agentDir: params.agentDir,
run: (provider, model, options) =>
runEmbeddedPiAgent({

View File

@@ -536,7 +536,9 @@ describe("runWithModelFallback", () => {
});
expect(result.result).toBe("ok");
const warning = warnSpy.mock.calls[0]?.[0] as string;
const warning = warnSpy.mock.calls
.map((call) => call[0] as string)
.find((value) => value.includes('Model "openai/gpt-6spoof" not found'));
expect(warning).toContain('Model "openai/gpt-6spoof" not found');
expect(warning).not.toContain("\u001B");
expect(warning).not.toContain("\n");

View File

@@ -19,6 +19,8 @@ import {
isFailoverError,
isTimeoutError,
} from "./failover-error.js";
import { logModelFallbackDecision } from "./model-fallback-observation.js";
import type { FallbackAttempt, ModelCandidate } from "./model-fallback.types.js";
import {
buildConfiguredAllowlistKeys,
buildModelAliasIndex,
@@ -32,11 +34,6 @@ import { isLikelyContextOverflowError } from "./pi-embedded-helpers.js";
const log = createSubsystemLogger("model-fallback");
type ModelCandidate = {
provider: string;
model: string;
};
export type ModelFallbackRunOptions = {
allowTransientCooldownProbe?: boolean;
};
@@ -47,15 +44,6 @@ type ModelFallbackRunFn<T> = (
options?: ModelFallbackRunOptions,
) => Promise<T>;
type FallbackAttempt = {
provider: string;
model: string;
error: string;
reason?: FailoverReason;
status?: number;
code?: string;
};
/**
* Fallback abort check. Only treats explicit AbortError names as user aborts.
* Message-based checks (e.g., "aborted") can mask timeouts and skip fallback.
@@ -342,12 +330,51 @@ const lastProbeAttempt = new Map<string, number>();
const MIN_PROBE_INTERVAL_MS = 30_000; // 30 seconds between probes per key
const PROBE_MARGIN_MS = 2 * 60 * 1000;
const PROBE_SCOPE_DELIMITER = "::";
const PROBE_STATE_TTL_MS = 24 * 60 * 60 * 1000;
const MAX_PROBE_KEYS = 256;
function resolveProbeThrottleKey(provider: string, agentDir?: string): string {
const scope = String(agentDir ?? "").trim();
return scope ? `${scope}${PROBE_SCOPE_DELIMITER}${provider}` : provider;
}
function pruneProbeState(now: number): void {
for (const [key, ts] of lastProbeAttempt) {
if (!Number.isFinite(ts) || ts <= 0 || now - ts > PROBE_STATE_TTL_MS) {
lastProbeAttempt.delete(key);
}
}
}
function enforceProbeStateCap(): void {
while (lastProbeAttempt.size > MAX_PROBE_KEYS) {
let oldestKey: string | null = null;
let oldestTs = Number.POSITIVE_INFINITY;
for (const [key, ts] of lastProbeAttempt) {
if (ts < oldestTs) {
oldestKey = key;
oldestTs = ts;
}
}
if (!oldestKey) {
break;
}
lastProbeAttempt.delete(oldestKey);
}
}
function isProbeThrottleOpen(now: number, throttleKey: string): boolean {
pruneProbeState(now);
const lastProbe = lastProbeAttempt.get(throttleKey) ?? 0;
return now - lastProbe >= MIN_PROBE_INTERVAL_MS;
}
function markProbeAttempt(now: number, throttleKey: string): void {
pruneProbeState(now);
lastProbeAttempt.set(throttleKey, now);
enforceProbeStateCap();
}
function shouldProbePrimaryDuringCooldown(params: {
isPrimary: boolean;
hasFallbackCandidates: boolean;
@@ -360,8 +387,7 @@ function shouldProbePrimaryDuringCooldown(params: {
return false;
}
const lastProbe = lastProbeAttempt.get(params.throttleKey) ?? 0;
if (params.now - lastProbe < MIN_PROBE_INTERVAL_MS) {
if (!isProbeThrottleOpen(params.now, params.throttleKey)) {
return false;
}
@@ -379,7 +405,12 @@ export const _probeThrottleInternals = {
lastProbeAttempt,
MIN_PROBE_INTERVAL_MS,
PROBE_MARGIN_MS,
PROBE_STATE_TTL_MS,
MAX_PROBE_KEYS,
resolveProbeThrottleKey,
isProbeThrottleOpen,
pruneProbeState,
markProbeAttempt,
} as const;
type CooldownDecision =
@@ -429,11 +460,15 @@ function resolveCooldownDecision(params: {
}
// Billing is semi-persistent: the user may fix their balance, or a transient
// 402 might have been misclassified. Probe the primary only when fallbacks
// exist; otherwise repeated single-provider probes just churn the disabled
// auth state without opening any recovery path.
// 402 might have been misclassified. Probe single-provider setups on the
// standard throttle so they can recover without a restart; when fallbacks
// exist, only probe near cooldown expiry so the fallback chain stays preferred.
if (inferredReason === "billing") {
if (params.isPrimary && params.hasFallbackCandidates && shouldProbe) {
const shouldProbeSingleProviderBilling =
params.isPrimary &&
!params.hasFallbackCandidates &&
isProbeThrottleOpen(params.now, params.probeThrottleKey);
if (params.isPrimary && (shouldProbe || shouldProbeSingleProviderBilling)) {
return { type: "attempt", reason: inferredReason, markProbe: true };
}
return {
@@ -468,6 +503,7 @@ export async function runWithModelFallback<T>(params: {
cfg: OpenClawConfig | undefined;
provider: string;
model: string;
runId?: string;
agentDir?: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
@@ -490,7 +526,11 @@ export async function runWithModelFallback<T>(params: {
for (let i = 0; i < candidates.length; i += 1) {
const candidate = candidates[i];
const isPrimary = i === 0;
const requestedModel =
params.provider === candidate.provider && params.model === candidate.model;
let runOptions: ModelFallbackRunOptions | undefined;
let attemptedDuringCooldown = false;
if (authStore) {
const profileIds = resolveAuthProfileOrder({
cfg: params.cfg,
@@ -501,9 +541,6 @@ export async function runWithModelFallback<T>(params: {
if (profileIds.length > 0 && !isAnyProfileAvailable) {
// All profiles for this provider are in cooldown.
const isPrimary = i === 0;
const requestedModel =
params.provider === candidate.provider && params.model === candidate.model;
const now = Date.now();
const probeThrottleKey = resolveProbeThrottleKey(candidate.provider, params.agentDir);
const decision = resolveCooldownDecision({
@@ -524,11 +561,27 @@ export async function runWithModelFallback<T>(params: {
error: decision.error,
reason: decision.reason,
});
logModelFallbackDecision({
decision: "skip_candidate",
runId: params.runId,
requestedProvider: params.provider,
requestedModel: params.model,
candidate,
attempt: i + 1,
total: candidates.length,
reason: decision.reason,
error: decision.error,
nextCandidate: candidates[i + 1],
isPrimary,
requestedModelMatched: requestedModel,
fallbackConfigured: hasFallbackCandidates,
profileCount: profileIds.length,
});
continue;
}
if (decision.markProbe) {
lastProbeAttempt.set(probeThrottleKey, now);
markProbeAttempt(now, probeThrottleKey);
}
if (
decision.reason === "rate_limit" ||
@@ -537,6 +590,23 @@ export async function runWithModelFallback<T>(params: {
) {
runOptions = { allowTransientCooldownProbe: true };
}
attemptedDuringCooldown = true;
logModelFallbackDecision({
decision: "probe_cooldown_candidate",
runId: params.runId,
requestedProvider: params.provider,
requestedModel: params.model,
candidate,
attempt: i + 1,
total: candidates.length,
reason: decision.reason,
nextCandidate: candidates[i + 1],
isPrimary,
requestedModelMatched: requestedModel,
fallbackConfigured: hasFallbackCandidates,
allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe,
profileCount: profileIds.length,
});
}
}
@@ -547,6 +617,21 @@ export async function runWithModelFallback<T>(params: {
options: runOptions,
});
if ("success" in attemptRun) {
if (i > 0 || attempts.length > 0 || attemptedDuringCooldown) {
logModelFallbackDecision({
decision: "candidate_succeeded",
runId: params.runId,
requestedProvider: params.provider,
requestedModel: params.model,
candidate,
attempt: i + 1,
total: candidates.length,
previousAttempts: attempts,
isPrimary,
requestedModelMatched: requestedModel,
fallbackConfigured: hasFallbackCandidates,
});
}
const notFoundAttempt =
i > 0 ? attempts.find((a) => a.reason === "model_not_found") : undefined;
if (notFoundAttempt) {
@@ -590,6 +675,23 @@ export async function runWithModelFallback<T>(params: {
status: described.status,
code: described.code,
});
logModelFallbackDecision({
decision: "candidate_failed",
runId: params.runId,
requestedProvider: params.provider,
requestedModel: params.model,
candidate,
attempt: i + 1,
total: candidates.length,
reason: described.reason,
status: described.status,
code: described.code,
error: described.message,
nextCandidate: candidates[i + 1],
isPrimary,
requestedModelMatched: requestedModel,
fallbackConfigured: hasFallbackCandidates,
});
await params.onError?.({
provider: candidate.provider,
model: candidate.model,

View File

@@ -0,0 +1,15 @@
import type { FailoverReason } from "./pi-embedded-helpers.js";
export type ModelCandidate = {
provider: string;
model: string;
};
export type FallbackAttempt = {
provider: string;
model: string;
error: string;
reason?: FailoverReason;
status?: number;
code?: string;
};

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolvePluginTools } from "../plugins/tools.js";
import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime.js";
import type { GatewayMessageChannel } from "../utils/message-channel.js";
import { resolveSessionAgentId } from "./agent-scope.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
@@ -69,9 +70,20 @@ export function createOpenClawTools(
senderIsOwner?: boolean;
/** Ephemeral session UUID — regenerated on /new and /reset. */
sessionId?: string;
/**
* Workspace directory to pass to spawned subagents for inheritance.
* Defaults to workspaceDir. Use this to pass the actual agent workspace when the
* session itself is running in a copied-workspace sandbox (`ro` or `none`) so
* subagents inherit the real workspace path instead of the sandbox copy.
*/
spawnWorkspaceDir?: string;
} & SpawnedToolContext,
): AnyAgentTool[] {
const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir);
const spawnWorkspaceDir = resolveWorkspaceRoot(
options?.spawnWorkspaceDir ?? options?.workspaceDir,
);
const runtimeWebTools = getActiveRuntimeWebToolsMetadata();
const imageTool = options?.agentDir?.trim()
? createImageTool({
config: options?.config,
@@ -100,10 +112,12 @@ export function createOpenClawTools(
const webSearchTool = createWebSearchTool({
config: options?.config,
sandboxed: options?.sandboxed,
runtimeWebSearch: runtimeWebTools?.search,
});
const webFetchTool = createWebFetchTool({
config: options?.config,
sandboxed: options?.sandboxed,
runtimeFirecrawl: runtimeWebTools?.fetch.firecrawl,
});
const messageTool = options?.disableMessageTool
? null
@@ -178,7 +192,7 @@ export function createOpenClawTools(
agentGroupSpace: options?.agentGroupSpace,
sandboxed: options?.sandboxed,
requesterAgentIdOverride: options?.requesterAgentIdOverride,
workspaceDir,
workspaceDir: spawnWorkspaceDir,
}),
createSubagentsTool({
agentSessionKey: options?.agentSessionKey,

View File

@@ -0,0 +1,135 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
activateSecretsRuntimeSnapshot,
clearSecretsRuntimeSnapshot,
prepareSecretsRuntimeSnapshot,
} from "../secrets/runtime.js";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { createOpenClawTools } from "./openclaw-tools.js";
vi.mock("../plugins/tools.js", () => ({
resolvePluginTools: () => [],
}));
function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
}
function findTool(name: string, config: OpenClawConfig) {
const allTools = createOpenClawTools({ config, sandboxed: true });
const tool = allTools.find((candidate) => candidate.name === name);
expect(tool).toBeDefined();
if (!tool) {
throw new Error(`missing ${name} tool`);
}
return tool;
}
function makeHeaders(map: Record<string, string>): { get: (key: string) => string | null } {
return {
get: (key) => map[key.toLowerCase()] ?? null,
};
}
async function prepareAndActivate(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv }) {
const snapshot = await prepareSecretsRuntimeSnapshot({
config: params.config,
env: params.env,
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
activateSecretsRuntimeSnapshot(snapshot);
return snapshot;
}
describe("openclaw tools runtime web metadata wiring", () => {
const priorFetch = global.fetch;
afterEach(() => {
global.fetch = priorFetch;
clearSecretsRuntimeSnapshot();
});
it("uses runtime-selected provider when higher-precedence provider ref is unresolved", async () => {
const snapshot = await prepareAndActivate({
config: asConfig({
tools: {
web: {
search: {
apiKey: { source: "env", provider: "default", id: "MISSING_BRAVE_KEY_REF" },
gemini: {
apiKey: { source: "env", provider: "default", id: "GEMINI_WEB_KEY_REF" },
},
},
},
},
}),
env: {
GEMINI_WEB_KEY_REF: "gemini-runtime-key",
},
});
expect(snapshot.webTools.search.selectedProvider).toBe("gemini");
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
candidates: [
{
content: { parts: [{ text: "runtime gemini ok" }] },
groundingMetadata: { groundingChunks: [] },
},
],
}),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
const webSearch = findTool("web_search", snapshot.config);
const result = await webSearch.execute("call-runtime-search", { query: "runtime search" });
expect(mockFetch).toHaveBeenCalled();
expect(String(mockFetch.mock.calls[0]?.[0])).toContain("generativelanguage.googleapis.com");
expect((result.details as { provider?: string }).provider).toBe("gemini");
});
it("skips Firecrawl key resolution when runtime marks Firecrawl inactive", async () => {
const snapshot = await prepareAndActivate({
config: asConfig({
tools: {
web: {
fetch: {
firecrawl: {
enabled: false,
apiKey: { source: "env", provider: "default", id: "MISSING_FIRECRAWL_KEY_REF" },
},
},
},
},
}),
});
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve({
ok: true,
status: 200,
headers: makeHeaders({ "content-type": "text/html; charset=utf-8" }),
text: () =>
Promise.resolve(
"<html><body><article><h1>Runtime Off</h1><p>Use direct fetch.</p></article></body></html>",
),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
const webFetch = findTool("web_fetch", snapshot.config);
await webFetch.execute("call-runtime-fetch", { url: "https://example.com/runtime-off" });
expect(mockFetch).toHaveBeenCalled();
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://example.com/runtime-off");
expect(String(mockFetch.mock.calls[0]?.[0])).not.toContain("api.firecrawl.dev");
});
});

View File

@@ -0,0 +1,185 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import * as loggingConfigModule from "../logging/config.js";
import {
buildApiErrorObservationFields,
buildTextObservationFields,
sanitizeForConsole,
} from "./pi-embedded-error-observation.js";
const OBSERVATION_BEARER_TOKEN = "sk-redact-test-token";
const OBSERVATION_COOKIE_VALUE = "session-cookie-token";
afterEach(() => {
vi.restoreAllMocks();
});
describe("buildApiErrorObservationFields", () => {
it("redacts request ids and exposes stable hashes instead of raw payloads", () => {
const observed = buildApiErrorObservationFields(
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_overload"}',
);
expect(observed).toMatchObject({
rawErrorPreview: expect.stringContaining('"request_id":"sha256:'),
rawErrorHash: expect.stringMatching(/^sha256:/),
rawErrorFingerprint: expect.stringMatching(/^sha256:/),
providerErrorType: "overloaded_error",
providerErrorMessagePreview: "Overloaded",
requestIdHash: expect.stringMatching(/^sha256:/),
});
expect(observed.rawErrorPreview).not.toContain("req_overload");
});
it("forces token redaction for observation previews", () => {
const observed = buildApiErrorObservationFields(
`Authorization: Bearer ${OBSERVATION_BEARER_TOKEN}`,
);
expect(observed.rawErrorPreview).not.toContain(OBSERVATION_BEARER_TOKEN);
expect(observed.rawErrorPreview).toContain(OBSERVATION_BEARER_TOKEN.slice(0, 6));
expect(observed.rawErrorHash).toMatch(/^sha256:/);
});
it("redacts observation-only header and cookie formats", () => {
const observed = buildApiErrorObservationFields(
`x-api-key: ${OBSERVATION_BEARER_TOKEN} Cookie: session=${OBSERVATION_COOKIE_VALUE}`,
);
expect(observed.rawErrorPreview).not.toContain(OBSERVATION_COOKIE_VALUE);
expect(observed.rawErrorPreview).toContain("x-api-key: ***");
expect(observed.rawErrorPreview).toContain("Cookie: session=");
});
it("does not let cookie redaction consume unrelated fields on the same line", () => {
const observed = buildApiErrorObservationFields(
`Cookie: session=${OBSERVATION_COOKIE_VALUE} status=503 request_id=req_cookie`,
);
expect(observed.rawErrorPreview).toContain("Cookie: session=");
expect(observed.rawErrorPreview).toContain("status=503");
expect(observed.rawErrorPreview).toContain("request_id=sha256:");
});
it("builds sanitized generic text observation fields", () => {
const observed = buildTextObservationFields(
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_prev"}',
);
expect(observed).toMatchObject({
textPreview: expect.stringContaining('"request_id":"sha256:'),
textHash: expect.stringMatching(/^sha256:/),
textFingerprint: expect.stringMatching(/^sha256:/),
providerErrorType: "overloaded_error",
providerErrorMessagePreview: "Overloaded",
requestIdHash: expect.stringMatching(/^sha256:/),
});
expect(observed.textPreview).not.toContain("req_prev");
});
it("redacts request ids in formatted plain-text errors", () => {
const observed = buildApiErrorObservationFields(
"LLM error overloaded_error: Overloaded (request_id: req_plaintext_123)",
);
expect(observed).toMatchObject({
rawErrorPreview: expect.stringContaining("request_id: sha256:"),
rawErrorFingerprint: expect.stringMatching(/^sha256:/),
requestIdHash: expect.stringMatching(/^sha256:/),
});
expect(observed.rawErrorPreview).not.toContain("req_plaintext_123");
});
it("keeps fingerprints stable across request ids for equivalent errors", () => {
const first = buildApiErrorObservationFields(
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_001"}',
);
const second = buildApiErrorObservationFields(
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_002"}',
);
expect(first.rawErrorFingerprint).toBe(second.rawErrorFingerprint);
expect(first.rawErrorHash).not.toBe(second.rawErrorHash);
});
it("truncates oversized raw and provider previews", () => {
const longMessage = "X".repeat(260);
const observed = buildApiErrorObservationFields(
`{"type":"error","error":{"type":"server_error","message":"${longMessage}"},"request_id":"req_long"}`,
);
expect(observed.rawErrorPreview).toBeDefined();
expect(observed.providerErrorMessagePreview).toBeDefined();
expect(observed.rawErrorPreview?.length).toBeLessThanOrEqual(401);
expect(observed.providerErrorMessagePreview?.length).toBeLessThanOrEqual(201);
expect(observed.providerErrorMessagePreview?.endsWith("…")).toBe(true);
});
it("caps oversized raw inputs before hashing and fingerprinting", () => {
const oversized = "X".repeat(70_000);
const bounded = "X".repeat(64_000);
expect(buildApiErrorObservationFields(oversized)).toMatchObject({
rawErrorHash: buildApiErrorObservationFields(bounded).rawErrorHash,
rawErrorFingerprint: buildApiErrorObservationFields(bounded).rawErrorFingerprint,
});
});
it("returns empty observation fields for empty input", () => {
expect(buildApiErrorObservationFields(undefined)).toEqual({});
expect(buildApiErrorObservationFields("")).toEqual({});
expect(buildApiErrorObservationFields(" ")).toEqual({});
});
it("re-reads configured redact patterns on each call", () => {
const readLoggingConfig = vi.spyOn(loggingConfigModule, "readLoggingConfig");
readLoggingConfig.mockReturnValueOnce(undefined);
readLoggingConfig.mockReturnValueOnce({
redactPatterns: [String.raw`\bcustom-secret-[A-Za-z0-9]+\b`],
});
const first = buildApiErrorObservationFields("custom-secret-abc123");
const second = buildApiErrorObservationFields("custom-secret-abc123");
expect(first.rawErrorPreview).toContain("custom-secret-abc123");
expect(second.rawErrorPreview).not.toContain("custom-secret-abc123");
expect(second.rawErrorPreview).toContain("custom");
});
it("fails closed when observation sanitization throws", () => {
vi.spyOn(loggingConfigModule, "readLoggingConfig").mockImplementation(() => {
throw new Error("boom");
});
expect(buildApiErrorObservationFields("request_id=req_123")).toEqual({});
expect(buildTextObservationFields("request_id=req_123")).toEqual({
textPreview: undefined,
textHash: undefined,
textFingerprint: undefined,
httpCode: undefined,
providerErrorType: undefined,
providerErrorMessagePreview: undefined,
requestIdHash: undefined,
});
});
it("ignores non-string configured redact patterns", () => {
vi.spyOn(loggingConfigModule, "readLoggingConfig").mockReturnValue({
redactPatterns: [
123 as never,
{ bad: true } as never,
String.raw`\bcustom-secret-[A-Za-z0-9]+\b`,
],
});
const observed = buildApiErrorObservationFields("custom-secret-abc123");
expect(observed.rawErrorPreview).not.toContain("custom-secret-abc123");
expect(observed.rawErrorPreview).toContain("custom");
});
});
describe("sanitizeForConsole", () => {
it("strips control characters from console-facing values", () => {
expect(sanitizeForConsole("run-1\nprovider\tmodel\rtest")).toBe("run-1 provider model test");
});
});

View File

@@ -0,0 +1,199 @@
import { readLoggingConfig } from "../logging/config.js";
import { redactIdentifier } from "../logging/redact-identifier.js";
import { getDefaultRedactPatterns, redactSensitiveText } from "../logging/redact.js";
import { getApiErrorPayloadFingerprint, parseApiErrorInfo } from "./pi-embedded-helpers.js";
import { stableStringify } from "./stable-stringify.js";
const MAX_OBSERVATION_INPUT_CHARS = 64_000;
const MAX_FINGERPRINT_MESSAGE_CHARS = 8_000;
const RAW_ERROR_PREVIEW_MAX_CHARS = 400;
const PROVIDER_ERROR_PREVIEW_MAX_CHARS = 200;
const REQUEST_ID_RE = /\brequest[_ ]?id\b\s*[:=]\s*["'()]*([A-Za-z0-9._:-]+)/i;
const OBSERVATION_EXTRA_REDACT_PATTERNS = [
String.raw`\b(?:x-)?api[-_]?key\b\s*[:=]\s*(["']?)([^\s"'\\;]+)\1`,
String.raw`"(?:api[-_]?key|api_key)"\s*:\s*"([^"]+)"`,
String.raw`(?:\bCookie\b\s*[:=]\s*[^;=\s]+=|;\s*[^;=\s]+=)([^;\s\r\n]+)`,
];
function resolveConfiguredRedactPatterns(): string[] {
const configured = readLoggingConfig()?.redactPatterns;
if (!Array.isArray(configured)) {
return [];
}
return configured.filter((pattern): pattern is string => typeof pattern === "string");
}
function truncateForObservation(text: string | undefined, maxChars: number): string | undefined {
const trimmed = text?.trim();
if (!trimmed) {
return undefined;
}
return trimmed.length > maxChars ? `${trimmed.slice(0, maxChars)}` : trimmed;
}
function boundObservationInput(text: string | undefined): string | undefined {
const trimmed = text?.trim();
if (!trimmed) {
return undefined;
}
return trimmed.length > MAX_OBSERVATION_INPUT_CHARS
? trimmed.slice(0, MAX_OBSERVATION_INPUT_CHARS)
: trimmed;
}
export function sanitizeForConsole(text: string | undefined, maxChars = 200): string | undefined {
const trimmed = text?.trim();
if (!trimmed) {
return undefined;
}
const withoutControlChars = Array.from(trimmed)
.filter((char) => {
const code = char.charCodeAt(0);
return !(
code <= 0x08 ||
code === 0x0b ||
code === 0x0c ||
(code >= 0x0e && code <= 0x1f) ||
code === 0x7f
);
})
.join("");
const sanitized = withoutControlChars
.replace(/[\r\n\t]+/g, " ")
.replace(/\s+/g, " ")
.trim();
return sanitized.length > maxChars ? `${sanitized.slice(0, maxChars)}` : sanitized;
}
function replaceRequestIdPreview(
text: string | undefined,
requestId: string | undefined,
): string | undefined {
if (!text || !requestId) {
return text;
}
return text.split(requestId).join(redactIdentifier(requestId, { len: 12 }));
}
function redactObservationText(text: string | undefined): string | undefined {
if (!text) {
return text;
}
// Observation logs must stay redacted even when operators disable general-purpose
// log redaction, otherwise raw provider payloads leak back into always-on logs.
const configuredPatterns = resolveConfiguredRedactPatterns();
return redactSensitiveText(text, {
mode: "tools",
patterns: [
...getDefaultRedactPatterns(),
...configuredPatterns,
...OBSERVATION_EXTRA_REDACT_PATTERNS,
],
});
}
function extractRequestId(text: string | undefined): string | undefined {
if (!text) {
return undefined;
}
const match = text.match(REQUEST_ID_RE);
return match?.[1]?.trim() || undefined;
}
function buildObservationFingerprint(params: {
raw: string;
requestId?: string;
httpCode?: string;
type?: string;
message?: string;
}): string | null {
const boundedMessage =
params.message && params.message.length > MAX_FINGERPRINT_MESSAGE_CHARS
? params.message.slice(0, MAX_FINGERPRINT_MESSAGE_CHARS)
: params.message;
const structured =
params.httpCode || params.type || boundedMessage
? stableStringify({
httpCode: params.httpCode,
type: params.type,
message: boundedMessage,
})
: null;
if (structured) {
return structured;
}
if (params.requestId) {
return params.raw.split(params.requestId).join("<request_id>");
}
return getApiErrorPayloadFingerprint(params.raw);
}
export function buildApiErrorObservationFields(rawError?: string): {
rawErrorPreview?: string;
rawErrorHash?: string;
rawErrorFingerprint?: string;
httpCode?: string;
providerErrorType?: string;
providerErrorMessagePreview?: string;
requestIdHash?: string;
} {
const trimmed = boundObservationInput(rawError);
if (!trimmed) {
return {};
}
try {
const parsed = parseApiErrorInfo(trimmed);
const requestId = parsed?.requestId ?? extractRequestId(trimmed);
const requestIdHash = requestId ? redactIdentifier(requestId, { len: 12 }) : undefined;
const rawFingerprint = buildObservationFingerprint({
raw: trimmed,
requestId,
httpCode: parsed?.httpCode,
type: parsed?.type,
message: parsed?.message,
});
const redactedRawPreview = replaceRequestIdPreview(redactObservationText(trimmed), requestId);
const redactedProviderMessage = replaceRequestIdPreview(
redactObservationText(parsed?.message),
requestId,
);
return {
rawErrorPreview: truncateForObservation(redactedRawPreview, RAW_ERROR_PREVIEW_MAX_CHARS),
rawErrorHash: redactIdentifier(trimmed, { len: 12 }),
rawErrorFingerprint: rawFingerprint
? redactIdentifier(rawFingerprint, { len: 12 })
: undefined,
httpCode: parsed?.httpCode,
providerErrorType: parsed?.type,
providerErrorMessagePreview: truncateForObservation(
redactedProviderMessage,
PROVIDER_ERROR_PREVIEW_MAX_CHARS,
),
requestIdHash,
};
} catch {
return {};
}
}
export function buildTextObservationFields(text?: string): {
textPreview?: string;
textHash?: string;
textFingerprint?: string;
httpCode?: string;
providerErrorType?: string;
providerErrorMessagePreview?: string;
requestIdHash?: string;
} {
const observed = buildApiErrorObservationFields(text);
return {
textPreview: observed.rawErrorPreview,
textHash: observed.rawErrorHash,
textFingerprint: observed.rawErrorFingerprint,
httpCode: observed.httpCode,
providerErrorType: observed.providerErrorType,
providerErrorMessagePreview: observed.providerErrorMessagePreview,
requestIdHash: observed.requestIdHash,
};
}

View File

@@ -32,7 +32,7 @@ const OPENROUTER_CREDITS_MESSAGE = "Payment Required: insufficient credits";
// Issue-backed Anthropic/OpenAI-compatible insufficient_quota payload under HTTP 400:
// https://github.com/openclaw/openclaw/issues/23440
const INSUFFICIENT_QUOTA_PAYLOAD =
'{"type":"error","error":{"type":"insufficient_quota","message":"Your account has insufficient quota balance to run this request."}}';
'{"type":"error","error":{"type":"insufficient_quota","message":"Your account has insufficient quota balance to run this request."}}'; // pragma: allowlist secret
// Together AI error code examples: https://docs.together.ai/docs/error-codes
const TOGETHER_PAYMENT_REQUIRED_MESSAGE =
"402 Payment Required: The account associated with this API key has reached its maximum allowed monthly spending limit.";
@@ -42,7 +42,7 @@ const TOGETHER_ENGINE_OVERLOADED_MESSAGE =
const GROQ_TOO_MANY_REQUESTS_MESSAGE =
"429 Too Many Requests: Too many requests were sent in a given timeframe.";
const GROQ_SERVICE_UNAVAILABLE_MESSAGE =
"503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance.";
"503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance."; // pragma: allowlist secret
describe("isAuthPermanentErrorMessage", () => {
it("matches permanent auth failure patterns", () => {
@@ -443,6 +443,7 @@ describe("isLikelyContextOverflowError", () => {
describe("isTransientHttpError", () => {
it("returns true for retryable 5xx status codes", () => {
expect(isTransientHttpError("499 Client Closed Request")).toBe(true);
expect(isTransientHttpError("500 Internal Server Error")).toBe(true);
expect(isTransientHttpError("502 Bad Gateway")).toBe(true);
expect(isTransientHttpError("503 Service Unavailable")).toBe(true);
@@ -457,6 +458,19 @@ describe("isTransientHttpError", () => {
});
});
describe("classifyFailoverReasonFromHttpStatus", () => {
it("treats HTTP 499 as transient for structured errors", () => {
expect(classifyFailoverReasonFromHttpStatus(499)).toBe("timeout");
expect(classifyFailoverReasonFromHttpStatus(499, "499 Client Closed Request")).toBe("timeout");
expect(
classifyFailoverReasonFromHttpStatus(
499,
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}',
),
).toBe("overloaded");
});
});
describe("isFailoverErrorMessage", () => {
it("matches auth/rate/billing/timeout", () => {
const samples = [

View File

@@ -189,7 +189,7 @@ const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i;
const HTTP_STATUS_CODE_PREFIX_RE = /^(?:http\s*)?(\d{3})(?:\s+([\s\S]+))?$/i;
const HTML_ERROR_PREFIX_RE = /^\s*(?:<!doctype\s+html\b|<html\b)/i;
const CLOUDFLARE_HTML_ERROR_CODES = new Set([521, 522, 523, 524, 525, 526, 530]);
const TRANSIENT_HTTP_ERROR_CODES = new Set([500, 502, 503, 504, 521, 522, 523, 524, 529]);
const TRANSIENT_HTTP_ERROR_CODES = new Set([499, 500, 502, 503, 504, 521, 522, 523, 524, 529]);
const HTTP_ERROR_HINTS = [
"error",
"bad request",
@@ -375,6 +375,12 @@ export function classifyFailoverReasonFromHttpStatus(
}
return "timeout";
}
if (status === 499) {
if (message && isOverloadedErrorMessage(message)) {
return "overloaded";
}
return "timeout";
}
if (status === 502 || status === 504) {
return "timeout";
}

View File

@@ -2,8 +2,10 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { registerLogTransport, resetLogger, setLoggerOverride } from "../logging/logger.js";
import { redactIdentifier } from "../logging/redact-identifier.js";
import type { AuthProfileFailureReason } from "./auth-profiles.js";
import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js";
@@ -51,6 +53,7 @@ vi.mock("./models-config.js", async (importOriginal) => {
});
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent;
let unregisterLogTransport: (() => void) | undefined;
beforeAll(async () => {
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js"));
@@ -64,6 +67,13 @@ beforeEach(() => {
sleepWithAbortMock.mockClear();
});
afterEach(() => {
unregisterLogTransport?.();
unregisterLogTransport = undefined;
setLoggerOverride(null);
resetLogger();
});
const baseUsage = {
input: 0,
output: 0,
@@ -720,6 +730,61 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
expect(sleepWithAbortMock).toHaveBeenCalledWith(321, undefined);
});
it("logs structured failover decision metadata for overloaded assistant rotation", async () => {
const records: Array<Record<string, unknown>> = [];
setLoggerOverride({
level: "trace",
consoleLevel: "silent",
file: path.join(os.tmpdir(), `openclaw-auth-rotation-${Date.now()}.log`),
});
unregisterLogTransport = registerLogTransport((record) => {
records.push(record);
});
await runAutoPinnedRotationCase({
errorMessage:
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_overload"}',
sessionKey: "agent:test:overloaded-logging",
runId: "run:overloaded-logging",
});
const decisionRecord = records.find(
(record) =>
record["2"] === "embedded run failover decision" &&
record["1"] &&
typeof record["1"] === "object" &&
(record["1"] as Record<string, unknown>).decision === "rotate_profile",
);
expect(decisionRecord).toBeDefined();
const safeProfileId = redactIdentifier("openai:p1", { len: 12 });
expect((decisionRecord as Record<string, unknown>)["1"]).toMatchObject({
event: "embedded_run_failover_decision",
runId: "run:overloaded-logging",
decision: "rotate_profile",
failoverReason: "overloaded",
profileId: safeProfileId,
providerErrorType: "overloaded_error",
rawErrorPreview: expect.stringContaining('"request_id":"sha256:'),
});
const stateRecord = records.find(
(record) =>
record["2"] === "auth profile failure state updated" &&
record["1"] &&
typeof record["1"] === "object" &&
(record["1"] as Record<string, unknown>).profileId === safeProfileId,
);
expect(stateRecord).toBeDefined();
expect((stateRecord as Record<string, unknown>)["1"]).toMatchObject({
event: "auth_profile_failure_state_updated",
runId: "run:overloaded-logging",
profileId: safeProfileId,
reason: "overloaded",
});
});
it("rotates for overloaded prompt failures across auto-pinned profiles", async () => {
const { usageStats } = await runAutoPinnedPromptErrorRotationCase({
errorMessage: '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}',
@@ -1013,6 +1078,54 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
});
});
it("can probe one billing-disabled profile when transient cooldown probe is allowed without fallback models", async () => {
await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => {
await writeAuthStore(agentDir, {
usageStats: {
"openai:p1": {
lastUsed: 1,
disabledUntil: now + 60 * 60 * 1000,
disabledReason: "billing",
},
"openai:p2": {
lastUsed: 2,
disabledUntil: now + 60 * 60 * 1000,
disabledReason: "billing",
},
},
});
runEmbeddedAttemptMock.mockResolvedValueOnce(
makeAttempt({
assistantTexts: ["ok"],
lastAssistant: buildAssistant({
stopReason: "stop",
content: [{ type: "text", text: "ok" }],
}),
}),
);
const result = await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:test:billing-cooldown-probe-no-fallbacks",
sessionFile: path.join(workspaceDir, "session.jsonl"),
workspaceDir,
agentDir,
config: makeConfig(),
prompt: "hello",
provider: "openai",
model: "mock-1",
authProfileIdSource: "auto",
allowTransientCooldownProbe: true,
timeoutMs: 5_000,
runId: "run:billing-cooldown-probe-no-fallbacks",
});
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
expect(result.payloads?.[0]?.text ?? "").toContain("ok");
});
});
it("treats agent-level fallbacks as configured when defaults have none", async () => {
await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => {
await writeAuthStore(agentDir, {

View File

@@ -61,6 +61,7 @@ import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
import { log } from "./logger.js";
import { resolveModel } from "./model.js";
import { runEmbeddedAttempt } from "./run/attempt.js";
import { createFailoverDecisionLogger } from "./run/failover-observation.js";
import type { RunEmbeddedPiAgentParams } from "./run/params.js";
import { buildEmbeddedRunPayloads } from "./run/payloads.js";
import {
@@ -762,6 +763,7 @@ export async function runEmbeddedPiAgent(
reason,
cfg: params.config,
agentDir,
runId: params.runId,
});
};
const resolveAuthProfileFailureReason = (
@@ -848,6 +850,7 @@ export async function runEmbeddedPiAgent(
sessionId: params.sessionId,
sessionKey: params.sessionKey,
trigger: params.trigger,
memoryFlushWritePath: params.memoryFlushWritePath,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
agentAccountId: params.agentAccountId,
@@ -1226,11 +1229,26 @@ export async function runEmbeddedPiAgent(
reason: promptProfileFailureReason,
});
const promptFailoverFailure = isFailoverErrorMessage(errorText);
// Capture the failing profile before auth-profile rotation mutates `lastProfileId`.
const failedPromptProfileId = lastProfileId;
const logPromptFailoverDecision = createFailoverDecisionLogger({
stage: "prompt",
runId: params.runId,
rawError: errorText,
failoverReason: promptFailoverReason,
profileFailureReason: promptProfileFailureReason,
provider,
model: modelId,
profileId: failedPromptProfileId,
fallbackConfigured,
aborted,
});
if (
promptFailoverFailure &&
promptFailoverReason !== "timeout" &&
(await advanceAuthProfile())
) {
logPromptFailoverDecision("rotate_profile");
await maybeBackoffBeforeOverloadFailover(promptFailoverReason);
continue;
}
@@ -1249,15 +1267,20 @@ export async function runEmbeddedPiAgent(
// are configured so outer model fallback can continue on overload,
// rate-limit, auth, or billing failures.
if (fallbackConfigured && promptFailoverFailure) {
const status = resolveFailoverStatus(promptFailoverReason ?? "unknown");
logPromptFailoverDecision("fallback_model", { status });
await maybeBackoffBeforeOverloadFailover(promptFailoverReason);
throw new FailoverError(errorText, {
reason: promptFailoverReason ?? "unknown",
provider,
model: modelId,
profileId: lastProfileId,
status: resolveFailoverStatus(promptFailoverReason ?? "unknown"),
status,
});
}
if (promptFailoverFailure || promptFailoverReason) {
logPromptFailoverDecision("surface_error");
}
throw promptError;
}
@@ -1282,6 +1305,21 @@ export async function runEmbeddedPiAgent(
resolveAuthProfileFailureReason(assistantFailoverReason);
const cloudCodeAssistFormatError = attempt.cloudCodeAssistFormatError;
const imageDimensionError = parseImageDimensionError(lastAssistant?.errorMessage ?? "");
// Capture the failing profile before auth-profile rotation mutates `lastProfileId`.
const failedAssistantProfileId = lastProfileId;
const logAssistantFailoverDecision = createFailoverDecisionLogger({
stage: "assistant",
runId: params.runId,
rawError: lastAssistant?.errorMessage?.trim(),
failoverReason: assistantFailoverReason,
profileFailureReason: assistantProfileFailureReason,
provider: activeErrorContext.provider,
model: activeErrorContext.model,
profileId: failedAssistantProfileId,
fallbackConfigured,
timedOut,
aborted,
});
if (
authFailure &&
@@ -1339,6 +1377,7 @@ export async function runEmbeddedPiAgent(
const rotated = await advanceAuthProfile();
if (rotated) {
logAssistantFailoverDecision("rotate_profile");
await maybeBackoffBeforeOverloadFailover(assistantFailoverReason);
continue;
}
@@ -1371,6 +1410,7 @@ export async function runEmbeddedPiAgent(
const status =
resolveFailoverStatus(assistantFailoverReason ?? "unknown") ??
(isTimeoutErrorMessage(message) ? 408 : undefined);
logAssistantFailoverDecision("fallback_model", { status });
throw new FailoverError(message, {
reason: assistantFailoverReason ?? "unknown",
provider: activeErrorContext.provider,
@@ -1379,6 +1419,7 @@ export async function runEmbeddedPiAgent(
status,
});
}
logAssistantFailoverDecision("surface_error");
}
const usage = toNormalizedUsage(usageAccumulator);
@@ -1417,6 +1458,7 @@ export async function runEmbeddedPiAgent(
suppressToolErrorWarnings: params.suppressToolErrorWarnings,
inlineToolResultsAllowed: false,
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
});
// Timeout aborts can leave the run without any assistant payloads.
@@ -1439,6 +1481,7 @@ export async function runEmbeddedPiAgent(
systemPromptReport: attempt.systemPromptReport,
},
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
messagingToolSentTexts: attempt.messagingToolSentTexts,
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
messagingToolSentTargets: attempt.messagingToolSentTargets,
@@ -1486,6 +1529,7 @@ export async function runEmbeddedPiAgent(
: undefined,
},
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
messagingToolSentTexts: attempt.messagingToolSentTexts,
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
messagingToolSentTargets: attempt.messagingToolSentTargets,

View File

@@ -0,0 +1,373 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Api, Model } from "@mariozechner/pi-ai";
import type {
AuthStorage,
ExtensionContext,
ModelRegistry,
ToolDefinition,
} from "@mariozechner/pi-coding-agent";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createHostSandboxFsBridge } from "../../test-helpers/host-sandbox-fs-bridge.js";
import { createPiToolsSandboxContext } from "../../test-helpers/pi-tools-sandbox-context.js";
const hoisted = vi.hoisted(() => {
const spawnSubagentDirectMock = vi.fn();
const createAgentSessionMock = vi.fn();
const sessionManagerOpenMock = vi.fn();
const resolveSandboxContextMock = vi.fn();
const subscribeEmbeddedPiSessionMock = vi.fn();
const acquireSessionWriteLockMock = vi.fn();
const sessionManager = {
getLeafEntry: vi.fn(() => null),
branch: vi.fn(),
resetLeaf: vi.fn(),
buildSessionContext: vi.fn(() => ({ messages: [] })),
appendCustomEntry: vi.fn(),
};
return {
spawnSubagentDirectMock,
createAgentSessionMock,
sessionManagerOpenMock,
resolveSandboxContextMock,
subscribeEmbeddedPiSessionMock,
acquireSessionWriteLockMock,
sessionManager,
};
});
vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
const actual = await importOriginal<typeof import("@mariozechner/pi-coding-agent")>();
return {
...actual,
createAgentSession: (...args: unknown[]) => hoisted.createAgentSessionMock(...args),
DefaultResourceLoader: class {
async reload() {}
},
SessionManager: {
open: (...args: unknown[]) => hoisted.sessionManagerOpenMock(...args),
} as unknown as typeof actual.SessionManager,
};
});
vi.mock("../../subagent-spawn.js", () => ({
SUBAGENT_SPAWN_MODES: ["run", "session"],
spawnSubagentDirect: (...args: unknown[]) => hoisted.spawnSubagentDirectMock(...args),
}));
vi.mock("../../sandbox.js", () => ({
resolveSandboxContext: (...args: unknown[]) => hoisted.resolveSandboxContextMock(...args),
}));
vi.mock("../../session-tool-result-guard-wrapper.js", () => ({
guardSessionManager: () => hoisted.sessionManager,
}));
vi.mock("../../pi-embedded-subscribe.js", () => ({
subscribeEmbeddedPiSession: (...args: unknown[]) =>
hoisted.subscribeEmbeddedPiSessionMock(...args),
}));
vi.mock("../../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => undefined,
}));
vi.mock("../../../infra/machine-name.js", () => ({
getMachineDisplayName: async () => "test-host",
}));
vi.mock("../../../infra/net/undici-global-dispatcher.js", () => ({
ensureGlobalUndiciStreamTimeouts: () => {},
}));
vi.mock("../../bootstrap-files.js", () => ({
makeBootstrapWarn: () => () => {},
resolveBootstrapContextForRun: async () => ({ bootstrapFiles: [], contextFiles: [] }),
}));
vi.mock("../../skills.js", () => ({
applySkillEnvOverrides: () => () => {},
applySkillEnvOverridesFromSnapshot: () => () => {},
resolveSkillsPromptForRun: () => "",
}));
vi.mock("../skills-runtime.js", () => ({
resolveEmbeddedRunSkillEntries: () => ({
shouldLoadSkillEntries: false,
skillEntries: undefined,
}),
}));
vi.mock("../../docs-path.js", () => ({
resolveOpenClawDocsPath: async () => undefined,
}));
vi.mock("../../pi-project-settings.js", () => ({
createPreparedEmbeddedPiSettingsManager: () => ({}),
}));
vi.mock("../../pi-settings.js", () => ({
applyPiAutoCompactionGuard: () => {},
}));
vi.mock("../extensions.js", () => ({
buildEmbeddedExtensionFactories: () => [],
}));
vi.mock("../google.js", () => ({
logToolSchemasForGoogle: () => {},
sanitizeSessionHistory: async ({ messages }: { messages: unknown[] }) => messages,
sanitizeToolsForGoogle: ({ tools }: { tools: unknown[] }) => tools,
}));
vi.mock("../../session-file-repair.js", () => ({
repairSessionFileIfNeeded: async () => {},
}));
vi.mock("../session-manager-cache.js", () => ({
prewarmSessionFile: async () => {},
trackSessionManagerAccess: () => {},
}));
vi.mock("../session-manager-init.js", () => ({
prepareSessionManagerForRun: async () => {},
}));
vi.mock("../../session-write-lock.js", () => ({
acquireSessionWriteLock: (...args: unknown[]) => hoisted.acquireSessionWriteLockMock(...args),
resolveSessionLockMaxHoldFromTimeout: () => 1,
}));
vi.mock("../tool-result-context-guard.js", () => ({
installToolResultContextGuard: () => () => {},
}));
vi.mock("../wait-for-idle-before-flush.js", () => ({
flushPendingToolResultsAfterIdle: async () => {},
}));
vi.mock("../runs.js", () => ({
setActiveEmbeddedRun: () => {},
clearActiveEmbeddedRun: () => {},
}));
vi.mock("./images.js", () => ({
detectAndLoadPromptImages: async () => ({ images: [] }),
}));
vi.mock("../../system-prompt-params.js", () => ({
buildSystemPromptParams: () => ({
runtimeInfo: {},
userTimezone: "UTC",
userTime: "00:00",
userTimeFormat: "24h",
}),
}));
vi.mock("../../system-prompt-report.js", () => ({
buildSystemPromptReport: () => undefined,
}));
vi.mock("../system-prompt.js", () => ({
applySystemPromptOverrideToSession: () => {},
buildEmbeddedSystemPrompt: () => "system prompt",
createSystemPromptOverride: (prompt: string) => () => prompt,
}));
vi.mock("../extra-params.js", () => ({
applyExtraParamsToAgent: () => {},
}));
vi.mock("../../openai-ws-stream.js", () => ({
createOpenAIWebSocketStreamFn: vi.fn(),
releaseWsSession: () => {},
}));
vi.mock("../../anthropic-payload-log.js", () => ({
createAnthropicPayloadLogger: () => undefined,
}));
vi.mock("../../cache-trace.js", () => ({
createCacheTrace: () => undefined,
}));
vi.mock("../../model-selection.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../model-selection.js")>();
return {
...actual,
normalizeProviderId: (providerId?: string) => providerId?.trim().toLowerCase() ?? "",
resolveDefaultModelForAgent: () => ({ provider: "openai", model: "gpt-test" }),
};
});
const { runEmbeddedAttempt } = await import("./attempt.js");
type MutableSession = {
sessionId: string;
messages: unknown[];
isCompacting: boolean;
isStreaming: boolean;
agent: {
streamFn?: unknown;
replaceMessages: (messages: unknown[]) => void;
};
prompt: (prompt: string, options?: { images?: unknown[] }) => Promise<void>;
abort: () => Promise<void>;
dispose: () => void;
steer: (text: string) => Promise<void>;
};
function createSubscriptionMock() {
return {
assistantTexts: [] as string[],
toolMetas: [] as Array<{ toolName: string; meta?: string }>,
unsubscribe: () => {},
waitForCompactionRetry: async () => {},
getMessagingToolSentTexts: () => [] as string[],
getMessagingToolSentMediaUrls: () => [] as string[],
getMessagingToolSentTargets: () => [] as unknown[],
getSuccessfulCronAdds: () => 0,
didSendViaMessagingTool: () => false,
didSendDeterministicApprovalPrompt: () => false,
getLastToolError: () => undefined,
getUsageTotals: () => undefined,
getCompactionCount: () => 0,
isCompacting: () => false,
};
}
describe("runEmbeddedAttempt sessions_spawn workspace inheritance", () => {
const tempPaths: string[] = [];
beforeEach(() => {
hoisted.spawnSubagentDirectMock.mockReset().mockResolvedValue({
status: "accepted",
childSessionKey: "agent:main:subagent:child",
runId: "run-child",
});
hoisted.createAgentSessionMock.mockReset();
hoisted.sessionManagerOpenMock.mockReset().mockReturnValue(hoisted.sessionManager);
hoisted.resolveSandboxContextMock.mockReset();
hoisted.subscribeEmbeddedPiSessionMock.mockReset().mockImplementation(createSubscriptionMock);
hoisted.acquireSessionWriteLockMock.mockReset().mockResolvedValue({
release: async () => {},
});
hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null);
hoisted.sessionManager.branch.mockReset();
hoisted.sessionManager.resetLeaf.mockReset();
hoisted.sessionManager.buildSessionContext.mockReset().mockReturnValue({ messages: [] });
hoisted.sessionManager.appendCustomEntry.mockReset();
});
afterEach(async () => {
while (tempPaths.length > 0) {
const target = tempPaths.pop();
if (target) {
await fs.rm(target, { recursive: true, force: true });
}
}
});
it("passes the real workspace to sessions_spawn when workspaceAccess is ro", async () => {
const realWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-real-workspace-"));
const sandboxWorkspace = await fs.mkdtemp(
path.join(os.tmpdir(), "openclaw-sandbox-workspace-"),
);
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-dir-"));
tempPaths.push(realWorkspace, sandboxWorkspace, agentDir);
hoisted.resolveSandboxContextMock.mockResolvedValue(
createPiToolsSandboxContext({
workspaceDir: sandboxWorkspace,
agentWorkspaceDir: realWorkspace,
workspaceAccess: "ro",
fsBridge: createHostSandboxFsBridge(sandboxWorkspace),
tools: { allow: ["sessions_spawn"], deny: [] },
sessionKey: "agent:main:main",
}),
);
hoisted.createAgentSessionMock.mockImplementation(
async (params: { customTools: ToolDefinition[] }) => {
const session: MutableSession = {
sessionId: "embedded-session",
messages: [],
isCompacting: false,
isStreaming: false,
agent: {
replaceMessages: (messages: unknown[]) => {
session.messages = [...messages];
},
},
prompt: async () => {
const spawnTool = params.customTools.find((tool) => tool.name === "sessions_spawn");
expect(spawnTool).toBeDefined();
if (!spawnTool) {
throw new Error("missing sessions_spawn tool");
}
await spawnTool.execute(
"call-sessions-spawn",
{ task: "inspect workspace" },
undefined,
undefined,
{} as unknown as ExtensionContext,
);
},
abort: async () => {},
dispose: () => {},
steer: async () => {},
};
return { session };
},
);
const model = {
api: "openai-completions",
provider: "openai",
compat: {},
contextWindow: 8192,
input: ["text"],
} as unknown as Model<Api>;
const result = await runEmbeddedAttempt({
sessionId: "embedded-session",
sessionKey: "agent:main:main",
sessionFile: path.join(realWorkspace, "session.jsonl"),
workspaceDir: realWorkspace,
agentDir,
config: {},
prompt: "spawn a child session",
timeoutMs: 10_000,
runId: "run-1",
provider: "openai",
modelId: "gpt-test",
model,
authStorage: {} as AuthStorage,
modelRegistry: {} as ModelRegistry,
thinkLevel: "off",
senderIsOwner: true,
disableMessageTool: true,
});
expect(result.promptError).toBeNull();
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledTimes(1);
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
expect.objectContaining({
task: "inspect workspace",
}),
expect.objectContaining({
workspaceDir: realWorkspace,
}),
);
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
workspaceDir: sandboxWorkspace,
}),
);
});
});

View File

@@ -869,7 +869,13 @@ export async function runEmbeddedAttempt(
runId: params.runId,
agentDir,
workspaceDir: effectiveWorkspace,
// When sandboxing uses a copied workspace (`ro` or `none`), effectiveWorkspace points
// at the sandbox copy. Spawned subagents should inherit the real workspace instead.
spawnWorkspaceDir:
sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? resolvedWorkspace : undefined,
config: params.config,
trigger: params.trigger,
memoryFlushWritePath: params.memoryFlushWritePath,
abortSignal: runAbortController.signal,
modelProvider: params.model.provider,
modelId: params.modelId,
@@ -1544,6 +1550,7 @@ export async function runEmbeddedAttempt(
getMessagingToolSentTargets,
getSuccessfulCronAdds,
didSendViaMessagingTool,
didSendDeterministicApprovalPrompt,
getLastToolError,
getUsageTotals,
getCompactionCount,
@@ -2058,6 +2065,7 @@ export async function runEmbeddedAttempt(
lastAssistant,
lastToolError: getLastToolError?.(),
didSendViaMessagingTool: didSendViaMessagingTool(),
didSendDeterministicApprovalPrompt: didSendDeterministicApprovalPrompt(),
messagingToolSentTexts: getMessagingToolSentTexts(),
messagingToolSentMediaUrls: getMessagingToolSentMediaUrls(),
messagingToolSentTargets: getMessagingToolSentTargets(),

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { normalizeFailoverDecisionObservationBase } from "./failover-observation.js";
describe("normalizeFailoverDecisionObservationBase", () => {
it("fills timeout observation reasons for deadline timeouts without provider error text", () => {
expect(
normalizeFailoverDecisionObservationBase({
stage: "assistant",
runId: "run:timeout",
rawError: "",
failoverReason: null,
profileFailureReason: null,
provider: "openai",
model: "mock-1",
profileId: "openai:p1",
fallbackConfigured: false,
timedOut: true,
aborted: false,
}),
).toMatchObject({
failoverReason: "timeout",
profileFailureReason: "timeout",
timedOut: true,
});
});
it("preserves explicit failover reasons", () => {
expect(
normalizeFailoverDecisionObservationBase({
stage: "assistant",
runId: "run:overloaded",
rawError: '{"error":{"type":"overloaded_error"}}',
failoverReason: "overloaded",
profileFailureReason: "overloaded",
provider: "openai",
model: "mock-1",
profileId: "openai:p1",
fallbackConfigured: true,
timedOut: true,
aborted: false,
}),
).toMatchObject({
failoverReason: "overloaded",
profileFailureReason: "overloaded",
timedOut: true,
});
});
});

View File

@@ -0,0 +1,76 @@
import { redactIdentifier } from "../../../logging/redact-identifier.js";
import type { AuthProfileFailureReason } from "../../auth-profiles.js";
import {
buildApiErrorObservationFields,
sanitizeForConsole,
} from "../../pi-embedded-error-observation.js";
import type { FailoverReason } from "../../pi-embedded-helpers.js";
import { log } from "../logger.js";
export type FailoverDecisionLoggerInput = {
stage: "prompt" | "assistant";
decision: "rotate_profile" | "fallback_model" | "surface_error";
runId?: string;
rawError?: string;
failoverReason: FailoverReason | null;
profileFailureReason?: AuthProfileFailureReason | null;
provider: string;
model: string;
profileId?: string;
fallbackConfigured: boolean;
timedOut?: boolean;
aborted?: boolean;
status?: number;
};
export type FailoverDecisionLoggerBase = Omit<FailoverDecisionLoggerInput, "decision" | "status">;
export function normalizeFailoverDecisionObservationBase(
base: FailoverDecisionLoggerBase,
): FailoverDecisionLoggerBase {
return {
...base,
failoverReason: base.failoverReason ?? (base.timedOut ? "timeout" : null),
profileFailureReason: base.profileFailureReason ?? (base.timedOut ? "timeout" : null),
};
}
export function createFailoverDecisionLogger(
base: FailoverDecisionLoggerBase,
): (
decision: FailoverDecisionLoggerInput["decision"],
extra?: Pick<FailoverDecisionLoggerInput, "status">,
) => void {
const normalizedBase = normalizeFailoverDecisionObservationBase(base);
const safeProfileId = normalizedBase.profileId
? redactIdentifier(normalizedBase.profileId, { len: 12 })
: undefined;
const safeRunId = sanitizeForConsole(normalizedBase.runId) ?? "-";
const safeProvider = sanitizeForConsole(normalizedBase.provider) ?? "-";
const safeModel = sanitizeForConsole(normalizedBase.model) ?? "-";
const profileText = safeProfileId ?? "-";
const reasonText = normalizedBase.failoverReason ?? "none";
return (decision, extra) => {
const observedError = buildApiErrorObservationFields(normalizedBase.rawError);
log.warn("embedded run failover decision", {
event: "embedded_run_failover_decision",
tags: ["error_handling", "failover", normalizedBase.stage, decision],
runId: normalizedBase.runId,
stage: normalizedBase.stage,
decision,
failoverReason: normalizedBase.failoverReason,
profileFailureReason: normalizedBase.profileFailureReason,
provider: normalizedBase.provider,
model: normalizedBase.model,
profileId: safeProfileId,
fallbackConfigured: normalizedBase.fallbackConfigured,
timedOut: normalizedBase.timedOut,
aborted: normalizedBase.aborted,
status: extra?.status,
...observedError,
consoleMessage:
`embedded run failover decision: runId=${safeRunId} stage=${normalizedBase.stage} decision=${decision} ` +
`reason=${reasonText} provider=${safeProvider}/${safeModel} profile=${profileText}`,
});
};
}

View File

@@ -1,5 +1,6 @@
import type { ImageContent } from "@mariozechner/pi-ai";
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
import type { ReplyPayload } from "../../../auto-reply/types.js";
import type { AgentStreamParams } from "../../../commands/agent/types.js";
import type { OpenClawConfig } from "../../../config/config.js";
import type { enqueueCommand } from "../../../process/command-queue.js";
@@ -28,6 +29,8 @@ export type RunEmbeddedPiAgentParams = {
agentAccountId?: string;
/** What initiated this agent run: "user", "heartbeat", "cron", or "memory". */
trigger?: string;
/** Relative workspace path that memory-triggered writes are allowed to append to. */
memoryFlushWritePath?: string;
/** Delivery target (e.g. telegram:group:123:topic:456) for topic/thread routing. */
messageTo?: string;
/** Thread/topic identifier for routing replies to the originating thread. */
@@ -104,7 +107,7 @@ export type RunEmbeddedPiAgentParams = {
blockReplyChunking?: BlockReplyChunking;
onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onReasoningEnd?: () => void | Promise<void>;
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onToolResult?: (payload: ReplyPayload) => void | Promise<void>;
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
lane?: string;
enqueue?: typeof enqueueCommand;

View File

@@ -82,4 +82,13 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => {
expect(payloads).toHaveLength(0);
});
it("suppresses assistant text when a deterministic exec approval prompt was already delivered", () => {
const payloads = buildPayloads({
assistantTexts: ["Approval is needed. Please run /approve abc allow-once"],
didSendDeterministicApprovalPrompt: true,
});
expect(payloads).toHaveLength(0);
});
});

View File

@@ -102,6 +102,7 @@ export function buildEmbeddedRunPayloads(params: {
suppressToolErrorWarnings?: boolean;
inlineToolResultsAllowed: boolean;
didSendViaMessagingTool?: boolean;
didSendDeterministicApprovalPrompt?: boolean;
}): Array<{
text?: string;
mediaUrl?: string;
@@ -125,14 +126,17 @@ export function buildEmbeddedRunPayloads(params: {
}> = [];
const useMarkdown = params.toolResultFormat === "markdown";
const suppressAssistantArtifacts = params.didSendDeterministicApprovalPrompt === true;
const lastAssistantErrored = params.lastAssistant?.stopReason === "error";
const errorText = params.lastAssistant
? formatAssistantErrorText(params.lastAssistant, {
cfg: params.config,
sessionKey: params.sessionKey,
provider: params.provider,
model: params.model,
})
? suppressAssistantArtifacts
? undefined
: formatAssistantErrorText(params.lastAssistant, {
cfg: params.config,
sessionKey: params.sessionKey,
provider: params.provider,
model: params.model,
})
: undefined;
const rawErrorMessage = lastAssistantErrored
? params.lastAssistant?.errorMessage?.trim() || undefined
@@ -184,8 +188,9 @@ export function buildEmbeddedRunPayloads(params: {
}
}
const reasoningText =
params.lastAssistant && params.reasoningLevel === "on"
const reasoningText = suppressAssistantArtifacts
? ""
: params.lastAssistant && params.reasoningLevel === "on"
? formatReasoningMessage(extractAssistantThinking(params.lastAssistant))
: "";
if (reasoningText) {
@@ -243,13 +248,14 @@ export function buildEmbeddedRunPayloads(params: {
}
return isRawApiErrorPayload(trimmed);
};
const answerTexts = (
params.assistantTexts.length
? params.assistantTexts
: fallbackAnswerText
? [fallbackAnswerText]
: []
).filter((text) => !shouldSuppressRawErrorText(text));
const answerTexts = suppressAssistantArtifacts
? []
: (params.assistantTexts.length
? params.assistantTexts
: fallbackAnswerText
? [fallbackAnswerText]
: []
).filter((text) => !shouldSuppressRawErrorText(text));
let hasUserFacingAssistantReply = false;
for (const text of answerTexts) {

View File

@@ -54,6 +54,7 @@ export type EmbeddedRunAttemptResult = {
actionFingerprint?: string;
};
didSendViaMessagingTool: boolean;
didSendDeterministicApprovalPrompt?: boolean;
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];

View File

@@ -79,6 +79,36 @@ describe("runEmbeddedPiAgent usage reporting", () => {
);
});
it("forwards memory flush write paths into memory-triggered attempts", async () => {
mockedRunEmbeddedAttempt.mockResolvedValueOnce({
aborted: false,
promptError: null,
timedOut: false,
sessionIdUsed: "test-session",
assistantTexts: [],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
await runEmbeddedPiAgent({
sessionId: "test-session",
sessionKey: "test-key",
sessionFile: "/tmp/session.json",
workspaceDir: "/tmp/workspace",
prompt: "flush",
timeoutMs: 30000,
runId: "run-memory-forwarding",
trigger: "memory",
memoryFlushWritePath: "memory/2026-03-10.md",
});
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledWith(
expect.objectContaining({
trigger: "memory",
memoryFlushWritePath: "memory/2026-03-10.md",
}),
);
});
it("reports total usage from the last turn instead of accumulated total", async () => {
// Simulate a multi-turn run result.
// Turn 1: Input 100, Output 50. Total 150.

View File

@@ -54,8 +54,13 @@ describe("handleAgentEnd", () => {
const warn = vi.mocked(ctx.log.warn);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0]?.[0]).toContain("runId=run-1");
expect(warn.mock.calls[0]?.[0]).toContain("error=connection refused");
expect(warn.mock.calls[0]?.[0]).toBe("embedded run agent end");
expect(warn.mock.calls[0]?.[1]).toMatchObject({
event: "embedded_run_agent_end",
runId: "run-1",
error: "connection refused",
rawErrorPreview: "connection refused",
});
expect(onAgentEvent).toHaveBeenCalledWith({
stream: "lifecycle",
data: {
@@ -65,6 +70,59 @@ describe("handleAgentEnd", () => {
});
});
it("attaches raw provider error metadata without changing the console message", () => {
const ctx = createContext({
role: "assistant",
stopReason: "error",
provider: "anthropic",
model: "claude-test",
errorMessage: '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}',
content: [{ type: "text", text: "" }],
});
handleAgentEnd(ctx);
const warn = vi.mocked(ctx.log.warn);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0]?.[0]).toBe("embedded run agent end");
expect(warn.mock.calls[0]?.[1]).toMatchObject({
event: "embedded_run_agent_end",
runId: "run-1",
error: "The AI service is temporarily overloaded. Please try again in a moment.",
failoverReason: "overloaded",
providerErrorType: "overloaded_error",
});
});
it("redacts logged error text before emitting lifecycle events", () => {
const onAgentEvent = vi.fn();
const ctx = createContext(
{
role: "assistant",
stopReason: "error",
errorMessage: "x-api-key: sk-abcdefghijklmnopqrstuvwxyz123456",
content: [{ type: "text", text: "" }],
},
{ onAgentEvent },
);
handleAgentEnd(ctx);
const warn = vi.mocked(ctx.log.warn);
expect(warn.mock.calls[0]?.[1]).toMatchObject({
event: "embedded_run_agent_end",
error: "x-api-key: ***",
rawErrorPreview: "x-api-key: ***",
});
expect(onAgentEvent).toHaveBeenCalledWith({
stream: "lifecycle",
data: {
phase: "error",
error: "x-api-key: ***",
},
});
});
it("keeps non-error run-end logging on debug only", () => {
const ctx = createContext(undefined);

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